Skip to main content

๐Ÿง  Unit 2 โ€” Solidity Lanjutan

Goal Unit Ini

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
Prasyarat
  • โœ… Unit 1 selesai โ€” kamu sudah men-deploy dan menguji beberapa contract di Remix, dan paham mapping, require, event, dan payable
Format unit ini sama seperti Unit 1

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: cek require dulu, baru isi function berjalan.
  • public hanyaPemilik โ€” memasang modifier itu ke sebuah function. Sekarang setBatas dan gantiPemilik sama-sama terlindungi hanya dengan menambahkan satu kata.
Kenapa ada 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โ€‹

  1. Compile โ†’ Deploy dari akun pertama (akun ini jadi pemilik)

  2. Ketik 100 di setBatas โ†’ klik โ†’ cek batasMaksimal โ†’ Hasil: 100 โœ…

  3. Ganti ke akun kedua di dropdown ACCOUNT

  4. Ketik 999 di setBatas โ†’ klik

    โ†’ Hasil: GAGAL dengan "Bukan pemilik" โ€” satu modifier melindungi function tanpa menyalin require ke mana-mana โœ…

  5. Masih di akun kedua, cek batasMaksimal โ†’ Hasil: tetap 100 (transaksi tadi dibatalkan seluruhnya)

  6. Kembali ke akun pertama, coba gantiPemilik dengan 0x0000000000000000000000000000000000000000

    โ†’ 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:

  • is menandai 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โ€‹

  1. Compile. Di dropdown CONTRACT (di tab Deploy) akan muncul beberapa contract โ€” pilih Anak โ†’ Deploy
  2. Klik sapa โ†’ Hasil: Halo dari anak (versi yang menimpa) โœ…
  3. Klik sapaInduk โ†’ Hasil: Halo dari induk (versi asli lewat super) โœ…
Kenapa dropdown CONTRACT penting

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(...) dan onlyOwner โ€” keduanya diwarisi; kamu tidak menulisnya sendiri.
Jangan menulis ulang yang sudah teruji

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โ€‹

  1. Compile (butuh beberapa detik lebih lama โ€” Remix sedang mengunduh OpenZeppelin) โ†’ pilih contract TokenCLC โ†’ Deploy dari akun pertama

  2. Klik name โ†’ Hasil: CLC11 Token, klik symbol โ†’ Hasil: CLC11 โœ…

  3. Salin alamat akun pertama, tempel ke balanceOf โ†’ klik

    โ†’ Hasil: 1000000000000000000000000 (1 juta token, 18 desimal) โœ…

  4. Sekarang transfer. Isi transfer: to = alamat akun kedua, amount = 1000000000000000000000 (1000 token) โ†’ klik

  5. Cek balanceOf untuk akun kedua โ†’ Hasil: 1000000000000000000000 โœ…

  6. Ganti ke akun kedua, coba mint ke dirimu sendiri dengan jumlah berapa pun

    โ†’ Hasil: GAGAL โ€” mint dilindungi onlyOwner, dan akun kedua bukan owner โœ…

  7. Kembali ke akun pertama, mint 500 token ke siapa pun โ†’ berhasil โœ…

Kamu baru saja membuat token yang berfungsi penuh

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 seperti require yang gagal.
  • Bedanya: error ini membawa dua angka (diminta dan tersedia), jadi saat debugging kamu tahu persis berapa selisihnya.

๐Ÿงช Coba di Remixโ€‹

  1. Compile โ†’ Deploy

  2. Isi VALUE = 1 Ether โ†’ klik setor

  3. Cek tabungan dengan alamatmu โ†’ Hasil: 1000000000000000000 โœ…

  4. Coba tarik lebih banyak: ketik 5000000000000000000 (5 INJ) di menarik โ†’ klik

  5. Buka Terminal, klik transaksi yang gagal itu

    โ†’ Hasil: transaksi ter-revert dengan TabunganTidakCukup, dan membawa angka diminta: 5000000000000000000, tersedia: 1000000000000000000 โœ…

Inilah keunggulan yang tidak terlihat dari string

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, .length menghitung isinya.
  • peserta[i].poin += _jumlah โ€” mengakses satu field dari satu elemen array.
  • Mapping indeksPeserta dipakai supaya kita bisa menemukan posisi seseorang tanpa harus men-loop seluruh array.

๐Ÿงช Coba di Remixโ€‹

  1. Compile โ†’ Deploy dari akun pertama

  2. Ketik "Budi" di daftar โ†’ klik

  3. Ganti ke akun kedua โ†’ ketik "Ani" di daftar โ†’ klik

  4. Klik jumlahPeserta โ†’ Hasil: 2 โœ…

  5. Ketik 0 di peserta โ†’ Hasil: data Budi (nama, alamat akun pertama, poin 0, lulus false)

  6. Ketik 1 di peserta โ†’ Hasil: data Ani โœ…

  7. Masih sebagai akun kedua, ketik 50 di tambahPoin โ†’ klik โ†’ cek peserta indeks 1

    โ†’ Hasil: poin Ani = 50, poin Budi tetap 0 โœ…

Jangan pernah loop array yang bisa tumbuh tanpa batas

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 mendeklarasikan balanceOf, tanpa badan function.
  • IToken(token) โ€” memberi tahu Solidity: "perlakukan alamat ini sebagai sesuatu yang punya function balanceOf".
  • PembacaSaldo tidak tahu apa pun tentang isi TokenMini โ€” cukup tahu bentuk function-nya. Itulah inti komposabilitas.

๐Ÿงช Coba di Remixโ€‹

  1. Compile. Deploy TokenMini dulu โ†’ salin alamatnya (ikon copy di sebelah nama contract di Deployed Contracts)

  2. Di TokenMini, ketik 777 di cetakUntukSaya โ†’ klik

  3. Sekarang deploy PembacaSaldo (ganti pilihan di dropdown CONTRACT dulu)

  4. Di PembacaSaldo, isi cekSaldo: token = alamat TokenMini yang tadi kamu salin, siapa = alamat akun aktifmu โ†’ klik

    โ†’ Hasil: 777 โœ… โ€” satu contract berhasil membaca state contract lain, hanya berbekal interface

Kenapa ini besar

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โ€‹

  1. Compile. Deploy BankRentan dulu

  2. Kita isi dulu banknya seolah ada nasabah lain. Dengan akun kedua, isi VALUE = 5 Ether โ†’ klik setor di BankRentan

  3. Klik saldoBank โ†’ Hasil: 5000000000000000000 (5 INJ tersimpan)

  4. Sekarang deploy Penyerang, isi field constructor _bank = alamat BankRentan โ†’ Deploy dari akun pertama

  5. Di Penyerang, isi VALUE = 1 Ether โ†’ klik serang

  6. Kembali ke BankRentan, klik saldoBank

    โ†’ Hasil: 0 (atau mendekati nol) โ€” penyerang menyetor 1 INJ tapi keluar membawa 6 INJ. Dana nasabah lain lenyap โœ… (secara mengerikan)

Kamu baru saja melihat kenapa urutan itu segalanya

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โ€‹

  1. Ganti function tarik() di BankRentan dengan versi aman di atas โ†’ Compile ulang

  2. Deploy BankRentan yang baru, isi lagi 5 INJ dari akun kedua

  3. Deploy Penyerang baru dengan alamat bank yang baru โ†’ serang dengan VALUE 1 Ether

    โ†’ Hasil: serangan gagal / hanya menarik 1 INJ miliknya sendiri. Saat receive() memanggil tarik() lagi, saldonya sudah 0, jadi require(jumlah > 0) menolaknya โœ…

  4. Klik saldoBank โ†’ Hasil: masih 5000000000000000000 โ€” dana nasabah lain aman โœ…

Satu baris yang dipindah = kerugian jutaan dolar dicegah

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");
}
Function tanpa pembatas = terbuka untuk semua

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.

OperasiBiaya 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;
Cara benar-benar melihat perbedaan gas di Remix

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:

Jangan optimasi terlalu dini

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 ditambahkanPakai konsep
Modifier hanyaPemilikModifier
setBatasSetoran(uint256) yang hanya bisa dipanggil pemilikModifier + kontrol akses
Ganti semua require berstring jadi custom errorCustom error
struct Transaksi { uint256 jumlah; uint256 waktu; bool masukan; } dan simpan riwayat per penggunaStruct + array
riwayatSaya() yang mengembalikan array transaksi milik pemanggilArray + view
Pastikan menarik memakai checks-effects-interactionsKeamanan

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 require berstring sudah jadi custom error
  • setBatasSetoran gagal kalau dipanggil non-pemilik
  • menabung menolak jumlah nol dan jumlah di atas batas
  • menarik mengurangi saldo sebelum mengirim (urutan CEI)
  • riwayatSaya mengembalikan array transaksimu
  • Ter-compile tanpa error

๐Ÿงช Cara menguji hasilmuโ€‹

  1. Deploy dari akun pertama. setBatasSetoran ke 2000000000000000000 (2 INJ)
  2. menabung dengan VALUE 1 Ether โ†’ berhasil. menabung dengan VALUE 3 Ether โ†’ GAGAL MelebihiBatas โœ…
  3. menabung dengan VALUE 0 โ†’ GAGAL JumlahNol โœ…
  4. Ganti akun kedua โ†’ setBatasSetoran โ†’ GAGAL BukanPemilik โœ…
  5. Kembali ke akun pertama, menarik 500000000000000000 โ†’ berhasil
  6. Klik riwayatSaya โ†’ Hasil: dua entri โ€” satu setoran, satu penarikan, dengan timestamp โœ…
Kalau macet

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โ€‹

Yang Harus Kamu Ingat
  • 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 require dan 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 cost di Terminal
  • Benar dan aman dulu, hemat gas belakangan

โœ… Quick Checkโ€‹

  1. Apa fungsi _; di dalam modifier?
  2. Kenapa mentransfer kepemilikan ke address(0) berbahaya?
  3. Dalam demo reentrancy tadi, baris mana yang dipindah untuk menutup celahnya, dan kenapa itu berhasil?
  4. Kenapa loop pada array yang tumbuh tanpa batas bisa mematikan function selamanya?
  5. Sebutkan satu keunggulan custom error dibanding pesan require berupa string.
  6. Operasi apa yang paling mahal dalam hal gas?
Jawaban #3

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 ๐Ÿ‘‰