Chapter 13-2: ERC-721 (NFT)
NFT란 무엇인가
NFT(Non-Fungible Token, 대체 불가능 토큰)는 각각이 고유한 정체성을 가지는 토큰이다. ERC-20 토큰과 달리 모든 NFT는 서로 다르다.
대체 가능(Fungible) vs 대체 불가능(Non-Fungible):
대체 가능:
- 1만원권 지폐 A = 1만원권 지폐 B (동일한 가치, 교환 가능)
- 1 ETH = 다른 1 ETH
- 1 USDC = 다른 1 USDC
대체 불가능:
- 모나리자 원본 ≠ 다른 그림 (각각 고유)
- CryptoPunk #1 ≠ CryptoPunk #2 (같은 컬렉션이지만 다른 자산)
- 이벤트 티켓 A석 3열 5번 ≠ A석 3열 6번
ERC-20 vs ERC-721 비교:
| ERC-20 | ERC-721 | |
|---|---|---|
| 단위 | amount (수량) | tokenId (고유 번호) |
| 교환성 | 완전히 동일 | 각각 고유 |
| 잔액 | balanceOf(address) → 수량 | balanceOf(address) → 보유 개수 |
| 소유권 | 계정마다 잔액 | tokenId마다 소유자 |
| 대표 예 | USDC, UNI | CryptoPunks, BAYC |
ERC-721 인터페이스
interface IERC721 {
// ============ 이벤트 ============
/// @dev 토큰 전송 (mint: from=0, burn: to=0)
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
/// @dev 단일 토큰 승인
event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);
/// @dev 전체 컬렉션 승인/취소
event ApprovalForAll(address indexed owner, address indexed operator, bool approved);
// ============ 조회 함수 ============
/// @notice 주소가 보유한 NFT 개수
function balanceOf(address owner) external view returns (uint256 balance);
/// @notice 특정 tokenId의 소유자
function ownerOf(uint256 tokenId) external view returns (address owner);
/// @notice 특정 tokenId를 이동시킬 수 있도록 승인된 주소
function getApproved(uint256 tokenId) external view returns (address operator);
/// @notice operator가 owner의 모든 NFT를 관리할 수 있는지 여부
function isApprovedForAll(address owner, address operator) external view returns (bool);
// ============ 전송 함수 ============
/// @notice 안전한 전송 (수신자가 컨트랙트면 onERC721Received 확인)
function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external;
function safeTransferFrom(address from, address to, uint256 tokenId) external;
/// @notice 단순 전송 (수신자 확인 없음)
function transferFrom(address from, address to, uint256 tokenId) external;
// ============ 승인 함수 ============
/// @notice 특정 tokenId의 이동 권한을 approve에게 부여
function approve(address to, uint256 tokenId) external;
/// @notice operator에게 모든 NFT 관리 권한 부여/취소
function setApprovalForAll(address operator, bool approved) external;
}
ERC-20과 ERC-721 승인의 차이:
ERC-20: approve(spender, amount) — 금액 기준 승인
ERC-721: approve(spender, tokenId) — 특정 토큰 ID 기준 승인
ERC-721: setApprovalForAll(op, bool) — 전체 컬렉션 승인 (NFT 마켓플레이스용)
메타데이터와 tokenURI
NFT의 핵심 가치는 메타데이터다. 이미지, 속성, 설명 등의 정보가 담긴 JSON을 가리키는 URI다.
interface IERC721Metadata {
function name() external view returns (string memory);
function symbol() external view returns (string memory);
function tokenURI(uint256 tokenId) external view returns (string memory);
}
메타데이터 JSON 형식
{
"name": "CryptoPunk #1",
"description": "A unique CryptoPunk character",
"image": "ipfs://QmXxx.../1.png",
"attributes": [
{ "trait_type": "Background", "value": "Blue" },
{ "trait_type": "Type", "value": "Human" },
{ "trait_type": "Accessory", "value": "Sunglasses" }
]
}
tokenURI 패턴
// 패턴 1: IPFS 기반 (분산 스토리지 - 탈중앙화)
function tokenURI(uint256 tokenId) public view returns (string memory) {
require(_exists(tokenId), "Token does not exist");
return string(abi.encodePacked(
"ipfs://QmBaseHash/",
Strings.toString(tokenId),
".json"
));
}
// 결과: "ipfs://QmBaseHash/42.json"
// 패턴 2: 중앙화 서버 (업데이트 가능하지만 탈중앙화 아님)
string private _baseURI = "https://api.myproject.com/metadata/";
function tokenURI(uint256 tokenId) public view returns (string memory) {
return string(abi.encodePacked(_baseURI, Strings.toString(tokenId)));
}
// 결과: "https://api.myproject.com/metadata/42"
// 패턴 3: 온체인 메타데이터 (Base64 인코딩)
function tokenURI(uint256 tokenId) public view returns (string memory) {
string memory json = Base64.encode(bytes(string(abi.encodePacked(
'{"name":"Token #', Strings.toString(tokenId),
'","description":"On-chain NFT","image":"data:image/svg+xml;base64,',
_generateSVG(tokenId),
'"}'
))));
return string(abi.encodePacked("data:application/json;base64,", json));
}
간단한 NFT 컨트랙트 구현
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
/// @title SimpleNFT - 기본 ERC-721 구현 (교육용)
contract SimpleNFT {
using Strings for uint256;
// ============ 에러 ============
error NotOwnerOrApproved();
error InvalidTokenId(uint256 tokenId);
error NotERC721Receiver(address to);
error AlreadyMinted(uint256 tokenId);
error MaxSupplyReached();
error NotOwner();
error ZeroAddress();
// ============ 이벤트 ============
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);
event ApprovalForAll(address indexed owner, address indexed operator, bool approved);
// ============ 상태 변수 ============
string public name;
string public symbol;
string private _baseTokenURI;
address public owner;
uint256 public nextTokenId;
uint256 public maxSupply;
// tokenId => 소유자
mapping(uint256 => address) private _owners;
// 소유자 => 보유 개수
mapping(address => uint256) private _balances;
// tokenId => 승인된 주소
mapping(uint256 => address) private _tokenApprovals;
// 소유자 => operator => 전체 승인 여부
mapping(address => mapping(address => bool)) private _operatorApprovals;
// ============ 생성자 ============
constructor(
string memory _name,
string memory _symbol,
string memory baseURI,
uint256 _maxSupply
) {
name = _name;
symbol = _symbol;
_baseTokenURI = baseURI;
maxSupply = _maxSupply;
owner = msg.sender;
}
// ============ 조회 함수 ============
function balanceOf(address account) public view returns (uint256) {
if (account == address(0)) revert ZeroAddress();
return _balances[account];
}
function ownerOf(uint256 tokenId) public view returns (address) {
address tokenOwner = _owners[tokenId];
if (tokenOwner == address(0)) revert InvalidTokenId(tokenId);
return tokenOwner;
}
function tokenURI(uint256 tokenId) public view returns (string memory) {
if (_owners[tokenId] == address(0)) revert InvalidTokenId(tokenId);
return string(abi.encodePacked(_baseTokenURI, tokenId.toString(), ".json"));
}
function getApproved(uint256 tokenId) public view returns (address) {
if (_owners[tokenId] == address(0)) revert InvalidTokenId(tokenId);
return _tokenApprovals[tokenId];
}
function isApprovedForAll(address tokenOwner, address operator) public view returns (bool) {
return _operatorApprovals[tokenOwner][operator];
}
// ============ 승인 함수 ============
function approve(address to, uint256 tokenId) public {
address tokenOwner = ownerOf(tokenId);
if (msg.sender != tokenOwner && !isApprovedForAll(tokenOwner, msg.sender)) {
revert NotOwnerOrApproved();
}
_tokenApprovals[tokenId] = to;
emit Approval(tokenOwner, to, tokenId);
}
function setApprovalForAll(address operator, bool approved) public {
if (operator == address(0)) revert ZeroAddress();
_operatorApprovals[msg.sender][operator] = approved;
emit ApprovalForAll(msg.sender, operator, approved);
}
// ============ 전송 함수 ============
function transferFrom(address from, address to, uint256 tokenId) public {
if (!_isApprovedOrOwner(msg.sender, tokenId)) revert NotOwnerOrApproved();
_transfer(from, to, tokenId);
}
function safeTransferFrom(address from, address to, uint256 tokenId) public {
safeTransferFrom(from, to, tokenId, "");
}
function safeTransferFrom(
address from,
address to,
uint256 tokenId,
bytes memory data
) public {
if (!_isApprovedOrOwner(msg.sender, tokenId)) revert NotOwnerOrApproved();
_safeTransfer(from, to, tokenId, data);
}
// ============ 민팅 (소유자만) ============
function mint(address to) external returns (uint256 tokenId) {
if (msg.sender != owner) revert NotOwner();
if (nextTokenId >= maxSupply) revert MaxSupplyReached();
if (to == address(0)) revert ZeroAddress();
tokenId = nextTokenId;
nextTokenId++;
_mint(to, tokenId);
}
/// @notice 여러 NFT를 한 번에 민팅
function batchMint(address to, uint256 quantity) external {
if (msg.sender != owner) revert NotOwner();
if (nextTokenId + quantity > maxSupply) revert MaxSupplyReached();
if (to == address(0)) revert ZeroAddress();
for (uint256 i = 0; i < quantity; i++) {
_mint(to, nextTokenId);
nextTokenId++;
}
}
// ============ 내부 함수 ============
function _mint(address to, uint256 tokenId) internal {
_balances[to] += 1;
_owners[tokenId] = to;
emit Transfer(address(0), to, tokenId);
}
function _burn(uint256 tokenId) internal {
address tokenOwner = ownerOf(tokenId);
delete _tokenApprovals[tokenId];
_balances[tokenOwner] -= 1;
delete _owners[tokenId];
emit Transfer(tokenOwner, address(0), tokenId);
}
function _transfer(address from, address to, uint256 tokenId) internal {
if (ownerOf(tokenId) != from) revert NotOwnerOrApproved();
if (to == address(0)) revert ZeroAddress();
delete _tokenApprovals[tokenId];
_balances[from] -= 1;
_balances[to] += 1;
_owners[tokenId] = to;
emit Transfer(from, to, tokenId);
}
function _safeTransfer(
address from,
address to,
uint256 tokenId,
bytes memory data
) internal {
_transfer(from, to, tokenId);
// 수신자가 컨트랙트라면 ERC721Receiver 인터페이스 확인
if (to.code.length > 0) {
bytes4 retval = IERC721Receiver(to).onERC721Received(
msg.sender, from, tokenId, data
);
if (retval != IERC721Receiver.onERC721Received.selector) {
revert NotERC721Receiver(to);
}
}
}
function _isApprovedOrOwner(address spender, uint256 tokenId) internal view returns (bool) {
address tokenOwner = ownerOf(tokenId);
return (
spender == tokenOwner ||
isApprovedForAll(tokenOwner, spender) ||
getApproved(tokenId) == spender
);
}
function _exists(uint256 tokenId) internal view returns (bool) {
return _owners[tokenId] != address(0);
}
}
/// @notice 컨트랙트가 NFT를 안전하게 받을 수 있음을 나타내는 인터페이스
interface IERC721Receiver {
function onERC721Received(
address operator,
address from,
uint256 tokenId,
bytes calldata data
) external returns (bytes4);
}
safeTransferFrom의 중요성
// 일반 transfer: 컨트랙트 주소로 보내면 NFT가 영원히 잠길 수 있음
token.transferFrom(alice, contractAddress, tokenId);
// contractAddress가 NFT를 처리할 코드가 없다면 영원히 locked!
// safeTransfer: 컨트랙트가 NFT를 받을 수 있는지 확인 후 전송
token.safeTransferFrom(alice, contractAddress, tokenId);
// 컨트랙트가 onERC721Received를 구현하지 않으면 revert
NFT를 받는 컨트랙트 구현 예:
contract NFTVault is IERC721Receiver {
mapping(uint256 => address) public depositor;
function onERC721Received(
address operator,
address from,
uint256 tokenId,
bytes calldata data
) external override returns (bytes4) {
depositor[tokenId] = from;
// 반드시 이 selector를 반환해야 함
return IERC721Receiver.onERC721Received.selector;
}
}
Foundry 테스트
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test} from "forge-std/Test.sol";
import {SimpleNFT} from "../src/SimpleNFT.sol";
contract SimpleNFTTest is Test {
SimpleNFT public nft;
address public owner = makeAddr("owner");
address public alice = makeAddr("alice");
address public bob = makeAddr("bob");
function setUp() public {
vm.prank(owner);
nft = new SimpleNFT(
"SimpleNFT", "SNFT",
"ipfs://QmBase/",
100 // maxSupply
);
}
function test_Mint() public {
vm.prank(owner);
uint256 tokenId = nft.mint(alice);
assertEq(tokenId, 0);
assertEq(nft.ownerOf(0), alice);
assertEq(nft.balanceOf(alice), 1);
assertEq(nft.nextTokenId(), 1);
}
function test_TokenURI() public {
vm.prank(owner);
nft.mint(alice);
assertEq(nft.tokenURI(0), "ipfs://QmBase/0.json");
}
function test_Transfer() public {
vm.prank(owner);
nft.mint(alice);
vm.prank(alice);
nft.transferFrom(alice, bob, 0);
assertEq(nft.ownerOf(0), bob);
assertEq(nft.balanceOf(alice), 0);
assertEq(nft.balanceOf(bob), 1);
}
function test_ApproveAndTransfer() public {
vm.prank(owner);
nft.mint(alice);
// alice가 bob에게 토큰 0 이동 권한 부여
vm.prank(alice);
nft.approve(bob, 0);
assertEq(nft.getApproved(0), bob);
// bob이 alice 대신 전송
vm.prank(bob);
nft.transferFrom(alice, bob, 0);
assertEq(nft.ownerOf(0), bob);
}
function test_SetApprovalForAll() public {
vm.prank(owner);
nft.batchMint(alice, 3);
// alice가 bob에게 모든 NFT 관리 권한 부여
vm.prank(alice);
nft.setApprovalForAll(bob, true);
assertTrue(nft.isApprovedForAll(alice, bob));
// bob이 alice의 NFT #1을 자신에게 전송
vm.prank(bob);
nft.transferFrom(alice, bob, 1);
assertEq(nft.ownerOf(1), bob);
}
function test_RevertTransferFromNonOwner() public {
vm.prank(owner);
nft.mint(alice);
vm.expectRevert(SimpleNFT.NotOwnerOrApproved.selector);
vm.prank(bob);
nft.transferFrom(alice, bob, 0);
}
function test_MaxSupply() public {
vm.startPrank(owner);
nft.batchMint(alice, 100); // maxSupply 채우기
vm.expectRevert(SimpleNFT.MaxSupplyReached.selector);
nft.mint(alice);
vm.stopPrank();
}
}
정리
ERC-721의 핵심 개념:
- tokenId — 각 NFT의 고유 식별자. ERC-20의 amount와 달리 개별 자산을 가리킴
- ownerOf(tokenId) — 특정 NFT의 현재 소유자
- approve vs setApprovalForAll — 단일 토큰 승인 vs 전체 컬렉션 승인
- safeTransfer — 컨트랙트 수신자가 NFT를 처리할 수 있는지 확인
- tokenURI — NFT의 메타데이터(이미지, 속성)를 가리키는 URI
다음 챕터에서는 OpenZeppelin 라이브러리를 활용해 이를 더 쉽게 구현하는 방법을 배운다.