Property-Based Testing untuk Smart Contract — Fuzzing dengan Foundry
Engineering Team
Mengapa Unit Test Tidak Cukup
Unit test memverifikasi bahwa kode Anda bekerja untuk input spesifik yang Anda pilih. Tetapi smart contract menangani input dari pengguna mana pun — termasuk penyerang yang secara aktif mencari edge case. Property-based testing membalikkan pendekatan: alih-alih menentukan input, Anda mendefinisikan properti yang harus selalu benar, dan fuzzer mencari input yang melanggarnya.
Properti vs Unit Test
// Unit test: satu input spesifik
function testTransfer() public {
token.transfer(alice, 100);
assertEq(token.balanceOf(alice), 100);
}
// Property test: untuk SEMUA input yang valid
function testFuzz_Transfer(address to, uint256 amount) public {
amount = bound(amount, 0, token.balanceOf(address(this)));
vm.assume(to != address(0));
uint256 totalBefore = token.totalSupply();
token.transfer(to, amount);
uint256 totalAfter = token.totalSupply();
// PROPERTI: total supply tidak berubah
assertEq(totalBefore, totalAfter);
}
Mendefinisikan Invariant
Invariant adalah kondisi yang harus SELALU benar, terlepas dari urutan operasi:
Invariant ERC20
totalSupply == sum(balances[semua_address])transfertidak mengubah totalSupplybalanceOf(x)tidak pernah negatif (dijamin oleh uint256)transfer(to, amount)mengurangi pengirim dan menambah penerima persisamount
Invariant AMM (Uniswap V2)
reserve0 * reserve1 >= k(setelah fee)reserve0 > 0 && reserve1 > 0(selalu memiliki likuiditas)- Swap tidak pernah mengeluarkan lebih dari reserve yang ada
price_impactmeningkat secara monoton dengan ukuran swap
Invariant Lending Protocol
total_deposited >= total_borrowed- Posisi dengan
health_factor > 1tidak bisa dilikuidasi - Likuidasi selalu mengurangi bad debt
Fuzzing dengan Foundry
Dasar: Fuzz Test
function testFuzz_DepositWithdraw(
uint256 depositAmount,
uint256 withdrawAmount
) public {
depositAmount = bound(depositAmount, 1, 1000 ether);
withdrawAmount = bound(withdrawAmount, 1, depositAmount);
deal(address(token), address(this), depositAmount);
token.approve(address(vault), depositAmount);
vault.deposit(depositAmount);
uint256 sharesBefore = vault.balanceOf(address(this));
vault.withdraw(withdrawAmount);
// Properti: shares berkurang proporsional
assertLe(
vault.balanceOf(address(this)),
sharesBefore
);
}
Lanjutan: Invariant Testing
contract VaultInvariant is Test {
Vault vault;
Token token;
InvariantHandler handler;
function setUp() public {
token = new Token();
vault = new Vault(address(token));
handler = new InvariantHandler(vault, token);
targetContract(address(handler));
}
// Foundry memanggil fungsi handler secara acak
function invariant_totalAssetsMatchDeposits() public {
assertEq(
vault.totalAssets(),
handler.ghost_totalDeposited() - handler.ghost_totalWithdrawn()
);
}
function invariant_solvency() public {
assertGe(
token.balanceOf(address(vault)),
vault.totalAssets()
);
}
}
contract InvariantHandler is Test {
Vault vault;
Token token;
uint256 public ghost_totalDeposited;
uint256 public ghost_totalWithdrawn;
function deposit(uint256 amount) external {
amount = bound(amount, 1, 100 ether);
deal(address(token), address(this), amount);
token.approve(address(vault), amount);
vault.deposit(amount);
ghost_totalDeposited += amount;
}
function withdraw(uint256 amount) external {
uint256 max = vault.balanceOf(address(this));
if (max == 0) return;
amount = bound(amount, 1, max);
vault.withdraw(amount);
ghost_totalWithdrawn += amount;
}
}
Konfigurasi Fuzzing
# foundry.toml
[fuzz]
runs = 10000 # Iterasi per fuzz test
max_test_rejects = 1000 # Skip input yang tidak valid
seed = 42 # Untuk reprodusibilitas
[invariant]
runs = 256 # Urutan operasi acak
depth = 100 # Operasi per urutan
fail_on_revert = false # Jangan gagal pada revert handler
Menemukan Kerentanan Nyata
Contoh kerentanan yang ditemukan oleh fuzzing:
1. Rounding Error di Vault
// Bug: deposit 1 wei menghasilkan 0 shares (pembulatan ke bawah)
// tetapi token tetap ditransfer ke vault
function testFuzz_MinDeposit(uint256 amount) public {
amount = bound(amount, 1, 100);
deal(address(token), address(this), amount);
token.approve(address(vault), amount);
uint256 shares = vault.deposit(amount);
// Ditemukan: shares == 0 untuk amount == 1
// Token hilang selamanya!
assertGt(shares, 0, "Zero shares minted");
}
2. Overflow di Kalkulasi Harga
function testFuzz_PriceOverflow(
uint256 reserve0,
uint256 reserve1,
uint256 amountIn
) public {
reserve0 = bound(reserve0, 1e18, 1e30);
reserve1 = bound(reserve1, 1e18, 1e30);
amountIn = bound(amountIn, 1e18, 1e28);
// Ditemukan: mul(amountIn, 997) overflow untuk amountIn besar
uint256 output = getAmountOut(amountIn, reserve0, reserve1);
assertGt(output, 0);
}
Differential Testing
Bandingkan implementasi Huff dengan referensi Solidity:
function testFuzz_Differential(
uint256 a,
uint256 b
) public {
vm.assume(b > 0); // Hindari division by zero
// Referensi Solidity
uint256 expected = a / b;
// Implementasi Huff
(bool ok, bytes memory data) = huffContract.call(
abi.encodeWithSignature("div(uint256,uint256)", a, b)
);
assertTrue(ok);
uint256 actual = abi.decode(data, (uint256));
assertEq(actual, expected, "Huff div mismatch");
}
Kesimpulan
Property-based testing dan fuzzing adalah senjata paling kuat untuk menemukan bug smart contract. Definisikan invariant yang jelas, tulis handler yang mencakup semua operasi, dan jalankan ribuan iterasi. Untuk kontrak Huff, tambahkan differential testing terhadap referensi Solidity untuk memverifikasi kebenaran di tingkat opcode.