はじめに

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

今回は、DAppの参加者数に応じて自動的に報酬を分配できる、分散型のトークン報酬システムの仕組みを提案しているERC1973についてまとめていきます!

以下にまとめられているものを翻訳・要約・補足しながらまとめていきます。

他にも様々なEIPについてまとめています。

概要

ERC1973では、分散型アプリケーション(DApp)においてアクティブな参加者に対し、ブロックごとに発行されるトークンを均等に分配する仕組みが紹介されています。 この仕組みは、トークンをMintして、DAppのエコシステム内にいる参加者へ公平に配布するための標準的なインターフェースです。

トークンの発行はブロック単位で行われ、発行量は設定可能です(例:1ブロックごとに10.2356トークン、0.1トークン、1350トークンなど)。 このインターフェースの特長は、任意の参加者が報酬を請求できる「プル型(pull-based)」の設計になっており、トークン配布が完全に分散化されている点にあります。

参加者の数に応じて、1人あたりに分配されるトークン量は動的に変化します。 つまり、初期の参加者が少ない時期は1人あたりの報酬が大きく、ネットワークが拡大し参加者が増えると、1人あたりの報酬が自然と少なくなるように設計されています。

動機

従来のトークン配布は、多くの場合「プッシュ型(push-based)」で行われてきました。 これは、DApp側が全参加者のアドレスをループ処理してトークンを一括で送る方法です。 しかし、Ethereumのようなブロックチェーンでは、1つのトランザクションで実行できる処理量に制限(ブロックガスリミット)があるため、参加者が増えるほど配布処理が重くなり、実行が困難になります。

例えば、5000人のアドレスに対してごく少額のトークン(0.0000001トークン)を配るだけでも、非常に多くのガスが必要になり非効率的です。 また、そのトランザクションを送るためのガス代をDAppの運営者が負担する必要があるため、結果的に中央集権的な運用になりがちです。

この課題を解決するためには、各参加者が自ら報酬を請求する「プル型」の仕組みが必要です。 しかし、現時点ではこの方法を使って数万人規模の参加者にスケーラブルに報酬を配布できる標準的な仕組みは存在しません。

ERC1973では、TPP(Token Per Participant)やラウンドマスク、参加者マスクといった概念を導入することで、完全に分散化された形で効率よくトークン報酬を配布できる仕組みを実現しようとしています。 これにより、DApp開発者はガス代の負担を最小限に抑えつつ、大規模なネットワークにおいても持続可能な報酬設計を行うことができます。

仕様

定義

TPP(Token Per Participant)とは

TPPとは、エコシステム内の参加者1人あたりに割り当てられるトークン量を意味します。 これは、1ブロックごとにミントされるトークン量を、その時点でのアクティブな参加者数で割ることで計算されます。 例えば、10トークンがミントされ、アクティブ参加者が100人であれば、TPPは 10 / 100 = 0.1 トークンとなります。

この指標は、参加者の増減に応じて報酬が動的に変化する仕組みを支えています。 初期段階で参加者が少ないと、1人あたりの報酬は大きく、参加者が増えるにつれて1人あたりの報酬が自然と小さくなります。

roundMaskとは

roundMaskは、これまでのすべてのブロックにおけるTPPの累積値を表すスナップショットです。 これは全体の報酬履歴を保持するために使用され、時間経過とともに更新されていきます。

例を挙げると、以下のように2つのミントイベントがあった場合、

  • 1回目 10トークンが100人に対してミント → TPP = 10 / 100
  • 2回目 12トークンが95人に対してミント → TPP = 12 / 95

このとき、roundMaskは 10/100 + 12/95 という合計値になります。

この値はスマートコントラクト全体で共有され、すべての参加者にとって共通の報酬基準となります。

participantMaskとは

participantMaskは、各参加者(msg.sender)が自分自身の報酬権利を追跡するための値です。 参加者がネットワークに参加または離脱するタイミングでこのマスクが更新されます。

新しく参加したときには、直前のroundMaskの値がそのままparticipantMaskとして記録されます。 つまり、参加者が報酬を受け取る基準点を示すマーカーのようなものです。

報酬の計算方法

報酬は以下のように計算されます。

text
報酬 = 現在の roundMask - その参加者の participantMask

これは、「ネットワーク全体で累積された報酬量」から「その参加者が入ってきた時点の報酬量」を引いた差分が、その参加者にとっての報酬という考え方です。

報酬計算の例

例えば、以下のような条件を考えます。

  • 各トランザクションで20トークンがミントされる
  • 初期参加者数は10人
  • t2で11人目の参加者が新規参加し、t5の前に離脱する

roundMaskの推移は以下のようになります。

text
t1: roundMask = 20 / 10
t2: roundMask += 20 / 11
t3: roundMask += 20 / 11
t4: roundMask += 20 / 11
t5: roundMask += 20 / 10
t6: roundMask += 20 / 10

11人目の参加者がt2に参加した際のparticipantMaskは 20 / 10 で、t5の前に離脱するため、受け取る報酬は以下になります。

text
[(20 / 11) + (20 / 11) + (20 / 11)] = 合計報酬

つまり、t2〜t4のroundMaskの増加分がこの参加者の受け取るトークン量になります。

再参加時の処理

一度離脱した参加者が、後の任意の時点(例えば t27 や t35)で再度ネットワークに参加した場合、新たなparticipantMaskがその時点のroundMaskに基づいて割り当てられます。 このように、参加と離脱を繰り返しても、それぞれの区間で正確に報酬を計算できるようになっています。

tokensPerBlockとは

この値は、1ブロックあたりに新しく発行されるトークンの量を示します。 ネットワークの成長や報酬設計に応じて任意に設定できます(例:0.1トークン、10トークン、1000トークンなど)。

blockFreezeIntervalとは

トークンが連続して何ブロックごとにミント可能かを定義するパラメータです。 例えば、この値が50に設定されていて、あるミントがブロック番号100で実行された場合、次のミントはブロック150まで実行できません。 これにより、過剰な報酬発行を防ぎ、インフレーション制御にも役立ちます。

lastMintedBlockNumberとは

最後にトークンがミントされたブロック番号を記録する値です。これにより、blockFreezeIntervalと組み合わせて、次のミントが可能なタイミングを判定します。

totalParticipantsとは

現在アクティブな参加者の総数です。この値はTPPの計算などに使われ、参加者が増減するたびにリアルタイムで更新されます。

tokencontractAddressとは

ミントされたトークンを受け取るトークンコントラクトのアドレスです。 基本的には現在のコントラクト自身(address(this))が設定されますが、必要に応じて別のアドレスを指定することも可能です。 これは、外部のERC20トークンコントラクトなどに柔軟に対応するためです。

インターフェース

js
pragma solidity ^0.5.2;

import "openzeppelin-solidity/contracts/token/ERC20/ERC20Mintable.sol";
import "openzeppelin-solidity/contracts/token/ERC20/ERC20Detailed.sol";

contract Rewards is ERC20Mintable, ERC20Detailed {

    using SafeMath for uint256;
    
    uint256 public roundMask;
    uint256 public lastMintedBlockNumber;
    uint256 public totalParticipants = 0;
    uint256 public tokensPerBlock; 
    uint256 public blockFreezeInterval; 
    address public tokencontractAddress = address(this);
    mapping(address => uint256) public participantMask; 
    
    /**
     * @dev constructor, initializes variables.
     * @param _tokensPerBlock The amount of token that will be released per block, entered in wei format (E.g. 1000000000000000000)
     * @param _blockFreezeInterval The amount of blocks that need to pass (E.g. 1, 10, 100) before more tokens are brought into the ecosystem.
     */
    constructor(
         uint256 _tokensPerBlock, uint256 _blockFreezeInterval
     ) public ERC20Detailed("Simple Token", "SIM", 18){ 
        lastMintedBlockNumber = block.number;
        tokensPerBlock = _tokensPerBlock;
        blockFreezeInterval = _blockFreezeInterval;
    }
    
    /**
     * @dev Modifier to check if msg.sender is whitelisted as a minter. 
     */
    modifier isAuthorized() {
        require(isMinter(msg.sender));
        _;
    }
    
    /**
     * @dev Function to add participants in the network. 
     * @param _minter The address that will be able to mint tokens.
     * @return A boolean that indicates if the operation was successful.
     */
    function addMinters(address _minter) external returns (bool) {
        _addMinter(_minter);
        totalParticipants = totalParticipants.add(1);
        updateParticipantMask(_minter);
        return true;
    }
    
    
    /**
     * @dev Function to remove participants in the network. 
     * @param _minter The address that will be unable to mint tokens.
     * @return A boolean that indicates if the operation was successful.
     */
    function removeMinters(address _minter) external returns (bool) {
        totalParticipants = totalParticipants.sub(1);
        _removeMinter(_minter); 
        return true;
    }
    
    
    /**
     * @dev Function to introduce new tokens in the network. 
     * @return A boolean that indicates if the operation was successful.
     */
    function trigger() external isAuthorized returns (bool) {
        bool res = readyToMint();
        if(res == false) {
        return false;
        } else {
        mintTokens();
        return true;
        }
    }
    
    /**
     * @dev Function to withdraw rewarded tokens by a participant. 
     * @return A boolean that indicates if the operation was successful.
     */
    function withdraw() external isAuthorized returns (bool) {
        uint256 amount = calculateRewards();
        require(amount >0);
        ERC20(tokencontractAddress).transfer(msg.sender, amount);
    }
    
    /**
     * @dev Function to check if new tokens are ready to be minted. 
     * @return A boolean that indicates if the operation was successful.
     */
    function readyToMint() public view returns (bool) {
        uint256 currentBlockNumber = block.number;
        uint256 lastBlockNumber = lastMintedBlockNumber;
        if(currentBlockNumber > lastBlockNumber + blockFreezeInterval) { 
        return true;
        } else {
        return false;
        }
    }
    
    /**
     * @dev Function to calculate current rewards for a participant. 
     * @return A uint that returns the calculated rewards amount.
     */
    function calculateRewards() private returns (uint256) {
        uint256 playerMask = participantMask[msg.sender];
        uint256 rewards = roundMask.sub(playerMask);
        updateParticipantMask(msg.sender);
        return rewards;
    }
    
    /**
     * @dev Function to mint new tokens into the economy. 
     * @return A boolean that indicates if the operation was successful.
     */
    function mintTokens() private returns (bool) {
        uint256 currentBlockNumber = block.number;
        uint256 tokenReleaseAmount = (currentBlockNumber.sub(lastMintedBlockNumber)).mul(tokensPerBlock);
        lastMintedBlockNumber = currentBlockNumber;
        mint(tokencontractAddress, tokenReleaseAmount);
        calculateTPP(tokenReleaseAmount);
        return true;
    }
    
     /**
    * @dev Function to calculate TPP (token amount per participant).
    * @return A boolean that indicates if the operation was successful.
    */
    function calculateTPP(uint256 tokens) private returns (bool) {
        uint256 tpp = tokens.div(totalParticipants);
        updateRoundMask(tpp);
        return true;
    }
    
     /**
    * @dev Function to update round mask. 
    * @return A boolean that indicates if the operation was successful.
    */
    function updateRoundMask(uint256 tpp) private returns (bool) {
        roundMask = roundMask.add(tpp);
        return true;
    }
    
     /**
    * @dev Function to update participant mask (store the previous round mask)
    * @return A boolean that indicates if the operation was successful.
    */
    function updateParticipantMask(address participant) private returns (bool) {
        uint256 previousRoundMask = roundMask;
        participantMask[participant] = previousRoundMask;
        return true;
    }

}

変数・Modifier

roundMask
javascript
uint256 public roundMask;

報酬の累積スナップショットを記録する変数。 この変数は、これまでにミントされたトークンのうち、1参加者あたりに配布されたトークン量の合計を保持します。 各参加者の報酬計算の基準として使用されます。

パラメータ

  • roundMask
    • 現在のトークン配布の累積値を保持するための変数。
lastMintedBlockNumber
javascript
uint256 public lastMintedBlockNumber;

最後にトークンがミントされたブロック番号を記録する変数。 blockFreezeIntervalと併用することで、次にミントできるタイミングの制御に使用されます。

パラメータ

  • lastMintedBlockNumber
    • 最終ミントが実行されたブロック番号。
totalParticipants
javascript
uint256 public totalParticipants = 0;

現在アクティブな参加者の総数を記録する変数。 トークン配布量(TPP)の計算に用いられます。 addMintersまたはremoveMinters関数で増減されます。

パラメータ

  • totalParticipants
    • アクティブなミンターの人数。
tokensPerBlock
javascript
uint256 public tokensPerBlock;

1ブロックあたりにミントされるトークン数を記録する変数。 Mintされるトークンの発行速度を調整するために使用されます。

パラメータ

  • tokensPerBlock
    • 各ブロックで生成されるトークン量(wei単位)。
blockFreezeInterval
javascript
uint256 public blockFreezeInterval;

トークンのMint間隔をブロック数で制御する変数。 最後のMintからこの数のブロックが経過しないと、新たなMintは許可されません。

パラメータ

  • blockFreezeInterval
    • Mint可能となるまでの待機ブロック数。
tokencontractAddress
javascript
address public tokencontractAddress = address(this);

報酬としてTransferするトークンのアドレス。 デフォルトではこのコントラクト自身がERC20トークンとなっており、自身のバランスから報酬を送付します。

パラメータ

  • tokencontractAddress
    • トークン送付元アドレス。
participantMask
javascript
mapping(address => uint256) public participantMask;

各参加者の報酬計算の基準値を記録するマッピング配列。 ある参加者が前回報酬を受け取ったとき、または初めて参加したときのroundMaskのスナップショットを記録しています。

パラメータ

  • participantMask
    • 参加者アドレスごとのroundMask基準値。
isAuthorized
javascript
modifier isAuthorized() {
    require(isMinter(msg.sender));
    _;
}

呼び出し元アドレスがミンターかどうかを検証する修飾子。 msg.senderがERC20MintableのisMinter関数で許可されたミンターであることを保証します。

パラメータ

  • msg.sender
    • 呼び出し元アドレスがミンターであることを確認。

関数

addMinters
javascript
function addMinters(address _minter) external returns (bool) {
    _addMinter(_minter);
    totalParticipants = totalParticipants.add(1);
    updateParticipantMask(_minter);
    return true;
}

新たなミンター(参加者)を追加する関数。 与えられたアドレスをミンターとして登録し、参加者数を1人分加算してそのアドレスのparticipantMaskも更新します。

引数

  • _minter
    • ミンターとして登録するアドレス。

戻り値

  • true
    • 成功した場合はtrueを返します。
removeMinters
javascript
function removeMinters(address _minter) external returns (bool) {
    totalParticipants = totalParticipants.sub(1);
    _removeMinter(_minter); 
    return true;
}

既存のミンターをネットワークから削除する関数。 アドレスのミンター権限を剥奪し、参加者数を1人分減算します。

引数

  • _minter
    • 削除するミンターのアドレス。

戻り値

  • true
    • 成功時にtrueを返します。
trigger
javascript
function trigger() external isAuthorized returns (bool) {
    bool res = readyToMint();
    if(res == false) {
        return false;
    } else {
        mintTokens();
        return true;
    }
}

Mint条件が満たされた場合にトークンをMintする関数。 現在のブロックが前回Mintから指定ブロック数以上経過していれば、新しいトークンをMintします。

戻り値

  • true
    • Mintに成功した場合。
  • false
    • Mint条件が満たされていない場合。
withdraw
javascript
function withdraw() external isAuthorized returns (bool) {
    uint256 amount = calculateRewards();
    require(amount >0);
    ERC20(tokencontractAddress).transfer(msg.sender, amount);
}

参加者が自分の報酬を引き出す関数。 calculateRewards関数で得た報酬額をコントラクトからmsg.senderにTransferします。

readyToMint
javascript
function readyToMint() public view returns (bool) {
    uint256 currentBlockNumber = block.number;
    uint256 lastBlockNumber = lastMintedBlockNumber;
    if(currentBlockNumber > lastBlockNumber + blockFreezeInterval) { 
        return true;
    } else {
        return false;
    }
}

新しいトークンをMint可能かどうかを判定する関数。 前回Mintから設定されたブロック間隔が経過しているかを判定します。

戻り値

  • true
    • Mint可能。
  • false
    • まだMintできない。

calculateRewards

javascript
function calculateRewards() private returns (uint256) {
    uint256 playerMask = participantMask[msg.sender];
    uint256 rewards = roundMask.sub(playerMask);
    updateParticipantMask(msg.sender);
    return rewards;
}

参加者に割り当てられた報酬を計算する関数。 roundMaskと参加者のparticipantMaskの差分から、受け取り可能な報酬を算出します。

戻り値

  • rewards
    • msg.senderが受け取れるトークン量。
mintTokens
javascript
function mintTokens() private returns (bool) {
    uint256 currentBlockNumber = block.number;
    uint256 tokenReleaseAmount = (currentBlockNumber.sub(lastMintedBlockNumber)).mul(tokensPerBlock);
    lastMintedBlockNumber = currentBlockNumber;
    mint(tokencontractAddress, tokenReleaseAmount);
    calculateTPP(tokenReleaseAmount);
    return true;
}

新たにトークンをMintする関数。 Mint間隔とトークン発行数を計算し、コントラクトにトークンをMintしてからTPPを更新します。

戻り値

  • true
    • Mint成功時。
calculateTPP
javascript
function calculateTPP(uint256 tokens) private returns (bool) {
    uint256 tpp = tokens.div(totalParticipants);
    updateRoundMask(tpp);
    return true;
}

1人あたりに配布されるトークン量(TPP)を計算する関数。 Mintされたトークン総量を参加者数で割り、TPPを求めてroundMaskを更新します。

引数

  • tokens
    • 今回Mintされたトークン総量。

戻り値

  • true
    • 計算が成功した場合。
updateRoundMask
javascript
function updateRoundMask(uint256 tpp) private returns (bool) {
    roundMask = roundMask.add(tpp);
    return true;
}

roundMaskの値を更新する関数。 新しいTPP値を加算して、累積された報酬スナップショットを更新します。

引数

  • tpp
    • 1人あたりの報酬。

戻り値

  • true
    • 更新が成功した場合。
updateParticipantMask
javascript
function updateParticipantMask(address participant) private returns (bool) {
    uint256 previousRoundMask = roundMask;
    participantMask[participant] = previousRoundMask;
    return true;
}

個別参加者のマスクを更新する関数。 参加者が報酬を請求または新規参加した時点のroundMaskを記録します。

引数

  • participant
    • 対象の参加者アドレス。

戻り値

  • true
    • 正常に記録された場合。

補足

背景

DApp(分散型アプリケーション)における健全で持続可能なトークンエコノミーを構築するためには、「インセンティブ(報酬)」が重要な役割を果たします。 参加者がネットワークに貢献する動機として機能するためです。 しかし、現状では数万人規模の参加者に対して効率的かつ平等に報酬を分配する標準的な仕組みが存在していません。

多くのDAppは、報酬分配の処理を実際にはサーバーサイドで制御しており、中央集権的な構成をとっています。 つまり、ブロックチェーンという分散型基盤を活用しながらも、報酬の仕組みそのものは非分散的になってしまっているのが実情です。

中央集権的な報酬分配の問題

持続的なトークン報酬設計が機能しているDAppの多くは、中央集権型のサーバーや、信頼された一部のノード群がスマートコントラクトを定期的に実行し、トークンを分配しています。 しかしこの方法は、以下のような問題を抱えています。

  • 実行主体が限られており、検閲耐性が弱い。
  • 管理者に依存する設計であるため、真に自律的ではない。
  • 万が一、実行主体に障害が起きた場合、報酬分配が止まる。

このように、報酬分配の仕組みがDApp全体の分散性を損ねてしまう要因となっています。

ERC1973の目的

ERC1973は、こうした課題を解決するために設計されています。 中心的な発想は、報酬分配そのものをネットワークの「参加者自身」に委ねるというものです。 つまり、参加者の誰もが報酬をトリガー(実行)でき、その分配は現在の参加者数に応じて自動的に調整される仕組みです。

このアプローチにより、以下のようなメリットが実現されます。

  • サーバーや特定ノードへの依存がなくなるため、完全な分散化が可能になる。
  • 参加者の増減に応じて分配量が変化するため、スケーラビリティに優れた設計となる。
  • どの参加者でもトリガー可能なため、オープンで参加性の高い経済圏が構築できる。

このように、ERC1973はDAppが自律的かつ拡張可能なインセンティブ構造を持つための根幹となる仕様として位置づけられています。 真に分散されたアプリケーションを成立させるためには、こうした報酬分配の標準化が不可欠なのです。

参考実装

はじめに

ERC1973では、スケーラブルかつ分散化された報酬分配システムを構築するための仕組みが提案されています。 実際に動作する一例として、Ropstenネットワーク上のデプロイ済みコントラクトと、それを使った報酬の流れが示されています。 ここではその手順と各ステップで何が起きているのかを、順を追って詳しく解説します。

ステップ1:コントラクトのデプロイ

まず最初に、Rewardsコントラクトを以下の2つの初期パラメータでデプロイします。

  • _tokensPerBlock 1e18(= 1 SIMトークン)
  • _blockFreezeInterval 1(= 毎ブロックごとにミント可能)

これにより、毎ブロック1 SIMトークンが発行され、ブロック間の制限は最小の1ブロックに設定されます。

ステップ2:参加者の登録

最初の参加者として、以下の2名をミンター(報酬をトリガー可能なアクター)として登録します。

  • Alice(アドレス:0x123)
  • Bob(アドレス:0x456)

addMinters(address) 関数を使うことで、2名ともネットワークに正式参加となり、報酬分配の対象になります。

ステップ3:報酬のトリガーとミント(1回目)

Alice または Bob のどちらかが trigger() 関数を呼び出します。 この時点で前回のMintから65ブロックが経過していたため、65 SIMトークンが新たにMintされます。

  • 総発行量:65 SIM
  • 参加者数:2人(Alice と Bob)
  • 1人あたりの報酬(TPP):32.5 SIM

この結果、roundMask(RM) の値は 32500000000000000000(= 32.5 * 10^18)となります。

ステップ4:Alice が報酬を引き出す

Alice が withdraw() を呼び出すことで、累積された報酬を受け取ります。

  • 受け取り:32.5 SIM
  • participantMask(PM):Alice にとっての報酬受け取りスナップショットが 32500000000000000000 に更新されます。

ステップ5:参加者の追加(Satoshi と Vitalik)

新たに以下の2名を追加します。

  • Satoshi(アドレス:0x321)
  • Vitalik(アドレス:0x654)

これにより、参加者は合計4名となります。

ステップ6:報酬のトリガーとミント(2回目)

Alice / Bob / Satoshi / Vitalik の誰かが再度 trigger() を実行した時点で、101ブロックが経過しており、101 SIMトークンがミントされます。

  • 総発行量:101 SIM
  • 参加者数:4人
  • 1人あたりの報酬(TPP):25.25 SIM

これにより、roundMask は以下のように更新されます:

text
新RM = 32500000000000000000 + 25250000000000000000 = 57750000000000000000

ステップ7:Alice が再度報酬を引き出す

Alice は2回目の報酬を受け取ります。このとき、前回のスナップショットとの差分が彼女の報酬になります。

  • 差分:57750000000000000000 - 32500000000000000000 = 25250000000000000000
  • 受け取り:25.25 SIM
  • participantMask:Alice の PM は 57750000000000000000 に更新されます。

ステップ8:Bob が報酬を引き出す

Bob はまだ一度も withdraw() をしていなかったため、2回分の報酬をまとめて受け取ります。

  • 1回目:32.5 SIM(65 / 2)
  • 2回目:25.25 SIM(101 / 4)
  • 合計:57.75 SIM
  • participantMask:Bob の PM も 57750000000000000000 に更新されます。

引用

Lee Raj (@lerajk), Qin Jian (@qinjian), "ERC-1973: Scalable Rewards [DRAFT]," Ethereum Improvement Proposals, no. 1973, April 2019. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-1973.

最後に

今回は、DAppの参加者数に応じて自動的に報酬を分配できる、分散型のトークン報酬システムの仕組みを提案しているERC1973についてまとめていきます! 今回は「」についてまとめてきました! いかがだったでしょうか?

質問などがある方は以下のTwitterのDMなどからお気軽に質問してください!

他の媒体でも情報発信しているのでぜひ他も見ていってください!