ERC-20: SolidityとFoundryで最初のトークンを作成する
1. 概要
ERC-20はEthereum上で最も一般的なトークン標準です。USDC、DAI、LINKなど他の数千のトークンもこれに従っています。この記事では、標準がどのように機能するかを理解するために最小限の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);
}
このインターフェースは2つのグループに分けることができます:
- 直接転送:
transferはトークンを呼び出し元(msg.sender)からtoに転送します。 - 許可(Allowance):
approveはspenderがあなたの最大valueのトークンを費やすことを許可します。その後、spenderはtransferFromを呼び出して引き出します。これはDEX取引所があなたがスワップするときにあなたのウォレットからトークンを「引き出す」ことができるメカニズムです。
3つの関数name()、symbol()、およびdecimals()はメタデータ拡張に属します。理論上は必須ではありませんが、ほぼすべてのトークンが持っています。
3. Foundryで環境を準備する
この記事ではFoundryを使用します。contractとテストの両方を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/
プロジェクト構造は、contract用の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[from][msg.sender] = allowed - amount;
_transfer(from, to, amount);
return true;
}
function _transfer(address from, address to, uint256 amount) internal {
require(to != address(0), "ゼロアドレス");
require(balanceOf[from] >= amount, "残高が不足しています");
balanceOf[from] -= amount;
balanceOf[to] += amount;
emit Transfer(from, to, amount);
}
}
注目すべき点:
public状態変数は自動的にgetterを生成します。mapping(...) public balanceOfを宣言すると、balanceOf(address)関数が生成され、インターフェース内のシグネチャと正確に一致します。allowanceも同様です。decimals = 18。 ブロックチェーンに小数点はないため、トークンはすべての残高を整数として保存します。ウォレットが「1.5 CAFE」を表示する場合、contract内の実際の値は1.5 * 10^18です。- Constructorは
address(0)からのイベントTransferを発行します。 これは「トークンが作成された」(ミント)することを示す慣例です。ブロックエクスプローラはこのイベントに基づいてトランザクション履歴を表示します。 transferFromは転送前に許可を差し引きます。 これを行わない場合、spenderは無限に引き出すことができます。- SafeMathは不要です。 Solidity 0.8から、
-=操作はアンダーフロー時に自動的にrevertします。ただし、明確なメッセージ付きのrequireはデバッグを容易にします。
5. Foundryでテストを書く
Foundryのテストは、Testを継承するcontractです。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("許可が低すぎます"));
token.transferFrom(alice, bob, 1);
}
}
各部分の説明:
setUp()は各テストの前に実行されます。テストcontractはトークンをデプロイする側なので、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は他の人がmintを呼び出すのを防ぎます。
OpenZeppelin v5はrevert文字列の代わりにカスタムエラーを使用します。したがって、テストもエラーセレクタに基づいてチェックする必要があります。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. 一般的なエラーと注意事項
注意: productionのためにERC-20を自分で作成しないでください。セクション4のバージョンはユーザー学習用のみです。実際のトークンの場合、OpenZeppelinを継承し、独自のロジック部分のみを追加してください。
decimalsを誤る。transfer(to, 100)を呼び出すと、わずか100 * 10^-18トークンのみが転送され、ほぼ0に相当します。常に10 ** decimals()を乗じてください。逆に、USDCは18ではなく6 decimalsを使用するため、他の人のトークンを操作するときに1e18をハードコーディングしないでください。approveのレース条件。 allowanceを100から50に変更するとします。悪意のあるspenderはmempoolを監視し、新しいコマンドが確認される前に100を引き出してから、さらに50を引き出すことができます。安全な方法は、先にallowanceを0に設定するか、permit(ERC-2612)をサポートするトークンを使用することです。- 非標準トークン。 USDTなどの古いトークンは
transferからboolを返しません。contractが外部トークンを呼び出す場合、transferを直接呼び出す代わりにSafeERC20ライブラリを使用します(using SafeERC20 for IERC20;を使用してからsafeTransferを呼び出します)。 - contractに送信されたトークンが引っかかる。 ERC-20には受取人に通知するメカニズムがありません。誰かが引き出し関数のないcontractにトークンを送信する場合、そのトークンは永久にロックされます。
- Fee-on-transferおよびrebasingトークン。 受け取る金額が送信される金額より少ない、または残高が時間とともに自動的に変更されるトークンがあります。contractが任意のトークンを受け取る場合、
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を使用してトークンを安全に受け取るcontractの作成も練習すべきです。