はじめに

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

今回は、ERC20トークンに複数のアンロック条件を組み合わせて設定し、すべての条件が満たされた時にのみトークンを受け取れるようにする仕組みを提案しているERC7994についてまとめていきます!

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

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

概要

ERC7994は、ERC7291で導入されたPurpose Bound Money(目的拘束型通貨)の概念をERC20トークンに拡張し、複数のアンロック条件を組み合わせてプログラマブルな送金制約を実現する規格です。

ERC7291はERC1155ベースでトークンの使用先を制限する仕組みを提供していましたが、ERC7994はこれをさらに一般化し、時間的制約、本人確認、用途制限といった複数の条件を同時に課すことを可能にします。 トークンは送信者がロックし、受取人はすべての条件が満たされた時にのみclaim関数でトークンを引き出せます。 有効期限も設定できるため、一定期間内に条件が満たされなかった場合は自動的に請求不可能になります。

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

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

動機

ERC7291はトークンの使用先を制限する仕組みを導入しましたが、実際のユースケースでは複数の条件を同時に満たす必要がある場面が多く存在します。

奨学金の給付

教育機関が学生に奨学金トークンを配布するケースを考えます。 受取人がKYC済みであること、25歳未満であること、大学に在籍していること、これらすべてが確認できて初めてトークンを使用可能にしたいという要件があります。 単一の条件では、KYCを通過したが対象年齢を超えている人物にもトークンが渡ってしまう恐れがありますが、複数条件を組み合わせることで正確な給付対象を制御できます。

NGOによる人道支援

NGOが食料や医薬品の購入にのみ使える支援トークンを配布する場合、用途制限(食料・医薬品のみ)に加えて、特定のアンロック日以降でなければ使えないという時間的制約も同時に設定したいケースがあります。 ERC7994を使えば、こうした複合的な条件をひとつのバインディングにまとめて設定できます。

給与トークンの段階的なアンロック

企業が従業員に給与トークンを配布する場合、毎月のアンロックスケジュールに加えて、支払い先をホワイトリストに登録された業者(銀行、医療機関など)に限定する条件を組み合わせたいケースがあります。 これにより、給与が意図された用途にのみ使われることを保証しつつ、段階的にトークンがアンロックされる仕組みを構築できます。

この提案による解決策

ERC7994はERC20トークンに対して複数のアンロック条件をレイヤー的に積み重ねることで、コンプライアンス対応の助成金、規制に準拠したペイメント、条件付きのインセンティブプログラムなど幅広いユースケースに対応できます。 UnlockCondition構造体の配列として条件を定義し、条件チェッカーコントラクトをプラグイン形式で登録する設計により、新しい条件タイプの追加が容易になっています。 また、有効期限の設定やバインディングIDによる一意管理を組み合わせることで、安全かつ柔軟な条件付き送金の仕組みを実現しています。

仕様

ERC7994に準拠する実装は、IPurposeBoundERC20インターフェースを実装する必要があります。 このインターフェースは、条件付きの送金(purpose binding)を作成、検証、請求するための関数を定義しています。

インターフェース定義

javascript
pragma solidity 0.8.23;

interface IPurposeBoundERC20 {
    struct UnlockCondition {
        bytes32 conditionType; // e.g., "TIME", "KYC", "WHITELIST"
        bytes conditionData;   // e.g., timestamp, Merkle root, etc.
    }

    function bindPurpose(
        address recipient,
        uint256 amount,
        UnlockCondition[] calldata conditions,
        uint256 expiry
    ) external returns (bytes32 bindingId);

    function claim(bytes32 bindingId) external;

    function isUnlocked(bytes32 bindingId) external view returns (bool);
}

このインターフェースは、構造体、バインディング作成関数、請求関数、条件チェック関数の4つの要素で構成されています。

以下の図は、バインディング作成からトークン請求までの処理フローを示しています。

sequenceDiagram
    participant Sender as 送信者
    participant Contract as PurposeBoundERC20
    participant Checker as ConditionChecker
    participant Recipient as 受取人

    Sender->>Contract: bindPurpose(recipient, amount, conditions, expiry)
    Contract->>Contract: トークンをロック (_transfer)
    Contract-->>Sender: bindingId

    Recipient->>Contract: claim(bindingId)
    Contract->>Contract: isUnlocked(bindingId)
    loop 各条件をチェック
        Contract->>Checker: isConditionMet(recipient, conditionData)
        Checker-->>Contract: true/false
    end
    Contract->>Recipient: トークン転送

送信者がbindPurposeを呼び出すと、指定した量のトークンがコントラクトにロックされ、一意のbindingIdが返されます。 受取人がclaimを呼び出すと、コントラクトは内部でisUnlockedを実行し、登録されたすべての条件チェッカーに対してisConditionMetを順番に問い合わせます。 すべての条件がtrueを返した場合にのみ、ロックされていたトークンが受取人に転送されます。

以下でそれぞれの要素を詳しく説明します。

構造体

UnlockCondition

javascript
struct UnlockCondition {
    bytes32 conditionType; // e.g., "TIME", "KYC", "WHITELIST"
    bytes conditionData;   // e.g., timestamp, Merkle root, etc.
}

トークンのアンロックに必要な個々の条件を表す構造体です。 conditionTypeとconditionDataのペアで1つの条件を定義し、バインディング作成時にこの構造体の配列として複数の条件を指定できます。

conditionTypeは条件の種類を示す32バイトの識別子です。 "TIME"であれば時間ベースのアンロック条件を意味し、"KYC"であれば本人確認の完了を意味します。 "WHITELIST"はホワイトリストへの登録を条件とするケースで使われます。 この識別子はオンチェーンの条件チェッカーコントラクトへのルーティングに使用されます。

conditionDataは条件の評価に必要な具体的なデータです。 例えば"TIME"条件であればアンロック対象のUnixタイムスタンプがエンコードされ、"KYC"条件であればMerkleルートやオフチェーン検証データがエンコードされます。 bytes型であるため任意の長さのデータを格納でき、条件の種類に応じて柔軟なデータ構造を持てます。

フィールド 型 説明
conditionType bytes32 条件の種類を示す識別子。"TIME"、"KYC"、"WHITELIST"など。
conditionData bytes 条件の評価に必要なデータ。タイムスタンプ、Merkleルートなどがエンコードされる。

関数

bindPurpose

javascript
function bindPurpose(
    address recipient,
    uint256 amount,
    UnlockCondition[] calldata conditions,
    uint256 expiry
) external returns (bytes32 bindingId);

指定した受取人に対して、条件付きでトークンをロックするバインディングを作成する関数です。

この関数を呼び出すと、送信者の残高からamount分のトークンがコントラクト自身に転送され、ロック状態になります。 受取人(recipient)はすべてのconditionsが満たされるまでトークンを引き出せません。 expiryにはUnixタイムスタンプで有効期限を設定でき、この時刻を過ぎるとバインディングは無効になり、トークンの請求ができなくなります。 expiryが0の場合は有効期限なしとして扱われます。

バインディングの作成に成功すると、一意のbindingIdが返されます。 このIDは送信者、受取人、金額、条件、有効期限、ブロックタイムスタンプからハッシュ計算で生成されるため、同じパラメータでも作成タイミングが異なれば異なるIDになります。

パラメータ 型 説明
recipient address トークンの受取人アドレス。ゼロアドレスは不可。
amount uint256 ロックするトークンの量。0より大きい値である必要がある。
conditions UnlockCondition[] calldata アンロック条件の配列。すべての条件が満たされた時にのみ請求可能。
expiry uint256 有効期限のUnixタイムスタンプ。0の場合は有効期限なし。
  • 戻り値

bytes32 bindingId 作成されたバインディングの一意の識別子。

claim

javascript
function claim(bytes32 bindingId) external;

バインディングされたトークンを受取人が引き出す関数です。

この関数は、bindingIdで指定されたバインディングのトークンを受取人が請求する時に呼び出します。 呼び出し元(msg.sender)がバインディングの受取人と一致していること、まだ請求済みでないこと、すべてのアンロック条件が満たされていることが確認され、すべてを通過した場合にのみトークンがコントラクトから受取人に転送されます。 いずれかの条件が満たされていない場合はトランザクションがリバートされます。

パラメータ 型 説明
bindingId bytes32 請求対象のバインディングID。

isUnlocked

javascript
function isUnlocked(bytes32 bindingId) external view returns (bool);

バインディングのすべてのアンロック条件が満たされているかを確認する関数です。

この関数は、指定されたバインディングに設定された条件をすべて順番にチェックし、すべてが満たされている場合にのみtrueを返します。 既に請求済みのバインディングや、有効期限を過ぎたバインディングに対してはfalseを返します。 view関数であるため状態を変更せず、ガスコストをかけずに条件の充足状況を事前確認できます。

パラメータ 型 説明
bindingId bytes32 確認対象のバインディングID。
  • 戻り値

bool すべての条件が満たされていればtrue、それ以外はfalse。

補足

柔軟性

アンロック条件はモジュラーかつ拡張可能に設計されています。 conditionTypeを新たに定義し、対応する条件チェッカーコントラクトを登録するだけで、新しい種類の条件を追加できます。 既存のバインディングや他の条件チェッカーに影響を与えることなく、システムを拡張できる設計です。

構成性

条件チェッカーをプラグイン形式にすることで、DAO、給与管理、教育、コンプライアンス対応トークンなど、さまざまなユースケースに統合できます。 同じIPurposeBoundERC20インターフェースを使いながら、条件チェッカーの組み合わせを変えるだけで異なるビジネスロジックを実現できるため、コントラクトの再利用性が高まります。

オフチェーン検証のアプローチ

KYCのようなオフチェーンでの検証結果をオンチェーンで利用する場合、Merkleルートなどの暗号学的な証明を通じてデータの改ざんを防止します。 オフチェーンの検証をオンチェーンの証明で裏付けることで、トラストレスな条件評価を実現しています。

参考実装

参考実装では、IPurposeBoundERC20インターフェースの具体的な実装例が示されています。 条件チェッカーのプラグイン方式と、時間ベースの条件チェッカーの実装例を含んでいます。

条件チェッカーインターフェース

javascript
interface IConditionChecker {
    function isConditionMet(address recipient, bytes calldata conditionData) external view returns (bool);
}

各条件の評価ロジックを外部コントラクトとして実装するためのインターフェースです。 recipient(受取人アドレス)とconditionData(条件固有のデータ)を受け取り、条件が満たされていればtrueを返します。 "TIME"条件、"KYC"条件、"WHITELIST"条件など、条件の種類ごとに異なるコントラクトがこのインターフェースを実装します。

メインコントラクト

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

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "./IPurposeBoundERC20.sol";
import "./IConditionChecker.sol";

contract PurposeBoundERC20 is ERC20, IPurposeBoundERC20 {
    struct StoredBinding {
        address recipient;
        uint256 amount;
        UnlockCondition[] conditions;
        bool claimed;
        uint256 expiry;
    }

    mapping(bytes32 => address) public conditionResolvers;
    mapping(bytes32 => StoredBinding) public boundTransfers;

    event PurposeBound(bytes32 indexed bindingId, address indexed from, address indexed to, uint256 amount);
    event Claimed(bytes32 indexed bindingId, address indexed recipient);

    constructor(string memory name_, string memory symbol_) ERC20(name_, symbol_) {
        _mint(msg.sender, 1_000_000 ether);
    }

    function setConditionResolver(bytes32 conditionType, address checker) external {
        conditionResolvers[conditionType] = checker;
    }
}

StoredBinding構造体は、バインディングの全情報をオンチェーンに保存するための内部データ構造です。 recipient(受取人)、amount(ロック量)、conditions(アンロック条件の配列)、claimed(請求済みフラグ)、expiry(有効期限)を保持します。

conditionResolversマッピングは、conditionTypeごとに対応する条件チェッカーコントラクトのアドレスを登録するためのものです。 setConditionResolver関数で管理者が条件チェッカーを登録・更新できます。 本番環境ではアクセス制御(onlyOwnerやAccessControl)を設けることが推奨されています。

boundTransfersマッピングは、bindingIdをキーとしてバインディング情報を保存します。

PurposeBoundイベントはバインディング作成時に、Claimedイベントは請求完了時にそれぞれ発行されます。 これらのイベントにより、オフチェーンのシステムがバインディングの作成と請求をリアルタイムに追跡できます。

bindPurpose関数の実装

javascript
function bindPurpose(
    address recipient,
    uint256 amount,
    UnlockCondition[] calldata conditions,
    uint256 expiry
) external override returns (bytes32 bindingId) {
    require(recipient != address(0), "Invalid recipient");
    require(amount > 0, "Invalid amount");

    bindingId = keccak256(abi.encodePacked(msg.sender, recipient, amount, conditions, expiry, block.timestamp));
    StoredBinding storage stored = boundTransfers[bindingId];
    require(stored.amount == 0, "Binding exists");

    _transfer(msg.sender, address(this), amount);

    for (uint i = 0; i < conditions.length; i++) {
        stored.conditions.push(conditions[i]);
    }

    stored.recipient = recipient;
    stored.amount = amount;
    stored.expiry = expiry;

    emit PurposeBound(bindingId, msg.sender, recipient, amount);
}

バインディング作成の実装では、まずバリデーションとして受取人がゼロアドレスでないこと、金額が0より大きいことを確認しています。

bindingIdはkeccak256で送信者、受取人、金額、条件、有効期限、現在のブロックタイムスタンプをエンコードして生成します。 block.timestampが含まれているため、同一パラメータでもタイミングが異なれば異なるIDが生成され、IDの衝突が防止されます。 既に同じbindingIdが存在する場合はリバートします。

_transferで送信者の残高からコントラクト自身にトークンを移動させ、ロック状態にします。 その後、条件配列をストレージにコピーし、受取人、金額、有効期限を設定してPurposeBoundイベントを発行します。

isUnlocked関数の実装

javascript
function isUnlocked(bytes32 bindingId) public view override returns (bool) {
    StoredBinding storage binding = boundTransfers[bindingId];
    if (binding.claimed) return false;
    if (binding.expiry > 0 && block.timestamp > binding.expiry) return false;

    for (uint i = 0; i < binding.conditions.length; i++) {
        UnlockCondition storage cond = binding.conditions[i];
        address checker = conditionResolvers[cond.conditionType];
        require(checker != address(0), "Checker not set");
        if (!IConditionChecker(checker).isConditionMet(binding.recipient, cond.conditionData)) {
            return false;
        }
    }

    return true;
}

アンロック状態の確認は3段階で行われます。 まず、既に請求済みであればfalseを返します。 次に、有効期限が設定されていて(expiry > 0)、かつ現在時刻が有効期限を過ぎていればfalseを返します。 最後に、すべての条件を順番にチェックし、conditionResolversマッピングから対応する条件チェッカーを取得してisConditionMetを呼び出します。

条件チェッカーが未登録(ゼロアドレス)の場合はリバートし、いずれかの条件が満たされていなければfalseを返します。 すべてのチェックを通過した場合にのみtrueを返します。

claim関数の実装

javascript
function claim(bytes32 bindingId) external override {
    StoredBinding storage binding = boundTransfers[bindingId];
    require(msg.sender == binding.recipient, "Not recipient");
    require(!binding.claimed, "Already claimed");
    require(isUnlocked(bindingId), "Conditions not met");

    binding.claimed = true;
    _transfer(address(this), binding.recipient, binding.amount);

    emit Claimed(bindingId, binding.recipient);
}

請求処理では、呼び出し元がバインディングの受取人と一致していること、まだ請求されていないこと、isUnlockedがtrueを返すことの3つを検証します。 すべての検証を通過すると、claimedフラグをtrueに設定してから、コントラクトから受取人にトークンを転送します。 claimedフラグの設定をトークン転送より先に行うことで、リエントランシー攻撃のリスクを軽減しています。

時間ベースの条件チェッカー

javascript
contract TimeConditionChecker is IConditionChecker {
    function isConditionMet(address, bytes calldata conditionData) external view override returns (bool) {
        uint256 unlockTime = abi.decode(conditionData, (uint256));
        return block.timestamp >= unlockTime;
    }
}

"TIME"条件に対応する条件チェッカーの実装例です。 conditionDataからアンロック時刻(Unixタイムスタンプ)をデコードし、現在のブロックタイムスタンプがアンロック時刻以降であればtrueを返します。 recipient引数は時間条件では使用しないため無視されています。

このように条件チェッカーをプラグイン形式で実装することで、KYC検証、ホワイトリストチェック、地理的制限など、さまざまな条件チェッカーを個別に開発してシステムに組み込めます。

互換性

ERC7994はERC20の拡張として設計されており、既存のERC20トークンの動作には変更を加えません。 ERC7291で導入されたPBMの概念をERC20互換トークンに適用したものであり、ERC1155ベースのERC7291とは独立して使用できます。

セキュリティ

条件チェックメカニズムの安全性

Merkleルートやタイムスタンプといった条件チェックのメカニズムは、改ざん耐性を持つように実装する必要があります。 特にオフチェーンデータ(KYC結果など)をオンチェーンで検証する場合、暗号学的な証明(Merkle Proof等)を使ってデータの正当性を担保する必要があります。

claim関数のアトミックな検証

claim関数はすべての条件を一度のトランザクションでアトミックに検証する必要があります。 条件チェックとトークン転送の間に状態が変化すると、意図しないトークンの流出が発生する可能性があるため、検証と実行を不可分な操作として扱うことが重要です。

リプレイ攻撃の防止

一意のbindingIdと有効期限フィールドを使用してリプレイ攻撃を軽減する必要があります。 bindingIdはバインディング作成時のパラメータとブロックタイムスタンプから生成されるため一意性が保証され、claimedフラグにより二重請求が防止されます。 有効期限を設定することで、期限切れのバインディングが不正に利用されることも防げます。

引用

最後に

今回は「ERC7994による複数条件アンロック付きERC20トークンの仕組み」についてまとめてきました! いかがだったでしょうか?

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

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