ERC-20 với các Thanh chắn An toàn
Giới thiệu
Một trong những điều tuyệt vời về Ethereum là không có cơ quan trung ương nào có thể sửa đổi hoặc hoàn tác các giao dịch của bạn. Một trong những vấn đề lớn với Ethereum là không có cơ quan trung ương nào có quyền hoàn tác các sai lầm của người dùng hoặc các giao dịch bất hợp pháp. Trong bài viết này, bạn sẽ tìm hiểu về một số lỗi phổ biến mà người dùng mắc phải với các token ERC-20, cũng như cách tạo hợp đồng ERC-20 giúp người dùng tránh những lỗi đó, hoặc trao cho cơ quan trung ương một số quyền (ví dụ: đóng băng tài khoản).
Lưu ý rằng mặc dù chúng tôi sẽ sử dụng hợp đồng token ERC-20 của OpenZeppelin (opens in a new tab), bài viết này không giải thích chi tiết về nó. Bạn có thể tìm thấy thông tin này tại đây.
Nếu bạn muốn xem mã nguồn hoàn chỉnh:
- Mở Remix IDE (opens in a new tab).
- Nhấp vào biểu tượng sao chép github (
). - Sao chép kho lưu trữ github
https://github.com/qbzzt/20220815-erc20-safety-rails. - Mở contracts > erc20-safety-rails.sol.
Tạo hợp đồng ERC-20
Trước khi có thể thêm chức năng thanh chắn an toàn, chúng ta cần một hợp đồng ERC-20. Trong bài viết này, chúng ta sẽ sử dụng Trình hướng dẫn Hợp đồng OpenZeppelin (opens in a new tab). Mở nó trong một trình duyệt khác và làm theo các hướng dẫn sau:
-
Chọn ERC20.
-
Nhập các cài đặt này:
Thông số Giá trị Họ tên SafetyRailsToken Ký hiệu SAFE Đúc sẵn 1000 Tính năng Không Kiểm soát truy cập Có thể sở hữu Khả năng nâng cấp Không -
Cuộn lên và nhấp vào Mở trong Remix (đối với Remix) hoặc Tải xuống để sử dụng một môi trường khác. Tôi sẽ giả định bạn đang sử dụng Remix, nếu bạn sử dụng thứ khác, chỉ cần thực hiện các thay đổi phù hợp.
-
Bây giờ chúng ta đã có một hợp đồng ERC-20 đầy đủ chức năng. Bạn có thể mở rộng
.deps>npmđể xem mã được nhập. -
Biên dịch, triển khai và thử nghiệm hợp đồng để xem nó hoạt động như một hợp đồng ERC-20. Nếu bạn cần học cách sử dụng Remix, hãy sử dụng hướng dẫn này (opens in a new tab).
Các lỗi thường gặp
Các sai lầm
Người dùng đôi khi gửi token đến sai địa chỉ. Mặc dù chúng ta không thể đọc được suy nghĩ của họ để biết họ định làm gì, có hai loại lỗi thường xảy ra và dễ phát hiện:
-
Gửi các token đến địa chỉ của chính hợp đồng. Ví dụ, token OP của Optimism (opens in a new tab) đã tích lũy được hơn 120.000 (opens in a new tab) token OP trong vòng chưa đầy hai tháng. Điều này đại diện cho một lượng tài sản đáng kể mà có lẽ mọi người đã mất đi.
-
Gửi các token đến một địa chỉ trống, một địa chỉ không tương ứng với một tài khoản sở hữu bên ngoài hoặc một hợp đồng thông minh. Mặc dù tôi không có số liệu thống kê về tần suất xảy ra điều này, một sự cố có thể đã gây thiệt hại 20.000.000 token (opens in a new tab).
Ngăn chặn các giao dịch chuyển tiền
Hợp đồng ERC-20 của OpenZeppelin bao gồm một hook, _beforeTokenTransfer (opens in a new tab), được gọi trước khi một token được chuyển đi. Theo mặc định, hook này không làm gì cả, nhưng chúng ta có thể gắn chức năng của riêng mình vào nó, chẳng hạn như các kiểm tra sẽ hoàn tác nếu có sự cố.
Để sử dụng hook, hãy thêm hàm này sau hàm khởi tạo:
1 function _beforeTokenTransfer(address from, address to, uint256 amount)2 internal virtual3 override(ERC20)4 {5 super._beforeTokenTransfer(from, to, amount);6 }Một số phần của hàm này có thể mới nếu bạn không quen thuộc với Solidity:
1 internal virtualTừ khóa virtual có nghĩa là giống như chúng ta đã kế thừa chức năng từ ERC20 và ghi đè hàm này, các hợp đồng khác có thể kế thừa từ chúng ta và ghi đè hàm này.
1 override(ERC20)Chúng ta phải chỉ định rõ ràng rằng chúng ta đang ghi đè (opens in a new tab) định nghĩa token ERC20 của _beforeTokenTransfer. Nói chung, từ quan điểm bảo mật, các định nghĩa rõ ràng tốt hơn nhiều so với các định nghĩa ngầm - bạn không thể quên rằng mình đã làm điều gì đó nếu nó ở ngay trước mắt bạn. Đó cũng là lý do chúng ta cần chỉ định _beforeTokenTransfer của lớp cha nào mà chúng ta đang ghi đè.
1 super._beforeTokenTransfer(from, to, amount);Dòng này gọi hàm _beforeTokenTransfer của hợp đồng hoặc các hợp đồng mà chúng ta đã kế thừa có nó. Trong trường hợp này, đó chỉ là ERC20, Ownable không có hook này. Mặc dù hiện tại ERC20._beforeTokenTransfer không làm gì cả, chúng ta vẫn gọi nó phòng trường hợp chức năng được thêm vào trong tương lai (và sau đó chúng ta quyết định triển khai lại hợp đồng, vì các hợp đồng không thay đổi sau khi triển khai).
Lập trình các yêu cầu
Chúng ta muốn thêm các yêu cầu này vào hàm:
- Địa chỉ
tokhông thể bằngaddress(this), địa chỉ của chính hợp đồng ERC-20. - Địa chỉ
tokhông được để trống, nó phải là:- Một tài khoản sở hữu bên ngoài (EOA). Chúng ta không thể kiểm tra trực tiếp xem một địa chỉ có phải là EOA hay không, nhưng chúng ta có thể kiểm tra số dư ETH của một địa chỉ. Các EOA hầu như luôn có số dư, ngay cả khi chúng không còn được sử dụng - rất khó để xóa chúng đến wei cuối cùng.
- Một hợp đồng thông minh. Việc kiểm tra xem một địa chỉ có phải là hợp đồng thông minh hay không thì khó hơn một chút. Có một opcode kiểm tra độ dài mã bên ngoài, được gọi là
EXTCODESIZE(opens in a new tab), nhưng nó không có sẵn trực tiếp trong Solidity. Chúng ta phải sử dụng Yul (opens in a new tab), là hợp ngữ EVM, cho nó. Có những giá trị khác mà chúng ta có thể sử dụng từ Solidity (<address>.codevà<address>.codehash(opens in a new tab)), nhưng chúng tốn kém hơn.
Hãy xem qua từng dòng mã mới:
1 require(to != address(this), "Không thể gửi token đến địa chỉ hợp đồng");Đây là yêu cầu đầu tiên, kiểm tra xem to và this(address) có giống nhau không.
1 bool isToContract;2 assembly {3 isToContract := gt(extcodesize(to), 0)4 }Đây là cách chúng ta kiểm tra xem một địa chỉ có phải là hợp đồng hay không. Chúng ta không thể nhận đầu ra trực tiếp từ Yul, vì vậy thay vào đó, chúng ta xác định một biến để giữ kết quả (isToContract trong trường hợp này). Cách Yul hoạt động là mỗi opcode được coi là một hàm. Vì vậy, trước tiên chúng ta gọi EXTCODESIZE (opens in a new tab) để lấy kích thước hợp đồng, sau đó sử dụng GT (opens in a new tab) để kiểm tra xem nó có khác không (chúng ta đang xử lý các số nguyên không dấu, vì vậy tất nhiên nó không thể âm). Sau đó, chúng ta ghi kết quả vào isToContract.
1 require(to.balance != 0 || isToContract, "Không thể gửi token đến một địa chỉ trống");Và cuối cùng, chúng ta có kiểm tra thực tế cho các địa chỉ trống.
Quyền truy cập quản trị
Đôi khi có một quản trị viên có thể hoàn tác các sai lầm là rất hữu ích. Để giảm khả năng lạm dụng, quản trị viên này có thể là một multisig (opens in a new tab) để nhiều người phải đồng ý về một hành động. Trong bài viết này, chúng ta sẽ có hai tính năng quản trị:
-
Đóng băng và gỡ đóng băng các tài khoản. Điều này có thể hữu ích, ví dụ, khi một tài khoản có thể bị xâm phạm.
-
Dọn dẹp tài sản.
Đôi khi những kẻ lừa đảo gửi các token gian lận đến hợp đồng của token thật để có được tính hợp pháp. Ví dụ, xem tại đây (opens in a new tab). Hợp đồng ERC-20 hợp pháp là 0x4200....0042 (opens in a new tab). Trò lừa đảo giả mạo nó là 0x234....bbe (opens in a new tab).
Cũng có khả năng mọi người gửi nhầm các token ERC-20 hợp pháp vào hợp đồng của chúng ta, đây là một lý do khác để muốn có cách lấy chúng ra.
OpenZeppelin cung cấp hai cơ chế để cho phép truy cập quản trị:
- Các hợp đồng
Ownablecó một chủ sở hữu duy nhất. Các hàm có bổ từ (opens in a new tab)onlyOwnerchỉ có thể được gọi bởi chủ sở hữu đó. Chủ sở hữu có thể chuyển quyền sở hữu cho người khác hoặc từ bỏ hoàn toàn. Quyền của tất cả các tài khoản khác thường giống hệt nhau. - Các hợp đồng
AccessControlcó kiểm soát truy cập dựa trên vai trò (RBAC) (opens in a new tab).
Để đơn giản, trong bài viết này, chúng tôi sử dụng Ownable.
Đóng băng và gỡ đóng băng các hợp đồng
Việc đóng băng và gỡ đóng băng hợp đồng đòi hỏi một vài thay đổi:
-
Một ánh xạ (opens in a new tab) từ các địa chỉ đến các giá trị boolean (opens in a new tab) để theo dõi các địa chỉ nào bị đóng băng. Tất cả các giá trị ban đầu đều bằng không, đối với các giá trị boolean được hiểu là false. Đây là điều chúng ta muốn vì theo mặc định, các tài khoản không bị đóng băng.
1 mapping(address => bool) public frozenAccounts; -
Sự kiện (opens in a new tab) để thông báo cho bất kỳ ai quan tâm khi một tài khoản bị đóng băng hoặc gỡ đóng băng. Về mặt kỹ thuật, các sự kiện không bắt buộc đối với các hành động này, nhưng nó giúp mã ngoài chuỗi có thể lắng nghe các sự kiện này và biết điều gì đang xảy ra. Việc một hợp đồng thông minh phát ra chúng khi có điều gì đó có thể liên quan đến người khác xảy ra được coi là một hành vi lịch sự.
Các sự kiện được lập chỉ mục để có thể tìm kiếm tất cả các lần một tài khoản đã bị đóng băng hoặc gỡ đóng băng.
1 // Khi các tài khoản bị đóng băng hoặc gỡ đóng băng2 event AccountFrozen(address indexed _addr);3 event AccountThawed(address indexed _addr); -
Các hàm để đóng băng và gỡ đóng băng tài khoản. Hai hàm này gần như giống hệt nhau, vì vậy chúng ta sẽ chỉ xem xét hàm đóng băng.
1 function freezeAccount(address addr)2 public3 onlyOwnerCác hàm được đánh dấu
public(opens in a new tab) có thể được gọi từ các hợp đồng thông minh khác hoặc trực tiếp bằng một giao dịch.1 {2 require(!frozenAccounts[addr], "Tài khoản đã bị đóng băng");3 frozenAccounts[addr] = true;4 emit AccountFrozen(addr);5 } // freezeAccountNếu tài khoản đã bị đóng băng, hãy hoàn tác. Nếu không, hãy đóng băng nó và
emitmột sự kiện. -
Thay đổi
_beforeTokenTransferđể ngăn tiền được chuyển từ một tài khoản bị đóng băng. Lưu ý rằng tiền vẫn có thể được chuyển vào tài khoản bị đóng băng.1 require(!frozenAccounts[from], "Tài khoản bị đóng băng");
Dọn dẹp tài sản
Để giải phóng các token ERC-20 do hợp đồng này nắm giữ, chúng ta cần gọi một hàm trên hợp đồng token mà chúng thuộc về, hoặc là transfer (opens in a new tab) hoặc approve (opens in a new tab). Không có lý do gì để lãng phí gas trong trường hợp này cho các khoản được phép chi tiêu, chúng ta cũng có thể chuyển trực tiếp.
1 function cleanupERC20(2 address erc20,3 address dest4 )5 public6 onlyOwner7 {8 IERC20 token = IERC20(erc20);Đây là cú pháp để tạo một đối tượng cho một hợp đồng khi chúng ta nhận được địa chỉ. Chúng ta có thể làm điều này bởi vì chúng ta có định nghĩa cho các token ERC20 như một phần của mã nguồn (xem dòng 4), và tệp đó bao gồm định nghĩa cho IERC20 (opens in a new tab), giao diện cho một hợp đồng ERC-20 của OpenZeppelin.
1 uint balance = token.balanceOf(address(this));2 token.transfer(dest, balance);3 }Đây là một hàm dọn dẹp, vì vậy có lẽ chúng ta không muốn để lại bất kỳ token nào. Thay vì lấy số dư từ người dùng theo cách thủ công, chúng ta cũng có thể tự động hóa quy trình.
Kết luận
Đây không phải là một giải pháp hoàn hảo - không có giải pháp hoàn hảo nào cho vấn đề "người dùng đã mắc lỗi". Tuy nhiên, việc sử dụng các loại kiểm tra này ít nhất có thể ngăn ngừa một số sai lầm. Khả năng đóng băng tài khoản, mặc dù nguy hiểm, có thể được sử dụng để hạn chế thiệt hại của một số vụ tấn công bằng cách từ chối cho hacker số tiền bị đánh cắp.
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: 4 tháng 9, 2025