๐ง Unit 2 โ Solidity Lanjutan
Di akhir unit ini kamu akan:
- Bisa memakai modifier untuk kontrol akses
- Paham inheritance dan cara memakai library seperti OpenZeppelin โ termasuk membuat token ERC-20 dalam belasan baris
- Bisa memakai custom error, struct, array, dan interface
- Paham pola keamanan penting: reentrancy, kontrol akses, integer โ dan melihat sendiri serangan reentrancy berhasil lalu menutupnya
- Punya intuisi dasar tentang gas
- โ
Unit 1 selesai โ kamu sudah men-deploy dan menguji beberapa contract di Remix, dan paham
mapping,require,event, danpayable
Setiap konsep tetap: satu contract kecil โ deploy โ klik โ lihat hasilnya. Blok ๐งช Coba di Remix memberi langkah klik konkret dan hasil yang seharusnya kamu lihat.
Tetap pakai Remix VM (blockchain simulasi di browser) untuk semua latihan โ gratis, instan, dan memberimu beberapa akun uji yang akan kita manfaatkan lagi di sini, terutama untuk menguji kontrol akses dan reentrancy.
๐ก๏ธ Modifier โ Aturan yang Bisa Dipakai Ulangโ
Apa itu: pembungkus validasi yang bisa dipasang ke banyak function sekaligus.
Kenapa penting: di Unit 1, setiap function yang butuh cek "hanya pemilik" harus menulis require sendiri. Kalau ada sepuluh function seperti itu, kamu menyalin baris yang sama sepuluh kali โ dan cukup lupa sekali saja untuk membuka celah keamanan. Modifier menyelesaikan ini.
Buat BelajarModifier.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract BelajarModifier {
address public pemilik;
uint256 public batasMaksimal;
constructor() {
pemilik = msg.sender;
}
// Modifier: cek dulu, baru jalankan function
modifier hanyaPemilik() {
require(msg.sender == pemilik, "Bukan pemilik");
_; // di sini badan function disisipkan
}
function setBatas(uint256 _batas) public hanyaPemilik {
batasMaksimal = _batas;
}
function gantiPemilik(address _baru) public hanyaPemilik {
require(_baru != address(0), "Alamat nol tidak boleh");
pemilik = _baru;
}
}
Penjelasan:
modifier hanyaPemilik() { ... }โ mendefinisikan aturan yang bisa dipakai ulang.- Tanda
_;adalah tempat badan function disisipkan. Jadi urutannya: cekrequiredulu, baru isi function berjalan. public hanyaPemilikโ memasang modifier itu ke sebuah function. SekarangsetBatasdangantiPemiliksama-sama terlindungi hanya dengan menambahkan satu kata.
require(_baru != address(0))Alamat nol (0x000...0) adalah alamat yang tidak dimiliki siapa pun.
Kalau kamu mentransfer kepemilikan ke alamat nol, contract-mu terkunci selamanya โ tidak akan ada lagi yang bisa memanggil function hanyaPemilik. Ini kesalahan yang benar-benar pernah terjadi di proyek sungguhan. Selalu validasi alamat sebelum menyimpannya.
๐งช Coba di Remixโ
-
Compile โ Deploy dari akun pertama (akun ini jadi
pemilik) -
Ketik
100disetBatasโ klik โ cekbatasMaksimalโ Hasil:100โ -
Ganti ke akun kedua di dropdown ACCOUNT
-
Ketik
999disetBatasโ klikโ Hasil: GAGAL dengan
"Bukan pemilik"โ satu modifier melindungi function tanpa menyalinrequireke mana-mana โ -
Masih di akun kedua, cek
batasMaksimalโ Hasil: tetap100(transaksi tadi dibatalkan seluruhnya) -
Kembali ke akun pertama, coba
gantiPemilikdengan0x0000000000000000000000000000000000000000โ Hasil: GAGAL dengan
"Alamat nol tidak boleh"โ validasi menyelamatkanmu dari mengunci contract โ
๐งฌ Inheritance โ Mewarisi Contract Lainโ
Apa itu: membuat contract mewarisi variabel dan function dari contract lain.
Kenapa penting: ini yang memungkinkanmu memakai kode teruji orang lain (seperti OpenZeppelin) alih-alih menulis semuanya dari nol.
Buat BelajarInheritance.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Induk {
function sapa() public pure virtual returns (string memory) {
return "Halo dari induk";
}
}
contract Anak is Induk {
function sapa() public pure override returns (string memory) {
return "Halo dari anak";
}
function sapaInduk() public pure returns (string memory) {
return super.sapa(); // memanggil versi induk
}
}
Penjelasan:
ismenandai pewarisan โAnak is Induk.virtual= function ini boleh ditimpa oleh turunannya.override= function ini menimpa milik induk.super.sapa()= memanggil versi induk secara eksplisit, meski sudah ditimpa.
๐งช Coba di Remixโ
- Compile. Di dropdown CONTRACT (di tab Deploy) akan muncul beberapa contract โ pilih
Anakโ Deploy - Klik
sapaโ Hasil:Halo dari anak(versi yang menimpa) โ - Klik
sapaIndukโ Hasil:Halo dari induk(versi asli lewatsuper) โ
Satu file bisa berisi banyak contract. Remix men-deploy yang sedang terpilih di dropdown CONTRACT. Kalau kamu bingung kenapa tombol yang muncul tidak sesuai harapan, hampir selalu karena contract yang salah sedang terpilih.
๐ฆ Dalam Praktik โ Token ERC-20 dengan OpenZeppelinโ
Apa itu: OpenZeppelin adalah pustaka contract standar yang sudah diaudit dan dipakai ribuan proyek.
Kenapa penting: kamu jarang menulis token dari nol. Mewarisi dari OpenZeppelin memberimu logika transfer, approval, dan pembukuan saldo yang sudah benar โ kamu tinggal menambahkan bagian yang unik.
Buat TokenCLC.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract TokenCLC is ERC20, Ownable {
constructor()
ERC20("CLC11 Token", "CLC11")
Ownable(msg.sender)
{
// Cetak 1 juta token untuk pen-deploy
_mint(msg.sender, 1000000 * 10 ** decimals());
}
// Hanya owner yang boleh mencetak token baru
function mint(address ke, uint256 jumlah) public onlyOwner {
_mint(ke, jumlah);
}
}
Penjelasan:
import "@openzeppelin/..."โ Remix otomatis mengunduh library ini dari npm. Tidak perlu setup.is ERC20, Ownableโ mewarisi dua contract sekaligus.ERC20("CLC11 Token", "CLC11")โ memanggil constructor induk untuk memberi nama dan simbol token.Ownable(msg.sender)โ menjadikan pen-deploy sebagai owner._mint(...)danonlyOwnerโ keduanya diwarisi; kamu tidak menulisnya sendiri.
Kode standar seperti token, kontrol akses, dan proteksi reentrancy sudah ditulis, diaudit, dan dipakai ribuan proyek.
Menulis sendiri versimu bukan tanda kemampuan โ itu menambah risiko tanpa manfaat. Pakai OpenZeppelin, lalu habiskan energimu untuk bagian yang benar-benar unik dari proyekmu.
๐งช Coba di Remixโ
-
Compile (butuh beberapa detik lebih lama โ Remix sedang mengunduh OpenZeppelin) โ pilih contract
TokenCLCโ Deploy dari akun pertama -
Klik
nameโ Hasil:CLC11 Token, kliksymbolโ Hasil:CLC11โ -
Salin alamat akun pertama, tempel ke
balanceOfโ klikโ Hasil:
1000000000000000000000000(1 juta token, 18 desimal) โ -
Sekarang transfer. Isi
transfer:to= alamat akun kedua,amount=1000000000000000000000(1000 token) โ klik -
Cek
balanceOfuntuk akun kedua โ Hasil:1000000000000000000000โ -
Ganti ke akun kedua, coba
mintke dirimu sendiri dengan jumlah berapa punโ Hasil: GAGAL โ
mintdilindungionlyOwner, dan akun kedua bukan owner โ -
Kembali ke akun pertama,
mint500 token ke siapa pun โ berhasil โ
Contract belasan baris ini adalah token ERC-20 sungguhan. Kalau di-deploy ke Injective EVM testnet (lihat Unit 3), ia bisa dilihat di explorer, ditransfer antar-wallet, dan ditambahkan ke MetaMask seperti token lainnya. Itulah kekuatan mewarisi standar.
โ Custom Error โ Lebih Murah dari Stringโ
Apa itu: cara melaporkan kegagalan yang lebih hemat daripada pesan require berupa string.
Kenapa penting: string "Tabungan tidak cukup" disimpan di dalam bytecode contract dan memakan gas. Custom error tidak, dan bisa membawa data yang berguna untuk debugging.
Buat BelajarCustomError.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// Deklarasi di luar contract
error TabunganTidakCukup(uint256 diminta, uint256 tersedia);
contract BelajarCustomError {
mapping(address => uint256) public tabungan;
function setor() public payable {
tabungan[msg.sender] += msg.value;
}
function menarik(uint256 jumlah) public {
uint256 saldo = tabungan[msg.sender];
// Cara lama (string): require(jumlah <= saldo, "Tabungan tidak cukup");
// Cara hemat (custom error):
if (jumlah > saldo) {
revert TabunganTidakCukup(jumlah, saldo);
}
tabungan[msg.sender] = saldo - jumlah;
(bool sukses, ) = msg.sender.call{value: jumlah}("");
require(sukses, "Pengiriman gagal");
}
}
Penjelasan:
error TabunganTidakCukup(...)โ mendeklarasikan error, biasanya di luar contract.revert NamaError(...)โ memicu error itu dan membatalkan transaksi, sama sepertirequireyang gagal.- Bedanya: error ini membawa dua angka (
dimintadantersedia), jadi saat debugging kamu tahu persis berapa selisihnya.
๐งช Coba di Remixโ
-
Compile โ Deploy
-
Isi VALUE =
1Ether โ kliksetor -
Cek
tabungandengan alamatmu โ Hasil:1000000000000000000โ -
Coba tarik lebih banyak: ketik
5000000000000000000(5 INJ) dimenarikโ klik -
Buka Terminal, klik transaksi yang gagal itu
โ Hasil: transaksi ter-revert dengan
TabunganTidakCukup, dan membawa angkadiminta: 5000000000000000000, tersedia: 1000000000000000000โ
Pesan string hanya bisa memberi tahu bahwa sesuatu gagal. Custom error memberi tahu seberapa jauh gagalnya. Saat men-debug transaksi orang lain yang gagal di explorer, data seperti ini menghemat banyak waktu.
๐ Struct dan Arrayโ
Apa itu: struct mengelompokkan beberapa variabel jadi satu tipe; array menyimpan daftar berurutan yang bisa bertambah.
Kenapa penting: hampir semua contract nyata mengelola koleksi item kompleks โ daftar peserta, daftar campaign, daftar order. Ini kombinasi yang kamu pakai untuk itu.
Buat DaftarPeserta.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract DaftarPeserta {
struct Peserta {
string nama;
address wallet;
uint256 poin;
bool lulus;
}
// Array dinamis berisi struct
Peserta[] public peserta;
// Alamat โ posisinya di array, biar mudah dicari
mapping(address => uint256) public indeksPeserta;
function daftar(string memory _nama) public {
peserta.push(Peserta({
nama: _nama,
wallet: msg.sender,
poin: 0,
lulus: false
}));
indeksPeserta[msg.sender] = peserta.length - 1;
}
function tambahPoin(uint256 _jumlah) public {
uint256 i = indeksPeserta[msg.sender];
peserta[i].poin += _jumlah;
}
function jumlahPeserta() public view returns (uint256) {
return peserta.length;
}
}
Penjelasan:
struct Peserta { ... }โ tipe kustom yang menggabungkan empat data.Peserta[] public peserta;โ array dinamis;.push(...)menambah di akhir,.lengthmenghitung isinya.peserta[i].poin += _jumlahโ mengakses satu field dari satu elemen array.- Mapping
indeksPesertadipakai supaya kita bisa menemukan posisi seseorang tanpa harus men-loop seluruh array.
๐งช Coba di Remixโ
-
Compile โ Deploy dari akun pertama
-
Ketik
"Budi"didaftarโ klik -
Ganti ke akun kedua โ ketik
"Ani"didaftarโ klik -
Klik
jumlahPesertaโ Hasil:2โ -
Ketik
0dipesertaโ Hasil: data Budi (nama, alamat akun pertama, poin 0, lulus false) -
Ketik
1dipesertaโ Hasil: data Ani โ -
Masih sebagai akun kedua, ketik
50ditambahPoinโ klik โ cekpesertaindeks1โ Hasil: poin Ani =
50, poin Budi tetap0โ
Ini kesalahan desain klasik yang mematikan contract:
// โ BERBAHAYA
function bagikanHadiah() public {
for (uint256 i = 0; i < peserta.length; i++) {
// kirim hadiah ke peserta[i]
}
}
Setiap blok punya batas gas. Kalau array-nya tumbuh cukup besar, loop ini akan melebihi batas dan transaksinya selalu gagal โ selamanya. Function itu jadi mati permanen, dan tidak ada cara memperbaikinya karena contract tidak bisa diubah.
Pola yang benar: biarkan pengguna mengambil sendiri (pull), jangan kamu yang mengirim ke semua orang (push).
// โ
AMAN
mapping(address => uint256) public hadiahTertunda;
function klaimHadiah() public {
uint256 jumlah = hadiahTertunda[msg.sender];
require(jumlah > 0, "Tidak ada hadiah");
hadiahTertunda[msg.sender] = 0;
(bool sukses, ) = msg.sender.call{value: jumlah}("");
require(sukses, "Gagal");
}
Setiap pengguna membayar gas-nya sendiri, dan tidak ada loop yang bisa meledak.
๐ Interface โ Bicara dengan Contract Lainโ
Apa itu: deklarasi function apa saja yang dimiliki contract lain, tanpa isinya โ cukup untuk memanggilnya.
Kenapa penting: ini fondasi komposabilitas, kemampuan aplikasi Web3 saling memakai seperti balok Lego. Untuk memanggil token orang lain, kamu tidak butuh kode lengkapnya, cukup tahu bentuk function-nya.
Kita uji ini dengan dua contract di satu file โ sebuah token sederhana dan sebuah pembaca yang memanggilnya lewat interface.
Buat BelajarInterface.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// Interface: cuma bentuk function, tanpa isi
interface IToken {
function balanceOf(address pemilik) external view returns (uint256);
}
// Token kecil untuk diuji
contract TokenMini {
mapping(address => uint256) public saldo;
function cetakUntukSaya(uint256 jumlah) public {
saldo[msg.sender] += jumlah;
}
function balanceOf(address pemilik) external view returns (uint256) {
return saldo[pemilik];
}
}
// Contract lain yang membaca token di atas TANPA tahu isinya
contract PembacaSaldo {
function cekSaldo(address token, address siapa) public view returns (uint256) {
return IToken(token).balanceOf(siapa);
}
}
Penjelasan:
interface IToken { ... }โ hanya mendeklarasikanbalanceOf, tanpa badan function.IToken(token)โ memberi tahu Solidity: "perlakukan alamat ini sebagai sesuatu yang punya functionbalanceOf".PembacaSaldotidak tahu apa pun tentang isiTokenMiniโ cukup tahu bentuk function-nya. Itulah inti komposabilitas.
๐งช Coba di Remixโ
-
Compile. Deploy
TokenMinidulu โ salin alamatnya (ikon copy di sebelah nama contract di Deployed Contracts) -
Di
TokenMini, ketik777dicetakUntukSayaโ klik -
Sekarang deploy
PembacaSaldo(ganti pilihan di dropdown CONTRACT dulu) -
Di
PembacaSaldo, isicekSaldo:token= alamatTokenMiniyang tadi kamu salin,siapa= alamat akun aktifmu โ klikโ Hasil:
777โ โ satu contract berhasil membaca state contract lain, hanya berbekal interface
Di dunia nyata, token bisa berupa alamat token siapa pun yang sudah ada di chain โ kamu tidak butuh source code-nya, cukup interface standarnya. Beginilah cara satu dApp memakai token, DEX, atau oracle yang dibuat orang lain. Kamu akan memakai persis pola ini di Phase 3.
๐ Tiga Pola Keamanan yang Wajib Kamu Kuasaiโ
1. Reentrancy โ Sekarang Kita Serang Sungguhanโ
Apa itu: serangan di mana contract penerima memanggil balik function-mu sebelum state-mu sempat diperbarui, lalu menguras dana.
Kenapa penting: ini pernah menyebabkan salah satu kerugian terbesar dalam sejarah Ethereum. Membacanya saja tidak cukup โ di bawah ini kamu akan menjalankan serangannya sendiri di Remix VM, lalu menutupnya.
Buat DemoReentrancy.sol โ berisi bank yang rentan dan penyerangnya:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// โ BANK YANG RENTAN
contract BankRentan {
mapping(address => uint256) public saldo;
function setor() public payable {
saldo[msg.sender] += msg.value;
}
function tarik() public {
uint256 jumlah = saldo[msg.sender];
require(jumlah > 0, "Kosong");
// โ KIRIM DULU, baru nol-kan โ inilah celahnya
(bool ok, ) = msg.sender.call{value: jumlah}("");
require(ok, "Gagal");
saldo[msg.sender] = 0; // terlambat!
}
function saldoBank() public view returns (uint256) {
return address(this).balance;
}
}
// ๐ PENYERANG
contract Penyerang {
BankRentan public bank;
constructor(address _bank) {
bank = BankRentan(_bank);
}
// Dipanggil otomatis setiap contract ini menerima INJ
receive() external payable {
// Selama bank masih punya cukup dana, tarik lagi
if (address(bank).balance >= 1 ether) {
bank.tarik();
}
}
function serang() public payable {
bank.setor{value: 1 ether}(); // setor 1 INJ dulu
bank.tarik(); // lalu picu penarikan berantai
}
function ambilJarahan() public {
(bool ok, ) = msg.sender.call{value: address(this).balance}("");
require(ok);
}
}
Cara serangan ini bekerja: saat BankRentan mengirim INJ ke Penyerang, function receive() milik penyerang jalan otomatis. Karena bank mengirim dulu sebelum menol-kan saldo, receive() bisa memanggil tarik() lagi โ dan saldonya masih tercatat penuh. Berulang, sampai bank kosong.
๐งช Coba di Remix โ lihat serangannya berhasilโ
-
Compile. Deploy
BankRentandulu -
Kita isi dulu banknya seolah ada nasabah lain. Dengan akun kedua, isi VALUE =
5Ether โ kliksetordiBankRentan -
Klik
saldoBankโ Hasil:5000000000000000000(5 INJ tersimpan) -
Sekarang deploy
Penyerang, isi field constructor_bank= alamatBankRentanโ Deploy dari akun pertama -
Di
Penyerang, isi VALUE =1Ether โ klikserang -
Kembali ke
BankRentan, kliksaldoBankโ Hasil:
0(atau mendekati nol) โ penyerang menyetor 1 INJ tapi keluar membawa 6 INJ. Dana nasabah lain lenyap โ (secara mengerikan)
Penyerang menaruh 1 INJ dan mengambil 6. Selisih 5 INJ itu adalah setoran akun kedua โ dana orang lain. Di mainnet, ini uang sungguhan yang hilang permanen.
Sekarang mari tutup celahnya.
Perbaikannya โ cukup ubah urutan di tarik() menjadi checks-effects-interactions:
// โ
AMAN
function tarik() public {
uint256 jumlah = saldo[msg.sender];
require(jumlah > 0, "Kosong"); // Checks
saldo[msg.sender] = 0; // Effects โ nol-kan DULU
(bool ok, ) = msg.sender.call{value: jumlah}(""); // Interactions โ kirim terakhir
require(ok, "Gagal");
}
๐งช Coba di Remix โ lihat serangan yang sama gagalโ
-
Ganti function
tarik()diBankRentandengan versi aman di atas โ Compile ulang -
Deploy
BankRentanyang baru, isi lagi 5 INJ dari akun kedua -
Deploy
Penyerangbaru dengan alamat bank yang baru โserangdengan VALUE 1 Etherโ Hasil: serangan gagal / hanya menarik 1 INJ miliknya sendiri. Saat
receive()memanggiltarik()lagi, saldonya sudah0, jadirequire(jumlah > 0)menolaknya โ -
Klik
saldoBankโ Hasil: masih5000000000000000000โ dana nasabah lain aman โ
Yang berubah hanyalah posisi saldo[msg.sender] = 0. Itu saja. Untuk perlindungan berlapis, OpenZeppelin menyediakan ReentrancyGuard โ modifier nonReentrant yang mencegah function dipanggil ulang sebelum selesai. Tetap: checks-effects-interactions adalah pertahanan pertamamu.
2. Kontrol Aksesโ
// โ Siapa pun bisa memanggil ini dan menguras contract
function tarikSemuaDana() public {
payable(msg.sender).transfer(address(this).balance);
}
// โ
Dibatasi
function tarikSemuaDana() public hanyaPemilik {
(bool ok, ) = pemilik.call{value: address(this).balance}("");
require(ok, "Gagal");
}
Setiap kali kamu menulis function yang mengubah state, tanyakan: "apa yang terjadi kalau orang asing memanggil ini?"
Kalau jawabannya buruk, function itu butuh modifier. Kamu sudah punya hanyaPemilik dari awal unit ini โ pakai.
3. Integerโ
Sejak Solidity 0.8.0, overflow dan underflow otomatis membatalkan transaksi. Tapi tetap waspadai:
- Pembagian membuang sisa. Kalikan sebelum membagi:
(a * 100) / b, bukan(a / b) * 100 - Perhatikan desimal. Mencampur nilai 6-desimal (seperti USDC) dan 18-desimal (seperti INJ) adalah sumber bug yang sering terjadi
โฝ Berpikir tentang Gasโ
Apa itu: setiap operasi di contract punya harga, dibayar pemanggil dalam INJ.
Kenapa penting: operasi termahal jauh melampaui yang lain, jadi tahu yang mana membuat contract-mu jauh lebih murah dipakai.
| Operasi | Biaya relatif |
|---|---|
| Menulis ke state (slot baru) | ๐ด Sangat mahal |
| Menulis ke state (slot yang sudah terisi) | ๐ Mahal |
| Membaca dari state | ๐ก Sedang |
| Memancarkan event | ๐ข Murah |
| Operasi di memori | ๐ข Sangat murah |
Tiga kebiasaan hemat gasโ
1. Cache variabel state di memori kalau dipakai berkali-kali
// โ membaca state 3x
function boros() public view returns (uint256) {
return count + count + count;
}
// โ
membaca state 1x
function hemat() public view returns (uint256) {
uint256 c = count;
return c + c + c;
}
2. Pakai event untuk data yang hanya perlu dibaca dari luar chain
Kalau frontend-mu butuh riwayat aktivitas, jangan simpan array di state โ pancarkan event. Jauh lebih murah, seperti yang sudah kamu lihat di Unit 1.
3. Rapatkan variabel kecil dalam satu slot
// โ 3 slot penyimpanan
uint256 a;
uint128 b;
uint128 c;
// โ
2 slot โ b dan c muat bersama dalam satu slot 32-byte
uint128 b;
uint128 c;
uint256 a;
Setiap kali kamu memanggil function yang menulis state, buka transaksinya di Terminal dan lihat field transaction cost. Coba panggil boros vs hemat, atau bandingkan menyimpan data di array vs memancarkan event โ angkanya nyata dan langsung terlihat.
Tapi jangan terobsesi dulu:
Tulis kode yang benar dan jelas dulu. Baru optimasi kalau memang ada masalah.
Contract yang hemat gas tapi punya bug jauh lebih mahal daripada contract yang sedikit boros tapi aman. Prioritasnya: benar โ aman โ jelas โ hemat.
๐งฉ Tantangan โ Tingkatkan Celengan Jadi CelenganAmanโ
Ini menggabungkan hampir semua yang ada di unit ini. Ambil Celengan dari Unit 1 dan kembangkan sendiri.
Spesifikasiโ
| Yang ditambahkan | Pakai konsep |
|---|---|
Modifier hanyaPemilik | Modifier |
setBatasSetoran(uint256) yang hanya bisa dipanggil pemilik | Modifier + kontrol akses |
Ganti semua require berstring jadi custom error | Custom error |
struct Transaksi { uint256 jumlah; uint256 waktu; bool masukan; } dan simpan riwayat per pengguna | Struct + array |
riwayatSaya() yang mengembalikan array transaksi milik pemanggil | Array + view |
Pastikan menarik memakai checks-effects-interactions | Keamanan |
Kerangka awalโ
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// TODO 1: Deklarasikan custom error
// Contoh: error JumlahNol();
// Contoh: error MelebihiBatas(uint256 diminta, uint256 batas);
// Contoh: error BukanPemilik();
contract CelenganAman {
struct Transaksi {
uint256 jumlah;
uint256 waktu;
bool masukan; // true = setor, false = tarik
}
address public pemilik;
uint256 public batasSetoran;
mapping(address => uint256) private tabungan;
mapping(address => Transaksi[]) private riwayat;
constructor() {
pemilik = msg.sender;
batasSetoran = 100 ether;
}
// TODO 2: Buat modifier hanyaPemilik() yang revert BukanPemilik()
// TODO 3: setBatasSetoran โ hanya pemilik
// TODO 4: menabung โ payable
// - revert JumlahNol() kalau msg.value == 0
// - revert MelebihiBatas(...) kalau melebihi batasSetoran
// - tambah tabungan, catat Transaksi(masukan = true)
// TODO 5: menarik โ checks-effects-interactions
// - revert kalau jumlah > tabungan
// - kurangi tabungan DULU, catat Transaksi(masukan = false), baru kirim
// TODO 6: riwayatSaya() view returns (Transaksi[] memory)
function tabunganSaya() public view returns (uint256) {
return tabungan[msg.sender];
}
}
โ Checklist verifikasiโ
- Semua
requireberstring sudah jadi custom error -
setBatasSetorangagal kalau dipanggil non-pemilik -
menabungmenolak jumlah nol dan jumlah di atas batas -
menarikmengurangi saldo sebelum mengirim (urutan CEI) -
riwayatSayamengembalikan array transaksimu - Ter-compile tanpa error
๐งช Cara menguji hasilmuโ
- Deploy dari akun pertama.
setBatasSetoranke2000000000000000000(2 INJ) menabungdengan VALUE1Ether โ berhasil.menabungdengan VALUE3Ether โ GAGALMelebihiBatasโmenabungdengan VALUE0โ GAGALJumlahNolโ- Ganti akun kedua โ
setBatasSetoranโ GAGALBukanPemilikโ - Kembali ke akun pertama,
menarik500000000000000000โ berhasil - Klik
riwayatSayaโ Hasil: dua entri โ satu setoran, satu penarikan, dengan timestamp โ
Kerjakan satu TODO dalam satu waktu dan compile tiap selesai. Semua pola yang kamu butuhkan sudah muncul di contract-contract kecil di atas โ buka lagi, salin polanya, sesuaikan namanya. Masih macet? Bawa kode dan pesan errornya ke grup Telegram.
๐ฏ Rangkumanโ
- Modifier untuk aturan yang dipakai berulang; selalu validasi
address(0) - Pakai OpenZeppelin untuk kode standar โ kamu membuat token ERC-20 penuh dalam belasan baris
- Custom error lebih murah dari string
requiredan bisa membawa data untuk debugging - Jangan loop array tanpa batas โ pakai pola pull (pengguna mengklaim), bukan push
- Interface memungkinkan contract saling memanggil hanya dari bentuk function-nya โ dasar komposabilitas
- Reentrancy itu nyata โ kamu melihat sendiri 5 INJ dicuri, lalu ditutup hanya dengan memindah satu baris. Checks โ Effects โ Interactions
- Setiap function tulis tanpa pembatas terbuka untuk semua orang โ beri modifier
- Gas: menulis state itu mahal, event murah, memori sangat murah; lihat
transaction costdi Terminal - Benar dan aman dulu, hemat gas belakangan
โ Quick Checkโ
- Apa fungsi
_;di dalam modifier? - Kenapa mentransfer kepemilikan ke
address(0)berbahaya? - Dalam demo reentrancy tadi, baris mana yang dipindah untuk menutup celahnya, dan kenapa itu berhasil?
- Kenapa loop pada array yang tumbuh tanpa batas bisa mematikan function selamanya?
- Sebutkan satu keunggulan custom error dibanding pesan
requireberupa string. - Operasi apa yang paling mahal dalam hal gas?
Baris saldo[msg.sender] = 0 dipindah ke sebelum pengiriman token (call). Dengan begitu, saat contract penyerang memanggil tarik() lagi lewat receive(), saldonya sudah nol dan require(jumlah > 0) menolaknya โ penarikan berantai tidak pernah terjadi.
Lanjut: Unit 3 โ Deploy ke Injective EVM ๐