基於Java語言構建區塊鏈(四)—— 交易(UTXO)

wangwei_hz發表於2018-03-22

bitcoin-blockchain-transactions

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

引言

交易這一環節是整個比特幣系統當中最為關鍵的一環,並且區塊鏈唯一的目的就是通過安全的、可信的方式來儲存交易資訊,防止它們建立之後被人惡意篡改。今天我們開始實現交易這一環節,但由於這是一個很大的話題,所以我們分為兩部分:第一部分我們將實現區塊鏈交易的基本機制,到第二部分,我們再來研究它的細節。

比特幣交易

如果你開發過Web應用程式,為了實現支付系統,你可能會在資料庫中建立一些資料庫表:賬戶交易記錄。賬戶用於儲存使用者的個人資訊以及賬戶餘額等資訊,交易記錄用於儲存資金從一個賬戶轉移到另一個賬戶的記錄。但是在比特幣中,支付系統是以一種完全不一樣的方式實現的,在這裡:

  • 沒有賬戶
  • 沒有餘額
  • 沒有地址
  • 沒有 Coins(幣)
  • 沒有傳送者和接受者

由於區塊鏈是一個公開的資料庫,我們不希望儲存有關錢包所有者的敏感資訊。Coins 不會彙總到錢包中。交易不會將資金從一個地址轉移到另一個地址。沒有可儲存帳戶餘額的欄位或屬性。只有交易資訊。那比特幣的交易資訊裡面到底儲存的是什麼呢?

交易組成

一筆比特幣的交易由 交易輸入交易輸出 組成,資料結構如下:

/**
 * 交易
 *
 * @author wangwei
 * @date 2017/03/04
 */
@Data
public class Transaction {

    /**
     * 交易的Hash
     */
    private byte[] txId;
    /**
     * 交易輸入
     */
    private TXInput[] inputs;
    /**
     * 交易輸出
     */
    private TXOutput[] outputs;

    public Transaction() {
    }

    public Transaction(byte[] txId, TXInput[] inputs, TXOutput[] outputs) {
        this();
        this.txId = txId;
        this.inputs = inputs;
        this.outputs = outputs;
    }
}
複製程式碼

一筆交易的 交易輸入 其實是指向上一筆交易的交易輸出 (這個後面詳細說明)。我們錢包裡面的 Coin(幣)實際是儲存在這些 交易輸出 裡面。下圖表示了區塊鏈交易系統裡面各個交易相互引用的關係:

transactions-diagram

注意:

  1. 有些 交易輸出 並不是由 交易輸入 產生,而是憑空產生的(後面會詳細介紹)。
  2. 但,交易輸入 必須指向某個 交易輸出,它不能憑空產生。
  3. 在一筆交易裡面,交易輸入 可能會來自多筆交易所產生的 交易輸出

在整篇文章中,我們將使用諸如“錢”,“硬幣”,“花費”,“傳送”,“賬戶”等詞語。但比特幣中沒有這樣的概念,在比特幣交易中,交易資訊是由 鎖定指令碼 鎖定一個數值,並且只能被所有者的 解鎖指令碼 解鎖。(解鈴還須繫鈴人)

交易輸出

讓我們先從交易輸出開始,他的資料結構如下:

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

    /**
     * 數值
     */
    private int value;
    /**
     * 鎖定指令碼
     */
    private String scriptPubKey;
	
    public TXOutput() {
    }

    public TXOutput(int value, String scriptPubKey) {
        this();
        this.value = value;
        this.scriptPubKey = scriptPubKey;
    }
}
複製程式碼

實際上,它表示的是能夠儲存 "coins(幣)"的交易輸出(注意 value 欄位)。並且這裡所謂的 value 實際上是由儲存在 ScriptPubKey (鎖定指令碼)中的一個puzzle(難題) 所鎖定。在內部,比特幣使用稱為指令碼的指令碼語言,用於定義輸出鎖定和解鎖邏輯。這個語言很原始(這是故意的,以避免可能的黑客和濫用),但我們不會詳細討論它。 你可以在這裡找到它的詳細解釋。here

在比特幣中,value 欄位儲存著 satoshis 的任意倍的數值,而不是BTC的數量。satoshis 是比特幣的百萬分之一(0.00000001 BTC),因此這是比特幣中最小的貨幣單位(如1美分)。

satoshis:聰

鎖定指令碼是一個放在一個輸出值上的“障礙”,同時它明確了今後花費這筆輸出的條件。由於鎖定指令碼往往含有一個公鑰(即比特幣地址),在歷史上它曾被稱作一個指令碼公鑰程式碼。在大多數比特幣應用原始碼中,指令碼公鑰程式碼便是我們所說的鎖定指令碼。

由於我們還沒有實現錢包地址的邏輯,所以這裡先暫且忽略鎖定指令碼相關的邏輯。ScriptPubKey 將會儲存任意的字串(使用者定義的錢包地址)

順便說一句,擁有這樣的指令碼語言意味著比特幣也可以用作智慧合約平臺。

關於 交易輸出 的一個重要的事情是它們是不可分割的,這意味著你不能將它所儲存的數值拆開來使用。當這個交易輸出在新的交易中被交易輸入所引用時,它將作為一個整體被花費掉。 如果其值大於所需值,那麼剩餘的部分則會作為零錢返回給付款方。 這與真實世界的情況類似,例如,您支付5美元的鈔票用於購買1美元的東西,那麼你將會得到4美元的零錢。

交易輸入

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

    /**
     * 交易Id的hash值
     */
    private byte[] txId;
    /**
     * 交易輸出索引
     */
    private int txOutputIndex;
    /**
     * 解鎖指令碼
     */
    private String scriptSig;
	
    public TXInput() {
    }

    public TXInput(byte[] txId, int txOutputIndex, String scriptSig) {
        this();
        this.txId = txId;
        this.txOutputIndex = txOutputIndex;
        this.scriptSig = scriptSig;
    }
    
}
複製程式碼

前面提到過,一個交易輸入指向的是某一筆交易的交易輸出:

  • txId 儲存的是某筆交易的ID值
  • txOutputIndex 儲存的是交易中這個交易輸出的索引位置(因為一筆交易可能包含多個交易輸出)
  • scriptSig 主要是提供用於交易輸出中 ScriptPubKey 所需的驗證資料。
    • 如果這個資料被驗證正確,那麼相應的交易輸出將被解鎖,並且其中的 value 能夠生成新的交易輸出;
    • 如果不正確,那麼相應的交易輸出將不能被交易輸入所引用;

通過鎖定指令碼與解鎖指令碼這種機制,保證了某個使用者不能花費屬於他人的Coins。

同樣,由於我們尚未實現錢包地址功能,ScriptSig 將會儲存任意的使用者所定義的錢包地址。我們將會在下一章節實現公鑰和數字簽名驗證。

說了這麼多,我們來總結一下。交易輸出是"Coins"實際儲存的地方。每一個交易輸出都帶有一個鎖定指令碼,它決定了解鎖的邏輯。每一筆新的交易必須至少有一個交易輸入與交易輸出。一筆交易的交易輸入指向前一筆交易的交易輸出,並且提供用於鎖定指令碼解鎖需要的資料(ScriptSig 欄位),然後利用交易輸出中的 value 去建立新的交易輸出。

注意,這段話的原文如下,但是裡面有表述錯誤的地方,交易輸出帶有的是鎖定指令碼,而不是解鎖指令碼。

Let’s sum it up. Outputs are where “coins” are stored. Each output comes with an unlocking script, which determines the logic of unlocking the output. Every new transaction must have at least one input and output. An input references an output from a previous transaction and provides data (the ScriptSig field) that is used in the output’s unlocking script to unlock it and use its value to create new outputs.

那到底是先有交易輸入還是先有交易輸出呢?

雞與蛋的問題

在比特幣中,雞蛋先於雞出現。交易輸入源自於交易輸出的邏輯是典型的"先有雞還是先有蛋"的問題:交易輸入產生交易輸出,交易輸出又會被交易輸入所引用。在比特幣中,交易輸出先於交易輸入出現

當礦工開始開採區塊時,區塊中會被新增一個 coinbase 交易。coinbase 交易是一種特殊的交易,它不需要以前已經存在的交易輸出。它會憑空建立出交易輸出(i.e: Coins)。也即,雞蛋的出現並不需要母雞,這筆交易是作為礦工成功挖出新的區塊後的一筆獎勵。

正如你所知道的那樣,在區塊鏈的最前端,即第一個區塊,有一個創世區塊。他產生了區塊鏈中有史以來的第一個交易輸出,並且由於沒有前一筆交易,也就沒有相應的輸出,因此不需要前一筆交易的交易輸出。

讓我們來建立 coinbase 交易:

/**
 * 建立CoinBase交易
 *
 * @param to   收賬的錢包地址
 * @param data 解鎖指令碼資料
 * @return
 */
public Transaction newCoinbaseTX(String to, String data) {
    if (StringUtils.isBlank(data)) {
        data = String.format("Reward to '%s'", to);
    }
    // 建立交易輸入
    TXInput txInput = new TXInput(new byte[]{}, -1, data);
    // 建立交易輸出
    TXOutput txOutput = new TXOutput(SUBSIDY, to);
    // 建立交易
    Transaction tx = new Transaction(null, new TXInput[]{txInput}, new TXOutput[]{txOutput});
    // 設定交易ID
    tx.setTxId();
    return tx;
}
複製程式碼

coinbase交易只有一個交易輸入。在我們的程式碼實現中,txId 是空陣列,txOutputIndex 設定為了 -1。另外,coinbase交易不會在 ScriptSig 欄位上儲存解鎖指令碼,相反,存了一個任意的資料。

在比特幣中,第一個 coinbase 交易報刊了如下的資訊:"The Times 03/Jan/2009 Chancellor on brink of second bailout for banks". 點選檢視

SUBSIDY 是挖礦獎勵數量。在比特幣中,這個獎勵數量沒有儲存在任何地方,而是依據現有區塊的總數進行計算而得到:區塊總數 除以 210000。開採創世區塊得到的獎勵為50BTC,每過 210000 個區塊,獎勵會減半。在我們的實現中,我們暫且將挖礦獎勵設定為常數。(至少目前是這樣)

在區塊鏈中儲存交易資訊

從現在開始,每一個區塊必須儲存至少一個交易資訊,並且儘可能地避免在沒有交易資料的情況下進行挖礦。這意味著我們必須移除 Block 物件中的 date 欄位,取而代之的是 transactions

/**
 * 區塊
 *
 * @author wangwei
 * @date 2018/02/02
 */
@Data
public class Block {

    /**
     * 區塊hash值
     */
    private String hash;
    /**
     * 前一個區塊的hash值
     */
    private String previousHash;
    /**
     * 交易資訊
     */
    private Transaction[] transactions;
    /**
     * 區塊建立時間(單位:秒)
     */
    private long timeStamp;

    public Block() {
    }

    public Block(String hash, String previousHash, Transaction[] transactions, long timeStamp) {
        this();
        this.hash = hash;
        this.previousHash = previousHash;
        this.transactions = transactions;
        this.timeStamp = timeStamp;
    }
}
複製程式碼

相應地,newGenesisBlocknewBlock 也都需要做改變:

/**
 * <p> 建立創世區塊 </p>
 *
 * @param coinbase
 * @return
 */
public static Block newGenesisBlock(Transaction coinbase) {
    return Block.newBlock("", new Transaction[]{coinbase});
}

/**
 * <p> 建立新區塊 </p>
 *
 * @param previousHash
 * @param transactions
 * @return
 */
public static Block newBlock(String previousHash, Transaction[] transactions) {
     Block block = new Block("", previousHash, transactions, Instant.now().getEpochSecond(), 0);
     ProofOfWork pow = ProofOfWork.newProofOfWork(block);
     PowResult powResult = pow.run();
     block.setHash(powResult.getHash());
     block.setNonce(powResult.getNonce());
     return block;
}
複製程式碼

接下來,修改 newBlockchain 方法:

/**
  * <p> 建立區塊鏈 </p>
  *
  * @param address 錢包地址
  * @return
  */
public static Blockchain newBlockchain(String address) throws Exception {
    String lastBlockHash = RocksDBUtils.getInstance().getLastBlockHash();
    if (StringUtils.isBlank(lastBlockHash)) {
        // 建立 coinBase 交易
        Transaction coinbaseTX = Transaction.newCoinbaseTX(address, "");
        Block genesisBlock = Block.newGenesisBlock(coinbaseTX);
        lastBlockHash = genesisBlock.getHash();
        RocksDBUtils.getInstance().putBlock(genesisBlock);
        RocksDBUtils.getInstance().putLastBlockHash(lastBlockHash);
     }
     return new Blockchain(lastBlockHash);
}
複製程式碼

現在,程式碼有錢包地址的介面,將會收到開採創世區塊的獎勵。

工作量證明(Pow)

Pow演算法必須將儲存在區塊中的交易資訊考慮在內,以儲存交易資訊儲存的一致性和可靠性。因此,我們必須修改 ProofOfWork.prepareData 介面程式碼邏輯:

/**
 * 準備資料
 * <p>
 * 注意:在準備區塊資料時,一定要從原始資料型別轉化為byte[],不能直接從字串進行轉換
 * @param nonce
 * @return
 */
private String prepareData(long nonce) {
   byte[] prevBlockHashBytes = {};
   if (StringUtils.isNoneBlank(this.getBlock().getPrevBlockHash())) {
       prevBlockHashBytes = new BigInteger(this.getBlock().getPrevBlockHash(), 16).toByteArray();
   }

   return ByteUtils.merge(
           prevBlockHashBytes,
           this.getBlock().hashTransaction(),
           ByteUtils.toBytes(this.getBlock().getTimeStamp()),
           ByteUtils.toBytes(TARGET_BITS),
           ByteUtils.toBytes(nonce)
    );
}
複製程式碼

其中 hashTransaction 程式碼如下:

/**
 * 對區塊中的交易資訊進行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].getTxId();
   }
   return DigestUtils.sha256(ByteUtils.merge(txIds));
}
複製程式碼

同樣,我們使用雜湊值來作為資料的唯一標識。我們希望區塊中的所有交易資料都能通過一個雜湊值來定義它的唯一標識。為了達到這個目的,我們計算了每一個交易的唯一雜湊值,然後將他們串聯起來,再對這個串聯後的組合進行雜湊值計算。

比特幣使用更復雜的技術:它將所有包含在塊中的交易表示為 Merkle樹 ,並在Proof-of-Work系統中使用該樹的根雜湊。 這種方法只需要跟節點的雜湊值就可以快速檢查塊是否包含某筆交易,而無需下載所有交易。

UTXO(未花費交易輸出)

UTXO:unspend transaction output.(未被花費的交易輸出)

在比特幣的世界裡既沒有賬戶,也沒有餘額,只有分散到區塊鏈裡的UTXO.

UTXO 是理解比特幣交易原理的關鍵所在,我們先來看一段場景:

場景:假設你過去分別向A、B、C這三個比特幣使用者購買了BTC,從A手中購買了3.5個BTC,從B手中購買了4.5個BTC,從C手中購買了2個BTC,現在你的比特幣錢包裡面恰好剩餘10個BTC。

問題:這個10個BTC是真正的10個BTC嗎?其實不是,這句話可能聽起來有點怪。(什麼!我錢包裡面的BTC不是真正的BTC,你不要嚇我……)

解釋:前面提到過在比特幣的交易系統當中,並不存在賬戶、餘額這些概念,所以,你的錢包裡面的10個BTC,並不是說錢包餘額為10個BTC。而是說,這10個BTC其實是由你的比特幣地址(錢包地址|公鑰)鎖定了的散落在各個區塊和各個交易裡面的UTXO的總和。

UTXO 是比特幣交易的基本單位,每筆交易都會產生UTXO,一個UTXO可以是一“聰”的任意倍。給某人傳送比特幣實際上是創造新的UTXO,繫結到那個人的錢包地址,並且能被他用於新的支付。

一般的比特幣交易由 交易輸入交易輸出 兩部分組成。A向你支付3.5個BTC這筆交易,實際上產生了一個新的UTXO,這個新的UTXO 等於 3.5個BTC(3.5億聰),並且鎖定到了你的比特幣錢包地址上。

假如你要給你女(男)朋友轉 1.5 BTC,那麼你的錢包會從可用的UTXO中選取一個或多個可用的個體來拼湊出一個大於或等於一筆交易所需的比特幣量。比如在這個假設場景裡面,你的錢包會選取你和C的交易中的UTXO作為 交易輸入,input = 2BTC,這裡會生成兩個新的交易輸出,一個輸出(UTXO = 1.5 BTC)會被繫結到你女(男)朋友的錢包地址上,另一個輸出(UTXO = 0.5 BTC)會作為找零,重新繫結到你的錢包地址上。

有關比特幣交易這部分更詳細的內容,請檢視:《精通比特幣(第二版)》第6章 —— 交易

我們需要找到所有未花費的交易輸出(UTXO)。Unspent(未花費) 意味著這些交易輸出從未被交易輸入所指向。這前面的圖片中,UTXO如下:

  1. tx0, output 1;
  2. tx1, output 0;
  3. tx3, output 0;
  4. tx4, output 0.

當然,當我們檢查餘額時,我不需要區塊鏈中所有的UTXO,我只需要能被我們解鎖的UTXO(當前,我們還沒有實現金鑰對,而是替代為使用者自定義的錢包地址)。首先,我們在交易輸入與交易輸出上定義鎖定-解鎖的方法:

交易輸入:

public class TXInput {
  	
    ...
     
    /**
     * 判斷解鎖資料是否能夠解鎖交易輸出
     *
     * @param unlockingData
     * @return
     */
    public boolean canUnlockOutputWith(String unlockingData) {
        return this.getScriptSig().endsWith(unlockingData);
    }
}
複製程式碼

交易輸出:

public class TXOutput {
    
    ...
        
    /**
     * 判斷解鎖資料是否能夠解鎖交易輸出
     *
     * @param unlockingData
     * @return
     */
    public boolean canBeUnlockedWith(String unlockingData) {
        return this.getScriptPubKey().endsWith(unlockingData);
    }
}
複製程式碼

這裡我們暫時用 unlockingData 來與指令碼欄位進行比較。我們會在後面的文章中來對這部分內容進行優化,我們將會基於私鑰來實現使用者的錢包地址。

下一步,查詢所有與錢包地址繫結的包含UTXO的交易資訊,有點複雜(本篇先這樣實現,後面我們做一個與錢包地址對映的UTXO池來進行優化):

  • 從與錢包地址對應的交易輸入中查詢出所有已被花費了的交易輸出
  • 再來排除,尋找包含未被花費的交易輸出的交易
public class Blockchain {

    ...

    /**
     * 查詢錢包地址對應的所有未花費的交易
     *
     * @param address 錢包地址
     * @return
     */
    private Transaction[] findUnspentTransactions(String address) throws Exception {
        Map<String, int[]> allSpentTXOs = this.getAllSpentTXOs(address);
        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].canBeUnlockedWith(address)) {
                        unspentTxs = ArrayUtils.add(unspentTxs, transaction);
                    }
                }
            }
        }
        return unspentTxs;
    }


    /**
     * 從交易輸入中查詢區塊鏈中所有已被花費了的交易輸出
     *
     * @param address 錢包地址
     * @return 交易ID以及對應的交易輸出下標地址
     * @throws Exception
     */
    private Map<String, int[]> getAllSpentTXOs(String address) throws Exception {
        // 定義TxId ——> spentOutIndex[],儲存交易ID與已被花費的交易輸出陣列索引值
        Map<String, int[]> spentTXOs = new HashMap<>();
        for (BlockchainIterator blockchainIterator = this.getBlockchainIterator(); blockchainIterator.hashNext(); ) {
            Block block = blockchainIterator.next();

            for (Transaction transaction : block.getTransactions()) {
                // 如果是 coinbase 交易,直接跳過,因為它不存在引用前一個區塊的交易輸出
                if (transaction.isCoinbase()) {
                    continue;
                }
                for (TXInput txInput : transaction.getInputs()) {
                    if (txInput.canUnlockOutputWith(address)) {
                        String inTxId = Hex.encodeHexString(txInput.getTxId());
                        int[] spentOutIndexArray = spentTXOs.get(inTxId);
                        if (spentOutIndexArray == null) {
                            spentTXOs.put(inTxId, new int[]{txInput.getTxOutputIndex()});
                        } else {
                            spentOutIndexArray = ArrayUtils.add(spentOutIndexArray, txInput.getTxOutputIndex());
                            spentTXOs.put(inTxId, spentOutIndexArray);
                        }
                    }
                }
            }
        }
        return spentTXOs;
    }
    
    ...
}
複製程式碼

得到了所有包含UTXO的交易資料,接下來,我們就可以得到所有UTXO集合了:

public class Blockchain {

   ...

   /**
     * 查詢錢包地址對應的所有UTXO
     *
     * @param address 錢包地址
     * @return
     */
    public TXOutput[] findUTXO(String address) throws Exception {
        Transaction[] unspentTxs = this.findUnspentTransactions(address);
        TXOutput[] utxos = {};
        if (unspentTxs == null || unspentTxs.length == 0) {
            return utxos;
        }
        for (Transaction tx : unspentTxs) {
            for (TXOutput txOutput : tx.getOutputs()) {
                if (txOutput.canBeUnlockedWith(address)) {
                    utxos = ArrayUtils.add(utxos, txOutput);
                }
            }
        }
        return utxos;
    }
    
    ...
    
}
複製程式碼

現在,我們可以實現獲取錢包地址餘額的介面了:

public class CLI {

    ...
        
   /**
     * 查詢錢包餘額
     *
     * @param address 錢包地址
     */
    private void getBalance(String address) throws Exception {
        Blockchain blockchain = Blockchain.createBlockchain(address);
        TXOutput[] txOutputs = blockchain.findUTXO(address);
        int balance = 0;
        if (txOutputs != null && txOutputs.length > 0) {
            for (TXOutput txOutput : txOutputs) {
                balance += txOutput.getValue();
            }
        }
        System.out.printf("Balance of '%s': %d\n", address, balance);
    }
 
    ...
        
}
複製程式碼

查詢 wangwei 這個錢包地址的餘額:

$ java -jar blockchain-java-jar-with-dependencies.jar getbalance -address wangwei

# 輸出
Balance of 'wangwei': 10
複製程式碼

轉賬

現在,我們想要給某人傳送一些幣。因此,我們需要建立一筆新的交易,然後放入區塊中,再進行挖礦。到目前為止,我們只是實現了 coinbase 交易,現在我們需要實現常見的建立交易介面:

public class Transaction {
 
   ...
   
   /**
     * 從 from 向  to 支付一定的 amount 的金額
     *
     * @param from       支付錢包地址
     * @param to         收款錢包地址
     * @param amount     交易金額
     * @param blockchain 區塊鏈
     * @return
     */
    public static Transaction newUTXOTransaction(String from, String to, int amount, Blockchain blockchain) throws Exception {
        SpendableOutputResult result = blockchain.findSpendableOutputs(from, amount);
        int accumulated = result.getAccumulated();
        Map<String, int[]> unspentOuts = result.getUnspentOuts();

        if (accumulated < amount) {
            throw new Exception("ERROR: Not enough funds");
        }
        Iterator<Map.Entry<String, int[]>> iterator = unspentOuts.entrySet().iterator();

        TXInput[] txInputs = {};
        while (iterator.hasNext()) {
            Map.Entry<String, int[]> entry = iterator.next();
            String txIdStr = entry.getKey();
            int[] outIdxs = entry.getValue();
            byte[] txId = Hex.decodeHex(txIdStr);
            for (int outIndex : outIdxs) {
                txInputs = ArrayUtils.add(txInputs, new TXInput(txId, outIndex, from));
            }
        }

        TXOutput[] txOutput = {};
        txOutput = ArrayUtils.add(txOutput, new TXOutput(amount, to));
        if (accumulated > amount) {
            txOutput = ArrayUtils.add(txOutput, new TXOutput((accumulated - amount), from));
        }

        Transaction newTx = new Transaction(null, txInputs, txOutput);
        newTx.setTxId();
        return newTx;
    }
    
    ...
    
}    
複製程式碼

在建立新的交易輸出之前,我們需要事先找到所有的UTXO,並確保有足夠的金額。這就是 findSpendableOutputs 要乾的事情。之後,為每個找到的輸出建立一個引用它的輸入。接下來,我們建立兩個交易輸出:

  1. 一個 output 用於鎖定到接收者的錢包地址上。這個是真正被轉走的coins;
  2. 另一個 output 鎖定到傳送者的錢包地址上。這個就是 找零。只有當用於支付的UTXO總和大於要支付的金額時,才會建立這部分的 交易輸出。記住:交易輸出是不可分割的

findSpendableOutputs 需要呼叫我們之前建立的 findUnspentTransactions 介面:

public class Blockchain {

    ...
    
    /**
     * 尋找能夠花費的交易
     *
     * @param address 錢包地址
     * @param amount  花費金額
     */
    public SpendableOutputResult findSpendableOutputs(String address, int amount) throws Exception {
        Transaction[] unspentTXs = this.findUnspentTransactions(address);
        int accumulated = 0;
        Map<String, int[]> unspentOuts = new HashMap<>();
        for (Transaction tx : unspentTXs) {

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

            for (int outId = 0; outId < tx.getOutputs().length; outId++) {

                TXOutput txOutput = tx.getOutputs()[outId];

                if (txOutput.canBeUnlockedWith(address) && 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);
    }
    
    ...

}
複製程式碼

這個方法會遍歷所有的UTXO並統計他們的總額。當計算的總額恰好大於或者等於需要轉賬的金額時,方法會停止遍歷,然後返回用於支付的總額以及按交易ID分組的交易輸出索引值陣列。我們不想要花更多的錢。

現在,我們可以修改 Block.mineBlock 介面:

public class Block {
   
   ...
   
   /**
     * 打包交易,進行挖礦
     *
     * @param transactions
     */
    public void mineBlock(Transaction[] transactions) throws Exception {
        String lastBlockHash = RocksDBUtils.getInstance().getLastBlockHash();
        if (lastBlockHash == null) {
            throw new Exception("ERROR: Fail to get last block hash ! ");
        }
        Block block = Block.newBlock(lastBlockHash, transactions);
        this.addBlock(block);
    }
    
    ...
    
}    
複製程式碼

最後,我們來實現轉賬的介面:

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);
        blockchain.mineBlock(new Transaction[]{transaction});
        RocksDBUtils.getInstance().closeDB();
        System.out.println("Success!");
    }
    
    ...
    
}    
複製程式碼

轉賬,意味著建立一筆新的交易並且通過挖礦的方式將其存入區塊中。但是,比特幣不會像我們這樣做,它會把新的交易記錄先存到記憶體池中,當一個礦工準備去開採一個區塊時,它會把打包記憶體池中的所有交易資訊,並且建立一個候選區塊。只有當這個包含所有交易資訊的候選區塊被成功開採並且被新增到區塊鏈上時,這些交易資訊才算被確認。

讓我們來測試一下:

# 先確認 wangwei 的餘額
$ java -jar blockchain-java-jar-with-dependencies.jar getbalance -address wangwei
Balance of 'wangwei': 10

# 轉賬
$ java -jar blockchain-java-jar-with-dependencies.jar send -from wangwei -to Pedro -amount 6
Elapsed Time: 0.828 seconds 
correct hash Hex: 00000c5f50cf72db1f375a5d454f98bc49d07335db921cbef5fa9e58ad34d462 

Success!

# 查詢 wangwei 的餘額
$ java -jar blockchain-java-jar-with-dependencies.jar getbalance -address wangwei
Balance of 'wangwei': 4


# 查詢 Pedro 的餘額
$ java -jar blockchain-java-jar-with-dependencies.jar getbalance -address Pedro
Balance of 'Pedro': 6
複製程式碼

贊!現在讓我們來建立更多的交易並且確保從多個交易輸出進行轉賬是正常的:

$ java -jar blockchain-java-jar-with-dependencies.jar send -from Pedro -to Helen -amount 2
Elapsed Time: 2.533 seconds 
correct hash Hex: 00000c81d541ad407a3767ad633d1147602df86fe14e1962ec145ab17b633e88 

Success!


$ java -jar blockchain-java-jar-with-dependencies.jar send -from wangwei -to Helen -amount 2
Elapsed Time: 1.481 seconds 
correct hash Hex: 00000c3f8b82c2b970438f5f1f39d56bb8a9d66341efc92a02ffcbff91acd84b 

Success!
複製程式碼

現在,Helen 這個錢包地址上有了兩筆從 wangwei 和 Pedro 轉賬中產生的UTXO,讓我們將它們再轉賬給另外一個人:

$ java -jar blockchain-java-jar-with-dependencies.jar send -from Helen -to Rachel -amount 3
Elapsed Time: 17.136 seconds 
correct hash Hex: 000000b1226a947166c2b01a15d1cd3558ddf86fe99bad28a0501a2af60f6a02 

Success!

$ java -jar blockchain-java-jar-with-dependencies.jar getbalance -address wangwei
Balance of 'wangwei': 2
$ java -jar blockchain-java-jar-with-dependencies.jar getbalance -address Pedro  
Balance of 'Pedro': 4
$ java -jar blockchain-java-jar-with-dependencies.jar getbalance -address Helen
Balance of 'Helen': 1
$ java -jar blockchain-java-jar-with-dependencies.jar getbalance -address Rachel
Balance of 'Rachel': 3

複製程式碼

非常棒!讓我們來測試一下失敗的場景:

$ java -jar blockchain-java-jar-with-dependencies.jar send -from wangwei -to Ivan -amount 5 
java.lang.Exception: ERROR: Not enough funds
        at one.wangwei.blockchain.transaction.Transaction.newUTXOTransaction(Transaction.java:104)
        at one.wangwei.blockchain.cli.CLI.send(CLI.java:138)
        at one.wangwei.blockchain.cli.CLI.parse(CLI.java:73)
        at one.wangwei.blockchain.cli.Main.main(Main.java:7)
複製程式碼

總結

本篇內容有點難度,但好歹我們現在有了交易資訊了。儘管,缺少像比特幣這一類加密貨幣的一些關鍵特性:

  1. 錢包地址。我們還沒有基於私鑰的真實地址。
  2. 獎勵。挖礦絕對沒有利潤。
  3. UTXO集。當我們計算錢包地址的餘額時,我們需要遍歷所有的區塊中的所有交易資訊,當有許許多多的區塊時,這將花費不少的時間。此外,如果我們想驗證以後的交易,可能需要很長時間。 UTXO集旨在解決這些問題並快速處理交易。
  4. 記憶體池。 這是交易在打包成區塊之前儲存的地方。 在我們當前的實現中,一個塊只包含一筆交易,而且效率很低。

資料

相關文章