OpenZeppelin / OpenZeppelin/openzeppelin-contracts

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

Open
#6,447 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Solidity
Stars
27.2k
Forks
12.4k
Avg merge
2d 19h
Merged PRs (30d)
33

Description

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

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by reading the ERC-7540 base contracts and their _depositRequestId, _redeemRequestId, _fulfillRedeem, and _fulfillDeposit hooks, then compare the Lagoon, Cove, Amphor, and Nashpoint references. The work is done when the six listed design questions are resolved well enough to define an implementation PR or document why the abstraction should remain documentation-only.

Written by the indexing model from the issue text.

Assessment

Tech stack
solidity
Domain
blockchain
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Needs clarification
Newbie friendliness
30/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.