はじめに

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

今回は、ERC20トークンの所有権を示すPrincipalトークン提案しているERC5095についてまとめていきます!

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

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

概要

Principalトークンは、ある将来の時点で特定のERC20トークンの所有権を表すトークンです。 ERC5095は、ERC20の基本的な送受信機能に加えて、トークンの入金・出金や残高確認の機能を持ちます。 また、ERC2612で定義されているERC712形式の署名による承認機能も組み込まれています。

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

動機

現在、Principalトークンの設計には統一された基準がなく、プロジェクトごとに異なる実装がなされています。 例えば、将来の利回りを切り離して元本のみをトークン化する「イールドトークン化プラットフォーム」や、Principalトークンを担保にして固定金利で貸し借りする「固定金利型マネーマーケット」などが存在しますが、それぞれが独自の方式でPrincipalトークンを実装しています。

このようなバラバラな実装のせいで、アプリケーションやウォレットでの統合が困難になっています。 開発者は、各種Principalトークンごとに異なるアダプターを作成しなければならず、さらにその関連するプールコントラクトやカストディコントラクトのアダプターまで個別に用意する必要があります。 これにより、多くの開発リソースが無駄に消費されており、エコシステム全体の成長を妨げる原因となっています。

仕様

Principalトークン(以下PT)は、将来的に特定のERC20トークンと引き換え可能な権利を表すトークンです。

EIP-20の実装が必須

全てのPTはERC20を実装する必要があります。 これにより、balanceOf(残高確認)、transfer(送信)、totalSupply(総供給量)などの標準的な関数を利用できます。 これらの操作はすべて、PT自体の残高に対して行われます。

なお、PTを譲渡不可にしたい場合は、transferやtransferFromの呼び出し時にrevertする実装を行うことで実現できます。

メタデータ拡張の実装が必須

ERC20のメタデータ拡張(nameやsymbolなど)は必ず実装する必要があります。 トークン名やシンボルは、できる限り元になるトークンの名称や発行元プロトコルを示すべきです。 特にイールドトークン化を行うプロトコルの場合、どのマネーマーケットから発行されたか示すことが推奨されます。

承認操作の改善にERC2612の採用も可能

UXを改善するために、署名ベースの承認が可能なERC2612の実装も任意で許可されています。 これにより、ユーザーはガス代を節約しながらトークンの使用許可を与えることができます。

用語

  • underlying(元トークン)

    • PTが満期時に交換できるERC20トークンのことです。
  • maturity(満期)

    • PTが引き換え可能になるUNIXタイムスタンプです。
    • この時点以降、PTは元トークンと交換できます。
  • fee(手数料)

    • PTの利用者に課される手数料です。
    • 満期時の償還や、償還後に発生する利回りに対して設定されることがあります。
  • slippage(スリッページ)

    • 表示されている償還価値と、実際に受け取れる金額とのズレです。
    • 手数料として明示されていない場合でも、スリッページが発生することがあります。

関数

underlying

Principalトークンが満期時に引き換えられる対象のトークンアドレスを返す関数関数。
対象となるトークンは必ずERC20に準拠している必要があります。

yml
- name: underlying
  type: function
  stateMutability: view

  inputs: []

  outputs:
    - name: underlyingAddress
      type: address

maturity

Principalトークンが引き換え可能になるUNIXタイムスタンプを返す関数。

yml
- name: maturity
  type: function
  stateMutability: view

  inputs: []

  outputs:
    - name: timestamp
      type: uint256

convertToUnderlying

指定されたPrincipalトークンの量に対して、理想的な条件下で引き換えられるunderlyingトークンの量を計算する関数。

  • 手数料は含まれません。
  • 呼び出し元の違いによって結果が変わってはいけません。
  • 実際のスリッページやオンチェーン状況は考慮しません。
  • 整数オーバーフロー以外でrevertしてはいけません。
  • 小数点以下は切り捨てで処理します。
yml
- name: convertToUnderlying
  type: function
  stateMutability: view

  inputs:
    - name: principalAmount
      type: uint256

  outputs:
    - name: underlyingAmount
      type: uint256

convertToPrincipal

指定されたunderlyingトークンを得るために必要なPrincipalトークンの量を計算する関数。 convertToUnderlyingの逆の処理です。

  • 手数料は含まれません。
  • 呼び出し元によって結果が変わってはいけません。
  • スリッページやオンチェーン状況は考慮しません。
  • 整数オーバーフロー以外でrevertしてはいけません。
  • 小数点以下は切り捨てで処理します。
yml
- name: convertToPrincipal
  type: function
  stateMutability: view

  inputs:
    - name: underlyingAmount
      type: uint256

  outputs:
    - name: principalAmount
      type: uint256

maxRedeem

指定されたアドレスがredeem操作で引き出せるPrincipalトークンの最大量を返す関数。

  • 実際の制限より多い値を返してはいけません(必要なら少なめに見積もること)。
  • グローバルまたはユーザーごとの制限がある場合、それを考慮して返す必要があります。
  • redeemが一時的に無効な場合は、0を返します。
  • revertしてはいけません。
yml
- name: maxRedeem
  type: function
  stateMutability: view

  inputs:
    - name: holder
      type: address

  outputs:
    - name: maxPrincipalAmount
      type: uint256

previewRedeem

実際のredeemの前に、現在のオンチェーン状況をもとに受け取れるunderlyingトークン量をシミュレートする関数。

  • 手数料込みの値を返します。
  • 利用制限(maxRedeemなど)や残高チェックは無視して、理論的に得られる最大量を返します。
  • 一部の条件下ではrevertしても問題ありません(実際のredeemでも同様にrevertするような状況)。
  • convertToUnderlyingとの不一致はスリッページとして扱われます。
yml
- name: previewRedeem
  type: function
  stateMutability: view

  inputs:
    - name: principalAmount
      type: uint256

  outputs:
    - name: underlyingAmount
      type: uint256

redeem

指定したPrincipalトークンをburnし、相応のunderlyingトークンを指定先に送す関数。 満期後のみ実行可能です。

  • Redeemイベントを発行する必要があります。
  • msg.senderが保有者本人またはERC20のapproveをされている必要があります。
  • redeemに失敗する場合(残高不足、制限超過など)はrevertします。
  • 一部のプロトコルでは、事前にburn用の要求が必要な場合があります。
yml
- name: redeem
  type: function
  stateMutability: nonpayable

  inputs:
    - name: principalAmount
      type: uint256
    - name: to
      type: address
    - name: from
      type: address

  outputs:
    - name: underlyingAmount
      type: uint256

maxWithdraw

指定されたアドレスがwithdraw操作で引き出せるunderlyingトークンの最大量を返す関数。

  • 実際の制限より多い値を返してはいけません(必要なら少なめに見積もること)。
  • グローバルまたはユーザーごとの制限がある場合、それを考慮して返す必要があります。
  • withdrawが一時的に無効な場合は、0を返します。
  • revertしてはいけません。
yml
- name: maxWithdraw
  type: function
  stateMutability: view

  inputs:
    - name: holder
      type: address

  outputs:
    - name: maxUnderlyingAmount
      type: uint256

previewWithdraw

withdraw時に、現在のオンチェーン状況をもとにburnする必要があるPrincipalトークンの量をシミュレートする関数。

  • 手数料込みの値を返します。
  • 利用制限(maxWithdrawなど)や残高チェックは無視して、理論的に必要な量を返します。
  • withdrawと同様の条件でrevertしても問題ありません。
  • convertToPrincipalとの不一致はスリッページとして扱われます。
yml
- name: previewWithdraw
  type: function
  stateMutability: view

  inputs:
    - name: underlyingAmount
      type: uint256

  outputs:
    - name: principalAmount
      type: uint256

withdraw

指定したunderlyingトークンを得るために、相応のPrincipalトークンをburnします。

  • Redeemイベントを発行する必要があります。
  • msg.senderが保有者本人またはERC20のapproveをされている必要があります。
  • 実行条件を満たさない場合はrevertします(残高不足や制限超過など)。
  • 一部のプロトコルでは、事前にburn用の要求が必要な場合があります。

イベント

イベント仕様

Redeem

ユーザーがPrincipalトークンを償還して、対応するunderlyingトークンを受け取ったときに発行されるイベント。 以下の条件を満たす必要があります。

  • redeem関数が呼び出されてPrincipalトークンがburnされたときに発行する必要があります。

  • underlyingトークンがユーザーに送信されたことを記録します。

  • from

    • Principalトークンを償還したアドレス(indexed)。
  • to

    • underlyingトークンを受け取ったアドレス(indexed)。
  • amount

    • 送信されたunderlyingトークンの量(indexedではない)。

このイベントによって、誰がどれだけのPrincipalトークンを償還し、どのアドレスにunderlyingトークンが送られたかをログとして追跡できます。
ウォレットやブロックエクスプローラーなどの外部アプリケーションがユーザーのアクションを可視化する際にも重要な役割を果たします。

補足

Principalトークンのインターフェースは、統合するアプリケーション側(integrators)にとって扱いやすいよう、最小限のコア機能と、必要に応じて拡張できるオプション機能に分けて設計されています。

トークン内部の会計処理やunderlying資産の管理方法は仕様上では明記されていません。 これは、Principalトークンがオンチェーン上では「ブラックボックス」として扱われ、実際の内容はオフチェーンで確認する前提のためです。

ERC20との関係

PrincipalトークンはERC20に準拠しているため、既存のERC20トークンと同様にapprove(承認)やbalanceOf(残高取得)などの関数がそのまま使えます。 これにより、PrincipalトークンはERC5095固有のユースケースだけでなく、ERC20準拠のあらゆるウォレットやDeFiプロトコルでもすぐに利用できます。

償還機能の重要性

全てのPrincipalトークンは、満期になればunderlyingトークンと引き換え可能です。 これにより、ユーザーやプロトコルは、公開市場でPrincipalトークンを購入し、その後に確定利回り(fixed yield)を得るためにredeemする、という使い方が可能になります。
このredeem関数の存在だけで、そのPrincipalトークンが何であるかを把握できれば十分という設計です。

ERC4626との違い

Principalトークンは技術的にはERC4626(利回り付きボールト)のサブセットとも言えます。 ただし、ERC4626をそのまま継承してしまうと、PTには不要なmint(ミント)やdeposit(預け入れ)などの機能も実装しなければならなくなります。
また、Principalトークンでは途中引き出し(partial redeem)もあまり一般的ではないため、用途に合っていません。 こうした理由から、ERC4626ではなく独立した仕様として定義されています。

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

満期に関するイベントの非対応

Principalトークンは特定のUNIXタイムスタンプで満期を迎えますが、コントラクトは能動的にイベントを出せないため「満期になった瞬間」を通知するイベントを用意することは困難です。

そのため、満期を検出したい場合は、最初にredeemされたときのRedeemイベントを参照するか、各PTが満期を迎えるタイミングをアプリケーション側で管理する必要があります。

互換性

この仕様はERC20と完全に互換性があります。
既存のERC20ベースのウォレットやアプリケーションにそのまま組み込むことができます。

参考実装

ERC5095.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.14;

import {ERC20} from "yield-utils-v2/contracts/token/ERC20.sol";
import {MinimalTransferHelper} from "yield-utils-v2/contracts/token/MinimalTransferHelper.sol";

contract ERC5095 is ERC20 {
    using MinimalTransferHelper for ERC20;

    /* EVENTS
     *****************************************************************************************************************/

    event Redeem(address indexed from, address indexed to, uint256 underlyingAmount);

    /* MODIFIERS
     *****************************************************************************************************************/

    /// @notice A modifier that ensures the current block timestamp is at or after maturity.
    modifier afterMaturity() virtual {
        require(block.timestamp >= maturity, "BEFORE_MATURITY");
        _;
    }

    /* IMMUTABLES
     *****************************************************************************************************************/

    ERC20 public immutable underlying;
    uint256 public immutable maturity;

    /* CONSTRUCTOR
     *****************************************************************************************************************/

    constructor(
        string memory name_,
        string memory symbol_,
        uint8 decimals_,
        ERC20 underlying_,
        uint256 maturity_
    ) ERC20(name_, symbol_, decimals_) {
        underlying = underlying_;
        maturity = maturity_;
    }

    /* CORE FUNCTIONS
     *****************************************************************************************************************/

    /// @notice Burns an exact amount of principal tokens in exchange for an amount of underlying.
    /// @dev This reverts if before maturity.
    /// @param principalAmount The exact amount of principal tokens to be burned.
    /// @param from The owner of the principal tokens to be redeemed.  If not msg.sender then must have prior approval.
    /// @param to The address to send the underlying tokens.
    /// @return underlyingAmount The total amount of underlying tokens sent.
    function redeem(
        uint256 principalAmount,
        address from,
        address to
    ) public virtual afterMaturity returns (uint256 underlyingAmount) {
        _decreaseAllowance(from, principalAmount);

        // Check for rounding error since we round down in previewRedeem.
        require((underlyingAmount = _previewRedeem(principalAmount)) != 0, "ZERO_ASSETS");

        _burn(from, principalAmount);

        emit Redeem(from, to, principalAmount);

        _transferOut(to, underlyingAmount);
    }

    /// @notice Burns a calculated amount of principal tokens in exchange for an exact amount of underlying.
    /// @dev This reverts if before maturity.
    /// @param underlyingAmount The exact amount of underlying tokens to be received.
    /// @param from The owner of the principal tokens to be redeemed.  If not msg.sender then must have prior approval.
    /// @param to The address to send the underlying tokens.
    /// @return principalAmount The total amount of underlying tokens redeemed.
    function withdraw(
        uint256 underlyingAmount,
        address from,
        address to
    ) public virtual afterMaturity returns (uint256 principalAmount) {
        principalAmount = _previewWithdraw(underlyingAmount); // No need to check for rounding error, previewWithdraw rounds up.

        _decreaseAllowance(from, principalAmount);

        _burn(from, principalAmount);

        emit Redeem(from, to, principalAmount);

        _transferOut(to, underlyingAmount);
    }

    /// @notice An internal, overridable transfer function.
    /// @dev Reverts on failed transfer.
    /// @param to The recipient of the transfer.
    /// @param amount The amount of the transfer.
    function _transferOut(address to, uint256 amount) internal virtual {
        underlying.safeTransfer(to, amount);
    }

    /* ACCOUNTING FUNCTIONS
     *****************************************************************************************************************/

    /// @notice Calculates the amount of underlying tokens that would be exchanged for a given amount of principal tokens.
    /// @dev Before maturity, it converts to underlying as if at maturity.
    /// @param principalAmount The amount principal on which to calculate conversion.
    /// @return underlyingAmount The total amount of underlying that would be received for the given principal amount..
    function convertToUnderlying(uint256 principalAmount) external view returns (uint256 underlyingAmount) {
        return _convertToUnderlying(principalAmount);
    }

    function _convertToUnderlying(uint256 principalAmount) internal view virtual returns (uint256 underlyingAmount) {
        return principalAmount;
    }

    /// @notice Converts a given amount of underlying tokens to principal exclusive of fees.
    /// @dev Before maturity, it converts to principal as if at maturity.
    /// @param underlyingAmount The total amount of underlying on which to calculate the conversion.
    /// @return principalAmount The amount principal tokens required to provide the given amount of underlying.
    function convertToPrincipal(uint256 underlyingAmount) external view returns (uint256 principalAmount) {
        return _convertToPrincipal(underlyingAmount);
    }

    function _convertToPrincipal(uint256 underlyingAmount) internal view virtual returns (uint256 principalAmount) {
        return underlyingAmount;
    }

    /// @notice Allows user to simulate redemption of a given amount of principal tokens, inclusive of fees and other
    /// current block conditions.
    /// @dev This reverts if before maturity.
    /// @param principalAmount The amount of principal that would be redeemed.
    /// @return underlyingAmount The amount of underlying that would be received.
    function previewRedeem(uint256 principalAmount) external view afterMaturity returns (uint256 underlyingAmount) {
        return _previewRedeem(principalAmount);
    }

    function _previewRedeem(uint256 principalAmount) internal view virtual returns (uint256 underlyingAmount) {
        return _convertToUnderlying(principalAmount); // should include fees/slippage
    }

    /// @notice Calculates the maximum amount of principal tokens that an owner could redeem.
    /// @dev This returns 0 if before maturity.
    /// @param owner The address for which the redemption is being calculated.
    /// @return maxPrincipalAmount The maximum amount of principal tokens that can be redeemed by the given owner.
    function maxRedeem(address owner) public view returns (uint256 maxPrincipalAmount) {
        return block.timestamp >= maturity ? _balanceOf[owner] : 0;
    }

    /// @notice Allows user to simulate withdraw of a given amount of underlying tokens.
    /// @dev This reverts if before maturity.
    /// @param underlyingAmount The amount of underlying tokens that would be withdrawn.
    /// @return principalAmount The amount of principal tokens that would be redeemed.
    function previewWithdraw(uint256 underlyingAmount) external view afterMaturity returns (uint256 principalAmount) {
        return _previewWithdraw(underlyingAmount);
    }

    function _previewWithdraw(uint256 underlyingAmount) internal view virtual returns (uint256 principalAmount) {
        return _convertToPrincipal(underlyingAmount); // should include fees/slippage
    }

    /// @notice Calculates the maximum amount of underlying tokens that can be withdrawn by a given owner.
    /// @dev This returns 0 if before maturity.
    /// @param owner The address for which the withdraw is being calculated.
    /// @return maxUnderlyingAmount The maximum amount of underlying tokens that can be withdrawn by a given owner.
    function maxWithdraw(address owner) public view returns (uint256 maxUnderlyingAmount) {
        return _previewWithdraw(maxRedeem(owner));
    }
}

セキュリティ

Principalトークンの設計は、誰でもデプロイ可能な「パーミッションレス(許可不要)」なモデルです。 そのため、仕様上のインターフェースには従っているものの、中身が悪意ある実装になっているケースが考えられます。 例えば、償還処理が正しく実装されていないにもかかわらず、マーケット上で自由に取引できてしまうようなトークンです。

こうしたリスクを踏まえ、アプリケーションやプロトコルと統合する前に、必ず各Principalトークンの実装内容を確認することが推奨されています。

convertToUnderlyingは目安にすぎない

convertToUnderlying関数は、表示用やユーザー向けの見積もりとして便利ですが、実際に引き換え可能なunderlyingトークン量と完全に一致している必要はありません。

decimalsの一致

多くの標準と同様に、Principalトークンの桁数(decimals)は、できる限りunderlyingトークンに合わせるべきです。 これにより、フロントエンドでの表示や、外部ツール・ウォレットとの連携時に起こりがちな混乱を防ぎやすくなります。 とくに金融系のアプリケーションでは、桁数の不一致が重大なバグや誤認につながるため注意が必要です。

引用

Julian Traversa (@JTraversa), Robert Robbins (@robrobbins), Alberto Cuesta Cañada (@alcueca), "ERC-5095: Principal Token [DRAFT]," Ethereum Improvement Proposals, no. 5095, May 2022. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-5095.

最後に

今回は「ERC20トークンの所有権を示すPrincipalトークン提案しているERC5095」についてまとめてきました! いかがだったでしょうか?

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

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