-
Notifications
You must be signed in to change notification settings - Fork 12.4k
Explore epoch-based fulfillment for ERC-7540 (ERC7540EpochRedeem / ERC7540EpochDeposit) #6447
Description
This is a design exploration issue. Epoch-based settlement is the most common pattern among production ERC-7540 vaults, but also the hardest to generalize. The goal of this issue is to iterate on the design until we converge on an implementation that covers enough real use cases to justify inclusion in the library, or decide it belongs in documentation only.
Motivation
Of multiple production ERC-7540 deployments surveyed, 4 use epoch-based settlement, the single largest category:
| Project | Async deposit | Async redeem | Notes |
|---|---|---|---|
| Cove | Yes | Yes | Single epoch counter, accumulate per epoch |
| Nashpoint | No | Yes | Epoch-based redeem only |
| Amphor | Yes | Yes | Close/settle split |
| Lagoon | Yes | Yes | Odd epoch IDs for deposits, even for redeems; per-epoch NAV snapshot |
Epoch-based vaults are the natural fit for tranched products, RWA funds with periodic NAV updates, and any vault where all participants within a window must receive the same exchange rate. This is a recurring pattern in tokenized finance and one that protocols frequently ask about.
Why this is hard to generalize
The current production implementations diverge substantially:
- Epoch ID schemes: Lagoon uses odd IDs for deposits and even for redeems. Cove uses a single monotonic counter. Amphor splits close vs. settle into separate steps.
- NAV source: some vaults take NAV as a parameter at epoch close; others read it from
totalAssets(). - Per-epoch storage: Lagoon stores
depositRequest[controller]andredeemRequest[controller]per epoch ID. Cove accumulates totals without per-controller breakdown per epoch. - Settlement granularity: some settle all requests atomically; others settle deposit and redeem sides independently.
Despite these differences, there is a common skeleton that may be extractable.
Core abstraction to explore
The common structure across all epoch-based implementations is:
- An epoch is open. New requests are tagged with the current epoch ID.
- The epoch is closed — no new requests are accepted into it.
- The epoch is settled — all requests in it become Claimable at a single exchange rate.
┌──────────┐ close() ┌──────────┐ settle() ┌──────────┐
Requests ──► Open ├────────────────► Closed ├───────────────► Settled │
│ epoch = N │ │ epoch = N │ │ epoch = N │
└──────────┘ └──────────┘ └──────────┘
epoch N+1
opens automatically
Relationship with ERC-7540 request IDs
The ERC-7540 spec says:
Requests of the same
requestIdMUST be fungible with each other [...] all Requests with the samerequestIdMUST transition from Pending to Claimable at the same time and receive the same exchange rate.
This maps naturally to epochs: the epoch ID is the request ID. All controllers who requested within epoch N share the same settlement rate when epoch N is settled. The base contracts already expose _depositRequestId / _redeemRequestId as virtual functions that return 0 by default — an epoch implementation would override these to return the current epoch ID.
Sketch of a possible interface
abstract contract ERC7540EpochRedeem is ERC7540Redeem {
event EpochClosed(uint256 indexed epochId, uint256 totalPendingShares);
event EpochSettled(uint256 indexed epochId, uint256 totalShares, uint256 totalAssets);
uint256 public currentEpoch;
// Accumulated pending shares per epoch (not per-controller — that's in the base)
mapping(uint256 epochId => uint256 totalShares) public epochPendingShares;
// Which epoch each controller's latest request belongs to
mapping(address controller => uint256 epochId) public controllerEpoch;
/// @dev Override to tag requests with the current epoch
function _redeemRequestId(address controller) internal view override returns (uint256) {
return currentEpoch;
}
/// @dev Close the current epoch and open the next one
function closeEpoch() external virtual returns (uint256 closedEpochId) {
_authorizeCloseEpoch();
// ... snapshot totalPendingShares for the epoch, increment currentEpoch
}
/// @dev Settle a closed epoch — all controllers become Claimable at the same rate
function settleEpoch(uint256 epochId) external virtual returns (uint256 totalAssets) {
_authorizeSettleEpoch();
// ... iterate controllers in this epoch, call _fulfillRedeem for each
}
function _authorizeCloseEpoch() internal virtual;
function _authorizeSettleEpoch() internal virtual;
}Key design questions
These are the questions to resolve before this becomes an implementation PR:
1. How to enumerate controllers per epoch at settlement time
_fulfillRedeem / _fulfillDeposit work per-controller. To settle an epoch, the contract needs to either:
- (a) Maintain an on-chain list of controllers per epoch (gas cost grows with users, unbounded loop)
- (b) Let the settlement caller pass the list of controllers as calldata (off-chain indexing required, but bounded gas per tx)
- (c) Avoid iterating altogether: override
claimableRedeemRequestto compute claimable amounts lazily from the epoch's settlement rate, without explicitly calling_fulfillRedeemper controller at settlement time
Option (c) is what Lagoon does and is the most gas-efficient, but it requires overriding the view functions and storage layout from the base contracts significantly. Option (b) is simpler and matches how fulfillMultipleRedeems works in the controlled variant. Option (a) is the most straightforward but does not scale.
2. Close vs. settle: one step or two?
Some implementations (Amphor) separate closing the epoch (no more requests accepted) from settling it (computing the rate). This allows an off-chain NAV oracle to submit the rate between close and settle. Others (Cove) do it in one step. A two-step model is more general but adds complexity.
3. NAV input
At settlement, the exchange rate must be fixed for the epoch. Options:
- (a) Use the live
convertToAssets/convertToSharesat settlement time (simpler, same as delayed/controlled) - (b) Accept a
totalAssetsparameter at settlement and compute the rate from that snapshot (more flexible, required for off-chain NAV)
Option (b) is more powerful but introduces trust in the caller to provide an honest NAV. It would likely need to be combined with the _authorizeSettleEpoch hook for access control.
4. Deposit and redeem epoch coupling
Should deposits and redeems share the same epoch counter, or have independent counters? Lagoon uses independent counters (odd for deposits, even for redeems). Independent counters allow settling deposits and redeems at different cadences, which is useful when the two sides have different liquidity constraints.
5. Composability with delayed/controlled
Can ERC7540EpochRedeem compose cleanly with ERC7540DelayedDeposit or ERC7540ControlledDeposit? If epochs only apply to one direction, a vault could use epoch-based redeems with synchronous deposits, which is the Nashpoint pattern.
6. Interaction with the base _fulfillRedeem / _fulfillDeposit
The base hooks move amounts from pending → claimable per-controller using the live exchange rate. An epoch model that wants a snapshotted rate would need to either:
- Override
_fulfillRedeem/_fulfillDepositto use the epoch's locked rate instead of callingconvertToAssets - Or settle at the base's live rate (accepting that the rate at settlement time is the epoch's rate)
The second option is simpler and may be sufficient if close + settle happen atomically.
Production references
- Lagoon: https://github.com/hopperlabsxyz/lagoon-v0/blob/main/src/v0.5.0/ERC7540.sol (most complete, two-step, per-epoch NAV snapshot)
- Cove: https://github.com/Storm-Labs-Inc/cove-contracts-core/blob/master/src/BasketToken.sol (single-step close+settle)
- Amphor: https://github.com/AmphorProtocol/asynchronous-vault/tree/main (two-step close/settle)
- Nashpoint: https://github.com/nashpoint/nashpoint-smart-contracts/blob/main/src/Node.sol (epoch redeem only)
Success criteria
This issue is ready to become an implementation PR when:
- The controller enumeration question (1) is resolved
- Close vs. settle step count (2) is decided
- NAV input model (3) is agreed upon
- Epoch counter independence (4) is decided
- Composability with existing extensions (5) is validated
- Interaction with base fulfillment hooks (6) is prototyped