美文网首页区块链大学区块链研习社大数据
区块链开发:账户与交易模型 #C13

区块链开发:账户与交易模型 #C13

作者: 纳兰少 | 来源:发表于2019-03-25 11:07 被阅读5次

交易是区块链的核心所在,因为区块链作为一个分布式数据库,其存储的就是账本,也就是交易数据。交易一旦在区块链中确认,就无法被篡改。

区块链中有两种交易模型,一种是普通账户模型,或者也叫账户余额模型,这种模型在我们的日常应用中就已经广泛应用。例如我们我们的银行卡、支付宝、微信等,首先你有一个账户,然后打开账户会显示你的账户余额,当你进行消费、转账之后,账户余额会更新。这种模式的特点是主要记录账户的最终状态,由于余额的最终状态需要根据交易记录来进行结算,而且涉及到跨机构结算时耗费时间较长,后台一般使用我们之前介绍的一致性算法来保证各账本状态的一致性。

另一种模型称之为UTXO模型,UTXO即“Unspent Transaction Output”,未花费交易输出。它与普通账户模型最大的区别在于UTXO记录的是交易事件,而不是最终状态。也就是把交易记录存储在区块中,用户可以根据交易记录自行计算余额。类似于MySQL的binlog以及redis的AOF模式。

与普通账户模型相比,UTXO模型有以下的特性:

  1. 不可分割
  2. 隐私保护,可以为每一笔输出设置一个地址
  3. 安全性高,无需关心事务

普通账户模型还可以自定义数据类型,但是需要设计事务机制来保证数据一致性。而UTXO每个交易数据操作都是原子的,在安全性方面高于普通账户模型。

UTXO适用于高频率跨账户转账的应用,例如数字货币,但是在其他方面比如只能合约的实现上比不上普通账户模型。在这里我们先实现一个区块链中的UTXO模型。

区块链中的UTXO模型

基本结构

参考比特币的实现,一笔交易通常包含若干输入与输出,例如:

这一笔比特币交易包含6个输入,几十个输出,交易一共3.5kb,交易的输入输出会影响交易大小,比特币的交易费是根据字节收费的,交易尺寸越大越贵,而交易尺寸主要和输入输出的个数有关,也就是说,算法上并不规定输入输出的个数,而只有区块尺寸限制。

在比特币中将小于100kb的交易称为标准交易,超过100kb的称为非标准交易。它的前向input以及生成一个out约占用 161~250bytes 。所以在比特币中,大约的inputs/ouputs的最大数目限制为100KB/161B ~= 600个。

我们为UTXO模型设计如下的数据结构;

Transaction {
    id, // string
    input, // array of TxInput
    output // array of TxOutput
}

TxOutput {
    amount, // amount of token
    ScriptPubKey
}
TxInput {
    id, // string, Transaction id
    output_id, // index of Transaction's output
    ScriptSig
}

TxOutput表示转移amount金额给另一个账户,而这笔金额被ScriptPubKey锁住。ScriptPubKey表示一个数学难题,只有解开这个数学难题的账户才能使用这笔金额,ScriptPubKey的内容依据具体设计而不同。

TxInput表示对一笔TxOutput的引用,id是某笔Transactionidindex表示引用该Transaction的第几个输出,而ScriptSig就是对TxOutputScriptPubKey的解答,表示自己能够使用这笔交易输出中的金额。

可以看出,输入即输出,交易就是将一些输出组合起来转化为另外的输出组合。并且遵守如下规则

输入金额之和 = 输出金额之和 + 找零 + 手续费

所以TxOutput被引用后,必须全部使用完,剩余部分可以返回给自己作为找零,如果TxOutput被引用后只花费了一部分并且没有找零,那么多下来的部分就会作为矿工费被矿工获得。

实现:

// transactionjs

'use strict';
var Crypto = require("./crypto");

class TxOutput {
    constructor(amount, ScriptPubKey) {
        this.amount_ = amount;
        this.script_pubkey_ = ScriptPubKey;
    }
    toObject() {
        let output = {
            "amount": this.amount_,
            "ScriptPubKey": this.script_pubkey_
        };
        return output;
    }
}

class TxInput {
    constructor(id, index, ScriptSig) {
        this.id_ = id;
        this.index_ = index;
        this.script_sig_ = ScriptSig;
    }
    toObject() {
        let input = {
            "id": this.id_,
            "index": this.index_,
            "ScriptSig": this.script_sig_
        };
        return input;
    }
}

class Transaction {
    constructor(input, output) {
        this.input_ = [];
        for (i = 0; i < input.length; ++i) {
            this.input_.push(input[i].toObject());
        }
        this.output_ = [];
        for (var i = 0; i < output.length; ++i) {
            this.output_.push(output[i].toObject());
        }
        this.id_ = Crypto.calc_hash(JSON.stringify(this.input_) + JSON.stringify(this.output_));
        return this.toObject();
    }
    get_id() { return this.id_; }
    get_input() { return this.input_; }
    get_output() { return this.output_; }
    toObject() {
        let tx = {
            "id": this.id_,
            "input": this.input_,
            "output": this.output_
        };
        return tx;
    }
}

module.exports = {
    TxOutput,
    TxInput,
    Transaction
};

coinbase交易

coinbase交易是一种特殊的交易,它只有输出没有输入。矿工会在挖出的区块中添加coinbase交易,coinbase交易产生的输出就是矿工挖矿所获得的奖励。

let input = new TxInput(null, -1, data);
let output = new TxOutput(subsidy, to);
let TxCoinBase = new Transaction([input], [output]);

这里的输入input没有包含任何之前交易的输出,只是在ScriptSig中设置了任意一个字符串,例如比特币的第一笔coinbase交易中包含了如下信息:“The Times 03/Jan/2009 Chancellor on brink of second bailout for banks”

输出output中的subsidy是奖励的数额,to表示当前挖矿节点的地址(或者接收脚本)。在比特币种,subsidy初始值为50BTC,每挖出21000个区块后就减半,这里将其设置为定值50,也可以根据区块高度动态分配。

实现:

// blockchain.js

    create_coinbase() {
        let input = new TxInput(null, -1, `${new Date()} node: ${this.get_account_id()} coinbase tx`);
        let output = new TxOutput(50, this.get_account_keypair().publicKey.toString('hex'));
        let tx = new Transaction([input], [output]);
        return tx;
    }

获取UTXO

  • UTXO:交易中的某个输出没有被任何交易的输入所引用

在UTXO模型中,创建交易的关键就在于获取未被花费的输出,例如在下图所示的交易中以下四个输出属于UTXO:

  • tx0, output 1;
  • tx1, output 0;
  • tx3, output 0;
  • tx4, output 0.

但是这四个UTXO未必属于一个账户地址,所以某一账户要创建交易时,首先要获取属于自己账户的UTXO,可以采用如下的逻辑:

  1. 遍历区块
  2. 遍历每个区块的交易
  3. 遍历每个交易的output
    3.2 查看output是否已经在spentTXOs[transaction_id]中,存在说明已经花费了
    3.3 如果这个output是给该地址的,加入UTXO
  4. 如果不是coinbase交易,遍历该交易的input,获取transaction_id,和output index。
    添加到spentTXOs[transaction_id].push(index)

以上的过程,可以称之为扫块(Block scan),就是扫描全节点中的所有区块,然后将其转化为另外的数据模型进行处理。许多数字货币钱包、交易所以及区块链浏览器都使用了该技术。

// blockchainjs

    async get_utxo(cb) {
        let publicKey = this.get_public_key();
        let spentTXOs = {};
        await this.iterator_back((block) => {
            let txs = block.transactions;
            // tx
            for (var i = 0; i < txs.length; ++i) {
                let tx = txs[i];
                let transaction_id = tx.id;
                // output
                for (var j = 0; j < tx.output.length; ++j) {
                    let output = tx.output[j];
                    // owns
                    if (output.ScriptPubKey == publicKey) {
                        // not spent
                        if (spentTXOs.hasOwnProperty(transaction_id) &&
                            spentTXOs[transaction_id].hasOwnProperty(j)) {
                            continue;
                        } else {
                            if (!cb(transaction_id, j, output)) return false;
                        }
                    }
                }
                // input
                for (j = 0; j < tx.input.length; ++j) {
                    let input = tx.input[j];
                    // not coinbase
                    if (input.id != null && input.index != -1) {
                        if (!spentTXOs[input.id]) {
                            spentTXOs[input.id] = [];
                        }
                        spentTXOs[input.id].push(input.index);
                    }
                }
            }
            return true;
        },
        this.get_last_block().hash);
    }

    async get_balance() {
        let value = 0;
        await this.get_utxo((transaction_id, index, vout) => {
            value += vout.amount;
            return true;
        });
        return value;
    }

而获取一个账户余额的方法就是先获取该地址上所有的UTXO,然后将其金额累加即可。

这里使用倒叙遍历区块还有一个好处,就是当前区块的输出只能被后续区块花费,而不会被之前的区块花费,如果使用正向遍历,那么得要遍历完所有区块才能知道该交易输出是否被花费。

如何验证某节点是否有权使用这些UTXO? 简单来说可以用以下两种方式实现:

  1. TxOutput中的ScriptPubKey和TxInput中的ScriptSig都是接收者的地址,判断两个地址是否相同;
  2. ScriptPubKey为接收者的公钥,接收者和自己的公钥比对判断是否是转给自己的钱;
    ScriptSig是接收者对这个output的签名,其他节点可以通过这个签名来验证是否拥有使用权

如果是完整版,那么ScriptPubKey就应该是目标账户的地址(私钥->公钥->hash->base58->address等),ScriptSig包含publickey和signature。先验证signature是否正确,再验证publickey与address是否对应。这里简化一下就是ScriptPubKey就是pubkey,而ScriptSig就是signature,即账户地址用对方的公钥来表示。

// blockchain.js

    async verify_transaction(tx) {
        let input_amount = 0;
        for (var i = 0; i < tx.input.length; ++i) {
            let input = tx.input[i];
            // coinbase
            if (input.id == null) {
                // todo check milestone
                if (tx.output[0].amount == 50) {
                    return true;
                } else {
                    return false;
                }
            }
            let vout = null;
            if (this.tx_pool[input.id]) {
                vout = this.tx.tx_pool[input.id];
            } else {
                vout = await this.get_from_db(input.id);
            }
            if (!vout) {
                // invalid vout
                return false;
            }
            vout = vout.output[input.index];
            let res = Crypto.verify_signature(JSON.stringify(vout), input.ScriptSig, vout.ScriptPubKey);
            if (!res) {
                return false;
            }
            input_amount += vout.amount;
        }
        let output_amount = 0;
        for (i = 0; i < tx.output.length; ++i) {
            output_amount += tx.output[i].amount;
        }
        if (input_amount < output_amount) {
            return false;
        }
        return true;
    }

verify_transaction中主要验证每条交易输入是否有效,并且还要验证输入金额是否小于等于输出金额。而验证每条输入时先查询其引用的交易输出是否存在,然后验证交易输出的签名是否正确。

创建交易

创建交易可以按照如下的逻辑进行:

  1. 遍历UTXO,逐条累加金额
  2. 从utxo中逐条加入[TxInput],直到总额刚刚超过target_amount
  3. 如果总金额小于target_amount,抛出错误
  4. 创建一个TxOutput(target_amount,to)和一个TxOutput(total_amount-target_amount,from)(可以留出手续费)
  5. 创建交易Transaction(id,[TxInput],[TxOutput])
  6. 将交易存入交易池并广播给其他节点
    async create_transaction(to, amount) {
        let value = 0;
        let input = [];
        let output = [];
        let self = this;
        let tx = null;
        await this.get_utxo((transaction_id, index, vout) => {
            value += vout.amount;
            let signature = Crypto.sign(self.get_account_keypair(), JSON.stringify(vout));
            input.push(new TxInput(transaction_id, index, signature));
            if (value >= amount) {
                output.push(new TxOutput(amount, to));
                if (value > amount)
                    output.push(new TxOutput(value - amount, self.get_public_key()));
                tx = new Transaction(input, output);
                // stop
                return false;
            }
            return true;
        });
        if (value < amount) {
            throw new Error("amount is not enough!");
        }
        if (tx == null) {
            throw new Error("create transaction failed!");
        }
        this.tx_pool[tx.id] = tx;
        this.broadcast(Msg.transaction(tx));

        return tx;
    }

这里组合UTXO的方法是按照UTXO遍历的顺序来引用,诚然这不是一个高效的方法。高效的UTXO组合算法一方面要求精确,一方面有要求尽可能减少输入输出个数,在这里我们并不做过多研究。

加载交易

其他节点收到交易信息后会对其进行验证,验证通过后也是加入本地的交易池备用。

这里我们还要进一步完善节点的挖矿逻辑,节点开始挖矿时会在所有交易前面添加一笔coinbase交易,然后从交易池中加载一部分交易,这里我们是限制最大加载10条交易,也可以根据交易尺寸来进行限制。

    generate_block(keypair, cb) {
        // load transactions
        var tx = [this.create_coinbase()];
        var i = 0;
        for (let key in this.tx_pool) {
            if (i == 10)
                break;
            tx.push(this.tx_pool[key]);
            i++;
            console.log(`node ${this.get_account_id()} load tx ${key}`);
        }
        ...
    }

当其他节点接收到该区块并通过验证写入数据库后,该交易信息才算有效,我么可以通过查询数据库中的区块来追踪这笔交易转账。

这里我们只是简单实现了UTXO的主要功能,但是还有些细节例如手续费等没有实现。UTXO的使用根据算法不同,性能也会有很大的差异,高性能的UTXO算法主要包括如何减少输入输出个数以及并行验证等功能。

UTXO是比特币的原生设计,提供了另一种视角来看待数据的转移,但同时我们也要明白UTXO并不是一颗万能的银弹,时都是用UTXO模型要根据具体业务场景来判断。

代码地址:https://github.com/yjjnls/awesome-blockchain/tree/v0.3.0/src/js

相关文章

网友评论

    本文标题:区块链开发:账户与交易模型 #C13

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