LaravelZero 从零实现区块链(五)钱包、地址与密钥

引言

你可能听说过比特币是基于密码学,密码学可以用来证明秘密的知识,而不会泄露秘密(数字签名),或证明数据的真实性(数字指纹)。 (也许这就是数学的魅力吧)

这些类型的加密证明是比特币中关键的数学工具并在比特币应用程序中被广泛使用。今天我们来实现比特币中用来控制资金的所有权的密码学,包括密钥,地址和钱包。代码差异较大,具体点击查看

简介

比特币中没有存储任何个人帐户相关的信息,但是当别人发送一些币给我时,总要有某种途径识别出我是交易输出的所有者(即我拥有在这些输出上锁定的币)。比特币的所有权是通过数字密钥、比特币地址和数字签名来确定的。

数字密钥实际上并不存储在网络中,而是由用户生成之后,存储在一个叫做钱包的文件或简单的数据库中。存储在用户钱包中的数字密钥完全独立于比特币协议,可由用户的钱包软件生成并管理,而无需参照区块链或访问网络。密钥实现了比特币的许多有趣特性,包括去中心化信任和控制、所有权认证和基于密码学证明的安全模型。

公钥加密

公钥加密(public-key cryptography)算法使用的是成对的密钥:私钥+由私钥衍生出的唯一的公钥。

私钥、公钥、地址

私钥

私钥其实就是一个随机选出的数字而已,私钥用于生成支付比特币所必需的签名以证明对资金的所有权。所以私钥一定要保密,不能泄露给第三方。私钥还必须进行备份,以防意外丢失,因为私钥一旦丢失就难以复原,其所保护的比特币也将永远丢失。

公钥

公钥是通过椭圆曲线乘法从私钥计算得到的,在数学上,这是不可逆转的过程,所以我们无法从公钥推导出私钥。具体的数学原理就不展开了,有兴趣的小伙伴可以去学习下。

地址

比特币地址是一个由数字和字母组成的字符串,可以与任何想给你比特币的人分享。地址是由公钥经过哈希计算得到,我们平时见到的地址是公钥哈希以后,再加上版本前缀,最后通过 Base58Check 编码的到的。也就是类似这样 “1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa
”以 1 打头的地址。

地址生成过程

数字签名

在数学和密码学中,有一个数字签名(digital signature)的概念,算法可以保证:

  • 当数据从发送方传送到接收方时,数据不会被修改;
  • 数据由某一确定的发送方创建;
  • 发送方无法否认发送过数据这一事实。

通过在数据上应用签名算法(也就是对数据进行签名),你就可以得到一个签名,这个签名晚些时候会被验证。生成数字签名需要一个私钥,而验证签名需要一个公钥。

为了对数据进行签名,我们需要下面两样东西:

  • 要签名的数据
  • 私钥

应用签名算法可以生成一个签名,并且这个签名会被存储在交易输入中。为了对一个签名进行验证,我们需要以下三样东西:

  • 被签名的数据
  • 签名
  • 公钥

在比特币中,每一笔交易输入都会由创建交易的人签名。在被放入到一个块之前,必须要对每一笔交易进行验证。除了一些其他步骤,验证意味着:

  • 检查交易输入有权使用来自之前交易的输出
  • 检查交易签名是正确的

签名与验证

现在来回顾一个交易完整的生命周期:

  1. 起初,创世块里面包含了一个 coinbase 交易。在 coinbase 交易中,没有输入,所以也就不需要签名。coinbase 交易的输出包含了一个哈希过的公钥(使用的是 RIPEMD16(SHA256(PubKey)) 算法)
  2. 当一个人发送币时,就会创建一笔交易。这笔交易的输入会引用之前交易的输出。每个输入会存储一个公钥(没有被哈希)和整个交易的一个签名。
  3. 比特币网络中接收到交易的其他节点会对该交易进行验证。除了一些其他事情,他们还会检查:在一个输入中,公钥哈希与所引用的输出哈希相匹配(这保证了发送方只能花费属于自己的币);签名是正确的(这保证了交易是由币的实际拥有者所创建)。
  4. 当一个矿工准备挖一个新块时,他会将交易放到块中,然后开始挖矿。
  5. 当新块被挖出来以后,网络中的所有其他节点会接收到一条消息,告诉其他人这个块已经被挖出并被加入到区块链。
  6. 当一个块被加入到区块链以后,交易就算完成,它的输出就可以在新的交易中被引用。

地址实现

我们先从钱包开始,钱包其实是帮助我们生成并管理密钥对的地方。
在这之前我们先安装两个库

composer require mdanter/ecc
composer require bitwasp/bitcoin

新建一个 Wallet.phpWallets.php

class Wallet
{
    /**
     * @var string $privateKey
     */
    public $privateKey;

    /**
     * @var string $publicKey
     */
    public $publicKey;

    /**
     * Wallet constructor.
     * @throws \BitWasp\Bitcoin\Exceptions\RandomBytesFailure
     */
    public function __construct()
    {
        list($privateKey, $publicKey) = $this->newKeyPair();
        $this->privateKey = $privateKey;
        $this->publicKey = $publicKey;
    }

    /**
     * @return string
     * @throws \Exception
     */
    public function getAddress(): string
    {
        $addrCreator = new AddressCreator();
        $factory = new P2pkhScriptDataFactory();

        $scriptPubKey = $factory->convertKey((new PublicKeyFactory())->fromHex($this->publicKey))->getScriptPubKey();
        $address = $addrCreator->fromOutputScript($scriptPubKey);

        return $address->getAddress(Bitcoin::getNetwork());
    }

    /**
     * @return array
     * @throws \BitWasp\Bitcoin\Exceptions\RandomBytesFailure
     */
    private function newKeyPair(): array
    {
        $privateKeyFactory = new PrivateKeyFactory();
        $privateKey = $privateKeyFactory->generateCompressed(new Random());
        $publicKey = $privateKey->getPublicKey();
        return [$privateKey->getHex(), $publicKey->getHex()];
    }
}

class Wallets
{
    /**
     * @var Wallet[] $wallets
     */
    public $wallets;

    public function __construct()
    {
        $this->loadFromFile();
    }

    public function createWallet(): string
    {
        $wallet = new Wallet();

        $address = $wallet->getAddress();

        $this->wallets[$address] = $wallet;

        return $address;
    }

    public function saveToFile()
    {
        $walletsSer = serialize($this->wallets);

        if (!is_dir(storage_path())) {
            mkdir(storage_path(), 0777, true);
        }

        file_put_contents(storage_path() . '/walletFile', $walletsSer);
    }

    public function loadFromFile()
    {
        $wallets = [];
        if (file_exists(storage_path() . '/walletFile')) {
            $contents = file_get_contents(storage_path() . '/walletFile');

            if (!empty($contents)) {
                $wallets = unserialize($contents);
            }
        }
        $this->wallets = $wallets;
    }

    public function getWallet(string $from)
    {
        if (isset($this->wallets[$from])) {
            return $this->wallets[$from];
        }
        echo "钱包不存在该地址";
        exit(0);
    }

    public function getAddresses(): array
    {
        return array_keys($this->wallets);
    }
}

Wallet 当中,我们存储一对密钥,newKeyPair() 方法中使用第三方库(PrivateKeyFactory)创建了私钥与公钥,并返回对应的十六进制字符串。

getAddress() 则是从公钥计算出地址(解释下P2pkhScriptDataFactory,P2PKH是比特币中最常见的交易类型,即支付到一个公钥哈希,还记得之前说的生成地址时的前缀吗?类型不同,其实前缀是不一样的,这里我们只实现P2PKH这一种类型的地址就好了)。

Wallets 中,则是创建 Wallet 放入一个map中,有一些辅助方法saveToFile() loadFromFile()让我们能持久化钱包数据。

下面更新 * TXInput* 与 * TXOutput*

class TXInput
{
    /**
     * @var string $txId
     */
    public $txId;

    /**
     * @var int $vOut
     */
    public $vOut;

    /**
     * @var string $signature
     */
    public $signature;

    /**
     * @var string $pubKey
     */
    public $pubKey;

    public function __construct(string $txId, int $vOut, string $signature, string $pubKey)
    {
        $this->txId = $txId;
        $this->vOut = $vOut;
        $this->signature = $signature;
        $this->pubKey = $pubKey;
    }

    /**
     * @param string $pubKeyHash
     * @return bool
     * @throws \Exception
     */
    public function usesKey(string $pubKeyHash): bool
    {
        $pubKeyIns = (new PublicKeyFactory())->fromHex($this->pubKey);
        return $pubKeyIns->getPubKeyHash()->getHex() == $pubKeyHash;
    }
}

class TXOutput
{
    /**
     * @var int $value
     */
    public $value;

    /**
     * @var string $pubKeyHash
     */
    public $pubKeyHash;

    public function __construct(int $value, string $pubKeyHash)
    {
        $this->value = $value;
        $this->pubKeyHash = $pubKeyHash;
    }

    public function isLockedWithKey(string $pubKeyHash): bool
    {
        return $this->pubKeyHash == $pubKeyHash;
    }

    public static function NewTxOutput(int $value, string $address)
    {
        $txOut = new TXOutput($value, '');
        $pubKeyHash = $txOut->lock($address);
        $txOut->pubKeyHash = $pubKeyHash;
        return $txOut;
    }

    private function lock(string $address): string
    {
        $addCreator = new AddressCreator();
        $addInstance = $addCreator->fromString($address);

        $pubKeyHash = $addInstance->getScriptPubKey()->getHex();    // 这是携带版本+后缀校验的值,需要裁剪一下
        return $pubKeyHash = substr($pubKeyHash, 6, mb_strlen($pubKeyHash) - 10);
    }
}

注意,现在我们已经不再需要 scriptPubKeyscriptSig 字段,因为我们不会实现一个脚本语言。相反,scriptSig 会被分为 signaturepubKey 字段,scriptPubKey 被重命名为 pubKeyHash。我们会实现跟比特币里一样的输出锁定/解锁和输入签名逻辑,不同的是我们会通过方法(method)来实现。

usesKey 方法检查输入使用了指定密钥来解锁一个输出。注意到输入存储的是原生的公钥(也就是没有被哈希的公钥),但是这个函数要求的是哈希后的公钥。isLockedWithKey 检查是否提供的公钥哈希被用于锁定输出。这是一个 usesKey 的辅助函数,并且它们都被用于 findUnspentTransactions 来形成交易之间的联系。

lock 只是简单地锁定了一个输出。当我们给某个人发送币时,我们只知道他的地址,因为这个函数使用一个地址作为唯一的参数。然后,地址会被解码,从中提取出公钥哈希并保存在 pubKeyHash 字段。

另外为了方便修改,我们创建一个新的 newTxOutput,外面创建 TXOutput 的地方,都使用该方法代替。

实现签名

class Transaction {
    public function sign(string $privateKey, array $prevTXs)
    {
        if ($this->isCoinbase()) {
            return;
        }

        $txCopy = $this->trimmedCopy();

        foreach ($txCopy->txInputs as $inId => $txInput) {
            $prevTx = $prevTXs[$txInput->txId];
            $txCopy->txInputs[$inId]->signature = '';
            $txCopy->txInputs[$inId]->pubKey = $prevTx->txOutputs[$txInput->vOut]->pubKeyHash;
            $txCopy->setId();
            $txCopy->txInputs[$inId]->pubKey = '';

            $signature = (new PrivateKeyFactory())->fromHexCompressed($privateKey)->sign(new Buffer($txCopy->id))->getHex();
            $this->txInputs[$inId]->signature = $signature;
        }
    }

    public function verify(array $prevTXs): bool
    {
        $txCopy = $this->trimmedCopy();

        foreach ($this->txInputs as $inId => $txInput) {
            $prevTx = $prevTXs[$txInput->txId];
            $txCopy->txInputs[$inId]->signature = '';
            $txCopy->txInputs[$inId]->pubKey = $prevTx->txOutputs[$txInput->vOut]->pubKeyHash;
            $txCopy->setId();
            $txCopy->txInputs[$inId]->pubKey = '';

            $signature = $txInput->signature;
            $signatureInstance = SignatureFactory::fromHex($signature);

            $pubKey = $txInput->pubKey;
            $pubKeyInstance = (new PublicKeyFactory())->fromHex($pubKey);

            $bool = $pubKeyInstance->verify(new Buffer($txCopy->id), $signatureInstance);
            if ($bool == false) {
                return false;
            }
        }
        return true;
    }

    private function trimmedCopy(): Transaction
    {
        $inputs = [];
        $outputs = [];

        foreach ($this->txInputs as $txInput) {
            $inputs[] = new TXInput($txInput->txId, $txInput->vOut, '', '');
        }

        foreach ($this->txOutputs as $txOutput) {
            $outputs[] = new TXOutput($txOutput->value, $txOutput->pubKeyHash);
        }

        return new Transaction($inputs, $outputs);
    }

    public static function NewUTXOTransaction(string $from, string $to, int $amount, BlockChain $bc): Transaction
    {
        $wallets = new Wallets();
        $wallet = $wallets->getWallet($from);

        list($acc, $validOutputs) = $bc->findSpendableOutputs($wallet->getPubKeyHash(), $amount);
        if ($acc < $amount) {
            echo "余额不足";
            exit;
        }

        ......

        $tx = new Transaction($inputs, $outputs);
        $bc->signTransaction($tx, $wallet->privateKey);
        return $tx;
    }
}

trimmedCopy 复制出一个修剪后的交易副本,而不是一个完整交易,然后对交易的每一个输出构建好 pubKey,此时计算出当前交易的 hash 值作为签名的数据,在赋值回真实的交易输入签名字段。
$txCopy->txInputs[$inId]->pubKey = ''; 是为了保证每个输入($txInput)不受上一次迭代的影响。

verify 验证方法当然也是一样,构造出签名数据,用公钥验证。只有所有的交易输入签名都通过验证时,该方法才会返回 true

修改 NewUTXOTransaction 现在构造一笔交易时,需要签名($bc->signTransaction($tx, $wallet->privateKey);)。

BlockChain

class BlockChain {
    public function mineBlock(array $transactions)
    {
        $lastHash = Cache::get('l');
        if (is_null($lastHash)) {
            echo "还没有区块链,请先初始化";
            exit;
        }

        foreach ($transactions as $tx) {
            if (!$this->verifyTransaction($tx)) {
                echo "交易验证失败";
                exit(0);
            }
        }

        ......
    }

    public function signTransaction(Transaction $tx, string $privateKey)
    {
        $prevTXs = [];
        foreach ($tx->txInputs as $txInput) {
            $prevTx = $this->findTransaction($txInput->txId);
            $prevTXs[$prevTx->id] = $prevTx;
        }
        $tx->sign($privateKey, $prevTXs);
    }

    public function verifyTransaction(Transaction $tx): bool
    {
        $prevTXs = [];
        foreach ($tx->txInputs as $txInput) {
            $prevTx = $this->findTransaction($txInput->txId);
            $prevTXs[$prevTx->id] = $prevTx;
        }
        return $tx->verify($prevTXs);
    }

   // 还有些其他方法的修改
}

现在 mineBlock 时,需要验证交易的每个输入。还有些其他的修改,比如 findUnspentTransactions findSpentOutputs findSpendableOutputs findUTXO等方法,不再使用地址,而是 pubKeyHash 去寻找未花费输出。

CLI 更新

新建一个 CreateWallet 以及 ListAddresses 命令。

class CreateWallet extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'createwallet';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = '创建一个钱包';

    /**
     * Execute the console command.
     *
     * @return mixed
     */
    public function handle()
    {
        $wallets = new Wallets();
        $address = $wallets->createWallet();
        $wallets->saveToFile();
        $this->info("Your new address: {$address}");
    }
}

class ListAddresses extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'listaddresses';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = '钱包所有地址';

    /**
     * Execute the console command.
     *
     * @return mixed
     */
    public function handle()
    {
        $wallets = new Wallets();
        $addresses = $wallets->getAddresses();
        foreach ($addresses as $address) {
            $this->info($address);
        }
    }
}

测试

$ php blockchain createwallet
Your new address: 1LRqVSu8Kv9fPgdvXLWP5mTnMxC7TYiYjt

$ php blockchain init-blockchain 1LRqVSu8Kv9fPgdvXLWP5mTnMxC7TYiYjt
init blockchain: ✔

$ php blockchain createwallet
Your new address: 1PWiJKQzxdWnePvWjfD3EPnfskAxiGfejX

$ php blockchain send 1LRqVSu8Kv9fPgdvXLWP5mTnMxC7TYiYjt 1PWiJKQzxdWnePvWjfD3EPnfskAxiGfejX 30
send success
0000015150b22a1a85a78f3a408b2caca4a6a7165677654b3f461937eab982eb

$ php blockchain getbalance 1LRqVSu8Kv9fPgdvXLWP5mTnMxC7TYiYjt
balance of address '1LRqVSu8Kv9fPgdvXLWP5mTnMxC7TYiYjt' is: 20

$ php blockchain getbalance 1PWiJKQzxdWnePvWjfD3EPnfskAxiGfejX 
balance of address '1PWiJKQzxdWnePvWjfD3EPnfskAxiGfejX' is: 30

$ php blockchain listaddresses
1LRqVSu8Kv9fPgdvXLWP5mTnMxC7TYiYjt
1PWiJKQzxdWnePvWjfD3EPnfskAxiGfejX

Wow,看起来没啥问题。

总结

本次我们实现了钱包、地址、签名等功能,再次提醒,代码变动较大,有啥疑问请点击这里

在下一节我们会修改一下交易,让它看起来更接近真实的区块链。

本作品采用《CC 协议》,转载必须注明作者和本文链接
《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
《L02 从零构建论坛系统》
以构建论坛项目 LaraBBS 为线索,展开对 Laravel 框架的全面学习。应用程序架构思路贴近 Laravel 框架的设计哲学。
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!