基于Java语言构建区块链(四)—— 交易(UTXO)

基于Java语言构建区块链(四)—— 交易(UTXO)

最终内容请以原文为准:https://wangwei.one/posts/9cf9e42a.html

引言

上一篇 文章,我们实现了区块数据的持久化,本篇开始交易环节的实现。交易这一环节是整个比特币系统当中最为关键的一环,并且区块链唯一的目的就是通过安全的、可信的方式来存储交易信息,防止它们创建之后被人恶意篡改。今天我们开始实现交易这一环节,但由于这是一个很大的话题,所以我们分为两部分:第一部分我们将实现区块链交易的基本机制,到第二部分,我们再来研究它的细节。

比特币交易

如果你开发过Web应用程序,为了实现支付系统,你可能会在数据库中创建一些数据库表:账户交易记录。账户用于存储用户的个人信息以及账户余额等信息,交易记录用于存储资金从一个账户转移到另一个账户的记录。但是在比特币中,支付系统是以一种完全不一样的方式实现的,在这里:

  • 没有账户
  • 没有余额
  • 没有地址
  • 没有 Coins(币)
  • 没有发送者和接受者

由于区块链是一个公开的数据库,我们不希望存储有关钱包所有者的敏感信息。Coins 不会汇总到钱包中。交易不会将资金从一个地址转移到另一个地址。没有可保存帐户余额的字段或属性。只有交易信息。那比特币的交易信息里面到底存储的是什么呢?

交易组成

一笔比特币的交易由 交易输入交易输出 组成,数据结构如下:

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

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

    
}

一笔交易的 交易输入 其实是指向上一笔交易的交易输出 (这个后面详细说明)。我们钱包里面的 Coin(币)实际是存储在这些 交易输出 里面。下图表示了区块链交易系统里面各个交易相互引用的关系:

基于Java语言构建区块链(四)—— 交易(UTXO)

注意:

  1. 有些 交易输出 并不是由 交易输入 产生,而是凭空产生的(后面会详细介绍)。
  2. 但,交易输入 必须指向某个 交易输出,它不能凭空产生。
  3. 在一笔交易里面,交易输入 可能会来自多笔交易所产生的 交易输出

在整篇文章中,我们将使用诸如“钱”,“硬币”,“花费”,“发送”,“账户”等词语。但比特币中没有这样的概念,在比特币交易中,交易信息是由 锁定脚本 锁定一个数值,并且只能被所有者的 解锁脚本 解锁。(解铃还须系铃人)

交易输出

让我们先从交易输出开始,他的数据结构如下:

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

    /**
     * 数值
     */
    private int value;
    /**
     * 锁定脚本
     */
    private String 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
@AllArgsConstructor
@NoArgsConstructor
public class TXInput {

    /**
     * 交易Id的hash值
     */
    private byte[] txId;
    /**
     * 交易输出索引
     */
    private int txOutputIndex;
    /**
     * 解锁脚本
     */
    private String 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
@AllArgsConstructor
@NoArgsConstructor
public class Block {

    /**
     * 区块hash值
     */
    private String hash;
    /**
     * 前一个区块的hash值
     */
    private String previousHash;
    /**
     * 交易信息
     */
    private Transaction[] transactions;
    /**
     * 区块创建时间(单位:秒)
     */
    private long 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. 内存池。 这是交易在打包成区块之前存储的地方。 在我们当前的实现中,一个块只包含一笔交易,而且效率很低。

资料

上一篇:CNCF 官方大使张磊:Kubernetes 是一个“数据库”吗?


下一篇:基于Java语言构建区块链(六)—— 交易(Merkle Tree)