Developing а Fuse

If you want a particular Fuse to be available via the IPOR Fusion interface, it must meet certain criteria:

  • The Fuse must have very highunit test coverage for all the custom code

  • You need to supply the balance fuse, unless there is already one available.

  • Your fuse must have clear comprehensive documentation in-code

  • You can submit the fuse as a pull request to this repository: https://github.com/IPOR-Labs/ipor-fusion/tree/main/contracts/fuses

How to build your own fuse

Example fuse: https://github.com/IPOR-Labs/ipor-fusion/blob/main/contracts/fuses/erc4626/Erc4626SupplyFuse.sol

The below example outlines how the fuse should be built and documented.

Section: “Purpose”

This section should describe the outline of the actions performed by the fuse.

Example:

Fuse SupplyERC4626 allows for integration with any vault that supports the ERC4626 standard. Integration is limited to vaults that allow deposits and withdrawals without any time restrictions. Configuration containing the addresses of supported vaults is stored within plasmaVault.

Section: Validation and Constraints

The section should specify whether any constraints are coded into the fuse or stored within plasmaVault.

Implementation Constraints

Example:

  • Fuse SupplyERC4626 does not verify if the vault complies with ERC4626.

  • Fuse is limited to handling only vaults that allow deposits and withdrawals without any time restrictions.

  • Fuse supports only the vault’s accounting tokens (unless plasmaVault contains fuses that perform swaps).

Validations Performed Based on Data Stored Within plasmaVault

This section describes validations based on the data stored within plasmaVault. Validation data is set in plasmaVault by executing the following function:

function grandMarketSubstrates(uint256 marketId, bytes32[] calldata substrates) external restricted { 
    PlasmaVaultConfigLib.grandMarketSubstrates(marketId, substrates); 
}

This section describes what is contained in the substrates array and how to decode it.

The substrates array contains addresses of vaults supported by the Fuse, and there may be more than one. Inside the supplyFuse, the value is retrieved using the following function:

function isSubstrateAsAssetGranted(uint256 marketId, address substrateAsAsset) internal view returns (bool) { 
    PlasmaVaultStorageLib.MarketSubstratesStruct storage marketSubstrates = _getMarketSubstrates(marketId); 
    return marketSubstrates.substrateAllowances[addressToBytes32(substrateAsAsset)] == 1; 
}

Located in the PlasmaVaultConfigLib library

To convert an address to bytes32, you should use the addressToBytes32 function found in the PlasmaVaultStorageLib library.

function addressToBytes32(address addressInput) internal pure returns (bytes32) { 
    return bytes32(uint256(uint160(addressInput))); 
}

Section: Implementation Details

enter

This section should contain a detailed description of the implementation of the enter function.

The data passed to the enter function is provided in the Erc4626SupplyFuseEnterData structure:

struct Erc4626SupplyFuseEnterData { 
    /// @dev vault address 
    address vault; 
    
    /// @dev max amount to supply 
    uint256 amount; 
}

The main logic of the enter function resides within the private function _enter:

function _enter(Erc4626SupplyFuseEnterData memory data) internal { 
    if (!PlasmaVaultConfigLib.isSubstrateAsAssetGranted(MARKET_ID, data.vault)) { 
        revert Erc4626SupplyFuseUnsupportedVault("enter", data.vault, Errors.UNSUPPORTED_ERC4626); 
    } 

    address underlineAsset = IERC4626(data.vault).asset(); 
    ERC20(underlineAsset).forceApprove(data.vault, data.amount); 
    IERC4626(data.vault).deposit(data.amount, address(this)); 
    emit Erc4626SupplyFuse(VERSION, "enter", underlineAsset, data.vault, data.amount); 
}

The following code validates if the Atomist has approved the destination ERC4626 vault.

if (!PlasmaVaultConfigLib.isSubstrateAsAssetGranted(MARKET_ID, data.vault)) { 
    revert Erc4626SupplyFuseUnsupportedVault("enter", data.vault, Errors.UNSUPPORTED_ERC4626); 
}

Only the approved amount of underlineAsset can be transferred to the destination vault.

address underlineAsset = IERC4626(data.vault).asset(); 
ERC20(underlineAsset).forceApprove(data.vault, data.amount);

The requested amount is then deposited to address(this) (the fuse is invoked in the context of plasmaVault).

IERC4626(data.vault).deposit(data.amount, address(this));

Finally, an event with information about the current operation is emitted.

emit Erc4626SupplyFuse(VERSION, "enter", underlineAsset, data.vault, data.amount);

exit

This section should contain a detailed description of the implementation of the exit function.

The data passed to the exit function is provided in the Erc4626SupplyFuseExitData structure:

struct Erc4626SupplyFuseExitData { 
    /// @dev vault address 
    address vault; 
    
    /// @dev max amount to withdraw 
    uint256 amount; 
}

The main logic of the exit function is contained within the private function _exit:

function _exit(Erc4626SupplyFuseExitData memory data) internal { 
    if (!PlasmaVaultConfigLib.isSubstrateAsAssetGranted(MARKET_ID, data.vault)) { 
        revert Erc4626SupplyFuseUnsupportedVault("exit", data.vault, Errors.UNSUPPORTED_ERC4626); 
    } 
    
    uint256 vaultBalanceAssets = IERC4626(data.vault).convertToAssets( IERC4626(data.vault).balanceOf(address(this)) ); 
    uint256 shares = IERC4626(data.vault).withdraw( IporMath.min(data.amount, vaultBalanceAssets), address(this), address(this) ); emit Erc4626SupplyFuse(VERSION, "exit", IERC4626(data.vault).asset(), data.vault, shares); 
}

The following code is responsible for checking if the Atomist has accepted the vault with which Alpha wants to interact:

if (!PlasmaVaultConfigLib.isSubstrateAsAssetGranted(MARKET_ID, data.vault)) { 
    revert Erc4626SupplyFuseUnsupportedVault("exit", data.vault, Errors.UNSUPPORTED_ERC4626); 
}

The amount of assets stored in the vault can be read from:

uint256 vaultBalanceAssets = IERC4626(data.vault)
    .convertToAssets(IERC4626(data.vault)
    .balanceOf(address(this)) );

When withdrawing either data.amount of assets or the maximum amount can be withdrawn, whichever is smaller.

The hardcoded address(this) specifies that the assets are withdrawn to the plasmaVault (the fuse is executed in the context of plasmaVault).

uint256 shares = IERC4626(data.vault)
    .withdraw( IporMath.min(data.amount, vaultBalanceAssets), address(this), address(this) );

Finally, an event with information about the execution of the operation is emitted.

emit Erc4626SupplyFuse(VERSION, "exit", IERC4626(data.vault).asset(), data.vault, shares);

instantWithdraw

This section should contain a detailed description of the implementation of the instantWithdraw function. This optional function is used to instantly withdraw assets from external markets. For some strategies, it may not be desired to use the instant withdrawal. In those cases, the use of this function can be limited to the level of the vault.

Implementation:

/// @dev params[0] - amount in underlying asset, params[1] - vault address 
function instantWithdraw(bytes32[] calldata params) external override { 
    uint256 amount = uint256(params[0]); 
    address vault = PlasmaVaultConfigLib.bytes32ToAddress(params[1]); 
    _exit(Erc4626SupplyFuseExitData(vault, amount)); 
}

The params value is set to plasmaVault using the configureInstantWithdrawalFuses method.

function configureInstantWithdrawalFuses( PlasmaVaultLib.InstantWithdrawalFusesParamsStruct[] calldata fuses ) external restricted { 
    PlasmaVaultLib.configureInstantWithdrawalFuses(fuses); 
}

The first parameter in the array is the amount to withdraw from the external vault (this value is reserved in every fuse). The second parameter is the address of the vault from which to withdraw assets.

Next, the private function _exit with the parameters Erc4626SupplyFuseExitData(vault, amount) can be called.

Balance Fuse

General Purpose

The purpose of the Balance fuse is to count the funds that the plasma vault holds within external protocols. Each marketId found in IporFusionMarkets should have its own balance fuse. However, if it is not needed, and the given fuse does not generate any funds in the external protocol (such as for UNISWAP_SWAP_V2 and UNISWAP_SWAP_V3), the ZeroBalanceFuse should be connected. It is also important to remember that some fuses change the state in more than one place corresponding to different marketIds. In such cases, the Dependency graph should be configured using the updateDependencyBalanceGraph method.

Steps to Create a Balance Fuse

  • Define the Contract Start by defining the contract and inheriting from the appropriate interface, typically IMarketBalanceFuse.

  • Declare any state variables needed by the contract, such as the marketID(requaier) and any protocol-specific addresses or constants.

  • Implement the Constructor Implement the constructor to initialize the state variables. Ensure that any necessary validations are performed.

  • Implement the balanceOf Function Implement the balanceOf function to calculate the balance of the Plasma Vault in the associated protocol. This function should:

    • Retrieve the relevant market substrates.

    • Loop through each substrate to calculate the balance.

    • Convert the balance to a standard unit (e.g., USD) using a price oracle.

Example

This guide explains how to create a balance fuse similar to ERC4626BalanceFuse.sol and MorphoBlueBalanceFuse.sol.

Define the Contract

Start by defining the contract and inheriting from the IMarketBalanceFuse interface.

contract ExampleBalanceFuse is IMarketBalanceFuse {

Declare State Variables

Declare any state variables needed by the contract and Initialize.

uint256 public immutable MARKET_ID;

constructor(uint256 marketId_) {
    MARKET_ID = marketId_;
}

Implement the balanceOf Function

function balanceOf() external view override returns (uint256) {
    bytes32[] memory substrates = PlasmaVaultConfigLib.getMarketSubstrates(MARKET_ID);
    uint256 len = substrates.length;
    if (len == 0) {
        return 0;
    }

    uint256 balance;
    for (uint256 i; i < len; ++i) {
        // Calculate balance for each substrate
    }

    return balance;
}

Complete Example

// SPDX-License-Identifier: BUSL-1.1
pragma solidity 0.8.26;

import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol";
import {IERC20Metadata} from "@openzeppelin/contracts/interfaces/IERC20Metadata.sol";
import {IPriceOracleMiddleware} from "../../price_oracle/IPriceOracleMiddleware.sol";
import {IMarketBalanceFuse} from "../IMarketBalanceFuse.sol";
import {PlasmaVaultConfigLib} from "../../libraries/PlasmaVaultConfigLib.sol";
import {IporMath} from "../../libraries/math/IporMath.sol";
import {PlasmaVaultLib} from "../../libraries/PlasmaVaultLib.sol";

contract ExampleBalanceFuse is IMarketBalanceFuse {
    using SafeCast for uint256;

    uint256 public immutable MARKET_ID;

    constructor(uint256 marketId_) {
        MARKET_ID = marketId_;
    }

    function balanceOf() external view override returns (uint256) {
        /// @dev this substractes should be setup in coresponding fuse with the same MARKET_ID
        bytes32[] memory substrates = PlasmaVaultConfigLib.getMarketSubstrates(MARKET_ID);
        uint256 len = substrates.length;
        if (len == 0) {
            return 0;
        }

        uint256 balance;
        for (uint256 i; i < len; ++i) {
            // Calculate balance for each substrate
        }

        return balance;
    }

    function _convertToUsd(
        address priceOracleMiddleware_,
        address asset_,
        uint256 amount_
    ) internal view returns (uint256) {
        if (amount_ == 0) return 0;
        (uint256 price, uint256 decimals) = IPriceOracleMiddleware(priceOracleMiddleware_).getAssetPrice(asset_);
        return IporMath.convertToWad(amount_ * price, IERC20Metadata(asset_).decimals() + decimals);
    }
}

Last updated