Skip to content

ERC-7540: Epoch-based fulfillment strategy#230

Open
ernestognw wants to merge 60 commits into
masterfrom
feat/erc-7540-epoch
Open

ERC-7540: Epoch-based fulfillment strategy#230
ernestognw wants to merge 60 commits into
masterfrom
feat/erc-7540-epoch

Conversation

@ernestognw

@ernestognw ernestognw commented Apr 22, 2026

Copy link
Copy Markdown
Member

Summary

Production equivalents: Cove, Amphor, Lagoon.

Known pending work

  • Replace bare require statements in _fulfillDeposit/_fulfillRedeem with custom errors — typed errors *TooEarly, *EmptyEpoch, *AlreadyFulfilled are now thrown
  • Add fulfillment events to _fulfillDeposit/_fulfillRedeemERC7540Epoch{Deposit,Redeem}Fulfilled emitted with the locked rate
  • Verify whether saturatingSub is needed for the subtraction operations in _consumeClaimable* loops — resolved via saturatingSub + drained-epoch handling; residual-dust bound is documented as a NOTE in both contracts
  • Public getters for _epochs totals — totalDepositAssets/totalDepositShares and totalRedeemAssets/totalRedeemShares expose the per-epoch state
  • Public getter for _memberOf (per-controller epoch queue) — depends on Add values(start, end) slice accessor to DoubleEndedQueue openzeppelin-contracts#6522 for paginated DoubleEndedQueue access
  • Add tests — ERC7540EpochDeposit.test.js (435 lines) and ERC7540EpochRedeem.test.js (410 lines)

Test plan

  • Test epoch lifecycle: request → fulfill → claim across deposit and redeem
  • Test multi-epoch claims (controller with requests in multiple fulfilled epochs)
  • Test _requestQueueLimit enforcement (32 default)
  • Test partial claims within an epoch
  • Test that DoS via small requests is bounded per-account by queue limit
  • ERC-165 interface support validation — covered via shouldSupportInterfaces in the included shouldBehaveLikeERC7540{Operator,Deposit,Redeem} helpers

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Implemented ERC-7540 epoch-based asynchronous deposit protocol with automatic request batching, queue management, and pro-rata share distribution upon epoch fulfillment
    • Implemented ERC-7540 epoch-based asynchronous redemption protocol with automatic request batching, queue management, and pro-rata asset distribution upon epoch fulfillment
    • Added comprehensive test infrastructure for validating epoch-driven deposit and redemption request behavior

Amxx and others added 30 commits April 8, 2026 15:07
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
_computeAsyncMint was subtracting shares from an assets-denominated field,
and _computeAsyncWithdraw was subtracting assets from a shares-denominated
field. Move the decrement after the unit conversion so the correct value
is used.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- _redeemRedeemShareDestination → _redeemShareDestination
- _assetsToFullfillDeposit → _assetsToFulfillDeposit
- _sharesToFullfillReedem → _sharesToFulfillRedeem

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
These functions mutate state (epoch totals, queue pops, request
decrements), so "consume" better signals the non-pure nature.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Consolidate claim logic that was previously split between
_consumeClaimable* (ratio computation) and _deposit/_withdraw overrides
(bookkeeping) into a single _consumeClaimable* hook per subclass. This
fixes incorrect receiver-vs-controller keying in Admin and Delay
bookkeeping and makes the base ERC7540 stubs revert NotImplemented,
consistent with other abstract hooks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ernestognw and others added 4 commits April 22, 2026 12:15
The epoch-based fulfillment strategy (ERC7540EpochDeposit, ERC7540EpochRedeem,
and ERC7540EpochMock) is too complex for this initial PR. Move it to a follow-up
PR targeting this branch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Apr 22, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a9406a3a-99d9-411a-be4e-39009d183ba5

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

Three new contracts are introduced to implement ERC-7540 epoch-based asynchronous deposits and redemptions. An abstract ERC7540EpochDeposit contract handles deposit requests batched by epoch with pro-rata share conversion upon fulfillment. A complementary ERC7540EpochRedeem contract manages redemption requests similarly. A test mock contract (ERC7540EpochMock) combines both implementations for testing purposes.

Changes

Cohort / File(s) Summary
Epoch-Based Deposit & Redeem
contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol, contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol
Introduces two abstract contracts implementing ERC-7540 asynchronous deposit and redemption flows with per-epoch tracking of totals and requests, controller epoch queue management with limits, claimability determination via fulfillment status, and pro-rata conversion logic using floor/ceil rounding rules.
Test Mock
contracts/mocks/token/ERC7540EpochMock.sol
Adds abstract mock contract combining both epoch deposit and redeem functionality with internal method overrides (_requestDeposit, _requestRedeem, _requestQueueLimit) that forward to parent implementations for testing derived contracts.

Sequence Diagram(s)

sequenceDiagram
    participant Controller
    participant ERC7540EpochDeposit
    participant EpochQueue
    participant Fulfillment

    Controller->>ERC7540EpochDeposit: requestDeposit(assets)
    ERC7540EpochDeposit->>ERC7540EpochDeposit: Calculate currentDepositEpoch
    ERC7540EpochDeposit->>ERC7540EpochDeposit: Record assets to epoch.requests
    ERC7540EpochDeposit->>EpochQueue: Enqueue epochId if new (respecting _requestQueueLimit)
    Note over ERC7540EpochDeposit: Deposit request batched by epoch

    Fulfillment->>ERC7540EpochDeposit: _fulfillDeposit(epochId, totalShares)
    ERC7540EpochDeposit->>ERC7540EpochDeposit: Set epoch.totalShares (now claimable)
    Note over ERC7540EpochDeposit: Epoch becomes eligible for claims

    Controller->>ERC7540EpochDeposit: claimDeposit() or maxClaimableDeposit()
    loop For each queued epoch
        ERC7540EpochDeposit->>ERC7540EpochDeposit: Check if totalShares > 0 (fulfilled)
        ERC7540EpochDeposit->>ERC7540EpochDeposit: Pro-rata convert: shares = (requests × totalShares) / totalAssets
        ERC7540EpochDeposit->>ERC7540EpochDeposit: Reduce epoch.requests and totalAssets
    end
    ERC7540EpochDeposit->>Controller: Return minted shares
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

A rabbit hops through epochs bright,
Deposits queue in batches light,
When fulfilment's whisper breaks the night,
Pro-rata shares bloom just right! 🐰✨
Async redemptions, queued with care—
New ERC-7540 magic fair!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'ERC-7540: Epoch-based fulfillment strategy' clearly and concisely summarizes the main change: introducing epoch-based fulfillment logic for ERC-7540 vaults.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/erc-7540-epoch

Comment @coderabbitai help to get the list of available commands and usage tips.

Base automatically changed from feat/erc-7540-standalone to master April 28, 2026 13:36

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol`:
- Around line 183-188: Compute batchShares using the pre-mutation epoch totals
(use details.totalAssets and details.totalShares before any -= on
details.totalAssets/details.totalShares/requests[controller]/assets) so
conversions use the locked epoch rate; i.e., calculate batchShares =
batchAssets.mulDiv(details.totalShares, details.totalAssets,
Math.Rounding.Floor) before you decrement details.requests[controller],
details.totalAssets, details.totalShares, and assets, and then perform the
saturatingSub-style decrements after that calculation (apply same fix for the
other similar block around lines 211-217).
- Around line 58-74: The epoch fulfilment sentinel must not rely on totalShares
== 0 because _fulfillDeposit can legitimately set totalShares to 0; add an
explicit boolean (e.g., EpochDepositMetadata.fulfilled) to the
EpochDepositMetadata struct, set it to true in _fulfillDeposit, and update
_pendingDepositRequest, _claimableDepositRequest, and _assetsToFulfillDeposit to
check details.fulfilled (false = pending, true = claimable) instead of comparing
totalShares; also ensure any logic that previously relied on totalShares==0 is
adjusted to use the new fulfilled flag and that _fulfillDeposit still records
totalShares correctly while marking fulfilled.

In `@contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol`:
- Around line 59-75: The code treats details.totalAssets == 0 as the sole
sentinel for "fulfilled", which breaks when _fulfillRedeem writes totalAssets ==
0; to fix, add an explicit fulfilled flag to EpochRedeemMetadata (e.g., bool
fulfilled), set details.fulfilled = true inside _fulfillRedeem whenever the
epoch is processed (even if totalAssets == 0), and change _pendingRedeemRequest
and _claimableRedeemRequest to check details.fulfilled (pending when !fulfilled,
claimable when fulfilled). Also update any logic in _sharesToFulfillRedeem and
other spots that currently rely on totalAssets == 0 to use the new fulfilled
flag while still reading totalAssets for amounts.
- Around line 187-193: The batch share conversion uses mutated epoch totals
causing divide-by-zero and wrong rates; before mutating details.totalAssets or
details.totalShares, snapshot the epoch totals (e.g., uint256 epochAssets =
details.totalAssets; uint256 epochShares = details.totalShares) and compute
batchShares = batchAssets.mulDiv(epochShares, epochAssets, Math.Rounding.Floor)
using those snapshots, then decrement details.totalAssets/details.totalShares
(and details.requests[controller]) with saturating subtraction to avoid
underflow; apply the same change to the other conversion block that mirrors this
logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 421b1500-d74a-47ef-8a85-bf011a63b917

📥 Commits

Reviewing files that changed from the base of the PR and between 38b8329 and aaaa6c4.

📒 Files selected for processing (3)
  • contracts/mocks/token/ERC7540EpochMock.sol
  • contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol
  • contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol

Comment thread contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol Outdated
Comment thread contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol Outdated
Comment thread contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol Outdated
Comment thread contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol Outdated
ernestognw and others added 11 commits May 11, 2026 14:31
…ent events

Splits the combined require in `_fulfillDeposit` / `_fulfillRedeem` into
three typed errors (`*TooEarly`, `*EmptyEpoch`, `*AlreadyFulfilled`) so
callers can distinguish failure modes, and emits
`Epoch{Deposit,Redeem}Fulfilled` on success.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Folds the drained-epoch case (`totalAssets == 0` for deposit /
`totalShares == 0` for redeem) into the normal consume flow rather than
short-circuiting with a separate branch:

* Ternaries on `requested` and the cross-dim mulDiv yield 0 in the
  drained state, so the iteration pops the queue without consuming
  user input or hitting a division-by-zero.
* The storage decrement on `requests[controller]` subtracts the full
  stored slot when fully claimed, clearing stuck dust in the same line
  that does the normal-case decrement.
* `saturatingSub` absorbs the 1-wei ceil/floor excess in the cross-dim
  paths into the shared totals, so `totalAssets`/`totalShares` reach 0
  cleanly and the {_fulfill*} `EmptyEpoch` guard stays unambiguous.

`_pending{Deposit,Redeem}Request` now additionally check the opposite
total to distinguish "pending" from "fully-claimed post-fulfillment".
`_asyncMax{Mint,Withdraw}` skip drained epochs to avoid mulDiv revert.

NOTE in both files documents the residual-dust bound, the example case
that triggers it, why this isn't an ERC-4626 inflation attack (the
per-epoch totals are donation-proof), and {_decimalsOffset} as the knob
for finer per-claim granularity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ernestognw

Copy link
Copy Markdown
Member Author

Worth flagging that we still don't have a way to observe the epochs a user has pending requests in — the _epochs / _memberOf getter item in the "Known pending work" list above. Toward that, I just opened OpenZeppelin/openzeppelin-contracts#6522, which adds a values(start, end) slice accessor to DoubleEndedQueue. Once it lands, we can expose a paginated getter here that returns the queue of pending requests per account without copying the whole deque.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants