はじめに
前回は、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 の判定があり条件を満たしているアカウントのみ変換が可能でした。
そのトランザクションを見てみます。
0x606F72657e72cd1218444C69eF9D366c62C54978(実装は0x6Df76797b6593Aeb37B154F03e668DFF3e02354a)に対して、0x70a08231 をコールしていることが分かります。
0x70a08231 はEthereum Signature Database によると balanceOf(address) であることがわかります。
判定として、Soulbound Token の量で判断していると考えられます。
Soulbound Tokenの確認
SBTのコントラクトアドレス:0x606F72657e72cd1218444C69eF9D366c62C54978
このコントラクトに対して 0x70a08231(balanceOf(address))をコールすることで、SBTの保有状況を確認できます。
以下のコードで、SBTのname と Symbol を取得します:
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保有者のみ mint と transfer ができる 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);
}
}
_update を override して、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の判定をしていることが分かります。
失敗ケース
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>message が execution 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の判定をしていることが分かります。
失敗ケース
送信元が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>message が execution 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>message が execution reverted: Receiver must hold a Soulbound Token となります。
まとめ
SBTによるKYC判定を組み込んだERC-20トークンの実装と動作検証を行いました。
SBTの保有状況に応じて、トークン(ERC-20)の mint や transfer が制限されることで、より安全かつ信頼性の高いトークン運用が可能になります。
この仕組みは、KYCが必要なアプリケーション(限定コミュニティトークンなど)に応用できる可能性があります。


