blockchain in js (4) : 交易

作者: 糖酱桑 | 来源:发表于2018-09-02 14:59 被阅读3次

    交易1

    引言

    交易是比特币的核心,而blockchain的唯一目的就是安全可靠地存储交易信息,确保创建交易后,没人可以修改该交易信息。今天,让我们来实现交易。由于交易是个非常大的主题,因此将分两部分介绍。第一部分实现交易的框架,第二部分实现更过细节。

    There is no spoon

    Web应用一般需要创建如下数据表用于实现支付逻辑:账户表(accounts)、交易表(transactions)。accounts用于存储用户信息,如个人信息、账户余额;transactions用于存储资金的流动信息,如由谁支付给谁多少钱。比特币的实现则完全不同:

    1. 没有账户
    2. 没有余额
    3. 没有地址信息
    4. 没有货币信息
    5. 没有付款人、收款人

    由于blockchain是完全开放、公开的,因此我们不希望存储任何用户敏感信息。账户中不包含任何交易额信息。交易也不会将钱从一个账户转到另一个账户,也不存储任何账户余额信息。仅有的就是交易信息,交易信息到底有什么呢?

    比特币交易

    一个交易由多个输入和输出:

    class Transaction {
      constructor() {
        this.id = null;
        this.vIn = [];
        this.vOut = [];
      }
    }
    

    交易输入(下文称TXI)均与之前交易的输出(下文称TXO)相关联(存在一个例外情况,后续讨论);TXO存储交易额。下图展示了交易间相互关联的情况:

    4.1.png

    注意:

    1. 有一些输出并没有被关联到某个输入上
    2. 一笔交易的输入可以引用之前多笔交易的输出
    3. 一个输入必须引用一个输出

    贯穿本文,我们将会使用像“钱(money)”,“币(coin)”,“花费(spend)”,“发送(send)”,“账户(account)” 等等这样的词。但是在比特币中,其实并不存在这样的概念。交易仅仅是通过一个脚本(script)来锁定(lock)一些值(value),而这些值只可以被锁定它们的人解锁(unlock)。

    一个交易包括付款方和收款方,因此交易中的TXI都是付款方,而交易中的TXO有两种情况: ** 若TXI的总额正好和所需值相等,那么交易只有一个TXO,该TXO属于收款方;若TXI的总额大于所需值,那么交易有两个TXO,一个属于收款方,一个属于付款方。 ** (FindUnspentTransactions方法利用这个特点来获取某个地址(账户)的UTXO):

    ![4.3.png](https://img.haomeiwen.com/i785822/a247877a2f9d9066.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

    交易输出(TXO)

    class TXOutput {
      constructor(value, script) {
        this.value = value;
        this.scriptPubKey = script;
      }
    }
    

    输出主要包含两部分:

    1. 一定量的比特币(Value)
    2. 一个锁定脚本(ScriptPubKey),要花这笔钱,必须要解锁该脚本。

    实际上,正是输出里面存储了“币”(注意,也就是上面的 Value 字段)。而这里的存储,指的是用一个数学难题对输出进行锁定,这个难题被存储在 ScriptPubKey 里面。在内部,比特币使用了一个叫做 Script 的脚本语言,用它来定义锁定和解锁输出的逻辑。虽然这个语言相当的原始(这是为了避免潜在的黑客攻击和滥用而有意为之),并不复杂,但是我们也并不会在这里讨论它的细节。你可以在这里 找到详细解释。

    在比特币中,value 字段存储的是 satoshi 的数量,而不是 BTC 的数量。一个 satoshi 等于一亿分之一的 BTC(0.00000001 BTC),这也是比特币里面最小的货币单位(就像是 1 分的硬币)。

    由于还没有实现地址(address),所以目前我们会避免涉及逻辑相关的完整脚本。ScriptPubKey 将会存储一个任意的字符串(用户定义的钱包地址)。

    顺便说一下,有了一个这样的脚本语言,也意味着比特币其实也可以作为一个智能合约平台。

    关于输出,非常重要的一点是:它们是不可再分的(indivisible)。也就是说,你无法仅引用它的其中某一部分。要么不用,如果要用,必须一次性用完。当一个新的交易中引用了某个输出,那么这个输出必须被全部花费。如果它的值比需要的值大,那么就会产生一个找零,找零会返还给发送方。这跟现实世界的场景十分类似,当你想要支付的时候,如果一个东西值 1 美元,而你给了一个 5 美元的纸币,那么你会得到一个 4 美元的找零。

    交易输入(TXI)

    class TXInput {
      constructor(txId, value, script) {
        this.txId = txId;
        this.vOut = value;
        this.scriptSig = script;
      }
    }
    

    如上所述,TXI与之前的某个TXO相关联:txId 存储输出所属的交易的ID,vOut 存储输出的序号(一个交易可以包括多个TXO)。scriptSig存储一个脚本,与之关联的TXO的 ScriptPubKey 就来源于该脚本,因此两者可以进行校验:如果校验正确,与之关联的TXO被解锁并生成新的TXO;如果校验不正确,TXO压根不能够被TXI所引用。该机制保证钱只能被其拥有者使用,而不能被其他人使用。
    由于我们还没有实现地址(钱包),scriptSig 仅仅存储用户自定义的字符串而已,后续我们将实现公钥和签名核查。
    综上所述,TXO存储交易额,同时拥有一个解锁脚本。每个交易至少拥有一个TXI和一个TXO。TXI的scriptSig和TXO的scriptPubKey进行校验,验证成功后可以解锁交易输出,并创建新的TXO。
    那么问题来了:先有TXI还是先有TXO?

    先有蛋后有鸡

    比特币中,TXI关联TXO的逻辑和经典的“先有鸡还是先有蛋”的问题是一样的。比特币是先有蛋(TXO)后有鸡(TXI),TXO先于TXI出现。

    当blockchain的首个block(即genesis block)被挖到后,会生成一个coinbase交易。coinbase交易是一种特殊的交易,该TXI不会引用任何TXO,而会直接生成一个TXO,这是作为奖励给矿工的。

    Blockchain以genesis block开头,该block生成blockchain中第一个TXO。由于之前没有任何交易,因此该TXI不会与任何TXO关联。

    下面创建一个coinbase交易:

      static NewCoinbaseTX(to, data) {
        if (!data || data == '') {
          data = `Reward to ${to}`;
        }
    
        let txIn = new TXInput(null, -1, data);
        let txOut = new TXOutput(Transaction.SUBSIDY, to);
        let transaction = new Transaction();
        transaction.vIn.push(txIn);
        transaction.vOut.push(txOut);
    
        transaction.setId();
    
        return transaction; 
      }
    

    coinbase仅有一个TXI,该TXI的txId为空,value设置为-1,同时scriptSig中存储的不是脚本,而仅仅是一个普通字符串。

    比特币中,第一个coinbase交易包含如下信息“The Times 03/Jan/2009 Chancellor on brink of second bailout for banks”

    Subsidy是挖矿的奖励值,比特币中,该奖励值是基于总block数量计算得到的。挖出genesis奖励50BTC,每挖出210000个block,奖励值减半。我们的实现中,该奖励值是一个常量。

    交易

    从现在开始,每个block至少包含一个交易,Block结构中的Data字段江永Transactions字段代替。

    
      constructor(timestamp, transactions, prevBlockHash, hash) {
        this.timestamp = timestamp;
        this.transactions = transactions;
        this.prevBlockHash = prevBlockHash;
        this.hash = hash;
        this.nonce = 0;
      }
    
    

    NewBlockNewGenesisBlock方法也需要修改:

    
      static NewBlock(transactions, prevBlockHash) {
        let block = new Block(Date.now(), transactions, prevBlockHash);
        let pow = POW.NewProofOfWork(block);
        let powResult = pow.run();
        block.hash = powResult.hash;
        block.nonce = powResult.nonce;
        // block.setHash();
        return block;
      }
    
    
    
      newGenesisBlock() {
        return Block.NewBlock([], ``);
      }
    
    

    同时,修改 blockchain.js 里面的 函数:

    
      static NewBlockChain(address) {
        // get db instance
        let store = new InCache({
          storeName: "blockchain",
          autoSave: true,
          autoSaveMode: "timer"
        });
    
        let bc;
    
        // check db
        let lastHash = store.get('l');
        if (!lastHash) {
          bc = new BlockChain();
          let tx = Transaction.NewCoinbaseTX(address, genesisCoinbaseData);
          let block = bc.newGenesisBlock(tx);
    
          store.set(block.hash, block.toString());
          store.set('l', block.hash);
          lastHash = block.hash;
        } else 
          bc = new BlockChain();
        bc.db = store;
        bc.tip = lastHash;
    
        return bc;
      }
      
    

    数据库的第一个genesisBlock接收一个地址,并且默认写下一句话“The Times 03/Jan/2009 Chancellor on brink of second bailout for banks”。

    工作量证明

    为了保证blockchain的一致性和可靠性,PoW算法必须要考虑block中的交易信息,ProofOfWork.prepareData需要做如下修改:

    
      prepareData(nonce) {
        return this.block.prevBlockHash + "" 
        + this.block.hashTransaction() + ""   // 这个函数待会儿给出
        + this.block.timestamp + "" 
        + POW.trgetBits + "" 
        + nonce;
      }
      
    

    现在使用pow.block.HashTransactions()代替pow.block.Data:

    
      // 在block.js增加函数
      hashTransaction() {
        let txHashes = "";
        for (let i = 0; i < this.transactions.length; i++) {
          txHashes += JSON.stringify(this.transactions[i]);
        }
    
        let sha256 = new Hashes.SHA256();
        hash = sha256.hex(txHashes);
        return hash;
      }
    
    

    我们遍历所有交易,获取每个交易的hash值,然后将所有交易的hash值组合后最终得到一个代表block中整个交易的hash值,计算整个block的hash值时会引用该值。

    比特币使用了一个更加复杂的技术:它将一个块里面包含的所有交易表示为一个 Merkle tree ,然后在工作量证明系统中使用树的根哈希(root hash)。这个方法能够让我们快速检索一个块里面是否包含了某笔交易,即只需 root hash 而无需下载所有交易即可完成判断。

    给cli.js增加如下代码:

    
    program
      .command('addblockchain')
      .description('addblockchain for address')
      .option("-s, --address [addr]", "Which address to use")
      .action(function(options){
        var addr = options.address || "";
        let bc = BlockChain.NewBlockChain(addr);
    
        let iterator = bc.getBlockIterator();
        let currBlock = iterator.curr();
        console.log(currBlock);
        while(iterator.hasNext()) {
          let prev = iterator.next();
          console.log(prev);
        }
      });
    
    program.parse(process.argv);
    
    

    之后尝试运行:
    node src/cli.js addblockchain -s Ivan
    可得到正确结果。

    很好!我们已经获得了第一笔挖矿奖励,但是,我们要如何查看余额呢?

    未花费交易输出

    我们需要找到所有的未花费交易输出(unspent transactions outputs, UTXO)。未花费(unspent) 指的是这个输出还没有被包含在任何交易的输入中,或者说没有被任何输入引用。在上面的图示中,未花费的输出是:

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

    当然了,检查余额时,我们并不需要知道整个区块链上所有的 UTXO,只需要关注那些我们能够解锁的那些 UTXO(目前我们还没有实现密钥,所以我们将会使用用户定义的地址来代替)。首先,让我们定义在输入和输出上的锁定和解锁方法:

    
      // transaction.js
    
      // TXInput
    
      // unlockingData 理解为地址
      canUnlockOutputWith(unlockingData) {
        return this.scriptSig == unlockingData;  
      }
    
      // TXOutput
      canBeUnlockedWith(unlockingData) {
        retrn this.scriptPubKey == unlockingData;
      }
    
    

    在这里,我们只是将 script 字段与 unlockingData 进行了比较。在后续文章我们基于私钥实现了地址以后,会对这部分进行改进。

    下一步,找到包含未花费输出的交易,这一步其实相当困难:

    
      
      findUnspentTransactions(address) {
        let unspentTXs = [];
    
        let spentTXOs = {};
    
        let iterator = this.getBlockIterator();
        while(iterator.hasNext()) {
          let block = iterator.next();
    
          for (let i = 0; i < block.transactions.length; i++) {
            let tx = block.transactions[i];
            let txId = tx.id;
    
            for (let outIdx = 0; outIdx < tx.vOut.length; outIdx++) {
    
              // 如果被花费掉了
              let needToBreak = false;
    
              // 任何tx,只要有输入,都会被放在spentTXOs里。在稍下面代码里。这里要判断是否已经花费
              if (spentTXOs[txId] != null && spentTXOs[txId] != undefined) {
                for (let j = 0; j < spentTXOs[txId].length; j++) {
                  if (spentTXOs[txId][j] == outIdx) {
                    needToBreak = true;
                    break;
                  }
                }
              }
    
              if (needToBreak) {
                continue;
              }
    
              let out = tx.vOut[outIdx];
              if (out.canBeUnlockedWith(address)) {
                unspentTXs.push(tx);
              }
            }
    
            if (tx.isCoinbase() == false) {
              for (let k = 0; k < tx.vIn.length; k ++) {
                let vIn = tx.vIn[k];
                if (vIn.canUnlockOutputWith(address)) {
                  let vInId = vIn.txId;
                  if (!spentTXOs[vInId]) {
                    spentTXOs[vInId] = [];
                  }
                  spentTXOs[vInId].push(vIn.vOut);
                }
              }
            }
          }
        }
    
        return unspentTXs;
      }
    
    

    交易存储在block中,我们需要遍历blockchain中的每一个block:

      
      if (out.canBeUnlockedWith(address)) {
        unspentTXs.push(tx);
      }
    
    

    如果TXO是被指定地址锁定的,该TXO会作为候选TXO继续进行处理:

      
      if (spentTXOs[txId] != null && spentTXOs[txId] != undefined) {
        let needToBreak = false;
        for (let j = 0; j < spentTXOs[txId]; j++) {
          if (spentTXOs[txId][j] == outIdx) {
            needToBreak = true;
            break;
          }
        }
      }
    
    

    对于已经被TXI引用的TXO,不做处理;对于未被TXI引用的TXO,即UTXO,将包含其的交易保存到交易列表中。对于coinbase交易,不需要遍历TXI,因为其TXI不会引用任何TXO。

      
      if (tx.isCoinbase() == false) {
        for (let k = 0; k < tx.vIn; k ++) {
          let vIn = tx.vIn[k];
          if (vIn.canUnlockOutputWith(address)) {
            let vInId = vIn.txId;
            if (!spentTXOs[vInId]) {
              spentTXOs[vInId] = [];
            }
            spentTXOs[vInId].push(vIn.vOut);
          }
        }
      }
    
    

    最终,该函数返回包含UTXO的交易列表。通过接下来的函数,进一步处理,最终返回TXO列表。

      
      findUTXO(address) {
        let UTXOs = [];
        let txs = this.findUnspentTransactions(address);
    
        for (let i = 0; i < txs.length; i ++) {
          for (let j = 0; j < txs[i].vOut.length; j ++) {
            if (txs[i].vOut[j].canBeUnlockedWith(address)) {
              UTXOs.push(txs[i].vOut[j]);
            }
          }
        }
        return UTXOs;
      }
    
    

    OK!下面实现getbalance命令:

      
    program
      .command('getBalance')
      .description('getBalance for address')
      .option("-s, --address [addr]", "Which address to use")
      .action(function(options){
        var addr = options.address || "";
        let bc = BlockChain.NewBlockChain(addr);
    
        let UTXOs = bc.findUTXO(addr);
        let amount = 0;
        for (let i = 0; i < UTXOs.length; i ++) {
          amount += UTXOs[i].value;
        }
    
        console.log(`Balance of ${addr}: ${amount}`);
      });
    
    

    账户余额就是属于该账户的所有UTXO的总和。

    Ivan挖到genesis block后,其账户余额为10:

      //删除本地数据库之后,运行
      node src/cli.js addblockchain -s Ivan
    
      //之后运行下面这段获取余额
      node src/cli.js getBalance --address Ivan
    

    发送币

    货币流动起来才创造价值,为了达成此目的,我们需要创建一个交易,将该交易放到一个block中,然后通过挖矿挖到(PoW)该block。目前为止,我们仅仅实现了coinbase这种特殊的交易,下面我们实现通用的交易:

    
      // transaction.js
    
      static NewUTXOTransaction(fromAddr, to, amount, bc) {
        let inputs = [];
        let outputs = [];
    
        let obj = bc.findSpendableOutputs(fromAddr, amount);
        let acc = obj.amount;
        let outputs = obj.unspentOutputs;
    
        if (acc < amount) {
          console.log(`ERROR: Not enough funds`);
          return;
        }
    
        for (let txId in outputs) {
          let outs = outputs[txId];
          for (let key in outs) {
            let outIdx = outs[key];
            let input = new TXInput(txId, outIdx, fromAddr);
            inputs.push(input);
          }
        }
    
        outputs.push(new TXOutput(amount, to));
    
        if (acc > amount) {
          outputs.push(new TXOutput(acc - amount, fromAddr));
        }
    
        let tx = new Transaction();
        tx.vIn = inputs;
        tx.vOut = outputs;
        tx.setId();
    
        return tx;
      }
    
    

    在创建新的输出前,我们首先必须找到所有的未花费输出,并且确保它们有足够的价值(value),这就是 FindSpendableOutputs 方法要做的事情。随后,对于每个找到的输出,会创建一个引用该输出的输入。接下来,我们创建两个输出:

    1. 一个由接收者地址锁定。这是给其他地址实际转移的币。

    2. 一个由发送者地址锁定。这是一个找零。只有当未花费输出超过新交易所需时产生。记住:输出是不可再分的

    FindSpendableOutputs 方法基于之前定义的 FindUnspentTransactions 方法:

    
      // blockchain.js
    
      findSpendableOutputs(address, amount) {
        let unspentOutputs = {};
        let unspentTxs = this.findUnspentTransactions(address);
        let accumulated = 0;
    
        for (let key in unspentTxs) {
          let tx = unspentTxs[key];
          let txId = tx.id;
    
          let needBreak = false;
          for (let outIdx = 0; outIdx < tx.vOut.length; outIdx ++) {
            let output = tx.vOut[outIdx];
            if (output.canBeUnlockedWith(address) && accumulated < amount) {
              accumulated += output.value;
              if (!unspentOutputs[txId]) 
                unspentOutputs[txId] = [];
              unspentOutputs[txId].push(outIdx);
              if (accumulated >= amount) {
                needBreak = true;
                break;
              }
            }
          }
    
          if (needBreak)
            break;
        }
    
        return {
          amount: accumulated,
          unspentOutputs: unspentOutputs
        };
      }
    
    

    这个方法对所有的未花费交易进行迭代,并对它的值进行累加。当累加值大于或等于我们想要传送的值时,它就会停止并返回累加值,同时返回的还有通过交易 ID 进行分组的输出索引。我们只需取出足够支付的钱就够了。

    现在,我们可以修改 Blockchain.MineBlock 方法:

    
      mineBlock(txs) {
        let lastHash = this.db.get('l');
        let newBlock = Block.NewBlock(txs, lastHash);
        this.db.set(newBlock.hash, newBlock.toString());
        this.db.set('l', newBlock.hash);
        this.tip = newBlock.hash;
      }
    
    

    最后,让我们来实现 send 方法:

    
    program
      .command('send')
      .description('send value')
      .option("-s, --sendAddress [sendAddress]", "Which address to use")
      .option("-t, --toAddress [toAddress]", "Which address to use")
      .option("-v, --value [value]", "Value")
      .action(function(options){
        let sendAddr = options.sendAddress;
        let toAddr = options.toAddress;
        let value = options.value;
    
        let bc = BlockChain.NewBlockChain();
        let tx = Transaction.NewUTXOTransaction(sendAddr, toAddr, value, bc);
        bc.mineBlock([tx]);
    
        console.log(`Success!`);
      });
    
    

    消费意味着创建一个交易,然后挖一个block存储该交易,并将block添加到blockchain中。但是比特币的做法不同:比特币不会为一个新的交易马上去挖矿,而是会先将新交易缓存内存池mempool,当矿工即将挖矿时,从内存池中将所有交易取出,整体放到block中,并添加到blockchain。

    让我们尝试进行一些交易:

      
      // 初始化block
      // 之前先手动删除.incache文件
      node src/cli.js addblockchain -s Ivan
    
      // 开始发送coin
      node src/cli.js send -s Ivan -t Jay -v 2
      node src/cli.js send -s Ivan -t Jay -v 4
      node src/cli.js send -s Ivan -t Jay -v 1
    
      //结果
      node src/cli.js getBalance --address Jay
    
      node src/cli.js getBalance --address Ivan
    
    

    总结

    哇!经历重重困难,我们现在终于实现了交易了!不过,我们仍然缺少了比特币这类数字货币的一些关键特性:

    1. 地址(账户/钱包):仍没有基于私钥的地址
    2. 奖励:挖矿应该给予响应的奖励
    3. UTXO集合:在我们的实现中,为了获取余额需要遍历整个blockchain,效率非常低。此外,如果我们想验证交易,也会很慢。UTXO集合可以解决上述问题。
    4. Mempool:在我们的实现中,一个block仅仅包含一个交易,利用率不高。Mempool用于缓存多个交易然后打包到一个block中,从而提高利用率。

    链接

    相关文章

      网友评论

        本文标题:blockchain in js (4) : 交易

        本文链接:https://www.haomeiwen.com/subject/hfjmwftx.html