Developing a Fuse

Anatomy of an action fuse and its balance fuse, shown on the ERC4626 supply fuse: data structs, enter, exit, transient variants, instant withdraw, events and errors.

If you are integrating a new market, start with Integrating a New Market. It describes the whole process and the rules for the fuses you write. This page explains how a single fuse is built.

A fuse is a small stateless contract that the vault runs by delegatecall. This page walks through the generic ERC4626 fuses of the public IPOR-Labs/ipor-fusion (opens in new tab) repository, read at commit 632350d: Erc4626SupplyFuse (opens in new tab) (an action fuse) and Erc4626BalanceFuse (opens in new tab) (its balance fuse). Excerpts are short and link to the exact lines. Where the example differs from what the Coding Standard asks of new fuses, this page says so.

The shape of an action fuse

An action fuse moves the vault into or out of a position on an external venue. The Alpha (the role that executes strategy transactions on the vault) runs it through PlasmaVault.execute. The vault runs the call only for a fuse address that was added to that vault with addFuses(address[]), reads MARKET_ID() from the fuse and then delegates the encoded call. Because of the delegatecall, address(this) inside the fuse is the vault, and storage reads see the vault's storage.

contract Erc4626SupplyFuse is IFuseCommon, IFuseInstantWithdraw {

Source: Erc4626SupplyFuse.sol#L37 (opens in new tab)

IFuseCommon has one function, MARKET_ID(). IFuseInstantWithdraw is optional. The reference fuses do not implement IFuse (enter(bytes) and exit(bytes)); they expose typed functions, such as enter(Erc4626SupplyFuseEnterData).

The contract holds two immutables, and nothing else is stored:

    address public immutable VERSION;
    uint256 public immutable MARKET_ID;

    constructor(uint256 marketId_) {
        VERSION = address(this);
        MARKET_ID = marketId_;
    }

Source: Erc4626SupplyFuse.sol#L81-L87 (opens in new tab)

  • VERSION is the address of the fuse contract itself, not of the vault. It is the first parameter of every event and the key of the transient storage.
  • MARKET_ID identifies the market the fuse belongs to. It is immutable, so a fuse deployed with a wrong id must be redeployed.

This constructor does not reject marketId_ == 0. IPOR requires new fuses to do so, because the vault engine uses market id 0 as an empty slot. The guard is shown on AaveV4SupplyFuse:

    /// @notice Constructor for AaveV4SupplyFuse
    /// @param marketId_ The Market ID associated with the Fuse
    constructor(uint256 marketId_) {
        if (marketId_ == 0) {
            revert AaveV4SupplyFuseInvalidMarketId();
        }
        VERSION = address(this);
        MARKET_ID = marketId_;
    }

Source: AaveV4SupplyFuse.sol#L123-L131 (opens in new tab)

Data structs

enter and exit take a struct, so the Alpha encodes the call data once and the fuse decodes it without manual parsing.

/// @notice Data structure for entering - supply - the ERC4626 vault
struct Erc4626SupplyFuseEnterData {
    /// @dev ERC4626 vault address
    address vault;
    /// @dev amount to supply, this is amount of underlying asset in the given ERC4626 vault
    uint256 vaultAssetAmount;
    /// @dev minimum amount of shares expected to receive from the deposit
    uint256 minSharesOut;
}

Source: Erc4626SupplyFuse.sol#L15-L23 (opens in new tab)

/// @notice Data structure for exiting - withdrawing - the ERC4626 vault
struct Erc4626SupplyFuseExitData {
    /// @dev ERC4626 vault address
    address vault;
    /// @dev amount to withdraw, this is amount of underlying asset in the given ERC4626 vault
    uint256 vaultAssetAmount;
    /// @dev maximum number of vault shares that can be burned during the withdraw, 0 means no limit
    uint256 maxSharesBurned;
}

Source: Erc4626SupplyFuse.sol#L25-L33 (opens in new tab)

Both structs carry a caller-supplied limit: minSharesOut on enter and maxSharesBurned on exit, where 0 means no limit.

enter

enter returns early, without an event, when the requested amount is 0. Next it checks that the vault is a granted substrate of the market:

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

Source: Erc4626SupplyFuse.sol#L94-L96 (opens in new tab)

The substrates of this fuse are the addresses of the ERC4626 vaults the atomist allowed. The check comes before any approval or deposit. Then enter limits the amount to the asset balance of the vault, again returning 0 without an event if nothing is left, and moves the funds:

        ERC20(underlyingAsset).forceApprove(data_.vault, finalVaultAssetAmount);

        uint256 shares = IERC4626(data_.vault).deposit(finalVaultAssetAmount, address(this));

        if (shares < data_.minSharesOut) {
            revert Erc4626SupplyFuseInsufficientShares(shares, data_.minSharesOut);
        }

        emit Erc4626SupplyFuseEnter(VERSION, underlyingAsset, data_.vault, finalVaultAssetAmount);

Source: Erc4626SupplyFuse.sol#L109-L117 (opens in new tab)

In this excerpt, underlyingAsset is the asset of the ERC4626 vault and finalVaultAssetAmount is the requested amount limited to the balance of the vault.

The fuse approves exactly the amount it deposits, deposits to address(this) (the vault), reverts with Erc4626SupplyFuseInsufficientShares when fewer shares than minSharesOut are received and emits the event with the amount that was actually supplied. This example does not set the allowance back to zero after the deposit. IPOR requires new fuses to do that; the pattern is shown on AguaSupplyFuse (forceApprove(vault, amount), deposit, forceApprove(vault, 0)).

exit

exit and instantWithdraw share one internal function, _exit. It does the following:

  • returns 0 without an event when the requested amount is 0;
  • reverts with Erc4626SupplyFuseUnsupportedVault when the vault is not a granted substrate, on the instant-withdraw path too;
  • limits the amount to maxWithdraw(address(this)) of the vault, and returns 0 without an event when that is 0.

A zero maxWithdraw is therefore a silent no-op: it is neither a revert nor a failure event. The maxWithdraw read is not inside a try block, so a revert of that call propagates.

When called as exit, the Alpha decides the limit: a non-zero maxSharesBurned makes the fuse revert with Erc4626SupplyFuseExcessiveSharesBurned if more shares were burned. A failed vault withdraw is not caught on this path either, so the whole action reverts.

Transient variants

enterTransient() and exitTransient() take no arguments. They read their inputs from transient storage under the key VERSION, call the same enter or exit logic and write the result back to transient storage.

    function enterTransient() external {
        bytes32[] memory inputs = TransientStorageLib.getInputs(VERSION);
        address vault = TypeConversionLib.toAddress(inputs[0]);
        uint256 amount = TypeConversionLib.toUint256(inputs[1]);
        uint256 minSharesOut = TypeConversionLib.toUint256(inputs[2]);

        uint256 suppliedAmount = enter(Erc4626SupplyFuseEnterData({vault: vault, vaultAssetAmount: amount, minSharesOut: minSharesOut}));

        bytes32[] memory outputs = new bytes32[](1);
        outputs[0] = TypeConversionLib.toBytes32(suppliedAmount);
        TransientStorageLib.setOutputs(VERSION, outputs);
    }

Source: Erc4626SupplyFuse.sol#L120-L131 (opens in new tab)

exitTransient follows the same pattern with maxSharesBurned as the third input.

instantWithdraw

instantWithdraw is optional. The vault calls it when a user withdrawal needs more assets than the vault holds, for every fuse configured with configureInstantWithdrawalFuses, in the configured order, until the missing amount is covered. The first parameter is always the amount in the underlying asset; the other parameters belong to the fuse. For the ERC4626 fuse params[1] is the vault address:

    /// @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, 0), true);
    }

Source: Erc4626SupplyFuse.sol#L150-L157 (opens in new tab)

The vault does not wrap this call in try/catch, so a revert inside instantWithdraw reverts the user withdrawal. That is why the example catches the one call that can fail because of the venue, the vault withdraw, and reports the failure with an event instead:

        if (catchExceptions_) {
            try IERC4626(vault_).withdraw(finalVaultAssetAmount_, address(this), address(this)) returns (
                uint256 shares_
            ) {
                if (maxSharesBurned_ > 0 && shares_ > maxSharesBurned_) {
                    revert Erc4626SupplyFuseExcessiveSharesBurned(shares_, maxSharesBurned_);
                }
                shares = shares_;
                emit Erc4626SupplyFuseExit(VERSION, IERC4626(vault_).asset(), vault_, finalVaultAssetAmount_, shares);
            } catch {
                /// @dev if withdraw failed, continue with the next step
                emit Erc4626SupplyFuseExitFailed(VERSION, IERC4626(vault_).asset(), vault_, finalVaultAssetAmount_);
            }

Source: Erc4626SupplyFuse.sol#L186-L198 (opens in new tab)

Three things to note about this example:

  • Only the vault withdraw is inside try. The substrate check and the maxWithdraw read in _exit, and the asset() reads for the events in both branches, are outside the catch, so this path can still revert. The Erc4626SupplyFuseExcessiveSharesBurned check cannot fire here, because instantWithdraw passes maxSharesBurned = 0.
  • instantWithdraw passes maxSharesBurned = 0, so on the automatic instant-withdraw path the limit on burned shares is off. This differs from exit, where the Alpha can set a limit.
  • A failed withdraw emits Erc4626SupplyFuseExitFailed and continues; a zero amount or a zero maxWithdraw returns 0 without any event.

Events and errors

Events carry the fuse VERSION as the first parameter and are named with the fuse name as prefix. Errors are custom errors with the same prefix. The example declares no indexed parameters; IPOR requires the same of new fuses.

    event Erc4626SupplyFuseEnter(address version, address asset, address vault, uint256 vaultAssetAmount);

Source: Erc4626SupplyFuse.sol#L46 (opens in new tab)

    error Erc4626SupplyFuseUnsupportedVault(string action, address asset);

Source: Erc4626SupplyFuse.sol#L69 (opens in new tab)

The enter event reports the amount after limiting it to the available balance, so it shows what happened and not only what was requested. The example also declares Erc4626SupplyFuseExit, Erc4626SupplyFuseExitFailed, Erc4626SupplyFuseInsufficientShares and Erc4626SupplyFuseExcessiveSharesBurned. The NatSpec on the enter, exit and transient functions of this example is minimal; the Aave V4 fuses show the level of NatSpec described in the Coding Standard.

Substrates and vault configuration

A substrate is a bytes32 value that an atomist grants to a market with grantMarketSubstrates(marketId, substrates). Each call replaces the whole set of substrates of that market. A fuse checks the substrate before it moves funds. A plain address is checked with PlasmaVaultConfigLib.isSubstrateAsAssetGranted, and a richer encoding with isMarketSubstrateGranted. When a substrate carries more than an address, put the encoding and decoding in a substrate library, as AaveV4SubstrateLib does.

The usual order for an atomist is: add the fuses with addFuses, set the balance fuse, grant the substrates, make sure the vault's price manager or the shared Price Oracle Middleware has a price source for every asset, add a claim fuse, if any, to the vault's RewardsClaimManager with addRewardFuses, and then optionally configure instant withdraw and the dependency balance graph with updateDependencyBalanceGraphs. The What a Market Integration Contains section lists these components in detail.

The balance fuse

Each market needs a balance fuse. It implements IMarketBalanceFuse:

interface IMarketBalanceFuse {
    /// @notice Get the balance of the Plasma Vault in the market in USD
    /// @dev Notice! Every Balance Fuse have to implement this exact function signature, because it is used by Plasma Vault engine
    /// @return balanceValue The balance of the Plasma Vault in the market in USD, represented in 18 decimals
    function balanceOf() external returns (uint256 balanceValue);
}

Source: IMarketBalanceFuse.sol#L5-L10 (opens in new tab)

balanceOf() returns the balance of the vault in the market in USD with 18 decimals (WAD). Every balance fuse must use this exact signature, because the vault engine calls it. The interface does not declare balanceOf() as view, although the reference implementations do. You may declare it view, as they do. The vault calls it by delegatecall and does not catch a revert, so a reverting balance fuse blocks the balance update.

Erc4626BalanceFuse reads the substrates with PlasmaVaultConfigLib.getMarketSubstrates(MARKET_ID). For each vault it converts the shares held by the vault to assets, prices the asset through the Price Oracle Middleware and adds the value in WAD. It returns 0 immediately when the market has no substrates.

        for (uint256 i; i < len; ++i) {
            vault = IERC4626(PlasmaVaultConfigLib.bytes32ToAddress(vaults[i]));
            vaultAssets = vault.convertToAssets(vault.balanceOf(plasmaVault));
            asset = vault.asset();
            (price, priceDecimals) = IPriceOracleMiddleware(priceOracleMiddleware).getAssetPrice(asset);
            balance += IporMath.convertToWad(vaultAssets * price, IERC20Metadata(asset).decimals() + priceDecimals);
        }

        return balance;

Source: Erc4626BalanceFuse.sol#L70-L78 (opens in new tab)

This fuse does not check for a zero price itself; the middleware rejects a zero or negative price.

convertToAssets ignores withdrawal limits, as the valuation rule asks, but it also leaves out fees that an ERC4626 vault charges on withdrawal (EIP-4626). For a venue that charges an exit fee, value the position at what the exit pays; see Security Requirements.

ZeroBalanceFuse is a balance fuse that always returns 0. It is only for a market that creates no separate position of its own, with the position valued on another market. Never use it for a position that you cannot value, because it hides the position from the vault balance.

Where to go next

Last updated