Python-区块链开发实用指南(全)

Python 区块链开发实用指南(全)

原文:zh.annas-archive.org/md5/E6FBF7D7A6EED49747FB2B635A55F938

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

区块链被视为所有加密货币交易的公共账本的主要技术解决方案。本书是开发完全成熟的使用 Python 与区块链应用程序的各种构建模块进行交互的去中心化应用程序的实用指南。

面向 Python 开发人员的区块链实战首先演示了区块链技术和加密货币哈希是如何工作的。您将了解智能合约的基本原理和好处,比如抗审查和交易准确性。随着您的稳步进展,您将开始使用类似 Python 的 Vyper 构建智能合约。这种经验将进一步帮助您揭开智能合约的其他好处,比如可靠的存储和备份,以及效率。您还将使用 web3.py 与智能合约进行交互,并利用 web3.py 和 Populus 框架的力量构建提供安全性和与加密货币无缝集成的去中心化应用程序。随着您探索后续章节,您将学习如何在以太坊上创建自己的代币,并使用 PySide2 库构建一个可以处理以太坊和以太坊请求的评论(ERC-20)代币的加密货币钱包图形用户界面(GUI)。这将使用户能够无缝地存储、发送和接收数字货币。最后,您将在您的去中心化应用程序中实现星际文件系统(IPFS)技术,以提供一个可以存储和公开媒体的点对点文件系统。

通过本书,您将精通区块链编程,并能够使用 Python 在各种领域构建端到端的去中心化应用程序。

这本书是为谁准备的

如果您是一名想要进入区块链世界的 Python 开发人员,面向 Python 开发人员的区块链实战适合您。本书将成为您精通区块链生态系统并使用 Python 和库支持构建自己的去中心化应用程序的指南。

这本书涵盖了什么

第一章,区块链编程简介,讲述了比特币的故事以及比特币的价值所在。您将了解赋予比特币力量的基础技术,即区块链技术。此外,您还将了解以太坊最初的创建目的。

第二章,智能合约基础,展示了传统程序和智能合约之间的区别。您将了解传统程序存在哪些缺陷,以及为什么智能合约有潜力克服这些缺陷。您还将看到智能合约现在正在被应用在哪些领域。

第三章,使用 Vyper 实现智能合约,教您如何使用类似 Python 的 Vyper 编程语言编写智能合约。您将学习 Vyper 编程语言的许多重要特性。

第四章,使用 Web3 与智能合约交互,向您展示如何安装web3.py库,如何与智能合约交互,以及如何部署智能合约。

第五章,Populus 开发框架,向您展示如何使用 Populus 开发框架,并认识到它对开发人员的价值。

第六章,构建实用的去中心化应用程序,教您如何使用web3.py库和 Populus 开发框架构建去中心化应用程序。

第七章,前端去中心化应用程序,向您展示如何使用桌面前端构建类似 Twitter 的去中心化应用程序。

第八章,在以太坊中创建代币,教你如何创建自己的代币。这是一个关于如何启动自己的加密货币的实践学习指南。

第九章,加密货币钱包,向您展示如何使用桌面前端构建以太坊钱包。

第十章,星际文件系统-一个全新的文件系统,是对星际文件系统的介绍,人们可以在其中存储分布式文件。在区块链中,存储是昂贵的。在区块链上存储图像文件(更不用说视频文件了)已经是不可行的。IPFS 是一种新技术,旨在解决这个问题。您将了解 IPFS 是什么,以及这项技术目前的状态。

第十一章,使用 ipfsapi 与 IPFS 交互,教你如何使用 Python 库连接到 IPFS 节点。

第十二章,使用 IPFS 实现去中心化应用,向您展示如何实现一个利用 IPFS 技术的去中心化视频分享应用。

充分利用本书

必须具备 Python 的基本知识。本书适用于希望从事区块链开发的 Python 开发人员。

下载示例代码文件

您可以从www.packt.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packt.com/support并注册,文件将直接发送到您的邮箱。

您可以按照以下步骤下载代码文件:

  1. 登录或注册www.packt.com

  2. 选择 SUPPORT 选项卡。

  3. 点击“代码下载和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

文件下载后,请确保使用最新版本的解压缩或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Hands-On-Blockchain-for-Python-Developers。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还有来自我们丰富的书籍和视频目录的其他代码包,可在github.com/PacktPublishing/上找到。去看看吧!

下载彩色图像

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在这里下载:www.packtpub.com/sites/default/files/downloads/9781788627856_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。例如:"如果你在 Linux 平台上,你将下载这个文件:qt-unified-linux-x64-3.0.5-online.run。"

代码块设置如下:

"compilation": {
    "backend": {
      "class": "populus.compilation.backends.VyperBackend"
    },
    "contract_source_dirs": [
      "./contracts"
    ],
    "import_remappings": []
},

任何命令行输入或输出都是这样写的:

$ python3.6 -m venv qt-venv $ source qt-venv/bin/activate (qt-venv) $ pip install PySide2

粗体:表示一个新术语、一个重要单词或者屏幕上看到的单词。例如,菜单中的单词或对话框中的单词会出现在文本中,就像这样。例如:"点击下一步。然后你会看到一个登录屏幕。"

警告或重要说明会出现在这样的地方。提示和技巧会出现在这样的地方。

第一部分:区块链和智能合约

介绍区块链技术、智能合约和去中心化应用。

本节将涵盖以下章节:

  • 第一章,区块链编程简介

  • 第二章,智能合约基础

  • 第三章,使用 Vyper 实现智能合约

第一章:区块链编程简介

在本书中,我们将学习区块链编程,以便在寻找区块链机会时成为一个不可忽视的力量。为了实现这一点,你需要从理解区块链技术及其包含的内容开始。在本章中,我们将学习什么是区块链技术。区块链如何赋予比特币和以太坊力量?我们将直观地理解区块链技术。我们还将复制区块链背后的一些基本功能。

本章将涵盖以下主题:

  • 加密货币和区块链的崛起

  • 区块链技术

  • 密码学

  • 哈希函数

  • 共识

  • 区块链上的编码

加密货币和区块链的崛起

假设你在 2017 年不是隐居在山上的隐士,你一定听说过加密货币,尤其是比特币。你不必远去就能听到这个话题的风靡,它的术语和价值的增长。在这一点上,其他加密货币也开始增长,为以太坊达到 1000 美元!等标题铺平了道路。在这狂热中,人们讨论了有关加密货币的一切,从价格的波动到其背后的技术,即区块链。

区块链被视为将为人类带来新时代的正义和繁荣的技术。它将使财富民主化。它将夺取寡头的权力并归还给人民。它将保护人民的数据。然后到了 2018 年,加密货币下跌了。派对结束了。比特币现在的价格是 6000 美元,而以太坊的价格不到 400 美元。

然而,尽管围绕加密货币的炒作已经平息,但它仍然是一个经常讨论的话题。区块链会议和聚会在许多地方涌现,而投资继续涌入区块链初创公司。硅谷的巨头安德烈森·霍洛维茨从其有限合伙人那里获得了高达 3 亿美元的专门区块链基金。在这种情况下,机会就在资金流入的地方。区块链开发者的首席招聘官 Katheryn Griffith Hill 声称,目前每个区块链开发者有 14 个职位空缺。此外,我参加了雅加达的一个本地区块链活动的一位朋友评论说,我看到大约 100 名观众,但只有大约四五名开发者。50%的观众是投资者。有人想把钱投入区块链,但能够开发产品的人却更少。

区块链开始被用作无中间人的支付解决方案,即比特币。然后,人们发现区块链还具有一些其他有趣的特性。首先,它是透明的,意味着人们可以审计它,以检查是否存在洗钱行为。其次,它在一定程度上为用户提供隐私,可以用来避免个人资料被追踪。

然后,在以太坊发布后,人们突然开始创造如何在现实生活中应用区块链。从创建代表某物所有权的代币,比如自治组织或具有完全隐私支付的数字资产,到不能复制的数字资产(不像 MP3 文件)。

区块链技术

大多数人知道比特币存在是因为区块链。但区块链是什么?它是一个仅追加的数据库,由哈希链接的区块组成。在这里,每个区块包含许多由加密技术保护的价值转移交易(但也可能是其他东西);持有相同数据库的许多节点之间的共识决定下一个要追加的新区块。

你现在不必理解这个定义;这些都是很多需要消化的话!首先,我会向你解释区块链,这样你就可以适应这个新知识,随着我们在本书中的进展。

回到区块链的定义,我们可以总结定义为只能追加的数据库。一旦你把东西放入数据库,它就不能被改变;没有撤销。我们将在第二章 智能合约基础中讨论这一特性的影响。这个定义涉及许多事情,并开启了一个全新的世界。

那么,你可以把什么放入这个只能追加的数据库中呢?这取决于加密货币。对于比特币,你可以存储价值转移的交易。例如,纳尔逊向迪安发送 1 比特币。然而,在将它们附加到数据库之前,我们将许多交易累积到一个区块中。对于以太坊,你可以将更丰富的东西放入只能追加的数据库中。这不仅包括价值转移的交易,还可以是状态的改变。我在这里所说的状态是非常一般的。例如,购买演出门票的队列可以有一个状态。这个状态可以是空的或者满的。与比特币类似,在以太坊中,你需要在将所有交易一起附加到这个只能追加的数据库之前收集它们。

为了更清楚,我们将所有这些交易放入区块中,然后再将它们附加到只能追加的数据库中。除了交易列表,我们还在这个区块中存储其他东西,比如我们将区块追加到只能追加的数据库中的时间,目标的难度(如果你不了解这个,不用担心),以及父哈希(我马上会解释这个),以及其他许多东西。

现在你了解了区块链的区块元素,让我们来看看链元素。如前所述,除了交易列表,我们还将父哈希放在区块中。但现在,让我们只使用一个简单的 ID 来表示父级,而不是使用哈希。父 ID只是上一个区块的 ID。在这里,想象一下堆栈。一开始,没有区块。相反,我们放置了区块 A,其中包含三笔交易:交易 1交易 2交易 3。由于区块 A是第一个区块,它没有父级。然后,我们将区块 B应用到区块 A,其中包括两笔交易:交易 4交易 5区块 B不是这个区块链中的第一个区块。因此,我们将区块 B中的父级部分设置为区块 A的 ID,因为区块 A区块 B的父级。然后,我们将区块 C放入区块链中,其中包含两笔交易:交易 6交易 7

区块 C中的父级部分将是区块 B的 ID,依此类推。为了简化事情,我们为每个新区块从 0 开始递增 ID:

让我们实现一个数据库来记录人们喜欢和讨厌的历史。这意味着当你在历史上某个时刻说你喜欢猫时,你将无法改变那段历史。当你改变主意时(例如,如果你后来讨厌猫),你可以添加新的历史,但这不会改变你过去喜欢它们的事实。因此,我们可以看到在过去你喜欢猫,但现在你讨厌它们。我们希望使这个数据库充满诚信并且安全防止作弊。看一下以下代码块:

class Block:
    id = None
    history = None
    parent_id = None

block_A = Block()
block_A.id = 1
block_A.history = 'Nelson likes cat'

block_B = Block()
block_B.id = 2
block_B.history = 'Marie likes dog'
block_B.parent_id = block_A.id

block_C = Block()
block_C.id = 3
block_C.history = 'Sky hates dog'
block_C.parent_id = block_B.id

如果你学过计算机科学,你会认识到这种数据结构,它被称为链表。现在,有一个问题。假设玛丽讨厌纳尔逊,并希望给纳尔逊抹黑。玛丽可以通过改变区块 A 的历史来做到这一点:

block_A.history = 'Nelson hates cat'

这对喜欢猫的纳尔逊是不公平的。因此,我们需要添加一种只有纳尔逊才能写下自己偏好历史的方法。这样做的方法是使用私钥和公钥。

在区块链中签署数据

在区块链中,我们使用两个密钥对数据进行签名,以验证消息的真实性并保护免受未经授权的用户篡改。这两个密钥如下:

  • 私钥

  • 公钥

私钥的保密性受到保护,不向公众公开。另一方面,您可以将公钥公开。您可以告诉每个人,嘿,这是我的公钥

让我们生成私钥。为此,我们需要openssl软件。您可以通过以下方式安装它:

$ sudo apt-get install openssl

因此,Nelson 生成私钥,即nelsonkey.pem文件。他必须保守这个密钥。生成如下:

$ openssl genrsa -out nelsonkey.pem 1024

从私钥中,Nelson 生成公钥:

$ openssl rsa -in nelsonkey.pem -pubout > nelsonkey.pub

Nelson 可以与所有人分享这个公钥nelsonkey.pub。现实世界中,我们可以建立一个简单的公钥及其所有者的字典,如下所示:

{
'Nelson': 'nelsonkey.pub',
'Marie': 'mariekey.pub',
'Sky': 'skykey.pub'
}

我们现在将看一下 Nelson 如何证明他是唯一能够对其历史进行更改的人。

首先,让我们创建一个 Python 虚拟环境:

$ python3 -m venv blockchain
$ source blockchain/bin/activate
(blockchain) $

接下来,安装库:

(blockchain) $ pip install --upgrade pip
(blockchain) $ pip install wheel
(blockchain) $ pip install cryptography

这是可以用来签署消息的 Python 脚本。将此脚本命名为verify_message.py(请参考以下 GitLab 链接中的代码文件获取完整代码:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_01/verify_message.py):

from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization

# Generate private key
#private_key = rsa.generate_private_key(
# public_exponent=65537,
# key_size=2048,
# backend=default_backend()
#)
...
...

# Message validation executed by other people
public_key.verify(
    signature,
    message,
    padding.PSS(mgf=padding.MGF1(hashes.SHA256()),
                salt_length=padding.PSS.MAX_LENGTH),
    hashes.SHA256())

执行此脚本时,如预期的那样,不会发生任何事情。这意味着消息已通过公钥的签名进行了验证。签名只能由 Nelson 创建,因为您需要私钥才能创建签名。但是,要使用签名验证消息,您只需要公钥。

让我们看一个案例,Marie 试图使用名为falsify_message.py的脚本伪造事实。Marie 试图将Nelson hates cat放入历史数据库中,如下所示:

from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization

message = b'Nelson hates cat'
signature = b'Fake Signature'

with open("nelsonkey.pub", "rb") as key_file:
    public_key = serialization.load_pem_public_key(
        key_file.read(),
        backend=default_backend())

public_key.verify(
 signature,
 message,
 padding.PSS(mgf=padding.MGF1(hashes.SHA256()),
                salt_length=padding.PSS.MAX_LENGTH),
    hashes.SHA256())

这就是验证方法的工作原理。Nelson 计算消息的哈希值,然后用他的私钥对其进行加密。结果就是签名。例如,如果 Sky 想要验证签名,他有消息和签名。他计算消息的哈希值。然后,他使用公钥解密签名。结果与消息的哈希值进行比较。如果相同,那么一切正常。如果不同,要么消息已被更改,要么用于签署消息的私钥不同。

这样做时,您将获得以下输出:

那么签名是什么样的?回到verify_message.py,并将以下行附加到文件末尾。然后再次运行脚本:

print(signature)

签名看起来像这样:

每条消息都有不同的签名,Marie 不可能猜到签名以伪造消息。因此,有了私钥和公钥,我们可以验证消息是否确实来自授权人员,即使我们在不安全的通道上进行通信。

因此,有了私钥,Nelson 可以创建一个对其尝试签署的消息唯一的签名:

世界上每个拥有 Nelson 公钥的人都可以验证 Nelson 确实写了消息 A。Nelson 可以通过展示签名 A来证明他确实写了消息 A。每个人都可以获取这两个输入并验证真相:

因此,要验证是否是 Nelson 写的Nelson likes cat,请输入以下内容(请参考以下 GitLab 链接中的代码文件获取完整代码:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_01/validate_message.py):

# validate_message.py
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization

def fetch_public_key(user):
    with open(user + "key.pub", "rb") as key_file:
        public_key = serialization.load_pem_public_key(
           key_file.read(),
           backend=default_backend())
    return public_key

# Message coming from user
message = b"Nelson likes cat"

# Signature coming from user, this is very specific to public key.
# Download the public key from Gitlab repository of this code so this signature matches the message.
# Otherwise, you should generate your own signature.
signature = 
...
...
    padding.PSS(mgf=padding.MGF1(hashes.SHA256()),
                salt_length=padding.PSS.MAX_LENGTH),
    hashes.SHA256())

从链表到区块链

现在我们知道只有尼尔森能写尼尔森喜欢猫尼尔森讨厌猫,我们可以安心了。然而,为了使教程代码简短,我们不会集成使用私钥和公钥进行验证。我们假设只有授权的人能够在区块中写历史。看一下以下代码块:

>>> block_A.history = 'Nelson likes cat'

当发生这种情况时,我们假设是尼尔森写了那段历史。那么,用链表记录数据的问题是什么呢?

问题在于数据可以很容易地被更改。比如尼尔森想成为一名参议员。如果他的选区有很多人不喜欢猫,他们可能不会喜欢尼尔森喜欢它们的事实。因此,尼尔森想要更改历史:

>>> block_A.history = 'Nelson hates cat'

就像这样,历史已经改变了。我们可以通过每天在区块中记录所有历史的方式来避免这种作弊。因此,当尼尔森改变数据库时,我们可以将今天区块链中的数据与昨天区块链中的数据进行比较。如果不同,我们可以确认发生了可疑的事情。这种方法可能有效,但让我们看看是否能想出更好的办法。

让我们将我们的链表升级为区块链。为此,我们在Block类中添加一个新属性,即父哈希:

import hashlib
import json

class Block:
    id = None
    history = None
    parent_id = None
    parent_hash = None

block_A = Block()
block_A.id = 1
block_A.history = 'Nelson likes cat'

block_B = Block()
block_B.id = 2
block_B.history = 'Marie likes dog'
block_B.parent_id = block_A.id
block_B.parent_hash = hashlib.sha256(json.dumps(block_A.__dict__).encode('utf-8')).hexdigest()

block_C = Block()
block_C.id = 3
block_C.history = 'Marie likes dog'
block_C.parent_id = block_B.id
block_C.parent_hash = hashlib.sha256(json.dumps(block_B.__dict__).encode('utf-8')).hexdigest()

让我们演示hashlib()函数的作用:

>>> print(block_B.__dict__)
{'parent_hash': '880baef90c77ae39d49f364ff1074043eccb78717ecec85e5897c282482012f1', 'history': 'Marie likes dog', 'id': 2, 'parent_id': 1}
>>> print(json.dumps(block_B.__dict__))
{"parent_hash": "880baef90c77ae39d49f364ff1074043eccb78717ecec85e5897c282482012f1", "parent_id": 1, "history": "Marie likes dog", "id": 2}
>>> print(json.dumps(block_B.__dict__).encode(‘utf-8'))
b'{"id": 2, "parent_hash": "69a1db9d3430aea08030058a6bd63788569f1fde05adceb1be6743538b03dadb", "parent_id": 1, "history": "Marie likes dog"}'
>>> print(hashlib.sha256(json.dumps(block_B.__dict__).encode('utf-8')))
<sha256 HASH object @ 0x7f58518e3ee0>
>>> print(hashlib.sha256(json.dumps(block_B.__dict__).encode('utf-8')).hexdigest())
25a7a88637c507d33ae1402ba6b0ee87eefe9c90e33e75c43d56858358f1704e

如果我们改变block_A的历史,以下代码看起来像这样:

>>> block_A.history = 'Nelson hates cat'

再次,历史已经被改变就像那样。然而,这一次有一个转折。我们可以通过打印block_C的原始父哈希来验证这一变化已经发生:

>>> print(block_C.parent_hash)
ca3d23274de8d89ada13fe52b6000afb87ee97622a3edfa3e9a473f76ca60b33

现在,让我们重新计算每个区块的父哈希:

>>> block_B.parent_hash = hashlib.sha256(json.dumps(block_A.__dict__).encode('utf-8')).hexdigest()
>>> block_C.parent_hash = hashlib.sha256(json.dumps(block_B.__dict__).encode('utf-8')).hexdigest()
>>> print(block_C.parent_hash)
10b7d80f3ede91fdffeae4889279f3acbda32a0b9024efccc9c2318e2771e78c

这些区块是不同的。通过观察这些,我们可以非常确定历史已经被更改。因此,尼尔森将被当场抓住。现在,如果尼尔森想要更改历史而不被抓住,仅仅更改block_A中的历史已经不够了。尼尔森需要更改每个区块中的parent_hash属性(当然除了block_A)。这是更难的作弊。仅有三个区块,尼尔森就需要更改两个parent_hash属性。有了 1000 个区块,尼尔森就需要更改 999 个parent_hash属性!

密码学

区块链最流行的用途是创建加密货币。由于加密货币中有crypto这个词,你可能会认为你需要精通密码学才能成为区块链程序员。这是不正确的。你只需要了解密码学的两件事:

  • 私钥和公钥(非对称加密)

  • 哈希

这两个在本章的前一部分已经解释过了。你不需要知道如何设计哈希算法或私钥和公钥算法。你只需要对它们的工作方式和这些技术的含义有直观的理解。

私钥和公钥的含义是它实现了去中心化账户。在普通应用中,你有一个用户名和密码。这两个字段使某人能够访问他们的账户。但是拥有私钥和公钥使某人能够以去中心化的方式拥有一个账户。

对于哈希,它是一个单向函数,意味着给定一个输入,你可以轻松地得到输出。但是给定一个输出,你无法得到输入。一个简单版本的单向函数可能是这样的:

这是一个附加过程。如果我告诉你这个函数的一个输出是 999,然后问你输入是什么,你无法猜出答案。它可以是从 1 和 998 到 500 和 499 的任何东西。哈希函数就像这样。算法就像天空一样清晰(你可以在互联网上阅读任何哈希函数的算法),但是很难逆转算法。

因此,关于哈希,你需要知道的是:给定输入 input,你会得到这个 SHA-256 输出(十六进制):c96c6d5be8d08a12e7b5cdc1b207fa6b2430974c86803d8891675e76fd992c20。如果你不知道输入,你就无法仅凭这个输出得到输入。假设你知道输入 input,要找到另一个产生相同输出的输入是非常困难的。我们甚至不知道是否存在这样的输入。

这就是你成为区块链开发者时需要了解的有关加密的一切。但是,只有当你成为某种类型的区块链开发者时才是真的,这种开发者会在以太坊之上创建程序。

对称和非对称加密

对称加密使用发送方和接收方之间相同的密钥。这个密钥用于加密和解密消息。例如,你想创建一个加密函数来加密文本。对称加密可能就是简单地将文本加 5。如果 A(或者 ASCII 中的 65)是要加密的文本,那么这个加密函数将 65 加 5。加密后的文本将是 F(或者 ASCII 中的 71)。要解密它,只需从加密后的文本 F 中减去 5。

非对称加密是一种不同的东西。有两个密钥:公钥和私钥。它们之间有一个特殊的数学关系。如果你用公钥加密一条消息,你只能用私钥解密它。如果你用私钥加密一条消息,你只能用公钥解密它。与对称密钥(加减相同的数字)之间的关系不同,公钥和私钥之间没有直接的关系。有几种非对称加密算法。我将解释最简单的一种,即 RSA 算法。

生成两个素数,称为 pq。它们应该是非常大的数字(至少有数百位数),但是对于这个例子,我们选择了较小的数字:11 和 17。这些是你的私钥。不要让别人知道这些数字:

n = p x q

n 是一个合数。在我们的例子中,n187

然后,我们找到 e 数,它应该与 (p-1)x(q-1) 互质:

(p-1) x (q-1) = 160

互质意味着 e(p-1) x (q-1) 除了 1 之外不能被任何数字因数分解。除了 1 之外,没有其他数字可以整除它们而不产生余数。因此,e7。但是,e 也可以是 11。在这个例子中,我们选择 7 作为 e

en 是你的公钥。你可以把这些数字告诉你在公交车上遇到的陌生人、你的祖母、友好的邻居或者你的约会对象。

假设我们要加密的消息是 A。在现实世界中,加密这样一个短消息是不安全的。我们必须填充这个短消息。因此,A 将变成类似 xxxxxxxxxxxxxxxxxxxA。如果你查看本章早些时候加密消息的脚本,你会看到有一个填充函数。但是在这个例子中,我们不会填充消息。

加密函数是这样的:

encrypted_message = messagee (mod n)

因此,encrypted_message 将是 65 ** 7 % 187 = 142

在我们能够解密消息之前,我们需要找到 d 数:

e x d = 1 (mod (p-1) x (q-1))

d23

解密函数是这样的:

decrypted_message = encrypted_messaged mod n

因此,decrypted_message 将是 142 ** 23 % 187 = 65。65 在 ASCII 中是 A。

显然,x^y mod n 很容易计算,但是找到整数模 ny 次根确实很难。我们称之为陷门置换。对 n 因数分解以找到 pq 是非常困难的(从公钥生成私钥)。但是,从 pq 找到 n 是容易的(从私钥生成公钥)。这些属性使得非对称加密成为可能。

与对称加密相比,非对称加密使人们能够在不需要先交换密钥的情况下进行安全通信。你有两个密钥(私钥和公钥)。你把公钥给任何人。你只需要保护私钥的保密性。私钥就像是你的比特币/以太坊账户的密码。在任何加密货币中创建账户只是生成一个私钥。你的地址(或者说在加密货币中的用户名)是由公钥派生出来的。公钥本身可以由私钥派生出来。比特币的私钥的一个例子是钱包导入格式WIF):5K1vbDP1nxvVYPqdKB5wCVpM3y99MzNqMJXWTiffp7sRWyC7SrG

它有 51 个十六进制字符。每个字符可以有 16 种组合。因此,私钥的数量如下:16 ^ 51 = 25711008708143844408671393477458601640355247900524685364822016(实际数量并非如此,因为比特币的私钥的第一个数字在主网上始终为 5,但你明白我的意思)。这是一个巨大的数字。因此,通过强大的随机过程生成私钥时,有人找到另一个已经充满比特币的账户的概率是非常非常低的。但是私钥和公钥生成的账户类型没有重置密码功能。

如果有人向你的地址发送比特币,而你忘记了你的私钥,那么它就永远丢失了。因此,尽管你的公钥被记录在每个比特币节点中的区块链上,但人们不会得到私钥。

哈希函数

哈希是一个函数,它接受任意长度的输入并将其转换为固定长度的输出。因此,为了更清楚地说明这一点,我们可以看下面的代码示例:

>>> import hashlib
>>> hashlib.sha256(b"hello").hexdigest()
'2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824'
>>> hashlib.sha256(b"a").hexdigest()
'ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb'
>>> hashlib.sha256(b"hellohellohellohello").hexdigest()
'25b0b104a66b6a2ad14f899d190b043e45442d29a3c4ce71da2547e37adc68a9'

正如你所看到的,输入的长度可以是15,甚至20个字符,但输出始终是64个十六进制数字字符的长度。输出看起来是乱码,似乎输入和输出之间没有明显的联系。然而,如果给出相同的输入,它每次都会产生相同的输出:

>>> hashlib.sha256(b"a").hexdigest()
'ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb'
>>> hashlib.sha256(b"a").hexdigest()
'ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb'

即使只改变一个字符的输入,输出也会完全不同:

>>> hashlib.sha256(b"hello1").hexdigest()
'91e9240f415223982edc345532630710e94a7f52cd5f48f5ee1afc555078f0ab'
>>> hashlib.sha256(b"hello2").hexdigest()
'87298cc2f31fba73181ea2a9e6ef10dce21ed95e98bdac9c4e1504ea16f486e4'

现在输出的长度是固定的,这种情况下是 64,当然会有两个不同的输入产生相同的输出。

有趣的是:找到两个不同的输入,使得这个哈希函数的输出相同是非常困难的。不可能的任务:即使你劫持了世界上所有的计算机并让它们运行哈希计算,你也不太可能找到两个不同的输入产生相同的输出。

然而,并非所有的哈希函数都是安全的。SHA-1在 2017 年已经消亡。这意味着人们可以找到两个不同的长字符串,它们具有相同的输出。在这个例子中,我们将使用SHA-256

哈希函数的输出可以用作数字签名。想象一下,你有一个长度为 1000 万的字符串(比如你在写一部小说),为了确保这部小说没有被篡改,你告诉所有潜在的读者,他们必须数一遍这 1000 万个字符,以确保小说没有被损坏。但是没有人会这样做。但是通过哈希,你可以用只有 64 个字符的输出验证(比如通过 Twitter),你的潜在读者可以对他们购买/下载的小说进行哈希,并进行比较,以确保他们的小说是合法的。

因此,我们在块类中添加了父哈希。这样,我们在我们的块中保留了父块的数字签名。这意味着如果我们有任何块的内容发生变化,任何子块中的父哈希将无效,你将被当场抓住。

但是,如果你想改变任何区块的内容,你可以改变子区块的父哈希吗?显然可以。然而,改变内容的过程变得更加困难。你需要有两个步骤。现在,想象一下你有 10 个区块,你想改变第一个区块的内容:

  1. 在这种情况下,你必须改变其直接子区块中父区块的哈希。但是,遗憾的是,这会带来看不见的后果。从技术上讲,其直接子区块中的父哈希是该区块中的一部分。这意味着其子区块中的父哈希(第一个区块的孙子)将无效。

  2. 现在,你必须改变那个孙子的父哈希,但这会影响后续的区块,依此类推。现在,你必须改变所有区块的父哈希。为此,需要进行十个步骤。使用父哈希使篡改变得更加困难。

工作证明

所以,在这种情况下,我们有三个参与者:纳尔逊,玛丽和斯凯。但还有另一种类型的参与者:在区块链术语中,写入区块链的人被称为矿工。为了将交易放入区块链,矿工需要先做一些工作。

以前,我们有三个区块(block_Ablock_Bblock_C),但现在我们有一个候选区块(block_D),我们希望将其添加到区块链中,如下所示:

block_D = Block()
block_D.id = 4
block_D.history = 'Sky loves turtle'
block_D.parent_id = block_C.id

但是,我们不是直接将block_D添加到区块链中,而是首先要求矿工做一些谜题工作。我们对该区块进行序列化,并要求矿工应用一个额外的字符串,当附加到该区块的序列化字符串时,如果进行哈希,将显示前面至少有五个零的哈希输出。

这些都是需要仔细思考的话。首先,我们对区块进行序列化:

import json
block_serialized = json.dumps(block_D.__dict__).encode('utf-8')
print(block_serialized)
b'{"history": "Sky loves turtle", "parent_id": 3, "id": 4}'

如果对序列化的区块进行哈希,如果我们希望哈希输出的前面至少有五个零,那意味着我们希望输出看起来像这样:

00000aa21def23ee175073c6b3c89b96cfe618b6083dae98d2a92c919c1329be

或者,我们希望它看起来像这样:

00000be7b5347509c9df55ca35d27091b41a93acb2afd1447d1cc3e4b70c96ab

因此,这个谜题就像这样:

string serialization + answer = hash output with (at least) 5 leading zeros

矿工需要猜出正确的答案。如果将这个谜题转换为 Python 代码,它会是这样的:

answer = ?
input = b'{"history": "Sky loves turtle", "parent_id": 3, "id": 4}' + answer
output = hashlib.sha256(input).hexdigest()
// output needs to be 00000???????????????????????????????????????????????????????????

那么,矿工如何解决这样的问题呢?我们可以使用穷举法:

import hashlib

payload = b'{"history": "Sky loves turtle", "parent_id": 3, "id": 4}'
for i in range(10000000):
  nonce = str(i).encode('utf-8')
  result = hashlib.sha256(payload + nonce).hexdigest()
  if result[0:5] == '00000':
    print(i)
    print(result)
    break

因此,结果将如下所示:

184798
00000ae01f4cd7806e2a1fccd72fb18679cb07ede3a2a7ef028a0ecfd4aec153

这意味着答案是184798,或者{"history": "Sky loves turtle", "parent_id": 3, "id": 4}184798的哈希输出是前面有五个零的那个。在这个简单的脚本中,我们从 0 迭代到 9999999,并将其附加到输入中。这是一种天真的方法,但它有效。当然,你也可以附加字符而不是数字,比如 a、b 或 c。

现在,试着将前导零的数量增加到六个,甚至十个。在这种情况下,你能找到哈希输出吗?如果没有输出,你可以将范围限制从 10000000 增加到更高的数字,比如 1000000000000。一旦你对这个工作的艰辛有所了解,试着理解这一点:比特币在这本书写作时需要大约 18 个前导零的哈希输出。前导零的数量不是固定的,会根据情况而变化(但你不需要担心这个)。

那么,为什么我们需要工作证明呢?我们首先需要看一下共识的概念。

共识

正如我们所看到的,哈希函数使得篡改历史变得困难,但并非太难。即使我们有一个由 1000 个区块组成的区块链,利用现代计算机轻而易举地改变第一个区块的内容,并将其他区块上的 999 个父哈希值更改。因此,为了确保坏人无法更改历史(或者至少使其变得非常困难),我们将这个追加数据库分发给每个想要保存它的人(让我们称他们为矿工)。假设有十个矿工。在这种情况下,你不能仅仅在你的副本中更改区块链,因为其他九个矿工会责骂你,说一些像“嘿,我们的记录显示历史 A,但你的记录显示 B”的话。在这种情况下,多数人胜出。

然而,共识不仅仅是选择大多数人选择的区块链。问题在于当我们想要向区块链中添加一个新区块时。我们从哪里开始?我们该如何做?答案是我们广播。当我们广播包含新交易的候选区块时,它不会立即到达每个矿工。你可能会到达站在你旁边的矿工,但你的消息需要时间才能到达远离你的矿工。

这就是有趣的地方:远离你的矿工可能会先收到另一个新的候选区块。那么,我们如何同步所有这些事情,并确保大多数人拥有相同的区块链呢?简单的规则是选择最长的链。因此,如果你是中间的矿工,你可能会同时收到两个不同的候选区块,如下图所示:

你从西边得到这个:

block_E = Block()
block_E.id = 5
block_E.history = 'Sherly likes fish'
block_E.parent_id = block_D.id

你从东边得到这个:

block_E = Block()
block_E.id = 5
block_E.history = 'Johny likes shrimp'
block_E.parent_id = block_D.id

因此,我们将保留block_E的两个版本。我们的区块链现在有一个分支。然而,不久后,来自东边的其他区块已经到达。现在的情况是这样的:

这是来自西边的:

block_E = Block()
block_E.id = 5
block_E.history = 'Sherly likes fish'
block_E.parent_id = block_D.id

这是来自东边的:

block_E = Block()
block_E.id = 5
block_E.history = 'Johny likes shrimp'
block_E.parent_id = block_D.id

block_F = Block()
block_F.id = 6
block_F.history = 'Marie hates shark'
block_F.parent_id = block_E.id

block_G = Block()
block_G.id = 7
block_G.history = 'Sarah loves dog'
block_G.parent_id = block_F.id

到这一点,我们可以摆脱区块链的西侧版本,因为我们选择了更长的版本。

问题来了。假设 Sherly 讨厌鲨鱼,但她想从一个地区获得选票,那里的大多数人只投票给喜欢鲨鱼的候选人。为了获得更多选票,Sherly 广播了一个包含以下谎言的区块:

block_E = Block()
block_E.id = 5
block_E.history = 'Sherly loves shark'
block_E.parent_id = block_D.id

一切都很好。投票会话持续一天。一天过去后,区块链又增加了两个区块:

block_E = Block()
block_E.id = 5
block_E.history = 'Sherly loves shark'
block_E.parent_id = block_D.id

block_F = Block()
block_F.id = 6
block_F.history = 'Lin Dan hates crab'
block_F.parent_id = block_E.id

block_G = Block()
block_G.id = 7
block_G.history = 'Bruce Wayne loves bat'
block_G.parent_id = block_F.id

以下图示了三个区块:

现在,Sherly 需要从另一个地区获得选票,那里的大多数人只投票给讨厌鲨鱼的候选人。那么,Sherly 如何篡改区块链以使其对她有利呢?Sherly 可以广播四个区块!

block_E = Block()
block_E.id = 5
block_E.history = 'Sherly hates shark'
block_E.parent_id = block_D.id

block_F = Block()
block_F.id = 6
block_F.history = 'Sherly loves dog'
block_F.parent_id = block_E.id

block_G = Block()
block_G.id = 7
block_G.history = 'Sherly loves turtle'
block_G.parent_id = block_F.id

block_H = Block()
block_H.id = 8
block_H.history = 'Sherly loves unicorn'
block_H.parent_id = block_G.id

以下图示了四个区块:

矿工将选择来自 Sherly 而不是他们之前保存的区块链,其中包含“Sherly 爱鲨鱼”的历史。因此,Sherly 已经能够改变历史。这就是我们所说的双重支付攻击。

我们可以通过工作证明(添加区块的激励)来防止这种情况。我们在本章前面解释了工作证明,但我们还没有解释激励系统。激励意味着如果矿工成功地将新区块添加到区块链中,系统会给予他们数字奖励。我们可以将其整合到代码中如下:

import hashlib

payload = b'{"history": "Sky loves turtle", "parent_id": 3, "id": 4}'
for i in range(10000000):
  nonce = str(i).encode('utf-8')
  result = hashlib.sha256(payload + nonce).hexdigest()
  if result[0:5] == '00000':
 // We made it, time to claim the prize
 reward[miner_id] += 1
    print(i)
    print(result)
    break

如果 Sherly 想要更改历史(替换一些区块),她需要花费一些资源来在短时间内解决四个难题。等她完成这个过程时,大多数矿工保存的区块链可能已经添加了更多的区块,使得它比 Sherly 的区块链更长。

这是因为大多数矿工希望以最有效的方式获得我们提到的奖励。为了做到这一点,他们会获得一个新的候选区块,努力找到工作证明的答案,然后尽快将其添加到最长的链上。但是,为什么他们想要将其添加到最长的链而不是其他链呢?这是因为它保障了他们的奖励。

假设我们有两个版本的区块链。一个有三个区块,而另一个有八个区块。添加新区块的最明智的方法是将其添加到有八个区块的区块链中。如果有人将其添加到只有三个区块的区块链中,它更有可能被丢弃。因此,奖励将被从矿工那里拿走。最长的链无论如何都会吸引最多的矿工,您希望在被更多人保留的区块链版本中。

一些矿工可能会坚持将区块添加到只有三个区块的区块链中,而其他矿工也可能会坚持将区块添加到有八个区块的区块链中。我们称之为硬分叉。大多数时候,矿工会坚持保持最长的链的区块链。

要改变历史,Sherly 将需要击败至少 50%以上的矿工,这是不可能的。区块越老,其中的历史就越安全。假设一个人需要 5 分钟来完成谜题工作。在这种情况下,为了替换区块链中的最后五个区块,Sherly 需要超过 25 分钟(因为 Sherly 至少需要六个区块来说服矿工替换他们区块链中的最后五个区块)。但是在这 25 分钟内,其他矿工将继续向最受欢迎的区块链添加新的区块。因此,当 25 分钟过去时,最受欢迎的区块链将增加五个额外的区块!也许矿工们会打个小盹,一个小时不再添加任何区块。在这种情况下,Sherly 可以积累六个区块来篡改最受欢迎的区块链。然而,嵌入在区块链中的激励使矿工们 24/7 保持清醒,因为他们希望尽可能多地获得奖励。因此,对于 Sherly 来说,这是一场失败的战斗。

在区块链上编码

在撰写本书时,最受欢迎的两种加密货币是比特币和以太坊(偶尔,瑞波会占据第二位)。如果您向了解加密货币的人提出一个简单的问题,您可能会得到这样的答案:比特币只是用来发送货币,但您可以在以太坊上创建程序。该程序可以是代币、拍卖或托管等。但这只是半真。您也可以在比特币上创建程序。通常,人们称这个程序为脚本。事实上,在比特币交易中必须提供一个脚本。比特币交易可能很普通,所以如果我想向您发送 1 个比特币(比特币中的货币单位)并且您的比特币地址是 Z,我需要将这样的脚本上传到比特币区块链中:

What's your public key? If the public key is hashed, does it equal Z? If yes, could you provide your private key to prove that you own this public key?

但它可能会更加复杂。假设您想要至少需要四个授权签名中的两个签名来解锁此帐户;您可以使用比特币脚本来实现。发挥创造力,您可以想出类似这样的东西:

This transaction is frozen until 5 years from now. Then business will be as usual, that the spender must provide public key and private key.

但是比特币脚本是用简单的编程语言创建的,甚至无法循环。它是基于堆栈的。因此,您可以放置指令:对公钥进行哈希,检查签名,并检查当前时间。然后,它将在比特币节点上从左到右执行。

这意味着您无法在比特币上创建一个复杂的程序,比如拍卖。比特币的设计只是用来存储和转移价值(货币)。因此,它特意设计成避免复杂的程序。在比特币节点中,每个脚本都会被执行。没有循环,比特币脚本将会非常简单,您知道它何时会停止。但是如果您在比特币脚本中有一个循环,您就不知道它何时会停止。它可能在第四次迭代中停止,或者在第一百万次迭代中停止,或者在遥远的未来停止。

有些人对此限制感到不满,因此创建了以太坊。您在以太坊区块链上配备的编程语言比比特币中的编程语言要复杂得多(有whilefor结构)。从技术上讲,您可以在以太坊区块链上创建一个永远运行的程序。

您可以像在比特币中那样存储和转移价值。但在以太坊中,您可以做的远不止这些。您可以创建一个投票程序、担保服务、在线拍卖,甚至在其上创建另一种加密货币。因此,人们喜欢区分比特币BTC)和以太坊ETH)的货币。BTC 就像是数字黄金。ETH 就像是石油和天然气。如果我们采用这个类比,两者都是有价值的。但是,您可以使用石油和天然气来创造一个全新的世界,例如通过创造塑料、燃料等。另一方面,您可以做的事情与黄金相比相当有限,除了创造珠宝。

在以太坊上创建加密货币非常容易。如果您是一名熟练的程序员,您只需要一个周末。您只需继承一个类,并设置您的代币名称和供应限制。然后,您编译它并发布到以太坊生产区块链上,您就会拥有自己的加密货币。在此之前,创建另一种加密货币意味着分叉比特币。所需的技能水平相当深(C++、CMake,并替换比特币核心文件的许多部分)。

其他类型的区块链程序员…

本章旨在让您直观地了解区块链的工作原理。然而,这并不是它的完整范围。我的解释与比特币(甚至以太坊)的工作方式有很大不同。以太坊不使用SHA-256进行哈希;它通常使用Keccak-256算法。在我们的情况下,我们只在一个区块中放入一个历史记录/交易/有效负载,但比特币可以在一个区块中保存超过 1,000 笔交易。然后,我们通过使用 RSA 密码学生成私钥和公钥,而比特币和以太坊使用椭圆曲线密码学。在我们的情况下,有效负载是历史记录(谁喜欢/爱/讨厌动物),但在比特币中,它是对先前有效负载的依赖性交易。在以太坊本身中,它是程序的状态。因此,如果有效负载中的变量a等于整数5,它可能是类似于将变量a更改为整数7。在比特币共识中,我们选择具有最高哈希率功率的区块链,而不是具有最长链的区块链。例如,区块链 A 有两个区块,但每个区块的答案都是以 12 个前导零解决谜题,而区块链 B 有十个区块,但每个区块的答案只有五个前导零。在这种情况下,区块链 A 具有最高的哈希率功率。

现在,让我们回到以下问题:成为区块链程序员意味着什么?有多少种类型的区块链程序员?这本书的范围是什么?

区块链编程可能意味着您正在努力改进比特币的状态或创建比特币的分叉,如比特币现金。您需要 C++和 Python。如果您正在创建比特币分叉,例如比特币黄金,您需要更深入地了解密码学。在比特币黄金中,开发人员将工作证明哈希函数从 SHA-256 更改为 Equihash,因为 Equihash 是 ASIC 抗性的。ASIC 抗性意味着您无法创建特定的机器来进行哈希。您需要一台带有 GPU 的计算机来执行 Equihash 哈希函数,但本书不会讨论这一点。

此外,区块链编程可能意味着您正在努力改进以太坊虚拟机。您需要 Go、C++或 Python。您需要了解如何与低级加密库函数进行交互。对基本密码学的直观理解是不够的,但本书也不会讨论这一点。

区块链编程可能意味着你正在在以太坊上编写程序。为此,你需要 Solidity 或 Vyper,这本书将讨论这些内容。你只需要对基本密码学的直观理解。你已经摆脱了低级密码学。偶尔,你可能会在你编写的程序中使用哈希函数,但没有什么花哨的。

区块链编程可能意味着你正在编写一个与以太坊上的程序进行交互的程序,听起来有点元。但你为此所需取决于平台。如果是移动应用程序,你需要 Kotlin、Java、Swift、Obj-C,甚至 C++。如果是 Web 前端,你很可能需要 JavaScript。只需要对基本密码学的直观理解。这本书将讨论其中的一些内容。

这就好像我问你,“当有人想成为 Web 开发人员时,这意味着什么?”答案是多种多样的。我应该学习 Ruby、Java、PHP 还是 Python?我应该学习 Ruby on Rails、Laravel 还是 Django?

这本书将教你如何在以太坊上构建程序(不要与构建以太坊本身混淆)。将这与 Web 开发进行比较,这就好像说这本书将教你如何使用 Ruby on Rails 构建 Web 应用程序,但这本书不会教你如何解剖 Ruby on Rails 框架本身。这并不意味着 Ruby on Rails 的内部不重要,只是大多数情况下你不需要它们。

这本书将教你使用 Python 编程语言,假设你已经有 Python 的基本知识。但为什么选择 Python 呢?答案是老生常谈:Python 是最简单和最流行的编程语言之一。它降低了想要进入区块链领域的人的门槛。

总结

在这一章中,我们研究了比特币和以太坊等加密货币背后的技术。这项技术使价值或代码的去中心化存储成为可能。我们还涵盖了使用私钥和公钥来保护任何数据的完整性的密码学。此外,我们还学习了哈希函数、工作证明、共识和区块链编程的基本概念。

在下一章中,我们将学习智能合约,这是以太坊中的一种程序。智能合约不同于在服务器中运行的程序,比如使用 Ruby on Rails、Laravel 或 Django 编写的应用程序。区别不仅仅在于语法;这个概念与普通的 Web 应用程序完全不同。

参考资料

第二章:智能合约基础知识

在本章中,我们将探讨智能合约的基础知识。在比特币中,我们存储价值,在以太坊中,我们存储代码。我们在以太坊中存储的代码称为智能合约。智能合约是一种无需信任的代码,这意味着代码的完整性由算法和密码学保护。我们可以存储无需审查的代码,并且能够避免第三方干预,甚至是智能合约的开发者。这为创建许多类型的应用程序打开了可能性,例如透明的数字代币、无需信任的众售、安全的投票系统和自治组织。

本章将涵盖以下主题:

  • 安装以太坊开发环境

  • 编写智能合约

  • 将智能合约部署到以太坊区块链

  • 与智能合约交互

  • 为什么要使用智能合约?

安装以太坊开发环境

现在是时候创建一个智能合约了。如果您熟悉 Solidity、Truffle、Ganache 和智能合约基础知识,可以直接跳转到第三章,使用 Vyper 实现智能合约。在这里,我们将专注于使用 Solidity 创建的智能合约的内容。在本书的其余部分,我们将使用 Vyper 编程语言来开发智能合约。但是,在本书的其余部分,我们仍将使用相同的开发环境(如 Ganache)。

安装 Node.js

Node.js 是一个用于开发 Web 应用程序、移动应用程序和去中心化应用程序的流行框架。前往nodejs.org并下载最新版本(目前是版本 10)。以下是如何在 Ubuntu Linux 平台上安装 Node.js:

$ curl -sL https://deb.nodesource.com/setup_10.x | sudo -E bash -
$ sudo apt-get install -y nodejs
$ node --version
v10.15.0

安装 Truffle 和 Solidity

Truffle 是一个用于使用 Solidity 开发智能合约的开发框架。您可以在没有 Truffle 的情况下创建智能合约,但 Truffle 会让这一过程变得更加简单。没有 Truffle,您仍然可以编写智能合约,但是要编译它,您必须使用特定标志启动 Solidity 编译器。然后,为了将这个智能合约部署到区块链上,您必须创建一个脚本将字节码发送到区块链。有了 Truffle,为了编译,您可以调用truffle compile命令,为了将智能合约部署到区块链上,您可以在编写一个简单的迁移脚本后调用truffle migrate命令。Truffle 还为您提供了一个与区块链网络中的智能合约进行交互的工具。它拥有您开发智能合约所需的一切。然而,正如之前所述,我们在下一章中将不使用这个框架。

我们将从使用 Node.js 包管理器安装 Truffle 开始。在 Ubuntu Linux 中,为了全局安装 Truffle,我们必须使用sudo。正如前面所述,Truffle 是一个智能合约开发框架,包含许多工具,包括与区块链网络和开发区块链软件进行交互的控制台应用程序。除此之外,使用 Truffle,您还会得到 Solidity 编译器。

但首先,您需要确保npm在您的主目录中全局安装软件:

$ mkdir ~/.npm-global
$ npm config set prefix '~/.npm-global' 

然后将这行添加到~/.配置文件中:

export PATH=~/.npm-global/bin:$PATH

现在,打开一个新的终端,以便新的配置文件生效,或者,可以按照以下步骤操作:

$ source ~/.profile

然后,我们可以按照以下步骤安装 Truffle:

$ npm install -g truffle
$ truffle version
Truffle v5.0.2 (core: 5.0.2)
Solidity v0.5.0 (solc-js)
Node v10.15.0 

安装 Ganache

开发智能合约时,人们通常使用 Ganache。Ganache 是一个私有的、开发中的以太坊网络,您只能在以太坊的开发阶段使用它。Truffle 框架已经包含了与 Ganache 相同目的的以太坊区块链网络。两者之间的区别在于 Ganache 有一个前端 GUI 和更加用户友好的界面。

启动 Ganache 时,您将配备 10 个帐户,每个帐户都有 100 个以太币,这是以太坊区块链中的货币。正如您将在本章后面看到的,以太坊编程中的货币概念(如持有、发送和接收货币以及跟踪余额)是重要的。您需要花钱才能在以太坊区块链上启动智能合约。您可以从一个帐户向智能合约发送资金,智能合约反过来可以向其他智能合约或其他帐户发送资金。

要下载软件,请访问 Ganache 网站:www.truffleframework.com/ganache。对于 Linux 平台,该软件称为ganache-1.2.3-x86_64.AppImage。下载后,您必须在执行之前设置正确的权限:

$ chmod a+x ganache-1.2.3-x86_64.AppImage
$ ./ganache-1.2.3-x86_64.AppImage

编写智能合约

安装所有必需的软件后,我们可以开始编写智能合约。首先,我们将创建一个新目录,然后使用 Truffle 开发工具对其进行初始化:

$ mkdir my_first_smart_contract
$ cd my_first_smart_contract
$ truffle init

truffle init命令的输出如下:

这将命令 Truffle 初始化您的目录以成为智能合约开发项目。在该项目目录中开发智能合约时,有几个目录可供您使用:

$ ls
contracts migrations test truffle-config.js

通常,您会将智能合约的源代码合并到contracts文件夹中。migrations文件夹包含用于部署智能合约的文件,test文件夹包含test文件。您可以在truffle-config.js文件中配置智能合约部署设置。我们将使用以下代码创建第一个智能合约并将其命名为donation.sol

pragma solidity ⁰.5.0;

contract Donation {
  address public donatur;
  address payable donatee;
  uint public money;
  string public useless_variable;

  constructor() public {
    donatee = msg.sender;
    useless_variable = "Donation string";
  }

  function change_useless_variable(string memory param) public {
    useless_variable = param;
  }

  function donate() public payable {
    donatur = msg.sender;
    money = msg.value;
  }

  function receive_donation() public {
    donatee.transfer(address(this).balance);
  }
}

如果您是智能合约的新手,前面的示例中可能会有一些陌生的关键字。在本章中,我们不打算讨论与 Solidity 有关的所有内容。相反,我们只会研究构建智能合约和学习智能合约概念所必需的 Solidity 功能。

但首先,让我们将这个用 Solidity 编写的智能合约编译成以太坊字节码和应用程序二进制接口abi)。为此,我们将在 Truffle 项目目录中运行以下命令:

$ truffle compile

编译的结果可以在build/contracts文件夹中看到,名为Donation.json

如果您打开该文件,您会看到许多有趣的东西。这个.json文件有 1530 行长。此文件中的json对象有 14 个键。您现在只需要考虑两个键。第一个是接口(称为abi),第二个是可以在以太坊虚拟机上执行的二进制文件(称为bytecode)。有关本节中代码的代码文件,请参考以下 GitLab 链接:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_02/my_first_smart_contract/build/contracts/Donation.json

我们无法像编译 C 代码并直接执行二进制文件时那样运行此二进制文件。我们需要将此二进制文件放入以太坊虚拟机中。接口本身是我们在开发去中心化应用程序时以后与智能合约进行交互所需的。当您将智能合约部署到以太坊区块链时,您需要字节码。当您想要与已在以太坊区块链上部署的智能合约进行交互时,您需要abi接口。

将智能合约部署到以太坊区块链

以下是使用 Truffle 将智能合约部署到以太坊区块链的步骤:

  1. 编写迁移脚本:要部署您的智能合约,您需要编写一个迁移文件。创建一个名为migrations/2_deploy_donation.js的新文件。然后,我们用以下脚本填充这个文件:
var Donation = artifacts.require("./Donation.sol");

module.exports = function(deployer) {
  deployer.deploy(Donation);
};

至于migrations/1_initial_migration.jscontracts/Migrations.sol文件,我们暂时保持它们不变。Truffle 需要这些文件才能部署智能合约。

  1. 启动 Ganache(以太坊开发的区块链):现在您需要启动 Ganache。假设您已经获得了适当的权限,运行以下命令行来执行文件:
./ganache-1.2.3-x86_64.AppImage

如下截图所示,您有多个帐户,每个帐户的余额为 100 以太币:

从 Ganache 屏幕上您会注意到的一件事是RPC SERVER,它位于http://127.0.0.1:7545。这是您的以太坊区块链位于Truffle项目目录中的地方。

  1. 编辑 Truffle 配置文件:如果您打开truffle-config.js文件,在删除注释行后,代码将如下所示:
module.exports = {
  networks: {
  },
  mocha: {
  },
  compilers: {
    solc: {
    }
  }
};

清除它,并将以下代码添加到truffle-config.js文件中:

module.exports = {
  networks: {
    "development": {
      network_id: 5777,
      host: "localhost",
      port: 7545
    },
  }
};

hostport是从 Ganache 屏幕上的 RPC 服务器中获取的,network_id是从 Ganache 屏幕上的 Network ID 中获取的。

  1. 执行迁移脚本:要部署您的智能合约,您可以按照以下方式执行它:
$ truffle migrate

Truffle 框架将获取您在Donation.json文件中定义的字节码,并将其发送到以太坊区块链或 Ganache。这将为您提供以下输出:

2_deploy_donation.js部分,注意单词contract address:后面的十六进制数字,即0x3e9417399786347b6ab38f59d3f00829d6bba7b8。这是智能合约的地址,类似于 Web 应用程序的 URL。

如果您尝试部署智能合约时输出了Network is up to date.,您可以删除build/contracts目录中的文件,并使用truffle migrate命令运行这个版本:

$ truffle migrate --reset

现在,让我们来看看 Ganache 屏幕上的变化:

最重要的一点是,第一个帐户0xb105F01Ce341Ef9282dc2201BDfdA2c26903da77失去了钱。余额不再是 100 ETH,而是**99.98 ETH**。那么 0.02 ETH 去哪了?矿工需要有动力才能将您的智能合约写入以太坊区块链。请注意,当前区块不再是 0,而是 4。因此,0.02 ETH 将是成功将包含您的智能合约的区块放入区块链的矿工的费用。但是,在这种情况下,当然没有矿工,因为我们使用的是以太坊开发区块链 Ganache。Ganache 只是通过将智能合约纳入区块链来模拟交易费用。

如果您点击“TRANSACTIONS”选项卡,您将看到类似于这样的内容:

您现在已经创建了两个合约(Donation 和 Migrations)。一旦部署了智能合约,除非您应用了关闭它的方法,否则它将永远存在于区块链中。如果您的智能合约中有错误,您无法修补它。您必须在不同的地址部署一个修复后的智能合约。

与智能合约交互

要与驻留在以太坊区块链中的智能合约进行交互,在您的Truffle项目目录中执行以下命令:

$ truffle console

然后,在truffle控制台提示符中,执行以下命令:

truffle(development)> Donation.deployed().then(function(instance) { return instance.useless_variable.call(); });
'Donation string'

如果您对then感到困惑,Truffle 控制台使用回调的概念,通过它执行访问智能合约对象是异步执行的。在 Truffle 控制台中,此语句在回调被执行之前立即返回。在回调函数中,您将接受智能合约实例作为instance参数。然后,我们可以从这个instance参数中访问我们的useless_variable变量。然后,要检索值,我们必须在该变量上执行call方法。

Truffle 框架将使用Donation.json文件中定义的abi来了解您的智能合约中有哪些接口可用。请记住,在您的智能合约中定义了useless_variable并将其设置为构造函数(或初始化)函数中的Donation string。以这种方式读取公共变量是免费的;因为它存储在区块链中,所以不需要任何以太币。

让我提醒您,如果变量存储在区块链中意味着什么。如果您将此智能合约纳入以太坊生产区块链,useless_variable变量将存储在每个以太坊节点中。在撰写本文时,大约有 10,000 个节点。这个数字会不断变化,可以在这里看到:www.ethernodes.org。一个节点可以在一台计算机上,一台计算机可以容纳几个节点。但是,一台计算机很可能只持有一个节点,因为成为节点主机的要求非常高。如果您想与区块链交互,就需要一个节点(也有其他选项,例如使用 API 与他人的节点进行交互)。因此,免费读取useless_variable变量,因为您只是从自己的计算机上读取它。

如果您对这个免费概念感到困惑,让我们通过将useless_variable变量更改为其他内容来使其更清晰:

truffle(development)> Donation.deployed().then(function(instance) { return instance.change_useless_variable("sky is blue", {from: "0xb105F01Ce341Ef9282dc2201BDfdA2c26903da77" }); });

您将获得以下输出:

在单词from后面还有另一个神秘的十六进制数字,即0xb105F01Ce341Ef9282dc2201BDfdA2c26903da77。这是 Ganache 中第一个账户的公共地址。您可以通过查看 Ganache 屏幕来确认。在这里,您读取useless_variable变量的方式有所不同,并且使用不同的内容进行设置。更改变量的内容需要不同的语法,更重要的是需要一个要使用的账户。需要账户是因为在区块链中更改变量时需要花一些钱。当您在以太坊生产区块链中的智能合约中更改useless_variable变量的值时,基本上是向以太坊生产区块链中的所有以太坊节点广播,该区块链中有大约 10,000 个可用于更新useless_variable内容的节点。我们正在使用 Ganache,这是以太坊开发区块链,但在生产环境中,您需要使用私钥签署交易以更改变量的内容。私钥的作用类似于账户上的密码,但私钥无法更改,而您可以随意更新密码。如果您忘记密码,可以重置密码并单击确认电子邮件中的链接进行更新。在区块链中,这是不可能的。

如果您现在检查 Ganache,您的余额保持不变;只有区块编号从 4 增加到 5:

这是因为所需的金额非常小。您可以在执行最后一个命令后查看更改useless_variable值的输出。查看使用的燃气字段;这是在智能合约中执行函数时所花费的。使用的燃气量为 33684,但这是以 gwei 计算的,而不是以以太计算的。1 以太等于 1,000,000,000 gwei,因此大约为 0.00003 以太。在这种情况下,燃气是自动计算的,但以后,您可以设置在以太坊区块链中执行任何函数时要分配多少燃气。如果您没有贡献太多以太,并且分配的燃气量很小,那么您的执行很有可能会被赋予较低的优先级。交易确认所需的时间会更长(意味着变量的值已经被更改)。如果网络遇到大量交通,它可能会被矿工拒绝。

花钱改变程序状态的概念是新的。从区块链中读取所有内容是免费的,因为所有数据都在您的计算机上(如果您有以太坊节点),但是要更改或添加区块链中的内容需要花钱。这是因为您更改了全球各地的所有以太坊节点中的数据,这是昂贵的!除了更改智能合约的状态外,内存中运行的计算也需要花钱。

向智能合约发送以太币

现在,让我们向智能合约发送一些以太币。让我们使用第二个帐户。第二个帐户希望使用智能合约捐赠 5 个以太币,如下所示:

truffle(development)> Donation.deployed().then(function(instance) { return instance.donate({ from: "0x6d3eBC3000d112B70aaCA8F770B06f961C852014", value: 5000000000000000000 }); });

您将获得以下输出:

除了from字段外,您还需要添加一个value字段。在这个value字段中,您输入要发送到智能合约的金额。您可能会想知道为什么这个数字有这么多个零。在以太坊区块链中转移资金时,您必须使用最低的货币单位(类似于从美元转换为美分),这称为wei。1 以太是 1,000,000,000,000,000,000 wei(18 个零)。您想发送 5 个以太,使其为 5,000,000,000,000,000,000。现在,如果您查看 Ganache 屏幕,您会注意到余额下降到 95 个以太。因此,5 个以太现在存储在智能合约中,如下截图所示:

让我们使用第一个帐户提取这笔款项:

truffle(development)> Donation.deployed().then(function(instance) { return instance.receive_donation({ from: "0xb105F01Ce341Ef9282dc2201BDfdA2c26903da77" }); });

您将获得以下输出:

这个执行与以前的语法相同,减去了value字段。现在看一下 Ganache 屏幕。第一个帐户有 104 到 105 个以太币(例如 104.8 或 104.9 个以太币)。不是 105 个以太币,因为我们已经在使用第一个帐户启动智能合约时支出了一些钱,并且在执行智能合约中的一些功能时支付了交易费用:

为什么智能合约?

您现在已经看到了智能合约的运行。那么这一切是怎么回事?智能合约能做什么传统程序(普通 Web 应用程序)做不到的?当涉及到更改网络上的程序变量的值时,我们可以使用远程过程调用。更常见的是,我们可以将变量存储在数据库中,人们可以从 Web 应用程序中更改数据库中的值。在发送资金时,我们可以将我们的 Web 应用程序与 Stripe 或 PayPal 集成,从而使我们能够发送资金。或者,您可以创建一个用于存储数字货币的数据库表。以太坊中的以太币基本上是数字货币。实际上,普通的 Web 应用程序可以做任何智能合约可以做的事情,但速度更快,成本更低。区块链解决方案的关键区别在于可以无需信任。这意味着您可以信任程序本身,而不是操作员。

在正常的 Web 应用程序中,我们依靠对 Web 应用程序的运营者(开发者或系统管理员)的信任。我们希望他们诚实地开发和部署 Web 应用程序。作为 Web 应用程序的用户,我们无法确保 Web 应用程序是否真的做到了它告诉大家它正在尝试做的事情。

假设我们有一个托管视频的 Web 应用程序(例如 YouTube 或 Vimeo)。如果用户点击“喜欢”按钮,Web 应用程序可以增加视频的喜欢数。规则是用户只能给视频点赞一次。因此,你期望包含 400 个喜欢的视频有 400 个点赞的用户。如果我告诉你,在幕后,系统管理员可以人为地增加喜欢数呢?这意味着在 400 个喜欢中,可能只有 300 个来自真实用户,另外 100 个是系统管理员人为增加的。这并不一定是直接更新数据库中的表,例如通过UPDATE video_likes SET likes_amount = 400 WHERE video_id = 5;。增加喜欢数的方式可能被嵌入在系统内部。

普通用户可能不会注意到这一点。Web 应用程序的公司可以在 GitHub 上发布源代码。但是,如何确保 Web 应用程序确实是从 GitHub 托管的源代码构建的呢?如果在部署 Web 应用程序后,系统管理员或开发者对系统进行了修补怎么办?

有许多方法可以防止这种数字作弊。首先,我们可以引入 IT 审计员。他们可以来自政府或非政府组织。他们将审计系统的源代码,更重要的是,检查代码在生产系统中的运行情况。在这种情况下,你将把你的信任从开发者或系统管理员转移到第三方审计员。

作为一名 IT 顾问,我通过构建 Web 应用程序和移动应用程序谋生。我曾遇到一个潜在客户,他想要制作一份数字报纸(类似于《卫报》或《纽约时报》)。这位客户问我是否有办法将任何文章移动到最受欢迎的文章部分。这样做的动机是为了推广特定文章,即使这意味着系统会向用户撒谎关于最常被浏览的文章是什么。

Reddit 的 CEO 最近因在网站上修改关于他的评论而发表了公开道歉。更多信息请参阅以下链接:techcrunch.com/2016/11/23/reddit-huffman-trump/

即使是知名的网站也会玩数字作弊。例如,一些加密货币交易所伪造交易量。更多信息请参阅以下链接:medium.com/@sylvainartplayribes/chasing-fake-volume-a-crypto-plague-ea1a3c1e0b5e

智能合约是防止数字作弊的另一种方式。智能合约的开发者可以发布源代码并将智能合约部署到以太坊区块链中。人们可以验证发布的源代码和部署的智能合约是否一致。开发者可以在智能合约中创建欺骗的方法,但用户可以从区块链中智能合约的字节码重构智能合约的源代码,从而发现这种行为。开发者无法修补已经部署的智能合约。

还有其他智能合约属性,比如自治和抗审查。然而,没有任何属性能比得上它的透明性。或者说,你必须拥有比所有 10,000 个节点一起工作更强大的力量来保护以太坊系统的可信度。简单来说,你需要购买超过 5,000 台配备高端 GPU 的计算机。假设你有这么多资源来作弊。当你在以太坊系统中作弊时,诚实的矿工会在互联网上发出警报。因此,如果你有作弊的手段,你无法偷偷摸摸地做。此外,如果你有手段获得 5,000 台配备高端 GPU 的计算机,你可以成为以太坊平台上的矿工并获得丰厚的收入。因此,在以太坊平台上作弊是非常困难的。

总结

在这一章中,我们探讨了如何安装 Solidity 开发工具:Node.js,Truffle 或 Ganache。然后我们学习了如何编写智能合约并对其进行编译。之后,我们看了一下将智能合约部署到以太坊区块链的概念,然后使用 Truffle 控制台工具与已部署的智能合约进行交互。我们对向智能合约发送以太币以及在执行智能合约中的功能时的燃气使用概念有了了解。在最后一步,我们熟悉了为什么智能概念如此成功,具有创建一个无法绕过的、透明的程序的潜力。

在下一章中,我们将学习如何使用 Vyper 实现智能合约。

进一步阅读

以太坊黄皮书:ethereum.github.io/yellowpaper/paper.pdf

以太坊白皮书:github.com/ethereum/wiki/wiki/White-Paper

第三章:使用 Vyper 实现智能合约

许多学习如何编写智能合约的程序员会学习 Solidity 编程语言。有丰富的在线教程和书籍可以教你 Solidity。与 Truffle 框架结合使用,Solidity 形成了一个开发智能合约的绝佳组合。几乎所有存在于以太坊区块链上的智能合约都是用 Solidity 编程语言编写的。

在本章中,我们将探讨如何编写智能合约。但我们不会使用 Solidity 编程语言。相反,我们将使用 Vyper 编程语言。

本章将涵盖以下主题:

  • Vyper 背后的动机

  • 安装 Vyper

  • 使用 Vyper 创建智能合约

  • 将智能合约部署到 Ganache

  • 深入了解 Vyper

  • 与其他智能合约互动

  • 以编程方式编译代码

  • 其他技巧

Vyper 背后的动机

编写智能合约与开发普通的 Web 应用程序是不同的。在开发普通的 Web 应用程序时,座右铭是“快速移动,打破事物”。开发 Web 应用程序的速度至关重要。如果应用程序中有错误,你可以随后升级应用程序。或者,如果错误是灾难性的,你可以在线修补它或在引入修复之前将应用程序下线。有一个非常流行的词来描述开发普通 Web 应用程序的理想思维方式——敏捷。你需要灵活,以便随着需求的变化改变软件。

然而,编写智能合约需要不同的思维方式。智能合约的应用范围可以从编写金融应用程序到将火箭发射到太空。一旦部署了智能合约,要修复错误就非常困难。你不能替换智能合约,因为一旦部署了,就无法更改。如果编写一个函数来销毁智能合约,你可以销毁智能合约,但修复有问题的智能合约的唯一方法是部署一个新的智能合约,在新的地址中修复错误,然后将这种情况通知所有相关方。但你不能替换智能合约。

因此,理想的情况是在区块链上部署一个没有错误的智能合约,或者至少没有恶性错误。然而,在现实世界中发布的智能合约仍然会出现错误。

那么,智能合约中可能出现什么样的错误?第一种是会让你的资金消失的错误。比如,你正在为一个首次代币发行ICO)编写智能合约。ICO 是通过在以太坊区块链上出售你创建的代币来积累资本。基本上,人们用以太币购买你的代币。你可以自行设置价格,例如1 ETH = 100 YOURTOKEN。这意味着人们如果支付给你 1 个以太币,他们将获得 100 个你的代币。

你可能引入的第一个错误是人们可以向你的智能合约发送资金(以太币),但你无法提取它(要么你忘记实现提取方法,要么提取方法有问题)。这意味着你可以检查你的智能合约余额,以太币余额可能值得 100 万美元,但它将永远被困在那里,没有人能够索取它。

另一个错误可能是您忘记保护销毁智能合约的方法。在以太坊中,您有动机从区块链中删除东西,因为存储是昂贵的。因此,如果您部署了一个智能合约,您将支付燃气费,因为您的智能合约将被保留。您可以对其进行实验,然后如果您对您的智能合约感到厌倦,您可以销毁它。为此,以太坊将返还一些燃气到您的账户。这是为了阻止垃圾邮件攻击以太坊区块链。因此,回到我们智能合约错误的案例,想象一下您在智能合约中积累了价值 100 万美元的以太币,然后有人通过访问销毁它的功能销毁了您的智能合约账户。在这种情况下,您的以太币余额也将被销毁。

最后一种错误是允许黑客窃取您的以太坊余额并将其转移到他们的账户。这可能发生在许多不同的情况下。例如,也许您忘记在提款功能中设置正确的权限,或者提款功能中的权限太开放。

当然,所有这些错误都可以追溯到程序员的错误。为了避免这些错误,一个新的工作岗位诞生了——智能合约审计员,他审计您的智能合约,以确保它没有错误。然而,以太坊的发明者 Vitalik Buterin 随后审视了工具(在这种情况下是编程语言),并想知道是否可以通过改进工具本身来缓解这种情况。这种情况的罪魁祸首是 Solidity 编程语言。Vitalik 认为 Solidity 具有一些功能是强大的,但可能会产生错误。尽管 Solidity 的开发人员有改进 Solidity 安全性的计划,但 Vitalik 希望有一些自由来尝试新的视角。Vyper 就是从这里诞生的。

假设您创建了一个具有重要功能的父类。在当前或子类中,您使用此功能而不检查其定义。也许父类是由您团队中的其他人编写的。程序员有时懒得检查其他文件中的函数定义;他们会在源代码文件中上下滚动以阅读代码,但程序员通常不会检查继承功能启用的其他文件中的代码。

另一个可能使智能合约复杂且难以阅读的 Solidity 功能是修饰符,它类似于一个预备函数。以下代码显示了修饰符在 Solidity 中的使用方式:

modifier onlyBy(address _account)
{
  require(msg.sender == _account, "Sender not authorized.");
  _;
}
function withdraw() public onlyBy(owner)
{
  //withdraw money;
}

如果我们想使用withdraw()方法,智能合约将首先执行onlyBy()修饰符方法。使用require短语是为了确保调用此方法的msg.sender与作为参数发送的account变量相同。这个例子很简单。您可以一眼看完所有的代码。然而,请考虑这些函数是由许多行分隔开的,甚至在另一个文件中定义。程序员往往会忽视onlyBy()方法的定义。

函数重载是编程语言中最强大的功能之一。这是一个使您能够发送不同参数以获得不同函数的功能,如下面的代码所示:

    function flexible_function(uint _in) public {
        other_balance = _in;
    }

    function flexible_function(uint _in, uint _in2) public {
        other_balance = _in + _in2;
    }

    function flexible_function(uint _in, uint _in2, uint _in3) public {
        other_balance = _in + _in2 - _in3;
    }

然而,函数重载功能可能会误导程序员,导致他们执行不同意图的函数。程序员可能只记得flexible_function函数是这样做的,但可能会无意中执行与flexible_function不同类型的函数。

因此,一些聪明的人决定,虽然所有这些功能使得创建一个非常复杂的程序成为可能,但这些功能应该被限制在开发智能合约上。也许他们从太空飞船上编写程序的人那里得到了这个想法,在那里有规定禁止使用 C++的某些功能。或者,他们可能受到了 Java 为何被创建来取代 C++的启发。在 Java 中,不可能直接操纵内存。C++的创造者 Bjarne Stroustoup 说,C++是如此强大,以至于人们可以用 C++自己打自己的脚。

这些聪明的人决定创建一种比 Solidity 更简单的新编程语言。Python 是他们的主要灵感,因为这种编程语言的语法源自 Python。这种编程语言被称为Vyper。在 Vyper 中,诸如继承、函数重载、修饰符等功能都被移除了。Vyper 编程语言的创造者认为,移除这些功能可以使智能合约的开发更容易。重要的是,它还使代码更易于阅读。代码的阅读远远多于编写。考虑到所有这些因素,他们希望程序员在使用 Vyper 编程语言创建智能合约时能够减少错误。

安装 Vyper

默认情况下,Ubuntu Xenial 安装了 Python 3.5。Vyper 需要 Python 3.6 软件,因此如果您想使用 Ubuntu Xenial,您需要首先安装 Python 3.6。较新版本的 Ubuntu,如 Bionic Beaver,将已经安装了 Python 3.6。

因此,如果您没有安装 Python 3.6 软件,您必须首先使用以下命令安装它:

$ sudo apt-get install build-essential
$ sudo add-apt-repository ppa:deadsnakes/ppa
$ sudo apt-get update 
$ sudo apt-get install python3.6 python3.6-dev

Vyper 需要的不仅仅是 Python 3.6;您还需要安装开发文件python3.6-dev。然后,您可以通过以下步骤为 Python 3.6 创建一个虚拟环境:

  1. 首先,您必须使用以下代码安装virtualenv工具:
$ sudo apt-get install virtualenv
  1. 然后,使用以下代码为 Python 3.6 创建一个虚拟环境:
$ virtualenv -p python3.6 vyper-venv
  1. 现在,按照以下方式执行虚拟环境脚本:
$ source vyper-venv/bin/activate
  1. 接下来,使用以下命令安装 Vyper:
(vyper-venv) $ pip install vyper
  1. 如果这里没有任何错误,您就可以开始了。您可以按照以下方式测试 Vyper 编译器:
(vyper-venv) $ vyper --version
0.1.0b6

然后,您就准备好迈向下一个步骤了。

使用 Vyper 创建智能合约

现在让我们使用 Vyper 创建一个智能合约。首先,我们将创建一个扩展名为.vy的文件,并将其命名为hello.vy,如下所示:

name: public(bytes[24])

@public
def __init__():
    self.name = "Satoshi Nakamoto"

@public
def change_name(new_name: bytes[24]):
    self.name = new_name

@public
def say_hello() -> bytes[32]:
    return concat("Hello, ", self.name)

如果您来自 Solidity 或 Python 背景,您会注意到一个奇特之处:在使用 Vyper 编程语言编写的智能合约中,没有类(如 Python 编程语言中)和合约(如 Solidity 编程语言中)的概念。但是,有一个initializer函数。initializer函数的名称与 Python 编程语言中的__init__相同。

在 Python 中,您可以在一个文件中创建任意多的类。在 Vyper 中,规则是一个文件对应一个智能合约。这里也没有类或合约;文件本身就是一个类。

这是如何编译这个vyper文件的:

(vyper-venv) $ vyper hello.vy

从中,您将得到以下输出:

这是智能合约的字节码。请记住,要部署智能合约,您需要字节码,但要访问智能合约,您需要abi。那么如何获得abi?您可以通过运行以下命令来实现:

(vyper-venv) $ vyper -f json hello.vy

从中,您将得到以下输出:

如果您想在单个编译过程中同时获得abibytecode,您可以在编译过程中结合这两个标志,如下所示:

(vyper-venv) $ vyper -f json,bytecode hello.vy

这将给你以下输出:

将智能合约部署到 Ganache

那么,如何将这个智能合约部署到以太坊区块链上呢?有几种方法可以做到这一点,但让我们使用 Truffle 的一种熟悉的方式:

  1. 创建一个目录,并使用truffle init进行初始化,如下所示:
$ mkdir hello_project
$ cd hello_project
$ truffle init
  1. 就像您在上一章中所做的那样,将truffle-config.js设置为以下内容:
module.exports = {
  networks: {
    "development": {
      network_id: 5777,
      host: "localhost",
      port: 7545
    },
  }
};
  1. 创建一个build目录,如下所示:
$ mkdir -p build/contracts
$ cd build/contracts
  1. 然后在那里创建一个Hello.json文件,如下所示:
{
  "abi":
  "bytecode":
}
  1. 然后将abi字段填充为编译过程的abijson输出,并将bytecode字段填充为编译过程的bytecode输出。您需要用双引号引用bytecode值。不要忘记在abi字段和bytecode字段之间放置逗号。这将给您类似于以下内容:
{
  "abi": [{"name": "__init__", "outputs": [], "inputs": [], "constant": false, "payable": false, "type": "constructor"}, {"name": "change_name", "outputs": [], "inputs": [{"type": "bytes", "name": "new_name"}], "constant": false, "payable": false, "type": "function", "gas": 70954}, {"name": "say_hello", "outputs": [{"type": "bytes", "name": "out"}], "inputs": [], "constant": false, "payable": false, "type": "function", "gas": 8020}, {"name": "name", "outputs": [{"type": "bytes", "name": "out"}], "inputs": [], "constant": true, "payable": false, "type": "function", "gas": 5112}],
  "bytecode": "0x600035601c52740100000000000000000000000000000000000000006020526f7fffffffffffffffffffffffffffffff6040527fffffffffffffffffffffffffffffffff8000000000000000000000000000000060605274012a05f1fffffffffffffffff...
...
1600101808352811415610319575b50506020610160526040610180510160206001820306601f8201039050610160f3005b60006000fd5b61012861049703610128600039610128610497036000f3"
}
  1. 然后,您可以创建一个迁移文件来部署这个智能合约,方法是在migrations/2_deploy_hello.js中创建一个新文件,如下所示:
var Hello = artifacts.require("Hello");
module.exports = function(deployer) {
  deployer.deploy(Hello);
};

一切设置好之后,启动 Ganache!

  1. 然后,在hello_project目录中,您可以运行迁移过程,如下所示:
$ truffle migrate

您将看到类似于以下内容:

您使用 Vyper 编写的智能合约已经部署到了 Ganache。您的智能合约地址如下:

0x3E9417399786347B6Ab38f59d3f00829d6bba7b8

与智能合约交互

就像之前一样,您可以使用 Truffle 控制台与您的智能合约交互,如下所示:

$ truffle console

您的智能合约始终被赋予名称Contract。我们可以使用以下语句访问智能合约:

truffle(development)> Contract.at("0x3E9417399786347B6Ab38f59d3f00829d6bba7b8")

您将得到一个长输出,其中您可以看到abibytecode等,如下面的截图所示:

让我们使用以下语句查看智能合约的name变量的值:

truffle(development)> Contract.at("0x3E9417399786347B6Ab38f59d3f00829d6bba7b8").then(function(instance) { return instance.name.call(); });
'0x5361746f736869204e616b616d6f746f'

您可能注意到,神秘的输出看起来并不像 Satoshi Nakamoto。但实际上,这确实是 Satoshi Nakamoto,只是用十六进制写的。让我们丢弃0x,这只是一个指示器,表明这个字符串是十六进制形式。现在您有了5361746f736869204e616b616d6f746f字符串。取前两个数字,即53,并将其转换为十进制数。在 Python 中,您可以这样做:

>>> int(0x53)
83

因此,十进制数为83。您还记得 ASCII 表吗?这是一个数据表,保存了十进制数和字符之间的关系。因此,十进制数65代表字符 A(大写 A),十进制数66代表字符 B(大写 B)。

那么十进制数83的字符是什么?您可以使用 Python 来找出,如下所示:

>>> chr(83)
'S'

如果您对每个十六进制字符都这样做,其中每个十六进制字符占两个数字字符,它将拼写出 Satoshi Nakamoto。

让我们使用以下代码在这个智能合约中执行另一个方法:

truffle(development)> Contract.at("0x3E9417399786347B6Ab38f59d3f00829d6bba7b8").then(function(instance) { return instance.say_hello.call(); })
'0x48656c6c6f2c205361746f736869204e616b616d6f746f'

那个神秘的输出只是Hello, Satoshi Nakamoto

让我们按照以下方式更改名称:

truffle(development)> Contract.at("0x3E9417399786347B6Ab38f59d3f00829d6bba7b8").then(function(instance) { return instance.change_name(web3.utils.fromAscii("Vitalik Buterin"), { from: "0x6d3eBC3000d112B70aaCA8F770B06f961C852014" }); });

您将得到以下输出:

from字段中的值来自 Ganache 中的一个账户。您可以查看 Ganache 窗口并选择任何您喜欢的账户。

我们不能直接将字符串发送到change_name方法;我们必须首先使用web3.utils.fromAscii方法将其转换为十六进制字符串。

现在名称已经更改了吗?让我们找出来。运行以下命令:

truffle(development)> Contract.at("0x3E9417399786347B6Ab38f59d3f00829d6bba7b8").then(function(instance) { return instance.name.call(); });
'0x566974616c696b204275746572696e'

是的,名称已经更改了。如果您将该十六进制字符串转换为 ASCII 字符串,您将得到 Vitalik Buterin。

深入了解 Vyper

让我们来看看我们的智能合约:

name: public(bytes[24])

@public
def __init__():
    self.name = "Satoshi Nakamoto"

@public
def change_name(new_name: bytes[24]):
    self.name = new_name

@public
def say_hello() -> bytes[32]:
    return concat("Hello, ", self.name)

看一下第一行:

name: public(bytes[24])

字节数组基本上就是一个字符串。名为name的变量具有bytesstring数组类型。它的可见性是public。如果要将其设置为private,只需省略 public 关键字,如下所示:

name: bytes[24]

现在,看一下接下来的几行:

@public
def __init__():
    self.name = “Satoshi Nakamoto”

如果您来自 Python 背景,那么您将认识到 Python 装饰器函数。Vyper 中有四个这样的函数:

  • @public表示您可以像用户一样执行此方法(就像在上一章中的 Truffle 控制台中一样)。

  • @private表示只有相同智能合约内的其他方法才能访问此方法。您不能作为用户(在 Truffle 控制台中)调用该方法。

  • @payable表示您可以向此方法发送一些以太币。

  • @const表示此方法不应更改智能合约的状态。这意味着执行此方法不会花费以太币。这就像读取公共变量的值。

回到__init__()方法,您可以像这样向该方法传递参数:

i: public(uint256)

@public
def __init__(int_param: uint256):
    self.i = int_param

在部署智能合约时不要忘记发送参数。在我们的情况下,我们在 Truffle 软件中使用迁移,因此修改您的迁移文件2_deploy_hello.js如下:

var Hello = artifacts.require("Hello");
module.exports = function(deployer) {
    deployer.deploy(Hello, 4);
};

让我们继续阅读智能合约的以下行,以了解public方法:

@public
def change_name(new_name: bytes[24]):
    self.name = new_name

此方法修改了智能合约的状态,即name变量。这将产生燃气。

让我们继续阅读智能合约的下一行,以了解在public方法中返回值的情况:

@public
def say_hello() -> bytes[32]:
    return concat("Hello, ", self.name)

concat是一个内置函数,用于组合字符串。请参考vyper.readthedocs.io/en/latest/built-in-functions.html获取完整的内置函数列表。

您必须小心方法的返回值,该值由右箭头(→)指示。例如,看一下以下代码:

@public
def say_hello() -> bytes[28]:
    return concat("Hello, ", self.name)

在这种情况下,尽管“Hello,Satoshi Nakamoto”肯定少于 28 个字符,但它在编译时会失败。该字符串的长度为 23 个字符;但是,您必须记住self.name被定义为bytes[24],而Hello,的长度为 7 个字符。因为 24 + 7 是 31 个字符,所以您必须将其设置为更大的数组。

由于此方法不会更改此智能合约的状态,因此您可以在此方法的顶部添加@const,如下所示:

@public
@const
def say_hello() -> bytes[32]:
    return concat("Hello, ", self.name)

数据类型

让我们创建一个更复杂的智能合约,并将其命名为donation.vy,如下所示。您可以参考以下 GitLab 链接获取完整代码:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_03/donation.vy

struct DonaturDetail:
    sum: uint256(wei)
    name: bytes[100]
    time: timestamp

donatur_details: public(map(address, DonaturDetail))

...
...

@public
def withdraw_donation():
    assert msg.sender == self.donatee

    send(self.donatee, self.balance)

像以前一样编译和部署智能合约。不要忘记删除build/contracts目录中的所有文件,并且如果您重用项目目录,则重新启动您的 Ganache。

请看以下行:

struct DonaturDetail:
    sum: uint256(wei)
    name: bytes[100]
    time: timestamp

让我们逐一讨论 Vyper 数据类型:

  • 结构:第一个称为结构。Vyper 中的结构就像另一种编程语言中的结构一样;它是不同数据类型的容器。您可以按以下方式访问其成员:
DonaturDetail.name = "marie curie"
  • Wei:我们要了解的第二个数据类型是uint256(wei)。这指的是可以持有的特定以太币金额。正如您所知,1 个以太币等于 1,000,000,000,000,000,000 wei(18 个零)。为了持有如此大的金额,需要特定的数据类型。

  • 时间戳:第三个数据类型是timestamp数据类型。这是设计用来保存时间值的。

  • 地址:第四个是地址数据类型。这是设计用来保存地址值(例如0xdCad3a6d3569DF655070DEd06cb7A1b2Ccd1D3AF)。这可以是帐户或智能合约的地址。如果您想知道地址数据类型是什么样子,可以查看下面的 Ganache 截图。帐户地址是地址数据类型的示例。您可以向具有此数据类型的变量发送以太币:

  • 映射:第五个是map数据类型。这就像一个字典。一个简单的映射看起来是这样的:
simple_map: map(address, uint256)

在这里,键是address,值是uint256。这是如何向这个映射填充值的:

self.simple_map[0x9049386D4d5808e0Cd9e294F2aA3d70F01Fbf0C5] = 10

如果你习惯了 Python 中的字典数据类型,那么对于这种映射数据类型,有一个小技巧:你不能迭代这个映射。所以,不要期望像在 Python 中使用dictionary数据类型的变量那样在 Vyper 中迭代映射数据类型的变量。你可以通过查看以下代码来了解它是如何工作的:

for key in self.simple_map:
    // do something with self.simple_map[key]

以太坊虚拟机EVM)不会跟踪具有映射数据类型的变量的所有键。在 Python 中,你可以从具有字典数据类型的变量中获取所有键,如下面的代码所示:

self.simple_map.keys()

但是在 Vyper 中你不能这样做。

如果你访问一个不存在的键,它会返回值数据类型的默认值。在我们的例子中,如果我们做类似这样的操作,我们会得到0,如下面的代码所示:

self.simple_map[0x1111111111111111111111111111111111111111] => 0

如果你从来没有为0x1111111111111111111111111111111111111111键设置值,或者设置为0,都没有关系。如果你想要跟踪这些键,你需要将它们保存在一个单独的数组中。映射数据类型就像 Python 中的默认字典,如下面的代码所示:

>>> from collections import defaultdict
>>> d = defaultdict(lambda: 0, {})
>>> d['a']
0
>>> d['a'] = 0
>>> d['a']
0

所以,回到我们第二个定义的变量,让我们看一下下面的代码:

donatur_details: public(map(address, DonaturDetail))

这段代码展示了一个地址到包含weistringtimestamp数据类型的结构体的映射。我们想要用这种数据类型记录捐赠者的姓名、捐赠金额和捐赠时间。

  • 数组:第五种数据类型是数组数据类型,它没有无限大小。数组的大小必须在开始时设置。

看一下这些行:

donaturs: public(address[10])

这是一个包含 10 个地址的数组。

让我们看一下下面的行,学习如何在智能合约中保留所有者的账户:

donatee: public(address)
  • 整数:第六种数据类型是整数。它类似于uint256int128。请注意,uint256uint256(wei)是不同的。int128uint256之间的区别在于int128数据类型可以保存零、正数和负数。uint256数据类型只能保存零和正数,但它的上限比int128更高。

下面的代码将保存启动这个智能合约的人的地址:

index: int128

这是为了跟踪有多少捐赠者捐赠了。请注意,它没有一个公共修饰符。这意味着你不能从 Truffle 控制台访问这个变量。

让我们来看一下__init__()方法:

@public
def __init__():
    self.donatee = msg.sender

在每个方法中,都有一些特殊的对象。其中之一是msg。你可以通过msg.sender访问访问该方法的账户。你还可以通过msg.value找到以wei为单位的以太币的数量。在下面的代码中,我们想要保存这个智能合约的启动者的地址:

@payable
@public
def donate(name: bytes[100]):
    assert msg.value >= as_wei_value(1, "ether")
    assert self.index < 10

    self.donatur_details[msg.sender] = DonaturDetail({
                                         sum: msg.value,
                                         name: name,
                                         time: block.timestamp
                                       })

    self.donaturs[self.index] = msg.sender
    self.index += 1

在这里,@payable表示这个方法接受以太币支付。assert短语类似于 Python 编程语言中的assert。如果条件为false,那么方法的执行将被中止。在assert行之后,我们只是将self.donatur_details映射设置为一个DonaturDetail结构体,键为msg.sender。在结构体内部,你可以使用block.timestamp设置时间属性,表示当前时间。as_wei_value短语是一个内置函数。由于在这个智能合约中我们必须使用 wei 单位处理以太支付,使用这个内置函数是一个好主意。否则,你必须使用很多零,如下所示:

assert msg.value >= 1000000000000000000

提取以太币

智能合约的最后几行将是一个提取捐款到donatee账户的方法,如下面的代码所示:

@public
def withdraw_donation():
    assert msg.sender == self.donatee

    send(self.donatee, self.balance)

在这里,self.balance代表了在这个智能合约中累积的所有以太币。send短语是一个内置函数,用于将钱转移到第一个参数,也就是donatee

所以让我们在 Truffle 控制台中测试这个智能合约。确保你将方法中的地址更改为你的智能合约的地址。你可以使用 truffle migrate 命令获取它,如下所示:

truffle(development)> Contract.at("0x3E9417399786347B6Ab38f59d3f00829d6bba7b8").then(function(instance) { return instance.donatee.call(); });
'0xb105f01ce341ef9282dc2201bdfda2c26903da77'

这是 Ganache 中的第一个账户,如下面的截图所示:

让我们从 Ganache 中的第二个账户捐赠 2 个以太币,如下所示:

truffle(development)> Contract.at("0x3E9417399786347B6Ab38f59d3f00829d6bba7b8").then(function(instance) { return instance.donate(web3.utils.fromAscii("lionel messi"), {from: "0x6d3eBC3000d112B70aaCA8F770B06f961C852014", value: 2000000000000000000}); });

现在从 Ganache 中的第三个账户捐赠 3.5 个以太币,如下所示:

truffle(development)> Contract.at("0x3E9417399786347B6Ab38f59d3f00829d6bba7b8").then(function(instance) { return instance.donate(web3.utils.fromAscii("taylor swift"), {from: "0x9049386D4d5808e0Cd9e294F2aA3d70F01Fbf0C5", value: 3500000000000000000}); });

现在用以下代码查看捐赠者的捐赠情况:

truffle(development)> Contract.at("0x3E9417399786347B6Ab38f59d3f00829d6bba7b8").then(function(instance) { return instance.donatur_details__sum.call("0x9049386D4d5808e0Cd9e294F2aA3d70F01Fbf0C5"); });
<BN: 30927f74c9de0000>

你访问结构的属性的方式是在 donatur_details 结构后使用两个下划线。你将映射的键放在 call 函数中。如果你想知道 <BN: 30927f74c9de0000> 中的 30927f74c9de0000 是什么意思,它不是内存的位置,而是一个十六进制格式的数字。因为这个数字非常大(BN 是大数的缩写),EVM 必须以十六进制格式显示这个数字,如下所示:

truffle(development)> web3.utils.toBN(15);
<BN: f>
truffle(development)> web3.utils.toBN(9);
<BN: 9>
truffle(development)> web3.utils.toBN(100);
<BN: 64>
truffle(development)> web3.utils.toBN(3500000000000000000);
<BN: 30927f74c9de0000>

如果你看一下 Ganache,第二和第三个账户已经失去了一些钱,如下面的截图所示:

所以,让我们使用以下代码撤回捐赠:

truffle(development)> Contract.at("0x3E9417399786347B6Ab38f59d3f00829d6bba7b8").then(function(instance) { return instance.withdraw_donation({from: "0xb105F01Ce341Ef9282dc2201BDfdA2c26903da77"}); });

看一下你的 Ganache。在我的情况下,第一个账户有 105.48 ETH,如下面的截图所示:

其他数据类型

Vyper 还有其他数据类型,这些数据类型在捐赠智能合约中没有使用,如下面的列表所示:

  • bool:这种数据类型类似于普通的布尔值。它包含 true 或 false 值,如下面的代码所示:
bull_or_bear: bool = True
  • decimal:这种数据类型类似于 Python 中的 floatdouble,如下面的代码所示:
half_of_my_heart: decimal = 0.5
  • bytes32:这种数据类型类似于 bytes32,但有一个特点。如果值的长度小于 32 字节,它将用零字节填充。所以,如果你将 messi 值(5 个字符/字节)设置为 bytes32 数据类型变量(如下面的代码所示),它将变成 messi\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00
goat: bytes32 = convert('messi', bytes32)
  • Constant:这种数据类型在声明后不能被改变:
GOAT: constant(bytes[6]) = 'messi'

与 C++ 编程语言不同,未初始化的变量可以有垃圾值,Vyper 编程语言中所有未初始化的变量都有默认值。默认的整数数据类型值是 0。默认的布尔数据类型值是 false

有用的内置函数

你已经使用了内置函数,比如 sendassertas_wei_valueconcatconvert。然而,还有其他有用的函数,比如以下的函数:

  • sliceslice 短语是字节数据类型。它用于从字符串中获取子字符串等任务,如下面的代码所示:
first_name: bytes[10] = slice(name, start=0, len=10)
  • len:此函数用于获取值的长度,如下面的代码所示:
length_of_name: int128 = len(name)
  • selfdestruct:此函数用于销毁智能合约,如下面的代码所示。参数是这个智能合约发送其以太币的地址:
selfdestruct(self.donatee)
  • ceil:这个函数用于将整数四舍五入到上限,如下面的代码所示:
round_heart: int128 = ceil(half_of_my_heart)
  • floor:这个函数用于将整数四舍五入到下限,如下面的代码所示:
round_heart: int128 = floor(half_of_my_heart)
  • sha3:这是一个内置的哈希函数,如下面的代码所示:
secret_hash: bytes32 = sha3('messi')

事件

Vyper 支持事件。你可以在你的方法中向任何订阅者广播事件。例如,当人们用智能合约捐赠以太币时,你可以广播一个捐赠事件。要声明一个事件,你可以使用以下语句:

Donate: event({_from: indexed(address),  _value: uint256(wei)})

然后,在我们的 donate 方法中,你可以在捐赠交易发生后广播事件,如下面的代码所示:

@public
def donate(name: bytes[100]):
    log.Donate(msg.sender, msg.value)

我们将在后面的章节中更多地讨论事件。

与其他智能合约交互

你知道你的智能合约不必在那里孤独吗?你的智能合约可以与区块链上的其他智能合约进行交互。

地址数据类型不仅用于普通账户,还可以用于智能合约账户。因此,智能合约可以通过捐赠智能合约向我们的受赠人捐赠以太币!

重新启动您的 Ganache;我们将重新启动我们的区块链。还记得您的hello.vy Vyper 文件吗?我们想要部署我们的Hello智能合约并自定义名称。

我们的迁移文件migrations/2_deploy_hello.js仍然是相同的,如下所示:

var Hello = artifacts.require("Hello");
module.exports = function(deployer) {
  deployer.deploy(Hello);
};

再次编译您的hello.vy文件以获取接口和字节码。打开我们的合同 JSON 文件,build/contracts/Hello.json文件。清除所有内容并用以下代码替换它:

{
  "contractName": "Hello",
  "abi": <your Hello smart contract's interface>,
  "bytecode": "<your Hello smart contract's bytecode>"
}

您必须为您的智能合约命名,因为这次,您将部署两个智能合约。如果您不为您的智能合约命名,它将具有默认名称Contract。如果您只想部署一个智能合约,这不是问题。

然后,对于您的donation.vy,编辑它,并将以下代码行(加粗显示)添加到代码文件中(请参考以下 GitLab 链接中donation.vy的完整代码文件:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_03/donation.vy):

struct DonaturDetail:
    sum: uint256(wei)
    name: bytes[100]
    time: timestamp

contract Hello():
 def say_hello() -> bytes[32]: constant

donatur_details: public(map(address, DonaturDetail))

...
...

@public
def withdraw_donation():
    assert msg.sender == self.donatee

    send(self.donatee, self.balance)

@public
@constant
def donation_smart_contract_call_hello_smart_contract_method(smart_contract_address: address) -> bytes[32]:
 return Hello(smart_contract_address).say_hello()

请注意加粗的更改。这些更改是您要与之交互的智能合约接口的声明方式;您声明合约对象和要与之交互的方法。您不需要知道say_hello方法的实现,只需要知道接口(即它期望的参数和返回值)。

然后调用外部智能合约的donation_smart_contract_call_hello_smart_contract_method方法。将地址作为参数发送给合约对象并像往常一样调用该方法。如果您已经知道要与之交互的智能合约的地址,可以硬编码它。但我使用参数是因为我还不知道Hello智能合约的地址。

使用以下代码,为我们升级的Donation智能合约创建另一个迁移文件,migrations/3_deploy_donation.js

var Donation = artifacts.require("Donation");
module.exports = function(deployer) {
  deployer.deploy(Donation);
};

编译您的donation.vy并获取智能合约的接口和字节码。

然后,使用以下代码,为我们的Donation智能合约创建另一个合同 JSON 文件,build/contracts/Donation.json

{
  "contractName": "Donation",
  "abi": <your Donation smart contract's interface>,
  "bytecode": "<your Donation smart contract's bytecode>"
}

运行迁移。您可能需要使用--reset标志,如下所示:

$ truffle migrate --reset

您将获得以下输出:

注意Donation智能合约的地址和Hello智能合约的地址。Donation智能合约的地址是0x98Db4235158831BF9133faC1c4e1829021ecEB67Hello智能合约的地址是0xBc932d934cfE859F9Dc903fdd5DE135F32EbC20E。您的地址可能不同。

按以下方式运行 Truffle 控制台:

$ truffle console

现在我们的智能合约不再孤单,如下所示:

truffle(development)> Donation.at("0x98Db4235158831BF9133faC1c4e1829021ecEB67").then(function(instance) { return instance.donation_smart_contract_call_hello_smart_contract_method.call("0xBc932d934cfE859F9Dc903fdd5DE135F32EbC20E"); } );
'0x48656c6c6f2c205361746f736869204e616b616d6f746f'

智能合约之间交互的用例之一是创建一个去中心化交易智能合约。假设您的祖母启动了一个名为电网代币的代币智能合约,您的叔叔启动了一个名为 Wi-Fi 接入代币的代币智能合约。您可以创建一个智能合约,与电网代币和 Wi-Fi 接入代币进行交互。在您的智能合约中,您可以创建一个方法来启用这两个代币之间的交易;您只需获取它们的智能合约地址和接口。当然,您还需要编写交易的逻辑。

以编程方式编译代码

您可以创建一个脚本来编译 Vyper 代码,而不是使用命令行实用程序。确保您在包含hello.vydonation.vy的相同目录中。创建一个名为compiler.vy的脚本,如下所示:

import vyper
import os, json

filename = 'hello.vy'
contract_name = 'Hello'
contract_json_file = open('Hello.json', 'w')

with open(filename, 'r') as f:
    content = f.read()

current_directory = os.curdir

smart_contract = {}
smart_contract[current_directory] = content

format = ['abi', 'bytecode']
compiled_code = vyper.compile_codes(smart_contract, format, 'dict')

smart_contract_json = {
    'contractName': contract_name,
    'abi': compiled_code[current_directory]['abi'],
    'bytecode': compiled_code[current_directory]['bytecode']
}

json.dump(smart_contract_json, contract_json_file)

contract_json_file.close()

如果你使用以下命令执行这个脚本,你将得到一个Hello.json文件,你可以在 Truffle 中使用,如下代码所示:

(vyper-venv) $ python compiler.py

现在,让我们逐步学习这个脚本。首先,导入Vyper库和一些 Python 标准库,这样我们就可以写一个 JSON 文件,如下所示:

import vyper
import os, json

你需要一个 Vyper 文件,你想要给你的智能合约的名称,以及输出的 JSON 文件。以下代码将完成这个任务:

filename = 'hello.vy'
contract_name = 'Hello'
contract_json_file = open('Hello.json', 'w')

使用以下代码行获取 Vyper 文件的内容:

with open(filename, 'r') as f:
    content = f.read()

然后创建一个字典对象,其中键是指向你的 Vyper 文件的路径,值是 Vyper 文件的内容,如下所示:

current_directory = os.curdir

smart_contract = {}
smart_contract[current_directory] = content

要编译 Vyper 代码,你只需要使用vyper模块的compile_codes方法,如下所示:

format = ['abi', 'bytecode']
compiled_code = vyper.compile_codes(smart_contract, format, 'dict')

compile_codes方法的第一个参数是一个字典,其中键指向路径,值表示字符串中的 Vyper 代码。第二个参数是format,包括接口和字节码。第三个参数是可选的。如果你使用'dict',那么你将得到一个字典。如果你不给出第三个参数,那么你将得到一个数组。让我们看一下以下代码:

smart_contract_json = {
    'contractName': contract_name,
    'abi': compiled_code[current_directory]['abi'],
    'bytecode': compiled_code[current_directory]['bytecode']
}

因为我们使用了'dict'作为我们的第三个参数,我们得到了一个字典对象的结果。结果的键是我们的 Vyper 文件的路径。从技术上讲,你可以将它设置为任何你喜欢的字符串。一些开发人员使用文件路径来区分他们在项目目录中散布的 Vyper 文件。

最后的代码用于将结果写入输出的 JSON 文件:

json.dump(smart_contract_json, contract_json_file)

contract_json_file.close()

通过以编程方式编译 Vyper 代码,你可以在 Vyper 之上构建一个框架。在本书的后面章节中,你将使用一个名为 Populus 的框架来编译和部署 Vyper 文件。但你可能想要构建一个更好的框架,或者你可以构建一个 Vyper 集成开发环境(IDE),比如 JetBrains IDE,但用于 Vyper 编程语言。

其他技巧

Vyper 不像 Python 那样自由;有一些限制是你必须接受的。要克服这些限制,你需要与它们和解,或者你需要释放你的创造力。以下是一些关于如何做到这一点的提示。

第一个限制是数组必须有固定的大小。在 Python 中,你可能非常习惯于拥有一个可以根据需要扩展的列表,如下代码所示:

>>> flexible_list = []
>>> flexible_list.append('bitcoin')
>>> flexible_list.append('ethereum')
>>> flexible_list
['bitcoin', 'ethereum']

在 Vyper 中没有这样的东西。你必须声明你的数组有多大。然后你必须使用一个整数变量来跟踪你已经插入到这个固定大小数组中的项目数量。你在Donation智能合约中使用了这种策略。

如果你渴望拥有一个无限大小的数组,有一种方法可以实现这一点。你可以使用整数作为键的映射数据类型。你仍然使用一个整数变量来跟踪你已经插入到这个映射数据类型变量中的项目数量,如下代码所示:

infinite_array_of_strings: map(uint256, bytes[100])
index: int128

但由于infinite_array_of_strings是一个映射数据类型,你有责任保护这个变量免受非整数键的影响。

第二个限制是映射数据类型不能接受复合数据类型作为键。因此,你不能将映射数据类型或结构数据类型作为键。但它可以接受映射数据类型或结构数据类型作为值,如下代码所示:

mapping_of_mapping_of_mapping: map(uint256, map(uint256, map(uint256, bytes[10])))

如果你想将结构体作为映射数据类型变量的键,你可以先对它们进行序列化。例如,如果你想将两个字符串作为映射数据类型变量的键,你可以将这些字符串连接起来,作为你的映射数据类型变量的键,如下代码所示:

friend1_str: bytes32 = convert(friend1, bytes32)
friend2_str: bytes32 = convert(friend2, bytes32)
key: bytes[100] = concat(friend1_str, friend2_str)

dating[key] = True

或者你可以使用嵌套数组,如下所示:

dating[friend1_address][friend2_address] = True

哪种方法更好取决于情况和你的偏好。

第三个限制是 Vyper 编程语言无法访问现实世界。因此,在你的智能合约中不要想象以下内容:

nba_final_winner = nba.get_json_winner('2019/2020')

摘要

在本章中,我们学习了如何使用 Vyper 编程语言编写智能合约。首先,我们安装了 Vyper 编译器。然后我们开发了一个智能合约。通过这样做,我们了解了 Vyper 编程语言的大部分特性,包括函数修饰符、初始化函数和函数权限修改器。还有一些数据类型,如地址、整数、时间戳、映射、数组和字节数组(字符串)。我们学会了如何将 Vyper 源代码编译成智能合约,然后使用 Truffle 工具将其部署到 Ganache 上。我们还通过 Truffle 控制台与该智能合约进行了交互。

在下一章中,我们将学习关于web3.py。这是构建去中心化应用的第一步。

第二部分:Web3 和 Populus

本节包括一个实际项目,用于学习区块链编程,围绕一个安全的投票应用展开。

本节将涵盖以下章节:

  • 第四章,使用 Web3 与智能合约交互

  • 第五章,Populus 开发框架

  • 第六章,构建一个实用的去中心化应用

第四章:使用 Web3 与智能合约交互

在本章中,您将学习如何以编程方式连接到智能合约。在这里,您将使用 Python 编程语言来执行智能合约中的方法。为了实现这一点,您将使用web3.py库。在上一章中,您构建了一个智能合约并将其部署到以太坊区块链上。您还使用 Vyper 编写了一个智能合约。为了与该智能合约交互,您启动了 Truffle 控制台并输入了许多命令。这些命令被发送到区块链中的智能合约。根据您输入的命令,这可能会读取智能合约的状态或更改状态。在本章中,您将超越 Truffle 控制台。

本章将涵盖以下主题:

  • 去中心化应用介绍

  • Geth

  • 了解web3.py

  • 使用web3.py与智能合约交互

去中心化应用介绍

您将使用 Python 构建一个程序,以编程方式执行智能合约中的方法,我们称这个程序为去中心化应用。因此,有一个智能合约,还有一个去中心化应用。使用 Vyper 或 Solidity 编程语言编写的智能合约存在于以太坊区块链中。这意味着如果您将智能合约部署到以太坊生产区块链上,您的智能合约的字节码将写入每个以太坊节点。因此,如果我们在这个世界上有 1 万个以太坊节点,您的智能合约将被复制 1 万次。

然而,去中心化应用并不存储在以太坊区块链中。它存在于您的计算机中,邻居的计算机中,云中,但它并不存储在区块链上,并且不必像智能合约一样在全世界复制。人们使用各种编程语言构建去中心化应用。在以太坊的情况下,构建去中心化应用的最流行的编程语言是 Node.js 环境中的 Javascript 和 Python。在我们的情况下,我们将使用 Python 来构建去中心化应用。为此,我们需要一个库。在 Javascript 的情况下,我们需要一个web3.js库。在我们的情况下,也就是 Python,我们需要一个web3.py库。所有这些库的名称都包含了 web3 这个词。

人们喜欢把 web3 看作是互联网的第三个版本:一个去中心化的互联网。那么,如果这是第三个版本,你会问第一个和第二个版本是什么?互联网的第一个版本是您用来被动消费内容的互联网(想想静态网站)。互联网的第二个版本是社交互联网,您在其中生成内容并共同创造体验(想想 Facebook、Twitter 或 Instagram):

在前面的屏幕截图中,我们可以看到 Vyper 或 Solidity 字节码存在(复制)于许多以太坊节点(系统)中。但是使用web3库的程序可以存在于单个计算机(例如笔记本电脑或智能手机)中。

安装 web3

话不多说,让我们安装web3库。创建一个带有 Python 3.6 的虚拟环境如下:

$ virtualenv -p python3.6 web3-venv

激活虚拟环境并安装 Vyper 如下:

$ source web3-venv/bin/activate
(web3-venv) $ pip install vyper

然后,使用pip安装web3.py库:

(vyper-venv) $ pip install web3

现在,验证它是否按以下方式工作:

(vyper-venv) $ python
>>> import web3
>>> web3.__version__
'4.8.2'

如果您没有遇到任何错误,那就可以了。让我们使用web3连接到 Ganache 区块链。要做到这一点,首先启动 Ganache,然后返回到您的 Python 命令提示符:

>>> from web3 import Web3, HTTPProvider
>>> w3 = Web3(HTTPProvider('http://localhost:7545'))
>>> w3.eth.blockNumber
0
>>> w3.eth.getBlock('latest')
AttributeDict({'number': 0, 'hash': HexBytes('0x0bbde277e2147d93f12852a370e70e2efe9c66f45db6e80e0cba584508d3ebac'), 'parentHash': HexBytes('0x0000000000000000000000000000000000000000000000000000000000000000'), 'mixHash': 
...
...
HexBytes('0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421'), 'stateRoot': HexBytes('0x31740a2d8b535c624aa481ba7d6d696085438037246b7501b4f24f77f94f3994'), 'receiptsRoot': HexBytes('0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421'), 'miner': '0x0000000000000000000000000000000000000000', 'difficulty': 0, 'totalDifficulty': 0, 'extraData': HexBytes('0x'), 'size': 1000, 'gasLimit': 6721975, 'gasUsed': 0, 'timestamp': 1548300279, 'transactions': [], 'uncles': []})

我们在这里所做的是使用web3库连接到 Ganache 区块链。我们可以检索区块链上的信息,比如在这个特定区块链上挖掘了多少个区块。因为我们使用的是 Ganache,一个开发区块链,w3.eth.blockNumber返回0,因为我们在 Ganache 上没有创建任何交易。

Geth

Go EthereumGeth)是用 Go 语言编写的以太坊协议的实现。您可以使用 Geth 来同步以太坊节点,甚至构建私有以太坊区块链。如果您想成为矿工,这是您将使用的软件。您的以太坊节点是以太坊区块链的网关和一部分。您的程序与web3库需要以太坊节点才能与存储在区块链中的智能合约进行交互。

使用 Ganache 是很好的。但 Ganache 是一个假的区块链。没有矿工,因此很难模拟我们在真实以太坊区块链上可能遇到的一些情况。因此,让我们提高我们的游戏水平。我们现在不需要使用以太坊生产区块链,但我们可以使用介于开发和生产区块链之间的东西——Rinkeby 网络。如果以太坊生产区块链类似于生产服务器,那么 Rinkeby 网络就像一个暂存服务器。在 DevOps 的语言中,暂存服务器是尽可能模拟生产服务器的测试服务器。

因此,Rinkeby 不像 Ganache 那样是一个软件。它存在于互联网上。因此,使用 Rinkeby 网络,我们可以感受到与以太坊生产区块链打交道是什么感觉。在以太坊区块链的 Rinkeby 网络中,您可能会遇到的情况之一是确认交易需要时间。在 Ganache 中,确认交易只需要一小部分秒数。在 Rinkeby 网络中,确认交易可能需要 20-30 秒,甚至一分钟,因此您需要习惯。当然,并非以太坊生产区块链中的所有情况都可以在 Rinkeby 网络上复制。在以太坊生产区块链中特别发生的另一种情况是,它包括以太坊生产区块链有时会出现的高流量。例如,一个名为 Cryptokitties 的去中心化应用程序减慢了以太坊网络,因为有许多用户与该应用程序进行交互,可以在这里看到:techcrunch.com/2017/12/03/people-have-spent-over-1m-buying-virtual-cats-on-the-ethereum-blockchain/

还有另一个类似于 Rinkeby 网络的以太坊测试网络——Ropsten 网络。这里的区别在于 Rinkeby 网络在确认交易时使用权威证明PoA),而 Ropsten 网络使用工作量证明PoW)。现在不需要担心这种区别,因为使用 Rinkeby 网络类似于使用 Ropsten 网络。

连接到这种类型的以太坊区块链有两种方法——自己运行以太坊节点,或者使用其他人的节点。每种方法都有其优点和缺点。运行一个以太坊节点需要大量存储空间。连接到 Rinkeby 网络的节点需要大约 6GB 的存储空间。至于以太坊生产网络,需要高达 150GB 的存储空间。根据您的互联网连接,要完全运行,您需要一晚或几天时间,使节点与所有其他节点完全同步。

另一种方法是使用其他人的节点。有些人构建了一个连接到他们的以太坊节点的网络服务,因此您可以使用 API 连接到他们的以太坊节点。其中最受欢迎的服务之一是 Infura。在这里,您只需要在他们的网站上注册以获取他们的 API。

要在 Rinkeby 网络上运行我们自己的以太坊节点,请转到geth.ethereum.org/downloads/下载适用于您的操作系统的软件。对于 Ubuntu Linux,这是以tar.gz格式,因此您需要解压缩它。然后,将二进制文件放在方便的位置(例如/opt/bin/home/yourusername/Program/user/local/bin)。

完成此操作后,按以下方式同步节点:

$ ./geth --rinkeby

你可以使用不同的数据目录。默认情况下,Geth 将数据存储在~/.ethereum目录中:

$ ./geth --rinkeby --datadir /opt/data/ethereumdata

在我的情况下,这需要一个晚上。你的经验可能会因你的互联网连接速度而有所不同。

如果它完全同步(当输出不再经常更改时,你就知道这种情况),那么你可以在web3-venv虚拟环境中运行 Python,如下所示:

(web3-venv) $ python
>>> from web3 import Web3, IPCProvider
>>> w3 = Web3(IPCProvider("/home/yourusername/.ethereum/rinkeby/geth.ipc"))

在这里,我们使用了与之前示例中不同的提供者。在 Ganache 示例中,我们使用 HTTP 提供者。请记住,Ganache 使用http://localhost:7545,你需要在 Truffle 配置中使用这些信息。然而,在我们的情况下,当我们连接到以太坊节点时,我们使用进程间通信提供者IPC)。你还可以看到IPCProvider的一个参数,它是一个文件路径。因此,你的 Python 程序通过那个文件与以太坊节点通信。在计算机科学中,那个文件被称为pipe。你只需搜索geth.ipc文件在你的本地计算机上的位置。请记住,只有在运行geth软件时,geth.ipc才会出现。如果你停止它,geth.ipc文件将消失。

然后,在你像往常一样运行业务之前,你需要向 web3 中间件注入一些东西。这应该这样做,因为以太坊生产区块链中的区块大小与 Rinkeby 区块链中的区块大小不同:

>>> from web3.middleware import geth_poa_middleware
>>> w3.middleware_stack.inject(geth_poa_middleware, layer=0)

然后,你可以测试它,如下面的代码块所示:

>>> w3.eth.getBlock('latest')
AttributeDict({'difficulty': 2, 'proofOfAuthorityData': HexBytes('0xd883010813846765746888676f312e31312e32856c696e7578000000000000001c62ac5af9b2ea6bf897a99fff40af6474cd5680fc8239853f03db116b2154594d2ab77a6f18c41132ee819143d2d41819237468924d29cb4b1252d2385a862400'), 'gasLimit': 7000000, 'gasUsed': 1373640, 'hash': HexBytes('0xa14b569f874eefc75fe734bc28b7457755eff1da26794d6615f15e1739204067'), 'logsBloom': 
...
...
HexBytes('0x66e75c91271b45f5271d2fe2fd0efc66f48f641632e83a086fc57646a0c0bc3f'), 'uncles': []})

你得到的输出是 Rinkeby 网络区块链的最新区块的信息。你可以从区块链的区块中学到一些东西。你可以找到在这个区块中已确认的所有交易;使用的燃气,燃气限制等等。在 Rinkeby 网络中,矿工始终是零地址(0x0000000000000000000000000000000000000000),因为 Rinkeby 网络中的区块链使用权威证明。但在主网(生产网络)中,你可以找出谁获得了确认区块链的奖励。你可以从主网(以太坊生产网络)的最新区块中找到这些信息。当然,如果你愿意同步节点,你也可以从以太坊生产节点中找到相同的信息。

Geth 控制台

在我们继续使用web3库之前,让我们先尝试一下 Geth 软件。Geth 软件可以像 Truffle 控制台一样工作:

$ ./geth --rinkeby --verbosity 0 console

在该语句中,关键字是console,但为了使体验更愉快,你应该添加另一个--verbosity标志,值为0。这将防止你从geth软件中获得大量输出。

Welcome to the Geth JavaScript console!
instance: Geth/v1.8.16-stable-477eb093/darwin-amd64/go1.11
modules: admin:1.0 clique:1.0 debug:1.0 eth:1.0 miner:1.0 net:1.0 personal:1.0 rpc:1.0 txpool:1.0 web3:1.0
>

在 Geth 控制台中,你可以做任何你在 Truffle 控制台中可以做的事情。然而,我们现在想要创建一个以太坊账户。当你启动 Ganache 时,你会得到 10 个可以使用的账户。但在 Rinkeby 区块链中情况并非如此。你需要在 Rinkeby 中手动创建一个账户:

> personal.newAccount("password123")
"0x28f5b56b035da966afa609f65fd8f7d71ff68327"

这是创建一个新的以太坊账户的命令。你需要提供一个密码来在 Geth 控制台中创建一个账户。不要忘记这个账户的密码,因为没有恢复它的选项。该命令的输出是你账户的公共地址。

私钥被加密在以下目录的文件中:/home/yourusername/.geth/rinkeby/keystore

文件名类似于UTC—2018-10-12T09-30-20.687898000Z—28f5b56b035da966afa609f65fd8f7d71ff68327。这是时间戳和公钥的组合。你可以打开它,但你将无法在其中找到私钥。

{"address":"28f5b56b035da966afa609f65fd8f7d71ff68327","crypto":{"cipher":"aes-128-ctr","ciphertext":"38b091f59f879369a6afdd91f21c1a82deb59374677144c94dd529d3c9069d39","cipherparams":{"iv":"b168482d467df6e1fe4bdb5201a64a6a"},"kdf":"scrypt","kdfparams":{"dklen":32,"n":262144,"p":1,"r":8,"salt":"bd94440d3f2bb9313a0020331bac9410ff3cdc9f32756f41f72dde1ef7bf32e1"},"mac":"3313b72603e85e73f84a47ef7ed0e931db85441e1702e0d96f2f001c54170cb6"},"id":"7a033367-92fe-42d3-bec5-970076f35d8a","version":3}

要解密它,你可以使用web3库。将脚本命名为extract_private_key.py

from web3 import Web3
w3 = Web3()

# Change the filepath to your keystore's filepath
with open('/opt/data/ethereumdata/keystore/UTC--2018-10-12T09-30-20.687898000Z--28f5b56b035da966afa609f65fd8f7d71ff68327') as keyfile:
    encrypted_key = keyfile.read()
    private_key = w3.eth.account.decrypt(encrypted_key, 'password123')
    print(private_key)

如果你执行该脚本,你将看到你的私钥,它可以在其他情况下使用:

(web3-venv) $ python extract_private_key.py
b'\xa0\xe2\xa2\xf0$j\xe9L\xb3\xc0\x14Q\xb0D\xec\xa16\xa1\xca\xdd\x07.\x0f\x0f=5\xbd\xc5mb(r'

请不要再在生产环境中使用这个账户,因为私钥已经暴露。只能用于开发目的。

此私钥以bytes格式。如果要将其转换为十六进制字符串,可以这样做:

(web3-venv) $ python
>>>  b'\xa0\xe2\xa2\xf0$j\xe9L\xb3\xc0\x14Q\xb0D\xec\xa16\xa1\xca\xdd\x07.\x0f\x0f=5\xbd\xc5mb(r'.hex()
'a0e2a2f0246ae94cb3c01451b044eca136a1cadd072e0f0f3d35bdc56d622872'

了解 web3.py 库

现在,让我们使用这个库编写一个去中心化的应用程序。最简单的去中心化应用程序脚本将是从一个账户向另一个账户发送资金。将脚本命名为send_money_ganache.py

from web3 import Web3, HTTPProvider

w3 = Web3(HTTPProvider('http://localhost:7545'))

private_key = '59e31694256f71b8d181f47fc67914798c4b96990e835fc1407bf4673ead30e2'

transaction = {
  'to': Web3.toChecksumAddress('0x9049386D4d5808e0Cd9e294F2aA3d70F01Fbf0C5'),
  'value': w3.toWei('1', 'ether'),
  'gas': 100000,
  'gasPrice': w3.toWei('1', 'gwei'),
  'nonce': 0
}

signed = w3.eth.account.signTransaction(transaction, private_key)
tx = w3.eth.sendRawTransaction(signed.rawTransaction)

在执行此脚本之前,首先启动 Ganache。这样做后,选择任何您喜欢的公共地址,并将其放入交易字典中的to字段。这个账户将成为接收者。然后找到另一个账户,查看其私钥,并将值输入到private_key变量中:

value字段中放入一个以太。这意味着您想向另一个账户发送 1 个以太。这是一个简单的脚本,用来说明如何发送交易。它并不展示最佳实践,因为您不应该像这样将私钥嵌入代码中。例如,您可以从受限制的权限文件中读取私钥,或者可以从标准输入请求私钥。

如果您执行此脚本,您会注意到接收者的余额会增加 1 个 ETH,而发送者的余额会减少 1 个 ETH:

以下是输出:

Gas 和 gas 价格

正如您所知,以太坊区块链并不是免费的;有人必须维护它。我不是在谈论编写以太坊软件的开发人员,而是运行以太坊节点以确认交易的矿工。他们从以太坊软件本身获得 ETH 的报酬。此外,他们还从交易的费用中获得报酬。这个费用就是 gas 和 gas 价格。

为什么以太坊软件需要在奖励之上收取费用?这是为了防止用户滥发垃圾信息。如果交易是免费的,滥发者可以设置两个账户,之间来回发送资金。此外,这会给愿意支付更多的用户高优先级。如果有两个类似的交易,但第一笔交易使用的 gas 更多,它将在矿工的待办事项列表中具有更高的优先级。使用更少 gas 的交易最终会得到确认;它只需要等待更长一点时间。

因此有 gas 和 gas 价格。Gas 是您愿意在此交易中分配的 gas 数量。在先前的脚本中,您分配了 20,000 gas 来创建一个发送资金的交易。对于更复杂的交易,比如执行智能合约中的复杂方法,可能需要更多的 gas。如果您没有分配足够的 gas,您的交易将被拒绝,您也可能会损失 gas。但是,如果您放入的 gas 超过了所需量,如果您的交易成功,剩余的 gas 将退还给您。因此,您可能会想:为什么不尽可能多地设置 gas?有一个陷阱。如果一些方法在智能合约中失败了断言(例如:assert 1 == 2),您将失去直到断言行之前使用的所有 gas(但剩余的 gas 将被退还)。因此,您需要找到折中之道。

Gas 价格是 gas 的价格,因此 gas 不是免费的。它与以太坊本身分开。您可以用您拥有的 ETH 购买 gas。您可以在历史交易中查看 gas 价格。在以太坊生产区块链中,您可以在www.ethgasstation.info/上查看 gas 价格。

您如何估计交易所需的 gas?您需要了解 Solidity 或 Vyper 编程语言的所有复杂性。如果我分配一个 256 位整数变量并将其存储在存储器中,会花费多少?循环呢?构造一个结构呢?这听起来很复杂,但幸运的是,web3库有一个估算 gas 使用量的方法。首先,创建一个名为estimate_gas.py的脚本:

from web3 import Web3, HTTPProvider

w3 = Web3(HTTPProvider('http://localhost:7545'))

transaction = {
  'to': Web3.toChecksumAddress('0x9049386D4d5808e0Cd9e294F2aA3d70F01Fbf0C5'),
  'value': w3.toWei('1', 'ether'),
  'gas': 100000,
  'gasPrice': w3.toWei('1', 'gwei'),
  'nonce': 0
}

print("Estimating gas usage: " + str(w3.eth.estimateGas(transaction)))
print("Gas price: " + str(w3.eth.gasPrice))

您将得到以下输出:

Estimating gas usage: 21000
Gas price: 2000000000

如果出现与随机数相关的错误,请将随机数更改为 1 或更高,直到它起作用。我们将在下一节讨论随机数。

随机数

您可能已经注意到,如果尝试多次执行发送资金脚本,会出现与随机数相关的错误。如果您还没有尝试,请尝试一下。您必须增加随机数才能使其再次工作。随机数就像是您使用一个账户进行了多少笔交易的指示器。对于第一笔交易(在创建新账户后),您在随机数中放入零值。然后,对于第二笔交易,您在随机数中放入 1 的值。然后,对于第三笔交易,您在随机数中放入 2 的值。

但是跟踪随机数值变得愚蠢,特别是如果您想使用一个旧账户,而您不知道随机数值有多高。幸运的是,有一种方法可以从以太坊区块链中获取最新的随机数值。创建一个名为get_latest_nonce.py的脚本:

from web3 import Web3, HTTPProvider
w3 = Web3(HTTPProvider('http://localhost:7545'))
transaction_count = w3.eth.getTransactionCount("0xcc6d61988CdcF6eB510BffAeD4FC0d904f8d3e7D")
print(transaction_count)

尝试使用更高的随机数再次发送资金,然后执行此脚本。再做一次。这将显示给您价值总额。

有了这些新知识,您必须小心创建以太坊区块链上的交易。在现实世界中,交易可能需要时间才能得到确认。想象一下,您同时创建了两笔不同的交易,它们的随机数相同。如果这两笔交易都有效,只有其中一笔会被记录在以太坊区块链上。另一笔将被拒绝,因为存在随机数问题。这两笔交易中的哪一笔将得到确认?这将是相当随机的。

相反,您必须决定要先确认哪笔交易。给它一个较低的随机数。对于第二笔交易,您给予一个较高的随机数。但是,如果第一笔交易失败,第二笔交易将被拒绝,因为随机数太高(随机数跳过一个数字)。这是您需要牢记的事情。

那么为什么要有这些随机数官僚主义?这是为了防止相同的交易再次发生。想象一下,您广播了一笔交易,向您的邻居发送了 1 个 ETH。我可以复制这笔交易并再次广播。由于这笔交易是通过您的签名验证的,我可以清空您的账户。

在 Rinkeby 上创建交易

现在您已经在 Ganache 上玩得很开心了,让我们尝试在 Rinkeby 网络上创建一笔交易。您可能会注意到一个问题——与 Ganache 不同,您在这里什么都没有。您必须默认创建一个账户。创建 10 个账户很容易。但是余额呢?您在 Rinkeby 网络上使用 Geth 软件创建的每个账户都带有 0 ETH 的余额。但是发送资金需要资金来支付交易费。

在以太坊生产区块链中,您可以从加密货币交易所或挖矿中获得 ETH。但在 Rinkeby 网络中,您通过乞讨来获得资金。以下是如何做到这一点。

转到faucet.rinkeby.io/,然后使用 Twitter、Google+或 Facebook 等社交媒体平台之一,在 Rinkeby 网络中创建一个包含您的公共地址的帖子。然后,在 Rinkeby 水龙头网站的表单中发布您的社交媒体帖子。您有三个选择:8 小时内的 3 个以太币,1 天内的 7.5 个以太币,或 3 天内的 18.5 个以太币:

为了确保您获得了余额,您可以从 Geth 软件中检查余额。首先同步它。正如我之前所说,这个过程可能需要很长时间,也许几个小时,或者在我这种情况下需要一整夜:

$ ./geth --rinkeby

在您的本地区块链节点与 Rinkeby 网络完全同步之后,首先终止geth进程,然后再次启动geth,但使用不同的标志:

$ ./geth --rinkeby --verbosity 0 console

geth控制台中执行此命令:

> web3.eth.getBalance('0x28f5b56b035da966afa609f65fd8f7d71ff68327')
3000000000000000000

将此地址更改为您的地址。您应该从水龙头中获得一些 ETH。

假设您已经拥有 ETH,您可以在 Rinkeby 网络中创建一个交易。以下是在 Rinkeby 网络中发送以太币的脚本。您可以在以下 GitLab 链接上引用完整代码文件:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_04/send_money_rinkeby.py

from web3 import Web3, IPCProvider
from web3.middleware import geth_poa_middleware

# Change the path of geth.ipc according to your situation.
w3 = Web3(IPCProvider('/opt/data/ethereumdata/geth.ipc'))

w3.middleware_stack.inject(geth_poa_middleware, layer=0)

...
...

nonce = w3.eth.getTransactionCount(Web3.toChecksumAddress(from_account))

transaction = {
  'to': Web3.toChecksumAddress(to_account),
  'value': w3.toWei('1', 'ether'),
  'gas': 21000,
  'gasPrice': w3.toWei('2', 'gwei'),
  'nonce': nonce
}

signed = w3.eth.account.signTransaction(transaction, private_key)
w3.eth.sendRawTransaction(signed.rawTransaction)

根据您的情况,更改接收账户地址、您的私钥加密文件位置、您的密码和geth.ipc文件位置。

请记住,我们的私钥是加密在一个文件中的。因此我们读取该文件,然后用密码解锁。请记住,您不应该直接将密码嵌入代码中。然后您可以在等待几分钟后在geth控制台中检查您目标账户的余额:

> web3.eth.getBalance('0x99fb2eee85acbf878d4154de73d5fb1b7e88c328')
100000000000000000

您可以通过使用私钥对其进行签名来发送交易。这是在以太坊中创建交易的最通用方式。但还有另一种方式,涉及仅使用密码。

您可以像这样使用私钥:

signed = w3.eth.account.signTransaction(transaction, private_key)
w3.eth.sendRawTransaction(signed.rawTransaction)

或者,您可以在签署交易时使用密码,如下所示:

w3.personal.sendTransaction(transaction, password)

只有在您控制节点时才能使用密码,因为它需要一个加密的私钥文件。我在我的以太坊节点中创建了一些账户。我只能在为这些账户签署交易时使用密码。但是使用私钥,我可以使用任何账户。

使用 web3.py 与智能合约进行交互

您已经使用web3库在 Ganache 和 Rinkeby 网络中使用 Python 脚本发送了以太币。现在,让我们创建一个与智能合约交互的脚本。但在这之前,您需要学习如何使用gethweb3库的 Python 脚本启动智能合约。在第三章中,使用 Vyper 实现智能合约,您使用 Truffle 启动了一个智能合约。

使用 Geth 启动智能合约

在下一节中,我们将使用web3连接到一个智能合约。以下是如何将智能合约部署到 Rinkeby 区块链的方法:

$ ./geth --rinkeby --verbosity 0 console

geth控制台中,使用 Geth 软件列出所有您的账户:

> eth.accounts
["0x8b55f0a88a1c53a8976953cde4f141752e847a00", "0x1db565576054af728b46ada9814b1452dd2b7e66", "0x28f5b56b035da966afa609f65fd8f7d71ff68327", "0x5b0d65b07a61c7b760bf372bbec1b3894d4b0225", "0x99fb2eee85acbf878d4154de73d5fb1b7e88c328"]

所有这些账户都来自您使用此命令创建的 keystore 文件:personal.newAccount("password")。假设您想要解锁第一个账户,那么您可以使用personal.unlockAccount方法:

> personal.unlockAccount(eth.accounts[0], "password123")
true

现在,获取bytecode并将其放入一个变量中。请记住,当您使用 Vyper 编译器编译源代码时,您会得到bytecode

> bytecode = "smart contract bytecode"
> tx = eth.sendTransaction({from: eth.accounts[0], data: bytecode, gas: 500e3}

然后,检查您的智能合约是否已在区块链上得到确认:

> web3.eth.getTransactionReceipt(tx)

如果已确认,则应该获得以下输出:

{
 blockHash: "0xfed7dcbd5e8c68e17bff9f42cd30d95588674497ae719a04fd6a2ff219bb001d",
 blockNumber: 2534930,
 contractAddress: "0xbd3ffb07250634ba413e782002e8f880155007c8",
 cumulativeGasUsed: 1071323,
 from: "0x1db565576054af728b46ada9814b1452dd2b7e66",
 gasUsed: 458542,
 logs: [],
 logsBloom: "0x00000...",
 status: "0x1",
 to: null,
 transactionHash: "0x1a341c613c2f03a9bba32be3c8652b2d5a1e93f612308978bbff77ce05ab02c7",
 transactionIndex: 4
}

使用 web3 启动智能合约

您还可以使用web3库使用 Python 脚本启动智能合约。将此脚本命名为deploy_smart_contract_to_ganache.py。您可以在以下 GitLab 链接上引用完整代码文件,gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_04/deploy_smart_contract_to_ganache.py

from web3 import Web3, HTTPProvider
from vyper import compile_codes

contract_source_code = '''
name: public(bytes[24])

@public
def __init__():
    self.name = "Satoshi Nakamoto"

...
...

# Change the account to your situation.
tx_hash = HelloSmartContract.constructor().transact({'from': '0xb105F01Ce341Ef9282dc2201BDfdA2c26903da77'})

tx_receipt = w3.eth.waitForTransactionReceipt(tx_hash)
print(tx_receipt)

运行脚本。但请确保 Ganache 正在运行。您应该获得以下输出:

AttributeDict({'transactionHash': HexBytes('0xcfce0a28d0f8232735f99bcf871762f9780f19ab916e92c03d32fdabfd6b9e9a'), 'transactionIndex': 0, 'blockHash': HexBytes('0x84139a5c9ad050cf7be0678feb4aefc9e8b2806636245f16c790048e50347dfe'), 'blockNumber': 1, 'from': '0xb105f01ce341ef9282dc2201bdfda2c26903da77', 'to': None, 'gasUsed': 339198, 'cumulativeGasUsed': 339198, 'contractAddress': '0x9Dc44aa8d05c86388E647F954D00CaA858837804', 'logs': [], 'status': 1, 'logsBloom': HexBytes('0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000'), 'v': '0x1c', 'r': '0x74c63921055bd2fed65a731356b30220c6de3a28ec5fd26e296bf609d76d25ce', 's': '0x655395f422fa7b419caf87f99e2da09296b123eceb99aed4d19195e542b01bcd'})

首先,使用此语句创建一个智能合约对象:

HelloSmartContract = w3.eth.contract(abi=abi, bytecode=bytecode)

然后,为了部署一个智能合约,您只需要使用constructor方法:

tx_hash = HelloSmartContract.constructor().transact({'from': '0xb105F01Ce341Ef9282dc2201BDfdA2c26903da77'})

最后一步非常重要,那就是等待交易得到确认。这很重要,因为您需要在交易得到确认后获得智能合约的地址。

如果要将智能合约部署到 Rinkeby 网络,需要修改此脚本。创建一个名为deploy_smart_contract_to_rinkeby.py的新文件。您可以在以下 GitLab 链接上参考完整代码文件:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_04/deploy_smart_contract_to_rinkeby.py

from web3 import Web3, IPCProvider
from vyper import compile_codes

contract_source_code = '''
name: public(bytes[24])

...
...

signed = w3.eth.account.signTransaction(transaction, private_key)
tx_hash = w3.eth.sendRawTransaction(signed.rawTransaction)

tx_receipt = w3.eth.waitForTransactionReceipt(tx_hash)
print(tx_receipt)

将智能合约部署到 Rinkeby 网络时的区别(除了使用IPCProvider而不是HTTPProvider)是您还需要设置gasgasPricenonce。此外,您使用buildTransaction方法,获取transaction对象,并使用私钥对其进行签名。因为此脚本在 Rinkeby 网络中运行,所以需要确保您已分配足够的 gas。在 Rinkeby 网络上部署智能合约时,犯的一个常见错误是 gas 不足。然后,开发人员会困惑为什么之后无法访问智能合约。当您将此智能合约部署到 Rinkeby 网络时,需要等待一段时间。

玩转智能合约

在上一章中,我们使用 Vyper 开发了一个名为hello.vy的简单智能合约。让我们使用web3创建一个与此智能合约交互的脚本。如果您忘记了hello.vy的内容,这是文件的内容:

name: public(bytes[24])

@public
def __init__():
    self.name = "Satoshi Nakamoto"

@public
def change_name(new_name: bytes[24]):
    self.name = new_name

@public
def say_hello() -> bytes[32]:
    return concat("Hello, ", self.name)

编译并部署到 Ganache 或 Rinkeby 网络。现在,根据您是要连接到 Ganache 还是 Rinkeby 中的智能合约,选择以下选项之一。

第一个脚本是用于与 Rinkeby 网络中的智能合约进行交互。将脚本命名为play_with_smart_contract_in_rinkeby.py。您可以在以下 GitLab 链接上参考完整代码文件:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_04/play_with_smart_contract_in_rinkeby.py

from web3 import Web3, IPCProvider
from vyper import compile_codes

contract_source_code = '''
name: public(bytes[24])

...
...

signed_txn_hash = w3.eth.sendRawTransaction(signed_txn.rawTransaction)

w3.eth.waitForTransactionReceipt(signed_txn_hash)

print(Hello.functions.say_hello().call())

第二个脚本是用于与 Ganache 中的智能合约进行交互。将脚本命名为play_with_smart_contract_in_ganache.py。您可以在以下 GitLab 链接上参考完整代码文件:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_04/play_with_smart_contract_in_ganache.py

from web3 import Web3, HTTPProvider
from vyper import compile_codes

contract_source_code = '''
name: public(bytes[24])

...
...

signed_txn_hash = w3.eth.sendRawTransaction(signed_txn.rawTransaction)

w3.eth.waitForTransactionReceipt(signed_txn_hash)

print(Hello.functions.say_hello().call())

我们将逐行讨论代码以更好地理解概念:

from web3 import Web3, IPCProvider
from vyper import compile_codes

contract_source_code = '''
name: public(bytes[24])

...
...

smart_contract = {}
smart_contract['hello'] = contract_source_code

format = ['abi', 'bytecode']
compiled_code = compile_codes(smart_contract, format, 'dict')

abi = compiled_code['hello']['abi']

这部分脚本旨在获取智能合约的abibytecode

# Change the path of geth.ipc according to your situation.
w3 = Web3(IPCProvider('/opt/data/ethereumdata/geth.ipc'))

from web3.middleware import geth_poa_middleware
w3.middleware_stack.inject(geth_poa_middleware, layer=0)

# Change the address of the smart contract, the account, the password, and the path to the keystore according to your situation,
address = "0x58705EBBc791DB917c7771FdA6175b2D9F59D51A"
password = 'password123'
w3.eth.defaultAccount = '0x28f5b56b035da966afa609f65fd8f7d71ff68327'
with open('/opt/data/ethereumdata/keystore/UTC--2018-10-12T09-30-20.687898000Z--28f5b56b035da966afa609f65fd8f7d71ff68327') as keyfile:
    encrypted_key = keyfile.read()
    private_key = w3.eth.account.decrypt(encrypted_key, password)

Hello = w3.eth.contract(address=address, abi=abi)

此脚本适用于 Rinkeby 网络选项。您获取web3连接对象和私钥。然后,根据部署智能合约脚本中的abi和地址初始化智能合约对象:

w3 = Web3(HTTPProvider('http://localhost:7545'))

# Change the address of the smart contract, the private key, and the account according to your situation
address = "0x9Dc44aa8d05c86388E647F954D00CaA858837804"
private_key = '0x1a369cedacf0bf2f5fd16b5215527e8c8767cbd761ebefa28d9df0d389c60b6e'
w3.eth.defaultAccount = '0xb105F01Ce341Ef9282dc2201BDfdA2c26903da77'

Hello = w3.eth.contract(address=address, abi=abi)

此脚本适用于 Ganache 选项。您获取web3连接对象并设置私钥。然后,根据部署智能合约脚本中的abiaddress初始化智能合约对象。

在两个(Ganache 和 Rinkeby)脚本中,将值设置为w3.eth.defaultAccount。如果将地址设置为w3.eth.defaultAccount,这意味着该地址将广播交易。它还将执行智能合约中的方法。如果您还记得在 Truffle 控制台中所做的,您使用from参数指定将执行智能合约中的方法的账户,如下所示:

Donation.at("0x3e9417399786347b6ab38f59d3f00829d6bba7b8").change_useless_variable("sky is blue", {from: "0xb105F01Ce341Ef9282dc2201BDfdA2c26903da77" });

如果不使用默认账户,则在构建交易时需要指定from字段。

print(Hello.functions.name().call())

print(Hello.functions.say_hello().call())

要获取公共变量或不改变智能合约状态的公共方法的值,您需要使用合约对象的functions方法,然后是公共变量和公共方法(两者都必须使用()执行),最后执行call方法:

nonce = w3.eth.getTransactionCount(w3.eth.defaultAccount)

txn = Hello.functions.change_name(b"Vitalik Buterin").buildTransaction({
  'gas': 70000,
  'gasPrice': w3.toWei('1', 'gwei'),
  'nonce': nonce
})

如果您记得如何处理 nonce,那么有必要获取更新后的 nonce。对于将改变智能合约状态的交易,您需要使用buildTransaction,它提供了您已经认识的参数:gasgasPricenonce。如果您不使用w3.eth.defaultAccount,那么您需要在这里添加另一个参数:from。如果您想向智能合约发送一些以太币(例如 Donation 智能合约中的donate方法),您还需要添加另一个参数:value

如果您注意到,Rinkeby 网络脚本中的gasgasPrice参数要高得多:

txn = Hello.functions.change_name(b"Lionel Messi").buildTransaction({
        'gas': 500000,
        'gasPrice': w3.toWei('30', 'gwei'),
        'nonce': nonce
      })

在 Ganache 中,您可以使用 70,000 gas 和1 gwei的燃气价格。然而,在 Rinkeby 网络中,您必须小心。为了安全起见,在与 Rinkeby 网络中的智能合约交互时,我提高了燃气和燃气价格。如果您在 Rinkeby 网络中未能改变智能合约的状态,有时意味着您没有分配足够的燃气,燃气价格也不够高:

signed_txn = w3.eth.account.signTransaction(txn, private_key=private_key)

您使用私钥对此交易进行签名。然而,在 Ganache 中,您不必这样做。相反,您可以直接进行交易而无需私钥:

Hello.functions.change_name(b"Vitalik Buterin").transact()

相比之下,在 Rinkeby 网络或以太坊生产区块链中,您必须对交易进行签名:

signed_txn_hash = w3.eth.sendRawTransaction(signed_txn.rawTransaction)

然后,您广播您的交易:

w3.eth.waitForTransactionReceipt(signed_txn_hash)

在 Ganache 中,方法的执行速度会非常快,但在 Rinkeby 中可能需要几分钟。在一个合适的去中心化应用程序中,您可以使用异步编程或线程来处理这个问题:

print(Hello.functions.say_hello().call())

最后一行是为了确保它已经改变了智能合约中的name变量。

总结

在本章中,您学会了如何安装web3库。这个库是设计用来连接智能合约的。除此之外,您还学会了如何在 Rinkeby 网络上运行以太坊节点。您配置了web3以连接到 Rinkeby 网络上的以太坊区块链。您还学会了如何告诉web3连接到以太坊测试网络,比如 Ganache。此外,您创建了一个脚本,用于从一个账户发送以太币到另一个账户。最后,您创建了一个脚本来执行智能合约的方法,无论是读取公共变量的值还是改变智能合约的状态。

在下一章中,您将使用一个名为Populus的智能合约开发框架,它会处理与智能合约相关的手动工作,比如编译代码和部署代码。此外,Populus 框架提供了一种集成的方式来测试智能合约。

第五章:Populus 开发框架

在本章中,您将学习如何使用 Populus,这是一个智能合约开发框架。Populus 与 Truffle 一样,是一个旨在使您更容易开发智能合约的工具。如果我们回顾一下第三章,使用 Vyper 实现智能合约,您可能还记得我们必须手动创建一个.json文件,然后将控制台中的abi输出复制到.json文件中。使用 Populus,您可以避免重复执行先前执行过的手动工作。

本章将探讨以下主题:

  • 设置 Populus

  • 智能合约单元测试

  • 使用 Populus 部署智能合约

设置 Populus

Populus 是一个智能合约开发框架,就像 Truffle 一样。那么为什么我们应该使用 Populus 而不是 Truffle 呢?基本上,这是个人选择。Populus 是用 Python 编写的,并默认支持 Python 进行单元测试。如果您使用 Truffle,则默认使用 JavaScript 进行单元测试。这就像选择web3.py(Python)或web3.js(JavaScript)来构建去中心化应用程序。

要设置 Populus,请遵循以下步骤:

  1. 从头开始创建一个虚拟环境:
$ virtualenv -p python3.6 populus-venv
$ source populus-venv/bin/activate
  1. 我们安装web3populusvyper
(populus-venv) $ pip install eth-abi==1.2.2
(populus-venv) $ pip install eth-typing==1.1.0
(populus-venv) $ pip install py-evm==0.2.0a33
(populus-venv) $ pip install web3==4.7.2
(populus-venv) $ pip install -e git+https://github.com/ethereum/populus#egg=populus

我们必须安装特定版本的web3的原因是因为最新版本的web3(4.8.2)会破坏 Populus。

  1. 安装 Solidity 编译器:
(populus-venv) $ python
>>> from solc import install_solc
>>> install_solc('v0.4.25')
  1. 创建一个符号链接:
(populus-venv) $ ln -s /home/yourusername/.py-solc/solc-v0.4.25/bin/solc populus-venv/bin/
  1. 创建一个项目目录:
(populus-venv) $ mkdir populus_tutorial
(populus-venv) $ cd populus_tutorial
  1. 使用populus初始化此项目目录:
(populus_venv) $ populus init

这个命令就像truffle init。学习了populus之后,您可能甚至不再需要这个命令。这个命令将在您的项目目录中放置两个目录—contractstests

contracts中,有一个示例 Solidity 文件:Greeter.sol。在tests目录中,有一个示例测试文件:test_greeter.py

打开Greeter.sol,一个简单的智能合约,如下所示:

pragma solidity ⁰.4.0;

contract Greeter {
    string public greeting;

    // TODO: Populus seems to get no bytecode if `internal`
    function Greeter() public {
        greeting = 'Hello';
    }

    function setGreeting(string _greeting) public {
        greeting = _greeting;
    }

    function greet() public constant returns (string) {
        return greeting;
    }
}

在您可以编译智能合约之前,您必须创建一个名为project.json的项目配置。您必须首先进入项目目录:

(populus-venv) $ cp ../populus-
   venv/src/populus/populus/assets/defaults.v9.config.json project.json

populus-venv是您创建虚拟环境的地方。如果您看一下,它是一个重要的文件,包含 255 行。您可以使用以下命令编译它:

(populus-venv) $ populus compile

编译的结果是build/contracts.json。您可以在该.json文件中找到abibytecode。除了abibytecode之外,该.json文件中还有其他信息,例如编译器版本。

您已经熟悉了智能合约开发的工作流程。编译Solidity/Vyper文件,以便我们可以获得abibytecode。然后,使用web3库的abibytecode

现在,您将遇到一些新的东西。有另一个目录是您还没有见过的:tests目录。在该目录中,有一个名为test_greeter.py的测试文件,这是一个单元测试。在这里看一下:

def test_greeter(chain):
    greeter, _ = chain.provider.get_or_deploy_contract('Greeter')

    greeting = greeter.call().greet()
    assert greeting == 'Hello'

def test_custom_greeting(chain):
    greeter, _ = chain.provider.get_or_deploy_contract('Greeter')

    set_txn_hash = greeter.transact().setGreeting('Guten Tag')
    chain.wait.for_receipt(set_txn_hash)

    greeting = greeter.call().greet()
    assert greeting == 'Guten Tag'

您可以按以下方式执行此测试文件:

(populus-venv) $ py.test tests

这将给您以下输出:


============================= test session starts ==============================
platform linux -- Python 3.6.6, pytest-3.9.1, py-1.7.0, pluggy-0.8.0
rootdir: /tmp/pop_tut, inifile:
plugins: populus-2.2.0
collected 2 items                                                              

tests/test_greeter.py ..                                                [100%]

=============================== warnings summary ===============================

...

===================== 2 passed, 3 warnings in 0.88 seconds =====================

您的测试现在将通过。我们将在本章后面更深入地讨论智能合约单元测试。

添加对 Vyper 的支持

正如您所知,我们在本书中并不专注于 Solidity,而是 Vyper。我们需要为 Vyper 添加支持。首先,因为这是一个新的虚拟环境,所以安装vyper

(populus-venv) $ pip install vyper

然后,在项目目录中,在contracts目录中创建一个名为Greeter.vy.vy文件:

greeting: bytes[20]

@public
def __init__():
    self.greeting = "Hello"

@public
def setGreeting(x: bytes[20]):
    self.greeting = x

@public
def greet() -> bytes[20]:
    return self.greeting

在您可以编译这个 Vyper 代码之前,您需要在project.json文件中更改一些内容。

转到compilation键。该键的值是一个具有这些键的对象:backendbackendscontract_source_dirsimport_remappings。删除backends键,然后将backend键更改为以下代码:

"backend": {
      "class": "populus.compilation.backends.VyperBackend"
},

因此,compilation键的内容如下:

  "compilation": {
    "backend": {
      "class": "populus.compilation.backends.VyperBackend"
    },
    "contract_source_dirs": [
      "./contracts"
    ],
    "import_remappings": []
  },

然后,像往常一样运行编译:

(populus-venv) $ populus compile

确保这确实是一个 Vyper 编译,而不是 Solidity 编译,你可以打开build/contracts.json。在里面,你将能够看到以下内容:

{
  "Greeter": {
    …
    "source_path": "contracts/Greeter.vy"
  }
}

Vyper 的最新版本是 0.1.0b6,它破坏了 Populus。开发者需要一些时间来修复这个问题。如果在你阅读本书时这个 bug 还没有被修复,你可以自己修补 Populus。

首先,使用以下命令检查 bug 是否已经修复:

(populus-venv) $ cd populus-venv/src/populus
(populus-venv) $ grep -R "compile(" populus/compilation/backends/vyper.py
 bytecode = '0x' + compiler.compile(code).hex()
 bytecode_runtime = '0x' + compiler.compile(code, bytecode_runtime=True).hex()

在我们的情况下,这个 bug 还没有被修复。所以,让我们修补 Populus 来解决这个 bug。确保你仍然在同一个目录下(populus-venv/src/populus):

(populus-venv) $ wget https://patch-diff.githubusercontent.com/raw/ethereum/populus/pull/484.patch
(populus-venv) $ git apply 484.patch
(populus-venv) $ cd ../../../

我们的 Populus 开发框架现在支持 Vyper。

智能合约单元测试

如果你运行单元测试,你将收到一个错误,因为数据类型不正确。解决方法是将所有字符串数据类型更改为bytes数据类型。然后,由于弃用警告,你应该改变调用智能合约方法的方式。

最终,你的单元测试,位于tests/test_greeter.py,应该是这样的:

def test_greeter(chain):
    greeter, _ = chain.provider.get_or_deploy_contract('Greeter')

    greeting = greeter.functions.greet().call()
    assert greeting == b'Hello'

def test_custom_greeting(chain):
    greeter, _ = chain.provider.get_or_deploy_contract('Greeter')

    set_txn_hash = greeter.functions.setGreeting(b'Guten Tag').transact()
    chain.wait.for_receipt(set_txn_hash)

    greeting = greeter.functions.greet().call()
    assert greeting == b'Guten Tag'

然后,如果你再次运行你的单元测试,它将会成功。

让我们来看一下第一个方法:

def test_greeter(chain):
    greeter, _ = chain.provider.get_or_deploy_contract('Greeter')

    greeting = greeter.functions.greet().call()
    assert greeting == b'Hello'

你的单元测试函数大部分时间应该接受chain参数。从chain对象中,你可以获取一个提供者。如果你记得的话,提供者是一个连接到以太坊区块链的对象,无论是通过 HTTP 还是 IPC。从那里,你可以获取合约对象。测试框架是直接从区块链获取合约对象,还是在获取合约之前先部署合约,这取决于情况。在我们的情况下,是后者选项。测试框架在内存中创建一个合约对象并与之交互。单元测试结束后,一切都消失了。

之后,你将从永久区块链中获取合约对象。provider对象的get_or_deploy_contract返回两个对象。你通常只需要关注第一个对象,即合约对象。

contract对象开始,执行智能合约的方法对你来说应该很熟悉。要执行public方法或获取公共变量的值,你将使用functions,接着是方法或公共变量加上(),最后以call()结束。

执行公共方法后,你将得到返回值。在这种情况下,你会得到一个byte对象,不像 Solidity 示例中的string。在 Vyper 中,没有一流的字符串支持。字符串被存储为byte对象。Vyper 仍处于活跃的开发模式中,但预计将来会发生变化。

在第二个单元测试中,你将测试智能合约中改变变量状态的方法:

def test_custom_greeting(chain):
    greeter, _ = chain.provider.get_or_deploy_contract('Greeter')

    set_txn_hash = greeter.functions.setGreeting(b'Guten Tag').transact()
    chain.wait.for_receipt(set_txn_hash)

    greeting = greeter.functions.greet().call()
    assert greeting == b'Guten Tag'

一切都和第一个单元测试一样,只是这里有两行新的内容:

set_txn_hash = greeter.functions.setGreeting(b'Guten Tag').transact()
chain.wait.for_receipt(set_txn_hash)

这是使用交易的方式。记住,交易是任何涉及改变合约状态的事情。你调用functions,接着是将改变合约状态的方法加上(),最后以transact()和必要的参数结束。之后,使用chain对象,你等待交易完成。在测试期间,这非常快。但如果你在另一个以太坊区块链上测试,比如在 Rinkeby 网络上,交易可能会持续几分钟。

让我们来看一个更复杂的例子。还记得你在第三章中编写的捐赠智能合约吗,使用 Vyper 实现智能合约。为什么我们不测试一下那个智能合约呢?

让我们将捐赠智能合约保存在您在第三章中开发的源代码文件夹中,使用 Vyper 实现智能合约。您可以将代码保存在contracts/donation.vy中。如果您忘记了这一点,可以参考以下 GitLab 链接上的代码文件获取完整的代码,gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_05/populus_tutorial/contracts/Donation.vy

struct DonaturDetail:
    sum: uint256(wei)
    name: bytes[100]
    time: timestamp

donatur_details: public(map(address, DonaturDetail))

donaturs: public(address[10])

donatee: public(address)

index: int128

...
...

@public
def withdraw_donation():
    assert msg.sender == self.donatee

    send(self.donatee, self.balance)

我们想在这里测试许多不同的事情。

首先,让我们看一下构造方法:

@public
def __init__():
    self.donatee = msg.sender

我们想测试donatee变量是否包含启动智能合约的账户地址。

让我们编写我们的第一个单元测试。您可以将此单元测试保存在tests/test_donation.py中:

def test_donatee(web3, chain):
    donation, _ = chain.provider.get_or_deploy_contract('Donation')

    donatee = donation.functions.donatee().call()
    assert donatee == web3.eth.coinbase

在这个单元测试中,我们使用了两个参数版本,因为我们想获取web3对象。如果您交换参数的顺序,实际上并不重要。def test_donatee(web3, chain):def test_donatee(chain, web3):是一样的。

get_or_deploy_contract中的参数取自您的.vy文件的名称。因此,在命名源代码文件名时要小心。

有一件新的事情(但实际上并不新,因为您已经在第四章中遇到过这个),那就是web3.eth.coinbase。这是默认账户。在这个上下文(单元测试)中,它表示启动智能合约的账户的地址。

如果这个单元测试成功,让我们继续测试这个智能合约中的另一个方法:

@payable
@public
def donate(name: bytes[100]):
    assert msg.value >= as_wei_value(1, "ether")
    assert self.index < 10

    self.donatur_details[msg.sender] = DonaturDetail({
                                         sum: msg.value,
                                         name: name,
                                         time: block.timestamp
                                       })

    self.donaturs[self.index] = msg.sender
    self.index += 1

在这个方法中,您被迫向智能合约发送至少 1 个以太币。

通过编写第二个单元测试来测试失败情况:

def test_donate_less_than_1_eth(web3, chain):
    donation, _ = chain.provider.get_or_deploy_contract('Donation')

    with pytest.raises(eth_tester.exceptions.TransactionFailed):
        donation.transact({'value': web3.toWei('0.8', 'ether')}).donate(b'Taylor Swift')

现在,您需要添加两个import语句:

import pytest
import eth_tester

让我们学习如何在这个单元测试中处理异常。您可以将有问题的交易放在with语句中,该语句将捕获异常。基本上,您期望交易失败。当程序中出现故障时,它会返回一个特殊代码(例如,-1)或抛出异常。在这种情况下,它会抛出异常。

运行测试以查看它是否有效。然后,让我们测试成功的情况,也就是捐赠被接受(请参考以下 GitLab 链接上的代码文件获取完整的代码:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_05/populus_tutorial/tests/test_donation.py):

def test_donate_1_eth(web3, chain):
    import time

    donation, _ = chain.provider.get_or_deploy_contract('Donation')

    t = eth_tester.EthereumTester()
    account2 = t.get_accounts()[1]

...
...

    assert donatur == account2
    assert donation_sum == web3.toWei('1', 'ether')
    assert donation_name == donatur_name
    assert (int(time.time()) - donation_time) < 600 # could be flaky

    assert web3.eth.getBalance(donation.address) == web3.toWei('1', 'ether')

这是很多东西要消化的,所以让我们一步一步地讨论。

import time

我们将使用time库来检查此单元测试中的时间戳:

donation, _ = chain.provider.get_or_deploy_contract('Donation')

您已经知道这个声明。使用chain对象,您可以获取提供者,然后使用该提供者的get_or_deploy_contract方法。结果是一个捐赠智能合约对象:

t = eth_tester.EthereumTester()
account2 = t.get_accounts()[1]

eth_tester是一个旨在使您更轻松地测试智能合约的库。为此,您创建一个EthereumTester对象。该库中有许多辅助函数。其中一个函数是为您提供一些加载了大量 ETH 的账户,有点像 Ganache。第一个账户是管理账户或启动智能合约的账户。如前所示,您也可以从web3.eth.coinbase获取该账户。第二个账户等等是您可以在单元测试中使用的测试账户。

t.get_accounts()会给您很多账户。但是不要使用第一个账户,因为那将是管理账户。在这里,您使用第二个账户:

    donatur_name = b'Taylor Swift'
    set_txn_hash = donation.functions.donate(donatur_name).transact({'from': account2, 'value': web3.toWei('1', 'ether')})
    chain.wait.for_receipt(set_txn_hash)

你在之前的与 greeter 智能合约的单元测试中看到了transact函数。在这个transact方法中,你可以指定你想发送到智能合约的以太币金额。你也可以使用不同的账户。在这些transact参数中,我们还使用了辅助函数web3.toWei。否则,你必须使用很多零的数字。然后,你等待交易被确认:

donatur = donation.functions.donaturs(0).call()
donation_sum = donation.functions.donatur_details__sum(donatur).call()
donation_name = donation.functions.donatur_details__name(donatur).call()
donation_time = donation.functions.donatur_details__time(donatur).call()

在完成这些之后,你可以访问公共变量来确认状态的变化。

对于数组,你在数组方法中放入一个索引,就像donation.functions.donaturs(0).call()中一样。

对于映射,你提供映射方法的参数中的键,而不是整数索引,就像donation.functions.donatur_details__sum(donatur).call()中一样。

对于结构体,你通过追加两个下划线来访问成员,就像donation.functions.donatur_details__sum(donatur).call()中一样。

然后,我们通过断言所有这些变量来测试捐款:

assert donatur == account2

然后我们检查捐赠者地址是否被正确记录:

assert donation_sum == web3.toWei('1', 'ether')

然后我们检查捐款金额是否被正确记录:

assert donation_name == donatur_name

然后我们检查捐赠者的姓名是否被正确记录:

assert (int(time.time()) - donation_time) < 600 # could be flaky

然后我们检查捐款发生的时间。我们这样做是因为时间是在交易在区块链中确认时记录的,所以你永远不知道交易何时被确认。在我们的情况下,这会很快。然而,如果你在 Rinkeby 网络中测试,这是需要牢记的事情。在这里,我确保单元测试时间和智能合约中记录的时间戳之间的差异不超过 10 分钟(600 秒)。

然后我们直接从智能合约的余额中检查捐款金额:

assert web3.eth.getBalance(donation.address) == web3.toWei('1', 'ether')

这与之前的测试不同,之前的测试是通过检查公共变量的值来测试余额。在我们的情况下,我们有很多捐款,所以它们会不同。

让我们来看看智能合约中的最后一个方法:

@public
 def withdraw_donation():
     assert msg.sender == self.donatee

     send(self.donatee, self.balance)

这是提取捐款的方法。不要笑;有些人启动了一个没有提取以太币方法的智能合约。这些智能合约中的所有以太币都将永远被锁定,这就是为什么测试很重要。它确保你不会犯这样的愚蠢错误。

在这个提款方法中,你想要测试你能否从智能合约中提取以太币。然后,你需要确保只有特定的账户(在这种情况下是经理账户)能够从智能合约中提取以太币。

让我们为此创建一个单元测试。首先,我们创建一个单元测试,以确保另一个账户无法从智能合约中提取以太币:

def test_other_account_could_not_withdraw_money(web3, chain):
    donation, _ = chain.provider.get_or_deploy_contract('Donation')

    t = eth_tester.EthereumTester()
    account2 = t.get_accounts()[1]

    donatur_name = b'Taylor Swift'
    set_txn_hash = donation.functions.donate(donatur_name).transact({'from': account2, 'value': web3.toWei('1', 'ether')})
    chain.wait.for_receipt(set_txn_hash)

    with pytest.raises(eth_tester.exceptions.TransactionFailed):
        donation.functions.withdraw_donation().transact({'from': account2})

现在一切应该对你来说都很熟悉了。你在with语句中包装提取以太币的失败情况,以捕获异常。

现在,让我们测试经理账户确实能够提取以太币的成功情况:

def test_manager_account_could_withdraw_money(web3, chain):
     donation, _ = chain.provider.get_or_deploy_contract('Donation')

     t = eth_tester.EthereumTester()
     account2 = t.get_accounts()[1]

     donatur_name = b'Taylor Swift'
     set_txn_hash = donation.functions.donate(donatur_name).transact({'from': account2, 'value': web3.toWei('1', 'ether')})
     chain.wait.for_receipt(set_txn_hash)

     initial_balance = web3.eth.getBalance(web3.eth.coinbase)
     set_txn_hash = donation.functions.withdraw_donation().transact({'from': web3.eth.coinbase})
     chain.wait.for_receipt(set_txn_hash)

     after_withdraw_balance = web3.eth.getBalance(web3.eth.coinbase)

     assert abs((after_withdraw_balance - initial_balance) - web3.toWei('1', 'ether')) < web3.toWei('10', 'gwei')

这里的一切对你来说都很熟悉,除了最后一行。经理账户提取后的余额减去initial_balance不会完全是1个以太币,因为经理账户需要支付手续费。在这种情况下,我们只是使用一个粗略的测量来确保提取成功。只要差异低于 10 gwei,那就没问题。Gwei 是我们用来计算发送交易手续费的货币。1 gwei 等于 1,000,000,000 wei。如果你觉得这很困惑,你可以使用以太币货币进行断言,如下所示:

assert abs((after_withdraw_balance - initial_balance) - web3.toWei('1', 'ether')) < web3.toWei('0.00000001', 'ether')

运行测试:

(populus-venv) $ py.test tests/test_donation.py

你应该得到一个成功的结果:

使用构造函数中的参数

我们测试的两个智能合约在构造函数中都没有参数。但是如果我们编写一个构造函数中带有参数的智能合约呢?我们如何测试呢?

让我们编写一个简单的智能合约,构造函数中带有参数。命名为contracts/Greeter2.vy

greeting: bytes[20]

@public
def __init__(greeting_param: bytes[20]):
    self.greeting = greeting_param

@public
def setGreeting(x: bytes[20]):
    self.greeting = x

@public
def greet() -> bytes[20]:
    return self.greeting

然后,编写以下测试。命名为tests/test_greeter2.py

import pytest

@pytest.fixture()
def greeter2_contract(chain):
    Greeter2Factory = chain.provider.get_contract_factory('Greeter2')
    deploy_txn_hash = Greeter2Factory.constructor(b'Hola').transact()
    contract_address = chain.wait.for_contract_address(deploy_txn_hash)
    return Greeter2Factory(address=contract_address)

def test_greeter2(greeter2_contract):
    greeting2 = greeter2_contract.functions.greet().call()
    assert greeting2 == b'Hola'

在这里,您可以使用pytestfixture功能自定义部署智能合约。首先,您从链的提供者那里获取合约工厂,然后将参数放在factory对象的constructor方法中,以transact()方法结束。然后,等待直到它在区块链上部署。最后,您将收到地址。

在测试函数中,您将参数放置在与 fixture 函数名称完全相同的位置。在这里,我们的 fixture 函数是greeter2_contract。在测试函数中,您只需将此对象用作类似于chain.provider.get_or_deploy_contract('Donation')返回的对象的智能合约对象。

现在,按以下方式进行测试:

(populus-venv) $ py.test tests/test_greeter2.py

您应该得到一个成功的结果:

您仍然可以测试许多事项。我们只测试了一次捐赠,但我们可以接受多达 10 次捐赠。这也应该被检查。您希望测试覆盖率有多完整取决于您和项目的要求。还有许多单元测试方面我们在这里没有讨论,例如设置、拆卸和测试驱动开发。

使用 Populus 部署智能合约

Populus 不仅是一个旨在轻松开发和测试智能合约的框架。它还有一个部署智能合约到区块链的工具,包括私有链。

私有链基本上是您的私有以太坊区块链,类似于 Ganache,只是您自己构建它。就像在 Rinkeby 网络中的区块链一样,但您是唯一的矿工。您可以使用 geth 软件手动创建私有区块链;populus只是让您更容易。

创建新的私有chain的命令如下:

(populus-venv) $ populus chain new localblock

此命令将在您的项目目录中创建一些文件。所有生成的文件都位于chains目录内。生成的文件之一是chains/localblock/genesis.json。创世文件的目的是指示以太坊区块链的初始配置:

{
  "parentHash": "0x0000000000000000000000000000000000000000000000000000000000000000",
  "coinbase": "0xcb22827ab291b3094076de25d583c49b902a5606",
  "extraData": "0x686f727365",
  "config": {
    "daoForkBlock": 0,
    "daoForSupport": true,
    "homesteadBlock": 0
  },
  "timestamp": "0x0",
  "mixhash": "0x0000000000000000000000000000000000000000000000000000000000000000",
  "nonce": "0xdeadbeefdeadbeef",
  "alloc": {
    "0xcb22827ab291b3094076de25d583c49b902a5606":{
      "balance": "1000000000000000000000000000000"
    }
  },
  "gasLimit": "0x47d5cc",
  "difficulty": "0x01"
}

您不需要知道这些键的所有含义。coinbase表示所有挖矿奖励应该进入这个账户。alloc表示账户的初始余额。

除了创世文件之外,您还应该获得chains/localblock/init_chain.sh。打开它,您应该有以下内容:

#!/bin/sh
geth --rpc --rpcaddr 127.0.0.1 --rpcport 8545 --rpcapi admin,debug,eth,miner,net,personal,shh,txpool,web3,ws --ws --wsaddr 127.0.0.1 --wsport 8546 --wsapi admin,debug,eth,miner,net,personal,shh,txpool,web3,ws --datadir /home/yourusername/populus_tutorial/chains/localblock/chain_data --maxpeers 0 --networkid 1234 --port 30303 --ipcpath /home/yourusername/populus_tutorial/chains/localblock/chain_data/geth.ipc --nodiscover --mine --minerthreads 1 init /home/yourusername/populus_tutorial/chains/localblock/genesis.json

这个脚本基本上是用特定参数运行 geth 软件。最重要的参数是init,您将其提供给您的genesis.json文件。然后,运行您的私有区块链的初始化:

(populus-venv) $ ./chains/localblock/init_chain.sh

然后,打开chains/localblock/run_chain.sh文件,您应该有以下内容:

#!/bin/sh
geth --rpc --rpcaddr 127.0.0.1 --rpcport 8545 --rpcapi admin,debug,eth,miner,net,personal,shh,txpool,web3,ws --ws --wsaddr 127.0.0.1 --wsport 8546 --wsapi admin,debug,eth,miner,net,personal,shh,txpool,web3,ws --datadir /home/yourusername/populus_tutorial/chains/localblock/chain_data --maxpeers 0 --networkid 1234 --port 30303 --ipcpath /home/yourusername/populus_tutorial/chains/localblock/chain_data/geth.ipc --unlock 0xcb22827ab291b3094076de25d583c49b902a5606 --password /home/yourusername/populus_tutorial/chains/localblock/password --nodiscover --mine --minerthreads 1

您现在需要注意的重要标志是--mine--password--ipcpath--unlock--datadir--mine表示您想在这个私有区块链中进行挖矿,--password是密码文件的位置,--unlock解锁带有密码文件的账户,--datadir是您的私有以太坊区块链目录的位置,--ipcpath是当您运行您的私有以太坊区块链时geth.ipc将位于的位置。

在运行区块链之前,编辑chains/localblock/run_chain.sh脚本,将--ipcpath标志的值更改为/tmp/geth.ipc,然后运行区块链:

(populus-venv) $ ./chains/localblock/run_chain.sh

现在,编辑project.json文件。chains键有一个包含四个键的对象:testertempropstenmainnet。在这个对象中添加localblock键及其值。因此,localblock键必须与testermainnettempropsten键相邻,如下面的代码块所示:

     "localblock": {
       "chain": {
         "class": "populus.chain.ExternalChain"
       },
       "web3": {
         "provider": {
           "class": "web3.providers.ipc.IPCProvider",
         "settings": {
           "ipc_path":"/tmp/geth.ipc"
         }
        }
       },
       "contracts": {
         "backends": {
           "JSONFile": {"$ref": "contracts.backends.JSONFile"},
           "ProjectContracts": {
             "$ref": "contracts.backends.ProjectContracts"
           }
         }
       }
     }

然后,您可以将您的智能合约部署到您的私有区块链中,如下所示:

(populus_venv) $ populus deploy --chain localblock Donation
> Found 2 contract source files
  - contracts/Donation.vy
  - contracts/Greeter.vy
> Compiled 2 contracts
  - contracts/Donation.vy:Donation
  - contracts/Greeter.vy:Greeter
Beginning contract deployment. Deploying 1 total contracts (1 Specified, 0 because of library dependencies).

Donation
Deploying Donation
Deploy Transaction Sent: b'v\xc4`\x06h\x17\xf6\x10\xd7\xb2\x7f\xc6\x94\xeb\x91n\xae?]-\xf43\xb8F\xdc=}\xb33\x03|\xd4'
Waiting for confirmation...

Transaction Mined
=================
Tx Hash : b'v\xc4`\x06h\x17\xf6\x10\xd7\xb2\x7f\xc6\x94\xeb\x91n\xae?]-\xf43\xb8F\xdc=}\xb33\x03|\xd4'
Address : 0xab3B30CFeC1D50DCb0a13671D09d55e63b7cFf40
Gas Provided : 467715
Gas Used : 367715

Verified contract bytecode @ 0xab3B30CFeC1D50DCb0a13671D09d55e63b7cFf40
Deployment Successful.

然后,你可以像在第四章使用 Web3 与智能合约交互中那样玩弄你的智能合约。你可以参考以下 GitLab 链接的代码文件:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_05/populus_tutorial/interact_smart_contract_in_private_chain.py,获取以下代码块的完整代码:

>>> from web3 import Web3, IPCProvider
>>> w3 = Web3(IPCProvider(ipc_path="/tmp/geth.ipc"))
>>> w3.eth.coinbase
'0xcB22827aB291b3094076DE25D583C49b902a5606'
>>> w3.eth.getBalance(w3.eth.coinbase)
1000000011875000000000000000000
>>> address = "0xab3B30CFeC1D50DCb0a13671D09d55e63b7cFf40"
>>> false = False
>>> true = True
>>> abi = 
...
...
>>> donation = w3.eth.contract(address=address, abi=abi)
>>> donation.functions.donatee().call()
'0xcB22827aB291b3094076DE25D583C49b902a5606'

你还在使用 Ganache 吗?如果是的,我们可以部署到 Ganache 区块链。首先启动 Ganache,然后将以下内容添加到projects.json文件中的chains键对象中:

     "ganache": {
       "chain": {
         "class": "populus.chain.ExternalChain"
       },
       "web3": {
         "provider": {
           "class": "web3.providers.HTTPProvider",
           "settings": {
             "endpoint_uri": "http://localhost:7545"
           }
         }
       },
       "contracts": {
         "backends": {
           "JSONFile": {"$ref": "contracts.backends.JSONFile"},
           "ProjectContracts": {
             "$ref": "contracts.backends.ProjectContracts"
           }
         }
       }
     }

按照以下方式运行部署:

(populus_venv) $ populus deploy --chain ganache Donation
> Found 2 contract source files
  - contracts/Donation.vy
  - contracts/Greeter.vy
> Compiled 2 contracts
  - contracts/Donation.vy:Donation
  - contracts/Greeter.vy:Greeter
Beginning contract deployment. Deploying 1 total contracts (1 Specified, 0 because of library dependencies).

Donation
Deploying Donation
Deploy Transaction Sent: b'\xd4\xeb,{\xa0d\n\xb2\xb0\xb2\x1b\x18\xdd \xa1A\x89\xea`\xa8b?A\x14L\x99\xd1rR4\xc7\xfa'
Waiting for confirmation...

Transaction Mined
=================
Tx Hash : b'\xd4\xeb,{\xa0d\n\xb2\xb0\xb2\x1b\x18\xdd \xa1A\x89\xea`\xa8b?A\x14L\x99\xd1rR4\xc7\xfa'
Address : 0x9Dc44aa8d05c86388E647F954D00CaA858837804
Gas Provided : 467715
Gas Used : 367715

Verified contract bytecode @ 0x9Dc44aa8d05c86388E647F954D00CaA858837804
Deployment Successful.

然后,当你想在 Ganache 中玩弄你的智能合约时,你只需要像在[第四章中学到的那样调整提供者,使用 Web3 与智能合约交互

 >>> from web3 import Web3, HTTPProvider
 >>> w3 = Web3(HTTPProvider('http://localhost:7545'))

其余的代码是一样的。你可以将脚本命名为interact_smart_contract_in_ganache.py。在 Ganache 中,coinbase 账户和 manager 账户是 Ganache 中的第一个账户。参考 GitLab 链接获取与 Ganache 中智能合约交互的完整代码,gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_05/populus_tutorial/interact_smart_contract_in_ganache.py

from web3 import Web3, HTTPProvider
w3 = Web3(HTTPProvider("http://localhost:7545"))

print(w3.eth.coinbase)
print(w3.eth.getBalance(w3.eth.coinbase))

# Change this address to your smart contract address
address = "0x9Dc44aa8d05c86388E647F954D00CaA858837804"
false = False
true = True
abi = [
...
...
donation = w3.eth.contract(address=address, abi=abi)
print(donation.functions.donatee().call())

摘要

在本章中,你学会了如何使用 Populus 开发框架开发智能合约。Populus 不支持 Vyper,只支持 Solidity。因此,你需要通过安装 Vyper 并编辑 Populus 项目配置文件来添加 Vyper 支持。你还学会了如何为智能合约创建单元测试。在这个单元测试中,你使用web3对象与智能合约进行交互并进行程序化测试。之后,你学会了如何创建私有链。最后,你将智能合约部署到了这个私有链和 Ganache。

在下一章中,你将构建一个比捐赠智能合约更复杂的区块链应用。这个应用是去中心化的投票应用。

第六章:构建一个实用的去中心化应用程序

在本章中,我们将在区块链上编写一个流行的应用程序,这将是一个由区块链驱动的安全投票应用程序。您有所有开发此应用程序的工具,即 populus 和web3.py

以下是本章将涵盖的主题:

  • 开发一个简单的投票应用

  • 了解智能合约中的事件

  • 开发一个商业投票应用程序

  • 开发基于令牌的投票应用

  • 讨论另一种类型的投票应用程序

开发一个简单的投票应用

首先,我们将构建最简单的投票应用程序,比 Vyper 软件源代码中提供的投票应用程序示例还要简单。让我们设置我们的 Populus 项目目录:

$ virtualenv -p python3.6 voting-venv
$ source voting-venv/bin/activate (voting-venv) $ pip install eth-abi==1.2.2 (voting-venv) $ pip install eth-typing==1.1.0 (voting-venv) $ pip install web3==4.7.2 (voting-venv) $ pip install -e git+https://github.com/ethereum/populus#egg=populus (voting-venv) $ pip install vyper
(voting-venv) $ mkdir voting_project
(voting-venv) $ cd voting_project
(voting-venv) $ mkdir tests contracts
(voting-venv) $ cp ../voting-venv/src/populus/populus/assets/defaults.v9.config.json project.json

然后,通过将键编译的值更改为以下内容,将 Vyper 支持添加到project.json中:

"compilation": {
    "backend": {
      "class": "populus.compilation.backends.VyperBackend"
    },
    "contract_source_dirs": [
      "./contracts"
    ],
    "import_remappings": []
},

Vyper 的最新版本是 0.1.0b6,这破坏了 Populus。开发人员需要一些时间来解决这个问题。如果到您阅读本书时错误仍未被修复,您可以自行修补 Populus。

首先,使用以下命令检查错误是否已修复:

(voting-venv) $ cd voting-venv/src/populus
(voting-venv) $ grep -R "compile(" populus/compilation/backends/vyper.py
            bytecode = '0x' + compiler.compile(code).hex()
            bytecode_runtime = '0x' + compiler.compile(code, bytecode_runtime=True).hex()

在我们的案例中,错误尚未被修复。因此,让我们修补 Populus 以修复错误。确保您仍然在相同的目录(voting-venv/src/populus)中:

(voting-venv) $ wget https://patch-diff.githubusercontent.com/raw/ethereum/populus/pull/484.patch
(voting-venv) $ git apply 484.patch

现在,在contracts目录中创建一个简单的投票智能合约。命名为SimpleVoting.vy。*请参考以下 GitLab 链接获取完整代码 - gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_06/voting_project/contracts/SimpleVoting.vy

struct Proposal:
    name: bytes32
    vote_count: int128

Voting: event ({_from: indexed(address), _proposal: int128})

proposals: public(map(int128, Proposal))

proposals_count: public(int128)
voters_voted: public(map(address, int128))

...
...

@public
@constant
def winner_name() -> bytes32:
    return self.proposals[self.winning_proposal()].name

让我们讨论这个简单的投票智能合约。它受到 Vyper 源代码中投票示例的启发,但这个示例甚至更简化。原始示例具有委托功能,这将使事情难以理解。我们从结构数据类型变量声明开始:

struct Proposal:
    name: bytes32
    vote_count: int128

数据结构是一个具有复合数据类型的变量,其中包含提案的名称和提案的金额。Proposal结构中的vote_count数据类型为int128,而Proposal结构中的name数据类型为bytes32。您也可以使用uint256而不是int128数据类型来表示Proposal结构中的vote_count。不过,这不会有任何区别。但是,bytes32是一个新的数据类型。正如您可能还记得的那样,如果要在 Vyper 中使用字符串(或字节数组)数据类型,如果该字符串的长度小于 20,则使用bytes[20]

bytes32 是另一种类似于bytes[32]的字符串数据类型,但有一个特殊之处;如果您将b'messi'字符串设置为具有bytes[32]类型的变量,并使用web3检索它,您将得到b'messi'。但是,如果您将b'messi'字符串设置为具有bytes32类型的变量,并使用web3检索它,您将得到b'messi\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'。此字符串将填充直到达到 32 字节。默认情况下,您应该使用bytes[20]bytes[256]作为字符串数据类型,而不是使用bytes32。那么为什么我在这个智能合约中使用bytes32?我有一个很好的理由这样做,但我们需要先继续到构造函数以了解我为什么使用bytes32来保存提案的名称:

Voting: event ({_from: indexed(address), _proposal: int128})

这是我们第一次在智能合约中使用事件。event是 Vyper 中的一个关键字,用于创建事件。事件是智能合约内发生的事情,我们的客户端(web3程序)希望订阅。在这个语句中,Voting是事件的名称,有两个参数。第一个参数是_from,类型为addressindexed用于使用_from作为过滤器进行事件过滤。第二个参数是_proposal,类型为int128。记住,int128是一个 128 位整数。当我们在客户端程序中订阅它时,这个事件将变得更清晰。现在,让我们继续下一个:

proposals: public(map(int128, Proposal))

这个变量是一个映射数据类型变量,将一个int128数据类型变量映射到一个Proposal结构变量。基本上,这是一个提案列表:

proposals_count: public(int128)

这是一个辅助变量,用来计算这个智能合约中有多少提案:

voters_voted: public(int128[address])

这用于检查一个账户是否已经投票。我们不希望一个账户对同一个提案投票多次。记住,这是一个映射数据类型。默认情况下,不存在的值指向一个空值。在int128的上下文中,空值是0

@public
def __init__(_proposalNames: bytes32[2]):
    for i in range(2):
        self.proposals[i] = Proposal({
            name: _proposalNames[i],
            vote_count: 0
        })
        self.proposals_count += 1

这个构造函数有一个参数,是一个bytes32数组。在构造函数内部,它将迭代两次(我们将提案的数量硬编码为两个)。每次迭代都会将一个新成员设置到proposals映射变量中。name是从参数中设置的,vote_count初始化为 0。然后,对于每次迭代,proposals_count都会增加 1。

我之所以使用bytes32作为提案名称的数据类型,是因为如果我使用bytes[128]作为提案名称的数据类型,我无法将其作为参数发送。

Vyper 编程语言中的智能合约方法不能接受嵌套数组,比如bytes[128][2]作为参数(至少在 Vyper 的最新版本中是这样):

@public
def vote(proposal: int128):
    assert self.voters_voted[msg.sender] == 0
    assert proposal < self.proposals_count

    self.voters_voted[msg.sender] = 1
    self.proposals[proposal].vote_count += 1

    log.Voting(msg.sender, proposal)

这是投票的函数。它接受一个名为proposal的参数。在这里,用户用一个整数为提案投票。所以,如果用户使用0这样的参数调用vote方法,比如vote(0),这意味着用户为第一个提案投票。当你设计自己的投票智能合约时,当然你也可以使用字符串来投票,比如vote(b'proposal1')。在这里,我使用整数来简化事情。

在这个函数中,我们断言选民还没有使用这个语句投票:assert self.voters_voted[msg.sender] == 0。投票后,我们将voters_voted的值设置为选民的地址作为键的1self.voters_voted[msg.sender] = 1。我们还通过检查投票的值是否小于提案的数量(即2)来验证投票是否有效。这个函数的关键是以下语句:self.proposals[proposal].vote_count += 1。在这个函数的结尾,我们的Voting事件在这个语句中被使用:log.Voting(msg.sender, proposal)。这类似于广播发生了重要的事情——嘿,世界!有一个Voting事件,有两个参数,msg.sender作为address参数,proposal作为int128参数。然后,任何订阅了这个事件的人都会收到通知。事件的订阅发生在客户端,使用web3库,如下面的代码所示:

@private
@constant
def winning_proposal() -> int128:
    winning_vote_count: int128 = 0
    winning_proposal: int128 = 0
    for i in range(2):
        if self.proposals[i].vote_count > winning_vote_count:
            winning_vote_count = self.proposals[i].vote_count
            winning_proposal = i
    return winning_proposal

这个私有函数设计用来检查哪个提案获得了最多的投票:

@public
@constant
def winner_name() -> bytes32:
    return self.proposals[self.winning_proposal()].name

这个public函数设计用来获取获得最多票数的提案的名称。这个函数使用了前面描述的私有函数。

这个智能合约很简单,但并不完美,因为存在一个错误。例如,在vote函数中,我们没有处理投票的负值。此外,提案的数量是硬编码为 2。但是,它可以完成工作。

然后,你可以像通常一样编译智能合约的代码:

(voting-venv) $ populus compile

作为一个好公民,让我们为这个智能合约编写一个测试。在tests目录中创建一个名为test_simple_voting_app.py的文件。参考以下 GitLab 链接获取以下代码块的完整代码:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_06/voting_project/tests/test_simple_voting_app.py

import pytest
import eth_tester

@pytest.fixture()
def voting(chain):
    SimpleVotingFactory = chain.provider.get_contract_factory('SimpleVoting')
    deploy_txn_hash = SimpleVotingFactory.constructor([b'Messi', b'Ronaldo']).transact()
    contract_address = chain.wait.for_contract_address(deploy_txn_hash)
    return SimpleVotingFactory(address=contract_address)
...
...
    assert voting.functions.proposals__vote_count(0).call() == 2
    assert voting.functions.proposals__vote_count(1).call() == 1
    assert voting.functions.winner_name().call()[:5] == b'Messi'

让我们逐个讨论这个测试:

@pytest.fixture()
def voting(chain):
    SimpleVotingFactory = chain.provider.get_contract_factory('SimpleVoting')
    deploy_txn_hash = SimpleVotingFactory.constructor([b'Messi', b'Ronaldo']).transact()
    contract_address = chain.wait.for_contract_address(deploy_txn_hash)
    return SimpleVotingFactory(address=contract_address)

因为我们简单的投票智能合约的构造函数需要一个参数,所以我们需要在测试中使用一个 fixture,如第五章Populus 开发框架中所讨论的那样。然后,我们的 fixture 可以作为测试方法的参数使用:

def test_initial_state(voting):
    assert voting.functions.proposals_count().call() == 2

    messi = voting.functions.proposals__name(0).call()
    assert len(messi) == 32
    assert messi[:5] == b'Messi'
    assert voting.functions.proposals__name(1).call()[:7] == b'Ronaldo'
    assert voting.functions.proposals__vote_count(0).call() == 0
    assert voting.functions.proposals__vote_count(1).call() == 0

这是为了检查部署后智能合约的状态。这里有一件非常独特的事情;在提案变量内的结构数据的名称变量的长度是32,即使我们将其设置为值b'messi',这就是bytes32数据类型的特殊之处。这就是为什么我们要切片变量以获取我们想要的内容。然后,对于下一个测试方法,我们使用chain参数以及voting参数:

def test_vote(voting, chain):
    t = eth_tester.EthereumTester()
    account2 = t.get_accounts()[1]

    assert voting.functions.proposals__vote_count(0).call() == 0

    set_txn_hash = voting.functions.vote(0).transact({'from': account2})
    chain.wait.for_receipt(set_txn_hash)

    assert voting.functions.proposals__vote_count(0).call() == 1

这用于测试vote函数。我们测试vote函数是否确实改变了proposals变量的vote_count属性:

def test_fail_duplicate_vote(voting, chain):
    t = eth_tester.EthereumTester()
    account2 = t.get_accounts()[1]

    set_txn_hash = voting.functions.vote(0).transact({'from': account2})
    chain.wait.for_receipt(set_txn_hash)

    with pytest.raises(eth_tester.exceptions.TransactionFailed):
        voting.functions.vote(1).transact({'from': account2})

    with pytest.raises(eth_tester.exceptions.TransactionFailed):
        voting.functions.vote(0).transact({'from': account2})

这确保了我们不能使用同一个账户投票超过一次。正如我们在第五章Populus 开发框架中学到的那样,您可以使用pytest.raises with语句将失败的情况包装起来。最后一个测试用例是检查获胜的提案:

def test_winning_proposal(voting, chain):
    t = eth_tester.EthereumTester()
    account2 = t.get_accounts()[1]
    account3 = t.get_accounts()[2]
    account4 = t.get_accounts()[3]

    set_txn_hash = voting.functions.vote(0).transact({'from': account2})
    chain.wait.for_receipt(set_txn_hash)

    set_txn_hash = voting.functions.vote(0).transact({'from': account3})
    chain.wait.for_receipt(set_txn_hash)

    set_txn_hash = voting.functions.vote(1).transact({'from': account4})
    chain.wait.for_receipt(set_txn_hash)

    assert voting.functions.proposals__vote_count(0).call() == 2
    assert voting.functions.proposals__vote_count(1).call() == 1
    assert voting.functions.winner_name().call()[:5] == b'Messi'

在这个测试中,您可以使用t.get_accounts辅助方法使用三个账户。

部署一个构造函数中带有参数的智能合约

让我们将这个智能合约部署到以太坊区块链。然而,我们首先需要意识到有一些事情会使情况复杂化。首先,event在 Ganache 中不起作用,因此我们必须将其部署到 Rinkeby 网络或私有以太坊区块链。其次,我们的智能合约在构造函数中有一个参数。要部署带有参数的智能合约,我们需要使用不同的方法;我们不能像在第五章Populus 开发框架中演示的那样使用普通方法。在第五章Populus 开发框架中,我们使用 Populus 以这种方式部署了一个智能合约:populus deploy --chain localblock Donation.

Populus 方法只能部署一个没有参数的构造函数的智能合约。让我们一一克服这些障碍。我们需要做的第一件事是将其部署到私有以太坊区块链,如下所示:

  1. voting_project目录中,运行以下命令:
(voting-venv) $ populus chain new localblock
  1. 然后,使用init_chain.sh脚本初始化私有链:
(voting-venv) $ ./chains/localblock/init_chain.sh
  1. 编辑chains/localblock/run_chain.sh并将--ipcpath标志的值更改为/tmp/geth.ipc。然后,运行区块链:
(voting-venv) $ ./chains/localblock/run_chain.sh
  1. 现在,编辑project.json文件。chains键有一个对象,其中有 4 个键:testertempropstenmainnet。在这个对象中添加一个名为localblock的键及其值:
    "localblock": {
      "chain": {
        "class": "populus.chain.ExternalChain"
      },
      "web3": {
        "provider": {
          "class": "web3.providers.ipc.IPCProvider",
        "settings": {
          "ipc_path":"/tmp/geth.ipc"
        }
       }
      },
      "contracts": {
        "backends": {
          "JSONFile": {"$ref": "contracts.backends.JSONFile"},
          "ProjectContracts": {
            "$ref": "contracts.backends.ProjectContracts"
          }
        }
      }
    }

运行区块链需要一个专用的终端。因此,打开一个新的终端,执行一个虚拟环境脚本,然后进入voting_project目录。创建这个文件并命名为deploy_SmartVoting.py

from populus import Project
from populus.utils.wait import wait_for_transaction_receipt

def main():

    project = Project()

    chain_name = "localblock"

    with project.get_chain(chain_name) as chain:

        SimpleVoting = chain.provider.get_contract_factory('SimpleVoting')

        txhash = SimpleVoting.deploy(transaction={"from": chain.web3.eth.coinbase}, args=[[b'Messi', b'Ronaldo']])
        receipt = wait_for_transaction_receipt(chain.web3, txhash)
        simple_voting_address = receipt["contractAddress"]
        print("SimpleVoting contract address is", simple_voting_address)

if __name__ == "__main__":
    main()

现在,让我们讨论一下这个程序的作用:

from populus import Project
from populus.utils.wait import wait_for_transaction_receipt

我们从populus库中导入工具,Project代表project.json配置文件。wait_for_transaction_receipt是一个等待我们的交易在以太坊区块链中确认的函数:

def main():

    project = Project()

    chain_name = "localblock"

    with project.get_chain(chain_name) as chain:

main函数中,我们初始化了一个Project实例,然后获取了localblock链:

    "localblock": {
      "chain": {
        "class": "populus.chain.ExternalChain"
      },
      "web3": {
        "provider": {
          "class": "web3.providers.ipc.IPCProvider",
        "settings": {
          "ipc_path":"/tmp/geth.ipc"
        }
       }
      },
      "contracts": {
        "backends": {
          "JSONFile": {"$ref": "contracts.backends.JSONFile"},
          "ProjectContracts": {
            "$ref": "contracts.backends.ProjectContracts"
          }
        }
      }
    }

chain对象现在代表project.json文件中的这个json对象。

我们从build/contracts.json中获取SimpleVoting智能合约工厂:

SimpleVoting = chain.provider.get_contract_factory('SimpleVoting')

然后,我们将我们的智能合约部署到私有以太坊区块链上:

txhash = SimpleVoting.deploy(transaction={"from": chain.web3.eth.coinbase}, args=[[b'Messi', b'Ronaldo']])

它接收两个关键字参数,transactionargstransaction参数是一个交易字典。在这里,我们设置了from参数。chain.web3.eth.coinbase是我们的默认账户,在testing/development场景中很常见。在这里,我们使用默认账户而不使用私钥。在这个交易对象中,我们还可以设置gasgasPrice和其他交易参数。args关键字参数允许我们向智能合约的构造函数发送参数。它是一个嵌套数组,[[b'Messi', b'Ronaldo']],因为内部数组是智能合约构造函数中的_proposalNames参数。

外部数组被设计用来封装构造函数中的其他参数,但在这种情况下我们只有一个参数:

@public
def __init__(_proposalNames: bytes32[2]):
    for i in range(2):
        self.proposals[i] = {
            name: _proposalNames[i],
            vote_count: 0
        }
        self.proposals_count += 1

receipt = wait_for_transaction_receipt(chain.web3, txhash)

我们等待交易确认。然后,我们从部署过程中获取智能合约的地址:

simple_voting_address = receipt["contractAddress"]
print("SimpleVoting contract address is", simple_voting_address)

receipt对象是区块链中描述交易确认的对象。在这种情况下,我们关心的是地址,也就是receipt对象中的contractAddress键:

if __name__ == "__main__":
    main()

这是为了执行main函数而设计的。

不像 Ganache,那里有 10 个账户(每个账户都有 100 个以太币),在 Populus 的默认设置下的私有以太坊区块链中,你只有一个账户,配备了 1 万亿以太币!以下脚本允许你查看默认账户有多少以太币:

from web3 import Web3, IPCProvider

w3 = Web3(IPCProvider(ipc_path='/tmp/geth.ipc'))

print(w3.fromWei(w3.eth.getBalance(w3.eth.coinbase), 'ether'))

在这个智能合约中,我们想要使用多个账户来玩我们的智能合约。所以让我们在这个以太坊私有区块链中创建 10 个账户。在voting_project目录中创建一个新文件,命名为create_10_accounts_on_private_chain.py

from web3 import Web3, IPCProvider

w3 = Web3(IPCProvider(ipc_path='/tmp/geth.ipc'))

with open('10_accounts.txt', 'w') as f:
    for i in range(10):
        f.write(w3.personal.newAccount('password123') + "\n")

我们将新账户的地址写入文件,以便以后重复使用。你需要注意的函数是w3.personal.newAccount('password123')。这将给你公共地址。私钥将使用password123进行加密。这将保存在chains/localblock/chain_data/keystore目录中。加密文件的名称类似于UTC—2018-10-26T13-13-25.731124692Z—36461a003a03f857d60f5bd0b8e8a64aab4e4535。文件名的结尾部分是public地址。在这个文件名的示例中,public地址是36461a003a03f857d60f5bd0b8e8a64aab4e4535。执行这个脚本。10 个账户的public地址将被写入10_accounts.txt文件。

如果你查看chains/localblock/chain_data/keystore目录,你会看到至少 11 个文件。

这 10 个新账户中的每一个都配备了 0 个以太币。要在我们的智能合约中投票,你不能有空余的余额。那么,为什么我们不把默认账户的钱分发给这 10 个账户呢?在voting_project目录下创建一个文件,命名为distribute_money.py。参考以下 GitLab 链接中的代码文件获取完整的代码:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_06/voting_project/distribute_money.py

from web3 import Web3, IPCProvider
from populus.utils.wait import wait_for_transaction_receipt
import glob

w3 = Web3(IPCProvider(ipc_path='/tmp/geth.ipc'))

address = 'fa146d7af4b92eb1751c3c9c644fa436a60f7b75'

...
...

        signed = w3.eth.account.signTransaction(transaction, private_key)
        txhash = w3.eth.sendRawTransaction(signed.rawTransaction)
        wait_for_transaction_receipt(w3, txhash)

现在,让我们逐行讨论这个脚本:

from web3 import Web3, IPCProvider
from populus.utils.wait import wait_for_transaction_receipt
import glob

您已经了解了Web3IPCProviderwait_for_transaction_receiptglob来自 Python 标准库。它的目的是从目录中过滤文件:

w3 = Web3(IPCProvider(ipc_path='/tmp/geth.ipc'))

我们使用套接字连接到以太坊节点:

address = 'fa146d7af4b92eb1751c3c9c644fa436a60f7b75'

这是我们默认账户的地址。您怎么知道?您可以在连接到这个私有以太坊区块链的脚本中使用w3.eth.coinbase找到它,或者您可以查看chains/localblock/chain_data/keystore目录中的文件名。在初始化和运行私有以太坊区块链后,只有一个文件名。现在,在您初始化另外 10 个账户后,文件的数量自然会变成 11:

with open('chains/localblock/password') as f:
    password = f.read().rstrip("\n")

解锁默认账户的密码存储在chains/localblock/password的纯文本文件中:

    encrypted_private_key_file = glob.glob('chains/localblock/chain_data/keystore/*' + address)[0]
    with open(encrypted_private_key_file) as f2:
        private_key = w3.eth.account.decrypt(f2.read(), password)

找到这个之后,我们使用w3.eth.account.decrypt方法解密加密文件:

w3.eth.defaultAccount = w3.eth.coinbase

这是为了避免在创建交易时提供from参数的义务:

with open('10_accounts.txt', 'r') as f:
    accounts = f.readlines()
    for account in accounts:

我们打开了10_accounts.txt,里面包含了我们拥有的所有新账户,然后我们一个一个地迭代这些账户:

        nonce = w3.eth.getTransactionCount(Web3.toChecksumAddress(w3.eth.defaultAccount))
        transaction = {
          'to': Web3.toChecksumAddress(account.rstrip("\n")),
          'value': w3.toWei('10', 'ether'),
          'gas': 1000000,
          'gasPrice': w3.toWei('20', 'gwei'),
          'nonce': nonce
        }

在将其提供给交易对象之前,我们使用w3.eth.getTransactionCount检查最新的 nonce 值。交易对象有tovaluegasgasPrice,以及nonce键。在这里,我们想给每个账户发送 10 个以太币:

        signed = w3.eth.account.signTransaction(transaction, private_key)
        txhash = w3.eth.sendRawTransaction(signed.rawTransaction)

我们用我们的私钥对交易进行签名,然后使用w3.eth.sendRawTransaction方法将交易广播给矿工:

wait_for_transaction_receipt(w3, txhash)

这很重要。如果您只向一个账户发送资金,您可以跳过它。但是,由于我们按顺序广播了 10 笔交易,您必须在广播下一笔交易之前等待每笔交易先确认。

这样想:您广播了一个 nonce 为 3 的发送 10 个以太币的交易,然后矿工需要时间来确认这笔交易。但是,在短时间内,您广播了一个新的 nonce 为 4 的交易。得到这笔交易的矿工会向您抱怨,因为您试图从 nonce 2 跳到 nonce 4。请记住,nonce 3 的交易需要时间来确认。

执行文件后,您可以检查您的 10 个账户每个都有 10 个以太币。

让我们基于智能合约创建我们的简单去中心化投票应用。离开voting_project,创建一个新目录来包含我们的应用。创建目录后,输入以下内容:

(voting-venv) $ mkdir voting_dapp
(voting-venv) $ cd voting_dapp

让我们创建一个订阅Voting事件的程序。将此文件命名为watch_simple_voting.py

from web3 import Web3, IPCProvider

w3 = Web3(IPCProvider(ipc_path='/tmp/geth.ipc'))

false = False
true = True
abi = …. # Take the abi from voting_projects/build/contracts.json.

with open('address.txt', 'r') as f:
    content = f.read().rstrip("\n")

address = content

SimpleVoting = w3.eth.contract(address=address, abi=abi)

event_filter = SimpleVoting.events.Voting.createFilter(fromBlock=1)

import time
while True:
    print(event_filter.get_new_entries())
    time.sleep(2)

现在,让我们逐行讨论这个程序:

from web3 import Web3, IPCProvider

w3 = Web3(IPCProvider(ipc_path='/tmp/geth.ipc'))

We connect to private Ethereum blockchain using socket.

false = False
true = True
abi = …. # Take the abi from voting_projects/build/contracts.json.

我们需要abi来连接到一个智能合约。您可以从智能合约的编译中获得这个。由于abi是一个json对象,其中有一个布尔值设置为truefalse,而 Python 的布尔值是TrueFalse(注意大写),我们需要调整它:

with open('address.txt', 'r') as f:
    content = f.read().rstrip("\n")

address = content

要连接到一个智能合约,您需要一个地址。这是部署脚本中的地址。您也可以将地址设置为代码中硬编码的地址,如下所示:

address = '0x993FFADB39D323D8B134F6f0CdD83d510c45D306'

但是,我更喜欢把它放在一个外部文件中:

event_filter = SimpleVoting.events.Voting.createFilter(fromBlock=1)

这是为了创建一个订阅SimpleVoting智能合约的Voting事件。语法如下:

<name of smart contract>.events.<name of event>.createFilter(fromBlock=1)

fromBlock是历史指针。块越低,历史越早:

import time
while True:
    print(event_filter.get_new_entries())
    time.sleep(2)

然后,我们订阅投票事件。您会得到类似于这样的东西:

[]
[]
[]

让这个脚本运行。不要退出应用程序。打开一个新的终端,执行我们的虚拟环境脚本,并进入voting_dapp项目。这样做后,创建一个新的脚本,并将其命名为simple_voting_client.py。参考以下 GitLab 链接中的代码文件获取完整的代码:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_06/voting_dapp/simple_voting_client.py

from web3 import Web3, IPCProvider
from populus.utils.wait import wait_for_transaction_receipt
import glob

w3 = Web3(IPCProvider(ipc_path='/tmp/geth.ipc'))

with open('client_address.txt', 'r') as f:
    content = f.read().rstrip("\n")

address = content.lower()

...
...

signed = w3.eth.account.signTransaction(txn, private_key=private_key)
w3.eth.sendRawTransaction(signed.rawTransaction)

现在,让我们逐行讨论这个。我们从脚本的顶部开始:

from web3 import Web3, IPCProvider
from populus.utils.wait import wait_for_transaction_receipt
import glob

w3 = Web3(IPCProvider(ipc_path='/tmp/geth.ipc'))

with open('client_address.txt', 'r') as f:
    content = f.read().rstrip("\n")

address = content.lower()

encrypted_private_key_file = glob.glob('../voting_project/chains/localblock/chain_data/keystore/*' + address)[0]
with open(encrypted_private_key_file) as f:
    password = 'password123'
    private_key = w3.eth.account.decrypt(f.read(), password)
    w3.eth.defaultAccount = '0x' + address

这里的逻辑与之前的脚本相同。您首先使用password123打开加密文件。然后在client_address.txt文件中设置选民的账户地址,以使此脚本灵活。您可以在脚本中硬编码选民的账户地址:

false = False
true = True
abi = …

在这里,您以通常的方式从智能合约编译中设置abi

with open('address.txt', 'r') as f:
    content = f.read().rstrip("\n")

smart_contract_address = content

SimpleVoting = w3.eth.contract(address=smart_contract_address, abi=abi)

请记住,在这个脚本中有两个地址。第一个是选民或客户的地址。第二个是智能合约的地址。然后,您需要获取 nonce:

nonce = w3.eth.getTransactionCount(Web3.toChecksumAddress(w3.eth.defaultAccount))

在构建交易时,您使用此 nonce:

txn = SimpleVoting.functions.vote(0).buildTransaction({
        'gas': 70000,
        'gasPrice': w3.toWei('1', 'gwei'),
        'nonce': nonce
      })

这是vote函数。在这里,我们为索引为0的提案投票,即b'messi'。您提交gasgasPricenonce,并且省略from,因为您已经设置了w3.eth.defaultAccount

signed = w3.eth.account.signTransaction(txn, private_key=private_key)
w3.eth.sendRawTransaction(signed.rawTransaction)

最后几行是用于签名和广播交易的。

执行脚本,然后转到您运行watch_simple_voting.py脚本的终端。然后您会得到类似于这样的东西:

[]
[]
[]
[]
[AttributeDict({'args': AttributeDict({'_from': '0xf0738EF5635f947f13dD41F34DAe6B2caa0a9EA6', '_proposal': 0}), 'event': 'Voting', 'logIndex': 0, 'transactionIndex': 0, 'transactionHash': HexBytes('0x61b4c59425a6305af4f2560d1cd10d1540243b1f74ce07fa53a550ada2e649e7'), 'address': '0x993FFADB39D323D8B134F6f0CdD83d510c45D306', 'blockHash': HexBytes('0xb458542d9bee85ed7673d94f036e55f8daca188e5871cc910eb49cf4895964a0'), 'blockNumber': 3110})]
[]
[]
[]
[]
[]
[]

就是这样。在实际应用中,此事件可用于在分散式应用程序中提供通知。然后,您可以更新投票的排名或其他任何您喜欢的内容。

您还可以从一开始获取所有事件。还记得获取事件的代码吗?如下所示:

import time
while True:
    print(event_filter.get_new_entries())
    time.sleep(2)

您可以使用get_all_entries而不是get_new_entries来检索从一开始的所有事件,如下所示:

event_filter.get_all_entries()

开发商业投票应用程序

让我们将我们的智能合约升级为商业智能合约。为了投票,选民需要支付一小笔钱。这类似于美国偶像,人们通过短信投票来决定谁获胜。

返回voting_project目录,打开contracts目录中的新文件,命名为CommercialVoting.vy。有关此代码块的完整代码,请参考以下 GitLab 链接中的代码文件:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_06/voting_project/contracts/CommercialVoting.vy

struct Proposal:
    name: bytes32
    vote_count: int128

proposals: public(map(int128, Proposal))

voters_voted: public(map(address, int128))

manager: public(address)

...
...

@public
def withdraw_money():
    assert msg.sender == self.manager

    send(self.manager, self.balance)

这个智能合约类似于SimpleVoting.vy,但具有额外的支付功能。我们不会逐行讨论它,但我们将看一下之前的智能合约和这个之间的区别:

@public
def __init__(_proposalNames: bytes32[2]):
    for i in range(2):
        self.proposals[i] = Proposal({
            name: _proposalNames[i],
            vote_count: 0
        })
    self.manager = msg.sender

在这个构造函数中,我们保存了启动智能合约的账户的地址:

@public
@payable
def vote(proposal: int128):
    assert msg.value >= as_wei_value(0.01, "ether")
    assert self.voters_voted[msg.sender] == 0
    assert proposal < 2 and proposal >= 0

    self.voters_voted[msg.sender] = 1
    self.proposals[proposal].vote_count += 1

在这个vote函数中,我们添加了@payable装饰器,以便人们在想要投票时可以发送资金。除此之外,我们要求最低支付为0.01以太币,使用此语句:assert msg.value >= as_wei_value(0.01, "ether")

@public
def withdraw_money():
    assert msg.sender == self.manager

    send(self.manager, self.balance)

当然,我们必须创建一个从智能合约中提取以太币的函数。在这里,我们将以太币发送到经理账户。

现在,让我们继续测试智能合约。在tests目录中创建测试文件,命名为test_commercial_voting.py。有关完整代码,请参考以下 GitLab 链接中的代码文件:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_06/voting_project/tests/test_commercial_voting.py

import pytest
import eth_tester

@pytest.fixture()
def voting(chain):
    CommercialVotingFactory = chain.provider.get_contract_factory('CommercialVoting')
    deploy_txn_hash = CommercialVotingFactory.constructor([b'Messi', b'Ronaldo']).transact()
    contract_address = chain.wait.for_contract_address(deploy_txn_hash)
    return CommercialVotingFactory(address=contract_address)

...
...

    assert abs((after_withdraw_balance - initial_balance) - web3.toWei('1', 'ether')) < web3.toWei('10', 'gwei')

让我们逐个讨论测试函数:

def test_initial_state(voting, web3):
    assert voting.functions.manager().call() == web3.eth.coinbase

这是为了测试经理变量是否指向启动智能合约的账户。请记住,web3.eth.coinbase是默认账户。测试投票是否需要一定数量的以太币和账户,我们可以从t.get_accounts()中获取:

def test_vote_with_money(voting, chain, web3):
    t = eth_tester.EthereumTester()
    account2 = t.get_accounts()[1]
    account3 = t.get_accounts()[2]

    set_txn_hash = voting.functions.vote(0).transact({'from': account2,
                                                      'value': web3.toWei('0.05', 'ether')})
    chain.wait.for_receipt(set_txn_hash)

    set_txn_hash = voting.functions.vote(1).transact({'from': account3,
                                                      'value': web3.toWei('0.15', 'ether')})
    chain.wait.for_receipt(set_txn_hash)

    assert web3.eth.getBalance(voting.address) == web3.toWei('0.2', 'ether')

这是为了测试您可以在vote函数中发送以太币。您还测试了在智能合约中累积的以太币的余额:

def test_vote_with_not_enough_money(voting, web3):
    t = eth_tester.EthereumTester()
    account2 = t.get_accounts()[1]

    with pytest.raises(eth_tester.exceptions.TransactionFailed):
        voting.functions.vote(0).transact({'from': account2,
                                           'value': web3.toWei('0.005', 'ether')})

这是为了测试在您想要投票时,您需要发送至少0.01以太币:

def test_manager_account_could_withdraw_money(voting, web3, chain):
    t = eth_tester.EthereumTester()
    account2 = t.get_accounts()[1]

    set_txn_hash = voting.functions.vote(0).transact({'from': account2, 'value': web3.toWei('1', 'ether')})
    chain.wait.for_receipt(set_txn_hash)

    initial_balance = web3.eth.getBalance(web3.eth.coinbase)
    set_txn_hash = voting.functions.withdraw_money().transact({'from': web3.eth.coinbase})
    chain.wait.for_receipt(set_txn_hash)
    after_withdraw_balance = web3.eth.getBalance(web3.eth.coinbase)

    assert abs((after_withdraw_balance - initial_balance) - web3.toWei('1', 'ether')) < web3.toWei('10', 'gwei')

这是这个智能合约中最重要的测试之一。它旨在测试您是否可以正确地从智能合约中提取以太币。您可以在提取前后检查余额,并确保差额大约为 1 个以太币(因为您需要支付燃气费)。

开发基于代币的投票应用

现在,让我们在区块链上开发一个基于代币的投票应用。我所说的基于代币的投票是指,为了投票,您必须拥有在智能合约中创建的代币。如果您用这个代币投票,那么这个代币就会被销毁,这意味着您不能投两次票。在这个智能合约中,代币的数量也是有限的,不像之前的投票应用程序,无限的账户可以投票。让我们在contracts目录中编写一个智能合约,并将文件命名为TokenBasedVoting.vy。请参考以下 GitLab 链接中的代码文件获取完整代码:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_06/voting_project/contracts/TokenBasedVoting.vy

struct Proposal:
    name: bytes32
    vote_count: int128

proposals: public(map(int128, Proposal))

...
...
@public
@constant
def winner_name() -> bytes32:
    return self.proposals[self.winning_proposal()].name

让我们逐行讨论这个脚本:

struct Proposal:
    name: bytes32
    vote_count: int128

proposals: public(map(int128, Proposal))

token: public(map(address, bool))
index: int128
maximum_token: int128
manager: address

您已经熟悉了proposals变量,它与之前的投票应用具有相同的目的。token是一个新变量,旨在跟踪代币的所有者。indexmaximum_token是用来计算我们分配了多少个代币的变量。请记住,我们希望限制代币的数量。manager是启动智能合约的人:

@public
def __init__(_proposalNames: bytes32[2]):
    for i in range(2):
        self.proposals[i] = Proposal({
            name: _proposalNames[i],
            vote_count: 0
        })
    self.index = 0
    self.maximum_token = 8
    self.manager = msg.sender

在构造函数中,在设置proposals变量之后,我们将index初始化为0maximum_token初始化为8。在这个智能合约中只有8个代币可用,这意味着只能尝试8次投票。manager变量初始化为启动智能合约的变量:

@public
def assign_token(target: address):
    assert msg.sender == self.manager
    assert self.index < self.maximum_token
    assert not self.token[target]
    self.token[target] = True
    self.index += 1

在这个函数中,所有者可以将代币分配给任何账户。为了指示代币的所有者,我们将true值设置给token变量,并将其键指向targetindex增加了一,所以以后我们不能创建超过maximum_token变量的代币:

@public
def vote(proposal: int128):
    assert self.index == self.maximum_token
    assert self.token[msg.sender]
    assert proposal < 2 and proposal >= 0

    self.token[msg.sender] = False
    self.proposals[proposal].vote_count += 1

在这个vote函数中,我们通过将token映射变量设置为投票者的地址键的false来销毁代币。但首先,我们必须确保投票者是代币的有效所有者,使用这个语句:assert self.token[msg.sender]。我们还必须确保在分配了所有代币之后人们可以投票。当然,就像之前的投票应用程序一样,我们增加了投票者投票的提案的计数。

让我们为基于代币的投票应用创建一个测试。为此,在tests目录中创建一个名为test_token_based_voting.py的文件。请参考以下 GitLab 链接中的代码文件获取完整代码:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_06/voting_project/tests/test_token_based_voting.py。将以下代码添加到新文件中:

import pytest
import eth_tester

@pytest.fixture()
def voting(chain):
    TokenBasedVotingFactory = chain.provider.get_contract_factory('TokenBasedVoting')
    deploy_txn_hash = TokenBasedVotingFactory.constructor([b'Messi', b'Ronaldo']).transact()
    contract_address = chain.wait.for_contract_address(deploy_txn_hash)
    return TokenBasedVotingFactory(address=contract_address)

...
...

    set_txn_hash = voting.functions.vote(0).transact({'from': account2})
    chain.wait.for_receipt(set_txn_hash)

让我们逐行讨论这个脚本。我们从fixture函数开始:

import pytest
import eth_tester

@pytest.fixture()
def voting(chain):
    TokenBasedVotingFactory = chain.provider.get_contract_factory('TokenBasedVoting')
    deploy_txn_hash = TokenBasedVotingFactory.constructor([b'Messi', b'Ronaldo']).transact()
    contract_address = chain.wait.for_contract_address(deploy_txn_hash)
    return TokenBasedVotingFactory(address=contract_address)

像往常一样,我们通过手动部署智能合约来创建这个智能合约的fixture

def assign_tokens(voting, chain, web3):
    t = eth_tester.EthereumTester()
    accounts = t.get_accounts()

    for i in range(1, 9):
        set_txn_hash = voting.functions.assign_token(accounts[i]).transact({'from': web3.eth.coinbase})
        chain.wait.for_receipt(set_txn_hash)

这是一个为不同账户分配8个代币的helper函数:

def test_assign_token(voting, chain):
    t = eth_tester.EthereumTester()
    account2 = t.get_accounts()[1]

    assert not voting.functions.token(account2).call()

    set_txn_hash = voting.functions.assign_token(account2).transact({})
    chain.wait.for_receipt(set_txn_hash)

    assert voting.functions.token(account2).call()

这个test函数旨在检查assign_token函数是否可以将代币分配给目标地址:

def test_cannot_vote_without_token(voting, chain, web3):
    t = eth_tester.EthereumTester()
    account10 = t.get_accounts()[9]

    assign_tokens(voting, chain, web3)

    with pytest.raises(eth_tester.exceptions.TransactionFailed):
        voting.functions.vote(0).transact({'from': account10})

这个test函数旨在确保只有代币的所有者可以在这个智能合约中投票:

def test_can_vote_with_token(voting, chain, web3):
    t = eth_tester.EthereumTester()
    account2 = t.get_accounts()[1]

    assign_tokens(voting, chain, web3)

    assert voting.functions.proposals__vote_count(0).call() == 0

    set_txn_hash = voting.functions.vote(0).transact({'from': account2})
    chain.wait.for_receipt(set_txn_hash)

    assert voting.functions.proposals__vote_count(0).call() == 1

这个test函数旨在确保代币的所有者可以成功为提案投票。

让我解释一下为什么基于代币的投票非常了不起。只有8个可用的代币,这些代币可以用来在这个智能合约中投票。编写和部署这个智能合约的程序员甚至在这个智能合约上线后也无法改变规则。选民可以通过要求从程序员那里获取智能合约的源代码,并验证编译的字节码是否与智能合约地址中的字节码相同来验证规则是否公平。要从智能合约地址获取字节码,你可以这样做:

from web3 import Web3, HTTPProvider

w3 = Web3(HTTPProvider('http://127.0.0.1:8545'))
print(w3.eth.getCode('0x891dfe5Dbf551E090805CEee41b94bB2205Bdd17'))

然后,你编译作者的智能合约源代码并进行比较。它们一样吗?如果是,那么你可以审计智能合约,确保没有作弊。如果不是,那么你可以向作者投诉或决定不参与他们的智能合约。

在传统的网络应用程序中实现这种透明度并不容易。在 GitHub/GitLab 中验证代码并不意味着太多,因为开发者可能在他们的服务器上部署不同的代码。你可以被授予在他们服务器上的访客会话来验证代码的透明性,但是,开发者可能会部署一种复杂的方式来欺骗你。你可以每秒监视前端的网络应用程序,并部署一种监视策略,无论是手动还是借助 ML 来检测可疑活动。例如,你突然注意到一个评论突然被修改了,但后来没有被编辑的迹象,所以你可以肯定作弊发生在应用程序内部。然而,指责开发者并不容易,因为这是你的话对他们的话。你可能会被指控制造虚假证据。

有效的方法是雇佣一个可信赖和称职的审计员来做这项工作。审计员可以访问他们的网络应用程序,并有足够的权限读取数据库日志和服务器日志,以确保没有作弊发生。这只有在审计员无法被贿赂并且足够称职以避免被开发者欺骗的情况下才能实现。或者,你可以使用区块链。

投票是一个广泛的主题。我们在这个投票应用程序中还没有实现委托功能。我所说的委托类似于许多国家的民主制度。在一些民主国家,人们不直接选择他们的总理或总统。他们选择众议院的成员。在这些成员当选后,他们将选择总理。你可以创建一个实现委托系统的投票智能合约。如果你想进一步研究这个问题,请参考进一步阅读部分。

最后,我们的投票智能合约非常透明。这可能是好事,也可能是坏事,这取决于情况。透明度是好的,特别是在金融交易中,因为你可以审计日志以发现洗钱案件。然而,当涉及到投票,特别是在政治方面,保密性是一个可取的特性。如果选民没有保密性,他们可能会害怕被其他人迫害。智能合约中的投票保密性仍处于研究阶段。

总结

在本章中,你已经学会了如何创建一个区块链技术可以发挥作用的真实应用程序。这个真实应用程序是一个投票应用程序。从每个账户都可以投票的简单投票智能合约开始,我们逐渐创建了一个只有特定账户可以使用代币系统投票的投票应用程序。在构建这个投票智能合约时,我们还学习了如何编写一个脚本来部署带有构造函数的智能合约。在部署智能合约后,我们还学习了智能合约的一个特性,即事件。在web3脚本中,我们订阅这个事件来了解我们感兴趣的事情。最后,我们创建了辅助脚本来创建许多账户,并向其他账户发送资金以进行开发目的。

在下一章中,您将为您的web3脚本创建一个前端。您将构建一个适当的去中心化应用程序,以桌面应用程序的形式。

更多阅读

第三部分:前端去中心化应用程序

本节是一个实践性的学习指南,教你如何用桌面前端构建类似 Twitter 的应用程序。

接下来的章节将在本节中介绍:

  • 第七章,前端去中心化应用程序

第七章:前端分散式应用程序

在本章中,我们将学习如何编写具有 GUI 前端的分散式应用程序。这是一个桌面分散式应用程序。人们可以使用按钮和文本字段与智能合约进行交互。这比 Truffle 控制台或web3.py脚本更进一步。在编写此 GUI 前端时,我们将学习处理智能合约的最佳实践,包括如何处理延迟以及如何在分散式应用程序中提供附加值。

在本章中,我们将学习以下主题:

  • 设置 Qt GUI 库

  • 安装 Qt for Python

  • Qt for Python 速成课程

  • 为类似 Twitter 的应用程序编写智能合约

  • 为类似 Twitter 的应用程序构建 GUI

设置 Qt GUI 库

Qt 库是一个设计用于创建多平台应用程序的 C++框架。在 Linux 中,该库是 KDE 桌面的基础。它的对应物是 GTK+库,是 Gnome 桌面的基础。然而,Qt 库已经提升了自己的游戏。您可以使用 Qt 库在 Android 和 iOS 上创建应用程序。有时人们错误地认为 Qt 库只能创建 GUI 应用程序。然而,Qt 库还包括非 GUI 库,例如数据存储、多媒体和网络。Qt 库甚至包含一个线程库!过去,您可以免费使用 Qt 库创建开源应用程序。但是,如果您想使用 Qt 库创建专有应用程序,您必须支付许可费。现在,在使用 Qt 库时,您可以有更多的灵活性。

您可以免费使用 Qt 库创建专有应用程序,但只有在为 Qt 库提供重新链接机制、提供许可证副本并明确承认 Qt 使用等情况下才可以。详情请参阅www.qt.io/download

选择 GUI 库

Python 有大量可供选择的 GUI 库。其中包括 Tkinter、Phoenix(wxPython)、PyGObject、PyQt、Qt for Python(PySide2)等。决定为 Python 应用程序使用哪个 GUI 库有时是主观的。

Tkinter 是一个 Python GUI 库,但 UI 不够流畅和精致。PyGObject 只有在您想要在 Linux 中编写桌面应用程序时才是一个不错的选择,因为它使用 GTK+。在 Windows 和 Mac 中也支持 GTK+,但不是本地支持。

我将选择 Qt for Python(www.qt.io/qt-for-python)库。这是 Qt 自己的官方 Python 绑定 Qt 库。不过要小心,还有另一家公司提供的 Qt 库的 Python 绑定。这个库叫做 PyQt,公司是 Riverbank。这两个库都支持最新的 Qt 库,即 Qt 5。

这两个库之间存在差异。如果您想使用 PyQt 编写和销售专有应用程序,必须从 Riverbank 购买许可证。然而,对于 Qt for Python,您不需要这样做,尽管它有自己的一套限制。例如,您必须为 Qt 库提供重新链接机制。有关 PyQt 的书籍已经出版,但在撰写本书时,Qt for Python 还没有相关书籍。

以前,可靠的 Python 绑定 Qt 只能从 Riverbank 获得。然而,在今年中期,Qt 宣布了其最新 Qt 库(Qt 5.11)的 Python 绑定。Qt 非常重视推广这个 Python 绑定。

安装 Qt for Python

首先,您必须安装 Qt 5.11。转到www.qt.io/download。在下载库时,您有两种许可证可供选择——商业和开源。在这种情况下,您必须选择开源选项。

  1. 如果你在 Linux 平台上,你将下载以下文件:qt-unified-linux-x64-3.0.5-online.run

这是一个安装程序。如果你运行它,你会看到以下屏幕:

  1. 点击下一步。然后你将会看到一个登录屏幕。如果你还没有账户,创建一个;这是免费的。之后,通过以下屏幕登录:

  1. 然后,你会看到安装程序的欢迎屏幕。从这里,点击下一步:

  1. 指定你想要安装 Qt 库的位置:

  1. 然后选择 Qt 5.11.2 或最新的稳定版本:

安装程序完成工作之前还需要一段时间。

  1. 一旦 Qt 5 安装过程完成,你可以使用以下命令行安装 Python 绑定到 Qt:
$ python3.6 -m venv qt-venv
$ source qt-venv/bin/activate
(qt-venv) $ pip install PySide2 
  1. 我们将使用这个虚拟环境编写一个智能合约和一个去中心化应用程序。我们需要像往常一样安装populusvyperweb3库:
(qt-venv) $ pip install eth-abi==1.2.2
(qt-venv) $ pip install eth-typing==1.1.0
(qt-venv) $ pip install py-evm==0.2.0a33
(qt-venv) $ pip install web3==4.7.2
(qt-venv) $ pip install -e git+https://github.com/ethereum/populus#egg=populus
(qt-venv) $ pip install vyper
(qt-venv) $ mkdir twitter_like_project
(qt-venv) $ cd twitter_like_project
(qt-venv) $ mkdir tests contracts
(qt-venv) $ cp ../qt-venv/src/populus/populus/assets/defaults.v9.config.json project.json
  1. 将 Vyper 支持添加到project.json中,方法是将"compilation"键的值更改为以下内容:
"compilation": {
    "backend": {
      "class": "populus.compilation.backends.VyperBackend"
    },
    "contract_source_dirs": [
      "./contracts"
    ],
    "import_remappings": []
},

Vyper 的最新版本是 0.1.0b6,它破坏了 Populus。开发者需要一些时间来修复它。如果在你阅读本书时错误还没有被修复,你可以自己修补 Populus。

  1. 使用以下命令检查错误是否已经被修复:
(qt-venv) $ cd qt-venv/src/populus
(qt-venv) $ grep -R "compile(" populus/compilation/backends/vyper.py
 bytecode = '0x' + compiler.compile(code).hex()
 bytecode_runtime = '0x' + compiler.compile(code, bytecode_runtime=True).hex()

在我们的情况下,错误尚未被修复。

  1. 因此,让我们使用以下命令行修补 Populus 以修复错误。确保你仍然在同一个目录下(qt-venv/src/populus):
(qt-venv) $ wget https://patch-diff.githubusercontent.com/raw/ethereum/populus/pull/484.patch
(qt-venv) $ git apply 484.patch
(qt-venv) $ cd ../../../ 

Qt for Python 速成课程

让我们使用我们的 GUI 库编写一个简单的应用程序:

  1. 创建一个名为hello.py的文件:
import sys
from PySide2.QtWidgets import QApplication, QWidget

app = QApplication(sys.argv)
window = QWidget()
window.resize(400, 400)
window.show()
sys.exit(app.exec_())
  1. 然后使用以下命令运行它:
(qt-venv) $ python hello.py

现在你会看到一个空白窗口:

让我们通过这个文件更好地理解 Qt for Python:

import sys
from PySide2.QtWidgets import QApplication, QWidget

sys导入自标准 Python 库。这是必需的,因为我们想要在启动这个 GUI 应用程序脚本时从命令行获取参数。然后我们从PySide2.QtWidgets中导入QApplicationQWidget

什么是PySide2?它来自PySide,这是 Qt 4 的 Python 绑定。PySide2是 Qt 5 的 Python 绑定。PySide于 2009 年由 Nokia(Qt 的前任所有者)发布。此前,诺基亚未能与 Riverbank 达成PyQt许可证 LGPL 的协议。因此,诺基亚决定创建自己的 Python 绑定用于 Qt,并将其命名为PySide。Qt 的所有权从诺基亚转移到了 Qt 公司。Qt 公司决定加强他们的努力来开发这个 Python 绑定用于 Qt,特别是在 Qt 5 发布后。

我们从PySide2.QtWidgets中导入的两个类是QApplicationQWidget

  • QApplication是一个设计用来管理 GUI 应用程序流程和其设置的类。它检查你的桌面配置,比如字体,并将其传递给 GUI。它还理解来自桌面的传入对象,例如当你从文本编辑器复制文本并粘贴到 GUI 应用程序时。在你的 GUI 脚本中只能有一个QApplication
app = QApplication(sys.argv)

我们创建一个QApplication的实例,并传递命令行参数。大多数情况下,你不会使用任何命令行参数。例如,如果你想要告诉 GUI 应用程序使用不同的样式或从右到左显示文本以满足阿拉伯用户的需求,你可以传递命令行参数。

  • 我们从PySide2.QtWidgets中导入的第二个类是QWidget。这是你在创建 GUI 应用程序时将要使用的任何小部件的基类,比如按钮、文本字段、滑块或标签。如果你构造基类,你会得到一个空窗口。这类似于 iOS 中的 UIView。然后我们调整窗口大小:
window = QWidget()
window.resize(400, 400)

使用window.show()方法,我们显示了窗口对象,然后使用app.exec_()进入了主循环。这是QApplication将所有来自桌面的事件分发到 GUI 的地方。我们使用sys.exit()将这个过程包装起来,这样我们就可以从QApplication中获取返回代码:

window.show()
sys.exit(app.exec_())

布局

在添加另一个小部件之前,我们必须了解布局的概念。我们将使用两种布局,即QHBoxLayoutQVBoxLayout。这两种布局足以创建一个 GUI 应用程序。还有其他布局,比如QGridLayoutQFormLayout等等,但我们不需要它们。QHBoxLayoutQVBoxLayout就像 CSS 中的 flexbox。你可以将小部件放在使用QHBoxLayout的容器中,然后所有小部件将被放在一条水平线上。让我们看一个例子。将此脚本命名为hello_horizontal_layout.py

import sys
from PySide2.QtWidgets import QApplication, QWidget, QHBoxLayout, QPushButton, QLabel

app = QApplication(sys.argv)

hello_button = QPushButton('Hello')
very_label = QLabel('Very Very')
beautiful_button = QPushButton('Beautiful')
world_label = QLabel('World')

layout = QHBoxLayout()
layout.addWidget(hello_button)
layout.addWidget(very_label)
layout.addWidget(beautiful_button)
layout.addWidget(world_label)

window = QWidget()
window.setLayout(layout)
window.resize(200, 200)
window.show()

sys.exit(app.exec_())

我将逐行解释代码。我们首先导入库:

import sys
from PySide2.QtWidgets import QApplication, QWidget, QHBoxLayout, QPushButton, Qlabel

除了我们在之前的脚本中导入的类之外,我们还导入了QHBoxLayout(水平布局)和两个小部件(QPushButtonQLabel):

app = QApplication(sys.argv)

hello_button = QPushButton('Hello')
very_label = QLabel('Very Very')
beautiful_button = QPushButton('Beautiful')
world_label = Qlabel('World')

我们初始化了QApplication,然后是四个小部件(两个按钮和两个标签)。QPushButtonQLabel的第一个参数是它们的标签字符串。在这里,我们只是构建了四个小部件,但是还没有显示它们:

layout = QHBoxLayout()
layout.addWidget(hello_button)
layout.addWidget(very_label)
layout.addWidget(beautiful_button)
layout.addWidget(world_label)

上面的代码使用QHBoxLayout类构建了水平布局。然后我们将填充布局,使用我们之前构建的四个小部件。在这种情况下,hello_button将是布局中最左边的小部件,而world_label将是最右边的小部件。然后,我们构建了一个window对象,并告诉它使用我们的水平布局:

window = QWidget()
window.setLayout(layout)
window.resize(200, 200)

这个语句将把我们的小部件放在窗口内:

window.show()

sys.exit(app.exec_())

然后,我们显示窗口,执行QApplication实例,并使用以下命令运行它:

(qt-venv) $ python hello_horizontal_layout.py

然后你将会看到以下结果,从左到右显示了四个小部件:

要获得垂直布局,创建另一个脚本并将其命名为hello_vertical_layout.py

import sys
from PySide2.QtWidgets import QApplication, QWidget, QVBoxLayout, QPushButton, QLabel

app = QApplication(sys.argv)

hello_button = QPushButton('Hello')
very_label = QLabel('Very Very')
beautiful_button = QPushButton('Beautiful')
world_label = QLabel('World')

layout = QVBoxLayout()
layout.addWidget(hello_button)
layout.addWidget(very_label)
layout.addWidget(beautiful_button)
layout.addWidget(world_label)

window = QWidget()
window.setLayout(layout)
window.resize(200, 200)
window.show()

sys.exit(app.exec_())

在这里,你可以使用QVBoxLayout而不是HBoxLayout来获得垂直布局。使用以下命令运行脚本:

(qt_venv) $ python hello_vertical_layout.py

然后你将会看到四个小部件从上到下显示在窗口中:

如果你想要结合水平和垂直布局,你可以将水平布局嵌入到垂直布局中,反之亦然。为此,请创建一个名为hello_vertical_horizontal_layout.py的脚本。请参考以下 GitLab 链接中的代码文件获取完整的代码:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_07/crash_course_qt_for_python/hello_vertical_horizontal_layout.py

import sys
from PySide2.QtWidgets import QApplication, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel

app = QApplication(sys.argv)

hello_button = QPushButton('Hello')
very_label = QLabel('Very Very')
beautiful_button = QPushButton('Beautiful')
world_label = QLabel('World')

...
...

window = QWidget()
window.setLayout(horizontal_layout)
window.resize(200, 200)
window.show()

sys.exit(app.exec_())

这里需要注意的是,你需要使用addLayout方法从一个布局中添加一个嵌套布局:

vertical_layout = QVBoxLayout()
vertical_layout.addWidget(vertical_hello_button)
vertical_layout.addWidget(vertical_very_label)
vertical_layout.addWidget(vertical_beautiful_button)
vertical_layout.addWidget(vertical_world_label)

horizontal_layout = QHBoxLayout()
horizontal_layout.addWidget(hello_button)
horizontal_layout.addWidget(very_label)
horizontal_layout.addLayout(vertical_layout)

在这里要注意!你使用addLayout方法将一个布局嵌入到另一个布局中,而不是addWidget

运行以下脚本以查看嵌套布局:

(qt-venv) $ python hello_vertical_horizontal_layout.py

执行此操作将会给你以下的屏幕:

其他小部件

让我们创建一个脚本来展示更多种类的小部件。首先,将脚本命名为hello_varieties.py。请参考以下 GitLab 链接中的代码文件获取完整的代码:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_07/crash_course_qt_for_python/hello_varieties.py

脚本的内容可以在以下代码块中查看:

import sys
from PySide2.QtWidgets import (QApplication,
                               QWidget,
                               QVBoxLayout,
                               QHBoxLayout,
                               QGroupBox,
                               QPushButton,
                               QLabel,
                               QSpinBox,
                               QLineEdit,
                               QRadioButton,
                               QComboBox)

...
...

window = QWidget()
window.setLayout(layout)
window.show()

sys.exit(app.exec_())

让我们逐行解析代码。我们首先使用以下行导入库:

import sys
from PySide2.QtWidgets import (QApplication,
                               QWidget,
                               QVBoxLayout,
                               QHBoxLayout,
                               QGroupBox,
                               QPushButton,
                               QLabel,
                               QSpinBox,
                               QLineEdit,
                               QRadioButton,
                               QComboBox)

在这里,我们导入了许多新类型的小部件,例如QGroupBoxQSpinBoxQLineEditQRadioButtonQComboBox。Qt 中有很多小部件,我们只是没有时间讨论所有的小部件。然后,我们初始化所有小部件:

button = QPushButton('Button')
label = QLabel('Label')
spinbox = QSpinBox()
lineedit = QLineEdit()
radio_button1 = QRadioButton('Option 1')
radio_button2 = QRadioButton('Option 2')
radio_button3 = QRadioButton('Option 3')
combo_box = QComboBox()
combo_box.addItems(["Bitcoin", "Ethereum", "Monero", "Ripple"])

这些是新类型的小部件。让我们讨论这些新小部件及其独特属性。QSpinBox是一个用于选择数字的框。您可以通过单击上箭头或下箭头来旋转此小部件。QLineEdit是一个文本字段,而QRadioButton是一个单选按钮,QComboBox是一个选择框。然后,我们初始化水平布局和垂直布局:

vlayout = QVBoxLayout()
vlayout.addWidget(button)
vlayout.addWidget(radio_button1)
vlayout.addWidget(radio_button2)
vlayout.addWidget(radio_button3)
vlayout.addWidget(spinbox)

hlayout = QHBoxLayout()
hlayout.addWidget(lineedit)
hlayout.addWidget(label)
hlayout.addWidget(combo_box)

我们将一些小部件放入垂直布局中,并将其他一些小部件放入水平布局中。然后,我们需要将这些布局放入一个更大的容器中:

top_groupbox = QGroupBox('Top')
top_groupbox.setLayout(vlayout)
bottom_groupbox = QGroupBox('Bottom')
bottom_groupbox.setLayout(hlayout)

我们现在使用QGroupBox类创建了两个分组框。第一个分组框有一个垂直布局,而另一个分组框有一个水平布局。分组框类似于带有边框的容器。这类似于 HTML 中的<div>

layout = QVBoxLayout()
layout.addWidget(top_groupbox)
layout.addWidget(bottom_groupbox)

window = QWidget()
window.setLayout(layout)
window.show()

sys.exit(app.exec_())

为了容纳这两个分组框,我们需要另一个布局。在这里,我们使用垂直布局。其余部分与往常一样。我们构建一个窗口,给它一个布局,然后在启动QApplication实例之前显示它。

运行以下脚本以查看许多不同类型的小部件:

(qt_venv) $ python hello_varieties.py

然后,您将看到一个看起来像这样的屏幕:

回调

此时,我们已经创建了许多小部件和布局。现在我们需要给一个小部件一个工作要做,我的意思是当按钮被点击时我们想要按钮做什么。当前,如果点击按钮,按钮将不会执行任何操作。

让我们创建一个简单的脚本来解释如何为按钮的点击事件提供回调。将其命名为hello_connect_simple.py

脚本包含以下代码行:

import sys
from PySide2.QtWidgets import QApplication, QWidget, QVBoxLayout, QPushButton, QLabel
from PySide2 import QtCore

app = QApplication(sys.argv)

hello_button = QPushButton("Hello")
world_label = QLabel("Sun")

layout = QVBoxLayout()
layout.addWidget(hello_button)
layout.addWidget(world_label)

def set_text_in_world_label():
    world_label.setText("World")

hello_button.connect(QtCore.SIGNAL('clicked()'), set_text_in_world_label)

window = QWidget()
window.setLayout(layout)
window.resize(200, 200)
window.show()

sys.exit(app.exec_())

让我们逐行查看这段代码:

import sys
from PySide2.QtWidgets import QApplication, QWidget, QVBoxLayout, QPushButton, QLabel
from PySide2 import QtCore

除了您导入的通常库之外,您还必须导入QtCoreQtCore提供了小部件进行通信的一种方式。因此,如果您想要使按钮能够与标签通信,比如“在我被点击后,请将您的标签设置为此字符串”,您需要导入QtCore。然后创建小部件并将小部件放入布局中:

app = QApplication(sys.argv)

hello_button = QPushButton("Hello")
world_label = QLabel("Sun")

layout = QVBoxLayout()
layout.addWidget(hello_button)
layout.addWidget(world_label)

您实例化QApplication类,创建一个按钮和一个标签,然后将小部件放入垂直布局中。

def set_text_in_world_label():
    world_label.setText("World")
    hello_button.connect(QtCore.SIGNAL('clicked()'), set_text_in_world_label)

在上述代码行中,我们创建一个函数来设置标签中的文本。setTextQLabel的一个用于更改标签的方法。在下一行中,我们将按钮的clicked信号连接到此函数。这意味着如果我们点击QPushButtonset_text_in_world_label将被执行。QPushButton除了clicked之外还有其他信号,例如pressedreleased。然后,我们创建一个窗口小部件,并使用以下代码显示它:

window = QWidget()
window.setLayout(layout)
window.resize(200, 200)
window.show()

sys.exit(app.exec_())

运行脚本以测试我们创建的回调:

(qt-venv) $ python hello_connect.py

运行脚本后,您将得到以下屏幕:

然后,如果您点击按钮,标签小部件将更改文本:

按钮没有参数发送到回调函数。但是另一个小部件可能有参数发送到回调函数。

让我们创建一个脚本来说明这个选项。将脚本命名为hello_connect_param.py。脚本的内容可以在以下代码块中查看:

import sys
from PySide2.QtWidgets import QApplication, QWidget, QVBoxLayout, QLineEdit, QLabel
from PySide2 import QtCore

app = QApplication(sys.argv)

hello_line_edit = QLineEdit()
world_label = QLabel("")

layout = QVBoxLayout()
layout.addWidget(hello_line_edit)
layout.addWidget(world_label)

def set_world_label(text):
    world_label.setText(text.upper())
    hello_line_edit.textChanged.connect(set_world_label)

window = QWidget()
window.setLayout(layout)
window.resize(200, 200)
window.show()

sys.exit(app.exec_())

关注这些行,看看我们如何在小部件的回调中使用参数:

def set_world_label(text):
    world_label.setText(text.upper())
    hello_line_edit.textChanged.connect(set_world_label)

我们的回调函数有一个参数。行编辑小部件的textChanged信号有一个参数。当我们更改行编辑小部件中的文本时,文本将被发送到回调函数。

这行代码hello_line_edit.textChanged.connect(set_world_label)相当于hello_line_edit.connect(QtCore.SIGNAL('textChanged(QString)'), set_world_label)

运行脚本以测试具有参数的回调:

(qt_venv) $ python hello_connect_param.py

当您在行编辑中输入时,标签小部件中的文本也会更改:

我们使用回调是因为我们希望在将文本设置到标签小部件之前对其进行操作。但是,如果你想直接将文本参数从行编辑小部件发送到标签小部件,你不必创建一个专门的函数,你可以使用“信号”和“槽”。

为此,删除回调,然后将line hello_line_edit.textChanged.connect(set_world_label)更改为hello_line_edit.connect(QtCore.SIGNAL('textChanged(QString)'), world_label, QtCore.SLOT('setText(QString)'))

当你在行编辑中输入文本时,你的文本将直接显示在标签中。

你也可以将那一行更改为QtCore.QObject.connect(hello_line_edit, QtCore.SIGNAL('textChanged(QString)'), world_label, QtCore.SLOT('setText(QString)'))

我希望你能看到这里的模式。如果小部件 A 想要与小部件 B 通信,小部件 A 将使用“信号”与小部件 B 的“槽”连接。当然,你也可以将“槽”部分替换为一个自定义的回调,通过它调用小部件 B 的 API。

这个“信号”和“槽”概念是通用的,不仅限于 GUI 小部件。这意味着你可以定义自定义的“信号”和自定义的“槽”。

让我们创建一个脚本来说明这个新概念:

import sys
from PySide2 import QtCore

@QtCore.Slot(str)
def slot_func(param):
    print(param)

class Simple(QtCore.QObject):
    signal = QtCore.Signal(str)

simple = Simple()
simple.signal.connect(slot_func)
simple.signal.emit("Hello World")

让我们解剖这个脚本。我们导入了库:

import sys
from PySide2 import QtCore

槽和信号功能来自QtCore类。你可以使用Slot装饰器创建一个函数:

@QtCore.Slot(str)
def slot_func(param):
    print(param)

你也可以创建一个QObject的子类来创建一个“信号”对象:

class Simple(QtCore.QObject):
    signal = QtCore.Signal(str)

simple = Simple()
simple.signal.connect(slot_func)

你必须实例化这个类。“信号”必须是实例的一部分,而不是一个类。然后你可以将它与一个“槽”函数连接如下:

simple.signal.emit("Hello World")

最后一部分是将参数从“信号”发送到“槽”。

运行脚本以测试回调是否仍然有效:

(qt-venv) $ python hello_custom_signal_slot.py
Hello World

线程

要在 Qt 中使用线程,我们可以使用QtCore中的QThread类。希望你能看到这里有一个模式。QtCore除了创建小部件之外还有许多功能。线程很重要,因为在构建去中心化应用程序时,我们将不得不等待相当长的时间来确认交易。根据你在以太坊中的燃气和交通慷慨程度,等待时间可能从几分钟到半个小时不等。我们不希望 GUI 应用程序冻结一分钟,更不用说半个小时了。

让我们创建一个简单的脚本来演示如何使用QThread创建一个线程。将脚本命名为hello_thread.py

from PySide2 import QtCore
import time

class SimpleThread(QtCore.QThread):
    def __init__(self, parent=None):
        super(SimpleThread, self).__init__(parent)

    def run(self):
        time.sleep(2) # simulating latency in network
        print("world")

simple_thread = SimpleThread()
simple_thread.start()

print("hello")
simple_thread.wait()

然后,你将在编写run方法内部的类中对QThread进行子类化。要使用这个线程类,你需要实例化这个类,然后调用start方法。然后你可以在主应用程序中做所有你想做的事情,同时等待线程类完成它的工作。如果你想等待线程类完成工作,你可以从线程类中调用它的wait方法。

线程是一个广泛的主题。我们还没有讨论互斥锁或线程安全,但对于我们的去中心化应用程序来说,我们不需要讨论这些。

有些人对在他们的应用程序中使用线程感到恐惧。另一种选择是使用asyncio库使用单线程并发代码。这完全没问题,但在这种情况下,我们将使用线程。

我们已经知道如何使用 Qt 库来构建 Python 桌面应用程序的所有必要知识。在编写 GUI 去中心化应用程序之前,我们需要编写一个智能合约,因为没有这个,就不会有去中心化应用程序。

编写 Twitter 样应用程序的智能合约

我们现在要构建一个抗审查的类似 Twitter 的应用程序。这意味着即使智能合约的所有者也无法删除用户的推文。这个类似 Twitter 的应用程序非常简单;没有关注、点赞或转发的选项;它只包括推文。此外,用户无法删除他们的推文,推文必须少于 32 个字节,这甚至比 Twitter 原始推文限制还要短!

现在我们回到我们的 Populus 项目,修改我们的project.json文件。将这个添加到project.json中:

"ganache": {
   "chain": {
     "class": "populus.chain.ExternalChain"
   },
   "web3": {
     "provider": {
       "class": "web3.providers.HTTPProvider",
       "settings": {
         "endpoint_uri": "http://localhost:7545"
       }
     }
   },
   "contracts": {
     "backends": {
       "JSONFile": {"$ref": "contracts.backends.JSONFile"},
       "ProjectContracts": {
         "$ref": "contracts.backends.ProjectContracts"
       }
     }
   }
 }

这是智能合约。在twitter_like_projects/contracts目录中创建一个TwitterOnBlockchain.vy文件

struct Tweet:
    messages: bytes32[10]
    index: int128

tweets: public(map(address, Tweet))

@public
def write_a_tweet(tweet: bytes32):
    assert self.tweets[msg.sender].index < 10

    index: int128 = self.tweets[msg.sender].index
    self.tweets[msg.sender].messages[index] = tweet
    self.tweets[msg.sender].index += 1

这是一个非常简短的智能合约。

我们从结构数据类型变量声明和地址到该结构数据类型变量的映射开始:

struct Tweet:
    messages: bytes32[10]
    index: int128

tweets: public(map(address, Tweet))

tweets变量是从地址到messagesindex数组的映射数据类型。messages数组的长度为 10 个bytes32数据类型变量,这意味着每个账户或地址在这个智能合约中最多可以有 10 条推文。index是指向messages数组的指针。每次创建一条推文,index增加 1,这样它就可以指向messages数组中的下一个槽。

@public
def write_a_tweet(tweet: bytes32):
    assert self.tweets[msg.sender].index < 10
    index: int128 = self.tweets[msg.sender].index
    self.tweets[msg.sender].messages[index] = tweet
    self.tweets[msg.sender].index += 1

write_a_tweet函数是创建一条推文的方法。这只是将一条推文插入到messages数组中,并加入一些保护措施以避免越界错误。

测试

这是对这个智能合约的测试。您可以将测试文件保存在tests/test_twitter_on_blockchain.py中。有关完整代码,请参考以下 GitLab 链接中的代码文件:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_07/twitter_on_blockchain/tests/test_twitter_on_blockchain.py

以下代码块显示了代码:

import pytest
import eth_tester

def test_initial_condition(web3, chain):
    twitter_on_blockchain, _ = chain.provider.get_or_deploy_contract('TwitterOnBlockchain')
    assert twitter_on_blockchain.functions.tweets__index(web3.eth.coinbase).call() == 0

...
...

twitter_on_blockchain.functions.tweets__messages(web3.eth.coinbase,0).call()[:len(tweet)] == tweet
    assert twitter_on_blockchain.functions.tweets__messages(web3.eth.coinbase,1).call()[:len(tweet2)] == tweet2

这个测试确保index从零开始,并且在用户在这个智能合约中发推文后会增加一。它还检查推文是否保留在messages数组中。

让我们思考一下。如果你看看智能合约,所有者无法审查任何人的推文。所有者甚至无法删除智能合约,因此政府或黑手党都无法向智能合约的所有者施加压力来审查推文。将这种情况与应用程序所有者使用传统网络应用程序的情况进行比较。应用程序所有者可以通过从数据库中删除条目来审查推文。另外,政府或黑手党可以向应用程序所有者施加压力来审查推文,或者政府或黑手党可以关闭托管服务器。在区块链的情况下,这意味着人们需要关闭一半的 10,000 个节点才能破坏以太坊区块链,以摧毁智能合约。

这是区块链特有的一种特性——抗审查。这并不意味着它不能被审查,但很难被审查。为了关闭这个智能合约,政府必须禁止以太坊网络本身。另外,他们可以在所有公民的计算机上安装间谍软件,并确保他们不访问这个智能合约。此外,抗审查并不意味着它保护隐私。政府仍然可以弄清楚谁写了什么。

要部署智能合约,运行 Ganache,编译智能合约,然后使用以下命令将智能合约部署到 Ganache:

(qt-venv) $ populus deploy --chain ganache TwitterOnBlockchain

固定装置

在我们开发一个去中心化的 GUI 应用之前,让我们先写一个脚本来在智能合约中放一些固定数据。固定数据就像样本数据。这样可以让应用的开发体验更加愉快。将脚本命名为fixtures.py。完整的代码请参考以下 GitLab 链接中的代码文件:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_07/dapp/fixtures.py

from web3 import Web3, HTTPProvider
from populus.utils.wait import wait_for_transaction_receipt
w3 = Web3(HTTPProvider('http://localhost:7545'))

private_keys = ['dummy',
                '59e31694256f71b8d181f47fc67914798c4b96990e835fc1407bf4673ead30e2',
                'ac1e6abbe002699fbef756a2cbc2bf8c03cfac97adee84ce32f198219be94788']

...
...
        txhash = w3.eth.sendRawTransaction(signed.rawTransaction)
        wait_for_transaction_receipt(w3, txhash)

让我们逐行解析这个脚本。我们从web3中导入Web3类和HTTPProvider类。我们使用HTTPProvider是因为我们想使用 Ganache。然后我们从populus库中导入wait_for_transaction_receipt

from web3 import Web3, HTTPProvider
from populus.utils.wait import wait_for_transaction_receipt

w3 = Web3(HTTPProvider('http://localhost:7545'))

然后我们有了前三个账户的私钥:

private_keys = ['dummy',
                '59e31694256f71b8d181f47fc67914798c4b96990e835fc1407bf4673ead30e2',
                'ac1e6abbe002699fbef756a2cbc2bf8c03cfac97adee84ce32f198219be94788']

我们不需要第一个账户的私钥或启动智能合约的账户;我们只需要第二个和第三个账户:

true = True
false = False
abi = [{
         "constant": false,
         "gas": 71987,
         "inputs": [{
                      "name": "tweet",
                      "type": "bytes32"
                   }],
         "name": "write_a_tweet",
         "outputs": [],
         "payable": false,
         "type": "function"
       },
...
...
         "payable": false,
         "type": "function"
       }]

这是你可以从智能合约的编译中得到的abi

with open('address.txt', 'r') as f:
    address = f.read().rstrip("\n")

TwitterOnBlockchain = w3.eth.contract(address=address, abi=abi)

你将智能合约的地址放在address.txt文件中。在通过 Populus 部署智能合约时,你会收到这个地址。

然后,根据智能合约的地址和abi构建一个智能合约对象:

for i in range(1, 3):
    for j in range(1, 11):
        nonce = w3.eth.getTransactionCount(Web3.toChecksumAddress(w3.eth.accounts[i]))
        txn = TwitterOnBlockchain.functions.write_a_tweet(b'Tweet ' + str(j).encode('utf-8')).buildTransaction({'gas': 70000, 'gasPrice': w3.toWei('1', 'gwei'), 'nonce': nonce })
        signed = w3.eth.account.signTransaction(txn, private_key=private_keys[i])
        txhash = w3.eth.sendRawTransaction(signed.rawTransaction)
        wait_for_transaction_receipt(w3, txhash)

对于每个账户,你创建 10 条推文。每条推文都是像'Tweet 1','Tweet 2'这样的。这些固定数据使我们能够在不必先创建推文的情况下检查推文。这对检查应用的功能是有益的。

为 Twitter 类似应用构建 GUI 前端

现在让我们构建一个去中心化的 GUI 应用。将脚本命名为twitter_dapp.py。完整的代码请参考以下 GitLab 链接中的代码文件:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_07/dapp/twitter_dapp.py

from PySide2 import QtCore, QtWidgets
import web3
from web3 import Web3, HTTPProvider
from populus.utils.wait import wait_for_transaction_receipt

w3 = Web3(HTTPProvider('http://localhost:7545'))

...
...

if __name__ == '__main__':
    import sys
    app = QtWidgets.QApplication(sys.argv)
    twitter_dapp = TwitterDapp()
    twitter_dapp.show()
    sys.exit(app.exec_())

让我们逐行解析这个脚本。在这里,我们导入必要的类并将我们的web3对象设置为 Ganache,它在本地主机的端口7545上提供智能合约。

from PySide2 import QtCore, QtWidgets
import web3
from web3 import Web3, HTTPProvider
from populus.utils.wait import wait_for_transaction_receipt

w3 = Web3(HTTPProvider('http://localhost:7545'))
true = True
false = False

abi = [{
         "constant": false,
         "gas": 71987,
         "inputs": [{
                      "name": "tweet",
                      "type": "bytes32"
                   }],
         "name": "write_a_tweet",
         "outputs": [],
         "payable": false,
         "type": "function"
       },
...
...
         "payable": false,
         "type": "function"
       }]

然后,我们像往常一样定义abi

with open('address.txt', 'r') as f:
    address = f.read().rstrip("\n")

TwitterOnBlockchain = w3.eth.contract(address=address, abi=abi)

然后,我们通过提供智能合约的地址和abi来创建一个智能合约对象。

这是一个辅助函数,用于从字符串中移除'\x00'

def strip_x00_from_tweet(tweet):
    null_index = tweet.find(b'\x00')
    return tweet[:null_index]

记住,我们在智能合约中的推文数据类型是bytes32,它会一直添加\x00直到达到 32 个字节。因此,如果你将一个长度为 5 个字节的'messi'字符串设置为这个bytes32变量,它将变成'messi\x00\x00\x00\x00...\x00'。这个实用函数将从字符串中去掉\x00,这样我们就可以得到'messi'。然后,我们进入线程类的定义:

class Web3ReadTweetsThread(QtCore.QThread):
    fetched_posts = QtCore.Signal(list)
    account = ''

    def __init__(self, parent=None):
        super(Web3ReadTweetsThread, self).__init__(parent)

    def setAccount(self, account):
        self.account = account

    def run(self):
        try:
            index = TwitterOnBlockchain.functions.tweets__index(self.account).call()
        except web3.exceptions.ValidationError:
            return
        tweets = []
        for i in range(index):
            tweet = TwitterOnBlockchain.functions.tweets__messages(self.account, i).call()
            tweets.append(tweet.decode('utf-8'))
        self.fetched_posts.emit(tweets)

这是一个线程类,旨在从区块链中读取数据。但是读取数据不应该很快吗,因为我们使用的是 Ganache,不需要涉及矿工?如果你的区块链节点和 GUI 前端脚本在同一台计算机上,那么这个线程类是不需要的。但是,如果你将区块链节点放在云上呢?也许你的本地计算机没有足够的空间,这就是你这样做的原因。这种设置会在你的程序中引入延迟,所以最好使用一个线程来等待来自云端区块链的结果。

fetched_posts是一个信号,将把推文列表发送到我们稍后将定义的槽中。在run方法中,我们首先获取索引,这样我们就知道推特发布了多少条。由于我们从智能合约中得到的是一个字节,所以在将推文发送到slot函数之前,我们首先对其进行解码。

以下代码是一个线程类,我们用它来创建智能合约中的交易。要使用这个线程类,我们需要一个私钥。请记住,我们需要用私钥签署交易。然而,我们还需要地址以便获取 nonce。我们也可以要求用户输入地址,但这将是多余的。我们可以通过使用account = w3.eth.account.privateKeyToAccount('0x'+self.private_key)方法从私钥中派生地址:

class Web3WriteATweetThread(QtCore.QThread):
    write_a_tweet = QtCore.Signal()
    private_key = ''
    tweet = ''

...
...

w3.eth.sendRawTransaction(signed.rawTransaction)
        wait_for_transaction_receipt(w3, txhash)
        self.write_a_tweet.emit()

获取地址后,我们用这行获取nonce

nonce = w3.eth.getTransactionCount(Web3.toChecksumAddress(account.address))

然后像往常一样构建交易,签署交易,并发送交易到区块链。然后,我们等待交易确认。之后,我们向slot函数发出信号,稍后我们将定义该函数。

class TwitterDapp(QtWidgets.QWidget):
    private_key = '0x0'
    account = ''
    bookmark_file = 'bookmark.txt'
    addresses = []

    def __init__(self):
        super(TwitterDapp, self).__init__()

...
...
        self.web3_write_a_tweet_thread.write_a_tweet.connect(self.successfullyWriteATweet)

现在,是时候创建一个 GUI 了。我们设置窗口标题,子类化QWidget,并设置私人分组框,我们将从用户那里请求私钥。我们将添加一个写入按钮小部件,并配置一个用于显示推文的分组框。然后准备一个用于显示书签的分组框,并将它们全部添加到垂直布局中。此外,我们创建了两个线程实例。对于每个线程实例,我们将它们的信号连接到slot函数:

    def createPrivateKeyGroupBox(self):
        self.private_key_group_box = QtWidgets.QGroupBox("Account")
        self.private_key_field = QtWidgets.QLineEdit()
        self.welcome_message = QtWidgets.QLabel()

        layout = QtWidgets.QFormLayout()
        layout.addRow(QtWidgets.QLabel("Private key:"), self.private_key_field)
        button_box = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Ok)
        button_box.button(QtWidgets.QDialogButtonBox.Ok).clicked.connect(self.checkPrivateKey)
        layout.addRow(button_box)
        layout.addRow(self.welcome_message)

        self.private_key_group_box.setLayout(layout)

在这个方法中,我们创建一个分组框,放置行编辑器以接收私钥。我们还放置一个标签,用于在输入有效的私钥时显示通知。在这里,我们使用QFormLayout,而不是QHBoxLayoutQVBoxLayoutQFormLayout非常适用于创建两列布局。第一列通常用作标签的容器,而第二列通常用作输入的容器,例如行编辑器。因此,使用这个布局,我们使用addRow方法,它接受一个或两个参数。如果参数数量为两个,则参数将是左列上的小部件和右列上的小部件。然后我们添加一个私钥标签和一个行编辑器,然后是一个确定对话框按钮和一个通知标签。对于对话框按钮,我们将点击信号连接到一个名为checkPrivateKey的方法。

在接下来的方法中,我们在水平布局中创建一个按钮。我们将点击信号连接到一个名为writeANewTweet的方法:

    def createWritingTweetGroupBox(self):
        self.tweet_button = QtWidgets.QPushButton("Write a new tweet")
        self.tweet_button.setMaximumSize(200,40)
        self.write_button_layout = QtWidgets.QHBoxLayout()
        self.write_button_layout.addWidget(self.tweet_button)
        self.connect(self.tweet_button, QtCore.SIGNAL('clicked()'), self.writeANewTweet)

在接下来的方法中,我们创建一个分组框来显示推文。首先有一个行编辑器来接受推特的地址。这旁边有一个按钮用于获取推文和将地址加入书签。回调函数将稍后定义。我们还在布局内放置了一个布局来显示所有推文:

    def createTweetsGroupBox(self):
        self.tweets_group_box = QtWidgets.QGroupBox("Tweets")
        self.account_address = QtWidgets.QLineEdit()
        self.fetch_button = QtWidgets.QPushButton("Fetch")
        self.add_to_bookmark_button = QtWidgets.QPushButton("Bookmark it!")

...
...
        self.tweets_main_layout.addLayout(account_address_layout)
        self.tweets_main_layout.addSpacing(20)
        self.tweets_main_layout.addLayout(self.tweets_layout)
        self.tweets_group_box.setLayout(self.tweets_main_layout)

在接下来的方法中,我们创建一个分组框来显示书签。这个书签布局只是一个普通的垂直布局。我们从文本文件中读取书签,然后处理这些行。基本上,处理是从文本行中删除换行符:

    def createBookmarkGroupBox(self):
        self.bookmark_group_box = QtWidgets.QGroupBox("Bookmark")
        self.bookmark_layout = QtWidgets.QVBoxLayout()
        self.bookmark_group_box.setLayout(self.bookmark_layout)

        with open(self.bookmark_file) as f:
            addresses = f.readlines()
        self.addresses = list(map(lambda x: x.rstrip(), filter(lambda x: len(x) > 1, addresses)))
        self.fillBookmark()

这是用于获取推文的slot函数。它使用线程来完成任务。

    @QtCore.Slot()
    def fetchTweets(self):
        account = self.account_address.displayText()
        self.web3_read_tweets_thread.setAccount(account)
        self.web3_read_tweets_thread.start()

这是slot函数,它将推特的地址添加到书签文本文件中。在将书签添加到书签文本文件后,它会更新书签布局:

    @QtCore.Slot()
    def bookmarkAddress(self):
        account = self.account_address.displayText()
        if account:
            self.addresses.append(account)
            self.addresses = list(set(addresses))
        with open(self.bookmark_file, 'w') as f:
            for address in self.addresses:
                f.write(address)
        self.fillBookmark()

在这个方法中,我们启动一个只有一个行编辑器的输入对话框。获取输入后,我们在运行之前将输入发送到线程类:

    @QtCore.Slot()
    def writeANewTweet(self):
        text, ok = QtWidgets.QInputDialog.getText(self, "Write a new tweet", "Tweet:", QtWidgets.QLineEdit.Normal, "")
        if ok and text != '':
            self.web3_write_a_tweet_thread.setPrivateKey(self.private_key)
            self.web3_write_a_tweet_thread.setTweet(text)
            self.web3_write_a_tweet_thread.start()

这是一个回调函数,用于检查私钥是否有效。如果有效(地址可以从私钥中派生),我们将通知标签设置为成功:

    def checkPrivateKey(self):
        self.private_key = self.private_key_field.displayText()
        try:
            self.account = w3.eth.account.privateKeyToAccount('0x'+self.private_key)
        except ValueError:
            QtWidgets.QMessageBox.warning(self, 'Error', 'Private key is invalid.')
            return
        self.welcome_message.setText('Welcome, ' + self.account.address + '!')
        self.private_key_field.clear()

这两个方法用于填充推文到推文布局中。首先,我们清除垂直布局中的推文。我们可以使用takeAt方法从布局中获取每个小部件。没有方法可以删除布局的所有子小部件;我们必须逐个迭代它们。如果小部件存在,我们可以删除它。清除布局中的所有推文后,我们使用addWidget方法填充布局以显示新的推文。处理书签时也使用了相同的策略:

    def clearTweetsField(self):
        while True:
            label_item = self.tweets_layout.takeAt(0)
            if label_item is None:
                break
            else:
                label_item.widget().close()

    def fillPosts(self, posts):
        self.clearTweetsField()
        for post in posts:
            label_field = QtWidgets.QLabel(post)
            self.tweets_layout.addWidget(label_field)

在接下来的两种方法中使用相同的策略。我们通过逐个迭代并删除小部件来清除书签布局中的书签小部件。然后,我们通过addWidget方法在书签布局中填充书签。处理推文和书签的一个区别是,我们配置了书签部分中的标签,以便可以使用鼠标进行选择。负责此操作的方法是setTextInteractionFlags方法。

参数是标签的当前标志,该标志与Qt.TextSelectableByMouse标志掩码。如果您在不使用前一个值掩码的情况下使用此代码,label_field.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse),则可以删除标签小部件具有的所有当前标签选择配置标志。大多数情况下,您不希望这样做:

    def clearBookmarkField(self):
        while True:
            label_item = self.bookmark_layout.takeAt(0)
            if label_item is None:
                break
            else:
                label_item.widget().close()

    def fillBookmark(self, addresses):
        self.clearBookmarkField()
        for address in addresses:
            label_field = QtWidgets.QLabel(address)
            label_field.setTextInteractionFlags(label_field.textInteractionFlags() | QtCore.Qt.TextSelectableByMouse)
            self.bookmark_layout.addWidget(label_field)

这是设置标签小部件中成功消息的回调:

    def successfullyWriteATweet(self):
        self.welcome_message.setText('You have successfully written a new tweet!')

最后,我们启动 GUI 前端:

if __name__ == '__main__':
    import sys
    app = QtWidgets.QApplication(sys.argv)
    twitter_dapp = TwitterDapp()
    twitter_dapp.show()
    sys.exit(app.exec_())

在 Ganache 中输入第二个地址以获取执行固定脚本时设置的推文:

输入私钥并单击确定:

单击“撰写新推文”按钮。它将启动对话框。然后输入您的推文并单击确定:

输入新推文并确认后,您将收到成功消息。然后,您可以单击按钮(收藏!)将地址保存到bookmarks文本文件中:

如果您想为智能合约或去中心化应用程序构建前端,需要牢记两件事。首先,要优雅地处理阻塞功能(向区块链发送交易并等待),可以使用线程或单线程协程(使用asyncio库)。其次,虽然智能合约处理核心事务(审查抵抗消息),但您可以为去中心化应用程序增加更多价值。在我们的示例应用程序中,增加的价值是书签实用程序。您可以在智能合约中添加书签功能吗?当然可以。但是,如果将功能放在智能合约中,就会失去隐私,因为每个人都可以阅读您的书签。

简而言之,由于智能合约的限制,您无法将每个功能都放在智能合约中。因此,如果要使用机器学习添加情感分析,最好在智能合约之外进行此操作。

总结

在本章中,我们学习了如何为智能合约构建 GUI 前端,以及如何使用 Qt for Python 或PySide2库,该库利用了 Qt 5 库。我们抽出时间学习如何使用 Qt for Python 构建 GUI 应用程序。从一个简单的应用程序开始,我们了解了布局、各种小部件、信号和插槽以及线程,最终了解了如何使用PySide2库。我们构建了我们的智能合约,以简单的方式模拟 Twitter,享受这种类似 Twitter 应用程序的审查抵抗。接下来,我们构建了 GUI 前端。此 GUI 应用程序使用了迄今为止学到的所有概念。我们使用线程连接到区块链,以便 GUI 不会冻结。我们还通过在 GUI 应用程序中添加书签功能来增加附加值。

在下一章中,您将学习如何创建 ERC20 代币。这是使以太坊在开发人员中受欢迎的原因。代币可以在某些环境中用作货币的替代品。

第四部分:加密货币和钱包

本节是一个实践学习指南,教你如何发行自己的加密货币并构建处理它的钱包。

本节将涵盖以下章节:

  • 第八章,在以太坊中创建代币

  • 第九章,加密货币钱包

第八章:在以太坊中创建代币

在本章中,您将学习如何在以太坊上创建代币。代币有各种用途;它可以是一个社区的本地货币,它可以代表一个实物商品,它可以是游戏中的虚拟货币,或者它可以是忠诚度积分。有了这个代币,您可以构建一个新的加密货币。虽然以太坊本身就是一种加密货币,但您可以在其上构建一个新的加密货币。以太坊使得创建新代币变得更加容易,这推动了 2017 年许多新加密货币的诞生。

本章将涵盖以下主题:

  • 如何创建一个简单的代币智能合约

  • ERC 20(以太坊代币标准)

  • 如何出售您的代币

  • 如何自定义您的代币

代币智能合约

使用 Vyper 在以太坊上创建代币很容易。在构建代币之前,让我们遵循初始步骤准备我们的开发环境。

首先确保您已经安装了 geth,并且geth程序在$PATH环境变量中(这意味着您可以在不输入完整路径的情况下调用geth):

$ virtualenv -p python3.6 token-venv
$ source token-venv/bin/activate
(token-venv) $ pip install eth-abi==1.2.2
(token-venv) $ pip install eth-typing==1.1.0
(token-venv) $ pip install py-evm==0.2.0a33
(token-venv) $ pip install web3==4.7.2
(token-venv) $ pip install -e git+https://github.com/ethereum/populus#egg=populus
(token-venv) $ pip install vyper
(token-venv) $ mkdir token_project
(token-venv) $ cd token_project
(token-venv) $ mkdir tests contracts
(token-venv) $ cp ../token-venv/src/populus/populus/assets/defaults.v9.config.json project.json

通过将compilation键的值更改为以下内容,向project.json添加 Vyper 支持:

"compilation": {
    "backend": {
      "class": "populus.compilation.backends.VyperBackend"
    },
    "contract_source_dirs": [
      "./contracts"
    ],
    "import_remappings": []
},

Vyper 的最新版本是 0.1.0b6,它破坏了 Populus。开发者需要一些时间来解决这个问题。如果到您阅读本书时错误仍未被修复,您可以自己修补 Populus。

首先,使用以下命令检查错误是否已被修复:

(token-venv) $ cd voting-venv/src/populus
(token-venv) $ grep -R "compile(" populus/compilation/backends/vyper.py
 bytecode = '0x' + compiler.compile(code).hex()
 bytecode_runtime = '0x' + compiler.compile(code, bytecode_runtime=True).hex()

在我们的案例中,错误尚未被修复。因此,让我们修补 Populus 以修复错误。确保您仍然在同一个目录(token-venv/src/populus)中:

(token-venv) $ wget https://patch-diff.githubusercontent.com/raw/ethereum/populus/pull/484.patch
(token-venv) $ git apply 484.patch
(token-venv) $ cd ../../../

token_project目录中,运行以下命令:

(token-venv) $ populus chain new localblock

然后,使用init_chain.sh脚本初始化私有链:

(token-venv) $ ./chains/localblock/init_chain.sh

编辑chains/localblock/run_chain.sh并将--ipcpath的值更改为/tmp/geth.ipc

然后,运行区块链:

(voting-venv) $ ./chains/localblock/run_chain.sh

现在,编辑project.json文件。chains有一个包含四个键的对象:testertempropstenmainnet。向此对象添加一个名为localblock的键,并将其值添加到此对象中:

    "localblock": {
      "chain": {
        "class": "populus.chain.ExternalChain"
      },
      "web3": {
        "provider": {
          "class": "web3.providers.ipc.IPCProvider",
        "settings": {
          "ipc_path":"/tmp/geth.ipc"
        }
       }
      },
      "contracts": {
        "backends": {
          "JSONFile": {"$ref": "contracts.backends.JSONFile"},
          "ProjectContracts": {
            "$ref": "contracts.backends.ProjectContracts"
          }
        }
      }
    }

Mist

在第二章中,智能合约基础,和第三章中,使用 Vyper 实现智能合约,您使用 Truffle 控制台与智能合约进行交互。现在,我们将使用另一种软件与智能合约进行交互。我们将使用的软件是Mist,一个以太坊钱包。

为此,请访问github.com/ethereum/mist/releases。如果您使用 Linux Ubuntu,下载Mist-linux64-0-11-1.deb文件。

确保geth已经安装,并且geth的路径在$PATH环境变量中,这样您就可以从任何地方调用geth

然后,按照以下步骤安装 Mist:

$ dpkg -i Mist-linux64-0-11-1.deb

接下来,按照以下步骤运行应用程序:

$ ethereumwallet --rpc /tmp/geth.ipc

注意--rpc /tmp/geth.ipc标志。我们使用这个标志是因为我们使用在/tmp/geth.ipc中定义的套接字文件运行私有区块链。

运行应用程序后,您将看到这个屏幕。您可以通过点击“添加账户”按钮创建一个以太坊区块链账户:

就像您在geth控制台中所做的那样,您需要提供密码来创建一个新账户:

一旦您的钱包账户创建完成,您可以通过点击“发送”选项卡,从第一个账户向这个新账户发送一些钱:

然后,您可以在“TO”字段中插入您的新账户地址。不要忘记包括您想发送的以太币数量,然后点击窗口底部的“发送”按钮:

您必须填写密码以创建交易。默认账户的密码可以在chains/localblock/password文件中找到:

很快,您的交易将在区块链中得到确认:

让我们在token_project/contracts目录中创建一个简单的代币智能合约:

balances: public(map(address, uint256))

@public
def __init__():
    self.balances[msg.sender] = 10000

@public
def transfer(_to: address, _amount: uint256) -> bool:
    assert self.balances[msg.sender] >= _amount

    self.balances[msg.sender] -= _amount
    self.balances[_to] += _amount

    return True

这个智能合约数字化地创建了 10,000 个代币,并将它们全部分配给智能合约的所有者。然后,所有者可以使用transfer方法将代币转发给其他账户。

与创建传统网络应用程序中的简单代币相比,这个简单的智能合约是特殊的,因为一旦部署了这个智能合约,所有者无论多么绝望都无法更改代币数量。如果所有者刚刚执行了transfer方法将一些代币转移到另一个账户的地址,他们就无法再拿回来了。其他人在与之交互之前可以验证智能合约中的游戏规则。

将这个与我们在传统的网络应用程序中创建的简单代币进行比较。一旦您在这里创建了 10,000 个代币,您可以通过更新数据库中的代币数量来改变代币的数量。您可以随意更改规则,这会让其他想要与这个应用程序交互的人处于不利地位。

您还可以添加一个方法来增加智能合约中代币的数量:

balances: public(map(address, uint256))
owner: address

@public
def __init__():
    self.balances[msg.sender] = 10000
    self.owner = msg.sender

@public
def transfer(_to: address, _amount: uint256) -> bool:
    assert self.balances[msg.sender] >= _amount

    self.balances[msg.sender] -= _amount
    self.balances[_to] += _amount

    return True

@public
def mint(_new_supply: uint256):
    assert msg.sender == self.owner
    self.balances[msg.sender] = _new_supply

看一下mint方法。这可以用来增加所有者账户中的代币数量。

不同之处在于,您无法在部署智能合约后更改游戏规则。如果您部署了这个版本的智能合约,人们可以要求源代码和编译器的版本来验证代码。如果您不提供源代码,人们可能会对您的智能合约失去兴趣。如果您提供了源代码,人们可以检查并看到您有一个可以随时增加代币的方法。他们是否接受这个规则取决于他们自己,但至少通过智能合约,有透明度。

以下代码块是对这个简单代币智能合约的测试;这个测试并不全面,但它可以让你有一个良好的开始。对于代币智能合约来说,测试的重要性是至关重要的。首先,将测试命名为test_simple_token.py,并将其放在token_project/tests目录中。请参考以下 GitLab 链接中的代码文件获取完整的代码:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_08/token_project/tests/test_simple_token.py

import pytest
import eth_tester

def test_balance(web3, chain):
    simple_token, _ = chain.provider.get_or_deploy_contract('SimpleToken')

...
...

    with pytest.raises(eth_tester.exceptions.TransactionFailed):
        simple_token.functions.transfer(web3.eth.coinbase, 10).transact({'from': account2})

让我们使用 Mist 将我们的简单代币智能合约(第一个,没有mint函数)部署到区块链上。

要做到这一点,点击 CONTRACTS 选项卡,然后点击 DEPLOY NEW CONTRACT 按钮:

在部署合约屏幕上,选择要从中部署的账户,然后单击 CONTRACT BYTE CODE 选项卡。在那里插入我们简单代币智能合约的字节码。当然,首先编译智能合约的源代码,然后可以从token_project/build/contracts.json中获取我们简单代币的字节码。找到bytecode键的值,并将该值复制到 CONTRACT BYTE CODE 选项卡中,不包括双引号。完成后,单击 DEPLOY 按钮(在下面的截图中看不到;您需要向下滚动),然后像往常一样在密码对话框中填写密码:

很快,您的智能合约创建将在区块链中得到确认。

然后,要与智能合约交互,请单击“合同”选项卡,然后单击“监视合同”按钮。然后会出现对话框。填写智能合约的地址,然后填写智能合约本身的名称。您可以在此处使用任何名称。接下来,在 JSON INTERFACE 字段中填写智能合约的json接口。您可以从token_project/build/contracts.json(在contracts.json文件中,找到abi键的值)中获取json接口。观看智能合约的表单如下屏幕所示:

现在,您的简单代币智能合约将出现在屏幕上。点击“简单代币”标签,进入此智能合约的屏幕:

您可以使用Balances方法(Mist 喜欢大写方法)读取智能合约所有者的余额。在“Arg 0 - address”字段中插入所有者帐户的地址,然后按 Enter。从中,您将得到 10000 作为输出。

现在,让我们转移一些代币。选择一个功能。现在只有一个功能:Transfertransfer方法的大写版本)。在“执行自”字段中选择帐户 1,在“到”字段中选择帐户 2 的地址,并在“金额”字段中插入 20。最后,点击“执行”按钮:

在检查目标帐户余额之前,请等待确认。将目标地址输入“余额”字段,然后按 Enter。这将给您输出20

ERC20

很可能,您已经听说过 ERC20。当有新的加密货币问世时,通常会出现的第一个问题是——它是 ERC20 代币吗?人们对 ERC20 代币的错误假设是,他们认为它是基于以太坊的加密货币。从技术上讲,这是正确的,但这并不是全部。ERC20 是以太坊中用于创建代币的标准。我们刚刚创建的简单代币并不满足 ERC20 标准。是的,它是数字代币智能合约,但它不是 ERC20 代币。ERC20 是我们在 2017 年看到新加密货币数量增加的许多原因之一。但是,ERC20 并不是在以太坊上创建代币的要求。

要创建 ERC20 代币,您必须实现以下方法:

function totalSupply() public view returns (uint256)
function balanceOf(address _owner) public view returns (uint256 balance)
function transfer(address _to, uint256 _value) public returns (bool success)
function transferFrom(address _from, address _to, uint256 _value) public returns (bool success)
function approve(address _spender, uint256 _value) public returns (bool success)
function allowance(address _owner, address _spender) public view returns (uint256 remaining)
event Transfer(address indexed _from, address indexed _to, uint256 _value)
event Approval(address indexed _owner, address indexed _spender, uint256 _value)

这些方法使用 Solidity 语法。以下是可选方法,您也可以实现:

function name() public view returns (string)
function symbol() public view returns (string)
function decimals() public view returns (uint8)

这个 ERC20 代币有什么特别之处?在创建代币智能合约时是否有义务?为什么我们不能在不满足 ERC20 标准的情况下创建数字代币?

实际上,您不必遵循此标准;没有法律强制您创建 ERC20 代币。例如,ERC20 标准希望您告诉用户totalSupply方法中的代币总量。但是,您可以创建一个名为taylorSwiftIsTheBest的方法来返回代币的总供应量,然后您可以创建一个文档来解释这个方法。

但是,如果您遵循 ERC20 代币标准,将会有一些优势:

  • 首先,它使用户更容易审计您的智能合约。

  • 第二,您的 ERC20 代币将被以太坊钱包(例如我们刚刚使用的Mist和 Metamask)自动识别(以太坊钱包是Firefox/Opera/Chrome插件)。

  • 第三,这样做可以更容易地让加密货币交易所列出您的代币。基本上,这会让每个人的生活更轻松。

然而,你应该把 ERC20 标准视为指导,而不是严格的法律。你不必 100%遵循 ERC20 标准。并非所有建立在以太坊之上的热门代币都是 100%符合 ERC20 标准的。一个例子是 Golem 代币智能合约。它没有实现approve方法,等等。你可以在以下链接阅读 Golem 代币智能合约的源代码:etherscan.io/token/0xa74476443119A942dE498590Fe1f2454d7D4aC0d#readContract

话虽如此,让我们创建一个 ERC20 代币。这段代码是从 Vyper 项目的官方示例修改而来的(github.com/ethereum/vyper/blob/master/examples/tokens/ERC20.vy)。请参考以下 GitLab 链接中的代码文件获取完整修改后的代码:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_08/token_project/contracts/ERC20Token.vy

Transfer: event({_from: indexed(address), _to: indexed(address), _value: uint256})
Approval: event({_owner: indexed(address), _spender: indexed(address), _value: uint256})

...
...

@public
@constant
def allowance(_owner: address, _spender: address) -> uint256:
    return self.allowed[_owner][_spender]

让我们逐行描述这个智能合约:

Transfer: event({_from: indexed(address), _to: indexed(address), _value: uint256})
Approval: event({_owner: indexed(address), _spender: indexed(address), _value: uint256})

你必须定义两种类型的事件,TransferApproval。如果愿意,你可以定义更多的事件。Transfer事件用于在代币转移时使用,这样智能合约的用户可以订阅此事件。Approval事件用于在你批准某个账户的支出金额时使用:

name: public(bytes[10])
symbol: public(bytes[3])
totalSupply: public(uint256)
decimals: public(uint256)
balances: map(address, uint256)
allowed: map(address, map(address, uint256))

有六个变量。第一个变量是代币的名称。我使用了bytes[10]作为数据类型,因为我的代币名称少于 10 个字节。可以随意更改长度。第二个变量是代币的符号;名称和符号是不同的。例如,以太坊加密货币的名称是以太坊,但符号是 ETH。通常,符号的字符长度为 3。第三个变量是totalSupply,即代币的总数量,第四个变量是小数位。你知道 1 比特币等于 100,000,000 聪,所以比特币算法中总共会创建 21,000,000 比特币。因此,我们可以说比特币算法中的总供应量是 2,100,000,000,000,000。小数位是 8(因为 1 比特币等于 100,000,000,或 10⁸)。此外,第五个变量是balances。这是用于跟踪账户地址余额的变量。最后一个变量是allowed,这是一个嵌套映射。它旨在跟踪已获批准从另一个账户余额中支出以太币的账户。如果这还不清楚,我们稍后会深入讨论。现在,我们将继续讨论此 ERC20 代币智能合约的初始化方法,如下代码块所示:

@public
def __init__():
    _initialSupply: uint256 = 1000
    _decimals: uint256 = 3
    self.totalSupply = _initialSupply * 10 ** _decimals
    self.balances[msg.sender] = self.totalSupply
    self.name = 'Haha Coin'
    self.symbol = 'HAH'
    self.decimals = _decimals
    log.Transfer(ZERO_ADDRESS, msg.sender, self.totalSupply)

这是我们在智能合约中初始化代币的方式。我们在空中创建代币,然后将所有代币的硬币交给所有者。然后,我们设置代币的名称和符号以及小数位数。最后,我们发出转移事件。地址是ZERO_ADDRESS is 0x000000000000000000000000000000000000000。这表明智能合约的所有者已经从无处转移了硬币:

@public
@constant
def balanceOf(_owner: address) -> uint256:
    return self.balances[_owner]

这个方法用于返回特定账户的余额:

@public
def transfer(_to: address, _amount: uint256) -> bool:
    assert self.balances[msg.sender] >= _amount
    self.balances[msg.sender] -= _amount
    self.balances[_to] += _amount
    log.Transfer(msg.sender, _to, _amount)

    return True

这是用于转移代币的方法。首先,确保发送者的余额足够支出。然后,只需从发送者的余额中减去转移过程的数量,并将该金额添加到目标的余额中。不要忘记使用事件记录此交易:

@public
def transferFrom(_from: address, _to: address, _value: uint256) -> bool:
    assert _value <= self.allowed[_from][msg.sender]
    assert _value <= self.balances[_from]

    self.balances[_from] -= _value
    self.allowed[_from][msg.sender] -= _value
    self.balances[_to] += _value
    log.Transfer(_from, _to, _value)

    return True

@public
def approve(_spender: address, _amount: uint256) -> bool:
    self.allowed[msg.sender][_spender] = _amount
    log.Approval(msg.sender, _spender, _amount)

    return True

让我们先看一下approve方法。因此,每个账户都有一个名为allowed的映射,但这个映射是用来做什么的呢?这是每个账户如何让其他账户花费它的钱。例如,假设有 5 个账户——A 账户、B 账户、C 账户、D 账户和 E 账户。

A 账户有 50 个硬币的余额和一个名为allowed的映射变量,其中包括 B 账户、C 账户、D 账户和 E 账户的键。这个映射的值如下:

B 账户→3 个硬币

C 账户→7 个硬币

D 账户→2 个硬币

E 账户→3 个硬币

这意味着最多,B 账户可以从 A 账户的余额中花费 3 个硬币,C 账户最多可以从 A 账户的余额中花费 7 个硬币,D 账户最多可以从 A 账户的余额中花费 2 个硬币,E 账户最多可以从 A 账户的余额中花费 3 个硬币。

approve方法内部,我们有以下行:

self.allowed[msg.sender][_spender] = _amount

为了确保 B 账户可以从 A 账户的余额中花费 3 个硬币,A 账户调用了approve方法,_spender设置为 B 账户的地址,_amount设置为 3 个硬币。不要忘记记录这个批准。

然后,如果 B 账户想要花费 3 个或全部 3 个硬币,B 账户可以调用transferFrom方法,如下所示:

assert _value <= self.allowed[_from][msg.sender]

transferFrom方法内的第一个断言是确保 B 账户不花费超过 3 个硬币。

assert _value <= self.balances[_from]

我们确保 A 账户的余额至少有 3 个硬币,如下所示:

    self.balances[_from] -= _value
    self.allowed[_from][msg.sender] -= _value
    self.balances[_to] += _value
    log.Transfer(_from, _to, _value)

然后,我们从 A 账户的余额和 A 账户对 B 账户的津贴中减去花费的金额。之后,我们增加目标账户的余额。不要忘记记录这笔交易作为Transfer事件。

最后一个方法是检查这个津贴的映射:

@public
@constant
def allowance(_owner: address, _spender: address) -> uint256:
    return self.allowed[_owner][_spender]

这是为了确定 B 账户可以从 A 账户的余额中花费多少。

此时,您可能会问这个方法的意义是什么。如果我们想允许 B 账户代表 A 账户花费一些以太币,为什么我们不直接将硬币的数量转移到 B 账户呢?例如,如果 A 账户允许 B 账户从 A 账户的余额中花费 5 个硬币,这意味着 A 账户让 B 账户直接从 A 账户的余额中向 B 账户的地址发送 5 个硬币。那么,为什么 A 账户不直接向 B 账户的地址发送 5 个硬币以节省麻烦呢?如果 B 账户改变主意,他们可以退还代币的数量给 A 账户。

通常情况下,我们不允许普通账户代表我们花费。但是,我们允许智能合约这样做。有许多有效的原因,我们希望允许智能合约花费我们的钱。其中一个原因是去中心化交易智能合约的情况,您希望让去中心化交易智能合约出售您的代币。假设您创建了 1,000 个 HHH 硬币,然后您想在去中心化交易中出售其中一些。因此,您允许这个去中心化交易代表您花费一些硬币。也许您批准去中心化交易代表您花费 30 个 HHH 硬币。这个去中心化交易可以尝试代表您出售 30 个 HHH 硬币。您不会允许去中心化交易智能合约访问您余额中的所有硬币,只是因为您只想出售 30 个 HHH 硬币。尽管您可以审计智能合约,以确保智能合约不会尝试窃取您的硬币,但最好有另一层安全性。approve方法就是这样一层。

编译您的代币智能合约并部署它,就像以前一样。

完成后,转到“合同”选项卡,然后单击“WATCH TOKEN”按钮。在“添加代币”窗口中,插入您的 ERC20 智能合约的地址。然后,其他字段,如“TOKEN NAME”、“TOKEN SYMBOL”和“DECIMAL PLACES OF SMALLEST UNIT”将自动填充。这就是我们应该实现 ERC20 标准的原因之一:

添加代币后,您应该在合同屏幕上看到 HAHA COIN 标签:

然后,在发送标签中,选择 ERC20 智能合约的所有者,然后选择 Haha Coin 而不是以太币。向账户 2 发送 1,000 个 Haha 代币,然后等待确认:

现在,在同一个标签页中选择账户 2;您会看到账户 2 现在有 1,000 个 Haha 代币:

所有与 ERC20 兼容的钱包,如 Mist 和 MetaMask,都可以轻松识别和与您的代币进行交互。加密货币交易背后的人也不会在将您的 ERC20 代币整合到他们的交易所中遇到任何技术困难。想象一下,如果您不遵循 ERC20 标准,并且将一个方法命名为tailorSwiftTransferCoin来转移代币。在这种情况下,您必须为用户构建一个自定义钱包,然后他们才能与您的代币进行交互。

这是对这个 ERC20 代币的测试。请记住,这个测试并不全面。请参考以下 GitLab 链接中的代码文件获取完整的代码:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_08/token_project/tests/test_erc20_token.py

import pytest
import eth_tester

def test_balance(web3, chain):
    erc20_token, _ = chain.provider.get_or_deploy_contract('ERC20Token')

    token_name = erc20_token.functions.name().call()
    token_symbol = erc20_token.functions.symbol().call()
    decimals = erc20_token.functions.decimals().call()
    total_supply = erc20_token.functions.totalSupply().call()
    balance = erc20_token.functions.balanceOf(web3.eth.coinbase).call()

...
...

    assert balance_account1 == 999990
    assert balance_account2 == 0
    assert balance_account3 == 10
    assert allowance == 90

除了转账方法,这个测试还测试了approvaltransferFrom方法。

出售代币

现在我们有了一个代币,是时候出售这些代币了。我们希望用以太币出售我们的自定义代币。与比特币平台相比,在以太坊平台上创建一个众售代币非常容易。您已经知道如何在智能合约中创建一个方法来接受以太币。您还知道如何增加一些账户的代币余额。要出售代币,您必须将这两件事结合起来。就是这样。

这是首次代币发行ICO)的核心。以太坊的货币很有价值。尽管以太币的价格波动,但 1 个以太币的价值大约是 100 美元。人们会用真钱购买一些以太币,但不会购买我们的自定义代币。为了使我们的自定义代币有价值,我们首先必须使其有用,或者至少使其看起来有用。但要做到这一点,我们需要资本。那么为什么不将我们的代币(比如 60%)出售给早期采用者呢?然后,他们可以用以太币购买我们的自定义代币。然后,我们可以在将其转换为法定货币之前提取以太币,这样我们就可以雇佣更多的程序员并租用办公室来开发我们的新加密货币。这就是基本的想法。当然,因为 ICO 涉及大量资金,它也会吸引捕食者。

这是众售代币智能合约。这与我们之前的 ERC20 代币智能合约源代码相同,但有些许变化。将这个智能合约命名为CrowdSaleToken.vy,并保存在token_project/contracts目录中。请参考以下 GitLab 链接中的代码文件获取完整的代码:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_08/token_project/contracts/CrowdSaleToken.vy

Transfer: event({_from: indexed(address), _to: indexed(address), _value: uint256})
Approval: event({_owner: indexed(address), _spender: indexed(address), _value: uint256})
Payment: event({_buyer: indexed(address), _value: uint256(wei)})

name: public(bytes[10])
symbol: public(bytes[3])
totalSupply: public(uint256)
decimals: public(uint256)
balances: map(address, uint256)
ethBalances: public(map(address, uint256(wei)))
allowed: map(address, map(address, uint256))

...
...

@public
@constant
def allowance(_owner: address, _spender: address) -> uint256:
    return self.allowed[_owner][_spender]

让我们逐行讨论这个智能合约:

Transfer: event({_from: indexed(address), _to: indexed(address), _value: uint256})
Approval: event({_owner: indexed(address), _spender: indexed(address), _value: uint256})
Payment: event({_buyer: indexed(address), _value: uint256(wei)})

我们在另外两个事件之上添加了一个名为Payment的事件。当有人用以太币购买代币时,将调用此事件:

name: public(bytes[10])
symbol: public(bytes[3])
totalSupply: public(uint256)
decimals: public(uint256)
balances: map(address, uint256)
ethBalances: public(map(address, uint256(wei)))
allowed: map(address, map(address, uint256))

beneficiary: public(address)
minFundingGoal: public(uint256(wei))
maxFundingGoal: public(uint256(wei))
amountRaised: public(uint256(wei))
deadline: public(timestamp)
price: public(uint256(wei))
fundingGoalReached: public(bool)
crowdsaleClosed: public(bool)

我们添加了一些新变量,包括ethBalancesbeneficiaryminFundingGoalmaxFundingGoalamountRaiseddeadlinepricefundingGoalReachedcrowdsaleClosed

ethBalances是一个变量,用于跟踪买家在这个智能合约中花费了多少以太币。我们想跟踪这个数字,因为如果我们无法筹集到必要的金额,我们想要退还以太币。beneficiary是一个用于跟踪谁启动了这个智能合约的变量。这个beneficiary变量是唯一能够提取以太币的账户。

minFundingGoalmaxFundingGoal是设计为确保筹集的金额大于最低筹资目标且低于最高筹资目标的变量。

amountRaised是一个变量,用于跟踪在这个智能合约中筹集了多少以太币。

deadline是一个用于跟踪众筹过程截止日期的变量。

price是我们希望以太币价值我们数字代币的方式。这个变量旨在回答买家用 1 个以太币可以获得多少个我们的代币。

fundingGoalReached是一个布尔变量,表示我们的智能合约是否已经达到了目标。

crowdsaleClosed是一个布尔变量,表示我们的智能合约是否仍在接收购买过程。我们将从初始化方法开始:

@public
def __init__():
    _initialSupply: uint256 = 100
    _decimals: uint256 = 2
    self.totalSupply = _initialSupply * 10 ** _decimals
    self.name = 'Haha Coin'
    self.symbol = 'HAH'
    self.decimals = _decimals
    self.beneficiary = msg.sender
    self.balances[msg.sender] = self.totalSupply
    self.minFundingGoal = as_wei_value(30, "ether")
    self.maxFundingGoal = as_wei_value(50, "ether")
    self.amountRaised = 0
    self.deadline = block.timestamp + 3600 * 24 * 100 # 100 days
    self.price = as_wei_value(1, "ether") / 100
    self.fundingGoalReached = False
    self.crowdsaleClosed = False

这个智能合约中的总代币供应量为 10,000。我们希望至少筹集 30 个以太币,最多筹集 50 个以太币。

截止日期设置为智能合约部署在区块链上后的 100 天。block.timestamp大致是当前时间,或者包含此智能合约代码的区块确认的时间。

1 个代币的价格设定为0.01以太币。这意味着 1 个以太币可以购买 100 个我们的代币。然后,我们进入智能合约中的默认函数:

@public
@payable
def __default__():
    assert msg.sender != self.beneficiary
    assert self.crowdsaleClosed == False
    assert self.amountRaised + msg.value < self.maxFundingGoal
    assert msg.value >= as_wei_value(0.01, "ether")
    self.ethBalances[msg.sender] += msg.value
    self.amountRaised += msg.value
    tokenAmount: uint256 = msg.value / self.price
    self.balances[msg.sender] += tokenAmount
    self.balances[self.beneficiary] -= tokenAmount
    log.Payment(msg.sender, msg.value)

这是用户用来购买代币的方法。__default__是一个默认的回退函数。如果有人没有在智能合约上执行方法并支付以太币,将执行此函数。实际上,我们不必使用默认函数来接受付款。您可以使用标准方法,就像您在以前的智能合约中所做的那样。我们只是使用默认函数来解释这个概念。

在这个付款方法中,我们确保买家不是受益人,众筹仍在进行,并且用以太币发送到此方法的金额不超过 50 个以太币的最高筹资目标。最后,每次购买行为必须至少为 0.01 以太币。然后,我们增加该买家的以太币余额,并增加筹集的以太币金额。然后,我们通过将以太币金额除以 1 个代币的价格来检查他们购买的代币数量。

最后,我们必须增加该买家的代币余额,并减少智能合约所有者的代币余额。不要忘记记录此事件。然后,我们进入可以检查是否达到目标的方法:

@public
def checkGoalReached():
    assert block.timestamp > self.deadline
    if self.amountRaised >= self.minFundingGoal:
        self.fundingGoalReached = True
    self.crowdsaleClosed = True

首先,我们确保只有在截止日期过去后才能成功执行此方法。如果筹集的金额超过最低筹资目标,我们将fundingGoalReached变量设置为true。最后,我们将crowdsaleClosed变量设置为true

为了简单起见,我们只检查block.timestamp变量是否大于截止日期。然而,区块中的时间戳可以填入矿工喜欢的任何内容;它不一定是区块确认时的当前时间。但是,如果矿工将过去的时间戳作为block.timestamp的值,所有其他矿工都会拒绝它。同样,如果矿工将未来时间戳(例如,一年后)作为block.timestamp的值,所有其他矿工也会拒绝它。为了使截止日期检查过程更安全,您必须将其与block.number结合起来,以检查自从智能合约启动以来已确认了多少个区块。然后,我们进入智能合约的管理者可以提取在智能合约中累积的以太币的方法:

@public
def safeWithdrawal():
    assert self.crowdsaleClosed == True
    if self.fundingGoalReached == False:
        if msg.sender != self.beneficiary:
            if self.ethBalances[msg.sender] > 0:
                self.ethBalances[msg.sender] = 0
                self.balances[self.beneficiary] += self.balances[msg.sender]
                self.balances[msg.sender] = 0
                send(msg.sender, self.ethBalances[msg.sender])
    if self.fundingGoalReached == True:
        if msg.sender == self.beneficiary:
            if self.balance > 0:
                send(msg.sender, self.balance)

safeWithdrawal方法的运行方式不同,取决于是否达到了筹资目标。在上述方法内部,我们确保众筹已经关闭。如果未达到筹资目标,我们确保每个买家都可以取回他们的以太币。如果达到了筹资目标,我们确保受益人可以从智能合约中提取所有以太币。其他方法的剩余部分与先前的智能合约相同。但是,我们添加了一些断言来确保这些方法只能在众筹结束后执行。

以下代码块是对这个众筹智能合约的测试。请参考以下 GitLab 链接中的代码文件获取完整的代码文件:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_08/token_project/tests/test_crowd_sale_token.py

import pytest
import eth_tester
import time

def test_initialization(web3, chain):
    crowd_sale_token, _ = chain.provider.get_or_deploy_contract('CrowdSaleToken')

...
...

    assert abs(beforeCrowdsaleEthBalanceAccount2 - afterCrowdsaleEthBalanceAccount2 - web3.toWei('40', 'ether')) < web3.toWei('1', 'gwei')
    assert abs(afterCrowdsaleEthBalanceAccount1 - beforeCrowdsaleEthBalanceAccount1 - web3.toWei('40', 'ether')) < web3.toWei('1', 'gwei')

特别关注test_withdrawaltest_refund,尤其是这些行:

    # move forward 101 days
    web3.testing.timeTravel(int(time.time()) + 3600 * 24 * 101)
    web3.testing.mine(1)

我们不想等待 100 天,我们想在测试中操纵时钟,使其相信已经到达截止日期(从现在开始的 101 天)。因此,我们假装已经过去了 101 天,然后我们确认了 1 个区块。因此,智能合约内部的block.timestamp变量将是从现在开始的 101 天。

稳定币

您已经创建了一个可以自主出售的数字代币。但是,您不应该局限于通用代币。您可以通过添加更多方法来使您的代币智能合约更具创意。您应该添加哪些方法取决于您的智能合约的目的。在游戏中用作货币的代币智能合约将具有不同的方法,而在供应链跟踪系统中使用的代币智能合约将具有不同的方法。

让我们创建一个稳定币智能合约。这是一个与法定货币(如美元)挂钩的代币智能合约。我们还希望这个智能合约成为一个银行,作为所有者,我们可以冻结账户。

我们可以基于 ERC 20 代币智能合约进行工作。我们只需要添加三种方法——一个冻结账户的方法,一个添加代币的方法,以及一个丢弃代币的方法。

这是智能合约。您可以参考以下 GitLab 链接中的代码文件获取完整的代码文件:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_08/token_project/contracts/StableCoin.vy

Transfer: event({_from: indexed(address), _to: indexed(address), _value: uint256})
Approval: event({_owner: indexed(address), _spender: indexed(address), _value: uint256})
Freeze: event({_account: indexed(address), _freeze: bool})

name: public(bytes[10])
symbol: public(bytes[3])
totalSupply: public(uint256)
decimals: public(uint256)
balances: map(address, uint256)
allowed: map(address, map(address, uint256))
frozenBalances: public(map(address, bool))
owner: public(address)

...
...

@public
@constant
def allowance(_owner: address, _spender: address) -> uint256:
    return self.allowed[_owner][_spender]

让我们逐行讨论智能合约:

Transfer: event({_from: indexed(address), _to: indexed(address), _value: uint256})
Approval: event({_owner: indexed(address), _spender: indexed(address), _value: uint256})
Freeze: event({_account: indexed(address), _freeze: bool})

我们需要在上述代码中添加另一个事件,用于冻结账户操作。

我们在 ERC20 代币智能合约的变量之上添加了两个新变量,即frozenBalancesowner

name: public(bytes[10])
symbol: public(bytes[3])
totalSupply: public(uint256)
decimals: public(uint256)
balances: map(address, uint256)
allowed: map(address, map(address, uint256))
frozenBalances: public(map(address, bool))
owner: public(address)

frozenBalances是一个映射变量,用于跟踪已被冻结的账户。owner是一个变量,用于跟踪智能合约的所有者。

在这个初始化方法中,我们将owner变量设置为启动这个智能合约的账户:

@public
def __init__():
    _initialSupply: uint256 = 1000
    _decimals: uint256 = 3
    self.totalSupply = _initialSupply * 10 ** _decimals
    self.balances[msg.sender] = self.totalSupply
    self.name = 'Haha Coin'
    self.symbol = 'HAH'
    self.decimals = _decimals
    self.owner = msg.sender
    log.Transfer(ZERO_ADDRESS, msg.sender, self.totalSupply)

在接下来的方法中,我们确保只有所有者可以调用此方法:

@public
def freezeBalance(_target: address, _freeze: bool) -> bool:
    assert msg.sender == self.owner
    self.frozenBalances[_target] = _freeze
    log.Freeze(_target, _freeze)

    return True

然后,我们设置了frozenBalances映射变量的值。真值表示账户被冻结。不要忘记调用Freeze事件。

下一个方法是增加硬币:

@public
def mintToken(_mintedAmount: uint256) -> bool:
    assert msg.sender == self.owner
    self.totalSupply += _mintedAmount
    self.balances[msg.sender] += _mintedAmount
    log.Transfer(ZERO_ADDRESS, msg.sender, _mintedAmount)

    return True

我们增加了总供应量和所有者账户的余额。

接下来的方法旨在销毁硬币:

@public
def burn(_burntAmount: uint256) -> bool:
    assert msg.sender == self.owner
    assert self.balances[msg.sender] >= _burntAmount
    self.totalSupply -= _burntAmount
    self.balances[msg.sender] -= _burntAmount
    log.Transfer(msg.sender, ZERO_ADDRESS, _burntAmount)

    return True

这与上一种方法类似,但其目的是减少总供应量中的硬币数量。你为什么要销毁硬币?有许多有效的原因。假设您正在使用这个智能合约跟踪美元。假设您口袋里有 1 万美元。因此,智能合约中的总供应量为 10,000(1 个硬币等于 1 美元),但有一天,您的 10,000 美元中有 2,000 美元被小偷偷走了。为了确保智能合约的一致性,您可以使用这种方法销毁 2,000 个硬币。

其他方法与 ERC20 代币智能合约下完全相同,只有两个例外:transfertransferFrom。在这些方法中,我们有额外的断言:

assert self.frozenBalances[msg.sender] == False

以下代码块是对这个智能合约的测试。您可以参考以下 GitLab 链接中的代码文件获取完整的代码:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_08/token_project/tests/test_stable_token.py

import pytest
import eth_tester
import time

def test_initialization(web3, chain):
    stable_coin, _ = chain.provider.get_or_deploy_contract('StableCoin')

...
...

    new_total_supply = stable_coin.functions.totalSupply().call()
    assert new_total_supply == 999900

总结

在本章中,您已经学会了如何在以太坊上创建代币。您使用了 Mist 作为以太坊钱包,部署了合约并与代币智能合约进行交互。然后,您通过创建某些方法的实现来实现 ERC 20 标准的代币智能合约。您还看到了这些标准方法如何帮助 Mist 识别您的代币。然后,您创建了一种方法来出售代币以获取以太币。您在这个智能合约中设置了截止日期,然后使用时间旅行方法来模拟智能合约的测试中过期的截止日期。最后,您添加了其他方法来冻结和解冻其他账户。您使用的示例是将硬币与实际资产(如法定货币)挂钩的稳定币。

在下一章中,您将创建一个可以处理以太币和 ERC20 代币的加密货币钱包。

进一步阅读

第九章:加密货币钱包

在本章中,您将学习如何构建一个桌面加密货币钱包。您仍将使用相同的 GUI 库,Qt for Python 或 PySide2 来创建桌面应用程序。这个加密货币钱包可以发送以太币和 ERC20 代币。在构建这个加密货币钱包之前,您将学习 PySide2 库的高级功能,比如标签、组合框、大小策略以及添加拉伸以控制布局中小部件的分布。此外,您还将把测试集成到应用程序中。

在本章中,我们将涵盖以下主题:

  • PySide2 库的高级功能

  • Pytest Qt,用于测试 Python 应用程序的 Qt 库

  • 如何构建加密货币钱包

  • 构建加密货币钱包时需要考虑的一些因素

PySide2 库的高级功能

本章需要读者对 PySide2 库有一定的了解。如果您还没有这样做,您应该先阅读第七章 前端去中心化应用,因为本章是基于那一章的。如果您已经熟悉了使用PySide2构建 GUI,那么您就具备了构建桌面加密货币钱包所需的技能,至少从用户界面UI)的角度来看。但是,您构建的应用程序对用户来说可能会很刺耳。例如,如果您在水平布局中加入了一个按钮,并且该按钮是水平布局中唯一的小部件,当您调整具有水平布局的窗口大小时,按钮将被拉伸到右侧和左侧。如果这不是您想要发生的,您需要一种方法告诉按钮保持其宽度。

因此,让我们从PySide2库中学习其他功能,比如标签、大小策略和网格布局,这样我们就有了制作应用程序 UI 更具吸引力的技能。我们的应用程序可能不会赢得苹果最佳设计奖,但至少对用户来说不会那么刺耳。

此外,在第七章 前端去中心化应用中,我们忽略了测试。由于加密货币钱包应用程序是处理人们的资金的应用程序,错误是代价高昂的。因此,我们需要在用户之前捕捉到任何错误。因此,我们应该为我们的加密货币钱包编写适当的测试。但是,我们将专注于对加密货币钱包的 UI 部分进行测试。我们不会专注于测试内部方法。换句话说,我们的测试将是集成测试。

如果您还没有安装Qt库,请阅读第七章 前端去中心化应用,了解如何安装。之后,使用以下命令为您的项目创建一个虚拟环境:

$ virtualenv -p python3.6 wallet-venv
$ source wallet-venv/bin/activate
(wallet-venv) $ pip install PySide2
(wallet-venv) $ pip install web3==4.7.2

我们还希望安装一个测试库来测试我们的应用程序,可以通过以下命令来完成:

(wallet-venv) $ pip install pytest-qt

现在所有的库都已经设置好了,让我们写一个简单的应用程序来测试一下。

测试应用程序

创建一个名为advanced_course_qt的目录。我们可以把所有的教程文件放在这里。将第一个脚本命名为button_and_label.py,并使用以下代码为该按钮创建一个按钮和一个标签(请参考以下 GitLab 链接上的代码文件获取完整代码:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_09/advanced_course_qt/button_and_label.py):

from PySide2.QtWidgets import QWidget, QApplication, QLabel, QPushButton, QVBoxLayout
from PySide2.QtCore import Qt
import sys

class ButtonAndLabel(QWidget):

...
...

if __name__ == "__main__":

    app = QApplication(sys.argv)
    button_and_label = ButtonAndLabel()
    button_and_label.show()
    sys.exit(app.exec_())

运行上述代码,看看这个应用程序是关于什么的。该应用程序由一个按钮和一个标签组成:

如果您点击按钮,标签上的文本将会改变,如下图所示:

让我们测试一下这个应用程序。将测试命名为test_button_and_label.py,并将其放在相同的目录中。使用以下代码块进行测试应用程序:

from button_and_label import ButtonAndLabel
from PySide2 import QtCore

def test_button_and_label(qtbot):
    widget = ButtonAndLabel()
    qtbot.addWidget(widget)

    assert widget.label.text() == "label: before clicked"

    qtbot.mouseClick(widget.button, QtCore.Qt.LeftButton)

    assert widget.label.text() == "label: after clicked

使用以下命令运行测试:

(wallet-venv) $ pytest test_button_and_label.py

请注意,(wallet-venv) $ python test_button_and_label.py命令是一个微不足道的错误,经常用于运行测试。不要上当!

在这个测试脚本中,我们导入我们的widget类。然后,我们创建一个以test_开头的测试方法。这个方法有一个名为qtbot的参数。不要更改它的名称。qtbot是一个特殊的名称,不得更改。在这个方法内部,qtbot可以用来与widget类交互。

首先,我们实例化一个我们想要测试的widget类。然后,我们使用qtbotaddWidget方法添加该widget实例:

qtbot.addWidget(widget)

然后,在单击按钮之前,我们测试label变量上的text

assert widget.label.text() == "label: before clicked"

正如您所看到的,我们可以从widget中访问label。这是因为我们在button_and_label.py中使用以下代码声明了label变量:

self.label = QLabel("label: before clicked")

如果您在button_and_label.py中使用以下代码声明标签:

label = QLabel("label: before clicked")

然后,您将无法从测试中的widget实例访问label。当然,您可以通过创建一个变量来保存标签的文本来规避这种情况。然而,将label作为widget实例属性是测试标签文本的最简单方法。您将在所有后续测试中使用这种策略。简而言之,如果您想测试小部件(如标签、按钮或组合框),请将该widget作为其父小部件实例的属性。然后,我们继续讨论如何单击按钮小部件:

qtbot.mouseClick(widget.button, QtCore.Qt.LeftButton)

在测试期间单击按钮,您可以使用qtbotmouseClick方法。qtbotmouseClick方法的第一个参数是一个按钮小部件,或者是接受点击事件的东西。第二个参数是检测鼠标点击事件性质的选项。在这种情况下,测试将只接受左键点击。

以下代码是测试并显示单击按钮后标签的文本:

assert widget.label.text() == "label: after clicked"

在构建 GUI 应用程序时,我们偶尔需要显示对象列表。在我们的加密货币钱包中,列表可以包含账户。因此,让我们为这种情况编写一个测试。但首先,我们必须创建一个脚本来显示对象列表。将脚本命名为button_and_list.py,并使用以下代码块进行脚本(有关完整代码,请参考以下 GitLab 链接中的代码文件:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_09/advanced_course_qt/button_and_list.py):

from PySide2.QtWidgets import QWidget, QApplication, QLabel, QPushButton, QVBoxLayout
from PySide2.QtCore import Qt
import sys

class ButtonAndList(QWidget):

...
...

if __name__ == "__main__":

    app = QApplication(sys.argv)
    button_and_list = ButtonAndList()
    button_and_list.show()
    sys.exit(app.exec_())

运行脚本以查看应用程序的外观。以下是单击按钮之前的按钮的屏幕截图:

以下显示了单击按钮的结果:

这里只有一个按钮,如果您单击它,将会出现一个新的标签,文本简单地显示为1。如果您再次单击按钮,将会在底部出现一个新的标签,文本显示为2,依此类推。

单击按钮后出现的新标签是垂直框布局的一部分。这意味着我们需要将垂直框布局作为小部件实例的属性,以便在测试中访问它。

让我们为这个 GUI 脚本编写一个测试,如下面的代码块所示,并将其命名为test_button_and_list.py

from button_and_list import ButtonAndList
from PySide2 import QtCore

def test_button_and_list(qtbot):
    widget = ButtonAndList()
    qtbot.addWidget(widget)

    qtbot.mouseClick(widget.button, QtCore.Qt.LeftButton)
    qtbot.mouseClick(widget.button, QtCore.Qt.LeftButton)
    qtbot.mouseClick(widget.button, QtCore.Qt.LeftButton)

    label_item = widget.v_layout.takeAt(2)
    assert label_item.widget().text() == "3"

    label_item = widget.v_layout.takeAt(1)
    assert label_item.widget().text() == "2"

    label_item = widget.v_layout.takeAt(0)
    assert label_item.widget().text() == "1"

正如我们在前面的代码块中所看到的,在qtbotmouseClick方法第三次执行之后,我们使用以下代码从垂直框布局中获取标签:

label_item = widget.v_layout.takeAt(2)

我们通过takeAt方法获取小部件的子小部件。在这种情况下我们使用的参数是2。这意味着我们想要获取第三个子小部件,也就是最后一个。然后,我们使用以下代码测试小部件的文本:

assert label_item.widget().text() == "3"

让我们创建一个更复杂的场景。到目前为止,我们测试的所有内容都在一个窗口内,但如果有一个输入对话框怎么办?我们如何测试对话框?

让我们创建一个具有对话框的 GUI 脚本,并将其命名为button_and_dialog.py:(有关完整代码,请参考以下 GitLab 链接的代码文件:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_09/advanced_course_qt/button_and_dialog.py):

from PySide2.QtWidgets import QWidget, QApplication, QLabel, QPushButton, QVBoxLayout, QInputDialog, QLineEdit
from PySide2.QtCore import Qt
import sys

class ButtonAndDialog(QWidget):

...
...

if __name__ == "__main__":

    app = QApplication(sys.argv)
    button_and_dialog = ButtonAndDialog()
    button_and_dialog.show()
    sys.exit(app.exec_())

运行代码以查看应用程序。有一个按钮和空白空间在其下方:

点击按钮,然后会出现一个对话框,之后您应该在输入对话框中输入任何文本,然后点击 OK:

您输入的文本将出现在按钮下方:

让我们看一下以下代码块中的另一个测试脚本,以了解如何处理涉及两个不同窗口的流程。在这个测试方法中,除了qtbot之外,我们还有另一个参数叫做monkeypatch。将测试文件命名为test_button_and_dialog.py

from button_and_dialog import ButtonAndDialog
from PySide2.QtWidgets import QInputDialog
from PySide2 import QtCore

def test_button_and_dialog(qtbot, monkeypatch):
    widget = ButtonAndDialog()
    qtbot.addWidget(widget)

    monkeypatch.setattr(QInputDialog, 'getText', lambda *args: ("New Text", True))
    qtbot.mouseClick(widget.button, QtCore.Qt.LeftButton)

    assert widget.label.text() == "New Text"

monkeypatch用于覆盖对话框输入。这意味着在测试中,QInputDialoggetText方法将返回一个("New Text", True)元组。记得QInputDialog的 API 吗?这返回一个元组。这个元组包含两个参数——我们在对话框中输入的文本,以及我们是否点击了 OK 或取消按钮。

QInputDialoggetText方法接受四个参数:此对话框基于的窗口实例、标题、输入字段之前的标签和输入字段的类型。当您在输入字段中输入文本,例如To the moon!,然后点击OK按钮时,它会返回一个元组,其中包含字符串To the moon!和您是否点击了OK按钮的boolean值:

new_text, ok = QInputDialog.getText(self, "Write A Text", "New Text:", QlineEdit.Normal)

然而,monkeypatch会修补这个方法,因此在测试中,不会启动对话框。我们绕过它们。就好像启动对话框的行被以下代码替换了一样:

new_text, ok = ("New Text", True)

对于所有这些测试,我们总是使用一个按钮类型的小部件来启动某些操作(更改标签上的文本)。让我们使用另一种类型的小部件来更改标签,如下面的代码块所示,并将脚本命名为combobox_and_label.py

from PySide2.QtWidgets import QWidget, QApplication, QLabel, QComboBox, QVBoxLayout
from PySide2.QtCore import Qt
import sys

class ComboBoxAndLabel(QWidget):

    def __init__(self):
        super(ComboBoxAndLabel, self).__init__()

        self.combobox = QComboBox()
        self.combobox.addItems(["Orange", "Apple", "Grape"])
        self.combobox.currentTextChanged.connect(self.comboboxSelected)

        self.label = QLabel("label: before selecting combobox")

        layout = QVBoxLayout()
        layout.addWidget(self.combobox)
        layout.addWidget(self.label)

        self.setLayout(layout)

    def comboboxSelected(self, value):
        self.label.setText(value)

if __name__ == "__main__":

    app = QApplication(sys.argv)
    combobox_and_label = ComboBoxAndLabel()
    combobox_and_label.show()
    sys.exit(app.exec_())

这个 GUI 脚本使用组合框来更改标签上的文本。它使用所选标签的文本设置标签上的文本。运行脚本以查看它的外观:

现在,让我们创建一个测试脚本来测试这个组合框小部件,并将其命名为test_combobox_and_label.py

from combobox_and_label import ComboBoxAndLabel
from PySide2 import QtCore

def test_combobox_and_label(qtbot):
    widget = ComboBoxAndLabel()
    qtbot.addWidget(widget)

    assert widget.label.text() == "label: before selecting combobox"

    qtbot.keyClicks(widget.combobox, "Grape")

    assert widget.label.text() == "Grape"

我们可以从这里得到的关键点是使用qtbot更改combobox的选定选项的方式:

qtbot.keyClicks(widget.combobox, "Grape")

这个方法的名称不直观;它接受两个参数。第一个是小部件,或者在这种情况下是组合框。第二个是组合框中的选项文本。这个keyClicks方法不仅用于选择组合框中的选项。它也可以用于在行编辑中输入文本。只需将行编辑小部件放在第一个参数中。

这些测试知识足以测试我们的加密货币钱包。在我们开始构建加密货币钱包之前,让我们了解一下PySide2的一些其他功能,包括网格布局、选项卡和大小策略。

Qt for Python 或 PySide2 的高级功能

我们想要在这里学习的第一件事是拉伸。我们知道如何将小部件添加到框布局(垂直或水平)。但是,我们可以在一定程度上配置如何分配这些添加到框布局中的小部件。我们应该拉伸小部件,将小部件放在水平布局的顶部,并让空间吞噬其余部分吗?

让我们创建一个脚本来解释在框布局中小部件分布的配置,并将脚本命名为add_stretch.py(请参考以下 GitLab 链接的完整代码文件:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_09/advanced_course_qt/add_stretch.py):

from PySide2.QtWidgets import QFrame, QLabel, QWidget, QApplication, QPushButton, QHBoxLayout, QVBoxLayout, QSizePolicy, QSizePolicy
from PySide2.QtCore import Qt
import sys

class AddStretch(QWidget):

...
...

if __name__ == "__main__":

    app = QApplication(sys.argv)
    widget = AddStretch()
    widget.resize(500, 500)
    widget.show()
    sys.exit(app.exec_())

运行脚本查看效果:

如果您在垂直容器的末尾添加拉伸,它会将小部件推到垂直容器的开头,并让其余部分成为空白空间。如果您在开头添加拉伸,它会将小部件推到垂直容器的末尾,并让其余部分成为空白空间。如果您不添加任何拉伸,小部件将在布局中均匀分布。

就应用的功能而言,这并没有什么区别。但是,如果您选择正确的选项,它可以使 UI 更具吸引力。

我们总是使用框布局(垂直框布局或水平框布局)。在大多数情况下,框布局就足够了。然而,偶尔您可能想要使用更复杂的布局。Qt 有一个比框布局更强大的网格布局。

让我们创建一个脚本来探索网格布局的强大功能,并将脚本命名为create_grid_window.py(请参考以下 GitLab 链接的完整代码文件:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_09/advanced_course_qt/create_grid_window.py):

from PySide2.QtWidgets import QWidget, QApplication, QLabel, QGridLayout
from PySide2.QtCore import Qt
import sys

class GridWindow(QWidget):

...
...

if __name__ == "__main__":

    app = QApplication(sys.argv)
    gridWindow = GridWindow()
    gridWindow.show()
    sys.exit(app.exec_())

运行脚本查看网格布局如何管理其子窗口:

网格就像一个表格或电子表格。与水平布局的行或垂直布局的列不同,您将小部件添加到由行和列组成的表格中。

如果您想要将小部件添加到第一行和第一列,请使用以下语句:

layout.addWidget(label, 0, 0)

第一个参数表示行。第二个参数表示列。因此,如果您想要将小部件添加到第二行和第一列,使用以下语句:

layout.addWidget(label, 1, 0)

网格布局的addWidget方法接受可选的第三和第四个参数。第三个参数指示您希望此小部件延伸到多少行。第四个参数指示您希望此小部件延伸到多少列:

layout.addWidget(label, 1, 1, 2, 2)

如果您拉伸窗口,您将看到类似以下截图的内容:

看一下标签 G。这可以延伸到两行和两列。

现在,让我们谈谈如果增加包含小部件的父窗口的大小会发生什么。小部件是否应随之调整大小?小部件是否应保持不变,并允许边距变宽?您可以使用大小策略来决定调整大小的配置。让我们创建一个名为button_with_sizepolicy.py的脚本来演示大小配置策略(请参考以下 GitLab 链接的完整代码文件:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_09/advanced_course_qt/button_with_sizepolicy.py):

from PySide2.QtWidgets import QWidget, QApplication, QPushButton, QVBoxLayout, QSizePolicy
from PySide2.QtCore import Qt
import sys

class ButtonWithSizePolicy(QWidget):

...
...

if __name__ == "__main__":

    app = QApplication(sys.argv)
    button_with_size_policy_widget = ButtonWithSizePolicy()
    button_with_size_policy_widget.resize(500, 200)
    button_with_size_policy_widget.show()
    sys.exit(app.exec_())

运行脚本以查看每个按钮如何根据不同的大小策略显示不同:

然后,尝试调整窗口大小以理解大小策略配置:

QSizePolicy.Maximum表示小部件不能比大小提示更大,或者在这种情况下按钮的内容。如果您希望按钮保持其原始大小,请使用此大小策略。QSizePolicy.Preferred表示它更喜欢大小提示,但它可以更大或更小。QSizePolicy.Expanding表示小部件应尽可能扩展。QSizePolicy.Minimum表示小部件可以扩展,但不能小于大小提示。QSizePolicy.MinimumExpanding表示小部件不能小于大小提示,但尽可能扩展。

在创建 GUI 应用程序时,大多数情况下您不会将所有功能/小部件放在一个窗口中。否则,窗口将比显示器的屏幕分辨率大。

您可以启动一个对话框,其中包含一个按钮来容纳更多功能/小部件。这当然有效。但是您真正想要的是类似控制器的东西。在 Qt 中,您有StackView。StackView 可以包含许多窗口,但一次只显示一个窗口。

我们不会直接使用 StackView。相反,我们使用选项卡视图。选项卡视图在幕后使用 StackView。让我们创建一个脚本来使用选项卡视图,并将其命名为tabbed_window.py

from PySide2.QtWidgets import QTabWidget, QApplication, QWidget
import sys
from button_and_label import ButtonAndLabel

class TabbedWindow(QTabWidget):

    def __init__(self, parent=None):
        super(TabbedWindow, self).__init__(parent)
        widget1 = QWidget()
        self.widget2 = ButtonAndLabel()
        widget3 = QWidget()
        self.addTab(widget1, "Tab 1")
        self.addTab(self.widget2, "Tab 2")
        self.addTab(widget3, "Tab 3")

if __name__ == "__main__":

    app = QApplication(sys.argv)
    tabbedWindow = TabbedWindow()
    tabbedWindow.show()
    sys.exit(app.exec_())

这个选项卡窗口有三个选项卡。每个选项卡都包含一个小部件。第二个选项卡甚至包含一个我们在单独的脚本button_and_label.py中创建的小部件。这个小部件在第二个选项卡中有一个按钮和一个标签。要向选项卡窗口添加选项卡,您可以使用addTab方法。第一个参数是小部件,第二个参数是选项卡的标题。

运行脚本查看选项卡视图的工作原理。在下面的截图中,我们看到选项卡 1:

在下面的截图中,我们看到选项卡 2 和来自button_and_label.py的小部件:

构建加密货币钱包

现在您已经了解了 Qt for Python 库的其他功能,让我们开始构建一个桌面加密货币钱包。由于这是一个复杂的应用程序,我们不应该将所有东西都放在一个文件中;相反,我们将其分成许多文件。我们甚至将许多文件分成不同的目录。我们还希望将此应用程序保持足够基本,以用于教程的目的。因此,我们不会在此应用程序中放置很多功能。这个加密货币钱包可以创建新账户,向另一个账户发送以太币,并监视 ERC20 代币,以便稍后将一些代币发送到另一个账户。但它不会具有您从正式加密货币钱包中期望的完整功能。

首先,让我们使用以下命令创建项目目录及其内部目录:

$ mkdir wallet
$ mkdir wallet/icons
$ mkdir wallet/images
$ mkdir wallet/tests
$ mkdir wallet/tools
$ mkdir wallet/wallet_threads
$ mkdir wallet/wallet_widgets

主应用程序、主库及其配置文件放在主目录wallet中。一些用于提升应用程序 UI 的图标放在icons目录中。头像图像放在images目录中。测试文件放在tests目录中。与区块链和 UI 无关的库文件放在tools目录中。线程类放在wallet_threads目录中。最后,主小部件的子小部件放在wallet_widgets目录中。

区块链类

让我们在wallet中创建一个名为blockchain.py的区块链接口代码文件。这个文件负责连接到区块链。它的职责包括检查账户余额,获取本地账户,发送交易以及获取代币信息。通过将所有区块链功能放在一个类或文件中,我们更容易调试问题,测试实现,并开发功能。转到gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/tree/master/chapter_09/wallet并参考本节的blockchain.py代码文件。

这个区块链类有 10 个方法来与区块链交互。此外,它还具有 ERC20 代币的通用json接口。

让我们逐行讨论这个区块链类文件:

from web3 import Web3, IPCProvider
from web3.exceptions import ValidationError
from populus.utils.wait import wait_for_transaction_receipt
from collections import namedtuple
from os.path import exists
import json

SendTransaction = namedtuple("SendTransaction", "sender password destination amount fee")
TokenInformation = namedtuple("TokenInformation", "name symbol totalSupply address")

在导入所需的库之后,我们创建了两个命名元组。那么,为什么我们要创建这些命名元组呢?基本上,我们这样做是为了避免错误。在加密货币钱包中出现错误是很昂贵的。

想象一下你有以下函数:

def send_transaction(sender, password, destination, amount, fee):
    // the code to create transaction

你可以这样执行这个函数:

send_transaction("427af7b53b8f56adf6f13932bb17c42ed2a53d04", “password”, "6ad2ffd2e08bd73f5c50db60fdc82a58b0590b99", 3, 2)

如果你交换了发送方和接收方,在最坏的情况下,你会得到一个未处理的异常,程序会停止,因为私钥与发送方不匹配。但是,如果你交换了金额和费用呢?在这种情况下,你会向某人发送少量的代币,但支付了非常高的费用。有许多方法可以避免这个错误。例如,你可以使用关键字参数,就像下面的代码块中所示,或者你可以使用命名元组:

send_transaction(SendTransaction(sender="0xaksdfkas", password="password", destination="0xkkkkkk", amount=3, fee=2))

现在,让我们继续讨论 ERC20 代币智能合约的json接口。当我们想发送以太币时,这是不需要的:只有当我们想发送代币时才需要。

true = True
false = False
erc20_token_interface = [
            {
                "anonymous": false,
                "inputs": [
                    {
                        "indexed": true,
                        "name": "_from",
                        "type": "address"
                    },
                    {
                        "indexed": true,
                        "name": "_to",
                        "type": "address"
                    },
                    {
                        "indexed": false,
                        "name": "_value",
                        "type": "uint256"
                    }
                ],
                "name": "Transfer",
                "type": "event"
            },
...

正如你所知,为了与智能合约交互,你需要智能合约的json接口(abi)。你可能想知道我们如何获得这个json接口。这是通过 ERC20 代币智能合约的编译输出实现的。不管名称、小数点数量和符号是什么,只要接口来自满足 ERC20 标准的智能合约,我们就应该得到正确的接口。我决定将接口放在与Blockchain类相同的文件中,以简化事情。但是,你可以将接口放在json文件中,然后在Blockchain类文件中加载json文件。然后,我们继续定义Blockchain类:

class Blockchain:

    tokens_file = 'tokens.json'

    def __init__(self):
        self.w3 = Web3(IPCProvider('/tmp/geth.ipc'))

在这里,我们开始Blockchain类。在它的初始化方法中,我们构建一个w3变量来连接区块链。我们使用 IPC 提供程序硬编码连接到区块链。如果你使用HTTPProvider,或者使用不同的IPC文件路径,你可以更改这个配置。tokens_file变量是保存我们监视的所有代币的文件。

让我们来看看以下代码行:

    def get_accounts(self):
        return map(lambda account: (account, self.w3.fromWei(self.w3.eth.getBalance(account), 'ether')), self.w3.eth.accounts)

我们使用w3.eth.accounts获取所有本地账户,然后使用w3.eth.getBalance从每个账户获取余额。本地账户是你在本地节点创建的账户。通常,文件保存在keystore目录中。

以下代码用于在本地区块链节点中创建一个新账户:

    def create_new_account(self, password):
        return self.w3.personal.newAccount(password)

账户文件将使用我们提供的密码进行加密。要查看私钥,我们需要用密码解密账户文件。但是,这除了备份目的外是不必要的。

使用以下代码从一个地址获取余额:

    def get_balance(self, address):
        return self.w3.fromWei(self.w3.eth.getBalance(address), 'ether')

余额以 wei 为单位。然后,我们将 wei 中的余额转换为以太币中的余额。

下面的代码块旨在获取 ERC20 代币的余额,而不是以太币的余额:

    def get_token_balance(self, account_address, token_information):
        try:
            token_contract = self.w3.eth.contract(address=token_information.address, abi=erc20_token_interface)
            balance = token_contract.functions.balanceOf(account_address).call()
        except ValidationError:
            return None
        return balance

首先,我们获取接受两个参数的合同对象——智能合同的地址和 json 接口。如果您还记得在第八章中学到的内容,在以太坊中创建代币,ERC20 代币需要有一个balanceOf方法。此方法的目的是从账户地址获取代币的余额。

以下代码块用于创建发送以太币的交易:

    def create_send_transaction(self, tx):
        nonce = self.w3.eth.getTransactionCount(tx.sender)
        transaction = {
          'from': tx.sender,
          'to': Web3.toChecksumAddress(tx.destination),
          'value': self.w3.toWei(str(tx.amount), 'ether'),
          'gas': 21000,
          'gasPrice': self.w3.toWei(str(tx.fee), 'gwei'),
          'nonce': nonce
        }

        tx_hash = self.w3.personal.sendTransaction(transaction, tx.password)
        wait_for_transaction_receipt(self.w3, tx_hash)

首先,您获取nonce,然后构建一个交易对象。要使用密码而不是私钥发送此交易,您需要使用w3.personal对象的sendTransaction方法。然后,您等待交易得到确认。

在学习涉及发送以太币的交易之后,让我们继续下一个代码块,这是用于创建发送 ERC20 代币的交易的方法:

    def create_send_token_transaction(self, tx, token_information):
        nonce = self.w3.eth.getTransactionCount(tx.sender)
        token_contract = self.w3.eth.contract(address=token_information.address, abi=erc20_token_interface)
        transaction = token_contract.functions.transfer(tx.destination, int(tx.amount)).buildTransaction({
                  'from': tx.sender,
                  'gas': 70000,
                  'gasPrice': self.w3.toWei(str(tx.fee), 'gwei'),
                  'nonce': nonce
              })

        tx_hash = self.w3.personal.sendTransaction(transaction, tx.password)
        wait_for_transaction_receipt(self.w3, tx_hash)

首先,您获取nonce,然后构建一个合同对象。然后,您调用此智能合同对象的transfer方法。请记住,ERC20 代币需要有一个transfer方法来转移代币,该方法接受两个参数——目的地和代币数量。然后,通过从该方法构建交易来执行此方法,然后将其传递给w3.personal对象的sendTransaction方法。最后,我们等待此交易得到确认。

以下代码块用于从代币智能合同中获取信息:

    def get_information_of_token(self, address):
        try:
            token_contract = self.w3.eth.contract(address=address, abi=erc20_token_interface)
            name = token_contract.functions.name().call()
            symbol = token_contract.functions.symbol().call()
            total_supply = token_contract.functions.totalSupply().call()
        except ValidationError:
            return None
        token_information = TokenInformation(name=name.decode('utf-8'),
                                             symbol=symbol.decode('utf-8'),
                                             totalSupply=total_supply,
                                             address=address)
        return token_information

首先,我们创建一个合同对象。然后,为了获取名称、符号和总供应量,我们从智能合同中访问namesymboltotalSupply方法。因为名称和符号是字节对象,我们需要将其解码为字符串。我们将这些信息封装在一个名为TokenInformation的元组中。

以下代码是一种方便的方式,用于在命名的tuple中封装代币信息字典:

    def get_token_named_tuple(self, token_dict, address):
        return TokenInformation(name=token_dict['name'],
                                totalSupply=token_dict['total_supply'],
                                symbol=token_dict['symbol'],
                                address=address)

以下代码用于从配置文件中获取我们正在观察的所有代币:

    def get_tokens(self):
        tokens = {}
        if exists(self.tokens_file):
            with open(self.tokens_file) as json_data:
                tokens = json.load(json_data)
        return tokens

有许多代币的智能合同,但我们只想使用其中的一些。因此,我们将与这些代币智能合同相关的信息保存到一个json文件中。然后,我们转到文件的最后一行,即构造一个Blockchain类实例:

blockchain = Blockchain()

我们这样做是为了让导入此模块的任何文件都可以立即获得区块链对象,并且两个不同的文件将获得相同的对象。这类似于单例模式。

线程类

现在,让我们编写线程对象,以便访问区块链。在区块链中创建交易时,通常希望使用线程或非阻塞函数。因此,每当我们想要广播一个交易时,我们使用这些线程类。这些线程类将使用我们之前描述的区块链对象。

使用以下代码块在wallet_threads目录中创建一个balance_thread.py文件:

from PySide2.QtCore import QThread, Signal
from time import sleep
from blockchain import blockchain

class BalanceThread(QThread):

    get_balance_transaction = Signal(map)

    def __init__(self, parent=None):
        super(BalanceThread, self).__init__(parent)
        self.quit = False

    def kill(self):
        self.quit = True

    def run(self):
        while True:
            sleep(2)
            if self.quit:
                break
            accounts = blockchain.get_accounts()
            self.get_balance_transaction.emit(accounts)

这个线程类不会在区块链中创建任何交易;它的目的是读取每个账户中以太币的余额。那么,为什么我们需要一个线程来读取余额呢?读取余额应该是快速的吗?想象一下,当您启动您的加密货币钱包时,您看到您的余额是 10 个以太币。然后,有人给您发送了一些以太币。您希望您的余额尽快反映出来,对吧?这就是这个线程的目的;它将每 2 秒检查一次每个账户的余额。kill方法旨在关闭应用程序并停止线程的工作。这不是强制性的,但如果您不这样做,当您关闭应用程序时,您会收到一个令人讨厌的警告,指出应用程序在线程仍在运行时被销毁。

现在,让我们在wallet_threads目录中创建另一个线程类,并将其命名为send_thread.py

from PySide2.QtCore import QThread, Signal
from blockchain import blockchain

class SendThread(QThread):

    send_transaction = Signal()

    def __init__(self, parent=None):
        super(SendThread, self).__init__(parent)

    def prepareTransaction(self, tx):
        self.tx = tx

    def run(self):
        blockchain.create_send_transaction(self.tx)
        self.send_transaction.emit()

这个线程类的目的是调用区块链对象的create_send_transaction方法。在运行线程之前,我们需要使用SendTransaction命名的tuple参数调用这个线程类的prepareTransaction方法。

现在,让我们在wallet_threads目录中创建另一个线程类,并将其命名为send_token_thread.py

from PySide2.QtCore import QThread, Signal
from blockchain import blockchain

class SendTokenThread(QThread):

    send_token_transaction = Signal()

    def __init__(self, parent=None):
        super(SendTokenThread, self).__init__(parent)

    def prepareTransaction(self, tx, token_information):
        self.tx = tx
        self.token_information = token_information

    def run(self):
        blockchain.create_send_token_transaction(self.tx, self.token_information)
        self.send_token_transaction.emit()

这与SendThread类类似。这个线程的目的是调用create_send_token_transaction方法,这次接受两个参数,一个名为SendTransaction的元组,另一个名为TokenInformation的元组。

Identicon 和图标

现在,让我们了解一下 identicon 库是什么。identicon 库的目的是根据特定字符串的哈希生成一个自定义头像图像(如分形)。如果您登录 StackOverflow 并且没有设置个人资料图片,您的头像将由 identicon 库生成。

截图将会出现如下:

或者会出现这样的情况:

这是可选的。我们的加密货币钱包可以在没有这些头像图像的情况下正常运行。这只是为了提升用户界面的吸引力。

gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_09/wallet/tools/identicon.py下载文件到tools目录。这是 Shin Adachi 的出色工作。我已经修改过了,使其能够在 Python 3 中运行。您不必理解这个文件;把它当作一个第三方库。

然后,在tools目录中创建一个文件,使用以下代码块来使用这个库,并将其命名为util.py

from os.path import isdir, exists
from os import mkdir
from tools.identicon import render_identicon

def render_avatar(code):
    code = int(code, 16)
    img_filename = 'images/%08x.png' % code
    if exists(img_filename):
        return img_filename
    img = render_identicon(code, 24)
    if not isdir('images'):
        mkdir('images')
    img.save(img_filename, 'PNG')
    return img_filename

基本上,这种方法可以使用账户地址来渲染头像图像。这有助于使应用程序更具吸引力。因此,当您创建一个账户时,您会得到一个根据您的地址而独一无二的头像。

然后,在icons文件夹中下载一些图标。您需要其中的两个:ajax-loader.gifcopy.svg。您可以从免费图标网站下载copy.svg。任何显示复制操作的图标都可以。然后,您可以从ajaxload.info/下载ajax-loader.gif

构建小部件

让我们使用以下代码块创建我们的主应用程序。这是我们的加密货币钱包的主入口。将其命名为wallet.py

from PySide2.QtWidgets import QTabWidget, QApplication
import sys

from wallet_widgets.account_widget import AccountWidget
from wallet_widgets.send_widget import SendWidget
from wallet_widgets.token_widget import TokenWidget

class WalletWidget(QTabWidget):

    def __init__(self, parent=None):
        super(WalletWidget, self).__init__(parent)
        self.account_widget = AccountWidget()
        self.send_widget = SendWidget()
        self.token_widget = TokenWidget()
        self.addTab(self.account_widget, "Account")
        self.addTab(self.send_widget, "Send")
        self.addTab(self.token_widget, "Token")

    def killThreads(self):
        self.account_widget.kill()

if __name__ == "__main__":

    app = QApplication(sys.argv)
    wallet_widget = WalletWidget()
    wallet_widget.show()
    return_app = app.exec_()
    wallet_widget.killThreads()
    sys.exit(return_app)

WalletWidget是一个带有选项卡的窗口。有三个选项卡:

  • 第一个选项卡旨在容纳一个账户小部件。该小部件负责管理账户(列出账户和创建新账户)。

  • 第二个选项卡旨在容纳一个小部件,用户可以使用它来创建发送以太币或 ERC20 代币的交易。任何涉及发送以太币或代币的操作都是在这个小部件中完成的。

  • 第三个选项卡用于容纳令牌小部件。该小部件负责监视 ERC20 代币。监视 ERC20 代币意味着从 ERC20 自定义代币智能合约获取信息,并使这些代币能够在发送交易小部件中被花费。

这三个小部件将在其他文件中定义,稍后将讨论。

killThreads方法是可选的。如果您不使用这个方法,当您关闭应用程序时,您将会收到警报,因为应用程序创建的线程尚未完成其任务。

账户小部件

现在,让我们在选项卡窗口的第一个选项卡中创建第一个小部件。将文件放入wallet_widgets目录,并将其命名为account_widget.py。然后,您可以从以下链接获取完整的代码文件:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/tree/master/chapter_09/wallet/wallet_widgets

如前所述,此小部件将显示在钱包选项卡窗口的第一个选项卡中。在此选项卡中,您将获得列出帐户和创建新帐户功能。

使用以下代码从PySide2导入许多类型的小部件和类:

from PySide2.QtWidgets import (QWidget,
                               QGridLayout,
                               QVBoxLayout,
                               QHBoxLayout,
                               QPushButton,
                               QLabel,
                               QInputDialog,
                               QLineEdit,
                               QToolTip,
                               QApplication,
                               QSizePolicy)
from PySide2.QtCore import Slot, SIGNAL, QSize
from PySide2.QtGui import QPixmap, QIcon, QCursor, QClipboard
from time import sleep
from blockchain import blockchain
from tools.util import render_avatar
from wallet_threads.balance_thread import BalanceThread

我们还导入了blockchain对象和render_avatar方法等其他内容。此外,我们将使用balance_thread实例,这是一个用于更新帐户余额的线程。

使用以下代码块创建一个按钮,允许我们在小部件内创建一个帐户:

class AccountWidget(QWidget):

    balance_widgets = {}

    def __init__(self, parent=None):
        super(AccountWidget, self).__init__(parent)

        self.create_account_button = QPushButton("Create Account")
        self.create_account_button.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
        self.connect(self.create_account_button, SIGNAL('clicked()'), self.createNewAccount)

        self.accounts_layout = QVBoxLayout()

        accounts = blockchain.get_accounts()

        for account, balance in accounts:
            self._addAccountToWindow(account, balance)

        layout = QGridLayout()

        layout.addWidget(self.create_account_button, 0, 0)
        layout.addLayout(self.accounts_layout, 1, 0)

        self.setLayout(layout)

        self.balance_thread = BalanceThread()
        self.balance_thread.get_balance_transaction.connect(self._updateBalances)
        self.balance_thread.start()

所有这些帐户将放在accounts_layout垂直框布局中。我们从区块链对象中获取所有本地帐户,然后使用addAccountToWindow方法将此帐户放入 accounts 布局中。之后,我们将按钮和accounts_layout放入主布局中。最后,我们将BalanceThread线程实例的槽连接到_updateBalances方法并运行该线程。

使用以下代码启动输入对话框并请求密码:

    @Slot()
    def createNewAccount(self):
        password, ok = QInputDialog.getText(self, "Create A New Account",
                 "Password:", QLineEdit.Normal)
        if ok and password != '':
            new_account = blockchain.create_new_account(password)
            self._addAccountToWindow(new_account, 0, resize_parent=True)

在这里,我们调用blockchain对象的create_new_account方法。新帐户的地址将被发送到_addAccountToWindow方法,该方法将在垂直框布局中包含新帐户信息。

接下来,我们使用以下代码块将帐户的地址复制到剪贴板:

    def copyAddress(self, address):
        QToolTip.showText(QCursor.pos(), "Address %s has been copied to clipboard!" % address)
        clipboard = QApplication.clipboard()
        clipboard.setText(address)

在这里,我们获取剪贴板对象并将内容复制到其中。因此,在每个帐户信息中,都将有一个按钮连接到此方法。然而,我们将首先在工具提示中显示此复制操作的信息。Qcursor.pos()是我们鼠标的位置。QtoolTipshowText方法用于显示工具提示。

有四个主要的小部件——帐户地址标签、复制帐户地址的按钮、此帐户余额的标签和头像图片。为了显示头像图片,我们可以使用一个标签。但是我们使用setPixmap方法而不是setText方法,如下面的代码块中所提供的:

    def _addAccountToWindow(self, account, balance, resize_parent=False):
        wrapper_layout = QVBoxLayout()
        account_layout = QHBoxLayout()
        rows_layout = QVBoxLayout()
        address_layout = QHBoxLayout()
        account_label = QLabel(account)
...
...
        avatar.setPixmap(pixmap)
        account_layout.addWidget(avatar)
        account_layout.addLayout(rows_layout)
        wrapper_layout.addLayout(account_layout)
        wrapper_layout.addSpacing(20)
        self.accounts_layout.addLayout(wrapper_layout)

        if resize_parent:
            sizeHint = self.sizeHint()
            self.parentWidget().parentWidget().resize(QSize(sizeHint.width(), sizeHint.height() + 40))

setPixmap接受Qpixmap对象。如果resize_parent为 true,则我们将增加窗口的高度。我们使用名为parentWidget的方法访问主窗口,即选项卡窗口。这必须链接并调用两次,如self.parentWidget().parentWidget()。第一个父窗口是堆栈视图。选项卡小部件是使用堆栈视图构建的。

使用以下代码调用BalanceThread实例的kill()方法:

    def kill(self):
        self.balance_thread.kill()
        sleep(2)

这将告诉线程停止其任务。

下一个方法是由线程实例用于更新余额的:

    @Slot()
    def _updateBalances(self, accounts):
        for account, balance in accounts:
            self.balance_widgets[account].setText('Balance: %.5f ethers' % balance)

balance_widgets[account]保存特定帐户的余额标签。

发送交易小部件

第二个小部件是SendWidget。在wallet_widgets目录中创建一个名为send_widget.py的文件。此小部件负责从 ERC20 令牌发送以太币或硬币。有关此部分的完整代码,请转到以下 GitLab 链接:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/tree/master/chapter_09/wallet/wallet_widgets

此小部件是选项卡窗口中最复杂的小部件。在此小部件中,我们需要选择发送方的帐户,然后根据该帐户显示与此帐户相关的以太币或 ERC20 令牌的余额。余额显示为以太币或 ERC20 令牌取决于在此小部件的另一部分中选择了以太坊还是 ERC20 令牌。我们还需要添加一个行编辑,以便人们可以填写目标地址。此外,我们需要一种选择费用的方式,因为有时人们不介意支付更高的费用,以便他们的交易能够更快地得到处理。然后,有一个按钮启动输入对话框,请求密码,以便我们可以创建交易。

要从PySide2库导入小部件和类,请使用以下代码块:

from PySide2.QtWidgets import (QWidget,
                               QGridLayout,
                               QVBoxLayout,
                               QHBoxLayout,
                               QPushButton,
                               QLabel,
                               QInputDialog,
                               QLineEdit,
                               QToolTip,
                               QComboBox,
                               QApplication,
                               QSlider,
                               QSizePolicy)
from PySide2.QtCore import Slot, SIGNAL, QSize, Qt
from PySide2.QtGui import QPixmap, QMovie, QPalette, QColor
from os.path import isdir, exists
from os import mkdir
from tools.util import render_avatar
from blockchain import blockchain, SendTransaction
from wallet_threads.send_thread import SendThread
from wallet_threads.send_token_thread import SendTokenThread

我们还导入了其他东西,比如渲染头像的工具,与区块链交互的方法,以及创建交易和检索有关代币信息的线程类。

使用以下代码初始化SendWidget类:

class SendWidget(QWidget):

    tokens_file = 'tokens.json'

    def __init__(self, parent=None):
        super(SendWidget, self).__init__(parent)

        self.token_name = 'Ethereum'

        self.setupSenderSection()
        self.setupDestinationSection()
        self.setupTokenSection()
        self.setupProgressSection()
        self.setupSendButtonSection()
        self.setupFeeSection()

        self.send_thread = SendThread()
        self.send_thread.send_transaction.connect(self.sendTransactionFinished)
        self.send_token_thread = SendTokenThread()
        self.send_token_thread.send_token_transaction.connect(self.sendTransactionFinished)

        layout = QGridLayout()

        layout.addLayout(self.sender_layout, 0, 0)
        layout.addLayout(self.destination_layout, 0, 1)
        layout.addLayout(self.progress_layout, 1, 0, 1, 2, Qt.AlignCenter)
        layout.addLayout(self.token_layout, 2, 0)
        layout.addLayout(self.send_layout, 2, 1)
        layout.addLayout(self.slider_layout, 3, 0)

        self.setLayout(layout)

tokens_file保存了tokens.json文件。这个配置文件包含了我们监视的所有 ERC20 代币。token_name最初设置为Ethereum,因为默认情况下,我们的加密货币钱包应该处理以太坊交易,而不是 ERC20 代币。在这个小部件中,我们可以发送以太币或自定义代币。然后,我们调用六种方法来建立六种内部布局。这个小部件由六种布局组成。发送方布局用于选择发送方的账户。目的地布局是一个设计用来保存交易目的地账户的字段。进度布局默认情况下是隐藏的,用于显示在发送交易后交易仍在确认中。代币布局用于选择您是要发送 ERC20 代币还是以太币。此外,发送布局用于保存发送按钮,滑块布局用于保存选择交易费用的滑块。我们还创建了两个线程实例——第一个用于发送以太币,而第二个用于发送 ERC20 代币。对于主布局,我们使用网格布局。这种布局更容易布置我们的小部件。

以下代码块是用于设置创建交易小部件的发送方布局部分的方法:

    def setupSenderSection(self):
        accounts = blockchain.get_accounts()

        sender_label = QLabel("Sender")
        sender_label.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)

        self.balance_label = QLabel("Balance: ")
        self.balance_label.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)

        self.avatar = QLabel()

        self.sender_combo_box = QComboBox()
        self.sender_items = []
        for account, balance in accounts:
            self.sender_items.append(account)
        self.sender_combo_box.addItems(self.sender_items)
        self.sender_combo_box.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
        self.sender_combo_box.currentTextChanged.connect(self.filterSender)

        first_account = self.sender_items[0]
        self.filterSender(first_account)
        self.setAvatar(first_account, self.avatar)

        self.sender_layout = QVBoxLayout()
        sender_wrapper_layout = QHBoxLayout()
        sender_right_layout = QVBoxLayout()
        sender_right_layout.addWidget(sender_label)
        sender_right_layout.addWidget(self.sender_combo_box)
        sender_right_layout.addWidget(self.balance_label)
        sender_wrapper_layout.addWidget(self.avatar)
        sender_wrapper_layout.addLayout(sender_right_layout)
        sender_wrapper_layout.addStretch()

        self.sender_layout.addLayout(sender_wrapper_layout)
        self.sender_layout.addStretch()

在这里,您有一个下拉框来选择本地账户,一个头像图像和一个余额标签。如果您更改下拉框的值,这将自动更改余额标签和头像图像上的文本。

以下代码块是用于设置目的地布局部分的方法:

    def setupDestinationSection(self):
        self.destination_layout = QVBoxLayout()

        destination_label = QLabel("Destination")
        destination_label.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)

        self.destination_line_edit = QLineEdit()
        self.destination_line_edit.setFixedWidth(380);
        self.destination_line_edit.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)

        self.destination_layout.addWidget(destination_label)
        self.destination_layout.addWidget(self.destination_line_edit)
        self.destination_layout.addStretch()

这个方法主要是一个行编辑。您可以在这个行编辑中粘贴或输入目的地的地址。

以下代码块是用于设置代币布局部分的方法:

    def setupTokenSection(self):
        token_label = QLabel("Token")
        token_label.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)

        token_combo_box = QComboBox()

        tokens = blockchain.get_tokens()
        first_token = 'Ethereum'
        items = [first_token]
        self.token_address = {'Ethereum': '0xcccccccccccccccccccccccccccccccccccccccc'}
        self.token_informations = {}

        for address, token_from_json in tokens.items():
            token_information = blockchain.get_token_named_tuple(token_from_json, address)
            self.token_informations[token_information.name] = token_information
            self.token_address[token_information.name] = token_information.address
            items.append(token_information.name)

        self.amount_label = QLabel("Amount (in ethers)")

        token_combo_box.addItems(items)
        token_combo_box.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
        token_combo_box.currentTextChanged.connect(self.filterToken)

        self.token_avatar = QLabel()

        self.filterToken(first_token)
        token_address = self.token_address[first_token]
        self.setAvatar(token_address, self.token_avatar)

        self.token_layout = QVBoxLayout()
        token_wrapper_layout = QHBoxLayout()
        token_right_layout = QVBoxLayout()
        token_right_layout.addWidget(token_label)
        token_right_layout.addWidget(token_combo_box)
        token_wrapper_layout.addWidget(self.token_avatar)
        token_wrapper_layout.addLayout(token_right_layout)
        token_wrapper_layout.addStretch()
        self.token_layout.addLayout(token_wrapper_layout)

该部分有一个代币的头像,下拉框可以选择以太坊或其他 ERC20 代币,以及 ERC20 代币的总供应量。如果我们改变下拉框的值,它将改变头像和总供应量标签。代币的头像来自代币智能合约的地址。然而,以太坊没有地址,因为它本身就是平台。因此,对于以太坊,我们使用以下虚拟地址:0xcccccccccccccccccccccccccccccccccccccccc

以下代码块是用于设置进度布局部分的方法:

    def setupProgressSection(self):
        self.progress_layout = QHBoxLayout()
        progress_vertical_layout = QVBoxLayout()
        progress_wrapper_layout = QHBoxLayout()
        self.progress_label = QLabel()
        movie = QMovie('icons/ajax-loader.gif')
        self.progress_label.setMovie(movie)
        movie.start()
        self.progress_label.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
        self.progress_description_label = QLabel()
        self.progress_description_label.setText("Transaction is being confirmed. Please wait!")
        self.progress_description_label.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
        progress_wrapper_layout.addWidget(self.progress_label)
        progress_wrapper_layout.addWidget(self.progress_description_label)
        progress_vertical_layout.addLayout(progress_wrapper_layout, 1)
        self.progress_layout.addLayout(progress_vertical_layout)
        self.sendTransactionFinished()

基本上,这是一个标签,用于显示交易正在确认中。在这一部分,有一个标签用于显示加载活动指示器。首先,我们初始化一个接受gif文件的QMovie对象。然后,通过调用该标签的setMovie方法,将这个Qmovie设置到一个标签上。

以下代码块是用于设置发送布局部分的方法,用于创建交易的小部件:

    def setupSendButtonSection(self):
        self.send_layout = QVBoxLayout()
        self.amount_line_edit = QLineEdit()
        self.send_button = QPushButton("Send")
        self.send_button.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
        self.send_button.clicked.connect(self.sendButtonClicked)
        pal = self.send_button.palette()
        pal.setColor(QPalette.Button, QColor(Qt.green))
        self.send_button.setAutoFillBackground(True)
        self.send_button.setPalette(pal)
        self.send_button.update()
        self.send_layout.addWidget(self.amount_label)
        self.send_layout.addWidget(self.amount_line_edit)
        self.send_layout.addWidget(self.send_button)

这一部分用于保存一个发送按钮,与一个回调连接。这个发送按钮是定制的,通过使用背景颜色使其看起来更有吸引力。更改按钮的颜色的方法很简单:

使用以下代码从按钮获取调色板对象,然后将颜色设置为该调色板对象:

        pal = self.send_button.palette()
        pal.setColor(QPalette.Button, QColor(Qt.green))

在这里,我们使用预定义的颜色。

以下代码块用于创建滑块和指示我们在滑块中选择了什么值的标签:

    def setupFeeSection(self):
        self.slider_layout = QVBoxLayout()
        fee_label = QLabel("Fee")
        self.fee_slider = QSlider(Qt.Horizontal)
        self.fee_slider.setRange(1, 10)
        self.fee_slider.setValue(3)
        self.fee_slider.valueChanged.connect(self.feeSliderChanged)
        self.gwei_label = QLabel()
        self.feeSliderChanged(3)
        self.slider_layout.addWidget(fee_label)
        self.slider_layout.addWidget(self.fee_slider)
        self.slider_layout.addWidget(self.gwei_label)

滑块的目的是选择交易的费用。如果您选择更高的费用,交易将更快地处理。

以下代码块用于选择以太坊或 ERC20 代币:

    def filterToken(self, token_name):
        address = self.token_address[token_name]
        token_information = None
        if token_name != 'Ethereum':
            token_information = self.token_informations[token_name]
            self.amount_label.setText("Amount")
        else:
            self.amount_label.setText("Amount (in ethers)")
        self.updateBalanceLabel(token_name, self.sender_account, token_information)
        self.setAvatar(address, self.token_avatar)
        self.token_name = token_name

这是当我们更改代币组合框的值时将执行的回调。我们在这里更新帐户的以太币或代币余额。这样做后,我们更改代币的头像。我们还更新了代币的总供应量。

以下代码块用于选择发送方帐户:

    def filterSender(self, account_address):
        self.sender_account = account_address
        token_information = None
        if self.token_name != 'Ethereum':
            token_information = self.token_informations[self.token_name]
        self.updateBalanceLabel(self.token_name, account_address, token_information)
        self.setAvatar(account_address, self.avatar)

这是当我们更改发送者组合框的值时将执行的回调。在这里,我们更新帐户的以太币或代币余额,然后根据地址更改帐户的头像。

以下代码块是用于将帐户余额设置到标签的方法:

    def updateBalanceLabel(self, token_name, account_address, token_information=None):
        if token_name == 'Ethereum':
            self.balance_label.setText("Balance: %.5f ethers" % blockchain.get_balance(account_address))
        else:
            self.balance_label.setText("Balance: %d coins" % blockchain.get_token_balance(account_address, token_information))

updateBalanceLabel方法中,如果我们使用以太坊,我们使用blockchain对象的get_balance方法设置balance_label的文本。如果我们使用 ERC20 代币,我们使用blockchainget_token_balance方法。

以下代码块是用于设置头像的方法:

    def setAvatar(self, code, avatar):
        img_filename = render_avatar(code)
        pixmap = QPixmap(img_filename)
        avatar.setPixmap(pixmap)

此方法用于设置代币和帐户的头像。

以下代码块是当我们更改费用滑块的值时将执行的回调:

    def feeSliderChanged(self, value):
        self.gwei_label.setText("%d GWei" % value)
        self.fee = value

以下代码块是单击发送按钮时将执行的方法:

    def sendButtonClicked(self):
        password, ok = QInputDialog.getText(self, "Create A New Transaction",
                 "Password:", QLineEdit.Password)
        if ok and password != '':
            self.progress_label.setVisible(True)
            self.progress_description_label.setVisible(True)
            tx = SendTransaction(sender=self.sender_account,
                                 password=password,
                                 destination=self.destination_line_edit.text(),
                                 amount=self.amount_line_edit.text(),
                                 fee=self.fee)
            token_information = None
            if self.token_name != 'Ethereum':
                token_information = self.token_informations[self.token_name]
                self.send_token_thread.prepareTransaction(tx, token_information)
                self.send_token_thread.start()
            else:
                self.send_thread.prepareTransaction(tx)
                self.send_thread.start()

在这里,我们将被要求在输入对话框中提供密码。如果我们点击确定,那么我们将设置进度标签和加载活动指示器为可见。我们构造了一个名为SendTransaction的元组,然后将其发送到处理发送以太币或 ERC20 代币交易的线程类对象。最后,我们运行线程。

以下代码块用于在交易完成时隐藏进度标签(加载指示器):

    def sendTransactionFinished(self):
        self.progress_label.setVisible(False)
        self.progress_description_label.setVisible(False)

此方法将在线程实例完成工作后被调用(无论是发送以太币还是作为 ERC20 代币发送硬币)。

代币小部件

最后的小部件是代币小部件。该小部件负责监视 ERC20 代币。在wallet_widgets目录中创建token_widget.py。转到提供完整代码文件的以下 GitLab 链接:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/tree/master/chapter_09/wallet/wallet_widgets

这个最后的小部件在主小部件的第三个选项卡中。这里的目的是监视 ERC20 代币并列出所有已监视的 ERC20 代币。有一个按钮来启动输入对话框,一个按钮来请求 ERC20 智能合约代币的地址,然后有一个垂直布局来显示所有 ERC20 代币:

from PySide2.QtWidgets import (QWidget,
                               QGridLayout,
                               QVBoxLayout,
                               QHBoxLayout,
                               QPushButton,
                               QLabel,
                               QInputDialog,
                               QLineEdit,
                               QToolTip,
                               QComboBox,
                               QApplication,
                               QSlider,
                               QSizePolicy)
from PySide2.QtCore import Slot, SIGNAL, QSize, Qt
from PySide2.QtGui import QPixmap, QMovie, QPalette, QColor
from os.path import isdir, exists
from os import mkdir
from time import sleep
import json
from tools.util import render_avatar
from blockchain import blockchain, SendTransaction, TokenInformation

像往常一样,我们导入了许多东西,比如渲染头像工具、区块链对象以建立有关区块链上代币的信息,以及一些处理文件系统的库。除此之外,我们还从PySide2导入了 UI 类,比如许多类型的小部件,以及用于将回调附加到小部件的类。除了 UI 类,我们还从 PySide2 导入了非 UI 类,比如slotsignal

使用以下代码块进行初始化方法:

class TokenWidget(QWidget):

    tokens_file = 'tokens.json'

    def __init__(self, parent=None):
        super(TokenWidget, self).__init__(parent)

        self.watch_token_button = QPushButton("Watch Token")

        tokens = blockchain.get_tokens()

...
...

        self.watch_token_button.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
        self.connect(self.watch_token_button, SIGNAL('clicked()'), self.watchNewToken)

        layout.addWidget(self.watch_token_button, 0, 0)
        layout.addLayout(self.tokens_layout, 1, 0)

        self.setLayout(layout)

在这个初始化方法中,我们创建一个按钮,链接到watchNewToken方法,然后创建一个垂直框布局来容纳所有代币信息。我们还声明了tokens_file对象,它保存了tokens.json配置文件。该文件跟踪所有与 ERC20 代币相关的信息。

使用以下代码块为每个代币信息创建一个头像图像、代币名称标签、代币符号标签和代币总供应标签:

    def _addTokenToWindow(self, token_information, resize_parent=False):
        wrapper_layout = QVBoxLayout()
        token_layout = QHBoxLayout()
        rows_layout = QVBoxLayout()
        token_label = QLabel(token_information.name)
...
...
        if resize_parent:
            sizeHint = self.size()
            self.parentWidget().parentWidget().resize(QSize(sizeHint.width(), sizeHint.height() + 100))

如果resize_parent为 true,这意味着我们通过对话框添加代币信息。换句话说,我们要求父窗口增加其高度。如果resize_parentfalse,这意味着此方法是从开始调用的。

以下代码块是用于使用对话框请求智能合约地址的方法:

    @Slot()
    def watchNewToken(self):
        address, ok = QInputDialog.getText(self, "Watch A New Token",
                 "Token Smart Contract:", QLineEdit.Normal)
        if ok and address != '':
            token_information = blockchain.get_information_of_token(address)
            self._addTokenToWindow(token_information, resize_parent=True)
            token_data = {}
            if exists(self.tokens_file):
                with open(self.tokens_file) as json_data:
                    token_data = json.load(json_data)
            token_data[token_information.address] = {'name': token_information.name,
                                                     'symbol': token_information.symbol,
                                                     'total_supply': token_information.totalSupply}
            with open(self.tokens_file, 'w') as outfile:
                json.dump(token_data, outfile)

如果用户确认了智能合约的地址,我们将使用blockchain对象的get_information_of_token方法获取代币信息。然后,将这个代币的信息放在垂直框布局中。稍后,我们将代币的信息保存在 json 文件中。这样做是为了在重新启动应用程序时可以加载这些代币信息。

在启动加密货币钱包之前,请确保首先运行私有链,然后在私有链中部署一个或两个 ERC20 智能合约。您可以在第八章中使用 ERC20 智能合约源代码,在以太坊中创建代币。完成此操作后,使用以下命令运行桌面加密货币钱包:

(wallet-venv) $ python wallet.py

您将在以下截图中看到最终输出:

在上述截图中,我们看到账户选项卡显示了每个账户的余额。确保至少有两个账户。如果没有,请通过单击“创建账户”按钮从此选项卡创建一个。

以下截图显示了发送选项卡,我们可以向任何我们选择的账户发送以太币:

在第二个选项卡中,尝试发送以太币。在交易确认之前需要一些时间。因此,尝试向另一个账户发送 ERC20 代币(但您必须首先在第三个选项卡中添加 ERC20 代币),如下截图所示:

最后,在第三个选项卡中,尝试观察一个代币智能合约。单击“观察代币”按钮时,在对话框中放入智能合约地址:

您的代币将在第二个选项卡中反映出来。

测试

让我们为这个 GUI 应用程序编写测试。这些测试不应该是详尽无遗的。我们将创建三个测试,每个选项卡一个。我们不会为应用程序的非 UI 部分创建测试。本节只是为了演示如何测试 UI 应用程序。

第一个选项卡的第一个测试是一个账户小部件测试。将测试命名为test_account.py,并将其保存在tests目录中。以下代码块是测试脚本:

import sys, os
sys.path.append(os.path.realpath(os.path.dirname(__file__)+"/.."))

from wallet import WalletWidget
from PySide2.QtWidgets import QInputDialog
from PySide2 import QtCore

def test_account(qtbot, monkeypatch):
    wallet = WalletWidget()
    qtbot.addWidget(wallet)

    old_accounts_amount = wallet.account_widget.accounts_layout.count()

    monkeypatch.setattr(QInputDialog, 'getText', lambda *args: ("password", True))
    qtbot.mouseClick(wallet.account_widget.create_account_button, QtCore.Qt.LeftButton)

    accounts_amount = wallet.account_widget.accounts_layout.count()
    assert accounts_amount == old_accounts_amount + 1

    wallet.killThreads()

在这个测试中,我们测试了在点击按钮、启动对话框、填写密码,然后点击确定之前,账户布局有多少子项,然后我们再次检查创建新账户后子项的数量。数量应该增加一个。为了进行这个测试,我们对对话框进行了补丁,以便更容易进行测试。

这个测试并不全面。我们没有测试失败的情况。我将把这留给读者作为练习。

第二个选项卡的测试是发送交易小部件测试。将测试文件命名为test_send.py,并将其保存在tests目录中。测试脚本如下所示(有关完整代码,请参考以下 GitLab 链接中的代码文件:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_09/wallet/tests/test_send.py):

import sys, os
sys.path.append(os.path.realpath(os.path.dirname(__file__)+"/.."))
from time import sleep

from wallet import WalletWidget
from PySide2.QtWidgets import QInputDialog
from PySide2 import QtCore

...
...

    qtbot.keyClicks(wallet.send_widget.sender_combo_box, second_account)
    balance_of_second_account = int(float(wallet.send_widget.balance_label.text().split()[1]))

    assert balance_of_second_account - old_balance_of_second_account == 10

    wallet.killThreads()

在这个测试中,我们检查了组合框中第二个账户的余额。这个第二个账户将是目标账户。在这里,我们从标签中读取余额,然后将组合框的值改回第一个账户,这将是发送方。之后,我们将目标账户的地址设置为目标行编辑。然后我们在金额行编辑中设置以太币的数量,并点击发送按钮,但请记住,我们需要对输入对话框进行补丁。最后,在将账户组合框的值再次更改为第二个账户之前,我们等待大约 20 秒。我们从标签中检索余额,然后比较旧值和新值之间的差异,应该是 10 以太币。

第三个选项卡的测试是为了测试令牌小部件。将其命名为test_token.py并保存在tests目录中。此测试的测试脚本如下所示:

import sys, os
sys.path.append(os.path.realpath(os.path.dirname(__file__)+"/.."))

from wallet import WalletWidget
from PySide2.QtWidgets import QInputDialog
from PySide2 import QtCore

def test_token(qtbot, monkeypatch):
    wallet = WalletWidget()
    qtbot.addWidget(wallet)

    old_tokens_amount = wallet.token_widget.tokens_layout.count()

    address = None
    with open('address.txt') as f:
        address = f.readline().rstrip()

    monkeypatch.setattr(QInputDialog, 'getText', lambda *args: (address, True))
    qtbot.mouseClick(wallet.token_widget.watch_token_button, QtCore.Qt.LeftButton)

    tokens_amount = wallet.token_widget.tokens_layout.count()
    assert tokens_amount == old_tokens_amount + 1

    wallet.killThreads()

首先,我们将令牌智能合约的地址加载到address.txt文件中,因为我们不希望在测试文件中将其硬编码。策略与账户小部件测试中的相同。我们检查垂直框布局有多少子项。完成后,我们点击按钮,启动对话框,填写智能合约的地址,然后点击确定。接下来,我们再次检查垂直框布局有多少子项。这个数字应该再次增加 1。

就像我说的,这个测试实际上并不完整。我们也应该测试令牌信息。但是,这个测试是一个很好的开始。

您可以使用以下命令运行前述测试:

(wallet-venv) $ pytest tests

构建加密货币钱包时需要考虑的因素

您现在已经创建了一个桌面加密货币钱包。但是,这个钱包还没有完全完成。加密货币钱包是一个广泛的话题,变化如此之快,以至于可以单独写一本书来讨论这个话题。您可以将其他功能实现到加密货币钱包应用程序中,比如已确认的区块数量。在我们的应用程序中,我们只等待一个交易,但有些用户可能想要先确认几个区块。如果交易仅经过一个区块的确认,那么它有一点可能被更长的区块替换。然而,经过 12 个区块后,区块中的交易基本上是安全且不可逆的,如下面的链接所解释的:ethereum.stackexchange.com/questions/319/what-number-of-confirmations-is-considered-secure-in-ethereum

我们的加密货币钱包是一个纯粹的加密货币钱包。但是,您也可以向我们的加密货币钱包添加与钱包功能无关的其他功能。例如,Mist 加密货币钱包不仅仅是一个钱包;它还是一个去中心化应用程序浏览器。它还可以编译智能合约的源代码并将其部署到区块链上。

如果您想要制作一个完整的加密货币钱包,您应该实现许多功能。一些想法包括生成二维码、导出加密私钥的选项、导入私钥、使用种子短语生成账户、输入验证以及记住密码一段时间。

在这里,我们正在构建一个桌面加密货币钱包。桌面应用程序可以拥有大量的内存和存储空间。但是,如果您正在构建一个移动加密货币钱包,情况就不同了。例如,比特币桌面加密货币钱包可以在本地访问完整节点。但是,您不能将完整的比特币节点放在手机上,因为它太大了。当然,您可以将完整的比特币节点放在云上,让移动加密货币钱包应用程序访问它。但是,大多数人不想在云上设置完整节点。因此,比特币移动加密货币钱包的任何开发者通常使用简化支付验证(SPV)。这样,比特币移动加密货币钱包就不需要在手机上存储完整的比特币节点。

如果您想要构建加密货币钱包或为现有的加密货币钱包做出贡献,您需要牢记两件事:安全和用户体验(UX)。

安全

加密货币钱包处理资金,因此您需要确保其安全。安全是一个复杂的话题,在这里我们将简要讨论。

不要仅仅因为可以而安装第三方库;每个库都是另一个攻击向量。在应用程序中慎重打包第三方库。我们的加密货币钱包使用来自以太坊 GitHub 的库,如 web3.py 和 Populus。这应该没问题,因为它们是核心库。我们还使用 Qt 公司的PySide2库。这个库是必不可少的,因为没有 GUI 库就不能有 GUI 应用程序。我们还使用第三方库来生成 identicon 头像图像。在这里我们需要小心。这个库是一个单一文件,我已经完全阅读过以确保没有隐藏的恶意软件。因此,我可以自信地将其整合到我们的应用程序中。

在宣布交易完成之前,请使用最少数量的确认。确认次数足够多取决于您的威胁和风险建模。通常,12 次确认会使撤销交易变得不切实际。Mist 钱包使用 12 次确认,而 ZCash 钱包使用 10 次确认。

您还可以在创建加密货币钱包的帐户时强制用户创建一个良好的密码,因为大多数用户倾向于使用糟糕的密码创建帐户。但在这里要小心;您不希望过分打扰他们。

用户体验

如果一个应用程序非常安全但使用起来非常困难,那就没有用了。因此,我们需要让用户感到不那么害怕。比特币的创造者中本聪在构建软件时非常注重用户体验。以前,人们使用 base64 格式将二进制转换为文本。然而,中本聪使用 base58 来表示比特币地址。Base58 类似于 base64,但在打印时不会引起混淆的字符,比如 I(大写 i)和 l(小写 L)。

Zcash 发布了一个关于设计加密货币钱包的用户体验指南,可以在以下链接找到:zcash.readthedocs.io/en/latest/rtd_pages/ux_wallet_checklist.html。这里并非所有内容都可以实施,因为 Zcash 有以太坊没有的私人交易。然而,其他建议可以实施;例如,市场信息。不管喜欢与否,人们将加密货币价格挂钩到法定货币上,向人们展示 1 个以太的市场价格是个好主意。如果网络拥挤,您也应通知用户。您可以建议用户等待或增加交易费用。

如果您要构建 iOS 加密货币钱包,应遵循苹果人机界面指南。如果您要构建 Android 加密货币钱包,应遵循 Material Design 指南。在选择排版和颜色时要小心。在设计加密货币钱包时,应进行用户访谈。用户体验是一个广泛的主题。平衡用户体验和安全是一门微妙的艺术。在构建加密货币钱包时不应忽视用户体验。

摘要

在本章中,我们已经熟悉了PySide2的标签视图、大小策略和网格布局。然后,我们还学习了如何测试 Qt 应用程序。接下来,我们开始构建一个桌面加密货币钱包。我们将应用程序分成许多部分:区块链、线程、小部件、标识工具和测试。加密货币钱包的区块链部分是基于web3Populus库的,其目的是在区块链中读取和创建交易。线程是在创建交易时,UI 部分和区块链对象之间的中间人。标识工具用于根据特定字符串(通常是账户地址或代币智能合约地址)创建头像图像。小部件部分是一个带有三个选项卡的选项卡小部件。第一个选项卡是账户小部件,第二个选项卡是发送交易小部件,第三个选项卡是代币小部件。最后,我们为这个应用程序创建了测试。

在下一章中,我们将开始学习区块链技术范围之外的主题。这项技术被称为 IPFS。它仍然是分散式技术的一部分,但这项技术将克服与区块链技术相关的弱点;换句话说,它的存储是昂贵的。

第五部分:去中心化文件系统

本节是对星际文件系统的介绍,人们可以以分布式的方式存储文件。我们还将构建一个去中心化的类似 Youtube 的应用程序。

接下来的章节将在本节中涵盖:

  • [第十章],星际网络-一个崭新的文件系统

  • [第十一章],使用 ipfsapi 与 IPFS 进行交互

  • [第十二章],使用 IPFS 实现去中心化应用程序

第十章:星际文件系统-一个崭新的文件系统

在本章中,我们将学习星际文件系统IPFS)。IPFS 实际上并不是区块链技术的一部分;相反,它是对其的补充。IPFS 与区块链是天作之合。正如你在之前的章节中学到的,区块链中的存储是昂贵的。通常,人们在区块链中保存文件的链接,并将实际文件保存在普通存储中,比如云存储。但这种策略遭受了中心化的命运。IPFS 为区块链开发者提供了一种避免这种情况的方法。

在本章中,你将学习以下内容:

  • IPFS 背后的动机

  • Merkle DAG

  • 点对点网络

IPFS 背后的动机

IPFS 不是一个普通的文件系统,比如fat32ntfsext3。它更类似于 Dropbox。它是一个跨设备的文件系统。你可以把文件保存在这个文件系统中,全世界的人都可以像在自己的电脑上一样轻松地访问它。如果以太坊可以被认为是世界上唯一的操作系统,那么 IPFS 可以被认为是世界上唯一的存储!

IPFS 网站的口号是IPFS 是分布式网络。IPFS 试图取代,或者至少补充 HTTP。HTTP 协议已经服务了我们很长时间,超过 20 年,但它被认为不足以应对即将出现的挑战,比如带宽需求增加或文件冗余。HTTP 使用客户端-服务器模型。你只能选择这两种角色中的一种:要么是服务器,要么是客户端。

这种架构存在一些问题:

  • 第一个问题是,要担任服务器角色,我们必须有足够的资源。如果没有,如果服务器被大量请求淹没,它可能会迅速崩溃。处理每分钟一百万个请求所需的资源对许多普通人来说是不可及的。

  • 第二个问题是,服务器和客户端的架构在某些情况下并不高效。想象一下,你坐在公园里的一位老奶奶旁边,你们两个都在从同一个 URL 观看一只可爱熊猫的视频(类似example.com/cute_panda.mp4)。假设这个视频的大小是 20MB。这意味着服务器必须两次发送一个 20MB 的文件到两个不同的位置,即使这两个不同的位置距离很近,相隔一米。换句话说,服务器使用了 40MB 的带宽。然而,想象一下,如果你可以不是从服务器,而是从坐在你旁边的老奶奶那里获取文件(在这种情况下,假设老奶奶比你早两分钟观看了这个可爱熊猫的视频)。这不是更高效吗?

Juan Benet 在 2013 年末受到启发建立了 IPFS。当时,他正在研究知识工具,这个术语指的是可以有效地从论文中获取知识的软件。比如,一个科学家读了很多论文。如果科学家能更快地获取这些知识就更好了。Benet 遇到的问题是数据集分发需要太多的工作。没有简单的方法来处理数据集的版本控制。他看了各种工具,比如 Git 和 BitTorrent,并想知道它们是否可以结合起来解决这个问题。结果,IPFS 诞生了。BitTorrent 启发了 IPFS 在节点之间分发文件和查找文件。Git 启发了 IPFS 保持文件的完整性和将保存的文件转换为存储。

IPFS 是一个点对点的超媒体协议,使网络更快、更安全、更开放。IPFS 的目标是务实的和理想化的。除了节省带宽,它的另一个目标是增加文件的寿命。在服务器上保存文件很长时间(比如十年)需要大量的资源。我们希望文件保持存活的原因通常是因为它对服务器所有者有某种经济利益;例如,如果是博客文章,可以通过广告实现货币化。如果不是,文件有可能会被存储服务器的所有者销毁。这就是当 Geocities 关闭时发生的情况。

Geocities 是一个允许人们创建自己个人网站的网站。它类似于wordpress.commedium.com。一些服务器所有者会保持文件存活,即使没有广告,就像维基百科一样,它靠捐赠维持运转。除此之外,文件就没有那么幸运了。

IPFS 的其他目标更加理想化,涉及到如何使我们提供内容的方式更加民主化。目前,内容非常集中。我们通常只会去一些网站,比如 Facebook、Instagram、Reddit、Medium、Netflix、Amazon、Google、Wikipedia 等等。这种信息的寡头垄断阻碍了互联网的创新,因为信息实际上是由少数几家公司控制的。除了维基百科,大多数,如果不是全部,公司都受到富有的股东的约束。这种情况与 10 年前形成了鲜明对比,当时互联网被认为是财富和信息的伟大均等化者,类似于印刷技术。

这种重度集中化的另一个缺点是提供的信息容易受到审查。例如,Google 是一家位于加利福尼亚州山景城的公司,因此受美国法律约束。大多数有权做决定的人(高级管理人员和 C 级别人员)都是美国人,因此在他们对世界的看法中存在美国偏见。在欧洲大多数国家都是合法的事情,在美国可能因为被认为是亵渎或危险而被审查。IPFS 项目的创始人将这种情况比作被国家或强大机构认为危险而被焚烧的书籍。IPFS 项目的一个目标是增加文件对审查的抵抗力。IPFS 使人们更容易地镜像和提供危险文件。我们将在本章的后面讨论 IPFS 如何实现这一目标。

IPFS 的最终目标更加务实,涉及到我们脆弱的互联网基础设施,由计算机网络和由光纤电缆连接的核心路由器组成。如果连接的电缆意外或故意受损,一个区块或区域可能会离线。2011 年,一名女子用铁锹损坏了为亚美尼亚带来互联网的电缆,当时她在挖掘寻找金属出售。IPFS 项目并不能完全解决这个问题,但它可以在一定程度上减轻损害。

你可以在这里找到关于那位女士和她的铁锹的事件:web.archive.org/web/20141225063937/http://www.wsj.com/articles/SB10001424052748704630004576249013084603344.

Merkle DAG

如果你已经了解了 Git 的内部工作原理,Merkle 有向无环图(DAG)就不会太陌生。作为一个版本控制系统软件,Git 需要保留文件的许多版本并轻松地将它们分发给其他人。它还需要能够快速检查文件的完整性。

Merkle DAG 由两个词组成:Merkle 和 DAG。让我们先讨论 Merkle。实际上,在这个上下文中,Merkle 的完整词是 Merkle 树。 Merkle 树是一种快速检查部分数据是否被篡改的方法。

Merkle 树

让我们看一个 Merkle 树的例子以便理解它。假设您有八个数据。在这种情况下,我们将使用动物的名称作为我们的数据,但在使用 Merkle 树的比特币中,数据通常是交易。回到 Merkle 树:按顺序放置数据,所以在这种情况下,猫是第一条数据,狗是第二条,蚂蚁是第三条,依此类推:

我们获取每个数据的哈希,例如猫,狗,蚂蚁等。在这个演示中,我们使用哈希函数 SHA256。由于空间有限,我们在图中截断了完整的哈希结果。现在,我们将数据从左到右排序,所以“猫”字符串的哈希是Data 1,“狗”字符串的哈希是Data 2,“蚂蚁”字符串的哈希是Data 3,依此类推。

现在是有趣的部分。对于Data 1Data 2,我们合并哈希并对结果进行哈希。合并哈希意味着连接它。对Data 3Data 4Data 5Data 6Data 7Data 8也是如此。

这可能让您想起淘汰赛。我们现在进入半决赛阶段。我们现在有Hash 1(来自Data 1Data 2),Hash 2(来自Data 3Data 4),Hash 3(来自Data 5Data 6),以及Hash 4(来自Data 7Data 8)。

然后我们连接Hash 1Hash 2,对结果进行哈希,并将其命名为Hash 5。然后我们对Hash 3Hash 4做同样的事情。将结果命名为Hash 6

我们现在进入最后阶段。合并Hash 5Hash 6,然后对结果进行哈希。结果就是Root Hash。这个Root Hash可以保证所有数据的完整性(从Data 1Data 8)。如果更改任何数据,Root Hash将会不同。

您可能会问为什么我们不只是从一开始就连接所有数据(从Data 1Data 8)然后对结果进行哈希。然而,事实证明,Merkle 树比仅仅连接所有数据然后对其进行哈希(这种技术称为哈希列表,在某些情况下使用)具有一些优点。其中一个好处是,当我们使用 Merkel 树时,检查部分数据的完整性更容易和更便宜。

在 Merkle 树中,要检查Data 5的完整性,您只需要下载Data 5Data 6Hash 4Hash 5Root Hash,如下图所示。您不需要下载所有数据:

如果您使用天真的方法,您需要下载所有数据的哈希值(Data 1Data 8)和Root Hash。在这个例子中,我们只有八个数据。想象一下,如果我们有 100 个数据,你需要下载整个数据集。Merkle 树使这个过程更有效,因为我们不需要下载完整的数据集。

如果我们有奇数个节点,比如七个,一般规则(比特币实现的规则)是克隆最后一个节点,所以Data 8Data 7的副本。然而,您也可以使用另一条规则;我见过 Merkle 树的一种实现,其中单个数据(在我们的例子中是Data 7)被简单地提升到顶部。在这种情况下,Hash 4就是Data 7

这就是当人们使用简化支付验证时比特币所做的事情。使用移动应用程序下载完整节点是困难的。为了发送比特币交易,用户只下载节点的重要部分而不是完整节点。Merkle 树使这一过程成为可能。

在下一节中,我们将继续学习 DAGs。

指令丙烯酸图(DAGs)

指令丙烯酸图DAGs),顾名思义,是图,其中每个顶点(或节点)都可以有指向其他顶点的边,如下图所示:

箭头的方向并不重要,只要保持一致即可:

规则是这些边不应该构成一个循环。在下图中,我们可以看到顶点 A、C 和 D 构成一个循环,这违反了 DAG 的规则:

现在,如果你结合 Merkle 树和 DAG,你会得到一个 Merkle DAG。这是 Git 和 IPFS 使用的数据结构。

在 Merkle 树中,只有叶节点保存数据。然而,在 Merkle DAG 中,任何节点都可以保存数据。在 Merkle 树中,树必须是平衡的,但在 Merkle DAG 中没有这样的限制。

在我们深入 Merkle DAG 之前,让我们先了解内容寻址,因为 Merkle DAG 依赖于这个特性。

内容寻址

在链表中,你用指针将节点(或块)链接在一起。指针是指向内存的数据类型。例如,假设我们有两个节点,节点 A 和节点 B。节点 A 是头,节点 B 是尾。节点的结构有两个重要的组成部分。第一个组成部分是数据组成部分,你在其中存储数据。在 Git 中,这个数据可以是文件的内容。第二个组成部分是指向另一个节点的链接。在链表中,这是指向节点地址的指针。

但是,使用内容寻址时,我们不仅仅添加一个指针,还会添加目标的哈希值(在这种情况下是节点 B)。你可能会认出这个概念;这正是区块链中发生的事情。然而,Merkle DAG 不是一个线性延伸的链表。Merkle DAG 是一个可以有分支的树。

这是一个链表。它用于区块链的数据结构:

现在,考虑这种情况。我们有三个节点:节点 A1 和 A2 都是指向节点 B 的头。我们不是把指针放在节点 A1 和节点 A2 上,而是把指针放在节点 B 上。节点 B 现在有两个指针。节点 B 对节点 A1 和节点 A2 进行哈希,然后连接两个哈希再次进行哈希。这样,节点 B 可以保持节点 A1 和节点 A2 的内容的完整性。如果有人改变了节点 A1 或节点 A2 的内容,节点 B 保存的哈希将无效:

IPFS 在获取文档方面与 HTTP 不同。HTTP 使用链接,它们像指针一样工作。例如,假设我们有以下链接:example.com/cute_panda.png。这使用一个位置来获取名为cute_panda.png的文档。只有一个提供者可以提供这个文档,那就是example.com。然而,IPFS 不使用 URL 链接。相反,它使用哈希链接,比如ipfs://QmYeAiiK1UfB8MGLRefok1N7vBTyX8hGPuMXZ4Xq1DPyt7。当你访问这个哈希链接时,IPFS 软件将找到文档,当哈希后,将给出相同的哈希输出。因为哈希是一个单向函数,IPFS 必须有一些其他信息来定位文档。基本上,它会将请求广播到附近具有这个哈希输出的文档的节点。如果附近的节点没有这些文件,它们会将请求转发给它们附近的节点。这种对等查找请求非常复杂。IPFS 使用 S/Kademlia 分布式哈希表,我们将在本章的后面部分讨论。

有趣的是,当你使用内容寻址时,可能会有多个提供者可以提供这个文档。在cute_panda.png文档的情况下,可能有四个以上的节点可以提供这个文档。我们可以选择最近的节点来使下载过程更有效率。这个特性也使得审查变得更加困难。在 HTTP 的情况下,一个行为者可以禁止服务器example.com。然而,在 IPFS 的情况下,任何人都可以启动一个新节点并提供文档。现在,IPFS 是透明的,也许太过透明。请求文档的节点可以看到提供文档的节点的 IP 地址,反之亦然。行为者可以禁止 IP 地址以阻止这个文档的传播。然而,使 IPFS 与 Tor 一起工作的开发,Tor 是一种允许用户匿名浏览网站的软件,仍处于早期阶段。

如果你从example.com/cute_panda.png下载一个文档,那么你在那一刻得到的文档可能与你的朋友昨天从同样的 URL 下载的文档不同。可能是服务器的管理员在你今天下载之前改变了文档。

然而,通过内容寻址系统,你从 IPFS 哈希链接ipfs://QmYeAiiK1UfB8MGLRefok1N7vBTyX8hGPuMXZ4Xq1DPyt7获取的文档,无论何时何地下载,都是相同的。这个哈希链接保证了没有人可以篡改文档。如果你改变文档并上传到 IPFS,IPFS 的 URL 或哈希将会不同。

我们可以创建一个简单的 Python 脚本来说明这种情况。在这个目录中创建一个名为ipfs_tutorial的目录。在这个目录中创建三个示例文件。第一个示例文件是hello.txt,内容是I am a good boy.\n。第二个示例文件是hello2.txt,内容是I am a good girl.\n。第三个示例文件是hello3.txt,内容是I am a good horse.\n。第四个示例文件是hello4.txt,内容是I am a good girl.\n。第二个和第四个文件具有相同的内容是故意的。如果你愿意,你可以创建不同的文件,但请确保其中至少有两个文件具有相同的内容。

创建一个 Python 脚本,如下面的代码块所示,并将其命名为create_hash_from_content.py

from os import listdir
from hashlib import sha256

files = [f for f in listdir('.') if 'hello' in f]

hashes = {}

for file in files:
    with open(file) as f:
        content = f.read().encode('utf-8')
        hash_of_content = sha256(content).hexdigest()
        hashes[hash_of_content] = content

content = hashes['20c38a7a55fc8a8e7f45fde7247a0436d97826c20c5e7f8c978e6d59fa895fd2']
print(content.decode('utf-8'))

print(len(hashes))

这个脚本列出了同一目录中所有以hello开头的文件。如果你的示例文件不是以hello开头的,你可以修改这部分。长哈希是hello2.txt内容的哈希值。

当你运行这个脚本时,你会得到以下结果:

I am a good girl.

3

正如你所看到的,有四个文件,但最终输出是三个,而不是四个。这是因为有三个文件具有独特的内容,而不是四个。这就是内容寻址的工作原理。它不关心文件名,只关心内容。文件名是hello1.txt还是hello2.txthello4.txt都无关紧要,重要的是内容I am a good girl.\n是相同的。从技术上讲,这是一个“善意的谎言”;有一种情况下 IPFS 必须考虑文件名,不能忽视它。我将在本章后面解释这个问题的真相。

在前面的例子中,我们看到的是正常的哈希。没有 Markle DAG 甚至 Merkle 树。现在让我们创建一个更复杂的场景,有一个大文件。对大文件进行哈希不是有效的。通常,我们将文件分割成多个相同大小的小块。例如,一个 900KB 的文件会变成四个文件。第一、第二和第三个文件的大小为 250KB。第四个文件的大小为 150KB。然后,我们对每个较小的文件进行哈希,并与 Merkle 树结合。

为了说明目的,我们不会使用一个大文件,但我们会设置一些虚构的限制。我们不希望对跨越多行的内容进行哈希。如果文本文件有四行,我们会将它们分成四个更小的文件。

在项目目录中,创建一个名为hello_big.txt的文件,并输入以下内容:

I am a big boy.
I am a tall girl.
I am a fast horse.
I am a slow dragon.

在创建一个脚本来对这个大文件进行哈希之前,让我们创建一个非常简单的 Merkle 树库,并将其命名为merkle_tree.py。有关完整代码文件,请参考 GitLab 链接:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/tree/master/chapter_10

让我们从初始化开始讨论这个 Merkle 树库:

    def __init__(self, leaf_nodes : List[str]):
        self.hash_nodes : List[str] = []
        self.leaf_nodes : List[str] = leaf_nodes
        self._turn_leaf_nodes_to_hash_nodes()
        if len(leaf_nodes) < 4:
            self.root_hash = self._hash_list()
        else:
            self.root_hash = self._build_root_hash()

确保至少有四个节点。如果没有,我们可以使用哈希列表技术。leaf_nodes是原始数据节点。它们是字符串列表,比如['cat', 'dog', 'unicorn', 'elephant']hash_nodes是数据节点的哈希列表,比如[hash of 'cat', hash of 'dog', hash of 'unicorn', hash of 'elephant']['77af778...', 'cd6357e...', 'c6cb50e...', 'cd08c4c...']

如果节点少于四个,我们使用_hash_list()方法对数据进行哈希列表。我们在对它们进行哈希之前将所有数据拼接起来:

    def _hash_list(self):
        long_node = "".join(self.hash_nodes)
        return self._hash(long_node.encode('utf-8'))

_turn_leaf_nodes_to_hash_nodes()方法中,我们根据leaf_nodes填充hash_nodes。这是一对一的映射:

    def _turn_leaf_nodes_to_hash_nodes(self):
        for node in self.leaf_nodes:
            self.hash_nodes.append(self._hash(node.encode('utf-8')))

_hash()方法中,我们包装了sha256哈希函数。这是为了使类的定制更容易,因为我们可能想使用不同的哈希函数:

    def _hash(self, data : bytes) > bytes:
        return sha256(data).hexdigest()

以下代码块显示了如何从哈希节点中获取根节点:


    def _build_root_hash(self) > bytes:
        parent_amount = ceil(len(self.hash_nodes) / 2)
        nodes : List[str] = self.hash_nodes

        while parent_amount > 1:
            parents : List[bytes] = []
            i = 0
            while i < len(nodes):
                node1 = nodes[i]
                if i + 1 >= len(nodes):
                    node2 = None
                else:
                    node2 = nodes[i+1]
                parents.append(self._convert_parent_from_two_nodes(node1, node2))
                i += 2
            parent_amount = len(parents)
            nodes = parents

        return parents[0]

在这里,我们对哈希节点进行多次迭代。每次迭代都会跳过两步。对于每次迭代,它都会处理两个节点。它连接这两个节点的哈希,然后对结果进行哈希。得到的哈希是这两个节点的父节点。这个父节点成为将要再次迭代的哈希节点的一部分。这个父节点和它的邻居将再次连接并进行哈希,依此类推。如果哈希节点数为奇数,最后一个节点将在进行哈希之前与自身连接。如果只有一个父节点,我们返回该节点的哈希,即根哈希

    def _convert_parent_from_two_nodes(self, node1 : bytes, node2) -> bytes:
        if node2 == None:
            return self._hash((node1 + node1).encode('utf-8'))
        return self._hash((node1 + node2).encode('utf-8'))

_convert_parent_from_two_nodes()方法允许我们从两个子节点获取父哈希。我们将这两个节点连接起来并对它们进行哈希。如果第二个节点是None,表示节点数为奇数或者我们正在处理最后一个节点,我们只需将节点与自身连接再进行哈希。

现在 Merkle 树库已经准备好了,我们将创建一个 Python 脚本来对hello_big.txt文件进行哈希,并将其命名为hash_big_file.py

from os import listdir
from hashlib import sha256
from merkle_tree import MerkleTree

hashes = {}

file = 'hello_big.txt'
with open(file) as f:
    lines = f.read().split('\n')
    hash = []
    hash_of_hash = []
    merkle_tree = MerkleTree(lines)
    root_hash = merkle_tree.root_hash

hashes[root_hash] = []
for line in lines:
    hashes[root_hash].append(line)

print(hashes)

如果执行这个 Python 脚本,你将得到以下输出:

{'ba7a7738a34a0e60ef9663c669a7fac406ae9f84441df2b5ade3de1067c41808': ['I am a big boy.', 'I am a tall girl.', 'I am a fast horse.', 'I am a slow dragon.', '']}

如果文件很大,你不会直接对其进行哈希,因为这可能会导致内存耗尽。相反,你会将文件分割。在这里,我们根据换行符分割文本文件。如果处理二进制文件,你会逐块读取文件并将该块保存到一个较小的文件中。当然,在将它们输入 Merkle 树之前,你需要将二进制数据序列化为文本数据。一旦完成了这一步,你就可以将数据片段输入 Merkle 树。你会得到根哈希,它将保护原始文件的完整性。如果在数据片段中更改了一个位,根哈希将会不同。

Merkle DAG 数据结构

我们使用内容寻址来处理文件。如果文件很大,我们可以对其进行分割,并使用 Merkle 树获取根哈希。在这种情况下,我们只关心文件的内容;我们甚至不保存它的名称。

然而,有一种情况,文件名确实很重要。例如,假设您想保存一个包含 100 张可爱熊猫图片的文件目录。在这种情况下,文件的名称并不重要;我们关心的是内容,即可爱熊猫的图片!然而,如果这是一个编程项目的目录,文件的名称就很重要。如果一个 Python 文件尝试导入另一个包含在不同文件中的 Python 库,我们必须保留文件的名称。假设我们有一个名为main.py的 Python 文件,其内容如下:

from secret_algorithm import SuperSecretAlgorithm

# execute it
SuperSecretAlgorithm()

main.py文件依赖于同一目录中的另一个名为secret_algorithm.py的文件。重要的不仅是secret_algorithm.py文件的内容,还有它的名称。如果文件名更改,main.py将无法导入该库。

为了保存内容和文件名,我们需要使用 Merkle DAG 数据结构。如前所述,Merkle DAG 和 Merkle 树之间的一个区别是,Merkle DAG 中的任何节点都可以保存数据,而不仅仅是叶节点,这是 Merkle 树中的情况。

让我们创建一个包含示例文件和一个嵌套目录的示例目录:

$ mkdir sample_directory
$ cd sample_directory
$ // Create some files
$ mkdir inner_directory
$ cd inner_directory
$ // Create some files

然后,创建一个 Python 脚本来解释这种新的数据结构。在项目目录中创建一个名为merkle_dag.py的文件。有关完整代码文件,请参考 GitLab 链接:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/tree/master/chapter_10

让我们讨论MerkleDAGNode类,从其初始化方法开始:

    def __init__(self, filepath : str):
        self.pointers = {}
        self.dirtype = isdir(filepath)
        self.filename = Path(filepath).name
        if not self.dirtype:
            with open(filepath) as f:
                self.content = f.read()
            self.hash = self._hash((self.filename + self.content).encode('utf-8'))
        else:
            self.content = self._iterate_directory_contents(filepath)
            nodes_in_str_array = list(map(lambda x: str(x), self.content))
            if nodes_in_str_array:
                self.hash = self._hash((self.filename + MerkleTree(nodes_in_str_array).root_hash).encode('utf-8'))
            else:
                self.hash = self._hash(self.filename.encode('utf-8'))

_init_()方法接受文件路径作为参数。这可以是文件或目录的路径。我们假设这是一个有效的路径,而不是一个符号链接。self.pointers将在_iterate_directory_contents()方法的部分中解释。self.dirtype用于区分目录或文件。self.filename用于保存文件或目录的名称。

如果参数是文件的路径(而不是目录),我们将内容读入self.content。为了演示目的,我们假设文件的内容很小,我们不尝试像之前那样拆分文件。然后,我们根据文件名和内容计算哈希值。

如果参数是目录的路径,则内容将是该目录内部文件的MerkleDAGNode对象数组。为了计算哈希值,我们使用 Merkle 树来获取其子节点的根哈希。但是,我们需要在再次对其进行哈希之前将其与目录的名称连接起来:

    def _hash(self, data : bytes) -> bytes:
        return sha256(data).hexdigest()

_hash()sha256哈希函数的包装方法。

_iterate_directory_contents()方法用于迭代目录的内部子项。我们将该目录内的每个文件或目录转换为MerkleDAGNode对象。self.pointers对象用于更轻松地根据文件名访问MerkleDAGNode。基本上,这就像一个递归函数,特别是当我们遇到一个目录时:

    def _iterate_directory_contents(self, directory : str):
        nodes = []
        for f in listdir(directory):
            merkle_dag_node = MerkleDAGNode(directory + '/' + f)
            nodes.append(merkle_dag_node)
            self.pointers[f] = merkle_dag_node
        return nodes

_repr_()方法用于更轻松地打印对象以进行调试:

    def __repr__(self):
        return 'MerkleDAGNode: ' + self.hash + ' || ' + self.filename

_eq_()方法是必需的,以便我们可以将MerkleDAGNode对象与其他MerkleDAGNode对象进行比较。这在测试过程中非常有用:

    def __eq__(self, other):
        if isinstance(other, MerkleDAGNode):
            return self.hash == other.hash
        return False

让我们创建一个hash_directory.py文件来演示这种数据结构的强大功能:

from merkle_dag import MerkleDAGNode

outer_directory = 'sample_directory'

node = MerkleDAGNode(outer_directory)
print(node)
print(node.content)

如果执行脚本,您将获得以下结果:

MerkleDAGNode: ec618189b9de0dae250ab5fa0fd9bf1abc158935c66ff8595446f5f9b929e037 || sample_directory
[MerkleDAGNode: 97b97507c37bd205aa15073fb65367b45eb11a975fe78cd548916f5a3da9692a || hello2.txt, MerkleDAGNode: 8ced218a323755a7d4969187449177bb2338658c354c7174e21285b579ae2bca || hello.txt, MerkleDAGNode: c075280aef64223bd38b1bed1017599852180a37baa0eacce28bb92ac5492eb9 || inner_directory, MerkleDAGNode: bc908dfb86941536321338ff8dab1698db0e65f6b967a89bb79f5101d56e1d51 || hello3.txt]

输出是 Merkle DAG 节点的模式。

这就是 Git 保存文件的方式。我们的实现只是为了教育目的,不适合生产目的。在现实世界中,你应该有很多优化。你可以实现的一个优化是使用数据的引用,就像 Git 一样。如果有两个不同的文件具有相同的内容(但文件名不同),则内容只保存一次。另一个优化是 Git 使用压缩。下图说明了 Git 的概念,我们有两个文件文件 B文件 D。这两个文件都有相同的内容内容 xxx文件 B只保存一次在目录 A中。文件 D保存在目录 C中,有一个不同的内容内容 yyy目录 C也保存在目录 A中。但文件 B文件 D的内容内容 xxx只保存一次:

现在我们知道了如何使用 Merkle DAG 保存文件目录,如果我们想要更改文件的内容怎么办?我们应该放弃这个 Merkle DAG 节点并创建一个全新的节点吗?解决这个问题的更有效的方法是使用一个版本控制系统。一个文件可以有版本 1、版本 2、版本 3 等。实现版本控制的最简单方法是使用链表,如下图所示:

点对点网络

我们了解了如何在 IPFS 中保存文件。关键是哈希。值是文件或目录的名称和文件或目录的内容。如果我们正在构建一个集中式系统,我们的故事就结束了。我们只需要添加一些其他东西来创建一个保存文件并根据哈希搜索文件的软件。这个软件类似于数据库,比如 SQLite 或 LevelDB。IPFS 都不是这些;它是一个像数据库一样分散在各处的点对点文件系统。换句话说,它是一个分布式哈希表。

IPFS 使用 S/Kademlia,Kademlia 的扩展版本,作为分布式哈希表。在讨论 Kademlia 之前,让我们讨论它的前身。

首先,想象一个哈希表,就像 Python 中的字典,如下表所示:

2
5 独角兽
9 大象
11
4 犀牛
101 蓝鹦鹉
33

在 IPFS 中,关键是哈希,而不是数字。但为了演示目的,让我们把它变成一个简单的整数。值只是动物的简单名称,而不是文件的内容或目录中文件的内容。

现在,想象你有四个节点。一个节点可以是位于不同大陆的计算机。

让我们定义哪个节点持有哪些键:

节点
A 2, 9, 11
B 5
C 4, 33
D 101

你把这个表保存在一个中央服务器上。其中一个节点将是中央节点。这意味着如果有人想要访问键五,他们必须在收到答案之前向中央服务器询问,节点 B。之后,请求可以被定向到节点 B。节点 B 会向数据请求者返回“独角兽”。

这种方法非常高效;没有时间被浪费。Napster,点对点音乐共享系统,使用了这种方法。缺点是中央服务器是单点故障。对手(不喜欢这些信息传播的人;在 Napster 的情况下,这可能是大音乐公司)可能会攻击中央服务器。

一个解决方案是询问所有节点关于哪个节点保存了密钥,而不是将这些信息保存在中央节点中。这就是 Gnutella 所做的。这种设置对审查和对手的攻击是有抵抗力的,但对于请求数据的节点和人来说会增加难度。当接收到许多请求时,节点必须努力工作。这种设置称为泛洪。它适用于比特币,但不适用于 IPFS。

这就是分布式哈希表技术被创建的原因。有几种分布式哈希表算法,其中之一是 Kademlia。这个算法是由 Petar Maymounkov 和 David Mazières 于 2002 年创建的。后来被 eDonkey 文件共享平台使用。

数据和节点的接近概念

在分布式哈希表中,我们不会将数据放在每个节点上。我们根据接近的概念将数据放在某些节点上。我们希望将数据放在附近的节点上。这意味着我们不仅在节点之间有距离的概念,而且在数据和节点之间也有距离的概念。

想象一下,在这个分布式哈希表中启动或创建的每个节点都被赋予 1 到 1000 之间的 ID。每个节点 ID 都是唯一的,因此最多可以有 1,000 个节点。在现实世界中可能会有超过 1,000 个节点,但这将作为一个例子。假设我们有 10 个节点:

节点 ID
5
13
45
48
53
60
102
120
160
220

我们还有一些数据。为了简单起见,这种情况下的数据只是一些字符串:

数据
独角兽
飞马

为了能够判断这些数据与某些节点的接近程度或远离程度,我们需要将这些数据转换为 1 到 1000 之间的数字。在现实世界中,您可以对数据进行哈希。但是对于我们的实际演示,我们将只分配一个随机数:

密钥 数据
54 独角兽
2 飞马
100
900
255

如果我们想要将独角兽数据存储在最近的四个节点中(四只是一个配置数字),可以按以下方式进行。首先,您检查密钥,即 54。然后,我们想要获取最接近 54 的四个节点。如果您检查节点 ID 列表,最近的四个节点是 45、48、53 和 60。因此,我们将独角兽数据存储在这四个节点中。如果我们想要存储猫数据,从其密钥 100 的最近邻居是 53、60、102 和 120,因此我们将猫数据存储在这四个节点中。

在计算距离时,我们将数据视为节点。这就是我们在分布式哈希表中查找数据的方式。数据和节点共享相同的空间。

XOR 距离

然而,在 Kademlia 中,我们不是通过十进制减法来测量距离的。为了清楚起见,十进制减法只是普通的减法。45 和 50 之间的距离是 5。53 和 63 之间的距离是 10。

在 Kademlia 中,通过 XOR 距离来测量距离。3 和 6 之间的 XOR 距离是 5,而不是 3。下面是如何计算它的方法:

3 的二进制版本是 011。6 的二进制版本是 110。我所说的二进制版本是指二进制中的数字。XOR 表示“异或”。使用 XOR 运算,1 XOR 0 是 1,1 XOR 1 是 0,0 XOR 0 是 0,0 XOR 1 是 1。如果两个操作数相同,则结果为 0。如果两个操作数不同,则结果为 1。

011
110
---xor
101

101 是 5 的二进制版本。

XOR 距离具有一些有用的特性,促使 Kademlia 论文的作者选择 XOR 距离来衡量节点之间的距离。

第一个特性是节点与自身的 XOR 距离为 0。与 ID 为 5 的节点最接近的节点是另一个 ID 为 5 的节点,或者是它自己。5 的二进制版本是 0101:

0101
0101
----xor
0000

只有当我们测量节点与自身之间的距离时,才可能出现 0 距离。

第二个属性是不同节点之间的距离是对称的。4 和 8 之间的异或距离与 8 和 4 之间的异或距离相同。4 的二进制版本是 0100,8 的二进制版本是 1000。所以,如果我们使用它们的二进制值计算它们之间的距离,我们会得到相同的值。4 和 8 之间的异或距离如下:

0100
1000
----xor
1100

8 和 4 之间的异或距离如下:

1000
0100
----xor
1100

如果你习惯使用十进制减法距离,这对你来说会很直观。

最后一个有用的属性是节点 X 和节点 Z 之间的距离小于或等于节点 X 和节点 Y 之间的距离加上节点 Y 和节点 Z 之间的距离。这个属性很重要,因为 Kademlia 分布式哈希表中的节点不保存所有其他节点的地址。它只保存一些节点的地址。但是一个节点可以通过中间节点到达另一个节点。节点 X 知道节点 Y 的地址,但不知道节点 Z 的地址。节点 Y 知道节点 Z 的地址。节点 X 可以从节点 Y 查询节点 Y 的邻居节点。然后,节点 X 可以到达节点 Z,知道到节点 Z 的距离小于或等于节点 X 和节点 Y 的距离加上节点 Y 和节点 Z 的距离。

如果这个属性不成立,节点 X 搜索节点的时间越长,特定节点之间的距离就越远,这不是我们想要的。但是有了这个属性,其他节点的邻居节点的地址可能比组合距离小,甚至相同。

当你考虑使用异或距离时,你应该想到两个数字共享的前缀越多,这两个数字之间的距离就越短。例如,如果数字共享三个公共前缀,比如五和四,距离就是一:

0101
0100
----xor
0001

同样,对于数字 14 和 15,距离也是 1:

1110
1111
----xor
0001

但是,如果位的差异在左边,比如 5 和 13 的情况,距离可能很大,在这种情况下是 8:

0101
1101
----xor
1000

4 和 5 之间的异或距离是 1,但 5 和 6 之间的异或距离是 3。如果你习惯十进制减法距离,这是违反直觉的。为了更容易解释这个概念,让我们创建一个由 1 到 15 的数字组成的二叉树:

仔细看这棵树。4 和 5 之间的异或距离是 1,但 5 和 6 之间的异或距离是 3。如果你看图片,4 和 5 在一个直接的分支下,而 5 和 6 在一个更大的分支下,这意味着更大的距离。直接的分支对应右边的位。直接分支的父分支对应第二右边的位。顶部分支对应左边的位。所以,如果数字被顶部分支分开,距离至少是 8。8 的二进制版本是 1000。

这只是为了理解目的;这不是严格的数学定义。如果你看从 5 到 11 和 5 到 13 的路径,你应该得到大致相同的距离,但事实并非如此。5 和 13 的异或距离是 8,但 5 和 11 的异或距离是 14。

在 Python 中,你可以使用^运算符对两个数字进行异或运算:

>> 5 ^ 11
14

你可以使用bin函数将任何十进制数转换为它的二进制版本:

>>> bin(5)
'0b101'

然后,如果你想将二进制数转换回十进制数,使用int函数:

>>> int('0b101', 2)
5

int函数的第二个参数表示第一个参数的基数。二进制是基数 2。

现在我们已经了解了异或距离,我们将看一下节点如何保存其他节点的地址。一个节点在分布式哈希表中不保存所有其他节点。一个节点可以保存的节点数量取决于节点中的位数和k配置数字。让我们逐一讨论这些。

记得我们之前看到的树图片吗?它有 16 个叶子。现在想象最小的树:

它有两片叶子。让我们再加倍这棵树:

树现在有四片叶子。让我们再次加倍:

树现在有八片叶子。如果再次加倍,你会得到一棵像我们之前的树一样有 16 片叶子的树。

我们可以看到的进展是 2,4,8,16。如果我们继续这个过程,数字将是 32,64,128,等等。这可以写成 2^(1,) 2^(2,) 2^(3,) 2^(4,) 2^(5 )... 2^n。

让我们专注于一棵有 16 片叶子的树。当我们表示叶子编号时,我们必须使用一个 4 位的二进制数,比如 0001 或者 0101,因为最大的数字是 15,或者 1111。如果我们使用一棵有 64 片叶子的树,我们必须使用一个 6 位的数字,比如 000001,010101,因为最大可能的数字是 63 或者 111111。位数越大,节点在其地址簿中保存的节点数量就越多。

然后,我们有k配置数字。k决定了一个节点在一个桶中可以保存的最大节点数量。桶的数量与分布式哈希表中使用的位数相同。在有 16 片叶子的树中,桶的数量是 4。在有 64 片叶子的树中,桶的数量是 6。每个桶对应一个位。假设我们有一棵有 16 片叶子的树,所以每个数字有 4 位,比如 0101 或者 1100。这意味着节点有四个桶。

第一个桶对应于从左边开始的第一个位。第二个桶对应于从左边开始的第二个位。第三个桶对应于从左边开始的第三个位。第四个桶对应于从左边开始的第四个位。

让我们看一个在有 16 片叶子的树中 ID 为 3 的节点的例子。现在,我们假设在有 16 片叶子的树中有 16 个节点。在现实世界中,树会是稀疏的,很多分支会是空的。

在描述 Kademlia 的论文中,作者使用了 160 个桶或者 160 位地址。这棵树中的叶子数量是巨大的。作为对比,2⁷⁸是可见宇宙中的原子数量。在这篇论文中,k配置数字选择为 20,因此一个节点的地址簿中最多可以有 3,200 个节点。

在这个例子中,假设k数字是 2。这意味着对于每个桶,节点保存另外两个节点。第一个桶对应于第一个位,对应于树的另一半,节点不驻留在这里。我们在树的这一半有八个节点,但是我们只能保存其中的两个,因为k数字是 2。让我们选择节点 11 和 14 放入这个桶。如何选择哪些节点放入哪些桶将在后面描述。

然后,让我们把包含节点的一半的树分成两个分支。第一个分支包括 ID 为 0 的节点,ID 为 1 的节点,ID 为 2 的节点和 ID 为 3 的节点。第二个分支包括 ID 为 4 的节点,ID 为 5 的节点,ID 为 6 的节点和 ID 为 7 的节点。第二个分支是第二个桶。这个分支有四个节点,但我们只能保存两个节点。让我们选择 ID 为 4 的节点和 ID 为 5 的节点。

然后,让我们把包含我们的节点(ID 为 3 的节点)的分支分成两个小分支。第一个小分支包括 ID 为 0 的节点和 ID 为 1 的节点。第二个小分支包括 ID 为 2 的节点和 ID 为 3 的节点。所以第三个桶是第一个小分支。这里只有两个节点,ID 为 0 的节点和 ID 为 1 的节点,所以我们都保存。

最后,让我们把包含我们的节点(ID 为 3 的节点)的小分支分成两个小分支。第一个小分支包括 ID 为 2 的节点,第二个小分支包括 ID 为 3 的节点。第四个桶,或者最后一个桶,将是包含节点 3 的分支。

我们只保存了一个节点,因为它小于k配置数字:

以下图表显示了完整的四个桶。每个桶都是源节点不在的分支的一半。不同节点的桶配置是不同的。ID 为 11 的节点可能有以下桶配置:

让我们看一个例子,说明一个特定节点如何找到不在其地址簿中的另一个节点。假设k配置号为 1。源节点是树中具有 16 个叶子的 ID 为 3 的节点。对于第一个桶(由 ID 为 8 到 ID 为 15 的节点组成的最大分支),ID 为 3 的节点保存了 ID 为 10 的节点。但是 ID 为 3 的节点想要找到 ID 为 13 的节点。ID 为 3 的节点向 ID 为 10 的节点发送请求:“你能帮我找到 ID 为 13 的节点吗?”ID 为 10 的节点在其相应的桶中保存了 ID 为 14 的节点(由 ID 为 12、13、14 和 15 的节点组成的分支)。ID 为 10 的节点将 ID 为 14 的节点交给 ID 为 3 的节点。ID 为 3 的节点向 ID 为 14 的节点提出同样的问题:“你能帮我找到 ID 为 13 的节点吗?”ID 为 14 的节点没有,但它在其桶中保存了 ID 为 12 的节点(由 ID 为 12 和 ID 为 13 的节点组成的分支)。ID 为 14 的节点将 ID 为 12 的节点交给 ID 为 3 的节点。ID 为 3 的节点再次向 ID 为 12 的节点提出同样的问题。这次,ID 为 12 的节点可以将目标节点或 ID 为 13 的节点交给 ID 为 3 的节点。大团圆!

以下图表显示了节点:

你注意到 ID 为 3 的节点必须重复请求多少次了吗?四次。如果这个数字听起来很熟悉,那是因为这棵树有 16 个叶子,即 2^(4.)在计算机科学中,到达目的地所需的跳数的最坏情况是 2 log n + cn是树有多少叶子,c是常数。

你刚刚看到的树有满的节点;没有空叶子或空分支。然而,在现实世界中,有空分支和空叶子。想象一下,你有一棵具有 1,024(2¹⁰)个叶子的树,k号为 3。你启动 ID 为 0 的第一个节点。这个节点将是源节点。我们将从 ID 为 0 的节点的角度看树:

然后,启动 ID 为 800 的节点:

树将被分成两个桶。然后,启动 ID 为 900 的节点和 ID 为 754 的节点:

如果我们向桶中添加另一个节点会怎样?让我们启动 ID 为 1011 的节点。ID 为 0 的节点将 ping 最近未使用的节点,即 ID 为 800 的节点,以查看其是否仍然存活。如果是,它将检查其他节点。如果 ID 为 754 的节点不存活,那么此节点将被 ID 为 1011 的节点替换。如果所有节点仍然存活,则 ID 为 1011 的节点将被拒绝进入桶。这样做的原因是为了避免新节点淹没系统。我们假设运行时间更长的节点是值得信赖的,我们更喜欢这些节点而不是新节点。假设我们拒绝 ID 为 1011 的节点。

首先,我们启动 ID 为 490 的节点。然后,我们分割 ID 为 0 的节点所在的分支:

现在,让我们添加 ID 为 230 的节点:

让我们添加 ID 为 60 的节点:

...等等。每次我们在源节点所在的分支中添加一个节点,它都会将桶分成两部分,直到达到最低级别。如果我们在源节点不在的其他分支中添加一个节点,我们会一直添加节点,直到达到k号。

现在,您已经基本了解了 Kademlia 的工作原理。然而,这还不是全部。如果插入一个节点,节点需要告诉旧节点它的存在。该节点还需要从旧节点获取联系人。我提到,当节点插入到源节点所在的分支时,分支会分裂,但即使源节点不驻留在那里,也有分支分裂的情况。这是因为要求节点在至少有k个节点的分支中保留所有有效节点,即使这意味着源节点不驻留的分支也必须分裂。

除了路由算法之外,Kademlia 还有其他重要方面。节点需要每小时重新发布密钥和值(数据),以预期旧节点离开和新节点加入系统。这些节点更接近,因此更适合保存数据。还有一种加速查找算法,这样当一个节点正在寻找另一个节点时,我们可以使用更少的步骤。

您可以参考 Kademlia 论文获取完整的规范。pdos.csail.mit.edu/~petar/papers/maymounkov-kademlia-lncs.pdf

IPFS 使用 S/Kademlia,这是 Kademlia 的扩展版本。它与原始的 Kademlia 算法不同之处在于 S/Kademlia 有一些安全要求。并非所有节点加入 Kademlia 分布式哈希表都是出于崇高的目的。因此,在 S/Kademlia 中,为了生成节点的 ID,需要节点生成一个密码学密钥对,这样很难篡改节点之间的通信。其他要求包括在节点能够生成其 ID 之前使用工作证明(就像比特币和以太坊中一样)。还有一些路由算法的调整,以确保节点在对手中间能够与其他节点通信,比如那些在网络中发送垃圾信息的节点。

总结

在本章中,我们学习了 IPFS。我们首先研究了 IPFS 项目的动机和历史。虽然 IPFS 不是区块链技术的一部分,但它类似于区块链,因为它是区块链技术的补充。然后,我们了解了 IPFS 文件系统中保存的内容的数据结构。这个数据结构是基于 Merkle 树的 Merkle 有向无环图(DAG)。我们创建了简单的 Merkle 树和 Merkle DAG 库,以了解这些数据结构的独特性。Merkle 树提供了一种简单的方法来检查部分数据的完整性,而 Merkle DAG 在我们想要保存一个带有文件的目录并且想要保留文件名时使用。然后,我们了解了 Kademlia 分布式哈希表的点对点网络方面。节点之间的距离是基于 XOR 距离。节点也被保存在对应于位寻址的桶中。最后,我们展示了节点如何通过跳转桶来找到其他节点。

在下一章中,我们将使用 IPFS 软件并以编程方式与其交互。

第十一章:使用 ipfsapi 与 IPFS 进行交互

在本章中,我们将学习如何使用 Python 以编程方式与 IPFS 进行交互。我们可以在这里进行一些交互,例如添加文件,检索文件,托管可变文件,订阅主题,发布主题,并将文件复制到可变文件系统MFS)。首先,我们必须安装 IPFS 软件并启动它。然后,我们将学习如何安装 IPFS Python 库,并了解其大部分 API。

在本章中,我们将涵盖以下主题:

  • 安装 IPFS 软件及其库

  • 内容哈希

  • ipfsapi API

安装 IPFS 软件及其库

在撰写本文时,只有两种 IPFS 实现:go-ipfs(用 Go 语言编写)和js-ipfs(用 JavaScript 编写)。截至目前,还没有用 Python 编写的 IPFS 实现。Go 实现是更受欢迎的,因此我们将使用它。

转到dist.ipfs.io/#go-ipfs,并为您的平台下载软件。对于 Ubuntu Linux,文件名为go-ipfs_v0.4.18_linux-amd64.tar.gz

使用以下命令行提取此内容:

$ tar xvfz go-ipfs_v0.4.18_linux-amd64.tar.gz

然后,使用以下命令安装二进制文件:

$ cd go-ipfs
$ sudo ./install.sh

此步骤是可选的。在这里,我们将IPFS_PATH环境变量导出到我们的 shell:

$ export IPFS_PATH=/path/to/ipfsrepo

这是ipfs存储文件的位置。您可以将此语句存储在~/.bashrc中。默认情况下(没有此环境变量),ipfs将使用~/.ipfs(主目录中的.ipfs目录)作为存储数据的位置。

设置环境变量后,初始化ipfs本地存储库。您只需执行此步骤一次:

$ ipfs init

如果您在云中运行ipfs(例如 Amazon Web Services,Google Cloud Platform,Digital Ocean 或 Azure),您应该使用服务器配置文件标志:

$ ipfs init --profile server

否则,您将收到来自云提供商的烦人警告信,因为 IPFS 守护程序默认情况下(没有服务器配置文件标志),会执行类似于端口扫描的操作。

然后,启动守护程序,如下所示:

$ ipfs daemon

默认情况下,API 服务器正在端口 5001 上监听。我们将通过此端口以编程方式与 IPFS 进行交互。默认情况下,它只在本地侦听。如果您想向外界打开此端口,请小心。IPFS 中没有访问控制列表ACL)。任何可以访问此端口的人都可以向 IPFS 上传数据。

默认情况下,网关服务器正在端口 8080 上监听。我们使用此端口从 IPFS 点对点文件系统下载文件。默认情况下,Swarm 正在端口 4001 上监听。这是其他节点从我们的存储中下载文件的方式。所有这些端口都可以更改。

IPFS 有一个仪表板,可以通过以下链接访问:localhost:5001/webui。以下是仪表板的屏幕截图:

正如您所看到的,大多数 IPFS 节点位于美国,中国和德国。

单击 Peers 选项卡,以查看 IPFS 节点根据其 IP 地址的分布,如下屏幕截图所示:

可以在此选项卡中看到节点,包括它们的 IP 地址。如果您担心节点的隐私,请记住隐私功能的开发仍处于初期阶段。

您可以在设置选项卡中配置 IPFS 设置,如下屏幕截图所示:

现在我们的 IPFS 守护程序已启动,让我们安装我们的ipfs Python 库。

打开一个新的终端,因为我们不想打扰我们的守护程序。然后,运行以下命令:

$ virtualenv -p python3.6 ipfs-venv
$ source ipfs-venv/bin/activate
(ipfs-venv) $ pip install ipfsapi

以前,ipfs Python 库被称为py-ipfs-api,但现在已更名为ipfsapi

内容哈希

在 IPFS 快速入门文档中(docs.ipfs.io/introduction/usage),他们教您的第一件事是下载可爱的猫图片。使用以下代码来执行此操作:

$ ipfs cat /ipfs/QmW2WQi7j6c7UgJTarActp7tDNikE4B2qXtFCfLPdsgaTQ/cat.jpg >cat.jpg
$ eog cat.jpg 

运行上述代码后,将下载猫图片,并且您将得到以下输出:

eog是 Ubuntu 中的图像查看器。

为了遵循传统,让我们创建一个 Python 脚本,以便使用 Python 以编程方式下载前面的图像,并将脚本命名为download_cute_cat_picture.py

import ipfsapi

c = ipfsapi.connect()
cute_cat_picture = 'QmW2WQi7j6c7UgJTarActp7tDNikE4B2qXtFCfLPdsgaTQ/cat.jpg'
c.get(cute_cat_picture)

执行此脚本后,图像将在您的目录中命名为cat.jpg

正如您可能已经注意到的那样,在长哈希之后有一个cat.jpg文件名。从技术上讲,我们在这里做的是在包含一张可爱猫图片的目录中下载文件。如果您愿意,可以尝试一下。要这样做,创建另一个脚本并将其命名为download_a_directory_of_cute_cat_picture.py,然后运行以下代码:

import ipfsapi

c = ipfsapi.connect()
directory = 'QmW2WQi7j6c7UgJTarActp7tDNikE4B2qXtFCfLPdsgaTQ'
c.get(directory)

执行此脚本后,您将在包含此脚本的目录中得到一个名为QmW2WQi7j6c7UgJTarActp7tDNikE4B2qXtFCfLPdsgaTQ的目录。如果您查看此目录的内部,将找到猫图片文件。

让我们逐行查看脚本,以了解ipfsapi库的用法。您可以使用以下代码导入库:

import ipfsapi

以下代码用于获取到 IPFS 守护程序的连接对象:

c = ipfsapi.connect()

connect方法接受一些参数。最重要的两个参数是hostport

c = ipfsapi.connect(host="ipfshost.net", port=5001)

默认情况下,我们连接到本地主机的 5001 端口:

c.get(cute_cat_picture)

然后,我们使用 IPFS HTTP API 的方法从c对象中。get是用于与 IPFS 守护程序交互的方法之一。对于此方法,通常有一个与ipfs客户端软件相对应的参数:

$ ipfs get QmW2WQi7j6c7UgJTarActp7tDNikE4B2qXtFCfLPdsgaTQ/cat.jpg

您可能会注意到,在创建第一个 Python 脚本之前,我们在命令行界面中使用了ipfs cat命令。但是,在脚本中,我们使用了get方法。ipfsapi库中也有一个cat方法。

get方法用于下载文件,而cat方法用于获取文件的内容。

让我们创建一个使用cat方法的脚本,并将其命名为cat_cute_cat.py

import ipfsapi

c = ipfsapi.connect()
result = c.cat('QmW2WQi7j6c7UgJTarActp7tDNikE4B2qXtFCfLPdsgaTQ/cat.jpg')
with open('cat.jpg', 'wb') as f:
    f.write(result)

cat方法返回文件内容的字节对象。它接受两个可选参数,offsetlengthoffset是文件中要开始获取内容的起始位置。length是从offset位置开始获取的内容的长度。如果要构建一个具有暂停和恢复功能的下载管理器(或视频流播放器),这些参数非常重要。您可能并不总是想要下载整个文件。

现在,让我们将一个文件添加到 IPFS。要做到这一点,创建一个简单的文件并将其命名为hello.txt。这是文件的内容:

I am a good unicorn.

确保在字符串之后有一个新行:

$ cat hello.txt
I am a good unicorn.
$

如果命令提示符出现在字符串的下一行,则一切正常。您可以继续进行。

但是,假设命令提示符出现在字符串的右侧,如下面的代码块所示:

$ cat hello.txt
I am a good unicorn.$

这意味着您没有新行,需要在字符串之后添加它。

现在,让我们创建一个脚本将此文件添加到 IPFS,并将其命名为add_file.py

import ipfsapi

c = ipfsapi.connect()
result = c.add('hello.txt')
print(result)

执行此代码将给出以下输出:

(ipfs-venv) $ python add_file.py
{'Name': 'hello.txt', 'Hash': 'QmY7MiYeySnsed1Z3KxqDVYuM8pfiT5gGTqprNaNhUpZgR', 'Size': '29'}

我们可以使用catget方法检索文件的内容,即I am a good unicorn.\n。让我们在名为get_unicorn.py的脚本中使用cat方法,如下面的代码块所示:

import ipfsapi

c = ipfsapi.connect()
result = c.cat('QmY7MiYeySnsed1Z3KxqDVYuM8pfiT5gGTqprNaNhUpZgR')
print(result)

运行此代码将给出以下输出:

(ipfs-venv) $ python get_unicorn.py
b'I am a good unicorn.\n'

正如我们在第十章中提到的星际文件系统-一个勇敢的新文件系统,我们通过哈希获取文件的内容。通过这种方式,我们只检索内容,而不是文件的名称。

但是,如何将b'I am a good unicorn.\n'转换为'QmY7MiYeySnsed1Z3KxqDVYuM8pfiT5gGTqprNaNhUpZgR'?只是通过对内容进行哈希吗?例如,要对文件的内容进行哈希,你可以使用 SHA-256 哈希函数:

import hashlib
the_hash = hashlib.sha256(b'I am a good unicorn.\n').hexdigest()

不要那么快!原来你需要先了解 protobuf、multihash 和 base58 的过程。让我们在接下来的部分讨论这些。

Protobuf

如果你尝试安装 Google 开源软件,比如Tensorflow,你会遇到 protobuf,因为它被Tensorflow使用。Protobuf是一个序列化库。如果你从官方文档中学习 Python,你会知道 Pickle 是一种序列化数据的方式。如果你学习 Web 开发编程,很可能你会使用.json.xml作为数据序列化。

在将b'I am a good unicorn.\n'传递给 IPFS 之前,我们需要将我们的数据包装在一个数据结构中。让我们创建一个脚本来证明我的说法,并将脚本命名为get_unicorn_block.py

import ipfsapi

c = ipfsapi.connect()
result = c.block_get('QmY7MiYeySnsed1Z3KxqDVYuM8pfiT5gGTqprNaNhUpZgR')
print(result)

运行脚本将允许你看到文件内容被其他内容包裹:

(ipfs-venv) $ python get_unicorn_block.py
b'\n\x1b\x08\x02\x12\x15I am a good unicorn.\n\x18\x15'

我们可以看到我们的文件内容仍然完整,b'I am a good unicorn.\n',在神秘的字符串之间。左边和右边的垃圾字符是什么?这是 IPFS 中数据节点的数据结构。

在我们对这个序列化数据进行反序列化之前,让我们快速学习如何使用protobuf

  1. 使用以下命令安装protobuf-compiler
$ sudo apt-get install protobuf-compiler

你的protobuf编译器是protoc

$ protoc --version
libprotoc 2.6.1
  1. 然后,使用以下命令安装 Python 的protobuf库:
(ipfs-venv) $ pip install protobuf
  1. 在使用protobuf对数据进行序列化之前,你需要先创建一个数据结构格式。这个格式必须保存在一个文件中。让我们把格式文件命名为crypto.proto,并使用以下脚本:
syntax = "proto2";

package crypto;

message CryptoCurrency {
 required string name = 1;
 optional int32 total_supply = 2;

 enum CryptoType {
 BITCOIN = 0;
 ERC20 = 1;
 PRIVATE = 2;
 } required CryptoType type = 3 [default = ERC20];
}

当你查看这个数据结构时,它就像一个没有方法的结构或类。在声明你使用的语法之后,你声明package以避免名称冲突。message就像另一种主流编程语言中的类或结构关键字。这个message是许多数据类型的封装。在我们的情况下,它们是stringint32enum

  1. 在 Python 中对数据进行 protobuf 序列化之前,我们需要将这个.proto文件转换成一个 Python 模块文件:
$ protoc crypto.proto --python_out=.

python_out参数用于指示你想要将这个 Python 文件输出到哪个目录。

你应该为你生成的crypto_pb2.py文件。参考 GitLab 链接中的代码文件:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/tree/master/chapter_11

如果你没有阅读文件顶部的注释,让我为你读一下:不要直接编辑这个文件。如果你想要在序列化过程中更改数据结构,你需要修改.proto文件,然后编译它。现在你已经为你生成了这个Python库文件,你可以丢掉.proto文件。但是,保留它作为文档是个好主意。

现在,让我们使用一个 Python 脚本测试序列化和反序列化过程。将脚本命名为serialize_crypto_data.py

import crypto_pb2

cryptocurrency = crypto_pb2.CryptoCurrency()
cryptocurrency.name = 'Bitcoin Cash'
cryptocurrency.total_supply = 21000000
cryptocurrency.type = crypto_pb2.CryptoCurrency.BITCOIN

serialized_data = cryptocurrency.SerializeToString()
print(serialized_data)

cryptocurrency2 = crypto_pb2.CryptoCurrency()
cryptocurrency2.ParseFromString(serialized_data)
print(cryptocurrency2)

如果你执行这个脚本,你将得到以下输出:

(ipfs-venv) $ python serialize_crypto_data.py
b'\n\x0cBitcoin Cash\x10\xc0\xde\x81\n\x18\x00'
name: "Bitcoin Cash"
total_supply: 21000000
type: BITCOIN

序列化输出,b'\n\x0cBitcoin Cash\x10\xc0\xde\x81\n\x18\x00',类似于我们在 IPFS 中的独角兽数据块。如果你解析这个二进制数据,你应该得到原始的 Python 对象。

现在你了解了 protobuf 的基本用法,让我们回到 IPFS 中的块数据:

(ipfs-venv) $ python get_unicorn_block.py
b'\n\x1b\x08\x02\x12\x15I am a good unicorn.\n\x18\x15'

这是 protobuf 中的序列化数据。在我们对其进行反序列化之前,我们需要获取相关的.proto文件。显然,我们需要两个.proto文件,unixfs.protomerkledag.proto

unixfs.proto可以从github.com/ipfs/go-unixfs/blob/master/pb/unixfs.proto下载,而merkeldag.proto可以从github.com/ipfs/go-merkledag/blob/master/pb/merkledag.proto下载。

以下代码块是unixfs.proto文件的内容:

syntax = "proto2";

package unixfs.pb;

message Data {
    enum DataType {
        Raw = 0;
        Directory = 1;
        File = 2;
        Metadata = 3;
        Symlink = 4;
        HAMTShard = 5;
    }

    required DataType Type = 1;
    optional bytes Data = 2;
    optional uint64 filesize = 3;
    repeated uint64 blocksizes = 4;

    optional uint64 hashType = 5;
    optional uint64 fanout = 6;
}

message Metadata {
    optional string MimeType = 1;
}

以下代码块是merkledag.proto文件的内容:

package merkledag.pb;

import "code.google.com/p/gogoprotobuf/gogoproto/gogo.proto";

option (gogoproto.gostring_all) = true;
option (gogoproto.equal_all) = true;
option (gogoproto.verbose_equal_all) = true;
option (gogoproto.goproto_stringer_all) = false;
option (gogoproto.stringer_all) =  true;
option (gogoproto.populate_all) = true;
option (gogoproto.testgen_all) = true;
option (gogoproto.benchgen_all) = true;
option (gogoproto.marshaler_all) = true;
option (gogoproto.sizer_all) = true;
option (gogoproto.unmarshaler_all) = true;

...
...

// An IPFS MerkleDAG Node
message PBNode {

  // refs to other objects
  repeated PBLink Links = 2;

  // opaque user data
  optional bytes Data = 1;
}

为了简化流程,你应该删除merkledag.proto文件中的以下行:

import "code.google.com/p/gogoprotobuf/gogoproto/gogo.proto";

option (gogoproto.gostring_all) = true;
option (gogoproto.equal_all) = true;
option (gogoproto.verbose_equal_all) = true;
option (gogoproto.goproto_stringer_all) = false;
option (gogoproto.stringer_all) =  true;
option (gogoproto.populate_all) = true;
option (gogoproto.testgen_all) = true;
option (gogoproto.benchgen_all) = true;
option (gogoproto.marshaler_all) = true;
option (gogoproto.sizer_all) = true;
option (gogoproto.unmarshaler_all) = true

然后,使用以下命令编译两个.proto文件:

$ protoc unixfs.proto merkledag.proto --python_out=.

完成后,你将得到生成的unixfs_pb2.pymerkledag_pb2.py文件。

让我们创建一个脚本来解码我们的块数据,b'\n\x1b\x08\x02\x12\x15I am a good unicorn.\n\x18\x15',并将脚本命名为unserialize_unicorn.py

import unixfs_pb2
import merkledag_pb2

outer_node = merkledag_pb2.PBNode()
outer_node.ParseFromString(b'\n\x1b\x08\x02\x12\x15I am a good unicorn.\n\x18\x15')
print(outer_node)

unicorn = unixfs_pb2.Data()
unicorn.ParseFromString(outer_node.Data)
print(unicorn)

运行脚本。这将给出以下输出:

(ipfs-venv) $ python unserialize_unicorn.py
Data: "\010\002\022\025I am a good unicorn.\n\030\025"

Type: File
Data: "I am a good unicorn.\n"
filesize: 21

让我们来剖析一下这里发生了什么。我们的原始数据,b'I am a good unicorn.\n',被包裹在unixfs proto 模块的Data中,然后再次被包裹在merkledag proto 模块的PBNode中。这就是为什么我们首先在脚本中用PBNode反序列化序列化的数据。然后,我们用Data反序列化结果。

Multihash

现在,让我们对序列化数据进行哈希。IPFS 使用 multihash 对数据进行哈希。这意味着它不仅输出哈希输出,还输出它使用的哈希函数、来自该哈希函数的哈希输出的长度以及该哈希函数的哈希输出。

让我们看一个 multihash 的使用示例。假设我们要哈希的数据是b'i love you'。我们选择sha256作为哈希函数,如下:

>>> from hashlib import sha256
>>> sha256(b'i love you').hexdigest()
'1c5863cd55b5a4413fd59f054af57ba3c75c0698b3851d70f99b8de2d5c7338f

让我们来检查一下这个哈希输出的长度:

>>> len('1c5863cd55b5a4413fd59f054af57ba3c75c0698b3851d70f99b8de2d5c7338f')
64

由于十六进制格式的数字始终占据两个字符,哈希输出的长度为 32(64/2)。但是,我们想要 32 的十六进制版本,即 0x20 或20

有一个哈希函数表,列出了 multihash 支持的所有哈希函数(sha1、shake、blake、keccak 等)。可以在这里看到:github.com/multiformats/multicodec/blob/master/table.csv

正如你所看到的,sha256被赋予了数字12

现在,我们使用以下命令将它们组合起来:

Hash function + the length of hash output from hash function + hash output from hash function
12 + 20 + 1c5863cd55b5a4413fd59f054af57ba3c75c0698b3851d70f99b8de2d5c7338f

或者,我们可以使用以下命令:

12201c5863cd55b5a4413fd59f054af57ba3c75c0698b3851d70f99b8de2d5c7338f

让我们再做一次,但是使用另一个函数,即sha1

>>> from hashlib import sha1
>>> sha1(b'i love you').hexdigest()
'bb7b1901d99e8b26bb91d2debdb7d7f24b3158cf'
>>> len('bb7b1901d99e8b26bb91d2debdb7d7f24b3158cf')
40

20 的十六进制版本是 0x14,或14

sha1哈希函数被赋予了数字 0x11 或11,来自哈希函数表。因此,输出如下:

11 + 14 + bb7b1901d99e8b26bb91d2debdb7d7f24b3158cf
1114bb7b1901d99e8b26bb91d2debdb7d7f24b3158cf

那么,为什么要使用 multihash 而不是普通的哈希函数,比如sha1sha256keccak256?有时的论点是哈希函数已经被破解,这意味着有人可以在合理的时间内找到两个不同的输入,得到相同的哈希输出。如果发生这种情况,那就非常危险。哈希用于完整性检查。想象一下,我给你发送了一份秘密文件,用来治愈癌症。为了确保它没有被篡改,我们对这份文件进行哈希,然后广播哈希输出。因此,任何想要了解这份文件的人都需要在阅读和执行之前验证文件的哈希。然而,想象一下我的敌人可以创建一个不同的文件。现在,这份文件不再是治愈癌症的文件,而是创建病毒的指南,但它仍然具有相同的哈希输出。如果你对错误的文件进行哈希,你将无意中执行该文件并创建病毒。

如果一个哈希函数被破解了(而且已经发生了,sha1哈希函数已经被破解了),程序员们需要升级他们的系统。然而,他们会遇到困难,因为通常他们对哈希函数做了一些假设。例如,如果他们使用sha1函数,他们会期望从哈希函数得到的输出是 20 个数字的长度。如果他们选择将哈希函数升级到sha256,他们需要替换所有预期旧哈希函数处理时长度为 20 个字符的代码,这是很麻烦的。

使用multihash,我们期望升级过程会变得简化,因为哈希函数的输出的函数和长度都嵌入在multihash函数的输出中。我们不再对哈希输出的长度做假设。

如果你仍然不能理解multihash的动机,让我们用以下代码来实验一下:

(ipfs-venv) $ pip install pymultihash
(ipfs-venv) $ python
>>> import multihash
>>> the_universal_hash = multihash.digest(b'i love you', 'sha1')
>>> the_universal_hash.verify(b'i love you')
True

你有没有注意到,当我们想要检查b'i love you'数据的完整性时,我们不会对哈希输出的长度做假设?然后我们发现了一个坏消息,那就是sha1哈希函数已经被破解了。为了升级我们的系统,我们需要做的只是将'sha1'字符串简单地替换为'sha2_256'字符串:

>>> the_universal_hash = multihash.digest(b'i love you', 'sha2_256')
>>> the_universal_hash.verify(b'i love you')
True

通过使用 multihash,升级 IPFS 软件中的哈希函数变得非常容易。哈希函数只是一个配置问题。

Base58

我们需要学习的最后一件事是base58。Base58 是base64的修改版本。这通常用于将二进制数据编码为 ASCII 字符串。以下代码块用于将b'i love you'编码为 ASCII 字符串:

>>> import base64
>>> base64.b64encode(b'i love you')
b'aSBsb3ZlIHlvdQ=='

base64模块是 Python 标准库的一部分。

通常,你不会用base64来编码另一个 ASCII 字符串。相反,你会编码二进制数据,比如一个图片文件。如果你用文本编辑器打开cat.jpg,你会得到类似于以下截图中显示的无意义文本:

这是使用base64进行编码的一个完美例子。为什么你想要用base64来编码二进制数据呢?一个用例是,如果你想在电子邮件中给你的朋友附上一张可爱的猫图片。电子邮件协议不允许二进制数据。以下代码块展示了如果我们附上图片会得到什么结果:

>>> c = None
>>> with open('cat.jpg', 'rb') as f:
...     c = f.read()
... 
>>> import base64
>>> base64.b64encode(c)
b'/9j/4AAQSkZJRgABAQIAJQAlAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAMgBQADASIAAhEBAxEB/8QAHwAAAAYDAQEBAAAAAAAAAAAAAAQFBgcIAgMJCgEL/8QAbhAAAAQCBgQJCAYHBQQGBQIXAgMEBQEGAAcREhMhFCMxQQgiJDNRYXGB8BUyNEORobHBQkRT0eHxCRYlUlRjczViZHSDF3KEkyZFgpSjszZVZZKkw9MKGHWitMRWhdTj5Eay8xkndoaVlsLS4v/EABwBAAEFAQEBAAAAAAAAAAAAAAACAwQFBgcBCP/EAEwRAAAEBAIIBAUDAgQFAgUCBwECAxEABCExQVEFEhNhcYGR8KGxwdEGFCIj4TIz8SRDBxVCUzREUmJjFnNUco……………...s0fQyVCRRbpSWOyyylf5pKJGnOLTlixICLAcYL6fZ25/hSCW3hIrDGFOwjWKFEvluXlXQ8MosvyoeVg4umYFuP8AV4psrOiyiO8V8M5xBw1BwcG8WAJeNhmW5/du25QypGNKzQKJcAEXxthvCtWiuBRPVVA9twcApcfLHKP/2Q=='

使用base64进行编码的过程(如何将b'i love you'转换为b'aSBsb3ZlIHlvdQ==')超出了本书的范围。如果你很好奇,你可以查看base64规范。

现在你已经熟悉了base64base58会变得非常简单。在base58编码中,打印时会产生歧义的字母,如 0、O、I 和 l,都被移除了。+(加号)和/(斜杠)字符也被移除了。这种base58编码是由中本聪设计的,用于编码大整数。比特币地址本质上就是一个非常大的整数。如果你曾经转移过任何加密货币的金额(不一定是比特币),你很可能会多次检查地址以确保地址是正确的。例如,你想要向你奶奶的比特币地址转移 1 比特币,她的地址是1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2。如果你和大多数人一样,你会多次验证地址的正确性,以确保地址不是错误的。通过移除 0、O、I 和 l 等模棱两可的字符,你会发现更容易确保这个地址是它应该是的。Base58 是软件中用来解决这个问题的良好用户体验设计之一。

因此,base58并不是设计用来编码可爱的猫图片的。你应该使用base64编码来实现这个目的。

让我们安装base58库来进行实验:

>>> import base58
>>> base58.b58encode(b'i love you')
b'6uZUjTpoUEryQ8'

通过使用base58,我们可以创建一个长的十六进制字符串,可以轻松地用我们自己的眼睛进行检查和验证。

结合 protobuf、multihash 和 base58

现在您已经了解了 protobuf、multihash 和 base58,我们终于可以理解b'I am a good unicorn.\n'文件内容如何变成'QmY7MiYeySnsed1Z3KxqDVYuM8pfiT5gGTqprNaNhUpZgR'的谜题了。

b'I am a good unicorn.\n'数据被包装在 IPFS 节点中,并使用 protobuf 序列化为b'\n\x1b\x08\x02\x12\x15I am a good unicorn.\n\x18\x15'。以下是如何在 Python 中执行的。

创建一个名为serialize_unicorn.py的脚本:

import unixfs_pb2
import merkledag_pb2

precious_data = b'I am a good unicorn.\n'

unicorn = unixfs_pb2.Data()
unicorn.Type = unixfs_pb2.Data.File
unicorn.Data = precious_data
unicorn.filesize = len(precious_data)

serialized_unicorn_node = unicorn.SerializeToString()

outer_node = merkledag_pb2.PBNode()
outer_node.Data = serialized_unicorn_node
print(outer_node.SerializeToString())

运行它。您应该得到以下输出:

(ipfs-venv) $ python serialize_unicorn.py
b'\n\x1b\x08\x02\x12\x15I am a good unicorn.\n\x18\x15'

然后,这个 protobuf 序列化的数据被sha256(IPFS 中的 multihash 默认使用sha256哈希函数)哈希为'912d1af8f0013cd12a514859d20e9a196eb2845981408a84cf3543bb359a4536'

以下是如何在 Python 中执行的:

>>> import hashlib
>>> hashlib.sha256(b'\n\x1b\x08\x02\x12\x15I am a good unicorn.\n\x18\x15').hexdigest()
'912d1af8f0013cd12a514859d20e9a196eb2845981408a84cf3543bb359a4536'

IPFS 在 multihash 表中使用的sha256函数数量为 12。表可以在这里看到:github.com/multiformats/multicodec/blob/master/table.csv

哈希输出的长度为32,或者用十六进制表示为 0x20。一个十六进制数字占据两个字符:

>>> len('912d1af8f0013cd12a514859d20e9a196eb2845981408a84cf3543bb359a4536') // 2
32
>>> hex(32)
'0x20'

让我们将它们连接起来:

12 + 20 + 912d1af8f0013cd12a514859d20e9a196eb2845981408a84cf3543bb359a4536
1220912d1af8f0013cd12a514859d20e9a196eb2845981408a84cf3543bb359a4536

如果您使用 base58 编码对此输出进行编码,您应该得到'QmY7MiYeySnsed1Z3KxqDVYuM8pfiT5gGTqprNaNhUpZgR'

以下是如何在 Python 中执行的。b58encode()方法只接受字节对象,而不是十六进制对象,因此您必须首先将十六进制字符串转换为字节对象:

>>> import codecs
>>> codecs.decode('1220912d1af8f0013cd12a514859d20e9a196eb2845981408a84cf3543bb359a4536', 'hex')
b'\x12 \x91-\x1a\xf8\xf0\x01<\xd1*QHY\xd2\x0e\x9a\x19n\xb2\x84Y\x81@\x8a\x84\xcf5C\xbb5\x9aE6'

codecs是 Python 标准库的一部分。

执行代码后,您将得到以下输出:

>>> base58.b58encode(b'\x12 \x91-\x1a\xf8\xf0\x01<\xd1*QHY\xd2\x0e\x9a\x19n\xb2\x84Y\x81@\x8a\x84\xcf5C\xbb5\x9aE6')
b'QmY7MiYeySnsed1Z3KxqDVYuM8pfiT5gGTqprNaNhUpZgR'

大功告成!谜题终于解开了。

ipfsapi API

让我们回到 ipfsapi 的 API。我们已经使用 IPFS API 添加了一个文件,并收到了用于引用文件内容的哈希。但是,如果我们添加一个大文件,它将被分成许多块。这是为了提高效率。

让我们从 Unsplash 下载一个相当大的图像文件。转到unsplash.com/photos/UBtUB4Qc-_4下载一个图像文件。下载的文件名为milada-vigerova-1284157-unsplash.jpg。将其放在与您的 IPFS Python 脚本文件相同的目录中。您可以使用任何图像文件,但请确保其大小至少为 1 MB。但是,如果您使用另一个图像文件,您应该得到一个不同的哈希。

使用以下代码块创建一个名为add_image_file.py的脚本:

import ipfsapi

c = ipfsapi.connect()
result = c.add('dose-juice-1184429-unsplash.jpg')
print(result)

运行它。您应该得到以下输出:

(ipfs-venv) $ python add_image_file.py
{'Name': 'milada-vigerova-1284157-unsplash.jpg', 'Hash': 'QmV5KPoHHqbq2NsALniERnaYjCJPi3UxLnpwdTkV1EbNZM', 'Size': '2604826'}

接下来,创建另一个脚本来列出此块中的所有块,并将脚本命名为list_blocks.py

import ipfsapi
import pprint

c = ipfsapi.connect()
blocks = c.ls('QmV5KPoHHqbq2NsALniERnaYjCJPi3UxLnpwdTkV1EbNZM')
pp = pprint.PrettyPrinter(indent=2)
pp.pprint(blocks)

pprint是 Python 标准库的一部分。

运行脚本。您应该得到以下输出:

(ipfs-venv) $ python list_blocks.py
{ 'Objects': [ { 'Hash': 'QmV5KPoHHqbq2NsALniERnaYjCJPi3UxLnpwdTkV1EbNZM',
 'Links': [ { 'Hash': 'Qmahxa3MABVtHWh7b2cbQb9hEfiuvwKeYceaqrW8pZjemV',
 'Name': '',
 'Size': 262158,
 'Type': 2},
 { 'Hash': ...
... 'QmbSa1vj3c1edyKFdTCaT88pYGTLS9n2mpRuL2B2NLUygv',
 'Name': '',
 'Size': 244915,
 'Type': 2}]}]}

正如我在第十章中解释的那样,星际文件系统-一个崭新的文件系统,一个大文件不会立即被哈希,因为内存问题。相反,它将被分成许多块。每个块的大小为 262,158 字节,最后一个除外。但是,您可以配置块的大小。每个块将被单独哈希。然后,文件内容的根哈希是这些哈希的组合。IPFS 使用默克尔树来计算根哈希。当然,您必须在使用 protobuf 序列化之前将每个块包装在 IPFS 节点内。然后,将包含所有这些块链接的容器节点。

您可以在没有.proto文件的情况下对以下 IPFS 块进行逆向工程:

{'Name': 'milada-vigerova-1284157-unsplash.jpg', 'Hash': 'QmV5KPoHHqbq2NsALniERnaYjCJPi3UxLnpwdTkV1EbNZM', 'Size': '2604826'}

记住这个图像文件的哈希。获取此文件内容的 IPFS 块。您可以使用 Python 脚本或 IPFS 命令行实用程序来执行:

$ ipfs block get QmV5KPoHHqbq2NsALniERnaYjCJPi3UxLnpwdTkV1EbNZM > block.raw

我们将以二进制格式保存块,然后可以使用protoc编译器解码此二进制文件。

$ protoc --decode_raw < block.raw

您应该得到以下结果:

2 {
  1 {
    2: "\267\301\242\262\250qw\216+\237\301\273\'\360%\"\2022\201#R\364h\262$\357\227\2355\244>x"
  }
  2: ""
  3: 262158
}
...
...
1 {
  1: 2
  3: 2604197
  4: 262144
  4: 262144
...
...
  4: 262144
  4: 244901
}

你可能对这种结构很熟悉。当你在没有 proto 文件的情况下解码 protobuf 中的序列化数据时,问题在于你必须猜测某个块内的 1、2、3 和 4 代表什么。如果你有 proto 文件,这一行3: 2604197将变成filesize: 2604197。因此,在解码 protobuf 中的序列化数据之前获取 proto 文件是一个好主意。

我们可以从这些块中重建原始文件。让我们创建脚本并将其命名为construct_image_from_blocks.py

import ipfsapi

c = ipfsapi.connect()

images_bytes = []

blocks = c.ls('QmV5KPoHHqbq2NsALniERnaYjCJPi3UxLnpwdTkV1EbNZM')
for block in blocks['Objects'][0]['Links']:
    bytes = c.cat(block['Hash'])
    images_bytes.append(bytes)

images = b''.join(images_bytes)
with open('image_from_blocks.jpg', 'wb') as f:
    f.write(images)

运行脚本后,如果你打开image_from_blocks.jpg,你将看到原始的图像文件。

我们已经添加了一个文件。现在,让我们尝试添加一个文件目录。

创建一个名为mysite的目录。在这个目录里,创建一个名为img的目录。将cat.jpg图像文件放在这个img目录中。然后,在img目录旁边创建一个名为index.html的文件。

index.html的内容如下代码块所示:

<html>
  <head>
    <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-GJzZqFGwb1QTTN6wy59ffF1BuGJpLSa9DkKMp0DgiMDm4iYMj70gZWKYbI706tWS" crossorigin="anonymous">
  </head>
  <body>
    <img src="img/cat.jpg" class="rounded-circle" />
  </body>
</html>

然后,在img目录旁边创建一个README.md文件。下面的代码块是README.md文件的内容:

This is Readme file.

现在,创建一个 Python 脚本将这个目录添加到 IPFS,并将脚本命名为add_directory.py

import ipfsapi
import pprint

c = ipfsapi.connect()
result = c.add('mysite', True)

pp = pprint.PrettyPrinter(indent=2)
pp.pprint(result)

运行脚本将给你以下输出:

(ipfs-venv) $ python add_directory.py
[ { 'Hash': 'QmWhZDjrm1ncLLRZ421towkyYescK3SUZdWEM5GxApfxJe',
 'Name': 'mysite/README.md',
 'Size': '29'},
 { 'Hash': 'QmUni2ApnGhZ89JEbmPZQ1QU9wcinnCoujjrYAy9TCQQjj',
 'Name': 'mysite/index.html',
 'Size': '333'},
 { 'Hash': 'Qmd286K6pohQcTKYqnS1YhWrCiS4gz7Xi34sdwMe9USZ7u',
 'Name': 'mysite/img/cat.jpg',
 'Size': '443362'},
 { 'Hash': 'QmW2WQi7j6c7UgJTarActp7tDNikE4B2qXtFCfLPdsgaTQ',
 'Name': 'mysite/img',
 'Size': '443417'},
 { 'Hash': 'QmZamPcNnfZjjTkoyrYjYMEA8pp29KmpmkuSvkicSGiZDp',
 'Name': 'mysite',
 'Size': '443934'}]

add方法的第二个参数涉及recursive参数。IPFS 使用 Merkle DAG 数据结构来保存这个文件目录。

我们可以在浏览器中打开我们的网站,使用以下 URL:http://localhost:8080/ipfs/QmZamPcNnfZjjTkoyrYjYMEA8pp29KmpmkuSvkicSGiZDp/。以下截图是网站在浏览器中的显示方式:

你也可以使用以下 URL 从另一个网关(使用另一个节点)访问 IPFS 路径:ipfs.io/ipfs/QmZamPcNnfZjjTkoyrYjYMEA8pp29KmpmkuSvkicSGiZDp/。根据你的互联网连接,这可能需要一些时间,因为 ipfs.io 服务器中的一个节点需要定位你计算机中的内容。

IPNS

能够发布由哈希保护完整性的文件或文件目录是很棒的。然而,偶尔你可能希望能够发布一个具有相同链接的动态文件。我在这里的意思是,哈希链接会在不同的时间生成不同的内容。一个用例是你想发布新闻。根据情况,新闻可能每分钟或每小时都会改变。

你可以通过使用星际命名系统IPNS)来做到这一点。哈希链接是从我们的 IPFS 节点中的加密密钥派生出来的。当我们启动 IPFS 守护程序时,我们成为 IPFS 点对点网络中的众多节点之一。我们的身份是基于一个加密密钥的。

让我们创建两个星座预测。这里的预测应该随着时间的推移而改变。第一个文件名是horoscope1.txt,这个文件的内容在下面的代码块中给出:

You will meet the love of your life today!

第二个文件名是horoscope2.txt,这个文件的内容在下面的代码块中给出:

You need to be careful when going outside!

让我们使用这个 Python 脚本添加这两个文件,命名为add_horoscope_predictions.py

import ipfsapi

c = ipfsapi.connect()
result = c.add('horoscope1.txt')
print(result)
result = c.add('horoscope2.txt')
print(result)

运行这将给你以下输出:

(ipfs-venv) $ python add_horoscope_predictions.py
{'Name': 'horoscope1.txt', 'Hash': 'QmTG4eE6ruUDhSKxqwofJXXqDFAmNzQiGdo4Z7WvVdLZuS', 'Size': '51'}
{'Name': 'horoscope2.txt', 'Hash': 'Qme1FUeEhA1myqQ8C1sCSXo4dDJzZApGD6StE26S72ZqyU', 'Size': '51'}

注意我们在输出中获得的这两个哈希值。

然后,创建一个脚本来列出我们所有的密钥,并将脚本命名为keys_list.py

import ipfsapi

c = ipfsapi.connect()
print(c.key_list())

运行上述脚本将给你以下输出:

(ipfs-venv) $ python keys_list.py
{'Keys': [{'Name': 'self', 'Id': 'QmVPUMd7mFG54zKDNNzPRgENsr5VTbBxWJThfVd6j9V4U8'}]}

现在,让我们发布我们的第一个星座预测。使用以下代码块创建一个名为publish_horoscope1.py的 Python 脚本:

import ipfsapi

c = ipfsapi.connect()
peer_id = c.key_list()['Keys'][0]['Id']
c.name_publish('QmY7MiYeySnsed1Z3KxqDVYuM8pfiT5gGTqprNaNhUpZgR')
result = ipfs.cat('/ipns/' + peer_id)
print(result)

运行这可能需要一些时间。在 IPNS 中发布文件有点慢。如果你足够耐心,你会得到以下输出:

(ipfs-venv) $ python publish_horoscope1.py
b'You will meet the love of your life today!\n'

你可以使用name_publish()方法发布内容。它接受内容的哈希链接(IPFS 路径,而不是文件名)作为第一个参数。

然后,要从 IPFS 访问内容,可以使用catget方法。在这里,我们使用cat方法。cat方法的参数不是哈希链接或 IPFS 路径,而是 IPNS 路径,它只是一个可以从keys_list.py脚本中获取的密钥。您必须在此之前加上'/ipns/'字符串。因此,IPNS 路径是'/ipns/ QmVPUMd7mFG54zKDNNzPRgENsr5VTbBxWJThfVd6j9V4U8'

现在,让我们发布更多数据。使用以下代码块创建一个名为publish_horoscope2.py的脚本:

import ipfsapi

c = ipfsapi.connect()
peer_id = c.key_list()['Keys'][0]['Id']
c.name_publish('Qme1FUeEhA1myqQ8C1sCSXo4dDJzZApGD6StE26S72ZqyU')
result = ipfs.cat('/ipns/' + peer_id)
print(result)

运行此脚本将给出与上一个不同的结果:

(ipfs-venv) $ python publish_horoscope2.py
b'You need to be careful when going outside!\n'

IPNS 路径是'ipns/ QmVPUMd7mFG54zKDNNzPRgENsr5VTbBxWJThfVd6j9V4U8',但我们得到了不同的结果。

这非常有趣,但我们是否仅限于单个 IPNS 路径?不。您可以生成另一个密钥,以便您可以有另一个 IPNS 路径。

使用以下代码块创建一个名为generate_another_key.py的 Python 脚本:

import ipfsapi

c = ipfsapi.connect()
print(c.key_list())
c.key_gen('another_key', 'rsa')
print(c.key_list())

运行上述脚本将给出以下输出:

(ipfs-venv) $ python generate_another_key.py
{'Keys': [{'Name': 'self', 'Id': 'QmVPUMd7mFG54zKDNNzPRgENsr5VTbBxWJThfVd6j9V4U8'}]}
{'Keys': [{'Name': 'self', 'Id': 'QmVPUMd7mFG54zKDNNzPRgENsr5VTbBxWJThfVd6j9V4U8'}, {'Name': 'another_key', 'Id': 'QmcU8u2Koy4fdrSjnSEjrMRYZVPLKP5YXQhLVePfUmjmkv'}]}

您的新 IPNS 路径从'another_key''/ipns/ QmcU8u2Koy4fdrSjnSEjrMRYZVPLKP5YXQhLVePfUmjmkv'

然后,当您想要在 IPNS 路径上发布内容时,只需在name_publish()中使用key参数。使用以下代码块创建一个名为publish_horoscope1_in_another_ipns.py的脚本:

import ipfsapi

c = ipfsapi.connect()
peer_id = c.key_list()['Keys'][1]['Id']
c.name_publish('QmTG4eE6ruUDhSKxqwofJXXqDFAmNzQiGdo4Z7WvVdLZuS', key='another_key')
result = c.cat('/ipns/' + peer_id)
print(result)

运行它。您应该会得到第一个星座预测。正如您可能已经观察到的那样,我们使用了另一个对等 ID。注意peer_id = c.key_list()['Keys'][1]['Id']中的索引 1。之前,我们使用的是索引 0。

在 IPNS 中发布不会永久存储它。默认情况下,它会将 IPFS 文件存储 24 小时。您可以使用name_publish()中的lifetime关键字来更改持续时间。例如,如果您想要在 IPNS 中发布 IPFS 文件5h,您可以这样做:

c.name_publish('QmTG4eE6ruUDhSKxqwofJXXqDFAmNzQiGdo4Z7WvVdLZuS', key='another_key', lifetime='5h')

固定

如果您想要在 IPFS 上删除文件怎么办?假设您意外使用ipfs add命令添加了一张裸照:

$ ipfs add nude_picture.jpg
added QmWgMcTdPY9Rv7SCBusK1gWBRJcBi2MxNkC1yC6uvLYPwK nude_picture.jpg
2.64 MiB / 2.64 MiB [==================================================] 100.00%

如何删除您的裸照?没有ipfs rm QmPCqvJHUs517pdcFNZ7EJMKcjEtbUBUDYxZcwsSijRtBx这样的东西,因为这个命令没有任何意义。它应该做什么?告诉每个持有您裸照的节点删除照片?那将违背 IPFS 的崇高目的。您可以做的是删除 IPFS 本地存储中的照片。在 IPFS 中删除本地文件的术语称为删除固定。在删除裸照文件的固定之后,内容仍然在 IPFS 本地存储中。但是当 IPFS 的垃圾收集器工作以清理对象时,它将删除您裸照文件的内容。希望没有人有机会在他们的节点上固定(下载)这个敏感文件!

让我们创建一个脚本来删除固定并要求垃圾收集器执行其工作。将脚本命名为removing_nude_picture.py

import ipfsapi

c = ipfsapi.connect()
c.pin_rm('QmWgMcTdPY9Rv7SCBusK1gWBRJcBi2MxNkC1yC6uvLYPwK')
c.repo_gc()

运行脚本。然后,如果您尝试获取您的裸照的内容,将会失败:

(ipfs-venv) $ ipfs get QmWgMcTdPY9Rv7SCBusK1gWBRJcBi2MxNkC1yC6uvLYPwK

当然,如果有人已经在另一个节点上固定了您的裸照,那么您仍然会得到您的裸照的内容。

在这个例子中,裸照基本上是一张熊猫的裸照,可以从 Unsplash 网站下载:unsplash.com/photos/IgdVdJCmzf4。如果您在这个例子中使用这张图片,请准备好其他人也会使用它。要测试删除固定是否真的有效,您可以使用一个这个世界上没有人拥有的真正独特的文件。有一种方法可以通过解剖 IPFS 存储路径中的LevelDB文件来检查文件是否已从您的本地存储中删除。但是,这超出了本书的范围。

Pubsub

IPFS 有一个实验性功能,即发布-订阅,或pubsub。基本上,IPFS 中的一个节点可以订阅一个主题。假设这个主题是比特币主题。IPFS 中的一个节点可以向比特币主题发布To the moon!消息。然后,任何订阅'bitcoin'主题的节点都可以收到该消息。

因为 pubsub 是一个实验性功能,您需要使用特定标志运行 IPFS 守护程序。使用以下命令使用--enable-pubsub-experiment标志运行 IPFS 守护程序:

$ ipfs daemon --enable-pubsub-experiment

订阅者和发布者都需要使用特定标志运行守护程序。

让我们创建一个脚本来订阅特定主题,并将脚本命名为subscribe_topic.py

import ipfsapi
from base64 import b64decode

c = ipfsapi.connect()
with c.pubsub_sub('bitcoin') as sub:
    for message in sub:
        string = b64decode(message['data'])
        print(string)
        break

订阅主题的方法是pubsub_sub。第一个参数是我们想要订阅的主题。当我们从订阅中接收数据时,我们还将获得有关发送者的信息。但是,目前我们只关心消息。这条消息是以base64编码的,所以我们必须先解码它。

运行脚本。这将在接收到任何消息之前等待。

让我们创建一个脚本来发布到这个主题,并将脚本命名为publish_topic.py

import ipfsapi

c = ipfsapi.connect()
c.pubsub_pub('bitcoin', 'To the moon!')

最好在另一台计算机上运行此脚本,这样您就可以对去中心化技术的奇迹感到惊叹。不要忘记您必须使用特定标志运行 IPFS 守护程序。但是如果您懒得话,也可以在同一台计算机上运行此脚本。

当订阅脚本正在运行时,运行发布脚本。

然后,在运行订阅脚本的终端中,您应该会得到以下输出:

(ipfs-venv) $ python subscribe_topic.py
b'To the moon!'

那么,pubsub 有什么好处呢?首先,您可以构建一个通知系统。想象一下,您为家人运行一个家庭文件共享系统。当您添加您女儿的照片时,您希望通知所有家庭成员。不仅仅是通知;您还可以构建一个去中心化的聊天系统(类似于 IRC 或 Slack)。当与其他技术结合使用时,例如无内容复制数据类型,甚至可以在 IPFS 之上构建一个去中心化的在线配对编程系统!

请注意,pubsub 仍然是实验性功能。IPFS 的开发人员在计划中有许多有趣的计划。其中最有趣的计划之一是计划在 pubsub 系统的顶部添加基于密码学密钥的身份验证系统。现在,每个人都可以发布和订阅主题。

可变文件系统

IPFS 有一个名为可变文件系统(MFS)的功能。MFS 与您的操作系统文件系统不同。让我们创建一个脚本来探索这个功能,并将脚本命名为exploring_mfs.py

import ipfsapi
import io

c = ipfsapi.connect()

print("By default our MFS is empty.")
print(c.files_ls('/')) # root is / just like Unix filesystem

print("We can create a directory in our MFS.")
c.files_mkdir('/classical_movies')

print("We can create a file in our MFS.")
c.files_write('/classical_movies/titanic',
              io.BytesIO(b"The ship crashed. The end."),
              create=True)

print("We can copy a file in our MFS.")
c.files_cp('/classical_movies/titanic',
           '/classical_movies/copy_of_titanic')

print("We can read the file.")
print(c.files_read('/classical_movies/titanic'))

print("We can remove the file.")
print(c.files_rm('/classical_movies/copy_of_titanic'))

print("Now our MFS is not empty anymore.")
print(c.files_ls('/'))
print(c.files_ls('/classical_movies'))

print("You can get the hash of a path in MFS.")
print(c.files_stat('/classical_movies'))

print("Then you can publish this hash into IPNS.")

运行这将给出以下输出:

(ipfs-venv) $ python exploring_mfs.py
By default our MFS is empty.
{'Entries': None}
We can create a directory in our MFS.
We can create a file in our MFS.
We can copy a file in our MFS.
We can read the file.
b'The ship crashed. The end.'
We can remove the file.
[]
Now our MFS is not empty anymore.
{'Entries': [{'Name': 'classical_movies', 'Type': 0, 'Size': 0, 'Hash': ''}]}
{'Entries': [{'Name': 'titanic', 'Type': 0, 'Size': 0, 'Hash': ''}]}
You can get the hash of a path in MFS.
{'Hash': 'QmNUrujevkYqRtYmpaj2Af1DvgR8rt9a7ApyiyXHnF5wym', 'Size': 0, 'CumulativeSize': 137, 'Blocks': 1, 'Type': 'directory'}
Then you can publish this hash into IPNS

您可能会想知道这个功能的意义是什么。在您的 MFS 中,没有任何您不能在操作系统文件系统中做的事情。在这个特定的例子中,是的,您是对的,这个功能是毫无意义的。但是在复制文件时,MFS 和您的操作系统文件系统之间有一个微妙的区别。让我们创建一个脚本来证明这个断言,并将脚本命名为copy_in_mfs.py

import ipfsapi

c = ipfsapi.connect()
c.files_cp('/ipfs/QmY8zTocoVNDJWUr33nhksBiZ3hxugFPhb6qSzpE761bVN', '/46MB_cute_bear.mp4')

print(c.files_ls('/'))

运行脚本将给出以下输出:

(ipfs-venv) $ python copy_in_mfs.py 
{'Entries': [{'Name': '46MB_cute_bear.mp4', 'Type': 0, 'Size': 0, 'Hash': ''}, {'Name': 'classical_movies', 'Type': 0, 'Size': 0, 'Hash': ''}]}

具有哈希链接QmY8zTocoVNDJWUr33nhksBiZ3hxugFPhb6qSzpE761bVN的文件是一个可爱的熊视频,可以从以下链接下载:videos.pexels.com/videos/bear-in-a-forest-855113。您可以使用以下命令下载:ipfs get QmY8zTocoVNDJWUr33nhksBiZ3hxugFPhb6qSzpE761bVN(假设我的 IPFS 节点在线,您可以从该 URL 下载视频并将视频固定在另一台计算机上以测试脚本,如果没有其他固定此视频的 IPFS 节点在线)。文件大小为 46 MB,但脚本执行非常快。考虑到我们必须下载视频文件,脚本的运行时间太快了。这是因为我们没有将视频下载到我们的存储中。在我们的 MFS 中/46MB_cute_bear.mp4的路径不是真正的传统文件,就像我们的操作系统文件系统中一样。您可以说它就像是指向 IPFS 中真实文件的符号链接,由 IPFS 生态系统中的一些节点固定。

这意味着您可以从 IPFS 路径复制 100 TB 文件到您的 MFS,而不会占用任何存储空间(除了一些元数据)。

如果您像计算机科学家一样思考,IPFS 文件系统就像一个巨大的图形数据库。

其他 API

IPFS HTTP API 还有其他我们没有空间讨论的方法。完整的参考资料可以在docs.ipfs.io/reference/api/http/中找到。有 API 可以引导您的节点(如果您想要基于某些现有节点构建您的节点列表),从节点中找到附近的节点,连接到特定节点,配置 IPFS,关闭 IPFS 守护程序等。

摘要

在本章中,您已经学会了如何使用 Python 通过 HTTP API 与 IPFS 进行交互。首先,您安装了 IPFS 软件并运行了守护进程。您通过将文件添加到 IPFS 并学习如何获取文件内容的哈希值(基于 protobuf、multihash 和 base58)来开始这一过程。然后,您看到如果将大文件添加到 IPFS 中,它将被分成许多块。您还可以将文件目录添加到 IPFS 中。基于这种能力,您可以在 IPFS 上托管静态网站。然后,您了解了如何在 IPNS 上发布 IPFS 文件,从而可以拥有动态内容。之后,您了解了 MFS,可以在其中从 IPFS 复制大文件,而不会在本地存储中产生任何显著的成本。

在下一章中,您将结合 IPFS 和智能合约来构建一个去中心化的应用程序。

进一步阅读

以下是与本章相关的各个网站的参考资料:

第十二章:使用 IPFS 实现去中心化应用

在本章中,我们将结合智能合约和星际文件系统IPFS)来构建一个去中心化的视频分享应用(类似于 YouTube 但是去中心化)。我们将使用一个 Web 应用作为区块链和 IPFS 的前端。正如之前所述,IPFS 并不是一种区块链技术。IPFS 是一种去中心化技术。然而,在区块链论坛、聚会或教程中,你可能会经常听到 IPFS 被提到。其中一个主要原因是 IPFS 克服了区块链的弱点,即其存储非常昂贵。

在本章中,我们将涵盖以下主题:

  • 去中心化视频分享应用的架构

  • 编写视频分享智能合约

  • 构建视频分享 Web 应用

去中心化视频分享应用的架构

这是我们的应用在完成后的样子——首先,你去一个网站,在那里你会看到一个视频列表(就像 YouTube 一样)。在这里,你可以在浏览器中播放视频,上传视频到你的浏览器,这样人们就可以观看你的可爱的猫视频,并喜欢其他人的视频。

从表面上看,这就像一个普通的应用。你可以用你喜欢的 Python Web 框架构建它,比如 Django、Flask 或 Pyramid。然后你可以使用 MySQL 或 PostgreSQL 作为数据库。你可以选择 NGINX 或 Apache 作为 Gunicorn Web 服务器前面的 Web 服务器。对于缓存,你可以使用 Varnish 进行整页缓存,使用 Redis 进行模板缓存。你还将在云上托管 Web 应用和视频,比如亚马逊网络服务AWS)或谷歌云平台GCP),Azure。然后你将使用内容传送网络使其在全球范围内可扩展。对于前端,你可以使用 JavaScript 框架,如 React.js、Angular.js、Vue.js 或 Ember。如果你是一个高级用户,你可以使用机器学习进行视频推荐。

然而,关键点在于,我们想要构建的是一个利用区块链技术的去中心化视频分享应用,而不是一个集中式应用。

让我们讨论一下我们所说的利用区块链技术构建去中心化视频分享应用的含义。

我们无法在以太坊区块链上存储视频文件,因为这非常昂贵;即使在以太坊区块链上存储图片文件也要花费很多钱。有人已经为我们在以下链接上进行了计算:ethereum.stackexchange.com/questions/872/what-is-the-cost-to-store-1kb-10kb-100kb-worth-of-data-into-the-ethereum-block

存储 1 KB 的成本大约为 0.032 ETH。一个体面的图像文件大约为 2 MB。如果你问硬盘制造商,1 MB 就是 1,000 KB,如果你问操作系统,就是 1,024 KB。我们简单地将其四舍五入为 1,000,因为这对我们的计算没有任何影响。因此,在以太坊上存储 2 MB 文件的成本大约是 2,000 乘以 0.032 ETH,等于 64 ETH。ETH 的价格一直在变化。在撰写本文时,1 ETH 的成本大约是 120 美元。这意味着要存储一个 2 MB 的图片文件(Unsplash 网站上的普通大小库存图片文件),你需要花费 7,680 美元。一个一分半钟的 MP4 格式视频文件大约为 46 MB。因此,你需要花费 176,640 美元来在以太坊上存储这个视频文件。

与其支付这个费用,区块链开发者通常会在区块链上存储视频文件的引用,并将视频文件本身存储在正常的存储介质上,比如 AWS。在 Vyper 智能合约中,你可以使用bytes数据类型:

cute_panda_video: bytes[128]

然后,你可以在智能合约中存储你在 AWS S3(aws.amazon.com/s3/)中存储的视频链接:

cute_panda_video = "http://abucket.s3-website-us-west-2.amazonaws.com/cute_panda_video.mp4"

这种方法都很好,但问题在于你依赖于 AWS。如果公司不喜欢你的可爱熊猫视频,他们可以删除它,而存在于智能合约中的 URL 也会变得无效。当然,你可以改变智能合约中cute_panda_video变量的值(除非你禁止这样做)。然而,这种情况会给我们的应用程序带来不便。如果你使用来自集中化公司的服务,你的命运取决于该公司的心情。

我们可以通过使用 IPFS 等去中心化存储来缓解这个问题。我们可以将 IPFS 路径(或 IPFS 哈希)存储为cute_panda_video变量的值,类似于以下示例:

cute_panda_video = "/ipfs/QmWgMcTdPY9Rv7SCBusK1gWBRJcBi2MxNkC1yC6uvLYPwK"

然后,我们可以在 AWS 和其他地方(比如 GCP)上启动我们的 IPFS 守护程序。因此,如果 AWS 审查我们的可爱熊猫视频,我们的可爱熊猫视频的 IPFS 路径仍然有效。我们可以从其他地方(比如 GCP)提供视频。你甚至可以将视频托管在你奶奶家的电脑上。痴迷于可爱熊猫视频的人甚至可以固定该视频,帮助我们提供可爱熊猫视频。

除了以去中心化的方式托管可爱的熊猫视频之外,去中心化视频分享应用程序还有其他价值。这个价值与区块链技术有关。假设我们想要构建一个视频点赞功能。我们可以将点赞值存储在区块链上。这可以防止腐败。想象一下,我们想要为最可爱的熊猫视频举办一个投票比赛,奖金是 10 个比特币。如果我们的比赛应用程序是以集中化的方式完成的(使用表将点赞值存储在 SQL 数据库中,比如 MySQL 或 PostgreSQL),我们作为集中化的管理员可以使用以下代码操纵获胜者:

UPDATE thumbs_up_table SET aggregate_voting_count = 1000000 WHERE video_id = 234;

当然,作弊并不是这么容易的。你需要通过确保聚合计数与个体计数匹配来掩盖你的行踪,这需要做得很微妙。你可以在一个小时内将聚合计数添加到 100 到 1,000 之间的随机数字,而不是一次性添加一百万张选票,这并不是在建议你欺骗用户,我只是在阐明我的观点。

通过区块链,我们可以防止集中化管理员对数据完整性的腐败。点赞值保存在智能合约中,并且你可以让人们审计智能合约的源代码。我们在去中心化视频分享应用程序上的点赞功能通过一个诚实的过程增加了视频的点赞数。

除了数据的完整性,我们还可以构建一个加密经济。我的意思是,我们可以在我们的智能合约中进行经济活动(比如出售、购买、竞标等)。我们可以在同一个智能合约中构建代币。这个代币的硬币可以用来点赞视频,这样点赞视频就不再是免费的。视频的所有者可以像把钱放进口袋一样把它兑现出来。这种动态可以激励人们上传更好的视频。

除此之外,去中心化的应用程序保证了 API 的独立性。应用程序的去中心化性质防止了 API 受到类似 Twitter API 丑闻的干扰或骚扰。很久以前,开发者可以在 Twitter API 的基础上自由开发有趣的应用程序,但后来 Twitter 对开发者如何使用他们的 API 施加了严格的限制。其中一个例子是 Twitter 曾经关闭了 Politwoops 对 API 的访问权限,该应用程序保存了政客删除的推文。不过现在访问权限已经重新激活。通过使我们的应用程序去中心化,我们可以增加 API 的民主性质。

出于教育目的,我们的应用程序有两个主要功能。首先,你可以看到视频列表,播放视频和上传视频。这些都是你在 YouTube 上做的正常事情。其次,你可以点赞视频,但只能用硬币或代币。

在着手构建应用程序之前,让我们设计智能合约的架构和 Web 应用程序的架构。

视频分享智能合约的架构

我们的应用程序始于智能合约。我们的智能合约需要做一些事情,具体如下:

  • 跟踪用户上传的视频

  • 利用代币及其标准操作(ERC20)

  • 提供一种用户可以使用硬币或代币点赞视频的方式

  • 用于点赞视频的硬币将转移到视频所有者

就是这样。我们始终努力使智能合约尽可能简短。代码行数越多,出现错误的机会就越大。而智能合约中的错误是无法修复的。

在编写这个智能合约之前,让我们考虑一下我们想要如何构建智能合约。智能合约的结构包括数据结构。让我们看一个例子,我们想要使用什么数据结构来跟踪用户的视频。

我们肯定想要使用一个映射变量,其中地址数据类型作为键。这里的困难部分是选择我们想要用作这个映射数据类型值的数据类型。正如我们在第三章中学到的,Vyper 中没有无限大小的数组。如果我们使用bytes32数组,我们将受限于作为这个映射值的数组的特定大小。这意味着用户最多可以拥有一定大小的视频。我们可以使用bytes32数组来保存非常大的视频列表,比如 100 万个视频。有人会上传超过 100 万个视频的机会有多大呢?如果你每天上传一个视频,十年内你只会上传 3650 个视频。然而,bytes32数组的问题是,它不能接受超过 32 字节大小的数据。IPFS 路径,比如QmWgMcTdPY9Rv7SCBusK1gWBRJcBi2MxNkC1yC6uvLYPwK,长度为 44 个字符。因此,你必须至少使用bytes[44]数据类型,但我们将其四舍五入为bytes[50]

相反,我们希望有另一个映射数据类型变量(让我们称之为映射 z)作为前一段描述的这个映射数据类型变量的值。映射 z 的键是整数,值是包含bytes[50]数据类型变量以保存 IPFS 路径和bytes[20]数据类型变量以保存视频标题的结构。有一个整数跟踪器来初始化映射 z 中键的值。这个整数跟踪器的初始值为 0。每次我们向映射 z 添加一个视频(IPFS 路径和视频标题),我们就将这个整数跟踪器加一。因此,下次我们添加另一个视频时,映射 z 的键不再是 0,而是 1。这个整数跟踪器对每个账户都是唯一的。我们可以创建另一个映射将账户映射到这个整数跟踪器。

在处理视频之后,我们关注点赞。我们如何存储用户 A 点赞视频 Z 的事实?我们需要确保用户不能多次点赞同一视频。最简单的方法是创建一个映射,其中bytes[100]数据类型作为键,boolean数据类型作为值。bytes[100]数据类型变量是使用视频点赞者的地址、视频上传者的地址和视频索引的组合。boolean数据类型变量用于指示用户是否已经点赞了视频。

此外,我们需要一个整数数据类型来保持视频点赞数量的总计。总点赞数是一个映射,其中bytes[100]数据类型作为键,integer数据类型作为值。bytes[100]数据类型变量是视频上传者的地址和视频索引的组合。

这种方法的缺点是很难跟踪哪些用户喜欢了智能合约中的特定视频。我们可以创建另一个映射来跟踪哪些用户喜欢了某个视频。然而,这会使我们的智能合约变得更加复杂。之前,我们不惜一切代价创建了一个专门用于跟踪用户上传的所有视频的映射。这是必要的,因为我们想要获取用户视频的列表。这就是我们所说的核心功能。然而,跟踪哪些用户喜欢了一个视频并不是我所说的核心功能。

只要我们能够让视频点赞的过程变得诚实,我们就不需要跟踪哪些用户喜欢了一个视频。如果我们真的渴望跟踪这些用户,我们可以在智能合约中使用事件。每当用户喜欢一个视频,它就会触发一个事件。然后,在客户端使用web3.py库,我们可以过滤这些事件以获取所有喜欢特定视频的用户。这将是一个昂贵的过程,应该单独完成主要应用程序。我们可以使用 Celery 进行后台作业,此时结果可以存储在数据库中,如 SQlite、PostgreSQL 或 MySQL。构建去中心化应用并不意味着完全否定集中化的方法。

有关代币的主题已在第八章中进行了彻底讨论,在以太坊中创建代币

视频共享 Web 应用程序的架构

我们将开发一个 Python Web 应用程序,用作我们智能合约的前端。这意味着我们需要一个适当的服务器来成为 Python Web 应用程序的主机。为此,我们至少需要一个 Gunicorn Web 服务器。换句话说,我们需要在集中服务器上托管我们的 Python Web 应用程序,例如在 AWS、GCP 或 Azure 中。这对于观看视频来说是可以的,但当用户想要上传视频时就会出现问题,因为这需要访问私钥。用户可能会担心我们在集中服务器上的 Python Web 应用程序会窃取他们的私钥。

因此,解决方案是将我们的 Python Web 应用程序的源代码发布在 GitHub 或 GitLab 上,然后告诉用户下载、安装并在他们的计算机上运行它。他们可以审计我们的 Python Web 应用程序的源代码,以确保没有恶意代码试图窃取他们的私钥。然而,如果他们需要每次审计源代码,那么我们就在 Git 存储库上添加另一个提交。

或者更好的是,我们可以将我们的 Python Web 应用程序的源代码存储在 IPFS 上。他们可以从 IPFS 下载这个源代码,并确保我们应用程序的源代码不会被篡改。他们只需要在使用之前审计一次源代码。

然而,虽然我们可以在 IPFS 上托管静态网站,但我们无法对 Python、PHP、Ruby 或 Perl Web 应用程序等动态网页执行相同的操作。这些动态网站需要一个适当的 Web 服务器。因此,任何下载我们的 Python Web 应用程序源代码的人都需要在执行我们的应用程序之前安装正确的软件。他们需要安装 Python 解释器、Web 服务器(Gunicorn、Apache 或 NGINX)以及所有必要的库。

然而,只有桌面用户才能这样做。移动用户无法执行我们的应用程序,因为 Android 或 iOS 平台上没有适当的 Python 解释器或 Web 服务器。

这就是 JavaScript 的亮点所在。您可以创建一个静态网站,使其具有动态性,以便在网页中实现交互。您还可以使用 React.js、Angular.js、Ember.js 或 Vue.js 创建一个复杂的 JavaScript Web 应用程序,并将其部署在 IPFS 上。桌面用户和移动用户都可以执行 JavaScript Web 应用程序。因为这是一本关于 Python 的书,我们仍然会考虑创建一个 Python Web 应用程序。但是,您应该记住 JavaScript 相对于 Python 的优势。

无论 JavaScript 有多好,它仍然不能解决移动用户的困境。移动平台上的计算能力仍然不如桌面平台上的计算能力强大。你仍然不能在移动平台上运行完整的以太坊节点,就像你不能在移动平台上运行 IPFS 软件一样。

让我们设计我们的 Web 应用程序。这有一些实用工具:

  • 播放视频

  • 上传视频

  • 点赞视频

  • 从许多用户中列出最近的视频

  • 列出一个特定用户的所有视频

列出一个特定用户的所有视频相对容易,因为在智能合约中,我们有一个无限大小的数组(基本上是一个以整数为键和另一个整数跟踪器的映射),我们可以根据用户获取视频。页面的控制器接受一个用户(或者基本上是智能合约中的地址)作为参数。

播放视频接受视频上传者的地址和视频索引作为参数。如果视频还没有存在于我们的存储中,我们会从 IPFS 上下载它。然后我们将视频提供给用户。

上传视频需要与以太坊节点进行交互。上传视频的方法或功能接受一个要使用的账户地址的参数,一个加密私钥的密码参数,一个视频文件的参数,以及一个视频标题的参数。我们首先将视频文件存储在 IPFS 上。然后如果成功,我们可以在区块链上存储关于这个视频的信息。

点赞视频也需要与以太坊节点进行交互。点赞视频的方法或功能接受一个视频点赞者的地址参数,一个加密私钥的密码参数,一个视频上传者的地址参数,以及一个视频索引的参数。在确保用户之前没有点赞视频的情况下,我们将信息存储在区块链上。

从许多用户中列出最近的视频有点棘手。所涉及的工作量相当大。在智能合约中,我们没有一个变量来跟踪所有参与用户。我们也没有一个变量来跟踪不同用户的所有视频。然而,我们可以通过在区块链上存储视频信息的方法创建一个事件。这样做之后,我们可以从这个事件中找到所有最近的视频。

现在是时候构建去中心化的视频分享应用程序了。

编写视频分享智能合约

话不多说,让我们设置我们的智能合约开发平台:

  1. 首先,我们按照以下方式设置我们的虚拟环境:
$ virtualenv -p python3.6 videos-venv
$ source videos-venv/bin/activate
(videos-venv) $
  1. 然后我们安装 Web3、Populus 和 Vyper:
(videos-venv) $ pip install eth-abi==1.2.2
(videos-venv) $ pip install eth-typing==1.1.0
(videos-venv) $ pip install py-evm==0.2.0a33
(videos-venv) $ pip install web3==4.7.2
(videos-venv) $ pip install -e git+https://github.com/ethereum/populus#egg=populus
(videos-venv) $ pip install vyper 

Vyper 的最新版本是 0.1.0b6,这破坏了 Populus。开发者需要一些时间来修复这个问题。如果在你阅读本书时 bug 还没有被修复,你可以自己修补 Populus。

  1. 使用以下命令检查这个库是否已经修复了 bug:
(videos-venv) $ cd videos-venv/src/populus
(videos-venv) $ grep -R "compile(" populus/compilation/backends/vyper.py
 bytecode = '0x' + compiler.compile(code).hex()
 bytecode_runtime = '0x' + compiler.compile(code, bytecode_runtime=True).hex()

在我们的情况下,bug 还没有被修复。

  1. 所以,让我们修补 Populus 以修复 bug。确保你仍然在同一个目录中(videos-venv/src/populus):
(videos-venv) $ wget https://patch-diff.githubusercontent.com/raw/ethereum/populus/pull/484.patch
(videos-venv) $ git apply 484.patch
(videos-venv) $ cd ../../../ 
  1. 在修补 Populus 之后,我们将创建我们的智能合约项目目录:
(videos-venv) $ mkdir videos-sharing-smart-contract
  1. 然后,我们将目录初始化为 Populus 项目目录:
(videos-venv) $ cd videos-sharing-smart-contract
(videos-venv) $ mkdir contracts tests 
  1. 接下来,我们将在 Populus 项目目录中下载 Populus 配置文件:
(videos-venv) $ wget https://raw.githubusercontent.com/ethereum/populus/master/populus/assets/defaults.v9.config.json -O project.json
  1. 我们现在将打开 Populus 的project.json配置文件,并覆盖compilation键的值,如下面的代码块所示:
  "compilation": {
    "backend": {
      "class": "populus.compilation.backends.VyperBackend"
    },
    "contract_source_dirs": [
      "./contracts"
    ],
    "import_remappings": []
  },
  1. 然后我们在videos-sharing-smart-contract/contracts/VideosSharing.vy中编写我们的智能合约代码,如下面的代码块所示(请参考以下 GitLab 链接的代码文件获取完整的代码:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_12/videos_sharing_smart_contract/contracts/VideosSharing.vy):
struct Video:
    path: bytes[50]
    title: bytes[20]

Transfer: event({_from: indexed(address), _to: indexed(address), _value: uint256})
Approval: event({_owner: indexed(address), _spender: indexed(address), _value: uint256})
UploadVideo: event({_user: indexed(address), _index: uint256})
LikeVideo: event({_video_liker: indexed(address), _video_uploader: indexed(address), _index: uint256})

...
...

@public
@constant
def video_aggregate_likes(_user_video: address, _index: uint256) -> uint256:
    _user_video_str: bytes32 = convert(_user_video, bytes32)
    _index_str: bytes32 = convert(_index, bytes32)
    _key: bytes[100] = concat(_user_video_str, _index_str)

    return self.aggregate_likes[_key]

现在,让我们逐步讨论我们的智能合约:

struct Video:
    path: bytes[50]
    title: bytes[20]

这是我们想要在区块链上保留的视频信息的结构体。Video结构体的path存储了 IPFS 路径,长度为 44。如果我们使用另一个哈希函数,IPFS 路径将有不同的长度。请记住,IPFS 在对对象进行哈希时使用多哈希。如果在 IPFS 配置中使用更昂贵的哈希函数,比如 SHA512,那么需要将bytes[]数组数据类型的大小加倍。例如,bytes[100]应该足够了。Video结构体的title存储了视频标题。在这里,我使用了bytes[20],因为我想要标题简短。如果你想要存储更长的标题,可以使用更长的字节,比如bytes[100]。然而,请记住,在区块链上存储的字节数越多,需要花费的 gas(费用)就越多。当然,你可以在这个结构体中添加更多信息,比如视频描述或视频标签,只要你知道后果,即执行存储视频信息的方法需要更多的 gas。

我们现在转移到事件列表:

Transfer: event({_from: indexed(address), _to: indexed(address), _value: uint256})
Approval: event({_owner: indexed(address), _spender: indexed(address), _value: uint256})
UploadVideo: event({_user: indexed(address), _index: uint256})
LikeVideo: event({_video_liker: indexed(address), _video_uploader: indexed(address), _index: uint256})

TransferApproval是 ERC20 标准事件的一部分。你可以在第八章中了解更多关于 ERC20 的信息,在以太坊中创建代币UploadVideo事件在我们的智能合约中上传视频信息时触发。我们保存视频上传者的地址和视频的索引。LikeVideo事件在我们的智能合约中喜欢视频时触发。

我们保存视频喜欢者的地址,视频上传者的地址和视频的索引:

user_videos_index: map(address, uint256)

这是我们无限数组的整数跟踪器。所以如果user_videos_index[用户 A 的地址] = 5,这意味着用户 A 已经上传了四个视频。

以下是 ERC20 标准的一部分:

name: public(bytes[20])
symbol: public(bytes[3])
totalSupply: public(uint256)
decimals: public(uint256)
balances: map(address, uint256)
allowed: map(address, map(address, uint256))

有关 ERC20 的更多信息,请参阅第八章,在以太坊中创建代币

我们继续下一行:

all_videos: map(address, map(uint256, Video))

这是保留所有用户所有视频的核心变量。address数据类型键用于保存用户的地址。map(uint256, Video)数据类型值是我们的无限数组。map(uint256, Video)中的uint256键从 0 开始,然后由user_videos_index变量跟踪。Video结构体是我们的视频信息。

接下来的两行代码用于喜欢:

likes_videos: map(bytes[100], bool)
aggregate_likes: map(bytes[100], uint256)

likes_videos变量是用来检查某个用户是否喜欢特定视频的变量。aggregate_likes变量是用来显示这个特定视频已经获得了多少赞。

我们现在已经定义了变量,将继续下面代码块中的代码:

@public
def __init__():
    _initialSupply: uint256 = 500
    _decimals: uint256 = 3
    self.totalSupply = _initialSupply * 10 ** _decimals
    self.balances[msg.sender] = self.totalSupply
    self.name = 'Video Sharing Coin'
    self.symbol = 'VID'
    self.decimals = _decimals
    log.Transfer(ZERO_ADDRESS, msg.sender, self.totalSupply)

...
...

@public
@constant
def allowance(_owner: address, _spender: address) -> uint256:
    return self.allowed[_owner][_spender]

这是标准的 ERC20 代码,你可以在第八章中了解更多,在以太坊中创建代币。然而,我对代码进行了小幅调整,如下面的代码块所示:

@private
def _transfer(_source: address, _to: address, _amount: uint256) -> bool:
    assert self.balances[_source] >= _amount
    self.balances[_source] -= _amount
    self.balances[_to] += _amount
    log.Transfer(_source, _to, _amount)

    return True

@public
def transfer(_to: address, _amount: uint256) -> bool:
    return self._transfer(msg.sender, _to, _amount)

在这个智能合约中,我将transfer方法的内部代码提取到了专用的私有方法中。这样做的原因是,转移代币的功能将在喜欢视频的方法中使用。记住,当我们喜欢一个视频时,我们必须向视频上传者支付代币。我们不能在另一个公共函数中调用公共函数。其余的代码是一样的(除了代币的名称):

@public
def upload_video(_video_path: bytes[50], _video_title: bytes[20]) -> bool:
    _index: uint256 = self.user_videos_index[msg.sender]

    self.all_videos[msg.sender][_index] = Video({ path: _video_path, title: _video_title })
    self.user_videos_index[msg.sender] += 1

    log.UploadVideo(msg.sender, _index)aggregate_likes

    return True

这是用于在区块链上存储视频信息的方法。我们在将视频上传到 IPFS 后调用这个方法。_video_path是 IPFS 路径,_video_title是视频标题。我们从视频上传者(msg.sender)那里获取最新的索引。然后我们根据视频上传者的地址和最新的索引将Video结构体的值设置为all_videos

然后我们增加整数跟踪器(user_videos_index)。不要忘记记录这个事件。

@public
@constant
def latest_videos_index(_user: address) -> uint256:
    return self.user_videos_index[_user]

@public
@constant
def videos_path(_user: address, _index: uint256) -> bytes[50]:
    return self.all_videos[_user][_index].path

@public
@constant
def videos_title(_user: address, _index: uint256) -> bytes[20]:
    return self.all_videos[_user][_index].title

在前面的代码块中的方法是用于客户端使用 web3 获取最新视频索引、视频 IPFS 路径和视频标题的便利方法。没有这些方法,你仍然可以获取视频的信息,但是使用 web3 访问嵌套映射数据类型变量中的结构变量并不直接。

以下代码显示了用于点赞视频的方法。它接受视频上传者的地址和视频索引。在这里,你创建了两个键——一个用于likes_videos,另一个用于aggregate_likeslikes_videos的键是视频点赞者的地址、视频上传者的地址和视频索引的组合。aggregate_likes的键是视频上传者的地址和视频索引的组合。创建键之后,我们确保视频点赞者将来不能再次点赞同一个视频,并且视频点赞者之前没有点赞过这个特定视频。点赞视频只是将likes_videos变量与我们创建的键设置为True。然后我们将aggregate_likes的值增加 1。最后,我们将代币中的一枚硬币从视频点赞者转移到视频上传者。不要忘记记录这个事件:

@public
def like_video(_user: address, _index: uint256) -> bool:
    _msg_sender_str: bytes32 = convert(msg.sender, bytes32)
    _user_str: bytes32 = convert(_user, bytes32)
    _index_str: bytes32 = convert(_index, bytes32)
    _key: bytes[100] = concat(_msg_sender_str, _user_str, _index_str)
    _likes_key: bytes[100] = concat(_user_str, _index_str)
 a particular
    assert _index < self.user_videos_index[_user]
    assert self.likes_videos[_key] == False

    self.likes_videos[_key] = True
    self.aggregate_likes[_likes_key] += 1
    self._transfer(msg.sender, _user, 1)

    log.LikeVideo(msg.sender, _user, _index)

    return True

以下代码行是用于检查特定用户是否已经点赞视频以及这个特定视频已经获得多少赞的便利方法:

@public
@constant
def video_has_been_liked(_user_like: address, _user_video: address, _index: uint256) -> bool:
    _user_like_str: bytes32 = convert(_user_like, bytes32)
    _user_video_str: bytes32 = convert(_user_video, bytes32)
    _index_str: bytes32 = convert(_index, bytes32)
    _key: bytes[100] = concat(_user_like_str, _user_video_str, _index_str)

    return self.likes_videos[_key]

@public
@constant
def video_aggregate_likes(_user_video: address, _index: uint256) -> uint256:
    _user_video_str: bytes32 = convert(_user_video, bytes32)
    _index_str: bytes32 = convert(_index, bytes32)
    _key: bytes[100] = concat(_user_video_str, _index_str)

    return self.aggregate_likes[_key]

让我们在videos_sharing_smart_contract/tests/test_video_sharing.py中编写一个测试。请参考以下 GitLab 链接中的代码文件获取完整的代码:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_12/videos_sharing_smart_contract/tests/test_videos_sharing.py

import pytest
import eth_tester

def upload_video(video_sharing, chain, account, video_path, video_title):
    txn_hash = video_sharing.functions.upload_video(video_path, video_title).transact({'from': account})
    chain.wait.for_receipt(txn_hash)

def transfer_coins(video_sharing, chain, source, destination, amount):
    txn_hash = video_sharing.functions.transfer(destination, amount).transact({'from': source})
    chain.wait.for_receipt(txn_hash)

...
...

   assert events[1]['args']['_video_liker'] == video_liker2
    assert events[1]['args']['_video_uploader'] == video_uploader
    assert events[1]['args']['_index'] == 0

    with pytest.raises(eth_tester.exceptions.TransactionFailed):
        like_video(video_sharing, chain, video_liker, video_uploader, 0)

让我们逐步详细讨论测试脚本。在下面的代码块中,导入必要的库之后,我们创建了三个便利函数——一个用于上传视频的函数,一个用于转移代币的函数,以及一个用于点赞视频的函数:

import pytest
import eth_tester

def upload_video(video_sharing, chain, account, video_path, video_title):
    txn_hash = video_sharing.functions.upload_video(video_path, video_title).transact({'from': account})
    chain.wait.for_receipt(txn_hash)

def transfer_coins(video_sharing, chain, source, destination, amount):
    txn_hash = video_sharing.functions.transfer(destination, amount).transact({'from': source})
    chain.wait.for_receipt(txn_hash)

def like_video(video_sharing, chain, video_liker, video_uploader, index):
    txn_hash = video_sharing.functions.like_video(video_uploader, index).transact({'from': video_liker})
    chain.wait.for_receipt(txn_hash)

如下面的代码块所示,在上传视频之前,我们确保最新视频的索引为 0。然后,在上传一个视频之后,我们应该检查最新视频的索引,这个索引应该增加 1。当然,我们也要检查视频路径和视频标题。然后我们再次上传一个视频并检查最新视频的索引,这时应该是 2。我们还要检查视频路径和视频标题。最后,我们检查事件,并确保它们已经被正确创建:

def test_upload_video(web3, chain):
    video_sharing, _ = chain.provider.get_or_deploy_contract('VideosSharing')

    t = eth_tester.EthereumTester()
    video_uploader = t.get_accounts()[1]

    index = video_sharing.functions.latest_videos_index(video_uploader).call()
    assert index == 0

...
...

    assert events[0]['args']['_user'] == video_uploader
    assert events[0]['args']['_index'] == 0

    assert events[1]['args']['_user'] == video_uploader
    assert events[1]['args']['_index'] == 1

让我们看一下测试脚本的下一部分:

def test_like_video(web3, chain):
    video_sharing, _ = chain.provider.get_or_deploy_contract('VideosSharing')

    t = eth_tester.EthereumTester()
    manager = t.get_accounts()[0]
    video_uploader = t.get_accounts()[1]
    video_liker = t.get_accounts()[2]
    video_liker2 = t.get_accounts()[3]

    transfer_coins(video_sharing, chain, manager, video_liker, 100)
    transfer_coins(video_sharing, chain, manager, video_liker2, 100)
    transfer_coins(video_sharing, chain, manager, video_uploader, 50)
    upload_video(video_sharing, chain, video_uploader, b'video-ipfs-path', b"video title")

...
...

    with pytest.raises(eth_tester.exceptions.TransactionFailed):
        like_video(video_sharing, chain, video_liker, video_uploader, 0)

首先,我们从管理员账户(启动智能合约的账户)向不同的账户转移一些代币,然后我们上传一个视频。在点赞视频之前,我们应该确保账户的代币余额是正确的,测试账户还没有点赞这个视频,并且累计点赞数仍然是 0。

完成这些之后,我们从特定账户点赞一个视频。视频点赞者的代币余额应该减少 1,视频上传者的代币余额应该增加 1。这意味着智能合约已记录下这个账户点赞了视频,并且这个视频的累计点赞数应该增加 1。

然后,我们从另一个账户点赞一个视频。视频点赞者的代币余额应该减少 1,视频上传者的代币余额应该再次增加 1。智能合约已记录下另一个账户点赞了这个视频,此时这个视频的累计点赞数应该再次增加 1,变为 2。

然后,我们确保视频点赞事件被触发。

最后,我们确保视频点赞者不能多次点赞同一视频。

我们不会讨论智能合约的 ERC20 部分的测试。请参考第八章,在以太坊中创建代币,了解如何测试 ERC20 代币智能合约。

要执行测试,请运行以下语句:

(videos-venv) $ py.test tests/test_videos_sharing.py

启动私有以太坊区块链

让我们使用 geth 启动我们的私有以太坊区块链。我们在这里不使用 Ganache,因为稳定版本的 Ganache 尚不支持事件(然而,Ganache 的 beta 版本(v 2.0.0 beta 2)已经支持事件):

  1. 我们将使用以下代码块来启动区块:
(videos-venv) $ cd videos_sharing_smart_contract
(videos-venv) $ populus chain new localblock
(videos-venv) $ ./chains/localblock/init_chain.sh
  1. 现在编辑chains/localblock/run_chain.sh。找到--ipcpath,然后更改值(--ipcpath后面的单词)为/tmp/geth.ipc

  2. 然后编辑project.json文件。chains对象指向四个键:testertempropstenmainnet。在chains对象中添加另一个键localblock

    "localblock": {
      "chain": {
        "class": "populus.chain.ExternalChain"
      },
      "web3": {
        "provider": {
          "class": "web3.providers.ipc.IPCProvider",
        "settings": {
          "ipc_path":"/tmp/geth.ipc"
        }
       }
      },
      "contracts": {
        "backends": {
          "JSONFile": {"$ref": "contracts.backends.JSONFile"},
          "ProjectContracts": {
            "$ref": "contracts.backends.ProjectContracts"
          }
        }
      }
    }
  1. 使用以下命令运行区块链:
(videos-venv) $ ./chains/localblock/run_chain.sh
  1. 使用以下命令编译我们的智能合约:
(videos-venv) $ populus compile
  1. 然后,使用以下命令将我们的智能合约部署到我们的私有区块链:
(videos-venv) $ populus deploy --chain localblock VideosSharing

将我们的智能合约部署的地址写入address.txt。该文件必须与videos_sharing_smart_contract目录相邻。

创建引导脚本

此脚本用于加载数据,以便更轻松地开发我们的应用程序。我们可以从videos.pexels.com/下载免费视频。在videos_sharing_smart_contract目录旁边创建一个stock_videos目录,并将一些 MP4 文件下载到该stock_videos目录中。在我的情况下,我下载了超过 20 个视频。

下载一些数据后,我们将创建一个名为bootstrap_videos.py的脚本。有关完整代码,请参考以下 GitLab 链接的代码文件:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_12/bootstrap_videos.py

import os, json
import ipfsapi
from web3 import Web3, IPCProvider
from populus.utils.wait import wait_for_transaction_receipt

w3 = Web3(IPCProvider('/tmp/geth.ipc'))

common_password = 'bitcoin123'
accounts = []
with open('accounts.txt', 'w') as f:
...
...
    nonce = w3.eth.getTransactionCount(Web3.toChecksumAddress(account))
    txn = VideosSharing.functions.upload_video(ipfs_path, title).buildTransaction({
                'from': account,
                'gas': 200000,
                'gasPrice': w3.toWei('30', 'gwei'),
                'nonce': nonce
              })
    txn_hash = w3.personal.sendTransaction(txn, common_password)
    wait_for_transaction_receipt(w3, txn_hash)

让我们逐步详细讨论脚本。在导入必要的库之后,在以下代码块中,我们创建了一个名为w3的对象,它是连接到我们私有区块链的连接对象:

import os, json
import ipfsapi
from web3 import Web3, IPCProvider
from populus.utils.wait import wait_for_transaction_receipt

w3 = Web3(IPCProvider('/tmp/geth.ipc'))

在以下代码行中,我们使用w3.personal.newAccount()方法创建新账户。然后我们将新账户的地址放入accounts.txt文件和accounts变量中。所有账户都使用'bitcoin123'作为密码:

common_password = 'bitcoin123'
accounts = []
with open('accounts.txt', 'w') as f:
    for i in range(4):
        account = w3.personal.newAccount(common_password)
        accounts.append(account)
        f.write(account + "\n")

记住:在我们的私有区块链上部署智能合约后,我们将智能合约的地址保存在address.txt文件中。现在是时候将文件的内容加载到address变量中了:

with open('address.txt', 'r') as f:
    address = f.read().rstrip("\n")

with open('videos_sharing_smart_contract/build/contracts.json') as f:
    contract = json.load(f)
    abi = contract['VideosSharing']['abi']

然后,我们加载我们的智能合约的abi或接口,可以从 Populus 项目目录的build目录中的contracts.json中获取。我们使用json.load()方法将 JSON 加载到contract变量中。abi来自json对象的'VideosSharing'键中的'abi'键。

然后,我们使用w3.eth.contract()方法初始化智能合约对象的地址和接口。然后我们使用ipfsapi.connect()方法获取 IPFS 连接对象:

VideosSharing = w3.eth.contract(address=address, abi=abi)

c = ipfsapi.connect()

接下来,我们想要向我们的新账户转移以太币。默认情况下,第一个账户(w3.eth.accounts[0])获得所有来自挖矿的奖励,因此它有足够的以太币可以分享。默认密码是'this-is-not-a-secure-password'

coinbase = w3.eth.accounts[0]
coinbase_password = 'this-is-not-a-secure-password'
# Transfering Ethers
for destination in accounts:
    nonce = w3.eth.getTransactionCount(Web3.toChecksumAddress(coinbase))
    txn = {
            'from': coinbase,
            'to': Web3.toChecksumAddress(destination),
            'value': w3.toWei('100', 'ether'),
            'gas': 70000,
            'gasPrice': w3.toWei('1', 'gwei'),
            'nonce': nonce
          }
    txn_hash = w3.personal.sendTransaction(txn, coinbase_password)
    wait_for_transaction_receipt(w3, txn_hash)

通过w3.personal.sendTransaction()方法发送以太币,该方法接受包含发送者('from')、目的地('to')、以太币数量('value')、gas、燃气价格('gasPrice')、nonce作为第一个参数,密码作为第二个参数的字典。然后我们使用wait_for_transaction_receipt()方法等待交易确认。

在转移以太币之后,我们将我们的代币的一些 ERC20 硬币转移到新账户。这是必要的,因为要喜欢一个视频,我们需要我们的 ERC20 代币的硬币:

# Transfering Coins
for destination in accounts:
    nonce = w3.eth.getTransactionCount(coinbase)
    txn = VideosSharing.functions.transfer(destination, 100).buildTransaction({
                'from': coinbase,
                'gas': 70000,
                'gasPrice': w3.toWei('1', 'gwei'),
                'nonce': nonce
              })
    txn_hash = w3.personal.sendTransaction(txn, coinbase_password)
    wait_for_transaction_receipt(w3, txn_hash)

我们为转移代币方法(VideosSharing.functions.transfer)构建了一个交易对象txn,该方法接受目标帐户和硬币数量的buildTransaction方法。这个方法接受发送者('from')、燃气、燃气价格('gasPrice')和 nonce 的字典。我们使用w3.personal.sendTransaction()方法创建一个交易,然后使用wait_for_transaction_receipt()方法等待交易被确认。

我们使用os.listdir()方法列出stock_videos目录中的所有文件。您已经将一些 MP4 文件下载到此目录中。在这样做之后,我们遍历这些文件:

# Uploading Videos
directory = 'stock_videos'
movies = os.listdir(directory)
length_of_movies = len(movies)
for index, movie in enumerate(movies):
    account = accounts[index//7]
    ipfs_add = c.add(directory + '/' + movie)
    ipfs_path = ipfs_add['Hash'].encode('utf-8')
    title = movie.rstrip('.mp4')[:20].encode('utf-8')

    nonce = w3.eth.getTransactionCount(Web3.toChecksumAddress(account))
    txn = VideosSharing.functions.upload_video(ipfs_path, title).buildTransaction({
                'from': account,
                'gas': 200000,
                'gasPrice': w3.toWei('30', 'gwei'),
                'nonce': nonce
              })
    txn_hash = w3.personal.sendTransaction(txn, common_password)
    wait_for_transaction_receipt(w3, txn_hash)

我们希望每个帐户上传七个视频(account = accounts [index//7])。因此,前七个视频将由第一个帐户上传,而第二批七个视频将由第二个帐户上传。然后我们将 MP4 文件添加到 IPFS(ipfs_add = c.add(directory + '/' + movie))。我们获取 IPFS 路径并将其转换为字节对象(ipfs_path = ipfs_add['Hash'].encode('utf-8')),将 MP4 文件名削减为 20 个字符并将其转换为字节对象,因为智能合约中的标题具有bytes[20]数据类型。

然后我们调用我们智能合约的upload_video方法(VideosSharing.functions.upload_video)。在将其作为参数发送到w3.personal.sendTransaction()方法之前,我们必须构建交易对象。我们等待交易像往常一样被确认,使用wait_for_transaction_receipt()方法。

但是,您必须小心upload_video方法,因为它会保存视频路径,该路径具有bytes[50]数据类型,并且视频标题,该标题具有bytes[20]数据类型在区块链上。它还会增加视频的索引并记录事件。所需的燃气和燃气价格比转移硬币或代币方法要多得多。要转移代币硬币,您可以使用 1 gwei 和 70,000 gas 的燃气价格。但是,对于我们的upload_video方法,这将失败。对于此方法,我使用 30 gwei 和 200,000 gas 的燃气价格。请记住,在区块链中存储是昂贵的。即使一些字符串也可能会提高操作所需的燃气和燃气价格。

  1. 确保您已经启动了您的私有区块链,然后启动 IPFS daemon
$ ipfs daemon

如果您不知道如何安装和启动 IPFS,请参考第十一章,使用 ipfsapi 与 IPFS 交互

  1. 现在,我们需要在我们的虚拟环境中安装 IPFS Python 库:
(videos-venv) $ pip install ipfsapi
  1. 然后,我们使用以下命令运行我们的引导脚本:
(videos-venv) $ python bootstrap_videos.py

这将需要一些时间。您可以通过访问智能合约并检查视频是否已上传来测试您的引导脚本是否成功。

  1. 创建一个名为check_bootstrap.py的脚本:
import json
from web3 import Web3, IPCProvider

w3 = Web3(IPCProvider('/tmp/geth.ipc'))

with open('accounts.txt', 'r') as f:
    account = f.readline().rstrip("\n")

with open('address.txt', 'r') as f:
    address = f.read().rstrip("\n")

with open('videos_sharing_smart_contract/build/contracts.json') as f:
    contract = json.load(f)
    abi = contract['VideosSharing']['abi']

VideosSharing = w3.eth.contract(address=address, abi=abi)

print(VideosSharing.functions.latest_videos_index(account).call())
  1. 运行脚本。如果输出为0,则您的引导脚本失败。如果您得到除0之外的一些输出,则您的视频信息已成功上传到区块链中。

构建视频分享网络应用程序

现在是时候构建我们智能合约的前端了。在第七章和第九章中,我们使用 Qt for Python 或Pyside2库创建了一个桌面应用程序。这次我们将使用 Django 库构建一个 Web 应用程序:

  1. 话不多说,让我们安装 Django:
(videos-venv) $ pip install Django
  1. 我们还需要 OpenCV Python 库来获取我们视频的缩略图:
(videos-venv) $ pip install opencv-python
  1. 现在让我们创建我们的 Django 项目目录。这将创建一个带有其设置文件的骨架 Django 项目:
(videos-venv) $ django-admin startproject decentralized_videos
  1. 在这个新目录中,创建一个static media目录:
(videos-venv) $ cd decentralized_videos
(videos-venv) $ mkdir static media
  1. 在同一个目录中,创建一个名为videos的 Django 应用程序:
(videos-venv) $ python manage.py startapp videos
  1. 然后更新我们的 Django 项目设置文件。该文件位于decentralized_videos/settings.py。将我们的新应用程序videos添加到INSTALLED_APPS变量中。确保'videos''django.contrib.staticfiles'字符串之间有逗号。我们需要将每个 Django 应用程序添加到这个变量中,以便 Django 项目能够识别它。一个 Django 项目可以由许多 Django 应用程序组成:
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'videos'
]
  1. 然后,在同一个文件中,添加以下代码行:
STATIC_URL = '/static/'

STATICFILES_DIRS = [
    os.path.join(BASE_DIR, "static"),
]

MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')

STATIC_URL变量定义了我们如何访问静态 URL。有了这个值,我们可以使用这个 URL 访问静态文件:http://localhost:8000/static/our_static_fileSTATICFILES_DIRS变量指的是我们在文件系统中保存静态文件的位置。我们简单地将视频存储在 Django 项目目录内的static目录中。MEDIA_URLSTATIC_URL的作用是一样的,但是用于媒体文件。媒体文件是用户上传到 Django 项目中的文件,而静态文件是我们作为开发者放入 Django 项目中的文件。

视图

现在让我们创建videos应用程序的视图文件。视图是一个类似 API 端点的控制器。该文件位于decentralized_videos/videos/views.py。请参考以下 GitLab 链接中的代码文件获取完整的代码:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_12/decentralized_videos/videos/views.py

from django.shortcuts import render, redirect
from videos.models import videos_sharing

def index(request):
    videos = videos_sharing.recent_videos()
    context = {'videos': videos}
    return render(request, 'videos/index.html', context)
...
...
def like(request):
    video_user = request.POST['video_user']
    index = int(request.POST['index'])
    password = request.POST['password']
    video_liker = request.POST['video_liker']
    videos_sharing.like_video(video_liker, password, video_user, index)
    return redirect('video', video_user=video_user, index=index)

让我们逐步讨论代码。首先,我们使用以下代码行导入所有所需的库:

from django.shortcuts import render, redirect
from videos.models import videos_sharing

renderredirect方法是 Django 库中的便利函数,用于渲染模板(如 HTML 文件)并将其从一个视图重定向到另一个视图。videos_sharing是我们将在models文件中很快创建的自定义实例。

接下来,我们将创建一个方法,这个方法将成为我们主页的视图:

def index(request):
    videos = videos_sharing.recent_videos()
    context = {'videos': videos}
    return render(request, 'videos/index.html', context)

我们从我们的模型实例中检索最近的视频。我们将构建这个类及其方法。我们使用包含videos对象的上下文渲染'videos/index.html'模板,稍后我们将创建这个模板。request参数是 POST 参数和 GET 参数的表示,还有其他内容。

然后,我们有以下代码行用于列出特定视频上传者的所有视频的页面:

def channel(request, video_user):
    videos = videos_sharing.get_videos(video_user)
    context = {'videos': videos, 'video_user': video_user}
    return render(request, 'videos/channel.html', context)

这个方法接受一个video_user参数,代表视频上传者的地址。我们从videos_sharing.get_videos方法中获取视频,该方法接受视频上传者的地址。然后我们使用包含视频和视频上传者地址的上下文渲染'videos/channel.html'模板文件。

在接下来的方法中,我们有一个用于播放视频的页面的视图:

def video(request, video_user, index):
    video = videos_sharing.get_video(video_user, index)
    context = {'video': video}
    return render(request, 'videos/video.html', context)

这个方法接受video_user参数,代表视频上传者的地址,以及index参数,代表视频的索引。我们从videos_sharing.get_video方法中获取特定视频,该方法接受video_userindex参数。接下来,我们使用包含这个视频的上下文渲染'videos/video.html'

然后,我们有一个视图,当我们上传视频文件、标题、视频上传者的地址和密码时调用:

def upload(request):
    context = {}
    if request.POST:
        video_user = request.POST['video_user']
        title = request.POST['title']
        video_file = request.FILES['video_file']
        password = request.POST['password']
        videos_sharing.upload_video(video_user, password, video_file, title)
        context['upload_success'] = True
    return render(request, 'videos/upload.html', context)

要检索 POST 参数,我们可以使用request.POST属性。然而,要访问我们正在上传的文件,我们使用request.FILES属性。这个视图用于上传文件的页面和处理文件本身。我们使用videos_sharing.upload_video方法将视频信息存储到区块链中。在这个方法的结尾,我们使用context渲染'videos/upload.html',如果我们成功上传了视频,就会包含一个成功的通知。

出于教育目的,我简化了上传代码,没有进行验证。此外,这个网络应用只被一个人使用。然而,如果你打算构建一个为许多陌生人提供服务的网络应用,你需要验证上传的文件。你还应该使用 Django 表单来处理 POST 参数,而不是手动处理。

接下来,在以下方法中,我们有喜欢视频的视图:

def like(request):
    video_user = request.POST['video_user']
    index = int(request.POST['index'])
    password = request.POST['password']
    video_liker = request.POST['video_liker']
    videos_sharing.like_video(video_liker, password, video_user, index)
    return redirect('video', video_user=video_user, index=index)

当我们想要喜欢一个视频时,我们会检索所有必要的信息,比如视频点赞者的地址、视频上传者的地址、视频的索引和密码,这样我们就可以获取特定的视频。然后我们使用videos_sharing.like_video方法来完成这项工作。点赞视频后,我们重定向到video视图。

模型

让我们在decentralized_videos/videos/models.py中创建我们的模型文件。大部分逻辑和繁重的操作都发生在这里。调用智能合约的方法和将文件存储到 IPFS 也发生在这里。请参考以下 GitLab 链接中的代码文件获取完整的代码:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_12/decentralized_videos/videos/models.py

import os.path, json
import ipfsapi
import cv2
from web3 import Web3, IPCProvider
from populus.utils.wait import wait_for_transaction_receipt
from decentralized_videos.settings import STATICFILES_DIRS, STATIC_URL, BASE_DIR, MEDIA_ROOT

class VideosSharing:
...
...
        txn_hash = self.w3.personal.sendTransaction(txn, password)
        wait_for_transaction_receipt(self.w3, txn_hash)

videos_sharing = VideosSharing()

让我们逐步讨论我们 Django 项目的核心功能。首先,我们从 Python 标准库、IPFS Python 库、OpenCV Python 库、web3 库、Populus 库中导入方便的方法,以及从 Django 设置文件中导入一些变量:

import os.path, json
import ipfsapi
import cv2
from web3 import Web3, IPCProvider
from populus.utils.wait import wait_for_transaction_receipt
from decentralized_videos.settings import STATICFILES_DIRS, STATIC_URL, BASE_DIR, MEDIA_ROOT

然后,我们从VideosSharing模型的初始化代码开始:

class VideosSharing:

    def __init__(self):
        self.w3 = Web3(IPCProvider('/tmp/geth.ipc'))
        with open('../address.txt', 'r') as f:
            address = f.read().rstrip("\n")

        with open('../videos_sharing_smart_contract/build/contracts.json') as f:
            contract = json.load(f)
            abi = contract['VideosSharing']['abi']

        self.SmartContract = self.w3.eth.contract(address=address, abi=abi)

        self.ipfs_con = ipfsapi.connect()

我们通过创建一个 web3 连接对象w3来初始化这个实例,创建一个智能合约对象,提供智能合约的地址和接口SmartContract,最后创建一个 IPFS 连接对象ipfs_con

然后,我们有在index视图中使用的方法:

    def recent_videos(self, amount=20):
        events = self.SmartContract.events.UploadVideo.createFilter(fromBlock=0).get_all_entries()
        videos = []
        for event in events:
            video = {}
            video['user'] = event['args']['_user']
            video['index'] = event['args']['_index']
            video['path'] = self.get_video_path(video['user'], video['index'])
            video['title'] = self.get_video_title(video['user'], video['index'])
            video['thumbnail'] = self.get_video_thumbnail(video['path'])
            videos.append(video)
        videos.reverse()
        return videos[:amount]

最近的视频可以从事件中检索。如果你还记得我们在智能合约中上传视频时,会记得我们在这里记录了一个事件。我们的事件是UploadVideo。因为这个 Django 项目只是一个玩具应用,我们从起始块获取所有事件。在现实世界中,你可能希望限制它(也许是最后 100 个块)。此外,你可能希望在后台作业(比如 cron)中将事件存储到数据库中,以便轻松检索。这个事件对象包含了视频上传者和视频的索引。根据这些信息,我们可以获取视频路径、视频标题和视频缩略图。我们在videos对象中累积视频,然后将其反转(因为我们想获取最近的视频),并将这个对象返回给方法的调用者。

然后,我们有一个从特定视频上传者那里获取视频的方法:

    def get_videos(self, user, amount=20):
        latest_index = self.SmartContract.functions.latest_videos_index(user).call()
        i = 0
        videos = []
        while i < amount and i < latest_index:
            video = {}
            index = latest_index - i - 1
            video['user'] = user
            video['index'] = index
            video['path'] = self.get_video_path(user, index)
            video['title'] = self.get_video_title(user, index)
            video['thumbnail'] = self.get_video_thumbnail(video['path'])
            videos.append(video)
            i += 1
        return videos

这在channel视图中使用。首先,我们获取这个视频上传者的最新视频索引。根据这些信息,我们可以找出视频上传者上传了多少个视频。然后,我们从最高索引到最低索引逐个检索视频,直到视频数量达到我们需要的数量。

这些是根据视频上传者的地址获取视频路径和视频标题的方法:


    def get_video_path(self, user, index):
        return self.SmartContract.functions.videos_path(user, index).call().decode('utf-8')

    def get_video_title(self, user, index):
        return self.SmartContract.functions.videos_title(user, index).call().decode('utf-8')

视频索引定义如下:

    def process_thumbnail(self, ipfs_path):
        thumbnail_file = STATICFILES_DIRS[0] + '/' + ipfs_path + '.png'
        if not os.path.isfile(thumbnail_file):
            video_path = STATICFILES_DIRS[0] + '/' + ipfs_path + '.mp4'
            cap = cv2.VideoCapture(video_path)
            cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
            _, frame = cap.read()
            cv2.imwrite(thumbnail_file, frame)

我们使用了我们智能合约的videos_pathvideos_title方法。不要忘记解码结果,因为bytes对象形成了我们的智能合约。

以下代码块是获取视频缩略图的方法:

    def get_video_thumbnail(self, ipfs_path):
        thumbnail_file = STATICFILES_DIRS[0] + '/' + ipfs_path + '.png'
        url_file = STATIC_URL + '/' + ipfs_path + '.png'
        if os.path.isfile(thumbnail_file):
            return url_file
        else:
            return "https://bulma.io/images/placeholders/640x480.png"

当我们在播放视频页面查看视频时,我们会检查是否有一个特定的带有.png文件扩展名的文件名。我们在static files目录中找到这个文件名模式。如果找不到文件,我们就从互联网上使用一个占位图片文件。

以下代码块是检索特定视频的方法:

    def get_video(self, user, index):
        video = {}
        ipfs_path = self.get_video_path(user, index)
        video_title = self.get_video_title(user, index)
        video_file = STATICFILES_DIRS[0] + '/' + ipfs_path + '.mp4'
        thumbnail_file = STATICFILES_DIRS[0] + '/' + ipfs_path + '.png'
        video['title'] = video_title
        video['user'] = user
        video['index'] = index
        video['aggregate_likes'] = self.SmartContract.functions.video_aggregate_likes(user, index).call()

        if os.path.isfile(video_file):
            video['url'] = STATIC_URL + '/' + ipfs_path + '.mp4'
        else:
            self.ipfs_con.get(ipfs_path)
            os.rename(BASE_DIR + '/' + ipfs_path, STATICFILES_DIRS[0] + '/' + ipfs_path + '.mp4')
            video['url'] = STATIC_URL + '/' + ipfs_path + '.mp4'

        if not os.path.isfile(thumbnail_file):
            self.process_thumbnail(ipfs_path)

        return video

这在video视图中使用。我们需要视频路径、视频标题、视频文件、视频缩略图和这个视频的总点赞数(我们可以使用智能合约的video_aggregate_likes方法获取)。我们检查静态文件目录中是否存在这个 MP4 文件。如果不存在,我们使用ipfs_con.get方法从 IPFS 中检索它。然后我们将文件移动到静态文件目录,并在尚未存在时创建缩略图图像。

在现实世界中,您可能希望使用 Celery 和 RabbitMQ 等后台作业来从 IPFS 中检索文件。对于这个玩具应用程序,我们只是以阻塞的方式下载视频。但是,安装和配置 Celery 和 RabbitMQ 并不是一件轻松的事情,我认为这会分散我们在这里的教育目的。

以下方法演示了当我们上传视频时会发生什么:

    def upload_video(self, video_user, password, video_file, title):
        video_path = MEDIA_ROOT + '/video.mp4'
        with open(video_path, 'wb+') as destination:
            for chunk in video_file.chunks():
                destination.write(chunk)
        ipfs_add = self.ipfs_con.add(video_path)
        ipfs_path = ipfs_add['Hash'].encode('utf-8')
        title = title[:20].encode('utf-8')
        nonce = self.w3.eth.getTransactionCount(Web3.toChecksumAddress(video_user))
        txn = self.SmartContract.functions.upload_video(ipfs_path, title).buildTransaction({
                    'from': video_user,
                    'gas': 200000,
                    'gasPrice': self.w3.toWei('30', 'gwei'),
                    'nonce': nonce
                  })
        txn_hash = self.w3.personal.sendTransaction(txn, password)
        wait_for_transaction_receipt(self.w3, txn_hash)

我们将文件保存在内存中的媒体目录中,然后使用ipfs_con.add方法将文件添加到 IPFS。我们获取 IPFS 路径并准备视频的标题。然后,我们从智能合约中调用upload_video方法。记得为此设置足够的 gas 和 gas 价格。这是一个非常昂贵的智能合约方法。我们等待交易确认。在现实世界中,您可能希望使用后台作业来执行所有这些步骤。

以下代码块显示了如何从视频生成缩略图:

    def process_thumbnail(self, ipfs_path):
        thumbnail_file = STATICFILES_DIRS[0] + '/' + ipfs_path + '.png'
        if not os.path.isfile(thumbnail_file):
            video_path = STATICFILES_DIRS[0] + '/' + ipfs_path + '.mp4'
            cap = cv2.VideoCapture(video_path)
            cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
            _, frame = cap.read()
            cv2.imwrite(thumbnail_file, frame)

确保不存在这样的文件后,我们获取视频对象。我们读取对象的第一帧并将其保存到图像文件中。这个视频功能来自 OpenCV Python 库。

然后,我们有点赞视频的方法:

    def like_video(self, video_liker, password, video_user, index):
        if self.SmartContract.functions.video_has_been_liked(video_liker, video_user, index).call():
            return
        nonce = self.w3.eth.getTransactionCount(Web3.toChecksumAddress(video_liker))
        txn = self.SmartContract.functions.like_video(video_user, index).buildTransaction({
                    'from': video_liker,
                    'gas': 200000,
                    'gasPrice': self.w3.toWei('30', 'gwei'),
                    'nonce': nonce
                  })
        txn_hash = self.w3.personal.sendTransaction(txn, password)
        wait_for_transaction_receipt(self.w3, txn_hash)

我们通过调用智能合约的video_has_been_liked方法来确保这个视频还没有被点赞。然后我们使用智能合约的like_video方法来点赞视频。

最后,我们创建了VideosSharing类的一个实例,以便可以导入这个实例:

videos_sharing = VideosSharing()

我更喜欢导入一个类的实例,而不是导入一个类。因此,我们在这里初始化一个类的实例。

模板

是时候写我们的模板了。首先,让我们使用以下命令行创建一个模板目录:

(videos-venv) $ cd decentralized_videos
(videos-venv) $ mkdir -p videos/templates/videos

然后,我们首先使用以下 HTML 代码创建我们的基本布局。这是所有我们模板将使用的布局。文件位于videos/templates/videos/base.html。您可以参考以下 GitLab 链接中的代码文件:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_12/decentralized_videos/videos/templates/videos/base.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Decentralized Videos Sharing Application</title>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma/0.7.2/css/bulma.min.css">
...
...
    </section>
    {% block content %}
    {% endblock %}
  </body>
</html>

在页眉中,我们导入 Bulma CSS 框架和 Font Awesome JavaScript 文件。在这个基本布局中,我们设置了我们的导航,其中包含首页链接和视频上传链接。在{% block content %}{% endblock %}之间的部分将由我们模板的内容填充。

虽然本书专注于教授 Python,尽量避免其他技术,如 CSS 和 JavaScript,但一些 CSS 是必要的,以使我们的 Web 应用程序看起来体面。您可以访问bulma.io了解这个 CSS 框架。

然后,让我们在videos/templates/videos/index.html中创建我们的第一个模板文件。使用以下代码块创建模板文件:

{% extends "videos/base.html" %}

{% block content %}
<section class="section">
  <div class="container">
    {% for video in videos %}
      {% cycle '<div class="columns">' '' '' '' %}
        <div class="column">
          <div class="card">
            <div class="card-image">
              <figure class="image is-4by3">
                <img src="{{ video.thumbnail }}" />
              </figure>
            </div>
            <p class="card-footer-item">
              <span><a href="{% url 'video' video_user=video.user index=video.index %}">{{ video.title }}</a></span>
            </p>
          </div>
        </div>
      {% cycle '' '' '' '</div>' %}
    {% endfor %}
  </div>
</section>
{% endblock %}

首先,我们确保这个模板扩展了我们的基本布局。然后我们在模板中显示我们的视频。我们使用card类 div 来显示视频。cycle方法用于生成columns类 div 来包含四个column类 div。第二个cycle方法用于关闭这个 div。在这个card的页脚,我们创建了一个链接到播放此视频的页面。url方法接受 URL 名称(我们将很快讨论)及其参数。

然后,我们将创建一个模板文件,在videos/templates/videos/video.html中播放视频。您可以参考以下 GitLab 链接中的代码文件获取完整的代码:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_12/decentralized_videos/videos/templates/videos/video.html

{% extends "videos/base.html" %}

{% block content %}
<section class="section">
  <div class="container">
    <nav class="breadcrumb" aria-label="breadcrumbs">
      <ul>
        <li><a href="/">Home</a></li>
        <li><a href="/channel/{{ video.user }}">Channel</a></li>
        <li class="is-active"><a href="#" aria-current="page">{{ video.title }}</a></li>
      </ul>
    </nav>

...
...

  </div>
</section>
{% endblock %}

在扩展基本布局之后,我们创建一个breadcrumb,以便用户可以转到视频上传者的频道页面。然后我们使用video HTML 标签显示视频。在视频下方,我们显示聚合点赞数。在页面底部,我们创建一个表单来点赞视频。这个表单接受视频点赞者的地址和用户输入的密码。有隐藏的输入来发送视频上传者的地址和视频索引。请注意,这个表单内有一个名为{% csrf_token %}的 CSRF 令牌。这是为了避免 CSRF 漏洞。

然后让我们创建一个模板文件,用于在videos/templates/videos/channel.html中列出特定视频上传者的所有视频。您可以参考以下 GitLab 链接中的代码文件获取完整的代码:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_12/decentralized_videos/videos/templates/videos/channel.html

{% extends "videos/base.html" %}

{% block content %}
<section class="section">
  <div class="container">
    <nav class="breadcrumb" aria-label="breadcrumbs">
      <ul>
        <li><a href="/">Home</a></li>
        <li class="is-active"><a href="#">{{ video_user }}</a>
...
...
            </p>
          </div>
        </div>
      {% cycle '' '' '' '</div>' %}
    {% endfor %}
  </div>
</section>
{% endblock %}

这个模板文件与索引模板相同,只是在视频列表的顶部有一个breadcrumb

让我们创建最后一个模板文件,用于在videos/templates/videos/upload.html中上传视频。您可以参考以下 GitLab 链接中的代码文件获取完整的代码:gitlab.com/arjunaskykok/hands-on-blockchain-for-python-developers/blob/master/chapter_12/decentralized_videos/videos/templates/videos/upload.html

{% extends "videos/base.html" %}

{% block content %}
<section class="section">
  <div class="container">
    <nav class="breadcrumb" aria-label="breadcrumbs">
      <ul>
        <li><a href="/">Home</a></li>
        <li class="is-active"><a href="#" aria-current="page">Uploading Video</a></li>
      </ul>
    </nav>
    <div class="content">
...
...
</section>
<script type="text/javascript">
var file = document.getElementById("video_file");
file.onchange = function() {
  if(file.files.length > 0) {
    document.getElementById('video_filename').innerHTML = file.files[0].name;
  }
};
</script>
{% endblock %}

在这个模板中,扩展基本布局后,我们创建了breadcrumb。然后,我们创建一个上传视频的表单。

这里有四个输入——视频标题、视频文件、视频上传者的地址和密码。模板底部的 JavaScript 代码用于在选择文件后将文件名设置为文件上传字段的标签。因为我们正在上传文件,所以需要将表单的enctype属性设置为"multipart/form-data"

Urls

urls文件是 Django 中的路由机制。打开decentralized_videos/videos/urls.py,删除内容,并用以下脚本替换:

from django.urls import path

from . import views

urlpatterns = [
    path('', views.index, name='index'),
    path('channel/<str:video_user>', views.channel, name='channel'),
    path('video/<str:video_user>/<int:index>', views.video, name='video'),
    path('upload-video', views.upload, name='upload'),
    path('like-video', views.like, name='like'),
]

还记得我们之前创建的视图文件吗?在这里,我们将视图映射到路由。我们通过http://localhost:8000/video/0x0000000000000000000000000000000000000000/1访问视频播放页面。参数将映射到video_user变量和index变量。path方法的第一个参数是我们在浏览器中调用它的方式。第二个方法是我们使用的视图,第三个参数是在模板中使用的路由名称。

然后我们需要将这些urls注册到项目urls文件中。编辑decentralized_videos/decentralized_videos/urls.py并添加我们的videos.urls路径,以便我们的 Web 应用知道如何将我们的 URL 路由到我们的videos视图:

from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path('', include('videos.urls')),
    path('admin/', admin.site.urls)
]

演示

现在是享受劳动成果的时候了。在运行服务器之前,请确保您在decentralized_videos目录中。不要忘记先运行私有区块链和 IPFS 守护程序:

(videos-venv) $ cd decentralized_videos
(videos-venv) $ python manage.py runserver

然后打开http://localhost:8000。在这里,您将看到最近的视频,如下图所示。如果您对为什么有一些视频的缩略图感到困惑,您需要转到播放视频的页面生成缩略图:

让我们点击其中一个视频:

您可以在这里播放视频。

要在 Web 上播放 HTML5 视频,我们可以使用 Chrome 浏览器。您也可以使用 Firefox 浏览器,但是您需要采取额外的步骤来启用浏览器上的视频播放,方法是按照以下网站上的步骤进行操作:stackoverflow.com/questions/40760864/how-to-play-mp4-video-in-firefox

您还可以使用表单喜欢视频。让我们点击面包屑中的“频道”链接:

这是特定视频上传者的视频列表。最后,让我们转到上传视频页面。点击导航菜单中的“上传”链接:

您可以在这里上传视频,如上图所示。

注意

为了使这个应用在现实世界中表现更好,有很多事情需要做。您需要添加测试,测试模型、视图、模板,最后,您需要进行完整的集成测试。您还需要使用 Celery 和 RabbitMQ 或 Redis 将繁重和长时间的操作(如在智能合约上调用操作,使用 IPFS 添加和获取文件)放在后台作业中。除此之外,您还需要添加一些 JavaScript 文件,以便使用轮询机制通知后台作业是否已经完成。您还可以使用 Django 通道来完成这项工作。

而不是在模型中访问智能合约的方法,也许最好是使用 cron 在后台任务中将区块链中的所有信息存储在数据库中。然后模型可以访问数据库以获取必要的信息。要上传和喜欢视频,我们需要每次发送我们的地址和密码。也许,为了方便起见,我们可以为用户提供一种临时保存地址和密码的方法。我们可以将这些保存在会话中、cookie 中,甚至是 web3 对象中。

在我们的玩具应用中,我们假设每个人都上传了有效的视频文件。如果有人上传了无效的视频文件,我们需要处理这种情况。此外,如果有人上传了无效的视频的 IPFS 路径,也需要相应处理。我们应该在智能合约中验证(使用更多的 gas)吗?还是应该在前端验证?我们需要处理许多边缘情况。我们还需要添加分页。搜索呢?我们需要爬取区块链上的事件。我们只关心视频标题,还是应该从视频文件本身提取信息?这些都是您需要考虑的问题,如果您想在现实世界中构建一个去中心化的视频分享应用。

摘要

在本章中,我们结合了 IPFS 技术和智能合约技术。我们构建了一个去中心化的视频分享应用程序。首先,我们编写了一个智能合约来存储视频信息和视频标题。我们还通过让点赞视频需要使用 ERC20 代币来内置加密经济学。除此之外,我们还了解到,即使存储视频信息,比如 IPFS 路径和标题的字节字符串,也需要比平常更多的 gas。在编写智能合约之后,我们使用 Django 库构建了一个 Web 应用程序。我们创建了一个项目,然后在这个项目内构建了一个应用程序。接着,我们构建了视图、模型、模板和 URL。在模型中,我们将视频文件存储在 IPFS 上,然后将 IPFS 路径存储在区块链上。我们使用 Bulma CSS 框架使模板更加美观,然后通过执行这个 Web 应用程序的功能来启动应用程序。

在本书中,我们学习了区块链是什么,以及智能合约是什么。我们使用 Vyper 编程语言构建了许多有趣的智能合约,比如投票智能合约、类似 Twitter 的应用程序智能合约、ERC20 代币智能合约和视频分享智能合约。我们还利用 web3 库与这些智能合约进行交互,并构建了去中心化应用程序。除此之外,我们使用 PySide2 库构建了我们的 GUI 前端,用于处理以太和 ERC20 代币的加密货币钱包应用程序。最后,我们还学习了一种补充的去中心化技术 IPFS,它可以成为区块链应用程序的存储解决方案。

掌握了所有这些技能之后,您就有能力在以太坊平台上构建许多有趣的应用程序。但以太坊仍然是一项新兴技术。诸如分片、权益证明和隐私等技术仍在以太坊中进行研究和开发。这些新技术可能会影响您所学到的技术,比如 Vyper 和 web3。因此,您需要关注以太坊平台的新更新。

进一步阅读

posted @ 2024-05-04 21:29  绝不原创的飞龙  阅读(65)  评论(0编辑  收藏  举报