Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
5aba1a2
Update dependencies to v5.6.1
Amxx Apr 8, 2026
15a1f9e
add initial implementation
Amxx Apr 8, 2026
9e4fcc9
add missing reduction of claimable assets and shares in the admin mod…
Amxx Apr 8, 2026
22e742f
reorder for CEI
Amxx Apr 8, 2026
1b2f899
ERC7540Delay: apply convertion rate at the moment of fulfillement
Amxx Apr 10, 2026
d7faf05
minimize
Amxx Apr 10, 2026
79d307a
add ERC7540EpochRedeem prototype
Amxx Apr 13, 2026
c370d9a
update
Amxx Apr 13, 2026
323fb55
update
Amxx Apr 14, 2026
e1aa292
prevent gas ddos risk
Amxx Apr 14, 2026
d74058e
update
Amxx Apr 15, 2026
b745f58
refactor internal request function
Amxx Apr 15, 2026
dbc03ac
epoch deposit
Amxx Apr 15, 2026
472e704
add mocks & fix override pattern
Amxx Apr 15, 2026
9ae5522
fix issue, thank you Claude
Amxx Apr 16, 2026
6cba4d8
Rename ERC7540AdminFulfill* to ERC7540Admin*
Amxx Apr 16, 2026
d771a6d
Fix unit mismatch in requests[controller] decrement for Epoch contracts
Amxx Apr 16, 2026
a909faa
Fix typos in ERC7540 contracts
Amxx Apr 16, 2026
56f7abe
More fixes
Amxx Apr 16, 2026
7a94368
Rename _computeAsync* to _consumeAsync* to reflect side effects
Amxx Apr 16, 2026
1888766
Rename _consumeAsync* to _consumeClaimable*
Amxx Apr 16, 2026
a06f789
Move claimable bookkeeping into _consumeClaimable* overrides
Amxx Apr 16, 2026
241048f
Add ERC7540Delay tests and SupportsInterface behavior helper
Amxx Apr 20, 2026
ed0962a
Add docs
ernestognw Apr 20, 2026
d15844d
Update production references
ernestognw Apr 20, 2026
7c1782e
improve tests
Amxx Apr 21, 2026
63498ed
fix bug
Amxx Apr 21, 2026
0c952d2
add ERC7540Admin tests
Amxx Apr 21, 2026
9d024df
use a behavior
Amxx Apr 21, 2026
d1492e1
add test
Amxx Apr 21, 2026
99c4e46
regenerate package-lock.json
Amxx Apr 21, 2026
438bab6
Use unimplemented function & add Sync module to enable syncrhonious b…
Amxx Apr 21, 2026
a80627b
Update contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol
Amxx Apr 21, 2026
68ccfc0
Use unimplemented function & add Sync module to enable syncrhonious b…
Amxx Apr 21, 2026
18dc793
Merge branch 'feat/erc-7540-standalone' of https://github.com/OpenZep…
Amxx Apr 21, 2026
82766b2
Use IERC6372 in the Delay modules
Amxx Apr 21, 2026
02e962e
Add overridden CLOCK_MODE to mock
ernestognw Apr 22, 2026
37a10d7
Add notes to _asyncMaxWithdraw and _asyncMaxDeposit
ernestognw Apr 22, 2026
8cf1a2e
Remove epoch strategy to separate PR
ernestognw Apr 22, 2026
4613320
Revert "Remove epoch strategy to separate PR"
ernestognw Apr 22, 2026
386456e
Merge branch 'master' into feat/erc-7540-epoch
Amxx Apr 29, 2026
aaaa6c4
Delete test/utils/introspection/SupportsInterface.behavior.js
Amxx Apr 29, 2026
2f3cc7d
Fix CodeRabbit review
ernestognw May 11, 2026
425c3b4
Replace `_fulfill*` bare requires with typed errors and emit fulfillm…
ernestognw May 11, 2026
c95444e
Prefix event
ernestognw May 11, 2026
8d69a3b
Check safety of operations
ernestognw May 11, 2026
2157f69
Handle drained epochs in consume loops and refine sentinel views
ernestognw May 12, 2026
e512444
nits
ernestognw May 15, 2026
e7519a4
Refactor + add getters
ernestognw May 15, 2026
724c302
RemoveNashpoint from examples
ernestognw May 15, 2026
cfe5081
Add tests
ernestognw May 15, 2026
91f33e4
Merge branch 'master' into feat/erc-7540-epoch
ernestognw May 15, 2026
a2e4a55
Docs
ernestognw May 15, 2026
0304589
Merge branch 'master' into feat/erc-7540-epoch
ernestognw May 15, 2026
ada0745
Update docs
ernestognw May 15, 2026
37a2606
Merge branch 'master' into feat/erc-7540-epoch
ernestognw May 15, 2026
e32361f
up
ernestognw May 15, 2026
5c57cbf
up
ernestognw May 15, 2026
7a53200
Add epoch getters
ernestognw May 27, 2026
416ee79
up
ernestognw May 27, 2026
e09cdb3
Review
ernestognw Jul 16, 2026
76d7444
up
ernestognw Jul 20, 2026
51eeb04
Auditor3 review
ernestognw Jul 20, 2026
cc6c1e5
Rename _requestQueueLimit to dissambiguate
ernestognw Jul 20, 2026
7b4ce4e
docs
ernestognw Jul 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 15-05-2026

- `ERC7540EpochDeposit`, `ERC7540EpochRedeem`: Add epoch-based batch fulfillment strategies for ERC-7540 vaults. Requests submitted in the same epoch share a single queue slot per controller and are settled together at one locked exchange rate when the admin closes the epoch via `_fulfillDeposit` / `_fulfillRedeem`.

## 28-04-2026

- `IERC7540`: Add interface for ERC-7540 asynchronous tokenized vaults, extending ERC-4626 with request-based deposit and redeem flows.
Expand Down
15 changes: 14 additions & 1 deletion contracts/account/modules/ERC7579MultisigStorage.sol
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,22 @@ abstract contract ERC7579MultisigStorage is ERC7579Multisig {
) internal view virtual override returns (bool valid) {
uint256 signersLength = signingSigners.length;

// Check validity of presigned signatures
// Check validity of presigned signatures and reject duplicates across the full array.
// The split into presigned/live buckets below would otherwise hide duplicates from
// {SignatureChecker-areValidSignaturesNow}'s deduplication.
uint256 presignedCount = 0;
bytes32 lastId = bytes32(0);
for (uint256 i = 0; i < signersLength; i++) {
bytes32 id = keccak256(signingSigners[i]);
if (lastId < id) {
lastId = id;
} else {
// Unordered entry: scan previous signers for a duplicate.
for (uint256 j = 0; j < i; ++j) {
if (id == keccak256(signingSigners[j])) return false;
}
}

if (signatures[i].length == 0) {
// Presigned signature
if (!isSigner(account, signingSigners[i]) || !presigned(account, signingSigners[i], hash)) {
Expand Down
27 changes: 27 additions & 0 deletions contracts/mocks/token/ERC7540EpochMock.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.27;

import {ERC7540} from "../../token/ERC20/extensions/ERC7540.sol";
import {ERC7540EpochDeposit} from "../../token/ERC20/extensions/ERC7540EpochDeposit.sol";
import {ERC7540EpochRedeem} from "../../token/ERC20/extensions/ERC7540EpochRedeem.sol";

abstract contract ERC7540EpochMock is ERC7540EpochDeposit, ERC7540EpochRedeem {
function _requestDeposit(
uint256 assets,
address controller,
address owner,
uint256 requestId
) internal virtual override(ERC7540, ERC7540EpochDeposit) returns (uint256) {
return super._requestDeposit(assets, controller, owner, requestId);
}

function _requestRedeem(
uint256 shares,
address controller,
address owner,
uint256 requestId
) internal virtual override(ERC7540, ERC7540EpochRedeem) returns (uint256) {
return super._requestRedeem(shares, controller, owner, requestId);
}
}
367 changes: 367 additions & 0 deletions contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol

Large diffs are not rendered by default.

367 changes: 367 additions & 0 deletions contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions contracts/token/README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Set of extensions and utilities for tokens (e.g ERC-20, ERC-721, ERC-1155) and d
* {ERC7540DelayRedeem}: Time-delay fulfillment strategy for asynchronous redemptions, becoming permissionlessly claimable after a configurable waiting period.
* {ERC7540SyncDeposit}: Module for enabling synchronous (ERC-4626) deposit flow in an ERC-7540 vault.
* {ERC7540SyncRedeem}: Module for enabling synchronous (ERC-4626) redeem flow in an ERC-7540 vault.
* {ERC7540EpochDeposit}: Epoch-based batch fulfillment strategy for asynchronous deposits, batching requests submitted in the same epoch and settling them at a single locked rate.
* {ERC7540EpochRedeem}: Epoch-based batch fulfillment strategy for asynchronous redemptions, batching requests submitted in the same epoch and settling them at a single locked rate.

== General

Expand Down Expand Up @@ -59,3 +61,7 @@ Set of extensions and utilities for tokens (e.g ERC-20, ERC-721, ERC-1155) and d
{{ERC7540SyncDeposit}}

{{ERC7540SyncRedeem}}

{{ERC7540EpochDeposit}}

{{ERC7540EpochRedeem}}
135 changes: 132 additions & 3 deletions docs/modules/ROOT/pages/erc7540.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,9 @@ The implementation is split into a base contract and strategy extensions:
```mermaid
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/>with explicit rate"]
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)"]
```

Expand Down Expand Up @@ -93,6 +94,18 @@ Each strategy comes in a deposit and redeem variant. You combine exactly one dep
| `ERC7540AdminDeposit`
| `ERC7540DelayRedeem`
| Admin deposit review, time-locked unstaking

| `ERC7540EpochDeposit`
| `ERC7540EpochRedeem`
| NAV-based fund with batched periodic settlement (RWA basket, weekly rebalance)

| `ERC7540EpochDeposit`
| `ERC7540SyncRedeem`
| Batched deposit gating with instant redemptions

| `ERC7540SyncDeposit`
| `ERC7540EpochRedeem`
| Instant deposits with periodic batched withdrawals
|===

WARNING: Combining `ERC7540SyncDeposit` + `ERC7540SyncRedeem` will revert at construction with `ERC7540MissingAsync`. At least one side must be async.
Expand Down Expand Up @@ -271,6 +284,120 @@ contract TimeLockVault is ERC7540DelayDeposit, ERC7540DelayRedeem {

TIP: 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]]
== 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 https://github.com/Storm-Labs-Inc/cove-contracts-core/blob/master/src/BasketToken.sol[Cove], https://github.com/AmphorProtocol/asynchronous-vault[Amphor], and https://github.com/hopperlabsxyz/lagoon-v0/blob/main/src/v0.5.0/ERC7540.sol[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:

```mermaid
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.

NOTE: 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

[source,solidity]
----
// 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.

[source,solidity]
----
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.

NOTE: 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

[source,solidity]
----
// 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);
}

}
----

TIP: 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.

[[mixed-strategy]]
== Mixing strategies

Expand Down Expand Up @@ -399,7 +526,7 @@ This ensures `convertToShares` / `convertToAssets` reflect the real exchange rat
[[custom-strategy]]
== Building a custom strategy

If neither the admin nor delay strategies fit your use case, you can build a custom fulfillment strategy by extending `ERC7540` directly and implementing the 14 virtual hooks.
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:

Expand Down Expand Up @@ -429,7 +556,7 @@ Each async side (deposit or redeem) requires 7 hooks:
| Return the maximum claimable amount (in shares) for `mint()` / `redeem()`
|===

For example, an epoch-based strategy could batch all requests within an epoch and fulfill them together at the epoch boundary.
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]]
== Security considerations
Expand All @@ -449,3 +576,5 @@ An operator approved via `setOperator` has broad permissions: they can request d
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.
2 changes: 1 addition & 1 deletion lib/@openzeppelin-contracts
Loading
Loading