Những thiệt hại lớn nhất thường gây ra bởi những lỗi ít gặp nhất.
Intro
Như đã chia sẻ trong bài viết này, dù việc code và audit có cẩn thận đến đâu, thì vẫn có thể có những lỗi tiềm tàng xảy ra do những kịch bản mà ta không hề hoặc chưa từng nghĩ tới khi viết test.
Fuzzing
, hay còn gọi là Fuzz Testing
giúp ta giảm thiểu một lượng lớn những case đó xảy ra bằng cách test với số lượng rất lớn những random input để mô phỏng hầu hết các kịch bản trong thực tế có thể xảy ra, đặc biệt các trường hợp biên.
Fuzzing
dùng để test property-based
của hệ thống.
property-based
là những thuộc tính không đổi trong một hệ thống (gọi là Invariant
) ví dụ như:
- một user không thể rút quá số tiền mà họ có.
- tổng số token của tất cả địa chỉ phải luôn bằng total supply.
- balance của contract X luôn luôn lớn hơn 0.
- chỉ có duy nhất owner là người có quyền mint thêm token.
- công thức luôn đúng với Uniswap
- etc
Fuzzing với Foundry
hãy xem một ví dụ rất đơn giản sau đây:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract MyContract {
uint256 public shouldAlwaysBeZero = 0;
uint256 private hiddenValue = 0;
function doStuff(uint256 data) public {
if (data == 2) {
shouldAlwaysBeZero = 1;
}
if (hiddenValue == 7) {
shouldAlwaysBeZero = 1;
}
hiddenValue = data;
}
}
thuộc tính không đổi mà ta mong muốn ở đây là:
biến
shouldAlwaysBeZero
luôn luôn là 0.
Ta viết thử unit test:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {MyContract} from "../src/MyContract.sol";
import {Test} from "forge-std/Test.sol";
contract MyContractTest is Test {
MyContract exampleContract;
function setUp() public {
exampleContract = new MyContract();
}
function test_IsAlwaysZero() public {
uint256 data = 0;
exampleContract.doStuff(data);
assert(exampleContract.shouldAlwaysBeZero() == 0);
}
}
chạy unit test:
$ forge test --match-contract MyContract
[⠒] Compiling...
[⠒] Compiling 26 files with 0.8.17
[⠘] Solc 0.8.17 finished in 3.60s
Compiler run successful!
Running 1 test for test/MyContract.t.sol:MyContractTest
[PASS] testIsAlwaysZeroUnit() (gas: 10409)
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 422.83µs
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
test passed. Tức với unit test, đoạn code trên không có lỗi? không, tất nhiên đoạn code trên dùng để làm ví dụ có lỗi, ta dễ dàng tìm ra rằng nếu đưa data = 2
vào thì sẽ thay đổi được biến shouldAlwaysBeZero
. Hoặc lần đầu ta đưa data
là 7 để thay đổi hiddenValue
, và gọi tiếp lần sau với data
bất kì thì ta cũng có thể có được kết quả tương tự.
Tuy nhiên không phải lúc nào mọi thứ cũng rõ ràng và đơn giản như vậy, ví dụ một hàm trông như thế này thì sao? liệu nó có lỗi gì không?
function hellFunc(uint128 numberr) public view onlyOwner returns (uint256) {
uint256 numberrr = uint256(numberr);
Int number = Int.wrap(numberrr);
if (Int.unwrap(number) == 1) {
if (numbr < 3) {
return Int.unwrap((Int.wrap(2) - number) * Int.wrap(100) / (number + Int.wrap(2)));
}
if (Int.unwrap(number) < 3) {
return Int.unwrap((Int.wrap(numbr) - number) * Int.wrap(92) / (number + Int.wrap(3)));
}
if (Int.unwrap(Int.wrap(Int.unwrap(Int.wrap(Int.unwrap(Int.wrap(1)) / Int.unwrap(Int.wrap(Int.unwrap(Int.wrap(Int.unwrap(Int.wrap(numbr)))))))))) == 9) {
return 1654;
}
return 5 - Int.unwrap(number);
}
if (Int.unwrap(number) > 100) {
_numbaar(Int.unwrap(number));
uint256 dog = _numbaar(Int.unwrap(number) + 50);
return (dog + numbr - (numbr / numbir) * numbor) - numbir;
}
if (Int.unwrap(number) > 1) {
if (Int.unwrap(number) < 3) {
return Int.unwrap((Int.wrap(2) - number) * Int.wrap(100) / (number + Int.wrap(2)));
}
if (numbr < 3) {
return (2 / Int.unwrap(number)) + 100 - (Int.unwrap(number) * 2);
}
if (Int.unwrap(number) < 12) {
if (Int.unwrap(number) > 6) {
return Int.unwrap((Int.wrap(2) - number) * Int.wrap(100) / (number + Int.wrap(2)));
}
}
if (Int.unwrap(number) < 154) {
if (Int.unwrap(number) > 100) {
if (Int.unwrap(number) < 120) {
return (76 / Int.unwrap(number)) + 100 - Int.unwrap(Int.wrap(uint256(uint256(uint256(uint256(uint256(uint256(uint256(uint256(uint256(uint256(uint256(uint256(numbr))))))))))))) + Int.wrap(uint256(2)));
}
}
if (Int.unwrap(number) > 95) {
return Int.unwrap(Int.wrap((Int.unwrap(number) % 99)) / Int.wrap(1));
}
if (Int.unwrap(number) > 88) {
return Int.unwrap((Int.wrap((Int.unwrap(number) % 99) + 3)) / Int.wrap(1));
}
if (Int.unwrap(number) > 80) {
return (Int.unwrap(number) + 19) - (numbr * 10);
}
return Int.unwrap(number) + numbr - Int.unwrap(Int.wrap(nunber) / Int.wrap(1));
}
if (Int.unwrap(number) < 7654) {
if (Int.unwrap(number) > 100000) {
if (Int.unwrap(number) < 1200000) {
return (2 / Int.unwrap(number)) + 100 - (Int.unwrap(number) * 2);
}
}
if (Int.unwrap(number) > 200) {
if (Int.unwrap(number) < 300) {
return (2 / Int.unwrap(number)) + Int.unwrap(Int.wrap(100) / (number + Int.wrap(2)));
}
}
}
}
if (Int.unwrap(number) == 0) {
if (Int.unwrap(number) < 3) {
return Int.unwrap((Int.wrap(2) - (number * Int.wrap(2))) * Int.wrap(100) / (Int.wrap(Int.unwrap(number)) + Int.wrap(2)));
}
if (numbr < 3) {
return (Int.unwrap(Int.wrap(2) - (number * Int.wrap(3)))) + 100 - (Int.unwrap(number) * 2);
}
if (numbr == 10) {
return Int.unwrap(Int.wrap(10));
}
return (236 * 24) / Int.unwrap(Int.wrap(Int.unwrap(Int.wrap(Int.unwrap(Int.wrap(Int.unwrap(number)))))));
}
return numbr + nunber - mumber - mumber;
}
Ta sẽ viết unit test thế nào để cover được hết các trường hợp có thể xảy ra cho hàm trên? Thực sự quá khó để tìm ra giá trị như thế nào sẽ có thể gây lỗi cho một hàm như vậy.
Có hai phương pháp phổ biến để tìm ra những case ngoại lai trong trường hợp này:
- Fuzzing (Fuzz Testing, Invariant Testing)
- Formal Verification / Symbolic Execution
Trong bài này ta đang nói về Fuzzing.
Ta sẽ viết thêm fuzz test cho ví dụ bên trên để tìm ra case có thể gây lỗi hệ thống:
function testFuzz_IsAlwaysZero(uint256 randomData) public {
exampleContract.doStuff(randomData);
assert(exampleContract.shouldAlwaysBeZero() == 0);
}
chạy lại test:
$ forge test --match-contract MyContract
[⠢] Compiling...
[⠆] Compiling 1 files with 0.8.17
[⠰] Solc 0.8.17 finished in 1.08s
Compiler run successful!
Running 2 tests for test/MyContract.t.sol:MyContractTest
[FAIL. Reason: panic: assertion failed (0x01); counterexample: calldata=0x8f8d14b70000000000000000000000000000000000000000000000000000000000000002 args=[2]] testFuzz_IsAlwaysZero(uint256) (runs: 15, μ: 29038, ~: 30365)
[PASS] test_IsAlwaysZero() (gas: 10442)
Test result: FAILED. 1 passed; 1 failed; 0 skipped; finished in 1.34ms
Ran 1 test suites: 1 tests passed, 1 failed, 0 skipped (2 total tests)
Failing tests:
Encountered 1 failing test in test/MyContract.t.sol:MyContractTest
[FAIL. Reason: panic: assertion failed (0x01); counterexample: calldata=0x8f8d14b70000000000000000000000000000000000000000000000000000000000000002 args=[2]] testFuzz_IsAlwaysZero(uint256) (runs: 15, μ: 29038, ~: 30365)
Encountered a total of 1 failing tests, 1 tests succeeded
Bằng việc thực hiện fuzz test, Foundry đã giúp ta tìm ra được một trường hợp fail của hệ thống, là khi randomData
là 2, đúng như những gì ta đã nói bên trên.
Việc random data này thực chất là semi-random, tức là random một cách thông minh để làm sao để có thể nhanh nhất đưa ra được những giá trị có liên quan nhất đến đoạn code của chúng ta, trong trường hợp này là cho đoạn if data == 2
, nó sẽ biết cách dùng 2 làm input.
Stateless Fuzzing với Stateful Fuzzing
Ta nhận thấy rằng Foundry đã giúp chúng ta tìm ra được trường hợp với input là 2 thì sẽ gây lỗi hệ thống. Nhưng lại không thể tìm ra trường hợp khi input là 7 và gọi thêm một lần nữa với input bất kì.
Đoạn code test trên của chúng ta được gọi là Stateless Fuzzing
, tức nó chỉ chạy một lần duy nhất để tìm ra xem có lỗi gì hay không mà không mà không sử dụng tới trạng thái của những lần chạy trước đó.
Để tìm ra được trường hợp gây lỗi với input là 7 kia thì ta sẽ cần sử dụng Stateful Fuzzing
, hay còn gọi là Invariant Testing
. Khác với Stateless Fuzzing
, Stateful Fuzzing
cho phép sử dụng trạng thái của những lời gọi hàm trước đó để sử dụng cho lời gọi hàm phía sau. Bằng cách này Stateful Testing
có thể kết hợp rất nhiều những lời gọi hàm liên tiếp nhau để tìm ra những kịch bản phức tạp hơn có thể gây lỗi cho hệ thống.
Thông thường khi nói đến Fuzzing
là ta đang nói đến Stateless Fuzzing
, còn Invariant Testing
là nói đến Stateful Fuzzing
.
Ta sẽ viết Invariant Testing
để tìm ra trường hợp gây lỗi với input là 7 như sau:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {MyContract} from "../src/MyContract.sol";
import {Test} from "forge-std/Test.sol";
import {StdInvariant} from "forge-std/StdInvariant.sol";
contract MyContractTest is StdInvariant, Test {
MyContract exampleContract;
function setUp() public {
exampleContract = new MyContract();
targetContract(address(exampleContract));
}
function invariant_testAlwaysReturnsZero() public {
assert(exampleContract.shouldAlwaysBeZero() == 0);
}
}
Với Invariant Testing
thay vì phải chủ động đưa vào các random data, hệ thống sẽ tự động gọi một loạt các random function với random data khác nhau, tạo nên số lượng kịch bản khổng lồ có thể diễn ra. Ta chỉ việc định nghĩa invariant là xong.
Khi này bằng vài lần chạy test thì bên cạnh trường hợp bằng 2 ta sẽ có trường hợp mới gây lỗi khi input bằng 7 ở lần gần cuối, đúng như kịch bản ta đã dự đoán:
$ forge test --match-contract MyContract
[⠢] Compiling...
No files changed, compilation skipped
Running 1 test for test/MyContract.t.sol:MyContractTest
[FAIL. Reason: panic: assertion failed (0x01)]
[Sequence]
sender=0x1d10A1FC0862B8f6CF2EF28f662A8072034b2f53 addr=[src/MyContract.sol:MyContract]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=doStuff(uint256) args=[35350500055297590134862393535083679839599368862750373679432841770512088 [3.535e70]]
sender=0x00000000000000000000000000000000000008b3 addr=[src/MyContract.sol:MyContract]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=doStuff(uint256) args=[0]
sender=0x0000000000000000000000000000000000000Ba5 addr=[src/MyContract.sol:MyContract]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=doStuff(uint256) args=[6144456733000558609646156 [6.144e24]]
sender=0xb70a64bc56381fed59844BB9C5b1A233539e38bA addr=[src/MyContract.sol:MyContract]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=doStuff(uint256) args=[990]
sender=0x6FCFe386Bc13CbA2ABd36a1047F34d2dFD71eECA addr=[src/MyContract.sol:MyContract]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=doStuff(uint256) args=[1]
sender=0x0000000000000000000000000000000000000160 addr=[src/MyContract.sol:MyContract]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=doStuff(uint256) args=[4274138836023 [4.274e12]]
sender=0x0000000000000000000000000000000000000ad2 addr=[src/MyContract.sol:MyContract]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=doStuff(uint256) args=[7]
sender=0x000000000000000000000000000000003F7286F4 addr=[src/MyContract.sol:MyContract]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=doStuff(uint256) args=[286183929312458427811880886228415353614959305 [2.861e44]]
invariant_IsAlwaysZeroUnit() (runs: 256, calls: 3834, reverts: 0)
Test result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 348.43ms
Ran 1 test suites: 0 tests passed, 1 failed, 0 skipped (1 total tests)
Failing tests:
Encountered 1 failing test in test/MyContract.t.sol:MyContractTest
[FAIL. Reason: panic: assertion failed (0x01)]
[Sequence]
sender=0x1d10A1FC0862B8f6CF2EF28f662A8072034b2f53 addr=[src/MyContract.sol:MyContract]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=doStuff(uint256) args=[35350500055297590134862393535083679839599368862750373679432841770512088 [3.535e70]]
sender=0x00000000000000000000000000000000000008b3 addr=[src/MyContract.sol:MyContract]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=doStuff(uint256) args=[0]
sender=0x0000000000000000000000000000000000000Ba5 addr=[src/MyContract.sol:MyContract]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=doStuff(uint256) args=[6144456733000558609646156 [6.144e24]]
sender=0xb70a64bc56381fed59844BB9C5b1A233539e38bA addr=[src/MyContract.sol:MyContract]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=doStuff(uint256) args=[990]
sender=0x6FCFe386Bc13CbA2ABd36a1047F34d2dFD71eECA addr=[src/MyContract.sol:MyContract]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=doStuff(uint256) args=[1]
sender=0x0000000000000000000000000000000000000160 addr=[src/MyContract.sol:MyContract]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=doStuff(uint256) args=[4274138836023 [4.274e12]]
sender=0x0000000000000000000000000000000000000ad2 addr=[src/MyContract.sol:MyContract]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=doStuff(uint256) args=[7]
sender=0x000000000000000000000000000000003F7286F4 addr=[src/MyContract.sol:MyContract]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=doStuff(uint256) args=[286183929312458427811880886228415353614959305 [2.861e44]]
invariant_IsAlwaysZeroUnit() (runs: 256, calls: 3834, reverts: 0)
Encountered a total of 1 failing tests, 0 tests succeeded
Thực sự tuyệt vời!
Tóm lại Fuzz Testing
và Invariant Testing
là kiến thức rất quan trọng về testing cho dev, đặc biệt là smart contract dev, nơi một lỗ hổng vô cùng nhỏ có thể gây ra thiệt hại hàng triệu đô.
Hãy nắm thật chắc và luôn luôn viết Fuzz Testing
cho mọi contract.