CryptoZombiesx的solidity学习
Solidity: Beginner to Intermediate Smart Contracts
lesson1 搭建僵尸工厂
第2章: 合约
从最基本的开始入手:
Solidity 的代码都包裹在合约里面. 一份合约就是以太应币应用的基本模块, 所有的变量和函数都属于一份合约, 它是你所有应用的起点.
一份名为 HelloWorld 的空合约如下:
1 | contract HelloWorld { |
版本指令
所有的 Solidity 源码都必须冠以 “version pragma” — 标明 Solidity 编译器的版本. 以避免将来新的编译器可能破坏你的代码。
例如: pragma solidity ^0.4.19; (当前 Solidity 的最新版本是 0.4.19).
综上所述, 下面就是一个最基本的合约 — 每次建立一个新的项目时的第一段代码:
1 | pragma solidity ^0.4.19; |
实战演习
为了建立我们的僵尸部队, 让我们先建立一个基础合约,称为 ZombieFactory。
- 在右边的输入框里输入
0.4.19,我们的合约基于这个版本的编译器。 - 建立一个空合约
ZombieFactory。
一切完毕,点击下面 “答案” . 如果没效果,点击 “提示”。
1 | pragma solidity ^0.4.19; |
第3章: 状态变量和整数
真棒!我们已经为我们的合约做了一个外壳, 下面学习 Solidity 中如何使用变量。
状态变量是被永久地保存在合约中。也就是说它们被写入以太币区块链中. 想象成写入一个数据库。
例子:
1 | contract Example { |
在上面的例子中,定义 myUnsignedInteger 为 uint 类型,并赋值100。
无符号整数: uint
uint 无符号数据类型, 指其值不能是负数,对于有符号的整数存在名为 int 的数据类型。
注: Solidity中,
uint实际上是uint256代名词, 一个256位的无符号整数。你也可以定义位数少的uints —uint8,uint16,uint32, 等…… 但一般来讲你愿意使用简单的uint, 除非在某些特殊情况下,这我们后面会讲。
实战演习
我们的僵尸DNA将由一个十六位数字组成。
定义 dnaDigits 为 uint 数据类型, 并赋值 16。
1 | pragma solidity ^0.4.19; |
第4章: 数学运算
在 Solidity 中,数学运算很直观明了,与其它程序设计语言相同:
- 加法:
x + y - 减法:
x - y, - 乘法:
x * y - 除法:
x / y - 取模 / 求余:
x % y(例如,13 % 5余3, 因为13除以5,余3)
Solidity 还支持 *乘方操作* (如:x 的 y次方) // 例如: 5 ** 2 = 25
1 | uint x = 5 ** 2; // equal to 5^2 = 25 |
实战演习
为了保证我们的僵尸的DNA只含有16个字符,我们先造一个uint数据,让它等于10^16。这样一来以后我们可以用模运算符 % 把一个整数变成16位。
- 建立一个
uint类型的变量,名字叫dnaModulus, 令其等于 10 的dnaDigits次方.
1 | pragma solidity ^0.4.19; |
第5章: 结构体
有时你需要更复杂的数据类型,Solidity 提供了 结构体:
1 | struct Person { |
结构体允许你生成一个更复杂的数据类型,它有多个属性。
注:我们刚刚引进了一个新类型,
string。 字符串用于保存任意长度的 UTF-8 编码数据。 如:string greeting = "Hello world!"。
实战演习
在我们的程序中,我们将创建一些僵尸!每个僵尸将拥有多个属性,所以这是一个展示结构体的完美例子。
- 建立一个
struct命名为Zombie. - 我们的
Zombie结构体有两个属性:name(类型为string), 和dna(类型为uint)。
1 | pragma solidity ^0.4.19; |
第6章: 数组
如果你想建立一个集合,可以用 数组这样的数据类型. Solidity 支持两种数组: 静态数组和动态数组:
1 | // 固定长度为2的静态数组: |
你也可以建立一个 结构体类型的数组 例如,上一章提到的 Person:
1 | Person[] people; // 这是动态数组,我们可以不断添加元素 |
记住:状态变量被永久保存在区块链中。所以在你的合约中创建动态数组来保存成结构的数据是非常有意义的。
公共数组
你可以定义 public 数组, Solidity 会自动创建 getter 方法. 语法如下:
1 | Person[] public people; |
其它的合约可以从这个数组读取数据(但不能写入数据),所以这在合约中是一个有用的保存公共数据的模式。
实战演习
为了把一个僵尸部队保存在我们的APP里,并且能够让其它APP看到这些僵尸,我们需要一个公共数组。
- 创建一个数据类型为
Zombie的结构体数组,用public修饰,命名为:zombies.
1 | pragma solidity ^0.4.19; |
第7章: 定义函数
在 Solidity 中函数定义的句法如下:
1 | function eatHamburgers(string _name, uint _amount) { |
这是一个名为 eatHamburgers 的函数,它接受两个参数:一个 string类型的 和 一个 uint类型的。现在函数内部还是空的。
注:: 习惯上函数里的变量都是以(
_)开头 (但不是硬性规定) 以区别全局变量。我们整个教程都会沿用这个习惯。
我们的函数定义如下:
1 | eatHamburgers("vitalik", 100); |
实战演习
在我们的应用里,我们要能创建一些僵尸,让我们写一个函数做这件事吧!
- 建立一个函数
createZombie。 它有两个参数: _name (类型为string), 和 _dna (类型为uint)。
暂时让函数空着——我们在后面会增加内容。
1 | pragma solidity ^0.4.19; |
第8章: 使用结构体和数组
创建新的结构体
还记得上个例子中的 Person 结构吗?
1 | struct Person { |
现在我们学习创建新的 Person 结构,然后把它加入到名为 people 的数组中.
1 | // 创建一个新的Person: |
你也可以两步并一步,用一行代码更简洁:
1 | people.push(Person(16, "Vitalik")); |
注:
array.push()在数组的 尾部 加入新元素 ,所以元素在数组中的顺序就是我们添加的顺序, 如:
1 | uint[] numbers; |
实战演习
让我们创建名为createZombie的函数来做点儿什么吧。
- 在函数体里新创建一个
Zombie, 然后把它加入zombies数组中。 新创建的僵尸的name和dna,来自于函数的参数。 - 让我们用一行代码简洁地完成它。
1 | pragma solidity ^0.4.19; |
第9章: 私有 / 公共函数
Solidity 定义的函数的属性默认为公共。 这就意味着任何一方 (或其它合约) 都可以调用你合约里的函数。
显然,不是什么时候都需要这样,而且这样的合约易于受到攻击。 所以将自己的函数定义为私有是一个好的编程习惯,只有当你需要外部世界调用它时才将它设置为公共。
如何定义一个私有的函数呢?
1 | uint[] numbers; |
这意味着只有我们合约中的其它函数才能够调用这个函数,给 numbers 数组添加新成员。
可以看到,在**函数名字后面使用关键字 private 即可。和函数的参数类似,私有函数的名字用(_)起始。**
实战演习
我们合约的函数 createZombie 的默认属性是公共的,这意味着任何一方都可以调用它去创建一个僵尸。 咱们来把它变成私有吧!
- 变
createZombie为私有函数,不要忘记遵守命名的规矩哦!
1 | pragma solidity ^0.4.19; |
第10章: 函数的更多属性
本章中我们将学习函数的返回值和修饰符。
返回值
要想函数返回一个数值,按如下定义:
1 | string greeting = "What's up dog"; |
Solidity 里,函数的定义里可包含返回值的数据类型(如本例中 string)。
函数的修饰符
上面的函数实际上没有改变 Solidity 里的状态,即,它没有改变任何值或者写任何东西。
这种情况下我们可以把函数定义为 view, 意味着它只能读取数据不能更改数据:
1 | function sayHello() public view returns (string) { |
Solidity 还支持 pure 函数, 表明这个函数甚至都不访问应用里的数据,例如:
1 | function _multiply(uint a, uint b) private pure returns (uint) { |
这个函数甚至都不读取应用里的状态 — 它的返回值完全取决于它的输入参数,在这种情况下我们把函数定义为 pure.
注:可能很难记住何时把函数标记为 pure/view。 幸运的是, Solidity 编辑器会给出提示,提醒你使用这些修饰符。
实战演习
我们想建立一个帮助函数,它根据一个字符串随机生成一个DNA数据。
- 创建一个
private函数,命名为_generateRandomDna。它只接收一个输入变量_str(类型string), 返回一个uint类型的数值。 - 此函数只读取我们合约中的一些变量,所以标记为
view。 - 函数内部暂时留空,以后我们再添加代码。
1 | pragma solidity ^0.4.19; |
第11章: Keccak256 和 类型转换
如何让 _generateRandomDna 函数返回一个全(半) 随机的 uint?
Ethereum 内部有一个散列函数keccak256,它用了SHA3版本。一个散列函数基本上就是把一个字符串转换为一个256位的16进制数字。字符串的一个微小变化会引起散列数据极大变化。
这在 Ethereum 中有很多应用,但是现在我们只是用它造一个伪随机数。
例子:
1 | //6e91ec6b618bb462a4a6ee5aa2cb0e9cf30f7a052bb467b0ba58b8748c00d2e5 |
显而易见,输入字符串只改变了一个字母,输出就已经天壤之别了。
注: 在区块链中安全地产生一个随机数是一个很难的问题, 本例的方法不安全,但是在我们的Zombie DNA算法里不是那么重要,已经很好地满足我们的需要了。
类型转换
有时你需要变换数据类型。例如:
1 | uint8 a = 5; |
上面, a * b 返回类型是 uint, 但是当我们尝试用 uint8 类型接收时, 就会造成潜在的错误。如果把它的数据类型转换为 uint8, 就可以了,编译器也不会出错。
实战演习
给 _generateRandomDna 函数添加代码! 它应该完成如下功能:
- 第一行代码取
_str的keccak256散列值生成一个伪随机十六进制数,类型转换为uint, 最后保存在类型为uint名为rand的变量中。 - 我们只想让我们的DNA的长度为16位 (还记得
dnaModulus?)。所以第二行代码应该return上面计算的数值对dnaModulus求余数(%)。
1 | pragma solidity ^0.4.19; |
第12章: 放在一起
我们就快完成我们的随机僵尸制造器了,来写一个公共的函数把所有的部件连接起来。
写一个公共函数,它有一个参数,用来接收僵尸的名字,之后用它生成僵尸的DNA。
实战演习
- 创建一个
public函数,命名为createRandomZombie. 它将被传入一个变量_name(数据类型是string)。 *(注: 定义公共函数public和定义一个私有private函数的做法一样)*。 - 函数的第一行应该调用
_generateRandomDna函数,传入_name参数, 结果保存在一个类型为uint的变量里,命名为randDna。 - 第二行调用
_createZombie函数, 传入参数:_name和randDna。 - 整个函数应该是4行代码 (包括函数的结束符号
})。
1 | pragma solidity ^0.4.19; |
第13章: 事件
我们的合约几乎就要完成了!让我们加上一个事件.
事件 是合约和区块链通讯的一种机制。你的前端应用“监听”某些事件,并做出反应。
例子:
1 | // 这里建立事件 |
你的 app 前端可以监听这个事件。JavaScript 实现如下:
1 | YourContract.IntegersAdded(function(error, result) { |
实战演习
我们想每当一个僵尸创造出来时,我们的前端都能监听到这个事件,并将它显示出来。
1。 定义一个 事件 叫做 NewZombie。 它有3个参数: zombieId (uint), name (string), 和 dna (uint)。
2。 修改 _createZombie 函数使得当新僵尸造出来并加入 zombies数组后,生成事件NewZombie。
3。 需要定义僵尸id。 array.push() 返回数组的长度类型是uint - 因为数组的第一个元素的索引是 0, array.push() - 1 将是我们加入的僵尸的索引。 zombies.push() - 1 就是 id,数据类型是 uint。在下一行中你可以把它用到 NewZombie 事件中。
1 | pragma solidity ^0.4.19; |
第14章: Web3.js
我们的 Solidity 合约完工了! 现在我们要写一段 JavaScript 前端代码来调用这个合约。
以太坊有一个 JavaScript 库,名为Web3.js。
在后面的课程里,我们会进一步地教你如何安装一个合约,如何设置Web3.js。 但是现在我们通过一段代码来了解 Web3.js 是如何和我们发布的合约交互的吧。
如果下面的代码你不能全都理解,不用担心。
1 | // 下面是调用合约的方式: |
我们的 JavaScript 所做的就是获取由zombieDetails 产生的数据, 并且利用浏览器里的 JavaScript 神奇功能 (我们用 Vue.js),置换出图像以及使用CSS过滤器。在后面的课程中,你可以看到全部的代码。
lesson2 僵尸攻击人类
第2章: 映射(Mapping)和地址(Address)
我们通过给数据库中的僵尸指定“主人”, 来支持“多玩家”模式。
如此一来,我们需要引入2个新的数据类型:mapping(映射) 和 address(地址)。
Addresses (地址)
以太坊区块链由 _ account _ (账户)组成,你可以把它想象成银行账户。一个帐户的余额是 以太 (在以太坊区块链上使用的币种),你可以和其他帐户之间支付和接受以太币,就像你的银行帐户可以电汇资金到其他银行帐户一样。
每个帐户都有一个“地址”,你可以把它想象成银行账号。这是账户唯一的标识符,它看起来长这样:
1 | 0x0cE446255506E92DF41614C46F1d6df9Cc969183 |
(这是 CryptoZombies 团队的地址,如果你喜欢 CryptoZombies 的话,请打赏我们一些以太币!😉)
我们将在后面的课程中介绍地址的细节,现在你只需要了解地址属于特定用户(或智能合约)的。
所以我们可以指定“地址”作为僵尸主人的 ID。当用户通过与我们的应用程序交互来创建新的僵尸时,新僵尸的所有权被设置到调用者的以太坊地址下。
Mapping(映射)
在第1课中,我们看到了 结构体 和 数组 。 映射 是另一种在 Solidity 中存储有组织数据的方法。
映射是这样定义的:
1 | //对于金融应用程序,将用户的余额保存在一个 uint类型的变量中: |
映射本质上是存储和查找数据所用的键-值对。在第一个例子中,键是一个 address,值是一个 uint,在第二个例子中,键是一个uint,值是一个 string。
实战演习
为了存储僵尸的所有权,我们会使用到两个映射:一个记录僵尸拥有者的地址,另一个记录某地址所拥有僵尸的数量。
1.创建一个叫做 zombieToOwner 的映射。其键是一个uint(我们将根据它的 id 存储和查找僵尸),值为 address。映射属性为public。
2.创建一个名为 ownerZombieCount 的映射,其中键是 address,值是 uint。
1 | // 在这里定义映射 |
第3章: Msg.sender
现在有了一套映射来记录僵尸的所有权了,我们可以修改 _createZombie 方法来运用它们。
为了做到这一点,我们要用到 msg.sender。
msg.sender
在 Solidity 中,有一些全局变量可以被所有函数调用。 其中一个就是 msg.sender,它指的是当前调用者(或智能合约)的 address。
注意:在 Solidity 中,功能执行始终需要从外部调用者开始。 一个合约只会在区块链上什么也不做,除非有人调用其中的函数。所以
msg.sender总是存在的。
以下是使用 msg.sender 来更新 mapping 的例子:
1 | mapping (address => uint) favoriteNumber; |
在这个小小的例子中,任何人都可以调用 setMyNumber 在我们的合约中存下一个 uint 并且与他们的地址相绑定。 然后,他们调用 whatIsMyNumber 就会返回他们存储的 uint。
使用 msg.sender 很安全,因为它具有以太坊区块链的安全保障 —— 除非窃取与以太坊地址相关联的私钥,否则是没有办法修改其他人的数据的。
实战演习
我们来修改第1课的 _createZombie 方法,将僵尸分配给函数调用者吧。
- 首先,在得到新的僵尸
id后,更新zombieToOwner映射,在id下面存入msg.sender。 - 然后,我们为这个
msg.sender名下的ownerZombieCount加 1。
跟在 JavaScript 中一样, 在 Solidity 中你也可以用 ++ 使 uint 递增。
1 | uint number = 0; |
修改两行代码即可。
1 | function _createZombie(string _name, uint _dna) private { |
第4章: Require
在第一课中,我们成功让用户通过调用 createRandomZombie函数 并输入一个名字来创建新的僵尸。 但是,如果用户能持续调用这个函数来创建出无限多个僵尸加入他们的军团,这游戏就太没意思了!
于是,我们作出限定:每个玩家只能调用一次这个函数。 这样一来,新玩家可以在刚开始玩游戏时通过调用它,为其军团创建初始僵尸。
我们怎样才能限定每个玩家只调用一次这个函数呢?
答案是使用require。 require使得函数在执行过程中,当不满足某些条件时抛出错误,并停止执行:
1 | function sayHiToVitalik(string _name) public returns (string) { |
如果你这样调用函数 sayHiToVitalik(“Vitalik”) ,它会返回“Hi!”。而如果调用的时候使用了其他参数,它则会抛出错误并停止执行。
因此,在调用一个函数之前,用 require 验证前置条件是非常有必要的。
实战演习
在我们的僵尸游戏中,我们不希望用户通过反复调用 createRandomZombie 来給他们的军队创建无限多个僵尸 —— 这将使得游戏非常无聊。
我们使用了 require 来确保这个函数只有在每个用户第一次调用它的时候执行,用以创建初始僵尸。
- 在
createRandomZombie的前面放置require语句。 使得函数先检查ownerZombieCount [msg.sender]的值为0,不然就抛出一个错误。
注意:在 Solidity 中,关键词放置的顺序并不重要
- 虽然参数的两个位置是等效的。 但是,由于我们的答案检查器比较呆板,它只能认定其中一个为正确答案
- 于是在这里,我们就约定把
ownerZombieCount [msg.sender]放前面吧
1 | function createRandomZombie(string _name) public { |
第5章: 继承(Inheritance)
我们的游戏代码越来越长。 当代码过于冗长的时候,最好将代码和逻辑分拆到多个不同的合约中,以便于管理。
有个让 Solidity 的代码易于管理的功能,就是合约 *inheritance* (继承):
1 | contract Doge { |
由于 BabyDoge 是从 Doge 那里 *inherits* (继承)过来的。 这意味着当你编译和部署了 BabyDoge,它将可以访问 catchphrase() 和 anotherCatchphrase()和其他我们在 Doge 中定义的其他公共函数。
这可以用于逻辑继承(比如表达子类的时候,Cat 是一种 Animal)。 但也可以简单地将类似的逻辑组合到不同的合约中以组织代码。
实战演习
在接下来的章节中,我们将要为僵尸实现各种功能,让它可以“猎食”和“繁殖”。 通过将这些运算放到父类 ZombieFactory 中,使得所有 ZombieFactory 的继承者合约都可以使用这些方法。
- 在
ZombieFactory下创建一个叫ZombieFeeding的合约,它是继承自 `ZombieFactory 合约的。
1 | // Start here |
第6章: 引入(Import)
哇!你有没有注意到,我们只是清理了下右边的代码,现在你的编辑器的顶部就多了个选项卡。 尝试点击它的标签,看看会发生什么吧!
代码已经够长了,我们把它分成多个文件以便于管理。 通常情况下,当 Solidity 项目中的代码太长的时候我们就是这么做的。
在 Solidity 中,当你有多个文件并且想把一个文件导入另一个文件时,可以使用 import 语句:
1 | import "./someothercontract.sol"; |
这样当我们在合约(contract)目录下有一个名为 someothercontract.sol 的文件( ./ 就是同一目录的意思),它就会被编译器导入。
实战演习
现在我们已经建立了一个多文件架构,并用 import 来读取来自另一个文件中合约的内容:
1.将 zombiefactory.sol 导入到我们的新文件 zombiefeeding.sol 中。
1 | // put import statement here |
第7章: Storage与Memory
在 Solidity 中,有两个地方可以存储变量 —— storage 或 memory。
Storage 变量是指**永久存储在区块链中的变量。 Memory 变量则是临时的**,当外部函数对某合约调用完成时,内存型变量即被移除。 你可以把它想象成存储在你电脑的硬盘或是RAM中数据的关系。
大多数时候你都用不到这些关键字,默认情况下 Solidity 会自动处理它们。 状态变量(在函数之外声明的变量)默认为“存储”形式,并永久写入区块链;而在函数内部声明的变量是“内存”型的,它们函数调用结束后消失。
然而也有一些情况下,你需要手动声明存储类型,主要用于处理函数内的 结构体 和 数组 时:
1 | contract SandwichFactory { |
如果你还没有完全理解究竟应该使用哪一个,也不用担心 —— 在本教程中,我们将告诉你何时使用 storage 或是 memory,并且当你不得不使用到这些关键字的时候,Solidity 编译器也发警示提醒你的。
现在,只要知道在某些场合下也需要你显式地声明 storage 或 memory就够了!
实战演习
是时候给我们的僵尸增加“猎食”和“繁殖”功能了!
当一个僵尸猎食其他生物体时,它自身的DNA将与猎物生物的DNA结合在一起,形成一个新的僵尸DNA。
- 创建一个名为
feedAndMultiply的函数。 使用两个参数:_zombieId(uint类型 )和_targetDna(也是uint类型)。 设置属性为public的。 - 我们不希望别人用我们的僵尸去捕猎。 首先,我们确保对自己僵尸的所有权。 通过添加一个
require语句来确保msg.sender只能是这个僵尸的主人(类似于我们在createRandomZombie函数中做过的那样)。
注意:同样,因为我们的答案检查器比较呆萌,只认识把
msg.sender放在前面的答案,如果你切换了参数的顺序,它就不认得了。 但你正常编码时,如何安排参数顺序都是正确的。
- 为了获取这个僵尸的DNA,我们的函数需要声明一个名为
myZombie数据类型为Zombie的本地变量(这是一个storage型的指针)。 将其值设定为在zombies数组中索引为_zombieId所指向的值。
到目前为止,包括函数结束符 } 的那一行, 总共4行代码。
1 | // Start here |
第8章: 僵尸的DNA
我们来把 feedAndMultiply 函数写完吧。
获取新的僵尸DNA的公式很简单:计算猎食僵尸DNA和被猎僵尸DNA之间的平均值。
例如:
1 | function testDnaSplicing() public { |
以后,我们也可以让函数变得更复杂些,比方给新的僵尸的 DNA 增加一些随机性之类的。但现在先从最简单的开始 —— 以后还可以回来完善它嘛。
实战演习
- 首先我们确保
_targetDna不长于16位。要做到这一点,我们可以设置_targetDna为_targetDna % dnaModulus,并且只取其最后16位数字。 - 接下来为我们的函数声明一个名叫
newDna的uint类型的变量,并将其值设置为myZombie的 DNA 和_targetDna的平均值(如上例所示)。
注意:您可以用
myZombie.name或myZombie.dna访问myZombie的属性。
- 一旦我们计算出新的DNA,再调用
_createZombie就可以生成新的僵尸了。如果你忘了调用这个函数所需要的参数,可以查看zombiefactory.sol选项卡。请注意,需要先给它命名,所以现在我们把新的僵尸的名字设为NoName- 我们回头可以编写一个函数来更改僵尸的名字。
注意:对于 Solidity 高手,你可能会注意到我们的代码存在一个问题。别担心,下一章会解决这个问题的 ;)
1 | function feedAndMultiply(uint _zombieId, uint _targetDna) public { |
第9章: 更多关于函数可见性
我们上一课的代码有问题!
编译的时候编译器就会报错。
错误在于,我们尝试从 ZombieFeeding 中调用 _createZombie 函数,但 _createZombie 却是 ZombieFactory 的 private (私有)函数。这意味着任何继承自 ZombieFactory 的子合约都不能访问它。
internal 和 external
除 public 和 private 属性之外,Solidity 还使用了另外两个描述函数可见性的修饰词:internal(内部) 和 external(外部)。
internal 和 private 类似,不过, 如果某个合约继承自其父合约,这个合约即可以访问父合约中定义的“内部”函数。(嘿,这听起来正是我们想要的那样!)。
external 与public 类似,只不过这些函数只能在合约之外调用 - 它们不能被合约内的其他函数调用。稍后我们将讨论什么时候使用 external 和 public。
声明函数 internal 或 external 类型的语法,与声明 private 和 public类 型相同:
1 | contract Sandwich { |
实战演习
将
_createZombie()函数的属性从private改为internal, 使得其他的合约也能访问到它。我们已经成功把你的注意力集中在到
zombiefactory.sol这个选项卡上啦。
1 | // 在这里修改函数的功能 |
第10章: 僵尸吃什么?
是时候让我们的僵尸去捕猎! 那僵尸最喜欢的食物是什么呢?
Crypto 僵尸喜欢吃的是…
CryptoKitties! 😱😱😱
(正经点,我可不是开玩笑😆)
为了做到这一点,我们要读出 CryptoKitties 智能合约中的 kittyDna。这些数据是公开存储在区块链上的。区块链是不是很酷?
别担心 —— 我们的游戏并不会伤害到任何真正的CryptoKitty。 我们只 读取 CryptoKitties 数据,但却无法在物理上删除它。
与其他合约的交互
如果我们的合约需要和区块链上的其他的合约会话,则需先定义一个 interface (接口)。
先举一个简单的栗子。 假设在区块链上有这么一个合约:
1 | contract LuckyNumber { |
这是个很简单的合约,您可以用它存储自己的幸运号码,并将其与您的以太坊地址关联。 这样其他人就可以通过您的地址查找您的幸运号码了。
现在假设我们有一个外部合约,使用 getNum 函数可读取其中的数据。
首先,我们定义 LuckyNumber 合约的 interface :
1 | contract NumberInterface { |
请注意,这个过程虽然看起来像在定义一个合约,但其实内里不同:
首先,我们只声明了要与之交互的函数 —— 在本例中为 getNum —— 在其中我们没有使用到任何其他的函数或状态变量。
其次,我们并没有使用大括号({ 和 })定义函数体,我们单单用分号(;)结束了函数声明。这使它看起来像一个合约框架。
编译器就是靠这些特征认出它是一个接口的。
在我们的 app 代码中使用这个接口,合约就知道其他合约的函数是怎样的,应该如何调用,以及可期待什么类型的返回值。
在下一课中,我们将真正调用其他合约的函数。目前我们只要声明一个接口,用于调用 CryptoKitties 合约就行了。
实战演习
我们已经为你查看过了 CryptoKitties 的源代码,并且找到了一个名为 getKitty的函数,它返回所有的加密猫的数据,包括它的“基因”(我们的僵尸游戏要用它生成新的僵尸)。
该函数如下所示:
1 | function getKitty(uint256 _id) external view returns ( |
这个函数看起来跟我们习惯的函数不太一样。 它竟然返回了…一堆不同的值! 如果您用过 JavaScript 之类的编程语言,一定会感到奇怪 —— 在 Solidity中,您可以让一个函数返回多个值。
现在我们知道这个函数长什么样的了,就可以用它来创建一个接口:
1.定义一个名为 KittyInterface 的接口。 请注意,因为我们使用了 contract 关键字, 这过程看起来就像创建一个新的合约一样。
2.在interface里定义了 getKitty 函数(不过是复制/粘贴上面的函数,但在 returns 语句之后用分号,而不是大括号内的所有内容。
1 | // Create KittyInterface here |
第11章: 使用接口
继续前面 NumberInterface 的例子,我们既然将接口定义为:
1 | contract NumberInterface { |
我们可以在合约中这样使用:
1 | contract MyContract { |
通过这种方式,只要将您合约的可见性设置为public(公共)或external(外部),它们就可以与以太坊区块链上的任何其他合约进行交互。
实战演习
我们来建个自己的合约去读取另一个智能合约– CryptoKitties 的内容吧!
- 我已经将代码中 CryptoKitties 合约的地址保存在一个名为
ckAddress的变量中。在下一行中,请创建一个名为kittyContract的 KittyInterface,并用ckAddress为它初始化 —— 就像我们为numberContract所做的一样。
1 | contract ZombieFeeding is ZombieFactory { |
第12章: 处理多返回值
getKitty 是我们所看到的第一个返回多个值的函数。我们来看看是如何处理的:
1 | function multipleReturns() internal returns(uint a, uint b, uint c) { |
实战演习
是时候与 CryptoKitties 合约交互起来了!
我们来定义一个函数,从 kitty 合约中获取它的基因:
创建一个名为
feedOnKitty的函数。它需要2个uint类型的参数,_zombieId和_kittyId,这是一个public类型的函数。函数首先要声明一个名为
kittyDna的uint。注意:在我们的
KittyInterface中,genes是一个uint256类型的变量,但是如果你记得,我们在第一课中提到过,uint是uint256的别名,也就是说它们是一回事。这个函数接下来调用
kittyContract.getKitty函数, 传入_kittyId,将返回的genes存储在kittyDna中。记住 ——getKitty会返回一大堆变量。 (确切地说10个 - 我已经为你数过了,不错吧!)。但是我们只关心最后一个–genes。数逗号的时候小心点哦!最后,函数调用了
feedAndMultiply,并传入了_zombieId和kittyDna两个参数。
1 | // define function here |
第13章: 奖励: Kitty 基因
我们的功能逻辑主体已经完成了…现在让我们来添一个奖励功能吧。
这样吧,给从小猫制造出的僵尸添加些特征,以显示他们是猫僵尸。
要做到这一点,咱们在新僵尸的DNA中添加一些特殊的小猫代码。
还记得吗,第一课中我们提到,我们目前只使用16位DNA的前12位数来指定僵尸的外观。所以现在我们可以使用最后2个数字来处理“特殊”的特征。
这样吧,把猫僵尸DNA的最后两个数字设定为99(因为猫有9条命)。所以在我们这么来写代码:如果这个僵尸是一只猫变来的,就将它DNA的最后两位数字设置为99。
if 语句
if语句的语法在 Solidity 中,与在 JavaScript 中差不多:
1 | function eatBLT(string sandwich) public { |
实战演习
让我们在我们的僵尸代码中实现小猫的基因。
首先,我们修改下
feedAndMultiply函数的定义,给它传入第三个参数:一条名为_species的字符串。接下来,在我们计算出新的僵尸的DNA之后,添加一个
if语句来比较_species和字符串"kitty"的keccak256哈希值。在
if语句中,我们用99替换了新僵尸DNA的最后两位数字。可以这么做:newDna = newDna - newDna % 100 + 99;。解释:假设
newDna是334455。那么newDna % 100是55,所以newDna - newDna % 100得到334400。最后加上99可得到334499。最后,我们修改了
feedOnKitty中的函数调用。当它调用feedAndMultiply时,增加“kitty”作为最后一个参数。
1 | // 这里修改函数定义 |
第14章: 放在一起
至此,你已经学完第二课了!
查看下→_→的演示,看看他们怎么运行起来得吧。继续,你肯定等不及看完这一页😉。点击小猫,攻击!看到你斩获一个新的小猫僵尸了吧!
JavaScript 实现
我们只用编译和部署 ZombieFeeding,就可以将这个合约部署到以太坊了。我们最终完成的这个合约继承自 ZombieFactory,因此它可以访问自己和父辈合约中的所有 public 方法。
我们来看一个与我们的刚部署的合约进行交互的例子, 这个例子使用了 JavaScript 和 web3.js:
1 | var abi = /* abi generated by the compiler */ |
实战演习
选择一只你想猎食的小猫。你自家僵尸的 DNA 会和小猫的 DNA 结合,生成一个新的小猫僵尸,加入你的军团!
看到新僵尸上那可爱的猫咪腿了么?这是新僵尸最后DNA中最后两位数字 99 的功劳!
你想要的话随时可以重新开始。捕获了一只猫咪僵尸,你一定很高兴吧!(不过你只能持有一只),继续前进到下一章,完成第二课吧!
lesson3 高级 Solidity 理论
第1章: 智能协议的永固性
到现在为止,我们讲的 Solidity 和其他语言没有质的区别,它长得也很像 JavaScript。
但是,在有几点以太坊上的 DApp 跟普通的应用程序有着天壤之别。
第一个例子,在你把智能协议传上以太坊之后,它就变得不可更改, 这种永固性意味着你的代码永远不能被调整或更新。
你编译的程序会一直,永久的,不可更改的,存在以太坊上。这就是 Solidity 代码的安全性如此重要的一个原因。如果你的智能协议有任何漏洞,即使你发现了也无法补救。你只能让你的用户们放弃这个智能协议,然后转移到一个新的修复后的合约上。
但这恰好也是智能合约的一大优势。代码说明一切。如果你去读智能合约的代码,并验证它,你会发现,一旦函数被定义下来,每一次的运行,程序都会严格遵照函数中原有的代码逻辑一丝不苟地执行,完全不用担心函数被人篡改而得到意外的结果。
外部依赖关系
在第2课中,我们将加密小猫(CryptoKitties)合约的地址硬编码到 DApp 中去了。有没有想过,如果加密小猫出了点问题,比方说,集体消失了会怎么样? 虽然这种事情几乎不可能发生,但是,如果小猫没了,我们的 DApp 也会随之失效 – 因为我们在 DApp 的代码中用“硬编码”的方式指定了加密小猫的地址,如果这个根据地址找不到小猫,我们的僵尸也就吃不到小猫了,而按照前面的描述,我们却没法修改合约去应付这个变化!
因此,我们不能硬编码,而要采用“函数”,以便于 DApp 的关键部分可以以参数形式修改。
比方说,我们不再一开始就把猎物地址给写入代码,而是写个函数 setKittyContractAddress, 运行时再设定猎物的地址,这样我们就可以随时去锁定新的猎物,也不用担心加密小猫集体消失了。
实战演习
请修改第2课的代码,使得可以通过程序更改 CryptoKitties 合约地址。
- 删除采用硬编码 方式的
ckAddress代码行。 - 之前创建
kittyContract变量的那行代码,修改为对kittyContract变量的声明 – 暂时不给它指定具体的实例。 - 创建名为
setKittyContractAddress的函数, 它带一个参数_address(address类型), 可见性设为external。 - 在函数内部,添加一行代码,将
kittyContract变量设置为返回值:KittyInterface(_address)。
注意:你可能会注意到这个功能有个安全漏洞,别担心 - 咱们到下一章里解决它;)
1 | contract ZombieFeeding is ZombieFactory { |
第2章: Ownable Contracts
上一章中,您有没有发现任何安全漏洞呢?
呀!setKittyContractAddress 可见性居然申明为“外部的”(external),岂不是任何人都可以调用它! 也就是说,任何调用该函数的人都可以更改 CryptoKitties 合约的地址,使得其他人都没法再运行我们的程序了。
我们确实是希望这个地址能够在合约中修改,但我可没说让每个人去改它呀。
要对付这样的情况,通常的做法是指定合约的“所有权” - 就是说,给它指定一个主人(没错,就是您),只有主人对它享有特权。
OpenZeppelin库的Ownable 合约
下面是一个 Ownable 合约的例子: 来自 _ OpenZeppelin _ Solidity 库的 Ownable 合约。 OpenZeppelin 是主打安保和社区审查的智能合约库,您可以在自己的 DApps中引用。等把这一课学完,您不要催我们发布下一课,最好利用这个时间把 OpenZeppelin 的网站看看,保管您会学到很多东西!
把楼下这个合约读读通,是不是还有些没见过代码?别担心,我们随后会解释。
1 | /** |
下面有没有您没学过的东东?
- 构造函数:
function Ownable()是一个 _ constructor_ (构造函数),构造函数不是必须的,它与合约同名,构造函数一生中唯一的一次执行,就是在合约最初被创建的时候。 - 函数修饰符:
modifier onlyOwner()。 修饰符跟函数很类似,不过是用来修饰其他已有函数用的, 在其他语句执行前,为它检查下先验条件。 在这个例子中,我们就可以写个修饰符onlyOwner检查下调用者,确保只有合约的主人才能运行本函数。我们下一章中会详细讲述修饰符,以及那个奇怪的_;。 indexed关键字:别担心,我们还用不到它。
所以Ownable 合约基本都会这么干:
- 合约创建,构造函数先行,将其
owner设置为msg.sender(其部署者) - 为它加上一个修饰符
onlyOwner,它会限制陌生人的访问,将访问某些函数的权限锁定在owner上。 - 允许将合约所有权转让给他人。
onlyOwner 简直人见人爱,大多数人开发自己的 Solidity DApps,都是从复制/粘贴 Ownable 开始的,从它再继承出的子类,并在之上进行功能开发。
既然我们想把 setKittyContractAddress 限制为 onlyOwner ,我们也要做同样的事情。
实战演习
首先,将 Ownable 合约的代码复制一份到新文件 ownable.sol 中。 接下来,创建一个 ZombieFactory,继承 Ownable。
1.在程序中导入 ownable.sol 的内容。 如果您不记得怎么做了,参考下 zombiefeeding.sol。
2.修改 ZombieFactory 合约, 让它继承自 Ownable。 如果您不记得怎么做了,看看 zombiefeeding.sol。
1 | // 1. 在这里导入 |
第3章: onlyOwner 函数修饰符
现在我们有了个基本版的合约 ZombieFactory 了,它继承自 Ownable 接口,我们也可以给 ZombieFeeding 加上 onlyOwner 函数修饰符。
这就是合约继承的工作原理。记得:
1 | ZombieFeeding 是个 ZombieFactory |
因此 ZombieFeeding 也是个 Ownable, 并可以通过 Ownable 接口访问父类中的函数/事件/修饰符。往后,ZombieFeeding 的继承者合约们同样也可以这么延续下去。
函数修饰符
函数修饰符看起来跟函数没什么不同,不过关键字modifier 告诉编译器,这是个modifier(修饰符),而不是个function(函数)。它不能像函数那样被直接调用,只能被添加到函数定义的末尾,用以改变函数的行为。
咱们仔细读读 onlyOwner:
1 | /** |
onlyOwner 函数修饰符是这么用的:
1 | contract MyContract is Ownable { |
注意 likeABoss 函数上的 onlyOwner 修饰符。 当你调用 likeABoss 时,首先执行 onlyOwner 中的代码, 执行到 onlyOwner 中的 _; 语句时,程序再返回并执行 likeABoss 中的代码。
可见,尽管函数修饰符也可以应用到各种场合,但最常见的还是放在函数执行之前添加快速的 require检查。
因为给函数添加了修饰符 onlyOwner,使得唯有合约的主人(也就是部署者)才能调用它。
注意:主人对合约享有的特权当然是正当的,不过也可能被恶意使用。比如,万一,主人添加了个后门,允许他偷走别人的僵尸呢?
所以非常重要的是,部署在以太坊上的 DApp,并不能保证它真正做到去中心,你需要阅读并理解它的源代码,才能防止其中没有被部署者恶意植入后门;作为开发人员,如何做到既要给自己留下修复 bug 的余地,又要尽量地放权给使用者,以便让他们放心你,从而愿意把数据放在你的 DApp 中,这确实需要个微妙的平衡。
实战演习
现在我们可以限制第三方对 setKittyContractAddress的访问,除了我们自己,谁都无法去修改它。
- 将
onlyOwner函数修饰符添加到setKittyContractAddress中。
1 | // 修改这个函数: |
第4章: Gas
厉害!现在我们懂了如何在禁止第三方修改我们的合约的同时,留个后门给咱们自己去修改。
让我们来看另一种使得 Solidity 编程语言与众不同的特征:
Gas - 驱动以太坊DApps的能源
在 Solidity 中,你的用户想要每次执行你的 DApp 都需要支付一定的 ***gas***,gas 可以用以太币购买,因此,用户每次跑 DApp 都得花费以太币。
一个 DApp 收取多少 gas 取决于功能逻辑的复杂程度。每个操作背后,都在计算完成这个操作所需要的计算资源,(比如,存储数据就比做个加法运算贵得多), 一次操作所需要花费的 *gas* 等于这个操作背后的所有运算花销的总和。
由于运行你的程序需要花费用户的真金白银,在以太坊中代码的编程语言,比其他任何编程语言都更强调优化。同样的功能,使用笨拙的代码开发的程序,比起经过精巧优化的代码来,运行花费更高,这显然会给成千上万的用户带来大量不必要的开销。
*为什么要用 gas* 来驱动?
以太坊就像一个巨大、缓慢、但非常安全的电脑。当你运行一个程序的时候,网络上的每一个节点都在进行相同的运算,以验证它的输出 —— 这就是所谓的“去中心化” 由于数以千计的节点同时在验证着每个功能的运行,这可以确保它的数据不会被被监控,或者被刻意修改。
可能会有用户用无限循环堵塞网络,抑或用密集运算来占用大量的网络资源,为了防止这种事情的发生,以太坊的创建者为以太坊上的资源制定了价格,想要在以太坊上运算或者存储,你需要先付费。
注意:如果你使用侧链,倒是不一定需要付费,比如咱们在 Loom Network 上构建的 CryptoZombies 就免费。你不会想要在以太坊主网上玩儿“魔兽世界”吧? - 所需要的 gas 可能会买到你破产。但是你可以找个算法理念不同的侧链来玩它。我们将在以后的课程中咱们会讨论到,什么样的 DApp 应该部署在太坊主链上,什么又最好放在侧链。
省 gas 的招数:结构封装 (Struct packing)
在第1课中,我们提到除了基本版的 uint 外,还有其他变种 uint:uint8,uint16,uint32等。
通常情况下我们不会考虑使用 uint 变种,因为无论如何定义 uint的大小,Solidity 为它保留256位的存储空间。例如,使用 uint8 而不是uint(uint256)不会为你节省任何 gas。
除非,把 uint 绑定到 struct 里面。
如果一个 struct 中有多个 uint,则尽可能使用较小的 uint, Solidity 会将这些 uint 打包在一起,从而占用较少的存储空间。例如:
1 | struct NormalStruct { |
所以,当 uint 定义在一个 struct 中的时候,尽量使用最小的整数子类型以节约空间。 **并且把同样类型的变量放一起**(即在 struct 中将把变量按照类型依次放置),这样 Solidity 可以将存储空间最小化。例如,有两个 struct:
1 | uint c; uint32 a; uint32 b;` 和 `uint32 a; uint c; uint32 b; |
前者比后者需要的gas更少,因为前者把uint32放一起了。
实战演习
在本课中,咱们给僵尸添2个新功能:level 和 readyTime - 后者是用来实现一个“冷却定时器”,以限制僵尸猎食的频率。
让我们回到 zombiefactory.sol。
- 为
Zombie结构体 添加两个属性:level(uint32)和readyTime(uint32)。因为希望同类型数据打成一个包,所以把它们放在结构体的末尾。
32位足以保存僵尸的级别和时间戳了,这样比起使用普通的uint(256位),可以更紧密地封装数据,从而为我们省点 gas。
1 | contract ZombieFactory is Ownable { |
第5章: 时间单位
level 属性表示僵尸的级别。以后,在我们创建的战斗系统中,打胜仗的僵尸会逐渐升级并获得更多的能力。
readyTime 稍微复杂点。我们希望增加一个“冷却周期”,表示僵尸在两次猎食或攻击之之间必须等待的时间。如果没有它,僵尸每天可能会攻击和繁殖1,000次,这样游戏就太简单了。
为了记录僵尸在下一次进击前需要等待的时间,我们使用了 Solidity 的时间单位。
时间单位
Solidity 使用自己的本地时间单位。
变量 now 将返回当前的unix时间戳(自1970年1月1日以来经过的秒数)。我写这句话时 unix 时间是 1515527488。
注意:Unix时间传统用一个32位的整数进行存储。这会导致“2038年”问题,当这个32位的unix时间戳不够用,产生溢出,使用这个时间的遗留系统就麻烦了。所以,如果我们想让我们的 DApp 跑够20年,我们可以使用64位整数表示时间,但为此我们的用户又得支付更多的 gas。真是个两难的设计啊!
Solidity 还包含秒(seconds),分钟(minutes),小时(hours),天(days),周(weeks) 和 年(years) 等时间单位。它们都会转换成对应的秒数放入 uint 中。所以 1分钟 就是 60,1小时是 3600(60秒×60分钟),1天是86400(24小时×60分钟×60秒),以此类推。
下面是一些使用时间单位的实用案例:
1 | uint lastUpdated; |
有了这些工具,我们可以为僵尸设定“冷静时间”功能。
实战演习
现在咱们给DApp添加一个“冷却周期”的设定,让僵尸两次攻击或捕猎之间必须等待 1天。
声明一个名为
cooldownTime的uint,并将其设置为1 days。(没错,”1 days“使用了复数, 否则通不过编译器)因为在上一章中我们给
Zombie结构体中添加level和readyTime两个参数,所以现在创建一个新的Zombie结构体时,需要修改_createZombie(),在其中把新旧参数都初始化一下。修改
zombies.push那一行, 添加加2个参数:1(表示当前的level)和uint32(now + cooldownTime)(现在+冷却时间,表示下次允许攻击的时间readyTime)。
注意:必须使用
uint32(...)进行强制类型转换,因为now返回类型uint256。所以我们需要明确将它转换成一个uint32类型的变量。
now + cooldownTime 将等于当前的unix时间戳(以秒为单位)加上”1天“里的秒数 - 这将等于从现在起1天后的unix时间戳。然后我们就比较,看看这个僵尸的 readyTime是否大于 now,以决定再次启用僵尸的时机有没有到来。
下一章中,我们将讨论如何通过 readyTime 来规范僵尸的行为。
1 | // 1. 在这里定义 `cooldownTime` |
第6章: 僵尸冷却
现在,Zombie 结构体中定义好了一个 readyTime 属性,让我们跳到 zombiefeeding.sol, 去实现一个”冷却周期定时器“。
按照以下步骤修改 feedAndMultiply:
- ”捕猎“行为会触发僵尸的”冷却周期“
- 僵尸在这段”冷却周期“结束前不可再捕猎小猫
这将限制僵尸,防止其无限制地捕猎小猫或者整天不停地繁殖。将来,当我们增加战斗功能时,我们同样用”冷却周期“限制僵尸之间打斗的频率。
首先,我们要定义一些辅助函数,设置并检查僵尸的 readyTime。
将结构体作为参数传入
由于结构体的存储指针可以以参数的方式传递给一个 private 或 internal 的函数,因此结构体可以在多个函数之间相互传递。
遵循这样的语法:
1 | function _doStuff(Zombie storage _zombie) internal { |
这样我们可以将某僵尸的引用直接传递给一个函数,而不用是通过参数传入僵尸ID后,函数再依据ID去查找。
实战演习
- 先定义一个
_triggerCooldown函数。它要求一个参数,_zombie,表示一某个僵尸的存储指针。这个函数可见性设置为internal。 - 在函数中,把
_zombie.readyTime设置为uint32(now + cooldownTime)。 - 接下来,创建一个名为
_isReady的函数。这个函数的参数也是名为_zombie的Zombie storage。这个功能只具有internal可见性,并返回一个bool值。 - 函数计算返回
(_zombie.readyTime <= now),值为true或false。这个功能的目的是判断下次允许猎食的时间是否已经到了。
1 | // 1. 在这里定义 `_triggerCooldown` 函数 |
第7章: 公有函数和安全性
现在来修改 feedAndMultiply ,实现冷却周期。
回顾一下这个函数,前一课上我们将其可见性设置为public。你必须仔细地检查所有声明为 public 和 external的函数,一个个排除用户滥用它们的可能,谨防安全漏洞。请记住,如果这些函数没有类似 onlyOwner 这样的函数修饰符,用户能利用各种可能的参数去调用它们。
检查完这个函数,用户就可以直接调用这个它,并传入他们所希望的 _targetDna 或 species 。打个游戏还得遵循这么多的规则,还能不能愉快地玩耍啊!
仔细观察,这个函数只需被 feedOnKitty() 调用,因此,想要防止漏洞,最简单的方法就是设其可见性为 internal。
实战演习
- 目前函数
feedAndMultiply可见性为public。我们将其改为internal以保障合约安全。因为我们不希望用户调用它的时候塞进一堆乱七八糟的 DNA。 feedAndMultiply过程需要参考cooldownTime。首先,在找到myZombie之后,添加一个require语句来检查_isReady()并将myZombie传递给它。这样用户必须等到僵尸的冷却周期结束后才能执行feedAndMultiply功能。- 在函数结束时,调用
_triggerCooldown(myZombie),标明捕猎行为触发了僵尸新的冷却周期。
1 | // 1. 使这个函数的可见性为 internal |
第8章: 进一步了解函数修饰符
相当不错!我们的僵尸现在有了“冷却定时器”功能。
接下来,我们将添加一些辅助方法。我们为您创建了一个名为 zombiehelper.sol 的新文件,并且将 zombiefeeding.sol 导入其中,这让我们的代码更整洁。
我们打算让僵尸在达到一定水平后,获得特殊能力。但是达到这个小目标,我们还需要学一学什么是“函数修饰符”。
带参数的函数修饰符
之前我们已经读过一个简单的函数修饰符了:onlyOwner。函数修饰符也可以带参数。例如:
1 | // 存储用户年龄的映射 |
看到了吧, olderThan 修饰符可以像函数一样接收参数,是“宿主”函数 driveCar 把参数传递给它的修饰符的。
来,我们自己生产一个修饰符,通过传入的level参数来限制僵尸使用某些特殊功能。
实战演习
- 在
ZombieHelper中,创建一个名为aboveLevel的modifier,它接收2个参数,_level(uint类型) 以及_zombieId(uint类型)。 - 运用函数逻辑确保僵尸
zombies[_zombieId].level大于或等于_level。 - 记住,修饰符的最后一行为
_;,表示修饰符调用结束后返回,并执行调用函数余下的部分。
1 | pragma solidity ^0.4.19; |
第9章: 僵尸修饰符
现在让我们设计一些使用 aboveLevel 修饰符的函数。
作为游戏,您得有一些措施激励玩家们去升级他们的僵尸:
- 2级以上的僵尸,玩家可给他们改名。
- 20级以上的僵尸,玩家能给他们定制的 DNA。
是实现这些功能的时候了。以下是上一课的示例代码,供参考:
1 | // 存储用户年龄的映射 |
实战演习
- 创建一个名为
changeName的函数。它接收2个参数:_zombieId(uint类型)以及_newName(string类型),可见性为external。它带有一个aboveLevel修饰符,调用的时候通过_level参数传入2, 当然,别忘了同时传_zombieId参数。 - 在这个函数中,首先我们用
require语句,验证msg.sender是否就是zombieToOwner [_zombieId]。 - 然后函数将
zombies[_zombieId] .name设置为_newName。 - 在
changeName下创建另一个名为changeDna的函数。它的定义和内容几乎和changeName相同,不过它第二个参数是_newDna(uint类型),在修饰符aboveLevel的_level参数中传递20。现在,他可以把僵尸的dna设置为_newDna了。
1 | // 在这里开始 |
第10章: 利用 ‘View’ 函数节省 Gas
酷炫!现在高级别僵尸可以拥有特殊技能了,这一定会鼓动我们的玩家去打怪升级的。你喜欢的话,回头我们还能添加更多的特殊技能。
现在需要添加的一个功能是:我们的 DApp 需要一个方法来查看某玩家的整个僵尸军团 - 我们称之为 getZombiesByOwner。
实现这个功能只需从区块链中读取数据,所以它可以是一个 view 函数。这让我们不得不回顾一下“gas优化”这个重要话题。
“view” 函数不花 “gas”
当玩家从外部调用一个view函数,是不需要支付一分 gas 的。
这是因为 view 函数不会真正改变区块链上的任何数据 - 它们只是读取。因此用 view 标记一个函数,意味着告诉 web3.js,运行这个函数只需要查询你的本地以太坊节点,而不需要在区块链上创建一个事务(事务需要运行在每个节点上,因此花费 gas)。
稍后我们将介绍如何在自己的节点上设置 web3.js。但现在,你关键是要记住,在所能只读的函数上标记上表示“只读”的“external view 声明,就能为你的玩家减少在 DApp 中 gas 用量。
注意:如果一个
view函数在另一个函数的内部被调用,而调用函数与view函数的不属于同一个合约,也会产生调用成本。这是因为如果主调函数在以太坊创建了一个事务,它仍然需要逐个节点去验证。所以标记为view的函数只有在外部调用时才是免费的。
实战演习
我们来写一个”返回某玩家的整个僵尸军团“的函数。当我们从 web3.js 中调用它,即可显示某一玩家的个人资料页。
这个函数的逻辑有点复杂,我们需要好几个章节来描述它的实现。
- 创建一个名为
getZombiesByOwner的新函数。它有一个名为_owner的address类型的参数。 - 将其申明为
external view函数,这样当玩家从web3.js中调用它时,不需要花费任何 gas。 - 函数需要返回一个
uint [](uint数组)。
先这么声明着,我们将在下一章中填充函数体。
1 | // 在这里创建你的函数 |
第11章: 存储非常昂贵
Solidity 使用storage(存储)是相当昂贵的,”写入“操作尤其贵。
这是因为,无论是写入还是更改一段数据, 这都将永久性地写入区块链。”永久性“啊!需要在全球数千个节点的硬盘上存入这些数据,随着区块链的增长,拷贝份数更多,存储量也就越大。这是需要成本的!
为了降低成本,不到万不得已,避免将数据写入存储。这也会导致效率低下的编程逻辑 - 比如每次调用一个函数,都需要在 memory(内存) 中重建一个数组,而不是简单地将上次计算的数组给存储下来以便快速查找。
在大多数编程语言中,遍历大数据集合都是昂贵的。但是在 Solidity 中,使用一个标记了external view的函数,遍历比 storage 要便宜太多,因为 view 函数不会产生任何花销。 (gas可是真金白银啊!)。
我们将在下一章讨论for循环,现在我们来看一下看如何如何在内存中声明数组。
在内存中声明数组
在数组后面加上 memory关键字, 表明这个数组是仅仅在内存中创建,不需要写入外部存储,并且在函数调用结束时它就解散了。与在程序结束时把数据保存进 storage 的做法相比,内存运算可以大大节省gas开销 – 把这数组放在view里用,完全不用花钱。
以下是申明一个内存数组的例子:
1 | function getArray() external pure returns(uint[]) { |
这个小例子展示了一些语法规则,下一章中,我们将通过一个实际用例,展示它和 for 循环结合的做法。
注意:内存数组 必须 用长度参数(在本例中为
3)创建。目前不支持array.push()之类的方法调整数组大小,在未来的版本可能会支持长度修改。
实战演习
我们要要创建一个名为 getZombiesByOwner 的函数,它以uint []数组的形式返回某一用户所拥有的所有僵尸。
- 声明一个名为
result的uint [] memory'(内存变量数组) - 将其设置为一个新的
uint类型数组。数组的长度为该_owner所拥有的僵尸数量,这可通过调用ownerZombieCount [_ owner]来获取。 - 函数结束,返回
result。目前它只是个空数列,我们到下一章去实现它。
1 | function getZombiesByOwner(address _owner) external view returns(uint[]) { |
第12章: For 循环
在之前的章节中,我们提到过,函数中使用的数组是运行时在内存中通过 for 循环实时构建,而不是预先建立在存储中的。
为什么要这样做呢?
为了实现 getZombiesByOwner 函数,一种“无脑式”的解决方案是在 ZombieFactory 中存入”主人“和”僵尸军团“的映射。
1 | mapping (address => uint[]) public ownerToZombies |
然后我们每次创建新僵尸时,执行 ownerToZombies [owner] .push(zombieId) 将其添加到主人的僵尸数组中。而 getZombiesByOwner 函数也非常简单:
1 | function getZombiesByOwner(address _owner) external view returns (uint[]) { |
这个做法有问题
做法倒是简单。可是如果我们需要一个函数来把一头僵尸转移到另一个主人名下(我们一定会在后面的课程中实现的),又会发生什么?
这个“换主”函数要做到:
1.将僵尸push到新主人的 ownerToZombies 数组中, 2.从旧主的 ownerToZombies 数组中移除僵尸, 3.将旧主僵尸数组中“换主僵尸”之后的的每头僵尸都往前挪一位,把挪走“换主僵尸”后留下的“空槽”填上, 4.将数组长度减1。
但是第三步实在是太贵了!因为每挪动一头僵尸,我们都要执行一次写操作。如果一个主人有20头僵尸,而第一头被挪走了,那为了保持数组的顺序,我们得做19个写操作。
由于写入存储是 Solidity 中最费 gas 的操作之一,使得换主函数的每次调用都非常昂贵。更糟糕的是,每次调用的时候花费的 gas 都不同!具体还取决于用户在原主军团中的僵尸头数,以及移走的僵尸所在的位置。以至于用户都不知道应该支付多少 gas。
注意:当然,我们也可以把数组中最后一个僵尸往前挪来填补空槽,并将数组长度减少一。但这样每做一笔交易,都会改变僵尸军团的秩序。
由于从外部调用一个 view 函数是免费的,我们也可以在 getZombiesByOwner 函数中用一个for循环遍历整个僵尸数组,把属于某个主人的僵尸挑出来构建出僵尸数组。那么我们的 transfer 函数将会便宜得多,因为我们不需要挪动存储里的僵尸数组重新排序,总体上这个方法会更便宜,虽然有点反直觉。
使用 for 循环
for循环的语法在 Solidity 和 JavaScript 中类似。
来看一个创建偶数数组的例子:
1 | function getEvens() pure external returns(uint[]) { |
这个函数将返回一个形为 [2,4,6,8,10] 的数组。
实战演习
我们回到 getZombiesByOwner 函数, 通过一条 for 循环来遍历 DApp 中所有的僵尸, 将给定的‘用户id’与每头僵尸的‘主人’进行比较,并在函数返回之前将它们推送到我们的result 数组中。
1.声明一个变量 counter,属性为 uint,设其值为 0 。我们用这个变量作为 result 数组的索引。
2.声明一个 for 循环, 从 uint i = 0 到 i <zombies.length。它将遍历数组中的每一头僵尸。
3.在每一轮 for 循环中,用一个 if 语句来检查 zombieToOwner [i] 是否等于 _owner。这会比较两个地址是否匹配。
4.在 if 语句中:
- 通过将
result [counter]设置为i,将僵尸ID添加到result数组中。 - 将counter加1(参见上面的for循环示例)。
就是这样 - 这个函数能返回 _owner 所拥有的僵尸数组,不花一分钱 gas。
1 | function getZombiesByOwner(address _owner) external view returns(uint[]) { |
第13章: 放在一起
恭喜您啊,居然把第三课也学完了!
让我们回顾一下:
- 添加了一种新方法来修改CryptoKitties合约
- 学会使用
onlyOwner进行调用权限限制 - 了解了 gas 和 gas 的优化
- 为僵尸添加了 “级别” 和 “冷却周期”属性
- 当僵尸达到一定级别时,允许修改僵尸的名字和 DNA
- 最后,定义了一个函数,用以返回某个玩家的僵尸军团
领奖时间
作为完成第三课的奖励,您的两个僵尸都已经升级了!
现在 NoName(你在第2课创建的小猫僵尸)已经升级到第2级,你可以调用 changeName 给它取个名字。 终于不再是无名之辈了!
去给您的 NoName 取个名字吧,等你做完下一章,本课程就结束了。
lesson4 僵尸作战系统
第1章: 可支付
截至目前,我们只接触到很少的 函数修饰符。 要记住所有的东西很难,所以我们来个概览:
- 我们有决定函数何时和被谁调用的可见性修饰符:
private意味着它只能被合约内部调用;internal就像private但是也能被继承的合约调用;external只能从合约外部调用;最后public可以在任何地方调用,不管是内部还是外部。 - 我们也有状态修饰符, 告诉我们函数如何和区块链交互:
view告诉我们运行这个函数不会更改和保存任何数据;pure告诉我们这个函数不但不会往区块链写数据,它甚至不从区块链读取数据。这两种在被从合约外部调用的时候都不花费任何gas(但是它们在被内部其他函数调用的时候将会耗费gas)。 - 然后我们有了自定义的
modifiers,例如在第三课学习的:onlyOwner和aboveLevel。 对于这些修饰符我们可以自定义其对函数的约束逻辑。
这些修饰符可以同时作用于一个函数定义上:
1 | function test() external view onlyOwner anotherModifier { /* ... */ } |
在这一章,我们来学习一个新的修饰符 payable.
payable 修饰符
payable 方法是让 Solidity 和以太坊变得如此酷的一部分 —— 它们是一种可以接收以太的特殊函数。
先放一下。当你在调用一个普通网站服务器上的API函数的时候,你无法用你的函数传送美元——你也不能传送比特币。
但是在以太坊中, 因为钱 (以太), 数据 (事务负载), 以及合约代码本身都存在于以太坊。你可以在同时调用函数 并付钱给另外一个合约。
这就允许出现很多有趣的逻辑, 比如向一个合约要求支付一定的钱来运行一个函数。
来看个例子
1 | contract OnlineStore { |
在这里,msg.value 是一种可以查看向合约发送了多少以太的方法,另外 ether 是一个內建单元。
这里发生的事是,一些人会从 web3.js 调用这个函数 (从DApp的前端), 像这样 :
1 | // 假设 `OnlineStore` 在以太坊上指向你的合约: |
注意这个 value 字段, JavaScript 调用来指定发送多少(0.001)以太。如果把事务想象成一个信封,你发送到函数的参数就是信的内容。 添加一个 value 很像在信封里面放钱 —— 信件内容和钱同时发送给了接收者。
注意: 如果一个函数没标记为
payable, 而你尝试利用上面的方法发送以太,函数将拒绝你的事务。
实战演习
我们来在僵尸游戏里面创建一个payable 函数。
假定在我们的游戏中,玩家可以通过支付ETH来升级他们的僵尸。ETH将存储在你拥有的合约中 —— 一个简单明了的例子,向你展示你可以通过自己的游戏赚钱。
- 定义一个
uint,命名为levelUpFee, 将值设定为0.001 ether。 - 定义一个名为
levelUp的函数。 它将接收一个uint参数_zombieId。 函数应该修饰为external以及payable。 - 这个函数首先应该
require确保msg.value等于levelUpFee。 - 然后它应该增加僵尸的
level:zombies[_zombieId].level++。
1 | // 1. 在这里定义 levelUpFee |
第2章: 提现
在上一章,我们学习了如何向合约发送以太,那么在发送之后会发生什么呢?
在你发送以太之后,它将被存储进以合约的以太坊账户中, 并冻结在哪里 —— 除非你添加一个函数来从合约中把以太提现。
你可以写一个函数来从合约中提现以太,类似这样:
1 | contract GetPaid is Ownable { |
注意我们使用 Ownable 合约中的 owner 和 onlyOwner,假定它已经被引入了。
你可以通过 transfer 函数向一个地址发送以太, 然后 this.balance 将返回当前合约存储了多少以太。 所以如果100个用户每人向我们支付1以太, this.balance 将是100以太。
你可以通过 transfer 向任何以太坊地址付钱。 比如,你可以有一个函数在 msg.sender 超额付款的时候给他们退钱:
1 | uint itemFee = 0.001 ether; |
或者在一个有卖家和卖家的合约中, 你可以把卖家的地址存储起来, 当有人买了它的东西的时候,把买家支付的钱发送给它 seller.transfer(msg.value)。
有很多例子来展示什么让以太坊编程如此之酷 —— 你可以拥有一个不被任何人控制的去中心化市场。
实战演习
在我们的合约里创建一个
withdraw函数,它应该几乎和上面的GetPaid一样。以太的价格在过去几年内翻了十几倍,在我们写这个教程的时候 0.01 以太相当于1美元,如果它再翻十倍 0.001 以太将是10美元,那我们的游戏就太贵了。
所以我们应该再创建一个函数,允许我们以合约拥有者的身份来设置
levelUpFee。a. 创建一个函数,名为
setLevelUpFee, 其接收一个参数uint _fee,是external并使用修饰符onlyOwner。b. 这个函数应该设置
levelUpFee等于_fee。
1 | // 1. 在这里创建 withdraw 函数 |
第3章: 僵尸战斗
在我们学习了可支付函数和合约余额之后,是时候为僵尸战斗添加功能了。
遵循上一章的格式,我们新建一个攻击功能合约,并将代码放进新的文件中,引入上一个合约。
实战演习
再来新建一个合约吧。熟能生巧。
如果你不记得怎么做了, 查看一下 zombiehelper.sol — 不过最好先试着做一下,检查一下你掌握的情况。
- 在文件开头定义 Solidity 的版本
^0.4.19. import自zombiehelper.sol.- 声明一个新的
contract,命名为ZombieBattle, 继承自ZombieHelper。函数体就先空着吧。
1 | pragma solidity ^0.4.19; |
第4章: 随机数
你太棒了!接下来我们梳理一下战斗逻辑。
优秀的游戏都需要一些随机元素,那么我们在 Solidity 里如何生成随机数呢?
真正的答案是你不能,或者最起码,你无法安全地做到这一点。
我们来看看为什么
用 keccak256 来制造随机数
Solidity 中最好的随机数生成器是 keccak256 哈希函数.
我们可以这样来生成一些随机数
1 | // 生成一个0到100的随机数: |
这个方法首先拿到 now 的时间戳、 msg.sender、 以及一个自增数 nonce (一个仅会被使用一次的数,这样我们就不会对相同的输入值调用一次以上哈希函数了)。
然后利用 keccak 把输入的值转变为一个哈希值, 再将哈希值转换为 uint, 然后利用 % 100 来取最后两位, 就生成了一个0到100之间随机数了。
这个方法很容易被不诚实的节点攻击
在以太坊上, 当你在和一个合约上调用函数的时候, 你会把它广播给一个节点或者在网络上的 *transaction* 节点们。 网络上的节点将收集很多事务, 试着成为第一个解决计算密集型数学问题的人,作为“工作证明”,然后将“工作证明”(Proof of Work, PoW)和事务一起作为一个 *block* 发布在网络上。
一旦一个节点解决了一个PoW, 其他节点就会停止尝试解决这个 PoW, 并验证其他节点的事务列表是有效的,然后接受这个节点转而尝试解决下一个节点。
这就让我们的随机数函数变得可利用了
我们假设我们有一个硬币翻转合约——正面你赢双倍钱,反面你输掉所有的钱。假如它使用上面的方法来决定是正面还是反面 (random >= 50 算正面, random < 50 算反面)。
如果我正运行一个节点,我可以 只对我自己的节点 发布一个事务,且不分享它。 我可以运行硬币翻转方法来偷窥我的输赢 — 如果我输了,我就不把这个事务包含进我要解决的下一个区块中去。我可以一直运行这个方法,直到我赢得了硬币翻转并解决了下一个区块,然后获利。
所以我们该如何在以太坊上安全地生成随机数呢
因为区块链的全部内容对所有参与者来说是透明的, 这就让这个问题变得很难,它的解决方法不在本课程讨论范围,你可以阅读 这个 StackOverflow 上的讨论 来获得一些主意。 一个方法是利用 *oracle* 来访问以太坊区块链之外的随机数函数。
当然, 因为网络上成千上万的以太坊节点都在竞争解决下一个区块,我能成功解决下一个区块的几率非常之低。 这将花费我们巨大的计算资源来开发这个获利方法 — 但是如果奖励异常地高(比如我可以在硬币翻转函数中赢得 1个亿), 那就很值得去攻击了。
所以尽管这个方法在以太坊上不安全,在实际中,除非我们的随机函数有一大笔钱在上面,你游戏的用户一般是没有足够的资源去攻击的。
因为在这个教程中,我们只是在编写一个简单的游戏来做演示,也没有真正的钱在里面,所以我们决定接受这个不足之处,使用这个简单的随机数生成函数。但是要谨记它是不安全的。
实战演习
我们来实现一个随机数生成函数,好来计算战斗的结果。虽然这个函数一点儿也不安全。
- 给我们合约一个名为
randNonce的uint,将其值设置为0。 - 建立一个函数,命名为
randMod(random-modulus)。它将作为internal函数,传入一个名为_modulus的uint,并returns一个uint。 - 这个函数首先将为
randNonce加一, (使用randNonce++语句)。 - 最后,它应该 (在一行代码中) 计算
now,msg.sender, 以及randNonce的keccak256哈希值并转换为uint—— 最后return% _modulus的值。 (天! 听起来太拗口了。如果你有点理解不过来,看一下我们上面计算随机数的例子,它们的逻辑非常相似)
1 | contract ZombieBattle is ZombieHelper { |
第5章: 僵尸对战
我们的合约已经有了一些随机性的来源,可以用进我们的僵尸战斗中去计算结果。
我们的僵尸战斗看起来将是这个流程:
- 你选择一个自己的僵尸,然后选择一个对手的僵尸去攻击。
- 如果你是攻击方,你将有70%的几率获胜,防守方将有30%的几率获胜。
- 所有的僵尸(攻守双方)都将有一个
winCount和一个lossCount,这两个值都将根据战斗结果增长。 - 若攻击方获胜,这个僵尸将升级并产生一个新僵尸。
- 如果攻击方失败,除了失败次数将加一外,什么都不会发生。
- 无论输赢,当前僵尸的冷却时间都将被激活。
这有一大堆的逻辑需要处理,我们将把这些步骤分解到接下来的课程中去。
实战演习
- 给我们合约一个
uint类型的变量,命名为attackVictoryProbability, 将其值设定为70。 - 创建一个名为
attack的函数。它将传入两个参数:_zombieId(uint类型) 以及_targetId(也是uint)。它将是一个external函数。
函数体先留空吧。
1 | // 在这里创建 attackVictoryProbability |
第6章: 重构通用逻辑
不管谁调用我们的 attack 函数 —— 我们想确保用户的确拥有他们用来攻击的僵尸。如果你能用其他人的僵尸来攻击将是一个很大的安全问题。
你能想一下我们如何添加一个检查步骤来看看调用这个函数的人就是他们传入的 _zombieId 的拥有者么?
想一想,看看你能不能自己找到一些答案。
花点时间…… 参考我们前面课程的代码来获得灵感。
答案在下面,在你有一些想法之前不要继续阅读。
答案
我们在前面的课程里面已经做过很多次这样的检查了。 在 changeName(), changeDna(), 和 feedAndMultiply()里,我们做过这样的检查:
1 | require(msg.sender == zombieToOwner[_zombieId]); |
这和我们 attack 函数将要用到的检查逻辑是相同的。 正因我们要多次调用这个检查逻辑,让我们把它移到它自己的 modifier 中来清理代码并避免重复编码。
实战演习
我们回到了 zombiefeeding.sol, 因为这是我们第一次调用检查逻辑的地方。让我们把它重构进它自己的 modifier。
创建一个
modifier, 命名为ownerOf。它将传入一个参数,_zombieId(一个uint)。它的函数体应该
requiremsg.sender等于zombieToOwner[_zombieId], 然后继续这个函数剩下的内容。 如果你忘记了修饰符的写法,可以参考zombiehelper.sol。将这个函数的
feedAndMultiply定义修改为其使用修饰符ownerOf。现在我们使用
modifier了,你可以删除这行了:require(msg.sender == zombieToOwner[_zombieId]);
1 | // 1. 在这里创建 modifier |
第7章: 更多重构
在 zombiehelper.sol里有几处地方,需要我们实现我们新的 modifier—— ownerOf。
实战演习
- 修改
changeName()使其使用ownerOf - 修改
changeDna()使其使用ownerOf
1 | // 1. 使用 `ownerOf` 修改这个函数: |
第8章: 回到攻击!
重构完成了,回到 zombieattack.sol。
继续来完善我们的 attack 函数, 现在我们有了 ownerOf 修饰符来用了。
实战演习
将
ownerOf修饰符添加到attack来确保调用者拥有_zombieId.我们的函数所需要做的第一件事就是获得一个双方僵尸的
storage指针, 这样我们才能很方便和它们交互:a. 定义一个
Zombie storage命名为myZombie,使其值等于zombies[_zombieId]。b. 定义一个
Zombie storage命名为enemyZombie, 使其值等于zombies[_targetId]。我们将用一个0到100的随机数来确定我们的战斗结果。 定义一个
uint,命名为rand, 设定其值等于randMod函数的返回值,此函数传入100作为参数。
1 | // 1. 在这里增加 modifier |
第9章: 僵尸的输赢
对我们的僵尸游戏来说,我们将要追踪我们的僵尸输赢了多少场。有了这个我们可以在游戏里维护一个 “僵尸排行榜”。
有多种方法在我们的DApp里面保存一个数值 — 作为一个单独的映射,作为一个“排行榜”结构体,或者保存在 Zombie 结构体内。
每个方法都有其优缺点,取决于我们打算如何和这些数据打交道。在这个教程中,简单起见我们将这个状态保存在 Zombie 结构体中,将其命名为 winCount 和 lossCount。
我们跳回 zombiefactory.sol, 将这些属性添加进 Zombie 结构体.
实战演习
修改
Zombie结构体,添加两个属性:a.
winCount, 一个uint16b.
lossCount, 也是一个uint16注意: 记住, 因为我们能在结构体中包装
uint, 我们打算用适合我们的最小的uint。 一个uint8太小了, 因为 2^8 = 256 —— 如果我们的僵尸每天都作战,不到一年就溢出了。但是 2^16 = 65536 (uint16)—— 除非一个僵尸连续179年每天作战,否则我们就是安全的。现在我们的
Zombie结构体有了新的属性, 我们需要修改_createZombie()中的函数定义。修改僵尸生成定义,让每个新僵尸都有
0赢和0输。
1 | struct Zombie { |
第10章: 僵尸胜利了 😄
有了 winCount 和 lossCount,我们可以根据僵尸哪个僵尸赢了战斗来更新它们了。
在第六章我们计算出来一个0到100的随机数。现在让我们用那个数来决定那谁赢了战斗,并以此更新我们的状态。
实战演习
创建一个
if语句来检查rand是不是 *小于或者等于*attackVictoryProbability。如果以上条件为
true, 我们的僵尸就赢了!所以:a. 增加
myZombie的winCount。b. 增加
myZombie的level。 (升级了啦!!!!!!!)c. 增加
enemyZombie的lossCount. (输家!!!!!! 😫 😫 😫)d. 运行
feedAndMultiply函数。 在zombiefeeding.sol里查看调用它的语句。 对于第三个参数 (_species),传入字符串 “zombie”. (现在它实际上什么都不做,不过在稍后, 如果我们愿意,可以添加额外的方法,用来制造僵尸变的僵尸)。
1 | function attack(uint _zombieId, uint _targetId) external ownerOf(_zombieId) { |
第11章: 僵尸失败 😞
我们已经编写了你的僵尸赢了之后会发生什么, 该看看 输了 的时候要怎么做了。
在我们的游戏中,僵尸输了后并不会降级 —— 只是简单地给 lossCount 加一,并触发冷却,等待一天后才能再次参战。
要实现这个逻辑,我们需要一个 else 语句。
else 语句和 JavaScript 以及很多其他语言的 else 语句一样。
1 | if (zombieCoins[msg.sender] > 100000000) { |
实战演习
添加一个
else语句。 若我们的僵尸输了:a. 增加
myZombie的lossCount。b. 增加
enemyZombie的winCount。在
else最后, 对myZombie运行_triggerCooldown方法。这让每个僵尸每天只能参战一次。
1 | function attack(uint _zombieId, uint _targetId) external ownerOf(_zombieId) { |
lesson5 ERC721 标准和加密收藏品
第1章: 以太坊上的代币
让我们来聊聊 代币.
如果你对以太坊的世界有一些了解,你很可能听过人们聊到代币——尤其是 *ERC20 代币*.
一个 代币 在以太坊基本上就是一个遵循一些共同规则的智能合约——即它实现了所有其他代币合约共享的一组标准函数,例如 transfer(address _to, uint256 _value) 和 balanceOf(address _owner).
在智能合约内部,通常有一个映射, mapping(address => uint256) balances,用于追踪每个地址还有多少余额。
所以基本上一个代币只是一个追踪谁拥有多少该代币的合约,和一些可以让那些用户将他们的代币转移到其他地址的函数。
它为什么重要呢?
由于所有 ERC20 代币共享具有相同名称的同一组函数,它们都可以以相同的方式进行交互。
这意味着如果你构建的应用程序能够与一个 ERC20 代币进行交互,那么它就也能够与任何 ERC20 代币进行交互。 这样一来,将来你就可以轻松地将更多的代币添加到你的应用中,而无需进行自定义编码。 你可以简单地插入新的代币合约地址,然后哗啦,你的应用程序有另一个它可以使用的代币了。
其中一个例子就是交易所。 当交易所添加一个新的 ERC20 代币时,实际上它只需要添加与之对话的另一个智能合约。 用户可以让那个合约将代币发送到交易所的钱包地址,然后交易所可以让合约在用户要求取款时将代币发送回给他们。
交易所只需要实现这种转移逻辑一次,然后当它想要添加一个新的 ERC20 代币时,只需将新的合约地址添加到它的数据库即可。
其他代币标准
对于像货币一样的代币来说,ERC20 代币非常酷。 但是要在我们僵尸游戏中代表僵尸就并不是特别有用。
首先,僵尸不像货币可以分割 —— 我可以发给你 0.237 以太,但是转移给你 0.237 的僵尸听起来就有些搞笑。
其次,并不是所有僵尸都是平等的。 你的2级僵尸”Steve“完全不能等同于我732级的僵尸”H4XF13LD MORRIS 💯💯😎💯💯“。(你差得远呢,Steve)。
有另一个代币标准更适合如 CryptoZombies 这样的加密收藏品——它们被称为*ERC721 代币.*
*ERC721 代币*是不能互换的,因为每个代币都被认为是唯一且不可分割的。 你只能以整个单位交易它们,并且每个单位都有唯一的 ID。 这些特性正好让我们的僵尸可以用来交易。
请注意,使用像 ERC721 这样的标准的优势就是,我们不必在我们的合约中实现拍卖或托管逻辑,这决定了玩家能够如何交易/出售我们的僵尸。 如果我们符合规范,其他人可以为加密可交易的 ERC721 资产搭建一个交易所平台,我们的 ERC721 僵尸将可以在该平台上使用。 所以使用代币标准相较于使用你自己的交易逻辑有明显的好处。
实战演习
我们将在下一章深入讨论ERC721的实现。 但首先,让我们为本课设置我们的文件结构。
我们将把所有ERC721逻辑存储在一个叫ZombieOwnership的合约中。
- 在文件顶部声明我们
pragma的版本(格式参考之前的课程)。 - 将
zombieattack.solimport进来。 - 声明一个继承
ZombieAttack的新合约, 命名为ZombieOwnership。合约的其他部分先留空。
1 | // 从这里开始 |
第2章: ERC721 标准, 多重继承
让我们来看一看 ERC721 标准:
1 | contract ERC721 { |
这是我们需要实现的方法列表,我们将在接下来的章节中逐个学习。
虽然看起来很多,但不要被吓到了!我们在这里就是准备带着你一步一步了解它们的。
注意: ERC721目前是一个 草稿,还没有正式商定的实现。在本教程中,我们使用的是 OpenZeppelin 库中的当前版本,但在未来正式发布之前它可能会有更改。 所以把这 一个 可能的实现当作考虑,但不要把它作为 ERC721 代币的官方标准。
实现一个代币合约
在实现一个代币合约的时候,我们首先要做的是将接口复制到它自己的 Solidity 文件并导入它,import "./erc721.sol";。 接着,让我们的合约继承它,然后我们用一个函数定义来重写每个方法。
但等一下—— ZombieOwnership已经继承自 ZombieAttack了 —— 它如何能够也继承于 ERC721呢?
幸运的是在Solidity,你的合约可以继承自多个合约,参考如下:
1 | contract SatoshiNakamoto is NickSzabo, HalFinney { |
正如你所见,当使用多重继承的时候,你只需要用逗号 , 来隔开几个你想要继承的合约。在上面的例子中,我们的合约继承自 NickSzabo 和 HalFinney。
来试试吧。
实战演习
我们已经在上面为你创建了带着接口的 erc721.sol 。
- 将
erc721.sol导入到zombieownership.sol - 声明
ZombieOwnership继承自ZombieAttack和ERC721
1 | pragma solidity ^0.4.19; |
第3章: balanceOf 和 ownerOf
太棒了,我们来深入讨论一下 ERC721 的实现。
我们已经把所有你需要在本课中实现的函数的空壳复制好了。
在本章节,我们将实现头两个方法: balanceOf 和 ownerOf。
balanceOf
1 | function balanceOf(address _owner) public view returns (uint256 _balance); |
这个函数只需要一个传入 address 参数,然后返回这个 address 拥有多少代币。
在我们的例子中,我们的“代币”是僵尸。你还记得在我们 DApp 的哪里存储了一个主人拥有多少只僵尸吗?
ownerOf
1 | function ownerOf(uint256 _tokenId) public view returns (address _owner); |
这个函数需要传入一个代币 ID 作为参数 (我们的情况就是一个僵尸 ID),然后返回该代币拥有者的 address。
同样的,因为在我们的 DApp 里已经有一个 mapping (映射) 存储了这个信息,所以对我们来说这个实现非常直接清晰。我们可以只用一行 return 语句来实现这个函数。
注意:要记得,
uint256等同于uint。我们从课程的开始一直在代码中使用uint,但从现在开始我们将在这里用uint256,因为我们直接从规范中复制粘贴。
实战演习
我将让你来决定如何实现这两个函数。
每个函数的代码都应该只有1行 return 语句。看看我们在之前课程中写的代码,想想我们都把这个数据存储在哪。如果你觉得有困难,你可以点“我要看答案”的按钮来获得帮助。
- 实现
balanceOf来返回_owner拥有的僵尸数量。 - 实现
ownerOf来返回拥有 ID 为_tokenId僵尸的所有者的地址。
1 | function balanceOf(address _owner) public view returns (uint256 _balance) { |
第4章: 重构
嘿嘿!我们刚刚的代码中其实有个错误,以至于其根本无法通过编译,你发现了没?
在前一个章节我们定义了一个叫 ownerOf 的函数。但如果你还记得第4课的内容,我们同样在zombiefeeding.sol 里以 ownerOf 命名创建了一个 modifier(修饰符)。
如果你尝试编译这段代码,编译器会给你一个错误说你不能有相同名称的修饰符和函数。
所以我们应该把在 ZombieOwnership 里的函数名称改成别的吗?
不,我们不能那样做!!!要记得,我们正在用 ERC721 代币标准,意味着其他合约将期望我们的合约以这些确切的名称来定义函数。这就是这些标准实用的原因——如果另一个合约知道我们的合约符合 ERC721 标准,它可以直接与我们交互,而无需了解任何关于我们内部如何实现的细节。
所以,那意味着我们将必须重构我们第4课中的代码,将 modifier 的名称换成别的。
实战演习
我们回到了 zombiefeeding.sol 。我们将把 modifier 的名称从 ownerOf 改成 onlyOwnerOf。
- 把修饰符定义中的名称改成
onlyOwnerOf - 往下滑到使用此修饰符的函数
feedAndMultiply。我们也需要改这里的名称。
注意:我们在
zombiehelper.sol和zombieattack.sol里也使用了这个修饰符,但为了不在这节课的重构里花太多时间,我们已经将那些文件里的修饰符名称为你改好了。
1 | // 1. 把修饰符名称改成 `onlyOwnerOf` |
第5章: ERC721: 转移标准
好了,我们将冲突修复了!
现在我们将通过学习把所有权从一个人转移给另一个人来继续我们的 ERC721 规范的实现。
注意 ERC721 规范有两种不同的方法来转移代币:
1 | function transfer(address _to, uint256 _tokenId) public; |
- 第一种方法是代币的拥有者调用
transfer方法,传入他想转移到的address和他想转移的代币的_tokenId。 - 第二种方法是代币拥有者首先调用
approve,然后传入与以上相同的参数。接着,该合约会存储谁被允许提取代币,通常存储到一个mapping (uint256 => address)里。然后,当有人调用takeOwnership时,合约会检查msg.sender是否得到拥有者的批准来提取代币,如果是,则将代币转移给他。
你注意到了吗,transfer 和 takeOwnership 都将包含相同的转移逻辑,只是以相反的顺序。 (一种情况是代币的发送者调用函数;另一种情况是代币的接收者调用它)。
所以我们把这个逻辑抽象成它自己的私有函数 _transfer,然后由这两个函数来调用它。 这样我们就不用写重复的代码了。
实战演习
让我们来定义 _transfer 的逻辑。
定义一个名为
_transfer的函数。它会需要3个参数:address _from、address _to和uint256 _tokenId。它应该是一个私有函数。我们有2个映射会在所有权改变的时候改变:
ownerZombieCount(记录一个所有者有多少只僵尸)和zombieToOwner(记录什么人拥有什么)。我们的函数需要做的第一件事是为 接收 僵尸的人(
address _to)增 加ownerZombieCount。使用++来增加。接下来,我们将需要为 发送 僵尸的人(
address _from)减少ownerZombieCount。使用--来扣减。最后,我们将改变这个
_tokenId的zombieToOwner映射,这样它现在就会指向_to。骗你的,那不是最后一步。我们还需要再做一件事情。
ERC721规范包含了一个
Transfer事件。这个函数的最后一行应该用正确的参数触发Transfer——查看erc721.sol看它期望传入的参数并在这里实现。
1 | // 在这里定义 _transfer() |
第6章: ERC721: 转移-续
太好了!刚才那是最难的部分——现在实现公共的 transfer 函数应该十分容易,因为我们的 _transfer 函数几乎已经把所有的重活都干完了。
实战演习
我们想确保只有代币或僵尸的所有者可以转移它。还记得我们如何限制只有所有者才能访问某个功能吗?
没错,我们已经有一个修饰符能够完成这个任务了。所以将修饰符
onlyOwnerOf添加到这个函数中。现在该函数的正文只需要一行代码。它只需要调用
_transfer。记得把
msg.sender作为参数传递进address _from。
1 | // 1. 在这里添加修饰符 |
第7章: ERC721: 批准
现在,让我们来实现 approve。
记住,使用 approve 或者 takeOwnership 的时候,转移有2个步骤:
- 你,作为所有者,用新主人的
address和你希望他获取的_tokenId来调用approve - 新主人用
_tokenId来调用takeOwnership,合约会检查确保他获得了批准,然后把代币转移给他。
因为这发生在2个函数的调用中,所以在函数调用之间,我们需要一个数据结构来存储什么人被批准获取什么。
实战演习
首先,让我们来定义一个映射
zombieApprovals。它应该将一个uint映射到一个address。这样一来,当有人用一个
_tokenId调用takeOwnership时,我们可以用这个映射来快速查找谁被批准获取那个代币。在函数
approve上, 我们想要确保只有代币所有者可以批准某人来获取代币。所以我们需要添加修饰符onlyOwnerOf到approve。函数的正文部分,将
_tokenId的zombieApprovals设置为和_to相等。最后,在 ERC721 规范里有一个
Approval事件。所以我们应该在这个函数的最后触发这个事件。(参考erc721.sol来确认传入的参数,并确保_owner是msg.sender)
1 | // 1. 在这里定义映射 |
第8章: ERC721: takeOwnership
太棒了,现在让我们完成最后一个函数来结束 ERC721 的实现。(别担心,这后面我们还会讲更多内容😉)
最后一个函数 takeOwnership, 应该只是简单地检查以确保 msg.sender 已经被批准来提取这个代币或者僵尸。若确认,就调用 _transfer;
实战演习
首先,我们要用一个
require句式来检查_tokenId的zombieApprovals和msg.sender相等。这样如果
msg.sender未被授权来提取这个代币,将抛出一个错误。为了调用
_transfer,我们需要知道代币所有者的地址(它需要一个_from来作为参数)。幸运的是我们可以在我们的ownerOf函数中来找到这个参数。所以,定义一个名为
owner的address变量,并使其等于ownerOf(_tokenId)。最后,调用
_transfer, 并传入所有必须的参数。(在这里你可以用msg.sender作为_to, 因为代币正是要发送给调用这个函数的人)。注意: 我们完全可以用一行代码来实现第2、3两步。但是分开写会让代码更易读。一点个人建议 :)
1 | function takeOwnership(uint256 _tokenId) public { |
第9章: 预防溢出
恭喜你,我们完成了 ERC721 的实现。
并不是很复杂,对吧?很多类似的以太坊概念,当你只听人们谈论它们的时候,会觉得很复杂。所以最简单的理解方式就是你自己来实现它。
不过要记住那只是最简单的实现。还有很多的特性我们也许想加入到我们的实现中来,比如一些额外的检查,来确保用户不会不小心把他们的僵尸转移给0 地址(这被称作 “烧币”, 基本上就是把代币转移到一个谁也没有私钥的地址,让这个代币永远也无法恢复)。 或者在 DApp 中加入一些基本的拍卖逻辑。(你能想出一些实现的方法么?)
但是为了让我们的课程不至于离题太远,所以我们只专注于一些基础实现。如果你想学习一些更深层次的实现,可以在这个教程结束后,去看看 OpenZeppelin 的 ERC721 合约。
合约安全增强: 溢出和下溢
我们将来学习你在编写智能合约的时候需要注意的一个主要的安全特性:防止溢出和下溢。
什么是 溢出 (*overflow*)?
假设我们有一个 uint8, 只能存储8 bit数据。这意味着我们能存储的最大数字就是二进制 11111111 (或者说十进制的 2^8 - 1 = 255).
来看看下面的代码。最后 number 将会是什么值?
1 | uint8 number = 255; |
在这个例子中,我们导致了溢出 — 虽然我们加了1, 但是 number 出乎意料地等于 0了。 (如果你给二进制 11111111 加1, 它将被重置为 00000000,就像钟表从 23:59 走向 00:00)。
下溢(underflow)也类似,如果你从一个等于 0 的 uint8 减去 1, 它将变成 255 (因为 uint 是无符号的,其不能等于负数)。
虽然我们在这里不使用 uint8,而且每次给一个 uint256 加 1 也不太可能溢出 (2^256 真的是一个很大的数了),在我们的合约中添加一些保护机制依然是非常有必要的,以防我们的 DApp 以后出现什么异常情况。
使用 SafeMath
为了防止这些情况,OpenZeppelin 建立了一个叫做 SafeMath 的 库(*library*),默认情况下可以防止这些问题。
不过在我们使用之前…… 什么叫做库?
一个**库** 是 Solidity 中一种特殊的合约。其中一个有用的功能是给原始数据类型增加一些方法。
比如,使用 SafeMath 库的时候,我们将使用 using SafeMath for uint256 这样的语法。 SafeMath 库有四个方法 — add, sub, mul, 以及 div。现在我们可以这样来让 uint256 调用这些方法:
1 | using SafeMath for uint256; |
我们将在下一章来学习这些方法,不过现在我们先将 SafeMath 库添加进我们的合约。
实战演习
我们已经帮你把 OpenZeppelin 的 SafeMath 库包含进 safemath.sol了,如果你想看一下代码的话,现在可以看看,不过我们下一章将深入进去。
首先我们来告诉我们的合约要使用 SafeMath。我们将在我们的 ZombieFactory 里调用,这是我们的基础合约 — 这样其他所有继承出去的子合约都可以使用这个库了。
- 将
safemath.sol引入到zombiefactory.sol. - 添加定义:
using SafeMath for uint256;.
1 | // 1. 在这里引入 |
第10章: SafeMath 第二部分
来看看 SafeMath 的部分代码:
1 | library SafeMath { |
首先我们有了 library 关键字 — 库和 合约很相似,但是又有一些不同。 就我们的目的而言,库允许我们使用 using 关键字,它可以自动把库的所有方法添加给一个数据类型:
1 | using SafeMath for uint; |
注意 mul 和 add 其实都需要两个参数。 在我们声明了 using SafeMath for uint 后,我们用来调用这些方法的 uint 就自动被作为第一个参数传递进去了(在此例中就是 test)
我们来看看 add 的源代码看 SafeMath 做了什么:
1 | function add(uint256 a, uint256 b) internal pure returns (uint256) { |
基本上 add 只是像 + 一样对两个 uint 相加, 但是它用一个 assert 语句来确保结果大于 a。这样就防止了溢出。
assert 和 require 相似,若结果为否它就会抛出错误。 assert 和 require 区别在于,require 若失败则会返还给用户剩下的 gas, assert 则不会。所以大部分情况下,你写代码的时候会比较喜欢 require,assert 只在代码可能出现严重错误的时候使用,比如 uint 溢出。
所以简而言之, SafeMath 的 add, sub, mul, 和 div 方法只做简单的四则运算,然后在发生溢出或下溢的时候抛出错误。
在我们的代码里使用 SafeMath
为了防止溢出和下溢,我们可以在我们的代码里找 +, -, *, 或 /,然后替换为 add, sub, mul, div.
比如,与其这样做:
1 | myUint++; |
我们这样做:
1 | myUint = myUint.add(1); |
实战演习
在 ZombieOwnership 中有两个地方用到了数学运算,来替换成 SafeMath 方法把。
- 将
++替换成 SafeMath 方法。 - 将
--替换成 SafeMath 方法。
1 | function _transfer(address _from, address _to, uint256 _tokenId) private { |
第11章: SafeMath 第三部分
太好了,这下我们的 ERC721 实现不会有溢出或者下溢了。
回头看看我们在之前课程写的代码,还有其他几个地方也有可能导致溢出或下溢。
比如, 在 ZombieAttack 里面我们有:
1 | myZombie.winCount++; |
我们同样应该在这些地方防止溢出。(通常情况下,总是使用 SafeMath 而不是普通数学运算是个好主意,也许在以后 Solidity 的新版本里这点会被默认实现,但是现在我们得自己在代码里实现这些额外的安全措施)。
不过我们遇到个小问题 — winCount 和 lossCount 是 uint16, 而 level 是 uint32。 所以如果我们用这些作为参数传入 SafeMath 的 add 方法。 它实际上并不会防止溢出,因为它会把这些变量都转换成 uint256:
1 | function add(uint256 a, uint256 b) internal pure returns (uint256) { |
这就意味着,我们需要再实现两个库来防止 uint16 和 uint32 溢出或下溢。我们可以将其命名为 SafeMath16 和 SafeMath32。
代码将和 SafeMath 完全相同,除了所有的 uint256 实例都将被替换成 uint32 或 uint16。
我们已经将这些代码帮你写好了,打开 safemath.sol 合约看看代码吧。
现在我们需要在 ZombieFactory 里使用它们。
Putting it to the Test
分配:
- 声明我们将为
uint32使用SafeMath32。 - 声明我们将为
uint16使用SafeMath16。 - 在 ZombieFactory 里还有一处我们也应该使用 SafeMath 的方法, 我们已经在那里留了注释提醒你。
1 | contract ZombieFactory is Ownable { |
第12章: SafeMath 第4部分
真棒,现在我们已经为我们的 DApp 里面用到的 uint 数据类型都实现了 SafeMath 了。
让我们把 ZombieAttack 里所有潜在的问题都修复了吧。 (其实在 ZombieHelper 里也有一处 zombies[_zombieId].level++; 需要修复,不过我们已经帮你做好了,这样我们就不用再来一章了 😉)。
实战演习
放心大胆去对 ZombieAttack 里所有的 ++ 操作都使用 SafeMath 方法吧。为了方便你找,我们已经在相应的地方留了注释给你。
1 | pragma solidity ^0.4.19; |
第13章: 注释
僵尸游戏的 Solidity 代码终于完成啦。
在以后的课程中,我们将学习如何将游戏部署到以太坊,以及如何和 Web3.js 交互。
不过在你离开第五课之前,我们来谈谈如何 给你的代码添加注释.
注释语法
Solidity 里的注释和 JavaScript 相同。在我们的课程中你已经看到了不少单行注释了:
1 | // 这是一个单行注释,可以理解为给自己或者别人看的笔记 |
只要在任何地方添加一个 // 就意味着你在注释。如此简单所以你应该经常这么做。
不过我们也知道你的想法:有时候单行注释是不够的。毕竟你生来话痨。
所以我们有了多行注释:
1 | contract CryptoZombies { |
特别是,最好为你合约中每个方法添加注释来解释它的预期行为。这样其他开发者(或者你自己,在6个月以后再回到这个项目中)可以很快地理解你的代码而不需要逐行阅读所有代码。
Solidity 社区所使用的一个标准是使用一种被称作 *natspec* 的格式,看起来像这样:
1 | /// @title 一个简单的基础运算合约 |
@title(标题) 和 @author (作者)很直接了.
@notice (须知)向 用户 解释这个方法或者合约是做什么的。 @dev (开发者) 是向开发者解释更多的细节。
@param (参数)和 @return (返回) 用来描述这个方法需要传入什么参数以及返回什么值。
注意你并不需要每次都用上所有的标签,它们都是可选的。不过最少,写下一个 @dev 注释来解释每个方法是做什么的。
实战演习
如果你还没注意到:CryptoZombies 的答案检查器在工作的时候将忽略所有的注释。所以这一章我们其实无法检查你的 natspec 注释了。全靠你自己咯。
话说回来,到现在你应该已经是一个 Solidity 小能手了。我们就假定你已经学会这些了。
大胆去做些尝试把,给 ZombieOwnership 加上一些 natspec 标签:
@title— 例如:一个管理转移僵尸所有权的合约@author— 你的名字@dev— 例如:符合 OpenZeppelin 对 ERC721 标准草案的实现
1 | pragma solidity ^0.4.19; |
总结一下
这节课里面我们学到了
- 代币, ERC721 标准,以及可交易的物件/僵尸
- 库以及如何使用库
- 如何利用 SafeMath 来防止溢出和下溢
- 代码注释和 natspec 标准
在接下来的两节课中,我们将学习如何将游戏部署到以太坊以及和 *web3.js* 交互 (这样你就能为你的 DApp 打造一个界面了 )。
lesson6 应用前端和 Web3.js
第1章: 介绍 Web3.js
完成第五课以后,我们的僵尸 DApp 的 Solidity 合约部分就完成了。现在我们来做一个基本的网页好让你的用户能玩它。 要做到这一点,我们将使用以太坊基金发布的 JavaScript 库 —— *Web3.js*.
什么是 Web3.js?
还记得么?以太坊网络是由节点组成的,每一个节点都包含了区块链的一份拷贝。当你想要调用一份智能合约的一个方法,你需要从其中一个节点中查找并告诉它:
- 智能合约的地址
- 你想调用的方法,以及
- 你想传入那个方法的参数
以太坊节点只能识别一种叫做 *JSON-RPC* 的语言。这种语言直接读起来并不好懂。当你你想调用一个合约的方法的时候,需要发送的查询语句将会是这样的:
1 | // 哈……祝你写所有这样的函数调用的时候都一次通过 |
幸运的是 Web3.js 把这些令人讨厌的查询语句都隐藏起来了, 所以你只需要与方便易懂的 JavaScript 界面进行交互即可。
你不需要构建上面的查询语句,在你的代码中调用一个函数看起来将是这样:
1 | CryptoZombies.methods.createRandomZombie("Vitalik Nakamoto 🤔") |
我们将在接下来的几章详细解释这些语句,不过首先我们来把 Web3.js 环境搭建起来。
准备好了么?
取决于你的项目工作流程和你的爱好,你可以用一些常用工具把 Web3.js 添加进来:
1 | // 用 NPM |
甚至,你可以从 github 直接下载压缩后的 .js 文件 然后包含到你的项目文件中:
1 | <script language="javascript" type="text/javascript" src="web3.min.js"></script> |
因为我们不想让你花太多在项目环境搭建上,在本教程中我们将使用上面的 script 标签来将 Web3.js 引入。
实战演习
我们为你建立了一个HTML 项目空壳 —— index.html。假设在和 index.html 同个文件夹里有一份 web3.min.js
- 使用上面的
script标签代码把web3.js添加进去以备接下来使用。
1 | <!DOCTYPE html> |
第2章: Web3 提供者
太棒了。现在我们的项目中有了Web3.js, 来初始化它然后和区块链对话吧。
首先我们需要 *Web3 Provider*.
要记住,以太坊是由共享同一份数据的相同拷贝的 节点 构成的。 在 Web3.js 里设置 Web3 的 Provider(提供者) 告诉我们的代码应该和 哪个节点 交互来处理我们的读写。这就好像在传统的 Web 应用程序中为你的 API 调用设置远程 Web 服务器的网址。
你可以运行你自己的以太坊节点来作为 Provider。 不过,有一个第三方的服务,可以让你的生活变得轻松点,让你不必为了给你的用户提供DApp而维护一个以太坊节点— *Infura*.
Infura
Infura 是一个服务,它维护了很多以太坊节点并提供了一个缓存层来实现高速读取。你可以用他们的 API 来免费访问这个服务。 用 Infura 作为节点提供者,你可以不用自己运营节点就能很可靠地向以太坊发送、接收信息。
你可以通过这样把 Infura 作为你的 Web3 节点提供者:
1 | var web3 = new Web3(new Web3.providers.WebsocketProvider("wss://mainnet.infura.io/ws")); |
不过,因为我们的 DApp 将被很多人使用,这些用户不单会从区块链读取信息,还会向区块链 写 入信息,我们需要用一个方法让用户可以用他们的私钥给事务签名。
注意: 以太坊 (以及通常意义上的 blockchains )使用一个公钥/私钥对来对给事务做数字签名。把它想成一个数字签名的异常安全的密码。这样当我修改区块链上的数据的时候,我可以用我的公钥来 证明 我就是签名的那个。但是因为没人知道我的私钥,所以没人能伪造我的事务。
加密学非常复杂,所以除非你是个专家并且的确知道自己在做什么,你最好不要在你应用的前端中管理你用户的私钥。
不过幸运的是,你并不需要,已经有可以帮你处理这件事的服务了: *Metamask*.
Metamask
Metamask 是 Chrome 和 Firefox 的浏览器扩展, 它能让用户安全地维护他们的以太坊账户和私钥, 并用他们的账户和使用 Web3.js 的网站互动(如果你还没用过它,你肯定会想去安装的——这样你的浏览器就能使用 Web3.js 了,然后你就可以和任何与以太坊区块链通信的网站交互了)
作为开发者,如果你想让用户从他们的浏览器里通过网站和你的DApp交互(就像我们在 CryptoZombies 游戏里一样),你肯定会想要兼容 Metamask 的。
注意: Metamask 默认使用 Infura 的服务器做为 web3 提供者。 就像我们上面做的那样。不过它还为用户提供了选择他们自己 Web3 提供者的选项。所以使用 Metamask 的 web3 提供者,你就给了用户选择权,而自己无需操心这一块。
使用 Metamask 的 web3 提供者
Metamask 把它的 web3 提供者注入到浏览器的全局 JavaScript对象web3中。所以你的应用可以检查 web3 是否存在。若存在就使用 web3.currentProvider 作为它的提供者。
这里是一些 Metamask 提供的示例代码,用来检查用户是否安装了MetaMask,如果没有安装就告诉用户需要安装MetaMask来使用我们的应用。
1 | window.addEventListener('load', function() { |
你可以在你所有的应用中使用这段样板代码,好检查用户是否安装以及告诉用户安装 MetaMask。
注意: 除了MetaMask,你的用户也可能在使用其他他的私钥管理应用,比如 Mist 浏览器。不过,它们都实现了相同的模式来注入
web3变量。所以我这里描述的方法对两者是通用的。
实战演习
我们在HTML文件中的 </body> 标签前面放置了一个空的 script 标签。可以把这节课的 JavaScript 代码写在里面。
- 把上面用来检测 MetaMask 是否安装的模板代码粘贴进来。请粘贴到以
window.addEventListener开头的代码块中。
1 | <!DOCTYPE html> |
第3章: 和合约对话
现在,我们已经用 MetaMask 的 Web3 提供者初始化了 Web3.js。接下来就让它和我们的智能合约对话吧。
Web3.js 需要两个东西来和你的合约对话: 它的 地址 和它的 ***ABI***。
合约地址
在你写完了你的智能合约后,你需要编译它并把它部署到以太坊。我们将在下一课中详述部署,因为它和写代码是截然不同的过程,所以我们决定打乱顺序,先来讲 Web3.js。
在你部署智能合约以后,它将获得一个以太坊上的永久地址。如果你还记得第二课,CryptoKitties 在以太坊上的地址是 0x06012c8cf97BEaD5deAe237070F9587f8E7A266d。
你需要在部署后复制这个地址以来和你的智能合约对话。
合约 ABI
另一个 Web3.js 为了要和你的智能合约对话而需要的东西是 ***ABI***。
ABI 意为应用二进制接口(Application Binary Interface)。 基本上,它是以 JSON 格式表示合约的方法,告诉 Web3.js 如何以合同理解的方式格式化函数调用。
当你编译你的合约向以太坊部署时(我们将在第七课详述), Solidity 编译器会给你 ABI,所以除了合约地址,你还需要把这个也复制下来。
因为我们这一课不会讲述部署,所以现在我们已经帮你编译了 ABI 并放在了名为cryptozombies_abi.js,文件中,保存在一个名为 cryptoZombiesABI 的变量中。
如果我们将cryptozombies_abi.js 包含进我们的项目,我们就能通过那个变量访问 CryptoZombies ABI 。
实例化 Web3.js
一旦你有了合约的地址和 ABI,你可以像这样来实例化 Web3.js。
1 | // 实例化 myContract |
实战演习
- 在文件的
<head>标签块中,用script标签引入cryptozombies_abi.js,好把 ABI 的定义引入项目。 - 在
<body>里的<script>开头 , 定义一个var,取名cryptoZombies, 不过不要对其赋值,稍后我们将用这个这个变量来存储我们实例化合约。 - 接下来,创建一个名为
startApp()的function。 接下来两步来完成这个方法。 startApp()里应该做的第一件事是定义一个名为cryptoZombiesAddress的变量并赋值为"你的合约地址"(这是你的合约在以太坊主网上的地址)。- 最后,来实例化我们的合约。模仿我们上面的代码,将
cryptoZombies赋值为newweb3js.eth.Contract(使用我们上面代码中通过script引入的cryptoZombiesABI和cryptoZombiesAddress)。
1 | <!DOCTYPE html> |
第4章: 调用和合约函数
我们的合约配置好了!现在来用 Web3.js 和它对话。
Web3.js 有两个方法来调用我们合约的函数: call and send.
Call
call 用来调用 view 和 pure 函数。它只运行在本地节点,不会在区块链上创建事务。
复习:
view和pure函数是只读的并不会改变区块链的状态。它们也不会消耗任何gas。用户也不会被要求用MetaMask对事务签名。
使用 Web3.js,你可以如下 call 一个名为myMethod的方法并传入一个 123 作为参数:
1 | myContract.methods.myMethod(123).call() |
Send
send 将创建一个事务并改变区块链上的数据。你需要用 send 来调用任何非 view 或者 pure 的函数。
注意:
send一个事务将要求用户支付gas,并会要求弹出对话框请求用户使用 Metamask 对事务签名。在我们使用 Metamask 作为我们的 web3 提供者的时候,所有这一切都会在我们调用send()的时候自动发生。而我们自己无需在代码中操心这一切,挺爽的吧。
使用 Web3.js, 你可以像这样 send 一个事务调用myMethod 并传入 123 作为参数:
1 | myContract.methods.myMethod(123).send() |
语法几乎 call()一模一样。
获取僵尸数据
来看一个使用 call 读取我们合约数据的真实例子
回忆一下,我们定义我们的僵尸数组为 公开(public):
1 | Zombie[] public zombies; |
在 Solidity 里,当你定义一个 public变量的时候, 它将自动定义一个公开的 “getter” 同名方法, 所以如果你像要查看 id 为 15 的僵尸,你可以像一个函数一样调用它: zombies(15).
这是如何在外面的前端界面中写一个 JavaScript 方法来传入一个僵尸 id,在我们的合同中查询那个僵尸并返回结果
注意: 本课中所有的示例代码都使用 Web3.js 的 1.0 版,此版本使用的是 Promises 而不是回调函数。你在线上看到的其他教程可能还在使用老版的 Web3.js。在1.0版中,语法改变了不少。如果你从其他教程中复制代码,先确保你们使用的是相同版本的Web3.js。
1 | function getZombieDetails(id) { |
我们来看看这里都做了什么
cryptoZombies.methods.zombies(id).call() 将和 Web3 提供者节点通信,告诉它返回从我们的合约中的 Zombie[] public zombies,id为传入参数的僵尸信息。
注意这是 异步的,就像从外部服务器中调用API。所以 Web3 在这里返回了一个 Promises. (如果你对 JavaScript的 Promises 不了解,最好先去学习一下这方面知识再继续)。
一旦那个 promise 被 resolve, (意味着我们从 Web3 提供者那里获得了响应),我们的例子代码将执行 then 语句中的代码,在控制台打出 result。
result 是一个像这样的 JavaScript 对象:
1 | { |
我们可以用一些前端逻辑代码来解析这个对象并在前端界面友好展示。
实战演习
我们已经帮你把 getZombieDetails 复制进了代码。
先为
zombieToOwner创建一个类似的函数。如果你还记得ZombieFactory.sol,我们有一个长这样的映射:1
mapping (uint => address) public zombieToOwner;
定义一个 JavaScript 方法,起名为
zombieToOwner。和上面的getZombieDetails类似, 它将接收一个id作为参数,并返回一个 Web3.jscall我们合约里的zombieToOwner。之后在下面,为
getZombiesByOwner定义一个方法。如果你还能记起ZombieHelper.sol,这个方法定义像这样:1
function getZombiesByOwner(address _owner)
我们的
getZombiesByOwner方法将接收owner作为参数,并返回一个对我们函数getZombiesByOwner的 Web3.jscall
1 | // 1. Define `zombieToOwner` here |
第5章: MetaMask 和账户
太棒了!你成功地写了一些前端代码来和你的第一个智能合约交互。
接下来我们综合一下——比如我们想让我们应用的首页显示用户的整个僵尸大军。
毫无疑问我们首先需要用 getZombiesByOwner(owner) 来查询当前用户的所有僵尸ID。
但是我们的 Solidity 合约需要 owner 作为 Solidity address。我们如何能知道应用用户的地址呢?
获得 MetaMask中的用户账户
MetaMask 允许用户在扩展中管理多个账户。
我们可以通过这样来获取 web3 变量中激活的当前账户:
1 | var userAccount = web3.eth.accounts[0] |
因为用户可以随时在 MetaMask 中切换账户,我们的应用需要监控这个变量,一旦改变就要相应更新界面。例如,若用户的首页展示它们的僵尸大军,当他们在 MetaMask 中切换了账号,我们就需要更新页面来展示新选择的账户的僵尸大军。
我们可以通过 setInterval 方法来做:
1 | var accountInterval = setInterval(function() { |
这段代码做的是,每100毫秒检查一次 userAccount 是否还等于 web3.eth.accounts[0] (比如:用户是否还激活了那个账户)。若不等,则将 当前激活用户赋值给 userAccount,然后调用一个函数来更新界面。
实战演习
我们来让应用在页面第一次加载的时候显示用户的僵尸大军,监控当前 MetaMask 中的激活账户,并在账户发生改变的时候刷新显示。
定义一个名为
userAccount的变量,不给任何初始值。在
startApp()函数的最后,复制粘贴上面样板代码中的accountInterval方法进去。将
updateInterface();替换成一个getZombiesByOwner的call函数,并传入userAccount。在
getZombiesByOwner后面链式调用then语句,并将返回的结果传入名为displayZombies的函数。 (语句像这样:.then(displayZombies);).我们还没有
displayZombies函数,将于下一章实现。
1 | // 1. declare `userAccount` here |