Smart contract security artinya memastikan contract cuma melakukan yang dijanjikan, bahkan saat yang memanggilnya berniat jahat. Contract publik: siapa pun bisa memanggil fungsi apa pun dengan input apa pun, dan setelah deploy kodenya gak bisa diperbaiki. Bayangin meja hadiah di Toko Kaos Raka: tiap pelanggan boleh ambil satu stiker gratis. Petugas yang benar mencoret nama dulu, baru memberikan stikernya. Petugas yang memberikan stiker dulu bisa ditipu pelanggan yang meminta lagi sebelum namanya dicoret. Lesson ini sisi bertahan: tiap kelas bug, cara mengenalinya, perbaikannya, dan test yang membuktikan perbaikan itu, semuanya pada contract mainan di chain lokal.
🧪 Tebakan cepat: contract
StikerGratismengeceksudahKlaim[msg.sender], lalu_safeMintstiker, lalu menandaisudahKlaim = true. Penerimanya contract yang memanggilklaim()lagi darionERC721Received. Apakah satu alamat bisa mendapat lebih dari satu stiker? Tebak dulu, jawabannya ada di bagian "Reentrancy".
Access control: siapa yang boleh
Bug yang paling sering dan paling mahal (nomor 1 di OWASP Smart Contract Top 10:2026) adalah fungsi penting tanpa penjaga. Kalau earn PoinRaka gak punya onlyOwner, siapa pun bisa mencetak POIN untuk dirinya sendiri. Dengan onlyOwner, Dinda ditolak dengan OwnableUnauthorizedAccount(Dinda), dan test di lesson Testing membuktikannya. Kalau Raka punya beberapa kasir, pakai role:
contract PoinRakaKasir is ERC20, AccessControl {
bytes32 public constant KASIR_ROLE = keccak256("KASIR_ROLE");
constructor() ERC20("Poin Raka", "POIN") {
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender); // Raka: boleh menambah dan mencabut kasir
}
function earn(address customer, uint256 rupiah) external onlyRole(KASIR_ROLE) {
_mint(customer, (rupiah / 1000) * 10 ** 18);
}
}
Di chain lokal, Raka sendiri memanggil earn dan ditolak: AccessControlUnauthorizedAccount(0xf39F...2266, 0x0ca0d86d...601f). Admin bukan kasir; ia harus grantRole(KASIR_ROLE, ...) dulu. Setelah Yoga diberi role itu, earn dari Yoga berhasil.
Reentrancy
Reentrancy terjadi saat contract memanggil kode luar sebelum selesai memperbarui state-nya, dan kode luar itu memanggil balik fungsi yang sama. Kasus klasiknya kirim ETH (The DAO, 2016). Di dunia Raka, jalurnya _safeMint: kalau penerima adalah contract, ERC-721 memanggil onERC721Received milik penerima. Itu panggilan eksternal.
// MAINAN RENTAN untuk latihan: jangan di-deploy
function klaim() external {
if (sudahKlaim[msg.sender]) revert SudahKlaim(); // checks
_safeMint(msg.sender, ++totalKlaim); // interaction: memanggil penerima
sudahKlaim[msg.sender] = true; // effects: terlambat
}
Perbaikannya pola Checks-Effects-Interactions (CEI): cek, ubah state, baru panggil keluar. Lapisan tambahannya nonReentrant dari OpenZeppelin 5.6.1. Pakai ReentrancyGuardTransient (transient storage EIP-1153); ReentrancyGuard versi storage ditandai deprecated dan akan diganti di v6.
function klaim() external nonReentrant {
if (sudahKlaim[msg.sender]) revert SudahKlaim(); // checks
sudahKlaim[msg.sender] = true; // effects dulu
_safeMint(msg.sender, ++totalKlaim); // interaction terakhir
}
Test-nya memakai test double PenerimaUlang, contract penerima yang memanggil klaim() sekali lagi dari onERC721Received:
Keamanan: reentrancy lewat _safeMint
✔ versi rentan: satu alamat dapat 2 stiker
✔ perbaikan CEI: klaim kedua ditolak SudahKlaim
✔ perbaikan CEI + nonReentrant: ditolak ReentrancyGuardReentrantCall
✔ pelanggan biasa tetap bisa klaim sekali
Jadi ya: di versi rentan satu alamat mendapat 2 stiker, dan penerima yang terus memanggil ulang bisa mendapat lebih. Itu jawaban tebakannya. Di versi CEI panggilan kedua revert, dan revert itu membatalkan seluruh transaksi: penerima gak dapat apa-apa.
Panggilan eksternal yang gak dicek
transfer dan transferFrom ERC-20 mengembalikan bool. OpenZeppelin revert kalau gagal, tapi beberapa token lama cuma mengembalikan false. Contract yang mengabaikannya memberi voucher gratis:
poin.transferFrom(msg.sender, address(this), 100e18); // RENTAN: return value diabaikan
poin.safeTransferFrom(msg.sender, address(this), 100e18); // using SafeERC20 for IERC20
Dengan token palsu yang selalu menjawab false, versi rentan menambah voucher walau gak ada token yang pindah; versi SafeERC20 revert SafeERC20FailedOperation(token). Hal yang sama berlaku untuk (bool ok, ) = to.call{value: x}(""): selalu cek ok.
Angka: overflow, unchecked, cast
Sejak Solidity 0.8, 5 - 10 pada uint8 revert: Arithmetic operation resulted in underflow or overflow. Tapi dua celah tetap ada, dan keduanya terbukti di chain lokal:
- di dalam
unchecked { },5 - 10jadi 251 tanpa error; - cast eksplisit gak dicek:
uint8(300)jadi 44. PakaiSafeCast.toUint8(300), yang revertSafeCastOverflowedUintDowncast(8, 300).
tx.origin bukan msg.sender
tx.origin adalah akun yang menandatangani transaksi; msg.sender adalah pemanggil langsung. require(tx.origin == owner) lolos untuk contract apa pun yang Raka panggil, termasuk contract jahat yang lalu memanggil tarik() atas nama Raka. Pakai msg.sender (atau onlyOwner, yang memakai msg.sender).
Front-running dan approve race
Transaksi menunggu di mempool sebelum masuk block, dan orang lain bisa menyalipnya. Lesson ERC-20 menunjukkan TukarPoin mengambil 90 POIN saat Dinda menurunkan izin dari 70 ke 20. OpenZeppelin 5 menghapus increaseAllowance, jadi pertahanannya: approve sejumlah yang dipakai tepat sebelum dipakai, turunkan ke 0 dulu sebelum mengganti, atau permit (ERC-2612, ERC20Permit): izin persis dengan batas waktu yang ditandatangani saat dipakai. Untuk swap, pakai minOut dan deadline (lesson DeFi).
Manipulasi oracle
Contract yang membaca harga dari cadangan kolam bisa ditipu dalam satu transaksi: di lesson DeFi harga POIN di kolam mainan bergeser dari 50 ke 199,70 RPU. Pakai price feed oracle atau TWAP, cek umur datanya, dan beri batas wajar.
Alat dan proses
Slither 0.11.6 (static analysis) menemukan reentrancy di versi rentan dan nol temuan di versi CEI:
$ slither contracts/keamanan/StikerGratis.sol --solc-remaps "@openzeppelin/=node_modules/@openzeppelin/" --detect reentrancy-no-eth,reentrancy-benign,reentrancy-events
Detector: reentrancy-no-eth
Reentrancy in StikerGratis.klaim() (contracts/keamanan/StikerGratis.sol#15-19):
External calls:
- _safeMint(msg.sender,++ totalKlaim) (contracts/keamanan/StikerGratis.sol#17)
...
State variables written after the call(s):
- sudahKlaim[msg.sender] = true (contracts/keamanan/StikerGratis.sol#18)
Hati-hati: dengan --filter-paths node_modules temuan ini hilang, karena jalur panggilannya lewat kode OpenZeppelin. Untuk TukarPoinRentan, Slither menandai unchecked-transfer dan tx-origin. Alat lain: Mythril (symbolic execution; rilis terakhirnya 0.24.8, Maret 2024), fuzzing dan invariant test dengan Foundry atau Echidna, lalu audit oleh pihak luar dan bug bounty setelah deploy. SWC Registry yang sering dikutip tutorial lama sudah gak dirawat sejak 2020 dan menunjuk ke EEA EthTrust Security Levels; daftar yang aktif dirawat misalnya OWASP Smart Contract Top 10:2026.
⚠️ Jebakan yang sering ketemu
- Fungsi admin tanpa penjaga. Tiap fungsi yang mencetak, menarik dana atau mengubah aturan butuh
onlyOwneratau role, plus test yang membuktikan orang lain ditolak. - Memanggil keluar sebelum mengubah state.
_safeMint,safeTransferFromERC-721, dan kirim ETH semuanya bisa memanggil kode penerima. CEI dulu,nonReentrantsebagai lapisan kedua. - Percaya alat saja. Slither melewatkan bug logika dan bisa dibuat buta oleh filter; test, review, dan audit tetap perlu.
- Mengira 0.8 menjaga semua angka.
uncheckeddan cast eksplisit gak dicek.
TL;DR: Lindungi tiap fungsi penting dengan onlyOwner atau onlyRole (access control, nomor 1 di OWASP Smart Contract Top 10:2026). Reentrancy: contract memanggil kode luar sebelum memperbarui state, lalu dipanggil balik; perbaiki dengan Checks-Effects-Interactions (update state sebelum external call) dan nonReentrant dari ReentrancyGuardTransient. Cek hasil panggilan eksternal (SafeERC20, ok dari call). Ingat unchecked dan cast gak dicek (uint8(300) = 44; pakai SafeCast). Pakai msg.sender, bukan tx.origin. Lawan front-running dengan approve secukupnya, permit, minOut dan deadline, dan manipulasi oracle dengan price feed atau TWAP. Buktikan tiap perbaikan dengan test yang gagal di versi rentan dan lulus di versi aman, jalankan Slither tanpa filter yang membutakan, lalu audit dan bug bounty. SWC Registry gak dirawat lagi sejak 2020.