KiemTienOnline360

Chia sẻ hành trình kiếm tiền online bắt đầu từ con số 0

Kiến thức lập trình, Kiến thức phần mềm, Kỹ nghệ phần mềm

Học lập trình Smart Contract từ cryptozombies

Học lập trình Smart Contract từ cryptozombies

Học lập trình Smart Contract từ cryptozombies

Chia sẻ bài viết
5
(1)

Level 01: Beginner to Intermediate Smart Contracts

Phần này sẽ dạy các bạn xây dựng một Game trên nền tảng Ethereum. Nó được thiết kế cho người mới bắt đầu với Solidity, nhưng nó giả định rằng bạn có một số kinh nghiệm lập trình bằng ngôn ngữ khác (ví dụ: Javascript).

Những gì bạn sẽ học:

  • Tạo Zombie Factory
  • Cách Zombies tấn công nạn nhân
  • Các khái niệm nâng cao về Solidity
  • Hệ thống chiến đấu Zombie (Zombie Battle System)
  • ERC721 và Crypto-Collectibles
  • Giao diện ứng dụng và Web3.js

Bài 1: Tạo Zombie Factory

Ở bài này chúng ta sẽ tạo ra “Zombie Factory” để xây dựng 1 quân đội zombie:

  • Factory này giống như CSDL chưa tất cả các zombie.
  • Factory sẽ có hàm tạo Zombie
  • Mỗi Zombie có chứa dữ liệu phân biệt với Zombie khác, và không trùng lặp

Phần 1: Zoombie DNA

Zombie được xác định duy nhất bởi “Zombie DNA“, nó đơn giản chỉ là một số có 16 chữ số, cứ hai chữ số xác định 1 thuộc tính. Ví dụ Zoom DNA 8356281049284737:

  • HEAD: 83 % 7 + 1 = 7
  • EYE GENE: 56 % 11 + 1
  • SHIRT GENE: 28 % 7 + 1
  • SKIN COLOR: …

Phần 2: Contract (Hợp đồng)

Code trên Sodility được đóng gói trong các Contract. Contract là khối cơ bản của các ứng dụng Ethereum – tất cả các biến và chức năng đều thuộc về một hợp đồng và đây sẽ là điểm khởi đầu của tất cả các dự án của bạn.

Một contract rỗng với tên HelloWorld có dạng như sau:

pragma solidity >=0.5.0 <0.6.0;
contract HelloWorld {

}

Tất cả mã nguồn solidity phải bắt đầu bằng “version pragma” – một khai báo về phiên bản của trình biên dịch. Điều này là để ngăn chặn các sự cố với các phiên bản trình biên dịch trong tương lai có khả năng tạo ra các thay đổi có thể phá vỡ mã của bạn.

Đối với phạm vi của hướng dẫn này, chúng ta sẽ muốn có thể biên dịch các hợp đồng thông minh của chúng ta với bất kỳ phiên bản trình biên dịch nào trong phạm vi từ 0.5.0 (bao gồm) đến 0.6.0 (độc quyền).

Phần 3: State Variables & Integers

Các biến trạng thái (State Variables) được lưu trữ vĩnh viễn trong bộ lưu trữ hợp đồng. Điều này có nghĩa là chúng được ghi vào chuỗi khối Ethereum. Hãy nghĩ về chúng giống như chúng ta lưu vào trong DB.

contract Example {
// This will be stored permanently in the blockchain
uint myUnsignedInteger = 100;
}

Trong hợp đồng ví dụ ở trên, chúng ta đã tạo một uint có tên là myUnsignedInteger và đặt nó bằng 100.

Kiểu dữ liệu uint là một số nguyên không dấu, có nghĩa là giá trị của nó phải không âm. Ngoài ra còn có một kiểu dữ liệu int cho các số nguyên có dấu.

Một kiểu dữ liệu hay sử dụng là kiểu string. Biến string được sử dụng để biểu diễn cho chuỗi dữ liệu UTF-8 với độ dài tùy ý.

Phần 4: Math Operations (Toán tử)

Math trong Solidity khá đơn giản. Các hoạt động sau đây giống như trong hầu hết các ngôn ngữ lập trình:

  • Phép cộng: x + y
  • Phép trừ: x – y
  • Phép nhân: x * y
  • Phân chia: x / y
  • Mô-đun (Lấy phần dư): x % y (Ví dụ: 13% 5 là 3, bởi vì nếu bạn chia 5 thành 13, 3 là phần còn lại)

Solidity cũng hỗ trợ toán tử hàm mũ (tức là “x theo lũy thừa của y”, x ^ y):

x = 5 ** 2; // bằng 5 ^ 2 = 25

Phần 5: Structs (Kiểu cấu trúc)

Đôi khi bạn cần một kiểu dữ liệu phức tạp hơn. Đối với điều này, Solidity cung cấp cấu trúc dữ liệu dạng struct:

struct Person {
uint age;
string name;
}

Các struct cho phép bạn tạo các kiểu dữ liệu phức tạp hơn có nhiều thuộc tính.

Phần 6: Arrays (Dữ liệu mảng)

Khi bạn muốn một bộ sưu tập của một cái gì đó, bạn có thể sử dụng một mảng Array. Có hai loại array trong Solidity: mảng cố định (fixed arrays) và mảng động (dynamic arrays):

// Array with a fixed length of 2 elements: 
uint[2] fixedArray;
// another fixed Array, can contain 5 strings:
string[5] stringArray;
// a dynamic Array - has no fixed size, can keep growing:
uint[] dynamicArray;

Bạn cũng có thể tạo một mảng cấu trúc. Sử dụng cấu trúc Person của chương trước:

Person[] people; // dynamic Array, we can keep adding to it

Hãy nhớ rằng các biến trạng thái được lưu trữ vĩnh viễn trong blockchain? Vì vậy, việc tạo một mảng cấu trúc động như thế này có thể hữu ích cho việc lưu trữ dữ liệu có cấu trúc trong hợp đồng của bạn, giống như một cơ sở dữ liệu.

Bạn có thể khai báo một mảng là public và Solidity sẽ tự động tạo một phương thức getter cho nó. Cú pháp có dạng như sau:

Person[] public people;

Các hợp đồng khác sau đó sẽ có thể đọc dữ liệu từ đó, nhưng không thể ghi vào mảng này. Vì vậy, đây là một mẫu hữu ích để lưu trữ dữ liệu công khai trong hợp đồng của bạn.

Phần 7: Function Declarations (Khai báo hàm)

Một hàm trong Sodility được khai báo dạng như sau:

function eatHamburgers(string memory _name, uint _amount) public {
}

Hàm trên có tên là eatHamburgers nhận 2 tham số: một tham số dạng string và một uint. Hiện tại, phần thân của hàm đang trống. Lưu ý rằng chúng ta đang chỉ định mức độ hiển thị chức năng là công khai. Chúng ta cũng cung cấp hướng dẫn về nơi biến _name sẽ được lưu trữ- trong bộ nhớ. Điều này là bắt buộc đối với tất cả các kiểu tham chiếu như Array, Struct, MapString.

Bạn hỏi kiểu tham chiếu là gì? Trước tiên bạn cần biết có hai cách để bạn có thể truyền một đối số cho một hàm Solidity:

  • Truyền theo giá trị, có nghĩa là trình biên dịch Solidity tạo một bản sao mới của giá trị của tham số và chuyển nó đến hàm của bạn. Điều này cho phép hàm của bạn sửa đổi giá trị mà không phải lo lắng rằng giá trị của tham số ban đầu bị thay đổi.
  • Truyền theo tham chiếu, có nghĩa là hàm của bạn được gọi với … tham chiếu đến biến ban đầu. Do đó, nếu hàm của bạn thay đổi giá trị của biến mà nó nhận được, thì giá trị của biến ban đầu sẽ bị thay đổi.

Lưu ý: Quy ước (nhưng không bắt buộc) bắt đầu tên biến tham số hàm bằng dấu gạch dưới (_) để phân biệt chúng với biến toàn cục. Chúng ta sẽ sử dụng quy ước đó trong suốt hướng dẫn của chúng ta.

Và bạn sẽ gọi hàm trên như sau:

eatHamburgers("vitalik", 100);

Chương 8: Làm việc với Struct và Array

Phần trước chúng ta đã nói về khai báo Struct và Array. Phần này sẽ hướng dẫn làm thế nào để tạo đối tượng cho chúng.

// create a New Person:
Person satoshi = Person(172, "Satoshi");
// Add that person to the Array:
people.push(satoshi);

Hoặc nối lại thành 1 lệnh:

people.push(Person(16, "Vitalik"));

Chúng ta sử dung array.push() để thêm 1 phần tử vào mangr:

uint[] numbers;
numbers.push(5);
numbers.push(10);
numbers.push(15);
// numbers is now equal to [5, 10, 15]

Chương 9: Private / Public Functions

Trong Sodility các hàm mặc định là public. Điều này có nghĩa là bất kỳ ai (hoặc bất kỳ hợp đồng nào khác) đều có thể gọi hàm trong hợp đồng của bạn và thực thi mã của nó.

Rõ ràng điều này không phải lúc nào cũng mong muốn và có thể khiến hợp đồng của bạn dễ bị tấn công. Vì vậy, bạn nên đánh dấu các chức năng của mình là private theo mặc định, và sau đó chỉ công khai các chức năng bạn muốn hiển thị với mọi người.

Dưới đây là cách khai báo hàm private:

uint[] numbers;
function _addToArray(uint _number) private {
numbers.push(_number);
}

Điều này có nghĩa là chỉ các hàm khác trong hợp đồng của chúng ta mới có thể gọi hàm này và thêm vào mảng số. Như bạn thấy, chúng ta sử dụng từ khóa private sau tên hàm. Và như với các tham số hàm, quy ước bắt đầu tên hàm riêng bằng dấu gạch dưới (_).

Chương 10: Tìm hiểu thêm về Function

* Giá trị trả về (Return Value):

Để trả về một giá trị từ một hàm, khai báo có dạng như sau:

string greeting = "What's up dog";
function sayHello() public returns (string memory) {
return greeting;
}

* Function modifiers:

Hàm trên không thực sự thay đổi trạng thái trong Solidity – ví dụ: Nó không thay đổi bất kỳ giá trị hoặc viết bất kỳ điều gì. Vì vậy, trong trường hợp này, chúng ta có thể khai báo nó như một hàm chỉ xem, có nghĩa là nó chỉ xem dữ liệu nhưng không sửa đổi nó:

function sayHello() public view returns (string memory) {

Solidity cũng chứa các chức năng thuần túy, có nghĩa là bạn thậm chí không truy cập bất kỳ dữ liệu nào trong ứng dụng. Hãy xem xét những điều sau:

function _multiply(uint a, uint b) private pure returns (uint) {
return a * b;
}

Hàm này thậm chí không đọc từ trạng thái của ứng dụng – giá trị trả về của nó chỉ phụ thuộc vào các tham số hàm của nó. Vì vậy, trong trường hợp này, chúng ta sẽ khai báo hàm là pure.

Chương 11: Keccak256 and Typecasting

* Keccak256:

Ethereum có tích hợp hàm băm keccak256, đây là một phiên bản của SHA3. Một hàm băm về cơ bản ánh xạ đầu vào thành một số thập lục phân 256 bit ngẫu nhiên. Một thay đổi nhỏ trong đầu vào sẽ gây ra sự thay đổi lớn trong hàm băm.

Nó hữu ích cho nhiều mục đích trong Ethereum, nhưng hiện tại chúng ta sẽ sử dụng nó để tạo số giả ngẫu nhiên. Cũng quan trọng, keccak256 mong đợi một tham số duy nhất của kiểu byte. Điều này có nghĩa là chúng ta phải “đóng gói” bất kỳ tham số nào trước khi gọi keccak256:

Ví dụ:

//6e91ec6b618bb462a4a6ee5aa2cb0e9cf30f7a052bb467b0ba58b8748c00d2e5 keccak256(abi.encodePacked("aaaab")); //b1f078126895a1424524de5321b339ab00408010b7cf0e6ed451514981e58aa9 keccak256(abi.encodePacked("aaaac"));

Như bạn có thể thấy, các giá trị trả về hoàn toàn khác nhau mặc dù chỉ thay đổi 1 ký tự trong đầu vào.

Lưu ý: Việc tạo số ngẫu nhiên an toàn trong blockchain là một vấn đề rất khó khăn. Phương pháp của chúng ta ở đây là không an toàn, nhưng vì bảo mật không phải là ưu tiên hàng đầu cho DNA Zombie của chúng ta, nên nó sẽ đủ tốt cho các mục đích của chúng ta.

* Typecasting (Chuyển đổi dữ liệu):

Đôi khi bạn cần chuyển đổi giữa các kiểu dữ liệu. Lấy ví dụ sau:

uint8 a = 5;
uint b = 6;
// throws an error because a * b returns a uint, not uint8:
uint8 c = a * b;
// we have to typecast b as a uint8 to make it work:
uint8 c = a * uint8(b);

Ở trên, a * b trả về một uint, nhưng chúng ta đang cố gắng lưu trữ nó dưới dạng uint8, điều này có thể gây ra các vấn đề tiềm ẩn. Bằng cách truyền nó dưới dạng uint8, nó sẽ hoạt động và trình biên dịch sẽ không gặp lỗi.

Chương 12: Kết nối chúng lại

Bây giờ chúng ta hãy tạo một hàm công khai liên kết mọi thứ lại với nhau. Chúng ta sẽ tạo một hàm công khai lấy đầu vào, tên của zombie và sử dụng tên đó để tạo ra một zombie với DNA ngẫu nhiên.

Contract của chúng ta như sau:

pragma solidity >=0.5.0 <0.6.0;
contract ZombieFactory {

event NewZombie(uint zombieId, string name, uint dna);

uint dnaDigits = 16;
uint dnaModulus = 10 ** dnaDigits;

struct Zombie {
string name;
uint dna;
}

Zombie[] public zombies;

function _createZombie(string memory _name, uint _dna) private {
uint id = zombies.push(Zombie(_name, _dna)) - 1;
emit NewZombie(id, _name, _dna);
}

function _generateRandomDna(string memory _str) private view returns (uint) {
uint rand = uint(keccak256(abi.encodePacked(_str)));
return rand % dnaModulus;
}

function createRandomZombie(string memory _name) public {
uint randDna = _generateRandomDna(_name);
_createZombie(_name, randDna);
}
}

Bạn làm theo hướng dẫn là okie nhé.

Chương 13: Events

Hợp đồng của chúng ta đã gần kết thúc! Bây giờ chúng ta hãy thêm một sự kiện.

Sự kiện là một cách để hợp đồng của bạn thông báo rằng điều gì đó đã xảy ra trên blockchain với giao diện người dùng của ứng dụng, có thể ‘lắng nghe‘ các sự kiện nhất định và thực hiện hành động khi chúng xảy ra.

Ví dụ:

// declare the event
event IntegersAdded(uint x, uint y, uint result);

function add(uint _x, uint _y) public returns (uint) {
uint result = _x + _y;
// fire an event to let the app know the function was called:
emit IntegersAdded(_x, _y, result);
return result;
}

Giao diện người dùng ứng dụng của bạn sau đó có thể lắng nghe sự kiện. Triển khai javascript sẽ trông giống như sau:

YourContract.IntegersAdded(function(error, result) {
// do something with result
})

Chương 14: Web3

Hợp đồng Solidity của chúng ta đã hoàn tất! Bây giờ chúng ta cần viết một giao diện người dùng javascript tương tác với hợp đồng.

Ethereum có một thư viện Javascript được gọi là Web3.js. Trong bài học sau, chúng ta sẽ đi sâu hơn về cách triển khai hợp đồng và thiết lập Web3.js. Nhưng bây giờ chúng ta hãy xem một số mã mẫu để biết cách Web3.js sẽ tương tác với hợp đồng đã triển khai của chúng ta.

Đừng lo lắng nếu điều này vẫn chưa có ý nghĩa.

// Here's how we would access our contract: 
var abi = /* abi generated by the compiler */
var ZombieFactoryContract = web3.eth.contract(abi)
var contractAddress = /* our contract address on Ethereum after deploying */
var ZombieFactory = ZombieFactoryContract.at(contractAddress)
// `ZombieFactory` has access to our contract's public functions and events

// some sort of event listener to take the text input:
$("#ourButton").click(function(e) {
var name = $("#nameInput").val()
// Call our contract's `createRandomZombie` function:
ZombieFactory.createRandomZombie(name)
})

// Listen for the `NewZombie` event, and update the UI
var event = ZombieFactory.NewZombie(function(error, result) {
if (error) return
generateZombie(result.zombieId, result.name, result.dna)
})
// take the Zombie dna, and update our image
function generateZombie(id, name, dna) {
let dnaStr = String(dna)
// pad DNA with leading zeroes if it's less than 16 characters
while (dnaStr.length < 16)
dnaStr = "0" + dnaStr

let zombieDetails = {
// first 2 digits make up the head. We have 7 possible heads, so % 7
// to get a number 0 - 6, then add 1 to make it 1 - 7. Then we have 7
// image files named "head1.png" through "head7.png" we load based on
// this number:
headChoice: dnaStr.substring(0, 2) % 7 + 1,
// 2nd 2 digits make up the eyes, 11 variations:
eyeChoice: dnaStr.substring(2, 4) % 11 + 1,
// 6 variations of shirts:
shirtChoice: dnaStr.substring(4, 6) % 6 + 1,
// last 6 digits control color. Updated using CSS filter: hue-rotate
// which has 360 degrees:
skinColorChoice: parseInt(dnaStr.substring(6, 8) / 100 * 360),
eyeColorChoice: parseInt(dnaStr.substring(8, 10) / 100 * 360),
clothesColorChoice: parseInt(dnaStr.substring(10, 12) / 100 * 360),
zombieName: name,
zombieDescription: "A Level 1 CryptoZombie",
}
return zombieDetails
}

Những gì javascript của chúng ta sau đó làm là lấy các giá trị được tạo trong zombieDetails ở trên và sử dụng một số thao tác javascript dựa trên trình duyệt (chúng tôi đang sử dụng Vue.js) để hoán đổi hình ảnh và áp dụng các bộ lọc CSS. Bạn sẽ nhận được tất cả mã cho điều này trong một bài học sau.

Bài 2: Zombies tấn công nạn nhân

Chương 1: Tổng quan

Trong bài học 1, chúng ta đã tạo một hàm lấy name, sử dụng nó để tạo một zombie ngẫu nhiên và thêm zombie đó vào cơ sở dữ liệu zombie của ứng dụng của chúng ta trên blockchain.

Trong bài học 2, chúng ta sẽ làm cho ứng dụng của mình giống trò chơi hơn: Chúng ta sẽ làm cho nó có nhiều người chơi và chúng ta cũng sẽ thêm một cách thú vị hơn để tạo ra các thây ma thay vì chỉ tạo chúng một cách ngẫu nhiên.

Chúng ta sẽ tạo ra những Zombie mới như thế nào? Bằng cách cho các Zombie của chúng ta “ăn” các dạng sống khác!

Zombie Feeding

Khi một con zombie ăn, nó sẽ lây nhiễm virus cho vật chủ. Sau đó, virus sẽ biến vật chủ thành một thây ma mới gia nhập đội quân của bạn. DNA của zombie mới sẽ được tính toán từ DNA của zombie trước đó và DNA của vật chủ.

Và những Zombie của chúng ta thích ăn gì nhất? Để tìm ra điều đó … Bạn sẽ phải hoàn thành bài học 2!

Chương 2: Mappings and Addresses

Hãy làm cho trò chơi của chúng ta có nhiều người chơi bằng cách cấp cho các Zombie trong cơ sở dữ liệu của chúng ta làm chủ sở hữu. Để làm điều này, chúng tôi sẽ cần 2 kiểu dữ liệu mới: mapping và address.

Addresses

Chuỗi khối Ethereum được tạo thành từ các tài khoản, bạn có thể coi đó giống như tài khoản ngân hàng. Một tài khoản có số dư Ether (đơn vị tiền tệ được sử dụng trên chuỗi khối Ethereum) và bạn có thể gửi và nhận các khoản thanh toán bằng Ether đến các tài khoản khác, giống như tài khoản ngân hàng của bạn có thể chuyển tiền đến các tài khoản ngân hàng khác. Mỗi tài khoản có một địa chỉ, mà bạn có thể coi đó giống như số tài khoản ngân hàng. Đó là một số nhận dạng duy nhất trỏ đến tài khoản đó và có dạng như sau:

0x0cE446255506E92DF41614C46F1d6df9Cc969183

(Địa chỉ này thuộc về nhóm CryptoZombies. Nếu bạn thích CryptoZombies, bạn có thể gửi cho chúng tôi một số Ether!)

Chúng ta sẽ đi sâu vào thực tế của các địa chỉ trong một bài học sau, nhưng bây giờ bạn chỉ cần hiểu rằng một địa chỉ thuộc sở hữu của một người dùng cụ thể (hoặc một hợp đồng thông minh). Vì vậy, chúng ta có thể sử dụng nó như một ID duy nhất để sở hữu các Zombie của chúng ta. Khi người dùng tạo ra các Zombie mới bằng cách tương tác với ứng dụng của chúng tôi, chúng tôi sẽ đặt quyền sở hữu các Zombie đó thành địa chỉ Ethereum.

Mappings

Trong Bài học 1, chúng ta đã xem xét structarray. mapping là một cách khác để lưu trữ dữ liệu có tổ chức trong Solidity. Việc xác định một ánh xạ trông giống như sau:

// Đối với ứng dụng tài chính, lưu trữ một gợi ý giữ số dư tài khoản của người dùng:
mapping (address => uint) public accountBalance;
// Or could be used to store / lookup usernames based on userId
mapping (uint => string) userIdToName;

mapping về cơ bản là một kho key-value để lưu trữ và tra cứu dữ liệu. Trong ví dụ đầu tiên, khóa là một address và giá trị là một uint, và trong ví dụ thứ hai, khóa là một uint và giá trị là string.

Chương 3: msg.sender

Bây giờ chúng ta đã có mapping để theo dõi ai sở hữu zombie, chúng ta sẽ muốn cập nhật phương thức _createZombie để sử dụng chúng. Để làm được điều này, chúng ta cần sử dụng một thứ gọi là msg.sender.

msg.sender

Trong Solidity, có một số biến toàn cục chắc chắn có sẵn cho tất cả các hàm. Một trong số đó là msg.sender, nó chứa địa chỉ của người (hoặc hợp đồng thông minh) đã gọi hàm hiện tại.

Lưu ý: Trong Solidity, việc thực thi hàm luôn cần bắt đầu với một trình gọi bên ngoài. Một hợp đồng sẽ chỉ ở trên blockchain không làm gì cả cho đến khi ai đó gọi một trong các chức năng của nó. Vì vậy, sẽ luôn có một msg.sender.

Dưới đây là một ví dụ về việc sử dụng msg.sender và cập nhật mapping:

mapping (address => uint) favoriteNumber;

function setMyNumber(uint _myNumber) public {
// Update our `favoriteNumber` mapping to store `_myNumber` under `msg.sender`
favoriteNumber[msg.sender] = _myNumber;
// ^ The syntax for storing data in a mapping is just like with arrays
}

function whatIsMyNumber() public view returns (uint) {
// Retrieve the value stored in the sender's address
// Will be `0` if the sender hasn't called `setMyNumber` yet
return favoriteNumber[msg.sender];
}

Trong ví dụ đơn giản này, bất kỳ ai cũng có thể gọi setMyNumber và lưu trữ một uint trong hợp đồng của chúng tôi, uint sẽ được gắn với địa chỉ của họ. Sau đó, khi họ gọi whatIsMyNumber, họ sẽ được trả về uint mà họ đã lưu trữ.

Sử dụng msg.sender cung cấp cho bạn sự bảo mật của chuỗi khối Ethereum – cách duy nhất ai đó có thể sửa đổi dữ liệu của người khác là ăn cắp khóa cá nhân được liên kết với địa chỉ Ethereum của họ.

Chương 4: require

Trong bài 1, chúng tôi đã thực hiện để người dùng có thể tạo zombie mới bằng cách gọi createRandomZombie và nhập tên. Tuy nhiên, nếu người dùng có thể tiếp tục gọi chức năng này để tạo ra số lượng zombie không giới hạn trong quân đội của họ, trò chơi sẽ không vui lắm.

Chúng ta sửa lại để mỗi người chơi chỉ có thể gọi chức năng này một lần. Theo cách đó người chơi mới sẽ gọi nó khi họ lần đầu tiên bắt đầu trò chơi để tạo ra zombie ban đầu trong quân đội của họ. Làm thế nào chúng ta có thể làm cho nó để hàm này chỉ có thể được gọi một lần cho mỗi người chơi?

Đối với điều đó chúng tôi sử dụng yêu cầu. Yêu cầu làm cho nó để hàm sẽ tạo ra một lỗi và ngừng thực thi nếu một số điều kiện không đúng:

<Đang cập nhật…>

Nguồn: https://cryptozombies.io/en/course

Bài viết này có hữu ích với bạn?

Kích vào một biểu tượng ngôi sao để đánh giá bài viết!

Xếp hạng trung bình 5 / 5. Số phiếu: 1

Bài viết chưa có đánh giá! Hãy là người đầu tiên đánh giá bài viết này.

Trả lời

Giao diện bởi Anders Norén