2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Mizuhiki 対応 ERC-20 をデプロイしてみた

2
Last updated at Posted at 2025-08-17

はじめに

前回は、Japan Smart Chainの「Mizuhiki」についてデモを通じて理解を深めました。
今回は、Mizuhikiに対応したERC-20トークンを実装し、Soulbound Token(以下、SBT)によるKYC判定を組み込んだトークンの動作を検証します。

Mizuhikiでは、対象のEOA(Externally Owned Account)にSBTを付与することで、KYC済みかどうかを判定します。ネイティブトークン(JETH)をMizuhiki JPY(MJPY)へ変換する際にも、SBT保有が条件となっていました。

Mizuhiki

対象の EOA に、Soulbound Token を付与することにより KYC 済みであるかを判定するという仕様でした。

ネイティブトークン(JETH) を Mizuhiki JPY(MJPY)へ変換するときに、Mizuhiki の判定があり条件を満たしているアカウントのみ変換が可能でした。
そのトランザクションを見てみます。

image.png

0x606F72657e72cd1218444C69eF9D366c62C54978(実装は0x6Df76797b6593Aeb37B154F03e668DFF3e02354a)に対して、0x70a08231 をコールしていることが分かります。

0x70a08231Ethereum Signature Database によると balanceOf(address) であることがわかります。

判定として、Soulbound Token の量で判断していると考えられます。

Soulbound Tokenの確認

SBTのコントラクトアドレス:0x606F72657e72cd1218444C69eF9D366c62C54978

このコントラクトに対して 0x70a08231balanceOf(address))をコールすることで、SBTの保有状況を確認できます。

以下のコードで、SBTのnameSymbol を取得します:

  const provider = new JsonRpcProvider(PROVIDER_URL);
  const CONTRACT_ADDRESS = "0x606F72657e72cd1218444C69eF9D366c62C54978";
  const sbt = new Contract(
    CONTRACT_ADDRESS,
    [
      "function name() external view returns (string _name)",
      "function symbol() external view returns (string _symbol)",
    ],
    provider
  );
  console.log("name:", await sbt.name());
  console.log("symbol:", await sbt.symbol());

結果:

name: Mizuhiki Verified
symbol: MIZVER

ERC-20

以下は、SBT保有者のみ minttransfer ができる ERC-20 のSolidityコードです:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/introspection/IERC165.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721.sol";

/**
 * @title Token
 * @dev このERC20トークンは、特定のSoulbound Token (SBT)を保有するアドレスのみがミント・送信・受信できます。
 * このコントラクトは、SBT保有者のみがトークンを受け取ったり送信できることを保証し、SBTとの強い紐付けを実現します。
 */
contract Token is ERC20, Ownable {
    // Soulbound Token (SBT)のアドレス
    address public sbt;

    /**
     * @notice TokenコントラクトをSoulbound Token (SBT)のアドレスで初期化します。
     * @param sbt_ Soulbound Tokenコントラクトのアドレス(ERC721インターフェースをサポートしている必要があります)。
     */
    constructor(address sbt_) ERC20("Token", "TKN") Ownable(msg.sender) {
        require(sbt_ != address(0), "Soulbound Token address cannot be zero");
        require(
            IERC165(sbt_).supportsInterface(type(IERC721).interfaceId),
            "Soulbound Token must implement IERC721"
        );
        sbt = sbt_;
    }

    /**
     * @notice 指定したアドレスにトークンをミントします。受取人がSoulbound Tokenを保有している場合のみ可能です。
     * @dev この関数はオーナーのみが呼び出せます。
     * @param account トークンを受け取るアドレス
     * @param value ミントするトークンの量
     */
    function mint(address account, uint256 value) public onlyOwner {
        require(account != address(0), "Account cannot be zero");
        require(
            IERC721(sbt).balanceOf(account) > 0,
            "Receiver must hold a Soulbound Token"
        );
        _mint(account, value);
    }

    /**
     * @notice 指定したアドレスからトークンをバーンします。
     * @dev この関数はオーナーのみが呼び出せます。
     * @param account トークンをバーンするアドレス
     * @param value バーンするトークンの量
     */
    function burn(address account, uint256 value) public onlyOwner {
        _burn(account, value);
    }

    /**
     * @notice 内部で呼ばれるトークン送信処理のフックです。
     * @dev 送信元・送信先の両方がSoulbound Token(SBT)を保有していることを検証します。
     * @param from 送信元アドレス
     * @param to 送信先アドレス
     * @param amount 送信するトークンの数量
     */
    function _update(
        address from,
        address to,
        uint256 amount
    ) internal override {
        if (from != address(0)) {
            require(
                IERC721(sbt).balanceOf(from) > 0,
                "Sender must hold a Soulbound Token"
            );
        }
        if (to != address(0)) {
            require(
                IERC721(sbt).balanceOf(to) > 0,
                "Receiver must hold a Soulbound Token"
            );
        }
        super._update(from, to, amount);
    }
}

_updateoverride して、address(0) 以外の場合 balanceOf でSBT(KYC)が存在するかをチェックします。

mint に対しても、SBT(KYC)の判定を入れています。

Mintの検証

以下のコードで Mint をしてみます。

async function mint() {
  let provider = null;
  try {
    // プロバイダーの設定
    provider = new JsonRpcProvider(PROVIDER_URL);
    // SBT取得
    const sbt = new Contract(
      SBT_CONTRACT_ADDRESS,
      ["function balanceOf(address _owner) external view returns (uint256)"],
      provider
    );
    // 署名者
    const signer = ethers.HDNodeWallet.fromPhrase(MNEMONIC).connect(provider);
    // トークン(ERC-20)取得
    const token = new Contract(
      TOKEN_CONTRACT_ADDRESS,
      [
        "function decimals() public view returns (uint8)",
        "function balanceOf(address _owner) public view returns (uint256 balance)",
        "function mint(address account, uint256 value) public",
      ],
      signer
    );
    // Token小数点以下の桁数を取得
    const decimals = await token.decimals();
    // Mintアカウント
    const account = signer.address;
    // Mint量
    const amount = parseUnits("1000", decimals);
    // SBT所有確認
    console.log("SBT Balance:", await sbt.balanceOf(account));
    // Mint前のToken残高
    console.log(
      "Token Balance Before Mint:",
      formatUnits(await token.balanceOf(account), decimals)
    );
    // Mint実行
    const tx = await token.mint(account, amount);
    const receipt = await tx.wait();
    console.log(receipt.status === 1 ? "Mint successful" : "Mint failed");
    // Mint後のToken残高
    console.log(
      "Token Balance After Mint:",
      formatUnits(await token.balanceOf(account), decimals)
    );
  } catch (e) {
    if (isError(e, "CALL_EXCEPTION")) {
      console.log(JSON.stringify(e, null, 2));
    } else {
      console.error(e);
    }
  } finally {
    if (provider) {
      provider.destroy();
    }
  }
}

成功ケース

SBTを保有しているアカウントに対して mint を実行すると、以下のように成功します:

SBT Balance: 1n
Token Balance Before Mint: 12999.0
Mint successful
Token Balance After Mint: 13999.0

トランザクションのトレースを見ても、SBTの判定をしていることが分かります。

image.png

失敗ケース

SBTを保有していないアカウントに対して mint を実行すると、estimateGas の時点で失敗し、以下のエラーが返されます:

SBT Balance: 0n
Token Balance Before Mint: 0.0
{
  "code": "CALL_EXCEPTION",
  "action": "estimateGas",
  "data": null,
  "reason": null,
  "transaction": {
    "to": "0xf1f2C5f56Be481d9046A61D1151359359F6a9583",
    "data": "0x40c10f19000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000003635c9adc5dea00000",
    "from": "0x326b9f6F4D0d68476547c3deFAD62AFec29961E1"
  },
  "invocation": null,
  "revert": null,
  "shortMessage": "missing revert data",
  "info": {
    "error": {
      "code": 3,
      "message": "execution reverted: Receiver must hold a Soulbound Token"
    },
    "payload": {
      "method": "eth_estimateGas",
      "params": [
        {
          "nonce": "0x17",
          "from": "0x326b9f6f4d0d68476547c3defad62afec29961e1",
          "to": "0xf1f2c5f56be481d9046a61d1151359359f6a9583",
          "data": "0x40c10f19000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000003635c9adc5dea00000"
        }
      ],
      "id": 9,
      "jsonrpc": "2.0"
    }
  }
}

info>error>messageexecution reverted: Receiver must hold a Soulbound Token となります。

Transferの検証

以下のコードで Transfer をしてみます。

async function transfer() {
  let provider = null;
  try {
    // プロバイダーの設定
    provider = new JsonRpcProvider(PROVIDER_URL);
    // SBT取得
    const sbt = new Contract(
      SBT_CONTRACT_ADDRESS,
      ["function balanceOf(address _owner) external view returns (uint256)"],
      provider
    );
    // 署名者
    const signer = ethers.HDNodeWallet.fromPhrase(MNEMONIC).connect(provider);
    // トークン(ERC-20)取得
    const token = new Contract(
      TOKEN_CONTRACT_ADDRESS,
      [
        "function decimals() public view returns (uint8)",
        "function balanceOf(address _owner) public view returns (uint256 balance)",
        "function transfer(address _to, uint256 _value) public returns (bool success)",
      ],
      signer
    );
    // Token小数点以下の桁数を取得
    const decimals = await token.decimals();
    // Toアカウント
    const toAccount = "<SBT保有アカウント>";
    // Mint量
    const amount = parseUnits("1000", decimals);
    // SBT所有確認
    console.log(
      "SBT Balance:",
      await sbt.balanceOf(signer.address),
      await sbt.balanceOf(toAccount)
    );
    // Transfer前のToken残高
    console.log(
      "Token Balance Before Transfer:",
      formatUnits(await token.balanceOf(signer.address), decimals),
      formatUnits(await token.balanceOf(toAccount), decimals)
    );
    // Transfer実行
    const tx = await token.transfer(toAccount, amount);
    const receipt = await tx.wait();
    console.log(
      receipt.status === 1 ? "Transfer successful" : "Transfer failed"
    );
    // Transfer後のToken残高
    console.log(
      "Token Balance After Transfer:",
      formatUnits(await token.balanceOf(signer.address), decimals),
      formatUnits(await token.balanceOf(toAccount), decimals)
    );
  } catch (e) {
    if (isError(e, "CALL_EXCEPTION")) {
      console.log(JSON.stringify(e, null, 2));
    } else {
      console.error(e);
    }
  } finally {
    if (provider) {
      provider.destroy();
    }
  }
}

成功ケース

送信元・送信先の両方がSBTを保有している場合、transfer は成功します:

SBT Balance: 1n 1n
Token Balance Before Transfer: 13999.0 2001.0
Transfer successful
Token Balance After Transfer: 12999.0 3001.0

トランザクションのトレースを見ても、SBTの判定をしていることが分かります。

image.png

失敗ケース

送信元がSBTを保有していない場合、transfer は失敗します:

SBT Balance: 0n 1n
Token Balance Before Transfer: 0.0 3001.0
{
  "code": "CALL_EXCEPTION",
  "action": "estimateGas",
  "data": null,
  "reason": null,
  "transaction": {
    "to": "0xf1f2C5f56Be481d9046A61D1151359359F6a9583",
    "data": "0xa9059cbb00000000000000000000000045eb028ab5ae2177ec12a2a28658292ba43cc0ef00000000000000000000000000000000000000000000003635c9adc5dea00000",
    "from": "0xC08B5542D177ac6686946920409741463a15dDdB"
  },
  "invocation": null,
  "revert": null,
  "shortMessage": "missing revert data",
  "info": {
    "error": {
      "code": 3,
      "message": "execution reverted: Sender must hold a Soulbound Token"
    },
    "payload": {
      "method": "eth_estimateGas",
      "params": [
        {
          "nonce": "0x0",
          "from": "0xc08b5542d177ac6686946920409741463a15dddb",
          "to": "0xf1f2c5f56be481d9046a61d1151359359f6a9583",
          "data": "0xa9059cbb00000000000000000000000045eb028ab5ae2177ec12a2a28658292ba43cc0ef00000000000000000000000000000000000000000000003635c9adc5dea00000"
        }
      ],
      "id": 13,
      "jsonrpc": "2.0"
    }
  }
}

estimateGas の時点で失敗となり、info>error>messageexecution reverted: Sender must hold a Soulbound Token となります。

送信先がSBTを保有していない場合も、transfer は失敗します:

SBT Balance: 1n 0n
Token Balance Before Transfer: 12999.0 0.0
{
  "code": "CALL_EXCEPTION",
  "action": "estimateGas",
  "data": null,
  "reason": null,
  "transaction": {
    "to": "0xf1f2C5f56Be481d9046A61D1151359359F6a9583",
    "data": "0xa9059cbb000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000003635c9adc5dea00000",
    "from": "0x326b9f6F4D0d68476547c3deFAD62AFec29961E1"
  },
  "invocation": null,
  "revert": null,
  "shortMessage": "missing revert data",
  "info": {
    "error": {
      "code": 3,
      "message": "execution reverted: Receiver must hold a Soulbound Token"
    },
    "payload": {
      "method": "eth_estimateGas",
      "params": [
        {
          "nonce": "0x18",
          "from": "0x326b9f6f4d0d68476547c3defad62afec29961e1",
          "to": "0xf1f2c5f56be481d9046a61d1151359359f6a9583",
          "data": "0xa9059cbb000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000003635c9adc5dea00000"
        }
      ],
      "id": 13,
      "jsonrpc": "2.0"
    }
  }
}

estimateGas の時点で失敗となり、info>error>messageexecution reverted: Receiver must hold a Soulbound Token となります。

まとめ

SBTによるKYC判定を組み込んだERC-20トークンの実装と動作検証を行いました。
SBTの保有状況に応じて、トークン(ERC-20)の minttransfer が制限されることで、より安全かつ信頼性の高いトークン運用が可能になります。

この仕組みは、KYCが必要なアプリケーション(限定コミュニティトークンなど)に応用できる可能性があります。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?