diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9d65d423..5537e07c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/contracts/account/modules/ERC7579MultisigStorage.sol b/contracts/account/modules/ERC7579MultisigStorage.sol
index 728354ee..3d8ca457 100644
--- a/contracts/account/modules/ERC7579MultisigStorage.sol
+++ b/contracts/account/modules/ERC7579MultisigStorage.sol
@@ -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)) {
diff --git a/contracts/mocks/token/ERC7540EpochMock.sol b/contracts/mocks/token/ERC7540EpochMock.sol
new file mode 100644
index 00000000..6bf9dd17
--- /dev/null
+++ b/contracts/mocks/token/ERC7540EpochMock.sol
@@ -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);
+ }
+}
diff --git a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol
new file mode 100644
index 00000000..b9cf3734
--- /dev/null
+++ b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol
@@ -0,0 +1,367 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.27;
+
+import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
+import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol";
+import {DoubleEndedQueue} from "@openzeppelin/contracts/utils/structs/DoubleEndedQueue.sol";
+import {ERC7540} from "./ERC7540.sol";
+
+/**
+ * @dev Epoch-based batch fulfillment strategy for asynchronous deposits.
+ *
+ * Extends {ERC7540} with a deposit flow where requests submitted during the same epoch are batched
+ * together and settled at a single exchange rate when the admin closes the epoch via {_fulfillDeposit}.
+ * All controllers within a fulfilled epoch receive the same pro-rata conversion from assets to shares.
+ *
+ * Production equivalents:
+ * https://github.com/Storm-Labs-Inc/cove-contracts-core/blob/master/src/BasketToken.sol[Cove],
+ * https://github.com/AmphorProtocol/asynchronous-vault/tree/main[Amphor],
+ * https://github.com/hopperlabsxyz/lagoon-v0/blob/main/src/v0.5.0/ERC7540.sol[Lagoon].
+ *
+ * The `requestId` returned by {requestDeposit} is the epoch ID. By default, epochs are weekly
+ * (`block.timestamp / 1 weeks`); override {currentDepositEpoch} to change the cadence or use
+ * manually-bumped epoch counters.
+ *
+ * Each account tracks its epoch memberships via a {DoubleEndedQueue} capped at
+ * {_depositRequestQueueLimit} entries (default: 32) to bound the O(n) loops in {_asyncMaxDeposit}
+ * and {_asyncMaxMint}. Users that hit the limit should claim fulfilled epochs to free up space.
+ *
+ * NOTE: Claims pay each controller's pro-rata share floor-rounded against the remaining epoch
+ * totals. With very small fulfillment values (e.g. an epoch settling 3 assets for 2 shares
+ * across 3 equal claimants), rounding can leave one controller with up to 1 "wei" of
+ * unclaimable residue. At realistic ERC-20 token decimals this is sub-unit and economically
+ * immaterial. Unlike ERC-4626's inflation-attack surface, the per-epoch `totalAssets` and
+ * `totalShares` cannot be inflated by donation (they only change via {requestDeposit} and
+ * {_fulfillDeposit}); deployers wanting finer per-claim granularity can set {_decimalsOffset}
+ * to scale share precision relative to assets.
+ */
+abstract contract ERC7540EpochDeposit is ERC7540 {
+ using Math for uint256;
+ using SafeCast for uint256;
+ using DoubleEndedQueue for DoubleEndedQueue.Bytes32Deque;
+
+ /**
+ * @dev Per-epoch deposit metadata. `totalShares` is zero while the epoch is Pending and
+ * set to the minted share total when the admin calls {_fulfillDeposit}.
+ */
+ struct EpochDepositMetadata {
+ uint256 totalAssets;
+ uint256 totalShares;
+ mapping(address account => uint256) requests;
+ }
+
+ mapping(uint256 epochId => EpochDepositMetadata) private _epochs;
+ mapping(address account => DoubleEndedQueue.Bytes32Deque) private _memberOf;
+
+ /// @dev Emitted when a deposit epoch transitions from Pending to Claimable via {_fulfillDeposit}.
+ event ERC7540EpochDepositFulfilled(uint256 indexed epochId, uint256 totalAssets, uint256 totalShares);
+
+ /// @dev Attempted to fulfill a deposit epoch that has not yet ended.
+ error ERC7540EpochDepositTooEarly(uint256 epochId);
+
+ /// @dev Attempted to fulfill a deposit epoch with no pending requests.
+ error ERC7540EpochDepositEmptyEpoch(uint256 epochId);
+
+ /// @dev Attempted to fulfill a deposit epoch that has already been fulfilled.
+ error ERC7540EpochDepositAlreadyFulfilled(uint256 epochId);
+
+ /// @dev Attempted to enqueue an epoch for `controller` past {_depositRequestQueueLimit}.
+ error ERC7540EpochDepositQueueLimitExceeded(address controller);
+
+ /// @inheritdoc ERC7540
+ function _isDepositAsync() internal pure virtual override returns (bool) {
+ return true;
+ }
+
+ /// @dev Returns the current epoch ID. Defaults to `block.timestamp / 1 weeks + 1`.
+ function currentDepositEpoch() public view virtual returns (uint256) {
+ // +1 to keep requestId != 0 so the strategy is never mistaken for ERC-7540's controller-only (requestId == 0) accounting mode.
+ return block.timestamp / 1 weeks + 1;
+ }
+
+ /**
+ * @dev Returns the total assets queued in `epochId`. Equals the sum of all deposit requests
+ * during the Pending phase; decreases as claimants consume their pro-rata share once the
+ * epoch is fulfilled, and reaches 0 once the epoch is fully claimed.
+ */
+ function totalDepositAssets(uint256 epochId) public view virtual returns (uint256) {
+ return _epochs[epochId].totalAssets;
+ }
+
+ /**
+ * @dev Returns the shares allocated to `epochId` at fulfillment. Zero before
+ * {_fulfillDeposit} is called; decreases as claimants consume their pro-rata share.
+ * Together with {totalDepositAssets} it encodes the locked epoch rate.
+ */
+ function totalDepositShares(uint256 epochId) public view virtual returns (uint256) {
+ return _epochs[epochId].totalShares;
+ }
+
+ /**
+ * @dev Returns the deposit epoch IDs that `controller` has open requests in, in queue order
+ * (oldest first). Fully claimed epochs are popped from the queue and no longer appear.
+ *
+ * Using `start = 0` and `end = type(uint64).max` will return the entire set of epochs.
+ */
+ function depositEpochs(
+ address controller,
+ uint256 start,
+ uint256 end
+ ) public view virtual returns (uint256[] memory epochIds) {
+ bytes32[] memory store = _memberOf[controller].values(start, end);
+ assembly ("memory-safe") {
+ epochIds := store
+ }
+ }
+
+ /**
+ * @dev A request is pending if its epoch has not yet been fulfilled (`totalShares == 0`) and
+ * still has assets queued (`totalAssets > 0`).
+ */
+ function _pendingDepositRequest(
+ uint256 requestId,
+ address controller
+ ) internal view virtual override returns (uint256) {
+ return totalDepositShares(requestId) == 0 ? _pendingAvailableDepositRequest(requestId, controller) : 0;
+ }
+
+ /**
+ * @dev Returns the controller's stored deposit request for `requestId`, or 0 if the epoch
+ * has been fully claimed (`totalAssets == 0`).
+ */
+ function _pendingAvailableDepositRequest(
+ uint256 requestId,
+ address controller
+ ) internal view virtual returns (uint256) {
+ return totalDepositAssets(requestId) == 0 ? 0 : _epochs[requestId].requests[controller];
+ }
+
+ /// @dev A request is claimable if its epoch has been fulfilled (`totalShares > 0`).
+ function _claimableDepositRequest(
+ uint256 requestId,
+ address controller
+ ) internal view virtual override returns (uint256) {
+ return totalDepositShares(requestId) == 0 ? 0 : _epochs[requestId].requests[controller];
+ }
+
+ /**
+ * @dev Sums claimable assets from `owner`'s fulfilled epochs oldest-first, stopping at the
+ * first Pending epoch. Fulfilled epochs behind a Pending one are not counted until the
+ * Pending one is fulfilled. Matches {_consumeClaimableDeposit}.
+ *
+ * NOTE: O(n) in `owner`'s epochs, bounded by {_depositRequestQueueLimit} (default 32). Per-account,
+ * so an attacker creating many small requests can only inflate their own queue, not
+ * other users'. Cross-controller DoS is not possible because epoch fulfillment via
+ * {_fulfillDeposit} is O(1) (it sets `totalShares` for the entire epoch in a single write).
+ */
+ function _asyncMaxDeposit(address owner) internal view virtual override returns (uint256 assets) {
+ uint256 result = 0;
+ for (uint256 i = 0; i < _memberOf[owner].length(); ++i) {
+ uint256 epochId = uint256(_memberOf[owner].at(i));
+ if (totalDepositShares(epochId) == 0) break; // stop at the oldest Pending epoch
+ result += _claimableDepositRequest(epochId, owner);
+ }
+ return result;
+ }
+
+ /// @dev Sums claimable shares across all fulfilled epochs the `owner` participates in. Same as {_asyncMaxDeposit}.
+ function _asyncMaxMint(address owner) internal view virtual override returns (uint256 shares) {
+ uint256 result = 0;
+ for (uint256 i = 0; i < _memberOf[owner].length(); ++i) {
+ uint256 epochId = uint256(_memberOf[owner].at(i));
+ if (totalDepositShares(epochId) == 0) break; // stop at the oldest Pending epoch
+ result += _convertToDepositShares(epochId, _claimableDepositRequest(epochId, owner), Math.Rounding.Floor);
+ }
+ return result;
+ }
+
+ /// @dev Converts `assets` to shares at `epochId`'s locked rate. Returns 0 if `totalAssets` is 0.
+ function _convertToDepositShares(
+ uint256 epochId,
+ uint256 assets,
+ Math.Rounding rounding
+ ) internal view virtual returns (uint256) {
+ // An epoch's `totalAssets` may be 0 while some `requests[*]` slots are non-zero,
+ // when other controllers' share-driven claims ({_consumeClaimableMint}) round
+ // `requested` up via ceil and the saturating decrement zeroes the shared pool
+ // before all per-controller residues are allocated.
+ uint256 totalAssets = totalDepositAssets(epochId);
+ return totalAssets == 0 ? 0 : assets.mulDiv(totalDepositShares(epochId), totalAssets, rounding);
+ }
+
+ /// @dev Converts `shares` to assets at `epochId`'s locked rate. Returns 0 if `totalShares` is 0.
+ function _convertToDepositAssets(
+ uint256 epochId,
+ uint256 shares,
+ Math.Rounding rounding
+ ) internal view virtual returns (uint256) {
+ // An epoch's `totalShares` may be 0 while some `requests[*]` slots are non-zero,
+ // when other controllers' share-driven claims ({_consumeClaimableMint}) round
+ // `requested` up via ceil and the saturating decrement zeroes the shared pool
+ // before all per-controller residues are allocated.
+ uint256 totalShares = totalDepositShares(epochId);
+ return totalShares == 0 ? 0 : shares.mulDiv(totalDepositAssets(epochId), totalShares, rounding);
+ }
+
+ /**
+ * @dev Records the request in the current epoch and enqueues the epoch ID for `controller`
+ * if not already present. A zero-amount request is a no-op at the epoch layer (it emits the
+ * event via `super` but does not create an unfulfillable epoch or occupy a queue slot).
+ *
+ * Requirements:
+ *
+ * * `_msgSender()` must be `controller` or an approved operator of `controller`. This is on
+ * top of the base's `owner` authentication and prevents third-party queue spam.
+ * * The controller's epoch queue must not exceed {_depositRequestQueueLimit}.
+ */
+ function _requestDeposit(
+ uint256 assets,
+ address controller,
+ address owner,
+ uint256 /* requestId */
+ ) internal virtual override returns (uint256) {
+ _checkOperatorOrController(_isDepositAsync(), controller, _msgSender());
+ uint256 epochId = currentDepositEpoch();
+ if (assets > 0) {
+ _epochs[epochId].totalAssets += assets;
+ _epochs[epochId].requests[controller] += assets;
+
+ (bool success, bytes32 lastEpochId) = _memberOf[controller].tryBack();
+ if (!success || lastEpochId != bytes32(epochId)) {
+ // Limit the number of pending epochs per account to avoid O(n) loop in
+ // _asyncMaxDeposit and _asyncMaxMint being a concern. Users that have reached
+ // the limit should claim fulfilled requests to clean up the queue.
+ require(
+ _memberOf[controller].length() < _depositRequestQueueLimit(),
+ ERC7540EpochDepositQueueLimitExceeded(controller)
+ );
+
+ _memberOf[controller].pushBack(bytes32(epochId));
+ }
+ }
+
+ return super._requestDeposit(assets, controller, owner, epochId);
+ }
+
+ /**
+ * @dev Fulfills a past epoch by setting its `totalShares`. All requests within the epoch
+ * become claimable at the rate `totalShares / totalAssets`.
+ *
+ * NOTE: When epoch transition is manual, the caller should bump the epoch before calling this.
+ *
+ * NOTE: Pending vs. fulfilled is distinguished by `totalShares == 0`. Admins are assumed not
+ * to fulfill at zero (a confiscation event with no economic purpose); if 0 is passed by
+ * accident, the call is a no-op and the admin can re-fulfill. This recovery only holds as
+ * long as derived contracts preserve the no-side-effect semantics of this function — if not,
+ * derived contracts should restrict `totalShares != 0`. A genuine total-loss settlement is
+ * not representable in this encoding; deployers needing that SHOULD override to encode the
+ * fulfilled state separately (e.g. an explicit boolean).
+ *
+ * NOTE: Out-of-order fulfillment is permitted, but each controller's claims stay gated on
+ * their oldest Pending epoch (see {_consumeClaimableDeposit}). Funds are not lost; a later
+ * fulfilled epoch simply waits until the older one is fulfilled. Derived contracts wanting
+ * strict FIFO settlement should enforce it here.
+ *
+ * Requirements:
+ *
+ * * `epochId` must be a past epoch (less than {currentDepositEpoch}).
+ * * The epoch must have pending assets and must not have been fulfilled already.
+ */
+ function _fulfillDeposit(uint256 epochId, uint256 totalShares) internal virtual {
+ require(epochId < currentDepositEpoch(), ERC7540EpochDepositTooEarly(epochId));
+
+ uint256 totalAssets = totalDepositAssets(epochId);
+ require(totalAssets > 0, ERC7540EpochDepositEmptyEpoch(epochId));
+ require(totalDepositShares(epochId) == 0, ERC7540EpochDepositAlreadyFulfilled(epochId));
+
+ _epochs[epochId].totalShares = totalShares;
+ emit ERC7540EpochDepositFulfilled(epochId, totalAssets, totalShares);
+ }
+
+ /**
+ * @dev Consumes `assets` from `controller`'s epochs oldest-first at each epoch's locked rate,
+ * dequeueing fully consumed ones. Breaks early when the oldest epoch is still Pending:
+ * consuming from it would burn `assets` for zero shares. Claims are therefore gated on the
+ * oldest Pending epoch, matching {_asyncMaxDeposit}.
+ *
+ * NOTE: Wrappers wanting stricter FIFO semantics should consider overriding to revert when
+ * the oldest epoch is Pending.
+ *
+ * NOTE: When the epoch's locked rate skews the `assets : shares` ratio (either direction),
+ * a small `assets` input can round `batchShares` to zero; the epoch's asset pool moves
+ * without minting shares to the caller. At realistic ERC-20 decimals the per-call drift is
+ * sub-cent. Deployers with non-standard decimals or a zero-drift requirement SHOULD override
+ * to revert when `batchAssets > 0 && batchShares == 0`.
+ */
+ function _consumeClaimableDeposit(uint256 assets, address controller) internal virtual override returns (uint256) {
+ uint256 shares = 0;
+
+ while (assets > 0) {
+ uint256 epochId = uint256(_memberOf[controller].front());
+ if (totalDepositShares(epochId) == 0) break; // oldest queued epoch is still Pending
+
+ uint256 requested = _pendingAvailableDepositRequest(epochId, controller);
+ if (requested <= assets) _memberOf[controller].popFront();
+
+ uint256 batchAssets = requested.min(assets);
+ uint256 batchShares = _convertToDepositShares(epochId, batchAssets, Math.Rounding.Floor);
+
+ EpochDepositMetadata storage details = _epochs[epochId];
+ details.requests[controller] -= batchAssets; // batchAssets <= requested via .min
+ details.totalAssets -= batchAssets; // batchAssets <= details.totalAssets (invariant: requests[c] <= totalAssets)
+ details.totalShares -= batchShares; // batchShares = floor(batchAssets * S/A) <= details.totalShares (since batchAssets <= totalAssets)
+ assets -= batchAssets; // batchAssets <= assets (via .min)
+ shares += batchShares;
+ }
+
+ return shares;
+ }
+
+ /**
+ * @dev Same as {_consumeClaimableDeposit} but iterates by shares instead of assets.
+ *
+ * NOTE: When the epoch's locked rate skews the `assets : shares` ratio (either direction),
+ * a small `shares` input can round `batchAssets` to zero; the epoch's share pool moves
+ * without consuming the caller's asset entitlement. At realistic ERC-20 decimals the per-call
+ * drift is sub-cent. Deployers with non-standard decimals or a zero-drift requirement SHOULD
+ * override to revert when `batchShares > 0 && batchAssets == 0`.
+ */
+ function _consumeClaimableMint(uint256 shares, address controller) internal virtual override returns (uint256) {
+ uint256 assets = 0;
+
+ while (shares > 0) {
+ uint256 epochId = uint256(_memberOf[controller].front());
+ if (totalDepositShares(epochId) == 0) break; // oldest queued epoch is still Pending
+
+ uint256 requestedAssets = _pendingAvailableDepositRequest(epochId, controller);
+ uint256 requested = _convertToDepositShares(epochId, requestedAssets, Math.Rounding.Ceil);
+ if (requested <= shares) _memberOf[controller].popFront();
+
+ uint256 batchShares = requested.min(shares);
+ // Cap batchAssets at requestedAssets so a ceil-floor gap on the last share of an
+ // earlier epoch cannot consume more assets than the controller was entitled to
+ // (prevents cross-epoch borrowing).
+ uint256 batchAssets = _convertToDepositAssets(epochId, batchShares, Math.Rounding.Floor).min(
+ requestedAssets
+ );
+
+ EpochDepositMetadata storage details = _epochs[epochId];
+ details.requests[controller] -= batchAssets; // batchAssets <= requestedAssets via .min
+ details.totalAssets -= batchAssets; // batchAssets <= requestedAssets <= totalAssets (invariant)
+ details.totalShares -= batchShares; // batchShares <= requested = ceil(rA*S/A) <= S (see contract-level NOTE)
+ shares -= batchShares; // batchShares <= shares (via .min)
+ assets += batchAssets;
+ }
+
+ return assets;
+ }
+
+ /**
+ * @dev Maximum number of epoch entries in a controller's queue. Defaults to 32.
+ * Prevents unbounded iteration in {_asyncMaxDeposit} and {_asyncMaxMint}.
+ */
+ function _depositRequestQueueLimit() internal view virtual returns (uint256) {
+ return 32;
+ }
+}
diff --git a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol
new file mode 100644
index 00000000..632468b8
--- /dev/null
+++ b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol
@@ -0,0 +1,367 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.27;
+
+import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
+import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol";
+import {DoubleEndedQueue} from "@openzeppelin/contracts/utils/structs/DoubleEndedQueue.sol";
+import {ERC7540} from "./ERC7540.sol";
+
+/**
+ * @dev Epoch-based batch fulfillment strategy for asynchronous redemptions.
+ *
+ * Extends {ERC7540} with a redeem flow where requests submitted during the same epoch are batched
+ * together and settled at a single exchange rate when the admin closes the epoch via {_fulfillRedeem}.
+ * All controllers within a fulfilled epoch receive the same pro-rata conversion from shares to assets.
+ *
+ * Production equivalents:
+ * https://github.com/Storm-Labs-Inc/cove-contracts-core/blob/master/src/BasketToken.sol[Cove],
+ * https://github.com/AmphorProtocol/asynchronous-vault/tree/main[Amphor],
+ * https://github.com/hopperlabsxyz/lagoon-v0/blob/main/src/v0.5.0/ERC7540.sol[Lagoon].
+ *
+ * The `requestId` returned by {requestRedeem} is the epoch ID. By default, epochs are weekly
+ * (`block.timestamp / 1 weeks`); override {currentRedeemEpoch} to change the cadence or use
+ * manually-bumped epoch counters.
+ *
+ * Each account tracks its epoch memberships via a {DoubleEndedQueue} capped at
+ * {_redeemRequestQueueLimit} entries (default: 32) to bound the O(n) loops in {_asyncMaxWithdraw}
+ * and {_asyncMaxRedeem}. Users that hit the limit should claim fulfilled epochs to free up space.
+ *
+ * NOTE: Claims pay each controller's pro-rata share floor-rounded against the remaining epoch
+ * totals. With very small fulfillment values (e.g. an epoch settling 3 shares for 2 assets
+ * across 3 equal claimants), rounding can leave one controller with up to 1 "wei" of
+ * unclaimable residue. At realistic ERC-20 token decimals this is sub-unit and economically
+ * immaterial. Unlike ERC-4626's inflation-attack surface, the per-epoch `totalShares` and
+ * `totalAssets` cannot be inflated by donation (they only change via {requestRedeem} and
+ * {_fulfillRedeem}); deployers wanting finer per-claim granularity can set {_decimalsOffset}
+ * to scale share precision relative to assets.
+ */
+abstract contract ERC7540EpochRedeem is ERC7540 {
+ using Math for uint256;
+ using SafeCast for uint256;
+ using DoubleEndedQueue for DoubleEndedQueue.Bytes32Deque;
+
+ /**
+ * @dev Per-epoch redeem metadata. `totalAssets` is zero while the epoch is Pending and
+ * set to the distributed asset total when the admin calls {_fulfillRedeem}.
+ */
+ struct EpochRedeemMetadata {
+ uint256 totalShares;
+ uint256 totalAssets;
+ mapping(address account => uint256) requests;
+ }
+
+ mapping(uint256 epochId => EpochRedeemMetadata) private _epochs;
+ mapping(address account => DoubleEndedQueue.Bytes32Deque) private _memberOf;
+
+ /// @dev Emitted when a redeem epoch transitions from Pending to Claimable via {_fulfillRedeem}.
+ event ERC7540EpochRedeemFulfilled(uint256 indexed epochId, uint256 totalShares, uint256 totalAssets);
+
+ /// @dev Attempted to fulfill a redeem epoch that has not yet ended.
+ error ERC7540EpochRedeemTooEarly(uint256 epochId);
+
+ /// @dev Attempted to fulfill a redeem epoch with no pending requests.
+ error ERC7540EpochRedeemEmptyEpoch(uint256 epochId);
+
+ /// @dev Attempted to fulfill a redeem epoch that has already been fulfilled.
+ error ERC7540EpochRedeemAlreadyFulfilled(uint256 epochId);
+
+ /// @dev Attempted to enqueue an epoch for `controller` past {_redeemRequestQueueLimit}.
+ error ERC7540EpochRedeemQueueLimitExceeded(address controller);
+
+ /// @inheritdoc ERC7540
+ function _isRedeemAsync() internal pure virtual override returns (bool) {
+ return true;
+ }
+
+ /// @dev Returns the current epoch ID. Defaults to `block.timestamp / 1 weeks + 1`.
+ function currentRedeemEpoch() public view virtual returns (uint256) {
+ // +1 to keep requestId != 0 so the strategy is never mistaken for ERC-7540's controller-only (requestId == 0) accounting mode.
+ return block.timestamp / 1 weeks + 1;
+ }
+
+ /**
+ * @dev Returns the total shares queued in `epochId`. Equals the sum of all redeem requests
+ * during the Pending phase; decreases as claimants consume their pro-rata share once the
+ * epoch is fulfilled, and reaches 0 once the epoch is fully claimed.
+ */
+ function totalRedeemShares(uint256 epochId) public view virtual returns (uint256) {
+ return _epochs[epochId].totalShares;
+ }
+
+ /**
+ * @dev Returns the assets allocated to `epochId` at fulfillment. Zero before
+ * {_fulfillRedeem} is called; decreases as claimants consume their pro-rata share.
+ * Together with {totalRedeemShares} it encodes the locked epoch rate.
+ */
+ function totalRedeemAssets(uint256 epochId) public view virtual returns (uint256) {
+ return _epochs[epochId].totalAssets;
+ }
+
+ /**
+ * @dev Returns the redeem epoch IDs that `controller` has open requests in, in queue order
+ * (oldest first). Fully claimed epochs are popped from the queue and no longer appear.
+ *
+ * Using `start = 0` and `end = type(uint64).max` will return the entire set of epochs.
+ */
+ function redeemEpochs(
+ address controller,
+ uint256 start,
+ uint256 end
+ ) public view virtual returns (uint256[] memory epochIds) {
+ bytes32[] memory store = _memberOf[controller].values(start, end);
+ assembly ("memory-safe") {
+ epochIds := store
+ }
+ }
+
+ /**
+ * @dev A request is pending if its epoch has not yet been fulfilled (`totalAssets == 0`) and
+ * still has shares queued (`totalShares > 0`).
+ */
+ function _pendingRedeemRequest(
+ uint256 requestId,
+ address controller
+ ) internal view virtual override returns (uint256) {
+ return totalRedeemAssets(requestId) == 0 ? _pendingAvailableRedeemRequest(requestId, controller) : 0;
+ }
+
+ /**
+ * @dev Returns the controller's stored redeem request for `requestId`, or 0 if the epoch
+ * has been fully claimed (`totalShares == 0`).
+ */
+ function _pendingAvailableRedeemRequest(
+ uint256 requestId,
+ address controller
+ ) internal view virtual returns (uint256) {
+ return totalRedeemShares(requestId) == 0 ? 0 : _epochs[requestId].requests[controller];
+ }
+
+ /// @dev A request is claimable if its epoch has been fulfilled (`totalAssets > 0`).
+ function _claimableRedeemRequest(
+ uint256 requestId,
+ address controller
+ ) internal view virtual override returns (uint256) {
+ return totalRedeemAssets(requestId) == 0 ? 0 : _epochs[requestId].requests[controller];
+ }
+
+ /**
+ * @dev Sums claimable assets from `owner`'s fulfilled epochs oldest-first, stopping at the
+ * first Pending epoch. Fulfilled epochs behind a Pending one are not counted until the
+ * Pending one is fulfilled. Matches {_consumeClaimableWithdraw}.
+ *
+ * NOTE: O(n) in `owner`'s epochs, bounded by {_redeemRequestQueueLimit} (default 32). Per-account,
+ * so an attacker creating many small requests can only inflate their own queue, not
+ * other users'. Cross-controller DoS is not possible because epoch fulfillment via
+ * {_fulfillRedeem} is O(1) (it sets `totalAssets` for the entire epoch in a single write).
+ */
+ function _asyncMaxWithdraw(address owner) internal view virtual override returns (uint256 assets) {
+ uint256 result = 0;
+ for (uint256 i = 0; i < _memberOf[owner].length(); ++i) {
+ uint256 epochId = uint256(_memberOf[owner].at(i));
+ if (totalRedeemAssets(epochId) == 0) break; // stop at the oldest Pending epoch
+ result += _convertToRedeemAssets(epochId, _claimableRedeemRequest(epochId, owner), Math.Rounding.Floor);
+ }
+ return result;
+ }
+
+ /// @dev Sums claimable shares across all fulfilled epochs the `owner` participates in. Same as {_asyncMaxWithdraw}.
+ function _asyncMaxRedeem(address owner) internal view virtual override returns (uint256 shares) {
+ uint256 result = 0;
+ for (uint256 i = 0; i < _memberOf[owner].length(); ++i) {
+ uint256 epochId = uint256(_memberOf[owner].at(i));
+ if (totalRedeemAssets(epochId) == 0) break; // stop at the oldest Pending epoch
+ result += _claimableRedeemRequest(epochId, owner);
+ }
+ return result;
+ }
+
+ /// @dev Converts `shares` to assets at `epochId`'s locked rate. Returns 0 if `totalShares` is 0.
+ function _convertToRedeemAssets(
+ uint256 epochId,
+ uint256 shares,
+ Math.Rounding rounding
+ ) internal view virtual returns (uint256) {
+ // An epoch's `totalShares` may be 0 while some `requests[*]` slots are non-zero,
+ // when other controllers' asset-driven claims ({_consumeClaimableWithdraw}) round
+ // `requested` up via ceil and the saturating decrement zeroes the shared pool
+ // before all per-controller residues are allocated.
+ uint256 totalShares = totalRedeemShares(epochId);
+ return totalShares == 0 ? 0 : shares.mulDiv(totalRedeemAssets(epochId), totalShares, rounding);
+ }
+
+ /// @dev Converts `assets` to shares at `epochId`'s locked rate. Returns 0 if `totalAssets` is 0.
+ function _convertToRedeemShares(
+ uint256 epochId,
+ uint256 assets,
+ Math.Rounding rounding
+ ) internal view virtual returns (uint256) {
+ // An epoch's `totalAssets` may be 0 while some `requests[*]` slots are non-zero,
+ // when other controllers' asset-driven claims ({_consumeClaimableWithdraw}) round
+ // `requested` up via ceil and the saturating decrement zeroes the shared pool
+ // before all per-controller residues are allocated.
+ uint256 totalAssets = totalRedeemAssets(epochId);
+ return totalAssets == 0 ? 0 : assets.mulDiv(totalRedeemShares(epochId), totalAssets, rounding);
+ }
+
+ /**
+ * @dev Records the request in the current epoch and enqueues the epoch ID for `controller`
+ * if not already present. A zero-amount request is a no-op at the epoch layer (it emits the
+ * event via `super` but does not create an unfulfillable epoch or occupy a queue slot).
+ *
+ * Requirements:
+ *
+ * * `_msgSender()` must be `controller` or an approved operator of `controller`. This is on
+ * top of the base's `owner` allowance/operator check and prevents third-party queue spam.
+ * * The controller's epoch queue must not exceed {_redeemRequestQueueLimit}.
+ */
+ function _requestRedeem(
+ uint256 shares,
+ address controller,
+ address owner,
+ uint256 /* requestId */
+ ) internal virtual override returns (uint256) {
+ _checkOperatorOrController(_isRedeemAsync(), controller, _msgSender());
+ uint256 epochId = currentRedeemEpoch();
+ if (shares > 0) {
+ _epochs[epochId].totalShares += shares;
+ _epochs[epochId].requests[controller] += shares;
+
+ (bool success, bytes32 lastEpochId) = _memberOf[controller].tryBack();
+ if (!success || lastEpochId != bytes32(epochId)) {
+ // Limit the number of pending epochs per account to avoid O(n) loop in
+ // _asyncMaxWithdraw and _asyncMaxRedeem being a concern. Users that have reached
+ // the limit should claim fulfilled requests to clean up the queue.
+ require(
+ _memberOf[controller].length() < _redeemRequestQueueLimit(),
+ ERC7540EpochRedeemQueueLimitExceeded(controller)
+ );
+
+ _memberOf[controller].pushBack(bytes32(epochId));
+ }
+ }
+
+ return super._requestRedeem(shares, controller, owner, epochId);
+ }
+
+ /**
+ * @dev Fulfills a past epoch by setting its `totalAssets`. All requests within the epoch
+ * become claimable at the rate `totalAssets / totalShares`.
+ *
+ * NOTE: When epoch transition is manual, the caller should bump the epoch before calling this.
+ *
+ * NOTE: Pending vs. fulfilled is distinguished by `totalAssets == 0`. Admins are assumed not
+ * to fulfill at zero (a confiscation event with no economic purpose); if 0 is passed by
+ * accident, the call is a no-op and the admin can re-fulfill. This recovery only holds as
+ * long as derived contracts preserve the no-side-effect semantics of this function — if not,
+ * derived contracts should restrict `totalAssets != 0`. A genuine total-loss settlement is
+ * not representable in this encoding; deployers needing that SHOULD override to encode the
+ * fulfilled state separately (e.g. an explicit boolean).
+ *
+ * NOTE: Out-of-order fulfillment is permitted, but each controller's claims stay gated on
+ * their oldest Pending epoch (see {_consumeClaimableWithdraw}). Funds are not lost; a later
+ * fulfilled epoch simply waits until the older one is fulfilled. Derived contracts wanting
+ * strict FIFO settlement should enforce it here.
+ *
+ * Requirements:
+ *
+ * * `epochId` must be a past epoch (less than {currentRedeemEpoch}).
+ * * The epoch must have pending shares and must not have been fulfilled already.
+ */
+ function _fulfillRedeem(uint256 epochId, uint256 totalAssets) internal virtual {
+ require(epochId < currentRedeemEpoch(), ERC7540EpochRedeemTooEarly(epochId));
+
+ uint256 totalShares = totalRedeemShares(epochId);
+ require(totalShares > 0, ERC7540EpochRedeemEmptyEpoch(epochId));
+ require(totalRedeemAssets(epochId) == 0, ERC7540EpochRedeemAlreadyFulfilled(epochId));
+
+ _epochs[epochId].totalAssets = totalAssets;
+ emit ERC7540EpochRedeemFulfilled(epochId, totalShares, totalAssets);
+ }
+
+ /**
+ * @dev Consumes `assets` from `controller`'s epochs oldest-first at each epoch's locked rate,
+ * dequeueing fully consumed ones. Breaks early when the oldest epoch is still Pending:
+ * consuming from it would burn `assets` for zero shares. Claims are therefore gated on the
+ * oldest Pending epoch, matching {_asyncMaxWithdraw}.
+ *
+ * NOTE: Wrappers wanting stricter FIFO semantics should consider overriding to revert when
+ * the oldest epoch is Pending.
+ *
+ * NOTE: When the epoch's locked rate skews the `shares : assets` ratio (either direction),
+ * a small `assets` input can round `batchShares` to zero; the epoch's asset pool moves
+ * without burning shares from the caller's request. At realistic ERC-20 decimals the per-call
+ * drift is sub-cent. Deployers with non-standard decimals or a zero-drift requirement SHOULD
+ * override to revert when `batchAssets > 0 && batchShares == 0`.
+ */
+ function _consumeClaimableWithdraw(uint256 assets, address controller) internal virtual override returns (uint256) {
+ uint256 shares = 0;
+
+ while (assets > 0) {
+ uint256 epochId = uint256(_memberOf[controller].front());
+ if (totalRedeemAssets(epochId) == 0) break; // oldest queued epoch is still Pending
+
+ uint256 requestedShares = _pendingAvailableRedeemRequest(epochId, controller);
+ uint256 requested = _convertToRedeemAssets(epochId, requestedShares, Math.Rounding.Ceil);
+ if (requested <= assets) _memberOf[controller].popFront();
+
+ uint256 batchAssets = requested.min(assets);
+ // Cap batchShares at requestedShares so a ceil-floor gap on the last asset of an
+ // earlier epoch cannot consume more shares than the controller was entitled to
+ // (prevents cross-epoch borrowing).
+ uint256 batchShares = _convertToRedeemShares(epochId, batchAssets, Math.Rounding.Floor).min(
+ requestedShares
+ );
+
+ EpochRedeemMetadata storage details = _epochs[epochId];
+ details.requests[controller] -= batchShares; // batchShares <= requestedShares via .min
+ details.totalAssets -= batchAssets; // batchAssets <= requested <= totalAssets (see invariants)
+ details.totalShares -= batchShares; // batchShares <= requestedShares <= totalShares (invariant)
+ assets -= batchAssets; // batchAssets <= assets (via .min)
+ shares += batchShares;
+ }
+
+ return shares;
+ }
+
+ /**
+ * @dev Same as {_consumeClaimableWithdraw} but iterates by shares instead of assets.
+ *
+ * NOTE: When the epoch's locked rate skews the `shares : assets` ratio (either direction),
+ * a small `shares` input can round `batchAssets` to zero; the epoch's share pool moves
+ * without paying assets to the caller. At realistic ERC-20 decimals the per-call drift is
+ * sub-cent. Deployers with non-standard decimals or a zero-drift requirement SHOULD override
+ * to revert when `batchShares > 0 && batchAssets == 0`.
+ */
+ function _consumeClaimableRedeem(uint256 shares, address controller) internal virtual override returns (uint256) {
+ uint256 assets = 0;
+
+ while (shares > 0) {
+ uint256 epochId = uint256(_memberOf[controller].front());
+ if (totalRedeemAssets(epochId) == 0) break; // oldest queued epoch is still Pending
+
+ uint256 requested = _pendingAvailableRedeemRequest(epochId, controller);
+ if (requested <= shares) _memberOf[controller].popFront();
+
+ uint256 batchShares = requested.min(shares);
+ uint256 batchAssets = _convertToRedeemAssets(epochId, batchShares, Math.Rounding.Floor);
+
+ EpochRedeemMetadata storage details = _epochs[epochId];
+ details.requests[controller] -= batchShares; // batchShares <= requested via .min
+ details.totalShares -= batchShares; // batchShares <= details.totalShares (invariant: requests[c] <= totalShares)
+ details.totalAssets -= batchAssets; // batchAssets = floor(batchShares * A/S) <= details.totalAssets (since batchShares <= totalShares)
+ shares -= batchShares; // batchShares <= shares (via .min)
+ assets += batchAssets;
+ }
+
+ return assets;
+ }
+
+ /**
+ * @dev Maximum number of epoch entries in a controller's queue. Defaults to 32.
+ * Prevents unbounded iteration in {_asyncMaxWithdraw} and {_asyncMaxRedeem}.
+ */
+ function _redeemRequestQueueLimit() internal view virtual returns (uint256) {
+ return 32;
+ }
+}
diff --git a/contracts/token/README.adoc b/contracts/token/README.adoc
index e14e7b5d..c0c0818c 100644
--- a/contracts/token/README.adoc
+++ b/contracts/token/README.adoc
@@ -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
@@ -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}}
diff --git a/docs/modules/ROOT/pages/erc7540.adoc b/docs/modules/ROOT/pages/erc7540.adoc
index 778c571f..0bfc5e27 100644
--- a/docs/modules/ROOT/pages/erc7540.adoc
+++ b/docs/modules/ROOT/pages/erc7540.adoc
@@ -59,8 +59,9 @@ The implementation is split into a base contract and strategy extensions:
```mermaid
flowchart TD
Base["ERC7540 (base)
Routing logic (sync vs async)
Operator management
ERC-4626 interface
totalAssets / totalSupply adjustments
14 virtual hooks for strategies to implement"]
- Base --> Admin["Admin strategy
Privileged caller fulfills
with explicit rate"]
+ Base --> Admin["Admin strategy
Privileged caller fulfills
per request with explicit rate"]
Base --> Delay["Delay strategy
Time-based, no
privileged caller needed"]
+ Base --> Epoch["Epoch strategy
Privileged caller batches
requests per time window"]
Base --> Sync["Sync strategy
Standard ERC-4626
(no async lifecycle)"]
```
@@ -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.
@@ -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
requestDeposit(100, ...)"] --> E["Epoch N (Pending)
totalAssets = 300"]
+ B["Bob
requestDeposit(200, ...)"] --> E
+ E -->|"_fulfillDeposit(epochId, 150)"| EF["Epoch N (Claimable)
totalAssets = 300
totalShares = 150"]
+ EF -->|"deposit() / mint()"| AS["Alice claims
50 shares (pro-rata)"]
+ EF -->|"deposit() / mint()"| BS["Bob claims
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
@@ -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:
@@ -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
@@ -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.
diff --git a/lib/@openzeppelin-contracts b/lib/@openzeppelin-contracts
index dab86115..74edc4ba 160000
--- a/lib/@openzeppelin-contracts
+++ b/lib/@openzeppelin-contracts
@@ -1 +1 @@
-Subproject commit dab8611521b481c8801ef7811eec5f9661869ce1
+Subproject commit 74edc4baff50b93c06977021ee9ba25987803291
diff --git a/package-lock.json b/package-lock.json
index 416f73c6..725dc837 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,7 +19,7 @@
"dev": true,
"license": "MIT",
"devDependencies": {
- "@changesets/changelog-github": "^0.6.0",
+ "@changesets/changelog-github": "^0.7.0",
"@changesets/cli": "^2.26.0",
"@changesets/pre": "^2.0.0",
"@changesets/read": "^0.6.0",
@@ -47,7 +47,7 @@
"hardhat-predeploy": "^0.4.1",
"husky": "^9.1.7",
"interoperable-addresses": "^0.1.3",
- "lint-staged": "^16.0.0",
+ "lint-staged": "^17.0.0",
"lodash.startcase": "^4.4.0",
"micromatch": "^4.0.2",
"p-limit": "^7.0.0",
@@ -60,7 +60,7 @@
"solidity-ast": "^0.4.50",
"solidity-coverage": "^0.8.14",
"solidity-docgen": "^0.6.0-beta.29",
- "undici": "^7.24.2",
+ "undici": "^8.2.0",
"yargs": "^18.0.0"
}
},
@@ -271,9 +271,9 @@
}
},
"node_modules/@changesets/changelog-github": {
- "version": "0.6.0",
- "resolved": "https://registry.npmjs.org/@changesets/changelog-github/-/changelog-github-0.6.0.tgz",
- "integrity": "sha512-wA2/y4hR/A1K411cCT75rz0d46Iezxp1WYRFoFJDIUpkQ6oDBAIUiU7BZkDCmYgz0NBl94X1lgcZO+mHoiHnFg==",
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/@changesets/changelog-github/-/changelog-github-0.7.0.tgz",
+ "integrity": "sha512-rBsbRvc4TVn+FvFnOVM3LxlFJfTXXCp8gfVJ+0BubxWNSVnLuAzowi5j+IEraLLP52w8AAs9QfKbPS3MMiXQJA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3575,26 +3575,26 @@
}
},
"node_modules/cli-truncate": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz",
- "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==",
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz",
+ "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "slice-ansi": "^5.0.0",
- "string-width": "^7.0.0"
+ "slice-ansi": "^8.0.0",
+ "string-width": "^8.2.0"
},
"engines": {
- "node": ">=18"
+ "node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-truncate/node_modules/ansi-regex": {
- "version": "6.1.0",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
- "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3604,39 +3604,31 @@
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
- "node_modules/cli-truncate/node_modules/emoji-regex": {
- "version": "10.4.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz",
- "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/cli-truncate/node_modules/string-width": {
- "version": "7.2.0",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
- "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+ "version": "8.2.1",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz",
+ "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "emoji-regex": "^10.3.0",
- "get-east-asian-width": "^1.0.0",
- "strip-ansi": "^7.1.0"
+ "get-east-asian-width": "^1.5.0",
+ "strip-ansi": "^7.1.2"
},
"engines": {
- "node": ">=18"
+ "node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-truncate/node_modules/strip-ansi": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
- "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
+ "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "ansi-regex": "^6.0.1"
+ "ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
@@ -3765,13 +3757,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/colorette": {
- "version": "2.0.20",
- "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
- "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -3792,16 +3777,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/commander": {
- "version": "14.0.0",
- "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz",
- "integrity": "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=20"
- }
- },
"node_modules/compare-versions": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz",
@@ -5434,9 +5409,9 @@
}
},
"node_modules/get-east-asian-width": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz",
- "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==",
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
+ "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -6870,13 +6845,16 @@
}
},
"node_modules/is-fullwidth-code-point": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz",
- "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==",
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
+ "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
"dev": true,
"license": "MIT",
+ "dependencies": {
+ "get-east-asian-width": "^1.3.1"
+ },
"engines": {
- "node": ">=12"
+ "node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
@@ -7190,19 +7168,6 @@
"node": ">= 0.8.0"
}
},
- "node_modules/lilconfig": {
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
- "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/sponsors/antonk52"
- }
- },
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -7211,68 +7176,64 @@
"license": "MIT"
},
"node_modules/lint-staged": {
- "version": "16.1.2",
- "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.1.2.tgz",
- "integrity": "sha512-sQKw2Si2g9KUZNY3XNvRuDq4UJqpHwF0/FQzZR2M7I5MvtpWvibikCjUVJzZdGE0ByurEl3KQNvsGetd1ty1/Q==",
+ "version": "17.0.5",
+ "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.5.tgz",
+ "integrity": "sha512-d12yC+/e8RhBjZtaxZn71FyrgU/P5e+uAPifhCLwdosQZP/zamSdKRWDC30ocVIbzDKiFG1McHc/LUgB92GIPw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "chalk": "^5.4.1",
- "commander": "^14.0.0",
- "debug": "^4.4.1",
- "lilconfig": "^3.1.3",
- "listr2": "^8.3.3",
- "micromatch": "^4.0.8",
- "nano-spawn": "^1.0.2",
- "pidtree": "^0.6.0",
+ "listr2": "^10.2.1",
+ "picomatch": "^4.0.4",
"string-argv": "^0.3.2",
- "yaml": "^2.8.0"
+ "tinyexec": "^1.1.2"
},
"bin": {
"lint-staged": "bin/lint-staged.js"
},
"engines": {
- "node": ">=20.17"
+ "node": ">=22.22.1"
},
"funding": {
"url": "https://opencollective.com/lint-staged"
+ },
+ "optionalDependencies": {
+ "yaml": "^2.8.4"
}
},
- "node_modules/lint-staged/node_modules/chalk": {
- "version": "5.4.1",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz",
- "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==",
+ "node_modules/lint-staged/node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
- "node": "^12.17.0 || ^14.13 || >=16.0.0"
+ "node": ">=12"
},
"funding": {
- "url": "https://github.com/chalk/chalk?sponsor=1"
+ "url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/listr2": {
- "version": "8.3.3",
- "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.3.3.tgz",
- "integrity": "sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==",
+ "version": "10.2.1",
+ "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz",
+ "integrity": "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "cli-truncate": "^4.0.0",
- "colorette": "^2.0.20",
- "eventemitter3": "^5.0.1",
+ "cli-truncate": "^5.2.0",
+ "eventemitter3": "^5.0.4",
"log-update": "^6.1.0",
"rfdc": "^1.4.1",
- "wrap-ansi": "^9.0.0"
+ "wrap-ansi": "^10.0.0"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=22.13.0"
}
},
"node_modules/listr2/node_modules/ansi-regex": {
- "version": "6.1.0",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
- "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -7283,9 +7244,9 @@
}
},
"node_modules/listr2/node_modules/ansi-styles": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
- "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -7295,39 +7256,38 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
- "node_modules/listr2/node_modules/emoji-regex": {
- "version": "10.4.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz",
- "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==",
+ "node_modules/listr2/node_modules/eventemitter3": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
+ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"dev": true,
"license": "MIT"
},
"node_modules/listr2/node_modules/string-width": {
- "version": "7.2.0",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
- "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+ "version": "8.2.1",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz",
+ "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "emoji-regex": "^10.3.0",
- "get-east-asian-width": "^1.0.0",
- "strip-ansi": "^7.1.0"
+ "get-east-asian-width": "^1.5.0",
+ "strip-ansi": "^7.1.2"
},
"engines": {
- "node": ">=18"
+ "node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/listr2/node_modules/strip-ansi": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
- "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
+ "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "ansi-regex": "^6.0.1"
+ "ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
@@ -7337,18 +7297,18 @@
}
},
"node_modules/listr2/node_modules/wrap-ansi": {
- "version": "9.0.0",
- "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz",
- "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==",
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz",
+ "integrity": "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "ansi-styles": "^6.2.1",
- "string-width": "^7.0.0",
- "strip-ansi": "^7.1.0"
+ "ansi-styles": "^6.2.3",
+ "string-width": "^8.2.0",
+ "strip-ansi": "^7.1.2"
},
"engines": {
- "node": ">=18"
+ "node": ">=20"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
@@ -7458,9 +7418,9 @@
}
},
"node_modules/log-update/node_modules/ansi-escapes": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz",
- "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==",
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
+ "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7474,9 +7434,9 @@
}
},
"node_modules/log-update/node_modules/ansi-regex": {
- "version": "6.1.0",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
- "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -7487,9 +7447,9 @@
}
},
"node_modules/log-update/node_modules/ansi-styles": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
- "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -7500,32 +7460,16 @@
}
},
"node_modules/log-update/node_modules/emoji-regex": {
- "version": "10.4.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz",
- "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==",
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
+ "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"dev": true,
"license": "MIT"
},
- "node_modules/log-update/node_modules/is-fullwidth-code-point": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz",
- "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "get-east-asian-width": "^1.0.0"
- },
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/log-update/node_modules/slice-ansi": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz",
- "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==",
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
+ "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7558,13 +7502,13 @@
}
},
"node_modules/log-update/node_modules/strip-ansi": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
- "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
+ "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "ansi-regex": "^6.0.1"
+ "ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
@@ -7574,9 +7518,9 @@
}
},
"node_modules/log-update/node_modules/wrap-ansi": {
- "version": "9.0.0",
- "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz",
- "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==",
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
+ "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8158,19 +8102,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/nano-spawn": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-1.0.2.tgz",
- "integrity": "sha512-21t+ozMQDAL/UGgQVBbZ/xXvNO10++ZPuTmKRO8k9V3AClVRht49ahtDjfY8l1q6nSHOrE5ASfthzH3ol6R/hg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=20.17"
- },
- "funding": {
- "url": "https://github.com/sindresorhus/nano-spawn?sponsor=1"
- }
- },
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -8379,6 +8310,22 @@
"wrappy": "1"
}
},
+ "node_modules/onetime": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
+ "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mimic-function": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -8754,19 +8701,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
- "node_modules/pidtree": {
- "version": "0.6.0",
- "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz",
- "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==",
- "dev": true,
- "license": "MIT",
- "bin": {
- "pidtree": "bin/pidtree.js"
- },
- "engines": {
- "node": ">=0.10"
- }
- },
"node_modules/pify": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
@@ -9265,22 +9199,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/restore-cursor/node_modules/onetime": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
- "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "mimic-function": "^5.0.0"
- },
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/retry": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
@@ -9853,26 +9771,26 @@
}
},
"node_modules/slice-ansi": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz",
- "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==",
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz",
+ "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "ansi-styles": "^6.0.0",
- "is-fullwidth-code-point": "^4.0.0"
+ "ansi-styles": "^6.2.3",
+ "is-fullwidth-code-point": "^5.1.0"
},
"engines": {
- "node": ">=12"
+ "node": ">=20"
},
"funding": {
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
}
},
"node_modules/slice-ansi/node_modules/ansi-styles": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
- "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -10794,6 +10712,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/tinyexec": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz",
+ "integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
@@ -10969,13 +10897,13 @@
}
},
"node_modules/undici": {
- "version": "7.25.0",
- "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
- "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz",
+ "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==",
"dev": true,
"license": "MIT",
"engines": {
- "node": ">=20.18.1"
+ "node": ">=22.19.0"
}
},
"node_modules/undici-types": {
@@ -11410,16 +11338,20 @@
}
},
"node_modules/yaml": {
- "version": "2.8.0",
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",
- "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==",
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
+ "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"dev": true,
"license": "ISC",
+ "optional": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yargs": {
diff --git a/test/token/ERC20/extensions/ERC7540.behavior.js b/test/token/ERC20/extensions/ERC7540.behavior.js
index fe52fb59..dc2ebb67 100644
--- a/test/token/ERC20/extensions/ERC7540.behavior.js
+++ b/test/token/ERC20/extensions/ERC7540.behavior.js
@@ -50,11 +50,13 @@ function shouldBehaveLikeERC7540Deposit({
balance,
supportCustomFulfill,
withTmpHolder,
+ gateOnController,
} = {}) {
initialAssets ??= ethers.parseEther('17000000');
initialShares ??= ethers.parseEther('42000000');
balance ??= ethers.parseEther('1000');
supportCustomFulfill ??= true;
+ gateOnController ??= false;
describe('Should behave like ERC7540Deposit', function () {
before(async function () {
@@ -69,6 +71,9 @@ function shouldBehaveLikeERC7540Deposit({
await this.token.connect(this.owner).approve(this.mock, ethers.MaxUint256);
await this.mock.connect(this.owner).setOperator(this.operator, true);
await this.mock.connect(this.controller).setOperator(this.operator, true);
+ if (gateOnController) {
+ await this.mock.connect(this.controller).setOperator(this.owner, true);
+ }
});
describe('supports ERC-7540 operator interface', function () {
@@ -386,11 +391,18 @@ function shouldBehaveLikeERC7540Deposit({
});
}
-function shouldBehaveLikeERC7540Redeem({ initialAssets, initialShares, balance, supportCustomFulfill } = {}) {
+function shouldBehaveLikeERC7540Redeem({
+ initialAssets,
+ initialShares,
+ balance,
+ supportCustomFulfill,
+ gateOnController,
+} = {}) {
initialAssets ??= ethers.parseEther('17000000');
initialShares ??= ethers.parseEther('42000000');
balance ??= ethers.parseEther('1000');
supportCustomFulfill ??= true;
+ gateOnController ??= false;
describe('Should behave like ERC7540Redeem', function () {
before(async function () {
@@ -405,6 +417,9 @@ function shouldBehaveLikeERC7540Redeem({ initialAssets, initialShares, balance,
await this.token.connect(this.owner).approve(this.mock, ethers.MaxUint256);
await this.mock.connect(this.owner).setOperator(this.operator, true);
await this.mock.connect(this.controller).setOperator(this.operator, true);
+ if (gateOnController) {
+ await this.mock.connect(this.controller).setOperator(this.owner, true);
+ }
});
describe('supports ERC-7540 operator interface', function () {
@@ -476,6 +491,9 @@ function shouldBehaveLikeERC7540Redeem({ initialAssets, initialShares, balance,
it('spends allowance when caller is neither owner nor operator', async function () {
await this.mock.connect(this.owner).approve(this.other, shares);
+ if (gateOnController) {
+ await this.mock.connect(this.controller).setOperator(this.other, true);
+ }
const tx = this.mock.connect(this.other).requestRedeem(shares, this.controller, this.owner);
const requestId = await this.getRequestId(tx);
@@ -488,6 +506,10 @@ function shouldBehaveLikeERC7540Redeem({ initialAssets, initialShares, balance,
});
it('revert of caller is neither owner nor operator and has no allowance', async function () {
+ if (gateOnController) {
+ await this.mock.connect(this.controller).setOperator(this.other, true);
+ }
+
await expect(this.mock.connect(this.other).requestRedeem(shares, this.controller, this.owner))
.to.be.revertedWithCustomError(this.mock, 'ERC20InsufficientAllowance')
.withArgs(this.other, 0n, shares);
diff --git a/test/token/ERC20/extensions/ERC7540EpochDeposit.test.js b/test/token/ERC20/extensions/ERC7540EpochDeposit.test.js
new file mode 100644
index 00000000..2c6ef7c0
--- /dev/null
+++ b/test/token/ERC20/extensions/ERC7540EpochDeposit.test.js
@@ -0,0 +1,615 @@
+const { ethers } = require('hardhat');
+const { expect } = require('chai');
+const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
+
+const time = require('@openzeppelin/contracts/test/helpers/time');
+const { shouldBehaveLikeERC7540Operator, shouldBehaveLikeERC7540Deposit } = require('./ERC7540.behavior');
+const { shouldBehaveLikeERC7575 } = require('./ERC7575.behavior');
+
+const name = 'Vault Shares';
+const symbol = 'vSHR';
+const tokenName = 'Asset Token';
+const tokenSymbol = 'AST';
+const week = 7n * 24n * 3600n;
+
+async function fixture() {
+ const token = await ethers.deployContract('$ERC20', [tokenName, tokenSymbol]);
+ const mock = await ethers.deployContract('$ERC7540EpochMock', [name, symbol, token]);
+ return { token, mock };
+}
+
+// Advances time so that `currentDepositEpoch() > epochId`. Idempotent: no-op if already past.
+// `currentDepositEpoch() = block.timestamp / week + 1`, so `epochId` is passed once
+// `block.timestamp >= epochId * week`.
+async function advancePast(epochId) {
+ const now = await time.clock.timestamp();
+ const target = BigInt(epochId) * week;
+ if (now < target) await time.increaseTo.timestamp(target);
+}
+
+describe('ERC7540EpochDeposit', function () {
+ beforeEach(async function () {
+ Object.assign(this, await loadFixture(fixture));
+
+ this.getRequestId = tx => time.clockFromReceipt.timestamp(tx).then(timestamp => timestamp / week + 1n);
+
+ // Behavior-test plumbing: fulfill the whole epoch at the (assets, shares) rate.
+ this.fulfillDeposit = async (requestId, _assets, shares) => {
+ await advancePast(requestId);
+ return this.mock.$_fulfillDeposit(requestId, shares);
+ };
+ this.fulfillRedeem = async (requestId, assets) => {
+ await advancePast(requestId);
+ return this.mock.$_fulfillRedeem(requestId, assets);
+ };
+ });
+
+ describe('metadata', function () {
+ it('token', async function () {
+ await expect(this.mock.asset()).to.eventually.equal(this.token);
+ });
+
+ it('name, symbol, decimals', async function () {
+ await expect(this.mock.name()).to.eventually.equal(name);
+ await expect(this.mock.symbol()).to.eventually.equal(symbol);
+ await expect(this.mock.decimals()).to.eventually.equal(18n);
+ });
+
+ it('reports async deposit and redeem', async function () {
+ await expect(this.mock.$_isDepositAsync()).to.eventually.equal(true);
+ await expect(this.mock.$_isRedeemAsync()).to.eventually.equal(true);
+ });
+
+ it('default epoch matches `block.timestamp / 1 weeks + 1`', async function () {
+ const now = await time.clock.timestamp();
+ await expect(this.mock.currentDepositEpoch()).to.eventually.equal(now / week + 1n);
+ });
+ });
+
+ shouldBehaveLikeERC7540Operator();
+ shouldBehaveLikeERC7540Deposit({ supportCustomFulfill: false, gateOnController: true });
+ shouldBehaveLikeERC7575();
+
+ describe('epoch state and getters', function () {
+ let user;
+
+ beforeEach(async function () {
+ [, user] = await ethers.getSigners();
+ await this.token.$_mint(user, 1000n);
+ await this.token.connect(user).approve(this.mock, ethers.MaxUint256);
+ });
+
+ it('`requestDeposit` returns the current epoch as `requestId`', async function () {
+ const tx = await this.mock.connect(user).requestDeposit(100n, user, user);
+ const expected = (await time.clockFromReceipt.timestamp(tx)) / week + 1n;
+ // requestId is also retrievable via the event (indexed third topic)
+ await expect(tx).to.emit(this.mock, 'DepositRequest').withArgs(user, user, expected, user, 100n);
+ });
+
+ it('`totalDepositAssets` / `totalDepositShares` reflect storage', async function () {
+ const tx = await this.mock.connect(user).requestDeposit(100n, user, user);
+ const epochId = await this.getRequestId(tx);
+
+ await expect(this.mock.totalDepositAssets(epochId)).to.eventually.equal(100n);
+ await expect(this.mock.totalDepositShares(epochId)).to.eventually.equal(0n);
+
+ await advancePast(epochId);
+ await this.mock.$_fulfillDeposit(epochId, 42n);
+
+ await expect(this.mock.totalDepositAssets(epochId)).to.eventually.equal(100n);
+ await expect(this.mock.totalDepositShares(epochId)).to.eventually.equal(42n);
+ });
+
+ it('`_convertToDepositShares` returns 0 when `totalAssets == 0`', async function () {
+ // unknown epoch -> totalAssets = 0
+ await expect(this.mock.$_convertToDepositShares(999n, 100n, 0n)).to.eventually.equal(0n);
+ });
+
+ it('`_convertToDepositAssets` returns 0 when `totalShares == 0`', async function () {
+ // pending epoch -> totalShares = 0
+ const tx = await this.mock.connect(user).requestDeposit(100n, user, user);
+ const epochId = await this.getRequestId(tx);
+ await expect(this.mock.$_convertToDepositAssets(epochId, 50n, 0n)).to.eventually.equal(0n);
+ });
+
+ it('`_convertToDepositShares` applies the locked rate', async function () {
+ const tx = await this.mock.connect(user).requestDeposit(100n, user, user);
+ const epochId = await this.getRequestId(tx);
+ await advancePast(epochId);
+ await this.mock.$_fulfillDeposit(epochId, 50n); // rate 1 asset = 0.5 shares
+
+ // Floor: floor(40 * 50 / 100) = 20
+ await expect(this.mock.$_convertToDepositShares(epochId, 40n, 0n)).to.eventually.equal(20n);
+ // Ceil: ceil(33 * 50 / 100) = 17
+ await expect(this.mock.$_convertToDepositShares(epochId, 33n, 1n)).to.eventually.equal(17n);
+ });
+
+ it('`_convertToDepositAssets` applies the locked rate', async function () {
+ const tx = await this.mock.connect(user).requestDeposit(100n, user, user);
+ const epochId = await this.getRequestId(tx);
+ await advancePast(epochId);
+ await this.mock.$_fulfillDeposit(epochId, 50n);
+
+ // Floor: floor(10 * 100 / 50) = 20
+ await expect(this.mock.$_convertToDepositAssets(epochId, 10n, 0n)).to.eventually.equal(20n);
+ // Ceil: ceil(11 * 100 / 50) = 22
+ await expect(this.mock.$_convertToDepositAssets(epochId, 11n, 1n)).to.eventually.equal(22n);
+ });
+
+ it('`_pendingAvailableDepositRequest` masks drained epochs', async function () {
+ const tx = await this.mock.connect(user).requestDeposit(100n, user, user);
+ const epochId = await this.getRequestId(tx);
+
+ // Pending: totalAssets > 0 -> returns requests[c]
+ await expect(this.mock.$_pendingAvailableDepositRequest(epochId, user)).to.eventually.equal(100n);
+
+ // Unknown epoch: totalAssets == 0 -> returns 0
+ await expect(this.mock.$_pendingAvailableDepositRequest(999n, user)).to.eventually.equal(0n);
+ });
+
+ it('pending vs. claimable sentinel distinguishes states', async function () {
+ const tx = await this.mock.connect(user).requestDeposit(100n, user, user);
+ const epochId = await this.getRequestId(tx);
+
+ // Pending state
+ await expect(this.mock.pendingDepositRequest(epochId, user)).to.eventually.equal(100n);
+ await expect(this.mock.claimableDepositRequest(epochId, user)).to.eventually.equal(0n);
+
+ await advancePast(epochId);
+ await this.mock.$_fulfillDeposit(epochId, 42n);
+
+ // Claimable state
+ await expect(this.mock.pendingDepositRequest(epochId, user)).to.eventually.equal(0n);
+ await expect(this.mock.claimableDepositRequest(epochId, user)).to.eventually.equal(100n);
+
+ // Consume the full claim
+ await this.mock.connect(user).deposit(100n, user, ethers.Typed.address(user));
+
+ // Fully-claimed (drained): totalAssets and totalShares both reach 0
+ await expect(this.mock.totalDepositAssets(epochId)).to.eventually.equal(0n);
+ await expect(this.mock.totalDepositShares(epochId)).to.eventually.equal(0n);
+ await expect(this.mock.pendingDepositRequest(epochId, user)).to.eventually.equal(0n);
+ await expect(this.mock.claimableDepositRequest(epochId, user)).to.eventually.equal(0n);
+ await expect(this.mock.maxDeposit(user)).to.eventually.equal(0n);
+ await expect(this.mock.maxMint(user)).to.eventually.equal(0n);
+ });
+ });
+
+ describe('_fulfillDeposit', function () {
+ let user;
+
+ beforeEach(async function () {
+ [, user] = await ethers.getSigners();
+ await this.token.$_mint(user, 1000n);
+ await this.token.connect(user).approve(this.mock, ethers.MaxUint256);
+ });
+
+ it('emits `ERC7540EpochDepositFulfilled` with the locked rate', async function () {
+ const tx = await this.mock.connect(user).requestDeposit(100n, user, user);
+ const epochId = await this.getRequestId(tx);
+ await advancePast(epochId);
+
+ await expect(this.mock.$_fulfillDeposit(epochId, 42n))
+ .to.emit(this.mock, 'ERC7540EpochDepositFulfilled')
+ .withArgs(epochId, 100n, 42n);
+ });
+
+ it('reverts `TooEarly` when the epoch is still the current one', async function () {
+ const tx = await this.mock.connect(user).requestDeposit(100n, user, user);
+ const epochId = await this.getRequestId(tx);
+ // Do NOT advance time — epochId == currentDepositEpoch()
+
+ await expect(this.mock.$_fulfillDeposit(epochId, 42n))
+ .to.be.revertedWithCustomError(this.mock, 'ERC7540EpochDepositTooEarly')
+ .withArgs(epochId);
+ });
+
+ it('reverts `EmptyEpoch` for an epoch with no requests', async function () {
+ const current = await this.mock.currentDepositEpoch();
+ await advancePast(current);
+
+ await expect(this.mock.$_fulfillDeposit(current, 42n))
+ .to.be.revertedWithCustomError(this.mock, 'ERC7540EpochDepositEmptyEpoch')
+ .withArgs(current);
+ });
+
+ it('reverts `AlreadyFulfilled` on a second non-zero fulfill', async function () {
+ const tx = await this.mock.connect(user).requestDeposit(100n, user, user);
+ const epochId = await this.getRequestId(tx);
+ await advancePast(epochId);
+ await this.mock.$_fulfillDeposit(epochId, 42n);
+
+ await expect(this.mock.$_fulfillDeposit(epochId, 50n))
+ .to.be.revertedWithCustomError(this.mock, 'ERC7540EpochDepositAlreadyFulfilled')
+ .withArgs(epochId);
+ });
+
+ // Documents the assumption baked into the sentinel: admin fulfilling at 0 is a no-op,
+ // not a permanent confiscation. The admin can retry with the correct value.
+ it('a 0-share fulfillment is a no-op and can be recovered by re-fulfilling', async function () {
+ const tx = await this.mock.connect(user).requestDeposit(100n, user, user);
+ const epochId = await this.getRequestId(tx);
+ await advancePast(epochId);
+
+ await this.mock.$_fulfillDeposit(epochId, 0n);
+
+ // State is still "pending" — sentinel hasn't tripped
+ await expect(this.mock.totalDepositShares(epochId)).to.eventually.equal(0n);
+ await expect(this.mock.pendingDepositRequest(epochId, user)).to.eventually.equal(100n);
+ await expect(this.mock.claimableDepositRequest(epochId, user)).to.eventually.equal(0n);
+
+ // Re-fulfill with the correct value
+ await expect(this.mock.$_fulfillDeposit(epochId, 42n))
+ .to.emit(this.mock, 'ERC7540EpochDepositFulfilled')
+ .withArgs(epochId, 100n, 42n);
+ await expect(this.mock.totalDepositShares(epochId)).to.eventually.equal(42n);
+ await expect(this.mock.claimableDepositRequest(epochId, user)).to.eventually.equal(100n);
+ });
+ });
+
+ describe('multi-epoch flow', function () {
+ it('a controller can hold requests across multiple epochs and claim each', async function () {
+ const [, user] = await ethers.getSigners();
+ await this.token.$_mint(user, 1000n);
+ await this.token.connect(user).approve(this.mock, ethers.MaxUint256);
+
+ // Epoch A
+ const txA = await this.mock.connect(user).requestDeposit(100n, user, user);
+ const epochA = await this.getRequestId(txA);
+
+ // Advance one week -> Epoch B
+ await time.increaseTo.timestamp(epochA * week);
+ const txB = await this.mock.connect(user).requestDeposit(50n, user, user);
+ const epochB = await this.getRequestId(txB);
+ expect(epochB).to.equal(epochA + 1n);
+
+ // Advance and fulfill both
+ await advancePast(epochB);
+ await this.mock.$_fulfillDeposit(epochA, 200n); // 100 assets -> 200 shares
+ await this.mock.$_fulfillDeposit(epochB, 50n); // 50 assets -> 50 shares
+
+ await expect(this.mock.maxDeposit(user)).to.eventually.equal(150n);
+ await expect(this.mock.maxMint(user)).to.eventually.equal(250n);
+
+ // Claim a single share - hits epochA (front of queue) only
+ await this.mock.connect(user).mint(50n, user, ethers.Typed.address(user));
+ await expect(this.mock.balanceOf(user)).to.eventually.equal(50n);
+
+ // Claim the rest - drains epochA then takes from epochB
+ await this.mock.connect(user).mint(this.mock.maxMint(user), user, ethers.Typed.address(user));
+ await expect(this.mock.balanceOf(user)).to.eventually.equal(250n);
+ await expect(this.mock.maxMint(user)).to.eventually.equal(0n);
+ });
+
+ it('multiple controllers in the same epoch share pro-rata', async function () {
+ const [, alice, bob, carol] = await ethers.getSigners();
+ for (const user of [alice, bob, carol]) {
+ await this.token.$_mint(user, 1000n);
+ await this.token.connect(user).approve(this.mock, ethers.MaxUint256);
+ }
+
+ const txA = await this.mock.connect(alice).requestDeposit(30n, alice, alice);
+ await this.mock.connect(bob).requestDeposit(40n, bob, bob);
+ await this.mock.connect(carol).requestDeposit(30n, carol, carol);
+ const epochId = await this.getRequestId(txA);
+
+ // Total assets pending in this epoch
+ await expect(this.mock.totalDepositAssets(epochId)).to.eventually.equal(100n);
+
+ await advancePast(epochId);
+ await this.mock.$_fulfillDeposit(epochId, 200n); // 100 -> 200, exact rate 1:2
+
+ // Each controller can mint their pro-rata share
+ await expect(this.mock.maxMint(alice)).to.eventually.equal(60n);
+ await expect(this.mock.maxMint(bob)).to.eventually.equal(80n);
+ await expect(this.mock.maxMint(carol)).to.eventually.equal(60n);
+
+ // Claim in order — each pops their queue entry without affecting the others
+ await this.mock.connect(alice).deposit(30n, alice, ethers.Typed.address(alice));
+ await this.mock.connect(bob).deposit(40n, bob, ethers.Typed.address(bob));
+ await this.mock.connect(carol).deposit(30n, carol, ethers.Typed.address(carol));
+
+ await expect(this.mock.balanceOf(alice)).to.eventually.equal(60n);
+ await expect(this.mock.balanceOf(bob)).to.eventually.equal(80n);
+ await expect(this.mock.balanceOf(carol)).to.eventually.equal(60n);
+
+ // Epoch fully drained
+ await expect(this.mock.totalDepositAssets(epochId)).to.eventually.equal(0n);
+ await expect(this.mock.totalDepositShares(epochId)).to.eventually.equal(0n);
+ });
+ });
+
+ describe('depositEpochs', function () {
+ let user;
+
+ beforeEach(async function () {
+ [, user] = await ethers.getSigners();
+ await this.token.$_mint(user, 10000n);
+ await this.token.connect(user).approve(this.mock, ethers.MaxUint256);
+ });
+
+ it('returns empty for a controller with no requests', async function () {
+ await expect(this.mock.depositEpochs(user, 0, ethers.MaxUint256)).to.eventually.deep.equal([]);
+ await expect(this.mock.depositEpochs(user, 0, 10)).to.eventually.deep.equal([]);
+ });
+
+ it('returns each epoch in queue order (oldest first)', async function () {
+ const e0 = await this.mock.currentDepositEpoch();
+ await this.mock.connect(user).requestDeposit(100n, user, user);
+ await time.increaseTo.timestamp(e0 * week);
+ await this.mock.connect(user).requestDeposit(100n, user, user);
+ await time.increaseTo.timestamp((e0 + 1n) * week);
+ await this.mock.connect(user).requestDeposit(100n, user, user);
+
+ await expect(this.mock.depositEpochs(user, 0, ethers.MaxUint256)).to.eventually.deep.equal([
+ e0,
+ e0 + 1n,
+ e0 + 2n,
+ ]);
+ });
+
+ it('collapses multiple requests in the same epoch into one entry', async function () {
+ const e0 = await this.mock.currentDepositEpoch();
+ await this.mock.connect(user).requestDeposit(50n, user, user);
+ await this.mock.connect(user).requestDeposit(50n, user, user);
+
+ await expect(this.mock.depositEpochs(user, 0, ethers.MaxUint256)).to.eventually.deep.equal([e0]);
+ });
+
+ it('pops fully-claimed epochs from the queue', async function () {
+ const e0 = await this.mock.currentDepositEpoch();
+ await this.mock.connect(user).requestDeposit(100n, user, user);
+ await time.increaseTo.timestamp(e0 * week);
+ await this.mock.connect(user).requestDeposit(100n, user, user);
+
+ await advancePast(e0 + 1n);
+ await this.mock.$_fulfillDeposit(e0, 50n);
+ await this.mock.$_fulfillDeposit(e0 + 1n, 50n);
+
+ await expect(this.mock.depositEpochs(user, 0, ethers.MaxUint256)).to.eventually.deep.equal([e0, e0 + 1n]);
+
+ // Claim the first epoch fully — _consumeClaimableDeposit pops it from the queue
+ await this.mock.connect(user).deposit(100n, user, ethers.Typed.address(user));
+ await expect(this.mock.depositEpochs(user, 0, ethers.MaxUint256)).to.eventually.deep.equal([e0 + 1n]);
+ });
+
+ it('paginates with [start, end)', async function () {
+ const e0 = await this.mock.currentDepositEpoch();
+ for (let i = 0; i < 4; i++) {
+ await this.mock.connect(user).requestDeposit(10n, user, user);
+ await time.increaseTo.timestamp((e0 + BigInt(i)) * week);
+ }
+ const all = [e0, e0 + 1n, e0 + 2n, e0 + 3n];
+
+ await expect(this.mock.depositEpochs(user, 0, 4)).to.eventually.deep.equal(all);
+ await expect(this.mock.depositEpochs(user, 1, 3)).to.eventually.deep.equal(all.slice(1, 3));
+ await expect(this.mock.depositEpochs(user, 0, 1)).to.eventually.deep.equal(all.slice(0, 1));
+ });
+
+ it('clamps out-of-bound `start` and `end`', async function () {
+ const e0 = await this.mock.currentDepositEpoch();
+ await this.mock.connect(user).requestDeposit(100n, user, user);
+
+ // end > length → clamped
+ await expect(this.mock.depositEpochs(user, 0, ethers.MaxUint256)).to.eventually.deep.equal([e0]);
+ // start > length → empty after both clamps
+ await expect(this.mock.depositEpochs(user, 10, ethers.MaxUint256)).to.eventually.deep.equal([]);
+ // start > end → empty
+ await expect(this.mock.depositEpochs(user, 1, 0)).to.eventually.deep.equal([]);
+ });
+ });
+
+ describe('queue limit', function () {
+ it('enforces `_depositRequestQueueLimit` per controller', async function () {
+ const [, user] = await ethers.getSigners();
+ await this.token.$_mint(user, 10000n);
+ await this.token.connect(user).approve(this.mock, ethers.MaxUint256);
+
+ // Fill the queue with 32 distinct epochs (default limit)
+ let epoch = await this.mock.currentDepositEpoch();
+ for (let i = 0; i < 32; i++) {
+ await this.mock.connect(user).requestDeposit(1n, user, user);
+ epoch = epoch + 1n;
+ await time.increaseTo.timestamp((epoch - 1n) * week);
+ }
+
+ // The 33rd distinct epoch should revert with the queue-limit error
+ await expect(this.mock.connect(user).requestDeposit(1n, user, user))
+ .to.be.revertedWithCustomError(this.mock, 'ERC7540EpochDepositQueueLimitExceeded')
+ .withArgs(user);
+ });
+
+ it('multiple requests in the same epoch share one queue slot', async function () {
+ const [, user] = await ethers.getSigners();
+ await this.token.$_mint(user, 1000n);
+ await this.token.connect(user).approve(this.mock, ethers.MaxUint256);
+
+ // Many requests in the same epoch - all share the same queue entry
+ for (let i = 0; i < 50; i++) {
+ await this.mock.connect(user).requestDeposit(1n, user, user);
+ }
+ // Did not hit the queue limit despite > 32 requests
+ const epochId = await this.mock.currentDepositEpoch();
+ await expect(this.mock.totalDepositAssets(epochId)).to.eventually.equal(50n);
+ });
+
+ it('zero-amount `requestDeposit` is a no-op at the epoch layer', async function () {
+ const [, user] = await ethers.getSigners();
+ await this.token.$_mint(user, 1000n);
+ await this.token.connect(user).approve(this.mock, ethers.MaxUint256);
+
+ const epochId = await this.mock.currentDepositEpoch();
+ await this.mock.connect(user).requestDeposit(0n, user, user);
+
+ await expect(this.mock.totalDepositAssets(epochId)).to.eventually.equal(0n);
+ await expect(this.mock.depositEpochs(user, 0, ethers.MaxUint256)).to.eventually.deep.equal([]);
+ });
+
+ it('third-party attribution to a foreign controller reverts', async function () {
+ const [, attacker, victim] = await ethers.getSigners();
+ await this.token.$_mint(attacker, 1000n);
+ await this.token.connect(attacker).approve(this.mock, ethers.MaxUint256);
+
+ await expect(this.mock.connect(attacker).requestDeposit(1n, victim, attacker))
+ .to.be.revertedWithCustomError(this.mock, 'ERC7540InvalidOperator')
+ .withArgs(victim, attacker);
+ });
+ });
+
+ describe('out-of-order fulfillment', function () {
+ let user;
+
+ beforeEach(async function () {
+ [, user] = await ethers.getSigners();
+ await this.token.$_mint(user, 1000n);
+ await this.token.connect(user).approve(this.mock, ethers.MaxUint256);
+ this.epochA = await this.mock
+ .connect(user)
+ .requestDeposit(100n, user, user)
+ .then(tx => this.getRequestId(tx));
+ await time.increaseTo.timestamp(this.epochA * week);
+ this.epochB = await this.mock
+ .connect(user)
+ .requestDeposit(50n, user, user)
+ .then(tx => this.getRequestId(tx));
+ await advancePast(this.epochB);
+ });
+
+ it('maxDeposit / maxMint hide a fulfilled epoch when an older one is still Pending', async function () {
+ await this.mock.$_fulfillDeposit(this.epochB, 100n); // out of order; B before A
+
+ await expect(this.mock.maxDeposit(user)).to.eventually.equal(0n);
+ await expect(this.mock.maxMint(user)).to.eventually.equal(0n);
+
+ await expect(this.mock.pendingDepositRequest(this.epochA, user)).to.eventually.equal(100n);
+ await expect(this.mock.claimableDepositRequest(this.epochB, user)).to.eventually.equal(50n);
+ });
+
+ it('_consumeClaimableDeposit is a no-op when the oldest epoch is Pending', async function () {
+ await this.mock.$_fulfillDeposit(this.epochB, 100n);
+
+ await this.mock.$_consumeClaimableDeposit(999n, user);
+
+ await expect(this.mock.totalDepositAssets(this.epochA)).to.eventually.equal(100n);
+ await expect(this.mock.totalDepositAssets(this.epochB)).to.eventually.equal(50n);
+ await expect(this.mock.totalDepositShares(this.epochB)).to.eventually.equal(100n);
+ await expect(this.mock.$_pendingAvailableDepositRequest(this.epochA, user)).to.eventually.equal(100n);
+ await expect(this.mock.depositEpochs(user, 0, ethers.MaxUint256)).to.eventually.deep.equal([
+ this.epochA,
+ this.epochB,
+ ]);
+ });
+
+ it('_consumeClaimableMint is a no-op when the oldest epoch is Pending', async function () {
+ await this.mock.$_fulfillDeposit(this.epochB, 100n);
+
+ await this.mock.$_consumeClaimableMint(999n, user);
+
+ await expect(this.mock.totalDepositAssets(this.epochA)).to.eventually.equal(100n);
+ await expect(this.mock.totalDepositAssets(this.epochB)).to.eventually.equal(50n);
+ await expect(this.mock.totalDepositShares(this.epochB)).to.eventually.equal(100n);
+ await expect(this.mock.$_pendingAvailableDepositRequest(this.epochA, user)).to.eventually.equal(100n);
+ await expect(this.mock.depositEpochs(user, 0, ethers.MaxUint256)).to.eventually.deep.equal([
+ this.epochA,
+ this.epochB,
+ ]);
+ });
+
+ it('claims unlock automatically once the older epoch is fulfilled', async function () {
+ await this.mock.$_fulfillDeposit(this.epochB, 100n); // B first
+ await this.mock.$_fulfillDeposit(this.epochA, 200n); // then A
+
+ await expect(this.mock.maxDeposit(user)).to.eventually.equal(150n);
+ await expect(this.mock.maxMint(user)).to.eventually.equal(300n);
+
+ // A single deposit call drains both epochs in queue order
+ await this.mock.connect(user).deposit(150n, user, ethers.Typed.address(user));
+ await expect(this.mock.balanceOf(user)).to.eventually.equal(300n);
+ await expect(this.mock.depositEpochs(user, 0, ethers.MaxUint256)).to.eventually.deep.equal([]);
+ });
+ });
+
+ describe('edge cases', function () {
+ it('a 1-share fulfillment makes mint(0) a no-op (per-claim rounding edge)', async function () {
+ const [, user] = await ethers.getSigners();
+ await this.token.$_mint(user, 1000n);
+ await this.token.connect(user).approve(this.mock, ethers.MaxUint256);
+
+ const tx = await this.mock.connect(user).requestDeposit(100n, user, user);
+ const epochId = await this.getRequestId(tx);
+ await advancePast(epochId);
+ await this.mock.$_fulfillDeposit(epochId, 1n); // 100 assets -> 1 share
+
+ // mint(0) is a valid no-op call
+ const before = await this.mock.balanceOf(user);
+ await this.mock.connect(user).mint(0n, user, ethers.Typed.address(user));
+ await expect(this.mock.balanceOf(user)).to.eventually.equal(before);
+
+ // Asset-driven `deposit` is the path that can absorb the floor-rounding dust
+ await this.mock.connect(user).deposit(100n, user, ethers.Typed.address(user));
+ await expect(this.mock.balanceOf(user)).to.eventually.equal(1n);
+ });
+
+ it('share-driven claims cannot consume more assets than the controller was entitled to', async function () {
+ // Pathological tiny-totals scenario. Without the `batchAssets <= requestedAssets` cap on
+ // the share-driven path, Alice's Case A would floor-convert 2 shares to 3 assets (overshoot
+ // by 1) and drain part of Bob's asset pool. With the cap in place Alice consumes exactly her
+ // entitlement (2 assets); the ceil/floor gap stays in the epoch as unclaimable dust.
+ //
+ // Setup: r_alice=2, r_bob=3, totalAssets=5. Fulfill S=3.
+ // Alice: requested=ceil(2*3/5)=2, batchAssets=min(floor(2*5/3), 2)=2.
+ // State: r_alice 2->0, totalAssets 5->3, totalShares 3->1.
+ // Bob: requested=ceil(3*1/3)=1, batchShares=min(1,1)=1, batchAssets=min(floor(1*3/1), 3)=3.
+ // State: r_bob 3->0, totalAssets 3->0, totalShares 1->0.
+ const [, alice, bob] = await ethers.getSigners();
+ for (const u of [alice, bob]) {
+ await this.token.$_mint(u, 1000n);
+ await this.token.connect(u).approve(this.mock, ethers.MaxUint256);
+ }
+
+ const tx = await this.mock.connect(alice).requestDeposit(2n, alice, alice);
+ await this.mock.connect(bob).requestDeposit(3n, bob, bob);
+ const epochId = await this.getRequestId(tx);
+ await advancePast(epochId);
+ await this.mock.$_fulfillDeposit(epochId, 3n);
+
+ // Alice's claim is capped at her request; overshoot stays in the pool.
+ await this.mock.$_consumeClaimableMint(2n, alice);
+ await expect(this.mock.totalDepositAssets(epochId)).to.eventually.equal(3n);
+ await expect(this.mock.totalDepositShares(epochId)).to.eventually.equal(1n);
+
+ // Bob's claim drains the remainder cleanly (no requests-slot dust).
+ await this.mock.$_consumeClaimableMint(1n, bob);
+ await expect(this.mock.totalDepositAssets(epochId)).to.eventually.equal(0n);
+ await expect(this.mock.totalDepositShares(epochId)).to.eventually.equal(0n);
+
+ // No dust visible from any public view.
+ await expect(this.mock.pendingDepositRequest(epochId, bob)).to.eventually.equal(0n);
+ await expect(this.mock.claimableDepositRequest(epochId, bob)).to.eventually.equal(0n);
+ await expect(this.mock.maxDeposit(bob)).to.eventually.equal(0n);
+ await expect(this.mock.maxMint(bob)).to.eventually.equal(0n);
+ });
+
+ it('a fully-drained epoch keeps the {_fulfillDeposit} sentinel intact', async function () {
+ // After a normal full distribution, `totalAssets` and `totalShares` both reach 0.
+ // The sentinel relies on `totalAssets > 0` to detect "still has pending value", so
+ // a re-fulfill attempt must revert with `EmptyEpoch` rather than silently re-trigger.
+ const [, user] = await ethers.getSigners();
+ await this.token.$_mint(user, 1000n);
+ await this.token.connect(user).approve(this.mock, ethers.MaxUint256);
+
+ const tx = await this.mock.connect(user).requestDeposit(100n, user, user);
+ const epochId = await this.getRequestId(tx);
+ await advancePast(epochId);
+ await this.mock.$_fulfillDeposit(epochId, 42n);
+ await this.mock.connect(user).deposit(100n, user, ethers.Typed.address(user));
+
+ await expect(this.mock.totalDepositAssets(epochId)).to.eventually.equal(0n);
+ await expect(this.mock.totalDepositShares(epochId)).to.eventually.equal(0n);
+
+ await expect(this.mock.$_fulfillDeposit(epochId, 50n))
+ .to.be.revertedWithCustomError(this.mock, 'ERC7540EpochDepositEmptyEpoch')
+ .withArgs(epochId);
+ });
+ });
+});
diff --git a/test/token/ERC20/extensions/ERC7540EpochRedeem.test.js b/test/token/ERC20/extensions/ERC7540EpochRedeem.test.js
new file mode 100644
index 00000000..123e4df6
--- /dev/null
+++ b/test/token/ERC20/extensions/ERC7540EpochRedeem.test.js
@@ -0,0 +1,585 @@
+const { ethers } = require('hardhat');
+const { expect } = require('chai');
+const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
+
+const time = require('@openzeppelin/contracts/test/helpers/time');
+const { shouldBehaveLikeERC7540Operator, shouldBehaveLikeERC7540Redeem } = require('./ERC7540.behavior');
+const { shouldBehaveLikeERC7575 } = require('./ERC7575.behavior');
+
+const name = 'Vault Shares';
+const symbol = 'vSHR';
+const tokenName = 'Asset Token';
+const tokenSymbol = 'AST';
+const week = 7n * 24n * 3600n;
+
+async function fixture() {
+ const token = await ethers.deployContract('$ERC20', [tokenName, tokenSymbol]);
+ const mock = await ethers.deployContract('$ERC7540EpochMock', [name, symbol, token]);
+ return { token, mock };
+}
+
+// Advances time so that `currentRedeemEpoch() > epochId`. Idempotent: no-op if already past.
+// `currentRedeemEpoch() = block.timestamp / week + 1`, so `epochId` is passed once
+// `block.timestamp >= epochId * week`.
+async function advancePast(epochId) {
+ const now = await time.clock.timestamp();
+ const target = BigInt(epochId) * week;
+ if (now < target) await time.increaseTo.timestamp(target);
+}
+
+describe('ERC7540EpochRedeem', function () {
+ beforeEach(async function () {
+ Object.assign(this, await loadFixture(fixture));
+
+ this.getRequestId = tx => time.clockFromReceipt.timestamp(tx).then(timestamp => timestamp / week + 1n);
+
+ this.fulfillDeposit = async (requestId, _assets, shares) => {
+ await advancePast(requestId);
+ return this.mock.$_fulfillDeposit(requestId, shares);
+ };
+ this.fulfillRedeem = async (requestId, assets) => {
+ await advancePast(requestId);
+ return this.mock.$_fulfillRedeem(requestId, assets);
+ };
+ });
+
+ describe('metadata', function () {
+ it('token', async function () {
+ await expect(this.mock.asset()).to.eventually.equal(this.token);
+ });
+
+ it('name, symbol, decimals', async function () {
+ await expect(this.mock.name()).to.eventually.equal(name);
+ await expect(this.mock.symbol()).to.eventually.equal(symbol);
+ await expect(this.mock.decimals()).to.eventually.equal(18n);
+ });
+
+ it('reports async deposit and redeem', async function () {
+ await expect(this.mock.$_isDepositAsync()).to.eventually.equal(true);
+ await expect(this.mock.$_isRedeemAsync()).to.eventually.equal(true);
+ });
+
+ it('default epoch matches `block.timestamp / 1 weeks + 1`', async function () {
+ const now = await time.clock.timestamp();
+ await expect(this.mock.currentRedeemEpoch()).to.eventually.equal(now / week + 1n);
+ });
+ });
+
+ shouldBehaveLikeERC7540Operator();
+ shouldBehaveLikeERC7540Redeem({ supportCustomFulfill: false, gateOnController: true });
+ shouldBehaveLikeERC7575();
+
+ describe('epoch state and getters', function () {
+ let user;
+
+ beforeEach(async function () {
+ [, user] = await ethers.getSigners();
+ await this.token.$_mint(this.mock, 1000n);
+ await this.mock.$_mint(user, 1000n);
+ });
+
+ it('`requestRedeem` returns the current epoch as `requestId`', async function () {
+ const tx = await this.mock.connect(user).requestRedeem(100n, user, user);
+ const expected = (await time.clockFromReceipt.timestamp(tx)) / week + 1n;
+ await expect(tx).to.emit(this.mock, 'RedeemRequest').withArgs(user, user, expected, user, 100n);
+ });
+
+ it('`totalRedeemShares` / `totalRedeemAssets` reflect storage', async function () {
+ const tx = await this.mock.connect(user).requestRedeem(100n, user, user);
+ const epochId = await this.getRequestId(tx);
+
+ await expect(this.mock.totalRedeemShares(epochId)).to.eventually.equal(100n);
+ await expect(this.mock.totalRedeemAssets(epochId)).to.eventually.equal(0n);
+
+ await advancePast(epochId);
+ await this.mock.$_fulfillRedeem(epochId, 42n);
+
+ await expect(this.mock.totalRedeemShares(epochId)).to.eventually.equal(100n);
+ await expect(this.mock.totalRedeemAssets(epochId)).to.eventually.equal(42n);
+ });
+
+ it('`_convertToRedeemAssets` returns 0 when `totalShares == 0`', async function () {
+ // unknown epoch -> totalShares = 0
+ await expect(this.mock.$_convertToRedeemAssets(999n, 100n, 0n)).to.eventually.equal(0n);
+ });
+
+ it('`_convertToRedeemShares` returns 0 when `totalAssets == 0`', async function () {
+ // pending epoch -> totalAssets = 0
+ const tx = await this.mock.connect(user).requestRedeem(100n, user, user);
+ const epochId = await this.getRequestId(tx);
+ await expect(this.mock.$_convertToRedeemShares(epochId, 50n, 0n)).to.eventually.equal(0n);
+ });
+
+ it('`_convertToRedeemAssets` applies the locked rate', async function () {
+ const tx = await this.mock.connect(user).requestRedeem(100n, user, user);
+ const epochId = await this.getRequestId(tx);
+ await advancePast(epochId);
+ await this.mock.$_fulfillRedeem(epochId, 50n); // rate 1 share = 0.5 assets
+
+ // Floor: floor(40 * 50 / 100) = 20
+ await expect(this.mock.$_convertToRedeemAssets(epochId, 40n, 0n)).to.eventually.equal(20n);
+ // Ceil: ceil(33 * 50 / 100) = 17
+ await expect(this.mock.$_convertToRedeemAssets(epochId, 33n, 1n)).to.eventually.equal(17n);
+ });
+
+ it('`_convertToRedeemShares` applies the locked rate', async function () {
+ const tx = await this.mock.connect(user).requestRedeem(100n, user, user);
+ const epochId = await this.getRequestId(tx);
+ await advancePast(epochId);
+ await this.mock.$_fulfillRedeem(epochId, 50n);
+
+ // Floor: floor(10 * 100 / 50) = 20
+ await expect(this.mock.$_convertToRedeemShares(epochId, 10n, 0n)).to.eventually.equal(20n);
+ // Ceil: ceil(11 * 100 / 50) = 22
+ await expect(this.mock.$_convertToRedeemShares(epochId, 11n, 1n)).to.eventually.equal(22n);
+ });
+
+ it('`_pendingAvailableRedeemRequest` masks drained epochs', async function () {
+ const tx = await this.mock.connect(user).requestRedeem(100n, user, user);
+ const epochId = await this.getRequestId(tx);
+
+ // Pending: totalShares > 0 -> returns requests[c]
+ await expect(this.mock.$_pendingAvailableRedeemRequest(epochId, user)).to.eventually.equal(100n);
+
+ // Unknown epoch: totalShares == 0 -> returns 0
+ await expect(this.mock.$_pendingAvailableRedeemRequest(999n, user)).to.eventually.equal(0n);
+ });
+
+ it('pending vs. claimable sentinel distinguishes states', async function () {
+ const tx = await this.mock.connect(user).requestRedeem(100n, user, user);
+ const epochId = await this.getRequestId(tx);
+
+ // Pending state
+ await expect(this.mock.pendingRedeemRequest(epochId, user)).to.eventually.equal(100n);
+ await expect(this.mock.claimableRedeemRequest(epochId, user)).to.eventually.equal(0n);
+
+ await advancePast(epochId);
+ await this.mock.$_fulfillRedeem(epochId, 42n);
+
+ // Claimable state
+ await expect(this.mock.pendingRedeemRequest(epochId, user)).to.eventually.equal(0n);
+ await expect(this.mock.claimableRedeemRequest(epochId, user)).to.eventually.equal(100n);
+
+ // Consume the full claim
+ await this.mock.connect(user).redeem(100n, user, user);
+
+ // Fully-claimed (drained): totalShares and totalAssets both reach 0
+ await expect(this.mock.totalRedeemShares(epochId)).to.eventually.equal(0n);
+ await expect(this.mock.totalRedeemAssets(epochId)).to.eventually.equal(0n);
+ await expect(this.mock.pendingRedeemRequest(epochId, user)).to.eventually.equal(0n);
+ await expect(this.mock.claimableRedeemRequest(epochId, user)).to.eventually.equal(0n);
+ await expect(this.mock.maxWithdraw(user)).to.eventually.equal(0n);
+ await expect(this.mock.maxRedeem(user)).to.eventually.equal(0n);
+ });
+ });
+
+ describe('_fulfillRedeem', function () {
+ let user;
+
+ beforeEach(async function () {
+ [, user] = await ethers.getSigners();
+ await this.token.$_mint(this.mock, 1000n);
+ await this.mock.$_mint(user, 1000n);
+ });
+
+ it('emits `ERC7540EpochRedeemFulfilled` with the locked rate', async function () {
+ const tx = await this.mock.connect(user).requestRedeem(100n, user, user);
+ const epochId = await this.getRequestId(tx);
+ await advancePast(epochId);
+
+ await expect(this.mock.$_fulfillRedeem(epochId, 42n))
+ .to.emit(this.mock, 'ERC7540EpochRedeemFulfilled')
+ .withArgs(epochId, 100n, 42n);
+ });
+
+ it('reverts `TooEarly` when the epoch is still the current one', async function () {
+ const tx = await this.mock.connect(user).requestRedeem(100n, user, user);
+ const epochId = await this.getRequestId(tx);
+
+ await expect(this.mock.$_fulfillRedeem(epochId, 42n))
+ .to.be.revertedWithCustomError(this.mock, 'ERC7540EpochRedeemTooEarly')
+ .withArgs(epochId);
+ });
+
+ it('reverts `EmptyEpoch` for an epoch with no requests', async function () {
+ const current = await this.mock.currentRedeemEpoch();
+ await advancePast(current);
+
+ await expect(this.mock.$_fulfillRedeem(current, 42n))
+ .to.be.revertedWithCustomError(this.mock, 'ERC7540EpochRedeemEmptyEpoch')
+ .withArgs(current);
+ });
+
+ it('reverts `AlreadyFulfilled` on a second non-zero fulfill', async function () {
+ const tx = await this.mock.connect(user).requestRedeem(100n, user, user);
+ const epochId = await this.getRequestId(tx);
+ await advancePast(epochId);
+ await this.mock.$_fulfillRedeem(epochId, 42n);
+
+ await expect(this.mock.$_fulfillRedeem(epochId, 50n))
+ .to.be.revertedWithCustomError(this.mock, 'ERC7540EpochRedeemAlreadyFulfilled')
+ .withArgs(epochId);
+ });
+
+ it('a 0-asset fulfillment is a no-op and can be recovered by re-fulfilling', async function () {
+ const tx = await this.mock.connect(user).requestRedeem(100n, user, user);
+ const epochId = await this.getRequestId(tx);
+ await advancePast(epochId);
+
+ await this.mock.$_fulfillRedeem(epochId, 0n);
+
+ // State is still "pending" — sentinel hasn't tripped
+ await expect(this.mock.totalRedeemAssets(epochId)).to.eventually.equal(0n);
+ await expect(this.mock.pendingRedeemRequest(epochId, user)).to.eventually.equal(100n);
+ await expect(this.mock.claimableRedeemRequest(epochId, user)).to.eventually.equal(0n);
+
+ await expect(this.mock.$_fulfillRedeem(epochId, 42n))
+ .to.emit(this.mock, 'ERC7540EpochRedeemFulfilled')
+ .withArgs(epochId, 100n, 42n);
+ await expect(this.mock.totalRedeemAssets(epochId)).to.eventually.equal(42n);
+ await expect(this.mock.claimableRedeemRequest(epochId, user)).to.eventually.equal(100n);
+ });
+ });
+
+ describe('multi-epoch flow', function () {
+ it('a controller can hold requests across multiple epochs and claim each', async function () {
+ const [, user] = await ethers.getSigners();
+ await this.token.$_mint(this.mock, 1000n);
+ await this.mock.$_mint(user, 1000n);
+
+ const txA = await this.mock.connect(user).requestRedeem(100n, user, user);
+ const epochA = await this.getRequestId(txA);
+
+ await time.increaseTo.timestamp(epochA * week);
+ const txB = await this.mock.connect(user).requestRedeem(50n, user, user);
+ const epochB = await this.getRequestId(txB);
+ expect(epochB).to.equal(epochA + 1n);
+
+ await advancePast(epochB);
+ await this.mock.$_fulfillRedeem(epochA, 200n); // 100 shares -> 200 assets
+ await this.mock.$_fulfillRedeem(epochB, 50n); // 50 shares -> 50 assets
+
+ await expect(this.mock.maxRedeem(user)).to.eventually.equal(150n);
+ await expect(this.mock.maxWithdraw(user)).to.eventually.equal(250n);
+
+ await this.mock.connect(user).withdraw(100n, user, user);
+ await expect(this.token.balanceOf(user)).to.eventually.equal(100n);
+
+ await this.mock.connect(user).withdraw(this.mock.maxWithdraw(user), user, user);
+ await expect(this.token.balanceOf(user)).to.eventually.equal(250n);
+ await expect(this.mock.maxWithdraw(user)).to.eventually.equal(0n);
+ });
+
+ it('multiple controllers in the same epoch share pro-rata', async function () {
+ const [, alice, bob, carol] = await ethers.getSigners();
+ await this.token.$_mint(this.mock, 1000n);
+ for (const u of [alice, bob, carol]) {
+ await this.mock.$_mint(u, 1000n);
+ }
+
+ const txA = await this.mock.connect(alice).requestRedeem(30n, alice, alice);
+ await this.mock.connect(bob).requestRedeem(40n, bob, bob);
+ await this.mock.connect(carol).requestRedeem(30n, carol, carol);
+ const epochId = await this.getRequestId(txA);
+
+ await expect(this.mock.totalRedeemShares(epochId)).to.eventually.equal(100n);
+
+ await advancePast(epochId);
+ await this.mock.$_fulfillRedeem(epochId, 200n); // 100 -> 200, exact rate 1:2
+
+ await expect(this.mock.maxWithdraw(alice)).to.eventually.equal(60n);
+ await expect(this.mock.maxWithdraw(bob)).to.eventually.equal(80n);
+ await expect(this.mock.maxWithdraw(carol)).to.eventually.equal(60n);
+
+ await this.mock.connect(alice).redeem(30n, alice, alice);
+ await this.mock.connect(bob).redeem(40n, bob, bob);
+ await this.mock.connect(carol).redeem(30n, carol, carol);
+
+ await expect(this.token.balanceOf(alice)).to.eventually.equal(60n);
+ await expect(this.token.balanceOf(bob)).to.eventually.equal(80n);
+ await expect(this.token.balanceOf(carol)).to.eventually.equal(60n);
+
+ await expect(this.mock.totalRedeemShares(epochId)).to.eventually.equal(0n);
+ await expect(this.mock.totalRedeemAssets(epochId)).to.eventually.equal(0n);
+ });
+ });
+
+ describe('redeemEpochs', function () {
+ let user;
+
+ beforeEach(async function () {
+ [, user] = await ethers.getSigners();
+ await this.token.$_mint(this.mock, 10000n);
+ await this.mock.$_mint(user, 10000n);
+ });
+
+ it('returns empty for a controller with no requests', async function () {
+ await expect(this.mock.redeemEpochs(user, 0, ethers.MaxUint256)).to.eventually.deep.equal([]);
+ await expect(this.mock.redeemEpochs(user, 0, 10)).to.eventually.deep.equal([]);
+ });
+
+ it('returns each epoch in queue order (oldest first)', async function () {
+ const e0 = await this.mock.currentRedeemEpoch();
+ await this.mock.connect(user).requestRedeem(100n, user, user);
+ await time.increaseTo.timestamp(e0 * week);
+ await this.mock.connect(user).requestRedeem(100n, user, user);
+ await time.increaseTo.timestamp((e0 + 1n) * week);
+ await this.mock.connect(user).requestRedeem(100n, user, user);
+
+ await expect(this.mock.redeemEpochs(user, 0, ethers.MaxUint256)).to.eventually.deep.equal([e0, e0 + 1n, e0 + 2n]);
+ });
+
+ it('collapses multiple requests in the same epoch into one entry', async function () {
+ const e0 = await this.mock.currentRedeemEpoch();
+ await this.mock.connect(user).requestRedeem(50n, user, user);
+ await this.mock.connect(user).requestRedeem(50n, user, user);
+
+ await expect(this.mock.redeemEpochs(user, 0, ethers.MaxUint256)).to.eventually.deep.equal([e0]);
+ });
+
+ it('pops fully-claimed epochs from the queue', async function () {
+ const e0 = await this.mock.currentRedeemEpoch();
+ await this.mock.connect(user).requestRedeem(100n, user, user);
+ await time.increaseTo.timestamp(e0 * week);
+ await this.mock.connect(user).requestRedeem(100n, user, user);
+
+ await advancePast(e0 + 1n);
+ await this.mock.$_fulfillRedeem(e0, 200n);
+ await this.mock.$_fulfillRedeem(e0 + 1n, 200n);
+
+ await expect(this.mock.redeemEpochs(user, 0, ethers.MaxUint256)).to.eventually.deep.equal([e0, e0 + 1n]);
+
+ // Claim the first epoch fully — _consumeClaimableRedeem pops it from the queue
+ await this.mock.connect(user).redeem(100n, user, user);
+ await expect(this.mock.redeemEpochs(user, 0, ethers.MaxUint256)).to.eventually.deep.equal([e0 + 1n]);
+ });
+
+ it('paginates with [start, end)', async function () {
+ const e0 = await this.mock.currentRedeemEpoch();
+ for (let i = 0; i < 4; i++) {
+ await this.mock.connect(user).requestRedeem(10n, user, user);
+ await time.increaseTo.timestamp((e0 + BigInt(i)) * week);
+ }
+ const all = [e0, e0 + 1n, e0 + 2n, e0 + 3n];
+
+ await expect(this.mock.redeemEpochs(user, 0, 4)).to.eventually.deep.equal(all);
+ await expect(this.mock.redeemEpochs(user, 1, 3)).to.eventually.deep.equal(all.slice(1, 3));
+ await expect(this.mock.redeemEpochs(user, 0, 1)).to.eventually.deep.equal(all.slice(0, 1));
+ });
+
+ it('clamps out-of-bound `start` and `end`', async function () {
+ const e0 = await this.mock.currentRedeemEpoch();
+ await this.mock.connect(user).requestRedeem(100n, user, user);
+
+ // end > length → clamped
+ await expect(this.mock.redeemEpochs(user, 0, ethers.MaxUint256)).to.eventually.deep.equal([e0]);
+ // start > length → empty after both clamps
+ await expect(this.mock.redeemEpochs(user, 10, ethers.MaxUint256)).to.eventually.deep.equal([]);
+ // start > end → empty
+ await expect(this.mock.redeemEpochs(user, 1, 0)).to.eventually.deep.equal([]);
+ });
+ });
+
+ describe('queue limit', function () {
+ it('enforces `_redeemRequestQueueLimit` per controller', async function () {
+ const [, user] = await ethers.getSigners();
+ await this.token.$_mint(this.mock, 10000n);
+ await this.mock.$_mint(user, 10000n);
+
+ let epoch = await this.mock.currentRedeemEpoch();
+ for (let i = 0; i < 32; i++) {
+ await this.mock.connect(user).requestRedeem(1n, user, user);
+ epoch = epoch + 1n;
+ await time.increaseTo.timestamp((epoch - 1n) * week);
+ }
+
+ await expect(this.mock.connect(user).requestRedeem(1n, user, user))
+ .to.be.revertedWithCustomError(this.mock, 'ERC7540EpochRedeemQueueLimitExceeded')
+ .withArgs(user);
+ });
+
+ it('zero-amount `requestRedeem` is a no-op at the epoch layer', async function () {
+ const [, user] = await ethers.getSigners();
+ await this.mock.$_mint(user, 1000n);
+
+ const epochId = await this.mock.currentRedeemEpoch();
+ await this.mock.connect(user).requestRedeem(0n, user, user);
+
+ await expect(this.mock.totalRedeemShares(epochId)).to.eventually.equal(0n);
+ await expect(this.mock.redeemEpochs(user, 0, ethers.MaxUint256)).to.eventually.deep.equal([]);
+ });
+
+ it('third-party attribution to a foreign controller reverts', async function () {
+ const [, attacker, victim] = await ethers.getSigners();
+ await this.mock.$_mint(attacker, 1000n);
+
+ await expect(this.mock.connect(attacker).requestRedeem(1n, victim, attacker))
+ .to.be.revertedWithCustomError(this.mock, 'ERC7540InvalidOperator')
+ .withArgs(victim, attacker);
+ });
+
+ it('multiple requests in the same epoch share one queue slot', async function () {
+ const [, user] = await ethers.getSigners();
+ await this.token.$_mint(this.mock, 1000n);
+ await this.mock.$_mint(user, 1000n);
+
+ for (let i = 0; i < 50; i++) {
+ await this.mock.connect(user).requestRedeem(1n, user, user);
+ }
+ const epochId = await this.mock.currentRedeemEpoch();
+ await expect(this.mock.totalRedeemShares(epochId)).to.eventually.equal(50n);
+ });
+ });
+
+ describe('out-of-order fulfillment', function () {
+ let user;
+
+ beforeEach(async function () {
+ [, user] = await ethers.getSigners();
+ await this.token.$_mint(this.mock, 1000n);
+ await this.mock.$_mint(user, 1000n);
+ this.epochA = await this.mock
+ .connect(user)
+ .requestRedeem(100n, user, user)
+ .then(tx => this.getRequestId(tx));
+ await time.increaseTo.timestamp(this.epochA * week);
+ this.epochB = await this.mock
+ .connect(user)
+ .requestRedeem(50n, user, user)
+ .then(tx => this.getRequestId(tx));
+ await advancePast(this.epochB);
+ });
+
+ it('maxWithdraw / maxRedeem hide a fulfilled epoch when an older one is still Pending', async function () {
+ await this.mock.$_fulfillRedeem(this.epochB, 100n); // out of order; B before A
+
+ await expect(this.mock.maxWithdraw(user)).to.eventually.equal(0n);
+ await expect(this.mock.maxRedeem(user)).to.eventually.equal(0n);
+
+ await expect(this.mock.pendingRedeemRequest(this.epochA, user)).to.eventually.equal(100n);
+ await expect(this.mock.claimableRedeemRequest(this.epochB, user)).to.eventually.equal(50n);
+ });
+
+ it('_consumeClaimableWithdraw is a no-op when the oldest epoch is Pending', async function () {
+ await this.mock.$_fulfillRedeem(this.epochB, 100n);
+
+ await this.mock.$_consumeClaimableWithdraw(999n, user);
+
+ await expect(this.mock.totalRedeemShares(this.epochA)).to.eventually.equal(100n);
+ await expect(this.mock.totalRedeemShares(this.epochB)).to.eventually.equal(50n);
+ await expect(this.mock.totalRedeemAssets(this.epochB)).to.eventually.equal(100n);
+ await expect(this.mock.$_pendingAvailableRedeemRequest(this.epochA, user)).to.eventually.equal(100n);
+ await expect(this.mock.redeemEpochs(user, 0, ethers.MaxUint256)).to.eventually.deep.equal([
+ this.epochA,
+ this.epochB,
+ ]);
+ });
+
+ it('_consumeClaimableRedeem is a no-op when the oldest epoch is Pending', async function () {
+ await this.mock.$_fulfillRedeem(this.epochB, 100n);
+
+ await this.mock.$_consumeClaimableRedeem(999n, user);
+
+ await expect(this.mock.totalRedeemShares(this.epochA)).to.eventually.equal(100n);
+ await expect(this.mock.totalRedeemShares(this.epochB)).to.eventually.equal(50n);
+ await expect(this.mock.totalRedeemAssets(this.epochB)).to.eventually.equal(100n);
+ await expect(this.mock.$_pendingAvailableRedeemRequest(this.epochA, user)).to.eventually.equal(100n);
+ await expect(this.mock.redeemEpochs(user, 0, ethers.MaxUint256)).to.eventually.deep.equal([
+ this.epochA,
+ this.epochB,
+ ]);
+ });
+
+ it('claims unlock automatically once the older epoch is fulfilled', async function () {
+ await this.mock.$_fulfillRedeem(this.epochB, 100n); // B first
+ await this.mock.$_fulfillRedeem(this.epochA, 200n); // then A
+
+ await expect(this.mock.maxWithdraw(user)).to.eventually.equal(300n);
+ await expect(this.mock.maxRedeem(user)).to.eventually.equal(150n);
+
+ await this.mock.connect(user).redeem(150n, user, user);
+ await expect(this.token.balanceOf(user)).to.eventually.equal(300n);
+ await expect(this.mock.redeemEpochs(user, 0, ethers.MaxUint256)).to.eventually.deep.equal([]);
+ });
+ });
+
+ describe('edge cases', function () {
+ it('a 1-asset fulfillment makes withdraw(0) a no-op (per-claim rounding edge)', async function () {
+ const [, user] = await ethers.getSigners();
+ await this.token.$_mint(this.mock, 1000n);
+ await this.mock.$_mint(user, 1000n);
+
+ const tx = await this.mock.connect(user).requestRedeem(100n, user, user);
+ const epochId = await this.getRequestId(tx);
+ await advancePast(epochId);
+ await this.mock.$_fulfillRedeem(epochId, 1n); // 100 shares -> 1 asset
+
+ const before = await this.token.balanceOf(user);
+ await this.mock.connect(user).withdraw(0n, user, user);
+ await expect(this.token.balanceOf(user)).to.eventually.equal(before);
+
+ // Share-driven `redeem` is the path that can absorb the floor-rounding dust
+ await this.mock.connect(user).redeem(100n, user, user);
+ await expect(this.token.balanceOf(user)).to.eventually.equal(1n);
+ });
+
+ it('asset-driven claims cannot consume more shares than the controller was entitled to', async function () {
+ // Pathological tiny-totals scenario. Without the `batchShares <= requestedShares` cap on
+ // the asset-driven path, Alice's Case A would floor-convert 2 assets to 3 shares (overshoot
+ // by 1) and drain part of Bob's share pool. With the cap in place Alice consumes exactly
+ // her entitlement (2 shares); the ceil/floor gap stays in the epoch as unclaimable dust.
+ //
+ // Setup: r_alice=2, r_bob=3, totalShares=5. Fulfill totalAssets=3.
+ // Alice: requested=ceil(2*3/5)=2, batchShares=min(floor(2*5/3), 2)=2.
+ // State: r_alice 2->0, totalShares 5->3, totalAssets 3->1.
+ // Bob: requested=ceil(3*1/3)=1, batchAssets=min(1,1)=1, batchShares=min(floor(1*3/1), 3)=3.
+ // State: r_bob 3->0, totalShares 3->0, totalAssets 1->0.
+ const [, alice, bob] = await ethers.getSigners();
+ await this.token.$_mint(this.mock, 1000n);
+ for (const u of [alice, bob]) {
+ await this.mock.$_mint(u, 1000n);
+ }
+
+ const tx = await this.mock.connect(alice).requestRedeem(2n, alice, alice);
+ await this.mock.connect(bob).requestRedeem(3n, bob, bob);
+ const epochId = await this.getRequestId(tx);
+ await advancePast(epochId);
+ await this.mock.$_fulfillRedeem(epochId, 3n);
+
+ // Alice's claim is capped at her request; overshoot stays in the pool.
+ await this.mock.$_consumeClaimableWithdraw(2n, alice);
+ await expect(this.mock.totalRedeemShares(epochId)).to.eventually.equal(3n);
+ await expect(this.mock.totalRedeemAssets(epochId)).to.eventually.equal(1n);
+
+ // Bob's claim drains the remainder cleanly (no requests-slot dust).
+ await this.mock.$_consumeClaimableWithdraw(1n, bob);
+ await expect(this.mock.totalRedeemShares(epochId)).to.eventually.equal(0n);
+ await expect(this.mock.totalRedeemAssets(epochId)).to.eventually.equal(0n);
+
+ // No dust visible from any public view.
+ await expect(this.mock.pendingRedeemRequest(epochId, bob)).to.eventually.equal(0n);
+ await expect(this.mock.claimableRedeemRequest(epochId, bob)).to.eventually.equal(0n);
+ await expect(this.mock.maxWithdraw(bob)).to.eventually.equal(0n);
+ await expect(this.mock.maxRedeem(bob)).to.eventually.equal(0n);
+ });
+
+ it('a fully-drained epoch keeps the {_fulfillRedeem} sentinel intact', async function () {
+ const [, user] = await ethers.getSigners();
+ await this.token.$_mint(this.mock, 1000n);
+ await this.mock.$_mint(user, 1000n);
+
+ const tx = await this.mock.connect(user).requestRedeem(100n, user, user);
+ const epochId = await this.getRequestId(tx);
+ await advancePast(epochId);
+ await this.mock.$_fulfillRedeem(epochId, 42n);
+ await this.mock.connect(user).redeem(100n, user, user);
+
+ await expect(this.mock.totalRedeemShares(epochId)).to.eventually.equal(0n);
+ await expect(this.mock.totalRedeemAssets(epochId)).to.eventually.equal(0n);
+
+ await expect(this.mock.$_fulfillRedeem(epochId, 50n))
+ .to.be.revertedWithCustomError(this.mock, 'ERC7540EpochRedeemEmptyEpoch')
+ .withArgs(epochId);
+ });
+ });
+});