
Building a Safe ERC-4626 Vault Adapter Library in Solidity
Why an adapter library is useful
ERC-4626 defines a common interface for deposits, mints, withdrawals, and redemptions. In practice, however, application contracts often need more than the raw interface:
- validate expected slippage before executing a deposit
- handle fee-on-transfer or fee-charging vault behavior
- compare preview values against actual results
- normalize asset and share amounts for downstream accounting
- avoid accidental over-withdrawal or under-minting
A library is a good fit when multiple contracts in your system need the same vault interaction rules. Instead of duplicating checks in every contract, you centralize them in one audited helper.
What this tutorial builds
We will create a library that provides:
- safe deposit and mint helpers
- safe withdraw and redeem helpers
- preview-based slippage protection
- consistent revert reasons
- optional post-action validation
The library is designed for use by a protocol contract, such as a strategy router, treasury manager, or automated rebalancer.
Understanding the ERC-4626 interaction model
ERC-4626 exposes two categories of functions:
| Category | Functions | Purpose |
|---|---|---|
| Preview functions | previewDeposit, previewMint, previewWithdraw, previewRedeem | Estimate outcomes before execution |
| Action functions | deposit, mint, withdraw, redeem | Execute the state-changing operation |
The preview functions are especially important because they let you define acceptable bounds before sending tokens or burning shares. A safe adapter should compare the previewed result with the caller’s minimum or maximum constraints.
Common integration risks
- Rounding mismatch
Vaults may round down on deposits and round up on withdrawals. If your contract assumes exact symmetry, users can receive less than expected.
- Unexpected fees
Some vaults charge management or performance fees that affect preview values and actual outcomes.
- Incorrect asset/share assumptions
Asset decimals and share decimals may differ. Never assume a 1:1 relationship.
- Unsafe external calls
Vault interactions are external calls. Your adapter should be minimal and should not hold mutable state across the call unless necessary.
Designing the library API
A good adapter library should be explicit about intent. Instead of a single generic helper, define separate functions for each operation and make slippage limits part of the signature.
A practical API might look like this:
safeDeposit(vault, assets, receiver, minShares)safeMint(vault, shares, receiver, maxAssets)safeWithdraw(vault, assets, receiver, owner, maxShares)safeRedeem(vault, shares, receiver, owner, minAssets)
This design mirrors the ERC-4626 semantics and keeps the caller responsible for bounds.
Implementation
Below is a compact but production-oriented library. It assumes a standard ERC-4626 interface and uses custom errors for gas-efficient reverts.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IERC20 {
function transferFrom(address from, address to, uint256 value) external returns (bool);
function approve(address spender, uint256 value) external returns (bool);
}
interface IERC4626 {
function asset() external view returns (address);
function deposit(uint256 assets, address receiver) external returns (uint256 shares);
function mint(uint256 shares, address receiver) external returns (uint256 assets);
function withdraw(uint256 assets, address receiver, address owner) external returns (uint256 shares);
function redeem(uint256 shares, address receiver, address owner) external returns (uint256 assets);
function previewDeposit(uint256 assets) external view returns (uint256 shares);
function previewMint(uint256 shares) external view returns (uint256 assets);
function previewWithdraw(uint256 assets) external view returns (uint256 shares);
function previewRedeem(uint256 shares) external view returns (uint256 assets);
}
library SafeERC4626Adapter {
error SlippageExceeded();
error ZeroAmount();
error InvalidReceiver();
error InvalidOwner();
function safeDeposit(
IERC4626 vault,
uint256 assets,
address receiver,
uint256 minShares
) internal returns (uint256 shares) {
if (assets == 0) revert ZeroAmount();
if (receiver == address(0)) revert InvalidReceiver();
shares = vault.previewDeposit(assets);
if (shares < minShares) revert SlippageExceeded();
uint256 actualShares = vault.deposit(assets, receiver);
if (actualShares < minShares) revert SlippageExceeded();
return actualShares;
}
function safeMint(
IERC4626 vault,
uint256 shares,
address receiver,
uint256 maxAssets
) internal returns (uint256 assets) {
if (shares == 0) revert ZeroAmount();
if (receiver == address(0)) revert InvalidReceiver();
assets = vault.previewMint(shares);
if (assets > maxAssets) revert SlippageExceeded();
uint256 actualAssets = vault.mint(shares, receiver);
if (actualAssets > maxAssets) revert SlippageExceeded();
return actualAssets;
}
function safeWithdraw(
IERC4626 vault,
uint256 assets,
address receiver,
address owner,
uint256 maxShares
) internal returns (uint256 shares) {
if (assets == 0) revert ZeroAmount();
if (receiver == address(0)) revert InvalidReceiver();
if (owner == address(0)) revert InvalidOwner();
shares = vault.previewWithdraw(assets);
if (shares > maxShares) revert SlippageExceeded();
uint256 actualShares = vault.withdraw(assets, receiver, owner);
if (actualShares > maxShares) revert SlippageExceeded();
return actualShares;
}
function safeRedeem(
IERC4626 vault,
uint256 shares,
address receiver,
address owner,
uint256 minAssets
) internal returns (uint256 assets) {
if (shares == 0) revert ZeroAmount();
if (receiver == address(0)) revert InvalidReceiver();
if (owner == address(0)) revert InvalidOwner();
assets = vault.previewRedeem(shares);
if (assets < minAssets) revert SlippageExceeded();
uint256 actualAssets = vault.redeem(shares, receiver, owner);
if (actualAssets < minAssets) revert SlippageExceeded();
return actualAssets;
}
}How the adapter works
Each helper follows the same pattern:
- validate inputs
- compute a preview value
- compare it to the caller’s bound
- execute the vault action
- validate the actual return value
This double-check approach is useful because preview functions are only estimates. If the vault changes state between preview and execution, the actual result may differ slightly. The post-action check ensures your contract still enforces its safety policy.
Why check both preview and return value?
Checking only the preview is not enough. Between the preview call and the action call, the vault’s exchange rate may move due to deposits, withdrawals, or strategy updates. Checking only the action return value is also insufficient if your contract wants to fail early before approving or transferring assets elsewhere.
Using both checks gives you:
- early rejection for obviously bad trades
- final validation against execution drift
- clearer invariants for downstream logic
Using the library in a protocol contract
Here is a simple example of a treasury contract that deposits assets into a vault and later redeems them.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "./SafeERC4626Adapter.sol";
contract TreasuryVaultManager {
using SafeERC4626Adapter for IERC4626;
IERC4626 public immutable vault;
IERC20 public immutable asset;
constructor(IERC4626 _vault) {
vault = _vault;
asset = IERC20(_vault.asset());
}
function depositToVault(uint256 assets, uint256 minShares) external returns (uint256 shares) {
require(asset.transferFrom(msg.sender, address(this), assets), "TRANSFER_FAILED");
require(asset.approve(address(vault), assets), "APPROVE_FAILED");
shares = vault.safeDeposit(assets, address(this), minShares);
}
function redeemFromVault(uint256 shares, uint256 minAssets) external returns (uint256 assets) {
assets = vault.safeRedeem(shares, msg.sender, address(this), minAssets);
require(asset.transfer(msg.sender, assets), "TRANSFER_FAILED");
}
}This example shows a common pattern: the protocol contract receives assets, approves the vault, deposits, and later redeems. In a real system, you would likely use a safer ERC-20 helper for approvals and transfers, but the vault adapter logic remains the same.
Best practices for production use
1. Always expose user-defined bounds
Never hardcode acceptable slippage in the library. Let the caller pass minShares, maxAssets, maxShares, or minAssets. Different integrations have different risk tolerances.
2. Prefer explicit receiver and owner parameters
ERC-4626 supports third-party deposits and withdrawals. Your adapter should not assume the caller is always the beneficiary or the asset owner.
3. Avoid stateful assumptions across calls
Do not cache preview values and use them later without revalidation. Vault exchange rates can change quickly, especially in active strategies.
4. Treat non-standard vaults carefully
Some vaults may implement additional fees or custom behavior around rounding. Test the adapter against the exact vault implementation you plan to support.
5. Keep revert reasons small and consistent
Custom errors are cheaper than string-based reverts and easier to standardize across a codebase. They also make testing more precise.
Handling decimals and accounting
One subtle issue in vault integrations is decimal normalization. ERC-4626 does not require the share token to use the same decimals as the underlying asset. A vault may use 6-decimal assets and 18-decimal shares, or vice versa.
Your adapter should not attempt to convert decimals manually unless your application has a specific accounting model. Instead:
- rely on preview functions for conversion
- store values in the units returned by the vault
- normalize only at the application boundary if needed
Example comparison
| Scenario | Bad assumption | Safer approach |
|---|---|---|
| Asset decimals differ from share decimals | Treat 1 asset as 1 share | Use previewDeposit and previewRedeem |
| Vault charges fees | Ignore fee impact | Enforce minShares or minAssets |
| Exchange rate moves between calls | Trust preview only | Validate preview and actual return |
| Third-party withdrawal | Assume caller owns shares | Pass explicit owner and validate permissions |
Testing the adapter
A safe library should be tested against both compliant and adversarial vault behavior.
Recommended test cases
- deposit with zero assets reverts
- mint with zero shares reverts
- deposit below
minSharesreverts - mint above
maxAssetsreverts - withdraw above
maxSharesreverts - redeem below
minAssetsreverts - receiver or owner set to zero address reverts
- preview and actual values differ slightly but remain within bounds
- vault with non-1:1 decimals behaves correctly
Fuzzing ideas
Fuzz the following inputs:
- asset amounts
- share amounts
- slippage bounds
- receiver and owner addresses
- vault exchange rate changes between preview and execution
The key invariant is that the adapter should never allow an operation outside the caller’s declared bounds.
When to extend the library
You may want to extend this adapter if your protocol needs:
- batch deposits or redemptions
- role-based vault access
- automatic approval management
- integration with permit-based asset transfers
- vault whitelisting and risk scoring
Keep the core library small and focused. Add higher-level orchestration in a separate contract or module so the adapter remains easy to audit.
Summary
A Solidity library for ERC-4626 vault interactions is most valuable when it enforces predictable bounds around deposits, mints, withdrawals, and redemptions. By combining preview checks, post-execution validation, and explicit slippage parameters, you can reduce integration risk without sacrificing composability.
The main design principle is simple: let the vault do the accounting, but let your adapter enforce the safety policy.
