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-1: ERC-20 토큰 구현

ERC-20 인터페이스 전체 설명

ERC-20은 이더리움에서 가장 많이 사용되는 토큰 표준이다. 6개의 필수 함수와 2개의 이벤트로 구성된다.

interface IERC20 {
    // ============ 조회 함수 ============

    /// @notice 토큰 총 발행량
    function totalSupply() external view returns (uint256);

    /// @notice 특정 주소의 잔액
    function balanceOf(address account) external view returns (uint256);

    /// @notice spender가 owner 대신 사용할 수 있는 허용량
    function allowance(address owner, address spender) external view returns (uint256);

    // ============ 전송 함수 ============

    /// @notice 호출자에서 to로 amount만큼 전송
    function transfer(address to, uint256 amount) external returns (bool);

    /// @notice spender가 from에서 to로 amount만큼 전송 (사전 승인 필요)
    function transferFrom(address from, address to, uint256 amount) external returns (bool);

    // ============ 승인 함수 ============

    /// @notice spender가 호출자 대신 amount만큼 사용하도록 승인
    function approve(address spender, uint256 amount) external returns (bool);

    // ============ 이벤트 ============

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);
}

approve + transferFrom 2단계 패턴

ERC-20에서 가장 중요하고 자주 오해받는 패턴이다. transfer 하나로 충분하지 않을까?

문제: 스마트 컨트랙트와의 상호작용

Alice가 DEX(Uniswap 같은 탈중앙화 거래소)에서 토큰을 스왑하려 한다고 가정하자.

Alice -> DEX.swap(token, 1000) 호출
DEX가 Alice의 토큰을 가져가야 함

문제는 DEX가 Alice의 서명 없이 Alice의 토큰을 가져갈 수 없다는 점이다. EVM에서 모든 트랜잭션은 발신자가 서명해야 하고, msg.sender는 현재 호출자다.

// DEX 내부에서 이런 코드를 쓸 수 없음
function swap(address token, uint256 amount) external {
    // 이 코드에서 msg.sender는 DEX 컨트랙트
    // Alice의 토큰을 가져오려면 Alice가 서명한 트랜잭션이 필요
    IERC20(token).transfer(dex, amount); // 오류! msg.sender가 DEX니까 DEX 잔액을 씀
}

해결: 2단계 approve + transferFrom

1단계: Alice가 DEX에게 허용량 승인
Alice -> token.approve(dex, 1000) 트랜잭션

2단계: DEX가 허용량 내에서 토큰 가져오기
Alice -> dex.swap() 트랜잭션
         └-> 내부에서 token.transferFrom(alice, dex, 1000) 호출
             (alice가 승인했으므로 가능)

Node.js 비유: OAuth 2.0의 토큰 위임과 비슷하다.

1단계: 사용자가 앱에 Google 계정 접근 권한 승인 (authorize)
2단계: 앱이 허용된 범위 내에서 사용자 대신 Google API 호출 (access)

실제 사용 예시

// 1단계: Alice가 approve 실행 (Alice의 지갑에서)
token.approve(uniswapRouter, 1000 * 1e18);

// 2단계: Alice가 swap 실행 (같은 트랜잭션 또는 나중에)
// Uniswap Router 내부:
// token.transferFrom(alice, uniswapPool, 1000 * 1e18);
// ethers.js 코드
// 1단계: approve
const approveTx = await tokenContract.approve(
    uniswapRouterAddress,
    ethers.parseUnits("1000", 18)
);
await approveTx.wait();

// 2단계: swap (Router가 내부적으로 transferFrom 호출)
const swapTx = await routerContract.swapExactTokensForETH(
    ethers.parseUnits("1000", 18),
    minEthOutput,
    [tokenAddress, wethAddress],
    aliceAddress,
    deadline
);
await swapTx.wait();

ERC-20 직접 구현 (OpenZeppelin 없이)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

/// @title ERC-20 토큰 완전 구현 (교육용)
contract ERC20 {
    // ============ 에러 ============
    error ERC20InsufficientBalance(address sender, uint256 balance, uint256 needed);
    error ERC20InvalidReceiver(address receiver);
    error ERC20InvalidSender(address sender);
    error ERC20InsufficientAllowance(address spender, uint256 allowance, uint256 needed);
    error ERC20InvalidApprover(address approver);
    error ERC20InvalidSpender(address spender);

    // ============ 이벤트 ============
    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);

    // ============ 상태 변수 ============
    string private _name;
    string private _symbol;
    uint8 private _decimals;

    uint256 private _totalSupply;
    mapping(address => uint256) private _balances;
    mapping(address => mapping(address => uint256)) private _allowances;

    // ============ 생성자 ============
    constructor(string memory name_, string memory symbol_) {
        _name = name_;
        _symbol = symbol_;
        _decimals = 18; // 이더리움 표준: 18자리 소수점
    }

    // ============ 메타데이터 (ERC-20 선택 사항이지만 사실상 필수) ============

    function name() public view returns (string memory) {
        return _name;
    }

    function symbol() public view returns (string memory) {
        return _symbol;
    }

    /// @notice 소수점 자릿수. 거의 항상 18
    /// 1 토큰 = 1 * 10^18 최소 단위
    function decimals() public view returns (uint8) {
        return _decimals;
    }

    // ============ ERC-20 필수 함수 ============

    function totalSupply() public view returns (uint256) {
        return _totalSupply;
    }

    function balanceOf(address account) public view returns (uint256) {
        return _balances[account];
    }

    function allowance(address owner, address spender) public view returns (uint256) {
        return _allowances[owner][spender];
    }

    /// @notice 호출자에서 to로 amount 전송
    function transfer(address to, uint256 amount) public returns (bool) {
        _transfer(msg.sender, to, amount);
        return true;
    }

    /// @notice 호출자가 spender에게 amount 사용 권한 부여
    function approve(address spender, uint256 amount) public returns (bool) {
        _approve(msg.sender, spender, amount);
        return true;
    }

    /// @notice from에서 to로 amount 전송 (사전 승인된 호출자만 가능)
    function transferFrom(address from, address to, uint256 amount) public returns (bool) {
        _spendAllowance(from, msg.sender, amount);
        _transfer(from, to, amount);
        return true;
    }

    // ============ 편의 함수 ============

    /// @notice 현재 허용량에 amount를 추가
    function increaseAllowance(address spender, uint256 addedValue) public returns (bool) {
        _approve(msg.sender, spender, _allowances[msg.sender][spender] + addedValue);
        return true;
    }

    /// @notice 현재 허용량에서 amount를 차감
    function decreaseAllowance(address spender, uint256 subtractedValue) public returns (bool) {
        uint256 currentAllowance = _allowances[msg.sender][spender];
        if (currentAllowance < subtractedValue) {
            revert ERC20InsufficientAllowance(spender, currentAllowance, subtractedValue);
        }
        unchecked {
            _approve(msg.sender, spender, currentAllowance - subtractedValue);
        }
        return true;
    }

    // ============ 내부 함수 ============

    /// @dev 실제 전송 로직
    function _transfer(address from, address to, uint256 amount) internal {
        if (from == address(0)) revert ERC20InvalidSender(address(0));
        if (to == address(0)) revert ERC20InvalidReceiver(address(0));

        uint256 fromBalance = _balances[from];
        if (fromBalance < amount) {
            revert ERC20InsufficientBalance(from, fromBalance, amount);
        }

        // unchecked: 위에서 fromBalance >= amount를 확인했으므로 언더플로 불가
        unchecked {
            _balances[from] = fromBalance - amount;
            _balances[to] += amount;
        }

        emit Transfer(from, to, amount);
    }

    /// @dev 토큰 발행 (supply 증가)
    function _mint(address to, uint256 amount) internal {
        if (to == address(0)) revert ERC20InvalidReceiver(address(0));

        _totalSupply += amount;
        unchecked {
            _balances[to] += amount;
        }

        emit Transfer(address(0), to, amount);
    }

    /// @dev 토큰 소각 (supply 감소)
    function _burn(address from, uint256 amount) internal {
        if (from == address(0)) revert ERC20InvalidSender(address(0));

        uint256 fromBalance = _balances[from];
        if (fromBalance < amount) {
            revert ERC20InsufficientBalance(from, fromBalance, amount);
        }

        unchecked {
            _balances[from] = fromBalance - amount;
            _totalSupply -= amount;
        }

        emit Transfer(from, address(0), amount);
    }

    /// @dev 허용량 설정
    function _approve(address owner, address spender, uint256 amount) internal {
        if (owner == address(0)) revert ERC20InvalidApprover(address(0));
        if (spender == address(0)) revert ERC20InvalidSpender(address(0));

        _allowances[owner][spender] = amount;
        emit Approval(owner, spender, amount);
    }

    /// @dev 허용량 소비 (transferFrom에서 사용)
    function _spendAllowance(address owner, address spender, uint256 amount) internal {
        uint256 currentAllowance = allowance(owner, spender);
        // type(uint256).max는 "무제한 허용"으로 취급 (차감하지 않음)
        if (currentAllowance != type(uint256).max) {
            if (currentAllowance < amount) {
                revert ERC20InsufficientAllowance(spender, currentAllowance, amount);
            }
            unchecked {
                _approve(owner, spender, currentAllowance - amount);
            }
        }
    }
}

decimals의 의미

1 ETH = 10^18 wei
1 USDC = 10^6 (USDC는 decimals=6)
1 대부분의 ERC-20 = 10^18 (decimals=18)

코드에서:
uint256 oneToken = 1 * 10**18;  // 또는 1e18
uint256 halfToken = 5 * 10**17; // 또는 0.5e18

사용자에게 보이는 값 = 실제 저장값 / 10^decimals

실제 토큰 컨트랙트 구현

위의 ERC-20을 상속해서 실제 사용할 토큰을 만든다:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "./ERC20.sol";

/// @title MyToken - 실제 사용 가능한 ERC-20 토큰
contract MyToken is ERC20 {
    address public owner;

    event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);

    modifier onlyOwner() {
        require(msg.sender == owner, "MyToken: not owner");
        _;
    }

    constructor(
        string memory name,
        string memory symbol,
        uint256 initialSupply
    ) ERC20(name, symbol) {
        owner = msg.sender;
        // 초기 공급량을 배포자에게 지급
        _mint(msg.sender, initialSupply);
    }

    /// @notice 새 토큰 발행 (소유자만)
    function mint(address to, uint256 amount) external onlyOwner {
        _mint(to, amount);
    }

    /// @notice 내 토큰 소각
    function burn(uint256 amount) external {
        _burn(msg.sender, amount);
    }

    function transferOwnership(address newOwner) external onlyOwner {
        require(newOwner != address(0), "MyToken: zero address");
        emit OwnershipTransferred(owner, newOwner);
        owner = newOwner;
    }
}

테스트 코드

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {Test, console} from "forge-std/Test.sol";
import {MyToken} from "../src/MyToken.sol";

contract MyTokenTest is Test {
    MyToken public token;

    address public owner = makeAddr("owner");
    address public alice = makeAddr("alice");
    address public bob = makeAddr("bob");
    address public spender = makeAddr("spender");

    uint256 public constant INITIAL_SUPPLY = 1_000_000 * 1e18;

    function setUp() public {
        vm.prank(owner);
        token = new MyToken("MyToken", "MTK", INITIAL_SUPPLY);
    }

    // ============ 기본 정보 테스트 ============

    function test_Metadata() public view {
        assertEq(token.name(), "MyToken");
        assertEq(token.symbol(), "MTK");
        assertEq(token.decimals(), 18);
        assertEq(token.totalSupply(), INITIAL_SUPPLY);
        assertEq(token.owner(), owner);
    }

    function test_InitialBalance() public view {
        assertEq(token.balanceOf(owner), INITIAL_SUPPLY);
        assertEq(token.balanceOf(alice), 0);
    }

    // ============ transfer 테스트 ============

    function test_Transfer() public {
        uint256 amount = 100 * 1e18;

        vm.prank(owner);
        bool success = token.transfer(alice, amount);

        assertTrue(success);
        assertEq(token.balanceOf(owner), INITIAL_SUPPLY - amount);
        assertEq(token.balanceOf(alice), amount);
        assertEq(token.totalSupply(), INITIAL_SUPPLY); // 총량 불변
    }

    function test_Transfer_EmitsEvent() public {
        uint256 amount = 100 * 1e18;

        vm.expectEmit(true, true, false, true);
        emit MyToken.Transfer(owner, alice, amount);

        vm.prank(owner);
        token.transfer(alice, amount);
    }

    function test_Transfer_RevertInsufficientBalance() public {
        vm.expectRevert(
            abi.encodeWithSelector(
                MyToken.ERC20InsufficientBalance.selector,
                alice, 0, 1
            )
        );
        vm.prank(alice);
        token.transfer(bob, 1);
    }

    function test_Transfer_RevertZeroAddress() public {
        vm.expectRevert(
            abi.encodeWithSelector(
                MyToken.ERC20InvalidReceiver.selector,
                address(0)
            )
        );
        vm.prank(owner);
        token.transfer(address(0), 1);
    }

    // ============ approve + transferFrom 테스트 ============

    function test_Approve() public {
        uint256 allowanceAmount = 500 * 1e18;

        vm.prank(owner);
        bool success = token.approve(spender, allowanceAmount);

        assertTrue(success);
        assertEq(token.allowance(owner, spender), allowanceAmount);
    }

    function test_TransferFrom() public {
        uint256 allowanceAmount = 500 * 1e18;
        uint256 transferAmount = 200 * 1e18;

        // 1단계: owner가 spender에게 허용
        vm.prank(owner);
        token.approve(spender, allowanceAmount);

        // 2단계: spender가 owner 대신 alice에게 전송
        vm.prank(spender);
        bool success = token.transferFrom(owner, alice, transferAmount);

        assertTrue(success);
        assertEq(token.balanceOf(owner), INITIAL_SUPPLY - transferAmount);
        assertEq(token.balanceOf(alice), transferAmount);
        // 허용량이 차감됨
        assertEq(token.allowance(owner, spender), allowanceAmount - transferAmount);
    }

    function test_TransferFrom_RevertInsufficientAllowance() public {
        uint256 allowanceAmount = 100 * 1e18;
        uint256 transferAmount = 200 * 1e18;

        vm.prank(owner);
        token.approve(spender, allowanceAmount);

        vm.expectRevert(
            abi.encodeWithSelector(
                MyToken.ERC20InsufficientAllowance.selector,
                spender, allowanceAmount, transferAmount
            )
        );
        vm.prank(spender);
        token.transferFrom(owner, alice, transferAmount);
    }

    function test_MaxAllowance_NotDecremented() public {
        // type(uint256).max 허용량은 차감하지 않음 (무제한)
        vm.prank(owner);
        token.approve(spender, type(uint256).max);

        vm.prank(spender);
        token.transferFrom(owner, alice, 100 * 1e18);

        // 여전히 max
        assertEq(token.allowance(owner, spender), type(uint256).max);
    }

    // ============ mint / burn 테스트 ============

    function test_Mint() public {
        uint256 mintAmount = 500 * 1e18;

        vm.prank(owner);
        token.mint(alice, mintAmount);

        assertEq(token.balanceOf(alice), mintAmount);
        assertEq(token.totalSupply(), INITIAL_SUPPLY + mintAmount);
    }

    function test_Mint_OnlyOwner() public {
        vm.expectRevert("MyToken: not owner");
        vm.prank(alice);
        token.mint(alice, 1);
    }

    function test_Burn() public {
        uint256 burnAmount = 100 * 1e18;

        vm.prank(owner);
        token.burn(burnAmount);

        assertEq(token.balanceOf(owner), INITIAL_SUPPLY - burnAmount);
        assertEq(token.totalSupply(), INITIAL_SUPPLY - burnAmount);
    }

    // ============ 퍼즈 테스트 ============

    function testFuzz_Transfer(address to, uint256 amount) public {
        vm.assume(to != address(0) && to != owner);
        amount = bound(amount, 1, INITIAL_SUPPLY);

        vm.prank(owner);
        token.transfer(to, amount);

        assertEq(token.balanceOf(to), amount);
        assertEq(token.totalSupply(), INITIAL_SUPPLY); // 불변식
    }

    function testFuzz_ApproveAndTransferFrom(
        uint256 approveAmount,
        uint256 transferAmount
    ) public {
        approveAmount = bound(approveAmount, 1, INITIAL_SUPPLY);
        transferAmount = bound(transferAmount, 1, approveAmount);

        vm.prank(owner);
        token.approve(spender, approveAmount);

        vm.prank(spender);
        token.transferFrom(owner, alice, transferAmount);

        assertEq(token.balanceOf(alice), transferAmount);
        assertEq(token.allowance(owner, spender), approveAmount - transferAmount);
    }
}

정리

ERC-20의 핵심 포인트:

  1. 6개의 표준 함수 — 모든 ERC-20 토큰이 동일한 인터페이스를 구현
  2. approve + transferFrom — 스마트 컨트랙트가 사용자 대신 토큰을 이동시키는 2단계 패턴
  3. decimals = 18 — 1 토큰은 10^18 최소 단위로 저장
  4. Transfer 이벤트 — address(0)에서 오면 mint, address(0)으로 가면 burn
  5. unchecked 블록 — 안전이 검증된 산술 연산에서 가스 절약

다음 챕터에서는 ERC-721(NFT)을 살펴본다.