What is Token?

Một token ở trong Ethereum chỉ đơn giản là một smart contract mà tuân thủ một số quy tắc chung.

Cụ thể hơn: một token là một contract mà có lưu lại danh sách ai sở hữu bao nhiêu token đó và chứa một vài hàm cho phép người dùng chuyển token của họ tới các địa chỉ khác.

Có một số chuẩn token1:

  • ERC20: interface cho các fungible (interchangeable - có thể thay thế) token chẳng hạn như là voting token, staking token hay các loại tiền ảo.
  • ERC721: interface cho các non-fungible token (NFT), đại diện cho quyền sở hữu của các artwork hoặc bài hát. ERC721 không thể thay thế bởi vì mỗi token sẽ được xem là độc nhất và không thể chia nhỏ.
  • ERC1155: cho phép thực hiện các cuộc trao đổi hiệu quả hơn và cho phép đóng gói các giao dịch, giúp tiết kiệm chi phí.

EIP and ERC

Ethereum Improvement Proposals (EIP) là các đề xuất giúp cải thiện Ethereum. Khi EIP sẵn sàng để trở thành một tiêu chuẩn thì nó sẽ được tạo một Ethereum Request for Comments (ERC). Chúng ta có thể theo dõi EIP và ETC ở Home | Ethereum Improvement Proposals.

ERC20

Một trong số những ERC phổ biến là ERC20 - là một tiêu chuẩn cho phép xây dựng các token mà bản chất của chúng là những record trong các smart contract.

Thực chất, để tạo ra một ERC20 token thì chỉ cần implement một số hàm chẳng hạn như transfer, balanceOf, etc. Ví dụ:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
 
contract ManualToken {
    string public name = "ManualToken";
    mapping(address => uint256) private s_balances;
 
    function totalSupply() public pure returns (uint256) {
        return 100 ether; // 100000000000000000000
    }
 
    function decimals() public pure returns (uint8) {
        return 18;
    }
 
    function balanceOf(address _owner) public view returns (uint256 balance) {
        return s_balances[_owner];
    }
 
    function transfer(address _to, uint256 _amount) public {
        uint256 previousBalance = balanceOf(msg.sender) + balanceOf(_to);
        s_balances[msg.sender] -= _amount;
        s_balances[_to] += _amount;
 
        require(balanceOf(msg.sender) + balanceOf(_to) == previousBalance);
    }
}

Có thể thấy, balance của một token mà ta thường hay thấy chỉ đơn giản là một mapping ở trong smart contract.

OpenZeppelin

Chúng ta có thể sử dụng thư viện của OpenZeppelin để tạo ra các ERC20 token: Contracts - OpenZeppelin Docs

Trước tiên, ta cần install thư viện:

forge install OpenZeppelin/openzeppelin-contracts

Note

Ở đây ta dùng Foundry để install.

Sau đó cho token contract kế thừa ERC20 contract từ OpenZeppelin:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
 
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
 
contract OurToken is ERC20 {
    //constructor goes here
    constructor(uint256 initialSupply) ERC20("OurToken", "OT") {
        _mint(msg.sender, initialSupply);
    }
}

Khi đó, ta có thể sử dụng các method của ERC20 mà không cần phả tự implement.

Approvals and transferFrom

Hàm transferFrom giúp smart contract có thể chuyển token dưới danh nghĩa của người dùng.

function transferFrom(address _from, address _to, uint256 _value) public returns (bool success)

Nếu _from không ủy quyền cho msg.sender của contract thì implementation của hàm này nên throw.

Để ủy quyền, ta cần sử dụng hàm approve:

function approve(address _spender, uint256 _value) public returns (bool success)

Theo mô tả của tiêu chuẩn, hàm approve sẽ giúp cho phép _spender rút một lượng _value token từ tài khoản của người dùng. Việc gọi lại hàm này sẽ ghi đè lượng token được phép chuyển trước đó.

Note

Có thể thấy rằng transferFrom sẽ được sử dụng bởi _to để rút token từ _from sau khi được approve bởi _from.

ERC20 API: An Attack Vector on the approve/transferFrom Methods

Có một kịch bản tấn công liên quan đến bản chất cố hữu của ERC20:

Cite

Here is a possible attack scenario:

  1. Alice allows Bob to transfer of Alice’s tokens () by calling the approve method on a Token smart contract, passing the Bob’s address and as the method arguments
  2. After some time, Alice decides to change from to () the number of Alice’s tokens Bob is allowed to transfer, so she calls the approve method again, this time passing the Bob’s address and as the method arguments
  3. Bob notices the Alice’s second transaction before it was mined and quickly sends another transaction that calls the transferFrom method to transfer Alice’s tokens somewhere
  4. If the Bob’s transaction will be executed before the Alice’s transaction, then Bob will successfully transfer Alice’s tokens and will gain an ability to transfer another tokens
  5. Before Alice noticed that something went wrong, Bob calls the transferFrom method again, this time to transfer Alice’s tokens.

So, an Alice’s attempt to change the Bob’s allowance from to ( and ) made it possible for Bob to transfer of Alice’s tokens, while Alice never wanted to allow so many of her tokens to be transferred by Bob.

Reference: ERC20 API: An Attack Vector on Approve/TransferFrom Methods - Google Tài liệu

Lý do mà kịch bản này xảy ra là vì hàm approve ghi đè giá trị token đã được approve trước đó.

Một số cách để giải quyết:

  • Người ủy quyền cần phải đảm bảo lượng token mà đã approve có giá trị là 0 trước khi tiếp tục thực hiện approve.
  • Chỉ ủy quyền cho những smart contract nào có source code được xác thực và không tồn tại mã độc.

Đã có một vài đề xuất cải thiện ERC20, một trong số đó là sử dụng hàm approve có signature như sau:

function approve(
  address _spender,
  uint256 _currentValue,
  uint256 _value)
returns (bool success

Trong implementation, ta sẽ kiểm tra xem lượng token đã được approve cho _spender có bằng _currentValue hay không (chẳng hạn kiểm tra bằng 0). Nếu có thì overwrite và return true còn nếu không thì return false.

ERC721

Differences from ERC20

ERC20 quản lý các token bằng một ánh xạ giữa address và số dư. Trong khi đó, ERC721 thì lại quản lý mỗi token thông qua tokenIdtokenURI - là một hàm trả về một URI trỏ đến metadata của token. Về bản chất, metadata của token chỉ đơn giản là một file JSON chứa các thông tin liên quan đến token:

{
  "name": "PUG",
  "description": "An adorable PUG pup!",
  "image": "https://bafybeicdlctvdhgvhnu5xqjm6tvjzaw3oyllq77deguvllb52hzu3ur76m.ipfs.dweb.link?filename=pug.png",
  "attributes": [
    {
      "trait_type": "cuteness",
      "value": 100
    }
  ]
}

Important

Tính duy nhất của ERC721 token sẽ được thể hiện thông qua tokenId.

Lý do mà cần dùng đến tokenURI là vì đôi khi các metadata có kích thước lớn chẳng hạn như image không thể lưu on-chain nên ta chỉ có thể lưu URI của nó. Để giải quyết vấn đề lưu trữ các asset nặng, chúng ta có thể lưu trữ hình ảnh (hoặc cả metadata) của một token ở trên IPFS.

Mặc dù là optional nhưng đa số các ERC721 token ngày nay đều có tokenURI. Một trong số đó là Axie. Contract của bộ sưu tập token này có hàm tokenURI nhận vào một số nguyên và kết quả trả về là một URL như sau: metadata.axieinfinity.com/axie/8119235

Tip

Để tạo ra một ERC721 token một cách dễ dàng thì ta có thể tận dụng thư viện ERC-721 - OpenZeppelin

Storing Image and Metadata on Chain

Chúng ta cũng có thể lưu trữ các image và metadata của token mà có kích thước nhỏ on chain bằng cách sử dụng Base64 encoding. Với image URL thì ta dùng scheme là data:image/png;base64, và với metadata thì scheme là data:application/json;base64,. Bằng cách này, trình duyệt vẫn có thể hiểu được metadata và hình ảnh của token.

Ví dụ, ta lưu URL của image on chain như sau:

string public constant HAPPY_SVG_URI = "data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjAwIDIwMCIgd2lkdGg9IjQwMCIgIGhlaWdodD0iNDAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogIDxjaXJjbGUgY3g9IjEwMCIgY3k9IjEwMCIgZmlsbD0ieWVsbG93IiByPSI3OCIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLXdpZHRoPSIzIi8CiAgPGcgY2xhc3M9ImV5ZXMiPgogICAgPGNpcmNsZSBjeD0iNjEiIGN5PSI4MiIgcj0iMTIiLz4KICAgIDxjaXJjbGUgY3g9IjEyNyIgY3k9IjgyIiByPSIxMiIvPgogIDwvZz4KICA8cGF0aCBkPSJtMTM2LjgxIDExNi41M2MuNjkgMjYuMTctNjQuMTEgNDItODEuNTItLjczIiBzdHlsZT0iZmlsbDpub25lOyBzdHJva2U6IGJsYWNrOyBzdHJva2Utd2lkdGg6IDM7Ii8+Cjwvc3ZnPg==";
string public constant SAD_SVG_URI = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAyNHB4IiBoZWlnaHQ9IjEwMjRweCIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8cGF0aCBmaWxsPSIjMzMzIiBkPSJNNTEyIDY0QzI2NC42IDY0IDY0IDI2NC42IDY0IDUxMnMyMDAuNiA0NDggNDQ4IDQ0OCA0NDgtMjAwLjYgNDQ4LTQ0OFM3NTkuNCA2NCA1MTIgNjR6bTAgODIwYy0yMDUuNCAwLTM3Mi0xNjYuNi0zNzItMzcyczE2Ni42LTM3MiAzNzItMzcyIDM3MiAxNjYuNiAzNzIgMzcyLTE2Ni42IDM3Mi0zNzIgMzcyeiIvPgogIDxwYXRoIGZpbGw9IiNFNkU2RTYiIGQ9Ik01MTIgMTQwYy0yMDUuNCAwLTM3MiAxNjYuNi0zNzIgMzcyczE2Ni42IDM3MiAzNzIgMzcyIDM3Mi0xNjYuNiAzNzItMzcyLTE2Ni42LTM3Mi0zNzItMzcyek0yODggNDIxYTQ4LjAxIDQ4LjAxIDAgMCAxIDk2IDAgNDguMDEgNDguMDEgMCAwIDEtOTYgMHptMzc2IDI3MmgtNDguMWMtNC4yIDAtNy44LTMuMi04LjEtNy40QzYwNCA2MzYuMSA1NjIuNSA1OTcgNTEyIDU5N3MtOTIuMSAzOS4xLTk1LjggODguNmMtLjMgNC4yLTMuOSA3LjQtOC4xIDcuNEgzNjBhOCA4IDAgMCAxLTgtOC40YzQuNC04NC4zIDc0LjUtMTUxLjYgMTYwLTE1MS42czE1NS42IDY3LjMgMTYwIDE1MS42YTggOCAwIDAgMS04IDguNHptMjQtMjI0YTQ4LjAxIDQ4LjAxIDAgMCAxIDAtOTYgNDguMDEgNDguMDEgMCAwIDEgMCA5NnoiLz4KICA8cGF0aCBmaWxsPSIjMzMzIiBkPSJNMjg4IDQyMWE0OCA0OCAwIDEgMCA5NiAwIDQ4IDQ4IDAgMSAwLTk2IDB6bTIyNCAxMTJjLTg1LjUgMC0xNTUuNiA2Ny4zLTE2MCAxNTEuNmE4IDggMCAwIDAgOCA4LjRoNDguMWM0LjIgMCA3LjgtMy4yIDguMS03LjQgMy43LTQ5LjUgNDUuMy04OC42IDk1LjgtODguNnM5MiAzOS4xIDk1LjggODguNmMuMyA0LjIgMy45IDcuNCA4LjEgNy40SDY2NGE4IDggMCAwIDAgOC04LjRDNjY3LjYgNjAwLjMgNTk3LjUgNTMzIDUxMiA1MzN6bTEyOC0xMTJhNDggNDggMCAxIDAgOTYgMCA0OCA0OCAwIDEgMC05NiAweiIvPgo8L3N2Zz4=";

Trước khi thực hiện Base64 encode cho metadata, ta cần chuyển kiểu string về thành bytes sử dụng abi.encodePacked. Ví dụ:

string memory tokenMetadata = string.concat(
	'{"name: "',
	name(),
	'", description: "An NFT that reflects your mood!", "attributes": [{"trait_type": "Mood", "value": 100}], "image": ',
	imageURI,
	'"}'
);
 
// Convert it into bytes for base64 encoding
bytes memory packedMetadata = abi.encodePacked(tokenMetadata);

Note

Trong ví dụ trên, ta sử dụng hàm string.concat để nối chuỗi nhằm xây dựng metadata on chain.

Để thực hiện Base64 encode, ta có thể dùng package Utilities của OpenZeppelin:

import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {Base64} from "@openzeppelin/contracts/utils/Base64.sol";
 
contract MoodNft is ERC721 {
	// ...
	function tokenURI(uint256 tokenId) public view override returns(string memory) {
		// ...
		string memory base64Metadata = Base64.encode(packedMetadata);
		// ...
		return string.concat(_baseURI(), base64Metadata);
	}
	// ...
 
	function _baseURI() internal pure override returns (string memory) {
        return "data:application/json;base64,";
    }
}

Với _baseURI là một hàm có trong ERC721 và ta có thể override.

Khi được gọi, giá trị trả về của tokenURI sẽ có thể được nhập vào browser để xem dữ liệu như sau:

data: application / json
;(base64,
  eyJuYW1lOiAiTW9vZCBORlQiLCBkZXNjcmlwdGlvbjogIkFuIE5GVCB0aGF0IHJlZmxlY3RzIHlvdXIgbW9vZCEiLCAiYXR0cmlidXRlcyI6IFt7InRyYWl0X3R5cGUiOiAiTW9vZCIsICJ2YWx1ZSI6IDEwMH1dLCAiaW1hZ2UiOiBkYXRhOmltYWdlL3N2Zyt4bWw7YmFzZTY0LFBITjJaeUIzYVdSMGFEMGlNVEF5TkhCNElpQm9aV2xuYUhROUlqRXdNalJ3ZUNJZ2RtbGxkMEp2ZUQwaU1DQXdJREV3TWpRZ01UQXlOQ0lnZUcxc2JuTTlJbWgwZEhBNkx5OTNkM2N1ZHpNdWIzSm5Mekl3TURBdmMzWm5JajRLSUNBOGNHRjBhQ0JtYVd4c1BTSWpNek16SWlCa1BTSk5OVEV5SURZMFF6STJOQzQySURZMElEWTBJREkyTkM0MklEWTBJRFV4TW5NeU1EQXVOaUEwTkRnZ05EUTRJRFEwT0NBME5EZ3RNakF3TGpZZ05EUTRMVFEwT0ZNM05Ua3VOQ0EyTkNBMU1USWdOalI2YlRBZ09ESXdZeTB5TURVdU5DQXdMVE0zTWkweE5qWXVOaTB6TnpJdE16Y3ljekUyTmk0MkxUTTNNaUF6TnpJdE16Y3lJRE0zTWlBeE5qWXVOaUF6TnpJZ016Y3lMVEUyTmk0MklETTNNaTB6TnpJZ016Y3llaUl2UGdvZ0lEeHdZWFJvSUdacGJHdzlJaU5GTmtVMlJUWWlJR1E5SWswMU1USWdNVFF3WXkweU1EVXVOQ0F3TFRNM01pQXhOall1Tmkwek56SWdNemN5Y3pFMk5pNDJJRE0zTWlBek56SWdNemN5SURNM01pMHhOall1TmlBek56SXRNemN5TFRFMk5pNDJMVE0zTWkwek56SXRNemN5ZWsweU9EZ2dOREl4WVRRNExqQXhJRFE0TGpBeElEQWdNQ0F4SURrMklEQWdORGd1TURFZ05EZ3VNREVnTUNBd0lERXRPVFlnTUhwdE16YzJJREkzTW1ndE5EZ3VNV010TkM0eUlEQXROeTQ0TFRNdU1pMDRMakV0Tnk0MFF6WXdOQ0EyTXpZdU1TQTFOakl1TlNBMU9UY2dOVEV5SURVNU4zTXRPVEl1TVNBek9TNHhMVGsxTGpnZ09EZ3VObU10TGpNZ05DNHlMVE11T1NBM0xqUXRPQzR4SURjdU5FZ3pOakJoT0NBNElEQWdNQ0F4TFRndE9DNDBZelF1TkMwNE5DNHpJRGMwTGpVdE1UVXhMallnTVRZd0xURTFNUzQyY3pFMU5TNDJJRFkzTGpNZ01UWXdJREUxTVM0MllUZ2dPQ0F3SURBZ01TMDRJRGd1TkhwdE1qUXRNakkwWVRRNExqQXhJRFE0TGpBeElEQWdNQ0F4SURBdE9UWWdORGd1TURFZ05EZ3VNREVnTUNBd0lERWdNQ0E1Tm5vaUx6NEtJQ0E4Y0dGMGFDQm1hV3hzUFNJak16TXpJaUJrUFNKTk1qZzRJRFF5TVdFME9DQTBPQ0F3SURFZ01DQTVOaUF3SURRNElEUTRJREFnTVNBd0xUazJJREI2YlRJeU5DQXhNVEpqTFRnMUxqVWdNQzB4TlRVdU5pQTJOeTR6TFRFMk1DQXhOVEV1Tm1FNElEZ2dNQ0F3SURBZ09DQTRMalJvTkRndU1XTTBMaklnTUNBM0xqZ3RNeTR5SURndU1TMDNMalFnTXk0M0xUUTVMalVnTkRVdU15MDRPQzQySURrMUxqZ3RPRGd1Tm5NNU1pQXpPUzR4SURrMUxqZ2dPRGd1Tm1NdU15QTBMaklnTXk0NUlEY3VOQ0E0TGpFZ055NDBTRFkyTkdFNElEZ2dNQ0F3SURBZ09DMDRMalJETmpZM0xqWWdOakF3TGpNZ05UazNMalVnTlRNeklEVXhNaUExTXpONmJURXlPQzB4TVRKaE5EZ2dORGdnTUNBeElEQWdPVFlnTUNBME9DQTBPQ0F3SURFZ01DMDVOaUF3ZWlJdlBnbzhMM04yWno0PSJ9)

Minting

Sau khi có URL của metadata thì ta có thể gán nó vào một mapping giữa tokenIdtokenURI:

mapping(uint256 => string) private s_tokenIdToUri;

Mỗi lần đúc (tạo ra) một ERC721 token mới, message sender có thể cung cấp metadata URL tùy ý cho token đó nhằm customize token theo ý thích:

function mintNft(string memory tokenUri) public {
	s_tokenIdToUri[s_tokenCounter] = tokenUri;
	_safeMint(msg.sender, s_tokenCounter);
	s_tokenCounter += 1;
}

Note

Có thể thấy, để đúc một ERC721 token thì ta gọi hàm _safeMint của ERC721 contract từ OpenZeppelin.

(Các) hàm _safeMint có signature như sau:

/**
 * @dev Mints `tokenId`, transfers it to `to` and checks for `to` acceptance.
 *
 * Requirements:
 *
 * - `tokenId` must not exist.
 * - If `to` refers to a smart contract, it must implement {IERC721Receiver-onERC721Received}, which is called upon a safe transfer.
 *
 * Emits a {Transfer} event.
 */
function _safeMint(address to, uint256 tokenId) internal {
	_safeMint(to, tokenId, "");
}
 
/**
 * @dev Same as {xref-ERC721-_safeMint-address-uint256-}[`_safeMint`], with an additional `data` parameter which is
 * forwarded in {IERC721Receiver-onERC721Received} to contract recipients.
 */
function _safeMint(address to, uint256 tokenId, bytes memory data) internal virtual {
	_mint(to, tokenId);
	ERC721Utils.checkOnERC721Received(_msgSender(), address(0), to, tokenId, data);
}

Hàm tokenURI chỉ đơn giản là trả về token URI tương ứng với từng token ID:

function tokenURI(
	uint256 tokenId
) public view override returns (string memory) {
	return s_tokenIdToUri[tokenId];
}

Note

Hàm này override hàm trong contract ERC721 của OpenZeppelin.

balanceOf & ownerOf

Hàm balanceOf có signature như sau:

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

Hàm này chỉ đơn giản là nhận vào một địa chỉ và trả về số lượng token mà địa chỉ đó sở hữu.

Signature của hàm ownerOf:

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

Hàm này sẽ nhận vào ID của token và trả về địa chỉ của chủ sở hữu token.

Chúng ta có thể dễ dàng implement các hàm này bằng cách sử dụng các mapping2.

transferFrom & approve

Hàm transferFrom giúp chuyển token có ID là _tokenId từ địa chỉ _from đến địa chỉ _to:

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

Hàm approve sẽ thực hiện lưu lại địa chỉ của chủ sở hữu mới của token có ID là _tokenId, thường là vào một mapping. Signature của hàm như sau:

function approve(address _approved, uint256 _tokenId) external payable;

Trước khi gọi hàm transferFrom thì sender cần phải gọi hàm approve để xác minh người nhận. Sau đó, khi transferFrom được gọi, nó sẽ kiểm tra xem địa chỉ gọi thực hiện hàm có phải là địa chỉ đã được approved hay địa chỉ của chủ sở hữu token hay không. Nếu không thì việc chuyển quyền sở hữu token sẽ không xảy ra.

Ví dụ implement:

mapping (uint => address) public zombieToOwner;
mapping (uint => address) zombieApprovals;
 
// Transfers a zombie token from one address to another
function _transfer(address _from, address _to, uint256 _tokenId) private {
	zombieToOwner[_tokenId] = _to;
	emit Transfer(_from, _to, _tokenId);
}
 
// Transfers a zombie token from one address to another, with approval check
function transferFrom(address _from, address _to, uint256 _tokenId) external payable {
	require (zombieToOwner[_tokenId] == msg.sender || zombieApprovals[_tokenId] == msg.sender);
	_transfer(_from, _to, _tokenId);
}
 
// Approves an address to take ownership of a specific zombie token
function approve(address _approved, uint256 _tokenId) external payable onlyOwnerOf(_tokenId) {
	zombieApprovals[_tokenId] = _approved;
	emit Approval(msg.sender, _approved, _tokenId);
}

Như vậy, approve chỉ có thể được gọi bởi chủ sở hữu token và transferFrom thì có thể được gọi bởi địa chỉ của chủ sở hữu token và cả địa chỉ đã được approved.

Có thể thấy, hai sự kiện TransferApproval lần lượt được emit ở trong hàm _transferapprove.

_isAuthorized

Đây là một hàm có sẵn trong contract ERC721 của OpenZeppelin từ phiên bản 5.0.0.

Trước đó, để authorize thì ta gọi sử dụng hàm _isApprovedOrOwner(address spender, uint256 tokenId) → bool và kiểm tra xem msg.sender có được quyền quản lý token có ID là tokenId hay không.

if(!_isApprovedOrOwner(msg.sender, tokenId)){
	revert;
}

Tuy nhiên, hàm _isApprovedOrOwner đã bị thay thế bằng _isAuthorized ở phiên bản 5.0.0 và hàm này assume rằng owner là chủ sở hữu của tokenId. Điều này đôi khi là không đúng do owner của ERC721 contract có thể không phải là chủ sở hữu của một token mà được mint ra bởi một address khác.

Thay vì phụ thuộc vào _isAuthorized thì ta có thể gọi hàm getApproved và hàm ownerOf để kiểm tra quyền của msg.sender. Với hàm getApproved sẽ lấy ra địa chỉ mà đã được approve cho token và hàm ownerOf dùng để lấy ra owner của token.

if(getApproved(tokenId) != msg.sender && ownerOf(tokenId) != msg.sender){
	revert;
}

Resources

Footnotes

  1. Tham khảo: Token Standards | ethereum.org.

  2. Xem thêm Mappings.