自从去年11月底 CryptoKitties 上线,不到一周,它的创世猫便卖出了超过11万美金(约合70万人民币)的天价。之后第二天,它更是让以太坊的全部交易量飙升6倍,瘫痪了整个区块链网络。
CryptoKitties 带火区块链游戏的同时,各种山寨也尾随而来,其中就包括西二旗某大厂试水区块链的汪星人游戏 CryptoDogs,真是连名字都可以照搬。不过话又说回来,作为去中心化应用dapp中第一款现象级的产品,CryptoKitties 自然避免不了被人研究、模仿,直至学得最好的那个人把它超越。
所以,作为一名有追求的区块链爱好者,直接山寨人家的 "Bad Artist" 式做法,我们肯定会有所不为;但是,"Good artists steal",如何学到 CryptoKitties 的精髓并把它用到将来区块链开发的创新之中,才是大家真正关心的话题。
要做到这一点,不读源代码,没有亲手撸一遍 CryptoKitties 程序,肯定是不够的。
这也正是这篇 Medium 热文的写作目的,通过对代码的学习,来帮大家深刻理解 CryptoKitties 这个游戏。
CryptoKitties 概述
如果从没接触过 CryptoKitties,你很有可能还不了解这个游戏到底是什么。其实,CryptoKitties 的本质是一个购买、销售、繁殖数字猫咪的游戏。每只数字猫咪的外观由其基因所决定,因此每只猫咪的外观都各不相同。你可以让两只猫咪通过繁殖来产生一个后代,其外观由父母双方的基因共同决定。你也可以用以太币从他人手中买下你所喜欢的猫咪,或是拍卖你所拥有的数字猫咪来换取以太币。
要想快速了解这个游戏的工作原理,最好的方法就是直接阅读源代码。
CryptoKitties 的源代码大部分是开源的(也有小部分没有开源,后面会讲到)。CryptoKitties 源码大约有2000行,本文将主要讲解其中相对重要的部分。
阅读完整代码,请查阅 EthFiddle 上的合约代码副本:
ethfiddle-com/09YbyJRfiI
CryptoKitties游 戏的源代码分成了一个个的子合约,而非一个包含所有逻辑的单一文件。
子合约通过下述方式继承主合约:
- contract KittyAccessControl
- contract KittyBase is KittyAccessControl
- contract KittyOwnership is KittyBase, ERC721
- contract KittyBreeding is KittyOwnership
- contract KittyAuction is KittyBreeding
- contract KittyMinting is KittyAuction
- contract KittyCore is KittyMinting
最终应用程序指向的合约是 KittyCore 合约,这个合约继承了前面所有合约的属性和方法。接下来,让我们一个一个地来分析这些合约。
KittyAccessControl:谁控制合约?
该合约负责管理各种不同的地址,同时定义了各种仅限于特定角色执行的限制性操作,这些角色被命名为“CEO”、“CFO”和“COO”。
KittyAccessControl 合约主要功能是管理其他合约,所以它不涉及游戏的具体逻辑。
该合约定义了以太坊地址“CEO”、“CFO”和“COO”的使用方法,它们对该合约有着特殊的所有权以及控制权限。
KittyAccessControl 合约还定义了一些函数修饰符,如 onlyCEO (该函数只有“CEO”才能执行),同时该合约还定义了一些暂停/恢复合约的方法以及提现方法。
1modifier onlyCLevel() {
2 require(
3 msg.sender == cooAddress ||
4 msg.sender == ceoAddress ||
5 msg.sender == cfoAddress
6 );
7 _;
8}
9
10 //...some other stuff
11
12// Only the CEO, COO, and CFO can execute this function:
13function pause() external onlyCLevel whenNotPaused {
14 paused = true;
15}
pause() 函数主要是方便开发人员进行版本更新,以防那些预见不到的 bug。但我同事 Luke 指出,这个方法可以让开发人员有权完全冻结合约,令所有人都无法转让、出售或繁殖他们的猫咪!当然,开发人员并不想这么做。但真正有趣的地方确实,大多数人仅仅因为该游戏运行在以太坊上,就会以为它是一款完全是去中心化的游戏。
我们接着来看其他合约。
KittyBase:数字猫是什么猫?
KittyBase 合约是我们定义最底层的代码的地方,这些代码会用到整个游戏的所有核心功能上,包括数据存储结构、相关常量与数据类型,以及管理这些数据的内部函数。
KittyBase 合约定义了应用程序的很多核心数据。首先它将Kitty定义为结构体类型:
1struct Kitty {
2 uint256 genes;
3 uint64 birthTime;
4 uint64 cooldownEndBlock;
5 uint32 matronId;
6 uint32 sireId;
7 uint32 siringWithId;
8 uint16 cooldownIndex;
9 uint16 generation;
10}
从这里可以看到,一只猫咪的本质就是一长串的无符号整数。
接下来一一介绍猫咪的每个属性的具体参数:
- genes — 是256位的整数,主要作为猫的遗传密码,是决定猫外貌的核心数据;
- birthTime — 猫出生时所打包的区块的时间戳;
- cooldownEndBlock — 猫可以再次繁殖的最小时间;
- matronId&sireId — 猫母亲的ID号和猫父亲的ID号;
- siringWithId — 如果猫怀孕了,则设置为父亲的ID,否则为零;
- cooldownIndex — 猫繁殖所需的冷却时间(猫需要等待多久才能繁殖);
- generation — 猫的“世代号”(指明这是第几代猫)。合约创造的第一只猫是0代,新一代猫的“世代号”是其父母中较大的一代再加1。
需要注意的是,在 CryptoKitties 游戏中,猫是无性别之分的,任意2只猫都可以一起繁殖。
KittyBase 合约声明了一个“猫”结构的数组,如下所示:
Kitty[] kitties;
该数组包含了所有猫咪的数据,所以它就像一个猫咪数据库一般。无论什么时候生成一个新的猫咪,它都会被添加到该数组内,数组的索引就是猫咪的 ID 号。下图显示的是创世猫,其 ID 号为“1”。
该合约中还包含有从猫咪 ID 号到其所有者地址的一个映射,主要用于追踪猫咪的所有者。
mapping (uint256 => address) public kittyIndexToOwner;
合约中还定义了其他一些映射,但此处不再深究每一个细节。
当猫咪的所有者从一个人转移到另外一个人时,kittyIndexToOwner 映射就会更新,从而识别新的所有者。
1/// @dev Assigns ownership of a specific Kitty to an address.
2function _transfer(address _from, address _to, uint256 _tokenId) internal {
3 // Since the number of kittens is capped to 2^32 we can't overflow this
4 ownershipTokenCount[_to]++;
5 // transfer ownership
6 kittyIndexToOwner[_tokenId] = _to;
7 // When creating new kittens _from is 0x0, but we can't account that address.
8 if (_from != address(0)) {
9 ownershipTokenCount[_from]--;
10 // once the kitten is transferred also clear sire allowances
11 delete sireAllowedToAddress[_tokenId];
12 // clear any previously approved ownership exchange
13 delete kittyIndexToApproved[_tokenId];
14 }
15 // Emit the transfer event.
16 Transfer(_from, _to, _tokenId);
17}
现在我们来看看当一个新的猫咪生成时都发生了些什么:
1function _createKitty(
2 uint256 _matronId,
3 uint256 _sireId,
4 uint256 _generation,
5 uint256 _genes,
6 address _owner
7)
8 internal
9 returns (uint)
10{
11 // These requires are not strictly necessary, our calling code should make
12 // sure that these conditions are never broken. However! _createKitty() is already
13 // an expensive call (for storage), and it doesn't hurt to be especially careful
14 // to ensure our data structures are always valid.
15 require(_matronId == uint256(uint32(_matronId)));
16 require(_sireId == uint256(uint32(_sireId)));
17 require(_generation == uint256(uint16(_generation)));
18
19 // New kitty starts with the same cooldown as parent gen/2
20 uint16 cooldownIndex = uint16(_generation / 2);
21 if (cooldownIndex > 13) {
22 cooldownIndex = 13;
23 }
24
25 Kitty memory _kitty = Kitty({
26 genes: _genes,
27 birthTime: uint64(now),
28 cooldownEndBlock: 0,
29 matronId: uint32(_matronId),
30 sireId: uint32(_sireId),
31 siringWithId: 0,
32 cooldownIndex: cooldownIndex,
33 generation: uint16(_generation)
34 });
35 uint256 newKittenId = kitties.push(_kitty) - 1;
36
37 // It's probably never going to happen, 4 billion cats is A LOT, but
38 // let's just be 100% sure we never let this happen.
39 require(newKittenId == uint256(uint32(newKittenId)));
40
41 // emit the birth event
42 Birth(
43 _owner,
44 newKittenId,
45 uint256(_kitty.matronId),
46 uint256(_kitty.sireId),
47 _kitty.genes
48 );
49
50 // This will assign ownership, and also emit the Transfer event as
51 // per ERC721 draft
52 _transfer(0, _owner, newKittenId);
53
54 return newKittenId;
55 }
从上面这段代码中可以看出,这个函数传递了母亲和父亲的 ID 号、猫咪的世代号、256位遗传密码和所有者的地址。然后创建猫咪,并将其加入到 Kitty 数组中,最后调用 _transfer() 将其分配给新的所有者。
是不是很酷!现在我们已经知道 CryptoKitties 游戏如何将一只猫咪定义为一种数据类型,如何将所有猫咪都存储在区块链中,以及如何跟踪这些猫咪的所有者。
KittyOwnership:作为通证的猫咪
CryptoKitties 游戏遵循 ERC721 通证标准,这是一种不可替代通证,它可以很好地追踪数字收藏品的所有权,比如数字扑克牌或 MMORPG(大型多人在线角色扮演游戏)中的稀有物品。
关于可互换性的说明:Ether 是可互换的通证,因为任意5个 ETH 都与其他5个 ETH 一样有着同样的价值。但是像 CryptoKitties 这样的通证是不可替代通证,每只猫咪的外观都各不相同,它们不能生而相同,所以无法相互交换。
你可以从合约的定义中看出,KittyOwnership 继承的是 ERC721 合约。
contract KittyOwnership is KittyBase, ERC721 {
而所有是 ERC721 通证都遵循同样的标准,所以 KittyOwnership 合约所实现的是如下功能:
1/// @title Interface for contracts conforming to ERC-721: Non-Fungible Tokens
2/// @author Dieter Shirley <dete@axiomzen.co> (https://github.com/dete)
3contract ERC721 {
4 // Required methods
5 function totalSupply() public view returns (uint256 total);
6 function balanceOf(address _owner) public view returns (uint256 balance);
7 function ownerOf(uint256 _tokenId) external view returns (address owner);
8 function approve(address _to, uint256 _tokenId) external;
9 function transfer(address _to, uint256 _tokenId) external;
10 function transferFrom(address _from, address _to, uint256 _tokenId) external;
11
12 // Events
13 event Transfer(address from, address to, uint256 tokenId);
14 event Approval(address owner, address approved, uint256 tokenId);
15
16 // Optional
17 // function name() public view returns (string name);
18 // function symbol() public view returns (string symbol);
19 // function tokensOfOwner(address _owner) external view returns (uint256[] tokenIds);
20 // function tokenMetadata(uint256 _tokenId, string _preferredTransport) public view returns (string infoUrl);
21
22 // ERC-165 Compatibility (https://github.com/ethereum/EIPs/issues/165)
23 function supportsInterface(bytes4 _interfaceID) external view returns (bool);
24}
因为这些方法是公开的,这就为用户提供了一个标准的方式来与 CryptoKitties 通证进行交互,就像跟其他任何 ERC721 通证进行交互时一样。你可以通过直接与以太坊区块链上的 CryptoKitties 合约进行交互,而不必通过他们的 Web 界面来将你的通证转让给其他人。所以,从这个层面上讲,你真的拥有自己的猫咪。(除非 CEO 暂停合约,但我想他应该是不会轻易这么做的😉)
我就不再深入讲解这些方法的具体实现了,你可以在 EthFiddle 上查看具体的代码(搜索 “KittyOwnership”)。
KittyBreeding猫咪的繁殖
该文件包含了两只猫在一起繁殖所需的方法,包括跟踪猫咪双亲的方法以及两只猫咪繁殖所依赖的外部基因组合合约,等等。
“外部基因组合合约”(geneScience)存储在一个单独的合约中,这一合约不是开源的(这就是我上文所提到的,这个游戏并不是所有的代码都开源)。
KittyBreeding 合约定义了一个方法,可以让 CEO 设置这个外部合约的地址:
1/// @dev Update the address of the genetic contract, can only be called by the CEO.
2/// @param _address An address of a GeneScience contract instance to be used from this point forward.
3function setGeneScienceAddress(address _address) external onlyCEO {
4 GeneScienceInterface candidateContract = GeneScienceInterface(_address);
5
6 // NOTE: verify that a contract is what we expect - https://github.com/Lunyr/crowdsale-contracts/blob/cfadd15986c30521d8ba7d5b6f57b4fefcc7ac38/contracts/LunyrToken.sol#L117
7 require(candidateContract.isGeneScience());
8
9 // Set the new contract address
10 geneScience = candidateContract;
11}
之所以这样做,开发者主要是想让游戏变得复杂一些。如果你知道一只猫咪的遗传密码是如何生成的,那么很容易你就可以推断出它跟哪一只猫咪繁殖就能有更大的几率获得一只“稀有猫咪”。
这个外部 geneScience 合约会在 theGiveBirth() 函数(我们稍后就会看到)中使用,用于确定新猫的遗传密码。
现在让我们看看,当两只猫进行繁殖时会发生什么:
1/// @dev Internal utility function to initiate breeding, assumes that all breeding
2/// requirements have been checked.
3function _breedWith(uint256 _matronId, uint256 _sireId) internal {
4 // Grab a reference to the Kitties from storage.
5 Kitty storage sire = kitties[_sireId];
6 Kitty storage matron = kitties[_matronId];
7
8 // Mark the matron as pregnant, keeping track of who the sire is.
9 matron.siringWithId = uint32(_sireId);
10
11 // Trigger the cooldown for both parents.
12 _triggerCooldown(sire);
13 _triggerCooldown(matron);
14
15 // Clear siring permission for both parents. This may not be strictly necessary
16 // but it's likely to avoid confusion!
17 delete sireAllowedToAddress[_matronId];
18 delete sireAllowedToAddress[_sireId];
19
20 // Every time a kitty gets pregnant, counter is incremented.
21 pregnantKitties++;
22
23 // Emit the pregnancy event.
24 Pregnant(kittyIndexToOwner[_matronId], _matronId, _sireId, matron.cooldownEndBlock);
25}
这个函数需要输入母亲和父亲的 ID 号,并在 kitties 数组中查找它们,然后将母亲的 siringWithId 属性设置为父亲的 ID 号(当 siringWithId 不为零时,表示母亲怀孕)。这个函数同时也会在父母双方中执行 triggerCooldown 函数,这个函数会使他们在一段时间内无法再次繁殖。
接下来,有一个公共的 giveBirth() 函数,主要用来生成一只新的猫咪:
1/// @notice Have a pregnant Kitty give birth!
2/// @param _matronId A Kitty ready to give birth.
3/// @return The Kitty ID of the new kitten.
4/// @dev Looks at a given Kitty and, if pregnant and if the gestation period has passed,
5/// combines the genes of the two parents to create a new kitten. The new Kitty is assigned
6/// to the current owner of the matron. Upon successful completion, both the matron and the
7/// new kitten will be ready to breed again. Note that anyone can call this function (if they
8/// are willing to pay the gas!), but the new kitten always goes to the mother's owner.
9function giveBirth(uint256 _matronId)
10 external
11 whenNotPaused
12 returns(uint256)
13{
14 // Grab a reference to the matron in storage.
15 Kitty storage matron = kitties[_matronId];
16
17 // Check that the matron is a valid cat.
18 require(matron.birthTime != 0);
19
20 // Check that the matron is pregnant, and that its time has come!
21 require(_isReadyToGiveBirth(matron));
22
23 // Grab a reference to the sire in storage.
24 uint256 sireId = matron.siringWithId;
25 Kitty storage sire = kitties[sireId];
26
27 // Determine the higher generation number of the two parents
28 uint16 parentGen = matron.generation;
29 if (sire.generation > matron.generation) {
30 parentGen = sire.generation;
31 }
32
33 // Call the sooper-sekret gene mixing operation.
34 uint256 childGenes = geneScience.mixGenes(matron.genes, sire.genes, matron.cooldownEndBlock - 1);
35
36 // Make the new kitten!
37 address owner = kittyIndexToOwner[_matronId];
38 uint256 kittenId = _createKitty(_matronId, matron.siringWithId, parentGen + 1, childGenes, owner);
39
40 // Clear the reference to sire from the matron (REQUIRED! Having siringWithId
41 // set is what marks a matron as being pregnant.)
42 delete matron.siringWithId;
43
44 // Every time a kitty gives birth counter is decremented.
45 pregnantKitties--;
46
47 // Send the balance fee to the person who made birth happen.
48 msg.sender.send(autoBirthFee);
49
50 // return the new kitten's ID
51 return kittenId;
52}
这段代码很容易理解。首先,它会执行一些检查,看看母亲是否准备好进行繁殖;然后使用 geneScience.mixGenes() 函数来确定孩子的基因,将新产生的猫咪的所有权分配给母亲的所有者;然后调用 KittyBase 合约中的 _createKitty() 函数。
需要注意的是,geneScience.mixGenes() 函数是一个黑匣子,我们看不到其中的内容,因为这个合约也是不开源的。因此,我们就无从获知子代猫咪的基因到底是由何决定的,但我们知道孩子的基因来源于母亲的基因和父亲的基因,还有母亲的 cooldownEndBlock。
KittyAuctions售出、买入与控制你的猫咪
在这个游戏中,利用公开的方法来拍卖、竞标或繁殖猫咪。在源代码中,拍卖功能实际上是在两个兄弟合约(其中一个用于买卖猫,另一个用于繁殖猫)中实现的。而创建拍卖合约和竞标合约主要是通过核心合约来实现。
根据开发者的说法,他们将这个拍卖功能分为两个“兄弟”合约,主要是因为这个功能的逻辑比较复杂,可能存在不易发现的 bug。通过设置两个“兄弟”合约,可以做到在升级这个两个合约的同时不中断追踪猫咪所有权的主合约。
这个 KittyAuctions 合约包含有 setSaleAuctionAddress() 函数和 setSiringAuctionAddress() 函数。setGeneScienceAddress() 函数只能由 “CEO” 调用,而且只能由 “CEO” 来设置处理这些函数的外部合约地址。
这就意味着,即使 CryptoKitties 合约本身不可改变,“CEO” 也可以灵活地改变这些拍卖合约的地址,从而改变拍卖规则。我认为,这不一定是坏事,因为开发人员有时候需要修正 bug,当然,我们还是要注意一下这里。
在本文中,我不准备详细讨论拍卖合约和竞标合约的逻辑,主要是想避免文章篇幅过长(因为它已经够长了!)。
KittyMinting“零代猫”工厂
这一部分,我们来讲一讲 CryptoKitties 是怎样来创建零代猫的。
我们最多可以制作5000只可以赠送的“营销”猫(“营销”猫是指专门为社区的初期发展而创建的猫)。除了“营销”猫之外,其他所有的零代猫只能由确定的算法创造出来,然后由算法决定进行拍卖的起始价格。无论这些猫是如何被创造出来的,它们的总数最多只能有50000只,这是整个游戏有的硬性限制。除此之外,其他猫咪的产生就只能依靠不断地繁殖。
合约能够创建的“营销”猫的数量和零代猫的数量都是硬编码的,如下所示:
1uint256 public constant PROMO_CREATION_LIMIT = 5000;
2uint256 public constant GEN0_CREATION_LIMIT = 45000;
下面是创建“营销“猫和零代猫的代码,这个代码规定了只有 “COO” 可以来创建此这些猫咪。
1/// @dev we can create promo kittens, up to a limit. Only callable by COO
2/// @param _genes the encoded genes of the kitten to be created, any value is accepted
3/// @param _owner the future owner of the created kittens. Default to contract COO
4function createPromoKitty(uint256 _genes, address _owner) external onlyCOO {
5 address kittyOwner = _owner;
6 if (kittyOwner == address(0)) {
7 kittyOwner = cooAddress;
8 }
9 require(promoCreatedCount < PROMO_CREATION_LIMIT);
10
11 promoCreatedCount++;
12 _createKitty(0, 0, 0, _genes, kittyOwner);
13}
14
15/// @dev Creates a new gen0 kitty with the given genes and
16/// creates an auction for it.
17function createGen0Auction(uint256 _genes) external onlyCOO {
18 require(gen0CreatedCount < GEN0_CREATION_LIMIT);
19
20 uint256 kittyId = _createKitty(0, 0, 0, _genes, address(this));
21 _approve(kittyId, saleAuction);
22
23 saleAuction.createAuction(
24 kittyId,
25 _computeNextGen0Price(),
26 0,
27 GEN0_AUCTION_DURATION,
28 address(this)
29 );
30
31 gen0CreatedCount++;
32}
通过 createPromoKitty() 函数,“COO” 可以用任何他想要的基因来创建一只新猫咪,然后发给任何他想给的人(但通过这个合约,“COO” 最多可以创建5000只猫咪)。我猜,他们这样做的目的是想把猫奖励给早期测试者,或是送给自己的朋友、家人,或是用作项目的推广,等等。但这也意味着,你的猫咪可能并不像你所想的那样独一无二,因为它有可能会有5000个相同的副本,也就可能存在5000个跟你的猫咪长的一模一样的猫!
在 createGen0Auction() 函数中,“COO” 也给新的猫提供基因的遗传密码,但没有将这个遗传密码分配给特定的人的地址,而是发起了一个拍卖,让用户利用竞标的形式购买猫咪。
KittyCore主合约
这是 CryptoKitties 合约的主合约(main),它被编译并运行在以太坊区块链上。这个智能合约是连接其他合约的纽带。
由于这款游戏遵循继承结构,它继承了我们之前所看到的所有合约,同时增加了几个新的方法,例如下面这个,使用猫咪 ID 来获取所有猫的数据的函数:
1/// @notice Returns all the relevant information about a specific kitty.
2/// @param _id The ID of the kitty of interest.
3function getKitty(uint256 _id)
4 external
5 view
6 returns (
7 bool isGestating,
8 bool isReady,
9 uint256 cooldownIndex,
10 uint256 nextActionAt,
11 uint256 siringWithId,
12 uint256 birthTime,
13 uint256 matronId,
14 uint256 sireId,
15 uint256 generation,
16 uint256 genes
17) {
18 Kitty storage kit = kitties[_id];
19
20 // if this variable is 0 then it's not gestating
21 isGestating = (kit.siringWithId != 0);
22 isReady = (kit.cooldownEndBlock <= block.number);
23 cooldownIndex = uint256(kit.cooldownIndex);
24 nextActionAt = uint256(kit.cooldownEndBlock);
25 siringWithId = uint256(kit.siringWithId);
26 birthTime = uint256(kit.birthTime);
27 matronId = uint256(kit.matronId);
28 sireId = uint256(kit.sireId);
29 generation = uint256(kit.generation);
30 genes = kit.genes;
31}
这是一个公开方法,它会从区块链上返回一个特定猫咪的所有数据。我认为,他们 Web 页面所展示的猫咪的所有数据都是通过这个方法从以太坊区块链上查询到的。
网址:www.cryptokitties-co
讲到这里,大家可能会疑惑,为什么没有看到任何图像数据?是什么决定了猫咪的样子呢?
从上面的代码中我们可以看出,一个“猫咪”实际上就是一串256位的无符号整数,这256位整数就代表其遗传密码。
在 Solidity 合约代码中没有任何地方存储猫的图像或猫的描述信息,也没有任何地方明确定义了这个256位整数的实际含义。所以,推断可知,对于遗传密码的解释发生在 CryptoKitty 的 Web 服务器上。
所以,CryptoKitties 虽说是用区块链做出来的游戏,也是对区块链应用的一个非常好的拓展,但它并不是100%的区块链应用(因为它用到了传统服务器)。未来某一天,一旦他们的网站突然关闭,又没有备份所有图像的话,你的猫咪就只剩下一长串毫无意义的256位整数。
在合约代码中,我找到了一个名为 ERC721Metadata 的合约,但是它好像什么事情都没做。
所以我猜想,他们最初的计划是将所有内容都存储在区块链中,但随着项目的进展,他们却决定不再这么做了(因为在以太坊中存储大量数据的成本太高),最终他们决定将大部分内容存储到 Web 服务器上。
最后,总结一下:
把 CryptoKitties 的代码撸到现在,我们搞明白的事情是以下这几个:
- 猫咪是如何被表示成数据的;
- 所有已生成的猫咪如何被存储到一个智能合约中的,以及该合约是如何跟踪猫咪的所有者的;
- 零代猫是如何产生的;
- 猫咪是如何进行繁殖的,新的猫咪是怎样生成的。
原文链接:
medium-com/loom-network/how-to-code-your-own-cryptokitties-style-game-on-ethereum-7c8ac86a4eb3