はじめに

『DApps開発入門』という本や色々記事を書いているかるでねです。

以下でも情報発信しているので、興味ある記事があればぜひ読んでみてください!

今回は、ERC20トークンの保有量をメンバーシップレベルとして解釈し、しきい値ベースのティア制アクセス制御を実現する仕組みを提案しているERC8063についてまとめていきます!

以下にまとめられているものを解説しながらまとめていきます。

他にも様々なEIP・BIP・SLIP・CAIP・ENSIP・RFC・ACPについてまとめています。

概要

ERC8063とは、ERC20トークンの残高をそのまま「グループメンバーシップのレベル」として解釈する仕組みを定義した提案です。

ERC20については以下の記事を参考にしてください。

この提案のコアは非常にシンプルです。 あるアカウントが特定のERC20トークンを持っていれば、そのアカウントはグループのメンバーとみなされます。 さらに、保有量が一定のしきい値(threshold)を超えていれば、より高いティア(階層)のメンバーとして扱われます。

例えば、balanceOf(account) が 0 であればそのアカウントは非メンバーであり、100 以上であればブロンズ、500 以上であればシルバー、1000 以上であればゴールドメンバーとして扱うことができます。

重要なのは、この提案が新しいトークン規格を作っているわけではないという点です。 既存のERC20トークンに対して「残高をメンバーシップレベルとして読み取る」という意味的な解釈を標準化しています。 そのため、既存のウォレットやブロックエクスプローラー、DEXといったツール群との互換性を一切損なうことなく、グループメンバーシップの仕組みを導入できます。

以下の図は、ERC8063のアプリケーション連携アーキテクチャを示しています。 1つのERC20トークンの残高を、複数のアプリケーションがそれぞれの用途でメンバーシップとして読み取る仕組みです。

ERC8063 アプリケーション連携アーキテクチャ

動機

多くのアプリケーションでは、アカウントのグループ化とメンバーシップに基づくアクセス制御が必要とされています。 しかし、従来の方法ではメンバーシップ管理のために独自のコントラクトやデータ構造を構築する必要がありました。 この提案はそうした課題を、ERC20トークンの既存の仕組みを活用して解決します。

DAOとガバナンス

DAO(分散型自律組織)では、投票権をトークン保有量に比例させるモデルが広く採用されています。 保有トークン数がそのまま議決権の重みになるため、この提案のしきい値ベースの考え方と自然に統合できます。 例えば、一定量以上のトークンを持つメンバーだけが提案権を持ち、さらに多く持つメンバーには追加の権限を付与するといった設計が、追加の実装なしに実現可能になります。

ロイヤリティプログラム

既存のポイントプログラムのように、利用実績に応じてブロンズ・シルバー・ゴールドのティアを設定するケースは日常的に見られます。 ERC8063では、トークン残高がそのままティアの判定基準になるため、ポイント残高とランクの管理を別々のシステムで行う必要がありません。 トークンの配布だけでメンバーシップレベルを自動的に反映できます。

アクセス制御とパートナーネットワーク

特定の機能やサービスへのアクセスを残高に応じて段階的に開放する仕組みも考えられます。 具体的には、パートナー企業が一定量のトークンを保有していることを参加条件とするパートナーネットワークの構築にも活用できます。 トークンの保有が参加資格の証明になるため、参加者リストの管理やアクセス制御リストの更新を手動で行う負担がなくなります。

この提案による解決策

ERC8063がこれらのユースケースに対して提供する価値は、以下の4点に集約されます。

特徴 説明
ゼロ実装コスト 既存のERC20トークンをそのまま「グループ」として使える。新しいコントラクトや関数の追加は不要
既存ツール互換 ウォレット、ブロックエクスプローラー、DEXがそのまま動作する
ティア制メンバーシップ 残高のしきい値で複数のメンバーシップ階層を自然に表現できる
移転可能なメンバーシップ ERC20の transfer 関数でメンバーシップを譲渡・委任できる

仕様

この提案に準拠するグループコントラクトは、ERC20とERC165を実装する必要があります。 加えて、メンバーシップの確認を簡便にするためのオプションインターフェースが定義されています。

ERC165については以下の記事を参考にしてください。

インターフェース

IERC8063

メンバーシップの確認をコントラクト上で行うための便利なインターフェースです。 このインターフェースの実装はオプションですが、実装するとアプリケーション側からのメンバーシップ判定が簡単になります。

javascript
// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.20;

/// @title IERC8063 — Optional membership introspection for ERC-20 tokens
interface IERC8063 {
    /// @notice Returns true if `account` holds at least `threshold` tokens
    /// @param account The address to check
    /// @param threshold Minimum balance required for membership at this tier
    function isMember(address account, uint256 threshold) external view returns (bool);
}
isMember

指定したアカウントが指定されたしきい値以上のトークンを保有しているかどうかを判定します。

  • 引数
パラメータ名 型 説明
account address メンバーシップを確認する対象のアドレス。EOAでもコントラクトでも指定可能
threshold uint256 そのティアに必要な最低保有量。例えばシルバーティアなら 500 * 10**18 を指定する
  • 戻り値

bool 対象アカウントの balanceOf がしきい値以上であれば true、そうでなければ false を返します。

この関数を実装する場合、isMember(account, threshold) は balanceOf(account) >= threshold と同じ結果を返す必要があります。 また、しきい値に 0 を指定した場合はすべてのアカウントに対して true を返します。 これはしきい値 0 が「全員がメンバー」という意味を持つためです。

ERC165インターフェースID

ERC165を通じたインターフェース検出もサポートしています。

インターフェースID 対象 期待される返り値
0x36372b07 ERC20 true を返すべき
IERC8063 のインターフェースID isMember 関数 オプションインターフェースを実装している場合は true

アプリケーションは supportsInterface を呼び出すことで、対象のコントラクトがメンバーシップ確認機能を備えているかどうかを判定できます。 IERC8063 をサポートしていない場合でも、balanceOf を直接呼び出すことでメンバーシップの判定は可能です。

メンバーシップの意味

ERC8063では、トークン残高が直接メンバーシップレベルを表します。

条件 メンバーシップ状態
balanceOf(account) == 0 非メンバー
balanceOf(account) > 0 メンバー
balanceOf(account) >= threshold そのしきい値に対応するティアのメンバー

しきい値はアプリケーション側が自由に定義します。 例えば、以下のようにティアを設計できます。

ティア 必要残高 用途例
Basic >= 1 基本的なグループ機能へのアクセス
Silver >= 100 * 10**decimals 優先サポート、追加機能の利用
Gold >= 500 * 10**decimals プレミアム機能、投票権の強化
Platinum >= 1000 * 10**decimals 最上位の権限、提案権の付与

この設計のポイントは、しきい値がコントラクト内にハードコードされるのではなく、アプリケーション側で定義される点です。 同じトークンに対して、あるアプリケーションでは100トークンをシルバーとし、別のアプリケーションでは200トークンをシルバーと定義するといった柔軟な運用が可能です。

以下の図は、トークン残高からメンバーシップティアと特典を判定する仕組みの全体像です。

ERC8063 ティア別メンバーシップの仕組み

以下のフロー図は、アカウントの残高からメンバーシップティアを判定する流れを示しています。

flowchart TD
    A["balanceOf(account) を確認"] --> B{"残高 == 0?"}
    B -- "はい" --> C["非メンバー"]
    B -- "いいえ" --> D{"残高 >= Platinum?"}
    D -- "はい" --> E["Platinum メンバー"]
    D -- "いいえ" --> F{"残高 >= Gold?"}
    F -- "はい" --> G["Gold メンバー"]
    F -- "いいえ" --> H{"残高 >= Silver?"}
    H -- "はい" --> I["Silver メンバー"]
    H -- "いいえ" --> J["Basic メンバー"]

このフロー図のように、残高を上位ティアから順にチェックしていくことで、そのアカウントが属するティアを判定します。 しきい値の判定は単純な数値比較であるため、ガスコストの面でも効率的です。

デプロイモデル

ERC20と同様に、各グループはそれぞれ独立したコントラクトとしてデプロイされます。

項目 説明
グループの識別 コントラクトアドレスで一意に識別される
デプロイ方法 任意のERC20トークンをデプロイする。そのトークンがそのままグループになる
グループの発見 Transfer イベントの監視、レジストリの参照、またはアドレスの直接指定で発見できる
名前とシンボル ERC20標準の name() と symbol() がそのままグループ名とシンボルになる

この設計の巧みなところは、グループを「新しい概念」としてではなく、既存のERC20トークンの「読み取り方」として定義している点です。 つまり、すでにデプロイ済みのERC20トークンも、アプリケーション側がそのトークンの残高をメンバーシップとして解釈するだけで、グループとして機能させることができます。

MintとBurn

トークンのMint(新規発行)とBurn(焼却)の方法は、この提案では規定されていません。 ユースケースに応じて実装者が自由に決定します。

Mint方式 説明
標準的な mint(address, uint256) オーナーが特定アドレスにトークンを発行する方式
ERC5679準拠の mint(address, uint256, bytes) 追加データを含むMint
固定供給 初期発行後にMintを一切行わない。メンバーシップは初期配布のみ
ガバナンス制御 投票などのガバナンスプロセスを経てMintを承認する方式
オープンMint 誰でも自由にMintしてグループに参加できる方式

Mint方式を標準化しないことで、さまざまなユースケースに柔軟に対応できます。 例えば、招待制のグループでは管理者のみがMintでき、オープンコミュニティでは誰でもMintできるといった設計が可能です。 Burnについても同様に実装定義であり、メンバーが自発的に脱退するケースや、管理者がメンバーを除名するケースなど、グループの性質に合わせた設計が求められます。

補足

設計判断の背景

この提案ではいくつかの重要な設計判断がなされています。

まず、グループを純粋なERC20トークンとして定義した理由は、新しいトークン規格を導入する必要を排除するためです。 ERC20トークンに「メンバーシップ」という意味を付与するだけで、ウォレット・DEX・エクスプローラーといった既存のエコシステム全体との互換性を即座に獲得できます。

次に、Mint・Burnの方式をこの提案で規定しない判断がなされています。 これは、ユースケースごとに最適なMintポリシーが大きく異なるためです。 招待制のプライベートグループとオープン参加のコミュニティでは、メンバーの追加方法は根本的に異なります。 Mint方式を規定すると、特定のユースケースに偏ってしまい、汎用性が失われます。

また、バイナリ(メンバー/非メンバー)ではなくしきい値ベースのメンバーシップを採用したのは、ティア制アクセスやロイヤリティプログラム、投票権の比例配分といったユースケースを自然に表現できるためです。 バイナリメンバーシップが必要な場合は、残高を 1 以下に制限するだけで対応できるため、しきい値ベースの方がより柔軟な設計です。

isMember 関数をオプションとした理由は、balanceOf を直接呼び出すことでも同等の判定が可能だからです。 この関数はあくまで利便性のための補助的なインターフェースであり、アプリケーションが balanceOf(account) >= threshold と直接比較する方法も同様に有効です。

階層型メンバーシップの実装例

原文では、しきい値ベースのメンバーシップを利用するアプリケーション側のコントラクト例として、PartnerBenefits コントラクトが紹介されています。 このコントラクトは、メンバーシップトークンの残高に応じて割引率を返す仕組みです。

contracts/PartnerBenefits.sol
contract PartnerBenefits {
    IERC20 public membershipToken;

    uint256 public constant BRONZE = 100 * 10**18;
    uint256 public constant SILVER = 500 * 10**18;
    uint256 public constant GOLD = 1000 * 10**18;

    function getDiscount(address user) external view returns (uint256) {
        uint256 balance = membershipToken.balanceOf(user);
        if (balance >= GOLD) return 30;
        if (balance >= SILVER) return 20;
        if (balance >= BRONZE) return 10;
        return 0;
    }

    function isMember(address user) external view returns (bool) {
        return membershipToken.balanceOf(user) > 0;
    }
}

このコントラクトでは、getDiscount 関数が残高に応じた割引率をパーセンテージで返します。 ゴールドメンバー(1000トークン以上)には30%、シルバーメンバー(500トークン以上)には20%、ブロンズメンバー(100トークン以上)には10%の割引が適用されます。 トークンを一切保有していないアカウントには割引は適用されません。

ティア 必要残高 割引率
ゴールド 1000トークン以上 30%
シルバー 500トークン以上 20%
ブロンズ 100トークン以上 10%
非メンバー 0トークン 0%

この実装で注目すべき点は、PartnerBenefits コントラクトがメンバーシップトークン自体を管理していないことです。 外部のERC20トークンの balanceOf を読み取るだけで、メンバーシップの判定と特典の付与を実現しています。 つまり、メンバーシップの管理はトークンコントラクトに任せ、特典のロジックだけを別コントラクトで実装するという、責務の明確な分離が行われています。

isMember 関数は、残高が 0 より大きいかどうかで単純なメンバー/非メンバーの判定を行います。 ティアの判定が不要な場合は、この関数だけでメンバーシップの確認が完結します。

オプション拡張

ERC173とERC8023による所有権管理

グループに単一のオーナーを設定したい場合、ERC173(Ownable)やERC8023による標準化されたオーナーシップパターンを組み合わせることができます。 例えば、オーナーのみがMintやBurnを実行できるようにして、メンバーシップの発行と取り消しを管理者に限定する運用が可能です。

バイナリメンバーシップ

ティア制が不要で「メンバーかどうか」の二値判定だけが必要なユースケースでは、すべてのアカウントの残高を 1 以下に制限する実装が考えられます。 この場合、残高が 1 であればメンバー、0 であれば非メンバーという単純な構造になります。 transfer で残高が 1 を超えないように制約を加えることで、この制限を強制できます。

互換性

ERC8063はERC20と完全に後方互換です。 この提案はトークンの動作を一切変更しておらず、既存のERC20トークンの残高を「メンバーシップ」として読み取るという意味的な解釈を定義しているだけです。

規格 互換性 備考
ERC20 ✅ 完全互換 グループコントラクトはERC20を実装する。既存のERC20トークンもそのままグループとして利用可能
ERC165 ✅ 完全互換 supportsInterface によるインターフェース検出をサポート
既存ウォレット ✅ そのまま動作 MetaMask等のウォレットで残高表示・送受信が可能
既存DEX ✅ そのまま動作 メンバーシップトークンを通常のトークンと同様に取引できる
ブロックエクスプローラー ✅ そのまま動作 Etherscan等で保有者一覧やTransferイベントを確認できる

この提案の最も重要な互換性の特徴は、すでにデプロイされているERC20トークンも、アプリケーション側が残高をメンバーシップとして解釈するだけでグループとして利用できる点です。 つまり、既存のトークンに対して一切の変更を加えることなく、メンバーシップの仕組みを後付けで導入できます。

セキュリティ

トークン経済性とメンバーシップの関係

メンバーシップがトークン残高に直結しているため、トークンの市場価格や流動性がメンバーシップの取得コストに影響します。 例えば、トークン価格が急騰した場合、新規メンバーがしきい値分のトークンを取得するコストが増加します。 逆に価格が暴落した場合は、メンバーシップの取得が容易になり、意図しない大量参加が起きる可能性があります。 アプリケーション設計時には、トークン価格の変動がメンバーシップの質に与える影響を考慮する必要があります。

しきい値操作リスク

しきい値をオンチェーンに保存してガバナンスで変更可能にしている場合、不正な変更によってメンバーシップの基準が操作されるリスクがあります。 例えば、悪意のある提案によってしきい値が極端に高く設定されると、既存メンバーのティアが強制的に降格されます。 しきい値の変更にはタイムロックやマルチシグによる承認を設けることが推奨されます。

トークン移転によるメンバーシップ変動

標準的なERC20の transfer 関数でトークンを移転すると、送信者のメンバーシップレベルが低下し、受信者のレベルが上昇します。 これは意図した動作ですが、ユースケースによっては問題になる場合があります。 例えば、ロイヤリティプログラムでメンバーシップの売買が望ましくない場合は、トークンの移転を制限する transfer のオーバーライドを検討する必要があります。

承認リスクとdecimal処理

ERC20標準の approve によるリスクがそのまま適用されます。 他のアドレスに対して approve を行うと、そのアドレスがメンバーシップトークンを移転できるようになり、結果として承認者のメンバーシップが失われる可能性があります。 また、しきい値を設定する時には、トークンの decimals を正しく考慮することが重要です。 decimals が 18 のトークンで「100トークン」をしきい値にする場合、実際の値は 100 * 10**18 です。 この変換を誤ると、意図したティア判定が正しく機能しません。

Mint権限のセキュリティ

Mintが許可されているコントラクトでは、誰がMintを実行できるかを厳密に管理する必要があります。 Mint権限が適切に制御されていないと、不正なメンバーシップの付与が発生します。 例えば、管理者のみがMint可能にする、マルチシグで承認する、ガバナンス投票を経るなど、ユースケースに応じた制御が求められます。

参考実装

IERC8063.sol

メンバーシップ確認のためのインターフェース定義です。

contracts/IERC8063.sol
// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.20;

/// @title IERC8063 — Optional membership introspection for ERC-20 tokens
/// @notice A Group is an ERC-20 token where balance represents membership level
interface IERC8063 {
    /// @notice Returns true if `account` holds at least `threshold` tokens
    /// @param account The address to check
    /// @param threshold Minimum balance required for membership at this tier
    /// @dev Equivalent to: balanceOf(account) >= threshold
    function isMember(address account, uint256 threshold) external view returns (bool);
}

IERC8063 は isMember 関数のみを定義したシンプルなインターフェースです。 この関数は balanceOf(account) >= threshold と等価であるため、内部的には balanceOf を呼び出してしきい値と比較するだけの実装になります。

ERC8063.sol

参考実装では、ERC20の基本機能に加えて、IERC8063 とERC165を実装しています。

contracts/ERC8063.sol
// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.20;

import "./IERC8063.sol";

contract ERC8063 is IERC8063 {
    string private _name;
    string private _symbol;
    uint8 private immutable _decimals;
    address private _owner;
    uint256 private _totalSupply;

    mapping(address => uint256) private _balances;
    mapping(address => mapping(address => uint256)) private _allowances;

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);

    modifier onlyOwner() {
        require(msg.sender == _owner, "Not owner");
        _;
    }

    constructor(
        string memory groupName,
        string memory groupSymbol,
        uint8 tokenDecimals,
        uint256 initialSupply
    ) {
        _name = groupName;
        _symbol = groupSymbol;
        _decimals = tokenDecimals;
        _owner = msg.sender;

        if (initialSupply > 0) {
            _balances[msg.sender] = initialSupply;
            _totalSupply = initialSupply;
            emit Transfer(address(0), msg.sender, initialSupply);
        }
    }

    // ============ ERC-165 ============

    function supportsInterface(bytes4 interfaceId) external pure returns (bool) {
        return
            interfaceId == type(IERC8063).interfaceId ||
            interfaceId == 0x36372b07 || // ERC-20
            interfaceId == 0x01ffc9a7;   // ERC-165
    }

    // ============ ERC-20 ============

    function name() public view returns (string memory) { return _name; }
    function symbol() public view returns (string memory) { return _symbol; }
    function decimals() public view returns (uint8) { return _decimals; }
    function totalSupply() public view returns (uint256) { return _totalSupply; }

    function balanceOf(address account) public view returns (uint256) {
        return _balances[account];
    }

    function transfer(address to, uint256 amount) public returns (bool) {
        _transfer(msg.sender, to, amount);
        return true;
    }

    function allowance(address owner, address spender) public view returns (uint256) {
        return _allowances[owner][spender];
    }

    function approve(address spender, uint256 amount) public returns (bool) {
        require(spender != address(0), "Zero address");
        _allowances[msg.sender][spender] = amount;
        emit Approval(msg.sender, spender, amount);
        return true;
    }

    function transferFrom(address from, address to, uint256 amount) public returns (bool) {
        uint256 currentAllowance = _allowances[from][msg.sender];
        require(currentAllowance >= amount, "Insufficient allowance");
        unchecked {
            _allowances[from][msg.sender] = currentAllowance - amount;
        }
        _transfer(from, to, amount);
        return true;
    }

    function _transfer(address from, address to, uint256 amount) internal {
        require(from != address(0), "Transfer from zero");
        require(to != address(0), "Transfer to zero");
        require(_balances[from] >= amount, "Insufficient balance");
        unchecked {
            _balances[from] -= amount;
        }
        _balances[to] += amount;
        emit Transfer(from, to, amount);
    }

    // ============ Minting/Burning ============

    function mint(address to, uint256 amount) external onlyOwner {
        require(to != address(0), "Mint to zero");
        _balances[to] += amount;
        _totalSupply += amount;
        emit Transfer(address(0), to, amount);
    }

    function burn(address from, uint256 amount) external {
        require(msg.sender == _owner || msg.sender == from, "Not authorized");
        require(_balances[from] >= amount, "Insufficient balance");
        unchecked {
            _balances[from] -= amount;
            _totalSupply -= amount;
        }
        emit Transfer(from, address(0), amount);
    }

    // ============ IERC8063 ============

    function isMember(address account, uint256 threshold) public view override returns (bool) {
        return _balances[account] >= threshold;
    }

    // ============ Ownership ============

    function owner() public view returns (address) { return _owner; }

    function transferOwnership(address newOwner) external onlyOwner {
        require(newOwner != address(0), "Zero address");
        _owner = newOwner;
    }
}

この参考実装のポイントを解説します。

コンストラクタ

コンストラクタでは、グループ名(name)、シンボル(symbol)、小数点桁数(decimals)、および初期供給量(initialSupply)を受け取ります。 初期供給量が 0 より大きい場合は、デプロイ者に全量が割り当てられます。 デプロイ者はその後 transfer で他のメンバーにトークンを配布することで、グループのメンバーシップを付与します。

supportsInterface

ERC165の supportsInterface 関数では、IERC8063 のインターフェースID、ERC20のインターフェースID(0x36372b07)、およびERC165自身のインターフェースID(0x01ffc9a7)に対して true を返します。 これにより、外部のアプリケーションがこのコントラクトのサポートするインターフェースを動的に検出できます。

isMember

isMember 関数の実装は非常にシンプルで、_balances[account] >= threshold の結果をそのまま返しています。 内部ストレージの _balances マッピングに直接アクセスしているため、balanceOf 関数を経由するよりもわずかにガスを節約できます。

MintとBurn

この参考実装では、mint はオーナーのみが実行でき、burn はオーナーまたはトークン所有者本人が実行できる設計になっています。 これは1つの実装例であり、ユースケースに応じて変更することが想定されています。

引用

最後に

今回は「ERC8063によるERC20トークンを活用したグループメンバーシップの仕組み」についてまとめてきました! いかがだったでしょうか?

この提案の最大の特徴は、新しいトークン規格を作らずに、既存のERC20トークンの残高に「メンバーシップレベル」という意味を付与するという、シンプルながら強力なアプローチです。 既存のインフラをそのまま活用できるため、導入のハードルが非常に低い点が魅力的です。

他でも色々記事を書いているのでぜひよろしければ読んでいってください!