源码地址:PHP从零实现区块链(四)交易1 – 简书

注:本例只是从网页版实现一下原理,源码非本人所写,只是将原帖的源码更改了一下,变成网页版

开始这个例子前,先解释一些概念以及统一命名叫法,这样便于理解代码。

1.这里的交易是采用UXTO模式。这也是比特币中采用的模式。

就是只记录帐号的交易事件,类似于某个点发送出去多少币,然后收到多少币。

为了便于理解,我先这样说,实际代码实现是有差别的。

那么它没有账户的具体余额,记录的只有一笔笔的交易,那么怎么得出余额呢?

得遍历区块的所有交易事件,找出这个账户总共接收了多少币减去总共发送的币的,就得出你的余额了。

你可以先这样简单的理解UXTO模式(实际“发送”和“接收”是采用共同的output记录)

2.Tranaction类

在下面的例子中,我们将看到tranaction数组将存在区块block里,代替了原本的data数据。

这个tranaction中就存储有一笔笔的转账记录,你可以叫它tx,tx1,tx2之类的,在后面指的就是它。

我在这里就把它叫做一个交易区块。

3.Txinput和TxOutput

而在一个Tranaction下,又包含有一组Txinput(数组形式)和一组TxOutput(数组形式)。

每个txoutput表示一笔输出交易,表示输出到这个地址多少币。(一笔输出记录)

而每个Txinput表示一笔输入记录,是这个币是从哪个地址输入的。也就是将会扣除此地址对应的币数量。

好,那上面的层次关系理解了,下面给出实际代码,我们的重点是主要搞清楚Txinput和Txoutput,而tranaction和block只是对它们一层一层的封装。

新建一个Transaction.php文件,代码如下:

txInputs = $txInputs;$this->txOutputs = $txOutputs;$this->setId();}private function setId(){$this->id = hash('sha256', serialize($this));}}

这个类的构造函数,将txInputs和txOutputs数组传进去,赋给类自有的txInputs和txoutputs。

然后就调用它的setId方法,将类序列化后,进行哈希运算,得到的哈希值就作为这个类的

id。const subsidy = 50;是一个常量,没什么特别的含义。在后面发起第一笔交易会用到。

这样一个交易区块就产生了(tx)。

我们记住,它的id就是这个区块的你可以看作是标识符,通过哈希运算得到。

而txInputs就记录着一笔笔输入记录。

txoutputs记录着一笔笔输出记录。

注意这里的输入输出针对的主体是交易区块,比如输出给张三50个币,那么这个币得有个来源,input就指明了来源,那么可以知道,一个交易区块内,两边的币数是相等的。

好,下面来看TxInput类和TxOutput类的具体实现

新建TxInput.php文件,代码如下:

txId = $txId;$this->vOut = $vOut;$this->scriptSig = $scriptSig;}public function canUnlockOutputWith(string $unlockingData): bool{return $this->scriptSig == $unlockingData;}}

新建TxOutput.php,代码如下:

value = $value;$this->scriptPubKey = $scriptPubKey;}public function canBeUnlockedWith(string $unlockingData): bool{return $this->scriptPubKey == $unlockingData;}}

1.关于TxOutput类很好理解,value值就是币的数量,scriptPubkey这里储存的是账号地址。

意思就是转给这个地址scriptPubkey多少币(value)。

通过构造函数传进去即可。

而这里面的canBeUnlockdWith函数,将在查询时用到,是怎么查询的呢?

比如我要查账号地址,zhangsan收到了多少币。那么遍历所有的区块,得到TxOutput对象。

然后调用canBeUnlockedWith函数,如果地址相等,那么说明这笔output是张三的。

这就是这个函数的作用。

2.关于TxInput类,如果是李四转张三50个币,所以应该是TxInput里写着李四的地址,然后也有个value值,记录着50个币。

注意了,这里不是这样实现的,我们来看看TxInput里的三个变量:
public $txId;
public $vOut;
public $scriptSig;

地址是存在scriptSig里的,这个没什么不同的。

但是并没有value变量,而是txId和vOut,什么意思呢?这两个变量定位到了一笔output。

txId就是那笔output所属交易区块的id,vOut就是那个交易区块下的output数组的索引。

而这笔output是和李四的地址绑定的,然后就会扣除掉李四的这笔output。

在后面我会采用通用的说法,就是只要被input指向过的output,我们称之为这笔output 是花费过的。

那么问题来了。input不能自定义一个值,得引入之前的output,会有以下情况:
如果你要转给张三50个币,
你没有这笔正好是50的output怎么办。
那么就是这样解决的,存在以下几种情况。
如果你有一笔output是多于50,比如70。
那么在input中引用这笔output
然后在output中,output张三50后,再建一笔output 20指向自己的地址,就是找零的意思。
然后如果你的output都是少于50.
就引用多笔output,然后多出的零头之类的,再output自己的地址。

那么关于input和ouput关系就明朗了,每笔input都得引入之前的某笔output,而每笔output则不是必需,如果有对应的,那就是花费过了,如果没有,就是你的余额。关系如下图:

图片来源:Building Blockchain in Go. Part 4: Transactions 1 – Going the distance

PS:这是国外的go语言版本,就是最原始的版本,你们有兴趣的也可以看看。

清楚了input,里面的代码我就不解释了,都差不多,构造函数,和解锁函数,参考output。

接着我们在Block.php进行一些修改,代码如下:

public function prepareData(int $nonce): string{return implode('', [$this->block->prevBlockHash,$this->block->hashTransactions(),$this->block->timestamp,config('blockchain.targetBits'),$nonce]);}

这里的$this->block->hashTransactions(),代替了之前的$this->block->data,

它也不是$this->block->transactions,而是通过hashTransactions方法,得到的transactions哈希值。是一样的效果。

因为我们在创建创世块的时候,需要new Block([$coinbase], ”);,第一个参数是个transactions数组。

所以我们需要在Transaction类中,创建下面的函数,用于创建创世块所需要transactions。

代码如下:

public static function NewCoinbaseTX(string $to, string $data): Transaction{if ($data == '') {$data = sprintf("Reward to '%s'", $to);}$txIn = new TXInput('', -1, $data);$txOut = new TXOutput(self::subsidy, $to);return new Transaction([$txIn], [$txOut]);}

注意这里的txInput,没有输入记录,只是标明这是创世块或coinbase,相当于挖矿得到币的,系统产生的。所以也没有对应的地址和指向哪个output。

只有一个TxOutput表明,输出了多少币给$to,因为它的来源不是别人转的。

然后再建立一个正常的交易方法,就是某人给某人转帐用的:

class Transaction{public static function NewUTXOTransaction(string $from, string $to, int $amount, BlockChain $bc): Transaction{list($acc, $validOutputs) = $bc->findSpendableOutputs($from, $amount);if ($acc  $outsIdx) {foreach ($outsIdx as $outIdx) {$inputs[] = new TXInput($txId, $outIdx, $from);}}$outputs[] = new TXOutput($amount, $to);if ($acc > $amount) {$outputs[] = new TXOutput($acc - $amount, $from);}return new Transaction($inputs, $outputs);}public function isCoinbase(): bool{return (count($this->txInputs) == 1) && ($this->txInputs[0]->txId == '') && ($this->txInputs[0]->vOut == -1);}}

上面的代码,想要理解,核心点在于理解findSpendableOutputs函数,这个函数添加在BlockChain类里,代码如下:

class BlockChain implements \Iterator{/** * 找出地址的所有未花费交易 * @param string $address * @return Transaction[] */public function findUnspentTransactions(string $address): array{$unspentTXs = [];$spentTXOs = [];/** * @var Block $block */foreach ($this as $block) {foreach ($block->transactions as $tx) {$txId = $tx->id;foreach ($tx->txOutputs as $outIdx => $txOutput) {if (isset($spentTXOs[$txId])) {foreach ($spentTXOs[$txId] as $spentOutIdx) {if ($spentOutIdx == $outIdx) {continue 2;}}}if ($txOutput->canBeUnlockedWith($address)) {$unspentTXs[$txId] = $tx;}}if (!$tx->isCoinbase()) {foreach ($tx->txInputs as $txInput) {if ($txInput->canUnlockOutputWith($address)) {$spentTXOs[$txInput->txId][] = $txInput->vOut;}}}}}return $unspentTXs;}/** * 找出所有已花费的输出 * @param string $address * @return array */public function findSpentOutputs(string $address): array{$spentTXOs = [];/** * @var Block $block */foreach ($this as $block) {foreach ($block->transactions as $tx) {if (!$tx->isCoinbase()) {foreach ($tx->txInputs as $txInput) {if ($txInput->canUnlockOutputWith($address)) {$spentTXOs[$txInput->txId][] = $txInput->vOut;}}}}}return $spentTXOs;}// 根据所有未花费的交易和已花费的输出,找出满足金额的未花费输出,用于构建交易public function findSpendableOutputs(string $address, int $amount): array{$unspentOutputs = [];$unspentTXs = $this->findUnspentTransactions($address);$spentTXOs = $this->findSpentOutputs($address);$accumulated = 0;/** * @var Transaction $tx */foreach ($unspentTXs as $tx) {$txId = $tx->id;foreach ($tx->txOutputs as $outIdx => $txOutput) {if (isset($spentTXOs[$txId])) {foreach ($spentTXOs[$txId] as $spentOutIdx) {if ($spentOutIdx == $outIdx) {// 说明这个tx的这个outIdx被花费过continue 2;}}}if ($txOutput->canBeUnlockedWith($address) && $accumulated value;$unspentOutputs[$txId][] = $outIdx;if ($accumulated >= $amount) {break 2;}}}}return [$accumulated, $unspentOutputs];}/** * 找出所有未花费的输出 * @param string $address * @return TXOutput[] */public function findUTXO(string $address): array{$UTXOs = [];$unspentTXs = $this->findUnspentTransactions($address);$spentTXOs = $this->findSpentOutputs($address);foreach ($unspentTXs as $transaction) {$txId = $transaction->id;foreach ($transaction->txOutputs as $outIdx => $output) {if (isset($spentTXOs[$txId])) {foreach ($spentTXOs[$txId] as $spentOutIdx) {if ($spentOutIdx == $outIdx) {// 说明这个tx的这个outIdx被花费过continue 2;}}}if ($output->canBeUnlockedWith($address)) {$UTXOs[] = $output;}}}return $UTXOs;}}

findSpendableOutputs解释:

在这个函数里开头就调用了这两句:

$unspentTXs = $this->findUnspentTransactions($address);

$spentTXOs = $this->findSpentOutputs($address);

所以我们先来理解findUnspentTransactions

这个函数是寻找对应地址未被花费过的output(没被input引用过的output)

交易区块中只要有一个output满足条件,不管此区块是否还有其它被input过的output。

就将此交易区块添加到unspentTXs数组里 $unspentTXs[$txId] = $tx

并且以此交易区块的id名作为元素下标。

代码有点多,我就讲一下大概的逻辑流程了,它的寻找方法是这样。

首先遍历所有区块下的output:

foreach ($this as $block) {

foreach ($block->transactions as $tx) {
$txId = $tx->id;

foreach ($tx->txOutputs as $outIdx => $txOutput)

然后判断此output是否被花费过(input过的output),如果是被花费过,则跳过此output,不进行添加。

if (isset($spentTXOs[$txId])) {
foreach ($spentTXOs[$txId] as $spentOutIdx) {
if ($spentOutIdx == $outIdx) {
continue 2;
}

上面的判断原理是这样的,先是判断这个交易区块里有没有存在被这个地址input过的output

通过是否设置了spentTXOS[$txID]。这个spentTXOS存储有这个地址的input,并且行数是以交易区块ID作为元素下标名的,是个二维数组,列存着索引。

spenttxos具体实现将在后面解释。

如果存在的话,就找出是哪个output,就是每循环一个output就和spentTXOS[txID]下所有的索引对比一下,如果相等,则表明这个output就是被花费过的,则跳到第二层循环,继续下一个output

不执行下面这个语句:

if ($txOutput->canBeUnlockedWith($address)) {//如果这笔txoutput对应着这个$address

$unspentTXs[$txId] = $tx; //则把这个交易区块赋给unspenttXs数组,并且元素下标名是这个交易区块的id}

所以说能被添加进的交易区块里面必定有未被花费过的output

然后寻找此区块对应地址的input,将它们添加到一个数组里

if (!$tx->isCoinbase()) {
foreach ($tx->txInputs as $txInput) {
if ($txInput->canUnlockOutputWith($address)) {
$spentTXOs[$txInput->txId][] = $txInput->vOut;
}

上面意思就是将这个区块下对应的这个地址的input添加到$spentTXOs数组里(如果有)

但是这里有个问题。为何先判断if (isset($spentTXOs[$txId]))

然后再 $spentTXOs[$txInput->txId][] = $txInput->vOut;

那是因为input的特性,只能指向的是之前的区块。

假设倒数第一个区块中有你对应地址的input
而倒数第二个区块中正好也有你对应地址的output。
那么,如果倒数第一个区块中的input没有引用你倒数第二个区块中的output.
那么此output就必定是未花费的。
而不用对比之前所有的input
因为区块按顺序的特性,input不可能知道后面的output也不会指向后面的output.
那么以此类推,如果倒数第三个区块中的output没有被倒数第一第二的input引用。
那么此output也必定是未花费的。

这个就是是边找边对比。(效率高一点)

后面的函数,是先找出所有的input然后再对比。(都可以,这样容易理解一些)

接着是 findSpentOutputs($address)函数(找出已花费的输出)

这个是找出地址所有的input,并将其这样存储:
$spentTXOs[$txInput->txId][] = $txInput->vOut;

这里的代码跟findUnspentTransactions函数最后那部分的代码差不多。

基本上就是把那部分拿出来单独做一个函数。

理解了这两个函数后,那么下面这两句:

$unspentTXs = $this->findUnspentTransactions($address);

$spentTXOs = $this->findSpentOutputs($address);

就明白了,unspentTXs获得了未花费的transactions(交易区块).

spentTXOs,获得了已花费的output(input)

我直接贴后面的代码吧,在旁边我已经加了注释:

*/foreach ($unspentTXs as $tx) { //这个地址对应的output所在的交易区块给tx$txId = $tx->id; //将这个交易区块的id给txIdforeach ($tx->txOutputs as $outIdx => $txOutput) {//遍历此交易区块的txoutput if (isset($spentTXOs[$txId])) {//如果这个区块的id等于这个地址input所关联的区块idforeach ($spentTXOs[$txId] as $spentOutIdx) {//那么将这个input关联区块id下的vout给$spentoutidxif ($spentOutIdx == $outIdx) {//如果相等,说明此output就是input关联的output// 说明这个tx的这个outIdx被花费过continue 2;}}}//上面遍历txoutput意思是,如果这笔output已经被花费了。则跳到下一个output//否则执行下面语句if ($txOutput->canBeUnlockedWith($address) && $accumulated value; //将继续相加,将这output的收到的币数量相加 $unspentOutputs[$txId][] = $outIdx; //将这个交易块的txid和未花费的output 索引添加进unspentOutputs数组if ($accumulated >= $amount) { //如果找到未花费的币的数量,可以用来支付交易了。break 2; // 则跳出foreach ($unspentTXs as $tx) 循环}}}}

那么,最终这个函数

public function findSpendableOutputs(string $address, int $amount): array

就是,根据amount数量,获取address里用于足够支付的output。将其添加到unspentOutputs数组

$unspentOutputs[$txId][] = $outIdx;

然后返回,对应这句:

list($acc,$validOutputs)=$bc->findSpendableOutputs($from,$amount);

里面的validOutputs接收的就是unspentOutputs。

但还有个参数,acc,接收的是$accumulated,这个是记录这笔Outputs总币数。

然后上面还有一个

public function findUTXO(string $address): array

这个函数,没被用到,在后面是用来查询余额的。

跟 findSpendableOutputs差不多,区别是它寻找所有的未花费的output,而不是像 findSpendableOutputs只找到满足交易数量的output就停止寻找了。

接下来我们还要在BlockChain类里移除addBlock,因为现在添加区块是需要transactions数据了。

我们直接新增一个mineBlock()代替,以及修改NewBlockChain(),代码如下:

/** * @param array $transactions * @throws \Exception */public function mineBlock(array $transactions){$lastHash = Cache::get('l');if (is_null($lastHash)) {echo "还没有区块链,请先初始化";exit;}$block = new Block($transactions, $lastHash);$this->tips = $block->hash;Cache::put('l', $block->hash);Cache::put($block->hash, serialize($block));}// 新建区块链public static function NewBlockChain(string $address): BlockChain{if (Cache::has('l')) {// 存在区块链$tips = Cache::get('l');} else {$coinbase = Transaction::NewCoinbaseTX($address, self::genesisCoinbaseData);$genesis = Block::NewGenesisBlock($coinbase);Cache::put($genesis->hash, serialize($genesis));Cache::put('l', $genesis->hash);$tips = $genesis->hash;}return new BlockChain($tips);}

NewBlockChain的功能之前说过,是用来创建创世块的。如果不了解的可以翻我前面那章看一下。

只是这里,新建创世块用transaction代替了data,这笔交易是用NewCoinbaseTx创建的,给了自己50个币。

(注意:我忘了改命名空间了,将所有的namespace App\Services;改为namespace App\Http\Controllers;)

好了,上面这些改造都完了的话,至此我们就可以来调用这些函数了。

开始使用交易功能了,创建创世块给一个地址50个币,然后转账给别人,再查询余额。

我们一个一个来。

我们来更改appcontroler下的app,先简单调用$bc = BlockChain::NewBlockChain($data);创建创世块测试一下,后面我将完善app功能,app函数测试代码如下:

get('add')!=""){//添加区块$data=$request->get('data');$time1 = time();$bc = BlockChain::NewBlockChain($data);$time2 = time();$spend = $time2 - $time1;echo('已添加,花费时间(s):'.$spend);echo('
新添加块的哈希值是:
'.$bc->tips);echo('
所有区块信息:
');foreach ($bc as $block){print_r($block);echo('
'); }}else{//显示所有区块echo('所有区块信息:
');foreach ($bc as $block){echo('
');print_r($block);}} } }

注意,因为原帖的代码没有贴完整,这句:$coinbase = Transaction::NewCoinbaseTX($address, self::genesisCoinbaseData);

用下面代替:

$coinbase = Transaction::NewCoinbaseTX($address, ‘genesisCoinbaseData’);

直接用字符串代替,关于原本指的是什么,你们可以直接去原文章的github上查看,那里有完整的代码。

好了,我们来看一下运行效果:

OK,创建创世块功能正常。

接下来我测试一下获取余额功能,但是没有相关的,我们看原文章命令代码是:

public function handle()
{
$address = $this->argument(‘address’);

$bc = BlockChain::GetBlockChain();
$UTXOs = $bc->findUTXO($address);

$balance = 0;
foreach ($UTXOs as $output) {
$balance += $output->value;
}
$this->info(sprintf(“balance of address ‘%s’ is: %s”, $address, $balance));
}

我们的代码少了个GetBlockChain函数,直接去github复制过来,添加到我们的BlockChain如下:

 public static function GetBlockChain(): BlockChain{if (!Cache::has('l')) {echo "还没有区块链,请先初始化";exit;}return new BlockChain(Cache::get('l'));}

测试调用代码:

在AppController增加下面函数:

 public static function getBalance($address) {$bc = BlockChain::GetBlockChain();$UTXOs = $bc->findUTXO($address);$balance = 0;foreach ($UTXOs as $output) { $balance += $output->value; }echo($address."的余额是:".$balance); }

然后AppController::getBalance(‘zhengyong’);调用,结果如下:

发送代币的就不测试了,我直接给出AppController相关的最终完整代码。

首先修改command.php html代码如下:

   创世块地址:   
转帐地址:to 数量:
地址:

对应AppController代码如下:

get('data');$time1 = time();$bc = BlockChain::NewBlockChain($data);$time2 = time();$spend = $time2 - $time1;echo('花费时间(s):'.$spend);echo('
创世块的哈希值是:
'.$bc->tips);echo('
所有区块信息:
');foreach ($bc as $block){print_r($block);echo('
'); }}else if($request->get('send')!=""){$from=$request->get('from');$to=$request->get('to');$amount=$request->get('amount');AppController::send($from,$to,$amount);}else if($request->get('balance')!=""){$address=$request->get('address');AppController::getBalance($address);}else{$bc = BlockChain::GetBlockChain();//显示所有区块echo('所有区块信息:
');foreach ($bc as $block){echo('
');print_r($block);} } } public static function send($from,$to,$amount) {$bc = BlockChain::GetBlockChain();$tx = Transaction::NewUTXOTransaction($from, $to, $amount, $bc);$bc->mineBlock([$tx]);echo('send success');echo('
');foreach ($bc as $block) {echo("$block->hash");break;}} public static function getBalance($address) {$bc = BlockChain::GetBlockChain();$UTXOs = $bc->findUTXO($address);$balance = 0;foreach ($UTXOs as $output) { $balance += $output->value; }echo($address."的余额是:".$balance); } }

大概测试了下,功能都正常:

本章完结,OK。

另附:在后面章节中将会实现帐户功能,因为这里主要是实现交易功能,像这里的账号,你只要知道他的名字,可以随便转账,这显然是不行的。