node.js与比特币
BTC中的utxo模型
BTC中引入了许多创新的概念与技术,区块链、PoW共识、RSA加密、萌芽阶段的智能合约等名词是经常被圈内人所提及,诚然这些创新的实现使得BTC变成了一种有可靠性和安全性保证的封闭生态系统,但是在这个BTC生态中如果没有搭配区块链模式的转账模块,那么货币的流通属性也就无从谈起了。若要实现转账交易模块, “是否采用传统的账户模型实现交易;如何在区块链上存储交易信息,如何实现信息压缩;如何验证交易信息;系统的最大交易并发量”等问题确实值得思考。
BTC一一解决了这些,它放弃了传统的基于账户的交易模型,而是采用基于区块链存储的utxo(unspent transaction output)模型。笔者尝试分析了为什么不使用传统的账户模型:
- BTC的存储单元为区块链,区块链的数据结构本质上是单向链表,它并不是传统的关系型数据库,无法新建账户表
- 存储压力。如果采用传统的方式,则账户表会随着时间的推移不停地增大,为后续的表的分片与备份造成很大困难
- 易造成隐私泄露。账户表的信息会直观的暴露余额等敏感信息
utxo模型则很有技巧的避免了这些,在utxo模型下实现的每一笔交易,都不需要显式的提供转账地址和接收地址(utxo中没有账户,也不需要提供地址),只需提供这比交易的 交易输入 和 交易输出 即可,而交易输入与交易输出又是什么?
交易输入指向一笔交易输出,而且 “这笔交易输出是可以供转账者消费的,因此这笔交易输出也被称作utxo(未花费交易输出)”,它包括“某一笔交易、指向这笔交易的某个可用交易输出的索引值和一个解锁脚本”。这个解锁脚本用来验证某笔可用的消费输出是否可以被提供解锁脚本的人所使用。
交易输出则是存储BTC“余额”的一个数据结构,它广义上包括两部分:BTC的数量和一个锁定脚本。 BTC的数量可以理解为余额,表示这笔交易产生的结果;而锁定脚本则是用某种算法锁定这个BTC余额,直到某人可以提供解锁该脚本的数据钥匙,这比数额BTC才会被这个人所消费。
从这个角度看,一笔交易会包含若干个交易输入,同时产生若干个交易输出。这些交易输入都会指向之前某笔交易的未被消费输出(utxo),并提供各自的解锁脚本以证明这些utxo里的BTC是属于转账方;同时将转账产生的所有交易输出用对应方的公钥进行加密(此处是为了更好的理解才解释为公钥加密,实质上是公钥哈希,即btc地址进行逆向base58编码的一段字符串),锁定这几笔交易输出,等待交易输入中的解锁脚本解锁。
所以,BTC没有账户的概念,所有的“余额”都在区块链上,不过这些余额都已经被加密了,只有提供私钥和签名的人才可以使用对应的utxo的余额,因此这就是为什么BTC持有者必须保存好自己的私钥的原因。
UTXO的node.js实现
交易输入
export class Input { private txId: string; private outputIndex: number; private unlockScript: string; public get $txId(): string { return this.txId; } public set $txId(value: string) { this.txId = value; } public get $outputIndex(): number { return this.outputIndex; } public set $outputIndex(value: number) { this.outputIndex = value; } public get $unlockScript(): string { return this.unlockScript; } public set $unlockScript(value: string) { this.unlockScript = value; } constructor(txId: string, index: number, unlockScript: string){ this.txId = txId; this.outputIndex = index; this.unlockScript = unlockScript; } // 反序列化,进行类型转换 public static createInputsFromUnserialize(objs: Array<Input>){ let ins = []; objs.forEach((obj)=>{ ins.push(new Input(obj.txId,obj.outputIndex,obj.unlockScript)); }); return ins; } canUnlock (privateKey: string): boolean{ if(privateKey == this.unlockScript){ return true; }else{ return false; } } }
私有属性txId标识 “某个可用的utxo所属的交易”,是一串sha256编码的字符串;
outputIndex表示 “这个可用的utxo在对应交易的序号值”;
unlockScript则是解锁脚本,此处并未完全按照BTC的原型去实现,而是简单的验证使用者的私钥来实现鉴权,原理上仍遵从BTC的思想。
交易输出
import * as rsaConfig from '../../rsa.json'; export class Output { private value: number; // 锁定脚本,需要使用UTXO归属者用私钥进行签名通过 // 当解锁UTXO成功后,此UTXO变为下一个交易的交易输入,同时使用接收方的地址(公钥)锁定本次交易的交易输出, // 等待接收方使用私钥签名使用该UTXO // 因此,btc没有账户的概念,所有的“钱”由自己的公钥所加密保存,只有用自己的私钥才能使用这些钱(即解锁了UTXO的解锁脚本) private lockScript: string; // 该属性仅仅在交易时使用,设置属性 private txId: string; // 该属性仅仅在交易时使用,设置属性 private index: number; public get $index(): number { return this.index; } public set $index(value: number) { this.index = value; } public get $txId(): string { return this.txId; } public set $txId(value: string) { this.txId = value; } public get $value(): number { return this.value; } public set $value(value: number) { this.value = value; } /* public get $lockScript(): string { return this.lockScript; } public set $lockScript(value: string) { this.lockScript = value; } */ constructor(value: number,publicKey: string){ this.value = value; this.lockScript = publicKey; } // 反序列化,进行类型转换 public static createOnputsFromUnserialize(objs: Array<Output>){ let outs = []; objs.forEach((obj)=>{ outs.push(new Output(obj.value,obj.lockScript)); }); return outs; } public canUnlock(privateKey: string): boolean{ if(privateKey == rsaConfig[this.lockScript]){ return true; }else{ return false; } } }
交易输出中的value属性标识当前utxo的余额,即BTC个数;
lockScript属性为锁定脚本,在我们的简易实现中就为接收方的公钥,并不是BTC中的逆波兰式,但大体原理相同,都需要提供私钥来进行解密。
一笔交易
一笔交易,包含了若干个交易输入和交易输出,同时也提供了一个txId唯一的标识这比交易。从结构上看是这样的:
export class Transaction { private txId: string; private inputTxs: Array<Input>; private outputTxs: Array<Output>; constructor(txId: string, inputs: Array<Input>, outputs: Array<Output>){ this.txId = txId; this.inputTxs = inputs; this.outputTxs = outputs; } public get $txId(): string { return this.txId; } public set $txId(value: string) { this.txId = value; } public get $inputTxs(): Array<Input> { return this.inputTxs; } public set $inputTxs(value: Array<Input>) { this.inputTxs = value; } public get $outputTxs(): Array<Output> { return this.outputTxs; } public set $outputTxs(value: Array<Output>) { this.outputTxs = value; } /* 1.交易结构各字段序列化为字节数组 2.把字节数组拼接为支付串 3.对支付串计算两次SHA256 得到交易hash */ public setTxId(){ let sha256 = crypto.createHash('sha256'); sha256.update(JSON.stringify(this.inputTxs) + JSON.stringify(this.outputTxs) + Date.now(),'utf8'); this.txId = sha256.digest('hex'); } }
其中 txId的计算这里并没有严格按照BTC实现的那样进行计算,而是简单的进行对象序列化进行一次sha256。
coinbase交易
我们都知道得到比特币需要挖矿,其实挖矿也属于一种交易,不过是一种没有确定交易输入的一种交易,它也被称作coinbase交易。coinbase交易在每一个区块中都会存在,它的总额包括了系统针对矿工打包交易过程的奖励以及其他转账方提供的手续费,如下图:
因此,创建一个coinbase交易也很容易
// coinbase交易用于给矿工奖励,input为空,output为矿工报酬 public static createCoinbaseTx(pubKey: string, info: string){ let input = new Input('',-1,info); let output = new Output(AWARD, pubKey); let tx = new Transaction('',[input],[output]) tx.setTxId(); return tx; }
在我们的实现中,只需提供锁定utxo的公钥以及一串描述字符串即可,最后设置交易的txId,完成coinbase交易的创建。
也提供了识别coinbase交易的方法:
public static isCoinbaseTx(tx: Transaction){ if(tx.$inputTxs.length == 1 && tx.$inputTxs[0].$outputIndex == -1 && tx.$inputTxs[0].$txId == ''){ return true; }else{ return false; } }
至此,coinbase交易就完成了,这是最简单的一种交易,并没有涉及到转账方,也就是交易输入。
转账交易
使用BTC就避免不了转账,转账事务在utxo模型的实现就是添加了一笔Transaction到某个区块而已。每一笔交易都需要交易输入和交易输出,因此在BTC中,转账的核心就是找到转账方的utxo进行消费,同时将指定数量的BTC划到指定的消费输出上,如果仍有剩余,则找零至自己的消费输出。
// 创建转账交易 public static createTransaction(from: string, fromPubkey: string, fromKey: string, to: string, toPubkey: string, coin: number){ let outputs = this.findUTXOToTransfer(fromKey, coin); console.log(`UTXOToTransfer: ${JSON.stringify(outputs)}, from: ${from} to ${to} transfer ${coin}`) let inputTx = [], sum = 0, outputTx = []; outputs.forEach((o)=>{ sum += o.$value; inputTx.push(new Input(o.$txId,o.$index,fromKey)); }); if(sum < coin){ throw Error(`余额不足,转账失败! from ${from} to ${to} transfer ${coin}btc, but only have ${sum}btc`); } // 公钥锁住脚本 outputTx.push(new Output(coin,toPubkey)); if(sum > coin){ outputTx.push(new Output(sum-coin,fromPubkey)); } let tx = new Transaction('',inputTx,outputTx); tx.setTxId(); return tx; }
创建一个交易,需要提供转账方的地址(公钥哈希)、转账方的公钥和私钥、接收方的地址、接收方的公钥以及转账的BTC数量。这笔交易由转账发发起,因此需要提供转账方的私钥进行解锁脚本。
首先,通过 findUTXOToTransfer 找到满足转账数量的可用的utxo,它需要提供转账方的私钥以及转账数量;
接下来根据获得的可用utxo,进行创建对应的交易输入;
然后用接收方的公钥加密交易输出,同时如果有余额的化找零给自己,用自己的公钥加密;
最后根据得到的交易输入与交易输出,创建一笔交易,计算txId,加入到区块中(我们的demo是在单机下进行模拟,并未实现多播),等待挖矿。
转账的核心在于 findUTXOToTransfer,在findUTXOToTransfer中,通过调用 getAllUnspentOutputTx拿到所有的可用的utxo,并筛选出满足给定数量BTC的utxo。
public static getAllUnspentOutputTx(secreteKey: string): Array<Transaction>{ let outputIndexHash: Object = this.getAllSpentOutput(secreteKey); let unspentOutputsTx = []; let keys = Object.keys(outputIndexHash); let block = BlockDao.getSingletonInstance().getBlock(chain.$lastACKHash); while(block && block instanceof Block){ block.$txs && block.$txs.forEach((tx)=>{ if(keys.includes(tx.$txId)){ tx.$outputTxs.forEach((output,i)=>{ // 过滤已消费的output if(i == outputIndexHash[tx.$txId]) return; if(output.canUnlock(secreteKey)){ unspentOutputsTx.push(tx); } }); }else{ for(let i=0,len=tx.$outputTxs.length;i<len;i++){ let output = tx.$outputTxs[i]; if(output.canUnlock(secreteKey)){ unspentOutputsTx.push(tx); break; } } } }); block = BlockDao.getSingletonInstance().getBlock(block.$prevHash); } return unspentOutputsTx; }
在getAllUnspentOutputTx中,通过 getAllSpentOutput 遍历本地持久化的区块链,拿到所有的可供消费utxo,这些utxo并不仅仅属于转账方,因此需要在针对每个utxo尝试进行验证逻辑,即output.canUnlock(secreteKey)。验证通过则证明这是属于转账方的BTC,可以用于交易。
在getAllSpentOutput中,通过遍历每一个交易输入获取它指向前面交易的某个utxo来得到所有的utxo,当然对于coinbase交易我们无法找到他的交易输入,因此会进行过滤。
至此,utxo的转账流程已经完成,下面需要做的就是把这比交易加入到区块中了,这已不是本文的核心。
尾声
本文所讲的utxo示例是基于作者对BTC实现的基础上的简单实现,有不当之处还请读者指出。另外,本文的代码开源在 https://github.com/royalrover... 的 feature/utxo分支 上,希望大家一起提建议!