ブロックチェーン技術ブログ

ブロックチェーンと英語、数学に関するブログ

ERC20を実装したトークンを作成する

今回は、ERC20の規格に準拠したトークンを作成してみましょう.

ERC20は、FT(Fungible Token)のためのインタフェースです。
ERC20に準拠することで、スマートコントラクトから扱いやすくなり、別の種類のトークンとの交換が容易になります。

ここで、FTとは代替可能なトークンであり、同じ種類のトークンであれば、すべてのトークンで同じ価値を持ちます.
BitcoinやEtherもFTです.
また、1 Bitcoinを0.7 Bitcoinと0.3 Bitcoinのように分割することも可能です.

FTと対になるものがNFT(Non-Fungible Token)です.
NFTは、各トークンが唯一無二であり、異なる性質を持ちます.
NFTはデータにイラストなどのコンテンツを参照するアドレスを保持します.
OpenSeaといったマーケットプレイスで、NFTを購入することができます.
また、FTのように、1つのNFTをより小さい単位に分割することはできません.

ERC20のインタフェース

ERC20に準拠するには、以下のMethodsとEventsを実装する必要があります.
以下に、MethodsとEventsのシグネチャと説明を記載しました.

EIP-20: Token Standard からMethods, Eventsのシグネチャを引用

Methods

メソッド名 引数 返り値 必須 説明
name - string - トークンの名前を返す
symbol - string - トークンのシンボル(ex: USDT)を返す
decimals - uint8 - ユーザのトークンの保有量を表示するときに、この数で割った値を使用する
totalSupply - uint256 トークンの総量を返す
balanceOf address _owner uint256 balance _ownerのアドレスのトークンの残高を返す
transfer address _to,
uint256 _value
bool success トークンを自身のアドレスからtoのアドレスに転送する
転送に成功した場合、Transferイベントを発火する必要がある
transferFrom address _from,
address _to,
uint256 _value
bool success トークンをfromのアドレスからtoのアドレスに転送する
転送に成功した場合、Transferイベントを発火する必要がある
approve address _spender,
uint256 _value
bool success ユーザが_valueで指定した量までのトークンを、_spenderが自身のアドレスに転送することを許可する
allowance address _owner,
address _spender
uint256 remaining ユーザが_ownerのアドレスから自身のアドレスに転送可能なトークンの量を返す

Events

イベント名 引数 説明
Transfer address indexed _from,
address indexed _to,
uint256 _value
トークンが転送されたときや新たにミントされたときに発火するイベント
Approval address indexed _owner,
address indexed _spender,
uint256 _value
approveの呼び出しに成功したときに発火するイベント

ERC20に準拠したトークンの実装

では、実際にトークンを実装してみましょう.
今回は、OpenZeppelinのERC20を継承して実装しました.
ERC 20 - OpenZeppelin Docs

ここで、ERC20IERC20を実装したものです.
以下のリンクからソースコードを確認できます.
github.com

ソースコード

今回、作成したトークンは、LotteryToken です.

LotteryToken は、以下の性質を持ちます.

  • mintと一緒にETHを送金する(コントラクトに預ける)ことで、送金したETHと同じの量のトークンを入手できる
  • 送金したETHと同じ量のトークンをburnすることで、コントラクトから送金したETHを引き落とすことができる
  • トークンを転送したとき(mint, burnを除く)に、トークンがmintされ、ETHを預けているアカウントのいずれかに転送される
    (トークンが転送されるアカウントは、預けているETHの割合に応じて決定される)

ソースコード全体は、以下のリンクから確認できます.
github.com

//SPDX-License-Identifier: UNLICENSED

pragma solidity ^0.8.9;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/utils/structs/EnumerableMap.sol";

contract LotteryToken is ERC20 {
    using EnumerableMap for EnumerableMap.AddressToUintMap;

    // transferが発生したときに、いずれかのアカウントに転送されるトークンの量
    uint REWARD = 100;

    // 各アカウント預けているETHの量を表すのに、EnumerableMapを使用した.
    // これを使うことで、Mapの要素数を取得したり、イテレートが可能.
    EnumerableMap.AddressToUintMap etherBalances;

    event Win(address indexed winner, uint amount);

    // コンストラクターで、name, symbol を設定
    constructor() ERC20("LotteryToken", "LT") {}

    function mint() external payable {
        require(msg.value > 0, "ETH is required to mint tokens.");

        (, uint amount) = etherBalances.tryGet(msg.sender);
        etherBalances.set(msg.sender, amount + msg.value);

        _mint(msg.sender, msg.value);
    }

    function redeem(uint amount) public {
        require(balanceOf(msg.sender) >= amount, "Redemption amount must not exceed balance.");

        // 引き落とすETHと同じ量のトークンを保持している必要がある
        (, uint curAmount) = etherBalances.tryGet(msg.sender);
        require(curAmount >= amount, "Redemption amount must not exceed ether balance.");

        _burn(msg.sender, amount);

        etherBalances.set(msg.sender, curAmount - amount);

        (bool sent,) = payable(msg.sender).call{value : amount}("");
        require(sent, "Failed to send Ether");
    }

    // transferの完了後に呼ばれるHook.
    function _afterTokenTransfer(address from, address to, uint256 amount) internal override {
        // mintもしくはburnしたときは、報酬なし
        if (from == address(0) || to == address(0)) return;

        // パラメータ群から、0 <= x < コントラクト残高 の範囲の値を生成する
        // 完全なランダムな値ではないので、注意すること
        uint targetAmount = uint(keccak256(abi.encodePacked(block.timestamp, totalSupply(), from, to, amount))) % address(this).balance;
        address receiver = _lottery(targetAmount);

        _mint(receiver, REWARD);
        emit Win(receiver, REWARD);
    }

    function _lottery(uint targetAmount) private view returns (address receiver) {
        // アカウントの残高に応じて、報酬を受け取るアカウントを決定する
        // アカウントの残高が多いほど、レンジが広くなり、targetAmountを含む確率が高くなる
        uint total;
        for (uint i = 0; i < etherBalances.length(); i++) {
            (address account, uint amount) = etherBalances.at(i);
            if (total <= targetAmount && targetAmount < total + amount) {
                receiver = account;
                break;
            }
            total = total + amount;
        }
    }
}

テストコード

const {expect} = require("chai");

const {loadFixture} = require("@nomicfoundation/hardhat-network-helpers");

describe("LotteryToken contract", function () {
  async function deployLotteryTokenFixture() {
    const LotteryToken = await ethers.getContractFactory("LotteryToken");

    const [owner, addr1, addr2] = await ethers.getSigners();

    const lotteryToken = await LotteryToken.deploy();

    await lotteryToken.deployed();

    return {lotteryToken, owner, addr1, addr2};
  }

  describe("Mint", function () {

    it("Should mint token", async function () {
      const {lotteryToken, owner} = await loadFixture(deployLotteryTokenFixture);

      await expect(lotteryToken.mint({value: 100}))
          .to.changeTokenBalance(lotteryToken, owner, 100)
          .to.changeEtherBalances([owner, lotteryToken], [-100, 100])
    });
  });

  describe("Transfer", function () {
    it("Should transfer token and win reward", async function () {
      const {lotteryToken, owner, addr1} = await loadFixture(deployLotteryTokenFixture);

      await lotteryToken.mint({value: 100})

      await expect(lotteryToken.transfer(addr1.address, 50))
          .to.changeTokenBalances(lotteryToken, [owner, addr1], [50, 50])
    });

    it("Should emit event", async function () {
      const {lotteryToken, owner, addr1} = await loadFixture(deployLotteryTokenFixture);

      await lotteryToken.mint({value: 100})

      await expect(lotteryToken.transfer(addr1.address, 50))
          .to.emit(lotteryToken, "Win").withArgs(owner.address, 100)
    });

    it("Should increase total supply", async function() {
      const {lotteryToken, owner, addr1, addr2} = await loadFixture(deployLotteryTokenFixture);

      await lotteryToken.mint({value: 100})
      await lotteryToken.connect(addr1).mint({value: 100})
      await lotteryToken.connect(addr2).mint({value: 100})

      await expect(lotteryToken.transfer(addr1.address, 10))
      await expect(lotteryToken.connect(addr1).transfer(addr2.address, 20))
      await expect(lotteryToken.connect(addr2).transfer(owner.address, 30))

      expect(await lotteryToken.connect(owner).totalSupply()).to.equal(600)
    });
  });

  describe("Redeem", function () {
    it("Should redeem ETH", async function () {
      const {lotteryToken, owner, addr1} = await loadFixture(deployLotteryTokenFixture);

      await lotteryToken.mint({value: 100})

      await expect(lotteryToken.redeem(100))
          .to.changeTokenBalance(lotteryToken, owner, -100)
          .to.changeEtherBalances([owner, lotteryToken], [100, -100])
    });

    it("Should revert redemption", async function () {
      const {lotteryToken, addr1} = await loadFixture(deployLotteryTokenFixture);

      await lotteryToken.mint({value: 1000})
      await lotteryToken.transfer(addr1.address, 500)

      await expect(lotteryToken.redeem(1000))
          .to.be.revertedWith("Redemption amount must not exceed balance.");
    });
  });
});