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)
VERSIONis 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_IDidentifies 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
0without an event when the requested amount is0; - reverts with
Erc4626SupplyFuseUnsupportedVaultwhen the vault is not a granted substrate, on the instant-withdraw path too; - limits the amount to
maxWithdraw(address(this))of the vault, and returns0without an event when that is0.
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
withdrawis insidetry. The substrate check and themaxWithdrawread in_exit, and theasset()reads for the events in both branches, are outside thecatch, so this path can still revert. TheErc4626SupplyFuseExcessiveSharesBurnedcheck cannot fire here, becauseinstantWithdrawpassesmaxSharesBurned = 0. instantWithdrawpassesmaxSharesBurned = 0, so on the automatic instant-withdraw path the limit on burned shares is off. This differs fromexit, where the Alpha can set a limit.- A failed withdraw emits
Erc4626SupplyFuseExitFailedand continues; a zero amount or a zeromaxWithdrawreturns0without 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
- Integrating a New Market: the process, from writing the fuses to the Fuse Whitelist Registry.
- What a Market Integration Contains: every contract a market integration can contain.
- Coding standard and security requirements: the rules for the fuses you write.
- Whitelist Review: what to prepare for the review of the deployed code.