✔️ MIP 25: Save Unwrapper

Summary

Currently, users of the protocol need to perform two transactions to deposit or withdraw from the savings product. This proposal outlines an upgrade to the contracts that would reduce the amount of transactions needed in order to ease any interaction with the save product and allow for easier integrations.

Abstract

The contracts to deposit into save and subsequently vault allow for combining 3 transactions into one single transaction. The SaveWrapper contract was used for this purpose. It allows minting of mUSD, depositing in save and the vault all in one transaction. However, this contract could not achieve the same for withdrawals, since the underlying contract for the vault and save did not support such functions.

This proposal adds various Unwrapper functions to the existing save and vault contracts to facilitate the unwrapping of any given credit or mAsset into any given bAsset. The Savings smart contract would be updated to allow users to redeem credits and unwrap them into any bAsset and finally send it to a beneficiary.

With theses proposed changes, it will possible to deposit and withdraw from ETH or any stablecoin on mStable into imUSD, and the imUSD vault in one transaction.

Motivation

Most of the users perform both transactions to deposit into the save contract and vault directly in one step. Additionally, users also mint mUSD and directly deposit it as well. While when withdrawing, the user needs first to redeem from the imUSD Vault to imUSD as one transaction, secondly to redeem from imUSD into mUSD as a second transaction and if the user desires to withdraw the bAsset (DAI, UDSC, USDT, sUSD) a third transaction would be needed. By giving the option to do it in one transaction makes it easier for users and other smart contracts to interact with mStable save product. Improving user experience and developer experience.

As an additional improvement over Savings Contract, it could be possible to add a referral address while depositing savings, this would trigger a Referral event with the following details: referral address, the beneficiary and the underlying asset; therefore enabling to track deposits for the mStable Alliance program as described in MIP 22.

Specification

The following alterations are proposed.

mStable Alliance

Update SavingsContract to add a new method depositSavings with an optional referrer address, that would trigger a Referral event with the following details: referral address, the beneficiary and the underlying asset. This Event would be used to generate the data to credit partners in the mStable Alliance program.

Unwrapper

A new new smart contract Unwrapper with its main method unwrapAndSend that supports converting imAsset/mAsset to both bAsset or fAsset, then send it to a beneficiary address. This new smart contract provides to abstract the logic of redeeming when the output token is a bAsset or to swap when the output token is an fAsset.

Redeem from imAsset/mAsset to bAsset/fAsset

Update SavingsContract with a new method redeemAndUnwrap with a parameter address _beneficiary

  1. Redeems as normal but does not do a token transfer.
  2. Approves and calls the Unwrapper.unwrapAndSend with the target bAsset/fAsset and beneficiary address.

Redeem from vault to bAsset/fAsset

Update BoostedVault with a new method withdrawAndUnwrap with a parameter address _beneficiary

  1. Redeems as normal but does not do a token transfer
  2. Approves and calls SavingsContract.redeemAndUnwrap with the target bAsset/fAsset and beneficiary address.

Technical Specification

Save interface

Ensures backwards compatibility by maintaining all functions from the previous SAVE interface.

Adds additional external functions to the Interface:

  • depositSavings : For mStable Alliance
  • redeemAndUnwrap: For Redeem from imAsset/mAsset to bAsset/fAsset
interface ISavingsContractV3 {
    // DEPRECATED but still backwards compatible
    function redeem(uint256 _amount) external returns (uint256 massetReturned);

    function creditBalances(address) external view returns (uint256); // V1 & V2 (use balanceOf)

    // --------------------------------------------

    function depositInterest(uint256 _amount) external; // V1 & V2

    function depositSavings(uint256 _amount) external returns (uint256 creditsIssued); // V1 & V2

    function depositSavings(uint256 _amount, address _beneficiary)
        external
        returns (uint256 creditsIssued); // V2

    function redeemCredits(uint256 _amount) external returns (uint256 underlyingReturned); // V2

    function redeemUnderlying(uint256 _amount) external returns (uint256 creditsBurned); // V2

    function exchangeRate() external view returns (uint256); // V1 & V2

    function balanceOfUnderlying(address _user) external view returns (uint256 underlying); // V2

    function underlyingToCredits(uint256 _underlying) external view returns (uint256 credits); // V2

    function creditsToUnderlying(uint256 _credits) external view returns (uint256 underlying); // V2

    function underlying() external view returns (IERC20 underlyingMasset); // V2

    // --------------------------------------------

    function redeemAndUnwrap(
        uint256 _amount,
        bool _isCreditAmt,
        uint256 _minAmountOut,
        address _output,
        address _beneficiary,
        address _router,
        bool _isBassetOut
    ) external returns (uint256 creditsBurned, uint256 massetReturned);

    function depositSavings(
        uint256 _underlying,
        address _beneficiary,
        address _referrer
    ) external returns (uint256 creditsIssued);
}

Redeem from vault to bAsset/fAsset (Mainnet)

Ensures backwards compatibility by maintaining all functions from the previous IBoostedVaultWithLockup interface.

Add additional external function withdrawAndUnwrap to the Interface:

interface IBoostedVaultWithLockup {
    /**
     * @dev Stakes a given amount of the StakingToken for the sender
     * @param _amount Units of StakingToken
     */
    function stake(uint256 _amount) external;

    /**
     * @dev Stakes a given amount of the StakingToken for a given beneficiary
     * @param _beneficiary Staked tokens are credited to this address
     * @param _amount      Units of StakingToken
     */
    function stake(address _beneficiary, uint256 _amount) external;

    /**
     * @dev Withdraws stake from pool and claims any unlocked rewards.
     * Note, this function is costly - the args for _claimRewards
     * should be determined off chain and then passed to other fn
     */
    function exit() external;

    /**
     * @dev Withdraws stake from pool and claims any unlocked rewards.
     * @param _first    Index of the first array element to claim
     * @param _last     Index of the last array element to claim
     */
    function exit(uint256 _first, uint256 _last) external;

    /**
     * @dev Withdraws given stake amount from the pool
     * @param _amount Units of the staked token to withdraw
     */
    function withdraw(uint256 _amount) external;

    /**
     * @dev Withdraws given stake amount from the pool and
     * redeems the staking token into a given asset.
     * @param _amount        Units of the staked token to withdraw
     * @param _minAmountOut  Minimum amount of `_output` to receive
     * @param _output        Address of desired output b/f-Asset
     * @param _beneficiary   Address to send output and any claimed reward to
     * @param _router        Router address to redeem/swap
     * @param _isBassetOut   Route action of redeem/swap
     */
    function withdrawAndUnwrap(
        uint256 _amount,
        uint256 _minAmountOut,
        address _output,
        address _beneficiary,
        address _router,
        bool _isBassetOut
    ) external;

    /**
     * @dev Claims only the tokens that have been immediately unlocked, not including
     * those that are in the lockers.
     */
    function claimReward() external;

    /**
     * @dev Claims all unlocked rewards for sender.
     * Note, this function is costly - the args for _claimRewards
     * should be determined off chain and then passed to other fn
     */
    function claimRewards() external;

    /**
     * @dev Claims all unlocked rewards for sender. Both immediately unlocked
     * rewards and also locked rewards past their time lock.
     * @param _first    Index of the first array element to claim
     * @param _last     Index of the last array element to claim
     */
    function claimRewards(uint256 _first, uint256 _last) external;

    /**
     * @dev Pokes a given account to reset the boost
     */
    function pokeBoost(address _account) external;

    /**
     * @dev Gets the last applicable timestamp for this reward period
     */
    function lastTimeRewardApplicable() external view returns (uint256);

    /**
     * @dev Calculates the amount of unclaimed rewards per token since last update,
     * and sums with stored to give the new cumulative reward per token
     * @return 'Reward' per staked token
     */
    function rewardPerToken() external view returns (uint256);

    /**
     * @dev Returned the units of IMMEDIATELY claimable rewards a user has to receive. Note - this
     * does NOT include the majority of rewards which will be locked up.
     * @param _account User address
     * @return Total reward amount earned
     */
    function earned(address _account) external view returns (uint256);

    /**
     * @dev Calculates all unclaimed reward data, finding both immediately unlocked rewards
     * and those that have passed their time lock.
     * @param _account User address
     * @return amount Total units of unclaimed rewards
     * @return first Index of the first userReward that has unlocked
     * @return last Index of the last userReward that has unlocked
     */
    function unclaimedRewards(address _account)
        external
        view
        returns (
            uint256 amount,
            uint256 first,
            uint256 last
        );
}

Redeem from vault to bAsset/fAsset (Polygon)

Add additional external function withdrawAndUnwrap to the Interface IStakingRewardsWithPlatformToken:

interface IStakingRewardsWithPlatformToken {
    /**
     * @dev Stakes a given amount of the StakingToken for the sender
     * @param _amount Units of StakingToken
     */
    function stake(uint256 _amount) external;

    /**
     * @dev Stakes a given amount of the StakingToken for a given beneficiary
     * @param _beneficiary Staked tokens are credited to this address
     * @param _amount      Units of StakingToken
     */
    function stake(address _beneficiary, uint256 _amount) external;

    /**
     * @dev Withdraws stake from pool and claims any unlocked rewards.
     */
    function exit() external;

    /**
     * @dev Withdraws given stake amount from the pool
     * @param _amount Units of the staked token to withdraw
     */
    function withdraw(uint256 _amount) external;

    /**
     * @dev Withdraws given stake amount from the pool and
     * redeems the staking token into a given asset.
     * @param _amount        Units of the staked token to withdraw
     * @param _minAmountOut  Minimum amount of `_output` to receive
     * @param _output        Address of desired output b/f-Asset
     * @param _beneficiary   Address to send output and any claimed reward to
     * @param _router        Router address to redeem/swap
     * @param _isBassetOut   Route action of redeem/swap
     */
    function withdrawAndUnwrap(
        uint256 _amount,
        uint256 _minAmountOut,
        address _output,
        address _beneficiary,
        address _router,
        bool _isBassetOut
    ) external;

    /**
     * @dev Claims outstanding rewards (both platform and native) for the sender.
     * First updates outstanding reward allocation and then transfers.
     */
    function claimReward() external;

    /**
     * @dev Claims outstanding rewards for the sender. Only the native
     * rewards token, and not the platform rewards
     */
    function claimRewardOnly() external;

    /**
     * @dev Gets the last applicable timestamp for this reward period
     */
    function lastTimeRewardApplicable() external view returns (uint256);

    /**
     * @dev Calculates the amount of unclaimed rewards a user has earned
     * @return 'Reward' per staked token
     */
    function rewardPerToken() external view returns (uint256, uint256);

    /**
     * @dev Calculates the amount of unclaimed rewards a user has earned
     * @param _account User address
     * @return Total reward amount earned
     */
    function earned(address _account) external view returns (uint256, uint256);
}

Migration

The following contracts need to be updated via proxy:

  • Mainnet imUSD Savings Vault 0x78BefCa7de27d07DC6e71da295Cc2946681A6c7B

  • Mainnet imBTC Savings Vault 0xF38522f63f40f9Dd81aBAfD2B8EFc2EC958a3016

  • Mainnet imUSD (SavingsContractV2 → SavingsContractV3) 0x30647a72Dc82d7Fbb1123EA74716aB8A317Eac19

  • Mainnet imBTC (SavingsContractV2 → SavingsContractV3) 0x17d8CBB6Bce8cEE970a4027d1198F6700A7a6c24

  • Polygon imUSD Savings Vault 0x32aBa856Dc5fFd5A56Bcd182b13380e5C855aa29

  • Polygon imUSD (SavingsContractV2 → SavingsContractV3) 0x5290Ad3d83476CA6A2b178Cd9727eE1EF72432af

Copyright

Copyright and related rights waived via CC0.

3 Likes

Highly in support of this.
Glad to see it’s turned into a MIP.

2 Likes

Thank you very much @dimsome for the great overview, very detailed.

This was originally my instigation so very pleased to see it some this far and am fully supportive. It will make the UX way better and integrations more straightforward

All credit goes to @doncesarts :slight_smile:

I am all for this proposal!

1 Like

Very good, lets move forward with this :slight_smile:

Thanks for the detail here @doncesarts , excited to see this happening!

1 Like

Just saw it was @doncesarts! My bad, well done on the great MIP :clap:

As an aspiring solidity developer, this was very interesting to read. I’m not at the point of making any technical contribution, but I was able to follow along and understood what I was reading.

The only comment I can make is, in the context of our goal as a protocol of further decentralization, does this addition hinder that in any way? Does this represent an opportunity to remove admin rights or other access control risks?

Last call on comments! Otherwise, we can put this Monday to Snapshot.

This addition does not hinder that. It just adds functionalities to allow for one transaction unwrapping, rather going the 3 steps. This MIP is not aimed at reducing admin rights, if we didn’t have upgradable contracts then this wouldn’t be possible without having all users migrate. So in a sense it’s good that we have the opportunity to do some upgrades. Once we are sure that we won’t need further upgrades, we could open up the discussion on revoking admin rights at that point. But that is hard to know upfront.

The vote is now up: Snapshot

1 Like

My comment is late but I just wanted to also add my support on this MIP!

I have a few small suggested changes to make it easier for integrators.

  1. Add the quantity of output tokens to the redeemAndUnwrap function on the SavingContract. That way a calling contract will know how many bAssets or fAssets were returned to the beneficiary.
/**
     * @notice Redeem credits into a specific amount of underlying, unwrap
     *      into a selected output asset, and send to a beneficiary
     *      Credits needed to burn is calculated using:
     *                    credits = underlying / exchangeRate
     * @param _amount         Units to redeem (either underlying or credit amount).
     * @param _isCreditAmt    `true` if `amount` is in credits. eg imUSD. `false` if `amount` is in underlying. eg mUSD.
     * @param _minAmountOut   Minimum amount of `output` tokens to unwrap for. This is to the same decimal places as the `output` token.
     * @param _output         Asset to receive in exchange for the redeemed mAssets. This can be a bAsset or a fAsset. For example:
        - bAssets (USDC, DAI, sUSD or USDT) or fAssets (GUSD, BUSD, alUSD, FEI or RAI) for mainnet imUSD Vault.
        - bAssets (USDC, DAI or USDT) or fAsset FRAX for Polygon imUSD Vault.
        - bAssets (WBTC, sBTC or renBTC) or fAssets (HBTC or TBTCV2) for mainnet imBTC Vault.
     * @param _beneficiary    Address to send `output` tokens to.
     * @param _router         mAsset address if the output is a bAsset. Feeder Pool address if the output is a fAsset.
     * @param _isBassetOut    `true` if `output` is a bAsset. `false` if `output` is a fAsset.
     * @return creditsBurned  Units of credits burned from sender. eg imUSD or imBTC.
     * @return massetReturned Units of the underlying mAssets that were redeemed or swapped for the output tokens. eg mUSD or mBTC.
     * @return outputQuantity Units of `output` tokens sent to the beneficiary.
     */
    function redeemAndUnwrap(
        uint256 _amount,
        bool _isCreditAmt,
        uint256 _minAmountOut,
        address _output,
        address _beneficiary,
        address _router,
        bool _isBassetOut
    )
        external
        override
        returns (
            uint256 creditsBurned,
            uint256 massetReturned,
            uint256 outputQuantity
        )
  1. Add the quantity of output tokens to the withdrawAndUnwrap function on the vault contracts: BoostedVault , BoostedDualVault, StakingRewards and StakingRewardsWithPlatformToken. Again, this is so the calling contract will know how many bAssets or fAssets were returned to the beneficiary.
/**
     * @notice Redeems staked interest-bearing asset tokens for either bAsset or fAsset tokens.
     * Withdraws a given staked amount of interest-bearing assets from the vault,
     * redeems the interest-bearing asset for the underlying mAsset and either
     * 1. Redeems the underlying mAsset tokens for bAsset tokens.
     * 2. Swaps the underlying mAsset tokens for fAsset tokens in a Feeder Pool.
     * @param _amount         Units of the staked interest-bearing asset tokens to withdraw. eg imUSD or imBTC.
     * @param _minAmountOut   Minimum units of `output` tokens to be received by the beneficiary. This is to the same decimal places as the `output` token.
     * @param _output         Asset to receive in exchange for the redeemed mAssets. This can be a bAsset or a fAsset. For example:
        - bAssets (USDC, DAI, sUSD or USDT) or fAssets (GUSD, BUSD, alUSD, FEI or RAI) for mainnet imUSD Vault.
        - bAssets (USDC, DAI or USDT) or fAsset FRAX for Polygon imUSD Vault.
        - bAssets (WBTC, sBTC or renBTC) or fAssets (HBTC or TBTCV2) for mainnet imBTC Vault.
     * @param _beneficiary    Address to send `output` tokens to.
     * @param _router         mAsset address if the `output` is a bAsset. Feeder Pool address if the `output` is a fAsset.
     * @param _isBassetOut    `true` if `output` is a bAsset. `false` if `output` is a fAsset.
     * @return outputQuantity Units of `output` tokens sent to the beneficiary. This is to the same decimal places as the `output` token.
     */
    function withdrawAndUnwrap(
        uint256 _amount,
        uint256 _minAmountOut,
        address _output,
        address _beneficiary,
        address _router,
        bool _isBassetOut
    )
        external
        override
        updateReward(msg.sender)
        updateBoost(msg.sender)
        returns (uint256 outputQuantity)

I’ve also done some detailed documentation of the new process flows mStable-process-docs/unwrapper at main · mstable/mStable-process-docs · GitHub

Hi @naddison I think those enhancements are quite good and worthy to be included .