ERC-20: Solidity와 Foundry로 첫 번째 토큰 만들기

1. 개요

ERC-20은 Ethereum에서 가장 널리 사용되는 토큰 표준입니다. USDC, DAI, LINK를 비롯한 수천 개의 토큰이 모두 이를 따르고 있습니다. 이 글에서는 ERC-20 표준이 어떻게 작동하는지 이해하기 위해 최소한의 ERC-20 토큰을 직접 작성한 후 Foundry로 테스트를 작성할 것입니다. 그다음 프로덕션 환경을 위해 OpenZeppelin 구현으로 전환하고 토큰을 로컬 블록체인에 배포합니다.

2. ERC-20이란?

ERC-20은 인터페이스입니다. 즉, 모든 토큰이 갖춰야 하는 함수와 이벤트의 집합입니다. 이 공통 인터페이스 덕분에 지갑, 거래소, DeFi 컨트랙트는 토큰의 내부 구현을 알 필요 없이 모든 토큰과 동일한 방식으로 상호작용할 수 있습니다.

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

interface IERC20 {
    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);

    function totalSupply() external view returns (uint256);
    function balanceOf(address account) external view returns (uint256);
    function transfer(address to, uint256 value) external returns (bool);
    function allowance(address owner, address spender) external view returns (uint256);
    function approve(address spender, uint256 value) external returns (bool);
    function transferFrom(address from, address to, uint256 value) external returns (bool);
}

이 인터페이스는 두 가지 그룹으로 나눌 수 있습니다:

  • 직접 전송: transfer는 호출자(msg.sender)에서 to로 토큰을 전송합니다.
  • 승인: approve는 spender가 최대 value만큼의 토큰을 사용할 수 있도록 허가합니다. 그 다음 spender는 transferFrom을 호출해서 토큰을 출금합니다. 이 메커니즘 덕분에 DEX 거래소가 스왑할 때 지갑에서 토큰을 “끌어올” 수 있습니다.

name(), symbol(), decimals() 세 함수는 메타데이터 확장 부분에 속합니다. 이론상 필수는 아니지만, 거의 모든 토큰이 이를 포함하고 있습니다.

3. Foundry로 환경 준비

이 글에서는 Foundry를 사용합니다. 컨트랙트와 테스트 모두 Solidity로 작성하고 테스트는 매우 빠르게 실행됩니다. Foundry를 설치한 후 프로젝트를 생성합니다:

forge init cafe-token
cd cafe-token
forge install OpenZeppelin/openzeppelin-contracts

forge install 명령어는 OpenZeppelin 라이브러리를 lib/ 디렉터리에 다운로드합니다. 더 짧은 import를 사용하기 위해 remapping을 선언해야 합니다. 루트 디렉터리의 remappings.txt 파일에 다음 줄을 추가합니다:

@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/

프로젝트 구조는 src/ (컨트랙트), test/ (테스트), script/ (배포 스크립트)로 구성됩니다. forge init이 생성한 Counter 샘플 파일은 삭제할 수 있습니다.

4. 최소한의 ERC-20 직접 작성

이 섹션의 목표는 ERC-20 표준을 이해하는 것이지, 실제로 사용하기 위한 것이 아닙니다. src/SimpleToken.sol 파일을 생성합니다:

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

contract SimpleToken {
    string public name = "Cafe Token";
    string public symbol = "CAFE";
    uint8 public constant decimals = 18;
    uint256 public totalSupply;

    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

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

    constructor(uint256 initialSupply) {
        totalSupply = initialSupply;
        balanceOf[msg.sender] = initialSupply;
        emit Transfer(address(0), msg.sender, initialSupply);
    }

    function transfer(address to, uint256 amount) external returns (bool) {
        _transfer(msg.sender, to, amount);
        return true;
    }

    function approve(address spender, uint256 amount) external returns (bool) {
        allowance[msg.sender][spender] = amount;
        emit Approval(msg.sender, spender, amount);
        return true;
    }

    function transferFrom(address from, address to, uint256 amount) external returns (bool) {
        uint256 allowed = allowance[from][msg.sender];
        require(allowed >= amount, "allowance too low");
        allowance[from][msg.sender] = allowed - amount;
        _transfer(from, to, amount);
        return true;
    }

    function _transfer(address from, address to, uint256 amount) internal {
        require(to != address(0), "zero address");
        require(balanceOf[from] >= amount, "balance too low");
        balanceOf[from] -= amount;
        balanceOf[to] += amount;
        emit Transfer(from, to, amount);
    }
}

주목할 점:

  • public 상태 변수는 자동으로 getter를 생성합니다. mapping(...) public balanceOf를 선언하면 인터페이스의 서명과 정확히 일치하는 balanceOf(address) 함수가 생성됩니다. allowance도 마찬가지입니다.
  • decimals = 18. 블록체인에는 소수점이 없으므로 토큰은 모든 잔액을 정수로 저장합니다. 지갑이 “1.5 CAFE”를 표시할 때, 컨트랙트의 실제 값은 1.5 * 10^18입니다.
  • 생성자가 address(0)에서 Transfer 이벤트를 발생시킵니다. 이는 “토큰이 생성되었다”(mint)는 의미입니다. 블록 탐색기들은 이 이벤트를 바탕으로 거래 내역을 표시합니다.
  • transferFrom은 전송 전에 allowance를 차감합니다. 그렇지 않으면 spender가 무한정 인출할 수 있습니다.
  • SafeMath가 필요하지 않습니다. Solidity 0.8부터는 -= 연산이 underflow 시 자동으로 revert합니다. 그럼에도 불구하고 명확한 메시지가 있는 require는 디버깅을 더 쉽게 합니다.

5. Foundry로 테스트 작성

Foundry의 테스트는 Test를 상속하는 컨트랙트입니다. test로 시작하는 각 함수는 하나의 테스트 케이스입니다. test/SimpleToken.t.sol 파일을 생성합니다:

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

import {Test} from "forge-std/Test.sol";
import {SimpleToken} from "../src/SimpleToken.sol";

contract SimpleTokenTest is Test {
    event Transfer(address indexed from, address indexed to, uint256 value);

    SimpleToken token;
    address alice = makeAddr("alice");
    address bob = makeAddr("bob");

    function setUp() public {
        token = new SimpleToken(1_000 ether);
    }

    function test_transfer_movesBalance() public {
        token.transfer(alice, 100 ether);

        assertEq(token.balanceOf(alice), 100 ether);
        assertEq(token.balanceOf(address(this)), 900 ether);
    }

    function test_transfer_emitsEvent() public {
        vm.expectEmit(true, true, false, true);
        emit Transfer(address(this), alice, 1 ether);

        token.transfer(alice, 1 ether);
    }

    function test_transferFrom_requiresAllowance() public {
        token.transfer(alice, 100 ether);

        vm.prank(alice);
        token.approve(bob, 30 ether);

        vm.prank(bob);
        token.transferFrom(alice, bob, 30 ether);

        assertEq(token.balanceOf(bob), 30 ether);
        assertEq(token.allowance(alice, bob), 0);

        vm.prank(bob);
        vm.expectRevert(bytes("allowance too low"));
        token.transferFrom(alice, bob, 1);
    }
}

각 부분 설명:

  • setUp()은 각 테스트 전에 실행됩니다. 테스트 컨트랙트가 토큰을 배포하는 주체이므로 address(this)가 전체 1000개 토큰을 보유합니다.
  • 1_000 ether는 1000 * 10^18의 줄임 표기일 뿐입니다. 여기서 ether 키워드는 단순히 1e18을 곱하는 것이며, 실제 ETH와는 관계가 없습니다.
  • makeAddr("alice")는 라벨이 있는 주소를 생성하므로 테스트 실패 시 로그가 더 읽기 쉬워집니다.
  • vm.prank(alice)는 다음 호출의 msg.sender를 alice로 만듭니다.
  • vm.expectEmit(true, true, false, true)는 바로 다음에 발생하는 이벤트의 topic 1 (from), topic 2 (to), 그리고 데이터 부분 (value)을 확인합니다.
  • vm.expectRevert는 다음 호출이 지정된 정확한 메시지로 revert되어야 함을 단언합니다.

테스트 실행:

forge test -vv
Ran 3 tests for test/SimpleToken.t.sol:SimpleTokenTest
[PASS] test_transferFrom_requiresAllowance() (gas: 79120)
[PASS] test_transfer_emitsEvent() (gas: 41871)
[PASS] test_transfer_movesBalance() (gas: 43552)
Suite result: ok. 3 passed; 0 failed; 0 skipped

컴파일러 버전에 따라 가스 값은 약간 다를 수 있습니다.

6. 실제 토큰을 위해 OpenZeppelin 사용

위의 자체 작성 버전은 작동하지만 감사를 받지 않았습니다. 실제 토큰의 경우 OpenZeppelin의 ERC20을 상속해야 합니다. 이 라이브러리는 수십억 달러의 가치에 대해 검증되었습니다. src/CafeToken.sol 파일을 생성합니다:

// 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 CafeToken is ERC20, Ownable {
    constructor(address initialOwner)
        ERC20("Cafe Token", "CAFE")
        Ownable(initialOwner)
    {
        _mint(initialOwner, 1_000_000 * 10 ** decimals());
    }

    function mint(address to, uint256 amount) external onlyOwner {
        _mint(to, amount);
    }
}

단 수십 줄의 코드로 완전한 토큰을 갖추었습니다:

  • ERC20("Cafe Token", "CAFE")는 name과 symbol을 설정합니다. 기본적으로 decimals()는 18을 반환합니다.
  • _mint는 totalSupply를 증가시키고, 받는 사람의 잔액에 추가하며, address(0)에서 Transfer 이벤트를 발생시킵니다.
  • Ownable(initialOwner) (OpenZeppelin v5)은 owner를 명시적으로 전달해야 합니다. onlyOwner modifier는 다른 사람이 mint를 호출하는 것을 막습니다.

OpenZeppelin v5는 revert 문자열 대신 custom error를 사용합니다. 따라서 테스트도 error selector를 확인해야 합니다. test/CafeToken.t.sol 파일을 생성합니다:

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

import {Test} from "forge-std/Test.sol";
import {CafeToken} from "../src/CafeToken.sol";
import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

contract CafeTokenTest is Test {
    CafeToken token;
    address owner = makeAddr("owner");
    address alice = makeAddr("alice");

    function setUp() public {
        token = new CafeToken(owner);
    }

    function test_initialSupply_goesToOwner() public view {
        assertEq(token.totalSupply(), 1_000_000e18);
        assertEq(token.balanceOf(owner), 1_000_000e18);
    }

    function test_transfer_revertsWhenBalanceTooLow() public {
        vm.prank(alice);
        vm.expectRevert(
            abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, alice, 0, 1)
        );
        token.transfer(owner, 1);
    }

    function test_mint_onlyOwner() public {
        vm.prank(alice);
        vm.expectRevert(
            abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)
        );
        token.mint(alice, 1e18);
    }
}

transfer가 revert될 때, ERC20InsufficientBalance(sender, balance, needed)는 정확히 누가 부족한지, 얼마나 부족한지를 알려줍니다. 이 정보는 일반적인 문자열보다 훨씬 더 유용합니다.

7. 로컬 네트워크에 배포 및 상호작용

Foundry에는 anvil이 포함되어 있으며, 이는 미리 ETH를 공급받은 10개의 계정을 갖춘 로컬 Ethereum 노드입니다. 별도의 터미널을 열고 실행합니다:

anvil

다른 터미널에서 anvil의 첫 번째 계정을 사용하여 토큰을 배포합니다. 이 개인 키는 공개 키이며 로컬 네트워크에만 사용됩니다:

export PK=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
export OWNER=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266

forge create src/CafeToken.sol:CafeToken \
  --rpc-url http://127.0.0.1:8545 \
  --private-key $PK \
  --broadcast \
  --constructor-args $OWNER
Deployer: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
Transaction hash: 0x...

cast를 사용하여 잔액을 읽고 토큰을 전송합니다:

export TOKEN=0x5FbDB2315678afecb367f032d93F642f64180aa3

cast call $TOKEN "balanceOf(address)(uint256)" $OWNER --rpc-url http://127.0.0.1:8545

cast send $TOKEN "transfer(address,uint256)" \
  0x70997970C51812dc3A010C7d01b50e0d17dc79C8 50ether \
  --private-key $PK --rpc-url http://127.0.0.1:8545
1000000000000000000000000 [1e24]

cast call은 데이터만 읽으므로 가스를 소비하지 않습니다. cast send는 anvil에 실제 트랜잭션을 생성합니다. 50ether 인수는 cast에 의해 자동으로 50 * 10^18으로 변환됩니다.

8. 흔한 오류와 주의사항

주의: 프로덕션을 위해 ERC-20을 직접 작성하지 마세요. 섹션 4의 버전은 학습용일 뿐입니다. 실제 토큰의 경우 OpenZeppelin을 상속하고 자신만의 로직만 추가하세요.

  • decimals 실수. transfer(to, 100)을 호출하면 100 * 10^-18 토큰만 전송되어 거의 0에 가깝습니다. 항상 10 ** decimals()를 곱하세요. 반대로 USDC는 18이 아닌 6 decimals를 사용하므로, 다른 사람의 토큰으로 작업할 때 1e18을 hard-code하지 마세요.
  • approve의 race condition. allowance를 100에서 50으로 변경한다고 가정합니다. 악의적인 spender는 mempool을 모니터링하고, 새 명령이 확인되기 전에 100을 인출한 다음 추가로 50을 인출할 수 있습니다. 안전한 방법은 먼저 allowance를 0으로 설정하거나 permit를 지원하는 토큰을 사용하는 것입니다 (ERC-2612).
  • 비표준 토큰. USDT같은 구형 토큰 중 일부는 transfer에서 bool을 반환하지 않습니다. 계약이 외부 토큰을 호출할 때, transfer를 직접 호출하는 대신 SafeERC20 라이브러리를 사용하세요 (using SafeERC20 for IERC20;를 사용한 후 safeTransfer 호출).
  • 컨트랙트로 전송된 토큰이 잠깁니다. ERC-20에는 수신자에게 알리는 메커니즘이 없습니다. 누군가 인출 함수가 없는 컨트랙트로 토큰을 보내면 해당 토큰은 영구적으로 잠깁니다.
  • 수수료 토큰과 rebasing 토큰. 수신 수량이 송신 수량보다 적거나 잔액이 시간에 따라 자동으로 변경되는 토큰들이 있습니다. 계약이 임의의 토큰을 수신하는 경우, amount 매개변수를 신뢰하는 대신 전송 전후의 잔액을 측정하세요.
  • 무제한 mint. onlyOwner인 mint 함수는 owner가 무한정 토큰을 발행할 수 있다는 의미입니다. 고정된 총 공급량을 원하면 이 함수를 제거하거나 한도를 설정하세요. 예를 들어 ERC20Capped를 사용하십시오.

9. 결론

이 글에서는 ERC-20 인터페이스를 이해했고 balanceOf, allowance, transfer, transferFrom이 어떻게 함께 작동하는지 명확히 보기 위해 최소한의 토큰을 직접 구현했습니다. 또한 vm.prank, vm.expectEmit, vm.expectRevert를 사용하여 Foundry로 테스트를 작성했습니다. 그 다음 OpenZeppelin 기반 CafeToken으로 전환하고, anvil에 배포하며, cast를 통해 상호작용했습니다.

다음 단계는 서명으로 approve할 수 있는 ERC20Permit, 토큰을 태울 수 있는 ERC20Burnable, 또는 총 공급량을 제한하는 ERC20Capped 같은 확장을 시도하는 것입니다. 또한 SafeERC20으로 토큰을 안전하게 수신하는 컨트랙트를 작성하는 연습도 해야 합니다.

solidityerc-20ethereumfoundrysmart-contract