跳至主要內容

具備安全防護機制的 ERC-20

erc-20
初階
歐里·波梅蘭茨
2022年8月15日
13 分鐘閱讀

簡介

以太坊的一大優點是沒有中央機構可以修改或撤銷您的交易。以太坊的一大缺點是沒有中央機構有權撤銷使用者的錯誤或非法交易。在本文中,您將了解使用者在使用 ERC-20 代幣時常犯的一些錯誤,以及如何建立 ERC-20 合約來幫助使用者避免這些錯誤,或賦予中央機構一些權力(例如凍結帳戶)。

請注意,雖然我們將使用 歐本齊柏林 ERC-20 代幣合約 (opens in a new tab),但本文不會詳細解釋它。您可以在這裡找到相關資訊。

如果您想查看完整的原始碼:

  1. 開啟 Remix IDE (opens in a new tab)
  2. 點擊複製 GitHub 圖示 (clone github icon)。
  3. 複製 GitHub 儲存庫 https://github.com/qbzzt/20220815-erc20-safety-rails
  4. 開啟 contracts > erc20-safety-rails.sol

建立 ERC-20 合約

在我們加入安全防護功能之前,我們需要一個 ERC-20 合約。在本文中,我們將使用 歐本齊柏林合約精靈 (OpenZeppelin Contracts Wizard) (opens in a new tab)。在另一個瀏覽器中開啟它並按照以下指示操作:

  1. 選擇 ERC20

  2. 輸入以下設定:

    參數
    名稱SafetyRailsToken
    代號SAFE
    預先鑄造1000
    功能None (無)
    存取控制Ownable (可擁有)
    可升級性None (無)
  3. 向上捲動並點擊 Open in Remix(適用於 Remix)或 Download 以使用不同的環境。我將假設您使用的是 Remix,如果您使用其他環境,請進行相應的更改。

  4. 我們現在擁有一個功能齊全的 ERC-20 合約。您可以展開 .deps > npm 來查看匯入的程式碼。

  5. 編譯、部署並試用該合約,以確認其作為 ERC-20 合約的功能。如果您需要學習如何使用 Remix,請使用本教學 (opens in a new tab)

常見錯誤

錯誤類型

使用者有時會將代幣發送到錯誤的地址。雖然我們無法讀心來知道他們原本想做什麼,但有兩種經常發生且容易偵測的錯誤類型:

  1. 將代幣發送到合約本身的地址。例如,Optimism 的 OP 代幣 (opens in a new tab)在不到兩個月的時間內累積了超過 120,000 (opens in a new tab) 個 OP 代幣。這代表了一筆可觀的財富,大概就這樣被人們遺失了。

  2. 將代幣發送到空地址,即不對應於外部擁有帳戶 (EOA)智能合約的地址。雖然我沒有關於這種情況發生頻率的統計數據,但一次事件就可能損失 20,000,000 個代幣 (opens in a new tab)

防止轉帳

歐本齊柏林 ERC-20 合約包含一個掛鉤 (hook) _beforeTokenTransfer (opens in a new tab),它會在代幣轉帳之前被呼叫。預設情況下,此掛鉤不會執行任何操作,但我們可以將自己的功能掛載其上,例如在出現問題時進行回滾的檢查。

要使用該掛鉤,請在建構函式之後加入此函式:

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

如果您對 Solidity 不是很熟悉,此函式的某些部分對您來說可能是新的:

        internal virtual

virtual 關鍵字表示,就像我們從 ERC20 繼承功能並覆寫此函式一樣,其他合約也可以繼承我們的合約並覆寫此函式。

        override(ERC20)

我們必須明確指定我們正在覆寫 (opens in a new tab) ERC20 代幣定義的 _beforeTokenTransfer。一般來說,從安全性的角度來看,明確的定義比隱含的定義要好得多——如果它就在您面前,您就不會忘記您做過的事情。這也是我們需要指定我們正在覆寫哪個父類別的 _beforeTokenTransfer 的原因。

        super._beforeTokenTransfer(from, to, amount);

這一行呼叫了我們所繼承且具有該掛鉤的合約的 _beforeTokenTransfer 函式。在這種情況下,只有 ERC20 有,Ownable 沒有這個掛鉤。即使目前 ERC20._beforeTokenTransfer 不執行任何操作,我們還是會呼叫它,以防未來加入新功能(然後我們決定重新部署合約,因為合約在部署後無法更改)。

編寫需求程式碼

我們希望將這些需求加入到函式中:

  • to 地址不能等於 address(this),即 ERC-20 合約本身的地址。
  • to 地址不能為空,它必須是以下之一:
    • 外部擁有帳戶 (EOA)。我們無法直接檢查地址是否為 EOA,但我們可以檢查地址的 ETH 餘額。EOA 幾乎總是有餘額,即使它們不再被使用——很難將它們清空到最後一個 Wei。
    • 智能合約。測試地址是否為智能合約稍微困難一些。有一個檢查外部程式碼長度的操作碼,稱為 EXTCODESIZE (opens in a new tab),但它無法直接在 Solidity 中使用。我們必須為此使用 Yul (opens in a new tab)(即 EVM 組合語言)。我們可以使用 Solidity 中的其他值(<address>.code<address>.codehash (opens in a new tab)),但它們會消耗更多燃料。

讓我們逐行檢視新程式碼:

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

這是第一個需求,檢查 tothis(address) 是否不相同。

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

這是我們檢查地址是否為合約的方法。我們無法直接從 Yul 接收輸出,因此我們定義一個變數來保存結果(在本例中為 isToContract)。Yul 的運作方式是將每個操作碼視為一個函式。因此,我們首先呼叫 EXTCODESIZE (opens in a new tab) 來取得合約大小,然後使用 GT (opens in a new tab) 檢查它是否不為零(我們處理的是無號整數,所以它當然不能是負數)。然後我們將結果寫入 isToContract

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

最後,我們對空地址進行實際檢查。

管理員存取權限

有時,擁有一個可以撤銷錯誤的管理員是很有用的。為了減少濫用的可能性,此管理員可以是一個多方簽名 (opens in a new tab),因此需要多人同意才能執行操作。在本文中,我們將擁有兩個管理功能:

  1. 凍結和解凍帳戶。例如,當帳戶可能遭到入侵時,這會很有用。

  2. 資產清理。

    有時詐騙者會將詐騙代幣發送到真實代幣的合約以獲得合法性。例如,請看這裡 (opens in a new tab)。合法的 ERC-20 合約是 0x4200....0042 (opens in a new tab)。假冒它的詐騙合約是 0x234....bbe (opens in a new tab)

    人們也有可能錯誤地將合法的 ERC-20 代幣發送到我們的合約中,這也是我們希望有辦法將它們取出的另一個原因。

歐本齊柏林提供了兩種機制來啟用管理員存取權限:

為了簡單起見,在本文中我們使用 Ownable

凍結和解凍合約

凍結和解凍合約需要進行幾項更改:

  • 從地址到布林值 (opens in a new tab)映射 (mapping) (opens in a new tab),以追蹤哪些地址被凍結。所有值最初都為零,對於布林值來說,這被解釋為 false。這正是我們想要的,因為預設情況下帳戶不會被凍結。

        mapping(address => bool) public frozenAccounts;
    
  • 事件 (opens in a new tab),用於在帳戶被凍結或解凍時通知任何感興趣的人。從技術上講,這些操作不需要事件,但它有助於鏈下程式碼能夠監聽這些事件並了解正在發生的事情。當發生可能與其他人相關的事情時,智能合約發出事件被認為是一種良好的禮貌。

    這些事件已被索引,因此可以搜尋帳戶被凍結或解凍的所有時間。

      // 當帳戶被凍結或解凍時
      event AccountFrozen(address indexed _addr);
      event AccountThawed(address indexed _addr);
    
  • 用於凍結和解凍帳戶的函式。這兩個函式幾乎相同,因此我們只會檢視凍結函式。

        function freezeAccount(address addr)
          public
          onlyOwner
    

    標記為 public (opens in a new tab) 的函式可以從其他智能合約呼叫,或直接由交易呼叫。

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

    如果帳戶已被凍結,則回滾。否則,將其凍結並 emit 一個事件。

  • 更改 _beforeTokenTransfer 以防止資金從凍結的帳戶中移出。請注意,資金仍然可以轉帳到凍結的帳戶中。

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

資產清理

要釋放此合約持有的 ERC-20 代幣,我們需要呼叫它們所屬的代幣合約上的一個函式,即 transfer (opens in a new tab)approve (opens in a new tab)。在這種情況下,沒有必要將燃料浪費在授權額度 (allowances) 上,我們不妨直接轉帳。

    function cleanupERC20(
        address erc20,
        address dest
    )
        public
        onlyOwner
    {
        IERC20 token = IERC20(erc20);

這是當我們收到地址時為合約建立物件的語法。我們可以這樣做,因為我們將 ERC20 代幣的定義作為原始碼的一部分(請參閱第 4 行),並且該檔案包含 IERC20 的定義 (opens in a new tab),即歐本齊柏林 ERC-20 合約的介面。

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

這是一個清理函式,因此我們大概不想留下任何代幣。與其手動從使用者那裡獲取餘額,我們不妨將該過程自動化。

結論

這不是一個完美的解決方案——對於「使用者犯錯」的問題,沒有完美的解決方案。然而,使用這類檢查至少可以防止一些錯誤。凍結帳戶的能力雖然危險,但可用於拒絕駭客存取被盜資金,從而限制某些駭客攻擊的損害。

請點擊這裡查看我的更多作品 (opens in a new tab)