Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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-20ERC-721
단위amount (수량)tokenId (고유 번호)
교환성완전히 동일각각 고유
잔액balanceOf(address) → 수량balanceOf(address) → 보유 개수
소유권계정마다 잔액tokenId마다 소유자
대표 예USDC, UNICryptoPunks, 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의 핵심 개념:

  1. tokenId — 각 NFT의 고유 식별자. ERC-20의 amount와 달리 개별 자산을 가리킴
  2. ownerOf(tokenId) — 특정 NFT의 현재 소유자
  3. approve vs setApprovalForAll — 단일 토큰 승인 vs 전체 컬렉션 승인
  4. safeTransfer — 컨트랙트 수신자가 NFT를 처리할 수 있는지 확인
  5. tokenURI — NFT의 메타데이터(이미지, 속성)를 가리키는 URI

다음 챕터에서는 OpenZeppelin 라이브러리를 활용해 이를 더 쉽게 구현하는 방법을 배운다.