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를 명시적으로 전달해야 합니다.onlyOwnermodifier는 다른 사람이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으로 토큰을 안전하게 수신하는 컨트랙트를 작성하는 연습도 해야 합니다.