ERC-20이란?
ERC는 Ethereum Request for Comments의 약자로, ERC-20은 이더리움 내에서 20번째로 제안된 요청입니다.
ERC-20는 토큰을 발행할 때 외부에서 접근하기 위한 인터페이스의 형식을 정의하기 위한 내용을 담고 있고, 현재까지도 가장 많이 활용되는 표준 인터페이스 중 하나입니다. (거래소에서 Ethereum을 전송할 때 ERC-20을 자주 볼 수 있음)
이더리움의 코인 Ether의 경우 지갑에 생성된 개인 키의 계정이 있고, 블록체인상의 계정에서 코인을 보관하고 있습니다.
하지만 ERC-20 토큰의 경우에는 토큰을 발행한 컨트랙트가 존재하고, 컨트랙트에 계정이 얼마를 가졌는지를 나타내는 방식으로 표현합니다. 따라서 실제 자산은 토큰 컨트랙트가 가지고 있다고 할 수 있습니다. 자산의 소유자는 컨트랙트에 있는 자산을 옮길 수 있는 "제어권"을 가지고 있는 셈입니다. 따라서 토큰 컨트랙트는 블록체인상에서 무결성을 보장받으므로 토큰을 관리하는 하나의 장부라고 할 수 있습니다.
오늘은 Solidity / Foundry를 활용해 ERC-20을 기반으로 토큰을 구현-배포하고 토큰을 주고받는 테스트까지 해보도록 하겠습니다.
프로젝트 생성
forge init my-token
을 통해 Foundry 프로젝트를 만들어주자.
토큰 구현
/src/ 디렉토리에 MyToken.sol 파일을 만들어 아래와 같이 작성합니다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MyToken is ERC20 {
constructor(uint256 initialSupply) ERC20("MyToken", "MTK"){
_mint(msg.sender, initialSupply);
}
}
ERC20("MyToken", "MTK")로 토큰의 이름과 심볼을 설정하였습니다.
토큰 배포
/script/ 디렉토리에 DeployToken.s.sol 파일을 만들어 아래와 같이 작성합니다.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.24;
import "forge-std/Script.sol";
import "../src/MyToken.sol";
contract DeployToken is Script {
function run() external {
vm.startBroadcast();
new MyToken(1_000_000 ether);
vm.stopBroadcast();
}
}
위에서 만든 MyToken을 불러와 초기 발행량을 100만개로 설정하여 발행합니다.
여기서 1_000_000 ether는 100만 개 단위의 토큰을 ether(10**18), 즉 소수점 18자리 기준으로 저장하겠다는 뜻입니다.
위와 같이 작성하고
forge script script/DeployToken.s.sol --rpc-url <테스트넷 URL> --private-key <배포자 키> --broadcast
이렇게 실행하면 배포할 수 있습니다.
여기서 <테스트넷 URL>은
https://www.infura.io/에서 Sepolia Testnet URL을 발급받을 수 있습니다.
<배포자 키>는 메타마스크의 개인 키를 입력하시면 됩니다.
[⠊] Compiling...
[⠢] Compiling 22 files with Solc 0.8.30
[⠆] Solc 0.8.30 finished in 2.12s
Compiler run successful!
Script ran successfully.
## Setting up 1 EVM.
==========================
Chain 11155111
Estimated gas price: 0.001007574 gwei
Estimated total gas used for script: 1224403
Estimated amount required: 0.000001233676628322 ETH
==========================
##### sepolia
✅ [Success] Hash: 0x360ad603bf34fe57321f9642ecf82b47ec077aeacc58566fd84075ea9906aa64
Contract Address: 0xd94F9141377012F40A58eC1657C3Ad1637C19dbe
Block: 8829929
Paid: 0.000000945210459081 ETH (941849 gas * 0.001003569 gwei)
✅ Sequence #1 on sepolia | Total Paid: 0.000000945210459081 ETH (941849 gas * avg 0.001003569 gwei)
==========================
ONCHAIN EXECUTION COMPLETE & SUCCESSFUL.
Transactions saved to: /Users/geunmin/Foundry/my-token/broadcast/DeployToken.s.sol/11155111/run-latest.json
Sensitive values saved to: /Users/geunmin/Foundry/my-token/cache/DeployToken.s.sol/11155111/run-latest.json
성공적으로 배포가 완료되면 아래와 같이 로그가 나오는데,
네트워크 : Chain 11155111 - Sepolia 테스트넷
트랜잭션 해시: 0x360ad603bf34fe57321f9642ecf82b47ec077aeacc58566fd84075ea9906aa64
컨트랙트 주소: 0xd94F9141377012F40A58eC1657C3Ad1637C19dbe
블록 번호: 8829929
가스 사용량: 941,849 gas
실제 지불 금액: 0.0000009452 ETH (초저가 가스비)
등의 정보를 알 수 있습니다.
https://sepolia.etherscan.io/address/0xd94F9141377012F40A58eC1657C3Ad1637C19dbe#code
Contract Address 0xd94F9141377012F40A58eC1657C3Ad1637C19dbe | Etherscan
The Contract Address 0xd94F9141377012F40A58eC1657C3Ad1637C19dbe page allows users to view the source code, transactions, balances, and analytics for the contract address. Users can also interact and make transactions to the contract directly on Etherscan.
sepolia.etherscan.io
을 확인하면 테스트넷에 배포한 MyToken을 이더스캔을 통해 확인할 수 있습니다.
https://sepolia.etherscan.io/tx/0x360ad603bf34fe57321f9642ecf82b47ec077aeacc58566fd84075ea9906aa64
Sepolia Transaction Hash: 0x360ad603bf... | Etherscan
Transfer 1 M MTK to 0x535A8BAa...911b4C337 | Success | Jul-24-2025 05:10:00 AM (UTC)
sepolia.etherscan.io
을 확인하면 100만개의 토큰이 발행되었음을 확인할 수 있습니다.
테스트
토큰 발행까지 완료했으면 이 토큰을 주고받을 수 있는지 테스트를 해봐야겠죠?
/test/ 디렉토리에 MyTokenTest.t.sol 파일을 만들어 아래와 같이 작성합니다.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import "../src/MyToken.sol";
contract MyTokenTest is Test {
MyToken public token;
address public alice;
address public bob;
function setUp() public {
alice = makeAddr("Alice");
bob = makeAddr("Bob");
// 배포 (테스트 로컬용)
token = new MyToken(1_000_000 ether);
// Alice에게 토큰 1000개 전송
token.transfer(alice, 1000 ether);
}
function testTransfer() public {
vm.prank(alice);
token.transfer(bob, 100 ether);
assertEq(token.balanceOf(bob), 100 ether);
}
function testApproveAndCheck() public{
vm.prank(alice);
token.approve(bob, 50 ether);
uint256 allowance = token.allowance(alice, bob);
assertEq(allowance, 50 ether);
}
}
여기서 vm.prank는 msg.sender를 임의로 지정할 수 있게 해주는 Foundry의 cheatcode 함수입니다.
vm.prank(alice)라고 작성하면 다음 한 번의 호출에서 msg.sender = alice가 됩니다.
이렇게 Alice가 Bob에게 100개의 토큰을 전송하는 상황, Alice가 bob에 approve한 상황등을 테스트해보았습니다.
❯ forge test -vvvv
[⠊] Compiling...
[⠑] Compiling 6 files with Solc 0.8.30
[⠘] Solc 0.8.30 finished in 1.65s
Compiler run successful!
Ran 2 tests for test/MyTokenTest.t.sol:MyTokenTest
[PASS] testApproveAndCheck() (gas: 41612)
Traces:
[41612] MyTokenTest::testApproveAndCheck()
├─ [0] VM::prank(Alice: [0xBf0b5A4099F0bf6c8bC4252eBeC548Bae95602Ea])
│ └─ ← [Return]
├─ [25296] MyToken::approve(Bob: [0x4dBa461cA9342F4A6Cf942aBd7eacf8AE259108C], 50000000000000000000 [5e19])
│ ├─ emit Approval(owner: Alice: [0xBf0b5A4099F0bf6c8bC4252eBeC548Bae95602Ea], spender: Bob: [0x4dBa461cA9342F4A6Cf942aBd7eacf8AE259108C], value: 50000000000000000000 [5e19])
│ └─ ← [Return] true
├─ [1223] MyToken::allowance(Alice: [0xBf0b5A4099F0bf6c8bC4252eBeC548Bae95602Ea], Bob: [0x4dBa461cA9342F4A6Cf942aBd7eacf8AE259108C]) [staticcall]
│ └─ ← [Return] 50000000000000000000 [5e19]
├─ [0] VM::assertEq(50000000000000000000 [5e19], 50000000000000000000 [5e19]) [staticcall]
│ └─ ← [Return]
└─ ← [Stop]
[PASS] testTransfer() (gas: 46105)
Traces:
[46105] MyTokenTest::testTransfer()
├─ [0] VM::prank(Alice: [0xBf0b5A4099F0bf6c8bC4252eBeC548Bae95602Ea])
│ └─ ← [Return]
├─ [30545] MyToken::transfer(Bob: [0x4dBa461cA9342F4A6Cf942aBd7eacf8AE259108C], 100000000000000000000 [1e20])
│ ├─ emit Transfer(from: Alice: [0xBf0b5A4099F0bf6c8bC4252eBeC548Bae95602Ea], to: Bob: [0x4dBa461cA9342F4A6Cf942aBd7eacf8AE259108C], value: 100000000000000000000 [1e20])
│ └─ ← [Return] true
├─ [850] MyToken::balanceOf(Bob: [0x4dBa461cA9342F4A6Cf942aBd7eacf8AE259108C]) [staticcall]
│ └─ ← [Return] 100000000000000000000 [1e20]
├─ [0] VM::assertEq(100000000000000000000 [1e20], 100000000000000000000 [1e20]) [staticcall]
│ └─ ← [Return]
└─ ← [Stop]
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 10.83ms (4.67ms CPU time)
Ran 1 test suite in 474.97ms (10.83ms CPU time): 2 tests passed, 0 failed, 0 skipped (2 total tests)
여기서 transfer는 직접 토큰을 보내는 것이고, approve는 다른 사람에게 토큰을 전송할 수 있는 권한을 준 것입니다.
approve의 개념에 대해 이해하기 어려울 수 있는데, allice가 bob에게 50토큰을 주었을 때 50개의 토큰이 바로 전송된 것이 아닙니다.
bob이 토큰을 사용하고 싶을 때 transferFrom을 통해 토큰을 받아 사용할 수 있습니다.
보통 DEX에서 토큰을 자동으로 swap하고 싶을 때 approve를 사용합니다.
오늘은 이렇게 Foundry로 토큰을 구현, 발행하고 테스트까지 진행해보았습니다.