ERC-721 adalah standar untuk NFT (Non-Fungible Token): token yang tiap unitnya unik, punya nomor sendiri (tokenId) dan pemilik sendiri, jadi gak bisa ditukar satu lawan satu seperti POIN. Raka membuat Kaos Edisi Kopi: 100 sertifikat bernomor untuk kaos edisi terbatas, simbol KOPI. Bayangin buku register sertifikat di toko: "No. 1 milik Dinda, No. 2 milik Yoga", plus satu baris alamat tempat deskripsi tiap nomor bisa dibaca. Buku itu gak menyimpan foto kaosnya; ia cuma mencatat nomor, pemilik, dan petunjuk ke deskripsinya.
🧪 Tebakan cepat: sertifikat #1 sudah dicetak untuk Dinda. Apa yang dikembalikan
tokenURI(1): gambar kaosnya, file JSON berisi deskripsinya, atau sebuah alamat (URL) teks? Tebak dulu, jawabannya ada di bagian "tokenURI dan metadata off-chain".
ERC-20 dan ERC-721
| ERC-20 (POIN) | ERC-721 (KOPI) | |
|---|---|---|
| Tiap unit | identik, bisa ditukar | unik, punya tokenId |
balanceOf(alamat) |
jumlah token (dengan decimals) | berapa NFT yang dimiliki |
| Pemilik | gak ada "pemilik token nomor sekian" | ownerOf(tokenId) |
| Memindahkan | transfer(to, value) |
safeTransferFrom(from, to, tokenId) |
| Pecahan | ya, decimals |
gak ada; 1 NFT ya 1 |
Event Transfer |
value di data |
tokenId ikut indexed: 4 topic, data kosong |
Tanda tangan event-nya sama, Transfer(address,address,uint256), jadi topic0-nya juga sama (0xddf252ad...). Bedanya di jumlah topic: ERC-721 menaruh tokenId sebagai topic ketiga.
Kaos Edisi Kopi dengan OpenZeppelin
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
contract KaosEdisiKopi is ERC721, Ownable {
uint256 public constant MAX_SUPPLY = 100;
uint256 public totalMinted;
error SoldOut();
constructor() ERC721("Kaos Edisi Kopi", "KOPI") Ownable(msg.sender) {}
function mint(address to) external onlyOwner returns (uint256) {
if (totalMinted == MAX_SUPPLY) revert SoldOut();
uint256 tokenId = ++totalMinted;
_safeMint(to, tokenId);
return tokenId;
}
function _baseURI() internal pure override returns (string memory) {
return "https://toko-kaos-raka.test/kopi/";
}
}
ERC721("Kaos Edisi Kopi", "KOPI"): nama dan simbol, dari OpenZeppelin 5.6.1. Di dalamnya adamapping(uint256 => address)daritokenIdke pemilik.++totalMintedmenaikkan dulu baru dipakai, jadi nomor pertama 1 dan terakhir 100._safeMint(to, tokenId)mencetak token baru dan memancarkanTransferdari alamat nol._mintmelakukan hal yang sama tanpa pengecekan penerima.onlyOwner: cuma Raka yang boleh mencetak._baseURIadalah awalan alamat metadata;tokenURI(id)= awalan + nomor.
Dikompilasi dengan solc 0.8.37 lalu di-deploy dari akun #0 setelah PoinRaka. Itu transaksi kedua akun #0, jadi alamatnya 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 (gas 1.991.736). supportsInterface(0x80ac58cd) menjawab true: begitu cara wallet dan marketplace mengecek "ini ERC-721".
Mint: #1 untuk Dinda, #2 untuk Yoga
Raka memanggil mint(Dinda) lalu mint(Yoga). Log mint pertama, persis dari receipt-nya:
topics: 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef Transfer(address,address,uint256)
0x0000000000000000000000000000000000000000000000000000000000000000 from: alamat nol (mint)
0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8 to: Dinda
0x0000000000000000000000000000000000000000000000000000000000000001 tokenId: 1
data: 0x
| Bacaan | Hasil |
|---|---|
ownerOf(1) |
Dinda 0x7099...79C8 |
ownerOf(2) |
Yoga 0x3C44...93BC |
balanceOf(Dinda) |
1 |
totalMinted() |
2 |
ownerOf(3) |
revert ERC721NonexistentToken(3) |
Jadi NFT baru dibuat dengan _mint(to, tokenId) atau _safeMint(to, tokenId), bukan dengan transferFrom dari alamat nol atau dengan mengisi URI.
safeTransferFrom dan onERC721Received
Dinda memindahkan sertifikat #1 ke akun keduanya (akun test #3, 0x90F79bf6EB2c4f870365E785982E1f101E93b906):
await dinda.writeContract({ address: KOPI, abi, functionName: 'safeTransferFrom', args: [DINDA, DINDA_2, 1n] })
// Transfer(Dinda, Dinda#2, 1); ownerOf(1) sekarang Dinda#2
Yang boleh memindahkan cuma pemilik atau yang ia izinkan. Yoga mencoba memindahkan #1 milik Dinda: ERC721InsufficientApproval(0x3C44...93BC, 1).
Kenapa safe? Kalau penerimanya contract, safeTransferFrom bertanya dulu: contract itu harus punya fungsi onERC721Received yang mengembalikan selector-nya, 0x150b7a02. Kalau gak, transfernya dibatalkan. Yoga salah memasukkan alamat PoinRaka (contract yang gak mengenal NFT):
Error: ERC721InvalidReceiver(address receiver)
(0x5FbDB2315678afecb367f032d93F642f64180aa3)
Dengan transferFrom biasa, transfer yang sama berhasil: ownerOf(2) jadi alamat PoinRaka, dan karena PoinRaka gak punya fungsi untuk memindahkan NFT, sertifikat #2 terkunci di sana selamanya. Karena itu pakai safeTransferFrom.
tokenURI dan metadata off-chain
await publicClient.readContract({ address: KOPI, abi, functionName: 'tokenURI', args: [1n] })
// 'https://toko-kaos-raka.test/kopi/1'
tokenURI(1) cuma mengembalikan teks alamat. Itu jawaban tebakannya. Contract-nya bahkan gak menyimpan teks itu per token: ia menyambung awalan dari _baseURI dengan nomornya. Gambar dan deskripsinya ada di luar blockchain (off-chain), dalam file JSON yang disajikan server toko. Formatnya mengikuti skema metadata ERC-721 (name, description, image), dan attributes adalah kebiasaan marketplace. Contoh isinya (domain .test cuma contoh, gak bisa dibuka):
{
"name": "Kaos Edisi Kopi #1",
"description": "Sertifikat bernomor Kaos Sablon Kopi edisi terbatas Toko Kaos Raka. Satu dari 100.",
"image": "https://toko-kaos-raka.test/kopi/1.png",
"attributes": [
{ "trait_type": "Ukuran", "value": "L" },
{ "trait_type": "Nomor", "value": 1, "display_type": "number" }
]
}
Jujur saja: NFT-nya gak berisi gambar. Yang dijamin blockchain cuma "#1 milik alamat ini" dan teks alamatnya. Kalau server toko mati atau isinya diganti, gambarnya ikut hilang atau berubah. IPFS (lesson berikutnya) membuat alamatnya terikat ke isi file, sehingga isinya gak bisa diganti diam-diam.
Batas 100: MAX_SUPPLY
Batasnya dicek di contract, bukan di website, karena siapa pun bisa memanggil contract tanpa lewat website. Di chain lokal, Raka mencetak sampai #100, lalu mencoba sekali lagi:
The contract function "mint" reverted.
Error: SoldOut()
totalMinted berhenti di 100, dan gak ada cara mencetak #101 tanpa deploy contract baru.
⚠️ Jebakan yang sering ketemu
- Mengira NFT menyimpan gambarnya. On-chain cuma ada pemilik dan alamat metadata. Simpan metadata di tempat yang bertahan lama, misalnya IPFS.
transferFromke contract. Kalau contract penerima gak bisa memindahkan NFT, token terkunci selamanya. PakaisafeTransferFrom.- Batas supply di frontend saja. Contract bisa dipanggil langsung; cek
MAX_SUPPLYdi contract. - tokenId mulai dari 0 tanpa sengaja.
totalMinted++memberi nomor 0 dulu;++totalMintedmemberi 1. Putuskan dan tulis di dokumentasi.
TL;DR: ERC-721 adalah standar NFT: tiap token punya tokenId dan pemilik sendiri (ownerOf), balanceOf menghitung jumlah NFT, dan gak ada decimals. Kaos Edisi Kopi (KOPI, OpenZeppelin 5.6.1) mencetak #1 sampai #100 lewat mint yang onlyOwner, memakai _safeMint, dan menolak #101 dengan SoldOut(). Event Transfer sama tanda tangannya dengan ERC-20, tapi tokenId jadi topic ketiga. safeTransferFrom mengecek onERC721Received (0x150b7a02) di contract penerima dan revert ERC721InvalidReceiver kalau gak ada; transferFrom biasa bisa mengunci NFT di contract. tokenURI(1) = 'https://toko-kaos-raka.test/kopi/1': cuma petunjuk ke file JSON off-chain; NFT-nya gak berisi gambar.