Chuyển đến nội dung chính

ERC-20 với các biện pháp an toàn

erc-20
Người mới bắt đầu
Ori Pomerantz
15 tháng 8, 2022
11 phút đọc

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ố sai lầm phổ biến mà người dùng mắc phải với token ERC-20, cũng như cách tạo các hợp đồng ERC-20 giúp người dùng tránh những sai lầm đó, hoặc trao cho một cơ quan trung ương một số quyền hạn (ví dụ như đóng băng tài khoản).

Lưu ý rằng mặc dù chúng ta 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 toàn bộ mã nguồn:

  1. Mở Remix IDE (opens in a new tab).
  2. Nhấp vào biểu tượng sao chép GitHub (clone github icon).
  3. Sao chép kho lưu trữ GitHub https://github.com/qbzzt/20220815-erc20-safety-rails.
  4. Mở contracts > erc20-safety-rails.sol.

Tạo một hợp đồng ERC-20

Trước khi có thể thêm chức năng rào 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 OpenZeppelin Contracts Wizard (opens in a new tab). Hãy mở nó trong một trình duyệt khác và làm theo các hướng dẫn sau:

  1. Chọn ERC20.

  2. Nhập các cài đặt sau:

    Thông sốGiá trị
    TênSafetyRailsToken
    Ký hiệuSAFE
    Đúc sẵn1000
    Tính năngKhông có
    Kiểm soát truy cậpOwnable
    Khả năng nâng cấpKhông có
  3. Cuộn lên và nhấp vào Open in Remix (đối với Remix) hoặc Download để sử dụng một môi trường khác. Tôi sẽ giả định rằng bạn đang sử dụng Remix, nếu bạn sử dụng công cụ khác, chỉ cần thực hiện các thay đổi cho phù hợp.

  4. 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.

  5. Biên dịch, triển khai và thử nghiệm với hợp đồng để xem nó hoạt động như một hợp đồng ERC-20. Nếu bạn cần tìm hiểu cách sử dụng Remix, hãy sử dụng hướng dẫn này (opens in a new tab).

Những sai lầm phổ biến

Các sai lầm

Người dùng đôi khi chuyển 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ì, nhưng có hai loại lỗi xảy ra rất nhiều và dễ phát hiện:

  1. Chuyển token đến chính địa chỉ của 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 đã đánh mất.

  2. Chuyển token đến một địa chỉ trống, một địa chỉ không tương ứng với tài khoản thuộc sở hữu bên ngoài (EOA) 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ề mức độ thường xuyên xảy ra điều này, nhưng 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 việc chuyể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. 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 nguyên nếu có sự cố.

Để sử dụng hook, hãy thêm hàm này sau hàm khởi tạo:

    function _beforeTokenTransfer(address from, address to, uint256 amount)
        internal virtual
        override(ERC20)
    {
        super._beforeTokenTransfer(from, to, amount);
    }

Một số phần của hàm này có thể mới nếu bạn không quá quen thuộc với Solidity:

        internal virtual

Từ khóa virtual có nghĩa là giống như việc 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.

        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. Nhìn chung, các định nghĩa rõ ràng tốt hơn rất nhiều so với các định nghĩa ngầm định từ góc độ bảo mật - 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 đè.

        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ó chứa 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ó trong 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 việc 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ỉ to không thể bằng address(this), địa chỉ của chính hợp đồng ERC-20.
  • Địa chỉ to không thể trống, nó phải là một trong hai:
    • Một tài khoản thuộc 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 sạch chúng đến đồng 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 khó hơn một chút. Có một mã lệnh 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ữ của EVM, cho việc này. Có những giá trị khác mà chúng ta có thể sử dụng từ Solidity (<address>.code<address>.codehash (opens in a new tab)), nhưng chúng tốn nhiều Gas hơn.

Hãy cùng xem qua từng dòng mã mới:

        require(to != address(this), "Can't send tokens to the contract address");

Đây là yêu cầu đầu tiên, kiểm tra xem tothis(address) không giống nhau.

        bool isToContract;
        assembly {
           isToContract := gt(extcodesize(to), 0)
        }

Đâ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 định nghĩa một biến để chứa kết quả (trong trường hợp này là isToContract). Cách Yul hoạt động là mọi mã lệnh đều đượ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, và sau đó sử dụng GT (opens in a new tab) để kiểm tra xem nó có khác không hay 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.

        require(to.balance != 0 || isToContract, "Can't send tokens to an empty address");

Và cuối cùng, chúng ta có bước kiểm tra thực tế đối với các địa chỉ trống.

Quyền truy cập quản trị

Đôi khi việc 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 thiểu khả năng lạm dụng, quản trị viên này có thể là một đa chữ ký (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ị:

  1. Đóng băng và hủy đóng băng 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.

  2. Dọn dẹp tài sản.

    Đôi khi những kẻ lừa đảo chuyển các token gian lận đến hợp đồng của token thật để đạt đượ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). Kẻ 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 chuyển nhầm các token ERC-20 hợp pháp đến 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ế để kích hoạt quyền truy cập quản trị:

Vì mục đích đơn giản, trong bài viết này chúng ta sử dụng Ownable.

Đóng băng và rã đông hợp đồng

Việc đóng băng và rã đông hợp đồng yêu cầu một số thay đổi:

  • Một mapping (opens in a new tab) từ các địa chỉ sang boolean (opens in a new tab) để theo dõi những đị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.

        mapping(address => bool) public frozenAccounts;
    
  • Các 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 rã đô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 cách làm tốt.

    Các sự kiện được lập chỉ mục nên sẽ 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 rã đông.

      // Khi các tài khoản bị đóng băng hoặc hủy đóng băng
      event AccountFrozen(address indexed _addr);
      event AccountThawed(address indexed _addr);
    
  • Các hàm để đóng băng và rã đô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.

        function freezeAccount(address addr)
          public
          onlyOwner
    

    Cá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.

      {
          require(!frozenAccounts[addr], "Account already frozen");
          frozenAccounts[addr] = true;
          emit AccountFrozen(addr);
      }  // freezeAccount
    

    Nếu tài khoản đã bị đóng băng, hãy hoàn nguyên. Nếu không, hãy đóng băng nó và emit một sự kiện.

  • Thay đổi _beforeTokenTransfer để ngăn tiền bị chuyển khỏi 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.

         require(!frozenAccounts[from], "The account is frozen");
    

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ề, có thể 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 trợ cấp (allowances), chúng ta cũng có thể chuyển trực tiếp.

    function cleanupERC20(
        address erc20,
        address dest
    )
        public
        onlyOwner
    {
        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 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.

        uint balance = token.balanceOf(address(this));
        token.transfer(dest, balance);
    }

Đâ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 sai lầm". Tuy nhiên, việc sử dụng các loại kiểm tra này ít nhất có thể ngăn chặn 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ụ hack bằng cách từ chối hacker truy cập vào các khoản tiền bị đánh cắp.

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