Tutorial: writing ERC-4626 adapter
In this tutorial, we'll write an adapter for ERC-4626 vaults.
First of all, let's understand our target contract. We'll use the OpenZeppelin's implementation (opens in a new tab) as a reference.
Do we need to make any adjustments to make the contract compatible with Gearbox protocol?
- We'll assume that vault's underlying asset is a typical non-rebasing ERC-20 token, already supported by the credit manager, so no gateways are needed.
- Vault contract itself is an ERC-20 token representing user's share in the vault, so we only need to get this token added to the credit manager by the DAO.
- Likely, there's no Chainlink oracle for the vault share, however
LPPriceFeed
contract can help us, which computes share's price as product between Chainlink price of underlying and exchange rate between share and underlying (the latter can be found usingconvertToAssets
function).
Interface
Let's now figure out what interface the adapter should have.
- There are four main state-modifying functions in the contract:
deposit
,mint
,withdraw
, andredeem
, and our adapter must also implement them. We can also adddepositAll
andredeemAll
versions that would deposit all balance of underlying asset and redeem all balance of shares, respectively. - Most state-reading functions should be called directly from the target contract.
It only makes sense to add the
asset()
function which returns underlying asset address. - Note that we know both underlying asset and vault's share addresses at the moment of adapter creation, so we can store their collateral masks and perform gas-optimized enabling/disabling.
Let's make those masks public and add
shareMask()
andassetMask()
functions to the interface.
Eventually, the adapter interface might look as follows:
interface IERC4626Adapter is IAdapter {
/// @notice Vault's underlying asset
function asset() external view returns (address);
/// @notice Collateral token mask of vault's share in the credit manager
function shareMask() external view returns (uint256);
/// @notice Collateral token mask of vault's underlying asset in the credit maanger
function assetMask() external view returns (uint256);
/// @notice Deposits given amount of underlying asset into the vault to mint vault's shares
/// @param assets Amount of underlying asset to deposit
function deposit(uint256 assets, address) external;
/// @notice Deposits the entire balance of underlying asset into the vault to mint vault's shares,
/// disables underlying asset
function depositAll() external;
/// @notice Deposits underlying asset into the vault to mint given amount of vault's shares
/// @param shares Amount of shares to mint
function mint(uint256 shares, address) external;
/// @notice Redeems vault's shares to withdraw given amount of underlying asset
/// @param assets Amount of underlying asset to withdraw
function withdraw(uint256 assets, address, address) external;
/// @notice Redeems given amount of vault's shares to withdraw underlying asset
/// @param Amount of shares to redeem
function redeem(uint256 shares, address, address) external;
/// @notice Redeems the entire balance of vault's shares to withdraw underlying asset,
/// disables vault's shares
function redeemAll() external;
}
It's worth noting that return values from original interface are omitted, and receiver
/owner
parameters are ignored because they must always be set to the credit account address.
Implementation
With understanding what the adapter should do, it's time to implement it.
Constructor
Let's start with contract constructor and state variables.
- All adapters must inherit
AbstractAdapter
to gain access to credit manager functionality. Our adapter should also implement the interface we prepared above. - Adapter type should be added to
AdapterType
enum in core repository. You can useAdapterType.ABSTRACT
until then. - We can make
asset
,shareMask
andassetMask
public immutable variables as they can't change during adapter's lifetime, and storing them in such a way is more gas-efficient. - All adapter constructors should take at least two parameters: credit manager address and target contract address. We then use them to initialize abstract adapter. Finally, we initialize state variables.
contract ERC4626Adapter is AbstractAdapter, IERC4626Adapter {
AdapterType public constant override _gearboxAdapterType = AdapterType.ERC4626;
uint16 public constant override _gearboxAdapterVersion = 1;
/// @inheritdoc IERC4626Adapter
address public immutable override asset;
/// @inheritdoc IERC4626Adapter
uint256 public immutable override shareMask;
/// @inheritdoc IERC4626Adapter
uint256 public immutable override assetMask;
/// @notice Constructor
/// @param _creditManager Address of the credit manager to connect the adapter to
/// @param _vault Address of the target vault contract
constructor(address _creditManager, address _vault) AbstractAdapter(_creditManager, _vault) {
asset = IERC4626(_vault).asset();
shareMask = _getMaskOrRevert(_vault);
assetMask = _getMaskOrRevert(asset);
}
}
Wrapping functions
Now let's actually implement the state-modifying functionality.
Let's consider a step-by-step process of writing a wrapper function, keeping in mind abstract adapter's helper functions.
Step 1. Call the wrapped function of the target contract with passed calldata.
function deposit(uint256 assets, address receiver) external override {
_execute(abi.encodeCall(IERC4626.deposit, (assets, receiver)));
}
That would revert because target contract needs an approval to execute the call.
Step 2. Handle token approvals: give approval before the operation and revoke after it
function deposit(uint256 assets, address receiver) external override {
_approveToken(asset, type(uint256).max);
_execute(abi.encodeCall(IERC4626.deposit, (assets, receiver)));
_approveToken(asset, 1);
}
We could've just called _approveToken(asset, assets + 1)
and probably gotten the same result, but let's remember how adversarial our environment is: we recommend setting the approval to 1 explicitly.
Okay, this would do the job, but is it safe? Nope, it transfers shares to an arbitrary address!
Step 3. Ensure that tokens recipient is always the credit account.
function deposit(uint256 assets, address) external override {
address creditAccount = _creditAccount();
_approveToken(asset, type(uint256).max);
_execute(abi.encodeCall(IERC4626.deposit, (assets, creditAccount)));
_approveToken(asset, 1);
}
Are we done? Hell no! A potential attacker would be able to call the function once again if he manages to execute his own code during target contract call!
Step 4. Always add the creditFacadeOnly
modifier.
function deposit(uint256 assets, address) external override creditFacadeOnly {
address creditAccount = _creditAccount();
_approveToken(asset, type(uint256).max);
_execute(abi.encodeCall(IERC4626.deposit, (assets, creditAccount)));
_approveToken(asset, 1);
}
This function is good now, but we should still take care of the user and simplify multicalls for them.
Step 5. Enable tokens received during the call and disable the ones that were fully spent.
function deposit(uint256 assets, address) external override creditFacadeOnly {
address creditAccount = _creditAccount();
_approveToken(asset, type(uint256).max);
_execute(abi.encodeCall(IERC4626.deposit, (assets, creditAccount)));
_approveToken(asset, 1);
_changeEnabledTokens(shareMask, 0);
}
Asset is not disabled because this function doesn't generally spend the entire balance.
Notice that this step is as important for security as the previous ones in case when function deals with arbitrary tokens, because it checks whether tokens are recognized by the system.
Full contract
Let's repeat the same process for other wrapping functions. After some refactoring and gas optimization, the contract would look like this:
contract ERC4626Adapter is AbstractAdapter, IERC4626Adapter {
AdapterType public constant override _gearboxAdapterType = AdapterType.ERC4626;
uint16 public constant override _gearboxAdapterVersion = 1;
/// @inheritdoc IERC4626Adapter
address public immutable override asset;
/// @inheritdoc IERC4626Adapter
uint256 public immutable override shareMask;
/// @inheritdoc IERC4626Adapter
uint256 public immutable override assetMask;
/// @notice Constructor
/// @param _creditManager Address of the credit manager to connect the adapter to
/// @param _vault Address of the target vault contract
constructor(address _creditManager, address _vault) AbstractAdapter(_creditManager, _vault) {
asset = IERC4626(_vault).asset();
shareMask = _checkToken(_vault);
assetMask = _checkToken(asset);
}
/// @inheritdoc IERC4626Adapter
function deposit(uint256 assets, address) external override creditFacadeOnly {
_deposit(_creditAccount(), assets, false);
}
/// @inheritdoc IERC4626Adapter
function depositAll() external override creditFacadeOnly {
address creditAccount = _creditAccount();
uint256 balance = IERC20(asset).balanceOf(creditAccount);
if (balance <= 1) return;
unchecked {
_deposit(creditAccount, balance - 1, true);
}
}
/// @dev Implementation of deposit
function _deposit(address creditAccount, uint256 assets, bool disableAsset) internal {
_approveToken(asset, type(uint256).max);
_execute(abi.encodeCall(IERC4626.deposit, (assets, creditAccount)));
_approveToken(asset, 1);
_changeEnabledTokens(shareMask, disableAsset ? assetMask : 0);
}
/// @inheritdoc IERC4626Adapter
function mint(uint256 shares, address) external override creditFacadeOnly {
address creditAccount = _creditAccount();
_approveToken(asset, type(uint256).max);
_execute(abi.encodeCall(IERC4626.mint, (shares, creditAccount)));
_approveToken(asset, 1);
_changeEnabledTokens(shareMask, 0);
}
/// @inheritdoc IERC4626Adapter
function withdraw(uint256 assets, address, address) external override creditFacadeOnly {
address creditAccount = _creditAccount();
// NOTE: no approval needed since target contract will be called by CA
_execute(abi.encodeCall(IERC4626.withdraw, (shares, creditAccount, creditAccount)));
_changeEnabledTokens(assetMask, 0);
}
/// @inheritdoc IERC4626Adapter
function redeem(uint256 shares, address, address) external override creditFacadeOnly {
_redeem(_creditAccount(), shares, false);
}
/// @inheritdoc IERC4626Adapter
function redeemAll() external override creditFacadeOnly {
address creditAccount = _creditAccount();
uint256 balance = IERC20(targetContract).balanceOf(creditAccount);
if (balance <= 1) return;
unchecked {
_redeem(creditAccount, balance - 1, true);
}
}
/// @dev Implementation of redeem
function _redeem(address creditAccount, uint256 shares, bool disableShare) internal {
// NOTE: no approval needed since target contract will be called by CA
_execute(abi.encodeCall(IERC4626.redeem, (shares, creditAccount, creditAccount)));
_changeEnabledTokens(assetMask, disableShare ? shareMask : 0);
}
}
Checklist
As promised, let's evaluate the new adapter against the checklist on the previous page.
- Adapter must be made compatible with Gearbox protocol — the only adaptation is custom oracle
- Adapter must inherit and make use of
AbstractAdapter
— inherits explicitly - All wrapping functions that modify account's state must have the
creditFacadeOnly
modifier — recall step 4 - All wrapping functions can only modify the state of the
_creditAccount()
— recall step 3 - All wrapping functions that allow to specify a recipient must set it to the
_creditAccount()
— recall step 3 - All wrapping functions that require token approval to execute an operation must reset it to
1
after — recall step 2 - All wrapping functions that receive/spend tokens must call
_enableToken()
/_disableToken()
on them (or_changeEnabledTokens
if masks were initialized in the constructor) — recall step 5 and constructor