はじめに
前回は、Mac M1環境でHardhatを使い、シンプルなCounterコントラクトをローカルにデプロイして、トランザクションがブロックに取り込まれる流れを確認しました↓
今回はその次のステップとして、独自のERC20トークンを作成し、あるアカウントから別のアカウントへ送金するところまでを扱います。
「トークンを作る」というと難しそうに聞こえますが、Ethereumの世界では ERC20 という共通規格が決まっているため、OpenZeppelin という実績あるライブラリを継承するだけで、安全なトークンを数行で作ることができます。
この記事では、
- ERC20トークンとは何を表しているのか
- OpenZeppelinを使うと何が省けるのか
- 送金するとブロックチェーン上で何が起きるのか
を、実際のコマンド出力を確認しながら説明します。
今回作るものの全体像
以下は、今回のトークン送金の流れをイメージにしたものです。
1. ERC20トークンとは何なのか
まず、そもそも「トークン」とは何かを整理します。
トークン(token)とは、ブロックチェーン上で発行・管理される「価値やモノを表すしるし(証票)」のことです。物理的なコインが存在するわけではなく、その実体はスマートコントラクトの中に記録された「誰が何個持っているか」という残高データです。
身近な例でいうと、次のようなものがトークンで表現できます。
- 通貨・ポイント(1ポイント = 1トークン)
- ゲーム内アイテムや会員権
- 株式や不動産などの資産の持ち分
重要なのは、トークンそのものはどこかに「置いてある」のではなく、コントラクトが管理する台帳(残高表)の数値として存在しているという点です。この記事の中で作るのも、まさにこの「残高表を持ったコントラクト」です。
そして、そのトークンの中でも代表的な種類が ERC20 です。
ERC20は、Ethereum上で「代替可能な(fungible)トークン」を扱うための共通規格です。代替可能とは、1枚1枚に区別がなく、同じ価値として交換できるという意味です。円やドルのような通貨、ポイント、社内通貨などがこれにあたります。
逆に、1点ものの絵画のように1つ1つが区別されるトークンは NFT / ERC721 と呼ばれ、別の規格になります。
規格が決めていること
ERC20は、トークンが最低限持つべき関数とイベントを定めています。
-
totalSupply()- 発行済みトークンの総量を返す
-
balanceOf(address)- 指定したアドレスの残高を返す
-
transfer(to, amount)- 自分の残高から相手へトークンを送る
-
approve(spender, amount)/transferFrom(from, to, amount)- 「代理で送金する権限」を第三者に与える仕組み
-
Transfer/Approvalイベント- 送金や承認が起きたことを、ブロックチェーン上のログとして記録する
なぜ規格が重要なのか
規格が統一されていることで、ウォレットや取引所は「中身が何のトークンか」を気にせず、同じ関数を呼ぶだけで残高表示や送金ができます。
つまりERC20とは、「トークンの共通インターフェース(約束事)」です。 この約束を守っている限り、次のような世界中のツールが、追加対応なしでそのトークンを扱えるようになります。
- ウォレット(MetaMask など)… 残高表示・送金
- 取引所(Uniswap などの分散型取引所、中央集権型取引所)… 上場・売買
- ブロックチェーンエクスプローラー(Etherscan など)… 取引履歴やホルダーの表示
- DeFiプロトコル(レンディング、ステーキングなど)… 担保や運用対象としての利用
これらはいずれも balanceOf や transfer といった決まった関数を呼ぶだけでよく、「中身が何のトークンか」を個別に考慮して実装する必要がありません。
ツールがERC20のABI(呼び出し規約)に準拠して作られていて、トークン側も同じABIを実装しているのでインターフェースが噛み合う、という事。
-
関数の「呼び出し方」が規格で固定されている
ERC20は balanceOf(address)や、transfer(address,uint256) のように、関数名と引数の型まで決めています。この「呼び出し方の仕様」を ABI(Application Binary Interface)と呼びます。 -
同じ呼び出し方 = 同じ ABI になる
規格に従って作られたトークンは、中身の実装が違っても、外から見た ABI が完全に一致します。ツールから見ると「どのERC20トークンも、同じボタンの付いた箱」に見えるわけです。 -
ツールは「箱の種類」ではなく「ボタン」に対して作られている
MetaMask や Etherscan は、あなたのMyTokenの存在を事前に知りません。それでも動くのは、「アドレスを入れたら balanceOf を呼ぶ」という処理がERC20のABIに準拠して書かれているからです。
トークンごとに個別対応する必要がない、というのはこの意味です。
2. OpenZeppelinを使うと何が省けるのか
ERC20の関数は、自分でゼロから実装することもできます。しかし残高を管理するマッピング、送金時の残高チェック、イベントの発火などを正しく書くのは手間がかかり、バグはそのまま資産の損失につながります。
そこで使うのが OpenZeppelin Contracts です。
何をセットアップするのか
- OpenZeppelin Contracts
- 監査済みで広く使われている、スマートコントラクトの標準ライブラリです。
- ERC20やアクセス制御などの「よくある実装」を安全な形で提供します。
インストールは npm で行います。
npm install @openzeppelin/contracts
何に使うのか
今回は、OpenZeppelinの ERC20 を 継承(extends) します。継承すると、balanceOf や transfer といった標準機能はすべてライブラリ側が実装済みなので、私たちは「トークンの名前」と「初期発行量」を決めるだけで済みます。
3. トークンのコントラクトを書く
作成したコントラクトは以下のとおりです。全文でも十数行に収まります。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
// OpenZeppelin の標準 ERC20 実装を継承する。
// balanceOf / transfer / approve / transferFrom / totalSupply、
// および Transfer / Approval イベントはすべて ERC20 側で実装済み。
import '@openzeppelin/contracts/token/ERC20/ERC20.sol';
contract MyToken is ERC20 {
// constructor でトークンの名前・シンボルを決め、
// デプロイしたアカウント(deployer)へ初期供給量をまとめて発行(mint)する。
// initialSupply は「最小単位」で受け取る(= 表示上の枚数 × 10^decimals)。
constructor(uint256 initialSupply) ERC20('MyToken', 'MTK') {
_mint(msg.sender, initialSupply);
}
}
読みどころを補足します。
-
is ERC20- OpenZeppelinのERC20を継承している宣言です。これだけで標準機能が手に入ります。
-
ERC20('MyToken', 'MTK')- トークンの正式名称(MyToken)とシンボル(MTK)を指定しています。シンボルはウォレットに表示される短い記号です。
-
_mint(msg.sender, initialSupply)- トークンを新規発行する内部関数です。
msg.sender(=デプロイした人)へ、初期供給量をまとめて渡します。
- トークンを新規発行する内部関数です。
🧠 decimals(小数点以下の桁数)について
ERC20は整数しか扱えません。そのため「小数」は decimals という桁数で表現します。標準では decimals = 18 で、これはEtherと同じ考え方です。
つまり「1000 MTK」を発行したいときは、内部的には 1000 × 10^18 という大きな整数を渡します。この変換は、後述のスクリプトで parseUnits が行ってくれます。
4. デプロイスクリプトで何をしているのか
デプロイ用のスクリプト(scripts/deploy-token.js)の要点です。
// 初期供給量: 1000 MTK(表示上)。ERC20 は内部を「最小単位」で扱うため、
// decimals(=18) を掛けた値を渡す。parseUnits がその変換を行う。
const initialSupply = ethers.utils.parseUnits('1000', 18);
const MyToken = await ethers.getContractFactory('MyToken');
const token = await MyToken.deploy(initialSupply);
await token.deployed();
-
parseUnits('1000', 18)- 「1000枚」を、ERC20が内部で扱う最小単位(1000 × 10^18)へ変換します。
-
getContractFactory('MyToken')- コンパイル済みのMyTokenから、デプロイ用の「工場」を作ります。
-
deploy(initialSupply)- 初期供給量を渡してデプロイします。この瞬間にコントラクトが誕生し、
_mintが実行されます。
- 初期供給量を渡してデプロイします。この瞬間にコントラクトが誕生し、
実行結果は次のとおりです。
$ npm run deploy:token
MyToken deployed to: 0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9
Deployer (initial holder): 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Name / Symbol: MyToken / MTK
Decimals: 18
Total supply: 1000.0 MTK
Transaction hash: 0x00f744d2269bb8bbd358e75c9dd8c16292ee31edb9704b75b0756aa7513b8d77
デプロイした人(Deployer)のアドレスに、1000 MTK が全量発行されました。この人を、以降 Alice と呼びます。
5. トークンを送金してみる
いよいよ本題です。Alice から Bob へ 10 MTK を送金します。
送金スクリプト(scripts/interact-token.js)の中心部分です。
// 先頭アカウントを Alice、2番目を Bob とする
const [alice, bob] = await ethers.getSigners();
const token = await ethers.getContractAt('MyToken', address);
// Alice -> Bob へ 10 MTK 送金
const amount = ethers.utils.parseUnits('10', 18);
const tx = await token.connect(alice).transfer(bob.address, amount);
const receipt = await tx.wait();
-
getSigners()- Hardhatがローカル用に用意している20個のテストアカウントを取得します。今回はその先頭2つをAlice・Bobとして使います。
-
token.connect(alice).transfer(bob.address, amount)- 「Aliceとして」トークンのtransfer関数を呼び出します。これがトランザクションになります。
-
tx.wait()- トランザクションがブロックに取り込まれるまで待ちます。
実行結果です。
$ npm run interact:token
Alice: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Bob: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8
---
[Before]
Alice: 1000.0 MTK
Bob: 0.0 MTK
---
[Transfer] 10 MTK Alice -> Bob
Transaction hash: 0x29be69865980e7b253a058a7096126c9b3fe33c65070465c5fa35a1c68b44cdf
Block number: 6
Gas used: 52210
Event: Transfer(from=0xf39...266, to=0x709...C8, value=10.0 MTK)
---
[After]
Alice: 990.0 MTK
Bob: 10.0 MTK
送金前は Alice:1000 / Bob:0 だった残高が、送金後は Alice:990 / Bob:10 に変わりました。狙いどおりの状態遷移です。
ここで重要なのは、トークンは「移動」しているのではなく、コントラクト内部の残高の数値が書き換わっているだけだという点です。Aliceの残高が10減り、Bobの残高が10増える。この加減算がブロックチェーン上で確定します。
6. Transferイベントを生データで確認する
送金結果を、Hardhatの出力だけでなく、生のJSON-RPCでも確かめてみます。ここが「本当にブロックチェーンで起きたこと」を裏付ける部分です。
トランザクションのレシートを取得します。
curl -s -X POST http://127.0.0.1:8545 \
-H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","id":1,"method":"eth_getTransactionReceipt",
"params":["0x29be69865980e7b253a058a7096126c9b3fe33c65070465c5fa35a1c68b44cdf"]}'
レシートには1件のログ(イベント)が含まれており、その先頭トピックは次の値でした。
0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
これは keccak256("Transfer(address,address,uint256)")、つまり Transferイベントの署名ハッシュ です。ERC20の送金では必ずこのイベントが発火し、ウォレットや取引所はこのログを監視することで「誰から誰へいくら送られたか」を検知しています。
つまりイベントログは、ブロックチェーンにおける「取引の証跡」です。 アプリケーションはこのログを読むことで、残高を毎回問い合わせなくても取引履歴を再構築できます。
7. デプロイと送金のgasを比較する
トランザクションには必ずgas(処理手数料の単位)がかかります。今回の2つのトランザクションのgas消費量を比べてみます。
| 操作 | 内容 | gasUsed | to |
|---|---|---|---|
| デプロイ | コントラクトを新規作成し 1000 MTK を発行 | 966,589 | null |
| 送金 | Alice → Bob へ 10 MTK 転送 | 52,210 | トークンのアドレス |
読み取れることが2つあります。
-
デプロイは送金の約18倍のgasを消費している。
コントラクトのコードをチェーン上に書き込むデプロイは、単なる残高の書き換えより圧倒的に重い処理だとわかります。 -
デプロイのトランザクションは
toがnull。
送り先アドレスが存在しない=「新しいコントラクトを生成するトランザクション」であることを意味します。一方、送金のtoはトークンのコントラクトアドレスになっています。
to: null かどうかは、そのトランザクションが「コントラクト作成」なのか「既存コントラクトの呼び出し」なのかを見分ける手がかりになります。
8. 全体の仕組みを振り返る
今回の流れをEthereumの用語で整理すると、次のようになります。
-
コントラクト作成:
MyTokenをデプロイした瞬間に、_mintが実行され、Aliceに1000 MTKが発行された。 -
状態(残高)の保持:
各アカウントの残高は、コントラクト内部のmapping(address => uint256)に記録されている。 -
トランザクション:
transferの呼び出しは状態を変更するため、トランザクションとして送信され、ブロックに取り込まれた。 -
イベント:
送金と同時にTransferイベントが発火し、ログとして永続的に記録された。 -
確定:
ブロックに取り込まれた時点で、残高の変更が確定した。
前回のCounterでは「数値をインクリメントする」だけでしたが、今回はそれが「誰がいくら持っているか」という資産の記録に変わりました。ブロックチェーンが「台帳(Ledger)」と呼ばれる理由が、少し実感できる検証だったと思います。
9. まとめ
今回は、OpenZeppelinを使って独自のERC20トークンを作り、AliceからBobへ送金するところまでを確認しました。
- ERC20は代替可能トークンの共通規格であり、OpenZeppelinを継承するだけで安全に実装できる
- トークンの残高は「移動」ではなく、コントラクト内部の数値の加減算で表現される
- 送金は必ず
Transferイベントを発火し、それが取引の証跡になる - JSON-RPCで、イベントの署名ハッシュや
to: nullといった生データからも検証できた
ERC20という規格に準拠した形でトークンを作り、送金処理をするとそのトークン残高のデータがブロックチェーンに記録される。ブロックチェーンのデータはトランザクションのレシート(ログ)を取得することで確認できるため、資産の移動を検知することが可能であるということを理解しました。
ブロックチェーン上での資産管理の動きは理解できてきましたが、Web2.0の頭のままだと、他システムインターフェース、帳票はどうするのか?などの疑問点が出てきますが、まずはWeb3.0の世界の基礎を抑えていきたいと思います。
次回は、このトークンをさらに発展させ、発行(Mint)と償還(Burn)、管理者権限を持つステーブルコインを作っていきます。
「預金 → トークン発行 → ブロックチェーン台帳」という、より金融に近い構造に踏み込む予定です。
最後まで読んでいただき、ありがとうございました。
デジタルアセットを学ぶロードマップ