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