Skip to content

Explore epoch-based fulfillment for ERC-7540 (ERC7540EpochRedeem / ERC7540EpochDeposit) #6447

@ernestognw

Description

@ernestognw

Part of #4761 / #6399.

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] and redeemRequest[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:

  1. An epoch is open. New requests are tagged with the current epoch ID.
  2. The epoch is closed — no new requests are accepted into it.
  3. 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 requestId MUST be fungible with each other [...] all Requests with the same requestId MUST 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 claimableRedeemRequest to compute claimable amounts lazily from the epoch's settlement rate, without explicitly calling _fulfillRedeem per 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 / convertToShares at settlement time (simpler, same as delayed/controlled)
  • (b) Accept a totalAssets parameter 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 / _fulfillDeposit to use the epoch's locked rate instead of calling convertToAssets
  • 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

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions