cryptozombies4

第 5 章:僵尸战斗 (Zombie Fightin’)

现在我们的合约中有了一个随机数来源,我们可以在僵尸对战中用它来计算战斗结果了。
我们的僵尸对战机制如下:

  • 你选择你的一只僵尸,然后选择一个对手的僵尸进行攻击。
  • 如果你是攻击方僵尸,你将有 70% 的胜率。防守方僵尸将有 30% 的胜率。
  • 所有僵尸(无论攻击方还是防守方)都将拥有 winCount(胜场数)和 lossCount(败场数),这些数值会根据战斗结果而递增。
  • 如果攻击方僵尸胜利,它会升级并生成一只新的僵尸。
  • 如果它失败了,什么也不会发生(除了它的 lossCount 增加)。
  • 无论胜利还是失败,攻击方僵尸的冷却时间都会被触发。

这需要实现很多逻辑,所以我们将在接下来的章节中分步骤进行。

实战练习 (Put it to the test)

  1. 给我们的合约添加一个 uint 类型的变量,名为 attackVictoryProbability,并将其值设置为 70。
  2. 创建一个名为 attack 的函数。它接收两个参数:_zombieId(一个 uint)和 _targetId(也是一个 uint)。它应该是一个 external 函数。
    目前,让函数体保持为空。

第 6 章:重构通用逻辑

无论是谁调用我们的 attack 函数,我们都需要确保调用者确实是他们所使用的僵尸的所有者。如果你可以用别人的僵尸去攻击,这将是一个严重的安全问题!
你能想到如何添加一个检查,来验证调用者是否是他们传入的 _zombieId 所对应僵尸的所有者吗?
仔细思考一下,看看你能否自己想出答案。

花一点时间…… 可以回顾我们之前课程的代码来寻找灵感……
答案在下面,请在思考之后再继续阅读。

答案

我们在之前的课程中已经多次做过这种检查了。在 changeName()、changeDna() 和 feedAndMultiply() 函数中,我们使用了如下的检查:

1
require(msg.sender == zombieToOwner[_zombieId]);

这和我们在 attack 函数中需要的逻辑完全相同。由于我们多次使用了相同的逻辑,让我们将其移到一个独立的修饰符(modifier)中,以清理代码并避免重复(Don’t Repeat Yourself)。

实战练习 (Put it to the test)

我们回到 zombiefeeding.sol 文件,因为这是我们第一次使用该逻辑的地方。让我们将其重构为一个独立的修饰符。

  1. 创建一个名为 ownerOf 的修饰符。它接收一个参数 _zombieId(uint 类型)。
    修饰符的函数体应该包含一个 require 语句,检查 msg.sender 是否等于 zombieToOwner[_zombieId],然后使用 _ 继续执行原函数。如果你不记得修饰符的语法,可以参考 zombiehelper.sol。
  2. 修改 feedAndMultiply 函数的定义,使其使用 ownerOf 修饰符。
  3. 既然我们已经使用了修饰符,你就可以删除原来函数体中的 require(msg.sender == zombieToOwner[_zombieId]); 这一行了。

第 7 章:更多重构

在 zombiehelper.sol 文件中,我们还有几个地方需要实现我们新的 ownerOf 修饰符。
动手实践
更新 changeName() 函数,使其使用 ownerOf 修饰符。
更新 changeDna() 函数,使其使用 ownerOf 修饰符。

第 8 章:回到攻击!

重构得差不多了 —— 回到 zombieattack.sol 文件。
既然我们已经有了 ownerOf 修饰符可以使用,我们就要继续定义我们的 attack 函数。

动手实践

为 attack 函数添加 ownerOf 修饰符,以确保调用者拥有 _zombieId 对应的僵尸。
我们的函数首先应该获取两个僵尸的 storage 指针,这样我们就能更容易地与它们交互:

a. 声明一个名为 myZombie 的 Zombie 类型 storage 变量,并将其设置为 zombies[_zombieId]。

b. 声明一个名为 enemyZombie 的 Zombie 类型 storage 变量,并将其设置为 zombies[_targetId]。

我们将使用一个介于 0 和 99 之间的随机数来决定战斗的结果。因此,声明一个名为 rand 的 uint 变量,并将其设置为 randMod 函数传入 100 作为参数后的结果。

第9章:僵尸的胜利与失败

在我们的僵尸游戏中,我们需要跟踪僵尸的胜利和失败次数。这样,我们就可以在游戏状态中维护一个“僵尸排行榜”。

我们可以通过多种方式存储这些数据——作为单独的映射、作为排行榜结构体,或者直接存储在 Zombie 结构体中。

根据我们打算如何与这些数据交互,每种方式都有其优缺点。在本教程中,为了简化起见,我们将把这些统计信息存储在 Zombie 结构体中,并将其命名为 winCountlossCount

现在让我们回到 zombiefactory.sol 文件,并向我们的 Zombie 结构体中添加这些属性。

动手实践
修改我们的 Zombie 结构体,增加两个新属性:

a. winCount,类型为 uint16

b. lossCount,类型为 uint16

注意:记住,由于我们可以将多个 uint 紧凑地存储在结构体中,因此我们希望使用尽可能小的 uint 类型。uint8 太小了,因为 2^8 = 256——如果我们的僵尸每天都参与一次战斗,这个值在一年内就会溢出。但是 2^16 = 65536——所以除非一个用户在 179 年内每天都赢或输了,否则我们应该不会遇到溢出问题。

现在我们在 Zombie 结构体中添加了新属性,我们需要修改 _createZombie() 函数的定义。

修改僵尸创建函数,使其为每个新创建的僵尸设置 0 胜利和 0 失败。

第10章:僵尸胜利 😄

现在我们有了 winCountlossCount,我们可以根据哪只僵尸赢得了战斗来更新这些统计数据。

在第6章中,我们计算了一个从 0 到 100 的随机数。现在,让我们使用这个数字来决定谁赢得了战斗,并相应地更新我们的统计信息。

动手实践
创建一个 if 语句,检查 rand 是否小于或等于 attackVictoryProbability

如果这个条件为真,我们的僵尸就赢了!那么:

a. 增加 myZombiewinCount

b. 增加 myZombie 的等级(升级!!!)。

c. 增加 enemyZombielossCount(失败者!!!!!! 😫 😫 😫)。

d. 运行 feedAndMultiply 函数。查看 zombiefeeding.sol 以了解如何调用它。在第三个参数(_species)中传递字符串 “zombie”。(目前它实际上不做任何事情,但如果我们以后想要为生成僵尸类型的僵尸添加额外的功能时,可能会用到它)。

第11章:僵尸失败 😞

现在我们已经编写了僵尸胜利时的逻辑,让我们来处理僵尸失败时的情况。

在我们的游戏中,当僵尸失败时,它们不会降级——它们只会将失败次数加到 lossCount 中,并且触发冷却时间,这样它们就必须等待一天才能再次攻击。

为了实现这个逻辑,我们需要使用一个 else 语句。

else 语句的写法与 JavaScript 和许多其他语言一样:

1
2
3
4
5
if (zombieCoins[msg.sender] > 100000000) {
// You rich!!!
} else {
// We require more ZombieCoins...
}

动手实践

添加一个 else 语句。如果我们的僵尸失败:

a. 增加 myZombielossCount

b. 增加 enemyZombiewinCount

c. 在 myZombie 上运行 _triggerCooldown 函数。这样,僵尸每天只能攻击一次。(记住,_triggerCooldown 已经在 feedAndMultiply 中调用过了。因此,无论僵尸是胜利还是失败,它的冷却时间都会被触发。)

第12章:总结

恭喜你!这就是第4课的内容。

测试战斗功能
现在可以在右侧测试你的战斗功能了!

领取奖励
在赢得战斗后:

  • 你的僵尸将升级
  • 你的僵尸将增加 winCount
  • 你将会生成一个新的僵尸,加入到你的军队中!

现在,尝试一下战斗功能,然后继续下一章,完成本节课的学习。

https://share.cryptozombies.io/en/lesson/4/share/test1124?id=WyJjenw2NjU1MDgiLDIsMTRd

第1章:以太坊上的代币

让我们来聊聊代币。

如果你在以太坊领域待过一段时间,你可能听过人们谈论代币——特别是 ERC20 代币。

以太坊上的代币基本上就是一个智能合约,它遵循一些通用的规则——也就是说,它实现了一组标准化的函数,这些函数是所有代币合约共有的,比如 transferFrom(address _from, address _to, uint256 _amount)balanceOf(address _owner)

在合约内部,通常会有一个映射:mapping(address => uint256) balances,它跟踪每个地址的余额。

所以,基本上,代币就是一个跟踪谁拥有多少代币的合约,并且提供一些功能,让用户可以将代币转移到其他地址。

为什么这很重要?
由于所有 ERC20 代币共享一组相同的函数和名称,它们可以以相同的方式进行交互。

这意味着,如果你构建了一个能够与某个 ERC20 代币交互的应用,那么它也能够与任何 ERC20 代币交互。这样,未来可以更轻松地将更多代币添加到你的应用中,而无需进行自定义编码。你只需将新代币的合约地址插入,哗啦,应用就能使用新的代币了。

一个例子就是交易所。当交易所添加一个新的 ERC20 代币时,它实际上只需要添加另一个智能合约进行交互。用户可以告诉这个合约将代币发送到交易所的钱包地址,交易所则可以指示合约将代币返回给用户,当他们请求提款时。

交易所只需要实现一次这种转账逻辑,然后,当它想要添加一个新的 ERC20 代币时,只需要将新的合约地址添加到它的数据库中。

其他代币标准
ERC20 代币非常适合充当货币的代币。但是,它们并不特别适合代表我们游戏中的僵尸。

首先,僵尸不像货币那样可以被分割——我可以向你转账 0.237 ETH,但转账 0.237 个僵尸显然没有意义。

其次,所有僵尸并不相等。你的 2 级僵尸 “Steve” 和我的 732 级僵尸 “H4XF13LD MORRIS 💯💯😎💯💯” 完全不同(Steve,根本无法比)。

有另一种代币标准,它更适合像 CryptoZombies 这样的加密收藏品——那就是 ERC721 代币。

ERC721 代币是不可互换的,因为每个代币都是唯一的,且不可分割。你只能以整单位进行交易,每个代币都有一个独特的 ID。所以,它们非常适合使我们的僵尸可以进行交易。

注意,使用像 ERC721 这样的标准有一个好处:我们不需要在合约中实现拍卖或托管逻辑来决定玩家如何交易或出售我们的僵尸。如果我们遵循该标准,其他人可以为加密可交易的 ERC721 资产构建一个交换平台,我们的 ERC721 僵尸就可以在该平台上使用。所以,使用代币标准而不是自己编写交易逻辑是有明确好处的。

动手实践
我们将在下一章深入探讨 ERC721 的实现。但是在此之前,我们先设置本节课的文件结构。

我们将把所有 ERC721 逻辑存储在一个名为 ZombieOwnership 的合约中。

在文件顶部声明我们的 pragma 版本(请参考之前课时的文件语法)。

该文件应从 zombieattack.sol 导入。

声明一个新的合约 ZombieOwnership,它继承自 ZombieAttack。现在先将合约的主体留空。

第2章:ERC721 标准与多重继承

让我们来看看 ERC721 标准:

1
2
3
4
5
6
7
8
9
contract ERC721 {
event Transfer(address indexed _from, address indexed _to, uint256 indexed _tokenId);
event Approval(address indexed _owner, address indexed _approved, uint256 indexed _tokenId);

function balanceOf(address _owner) external view returns (uint256);
function ownerOf(uint256 _tokenId) external view returns (address);
function transferFrom(address _from, address _to, uint256 _tokenId) external payable;
function approve(address _approved, uint256 _tokenId) external payable;
}

这是我们需要实现的方法列表,我们将在接下来的章节中逐步实现。

看起来方法很多,但不要感到压倒!我们会一步步带你完成。

实现代币合约
在实现代币合约时,第一步是将接口复制到一个独立的 Solidity 文件中,并导入它:import "./erc721.sol";。然后,我们让我们的合约继承它,并为每个方法覆盖一个函数定义。

但等一下——ZombieOwnership 已经继承了 ZombieAttack——它怎么还能继承 ERC721 呢?

幸运的是,在 Solidity 中,合约可以继承多个合约,如下所示:

1
2
3
contract SatoshiNakamoto is NickSzabo, HalFinney {
// Omg, the secrets of the universe revealed!
}

正如你所看到的,当使用多重继承时,只需要用逗号 , 分隔你继承的多个合约。在这个例子中,我们的合约继承了 NickSzaboHalFinney

让我们来试试。

动手实践
我们已经为你创建了 erc721.sol,其中包含上述接口。

erc721.sol 导入到 zombieownership.sol 中。

声明 ZombieOwnership 继承自 ZombieAttackERC721

第三章:balanceOf 和 ownerOf

很好,让我们深入探讨 ERC721 的实现!

我们已经为您复制了本节课需要实现的所有函数的空壳。

在本章中,我们将实现前两个方法:balanceOf 和 ownerOf。

balanceOf

1
function balanceOf(address _owner) external view returns (uint256 _balance);

这个函数只需接收一个地址,并返回该地址拥有的代币数量。

在我们的例子中,所谓的”代币”就是僵尸。你还记得我们在 DApp 的哪个部分存储了每个所有者拥有的僵尸数量吗?

ownerOf

1
function ownerOf(uint256 _tokenId) external view returns (address _owner);

这个函数接收一个代币ID(在我们这里就是僵尸ID),然后返回拥有该代币的地址地址。

同样地,这个函数的实现对我们来说非常直接,因为我们的DApp中已经有一个存储这个信息的映射。我们可以用一行代码实现这个函数,只需要一个返回语句。

注意:请记住,uint256 等同于 uint。我们之前一直在代码中使用 uint,但这里使用 uint256 是因为我们直接从规范中复制/粘贴了代码。

实践测试

我将由你来思考如何实现这两个函数。

每个函数都应该只是一行代码,即一个返回语句。查看我们之前课程的代码,看看这些数据存储在哪里。如果你无法解决,可以点击”显示答案”按钮获取帮助。

  1. 实现 balanceOf 来返回 _owner 拥有的僵尸数量。

  2. 实现 ownerOf 来返回拥有 ID 为 _tokenId 的僵尸的地址地址。


看不懂这两行代码:
return ownerZombieCount[_owner];
return zombieToOwner[_tokenId];

这两行代码涉及到合约中存储的映射(mapping),其作用是管理与“僵尸”相关的拥有者和数量信息。

  1. return ownerZombieCount[_owner];

    • 这行代码是返回某个地址(_owner)所拥有的僵尸数量。
    • ownerZombieCount 是一个映射(mapping(address => uint256)),用于存储每个地址拥有的僵尸数量。
    • _owner 是函数的输入参数,代表某个地址。通过 ownerZombieCount[_owner],你可以获取该地址拥有的僵尸数量。
    • 例如,ownerZombieCount[0x123...] 会返回地址 0x123... 拥有的僵尸数量。
  2. return zombieToOwner[_tokenId];

    • 这行代码是返回某个僵尸(由 tokenId 标识)的拥有者。
    • zombieToOwner 是一个映射(mapping(uint256 => address)),用于存储每个僵尸的拥有者地址。
    • _tokenId 是函数的输入参数,代表某个僵尸的唯一标识符。通过 zombieToOwner[_tokenId],你可以查询到这个僵尸的拥有者地址。
    • 例如,zombieToOwner[1] 会返回拥有 tokenId 为 1 的僵尸的地址。

总的来说,这两行代码分别返回了某个地址拥有的僵尸数量和某个特定僵尸的拥有者地址,它们是通过映射存储和查询这些信息的。

第四章:代码重构

糟糕!我们刚刚在代码中引入了一个错误,导致无法编译。你发现了吗?

在上一章中,我们定义了一个名为 ownerOf 的函数。但如果你回想一下第4课,我们在 zombiefeeding.sol 中还创建了一个同名的修饰器 ownerOf。

如果你尝试编译这段代码,编译器会报错,提示不能存在同名的修饰器和函数。

那么,我们是否应该将 ZombieOwnership 中的函数名改成其他名称?

不,绝不能这样做!记住,我们正在使用 ERC721 代币标准,这意味着其他合约会预期我们的合约具有这些确切名称的函数。这正是标准的意义所在——如果其他合约知道我们的合约符合 ERC721 标准,它就能直接与我们交互,而无需了解我们内部的实现细节。

因此,我们需要重构第4课中的代码,将修饰器的名称更改为其他名称。

实践测试

现在回到 zombiefeeding.sol 文件。我们需要将修饰器的名称从 ownerOf 改为 onlyOwnerOf。

  1. 将修饰器的定义名称改为 onlyOwnerOf
  2. 向下滚动到使用该修饰器的 feedAndMultiply 函数,这里也需要修改修饰器名称

注意:我们在 zombiehelper.sol 和 zombieattack.sol 中也使用了这个修饰器。为了避免在本课中花费过多时间进行重构,我们已提前帮你修改了这些文件中的修饰器名称。

第五章:ERC721:转账逻辑

太好了,我们解决了冲突!

现在,我们将继续实现 ERC721,看看如何将所有权从一个人转移到另一个人。

请注意,ERC721 规范中有两种不同的转移代币的方法:

1
function transferFrom(address _from, address _to, uint256 _tokenId) external payable;

1
2
function approve(address _approved, uint256 _tokenId) external payable;
function transferFrom(address _from, address _to, uint256 _tokenId) external payable;

第一种方式:

代币的拥有者调用 transferFrom,传入他的地址作为 _from 参数,目标地址作为 _to 参数,和他想要转移的代币的 _tokenId

第二种方式:

代币的拥有者首先调用 approve,指定一个他想要转移代币的地址和 _tokenId。合约会存储谁被授权接收该代币,通常保存在一个映射(mapping(uint256 => address))中。然后,拥有者或被批准的地址调用 transferFrom,合约会检查 msg.sender 是否是拥有者,或者是拥有者批准的接收者,如果是的话,就会将代币转移给他。

注意,这两种方法都有相同的转移逻辑。在一种情况下,代币的发送者调用 transferFrom 函数;在另一种情况下,拥有者或被批准的接收者调用该函数。

因此,我们可以将这些逻辑抽象成一个私有函数 _transfer,然后由 transferFrom 调用它。

测试

让我们定义 _transfer 的逻辑。

定义一个名为 _transfer 的函数,它将接受 3 个参数:address _fromaddress _touint256 _tokenId。它应该是一个私有函数。

我们有两个映射会在所有权更改时发生变化:ownerZombieCount(它记录每个拥有者拥有的僵尸数量)和 zombieToOwner(它记录每个僵尸的拥有者)。

我们函数的第一步应该是增加接收僵尸的人的 ownerZombieCount(即 _to 地址)。可以使用 ++ 来递增。

接下来,我们需要减少发送僵尸的人的 ownerZombieCount(即 _from 地址)。可以使用 -- 来递减。

最后,我们要改变 zombieToOwner 映射,将该 tokenId 对应的值更新为 _to 地址。

我撒谎了,那不是最后一步。我们应该再做一步。

ERC721 规范包括一个 Transfer 事件。函数的最后一行应该触发 Transfer 事件,传入正确的信息——查看 erc721.sol,看看它期待调用的参数,并在这里实现。

第六章:ERC721:转账(续)

太好了!这部分是比较困难的——现在实现外部的 transferFrom 函数会变得很容易,因为我们的 _transfer 函数已经完成了几乎所有的繁重工作。

测试

首先,我们想确保只有代币/僵尸的拥有者或已批准的地址能够转移它。让我们定义一个名为 zombieApprovals 的映射。它应该将一个 uint 映射到一个地址。这样,当一个不是拥有者的人调用 transferFrom 并提供一个 _tokenId 时,我们可以使用这个映射快速查找该地址是否被批准转移这个代币。

接下来,让我们在 transferFrom 中添加一个 require 语句。它应该确保只有代币/僵尸的拥有者或已批准的地址能够转移它。

最后,别忘了调用 _transfer

注意:

确保只有代币/僵尸的拥有者或已批准的地址能够转移它,意味着至少要满足以下条件之一:

  • zombieToOwner[_tokenId] 等于 msg.sender,或者
  • zombieApprovals[_tokenId] 等于 msg.sender

不用担心在 zombieApprovals 映射中填充数据,我们将在下一章进行处理。

第七章:ERC721: 授权机制

现在让我们来实现 approve 函数。

请记住,通过 approve 进行的转移操作分两步完成:

  1. 您作为代币所有者调用 approve,指定新所有者的 _approved 地址和要转移的 _tokenId
  2. 新所有者调用 transferFrom 并传入 _tokenId。接下来,合约会验证新所有者是否已获得授权,然后完成代币转移

由于这个过程涉及两次函数调用,我们需要使用 zombieApprovals 数据结构来在函数调用之间记录每个代币的授权信息。

实践测试

  1. approve 函数中,我们需要确保只有代币的所有者才能授权他人接收该代币。因此需要为 approve 添加 onlyOwnerOf 修饰器

  2. 在函数体中,将 _tokenId 对应的 zombieApprovals 值设置为 _approved 地址。

第八章:ERC721: 授权完成

太好了,我们即将完成!

还有最后一项工作——ERC721 规范中定义了一个 Approval 事件。因此我们需要在 approve 函数末尾触发该事件。

实践测试

现在让我们触发 Approval 事件。请查看 erc721.sol 文件了解事件参数,并确保使用 msg.sender 作为 _owner 参数。
太棒了,我们已经完成了 ERC721 的实现!

第九章:防止溢出

恭喜你,完成了我们的 ERC721 和 ERC721x 实现!

这并不难,对吧?当你听别人谈论 Ethereum 时,很多内容听起来确实很复杂,所以最好的理解方式就是自己动手实现一遍。

记住,这只是一个最基础的实现。我们可能还想为实现添加一些额外的功能,比如一些额外的检查,确保用户不会不小心将他们的僵尸转移到地址 0(这被称为“销毁”代币——基本上是将代币发送到一个没有私钥的地址,实质上使其无法恢复)。或者在 DApp 中加入一些基本的拍卖逻辑。(你能想到一些实现的方法吗?)

但我们希望这节课简洁易懂,所以我们选择了最基本的实现。如果你想查看更深入的实现示例,可以在本教程结束后查看 OpenZeppelin 的 ERC721 合约。

合约安全增强:溢出和下溢

我们将关注一个你在编写智能合约时应该了解的主要安全特性:防止溢出和下溢。

什么是溢出?

假设我们有一个 uint8 类型,它只能存储 8 位二进制数。这意味着我们能存储的最大数字是二进制数 11111111(或者在十进制中,2^8 - 1 = 255)。

看看以下代码。在最后,number 的值会是多少?

1
2
uint8 number = 255;
number++;

在这种情况下,我们导致了溢出——因此尽管我们增加了数值,但计数器现在却等于0(这很反直觉)。(就像二进制数11111111加1后,会重置回00000000,类似时钟从23:59走向00:00)。

下溢也是类似的情况,如果你从值为0的uint8减去1,它会变成255(因为uint是无符号整数,不能为负)。

虽然我们这里没有使用uint8,而且uint256每次递增1时发生溢出的可能性很小(2^256是个极大的数字),但在合约中加入防护措施仍然是好的做法,这样能确保我们的DApp未来不会出现意外行为。

使用SafeMath
为防止这种情况,OpenZeppelin创建了一个名为SafeMath的库,该库默认就能预防这些问题。

但在深入探讨之前…什么是库?

库是Solidity中的一种特殊合约类型,其用途之一是为原生数据类型附加函数。

例如,使用SafeMath库时,我们会采用using SafeMath for uint256的语法。SafeMath库包含4个函数——add、sub、mul和div。现在我们可以通过以下方式从uint256访问这些函数:

1
2
3
4
5
using SafeMath for uint256;

uint256 a = 5;
uint256 b = a.add(3); // 5 + 3 = 8
uint256 c = a.mul(2); // 5 * 2 = 10

我们将在下一章详细讲解这些函数的功能,现在先让我们将 SafeMath 库添加到合约中。

实践测试

我们已提前在 safemath.sol 文件中为你引入了 OpenZeppelin 的 SafeMath 库。如果你感兴趣可以快速浏览一下代码,不过我们将在下一章深入分析。

首先告知我们的合约使用 SafeMath。我们将把这个声明放在最基础的合约 ZombieFactory 中——这样所有继承该合约的子合约都能使用 SafeMath 库。

  1. 将 safemath.sol 导入到 zombiefactory.sol 中
  2. 添加声明语句 using SafeMath for uint256;