Solidity 教學: 異常處理 Handling Exception

本篇預計閱讀時間 15 分鐘, 實作時間 30 分鐘。

在開始之前先來思考底下的練習題,閱讀後即可練習完成。

練習題

  • 練習題 1

    • 小明想要發行 NFT,希望用戶可以在 2022 年1 月1 號才能開始鑄造 Mint,請問如何利用 require() 寫一個函式幫小明完成這個任務。

  • 練習題 2

    • 承上題,小明有一個 uint maxMint = 20; ,他希望每次用戶不能鑄造 mint 超過這個整數,如何幫助小明利用 require() 寫一個函式完成這個任務。

  • 練習題 3

    • 承上題,小明有一個 bool pause = false; ,他希望每次用戶鑄造 mint 前都要確認這個布林值已查看合約是否是停止狀態,請問如何利用 assert() 寫一個函式幫小明完成這個任務。

  • 練習題 4

    • 請問何種異常處理會直接把程式停止?

  • 練習題 5

    • 請實作可以啟動、暫停、凍結、刪除的智能合約。

  • 練習題 6

    • 請問以下程式碼的執行結果為何?

try feed.getData(token) returns (uint v) {
    require(1 == 2, "Will revert");
} catch (bytes memory /*lowLevelData*/) {
    return (0);
}

 

面對異常處理,我們得先簡單理解交易以及錯誤(Transactions and Errors),並且條列一些在 Solidity 中異常處理的性質。

  • 交易是自動化的,一旦送出便沒有辦法在過程中透過人為終止

  • 拋出 Errors 是會回復狀態的行為,並不會讓錯誤發生。

  • 語法包含 require, assert, revert,在更早的版本還有 throw;

  • 除了以下 Low-Level 的函數以外,皆為接連發生的(cascade)

    • Address.send, address.call, address.delegatecall, address.staticcall

  • Revertrequire 可以回傳錯誤訊息

0.4.10 版的 Solidity 新增了 require(), assert(), revert() 這三個語法

  • require() 用來檢查較不嚴重的錯誤,通常是在執行前就檢驗合理的輸入或條件,可以退回未使用到的 gas

  • assert() 用來檢查較嚴重的錯誤,會像以前一樣拿走所有的 gasLimit 的手續費

  • revert()require() 基本上相同,但是 revert() 沒有包括狀態檢查

0.6 之後新增了 try/catch 可以使用

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.11;
contract Exception{

    mapping(address => uint64) public balanceReveived;
    
    function receiveMoney() public payable{
        assert(balanceReceived[msg.sender] + uint64(msg.value) >= balanceReceived[msg.sender])
        balanceReveived[msg.sender] += uint64(msg.value);
    }
    function withdrawMoney(address payable _to, uint64 _amount) public {
    require(balanceReceived[msg.sender] <= _amount, "You don't have enough ether");
    assert(balanceReceived[msg.sender] >=     balanceReceived[msg.sender] - _amount)
    balanceReceived[msg.sender] -= _amount;
_to.transfer(_amount);
    }
}

以上例子之中我們可以發現:

  • 在加上 assert 之前,當我們想要匯入 10 ethers 兩次,實際上合約不會收到 20 ethers,原因是來自於 uint64

  • 加上 assert 之後,當我們想要匯入 10 ethers 兩次,便會在第二次動作時接收到回傳的錯誤。

使用異常處理的最主要目的是使當前的執行被停止或撤銷,並且把原先改變的狀態回復到原本狀態,包含帳戶在原先行為後的餘額。在異常發生時,Solidity 會執行一個回退的操作(0xfd)並且讓 EVM 把所有「狀態改變」回復到原先的狀態。

Throw

  • 於 Solidity 0.4.10 版本後移除

if (msg.sender != owner) {throw;}

 


Require

require() 用於檢驗簡單的條件:

  • 合理的輸入

  • 執行前必要的簡單條件

  • 回傳一則訊息

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.11;

contract error {
    function testRequire(uint _i) public pure {
        require(_i > 10, "Input must be greater than 10");
    }
}

Require 會被觸發的情況及性質如下:

  • require(X) 其中 X 為 false

  • 經由訊息呼叫函數,但此函數並沒有被適當地完成(像是 gas 不足、找不到對應函式、名稱錯誤)

  • 使用外部函數呼叫了一個沒有任何程式碼的合約

  • 合約裡面的物件沒有被宣告 payable modifier 卻接收到了 Ether

  • 唯讀的函式(getter function)接收到了 Ether

  • Address.transfer() 執行失敗

這類的錯誤並不包含以 Low-Level 的函式呼叫,也就是說我們之前在 address 小節提到的 .call、.delegatecall、.staticcall 這些函式並不會拋出異常,只會透過返回 false 來表達交易失敗。

Assert

assert() 必須指被用來檢測內部錯誤,意思就是用來測試或 debug,並且用來檢測不變量(不該被改變的常數等)。

以下的例子是 num 這個變數必須只為 0,在我們的預想之中它得是個常數不該被更改,所以為了確認我們沒有忽略它被改變的情況,這裡加上 assert()num 被改變時使程式強制中止。

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.11;

contract error {
    function testAssert(uint _i) public view {
        assert(_i == 0);
    }
}

Asserted 會被觸發的情況及性質如下:

  • assert(X) 當 X 之值為 false

  • 踩到了超出範圍的 index,像是以負值訪問陣列或 bytes。

  • 除以 0 或取除以 0 之餘數(5/0 or 23%0)

  • 位元移動運算(Byteshifting)時位移量為負

  • 轉換負值或過大的數字成 enum 資料結構

Assert 和 Require 的比較

  • require 面對 Revert operation(0xfd) 時會送回剩餘的gas

  • assert 面對 Invalid operation(0xfe) 時會消耗全部的gas

  • Assert 用於檢測完全不該在合約裡出現的情況

  • Require 用來檢測使用者的輸入等錯誤情況

 

Revert

revert() 用於確認複雜狀態,意思就是說我們必須寫上一連串的巢狀 if-else 或其他條件式選擇來過濾各種情形,最後才加上 revert() 如果我們進到了錯誤結果。

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.11;

contract error {
    function testRevert(uint _i) public pure {
        if (_i <= 10) {
          if (_i != 10){
            revert("Input must be equal 10");
          }
        }
    }
}

Revert 會回復交易狀態,與其他的異常處理語法比較可見下列比較例子:

if (amount > msg.value ether){
    revert("Not Enough Ether Provided");
}
// 另解 // Alternative way to do it:
require(amount <= msg.value ether, "Not Enough Ether Provided");

 

Try/Catch

try/catch 用於確認外部執行函數和合約建立時(在一個合約裡面建立另外一個合約時)的錯誤,可以利用類條件式選擇來排除錯誤情況。

CATCH

我們有幾種宣告 catch 的方法

  1. catch Error (string memory /*reason*/):當我們宣告過了 revert("reasonString")require(false, "reasonString"),且這些異常處理被觸發的話,catch 則會被執行。

  2. catch Panic(uint errorCode) { ... }:此類型的 catch 會在各種 assert、除以零、異常的陣列取值、運算溢位等情況發生時被執行。

  3. catch (bytes memory /*lowLevelData*/):當沒有其他符合的 catch 子句時,或是在decoding 錯誤訊息時出現問題、甚至外部呼叫時出現 assertion failing,便會執行此類 catch

  4. 如果我們不在乎任何的 Error Data,可以使用 catch { ... }



// SPDX-License-Identifier: MIT
// 以下例子取自 Solidity 官方文件
pragma solidity ^0.8.10;

contract Foo {address public owner;

    constructor(address _owner) {
        require(_owner != address(0), "invalid address");
        assert(_owner != 0x0000000000000000000000000000000000000001);owner = _owner;
}

    function myFunc(uint x) public pure returns (string memory) {
    require(x != 0, "require failed");
    return "my func was called";
    }
}

contract Bar {
    event Log(string message);
    event LogBytes(bytes data);

    Foo public foo;

    constructor() {
        foo = new Foo(msg.sender);
}
// Example of try / catch with external call
// tryCatchExternalCall(0) => Log("external call failed")
// tryCatchExternalCall(1) => Log("my func was called")function

tryCatchExternalCall(uint _i) public {try foo.myFunc(_i) returns (string memory result) {emit Log(result);
} catch {
        emit Log("external call failed");
    }
}

// Example of try / catch with contract creation
//tryCatchNewContract(0x0000000000000000000000000000000000000000) => Log("invalid address")// tryCatchNewContract(0x0000000000000000000000000000000000000001) => LogBytes("")
//tryCatchNewContract(0x0000000000000000000000000000000000000002) => Log("Foo created")
function tryCatchNewContract(address _owner) public {
    try new Foo(_owner) returns (Foo foo) {
        // you can use variable foo hereemit Log("Foo created");
} catch Error(string memory reason) {
        // catch failing revert() and require()emit Log(reason);
} catch (bytes memory reason) {
        // catch failing assert()emit LogBytes(reason);
        }
    }
}

TRY

try 這個關鍵字必須要根據「外部函式呼叫」或「創建合約」這類的行為來定義,例如:new ContractName()

而其中的 returns 部份是可有可無的,若需要的話,宣告 returns 部份必須要符合外部函式呼叫的回傳型態。



// SPDX-License-Identifier: GPL-3.0
// 以下程式碼取自 Solidity 官方文件
pragma solidity >=0.8.1;
interface DataFeed { function getData(address token) external returns (uint value); }

contract FeedConsumer {
    DataFeed feed;
    uint errorCount;
    function rate(address token) public returns (uint value, bool success) {

    // Permanently disable the mechanism if there are
    // more than 10 errors.
    require(errorCount < 10);
    try feed.getData(token) returns (uint v) {
        return (v, true);
    } catch Error(string memory /*reason*/) {

    // This is executed in case
    // revert was called inside getData
    // and a reason string was provided.errorCount++;
        return (0, false);
    } catch Panic(uint /*errorCode*/) {
    // This is executed in case of a panic,
    // i.e. a serious error like division by zero
    // or overflow. The error code can be used
    // to determine the kind of error.
    errorCount++;return (0, false);
} catch (bytes memory /*lowLevelData*/) {
    // This is executed in case revert() was used.
    errorCount++;
    return (0, false);
    }
  }
}

 

練習題解答

  • 練習題解答 1

    • 小明想要發行 NFT,希望用戶可以在 2022 年1 月1 號才能開始鑄造 Mint,請問如何利用 require() 寫一個函式幫小明完成這個任務。

function checkTime() view public {
require(block.timestamp > 1640966400);
// 設定區塊時間戳來定義開放時間,Sat Jan 01 2022 00:00:00 GMT+0800 // ( 台北標準時間 )

}

  • 練習題解答 2

    • 承上題,小明有一個 uint maxMint = 20; ,他希望每次用戶不能鑄造 mint 超過這個整數,如何幫助小明利用 require() 寫一個函式完成這個任務。

function checkMaxMint(uint _mintAmount, uint maxMintAmount) view

public {
require(_mintAmount > 0); // 每次必須鑄造超過 0 個 require(_mintAmount <= maxMintAmount);
// 鑄造的數量不可以大於每次最大鑄造數量

}

  • 練習題解答 3

    • 承上題,小明有一個 bool pause = false; ,他希望每次用戶鑄造 mint 前都要確認這個布林值已查看合約是否是停止狀態,請問如何利用 assert() 寫一個函式幫小明完成這個任務。

function checkPaused() view public {

    assert(!paused);

}

  • 練習題解答 4

    • 請問何種異常處理會直接把程式停止?

Assert()

  • 練習題解答 5

    • 請實作可以啟動、暫停、凍結、刪除的智能合約。

//SPDX-License-Identifier: MIT

pragma solidity ^0.8.11 contract Pause {

    address owner;
    bool paused; // 預設為 false

    constructor() public{ owner = msg.sender;

}

    function setPaused(bool _paused) public { require(msg.sender == owner, "You are not the Owner"); paused = _paused;

}

    function destroySmartContract(address payable _to) public { require(msg.sender == owner, "You are not the Owner"); selfdestruct(_to);

    }

}

  • 練習題解答 6

    • 請問以下程式碼的執行結果為何?

try feed.getData(token) returns (uint v) {
    require(1 == 2, "Will revert");
} catch (bytes memory /*lowLevelData*/) {
    return (0);
}

//SPDX-License-Identifier: MIT
try feed.getData(token) returns (uint v) {

require(1 == 2, "Will revert"); // 這裡會被執行

} catch (bytes memory /*lowLevelData*/) {

return (0); // 不會到這

}