Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ERC5095 - Principal Token Standard #5095

Merged
merged 52 commits into from
Jun 20, 2022
Merged

ERC5095 - Principal Token Standard #5095

merged 52 commits into from
Jun 20, 2022

Conversation

alcueca
Copy link
Contributor

@alcueca alcueca commented May 15, 2022


eip: 5095
title: Principal Token
description: Principal tokens (zero-coupon tokens) are redeemable for a single underlying EIP-20 token at a future timestamp.
author: Julian Traversa (@JTraversa), Robert Robbins (@robrobbins), Alberto Cuesta Cañada (@alcueca)
discussions-to: https://ethereum-magicians.org/t/eip-5095-principal-token-standard/9259
status: Draft
type: Standards Track
category: ERC
created: 2022-05-01
requires: 20, 2612

Abstract

Principal tokens represent ownership of an underlying EIP-20 token at a future timestamp.

This specification is an extension on the EIP-20 token that provides basic functionality for depositing
and withdrawing tokens and reading balances and the EIP-2612 specification that provides
EIP-712 signature based approvals.

Motivation

Principal tokens lack standardization which has led to a difficult to navigate development space and diverse implementation
schemes.

The primary examples include yield tokenization platforms which strip future yield leaving a principal
token behind, as well as fixed-rate money-markets which utilize principal tokens as a medium
to lend/borrow.

This inconsistency in implementation makes integration difficult at the application layer as well as
wallet layer which are key catalysts for the space's growth.
Developers are currently expected to implement individual adapters for each principal token, as well as adapters for
their pool contracts, and many times adapters for their custodial contracts as well, wasting significant developer resources.

Specification

All Principal Tokens (PTs) MUST implement EIP-20 to represent ownership of future underlying redemption.
If a PT is to be non-transferrable, it MAY revert on calls to transfer or transferFrom.
The EIP-20 operations balanceOf, transfer, totalSupply, etc. operate on the Principal Token balance.

All Principal Tokens MUST implement EIP-20's optional metadata extensions.
The name and symbol functions SHOULD reflect the underlying token's name and symbol in some way, as well as the origination protocol, and in the case of yield tokenization protocols, the origination money-market.

All Principal Tokens MAY implement EIP-2612 to improve the UX of approving PTs on various integrations.

Definitions:

  • underlying: The token that Principal Tokens are redeemable for at maturity.
    Has units defined by the corresponding EIP-20 contract.
  • maturity: The timestamp (unix) at which a Principal Token matures. Principal Tokens become redeemable for underlying at or after this timestamp.
  • fee: An amount of underlying or Principal Token charged to the user by the Principal Token. Fees can exist on redemption or post-maturity yield.
  • slippage: Any difference between advertised redemption value and economic realities of PT redemption, which is not accounted by fees.

Methods

underlying

The address of the underlying token used by the Principal Token for accounting, and redeeming.

MUST be an EIP-20 token contract.

MUST NOT revert.

- name: underlying
  type: function
  stateMutability: view

  inputs: []

  outputs:
    - name: underlyingAddress
      type: address

maturity

The unix timestamp (uint256) at or after which Principal Tokens can be redeemed for their underlying deposit.

MUST NOT revert.

- name: maturity
  type: function
  stateMutability: view

  inputs: []

  outputs:
    - name: timestamp
      type: uint256

convertToUnderlying

The amount of underlying that would be exchanged for the amount of PTs provided, in an ideal scenario where all the conditions are met.

Before maturity, the amount of underlying returned is as if the PTs would be at maturity.

MUST NOT be inclusive of any fees that are charged against redemptions.

MUST NOT show any variations depending on the caller.

MUST NOT reflect slippage or other on-chain conditions, when performing the actual redemption.

MUST NOT revert unless due to integer overflow caused by an unreasonably large input.

MUST round down towards 0.

This calculation MAY NOT reflect the "per-user" price-per-principal-token, and instead should reflect the "average-user's" price-per-principal-token, meaning what the average user should expect to see when exchanging to and from.

- name: convertToUnderlying
  type: function
  stateMutability: view

  inputs:
    - name: principalAmount
      type: uint256

  outputs:
    - name: underlyingAmount
      type: uint256

convertToPrincipal

The amount of principal tokens that the principal token contract would request for redemption in order to provide the amount of underlying specified, in an ideal scenario where all the conditions are met.

MUST NOT be inclusive of any fees.

MUST NOT show any variations depending on the caller.

MUST NOT reflect slippage or other on-chain conditions, when performing the actual exchange.

MUST NOT revert unless due to integer overflow caused by an unreasonably large input.

MUST round down towards 0.

This calculation MAY NOT reflect the "per-user" price-per-principal-token, and instead should reflect the "average-user's" price-per-principal-token, meaning what the average user should expect to see when redeeming.

- name: convertToPrincipal
  type: function
  stateMutability: view

  inputs:
    - name: underlyingAmount
      type: uint256

  outputs:
    - name: principalAmount
      type: uint256

maxRedeem

Maximum amount of principal tokens that can be redeemed from the holder balance, through a redeem call.

MUST return the maximum amount of principal tokens that could be transferred from holder through redeem and not cause a revert, which MUST NOT be higher than the actual maximum that would be accepted (it should underestimate if necessary).

MUST factor in both global and user-specific limits, like if redemption is entirely disabled (even temporarily) it MUST return 0.

MUST NOT revert.

- name: maxRedeem
  type: function
  stateMutability: view

  inputs:
    - name: holder
      type: address

  outputs:
    - name: maxPrincipalAmount
      type: uint256

previewRedeem

Allows an on-chain or off-chain user to simulate the effects of their redeemption at the current block, given current on-chain conditions.

MUST return as close to and no more than the exact amount of underliyng that would be obtained in a redeem call in the same transaction. I.e. redeem should return the same or more underlyingAmount as previewRedeem if called in the same transaction.

MUST NOT account for redemption limits like those returned from maxRedeem and should always act as though the redemption would be accepted, regardless if the user has enough principal tokens, etc.

MUST be inclusive of redemption fees. Integrators should be aware of the existence of redemption fees.

MUST NOT revert due to principal token contract specific user/global limits. MAY revert due to other conditions that would also cause redeem to revert.

Note that any unfavorable discrepancy between convertToUnderlying and previewRedeem SHOULD be considered slippage in price-per-principal-token or some other type of condition.

- name: previewRedeem
  type: function
  stateMutability: view

  inputs:
    - name: principalAmount
      type: uint256

  outputs:
    - name: underlyingAmount
      type: uint256

redeem

At or after maturity, burns exactly principalAmount of Principal Tokens from from and sends underlyingAmount of underlying tokens to to.

Interfaces and other contracts MUST NOT expect fund custody to be present. While custodial redemption of Principal Tokens through the Principal Token contract is extremely useful for integrators, some protocols may find giving the Principal Token itself custody breaks their backwards compatibility.

MUST emit the Redeem event.

MUST support a redeem flow where the Principal Tokens are burned from holder directly where holder is msg.sender or msg.sender has EIP-20 approval over the principal tokens of holder.
MAY support an additional flow in which the principal tokens are transferred to the Principal Token contract before the redeem execution, and are accounted for during redeem.

MUST revert if all of principalAmount cannot be redeemed (due to withdrawal limit being reached, slippage, the holder not having enough Principal Tokens, etc).

Note that some implementations will require pre-requesting to the Principal Token before a withdrawal may be performed. Those methods should be performed separately.

- 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

Maximum amount of the underlying asset that can be redeemed from the holder principal token balance, through a withdraw call.

MUST return the maximum amount of underlying tokens that could be redeemed from holder through withdraw and not cause a revert, which MUST NOT be higher than the actual maximum that would be accepted (it should underestimate if necessary).

MUST factor in both global and user-specific limits, like if withdrawals are entirely disabled (even temporarily) it MUST return 0.

MUST NOT revert.

- name: maxWithdraw
  type: function
  stateMutability: view

  inputs:
    - name: holder
      type: address

  outputs:
    - name: maxUnderlyingAmount
      type: uint256

previewWithdraw

Allows an on-chain or off-chain user to simulate the effects of their withdrawal at the current block, given current on-chain conditions.

MUST return as close to and no fewer than the exact amount of principal tokens that would be burned in a withdraw call in the same transaction. I.e. withdraw should return the same or fewer principalAmount as previewWithdraw if called in the same transaction.

MUST NOT account for withdrawal limits like those returned from maxWithdraw and should always act as though the withdrawal would be accepted, regardless if the user has enough principal tokens, etc.

MUST be inclusive of withdrawal fees. Integrators should be aware of the existence of withdrawal fees.

MUST NOT revert due to principal token contract specific user/global limits. MAY revert due to other conditions that would also cause withdraw to revert.

Note that any unfavorable discrepancy between convertToPrincipal and previewWithdraw SHOULD be considered slippage in price-per-principal-token or some other type of condition.

- name: previewWithdraw
  type: function
  stateMutability: view

  inputs:
    - name: underlyingAmount
      type: uint256

  outputs:
    - name: principalAmount
      type: uint256

withdraw

Burns principalAmount from holder and sends exactly underlyingAmount of underlying tokens to receiver.

MUST emit the Redeem event.

MUST support a withdraw flow where the principal tokens are burned from holder directly where holder is msg.sender or msg.sender has EIP-20 approval over the principal tokens of holder.
MAY support an additional flow in which the principal tokens are transferred to the principal token contract before the withdraw execution, and are accounted for during withdraw.

MUST revert if all of underlyingAmount cannot be withdrawn (due to withdrawal limit being reached, slippage, the holder not having enough principal tokens, etc).

Note that some implementations will require pre-requesting to the principal token contract before a withdrawal may be performed. Those methods should be performed separately.

- name: withdraw
  type: function
  stateMutability: nonpayable

  inputs:
    - name: underlyingAmount
      type: uint256
    - name: receiver
      type: address
    - name: holder
      type: address

  outputs:
    - name: principalAmount
      type: uint256

Events

Redeem

from has exchanged principalAmount of Principal Tokens for underlyingAmount of underlying, and transferred that underlying to to.

MUST be emitted when Principal Tokens are burnt and underlying is withdrawn from the contract in the EIP5095.redeem method.

- name: Redeem
  type: event

  inputs:
    - name: from
      indexed: true
      type: address
    - name: to
      indexed: true
      type: address
    - name: amount
      indexed: false
      type: uint256

Rationale

The Principal Token interface is designed to be optimized for integrators with a core minimal interface alongside optional interfaces to enable backwards compatibility. Details such as accounting and management of underlying are intentionally not specified, as Principal Tokens are expected to be treated as black boxes on-chain and inspected off-chain before use.

EIP-20 is enforced as implementation details such as token approval and balance calculation directly carry over. This standardization makes Principal Tokens immediately compatible with all EIP-20 use cases in addition to EIP-5095.

All principal tokens are redeemable upon maturity, with the only variance being whether further yield is generated post-maturity. Given the ubiquity of redemption, the presence of redeem allows integrators to purchase Principal Tokens on an open market, and them later redeem them for a fixed-yield solely knowing the address of the Principal Token itself.

This EIP draws heavily on the design of EIP-4626 because technically Principal Tokens could be described as a subset of Yield Bearing Vaults, extended with a maturity variable and restrictions on the implementation. However, extending EIP-4626 would force PT implementations to include methods (namely, mint and deposit) that are not necessary to the business case that PTs solve. It can also be argued that partial redemptions (implemented via withdraw) are rare for PTs.

PTs mature at a precise second, but given the reactive nature of smart contracts, there can't be an event marking maturity, because there is no guarantee of any activity at or after maturity. Emitting an event to notify of maturity in the first transaction after maturity would be imprecise and expensive. Instead, integrators are recommended to either use the first Redeem event, or to track themselves when each PT is expected to have matured.

Backwards Compatibility

This EIP is fully backward compatible with the EIP-20 specification and has no known compatibility issues with other standards.
For production implementations of Principal Tokens which do not use EIP-5095, wrapper adapters can be developed and used, or wrapped tokens can be implemented.

Reference Implementation

// 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));
    }
}

Security Considerations

Fully permissionless use cases could fall prey to malicious implementations which only conform to the interface in this EIP but not the specification, failing to implement proper custodial functionality but offering the ability to purchase Principal Tokens through secondary markets.

It is recommended that all integrators review each implementation for potential ways of losing user deposits before integrating.

The convertToUnderlying method is an estimate useful for display purposes,
and do not have to confer the exact amount of underlying assets their context suggests.

As is common across many standards, it is strongly recommended to mirror the underlying token's decimals if at all possible, to eliminate possible sources of confusion and simplify integration across front-ends and for other off-chain users.

Copyright

Copyright and related rights waived via CC0.

@eth-bot
Copy link
Collaborator

eth-bot commented May 15, 2022

All tests passed; auto-merging...

(pass) eip-5095.md

classification
updateEIP
  • passed!

@alcueca
Copy link
Contributor Author

alcueca commented May 15, 2022

I'll review comments from PR5089 and address them here if still needed.

EIPS/eip-5089.md Outdated Show resolved Hide resolved
@MicahZoltu
Copy link
Contributor

Most of the discussion here should be moved over to the discussions-to link so it doesn't get lost when this PR is merged as a draft.

Headers as code

Co-authored-by: lightclient <14004106+lightclient@users.noreply.github.com>
@devtooligan
Copy link

Most of the discussion here should be moved over to the discussions-to link so it doesn't get lost when this PR is merged as a draft.

agree with this. i started moving some of the comment threads over to the magicians discussion but i got throttled because i'm a new user...

@alcueca alcueca changed the title ERC5089 - Principal Token Standard ERC5095 - Principal Token Standard Jun 15, 2022
@alcueca
Copy link
Contributor Author

alcueca commented Jun 15, 2022

@lightclient, could we merge this into a draft? I don't think that any of the points open for discussion prevent that.

@alcueca alcueca requested a review from lightclient June 15, 2022 17:20
Copy link
Contributor

@SamWilsn SamWilsn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are still two spelling mistakes:

./EIPS/eip-5095.md:460: overrideable ==> overridable
./EIPS/eip-5095.md:511: maximium ==> maximum

@eth-bot eth-bot enabled auto-merge (squash) June 17, 2022 19:40
@MicahZoltu
Copy link
Contributor

@SamWilsn In the future, if all that is left are spelling mistakes feel free to add them as review suggestions and ping me. Assuming the author allows maintainer modifications to their branch (the default in GitHub) I can commit them. For trivial things like spelling being the only thing blocking an EIP from merging (especially for Draft/Review stage EIPs) I am usually willing to manually commit just to speed the process along. If the author dislikes the spelling corrections for some reason, they can just change it back after the Draft/Review PR is merged so I don't feel bad about not waiting for the author here.

auto-merge was automatically disabled June 20, 2022 13:20

Head branch was pushed to by a user without write access

@eth-bot eth-bot enabled auto-merge (squash) June 20, 2022 13:21
@alcueca
Copy link
Contributor Author

alcueca commented Jun 20, 2022

Typos fixed, thanks!

@eth-bot eth-bot merged commit 4f56923 into ethereum:master Jun 20, 2022
nachomazzara pushed a commit to nachomazzara/EIPs that referenced this pull request Jan 13, 2023
* 5070 initial commit

* Delete eip-5070.md.txt

* Update eip-5070.md

* Update eip-5070.md

* Update eip-5070.md

* Update eip-5070.md

* Update eip-5070.md

* Update eip-5070.md

* add maturity, remove totalObligation

* Update eip-5070.md

* Rationale + exchangeRate

* Update eip-5070.md

* Update eip-5070.md

* Update eip-5070.md

* Update eip-5070.md

* Update eip-5070.md

* Update eip-5070.md

* Update eip-5070.md

* Update eip-5070.md

* Update eip-5070.md

* review1

* Numbered EIP

* copyright correction

* Rewrote again from prevous 5089 and 4626

* added discussions link

* standard -> specification

* 5070 -> 5089

* 4626 -> 5089

* ERC links

* 712 link

* abbreviation placement

* typo

Co-authored-by: lightclient <14004106+lightclient@users.noreply.github.com>

* Update EIPS/eip-5089.md

Co-authored-by: lightclient <14004106+lightclient@users.noreply.github.com>

* Update EIPS/eip-5089.md

Co-authored-by: lightclient <14004106+lightclient@users.noreply.github.com>

* Update EIPS/eip-5089.md

Co-authored-by: lightclient <14004106+lightclient@users.noreply.github.com>

* Update EIPS/eip-5089.md

Co-authored-by: lightclient <14004106+lightclient@users.noreply.github.com>

* Update EIPS/eip-5089.md

Co-authored-by: lightclient <14004106+lightclient@users.noreply.github.com>

* Fixed PR comments.

* Update EIPS/eip-5089.md

Co-authored-by: Sam Wilson <57262657+SamWilsn@users.noreply.github.com>

* Update EIPS/eip-5089.md

Co-authored-by: Sam Wilson <57262657+SamWilsn@users.noreply.github.com>

* Apply suggestions from code review

Headers as code

Co-authored-by: lightclient <14004106+lightclient@users.noreply.github.com>

* fix: owner -> holder

* feat: removal of Mature

* fix: EIP -> ERC

* 5089 -> 5095

* feat: Added reference implementation

* Typos

Co-authored-by: Julian Traversa <unitedterrorhots@gmail.com>
Co-authored-by: alcueca <alberto@yield.is>
Co-authored-by: lightclient <14004106+lightclient@users.noreply.github.com>
Co-authored-by: Sam Wilson <57262657+SamWilsn@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

9 participants