ERC-7540: Asynchronous Tokenized Vaults
ERC-7540 extends ERC-4626 with asynchronous deposit and redeem flows. Where a standard ERC-4626 vault settles deposits and redemptions atomically in a single transaction, ERC-7540 introduces a request lifecycle that allows settlement to happen over time — whether gated by an admin, a time delay, or any other custom mechanism.
This is useful for vaults that cannot settle instantly: real-world asset (RWA) funds, cross-chain yield strategies, staking protocols with unbonding periods, or any system where assets need time to be deployed or unwound.
The OpenZeppelin implementation provides a modular base (ERC7540) plus composable strategy extensions that control how requests move through their lifecycle. Developers pick one deposit strategy and one redeem strategy, and the base contract handles the rest.
How it relates to ERC-4626
The key insight of ERC-7540 is that it reuses ERC-4626’s existing functions for claiming:
| Function | ERC-4626 (synchronous) | ERC-7540 (asynchronous) |
|---|---|---|
|
Transfers assets in, mints shares immediately |
Claims a previously fulfilled deposit request |
|
Same as deposit, but specifying shares |
Claims by specifying shares |
|
Burns shares, transfers assets out immediately |
Claims a previously fulfilled redeem request |
|
Same as withdraw, but specifying shares |
Claims by specifying shares |
ERC-7540 adds two new entry points — requestDeposit and requestRedeem — and four view functions to observe a request’s lifecycle: pendingDepositRequest, claimableDepositRequest, pendingRedeemRequest, and claimableRedeemRequest. Everything else builds on top of the existing ERC-4626 interface.
An ERC-7540 vault must have at least one async side (deposit or redeem). If both are synchronous, use a standard ERC4626 vault instead.
|
The request lifecycle
Every async request transitions through three states:
flowchart LR
None(["(none)"]) -->|"requestDeposit() / requestRedeem()"| Pending["Pending<br/><i>Assets/shares locked in vault</i>"]
Pending -->|Fulfillment| Claimable["Claimable<br/><i>Ready to claim</i>"]
Claimable -->|"deposit() / mint() / withdraw() / redeem()"| Claimed["Claimed<br/><i>Shares/assets delivered to receiver</i>"]
-
Pending: Assets (for deposits) or shares (for redeems) are locked in the vault. The request is not yet ready to be claimed.
-
Claimable: The request has been fulfilled and the controller can claim at any time. The exchange rate is strategy-dependent: it may be fixed at fulfillment time (e.g. admin strategy) or determined at claim time from the live vault conversion (e.g. delay strategy).
-
Claimed: The controller has called
deposit/mintorwithdraw/redeemto collect their shares or assets.
| Requests must NOT skip the Claimable state, even if fulfillment happens in the same block as the request. The ERC-7540 spec requires integrators to be able to observe the Pending and Claimable states separately. |
Architecture overview
The implementation is split into a base contract and strategy extensions:
flowchart TD
Base["<b>ERC7540 (base)</b><br/>Routing logic (sync vs async)<br/>Operator management<br/>ERC-4626 interface<br/>totalAssets / totalSupply adjustments<br/>14 virtual hooks for strategies to implement"]
Base --> Admin["<b>Admin strategy</b><br/>Privileged caller fulfills<br/>per request with explicit rate"]
Base --> Delay["<b>Delay strategy</b><br/>Time-based, no<br/>privileged caller needed"]
Base --> Epoch["<b>Epoch strategy</b><br/>Privileged caller batches<br/>requests per time window"]
Base --> Sync["<b>Sync strategy</b><br/>Standard ERC-4626<br/>(no async lifecycle)"]
Each strategy comes in a deposit and redeem variant. You combine exactly one deposit strategy with one redeem strategy:
| Deposit Strategy | Redeem Strategy | Use Case |
|---|---|---|
|
|
RWA vault with manual settlement on both sides |
|
|
Gated deposits, instant redemptions |
|
|
Instant deposits, admin-controlled withdrawals |
|
|
Liquid staking (instant stake, delayed unstake) |
|
|
Fully permissionless time-locked vault |
|
|
Admin deposit review, time-locked unstaking |
|
|
NAV-based fund with batched periodic settlement (RWA basket, weekly rebalance) |
|
|
Batched deposit gating with instant redemptions |
|
|
Instant deposits with periodic batched withdrawals |
Combining ERC7540SyncDeposit + ERC7540SyncRedeem will revert at construction with ERC7540MissingAsync. At least one side must be async.
|
Admin strategy
The admin strategy is the most flexible model. A privileged caller (admin, keeper, oracle relayer) explicitly transitions requests from Pending to Claimable by calling _fulfillDeposit or _fulfillRedeem, providing both the amount and the exchange rate.
This suits use cases where settlement depends on external actions: deploying assets to a yield source, unwinding positions, bridging across chains, or completing off-chain compliance checks.
Admin deposit flow
// 1. User requests a deposit
vault.requestDeposit(1000e6, controller, owner); // locks 1000 USDC
// 2. Admin fulfills (off-chain: assets deployed to yield source)
vault._fulfillDeposit(1000e6, 950e18, controller); // 950 shares at the determined rate
// 3. User claims their shares
vault.deposit(1000e6, receiver, controller); // receiver gets 950 shares
The exchange rate is locked at fulfillment time in the claimableAssets / claimableShares pair. This means the user knows exactly how many shares they will receive before claiming.
Admin redeem flow
// 1. User requests a redeem
vault.requestRedeem(950e18, controller, owner); // locks 950 shares
// 2. Admin fulfills (off-chain: positions unwound, assets bridged back)
vault._fulfillRedeem(950e18, 1010e6, controller); // 1010 USDC at the determined rate
// 3. User claims their assets
vault.redeem(950e18, receiver, controller); // receiver gets 1010 USDC
Building an admin vault
Here is a complete example combining admin-controlled deposits and redemptions. The contract exposes fulfillDeposit and fulfillRedeem behind access control:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.27;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {ERC7540} from "@openzeppelin/community-contracts/token/ERC20/extensions/ERC7540.sol";
import {ERC7540AdminDeposit} from "@openzeppelin/community-contracts/token/ERC20/extensions/ERC7540AdminDeposit.sol";
import {ERC7540AdminRedeem} from "@openzeppelin/community-contracts/token/ERC20/extensions/ERC7540AdminRedeem.sol";
contract RWAVault is ERC7540AdminDeposit, ERC7540AdminRedeem, Ownable {
constructor(
IERC20 asset_
) ERC7540(asset_) Ownable(msg.sender) {}
/// @dev Admin fulfills a deposit request with the determined exchange rate.
function fulfillDeposit(uint256 assets, uint256 shares, address controller) external onlyOwner {
_fulfillDeposit(assets, shares, controller);
}
/// @dev Admin fulfills a redeem request with the determined exchange rate.
function fulfillRedeem(uint256 shares, uint256 assets, address controller) external onlyOwner {
_fulfillRedeem(shares, assets, controller);
}
// Resolve multiple inheritance for _requestDeposit and _requestRedeem
function _requestDeposit(
uint256 assets, address controller, address owner, uint256 requestId
) internal override(ERC7540, ERC7540AdminDeposit) returns (uint256) {
return super._requestDeposit(assets, controller, owner, requestId);
}
function _requestRedeem(
uint256 shares, address controller, address owner, uint256 requestId
) internal override(ERC7540, ERC7540AdminRedeem) returns (uint256) {
return super._requestRedeem(shares, controller, owner, requestId);
}
}
The admin strategy uses requestId = 0 for all requests since accounting is per-controller only. The pendingDepositRequest(0, controller) and claimableDepositRequest(0, controller) functions reflect the controller’s current state.
|
Delay strategy
The delay strategy makes requests claimable automatically after a configurable time period. No privileged caller is needed — anyone can claim once the delay elapses. The exchange rate is computed at claim time using the vault’s live convertToShares / convertToAssets.
This suits use cases with protocol-dictated waiting periods: staking unbonding, time-locked deposits, or cooldown mechanisms.
Delay deposit flow
// 1. User requests a deposit (delay = 1 hour by default)
uint256 requestId = vault.requestDeposit(1000e6, controller, owner);
// requestId = block.timestamp + 1 hours (the maturity timestamp)
// 2. Time passes... no transaction needed
// 3. After the delay, user claims (exchange rate computed now)
vault.deposit(1000e6, receiver, controller);
The requestId returned by the delay strategy is the absolute timestamp at which the request becomes claimable. This makes it self-describing — you can tell when a request will mature just from its ID.
How checkpoints work
The delay strategy uses Checkpoints.Trace208 to track cumulative deposit/redeem amounts keyed by their maturity timepoint. Multiple requests accumulate and mature independently:
flowchart LR
T100["<b>t=100</b><br/>request 500<br/>maturity=160"] --> T120["<b>t=120</b><br/>request 300<br/>maturity=180"] --> T160["<b>t=160</b><br/>500 claimable<br/>300 still pending"] --> T180["<b>t=180</b><br/>800 claimable<br/>(all matured)"]
CP["<b>Checkpoints (cumulative)</b><br/>key=160 → value=500<br/>key=180 → value=800"]
T120 -.-> CP
The total claimable amount at any time T is:
claimable = checkpoint.upperLookup(T) - claimedAmount
Building a delay vault
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.27;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC7540} from "@openzeppelin/community-contracts/token/ERC20/extensions/ERC7540.sol";
import {ERC7540DelayDeposit} from "@openzeppelin/community-contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol";
import {ERC7540DelayRedeem} from "@openzeppelin/community-contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol";
contract TimeLockVault is ERC7540DelayDeposit, ERC7540DelayRedeem {
constructor(IERC20 asset_) ERC7540(asset_) {}
/// @dev Custom deposit delay: 1 day.
function depositDelay(address /*controller*/) public view override returns (uint48) {
return 1 days;
}
/// @dev Custom redeem delay: 7 days (e.g. unbonding period).
function redeemDelay(address /*controller*/) public view override returns (uint48) {
return 7 days;
}
// Resolve multiple inheritance
function clock() public view override(ERC7540DelayDeposit, ERC7540DelayRedeem) returns (uint48) {
return super.clock();
}
function CLOCK_MODE() public view override(ERC7540DelayDeposit, ERC7540DelayRedeem) returns (string memory) {
return super.CLOCK_MODE();
}
function _requestDeposit(
uint256 assets, address controller, address owner, uint256 requestId
) internal override(ERC7540, ERC7540DelayDeposit) returns (uint256) {
return super._requestDeposit(assets, controller, owner, requestId);
}
function _requestRedeem(
uint256 shares, address controller, address owner, uint256 requestId
) internal override(ERC7540, ERC7540DelayRedeem) returns (uint256) {
return super._requestRedeem(shares, controller, owner, requestId);
}
}
The delay can be customized per controller by overriding depositDelay(address) or redeemDelay(address). This enables tiered access — e.g. whitelisted addresses with shorter delays.
|
Epoch strategy
The epoch strategy batches all requests submitted in the same time window — an epoch — and settles them together at a single locked exchange rate when the admin closes the epoch. Every controller in a fulfilled epoch receives the same pro-rata conversion.
This suits use cases where settlement is naturally periodic: weekly rebalances, NAV-driven funds, basket tokens, or vaults that aggregate inflows and outflows and process them in batches. Production equivalents include Cove, Amphor, and Lagoon.
Unlike the admin strategy (per-request fulfillment), every controller in the same epoch shares a single locked rate, and a single _fulfillDeposit(epochId, totalShares) / _fulfillRedeem(epochId, totalAssets) call settles all of them at once:
flowchart TD
A["Alice<br/>requestDeposit(100, ...)"] --> E["<b>Epoch N (Pending)</b><br/>totalAssets = 300"]
B["Bob<br/>requestDeposit(200, ...)"] --> E
E -->|"_fulfillDeposit(epochId, 150)"| EF["<b>Epoch N (Claimable)</b><br/>totalAssets = 300<br/>totalShares = 150"]
EF -->|"deposit() / mint()"| AS["Alice claims<br/>50 shares (pro-rata)"]
EF -->|"deposit() / mint()"| BS["Bob claims<br/>100 shares (pro-rata)"]
Epoch identification
By default currentDepositEpoch() and currentRedeemEpoch() return block.timestamp / 1 weeks + 1. The + 1 keeps requestId != 0 so the epoch strategy is never mistaken for ERC-7540’s controller-only (requestId == 0) accounting mode. Both functions are virtual and intended to be overridden in production deployments — common patterns are a manually-bumped counter incremented inside the admin fulfillment, a different cadence (daily, hourly), or a cyclic scheme tied to an external rebalance trigger.
The contract requires epochId < currentXEpoch() to fulfill — only past epochs can be settled. New requests are always recorded against the current epoch, so the admin only ever sees totals for already-closed epochs at fulfillment time.
|
Epoch deposit flow
// 1. User requests a deposit in the current epoch
uint256 epochId = vault.requestDeposit(1000e6, controller, owner);
// 2. Time passes (next week starts, or admin bumps the counter)
// 3. Admin fulfills the entire epoch with totalShares for the epoch's totalAssets
vault.fulfillDeposit(epochId, 950e18); // 1000 USDC pending -> 950 shares allocated
// 4. User claims their pro-rata share
vault.deposit(1000e6, receiver, controller); // receiver gets 950 shares
If multiple controllers submitted requests in the same epoch, each claims their pro-rata portion of the allocated shares. The admin sees only the per-epoch totals via totalDepositAssets(epochId) / totalDepositShares(epochId) and doesn’t need per-controller bookkeeping at fulfillment.
Epoch redeem flow
Symmetric: shares are queued during the epoch, the admin fulfills with the asset total that will be distributed pro-rata, and controllers claim.
uint256 epochId = vault.requestRedeem(950e18, controller, owner);
// next epoch
vault.fulfillRedeem(epochId, 1010e6); // 950 shares -> 1010 USDC allocated
vault.redeem(950e18, receiver, controller); // receiver gets 1010 USDC
Per-controller queue
Each controller tracks the epochs they participate in via a DoubleEndedQueue capped at 32 entries by default (override _depositRequestQueueLimit() / _redeemRequestQueueLimit() to change). Claims walk the queue front-to-back, popping fully-consumed epochs. The cap is per-controller — an attacker cannot fill another user’s queue, and epoch fulfillment is O(1) (a single write per epoch) so cross-controller DoS is not possible.
To enforce the per-controller isolation, the epoch strategy tightens the base’s authorization: on top of the owner-side check, requestDeposit and requestRedeem also require msg.sender to be controller or an approved operator of controller. Third-party attribution (msg.sender != controller) needs the controller’s prior setOperator approval — the standard operator pattern applies.
|
Users that hit the queue limit must claim fulfilled epochs to free up slots before submitting new requests.
Rounding and dust handling
Within a single epoch, claims pay each controller’s pro-rata share floor-rounded against the remaining totalAssets / totalShares. In rare cases involving very small fulfillment values relative to the number of claimants, rounding can leave up to 1 wei of unclaimable per-controller residue. At realistic ERC-20 decimals this dust is sub-unit and economically immaterial.
Unlike the standard ERC-4626 inflation-attack surface, per-epoch totalAssets and totalShares cannot be inflated by donation — they only change via requestDeposit / requestRedeem and _fulfillDeposit / _fulfillRedeem. Deployers wanting finer per-claim granularity can set _decimalsOffset() to scale share precision relative to assets.
Building an epoch vault
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.27;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {ERC7540} from "@openzeppelin/community-contracts/token/ERC20/extensions/ERC7540.sol";
import {ERC7540EpochDeposit} from "@openzeppelin/community-contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol";
import {ERC7540EpochRedeem} from "@openzeppelin/community-contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol";
contract NAVVault is ERC7540EpochDeposit, ERC7540EpochRedeem, Ownable {
constructor(IERC20 asset_) ERC7540(asset_) Ownable(msg.sender) {}
/// @dev Admin closes a past deposit epoch with the determined total shares.
function fulfillDeposit(uint256 epochId, uint256 totalShares) external onlyOwner {
_fulfillDeposit(epochId, totalShares);
}
/// @dev Admin closes a past redeem epoch with the determined total assets.
function fulfillRedeem(uint256 epochId, uint256 totalAssets) external onlyOwner {
_fulfillRedeem(epochId, totalAssets);
}
// Resolve multiple inheritance
function _requestDeposit(
uint256 assets, address controller, address owner, uint256 requestId
) internal override(ERC7540, ERC7540EpochDeposit) returns (uint256) {
return super._requestDeposit(assets, controller, owner, requestId);
}
function _requestRedeem(
uint256 shares, address controller, address owner, uint256 requestId
) internal override(ERC7540, ERC7540EpochRedeem) returns (uint256) {
return super._requestRedeem(shares, controller, owner, requestId);
}
}
For production use, override currentDepositEpoch() and currentRedeemEpoch() to read a manually-bumped counter (e.g. incremented inside fulfillDeposit / fulfillRedeem). The timestamp-bucketed default is convenient for testing but rarely matches real settlement cadences.
|
Mixing strategies
A common pattern is to keep one side synchronous while making the other async. For example, a liquid staking vault might allow instant deposits but require a 7-day unbonding period for redemptions:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.27;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC7540} from "@openzeppelin/community-contracts/token/ERC20/extensions/ERC7540.sol";
import {ERC7540SyncDeposit} from "@openzeppelin/community-contracts/token/ERC20/extensions/ERC7540SyncDeposit.sol";
import {ERC7540DelayRedeem} from "@openzeppelin/community-contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol";
contract LiquidStakingVault is ERC7540SyncDeposit, ERC7540DelayRedeem {
constructor(IERC20 asset_) ERC7540(asset_) {}
function redeemDelay(address /*controller*/) public view override returns (uint48) {
return 7 days;
}
function _requestRedeem(
uint256 shares, address controller, address owner, uint256 requestId
) internal override(ERC7540, ERC7540DelayRedeem) returns (uint256) {
return super._requestRedeem(shares, controller, owner, requestId);
}
}
In this vault:
-
deposit(assets, receiver)works synchronously, just like ERC-4626 -
requestDeposit(…)reverts withERC7540SyncDeposit -
requestRedeem(shares, controller, owner)starts the async flow -
After the delay,
redeem(shares, receiver, controller)claims the assets
Authorization model
ERC-7540 extends ERC-4626’s two-party model (caller + owner) with a controller/operator system:
-
Owner: The account whose assets or shares are being used
-
Controller: The account that controls the request lifecycle and can claim
-
Operator: An account approved by a controller to act on their behalf
// Grant operator permissions
vault.setOperator(operatorAddress, true);
// Now the operator can manage requests for msg.sender
vault.requestDeposit(1000e6, controller, owner); // operator acting for owner
vault.deposit(1000e6, receiver, controller); // operator claiming for controller
The authorization rules differ by function:
| Function | Who can call |
|---|---|
|
|
|
|
|
|
|
|
For requestRedeem, authorization can come from either the operator system or standard ERC-20 approval. This dual authorization is consistent with ERC-6909. Operators are not subject to allowance restrictions, while non-infinite ERC-20 approvals are consumed.
|
Share custody models
During the async lifecycle, shares and assets must be held somewhere between request and claim. The base contract provides two configurable hooks that control this behavior:
Deposit side: _depositShareOrigin()
| Return value | Behavior |
|---|---|
|
Mint on claim. Shares are minted to the receiver when they call |
Non-zero address |
Pre-mint on fulfill. Shares are minted to the specified address (e.g. |
Redeem side: _redeemShareDestination()
| Return value | Behavior |
|---|---|
|
Burn on request. Shares are burned immediately when the user calls |
Non-zero address |
Escrow on request. Shares are transferred to the specified address when the user calls |
Why totalAssets and totalSupply are adjusted
The base ERC7540 contract overrides both to keep the share price accurate during the async lifecycle:
flowchart TD
TA["<b>totalAssets()</b> = asset.balanceOf(vault) − _totalPendingDepositAssets"]
TA --> TA1["<b>asset.balanceOf(vault)</b><br/>All assets in the vault,<br/>including pending ones"]
TA --> TA2["<b>_totalPendingDepositAssets</b><br/>Assets received but not yet<br/>converted to shares.<br/>Must not inflate the perceived yield."]
TS["<b>totalSupply()</b> = ERC20.totalSupply() + _totalPendingRedeemShares"]
TS --> TS1["<b>ERC20.totalSupply()</b><br/>The on-chain ERC-20 supply"]
TS --> TS2["<b>_totalPendingRedeemShares</b><br/>Shares already burned/escrowed<br/>but logically still outstanding<br/>(request not yet settled)"]
This ensures convertToShares / convertToAssets reflect the real exchange rate at all times, even while requests are in flight.
Building a custom strategy
If none of the bundled strategies (admin, delay, epoch) fit your use case, you can build a custom fulfillment strategy by extending ERC7540 directly and implementing the 14 virtual hooks.
Each async side (deposit or redeem) requires 7 hooks:
| Hook | Purpose |
|---|---|
|
Return |
|
Return the amount in Pending state for a given |
|
Return the amount in Claimable state for a given |
|
Consume claimable state when claiming via |
|
Consume claimable state when claiming via |
|
Return the maximum claimable amount (in assets) for |
|
Return the maximum claimable amount (in shares) for |
For example, an oracle-driven strategy could mark requests claimable when an external price feed crosses a threshold; or a per-request strategy could give each request its own settlement timeline driven by off-chain compliance checks.
Security considerations
Preview functions revert for async flows
Per the ERC-7540 spec, previewDeposit, previewMint, previewWithdraw, and previewRedeem revert when the corresponding side is async. This is because the exchange rate is unknown until fulfillment, so no reliable preview can be given.
Integrators should check supportsInterface for IERC7540Deposit / IERC7540Redeem to determine whether the vault is async, and avoid calling preview functions for async sides.
Operator trust
An operator approved via setOperator has broad permissions: they can request deposits using the controller’s assets, request redemptions using the controller’s shares, and claim on behalf of the controller. Users should only approve operators they fully trust.
Exchange rate manipulation
In the admin strategy, the admin has full control over the exchange rate at fulfillment time. The admin must ensure totalAssets() accurately reflects the vault’s holdings after deploying assets, to avoid diluting existing shareholders.
In the delay strategy, the exchange rate is computed at claim time using the live vault conversion. This means the rate can change between request and claim. If the vault’s underlying yield fluctuates significantly, claimers may receive more or fewer shares/assets than expected at request time.
In the epoch strategy, the admin picks a single rate per epoch that applies to every controller in the batch. Choosing a rate that does not reflect the real totalAssets() after deploying the epoch’s capital dilutes existing shareholders just as in the admin strategy, but the impact is amplified because every request in the epoch is settled at that same rate. The per-controller queue limit (_depositRequestQueueLimit / _redeemRequestQueueLimit, default 32) further bounds the per-account state — a user who fills their queue with small requests can only block themselves from submitting new ones, not other users, and fulfillment is O(1) per epoch.