はじめに
前回は、OpenZeppelinを使って独自のERC20トークン(MyToken / MTK)を作り、あるアカウントから別のアカウントへ送金するところまでを確認しました↓
今回はその発展形として、日本円にペッグ(連動)したステーブルコインを作ります。シンボルは JPYS、「1 JPYS = 1円」を想定します。
前回のMTKは「デプロイ時に固定量を発行し、あとはやり取りするだけ」のシンプルなトークンでした。しかしステーブルコインは、現実のお金と価値を結びつけるために、もう少し踏み込んだ仕組みが必要になります。
- 利用者が円を入金したら、その分だけトークンを発行(mint) する
- 利用者が円を引き出したら、その分のトークンを償還(burn) して消す
- 発行できるのは信頼できる管理者(発行体)だけ に限定する
- 不正やバグの発覚時に、送金を緊急停止(pause) できる
この記事では、これらを OpenZeppelin の監査済みライブラリを組み合わせて実装し、ローカルのHardhat上で実際に動かしながら、「なぜステーブルコインにはこれらの仕組みが必要なのか」を確認していきます。
今回作るものの全体像
以下は、今回のステーブルコインの「発行 → 送金 → 償還」の流れをイメージにしたものです。
1. そもそもステーブルコインとは何か
ステーブルコイン(stablecoin)とは、その名のとおり価格が安定した(stable)暗号資産です。ビットコインやイーサリアムは価格が激しく変動しますが、ステーブルコインは「1コイン = 1ドル」「1コイン = 1円」のように、法定通貨などの価値に連動するよう設計されています。
代表例が、米ドルに連動するUSDC やUSDT です。これらは「1 USDC = 1 USドル」を維持しています。
なぜ価格が安定するのか
もっとも分かりやすいのが担保型(法定通貨担保型) です。仕組みはシンプルで、
- 発行体(企業)が、利用者から預かった円やドルを銀行に保管する
- 預かった額と同額のトークンを発行(mint) して利用者に渡す
- 利用者がトークンを返して現金を引き出すとき、そのトークンを消す(burn)
このように「発行済みのトークン量 = 預かっている現金の量」を常に一致させることで、1トークンが常に1通貨分の価値を持つことを担保します。
☝ ここで前回のMTKとの決定的な違いが出てきます。MTKは総供給量が固定でした。しかしステーブルコインでは、入金・引き出しに応じて総供給量そのものが増えたり減ったりする必要があります。この「発行=mint」「償還=burn」こそが今回の主役です。
2. 普通のERC20と何が違うのか
前回のMTKと比べると、ステーブルコインには次の機能が加わります。それぞれ「なぜ必要か」とセットで見ていきます。
| 機能 | 内容 | なぜステーブルコインに必要か |
|---|---|---|
| mint(発行) | 新しくトークンを作り出す | 入金に応じてトークンを供給するため |
| burn(償還) | トークンを消す | 引き出しに応じて供給を減らすため |
| Ownable(管理者権限) | 発行などを管理者に限定 | 誰でも発行できたら価値の裏付けが崩れる |
| Pausable(緊急停止) | 全送金を一時停止 | ハッキングや規制対応など緊急時の安全装置 |
とくに重要なのが 「誰がmintできるのか」 という点です。もし誰でも自由にトークンを発行できてしまったら、裏付けとなる現金がないのにトークンだけが増え、あっという間に価値が崩壊します。そのため、発行は信頼できる発行体(管理者)だけに限定しなければなりません。
ビットコインのような「非中央集権」を良しとする文脈からすると、管理者がいるのは一見後退に見えます。しかし「現実の通貨と価値を結びつける」という目的のためには、現金を管理し供給量を調整する中央の主体がどうしても必要になる、という点がステーブルコインの本質です。
3. コントラクトを書く
これらを踏まえて作成したコントラクトが以下です。OpenZeppelinの部品を4つ組み合わせています。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
// ステーブルコインに必要な機能を、OpenZeppelin の監査済み実装から組み合わせて作る。
// - ERC20 : 残高管理・送金・イベントなど、トークンの基本機能
// - ERC20Burnable : 保有者が自分の残高を焼却(burn)する機能 = 償還(法定通貨の引き出し)に相当
// - Ownable : 管理者(発行体)を1人定め、mint など特権操作を制限する
// - Pausable : 緊急時・規制対応で全送金を一時停止できるようにする
import '@openzeppelin/contracts/token/ERC20/ERC20.sol';
import '@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol';
import '@openzeppelin/contracts/access/Ownable.sol';
import '@openzeppelin/contracts/utils/Pausable.sol';
// 日本円ペッグのステーブルコイン。1 JPYS = 1円 を想定する。
contract JpyStableCoin is ERC20, ERC20Burnable, Ownable, Pausable {
// デプロイ時点では 1 枚も発行しない。
// 「利用者が円を入金 → 発行体が同額を mint」という裏付けのある発行モデルにするため、
// 初期供給は 0 とし、供給量は入金・引き出しに応じて動的に増減させる。
// OpenZeppelin v5 では Ownable が初期オーナーを要求するため Ownable(msg.sender) を渡す。
constructor() ERC20('JPY Stable Coin', 'JPYS') Ownable(msg.sender) {}
// 円は「銭」までしか使わないため小数は不要だが、少額決済を扱えるよう
// USDC/USDT と同じく decimals = 6 とする(前回の MyToken は 18 だった)。
function decimals() public pure override returns (uint8) {
return 6;
}
// 発行(mint): 管理者(発行体)だけが、円の入金を裏付けに新規発行できる。
// 誰でも発行できてしまうと価値の裏付けが崩れるため onlyOwner で制限する。
function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}
// 緊急停止: 不正送金やバグ発覚時に、管理者が全送金を止められるようにする。
function pause() external onlyOwner {
_pause();
}
function unpause() external onlyOwner {
_unpause();
}
// ERC20 v5 では mint/transfer/burn はすべて _update を経由する。
// ここに whenNotPaused を付けることで、一時停止中はあらゆる残高移動を禁止する。
function _update(address from, address to, uint256 value) internal override whenNotPaused {
super._update(from, to, value);
}
}
読みどころを補足します。
-
is ERC20, ERC20Burnable, Ownable, Pausable- 4つの機能を多重継承しています。OpenZeppelinはこうした部品を安全に組み合わせられるよう設計されています。
-
constructor() ... Ownable(msg.sender)-
OpenZeppelin v5 での重要な変更点です。v4では
Ownableは引数なしでデプロイ者を自動的にオーナーにしていましたが、v5では初期オーナーを明示的に渡す必要があります。ここではmsg.sender(デプロイした人)を管理者にしています。 - また、前回と違い
_mintを書いていません。初期供給は0で、発行はあとからmintで行います。
-
OpenZeppelin v5 での重要な変更点です。v4では
-
mint(address to, uint256 amount) external onlyOwner- 新規発行の関数です。
onlyOwner修飾子により、管理者以外が呼ぶと失敗します。これが「価値の裏付け」を守る要です。
- 新規発行の関数です。
-
pause()/unpause()- 管理者が送金を止めたり再開したりできます。
-
_update(...) whenNotPaused-
ここがPausableの肝です。OpenZeppelin v5では、mint・transfer・burnといったあらゆる残高移動が内部的に
_updateを通ります。そこにwhenNotPausedを付けることで、一時停止中はすべての残高移動をまとめて止められます。 - (補足:v4では
_beforeTokenTransferというフックを使っていましたが、v5で_updateに一本化されました。)
-
ここがPausableの肝です。OpenZeppelin v5では、mint・transfer・burnといったあらゆる残高移動が内部的に
decimals を 6 にした理由
前回のMTKは、Etherと同じ decimals = 18 でした。今回はあえて 6 にしています。
理由は2つあります。
-
実在のステーブルコインに合わせるため。 USDCやUSDTは
decimals = 6を採用しています。 - 円との相性。 円は本来小数を使いませんが、少額決済(1円未満)も扱えるよう、小数点以下6桁の余地を持たせています。
decimalsが6ということは、「1 JPYS」を内部的には 1,000,000(= 1 × 10^6)という整数で表す、という意味です。この変換は、前回同様スクリプト側の parseUnits / formatUnits が担ってくれます。
4. デプロイする — 空の台帳を用意する
デプロイ用スクリプト(scripts/deploy-stablecoin.js)の要点です。前回と違い、初期供給量の引数がありません。
// ステーブルコインは初期供給を持たない。
// デプロイ = 「発行体(管理者)を1人定めた空の台帳を用意する」だけ。
const JpyStableCoin = await ethers.getContractFactory('JpyStableCoin');
const coin = await JpyStableCoin.deploy();
await coin.deployed();
実行結果は次のとおりです。
$ npm run deploy:stablecoin
JpyStableCoin deployed to: 0x0165878A594ca255338adfa4d48449f69242Eb8F
Owner (issuer): 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Name / Symbol: JPY Stable Coin / JPYS
Decimals: 6
Total supply: 0.0 JPYS
Transaction hash: 0x7a581502ce0a3799c9351b1cd2195e699f43af820990bac123071a616626e30b
注目してほしいのは Total supply: 0.0 JPYS です。デプロイ直後は1枚も発行されていません。この時点では「管理者が決まっただけの、空の台帳」ができただけです。ここに、これから入金に応じてトークンを発行していきます。
5. 発行・送金・償還を実演する
scripts/interact-stablecoin.js では、次の登場人物で「預金 → 発行 → 送金 → 償還」の一連の流れを再現します。
- Issuer(発行体 / 管理者)… デプロイ時にownerになったアカウント。銀行に相当。
- Alice / Bob … 一般の利用者。
以下、順番に実行結果を見ていきます。
5-1. 発行(mint)— 入金の裏付けとして発行する
Aliceが1000円を入金したと仮定し、発行体が同額の1000 JPYSを発行します。
// 発行体が Alice へ 1000 JPYS を発行
const mintAmount = ethers.utils.parseUnits('1000', 6);
await coin.connect(issuer).mint(alice.address, mintAmount);
[1] MINT 1000 JPYS -> Alice (¥1000 入金の裏付け)
Transaction hash: 0x5a89e2e4bb9d5b5cb1c66864fb56155c6fa787640841266d675e475e2e12e9e3
Block number: 8
Gas used: 71546
[After mint]
Alice: 1000.0 JPYS
Bob: 0.0 JPYS
Total supply: 1000.0 JPYS
総供給量が 0 → 1000 に増えました。発行体が現金を預かった分だけ、世の中に出回るトークンが増えたわけです。
5-2. 送金(transfer)— ここは普通のERC20と同じ
AliceからBobへ300 JPYSを送金します。ここは前回のMTKと同じ挙動です。
[2] TRANSFER 300 JPYS Alice -> Bob
Transaction hash: 0xba7c4db46e5971bef78aa880b347d01b659b2a03565511f239ce5026ee118ea6
Block number: 9
Gas used: 54466
Event: Transfer(from=0x709...C8, to=0x3C4...3BC, value=300.0 JPYS)
[After transfer]
Alice: 700.0 JPYS
Bob: 300.0 JPYS
Total supply: 1000.0 JPYS
Alice・Bobの残高は動きましたが、総供給量は1000のまま変わりません。送金は「持ち主が変わるだけ」で、トークンの総量には影響しないからです。
5-3. 償還(burn)— 引き出しでトークンを消す
Bobが100円を引き出したと仮定し、その分の100 JPYSを焼却(burn)します。
// Bob が自分の 100 JPYS を焼却(= ¥100 引き出し)
await coin.connect(bob).burn(ethers.utils.parseUnits('100', 6));
[3] BURN 100 JPYS by Bob (¥100 引き出し / 供給量が減る)
Transaction hash: 0x5a779d39d37c18f6fedce57b1c1186e9c220b369021c8c4b9c388ca0cd0f3f5a
Block number: 10
Gas used: 36482
[After burn]
Alice: 700.0 JPYS
Bob: 200.0 JPYS
Total supply: 900.0 JPYS
ここが送金との決定的な違いです。Bobの残高が減るだけでなく、総供給量が 1000 → 900 に減っています。焼却したトークンはどこかへ移ったのではなく、この世から消えました。発行体は、この100 JPYS分の現金(100円)を利用者に返せばよい、というわけです。
☝ mintで増え、burnで減る。 この総供給量の増減こそ、固定供給だった前回のMTKにはなかった、ステーブルコインの核心です。
6. 管理者権限を確かめる — 勝手な発行は防がれる
「発行は管理者だけ」というルールが本当に効いているか、一般利用者のAliceが自分でmintを試みてみます。
try {
await coin.connect(alice).mint(alice.address, mintAmount);
} catch (error) {
console.log('=> 期待どおり拒否されました:', ...);
}
[4] Alice が mint を試みる(管理者ではないので失敗するはず)
=> 期待どおり拒否されました: reverted with custom error
'OwnableUnauthorizedAccount("0x70997970C51812dc3A010C7d01b50e0d17dc79C8")'
OwnableUnauthorizedAccount というカスタムエラーで、しっかり拒否されました。カッコ内は、権限がないのに呼び出そうとしたAliceのアドレスです。
もしこの onlyOwner がなければ、誰でも好きなだけJPYSを発行でき、現金の裏付けのないトークンが無限に増やせてしまいます。アクセス制御は、ステーブルコインの価値を守る生命線なのです。
7. 緊急停止(Pausable)を確かめる
最後に、緊急停止の動作を確認します。ハッキングやバグの発覚時、管理者はすべての送金を止められます。
[5] Issuer が pause() を実行(緊急停止)
paused(): true
Alice -> Bob へ 10 JPYS 送金を試みる(止まっているはず)
=> 期待どおり停止中で拒否: reverted with custom error 'EnforcedPause()'
unpause() 後 paused(): false
pause() を実行すると paused() が true になり、その状態でAliceがBobへ送金しようとすると EnforcedPause() エラーで止まりました。そして unpause() で通常運用に戻せます。
これは、前述の _update に付けた whenNotPaused が効いているためです。transferだけでなくmint・burnも同じく止まります。実在のUSDCなども、こうした緊急停止機能を備えています。
8. gasを比較する — どの操作が重いのか
今回の各トランザクションのgas消費量を並べてみます。
| 操作 | 内容 | gasUsed | to |
|---|---|---|---|
| デプロイ | コントラクトを新規作成 | 1,287,794 | null |
| mint | 1000 JPYS を新規発行 | 71,546 | コントラクトのアドレス |
| transfer | 300 JPYS を Alice→Bob | 54,466 | コントラクトのアドレス |
| burn | 100 JPYS を焼却 | 36,482 | コントラクトのアドレス |
読み取れることがいくつかあります。
- デプロイが圧倒的に重い。 コード全体をチェーンに書き込むデプロイは、他の操作の20〜35倍のgasを消費しています。前回のMTKより機能が多い分、デプロイのgasも大きくなっています。
- burnがtransferより安い。 送金は「片方を減らし、片方を増やす」2つの残高更新が必要ですが、焼却は「片方を減らして消すだけ」。むしろストレージが空くため、gasが小さく済みます。
-
デプロイの
toはnull。 前回同様、送り先のない=「新しいコントラクトを生成する」トランザクションであることを示しています。
9. Transferイベントを生データで確認する
送金が本当にブロックチェーン上で起きたことを、生の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":["0xba7c4db46e5971bef78aa880b347d01b659b2a03565511f239ce5026ee118ea6"]}'
レシートのログに含まれるイベントの先頭トピックは、次の値でした。
0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
これは前回のMTKとまったく同じ、keccak256("Transfer(address,address,uint256)") = Transferイベントの署名ハッシュです。トークンの中身がステーブルコインに変わっても、ERC20規格に従っている限りイベントの形は共通です。だからこそ、ウォレットや取引所は「JPYSだから」と特別扱いすることなく、同じ仕組みで残高や履歴を扱えます。
10. 全体の仕組みを振り返る
今回の流れをまとめると、次のようになります。
- デプロイ:管理者を1人定めた、初期供給0の「空の台帳」を作った。
- 発行(mint):入金の裏付けとして、管理者がトークンを発行し、総供給量が増えた。
- 送金(transfer):持ち主は変わるが、総供給量は変わらない。
- 償還(burn):引き出しに応じてトークンを消し、総供給量が減った。
- 管理者権限(Ownable):発行を管理者に限定し、価値の裏付けを守った。
- 緊急停止(Pausable):緊急時に全送金を止められる安全装置を用意した。
前回のMTKが「発行済みの決まった量をやり取りするだけ」だったのに対し、今回は供給量そのものが現実のお金の出入りに連動して動くという、より金融に近い構造に踏み込みました。ブロックチェーンが「台帳(Ledger)」であること、そしてステーブルコインがその台帳を現実の通貨とつなぐ仕組みであることが、少し見えたのではないかと思います。
11. まとめ
今回は、OpenZeppelinの部品を組み合わせて日本円ペッグのステーブルコイン(JPYS)を作り、発行・送金・償還・権限制御・緊急停止までを実際に動かして確認しました。
- ステーブルコインは「発行済み量 = 預かった現金」を保つことで価値を安定させる
- そのため、供給量が動的に増減する mint(発行) と burn(償還) が核になる
- 誰でも発行できないよう Ownable で管理者に限定することが、価値の生命線
- 緊急時に備えた Pausable は、
_updateにwhenNotPausedを付けるだけで全操作に効く - トークンの中身が変わっても、ERC20規格に従う限り Transferイベントなどの外形は共通
これで、Counter(状態の変更)→ ERC20(資産の記録)→ ステーブルコイン(現実通貨との連動)と、ブロックチェーンでできることを一歩ずつ広げてこられました。
最後まで読んでいただき、ありがとうございました。
デジタルアセットを学ぶロードマップ