ERC-20 adalah standar interface untuk token fungible di Ethereum: token yang tiap unitnya sama persis dan bisa saling ditukar, seperti uang atau POIN. Standar itu cuma daftar fungsi dan event yang wajib ada, dengan nama dan perilaku yang disepakati. Bayangin colokan listrik standar. Alat apa pun yang colokannya standar bisa dipakai di stopkontak mana pun, tanpa adaptor khusus. Wallet, exchange dan block explorer adalah stopkontaknya: begitu PoinRaka mengikuti ERC-20, semuanya langsung bisa menampilkan dan memindahkan POIN tanpa kode tambahan.
🧪 Tebakan cepat: Dinda pernah mengizinkan aplikasi TukarPoin memakai 100 POIN-nya, dan TukarPoin sudah memakai 30 (sisa izin 70). Dinda berubah pikiran dan mengirim
approve(TukarPoin, 20)untuk menurunkannya. Berapa POIN paling banyak yang bisa diambil TukarPoin mulai saat itu: 20, 70, atau 90? Tebak dulu, jawabannya ada di bagian "Approve race: bahaya mengubah izin".
Isi standar ERC-20
| Fungsi / event | Gunanya |
|---|---|
totalSupply() |
jumlah semua token yang beredar |
balanceOf(account) |
saldo sebuah alamat |
transfer(to, value) |
memindahkan token milik sendiri |
approve(spender, value) |
mengizinkan alamat lain memakai token kita sampai value |
allowance(owner, spender) |
sisa izin itu |
transferFrom(from, to, value) |
spender memindahkan token from, memotong izinnya |
event Transfer(from indexed, to indexed, value) |
dipancarkan tiap token berpindah, termasuk mint dan burn |
event Approval(owner indexed, spender indexed, value) |
dipancarkan tiap approve |
name(), symbol() dan decimals() opsional, tapi hampir semua token punya. PoinRaka lama sudah punya balanceOf, totalSupply dan tiga yang opsional, tapi belum punya transfer, approve, allowance dan transferFrom, jadi belum ERC-20.
PoinRaka versi ERC-20 dengan OpenZeppelin
Menulis ERC-20 sendiri gampang salah. OpenZeppelin Contracts adalah pustaka contract yang sudah diaudit dan dipakai luas; versi terbarunya di npm per Oktober 2026 adalah 5.6.1.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
contract PoinRaka is ERC20, Ownable {
event Earned(address indexed customer, uint256 amount);
event Redeemed(address indexed customer, uint256 amount);
constructor() ERC20("Poin Raka", "POIN") Ownable(msg.sender) {}
function earn(address customer, uint256 rupiah) external onlyOwner {
uint256 amount = poinFor(rupiah);
_mint(customer, amount);
emit Earned(customer, amount);
}
function redeem(uint256 amount) external {
_burn(msg.sender, amount);
emit Redeemed(msg.sender, amount);
}
function poinFor(uint256 rupiah) public pure returns (uint256) {
return (rupiah / 1000) * 10 ** 18;
}
}
npm install @openzeppelin/[email protected] [email protected]
npx solcjs --abi --bin --include-path node_modules/ --base-path . PoinRaka.sol -o build
is ERC20, Ownable: mewarisi semua fungsi standar dari OpenZeppelin.ERC20("Poin Raka", "POIN")mengisinamedansymbol;decimals()bawaannya 18.onlyOwnermenggantikanrequire(msg.sender == owner, ...). Yang lain ditolak dengan errorOwnableUnauthorizedAccount(address)._mintmembuat token baru dan memancarkanTransferdari alamat nol._burnmemusnahkannya denganTransferke alamat nol. Kalau saldo kurang,_burnrevert denganERC20InsufficientBalance(sender, balance, needed), error standar ERC-6093 yang menggantikanNotEnoughPoin.- Kompilasi sukses gak mencetak apa-apa;
build/PoinRaka_sol_PoinRaka.bindan.abimuncul.
Di chain lokal yang baru, deploy-nya lagi-lagi transaksi pertama akun #0, jadi alamatnya tetap 0x5FbDB2315678afecb367f032d93F642f64180aa3 (gas 1.229.456, hampir dua kali versi lama karena fiturnya lebih banyak). Raka lalu mencatat empat belanja yang sama: Dinda 160 POIN, Yoga 115 POIN, totalSupply 275.
transfer: memindahkan milik sendiri
Dinda memberi Yoga 20 POIN:
await dinda.writeContract({ address: POIN, abi, functionName: 'transfer', args: [YOGA, parseUnits('20', 18)] })
| Dinda | Yoga | totalSupply |
|
|---|---|---|---|
| sebelum | 160 | 115 | 275 |
| sesudah (block 6) | 140 | 135 | 275 |
Receipt-nya berisi satu log Transfer(Dinda, Yoga, 20 POIN) dengan topic0 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef, keccak256 dari Transfer(address,address,uint256). Topic itu sama untuk semua token ERC-20 di dunia, jadi explorer bisa menampilkan riwayat token apa pun.
approve, allowance, transferFrom: izin untuk pihak lain
Token Dinda gak tersimpan di wallet-nya, tapi di mapping milik contract. Contract lain (atau alamat lain) gak bisa mengambilnya tanpa izin. Misalnya TukarPoin, aplikasi luar yang menukar POIN jadi voucher. Di chain lokal ia diperankan akun test #4 (0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65). Alurnya dua langkah:
- Dinda:
approve(TukarPoin, 100 POIN)→ eventApproval(Dinda, TukarPoin, 100),allowance(Dinda, TukarPoin)= 100. - TukarPoin:
transferFrom(Dinda, TukarPoin, 30 POIN)→ eventTransfer(Dinda, TukarPoin, 30), allowance tinggal 70, saldo Dinda 110.
Jadi approve diperlukan karena pemilik token harus memberi izin lebih dulu sebelum pihak lain boleh memindahkan tokennya. Kalau TukarPoin meminta lebih, contract menolak. Pesan asli viem:
Error: ERC20InsufficientAllowance(address spender, uint256 allowance, uint256 needed)
(0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65, 70000000000000000000, 80000000000000000000)
Di dalamnya cuma mapping bersarang. Ini inti logikanya kalau ditulis sendiri (dikompilasi dengan solc 0.8.37); OpenZeppelin melakukan hal yang sama dengan pengecekan dan error yang lebih rapi:
mapping(address => mapping(address => uint256)) public allowance;
function approve(address spender, uint256 value) external returns (bool) {
allowance[msg.sender][spender] = value;
emit Approval(msg.sender, spender, value);
return true;
}
function transferFrom(address from, address to, uint256 value) external returns (bool) {
uint256 allowed = allowance[from][msg.sender];
require(allowed >= value, "allowance kurang");
allowance[from][msg.sender] = allowed - value;
balanceOf[from] -= value;
balanceOf[to] += value;
emit Transfer(from, to, value);
return true;
}
decimals cuma untuk tampilan
Di dalam contract gak ada pecahan: saldo Dinda tersimpan sebagai 140000000000000000000. decimals() = 18 cuma memberi tahu wallet di mana koma harus ditaruh: formatUnits(140000000000000000000n, 18) = '140'. Angka mentah yang sama dengan decimals 6 akan tampil sebagai 140.000.000.000.000. Itu sebabnya USDC (6 decimals) dan POIN (18 decimals) harus dihitung dengan parseUnits/formatUnits sesuai decimals() masing-masing.
Approve race: bahaya mengubah izin
approve gak menambah atau mengurangi, tapi menimpa nilai izin. Kalau Dinda mengubah 70 jadi 20, TukarPoin yang curang bisa melihat transaksi itu di mempool dan menyalipnya dengan tip lebih tinggi. Ini terjadi sungguhan di node Hardhat (block ditambang manual):
| Urutan di block 9 | Dari | Tip | Akibat |
|---|---|---|---|
| 1 | TukarPoin: transferFrom(Dinda, TukarPoin, 70) |
2 gwei | izin lama 70 dipakai habis |
| 2 | Dinda: approve(TukarPoin, 20) |
1 gwei | izin baru 20 |
Lalu di block 10 TukarPoin mengambil 20 lagi. Sejak Dinda memutuskan menurunkan izin, TukarPoin mengambil 90 POIN, bukan 20. Itu jawaban tebakannya. Saldo Dinda tinggal 20.
Cara aman: approve cuma sejumlah yang memang mau dipakai, tepat sebelum dipakai; untuk mengubah izin, set ke 0 dulu, tunggu, cek allowance, baru set nilai baru; dan cabut izin yang gak dipakai lagi. OpenZeppelin 5 menghapus increaseAllowance dan decreaseAllowance, jadi pola ini yang dipakai.
⚠️ Jebakan yang sering ketemu
- Approve tanpa batas ke contract yang gak dikenal. Izin
type(uint256).maxgak pernah berkurang di OpenZeppelin. Kalau contract itu jahat atau dibobol, semua token kamu bisa diambil. - Lupa decimals.
transfer(yoga, 20)cuma 0,00000000000000002 POIN. PakaiparseUnits('20', 18). - Memanggil
decimals()dari fungsipure.decimals()di OpenZeppelin ituview, jadi solc menolak:TypeError: Function declared as pure, but this expression (potentially) reads from the environment or state and thus requires "view".Tulis10 ** 18langsung, atau ubah fungsinya jadiview. - Mengirim ke alamat yang salah. OpenZeppelin menolak alamat nol (
ERC20InvalidReceiver), tapi token yang dikirim ke contract yang gak bisa memindahkannya hilang selamanya.
TL;DR: ERC-20 adalah standar interface untuk token fungible: totalSupply, balanceOf, transfer, approve, allowance, transferFrom, plus event Transfer (topic0 0xddf252ad...) dan Approval. Pakai OpenZeppelin 5.6.1: contract PoinRaka is ERC20, Ownable, _mint cuma lewat onlyOwner, _burn untuk redeem, error standar seperti ERC20InsufficientBalance. transfer memindahkan milik sendiri; approve lalu transferFrom membiarkan pihak lain memindahkan token kita sebatas izin. decimals cuma untuk tampilan, saldo selalu bilangan bulat. approve menimpa nilai lama, jadi mengubah 70 ke 20 bisa disalip (TukarPoin dapat 90); approve secukupnya dan cabut yang gak dipakai.