はじめに
『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に準拠している必要があります。
- name: underlying
type: function
stateMutability: view
inputs: []
outputs:
- name: underlyingAddress
type: address
maturity
Principalトークンが引き換え可能になるUNIXタイムスタンプを返す関数。
- name: maturity
type: function
stateMutability: view
inputs: []
outputs:
- name: timestamp
type: uint256
convertToUnderlying
指定されたPrincipalトークンの量に対して、理想的な条件下で引き換えられるunderlying
トークンの量を計算する関数。
- 手数料は含まれません。
- 呼び出し元の違いによって結果が変わってはいけません。
- 実際のスリッページやオンチェーン状況は考慮しません。
- 整数オーバーフロー以外で
revert
してはいけません。 - 小数点以下は切り捨てで処理します。
- name: convertToUnderlying
type: function
stateMutability: view
inputs:
- name: principalAmount
type: uint256
outputs:
- name: underlyingAmount
type: uint256
convertToPrincipal
指定されたunderlying
トークンを得るために必要なPrincipalトークンの量を計算する関数。
convertToUnderlying
の逆の処理です。
- 手数料は含まれません。
- 呼び出し元によって結果が変わってはいけません。
- スリッページやオンチェーン状況は考慮しません。
- 整数オーバーフロー以外で
revert
してはいけません。 - 小数点以下は切り捨てで処理します。
- name: convertToPrincipal
type: function
stateMutability: view
inputs:
- name: underlyingAmount
type: uint256
outputs:
- name: principalAmount
type: uint256
maxRedeem
指定されたアドレスがredeem操作で引き出せるPrincipalトークンの最大量を返す関数。
- 実際の制限より多い値を返してはいけません(必要なら少なめに見積もること)。
- グローバルまたはユーザーごとの制限がある場合、それを考慮して返す必要があります。
- redeemが一時的に無効な場合は、
0
を返します。 -
revert
してはいけません。
- name: maxRedeem
type: function
stateMutability: view
inputs:
- name: holder
type: address
outputs:
- name: maxPrincipalAmount
type: uint256
previewRedeem
実際のredeemの前に、現在のオンチェーン状況をもとに受け取れるunderlying
トークン量をシミュレートする関数。
- 手数料込みの値を返します。
- 利用制限(
maxRedeem
など)や残高チェックは無視して、理論的に得られる最大量を返します。 - 一部の条件下では
revert
しても問題ありません(実際のredeemでも同様にrevertするような状況)。 -
convertToUnderlying
との不一致はスリッページとして扱われます。
- 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
用の要求が必要な場合があります。
- 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
してはいけません。
- name: maxWithdraw
type: function
stateMutability: view
inputs:
- name: holder
type: address
outputs:
- name: maxUnderlyingAmount
type: uint256
previewWithdraw
withdraw
時に、現在のオンチェーン状況をもとにburn
する必要があるPrincipalトークンの量をシミュレートする関数。
- 手数料込みの値を返します。
- 利用制限(
maxWithdraw
など)や残高チェックは無視して、理論的に必要な量を返します。 -
withdraw
と同様の条件でrevert
しても問題ありません。 -
convertToPrincipal
との不一致はスリッページとして扱われます。
- 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ベースのウォレットやアプリケーションにそのまま組み込むことができます。
参考実装
// 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などからお気軽に質問してください!
他の媒体でも情報発信しているのでぜひ他も見ていってください!