Nhảy đến nội dung chính

Hướng dẫn về Hợp đồng ERC-20

Solidity
erc-20
Người mới bắt đầu
Ori Pomerantz
9 tháng 3, 2021
34 số phút đọc

Giới thiệu

Một trong những ứng dụng cho Ethereum đó là cho một nhóm tạo ra Token có thể giao dịch, nói đơn giản là tiền tệ của riêng họ. Các token này thường tuân theo một tiêu chuẩn, ERC-20. Tiêu chuẩn này giúp có thể viết các công cụ, chẳng hạn như các bể thanh khoản và ví, hoạt động với tất cả các token ERC-20. Trong bài viết này, chúng tôi sẽ phân tích việc triển khai Solidity ERC20 của OpenZeppelin (opens in a new tab), cũng như định nghĩa giao diện (opens in a new tab).

Đây là mã nguồn có chú thích. Nếu bạn muốn triển khai ERC-20, hãy đọc hướng dẫn này (opens in a new tab).

Giao diện

Mục đích của một tiêu chuẩn như ERC-20 là cho phép nhiều triển khai token có thể tương tác trên các ứng dụng, như ví và sàn giao dịch phi tập trung. Để đạt được điều đó, chúng tôi tạo một giao diện (opens in a new tab). Bất kỳ mã nào cần sử dụng hợp đồng token đều có thể sử dụng các định nghĩa giống nhau trong giao diện và tương thích với tất cả các hợp đồng token sử dụng nó, cho dù đó là ví như MetaMask, một ứng dụng phi tập trung như etherscan.io, hoặc một hợp đồng khác như bể thanh khoản.

Minh họa về giao diện ERC-20

Nếu bạn là một lập trình viên có kinh nghiệm, bạn có thể nhớ đã thấy các cấu trúc tương tự trong Java (opens in a new tab) hoặc ngay cả trong các tệp tiêu đề C (opens in a new tab).

Đây là định nghĩa của Giao diện ERC-20 (opens in a new tab) từ OpenZeppelin. Đó là một bản dịch của tiêu chuẩn mà con người có thể đọc được (opens in a new tab) thành mã Solidity. Tất nhiên, bản thân giao diện không định nghĩa cách làm bất cứ điều gì. Điều đó được giải thích trong mã nguồn hợp đồng bên dưới.

 

1// SPDX-License-Identifier: MIT

Các tệp Solidity phải bao gồm một mã định danh giấy phép. Bạn có thể xem danh sách các giấy phép tại đây (opens in a new tab). Nếu bạn cần một giấy phép khác, chỉ cần giải thích nó trong các bình luận.

 

1pragma solidity >=0.6.0 <0.8.0;

Ngôn ngữ Solidity vẫn đang phát triển nhanh chóng, và các phiên bản mới có thể không tương thích với mã cũ (xem tại đây (opens in a new tab)). Do đó, bạn nên chỉ định không chỉ phiên bản tối thiểu của ngôn ngữ, mà còn cả phiên bản tối đa, phiên bản mới nhất mà bạn đã kiểm tra mã.

 

1/**
2 * @dev Giao diện của tiêu chuẩn ERC20 như được định nghĩa trong EIP.
3 */

@dev trong bình luận là một phần của định dạng NatSpec (opens in a new tab), được sử dụng để tạo tài liệu từ mã nguồn.

 

1interface IERC20 {

Theo quy ước, tên giao diện bắt đầu bằng I.

 

1 /**
2 * @dev Trả về số lượng token đang tồn tại.
3 */
4 function totalSupply() external view returns (uint256);

Hàm này là external, có nghĩa là nó chỉ có thể được gọi từ bên ngoài hợp đồng (opens in a new tab). Nó trả về tổng cung token trong hợp đồng. Giá trị này được trả về bằng cách sử dụng loại phổ biến nhất trong Ethereum, số nguyên không dấu 256 bit (256 bit là kích thước từ gốc của EVM). Hàm này cũng là một view, có nghĩa là nó không thay đổi trạng thái, vì vậy nó có thể được thực thi trên một nút duy nhất thay vì mọi nút trong chuỗi khối đều phải chạy nó. Loại hàm này không tạo ra một giao dịch và không tốn gas.

Lưu ý: Về lý thuyết, có vẻ như người tạo hợp đồng có thể gian lận bằng cách trả về tổng cung nhỏ hơn giá trị thực, khiến mỗi token có vẻ giá trị hơn so với thực tế. Tuy nhiên, nỗi sợ đó đã bỏ qua bản chất thực sự của chuỗi khối. Mọi thứ xảy ra trên chuỗi khối đều có thể được xác minh bởi mọi nút. Để đạt được điều này, mã ngôn ngữ máy và bộ nhớ của mọi hợp đồng đều có sẵn trên mọi nút. Mặc dù bạn không bắt buộc phải xuất bản mã Solidity cho hợp đồng của mình, nhưng sẽ không ai coi trọng bạn trừ khi bạn xuất bản mã nguồn và phiên bản Solidity mà nó được biên dịch, để nó có thể được xác minh với mã ngôn ngữ máy mà bạn đã cung cấp. Ví dụ: xem hợp đồng này (opens in a new tab).

 

1 /**
2 * @dev Trả về số lượng token thuộc sở hữu của `account`.
3 */
4 function balanceOf(address account) external view returns (uint256);

Đúng như tên gọi, balanceOf trả về số dư của một tài khoản. Các tài khoản Ethereum được xác định trong Solidity bằng loại address, chứa 160 bit. Nó cũng là externalview.

 

1 /**
2 * @dev Di chuyển `amount` token từ tài khoản của người gọi đến `recipient`.
3 *
4 * Trả về một giá trị boolean cho biết liệu hoạt động có thành công hay không.
5 *
6 * Phát ra một sự kiện {Transfer}.
7 */
8 function transfer(address recipient, uint256 amount) external returns (bool);

Hàm transfer chuyển token từ người gọi đến một địa chỉ khác. Điều này liên quan đến việc thay đổi trạng thái, vì vậy nó không phải là view. Khi người dùng gọi hàm này, nó sẽ tạo ra một giao dịch và tốn gas. Nó cũng phát ra một sự kiện, Transfer, để thông báo cho mọi người trên chuỗi khối về sự kiện này.

Hàm có hai loại đầu ra cho hai loại người gọi khác nhau:

  • Người dùng gọi hàm trực tiếp từ giao diện người dùng. Thông thường người dùng gửi một giao dịch và không đợi phản hồi, việc này có thể mất một khoảng thời gian không xác định. Người dùng có thể xem những gì đã xảy ra bằng cách tìm biên lai giao dịch (được xác định bởi hàm băm giao dịch) hoặc bằng cách tìm sự kiện Transfer.
  • Các hợp đồng khác, gọi hàm như một phần của một giao dịch tổng thể. Các hợp đồng đó nhận được kết quả ngay lập tức, vì chúng chạy trong cùng một giao dịch, vì vậy chúng có thể sử dụng giá trị trả về của hàm.

Loại đầu ra tương tự được tạo bởi các hàm khác làm thay đổi trạng thái của hợp đồng.

 

Hạn mức cho phép một tài khoản chi tiêu một số token thuộc về một chủ sở hữu khác. Điều này hữu ích, ví dụ, đối với các hợp đồng hoạt động như người bán. Các hợp đồng không thể giám sát các sự kiện, vì vậy nếu người mua chuyển token trực tiếp đến hợp đồng của người bán thì hợp đồng đó sẽ không biết nó đã được thanh toán. Thay vào đó, người mua cho phép hợp đồng người bán chi tiêu một số tiền nhất định và người bán chuyển số tiền đó. Điều này được thực hiện thông qua một hàm mà hợp đồng người bán gọi, do đó hợp đồng người bán có thể biết liệu nó có thành công hay không.

1 /**
2 * @dev Trả về số lượng token còn lại mà `spender` sẽ được
3 * phép chi tiêu thay mặt cho `owner` thông qua {transferFrom}. Con số này
4 * mặc định là không.
5 *
6 * Giá trị này thay đổi khi {approve} hoặc {transferFrom} được gọi.
7 */
8 function allowance(address owner, address spender) external view returns (uint256);

Hàm allowance cho phép bất kỳ ai truy vấn để xem hạn mức mà một địa chỉ (owner) cho phép một địa chỉ khác (spender) chi tiêu.

 

1 /**
2 * @dev Đặt `amount` làm hạn mức của `spender` trên các token của người gọi.
3 *
4 * Trả về một giá trị boolean cho biết liệu hoạt động có thành công hay không.
5 *
6 * QUAN TRỌNG: Cẩn thận rằng việc thay đổi hạn mức bằng phương pháp này có nguy cơ
7 * ai đó có thể sử dụng cả hạn mức cũ và mới do thứ tự
8 * giao dịch không may mắn. Một giải pháp khả thi để giảm thiểu tình trạng
9 * chạy đua này là trước tiên giảm hạn mức của người chi tiêu xuống 0 và đặt
10 * giá trị mong muốn sau đó:
11 * https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729
12 *
13 * Phát ra một sự kiện {Approval}.
14 */
15 function approve(address spender, uint256 amount) external returns (bool);
Hiện tất cả

Hàm approve tạo ra một hạn mức. Hãy chắc chắn đọc thông điệp về cách nó có thể bị lạm dụng. Trong Ethereum, bạn kiểm soát thứ tự các giao dịch của chính mình, nhưng bạn không thể kiểm soát thứ tự các giao dịch của người khác sẽ được thực hiện, trừ khi bạn không gửi giao dịch của mình cho đến khi bạn thấy giao dịch của phía bên kia đã xảy ra.

 

1 /**
2 * @dev Di chuyển `amount` token từ `sender` đến `recipient` bằng cách sử dụng
3 * cơ chế hạn mức. `amount` sau đó được khấu trừ từ hạn mức của người gọi
4 *
5 *
6 * Trả về một giá trị boolean cho biết liệu hoạt động có thành công hay không.
7 *
8 * Phát ra một sự kiện {Transfer}.
9 */
10 function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);
Hiện tất cả

Cuối cùng, transferFrom được người chi tiêu sử dụng để thực sự chi tiêu hạn mức.

 

1
2 /**
3 * @dev Được phát ra khi token `value` được chuyển từ một tài khoản (`from`) sang
4 * một tài khoản khác (`to`).
5 *
6 * Lưu ý rằng `value` có thể bằng không.
7 */
8 event Transfer(address indexed from, address indexed to, uint256 value);
9
10 /**
11 * @dev Được phát ra khi hạn mức của `spender` cho một `owner` được đặt bởi
12 * một cuộc gọi đến {approve}. `value` là hạn mức mới.
13 */
14 event Approval(address indexed owner, address indexed spender, uint256 value);
15}
Hiện tất cả

Những sự kiện này được phát ra khi trạng thái của hợp đồng ERC-20 thay đổi.

Hợp đồng thực tế

Đây là hợp đồng thực tế triển khai tiêu chuẩn ERC-20, lấy từ đây (opens in a new tab). Nó không có nghĩa là để được sử dụng nguyên trạng, nhưng bạn có thể kế thừa (opens in a new tab) từ nó để mở rộng nó thành một cái gì đó có thể sử dụng được.

1// SPDX-License-Identifier: MIT
2pragma solidity >=0.6.0 <0.8.0;

 

Các câu lệnh nhập

Ngoài các định nghĩa giao diện ở trên, định nghĩa hợp đồng nhập hai tệp khác:

1
2import "../../GSN/Context.sol";
3import "./IERC20.sol";
4import "../../math/SafeMath.sol";
  • GSN/Context.sol là các định nghĩa cần thiết để sử dụng OpenGSN (opens in a new tab), một hệ thống cho phép người dùng không có ether sử dụng chuỗi khối. Lưu ý rằng đây là một phiên bản cũ, nếu bạn muốn tích hợp với OpenGSN hãy sử dụng hướng dẫn này (opens in a new tab).
  • Thư viện SafeMath (opens in a new tab), ngăn chặn tràn số/tràn số dưới cho các phiên bản Solidity <0.8.0. Trong Solidity ≥0.8.0, các phép toán số học tự động hoàn nguyên khi tràn số/tràn số dưới, khiến SafeMath không cần thiết. Hợp đồng này sử dụng SafeMath để tương thích ngược với các phiên bản trình biên dịch cũ hơn.

 

Bình luận này giải thích mục đích của hợp đồng.

1/**
2 * @dev Việc triển khai giao diện {IERC20}.
3 *
4 * Việc triển khai này không phụ thuộc vào cách token được tạo ra. Điều này có nghĩa là
5 * một cơ chế cung cấp phải được thêm vào trong một hợp đồng phái sinh bằng cách sử dụng {_mint}.
6 * Để biết cơ chế chung, hãy xem {ERC20PresetMinterPauser}.
7 *
8 * MẸO: Để xem bài viết chi tiết, hãy xem hướng dẫn của chúng tôi
9 * https://forum.zeppelin.solutions/t/how-to-implement-erc20-supply-mechanisms/226[Cách
10 * triển khai cơ chế cung cấp].
11 *
12 * Chúng tôi đã tuân theo các nguyên tắc chung của OpenZeppelin: các hàm sẽ hoàn nguyên thay
13 * vì trả về `false` khi thất bại. Hành vi này tuy nhiên là thông thường
14 * và không mâu thuẫn với các kỳ vọng của ứng dụng ERC20.
15 *
16 * Ngoài ra, một sự kiện {Approval} được phát ra khi gọi hàm {transferFrom}.
17 * Điều này cho phép các ứng dụng tái tạo khoản cấp phép cho tất cả các tài khoản chỉ
18 * bằng cách lắng nghe các sự kiện đã nói. Các triển khai khác của EIP có thể không phát ra
19 * các sự kiện này, vì nó không được yêu cầu bởi đặc tả.
20 *
21 * Cuối cùng, các hàm không chuẩn {decreaseAllowance} và {increaseAllowance}
22 * đã được thêm vào để giảm thiểu các vấn đề đã biết xung quanh việc đặt
23 * khoản cấp phép. Xem {IERC20-approve}.
24 */
Hiện tất cả

Định nghĩa Hợp đồng

1contract ERC20 is Context, IERC20 {

Dòng này chỉ định sự kế thừa, trong trường hợp này là từ IERC20 ở trên và Context, cho OpenGSN.

 

1
2 using SafeMath for uint256;
3

Dòng này gắn thư viện SafeMath vào loại uint256. Bạn có thể tìm thấy thư viện này tại đây (opens in a new tab).

Định nghĩa Biến

Các định nghĩa này chỉ định các biến trạng thái của hợp đồng. Các biến này được khai báo là private, nhưng điều đó chỉ có nghĩa là các hợp đồng khác trên chuỗi khối không thể đọc chúng. Không có bí mật nào trên chuỗi khối, phần mềm trên mọi nút có trạng thái của mọi hợp đồng tại mỗi khối. Theo quy ước, các biến trạng thái được đặt tên là _<something>.

Hai biến đầu tiên là ánh xạ (opens in a new tab), có nghĩa là chúng hoạt động gần giống như mảng liên kết (opens in a new tab), ngoại trừ việc các khóa là các giá trị số. Lưu trữ chỉ được cấp phát cho các mục có giá trị khác với giá trị mặc định (không).

1 mapping (address => uint256) private _balances;

Ánh xạ đầu tiên, _balances, là các địa chỉ và số dư tương ứng của token này. Để truy cập số dư, hãy sử dụng cú pháp này: _balances[<address>].

 

1 mapping (address => mapping (address => uint256)) private _allowances;

Biến này, _allowances, lưu trữ các khoản cấp phép đã được giải thích trước đó. Chỉ số đầu tiên là chủ sở hữu của các token, và chỉ số thứ hai là hợp đồng có khoản cấp phép. Để truy cập số tiền mà địa chỉ A có thể tiêu từ tài khoản của địa chỉ B, hãy sử dụng _allowances[B][A].

 

1 uint256 private _totalSupply;

Đúng như tên gọi, biến này theo dõi tổng nguồn cung token.

 

1 string private _name;
2 string private _symbol;
3 uint8 private _decimals;

Ba biến này được sử dụng để cải thiện khả năng đọc. Hai biến đầu tiên tự giải thích, nhưng _decimals thì không.

Một mặt, Ethereum không có các biến dấu phẩy động hoặc biến phân số. Mặt khác, con người thích có thể chia nhỏ các token. Một lý do mọi người chọn vàng làm tiền tệ là vì rất khó để thối tiền khi ai đó muốn mua một con vịt bằng giá trị của một con bò.

Giải pháp là theo dõi các số nguyên, nhưng thay vì đếm token thực, chúng ta đếm một token phân số gần như vô giá trị. Trong trường hợp của ether, token phân số được gọi là wei, và 10^18 wei bằng một ETH. Tại thời điểm viết bài, 10.000.000.000.000 wei xấp xỉ một cent Mỹ hoặc Euro.

Các ứng dụng cần biết cách hiển thị số dư token. Nếu một người dùng có 3.141.000.000.000.000.000 wei, đó có phải là 3.14 ETH không? 31.41 ETH? 3.141 ETH? Trong trường hợp của ether, nó được định nghĩa là 10^18 wei cho một ETH, nhưng đối với token của bạn, bạn có thể chọn một giá trị khác. Nếu việc chia token không có ý nghĩa, bạn có thể sử dụng một giá trị _decimals bằng không. Nếu bạn muốn sử dụng cùng một tiêu chuẩn như ETH, hãy sử dụng giá trị 18.

Hàm dựng

1 /**
2 * @dev Đặt các giá trị cho {name} và {symbol}, khởi tạo {decimals} với
3 * giá trị mặc định là 18.
4 *
5 * Để chọn một giá trị khác cho {decimals}, hãy sử dụng {_setupDecimals}.
6 *
7 * Cả ba giá trị này đều là bất biến: chúng chỉ có thể được đặt một lần trong
8 * quá trình khởi tạo.
9 */
10 constructor (string memory name_, string memory symbol_) public {
11 // Trong Solidity ≥0.7.0, 'public' là ngầm định và có thể được bỏ qua.
12
13 _name = name_;
14 _symbol = symbol_;
15 _decimals = 18;
16 }
Hiện tất cả

Hàm dựng được gọi khi hợp đồng được tạo lần đầu tiên. Theo quy ước, các tham số hàm được đặt tên là <something>_.

Các Hàm Giao diện Người dùng

1 /**
2 * @dev Trả về tên của token.
3 */
4 function name() public view returns (string memory) {
5 return _name;
6 }
7
8 /**
9 * @dev Trả về ký hiệu của token, thường là một phiên bản ngắn hơn của
10 * tên.
11 */
12 function symbol() public view returns (string memory) {
13 return _symbol;
14 }
15
16 /**
17 * @dev Trả về số lượng số thập phân được sử dụng để lấy biểu diễn người dùng của nó.
18 * Ví dụ, nếu `decimals` bằng `2`, số dư là `505` token sẽ
19 * được hiển thị cho người dùng là `5,05` (`505 / 10 ** 2`).
20 *
21 * Các token thường chọn giá trị là 18, bắt chước mối quan hệ giữa
22 * ether và wei. Đây là giá trị mà {ERC20} sử dụng, trừ khi {_setupDecimals}
23 * được gọi.
24 *
25 * LƯU Ý: Thông tin này chỉ được sử dụng cho mục đích _hiển thị_: nó
26 * không ảnh hưởng đến bất kỳ phép tính số học nào của hợp đồng, bao gồm
27 * {IERC20-balanceOf} và {IERC20-transfer}.
28 */
29 function decimals() public view returns (uint8) {
30 return _decimals;
31 }
Hiện tất cả

Các hàm này, name, symbol, và decimals giúp các giao diện người dùng biết về hợp đồng của bạn để chúng có thể hiển thị nó một cách chính xác.

Loại trả về là string memory, có nghĩa là trả về một chuỗi được lưu trữ trong bộ nhớ. Các biến, chẳng hạn như chuỗi, có thể được lưu trữ ở ba vị trí:

Vòng đờiTruy cập Hợp đồngChi phí Gas
Bộ nhớLệnh gọi hàmĐọc/GhiHàng chục hoặc hàng trăm (cao hơn cho các vị trí cao hơn)
CalldataLệnh gọi hàmChỉ đọcKhông thể được sử dụng làm loại trả về, chỉ là loại tham số hàm
Lưu trữCho đến khi được thay đổiĐọc/GhiCao (800 cho đọc, 20k cho ghi)

Trong trường hợp này, memory là lựa chọn tốt nhất.

Đọc thông tin Token

Đây là các hàm cung cấp thông tin về token, hoặc là tổng nguồn cung hoặc là số dư của một tài khoản.

1 /**
2 * @dev Xem {IERC20-totalSupply}.
3 */
4 function totalSupply() public view override returns (uint256) {
5 return _totalSupply;
6 }

Hàm totalSupply trả về tổng nguồn cung token.

 

1 /**
2 * @dev Xem {IERC20-balanceOf}.
3 */
4 function balanceOf(address account) public view override returns (uint256) {
5 return _balances[account];
6 }

Đọc số dư của một tài khoản. Lưu ý rằng bất kỳ ai cũng được phép lấy số dư tài khoản của bất kỳ ai khác. Không có lý do gì để cố gắng che giấu thông tin này, vì nó có sẵn trên mọi nút. Không có bí mật nào trên chuỗi khối.

Chuyển Token

1 /**
2 * @dev Xem {IERC20-transfer}.
3 *
4 * Các yêu cầu:
5 *
6 * - `recipient` không được là địa chỉ không.
7 * - người gọi phải có số dư ít nhất là `amount`.
8 */
9 function transfer(address recipient, uint256 amount) public virtual override returns (bool) {
Hiện tất cả

Hàm transfer được gọi để chuyển token từ tài khoản của người gửi sang một tài khoản khác. Lưu ý rằng mặc dù nó trả về một giá trị boolean, giá trị đó luôn là true. Nếu việc chuyển khoản thất bại, hợp đồng sẽ hoàn nguyên lệnh gọi.

 

1 _transfer(_msgSender(), recipient, amount);
2 return true;
3 }

Hàm _transfer thực hiện công việc thực tế. Nó là một hàm riêng tư chỉ có thể được gọi bởi các hàm khác của hợp đồng. Theo quy ước, các hàm riêng tư được đặt tên là _<something>, giống như các biến trạng thái.

Thông thường trong Solidity, chúng ta sử dụng msg.sender cho người gửi thông điệp. Tuy nhiên, điều đó làm hỏng OpenGSN (opens in a new tab). Nếu chúng ta muốn cho phép các giao dịch không cần ether với token của mình, chúng ta cần sử dụng _msgSender(). Nó trả về msg.sender cho các giao dịch bình thường, nhưng đối với các giao dịch không cần ether thì trả về người ký ban đầu chứ không phải hợp đồng đã chuyển tiếp thông điệp.

Các Hàm Cấp phép

Đây là các hàm triển khai chức năng cấp phép: allowance, approve, transferFrom, và _approve. Ngoài ra, việc triển khai OpenZeppelin vượt ra ngoài tiêu chuẩn cơ bản để bao gồm một số tính năng cải thiện bảo mật: increaseAllowance, và decreaseAllowance.

Hàm cấp phép

1 /**
2 * @dev Xem {IERC20-allowance}.
3 */
4 function allowance(address owner, address spender) public view virtual override returns (uint256) {
5 return _allowances[owner][spender];
6 }

Hàm allowance cho phép mọi người kiểm tra bất kỳ khoản cấp phép nào.

Hàm phê duyệt

1 /**
2 * @dev Xem {IERC20-approve}.
3 *
4 * Các yêu cầu:
5 *
6 * - `spender` không được là địa chỉ không.
7 */
8 function approve(address spender, uint256 amount) public virtual override returns (bool) {

Hàm này được gọi để tạo một khoản cấp phép. Nó tương tự như hàm transfer ở trên:

  • Hàm chỉ gọi một hàm nội bộ (trong trường hợp này là _approve) để thực hiện công việc thực tế.
  • Hàm sẽ trả về true (nếu thành công) hoặc hoàn nguyên (nếu không).

 

1 _approve(_msgSender(), spender, amount);
2 return true;
3 }

Chúng tôi sử dụng các hàm nội bộ để giảm thiểu số lượng nơi xảy ra thay đổi trạng thái. Bất kỳ hàm nào thay đổi trạng thái đều là một rủi ro bảo mật tiềm tàng cần được kiểm tra về mặt bảo mật. Bằng cách này, chúng ta có ít cơ hội làm sai hơn.

Hàm transferFrom

Đây là hàm mà một người chi tiêu gọi để sử dụng một khoản cấp phép. Điều này đòi hỏi hai hoạt động: chuyển số tiền đang được chi tiêu và giảm khoản cấp phép đi số tiền đó.

1 /**
2 * @dev Xem {IERC20-transferFrom}.
3 *
4 * Phát ra một sự kiện {Approval} cho biết khoản cấp phép đã được cập nhật. Điều này không
5 * được EIP yêu cầu. Xem ghi chú ở đầu {ERC20}.
6 *
7 * Các yêu cầu:
8 *
9 * - `sender` và `recipient` không được là địa chỉ không.
10 * - `sender` phải có số dư ít nhất là `amount`.
11 * - người gọi phải có khoản cấp phép cho các token của ``sender`` ít nhất là
12 * `amount`.
13 */
14 function transferFrom(address sender, address recipient, uint256 amount) public virtual
15 override returns (bool) {
16 _transfer(sender, recipient, amount);
Hiện tất cả

 

Lệnh gọi hàm a.sub(b, "message") thực hiện hai việc. Đầu tiên, nó tính toán a-b, là khoản cấp phép mới. Thứ hai, nó kiểm tra xem kết quả này có phải là số âm hay không. Nếu nó là số âm, lệnh gọi sẽ hoàn nguyên với thông điệp được cung cấp. Lưu ý rằng khi một lệnh gọi hoàn nguyên, bất kỳ quá trình xử lý nào đã được thực hiện trước đó trong lệnh gọi đó đều bị bỏ qua, vì vậy chúng ta không cần phải hoàn tác _transfer.

1 _approve(sender, _msgSender(), _allowances[sender][_msgSender()].sub(amount,
2 "ERC20: số tiền chuyển vượt quá khoản cấp phép"));
3 return true;
4 }

Các bổ sung an toàn của OpenZeppelin

Việc đặt một khoản cấp phép khác không thành một giá trị khác không khác là rất nguy hiểm, bởi vì bạn chỉ kiểm soát thứ tự các giao dịch của riêng mình, chứ không phải của bất kỳ ai khác. Hãy tưởng tượng bạn có hai người dùng, Alice là người ngây thơ và Bill là người không trung thực. Alice muốn một số dịch vụ từ Bill, mà cô ấy nghĩ rằng có giá năm token - vì vậy cô ấy cấp cho Bill một khoản cấp phép là năm token.

Sau đó, có điều gì đó thay đổi và giá của Bill tăng lên mười token. Alice, người vẫn muốn dịch vụ, gửi một giao dịch đặt khoản cấp phép của Bill thành mười. Ngay khi Bill nhìn thấy giao dịch mới này trong bể giao dịch, anh ta gửi một giao dịch chi tiêu năm token của Alice và có giá gas cao hơn nhiều để nó sẽ được khai thác nhanh hơn. Bằng cách đó, Bill có thể chi tiêu năm token đầu tiên và sau đó, khi khoản cấp phép mới của Alice được khai thác, chi tiêu thêm mười token nữa với tổng giá là mười lăm token, nhiều hơn số tiền Alice dự định ủy quyền. Kỹ thuật này được gọi là chạy trước (opens in a new tab)

Giao dịch của AliceNonce của AliceGiao dịch của BillNonce của BillKhoản cấp phép của BillTổng thu nhập của Bill từ Alice
approve(Bill, 5)1050
transferFrom(Alice, Bill, 5)10.12305
approve(Bill, 10)11105
transferFrom(Alice, Bill, 10)10,124015

Để tránh vấn đề này, hai hàm này (increaseAllowancedecreaseAllowance) cho phép bạn sửa đổi hạn mức theo một số lượng cụ thể. Vì vậy, nếu Bill đã chi tiêu năm token, anh ta sẽ chỉ có thể chi tiêu thêm năm token nữa. Tùy thuộc vào thời gian, có hai cách điều này có thể hoạt động, cả hai đều kết thúc với việc Bill chỉ nhận được mười token:

A:

Giao dịch của AliceNonce của AliceGiao dịch của BillNonce của BillKhoản cấp phép của BillTổng thu nhập của Bill từ Alice
approve(Bill, 5)1050
transferFrom(Alice, Bill, 5)10.12305
increaseAllowance(Bill, 5)110+5 = 55
transferFrom(Alice, Bill, 5)10,124010

B:

Giao dịch của AliceNonce của AliceGiao dịch của BillNonce của BillKhoản cấp phép của BillTổng thu nhập của Bill từ Alice
approve(Bill, 5)1050
increaseAllowance(Bill, 5)115+5 = 100
transferFrom(Alice, Bill, 10)10,124010
1 /**
2 * @dev Tăng một cách nguyên tử hạn mức được cấp cho `spender` bởi người gọi.
3 *
4 * Đây là một giải pháp thay thế cho {approve} có thể được sử dụng như một biện pháp giảm thiểu cho
5 * các vấn đề được mô tả trong {IERC20-approve}.
6 *
7 * Phát ra một sự kiện {Approval} cho biết hạn mức đã được cập nhật.
8 *
9 * Các yêu cầu:
10 *
11 * - `spender` không thể là địa chỉ không.
12 */
13 function increaseAllowance(address spender, uint256 addedValue) public virtual returns (bool) {
14 _approve(_msgSender(), spender, _allowances[_msgSender()][spender].add(addedValue));
15 return true;
16 }
Hiện tất cả

Hàm a.add(b) là một hàm cộng an toàn. Trong trường hợp không thể xảy ra là a+b>=2^256, nó không quay vòng theo cách cộng thông thường.

1
2 /**
3 * @dev Giảm một cách nguyên tử hạn mức được cấp cho `spender` bởi người gọi.
4 *
5 * Đây là một giải pháp thay thế cho {approve} có thể được sử dụng như một biện pháp giảm thiểu cho
6 * các vấn đề được mô tả trong {IERC20-approve}.
7 *
8 * Phát ra một sự kiện {Approval} cho biết hạn mức đã được cập nhật.
9 *
10 * Các yêu cầu:
11 *
12 * - `spender` không thể là địa chỉ không.
13 * - `spender` phải có hạn mức cho người gọi ít nhất
14 * `subtractedValue`.
15 */
16 function decreaseAllowance(address spender, uint256 subtractedValue) public virtual returns (bool) {
17 _approve(_msgSender(), spender, _allowances[_msgSender()][spender].sub(subtractedValue,
18 "ERC20: decreased allowance below zero"));
19 return true;
20 }
Hiện tất cả

Các hàm sửa đổi thông tin Token

Đây là bốn hàm thực hiện công việc thực tế: _transfer, _mint, _burn, và _approve.

Hàm _transfer

1 /**
2 * @dev Di chuyển `amount` token từ `sender` sang `recipient`.
3 *
4 * Hàm nội bộ này tương đương với {transfer}, và có thể được sử dụng để
5 * ví dụ, triển khai phí token tự động, cơ chế phạt, v.v.
6 *
7 * Phát ra một sự kiện {Transfer}.
8 *
9 * Các yêu cầu:
10 *
11 * - `sender` không thể là địa chỉ không.
12 * - `recipient` không thể là địa chỉ không.
13 * - `sender` phải có số dư ít nhất `amount`.
14 */
15 function _transfer(address sender, address recipient, uint256 amount) internal virtual {
Hiện tất cả

Hàm này, _transfer, chuyển token từ một tài khoản sang một tài khoản khác. Nó được gọi bởi cả transfer (đối với các lần chuyển từ tài khoản của chính người gửi) và transferFrom (đối với việc sử dụng hạn mức để chuyển từ tài khoản của người khác).

 

1 require(sender != address(0), "ERC20: transfer from the zero address");
2 require(recipient != address(0), "ERC20: transfer to the zero address");

Không ai thực sự sở hữu địa chỉ không trong Ethereum (tức là, không ai biết một khóa riêng tư mà khóa công khai phù hợp của nó được chuyển đổi thành địa chỉ không). Khi mọi người sử dụng địa chỉ đó, thường là một lỗi phần mềm - vì vậy chúng tôi báo lỗi nếu địa chỉ không được sử dụng làm người gửi hoặc người nhận.

 

1 _beforeTokenTransfer(sender, recipient, amount);
2

Có hai cách để sử dụng hợp đồng này:

  1. Sử dụng nó như một mẫu cho mã của riêng bạn
  2. Kế thừa từ nó (opens in a new tab), và chỉ ghi đè những hàm mà bạn cần sửa đổi

Phương pháp thứ hai tốt hơn nhiều vì mã ERC-20 của OpenZeppelin đã được kiểm toán và cho thấy là an toàn. Khi bạn sử dụng kế thừa, nó sẽ rõ ràng những hàm nào bạn sửa đổi, và để tin tưởng vào hợp đồng của bạn, mọi người chỉ cần kiểm toán những hàm cụ thể đó.

Thường rất hữu ích khi thực hiện một hàm mỗi khi token được trao tay. Tuy nhiên, _transfer là một hàm rất quan trọng và có thể viết nó một cách không an toàn (xem bên dưới), vì vậy tốt nhất là không nên ghi đè nó. Giải pháp là _beforeTokenTransfer, một hàm móc (opens in a new tab). Bạn có thể ghi đè hàm này, và nó sẽ được gọi trên mỗi lần chuyển khoản.

 

1 _balances[sender] = _balances[sender].sub(amount, "ERC20: transfer amount exceeds balance");
2 _balances[recipient] = _balances[recipient].add(amount);

Đây là những dòng thực sự thực hiện việc chuyển khoản. Lưu ý rằng không có gì ở giữa chúng, và chúng tôi trừ số tiền được chuyển từ người gửi trước khi cộng nó vào người nhận. Điều này quan trọng vì nếu có một cuộc gọi đến một hợp đồng khác ở giữa, nó có thể đã được sử dụng để gian lận hợp đồng này. Bằng cách này, việc chuyển khoản là nguyên tử, không có gì có thể xảy ra ở giữa nó.

 

1 emit Transfer(sender, recipient, amount);
2 }

Cuối cùng, phát ra một sự kiện Transfer. Các sự kiện không thể truy cập được đối với các hợp đồng thông minh, nhưng mã chạy bên ngoài chuỗi khối có thể lắng nghe các sự kiện và phản ứng với chúng. Ví dụ, một chiếc ví có thể theo dõi khi chủ sở hữu nhận được nhiều token hơn.

Các hàm _mint và _burn

Hai hàm này (_mint_burn) sửa đổi tổng cung token. Chúng là nội bộ và không có hàm nào gọi chúng trong hợp đồng này, vì vậy chúng chỉ hữu ích nếu bạn kế thừa từ hợp đồng và thêm logic của riêng bạn để quyết định trong điều kiện nào để đúc token mới hoặc đốt token hiện có.

LƯU Ý: Mỗi token ERC-20 đều có logic kinh doanh riêng để quyết định việc quản lý token. Ví dụ, một hợp đồng cung cấp cố định có thể chỉ gọi _mint trong hàm khởi tạo và không bao giờ gọi _burn. Một hợp đồng bán token sẽ gọi _mint khi được thanh toán, và có lẽ sẽ gọi _burn tại một thời điểm nào đó để tránh lạm phát phi mã.

1 /** @dev Tạo `amount` token và gán chúng cho `account`, tăng
2 * tổng cung.
3 *
4 * Phát ra một sự kiện {Transfer} với `from` được đặt thành địa chỉ không.
5 *
6 * Các yêu cầu:
7 *
8 * - `to` không thể là địa chỉ không.
9 */
10 function _mint(address account, uint256 amount) internal virtual {
11 require(account != address(0), "ERC20: mint to the zero address");
12 _beforeTokenTransfer(address(0), account, amount);
13 _totalSupply = _totalSupply.add(amount);
14 _balances[account] = _balances[account].add(amount);
15 emit Transfer(address(0), account, amount);
16 }
Hiện tất cả

Đảm bảo cập nhật _totalSupply khi tổng số token thay đổi.

 

1 /**
2 * @dev Hủy `amount` token từ `account`, giảm
3 * tổng cung.
4 *
5 * Phát ra một sự kiện {Transfer} với `to` được đặt thành địa chỉ không.
6 *
7 * Các yêu cầu:
8 *
9 * - `account` không thể là địa chỉ không.
10 * - `account` phải có ít nhất `amount` token.
11 */
12 function _burn(address account, uint256 amount) internal virtual {
13 require(account != address(0), "ERC20: burn from the zero address");
14
15 _beforeTokenTransfer(account, address(0), amount);
16
17 _balances[account] = _balances[account].sub(amount, "ERC20: burn amount exceeds balance");
18 _totalSupply = _totalSupply.sub(amount);
19 emit Transfer(account, address(0), amount);
20 }
Hiện tất cả

Hàm _burn gần như giống hệt _mint, ngoại trừ nó đi theo hướng ngược lại.

Hàm _approve

Đây là hàm thực sự chỉ định các hạn mức. Lưu ý rằng nó cho phép chủ sở hữu chỉ định một hạn mức cao hơn số dư hiện tại của chủ sở hữu. Điều này là OK vì số dư được kiểm tra tại thời điểm chuyển khoản, khi nó có thể khác với số dư khi hạn mức được tạo ra.

1 /**
2 * @dev Đặt `amount` làm hạn mức của `spender` trên các token của `owner`.
3 *
4 * Hàm nội bộ này tương đương với `approve`, và có thể được sử dụng để
5 * ví dụ, đặt các hạn mức tự động cho một số hệ thống con, v.v.
6 *
7 * Phát ra một sự kiện {Approval}.
8 *
9 * Các yêu cầu:
10 *
11 * - `owner` không thể là địa chỉ không.
12 * - `spender` không thể là địa chỉ không.
13 */
14 function _approve(address owner, address spender, uint256 amount) internal virtual {
15 require(owner != address(0), "ERC20: approve from the zero address");
16 require(spender != address(0), "ERC20: approve to the zero address");
17
18 _allowances[owner][spender] = amount;
Hiện tất cả

 

Phát ra một sự kiện Approval. Tùy thuộc vào cách ứng dụng được viết, hợp đồng người chi tiêu có thể được thông báo về việc phê duyệt bởi chủ sở hữu hoặc bởi một máy chủ lắng nghe những sự kiện này.

1 emit Approval(owner, spender, amount);
2 }
3

Sửa đổi biến Decimals

1
2
3 /**
4 * @dev Đặt {decimals} thành một giá trị khác với giá trị mặc định là 18.
5 *
6 * CẢNH BÁO: Hàm này chỉ nên được gọi từ hàm khởi tạo. Hầu hết
7 * các ứng dụng tương tác với các hợp đồng token sẽ không mong đợi
8 * {decimals} thay đổi, và có thể hoạt động không chính xác nếu nó thay đổi.
9 */
10 function _setupDecimals(uint8 decimals_) internal {
11 _decimals = decimals_;
12 }
Hiện tất cả

Hàm này sửa đổi biến _decimals được sử dụng để cho giao diện người dùng biết cách diễn giải số lượng. Bạn nên gọi nó từ hàm khởi tạo. Sẽ là không trung thực nếu gọi nó tại bất kỳ thời điểm nào sau đó, và các ứng dụng không được thiết kế để xử lý nó.

Hook

1
2 /**
3 * @dev Móc được gọi trước bất kỳ lần chuyển token nào. Điều này bao gồm
4 * việc đúc và đốt.
5 *
6 * Điều kiện gọi:
7 *
8 * - khi `from` và `to` đều khác không, `amount` token của ``from``
9 * sẽ được chuyển đến `to`.
10 * - khi `from` bằng không, `amount` token sẽ được đúc cho `to`.
11 * - khi `to` bằng không, `amount` token của ``from`` sẽ bị đốt.
12 * - `from` và `to` không bao giờ cùng bằng không.
13 *
14 * Để tìm hiểu thêm về các móc, hãy truy cập xref:ROOT:extending-contracts.adoc#using-hooks[Sử dụng các Móc].
15 */
16 function _beforeTokenTransfer(address from, address to, uint256 amount) internal virtual { }
17}
Hiện tất cả

Đây là hàm móc sẽ được gọi trong các lần chuyển khoản. Nó trống ở đây, nhưng nếu bạn cần nó làm gì đó, bạn chỉ cần ghi đè nó.

Kết luận

Để xem lại, đây là một số ý tưởng quan trọng nhất trong hợp đồng này (theo ý kiến của tôi, ý kiến của bạn có thể khác):

  • Không có bí mật nào trên chuỗi khối. Bất kỳ thông tin nào mà một hợp đồng thông minh có thể truy cập đều có sẵn cho cả thế giới.
  • Bạn có thể kiểm soát thứ tự các giao dịch của mình, nhưng không thể kiểm soát khi nào giao dịch của người khác xảy ra. Đây là lý do tại sao việc thay đổi hạn mức có thể nguy hiểm, vì nó cho phép người chi tiêu chi tiêu tổng của cả hai hạn mức.
  • Các giá trị của loại uint256 sẽ quay vòng. Nói cách khác, 0-1=2^256-1. Nếu đó không phải là hành vi mong muốn, bạn phải kiểm tra nó (hoặc sử dụng thư viện SafeMath làm điều đó cho bạn). Lưu ý rằng điều này đã thay đổi trong Solidity 0.8.0 (opens in a new tab).
  • Thực hiện tất cả các thay đổi trạng thái của một loại cụ thể ở một nơi cụ thể, vì nó giúp việc kiểm toán dễ dàng hơn. Đây là lý do tại sao chúng ta có, ví dụ, _approve, được gọi bởi approve, transferFrom, increaseAllowance, và decreaseAllowance
  • Các thay đổi trạng thái phải là nguyên tử, không có bất kỳ hành động nào khác ở giữa chúng (như bạn có thể thấy trong _transfer). Điều này là do trong quá trình thay đổi trạng thái, bạn có một trạng thái không nhất quán. Ví dụ, giữa thời điểm bạn khấu trừ từ số dư của người gửi và thời điểm bạn cộng vào số dư của người nhận, có ít token tồn tại hơn so với thực tế. Điều này có thể bị lạm dụng nếu có các hoạt động giữa chúng, đặc biệt là các cuộc gọi đến một hợp đồng khác.

Bây giờ bạn đã thấy cách hợp đồng ERC-20 của OpenZeppelin được viết, và đặc biệt là cách nó được làm an toàn hơn, hãy đi và viết các hợp đồng và ứng dụng an toàn của riêng bạn.

Xem thêm công việc của tôi tại đây (opens in a new tab).

Lần cập nhật trang lần cuối: 22 tháng 10, 2025

Hướng dẫn này có hữu ích không?