From 5aba1a268064763d5047d824f23b1e910a17f5a9 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Wed, 8 Apr 2026 15:07:26 +0200 Subject: [PATCH 01/60] Update dependencies to v5.6.1 --- lib/@openzeppelin-contracts | 2 +- lib/@openzeppelin-contracts-upgradeable | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/@openzeppelin-contracts b/lib/@openzeppelin-contracts index dd965b40..5fd1781b 160000 --- a/lib/@openzeppelin-contracts +++ b/lib/@openzeppelin-contracts @@ -1 +1 @@ -Subproject commit dd965b40d264ba7347942b0af00a2e7757feaee0 +Subproject commit 5fd1781b1454fd1ef8e722282f86f9293cacf256 diff --git a/lib/@openzeppelin-contracts-upgradeable b/lib/@openzeppelin-contracts-upgradeable index 6139c67f..7bf4727a 160000 --- a/lib/@openzeppelin-contracts-upgradeable +++ b/lib/@openzeppelin-contracts-upgradeable @@ -1 +1 @@ -Subproject commit 6139c67f1853cfc0fc6ce8cba9de0223b6f6e63a +Subproject commit 7bf4727aacdbfaa0f36cbd664654d0c9e1dc52bf From 15a1f9e1aac575c1c92e16488db0f8e3999f01e7 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Wed, 8 Apr 2026 15:08:54 +0200 Subject: [PATCH 02/60] add initial implementation --- contracts/interfaces/IERC7540.sol | 158 ++++++ contracts/token/ERC20/extensions/ERC7540.sol | 500 ++++++++++++++++++ .../extensions/ERC7540AdminFulfillDeposit.sol | 67 +++ .../extensions/ERC7540AdminFulfillRedeem.sol | 66 +++ .../ERC20/extensions/ERC7540DelayDeposit.sol | 82 +++ .../ERC20/extensions/ERC7540DelayRedeem.sol | 87 +++ 6 files changed, 960 insertions(+) create mode 100644 contracts/interfaces/IERC7540.sol create mode 100644 contracts/token/ERC20/extensions/ERC7540.sol create mode 100644 contracts/token/ERC20/extensions/ERC7540AdminFulfillDeposit.sol create mode 100644 contracts/token/ERC20/extensions/ERC7540AdminFulfillRedeem.sol create mode 100644 contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol create mode 100644 contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol diff --git a/contracts/interfaces/IERC7540.sol b/contracts/interfaces/IERC7540.sol new file mode 100644 index 00000000..d5b6b5eb --- /dev/null +++ b/contracts/interfaces/IERC7540.sol @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: MIT + +pragma solidity >=0.8.4; + +/** + * @dev Interface for operator management in https://eips.ethereum.org/EIPS/eip-7540[ERC-7540] + * asynchronous vaults. Operators can manage deposit and redeem requests on behalf of a controller. + */ +interface IERC7540Operator { + /// @dev Emitted when `controller` sets the `approved` status for an `operator`. + event OperatorSet(address indexed controller, address indexed operator, bool approved); + + /** + * @dev Grants or revokes permissions for `operator` to manage requests on behalf of the caller. + * + * - MUST set the operator status to the `approved` value. + * - MUST emit the {OperatorSet} event when the operator status is set. + * - MUST return true. + */ + function setOperator(address operator, bool approved) external returns (bool); + + /// @dev Returns `true` if the `operator` is approved as an operator for a `controller`. + function isOperator(address controller, address operator) external view returns (bool status); +} + +/** + * @dev Interface for asynchronous deposit requests in https://eips.ethereum.org/EIPS/eip-7540[ERC-7540] + * vaults. Enables users to request deposits that transition through Pending and Claimable states + * before being claimed via the standard ERC-4626 deposit/mint functions. + */ +interface IERC7540Deposit { + /** + * @dev Emitted when `owner` has locked `assets` in the Vault to request a deposit. + * `controller` controls this request. `sender` is the caller of `requestDeposit`. + */ + event DepositRequest( + address indexed controller, + address indexed owner, + uint256 indexed requestId, + address sender, + uint256 assets + ); + + /** + * @dev Transfers `assets` from `owner` into the Vault and submits a request for asynchronous deposit. + * + * - MUST support ERC-20 approve / transferFrom on `asset` as a deposit request flow. + * - `owner` MUST equal `msg.sender` unless the `owner` has approved the `msg.sender` as an operator. + * - MUST revert if all of `assets` cannot be requested for deposit. + * - MUST emit the {DepositRequest} event. + * + * NOTE: Most implementations will require `owner` to have approved the Vault to spend at least `assets` of + * the underlying asset token (e.g. via `asset.approve(vault, assets)`) before calling this function. + */ + function requestDeposit(uint256 assets, address controller, address owner) external returns (uint256 requestId); + + /** + * @dev Returns the amount of requested assets in Pending state. + * + * - MUST NOT include any assets in Claimable state for deposit or mint. + * - MUST NOT show any variations depending on the caller. + * - MUST NOT revert unless due to integer overflow caused by an unreasonably large input. + */ + function pendingDepositRequest(uint256 requestId, address controller) external view returns (uint256 pendingAssets); + + /** + * @dev Returns the amount of requested assets in Claimable state for the controller to deposit or mint. + * + * - MUST NOT include any assets in Pending state. + * - MUST NOT show any variations depending on the caller. + * - MUST NOT revert unless due to integer overflow caused by an unreasonably large input. + */ + function claimableDepositRequest( + uint256 requestId, + address controller + ) external view returns (uint256 claimableAssets); + + /** + * @dev Claims a pending deposit request by minting shares to receiver. + * Decreases `claimableDepositRequest` for the controller and transfers shares to receiver. + * + * - MUST emit the Deposit event. + * - MUST revert if controller does not have sufficient claimable assets. + * - controller MUST equal msg.sender unless the controller has approved the msg.sender as an operator. + */ + function deposit(uint256 assets, address receiver, address controller) external returns (uint256 shares); + + /** + * @dev Claims a pending deposit request by minting exactly shares to receiver. + * Decreases `claimableDepositRequest` for the controller and transfers shares to receiver. + * + * - MUST emit the Deposit event. + * - MUST revert if controller does not have sufficient claimable assets. + * - controller MUST equal msg.sender unless the controller has approved the msg.sender as an operator. + */ + function mint(uint256 shares, address receiver, address controller) external returns (uint256 assets); +} + +/** + * @dev Interface for asynchronous redeem requests in https://eips.ethereum.org/EIPS/eip-7540[ERC-7540] + * vaults. Enables users to request redemptions that transition through Pending and Claimable states + * before being claimed via the standard ERC-4626 redeem/withdraw functions. + */ +interface IERC7540Redeem { + /** + * @dev Emitted when `sender` has locked `shares`, owned by `owner`, in the Vault to request a redemption. + * `controller` controls this request. + */ + event RedeemRequest( + address indexed controller, + address indexed owner, + uint256 indexed requestId, + address sender, + uint256 shares + ); + + /** + * @dev Assumes control of shares from owner into the Vault and submits a request for asynchronous redeem. + * + * Authorization for a `msg.sender` not equal to `owner` may come either from ERC-20 approval over + * the shares of `owner` or from an operator approval via {IERC7540Operator-setOperator}. Operators + * are not subject to allowance restrictions, while non-infinite ERC-20 approvals are consumed. + * + * - MUST revert if all of shares cannot be requested for redeem. + * - MUST emit the {RedeemRequest} event. + * + * NOTE: Most implementations will require `owner` to have approved the Vault to spend at least `shares` of + * the Vault's share token (e.g. via `share.approve(vault, shares)`) before calling this function. + */ + function requestRedeem(uint256 shares, address controller, address owner) external returns (uint256 requestId); + + /** + * @dev Returns the amount of requested shares in Pending state. + * + * - MUST NOT include any shares in Claimable state for redeem or withdraw. + * - MUST NOT show any variations depending on the caller. + * - MUST NOT revert unless due to integer overflow caused by an unreasonably large input. + */ + function pendingRedeemRequest(uint256 requestId, address controller) external view returns (uint256 pendingShares); + + /** + * @dev Returns the amount of requested shares in Claimable state for the controller to redeem or withdraw. + * + * - MUST NOT include any shares in Pending state for redeem or withdraw. + * - MUST NOT show any variations depending on the caller. + * - MUST NOT revert unless due to integer overflow caused by an unreasonably large input. + */ + function claimableRedeemRequest( + uint256 requestId, + address controller + ) external view returns (uint256 claimableShares); +} + +/** + * @dev Interface of the fully asynchronous Vault interface of ERC7540, as defined in + * https://eips.ethereum.org/EIPS/eip-7540 + */ +interface IERC7540 is IERC7540Operator, IERC7540Deposit, IERC7540Redeem {} diff --git a/contracts/token/ERC20/extensions/ERC7540.sol b/contracts/token/ERC20/extensions/ERC7540.sol new file mode 100644 index 00000000..6c02c03a --- /dev/null +++ b/contracts/token/ERC20/extensions/ERC7540.sol @@ -0,0 +1,500 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {IERC20, IERC20Metadata, ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import {IERC165, ERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {LowLevelCall} from "@openzeppelin/contracts/utils/LowLevelCall.sol"; +import {Memory} from "@openzeppelin/contracts/utils/Memory.sol"; + +import {IERC7540, IERC7540Operator, IERC7540Deposit, IERC7540Redeem} from "../../../interfaces/IERC7540.sol"; + +abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { + using Math for uint256; + + IERC20 private immutable _asset; + uint8 private immutable _underlyingDecimals; + + mapping(address owner => mapping(address controller => bool)) private _isOperator; + uint256 private _totalPendingDepositAssets; + uint256 private _totalPendingRedeemShares; + + /** + * @dev Attempted to deposit more assets than the max amount for `receiver`. + */ + error ERC4626ExceededMaxDeposit(address receiver, uint256 assets, uint256 max); + + /** + * @dev Attempted to mint more shares than the max amount for `receiver`. + */ + error ERC4626ExceededMaxMint(address receiver, uint256 shares, uint256 max); + + /** + * @dev Attempted to withdraw more assets than the max amount for `owner`. + */ + error ERC4626ExceededMaxWithdraw(address owner, uint256 assets, uint256 max); + + /** + * @dev Attempted to redeem more shares than the max amount for `owner`. + */ + error ERC4626ExceededMaxRedeem(address owner, uint256 shares, uint256 max); + + /// @dev The operator is not the caller or an operator of the controller + error ERC7540InvalidOperator(address controller, address operator); + + error ERC7540DepositIsSync(); + error ERC7540DepositIsAsync(); + error ERC7540RedeemIsSync(); + error ERC7540RedeemIsAsync(); + error NotImplemented(); + + /** + * @dev Set the underlying asset contract. This must be an ERC20-compatible contract (ERC-20 or ERC-777). + */ + constructor(IERC20 asset_) { + require(_isDepositAsync() || _isRedeemAsync(), "ERC7540: async deposit or redeem required"); + (bool success, uint8 assetDecimals) = _tryGetAssetDecimals(asset_); + _underlyingDecimals = success ? assetDecimals : 18; + _asset = asset_; + } + + function _isDepositAsync() internal pure virtual returns (bool) { + return false; + } + function _isRedeemAsync() internal pure virtual returns (bool) { + return false; + } + + /** + * @dev Attempts to fetch the asset decimals. A return value of false indicates that the attempt failed in some way. + */ + function _tryGetAssetDecimals(IERC20 asset_) private view returns (bool ok, uint8 assetDecimals) { + Memory.Pointer ptr = Memory.getFreeMemoryPointer(); + (bool success, bytes32 returnedDecimals, ) = LowLevelCall.staticcallReturn64Bytes( + address(asset_), + abi.encodeCall(IERC20Metadata.decimals, ()) + ); + Memory.unsafeSetFreeMemoryPointer(ptr); + + return + (success && LowLevelCall.returnDataSize() >= 32 && uint256(returnedDecimals) <= type(uint8).max) + ? (true, uint8(uint256(returnedDecimals))) + : (false, 0); + } + + /// @inheritdoc IERC165 + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return + interfaceId == type(IERC7540Operator).interfaceId || + (interfaceId == type(IERC7540Deposit).interfaceId && _isDepositAsync()) || + (interfaceId == type(IERC7540Redeem).interfaceId && _isRedeemAsync()) || + super.supportsInterface(interfaceId); + } + + /// @dev See {_checkOperatorOrController}. + modifier onlyOperatorOrController(bool async, address controller, address operator) { + _checkOperatorOrController(async, controller, operator); + _; + } + + /// @inheritdoc IERC7540Operator + function isOperator(address controller, address operator) public view returns (bool status) { + return _isOperator[controller][operator]; + } + + /// @inheritdoc IERC7540Operator + function setOperator(address operator, bool approved) public returns (bool) { + _setOperator(_msgSender(), operator, approved); + return true; + } + + /** + * @dev Set the `operator` status for the `controller` to the `approved` value + * + * Emits an {OperatorSet} event if the approval status changes. + */ + function _setOperator(address controller, address operator, bool approved) internal { + _isOperator[controller][operator] = approved; + emit OperatorSet(controller, operator, approved); + } + + /// @dev Reverts if the `operator` is not the caller or an operator of the `controller` + function _checkOperatorOrController(bool async, address controller, address operator) internal view virtual { + require( + !async || controller == operator || isOperator(controller, operator), + ERC7540InvalidOperator(controller, operator) + ); + } + + /** + * @dev Decimals are computed by adding the decimal offset on top of the underlying asset's decimals. This + * "original" value is cached during construction of the vault contract. If this read operation fails (e.g., the + * asset has not been created yet), a default of 18 is used to represent the underlying asset's decimals. + * + * See {IERC20Metadata-decimals}. + */ + function decimals() public view virtual override(IERC20Metadata, ERC20) returns (uint8) { + return _underlyingDecimals + _decimalsOffset(); + } + + /// @inheritdoc IERC4626 + function asset() public view virtual returns (address) { + return address(_asset); + } + + /// @inheritdoc IERC4626 + function totalAssets() public view virtual override returns (uint256) { + return IERC20(asset()).balanceOf(address(this)) - totalPendingDepositAssets(); + } + + /// @inheritdoc IERC20 + function totalSupply() public view virtual override(IERC20, ERC20) returns (uint256) { + return super.totalSupply() + totalPendingRedeemShares(); + } + + /// @inheritdoc IERC7540Deposit + function pendingDepositRequest(uint256 requestId, address controller) public view returns (uint256) { + require(_isDepositAsync(), "ERC7540: deposit must be async"); + return _pendingDepositRequest(requestId, controller); + } + + /// @inheritdoc IERC7540Deposit + function claimableDepositRequest(uint256 requestId, address controller) public view returns (uint256) { + require(_isDepositAsync(), "ERC7540: deposit must be async"); + return _claimableDepositRequest(requestId, controller); + } + + /// @inheritdoc IERC7540Redeem + function pendingRedeemRequest(uint256 requestId, address controller) public view returns (uint256) { + require(_isRedeemAsync(), "ERC7540: redeem must be async"); + return _pendingRedeemRequest(requestId, controller); + } + + /// @inheritdoc IERC7540Redeem + function claimableRedeemRequest(uint256 requestId, address controller) public view returns (uint256) { + require(_isRedeemAsync(), "ERC7540: redeem must be async"); + return _claimableRedeemRequest(requestId, controller); + } + + function totalPendingDepositAssets() public view virtual returns (uint256) { + return _totalPendingDepositAssets; + } + + function totalPendingRedeemShares() public view virtual returns (uint256) { + return _totalPendingRedeemShares; + } + + /// @inheritdoc IERC4626 + function convertToShares(uint256 assets) public view virtual returns (uint256) { + return _convertToShares(assets, Math.Rounding.Floor); + } + + /// @inheritdoc IERC4626 + function convertToAssets(uint256 shares) public view virtual returns (uint256) { + return _convertToAssets(shares, Math.Rounding.Floor); + } + + /// @inheritdoc IERC4626 + function maxDeposit(address owner) public view virtual returns (uint256) { + return _isDepositAsync() ? _asyncMaxDeposit(owner) : type(uint256).max; + } + + /// @inheritdoc IERC4626 + function maxMint(address owner) public view virtual returns (uint256) { + return _isDepositAsync() ? _asyncMaxMint(owner) : type(uint256).max; + } + + /// @inheritdoc IERC4626 + function maxWithdraw(address owner) public view virtual returns (uint256) { + return _isRedeemAsync() ? _asyncMaxWithdraw(owner) : previewRedeem(maxRedeem(owner)); + } + + /// @inheritdoc IERC4626 + function maxRedeem(address owner) public view virtual returns (uint256) { + return _isRedeemAsync() ? _asyncMaxRedeem(owner) : balanceOf(owner); + } + + /// @inheritdoc IERC4626 + function previewDeposit(uint256 assets) public view virtual returns (uint256) { + require(!_isDepositAsync(), ERC7540DepositIsAsync()); + return _convertToShares(assets, Math.Rounding.Floor); + } + + /// @inheritdoc IERC4626 + function previewMint(uint256 shares) public view virtual returns (uint256) { + require(!_isDepositAsync(), ERC7540DepositIsAsync()); + return _convertToAssets(shares, Math.Rounding.Ceil); + } + + /// @inheritdoc IERC4626 + function previewWithdraw(uint256 assets) public view virtual returns (uint256) { + require(!_isRedeemAsync(), ERC7540RedeemIsAsync()); + return _convertToShares(assets, Math.Rounding.Ceil); + } + + /// @inheritdoc IERC4626 + function previewRedeem(uint256 shares) public view virtual returns (uint256) { + require(!_isRedeemAsync(), ERC7540RedeemIsAsync()); + return _convertToAssets(shares, Math.Rounding.Floor); + } + + function requestDeposit( + uint256 assets, + address controller, + address owner + ) public virtual onlyOperatorOrController(_isDepositAsync(), owner, _msgSender()) returns (uint256) { + require(_isDepositAsync(), ERC7540DepositIsSync()); + + uint256 requestId = _requestDeposit(assets, controller, owner); + + // Must revert with ERC20InsufficientBalance or equivalent error if there's not enough balance. + _transferIn(owner, assets); + + emit DepositRequest(controller, owner, requestId, _msgSender(), assets); + return requestId; + } + + /// @inheritdoc IERC4626 + function deposit(uint256 assets, address receiver) public virtual override returns (uint256) { + return deposit(assets, receiver, _msgSender()); + } + + /// @inheritdoc IERC7540Deposit + function deposit( + uint256 assets, + address receiver, + address controller + ) public virtual onlyOperatorOrController(_isDepositAsync(), controller, _msgSender()) returns (uint256) { + // Note: if _isDepositAsync is false, controller is ignored. + uint256 maxAssets = maxDeposit(_isDepositAsync() ? controller : receiver); + if (assets > maxAssets) { + revert ERC4626ExceededMaxDeposit(_isDepositAsync() ? controller : receiver, assets, maxAssets); + } + + uint256 shares = _isDepositAsync() + ? Math.mulDiv(assets, maxMint(controller), maxDeposit(controller), Math.Rounding.Floor) + : previewDeposit(assets); + + _deposit(_msgSender(), receiver, assets, shares); + + return shares; + } + + /// @inheritdoc IERC4626 + function mint(uint256 shares, address receiver) public virtual override returns (uint256 assets) { + return mint(shares, receiver, _msgSender()); + } + + /// @inheritdoc IERC7540Deposit + function mint( + uint256 shares, + address receiver, + address controller + ) public virtual onlyOperatorOrController(_isDepositAsync(), controller, _msgSender()) returns (uint256) { + // Note: if _isDepositAsync is false, controller is ignored. + uint256 maxShares = maxMint(_isDepositAsync() ? _msgSender() : receiver); + if (shares > maxShares) { + revert ERC4626ExceededMaxMint(_isDepositAsync() ? _msgSender() : receiver, shares, maxShares); + } + + uint256 assets = _isDepositAsync() + ? Math.mulDiv(shares, maxDeposit(controller), maxMint(controller), Math.Rounding.Ceil) + : previewMint(shares); + + _deposit(_msgSender(), receiver, assets, shares); + + return assets; + } + + function requestRedeem(uint256 shares, address controller, address owner) public virtual returns (uint256) { + require(_isRedeemAsync(), ERC7540RedeemIsSync()); + + address sender = _msgSender(); + if (owner != sender && !isOperator(owner, sender)) { + _spendAllowance(owner, sender, shares); + } + _burn(owner, shares); + + uint256 requestId = _requestRedeem(shares, controller, owner); + + emit RedeemRequest(controller, owner, requestId, _msgSender(), shares); + return requestId; + } + + /// @inheritdoc IERC4626 + function withdraw( + uint256 assets, + address receiver, + address ownerOrController + ) public virtual onlyOperatorOrController(_isRedeemAsync(), ownerOrController, _msgSender()) returns (uint256) { + uint256 maxAssets = maxWithdraw(ownerOrController); + if (assets > maxAssets) { + revert ERC4626ExceededMaxWithdraw(ownerOrController, assets, maxAssets); + } + + uint256 shares = _isRedeemAsync() + ? Math.mulDiv(assets, maxRedeem(ownerOrController), maxWithdraw(ownerOrController), Math.Rounding.Ceil) + : previewWithdraw(assets); + + _withdraw(_msgSender(), receiver, ownerOrController, assets, shares); + + return shares; + } + + /// @inheritdoc IERC4626 + function redeem( + uint256 shares, + address receiver, + address ownerOrController + ) public virtual onlyOperatorOrController(_isRedeemAsync(), ownerOrController, _msgSender()) returns (uint256) { + uint256 maxShares = maxRedeem(ownerOrController); + if (shares > maxShares) { + revert ERC4626ExceededMaxRedeem(ownerOrController, shares, maxShares); + } + + uint256 assets = _isRedeemAsync() + ? Math.mulDiv(shares, maxWithdraw(ownerOrController), maxRedeem(ownerOrController), Math.Rounding.Floor) + : previewRedeem(shares); + + _withdraw(_msgSender(), receiver, ownerOrController, assets, shares); + + return assets; + } + + /** + * @dev Internal conversion function (from assets to shares) with support for rounding direction. + */ + function _convertToShares(uint256 assets, Math.Rounding rounding) internal view virtual returns (uint256) { + return assets.mulDiv(totalSupply() + 10 ** _decimalsOffset(), totalAssets() + 1, rounding); + } + + /** + * @dev Internal conversion function (from shares to assets) with support for rounding direction. + */ + function _convertToAssets(uint256 shares, Math.Rounding rounding) internal view virtual returns (uint256) { + return shares.mulDiv(totalAssets() + 1, totalSupply() + 10 ** _decimalsOffset(), rounding); + } + + function _requestDeposit( + uint256 assets, + address /*controller*/, + address /*owner*/ + ) internal virtual returns (uint256) { + _totalPendingDepositAssets += assets; + return 0; + } + + /** + * @dev Deposit/mint common workflow. + */ + function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal virtual { + // If asset() is ERC-777, `transferFrom` can trigger a reentrancy BEFORE the transfer happens through the + // `tokensToSend` hook. On the other hand, the `tokenReceived` hook, that is triggered after the transfer, + // calls the vault, which is assumed not malicious. + // + // Conclusion: we need to do the transfer before we mint so that any reentrancy would happen before the + // assets are transferred and before the shares are minted, which is a valid state. + if (!_isDepositAsync()) { + // slither-disable-next-line reentrancy-no-eth + _transferIn(caller, assets); + } else { + _totalPendingDepositAssets -= assets; + } + _mint(receiver, shares); + + emit Deposit(caller, receiver, assets, shares); + } + + function _requestRedeem( + uint256 shares, + address /*controller*/, + address /*owner*/ + ) internal virtual returns (uint256) { + _totalPendingRedeemShares += shares; + return 0; + } + + /** + * @dev Withdraw/redeem common workflow. + */ + function _withdraw( + address caller, + address receiver, + address owner, + uint256 assets, + uint256 shares + ) internal virtual { + if (!_isRedeemAsync() && caller != owner) { + _spendAllowance(owner, caller, shares); + } + + // If asset() is ERC-777, `transfer` can trigger a reentrancy AFTER the transfer happens through the + // `tokensReceived` hook. On the other hand, the `tokensToSend` hook, that is triggered before the transfer, + // calls the vault, which is assumed not malicious. + // + // Conclusion: we need to do the transfer after the burn so that any reentrancy would happen after the + // shares are burned and after the assets are transferred, which is a valid state. + if (!_isRedeemAsync()) { + _burn(owner, shares); + } else { + _totalPendingRedeemShares -= shares; + } + _transferOut(receiver, assets); + + emit Withdraw(caller, receiver, owner, assets, shares); + } + + /// @dev Performs a transfer in of underlying assets. The default implementation uses `SafeERC20`. Used by {_deposit}. + function _transferIn(address from, uint256 assets) internal virtual { + SafeERC20.safeTransferFrom(IERC20(asset()), from, address(this), assets); + } + + /// @dev Performs a transfer out of underlying assets. The default implementation uses `SafeERC20`. Used by {_withdraw}. + function _transferOut(address to, uint256 assets) internal virtual { + SafeERC20.safeTransfer(IERC20(asset()), to, assets); + } + + function _decimalsOffset() internal view virtual returns (uint8) { + return 0; + } + + function _pendingDepositRequest( + uint256 /*requestId*/, + address /*controller*/ + ) internal view virtual returns (uint256) { + revert NotImplemented(); + } + function _claimableDepositRequest( + uint256 /*requestId*/, + address /*controller*/ + ) internal view virtual returns (uint256) { + revert NotImplemented(); + } + function _pendingRedeemRequest( + uint256 /*requestId*/, + address /*controller*/ + ) internal view virtual returns (uint256) { + revert NotImplemented(); + } + function _claimableRedeemRequest( + uint256 /*requestId*/, + address /*controller*/ + ) internal view virtual returns (uint256) { + revert NotImplemented(); + } + function _asyncMaxDeposit(address /*owner*/) internal view virtual returns (uint256) { + revert NotImplemented(); + } + function _asyncMaxMint(address /*owner*/) internal view virtual returns (uint256) { + revert NotImplemented(); + } + function _asyncMaxWithdraw(address /*owner*/) internal view virtual returns (uint256) { + revert NotImplemented(); + } + function _asyncMaxRedeem(address /*owner*/) internal view virtual returns (uint256) { + revert NotImplemented(); + } +} diff --git a/contracts/token/ERC20/extensions/ERC7540AdminFulfillDeposit.sol b/contracts/token/ERC20/extensions/ERC7540AdminFulfillDeposit.sol new file mode 100644 index 00000000..843201fa --- /dev/null +++ b/contracts/token/ERC20/extensions/ERC7540AdminFulfillDeposit.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {ERC7540} from "./ERC7540.sol"; + +abstract contract ERC7540AdminFulfillDeposit is ERC7540 { + struct PendingDeposit { + uint256 pendingAssets; + uint256 claimableAssets; + uint256 claimableShares; + } + + mapping(address controller => PendingDeposit) private _deposits; + + /// @dev Emitted when a deposit request transitions from Pending to Claimable. + event DepositClaimable(address indexed controller, uint256 indexed requestId, uint256 assets, uint256 shares); + + /// @dev The amount of assets requested is greater than the amount of assets pending. + error ERC7540DepositInsufficientPendingAssets(uint256 assets, uint256 pendingAssets); + + function _isDepositAsync() internal pure virtual override returns (bool) { + return true; + } + + function _requestDeposit( + uint256 assets, + address controller, + address owner + ) internal virtual override returns (uint256) { + _deposits[controller].pendingAssets += assets; + return super._requestDeposit(assets, controller, owner); + } + + function _fulfillDeposit(uint256 assets, uint256 shares, address controller) internal virtual { + uint256 pendingAssets = pendingDepositRequest(0, controller); + require(assets <= pendingAssets, ERC7540DepositInsufficientPendingAssets(assets, pendingAssets)); + + _deposits[controller].pendingAssets -= assets; + _deposits[controller].claimableAssets += assets; + _deposits[controller].claimableShares += shares; + + emit DepositClaimable(controller, 0, assets, shares); + } + + function _pendingDepositRequest( + uint256 /*requestId*/, + address controller + ) internal view virtual override returns (uint256) { + return _deposits[controller].pendingAssets; + } + + function _claimableDepositRequest( + uint256 /*requestId*/, + address controller + ) internal view virtual override returns (uint256) { + return _deposits[controller].claimableAssets; + } + + function _asyncMaxDeposit(address owner) internal view virtual override returns (uint256) { + return _deposits[owner].claimableAssets; + } + + function _asyncMaxMint(address owner) internal view virtual override returns (uint256) { + return _deposits[owner].claimableShares; + } +} diff --git a/contracts/token/ERC20/extensions/ERC7540AdminFulfillRedeem.sol b/contracts/token/ERC20/extensions/ERC7540AdminFulfillRedeem.sol new file mode 100644 index 00000000..2d691319 --- /dev/null +++ b/contracts/token/ERC20/extensions/ERC7540AdminFulfillRedeem.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {ERC7540} from "./ERC7540.sol"; + +abstract contract ERC7540AdminFulfillRedeem is ERC7540 { + struct PendingRedeem { + uint256 pendingShares; + uint256 claimableShares; + uint256 claimableAssets; + } + mapping(address controller => PendingRedeem) private _redeems; + + /// @dev Emitted when a redeem request transitions from Pending to Claimable. + event RedeemClaimable(address indexed controller, uint256 indexed requestId, uint256 assets, uint256 shares); + + /// @dev The amount of shares requested is greater than the amount of shares pending. + error ERC7540RedeemInsufficientPendingShares(uint256 shares, uint256 pendingShares); + + function _isRedeemAsync() internal pure virtual override returns (bool) { + return true; + } + + function _requestRedeem( + uint256 shares, + address controller, + address owner + ) internal virtual override returns (uint256) { + _redeems[controller].pendingShares += shares; + return super._requestRedeem(shares, controller, owner); + } + + function _fulfillRedeem(uint256 shares, uint256 assets, address controller) internal virtual { + uint256 pendingShares = pendingRedeemRequest(0, controller); + require(shares <= pendingShares, ERC7540RedeemInsufficientPendingShares(shares, pendingShares)); + + _redeems[controller].pendingShares -= shares; + _redeems[controller].claimableShares += shares; + _redeems[controller].claimableAssets += assets; + + emit RedeemClaimable(controller, 0, assets, shares); + } + + function _pendingRedeemRequest( + uint256 /*requestId*/, + address controller + ) internal view virtual override returns (uint256) { + return _redeems[controller].pendingShares; + } + + function _claimableRedeemRequest( + uint256 /*requestId*/, + address controller + ) internal view virtual override returns (uint256) { + return _redeems[controller].claimableShares; + } + + function _asyncMaxWithdraw(address owner) internal view virtual override returns (uint256) { + return _redeems[owner].claimableAssets; + } + + function _asyncMaxRedeem(address owner) internal view virtual override returns (uint256) { + return _redeems[owner].claimableShares; + } +} diff --git a/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol b/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol new file mode 100644 index 00000000..ce56a3e3 --- /dev/null +++ b/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol @@ -0,0 +1,82 @@ +// 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 {Checkpoints} from "@openzeppelin/contracts/utils/structs/Checkpoints.sol"; +import {ERC7540} from "./ERC7540.sol"; + +abstract contract ERC7540DelayDeposit is ERC7540 { + using SafeCast for uint256; + using Checkpoints for Checkpoints.Trace208; + + mapping(address controller => Checkpoints.Trace208 trace) private _deposits; + mapping(address controller => uint256) private _claimedDeposits; + + function clock() internal view virtual returns (uint48) { + return uint48(block.timestamp); + } + + function delay(address /*controller*/) internal view virtual returns (uint48) { + return 1 hours; + } + + function _isDepositAsync() internal pure virtual override returns (bool) { + return true; + } + + function _requestDeposit( + uint256 assets, + address controller, + address owner + ) internal virtual override returns (uint256) { + // perform super call and ignore requestId + super._requestDeposit(assets, controller, owner); + + uint48 timepoint = clock() + delay(controller); + uint256 latest = _deposits[controller].latest(); + + _deposits[controller].push(timepoint, (assets + latest).toUint208()); + + return timepoint; + } + + function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal virtual override { + super._deposit(caller, receiver, assets, shares); + _claimedDeposits[receiver] += assets; + } + + function _pendingDepositRequest( + uint256 requestId, + address controller + ) internal view virtual override returns (uint256) { + uint48 timepoint = requestId.toUint48(); + return + requestId > clock() + ? _deposits[controller].upperLookup(timepoint) - _deposits[controller].upperLookup(timepoint - 1) + : 0; + } + + function _claimableDepositRequest( + uint256 requestId, + address controller + ) internal view virtual override returns (uint256) { + uint48 timepoint = requestId.toUint48(); + return + requestId > clock() + ? 0 + : Math.saturatingSub( + _deposits[controller].upperLookup(timepoint), + Math.max(_deposits[controller].upperLookup(timepoint - 1), _claimedDeposits[controller]) + ); + } + + function _asyncMaxDeposit(address owner) internal view virtual override returns (uint256) { + return _deposits[owner].latest() - _claimedDeposits[owner]; + } + + function _asyncMaxMint(address owner) internal view virtual override returns (uint256) { + return _deposits[owner].latest() - _claimedDeposits[owner]; + } +} diff --git a/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol b/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol new file mode 100644 index 00000000..2392d130 --- /dev/null +++ b/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol @@ -0,0 +1,87 @@ +// 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 {Checkpoints} from "@openzeppelin/contracts/utils/structs/Checkpoints.sol"; +import {ERC7540} from "./ERC7540.sol"; + +abstract contract ERC7540DelayRedeem is ERC7540 { + using SafeCast for uint256; + using Checkpoints for Checkpoints.Trace208; + + mapping(address controller => Checkpoints.Trace208) private _redeems; + mapping(address controller => uint256) private _claimedRedeems; + + function clock() internal view virtual returns (uint48) { + return uint48(block.timestamp); + } + + function delay(address /*controller*/) internal view virtual returns (uint48) { + return 1 hours; + } + + function _isDepositAsync() internal pure virtual override returns (bool) { + return true; + } + + function _requestRedeem( + uint256 shares, + address controller, + address owner + ) internal virtual override returns (uint256) { + // perform super call and ignore requestId + super._requestRedeem(shares, controller, owner); + + uint48 timepoint = clock() + delay(controller); + uint256 latest = _redeems[controller].latest(); + _redeems[controller].push(timepoint, (shares + latest).toUint208()); + + return timepoint; + } + + function _withdraw( + address caller, + address receiver, + address owner, + uint256 assets, + uint256 shares + ) internal virtual override { + super._withdraw(caller, receiver, owner, assets, shares); + _claimedRedeems[owner] += shares; + } + + function _pendingRedeemRequest( + uint256 requestId, + address controller + ) internal view virtual override returns (uint256) { + uint48 timepoint = requestId.toUint48(); + return + requestId > clock() + ? _redeems[controller].upperLookup(timepoint) - _redeems[controller].upperLookup(timepoint - 1) + : 0; + } + + function _claimableRedeemRequest( + uint256 requestId, + address controller + ) internal view virtual override returns (uint256) { + uint48 timepoint = requestId.toUint48(); + return + requestId > clock() + ? 0 + : Math.saturatingSub( + _redeems[controller].upperLookup(timepoint), + Math.max(_redeems[controller].upperLookup(timepoint - 1), _claimedRedeems[controller]) + ); + } + + function _asyncMaxWithdraw(address owner) internal view virtual override returns (uint256) { + return _redeems[owner].latest() - _claimedRedeems[owner]; + } + + function _asyncMaxRedeem(address owner) internal view virtual override returns (uint256) { + return _redeems[owner].latest() - _claimedRedeems[owner]; + } +} From 9e4fcc9d85712ea85e671559d1ab44383d90cd79 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Wed, 8 Apr 2026 15:13:40 +0200 Subject: [PATCH 03/60] add missing reduction of claimable assets and shares in the admin modules --- .../ERC20/extensions/ERC7540AdminFulfillDeposit.sol | 7 +++++++ .../ERC20/extensions/ERC7540AdminFulfillRedeem.sol | 13 +++++++++++++ 2 files changed, 20 insertions(+) diff --git a/contracts/token/ERC20/extensions/ERC7540AdminFulfillDeposit.sol b/contracts/token/ERC20/extensions/ERC7540AdminFulfillDeposit.sol index 843201fa..e7cb23f0 100644 --- a/contracts/token/ERC20/extensions/ERC7540AdminFulfillDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540AdminFulfillDeposit.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.27; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {ERC7540} from "./ERC7540.sol"; abstract contract ERC7540AdminFulfillDeposit is ERC7540 { @@ -43,6 +44,12 @@ abstract contract ERC7540AdminFulfillDeposit is ERC7540 { emit DepositClaimable(controller, 0, assets, shares); } + function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal virtual override { + _deposits[receiver].claimableAssets = Math.saturatingSub(_deposits[receiver].claimableAssets, assets); + _deposits[receiver].claimableShares = Math.saturatingSub(_deposits[receiver].claimableShares, shares); + super._deposit(caller, receiver, assets, shares); + } + function _pendingDepositRequest( uint256 /*requestId*/, address controller diff --git a/contracts/token/ERC20/extensions/ERC7540AdminFulfillRedeem.sol b/contracts/token/ERC20/extensions/ERC7540AdminFulfillRedeem.sol index 2d691319..ad5ad909 100644 --- a/contracts/token/ERC20/extensions/ERC7540AdminFulfillRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540AdminFulfillRedeem.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.27; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {ERC7540} from "./ERC7540.sol"; abstract contract ERC7540AdminFulfillRedeem is ERC7540 { @@ -42,6 +43,18 @@ abstract contract ERC7540AdminFulfillRedeem is ERC7540 { emit RedeemClaimable(controller, 0, assets, shares); } + function _withdraw( + address caller, + address receiver, + address owner, + uint256 assets, + uint256 shares + ) internal virtual override { + _redeems[receiver].claimableAssets = Math.saturatingSub(_redeems[receiver].claimableAssets, assets); + _redeems[receiver].claimableShares = Math.saturatingSub(_redeems[receiver].claimableShares, shares); + super._withdraw(caller, receiver, owner, assets, shares); + } + function _pendingRedeemRequest( uint256 /*requestId*/, address controller From 22e742f58689ccc0f723a7612da90e2cd28cb508 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Wed, 8 Apr 2026 15:21:18 +0200 Subject: [PATCH 04/60] reorder for CEI --- contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol | 2 +- contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol b/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol index ce56a3e3..1c3e1461 100644 --- a/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol @@ -43,8 +43,8 @@ abstract contract ERC7540DelayDeposit is ERC7540 { } function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal virtual override { - super._deposit(caller, receiver, assets, shares); _claimedDeposits[receiver] += assets; + super._deposit(caller, receiver, assets, shares); } function _pendingDepositRequest( diff --git a/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol b/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol index 2392d130..2bb46ead 100644 --- a/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol @@ -48,8 +48,8 @@ abstract contract ERC7540DelayRedeem is ERC7540 { uint256 assets, uint256 shares ) internal virtual override { - super._withdraw(caller, receiver, owner, assets, shares); _claimedRedeems[owner] += shares; + super._withdraw(caller, receiver, owner, assets, shares); } function _pendingRedeemRequest( From 1b2f8992402c8513949f2fc2aaf80dd2d88124d8 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Fri, 10 Apr 2026 16:09:57 +0200 Subject: [PATCH 05/60] ERC7540Delay: apply convertion rate at the moment of fulfillement --- contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol | 2 +- contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol b/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol index 1c3e1461..c5b1c0b1 100644 --- a/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol @@ -77,6 +77,6 @@ abstract contract ERC7540DelayDeposit is ERC7540 { } function _asyncMaxMint(address owner) internal view virtual override returns (uint256) { - return _deposits[owner].latest() - _claimedDeposits[owner]; + return _convertToShares(_deposits[owner].latest() - _claimedDeposits[owner], Math.Rounding.Floor); } } diff --git a/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol b/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol index 2bb46ead..ff1d58f3 100644 --- a/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol @@ -78,7 +78,7 @@ abstract contract ERC7540DelayRedeem is ERC7540 { } function _asyncMaxWithdraw(address owner) internal view virtual override returns (uint256) { - return _redeems[owner].latest() - _claimedRedeems[owner]; + return _convertToAssets(_redeems[owner].latest() - _claimedRedeems[owner], Math.Rounding.Floor); } function _asyncMaxRedeem(address owner) internal view virtual override returns (uint256) { From d7faf05a596acf593c5a15246744398ef79df9d5 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Fri, 10 Apr 2026 16:10:47 +0200 Subject: [PATCH 06/60] minimize --- contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol | 2 +- contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol b/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol index c5b1c0b1..72eb5661 100644 --- a/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol @@ -77,6 +77,6 @@ abstract contract ERC7540DelayDeposit is ERC7540 { } function _asyncMaxMint(address owner) internal view virtual override returns (uint256) { - return _convertToShares(_deposits[owner].latest() - _claimedDeposits[owner], Math.Rounding.Floor); + return _convertToShares(_asyncMaxDeposit(owner), Math.Rounding.Floor); } } diff --git a/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol b/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol index ff1d58f3..702c20d3 100644 --- a/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol @@ -78,7 +78,7 @@ abstract contract ERC7540DelayRedeem is ERC7540 { } function _asyncMaxWithdraw(address owner) internal view virtual override returns (uint256) { - return _convertToAssets(_redeems[owner].latest() - _claimedRedeems[owner], Math.Rounding.Floor); + return _convertToAssets(_asyncMaxRedeem(owner), Math.Rounding.Floor); } function _asyncMaxRedeem(address owner) internal view virtual override returns (uint256) { From 79d307ab99e8f7e13891958a05a3783c683e3707 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Mon, 13 Apr 2026 17:19:43 +0200 Subject: [PATCH 07/60] add ERC7540EpochRedeem prototype --- contracts/token/ERC20/extensions/ERC7540.sol | 52 +++--- .../ERC20/extensions/ERC7540EpochRedeem.sol | 153 ++++++++++++++++++ 2 files changed, 185 insertions(+), 20 deletions(-) create mode 100644 contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol diff --git a/contracts/token/ERC20/extensions/ERC7540.sol b/contracts/token/ERC20/extensions/ERC7540.sol index 6c02c03a..68e53520 100644 --- a/contracts/token/ERC20/extensions/ERC7540.sol +++ b/contracts/token/ERC20/extensions/ERC7540.sol @@ -274,15 +274,18 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { revert ERC4626ExceededMaxDeposit(_isDepositAsync() ? controller : receiver, assets, maxAssets); } - uint256 shares = _isDepositAsync() - ? Math.mulDiv(assets, maxMint(controller), maxDeposit(controller), Math.Rounding.Floor) - : previewDeposit(assets); - + uint256 shares = _computeDeposit(assets, controller); _deposit(_msgSender(), receiver, assets, shares); - return shares; } + function _computeDeposit(uint256 assets, address controller) internal virtual returns (uint256) { + return + _isDepositAsync() + ? Math.mulDiv(assets, maxMint(controller), maxDeposit(controller), Math.Rounding.Floor) + : previewDeposit(assets); + } + /// @inheritdoc IERC4626 function mint(uint256 shares, address receiver) public virtual override returns (uint256 assets) { return mint(shares, receiver, _msgSender()); @@ -300,15 +303,18 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { revert ERC4626ExceededMaxMint(_isDepositAsync() ? _msgSender() : receiver, shares, maxShares); } - uint256 assets = _isDepositAsync() - ? Math.mulDiv(shares, maxDeposit(controller), maxMint(controller), Math.Rounding.Ceil) - : previewMint(shares); - + uint256 assets = _computeMint(shares, controller); _deposit(_msgSender(), receiver, assets, shares); - return assets; } + function _computeMint(uint256 shares, address controller) internal virtual returns (uint256) { + return + _isDepositAsync() + ? Math.mulDiv(shares, maxDeposit(controller), maxMint(controller), Math.Rounding.Ceil) + : previewMint(shares); + } + function requestRedeem(uint256 shares, address controller, address owner) public virtual returns (uint256) { require(_isRedeemAsync(), ERC7540RedeemIsSync()); @@ -335,15 +341,18 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { revert ERC4626ExceededMaxWithdraw(ownerOrController, assets, maxAssets); } - uint256 shares = _isRedeemAsync() - ? Math.mulDiv(assets, maxRedeem(ownerOrController), maxWithdraw(ownerOrController), Math.Rounding.Ceil) - : previewWithdraw(assets); - + uint256 shares = _computeWithdraw(assets, ownerOrController); _withdraw(_msgSender(), receiver, ownerOrController, assets, shares); - return shares; } + function _computeWithdraw(uint256 assets, address controller) internal virtual returns (uint256) { + return + _isRedeemAsync() + ? Math.mulDiv(assets, maxRedeem(controller), maxWithdraw(controller), Math.Rounding.Ceil) + : previewWithdraw(assets); + } + /// @inheritdoc IERC4626 function redeem( uint256 shares, @@ -355,15 +364,18 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { revert ERC4626ExceededMaxRedeem(ownerOrController, shares, maxShares); } - uint256 assets = _isRedeemAsync() - ? Math.mulDiv(shares, maxWithdraw(ownerOrController), maxRedeem(ownerOrController), Math.Rounding.Floor) - : previewRedeem(shares); - + uint256 assets = _computeRedeem(shares, ownerOrController); _withdraw(_msgSender(), receiver, ownerOrController, assets, shares); - return assets; } + function _computeRedeem(uint256 shares, address controller) internal virtual returns (uint256) { + return + _isRedeemAsync() + ? Math.mulDiv(shares, maxWithdraw(controller), maxRedeem(controller), Math.Rounding.Floor) + : previewRedeem(shares); + } + /** * @dev Internal conversion function (from assets to shares) with support for rounding direction. */ diff --git a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol new file mode 100644 index 00000000..f3252e94 --- /dev/null +++ b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol @@ -0,0 +1,153 @@ +// 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"; + +abstract contract ERC7540EpocRedeem is ERC7540 { + using Math for uint256; + using SafeCast for uint256; + using DoubleEndedQueue for DoubleEndedQueue.Bytes32Deque; + + struct EpochMetadata { + uint256 totalShares; + uint256 totalAssets; + mapping(address account => uint256) requests; + } + + mapping(uint256 epochId => EpochMetadata) private _epochs; + mapping(address account => DoubleEndedQueue.Bytes32Deque) private _memberOf; + + function _isDepositAsync() internal pure virtual override returns (bool) { + return true; + } + + /// @dev Returns the current epoch. + function currentEpoch() public view virtual returns (uint256) { + return block.timestamp / 1 weeks; + } + + function _pendingRedeemRequest( + uint256 requestId, + address controller + ) internal view virtual override returns (uint256) { + EpochMetadata storage details = _epochs[requestId]; + return details.totalAssets == 0 ? details.requests[controller] : 0; + } + + function _claimableRedeemRequest( + uint256 requestId, + address controller + ) internal view virtual override returns (uint256) { + EpochMetadata storage details = _epochs[requestId]; + return details.totalAssets == 0 ? 0 : details.requests[controller]; + } + + 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)); + result += Math.mulDiv( + _claimableRedeemRequest(epochId, owner), + _epochs[epochId].totalAssets, + _epochs[epochId].totalShares, + Math.Rounding.Floor + ); + } + return result; + } + + function _asyncMaxRedeem(address owner) internal view virtual override returns (uint256 shares) { + // Shares + uint256 result = 0; + for (uint256 i = 0; i < _memberOf[owner].length(); ++i) { + uint256 epochId = uint256(_memberOf[owner].at(i)); + result += _claimableRedeemRequest(epochId, owner); + } + return result; + } + + function _requestRedeem( + uint256 shares, + address controller, + address owner + ) internal virtual override returns (uint256) { + // perform super call and ignore requestId + super._requestRedeem(shares, controller, owner); + + uint256 epochId = currentEpoch(); + _epochs[epochId].totalShares += shares; + _epochs[epochId].requests[controller] += shares; + + (bool success, bytes32 lastEpochId) = _memberOf[controller].tryBack(); + if (!success || lastEpochId != bytes32(epochId)) _memberOf[controller].pushBack(bytes32(epochId)); + + return epochId; + } + + function _sharesToFullfill(uint256 epochId) internal view virtual returns (uint256) { + return epochId < currentEpoch() && _epochs[epochId].totalAssets == 0 ? _epochs[epochId].totalShares : 0; + } + + /// Note: when epoch transition is manual, caller should bump the epoch before calling _fulfill + function _fulfill(uint256 epochId, uint256 totalAssets) internal virtual { + require(epochId < currentEpoch()); // TODO: too early + + EpochMetadata storage details = _epochs[epochId]; + require(details.totalShares > 0 && details.totalAssets == 0); // TODO: invalid resolve + + details.totalAssets = totalAssets; + // TODO: emit event + } + + function _computeWithdraw(uint256 assets, address controller) internal virtual override returns (uint256) { + uint256 shares = 0; + + while (assets > 0) { + uint256 epochId = uint256(_memberOf[controller].front()); + + EpochMetadata storage details = _epochs[epochId]; + + uint256 requested = details.requests[controller].mulDiv( + details.totalAssets, + details.totalShares, + Math.Rounding.Ceil + ); + if (requested >= assets) _memberOf[controller].popFront(); + + uint256 batchAssets = requested.min(assets); + uint256 batchShares = batchAssets.mulDiv(details.totalShares, details.totalAssets, Math.Rounding.Floor); + details.totalAssets -= batchAssets; + details.totalShares -= batchShares; + assets -= batchAssets; + shares += batchShares; + } + + return assets; + } + + function _computeRedeem(uint256 shares, address controller) internal virtual override returns (uint256) { + uint256 assets = 0; + + while (shares > 0) { + uint256 epochId = uint256(_memberOf[controller].front()); + + EpochMetadata storage details = _epochs[epochId]; + + uint256 requested = details.requests[controller]; + if (requested >= shares) _memberOf[controller].popFront(); + + uint256 batchShares = requested.min(shares); + uint256 batchAssets = batchShares.mulDiv(details.totalAssets, details.totalShares, Math.Rounding.Floor); + details.totalShares -= batchShares; + details.totalAssets -= batchAssets; + shares -= batchShares; + assets += batchAssets; + } + + return assets; + } +} From c370d9ae851c034561d8fd3e7bfda0668bcbf85c Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Tue, 14 Apr 2026 00:00:41 +0200 Subject: [PATCH 08/60] update --- contracts/token/ERC20/extensions/ERC7540.sol | 36 +++++++------------ .../ERC20/extensions/ERC7540EpochRedeem.sol | 4 +-- 2 files changed, 14 insertions(+), 26 deletions(-) diff --git a/contracts/token/ERC20/extensions/ERC7540.sol b/contracts/token/ERC20/extensions/ERC7540.sol index 68e53520..c8e87007 100644 --- a/contracts/token/ERC20/extensions/ERC7540.sol +++ b/contracts/token/ERC20/extensions/ERC7540.sol @@ -274,16 +274,13 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { revert ERC4626ExceededMaxDeposit(_isDepositAsync() ? controller : receiver, assets, maxAssets); } - uint256 shares = _computeDeposit(assets, controller); + uint256 shares = _isDepositAsync() ? _computeAsyncDeposit(assets, controller) : previewDeposit(assets); _deposit(_msgSender(), receiver, assets, shares); return shares; } - function _computeDeposit(uint256 assets, address controller) internal virtual returns (uint256) { - return - _isDepositAsync() - ? Math.mulDiv(assets, maxMint(controller), maxDeposit(controller), Math.Rounding.Floor) - : previewDeposit(assets); + function _computeAsyncDeposit(uint256 assets, address controller) internal virtual returns (uint256) { + return Math.mulDiv(assets, maxMint(controller), maxDeposit(controller), Math.Rounding.Floor); } /// @inheritdoc IERC4626 @@ -303,16 +300,13 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { revert ERC4626ExceededMaxMint(_isDepositAsync() ? _msgSender() : receiver, shares, maxShares); } - uint256 assets = _computeMint(shares, controller); + uint256 assets = _isDepositAsync() ? _computeAsyncMint(shares, controller) : previewMint(shares); _deposit(_msgSender(), receiver, assets, shares); return assets; } - function _computeMint(uint256 shares, address controller) internal virtual returns (uint256) { - return - _isDepositAsync() - ? Math.mulDiv(shares, maxDeposit(controller), maxMint(controller), Math.Rounding.Ceil) - : previewMint(shares); + function _computeAsyncMint(uint256 shares, address controller) internal virtual returns (uint256) { + return Math.mulDiv(shares, maxDeposit(controller), maxMint(controller), Math.Rounding.Ceil); } function requestRedeem(uint256 shares, address controller, address owner) public virtual returns (uint256) { @@ -341,16 +335,13 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { revert ERC4626ExceededMaxWithdraw(ownerOrController, assets, maxAssets); } - uint256 shares = _computeWithdraw(assets, ownerOrController); + uint256 shares = _isRedeemAsync() ? _computeAsyncWithdraw(assets, ownerOrController) : previewWithdraw(assets); _withdraw(_msgSender(), receiver, ownerOrController, assets, shares); return shares; } - function _computeWithdraw(uint256 assets, address controller) internal virtual returns (uint256) { - return - _isRedeemAsync() - ? Math.mulDiv(assets, maxRedeem(controller), maxWithdraw(controller), Math.Rounding.Ceil) - : previewWithdraw(assets); + function _computeAsyncWithdraw(uint256 assets, address controller) internal virtual returns (uint256) { + return Math.mulDiv(assets, maxRedeem(controller), maxWithdraw(controller), Math.Rounding.Ceil); } /// @inheritdoc IERC4626 @@ -364,16 +355,13 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { revert ERC4626ExceededMaxRedeem(ownerOrController, shares, maxShares); } - uint256 assets = _computeRedeem(shares, ownerOrController); + uint256 assets = _isRedeemAsync() ? _computeAsyncRedeem(shares, ownerOrController) : previewRedeem(shares); _withdraw(_msgSender(), receiver, ownerOrController, assets, shares); return assets; } - function _computeRedeem(uint256 shares, address controller) internal virtual returns (uint256) { - return - _isRedeemAsync() - ? Math.mulDiv(shares, maxWithdraw(controller), maxRedeem(controller), Math.Rounding.Floor) - : previewRedeem(shares); + function _computeAsyncRedeem(uint256 shares, address controller) internal virtual returns (uint256) { + return Math.mulDiv(shares, maxWithdraw(controller), maxRedeem(controller), Math.Rounding.Floor); } /** diff --git a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol index f3252e94..1bf1750e 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol @@ -103,7 +103,7 @@ abstract contract ERC7540EpocRedeem is ERC7540 { // TODO: emit event } - function _computeWithdraw(uint256 assets, address controller) internal virtual override returns (uint256) { + function _computeAsyncWithdraw(uint256 assets, address controller) internal virtual override returns (uint256) { uint256 shares = 0; while (assets > 0) { @@ -129,7 +129,7 @@ abstract contract ERC7540EpocRedeem is ERC7540 { return assets; } - function _computeRedeem(uint256 shares, address controller) internal virtual override returns (uint256) { + function _computeAsyncRedeem(uint256 shares, address controller) internal virtual override returns (uint256) { uint256 assets = 0; while (shares > 0) { From 323fb55f2c687a9f77abeab6e6bebeae65ffd69f Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Tue, 14 Apr 2026 07:49:55 +0200 Subject: [PATCH 09/60] update --- .../token/ERC20/extensions/ERC7540EpochRedeem.sol | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol index 1bf1750e..29f1d7b2 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol @@ -120,9 +120,9 @@ abstract contract ERC7540EpocRedeem is ERC7540 { uint256 batchAssets = requested.min(assets); uint256 batchShares = batchAssets.mulDiv(details.totalShares, details.totalAssets, Math.Rounding.Floor); - details.totalAssets -= batchAssets; - details.totalShares -= batchShares; - assets -= batchAssets; + details.totalAssets -= batchAssets; // May need saturatingSub for rounding handling + details.totalShares -= batchShares; // May need saturatingSub for rounding handling + assets -= batchAssets; // May need saturatingSub for rounding handling shares += batchShares; } @@ -142,9 +142,9 @@ abstract contract ERC7540EpocRedeem is ERC7540 { uint256 batchShares = requested.min(shares); uint256 batchAssets = batchShares.mulDiv(details.totalAssets, details.totalShares, Math.Rounding.Floor); - details.totalShares -= batchShares; - details.totalAssets -= batchAssets; - shares -= batchShares; + details.totalShares -= batchShares; // May need saturatingSub for rounding handling + details.totalAssets -= batchAssets; // May need saturatingSub for rounding handling + shares -= batchShares; // May need saturatingSub for rounding handling assets += batchAssets; } From e1aa2923900811b80596a0c66f0204cd340f0dea Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Tue, 14 Apr 2026 23:32:04 +0200 Subject: [PATCH 10/60] prevent gas ddos risk --- .../token/ERC20/extensions/ERC7540EpochRedeem.sol | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol index 29f1d7b2..2cd29cb9 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol @@ -83,7 +83,13 @@ abstract contract ERC7540EpocRedeem is ERC7540 { _epochs[epochId].requests[controller] += shares; (bool success, bytes32 lastEpochId) = _memberOf[controller].tryBack(); - if (!success || lastEpochId != bytes32(epochId)) _memberOf[controller].pushBack(bytes32(epochId)); + if (!success || lastEpochId != bytes32(epochId)) { + _memberOf[controller].pushBack(bytes32(epochId)); + + // Limit the number of pending epochs per account to 32 to avoid O(n) loop in _asyncMaxWithdraw and _asyncMaxRedeem being a concern. + // User that have reached the limit should execute pending (fulfilled) request to cleanup the queue. + require(_memberOf[controller].length() < _requestQueueLimit()); + } return epochId; } @@ -150,4 +156,8 @@ abstract contract ERC7540EpocRedeem is ERC7540 { return assets; } + + function _requestQueueLimit() internal view virtual returns (uint256) { + return 32; + } } From d74058e846b654324b523de2d9730d28baca1106 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Wed, 15 Apr 2026 16:13:12 +0200 Subject: [PATCH 11/60] update --- contracts/token/ERC20/extensions/ERC7540.sol | 49 +++++++++++++++++--- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/contracts/token/ERC20/extensions/ERC7540.sol b/contracts/token/ERC20/extensions/ERC7540.sol index c8e87007..5e433781 100644 --- a/contracts/token/ERC20/extensions/ERC7540.sol +++ b/contracts/token/ERC20/extensions/ERC7540.sol @@ -248,6 +248,8 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { ) public virtual onlyOperatorOrController(_isDepositAsync(), owner, _msgSender()) returns (uint256) { require(_isDepositAsync(), ERC7540DepositIsSync()); + _totalPendingDepositAssets += assets; + uint256 requestId = _requestDeposit(assets, controller, owner); // Must revert with ERC20InsufficientBalance or equivalent error if there's not enough balance. @@ -316,7 +318,12 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { if (owner != sender && !isOperator(owner, sender)) { _spendAllowance(owner, sender, shares); } - _burn(owner, shares); + if (_redeemRedeemShareDestination() == address(0)) { + _totalPendingRedeemShares += shares; + _burn(owner, shares); + } else { + _transfer(owner, _redeemRedeemShareDestination(), shares); + } uint256 requestId = _requestRedeem(shares, controller, owner); @@ -379,14 +386,19 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { } function _requestDeposit( - uint256 assets, + uint256 /*assets*/, address /*controller*/, address /*owner*/ ) internal virtual returns (uint256) { - _totalPendingDepositAssets += assets; return 0; } + function _mintSharesOnDepositFulfill(uint256 assets, uint256 shares) internal virtual { + require(_depositShareOrigin() != address(0), "TODO: shares minted on claim"); + _totalPendingDepositAssets -= assets; + _mint(_depositShareOrigin(), shares); + } + /** * @dev Deposit/mint common workflow. */ @@ -400,23 +412,31 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { if (!_isDepositAsync()) { // slither-disable-next-line reentrancy-no-eth _transferIn(caller, assets); - } else { + _mint(receiver, shares); + } else if (_depositShareOrigin() == address(0)) { _totalPendingDepositAssets -= assets; + _mint(receiver, shares); + } else { + _transfer(_depositShareOrigin(), receiver, shares); } - _mint(receiver, shares); emit Deposit(caller, receiver, assets, shares); } function _requestRedeem( - uint256 shares, + uint256 /*shares*/, address /*controller*/, address /*owner*/ ) internal virtual returns (uint256) { - _totalPendingRedeemShares += shares; return 0; } + function _burnSharesOnRedeemFulfill(uint256 /*assets*/, uint256 shares) internal virtual { + require(_redeemRedeemShareDestination() != address(0), "TODO: shares minted on claim"); + _totalPendingRedeemShares += shares; + _burn(_redeemRedeemShareDestination(), shares); + } + /** * @dev Withdraw/redeem common workflow. */ @@ -461,39 +481,54 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { return 0; } + function _depositShareOrigin() internal view virtual returns (address) { + return address(0); + } + + function _redeemRedeemShareDestination() internal view virtual returns (address) { + return address(0); + } + function _pendingDepositRequest( uint256 /*requestId*/, address /*controller*/ ) internal view virtual returns (uint256) { revert NotImplemented(); } + function _claimableDepositRequest( uint256 /*requestId*/, address /*controller*/ ) internal view virtual returns (uint256) { revert NotImplemented(); } + function _pendingRedeemRequest( uint256 /*requestId*/, address /*controller*/ ) internal view virtual returns (uint256) { revert NotImplemented(); } + function _claimableRedeemRequest( uint256 /*requestId*/, address /*controller*/ ) internal view virtual returns (uint256) { revert NotImplemented(); } + function _asyncMaxDeposit(address /*owner*/) internal view virtual returns (uint256) { revert NotImplemented(); } + function _asyncMaxMint(address /*owner*/) internal view virtual returns (uint256) { revert NotImplemented(); } + function _asyncMaxWithdraw(address /*owner*/) internal view virtual returns (uint256) { revert NotImplemented(); } + function _asyncMaxRedeem(address /*owner*/) internal view virtual returns (uint256) { revert NotImplemented(); } From b745f58fa945cdf63a731e2aff693885dd012295 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Wed, 15 Apr 2026 16:23:56 +0200 Subject: [PATCH 12/60] refactor internal request function --- contracts/token/ERC20/extensions/ERC7540.sol | 70 +++++++++---------- .../extensions/ERC7540AdminFulfillDeposit.sol | 5 +- .../extensions/ERC7540AdminFulfillRedeem.sol | 5 +- .../ERC20/extensions/ERC7540DelayDeposit.sol | 8 +-- .../ERC20/extensions/ERC7540DelayRedeem.sol | 8 +-- .../ERC20/extensions/ERC7540EpochRedeem.sol | 8 +-- 6 files changed, 49 insertions(+), 55 deletions(-) diff --git a/contracts/token/ERC20/extensions/ERC7540.sol b/contracts/token/ERC20/extensions/ERC7540.sol index 5e433781..a619edc5 100644 --- a/contracts/token/ERC20/extensions/ERC7540.sol +++ b/contracts/token/ERC20/extensions/ERC7540.sol @@ -246,17 +246,7 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { address controller, address owner ) public virtual onlyOperatorOrController(_isDepositAsync(), owner, _msgSender()) returns (uint256) { - require(_isDepositAsync(), ERC7540DepositIsSync()); - - _totalPendingDepositAssets += assets; - - uint256 requestId = _requestDeposit(assets, controller, owner); - - // Must revert with ERC20InsufficientBalance or equivalent error if there's not enough balance. - _transferIn(owner, assets); - - emit DepositRequest(controller, owner, requestId, _msgSender(), assets); - return requestId; + return _requestDeposit(assets, controller, owner, 0); // 0 is the default requestId } /// @inheritdoc IERC4626 @@ -312,23 +302,7 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { } function requestRedeem(uint256 shares, address controller, address owner) public virtual returns (uint256) { - require(_isRedeemAsync(), ERC7540RedeemIsSync()); - - address sender = _msgSender(); - if (owner != sender && !isOperator(owner, sender)) { - _spendAllowance(owner, sender, shares); - } - if (_redeemRedeemShareDestination() == address(0)) { - _totalPendingRedeemShares += shares; - _burn(owner, shares); - } else { - _transfer(owner, _redeemRedeemShareDestination(), shares); - } - - uint256 requestId = _requestRedeem(shares, controller, owner); - - emit RedeemRequest(controller, owner, requestId, _msgSender(), shares); - return requestId; + return _requestRedeem(shares, controller, owner, 0); // 0 is default requestId } /// @inheritdoc IERC4626 @@ -386,11 +360,20 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { } function _requestDeposit( - uint256 /*assets*/, - address /*controller*/, - address /*owner*/ + uint256 assets, + address controller, + address owner, + uint256 requestId ) internal virtual returns (uint256) { - return 0; + require(_isDepositAsync(), ERC7540DepositIsSync()); + + _totalPendingDepositAssets += assets; + + // Must revert with ERC20InsufficientBalance or equivalent error if there's not enough balance. + _transferIn(owner, assets); + + emit DepositRequest(controller, owner, requestId, _msgSender(), assets); + return requestId; } function _mintSharesOnDepositFulfill(uint256 assets, uint256 shares) internal virtual { @@ -424,11 +407,26 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { } function _requestRedeem( - uint256 /*shares*/, - address /*controller*/, - address /*owner*/ + uint256 shares, + address controller, + address owner, + uint256 requestId ) internal virtual returns (uint256) { - return 0; + require(_isRedeemAsync(), ERC7540RedeemIsSync()); + + address sender = _msgSender(); + if (owner != sender && !isOperator(owner, sender)) { + _spendAllowance(owner, sender, shares); + } + if (_redeemRedeemShareDestination() == address(0)) { + _totalPendingRedeemShares += shares; + _burn(owner, shares); + } else { + _transfer(owner, _redeemRedeemShareDestination(), shares); + } + + emit RedeemRequest(controller, owner, requestId, _msgSender(), shares); + return requestId; } function _burnSharesOnRedeemFulfill(uint256 /*assets*/, uint256 shares) internal virtual { diff --git a/contracts/token/ERC20/extensions/ERC7540AdminFulfillDeposit.sol b/contracts/token/ERC20/extensions/ERC7540AdminFulfillDeposit.sol index e7cb23f0..1c2f7c76 100644 --- a/contracts/token/ERC20/extensions/ERC7540AdminFulfillDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540AdminFulfillDeposit.sol @@ -27,10 +27,11 @@ abstract contract ERC7540AdminFulfillDeposit is ERC7540 { function _requestDeposit( uint256 assets, address controller, - address owner + address owner, + uint256 requestId ) internal virtual override returns (uint256) { _deposits[controller].pendingAssets += assets; - return super._requestDeposit(assets, controller, owner); + return super._requestDeposit(assets, controller, owner, requestId); } function _fulfillDeposit(uint256 assets, uint256 shares, address controller) internal virtual { diff --git a/contracts/token/ERC20/extensions/ERC7540AdminFulfillRedeem.sol b/contracts/token/ERC20/extensions/ERC7540AdminFulfillRedeem.sol index ad5ad909..b132b51d 100644 --- a/contracts/token/ERC20/extensions/ERC7540AdminFulfillRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540AdminFulfillRedeem.sol @@ -26,10 +26,11 @@ abstract contract ERC7540AdminFulfillRedeem is ERC7540 { function _requestRedeem( uint256 shares, address controller, - address owner + address owner, + uint256 requestId ) internal virtual override returns (uint256) { _redeems[controller].pendingShares += shares; - return super._requestRedeem(shares, controller, owner); + return super._requestRedeem(shares, controller, owner, requestId); } function _fulfillRedeem(uint256 shares, uint256 assets, address controller) internal virtual { diff --git a/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol b/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol index 72eb5661..47b7b319 100644 --- a/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol @@ -29,17 +29,15 @@ abstract contract ERC7540DelayDeposit is ERC7540 { function _requestDeposit( uint256 assets, address controller, - address owner + address owner, + uint256 /* requestId */ // discarded and replaced by timepoint based ids ) internal virtual override returns (uint256) { - // perform super call and ignore requestId - super._requestDeposit(assets, controller, owner); - uint48 timepoint = clock() + delay(controller); uint256 latest = _deposits[controller].latest(); _deposits[controller].push(timepoint, (assets + latest).toUint208()); - return timepoint; + return super._requestDeposit(assets, controller, owner, timepoint); } function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal virtual override { diff --git a/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol b/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol index 702c20d3..75a0de2a 100644 --- a/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol @@ -29,16 +29,14 @@ abstract contract ERC7540DelayRedeem is ERC7540 { function _requestRedeem( uint256 shares, address controller, - address owner + address owner, + uint256 /* requestId */ // discarded and replaced by timepoint based ids ) internal virtual override returns (uint256) { - // perform super call and ignore requestId - super._requestRedeem(shares, controller, owner); - uint48 timepoint = clock() + delay(controller); uint256 latest = _redeems[controller].latest(); _redeems[controller].push(timepoint, (shares + latest).toUint208()); - return timepoint; + return super._requestRedeem(shares, controller, owner, timepoint); } function _withdraw( diff --git a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol index 2cd29cb9..5c915e13 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol @@ -73,11 +73,9 @@ abstract contract ERC7540EpocRedeem is ERC7540 { function _requestRedeem( uint256 shares, address controller, - address owner + address owner, + uint256 /* requestId */ // discarded and replaced by timepoint based ids ) internal virtual override returns (uint256) { - // perform super call and ignore requestId - super._requestRedeem(shares, controller, owner); - uint256 epochId = currentEpoch(); _epochs[epochId].totalShares += shares; _epochs[epochId].requests[controller] += shares; @@ -91,7 +89,7 @@ abstract contract ERC7540EpocRedeem is ERC7540 { require(_memberOf[controller].length() < _requestQueueLimit()); } - return epochId; + return super._requestRedeem(shares, controller, owner, epochId); } function _sharesToFullfill(uint256 epochId) internal view virtual returns (uint256) { From dbc03ac5e7d0d2bb76927450ff20ead6cd4604f7 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Wed, 15 Apr 2026 18:01:51 +0200 Subject: [PATCH 13/60] epoch deposit --- .../ERC20/extensions/ERC7540EpochDeposit.sol | 160 ++++++++++++++++++ .../ERC20/extensions/ERC7540EpochRedeem.sol | 17 +- 2 files changed, 168 insertions(+), 9 deletions(-) create mode 100644 contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol diff --git a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol new file mode 100644 index 00000000..b2d4d780 --- /dev/null +++ b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol @@ -0,0 +1,160 @@ +// 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"; + +abstract contract ERC7540EpochDeposit is ERC7540 { + using Math for uint256; + using SafeCast for uint256; + using DoubleEndedQueue for DoubleEndedQueue.Bytes32Deque; + + struct EpochMetadata { + uint256 totalAssets; + uint256 totalShares; + mapping(address account => uint256) requests; + } + + mapping(uint256 epochId => EpochMetadata) private _epochs; + mapping(address account => DoubleEndedQueue.Bytes32Deque) private _memberOf; + + function _isDepositAsync() internal pure virtual override returns (bool) { + return true; + } + + /// @dev Returns the current epoch. + function currentDepositEpoch() public view virtual returns (uint256) { + return block.timestamp / 1 weeks; + } + + function _pendingDepositRequest( + uint256 requestId, + address controller + ) internal view virtual override returns (uint256) { + EpochMetadata storage details = _epochs[requestId]; + return details.totalShares == 0 ? details.requests[controller] : 0; + } + + function _claimableDepositRequest( + uint256 requestId, + address controller + ) internal view virtual override returns (uint256) { + EpochMetadata storage details = _epochs[requestId]; + return details.totalShares == 0 ? 0 : details.requests[controller]; + } + + 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)); + result += _claimableDepositRequest(epochId, owner); + } + return result; + } + + 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)); + result += Math.mulDiv( + _claimableDepositRequest(epochId, owner), + _epochs[epochId].totalShares, + _epochs[epochId].totalAssets, + Math.Rounding.Floor + ); + } + return result; + } + + function _requestDeposit( + uint256 assets, + address controller, + address owner, + uint256 /* requestId */ // discarded and replaced by timepoint based ids + ) internal virtual override returns (uint256) { + uint256 epochId = currentDepositEpoch(); + _epochs[epochId].totalAssets += assets; + _epochs[epochId].requests[controller] += assets; + + (bool success, bytes32 lastEpochId) = _memberOf[controller].tryBack(); + if (!success || lastEpochId != bytes32(epochId)) { + _memberOf[controller].pushBack(bytes32(epochId)); + + // Limit the number of pending epochs per account to 32 to avoid O(n) loop in _asyncMaxWithdraw and _asyncMaxRedeem being a concern. + // User that have reached the limit should execute pending (fulfilled) request to cleanup the queue. + require(_memberOf[controller].length() < _requestQueueLimit()); + } + + return super._requestDeposit(assets, controller, owner, epochId); + } + + function _assetsToFullfillDeposit(uint256 epochId) internal view virtual returns (uint256) { + return epochId < currentDepositEpoch() && _epochs[epochId].totalShares == 0 ? _epochs[epochId].totalAssets : 0; + } + + /// Note: when epoch transition is manual, caller should bump the epoch before calling _fulfill + function _fulfillDeposit(uint256 epochId, uint256 totalShares) internal virtual { + require(epochId < currentDepositEpoch()); // TODO: too early + + EpochMetadata storage details = _epochs[epochId]; + require(details.totalAssets > 0 && details.totalShares == 0); // TODO: invalid resolve + + details.totalShares = totalShares; + // TODO: emit event + } + + function _computeAsyncWithdraw(uint256 assets, address controller) internal virtual override returns (uint256) { + uint256 shares = 0; + + while (assets > 0) { + uint256 epochId = uint256(_memberOf[controller].front()); + + EpochMetadata storage details = _epochs[epochId]; + + uint256 requested = details.requests[controller].mulDiv( + details.totalAssets, + details.totalShares, + Math.Rounding.Ceil + ); + if (requested >= assets) _memberOf[controller].popFront(); + + uint256 batchAssets = requested.min(assets); + uint256 batchShares = batchAssets.mulDiv(details.totalShares, details.totalAssets, Math.Rounding.Floor); + details.totalAssets -= batchAssets; // May need saturatingSub for rounding handling + details.totalShares -= batchShares; // May need saturatingSub for rounding handling + assets -= batchAssets; // May need saturatingSub for rounding handling + shares += batchShares; + } + + return assets; + } + + function _computeAsyncRedeem(uint256 shares, address controller) internal virtual override returns (uint256) { + uint256 assets = 0; + + while (shares > 0) { + uint256 epochId = uint256(_memberOf[controller].front()); + + EpochMetadata storage details = _epochs[epochId]; + + uint256 requested = details.requests[controller]; + if (requested >= shares) _memberOf[controller].popFront(); + + uint256 batchShares = requested.min(shares); + uint256 batchAssets = batchShares.mulDiv(details.totalAssets, details.totalShares, Math.Rounding.Floor); + details.totalShares -= batchShares; // May need saturatingSub for rounding handling + details.totalAssets -= batchAssets; // May need saturatingSub for rounding handling + shares -= batchShares; // May need saturatingSub for rounding handling + assets += batchAssets; + } + + return assets; + } + + function _requestQueueLimit() internal view virtual returns (uint256) { + return 32; + } +} diff --git a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol index 5c915e13..a5341893 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol @@ -7,7 +7,7 @@ import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import {DoubleEndedQueue} from "@openzeppelin/contracts/utils/structs/DoubleEndedQueue.sol"; import {ERC7540} from "./ERC7540.sol"; -abstract contract ERC7540EpocRedeem is ERC7540 { +abstract contract ERC7540EpochRedeem is ERC7540 { using Math for uint256; using SafeCast for uint256; using DoubleEndedQueue for DoubleEndedQueue.Bytes32Deque; @@ -21,12 +21,12 @@ abstract contract ERC7540EpocRedeem is ERC7540 { mapping(uint256 epochId => EpochMetadata) private _epochs; mapping(address account => DoubleEndedQueue.Bytes32Deque) private _memberOf; - function _isDepositAsync() internal pure virtual override returns (bool) { + function _isRedeemAsync() internal pure virtual override returns (bool) { return true; } /// @dev Returns the current epoch. - function currentEpoch() public view virtual returns (uint256) { + function currentRedeemEpoch() public view virtual returns (uint256) { return block.timestamp / 1 weeks; } @@ -61,7 +61,6 @@ abstract contract ERC7540EpocRedeem is ERC7540 { } function _asyncMaxRedeem(address owner) internal view virtual override returns (uint256 shares) { - // Shares uint256 result = 0; for (uint256 i = 0; i < _memberOf[owner].length(); ++i) { uint256 epochId = uint256(_memberOf[owner].at(i)); @@ -76,7 +75,7 @@ abstract contract ERC7540EpocRedeem is ERC7540 { address owner, uint256 /* requestId */ // discarded and replaced by timepoint based ids ) internal virtual override returns (uint256) { - uint256 epochId = currentEpoch(); + uint256 epochId = currentRedeemEpoch(); _epochs[epochId].totalShares += shares; _epochs[epochId].requests[controller] += shares; @@ -92,13 +91,13 @@ abstract contract ERC7540EpocRedeem is ERC7540 { return super._requestRedeem(shares, controller, owner, epochId); } - function _sharesToFullfill(uint256 epochId) internal view virtual returns (uint256) { - return epochId < currentEpoch() && _epochs[epochId].totalAssets == 0 ? _epochs[epochId].totalShares : 0; + function _sharesToFullfillReedem(uint256 epochId) internal view virtual returns (uint256) { + return epochId < currentRedeemEpoch() && _epochs[epochId].totalAssets == 0 ? _epochs[epochId].totalShares : 0; } /// Note: when epoch transition is manual, caller should bump the epoch before calling _fulfill - function _fulfill(uint256 epochId, uint256 totalAssets) internal virtual { - require(epochId < currentEpoch()); // TODO: too early + function _fulfillRedeem(uint256 epochId, uint256 totalAssets) internal virtual { + require(epochId < currentRedeemEpoch()); // TODO: too early EpochMetadata storage details = _epochs[epochId]; require(details.totalShares > 0 && details.totalAssets == 0); // TODO: invalid resolve From 472e704097bd09e6aeab482ce594c024a21cf878 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Wed, 15 Apr 2026 18:40:52 +0200 Subject: [PATCH 14/60] add mocks & fix override pattern --- .../mocks/token/ERC7540AdminFulfillMock.sol | 106 +++++++++++++++ contracts/mocks/token/ERC7540DelayMock.sol | 110 +++++++++++++++ contracts/mocks/token/ERC7540EpochMock.sol | 125 ++++++++++++++++++ contracts/token/ERC20/extensions/ERC7540.sol | 1 + .../ERC20/extensions/ERC7540DelayDeposit.sol | 6 +- .../ERC20/extensions/ERC7540DelayRedeem.sol | 8 +- .../ERC20/extensions/ERC7540EpochDeposit.sol | 30 ++--- .../ERC20/extensions/ERC7540EpochRedeem.sol | 14 +- 8 files changed, 371 insertions(+), 29 deletions(-) create mode 100644 contracts/mocks/token/ERC7540AdminFulfillMock.sol create mode 100644 contracts/mocks/token/ERC7540DelayMock.sol create mode 100644 contracts/mocks/token/ERC7540EpochMock.sol diff --git a/contracts/mocks/token/ERC7540AdminFulfillMock.sol b/contracts/mocks/token/ERC7540AdminFulfillMock.sol new file mode 100644 index 00000000..4e93fd19 --- /dev/null +++ b/contracts/mocks/token/ERC7540AdminFulfillMock.sol @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {ERC7540} from "../../token/ERC20/extensions/ERC7540.sol"; +import {ERC7540AdminFulfillDeposit} from "../../token/ERC20/extensions/ERC7540AdminFulfillDeposit.sol"; +import {ERC7540AdminFulfillRedeem} from "../../token/ERC20/extensions/ERC7540AdminFulfillRedeem.sol"; + +abstract contract ERC7540AdminFulfillMock is ERC7540AdminFulfillDeposit, ERC7540AdminFulfillRedeem { + function _isDepositAsync() internal pure virtual override(ERC7540, ERC7540AdminFulfillDeposit) returns (bool) { + return super._isDepositAsync(); + } + + function _isRedeemAsync() internal pure virtual override(ERC7540, ERC7540AdminFulfillRedeem) returns (bool) { + return super._isRedeemAsync(); + } + + function _requestDeposit( + uint256 assets, + address controller, + address owner, + uint256 requestId + ) internal virtual override(ERC7540, ERC7540AdminFulfillDeposit) returns (uint256) { + return super._requestDeposit(assets, controller, owner, requestId); + } + + function _deposit( + address caller, + address receiver, + uint256 assets, + uint256 shares + ) internal virtual override(ERC7540, ERC7540AdminFulfillDeposit) { + super._deposit(caller, receiver, assets, shares); + } + + function _requestRedeem( + uint256 shares, + address controller, + address owner, + uint256 requestId + ) internal virtual override(ERC7540, ERC7540AdminFulfillRedeem) returns (uint256) { + return super._requestRedeem(shares, controller, owner, requestId); + } + + function _withdraw( + address caller, + address receiver, + address owner, + uint256 assets, + uint256 shares + ) internal virtual override(ERC7540, ERC7540AdminFulfillRedeem) { + super._withdraw(caller, receiver, owner, assets, shares); + } + + function _pendingDepositRequest( + uint256 requestId, + address controller + ) internal view virtual override(ERC7540, ERC7540AdminFulfillDeposit) returns (uint256) { + return super._pendingDepositRequest(requestId, controller); + } + + function _claimableDepositRequest( + uint256 requestId, + address controller + ) internal view virtual override(ERC7540, ERC7540AdminFulfillDeposit) returns (uint256) { + return super._claimableDepositRequest(requestId, controller); + } + + function _pendingRedeemRequest( + uint256 requestId, + address controller + ) internal view virtual override(ERC7540, ERC7540AdminFulfillRedeem) returns (uint256) { + return super._pendingRedeemRequest(requestId, controller); + } + + function _claimableRedeemRequest( + uint256 requestId, + address controller + ) internal view virtual override(ERC7540, ERC7540AdminFulfillRedeem) returns (uint256) { + return super._claimableRedeemRequest(requestId, controller); + } + + function _asyncMaxDeposit( + address owner + ) internal view virtual override(ERC7540, ERC7540AdminFulfillDeposit) returns (uint256) { + return super._asyncMaxDeposit(owner); + } + + function _asyncMaxMint( + address owner + ) internal view virtual override(ERC7540, ERC7540AdminFulfillDeposit) returns (uint256) { + return super._asyncMaxMint(owner); + } + + function _asyncMaxWithdraw( + address owner + ) internal view virtual override(ERC7540, ERC7540AdminFulfillRedeem) returns (uint256) { + return super._asyncMaxWithdraw(owner); + } + + function _asyncMaxRedeem( + address owner + ) internal view virtual override(ERC7540, ERC7540AdminFulfillRedeem) returns (uint256) { + return super._asyncMaxRedeem(owner); + } +} diff --git a/contracts/mocks/token/ERC7540DelayMock.sol b/contracts/mocks/token/ERC7540DelayMock.sol new file mode 100644 index 00000000..d5d80c7e --- /dev/null +++ b/contracts/mocks/token/ERC7540DelayMock.sol @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {ERC7540} from "../../token/ERC20/extensions/ERC7540.sol"; +import {ERC7540DelayDeposit} from "../../token/ERC20/extensions/ERC7540DelayDeposit.sol"; +import {ERC7540DelayRedeem} from "../../token/ERC20/extensions/ERC7540DelayRedeem.sol"; + +abstract contract ERC7540DelayMock is ERC7540DelayDeposit, ERC7540DelayRedeem { + function clock() public view virtual override(ERC7540DelayDeposit, ERC7540DelayRedeem) returns (uint48) { + return super.clock(); + } + + function _isDepositAsync() internal pure virtual override(ERC7540, ERC7540DelayDeposit) returns (bool) { + return super._isDepositAsync(); + } + + function _isRedeemAsync() internal pure virtual override(ERC7540, ERC7540DelayRedeem) returns (bool) { + return super._isRedeemAsync(); + } + + function _requestDeposit( + uint256 assets, + address controller, + address owner, + uint256 requestId + ) internal virtual override(ERC7540, ERC7540DelayDeposit) returns (uint256) { + return super._requestDeposit(assets, controller, owner, requestId); + } + + function _deposit( + address caller, + address receiver, + uint256 assets, + uint256 shares + ) internal virtual override(ERC7540, ERC7540DelayDeposit) { + super._deposit(caller, receiver, assets, shares); + } + + function _requestRedeem( + uint256 shares, + address controller, + address owner, + uint256 requestId + ) internal virtual override(ERC7540, ERC7540DelayRedeem) returns (uint256) { + return super._requestRedeem(shares, controller, owner, requestId); + } + + function _withdraw( + address caller, + address receiver, + address owner, + uint256 assets, + uint256 shares + ) internal virtual override(ERC7540, ERC7540DelayRedeem) { + super._withdraw(caller, receiver, owner, assets, shares); + } + + function _pendingDepositRequest( + uint256 requestId, + address controller + ) internal view virtual override(ERC7540, ERC7540DelayDeposit) returns (uint256) { + return super._pendingDepositRequest(requestId, controller); + } + + function _claimableDepositRequest( + uint256 requestId, + address controller + ) internal view virtual override(ERC7540, ERC7540DelayDeposit) returns (uint256) { + return super._claimableDepositRequest(requestId, controller); + } + + function _pendingRedeemRequest( + uint256 requestId, + address controller + ) internal view virtual override(ERC7540, ERC7540DelayRedeem) returns (uint256) { + return super._pendingRedeemRequest(requestId, controller); + } + + function _claimableRedeemRequest( + uint256 requestId, + address controller + ) internal view virtual override(ERC7540, ERC7540DelayRedeem) returns (uint256) { + return super._claimableRedeemRequest(requestId, controller); + } + + function _asyncMaxDeposit( + address owner + ) internal view virtual override(ERC7540, ERC7540DelayDeposit) returns (uint256) { + return super._asyncMaxDeposit(owner); + } + + function _asyncMaxMint( + address owner + ) internal view virtual override(ERC7540, ERC7540DelayDeposit) returns (uint256) { + return super._asyncMaxMint(owner); + } + + function _asyncMaxWithdraw( + address owner + ) internal view virtual override(ERC7540, ERC7540DelayRedeem) returns (uint256) { + return super._asyncMaxWithdraw(owner); + } + + function _asyncMaxRedeem( + address owner + ) internal view virtual override(ERC7540, ERC7540DelayRedeem) returns (uint256) { + return super._asyncMaxRedeem(owner); + } +} diff --git a/contracts/mocks/token/ERC7540EpochMock.sol b/contracts/mocks/token/ERC7540EpochMock.sol new file mode 100644 index 00000000..793cce5f --- /dev/null +++ b/contracts/mocks/token/ERC7540EpochMock.sol @@ -0,0 +1,125 @@ +// 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 _isDepositAsync() internal pure virtual override(ERC7540, ERC7540EpochDeposit) returns (bool) { + return super._isDepositAsync(); + } + + function _isRedeemAsync() internal pure virtual override(ERC7540, ERC7540EpochRedeem) returns (bool) { + return super._isRedeemAsync(); + } + + function _computeAsyncDeposit( + uint256 assets, + address controller + ) internal virtual override(ERC7540, ERC7540EpochDeposit) returns (uint256) { + return super._computeAsyncDeposit(assets, controller); + } + + function _computeAsyncMint( + uint256 shares, + address controller + ) internal virtual override(ERC7540, ERC7540EpochDeposit) returns (uint256) { + return super._computeAsyncMint(shares, controller); + } + + function _computeAsyncRedeem( + uint256 shares, + address controller + ) internal virtual override(ERC7540, ERC7540EpochRedeem) returns (uint256) { + return super._computeAsyncRedeem(shares, controller); + } + + function _computeAsyncWithdraw( + uint256 assets, + address controller + ) internal virtual override(ERC7540, ERC7540EpochRedeem) returns (uint256) { + return super._computeAsyncWithdraw(assets, controller); + } + + 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); + } + + function _pendingDepositRequest( + uint256 requestId, + address controller + ) internal view virtual override(ERC7540, ERC7540EpochDeposit) returns (uint256) { + return super._pendingDepositRequest(requestId, controller); + } + + function _claimableDepositRequest( + uint256 requestId, + address controller + ) internal view virtual override(ERC7540, ERC7540EpochDeposit) returns (uint256) { + return super._claimableDepositRequest(requestId, controller); + } + + function _pendingRedeemRequest( + uint256 requestId, + address controller + ) internal view virtual override(ERC7540, ERC7540EpochRedeem) returns (uint256) { + return super._pendingRedeemRequest(requestId, controller); + } + + function _claimableRedeemRequest( + uint256 requestId, + address controller + ) internal view virtual override(ERC7540, ERC7540EpochRedeem) returns (uint256) { + return super._claimableRedeemRequest(requestId, controller); + } + + function _asyncMaxDeposit( + address owner + ) internal view virtual override(ERC7540, ERC7540EpochDeposit) returns (uint256) { + return super._asyncMaxDeposit(owner); + } + + function _asyncMaxMint( + address owner + ) internal view virtual override(ERC7540, ERC7540EpochDeposit) returns (uint256) { + return super._asyncMaxMint(owner); + } + + function _asyncMaxWithdraw( + address owner + ) internal view virtual override(ERC7540, ERC7540EpochRedeem) returns (uint256) { + return super._asyncMaxWithdraw(owner); + } + + function _asyncMaxRedeem( + address owner + ) internal view virtual override(ERC7540, ERC7540EpochRedeem) returns (uint256) { + return super._asyncMaxRedeem(owner); + } + + function _requestQueueLimit() + internal + view + virtual + override(ERC7540EpochDeposit, ERC7540EpochRedeem) + returns (uint256) + { + return super._requestQueueLimit(); + } +} diff --git a/contracts/token/ERC20/extensions/ERC7540.sol b/contracts/token/ERC20/extensions/ERC7540.sol index a619edc5..ffdc5ac4 100644 --- a/contracts/token/ERC20/extensions/ERC7540.sol +++ b/contracts/token/ERC20/extensions/ERC7540.sol @@ -64,6 +64,7 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { function _isDepositAsync() internal pure virtual returns (bool) { return false; } + function _isRedeemAsync() internal pure virtual returns (bool) { return false; } diff --git a/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol b/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol index 47b7b319..1af75f36 100644 --- a/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol @@ -14,11 +14,11 @@ abstract contract ERC7540DelayDeposit is ERC7540 { mapping(address controller => Checkpoints.Trace208 trace) private _deposits; mapping(address controller => uint256) private _claimedDeposits; - function clock() internal view virtual returns (uint48) { + function clock() public view virtual returns (uint48) { return uint48(block.timestamp); } - function delay(address /*controller*/) internal view virtual returns (uint48) { + function depositDelay(address /*controller*/) public view virtual returns (uint48) { return 1 hours; } @@ -32,7 +32,7 @@ abstract contract ERC7540DelayDeposit is ERC7540 { address owner, uint256 /* requestId */ // discarded and replaced by timepoint based ids ) internal virtual override returns (uint256) { - uint48 timepoint = clock() + delay(controller); + uint48 timepoint = clock() + depositDelay(controller); uint256 latest = _deposits[controller].latest(); _deposits[controller].push(timepoint, (assets + latest).toUint208()); diff --git a/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol b/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol index 75a0de2a..8b62e5a1 100644 --- a/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol @@ -14,15 +14,15 @@ abstract contract ERC7540DelayRedeem is ERC7540 { mapping(address controller => Checkpoints.Trace208) private _redeems; mapping(address controller => uint256) private _claimedRedeems; - function clock() internal view virtual returns (uint48) { + function clock() public view virtual returns (uint48) { return uint48(block.timestamp); } - function delay(address /*controller*/) internal view virtual returns (uint48) { + function redeemDelay(address /*controller*/) public view virtual returns (uint48) { return 1 hours; } - function _isDepositAsync() internal pure virtual override returns (bool) { + function _isRedeemAsync() internal pure virtual override returns (bool) { return true; } @@ -32,7 +32,7 @@ abstract contract ERC7540DelayRedeem is ERC7540 { address owner, uint256 /* requestId */ // discarded and replaced by timepoint based ids ) internal virtual override returns (uint256) { - uint48 timepoint = clock() + delay(controller); + uint48 timepoint = clock() + redeemDelay(controller); uint256 latest = _redeems[controller].latest(); _redeems[controller].push(timepoint, (shares + latest).toUint208()); diff --git a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol index b2d4d780..ab194c49 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol @@ -12,13 +12,13 @@ abstract contract ERC7540EpochDeposit is ERC7540 { using SafeCast for uint256; using DoubleEndedQueue for DoubleEndedQueue.Bytes32Deque; - struct EpochMetadata { + struct EpochDepositMetadata { uint256 totalAssets; uint256 totalShares; mapping(address account => uint256) requests; } - mapping(uint256 epochId => EpochMetadata) private _epochs; + mapping(uint256 epochId => EpochDepositMetadata) private _epochs; mapping(address account => DoubleEndedQueue.Bytes32Deque) private _memberOf; function _isDepositAsync() internal pure virtual override returns (bool) { @@ -34,7 +34,7 @@ abstract contract ERC7540EpochDeposit is ERC7540 { uint256 requestId, address controller ) internal view virtual override returns (uint256) { - EpochMetadata storage details = _epochs[requestId]; + EpochDepositMetadata storage details = _epochs[requestId]; return details.totalShares == 0 ? details.requests[controller] : 0; } @@ -42,7 +42,7 @@ abstract contract ERC7540EpochDeposit is ERC7540 { uint256 requestId, address controller ) internal view virtual override returns (uint256) { - EpochMetadata storage details = _epochs[requestId]; + EpochDepositMetadata storage details = _epochs[requestId]; return details.totalShares == 0 ? 0 : details.requests[controller]; } @@ -99,26 +99,22 @@ abstract contract ERC7540EpochDeposit is ERC7540 { function _fulfillDeposit(uint256 epochId, uint256 totalShares) internal virtual { require(epochId < currentDepositEpoch()); // TODO: too early - EpochMetadata storage details = _epochs[epochId]; + EpochDepositMetadata storage details = _epochs[epochId]; require(details.totalAssets > 0 && details.totalShares == 0); // TODO: invalid resolve details.totalShares = totalShares; // TODO: emit event } - function _computeAsyncWithdraw(uint256 assets, address controller) internal virtual override returns (uint256) { + function _computeAsyncDeposit(uint256 assets, address controller) internal virtual override returns (uint256) { uint256 shares = 0; while (assets > 0) { uint256 epochId = uint256(_memberOf[controller].front()); - EpochMetadata storage details = _epochs[epochId]; + EpochDepositMetadata storage details = _epochs[epochId]; - uint256 requested = details.requests[controller].mulDiv( - details.totalAssets, - details.totalShares, - Math.Rounding.Ceil - ); + uint256 requested = details.requests[controller]; if (requested >= assets) _memberOf[controller].popFront(); uint256 batchAssets = requested.min(assets); @@ -132,15 +128,19 @@ abstract contract ERC7540EpochDeposit is ERC7540 { return assets; } - function _computeAsyncRedeem(uint256 shares, address controller) internal virtual override returns (uint256) { + function _computeAsyncMint(uint256 shares, address controller) internal virtual override returns (uint256) { uint256 assets = 0; while (shares > 0) { uint256 epochId = uint256(_memberOf[controller].front()); - EpochMetadata storage details = _epochs[epochId]; + EpochDepositMetadata storage details = _epochs[epochId]; - uint256 requested = details.requests[controller]; + uint256 requested = details.requests[controller].mulDiv( + details.totalShares, + details.totalAssets, + Math.Rounding.Ceil + ); if (requested >= shares) _memberOf[controller].popFront(); uint256 batchShares = requested.min(shares); diff --git a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol index a5341893..1d4c3700 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol @@ -12,13 +12,13 @@ abstract contract ERC7540EpochRedeem is ERC7540 { using SafeCast for uint256; using DoubleEndedQueue for DoubleEndedQueue.Bytes32Deque; - struct EpochMetadata { + struct EpochRedeemMetadata { uint256 totalShares; uint256 totalAssets; mapping(address account => uint256) requests; } - mapping(uint256 epochId => EpochMetadata) private _epochs; + mapping(uint256 epochId => EpochRedeemMetadata) private _epochs; mapping(address account => DoubleEndedQueue.Bytes32Deque) private _memberOf; function _isRedeemAsync() internal pure virtual override returns (bool) { @@ -34,7 +34,7 @@ abstract contract ERC7540EpochRedeem is ERC7540 { uint256 requestId, address controller ) internal view virtual override returns (uint256) { - EpochMetadata storage details = _epochs[requestId]; + EpochRedeemMetadata storage details = _epochs[requestId]; return details.totalAssets == 0 ? details.requests[controller] : 0; } @@ -42,7 +42,7 @@ abstract contract ERC7540EpochRedeem is ERC7540 { uint256 requestId, address controller ) internal view virtual override returns (uint256) { - EpochMetadata storage details = _epochs[requestId]; + EpochRedeemMetadata storage details = _epochs[requestId]; return details.totalAssets == 0 ? 0 : details.requests[controller]; } @@ -99,7 +99,7 @@ abstract contract ERC7540EpochRedeem is ERC7540 { function _fulfillRedeem(uint256 epochId, uint256 totalAssets) internal virtual { require(epochId < currentRedeemEpoch()); // TODO: too early - EpochMetadata storage details = _epochs[epochId]; + EpochRedeemMetadata storage details = _epochs[epochId]; require(details.totalShares > 0 && details.totalAssets == 0); // TODO: invalid resolve details.totalAssets = totalAssets; @@ -112,7 +112,7 @@ abstract contract ERC7540EpochRedeem is ERC7540 { while (assets > 0) { uint256 epochId = uint256(_memberOf[controller].front()); - EpochMetadata storage details = _epochs[epochId]; + EpochRedeemMetadata storage details = _epochs[epochId]; uint256 requested = details.requests[controller].mulDiv( details.totalAssets, @@ -138,7 +138,7 @@ abstract contract ERC7540EpochRedeem is ERC7540 { while (shares > 0) { uint256 epochId = uint256(_memberOf[controller].front()); - EpochMetadata storage details = _epochs[epochId]; + EpochRedeemMetadata storage details = _epochs[epochId]; uint256 requested = details.requests[controller]; if (requested >= shares) _memberOf[controller].popFront(); From 9ae5522520c86b703a035cae2daa2ddc39de4a08 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 16 Apr 2026 14:55:05 +0200 Subject: [PATCH 15/60] fix issue, thank you Claude --- contracts/token/ERC20/extensions/ERC7540.sol | 4 ++-- .../ERC20/extensions/ERC7540EpochDeposit.sol | 18 +++++++++++------- .../ERC20/extensions/ERC7540EpochRedeem.sol | 18 +++++++++++------- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/contracts/token/ERC20/extensions/ERC7540.sol b/contracts/token/ERC20/extensions/ERC7540.sol index ffdc5ac4..d90ba0ca 100644 --- a/contracts/token/ERC20/extensions/ERC7540.sol +++ b/contracts/token/ERC20/extensions/ERC7540.sol @@ -288,9 +288,9 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { address controller ) public virtual onlyOperatorOrController(_isDepositAsync(), controller, _msgSender()) returns (uint256) { // Note: if _isDepositAsync is false, controller is ignored. - uint256 maxShares = maxMint(_isDepositAsync() ? _msgSender() : receiver); + uint256 maxShares = maxMint(_isDepositAsync() ? controller : receiver); if (shares > maxShares) { - revert ERC4626ExceededMaxMint(_isDepositAsync() ? _msgSender() : receiver, shares, maxShares); + revert ERC4626ExceededMaxMint(_isDepositAsync() ? controller : receiver, shares, maxShares); } uint256 assets = _isDepositAsync() ? _computeAsyncMint(shares, controller) : previewMint(shares); diff --git a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol index ab194c49..e5a50462 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol @@ -115,17 +115,19 @@ abstract contract ERC7540EpochDeposit is ERC7540 { EpochDepositMetadata storage details = _epochs[epochId]; uint256 requested = details.requests[controller]; - if (requested >= assets) _memberOf[controller].popFront(); + if (requested <= assets) _memberOf[controller].popFront(); uint256 batchAssets = requested.min(assets); - uint256 batchShares = batchAssets.mulDiv(details.totalShares, details.totalAssets, Math.Rounding.Floor); + details.requests[controller] -= batchAssets; // May need saturatingSub for rounding handling details.totalAssets -= batchAssets; // May need saturatingSub for rounding handling - details.totalShares -= batchShares; // May need saturatingSub for rounding handling assets -= batchAssets; // May need saturatingSub for rounding handling + + uint256 batchShares = batchAssets.mulDiv(details.totalShares, details.totalAssets, Math.Rounding.Floor); + details.totalShares -= batchShares; // May need saturatingSub for rounding handling shares += batchShares; } - return assets; + return shares; } function _computeAsyncMint(uint256 shares, address controller) internal virtual override returns (uint256) { @@ -141,13 +143,15 @@ abstract contract ERC7540EpochDeposit is ERC7540 { details.totalAssets, Math.Rounding.Ceil ); - if (requested >= shares) _memberOf[controller].popFront(); + if (requested <= shares) _memberOf[controller].popFront(); uint256 batchShares = requested.min(shares); - uint256 batchAssets = batchShares.mulDiv(details.totalAssets, details.totalShares, Math.Rounding.Floor); + details.requests[controller] -= batchShares; // May need saturatingSub for rounding handling details.totalShares -= batchShares; // May need saturatingSub for rounding handling - details.totalAssets -= batchAssets; // May need saturatingSub for rounding handling shares -= batchShares; // May need saturatingSub for rounding handling + + uint256 batchAssets = batchShares.mulDiv(details.totalAssets, details.totalShares, Math.Rounding.Floor); + details.totalAssets -= batchAssets; // May need saturatingSub for rounding handling assets += batchAssets; } diff --git a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol index 1d4c3700..3224f380 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol @@ -119,17 +119,19 @@ abstract contract ERC7540EpochRedeem is ERC7540 { details.totalShares, Math.Rounding.Ceil ); - if (requested >= assets) _memberOf[controller].popFront(); + if (requested <= assets) _memberOf[controller].popFront(); uint256 batchAssets = requested.min(assets); - uint256 batchShares = batchAssets.mulDiv(details.totalShares, details.totalAssets, Math.Rounding.Floor); + details.requests[controller] -= batchAssets; // May need saturatingSub for rounding handling details.totalAssets -= batchAssets; // May need saturatingSub for rounding handling - details.totalShares -= batchShares; // May need saturatingSub for rounding handling assets -= batchAssets; // May need saturatingSub for rounding handling + + uint256 batchShares = batchAssets.mulDiv(details.totalShares, details.totalAssets, Math.Rounding.Floor); + details.totalShares -= batchShares; // May need saturatingSub for rounding handling shares += batchShares; } - return assets; + return shares; } function _computeAsyncRedeem(uint256 shares, address controller) internal virtual override returns (uint256) { @@ -141,13 +143,15 @@ abstract contract ERC7540EpochRedeem is ERC7540 { EpochRedeemMetadata storage details = _epochs[epochId]; uint256 requested = details.requests[controller]; - if (requested >= shares) _memberOf[controller].popFront(); + if (requested <= shares) _memberOf[controller].popFront(); uint256 batchShares = requested.min(shares); - uint256 batchAssets = batchShares.mulDiv(details.totalAssets, details.totalShares, Math.Rounding.Floor); + details.requests[controller] -= batchShares; // May need saturatingSub for rounding handling details.totalShares -= batchShares; // May need saturatingSub for rounding handling - details.totalAssets -= batchAssets; // May need saturatingSub for rounding handling shares -= batchShares; // May need saturatingSub for rounding handling + + uint256 batchAssets = batchShares.mulDiv(details.totalAssets, details.totalShares, Math.Rounding.Floor); + details.totalAssets -= batchAssets; // May need saturatingSub for rounding handling assets += batchAssets; } From 6cba4d85c8215e0b91e94a3a4c4b34b2e4cdf1d5 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 16 Apr 2026 14:58:00 +0200 Subject: [PATCH 16/60] Rename ERC7540AdminFulfill* to ERC7540Admin* Co-Authored-By: Claude Opus 4.6 (1M context) --- ...inFulfillMock.sol => ERC7540AdminMock.sol} | 34 +++++++++---------- ...illDeposit.sol => ERC7540AdminDeposit.sol} | 2 +- ...lfillRedeem.sol => ERC7540AdminRedeem.sol} | 2 +- 3 files changed, 19 insertions(+), 19 deletions(-) rename contracts/mocks/token/{ERC7540AdminFulfillMock.sol => ERC7540AdminMock.sol} (60%) rename contracts/token/ERC20/extensions/{ERC7540AdminFulfillDeposit.sol => ERC7540AdminDeposit.sol} (97%) rename contracts/token/ERC20/extensions/{ERC7540AdminFulfillRedeem.sol => ERC7540AdminRedeem.sol} (98%) diff --git a/contracts/mocks/token/ERC7540AdminFulfillMock.sol b/contracts/mocks/token/ERC7540AdminMock.sol similarity index 60% rename from contracts/mocks/token/ERC7540AdminFulfillMock.sol rename to contracts/mocks/token/ERC7540AdminMock.sol index 4e93fd19..d3e9019c 100644 --- a/contracts/mocks/token/ERC7540AdminFulfillMock.sol +++ b/contracts/mocks/token/ERC7540AdminMock.sol @@ -3,15 +3,15 @@ pragma solidity ^0.8.27; import {ERC7540} from "../../token/ERC20/extensions/ERC7540.sol"; -import {ERC7540AdminFulfillDeposit} from "../../token/ERC20/extensions/ERC7540AdminFulfillDeposit.sol"; -import {ERC7540AdminFulfillRedeem} from "../../token/ERC20/extensions/ERC7540AdminFulfillRedeem.sol"; +import {ERC7540AdminDeposit} from "../../token/ERC20/extensions/ERC7540AdminDeposit.sol"; +import {ERC7540AdminRedeem} from "../../token/ERC20/extensions/ERC7540AdminRedeem.sol"; -abstract contract ERC7540AdminFulfillMock is ERC7540AdminFulfillDeposit, ERC7540AdminFulfillRedeem { - function _isDepositAsync() internal pure virtual override(ERC7540, ERC7540AdminFulfillDeposit) returns (bool) { +abstract contract ERC7540AdminMock is ERC7540AdminDeposit, ERC7540AdminRedeem { + function _isDepositAsync() internal pure virtual override(ERC7540, ERC7540AdminDeposit) returns (bool) { return super._isDepositAsync(); } - function _isRedeemAsync() internal pure virtual override(ERC7540, ERC7540AdminFulfillRedeem) returns (bool) { + function _isRedeemAsync() internal pure virtual override(ERC7540, ERC7540AdminRedeem) returns (bool) { return super._isRedeemAsync(); } @@ -20,7 +20,7 @@ abstract contract ERC7540AdminFulfillMock is ERC7540AdminFulfillDeposit, ERC7540 address controller, address owner, uint256 requestId - ) internal virtual override(ERC7540, ERC7540AdminFulfillDeposit) returns (uint256) { + ) internal virtual override(ERC7540, ERC7540AdminDeposit) returns (uint256) { return super._requestDeposit(assets, controller, owner, requestId); } @@ -29,7 +29,7 @@ abstract contract ERC7540AdminFulfillMock is ERC7540AdminFulfillDeposit, ERC7540 address receiver, uint256 assets, uint256 shares - ) internal virtual override(ERC7540, ERC7540AdminFulfillDeposit) { + ) internal virtual override(ERC7540, ERC7540AdminDeposit) { super._deposit(caller, receiver, assets, shares); } @@ -38,7 +38,7 @@ abstract contract ERC7540AdminFulfillMock is ERC7540AdminFulfillDeposit, ERC7540 address controller, address owner, uint256 requestId - ) internal virtual override(ERC7540, ERC7540AdminFulfillRedeem) returns (uint256) { + ) internal virtual override(ERC7540, ERC7540AdminRedeem) returns (uint256) { return super._requestRedeem(shares, controller, owner, requestId); } @@ -48,59 +48,59 @@ abstract contract ERC7540AdminFulfillMock is ERC7540AdminFulfillDeposit, ERC7540 address owner, uint256 assets, uint256 shares - ) internal virtual override(ERC7540, ERC7540AdminFulfillRedeem) { + ) internal virtual override(ERC7540, ERC7540AdminRedeem) { super._withdraw(caller, receiver, owner, assets, shares); } function _pendingDepositRequest( uint256 requestId, address controller - ) internal view virtual override(ERC7540, ERC7540AdminFulfillDeposit) returns (uint256) { + ) internal view virtual override(ERC7540, ERC7540AdminDeposit) returns (uint256) { return super._pendingDepositRequest(requestId, controller); } function _claimableDepositRequest( uint256 requestId, address controller - ) internal view virtual override(ERC7540, ERC7540AdminFulfillDeposit) returns (uint256) { + ) internal view virtual override(ERC7540, ERC7540AdminDeposit) returns (uint256) { return super._claimableDepositRequest(requestId, controller); } function _pendingRedeemRequest( uint256 requestId, address controller - ) internal view virtual override(ERC7540, ERC7540AdminFulfillRedeem) returns (uint256) { + ) internal view virtual override(ERC7540, ERC7540AdminRedeem) returns (uint256) { return super._pendingRedeemRequest(requestId, controller); } function _claimableRedeemRequest( uint256 requestId, address controller - ) internal view virtual override(ERC7540, ERC7540AdminFulfillRedeem) returns (uint256) { + ) internal view virtual override(ERC7540, ERC7540AdminRedeem) returns (uint256) { return super._claimableRedeemRequest(requestId, controller); } function _asyncMaxDeposit( address owner - ) internal view virtual override(ERC7540, ERC7540AdminFulfillDeposit) returns (uint256) { + ) internal view virtual override(ERC7540, ERC7540AdminDeposit) returns (uint256) { return super._asyncMaxDeposit(owner); } function _asyncMaxMint( address owner - ) internal view virtual override(ERC7540, ERC7540AdminFulfillDeposit) returns (uint256) { + ) internal view virtual override(ERC7540, ERC7540AdminDeposit) returns (uint256) { return super._asyncMaxMint(owner); } function _asyncMaxWithdraw( address owner - ) internal view virtual override(ERC7540, ERC7540AdminFulfillRedeem) returns (uint256) { + ) internal view virtual override(ERC7540, ERC7540AdminRedeem) returns (uint256) { return super._asyncMaxWithdraw(owner); } function _asyncMaxRedeem( address owner - ) internal view virtual override(ERC7540, ERC7540AdminFulfillRedeem) returns (uint256) { + ) internal view virtual override(ERC7540, ERC7540AdminRedeem) returns (uint256) { return super._asyncMaxRedeem(owner); } } diff --git a/contracts/token/ERC20/extensions/ERC7540AdminFulfillDeposit.sol b/contracts/token/ERC20/extensions/ERC7540AdminDeposit.sol similarity index 97% rename from contracts/token/ERC20/extensions/ERC7540AdminFulfillDeposit.sol rename to contracts/token/ERC20/extensions/ERC7540AdminDeposit.sol index 1c2f7c76..50abf36e 100644 --- a/contracts/token/ERC20/extensions/ERC7540AdminFulfillDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540AdminDeposit.sol @@ -5,7 +5,7 @@ pragma solidity ^0.8.27; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {ERC7540} from "./ERC7540.sol"; -abstract contract ERC7540AdminFulfillDeposit is ERC7540 { +abstract contract ERC7540AdminDeposit is ERC7540 { struct PendingDeposit { uint256 pendingAssets; uint256 claimableAssets; diff --git a/contracts/token/ERC20/extensions/ERC7540AdminFulfillRedeem.sol b/contracts/token/ERC20/extensions/ERC7540AdminRedeem.sol similarity index 98% rename from contracts/token/ERC20/extensions/ERC7540AdminFulfillRedeem.sol rename to contracts/token/ERC20/extensions/ERC7540AdminRedeem.sol index b132b51d..0e44dfdc 100644 --- a/contracts/token/ERC20/extensions/ERC7540AdminFulfillRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540AdminRedeem.sol @@ -5,7 +5,7 @@ pragma solidity ^0.8.27; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {ERC7540} from "./ERC7540.sol"; -abstract contract ERC7540AdminFulfillRedeem is ERC7540 { +abstract contract ERC7540AdminRedeem is ERC7540 { struct PendingRedeem { uint256 pendingShares; uint256 claimableShares; From d771a6dda60c928db789a2d64fc1304225111e0e Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 16 Apr 2026 15:07:42 +0200 Subject: [PATCH 17/60] Fix unit mismatch in requests[controller] decrement for Epoch contracts _computeAsyncMint was subtracting shares from an assets-denominated field, and _computeAsyncWithdraw was subtracting assets from a shares-denominated field. Move the decrement after the unit conversion so the correct value is used. Co-Authored-By: Claude Opus 4.6 (1M context) --- contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol | 2 +- contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol index e5a50462..66931dc5 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol @@ -146,11 +146,11 @@ abstract contract ERC7540EpochDeposit is ERC7540 { if (requested <= shares) _memberOf[controller].popFront(); uint256 batchShares = requested.min(shares); - details.requests[controller] -= batchShares; // May need saturatingSub for rounding handling details.totalShares -= batchShares; // May need saturatingSub for rounding handling shares -= batchShares; // May need saturatingSub for rounding handling uint256 batchAssets = batchShares.mulDiv(details.totalAssets, details.totalShares, Math.Rounding.Floor); + details.requests[controller] -= batchAssets; // May need saturatingSub for rounding handling details.totalAssets -= batchAssets; // May need saturatingSub for rounding handling assets += batchAssets; } diff --git a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol index 3224f380..8d8742e7 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol @@ -122,11 +122,11 @@ abstract contract ERC7540EpochRedeem is ERC7540 { if (requested <= assets) _memberOf[controller].popFront(); uint256 batchAssets = requested.min(assets); - details.requests[controller] -= batchAssets; // May need saturatingSub for rounding handling details.totalAssets -= batchAssets; // May need saturatingSub for rounding handling assets -= batchAssets; // May need saturatingSub for rounding handling uint256 batchShares = batchAssets.mulDiv(details.totalShares, details.totalAssets, Math.Rounding.Floor); + details.requests[controller] -= batchShares; // May need saturatingSub for rounding handling details.totalShares -= batchShares; // May need saturatingSub for rounding handling shares += batchShares; } From a909faaf4f06da19a82333f37e2c0a915f8f8c1b Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 16 Apr 2026 15:11:06 +0200 Subject: [PATCH 18/60] Fix typos in ERC7540 contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _redeemRedeemShareDestination → _redeemShareDestination - _assetsToFullfillDeposit → _assetsToFulfillDeposit - _sharesToFullfillReedem → _sharesToFulfillRedeem Co-Authored-By: Claude Opus 4.6 (1M context) --- contracts/token/ERC20/extensions/ERC7540.sol | 10 +++++----- .../token/ERC20/extensions/ERC7540EpochDeposit.sol | 2 +- .../token/ERC20/extensions/ERC7540EpochRedeem.sol | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/contracts/token/ERC20/extensions/ERC7540.sol b/contracts/token/ERC20/extensions/ERC7540.sol index d90ba0ca..dbbf3e72 100644 --- a/contracts/token/ERC20/extensions/ERC7540.sol +++ b/contracts/token/ERC20/extensions/ERC7540.sol @@ -419,11 +419,11 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { if (owner != sender && !isOperator(owner, sender)) { _spendAllowance(owner, sender, shares); } - if (_redeemRedeemShareDestination() == address(0)) { + if (_redeemShareDestination() == address(0)) { _totalPendingRedeemShares += shares; _burn(owner, shares); } else { - _transfer(owner, _redeemRedeemShareDestination(), shares); + _transfer(owner, _redeemShareDestination(), shares); } emit RedeemRequest(controller, owner, requestId, _msgSender(), shares); @@ -431,9 +431,9 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { } function _burnSharesOnRedeemFulfill(uint256 /*assets*/, uint256 shares) internal virtual { - require(_redeemRedeemShareDestination() != address(0), "TODO: shares minted on claim"); + require(_redeemShareDestination() != address(0), "TODO: shares minted on claim"); _totalPendingRedeemShares += shares; - _burn(_redeemRedeemShareDestination(), shares); + _burn(_redeemShareDestination(), shares); } /** @@ -484,7 +484,7 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { return address(0); } - function _redeemRedeemShareDestination() internal view virtual returns (address) { + function _redeemShareDestination() internal view virtual returns (address) { return address(0); } diff --git a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol index 66931dc5..7fceab27 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol @@ -91,7 +91,7 @@ abstract contract ERC7540EpochDeposit is ERC7540 { return super._requestDeposit(assets, controller, owner, epochId); } - function _assetsToFullfillDeposit(uint256 epochId) internal view virtual returns (uint256) { + function _assetsToFulfillDeposit(uint256 epochId) internal view virtual returns (uint256) { return epochId < currentDepositEpoch() && _epochs[epochId].totalShares == 0 ? _epochs[epochId].totalAssets : 0; } diff --git a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol index 8d8742e7..2f934156 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol @@ -91,7 +91,7 @@ abstract contract ERC7540EpochRedeem is ERC7540 { return super._requestRedeem(shares, controller, owner, epochId); } - function _sharesToFullfillReedem(uint256 epochId) internal view virtual returns (uint256) { + function _sharesToFulfillRedeem(uint256 epochId) internal view virtual returns (uint256) { return epochId < currentRedeemEpoch() && _epochs[epochId].totalAssets == 0 ? _epochs[epochId].totalShares : 0; } From 56f7abe4aabd2a6984e30990144b3fdb44a6eb67 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 16 Apr 2026 15:15:23 +0200 Subject: [PATCH 19/60] More fixes --- contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol | 4 ++-- contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol index 7fceab27..9aa5a249 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol @@ -81,11 +81,11 @@ abstract contract ERC7540EpochDeposit is ERC7540 { (bool success, bytes32 lastEpochId) = _memberOf[controller].tryBack(); if (!success || lastEpochId != bytes32(epochId)) { - _memberOf[controller].pushBack(bytes32(epochId)); - // Limit the number of pending epochs per account to 32 to avoid O(n) loop in _asyncMaxWithdraw and _asyncMaxRedeem being a concern. // User that have reached the limit should execute pending (fulfilled) request to cleanup the queue. require(_memberOf[controller].length() < _requestQueueLimit()); + + _memberOf[controller].pushBack(bytes32(epochId)); } return super._requestDeposit(assets, controller, owner, epochId); diff --git a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol index 2f934156..c26041d9 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol @@ -81,11 +81,11 @@ abstract contract ERC7540EpochRedeem is ERC7540 { (bool success, bytes32 lastEpochId) = _memberOf[controller].tryBack(); if (!success || lastEpochId != bytes32(epochId)) { - _memberOf[controller].pushBack(bytes32(epochId)); - // Limit the number of pending epochs per account to 32 to avoid O(n) loop in _asyncMaxWithdraw and _asyncMaxRedeem being a concern. // User that have reached the limit should execute pending (fulfilled) request to cleanup the queue. require(_memberOf[controller].length() < _requestQueueLimit()); + + _memberOf[controller].pushBack(bytes32(epochId)); } return super._requestRedeem(shares, controller, owner, epochId); From 7a9436890d46b20f47f6a0500d2cedee657550e9 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 16 Apr 2026 15:30:13 +0200 Subject: [PATCH 20/60] Rename _computeAsync* to _consumeAsync* to reflect side effects These functions mutate state (epoch totals, queue pops, request decrements), so "consume" better signals the non-pure nature. Co-Authored-By: Claude Opus 4.6 (1M context) --- contracts/mocks/token/ERC7540EpochMock.sol | 16 ++++++++-------- contracts/token/ERC20/extensions/ERC7540.sol | 16 ++++++++-------- .../ERC20/extensions/ERC7540EpochDeposit.sol | 4 ++-- .../ERC20/extensions/ERC7540EpochRedeem.sol | 4 ++-- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/contracts/mocks/token/ERC7540EpochMock.sol b/contracts/mocks/token/ERC7540EpochMock.sol index 793cce5f..374d26eb 100644 --- a/contracts/mocks/token/ERC7540EpochMock.sol +++ b/contracts/mocks/token/ERC7540EpochMock.sol @@ -15,32 +15,32 @@ abstract contract ERC7540EpochMock is ERC7540EpochDeposit, ERC7540EpochRedeem { return super._isRedeemAsync(); } - function _computeAsyncDeposit( + function _consumeAsyncDeposit( uint256 assets, address controller ) internal virtual override(ERC7540, ERC7540EpochDeposit) returns (uint256) { - return super._computeAsyncDeposit(assets, controller); + return super._consumeAsyncDeposit(assets, controller); } - function _computeAsyncMint( + function _consumeAsyncMint( uint256 shares, address controller ) internal virtual override(ERC7540, ERC7540EpochDeposit) returns (uint256) { - return super._computeAsyncMint(shares, controller); + return super._consumeAsyncMint(shares, controller); } - function _computeAsyncRedeem( + function _consumeAsyncRedeem( uint256 shares, address controller ) internal virtual override(ERC7540, ERC7540EpochRedeem) returns (uint256) { - return super._computeAsyncRedeem(shares, controller); + return super._consumeAsyncRedeem(shares, controller); } - function _computeAsyncWithdraw( + function _consumeAsyncWithdraw( uint256 assets, address controller ) internal virtual override(ERC7540, ERC7540EpochRedeem) returns (uint256) { - return super._computeAsyncWithdraw(assets, controller); + return super._consumeAsyncWithdraw(assets, controller); } function _requestDeposit( diff --git a/contracts/token/ERC20/extensions/ERC7540.sol b/contracts/token/ERC20/extensions/ERC7540.sol index dbbf3e72..3534b719 100644 --- a/contracts/token/ERC20/extensions/ERC7540.sol +++ b/contracts/token/ERC20/extensions/ERC7540.sol @@ -267,12 +267,12 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { revert ERC4626ExceededMaxDeposit(_isDepositAsync() ? controller : receiver, assets, maxAssets); } - uint256 shares = _isDepositAsync() ? _computeAsyncDeposit(assets, controller) : previewDeposit(assets); + uint256 shares = _isDepositAsync() ? _consumeAsyncDeposit(assets, controller) : previewDeposit(assets); _deposit(_msgSender(), receiver, assets, shares); return shares; } - function _computeAsyncDeposit(uint256 assets, address controller) internal virtual returns (uint256) { + function _consumeAsyncDeposit(uint256 assets, address controller) internal virtual returns (uint256) { return Math.mulDiv(assets, maxMint(controller), maxDeposit(controller), Math.Rounding.Floor); } @@ -293,12 +293,12 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { revert ERC4626ExceededMaxMint(_isDepositAsync() ? controller : receiver, shares, maxShares); } - uint256 assets = _isDepositAsync() ? _computeAsyncMint(shares, controller) : previewMint(shares); + uint256 assets = _isDepositAsync() ? _consumeAsyncMint(shares, controller) : previewMint(shares); _deposit(_msgSender(), receiver, assets, shares); return assets; } - function _computeAsyncMint(uint256 shares, address controller) internal virtual returns (uint256) { + function _consumeAsyncMint(uint256 shares, address controller) internal virtual returns (uint256) { return Math.mulDiv(shares, maxDeposit(controller), maxMint(controller), Math.Rounding.Ceil); } @@ -317,12 +317,12 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { revert ERC4626ExceededMaxWithdraw(ownerOrController, assets, maxAssets); } - uint256 shares = _isRedeemAsync() ? _computeAsyncWithdraw(assets, ownerOrController) : previewWithdraw(assets); + uint256 shares = _isRedeemAsync() ? _consumeAsyncWithdraw(assets, ownerOrController) : previewWithdraw(assets); _withdraw(_msgSender(), receiver, ownerOrController, assets, shares); return shares; } - function _computeAsyncWithdraw(uint256 assets, address controller) internal virtual returns (uint256) { + function _consumeAsyncWithdraw(uint256 assets, address controller) internal virtual returns (uint256) { return Math.mulDiv(assets, maxRedeem(controller), maxWithdraw(controller), Math.Rounding.Ceil); } @@ -337,12 +337,12 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { revert ERC4626ExceededMaxRedeem(ownerOrController, shares, maxShares); } - uint256 assets = _isRedeemAsync() ? _computeAsyncRedeem(shares, ownerOrController) : previewRedeem(shares); + uint256 assets = _isRedeemAsync() ? _consumeAsyncRedeem(shares, ownerOrController) : previewRedeem(shares); _withdraw(_msgSender(), receiver, ownerOrController, assets, shares); return assets; } - function _computeAsyncRedeem(uint256 shares, address controller) internal virtual returns (uint256) { + function _consumeAsyncRedeem(uint256 shares, address controller) internal virtual returns (uint256) { return Math.mulDiv(shares, maxWithdraw(controller), maxRedeem(controller), Math.Rounding.Floor); } diff --git a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol index 9aa5a249..797cf1aa 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol @@ -106,7 +106,7 @@ abstract contract ERC7540EpochDeposit is ERC7540 { // TODO: emit event } - function _computeAsyncDeposit(uint256 assets, address controller) internal virtual override returns (uint256) { + function _consumeAsyncDeposit(uint256 assets, address controller) internal virtual override returns (uint256) { uint256 shares = 0; while (assets > 0) { @@ -130,7 +130,7 @@ abstract contract ERC7540EpochDeposit is ERC7540 { return shares; } - function _computeAsyncMint(uint256 shares, address controller) internal virtual override returns (uint256) { + function _consumeAsyncMint(uint256 shares, address controller) internal virtual override returns (uint256) { uint256 assets = 0; while (shares > 0) { diff --git a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol index c26041d9..96037336 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol @@ -106,7 +106,7 @@ abstract contract ERC7540EpochRedeem is ERC7540 { // TODO: emit event } - function _computeAsyncWithdraw(uint256 assets, address controller) internal virtual override returns (uint256) { + function _consumeAsyncWithdraw(uint256 assets, address controller) internal virtual override returns (uint256) { uint256 shares = 0; while (assets > 0) { @@ -134,7 +134,7 @@ abstract contract ERC7540EpochRedeem is ERC7540 { return shares; } - function _computeAsyncRedeem(uint256 shares, address controller) internal virtual override returns (uint256) { + function _consumeAsyncRedeem(uint256 shares, address controller) internal virtual override returns (uint256) { uint256 assets = 0; while (shares > 0) { From 1888766b3cd4c0fb6acae0438ead0e4086b46348 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 16 Apr 2026 15:41:55 +0200 Subject: [PATCH 21/60] Rename _consumeAsync* to _consumeClaimable* Co-Authored-By: Claude Opus 4.6 (1M context) --- contracts/mocks/token/ERC7540EpochMock.sol | 16 ++++++++-------- contracts/token/ERC20/extensions/ERC7540.sol | 18 ++++++++++-------- .../ERC20/extensions/ERC7540EpochDeposit.sol | 4 ++-- .../ERC20/extensions/ERC7540EpochRedeem.sol | 4 ++-- 4 files changed, 22 insertions(+), 20 deletions(-) diff --git a/contracts/mocks/token/ERC7540EpochMock.sol b/contracts/mocks/token/ERC7540EpochMock.sol index 374d26eb..a98ebcaf 100644 --- a/contracts/mocks/token/ERC7540EpochMock.sol +++ b/contracts/mocks/token/ERC7540EpochMock.sol @@ -15,32 +15,32 @@ abstract contract ERC7540EpochMock is ERC7540EpochDeposit, ERC7540EpochRedeem { return super._isRedeemAsync(); } - function _consumeAsyncDeposit( + function _consumeClaimableDeposit( uint256 assets, address controller ) internal virtual override(ERC7540, ERC7540EpochDeposit) returns (uint256) { - return super._consumeAsyncDeposit(assets, controller); + return super._consumeClaimableDeposit(assets, controller); } - function _consumeAsyncMint( + function _consumeClaimableMint( uint256 shares, address controller ) internal virtual override(ERC7540, ERC7540EpochDeposit) returns (uint256) { - return super._consumeAsyncMint(shares, controller); + return super._consumeClaimableMint(shares, controller); } - function _consumeAsyncRedeem( + function _consumeClaimableRedeem( uint256 shares, address controller ) internal virtual override(ERC7540, ERC7540EpochRedeem) returns (uint256) { - return super._consumeAsyncRedeem(shares, controller); + return super._consumeClaimableRedeem(shares, controller); } - function _consumeAsyncWithdraw( + function _consumeClaimableWithdraw( uint256 assets, address controller ) internal virtual override(ERC7540, ERC7540EpochRedeem) returns (uint256) { - return super._consumeAsyncWithdraw(assets, controller); + return super._consumeClaimableWithdraw(assets, controller); } function _requestDeposit( diff --git a/contracts/token/ERC20/extensions/ERC7540.sol b/contracts/token/ERC20/extensions/ERC7540.sol index 3534b719..6de2869a 100644 --- a/contracts/token/ERC20/extensions/ERC7540.sol +++ b/contracts/token/ERC20/extensions/ERC7540.sol @@ -267,12 +267,12 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { revert ERC4626ExceededMaxDeposit(_isDepositAsync() ? controller : receiver, assets, maxAssets); } - uint256 shares = _isDepositAsync() ? _consumeAsyncDeposit(assets, controller) : previewDeposit(assets); + uint256 shares = _isDepositAsync() ? _consumeClaimableDeposit(assets, controller) : previewDeposit(assets); _deposit(_msgSender(), receiver, assets, shares); return shares; } - function _consumeAsyncDeposit(uint256 assets, address controller) internal virtual returns (uint256) { + function _consumeClaimableDeposit(uint256 assets, address controller) internal virtual returns (uint256) { return Math.mulDiv(assets, maxMint(controller), maxDeposit(controller), Math.Rounding.Floor); } @@ -293,12 +293,12 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { revert ERC4626ExceededMaxMint(_isDepositAsync() ? controller : receiver, shares, maxShares); } - uint256 assets = _isDepositAsync() ? _consumeAsyncMint(shares, controller) : previewMint(shares); + uint256 assets = _isDepositAsync() ? _consumeClaimableMint(shares, controller) : previewMint(shares); _deposit(_msgSender(), receiver, assets, shares); return assets; } - function _consumeAsyncMint(uint256 shares, address controller) internal virtual returns (uint256) { + function _consumeClaimableMint(uint256 shares, address controller) internal virtual returns (uint256) { return Math.mulDiv(shares, maxDeposit(controller), maxMint(controller), Math.Rounding.Ceil); } @@ -317,12 +317,14 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { revert ERC4626ExceededMaxWithdraw(ownerOrController, assets, maxAssets); } - uint256 shares = _isRedeemAsync() ? _consumeAsyncWithdraw(assets, ownerOrController) : previewWithdraw(assets); + uint256 shares = _isRedeemAsync() + ? _consumeClaimableWithdraw(assets, ownerOrController) + : previewWithdraw(assets); _withdraw(_msgSender(), receiver, ownerOrController, assets, shares); return shares; } - function _consumeAsyncWithdraw(uint256 assets, address controller) internal virtual returns (uint256) { + function _consumeClaimableWithdraw(uint256 assets, address controller) internal virtual returns (uint256) { return Math.mulDiv(assets, maxRedeem(controller), maxWithdraw(controller), Math.Rounding.Ceil); } @@ -337,12 +339,12 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { revert ERC4626ExceededMaxRedeem(ownerOrController, shares, maxShares); } - uint256 assets = _isRedeemAsync() ? _consumeAsyncRedeem(shares, ownerOrController) : previewRedeem(shares); + uint256 assets = _isRedeemAsync() ? _consumeClaimableRedeem(shares, ownerOrController) : previewRedeem(shares); _withdraw(_msgSender(), receiver, ownerOrController, assets, shares); return assets; } - function _consumeAsyncRedeem(uint256 shares, address controller) internal virtual returns (uint256) { + function _consumeClaimableRedeem(uint256 shares, address controller) internal virtual returns (uint256) { return Math.mulDiv(shares, maxWithdraw(controller), maxRedeem(controller), Math.Rounding.Floor); } diff --git a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol index 797cf1aa..44450e56 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol @@ -106,7 +106,7 @@ abstract contract ERC7540EpochDeposit is ERC7540 { // TODO: emit event } - function _consumeAsyncDeposit(uint256 assets, address controller) internal virtual override returns (uint256) { + function _consumeClaimableDeposit(uint256 assets, address controller) internal virtual override returns (uint256) { uint256 shares = 0; while (assets > 0) { @@ -130,7 +130,7 @@ abstract contract ERC7540EpochDeposit is ERC7540 { return shares; } - function _consumeAsyncMint(uint256 shares, address controller) internal virtual override returns (uint256) { + function _consumeClaimableMint(uint256 shares, address controller) internal virtual override returns (uint256) { uint256 assets = 0; while (shares > 0) { diff --git a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol index 96037336..46570f97 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol @@ -106,7 +106,7 @@ abstract contract ERC7540EpochRedeem is ERC7540 { // TODO: emit event } - function _consumeAsyncWithdraw(uint256 assets, address controller) internal virtual override returns (uint256) { + function _consumeClaimableWithdraw(uint256 assets, address controller) internal virtual override returns (uint256) { uint256 shares = 0; while (assets > 0) { @@ -134,7 +134,7 @@ abstract contract ERC7540EpochRedeem is ERC7540 { return shares; } - function _consumeAsyncRedeem(uint256 shares, address controller) internal virtual override returns (uint256) { + function _consumeClaimableRedeem(uint256 shares, address controller) internal virtual override returns (uint256) { uint256 assets = 0; while (shares > 0) { From a06f78925494c05e151326cc7ce8e7da26cffbc0 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 16 Apr 2026 16:24:25 +0200 Subject: [PATCH 22/60] Move claimable bookkeeping into _consumeClaimable* overrides Consolidate claim logic that was previously split between _consumeClaimable* (ratio computation) and _deposit/_withdraw overrides (bookkeeping) into a single _consumeClaimable* hook per subclass. This fixes incorrect receiver-vs-controller keying in Admin and Delay bookkeeping and makes the base ERC7540 stubs revert NotImplemented, consistent with other abstract hooks. Co-Authored-By: Claude Opus 4.6 (1M context) --- contracts/mocks/token/ERC7540AdminMock.sol | 59 +++++++++------- contracts/mocks/token/ERC7540DelayMock.sol | 59 +++++++++------- contracts/mocks/token/ERC7540EpochMock.sol | 68 +++++++++---------- contracts/token/ERC20/extensions/ERC7540.sol | 32 ++++----- .../ERC20/extensions/ERC7540AdminDeposit.sol | 16 +++-- .../ERC20/extensions/ERC7540AdminRedeem.sol | 22 +++--- .../ERC20/extensions/ERC7540DelayDeposit.sol | 13 +++- .../ERC20/extensions/ERC7540DelayRedeem.sol | 19 +++--- 8 files changed, 162 insertions(+), 126 deletions(-) diff --git a/contracts/mocks/token/ERC7540AdminMock.sol b/contracts/mocks/token/ERC7540AdminMock.sol index d3e9019c..21e9860e 100644 --- a/contracts/mocks/token/ERC7540AdminMock.sol +++ b/contracts/mocks/token/ERC7540AdminMock.sol @@ -24,15 +24,6 @@ abstract contract ERC7540AdminMock is ERC7540AdminDeposit, ERC7540AdminRedeem { return super._requestDeposit(assets, controller, owner, requestId); } - function _deposit( - address caller, - address receiver, - uint256 assets, - uint256 shares - ) internal virtual override(ERC7540, ERC7540AdminDeposit) { - super._deposit(caller, receiver, assets, shares); - } - function _requestRedeem( uint256 shares, address controller, @@ -42,16 +33,6 @@ abstract contract ERC7540AdminMock is ERC7540AdminDeposit, ERC7540AdminRedeem { return super._requestRedeem(shares, controller, owner, requestId); } - function _withdraw( - address caller, - address receiver, - address owner, - uint256 assets, - uint256 shares - ) internal virtual override(ERC7540, ERC7540AdminRedeem) { - super._withdraw(caller, receiver, owner, assets, shares); - } - function _pendingDepositRequest( uint256 requestId, address controller @@ -59,18 +40,18 @@ abstract contract ERC7540AdminMock is ERC7540AdminDeposit, ERC7540AdminRedeem { return super._pendingDepositRequest(requestId, controller); } - function _claimableDepositRequest( + function _pendingRedeemRequest( uint256 requestId, address controller - ) internal view virtual override(ERC7540, ERC7540AdminDeposit) returns (uint256) { - return super._claimableDepositRequest(requestId, controller); + ) internal view virtual override(ERC7540, ERC7540AdminRedeem) returns (uint256) { + return super._pendingRedeemRequest(requestId, controller); } - function _pendingRedeemRequest( + function _claimableDepositRequest( uint256 requestId, address controller - ) internal view virtual override(ERC7540, ERC7540AdminRedeem) returns (uint256) { - return super._pendingRedeemRequest(requestId, controller); + ) internal view virtual override(ERC7540, ERC7540AdminDeposit) returns (uint256) { + return super._claimableDepositRequest(requestId, controller); } function _claimableRedeemRequest( @@ -80,6 +61,34 @@ abstract contract ERC7540AdminMock is ERC7540AdminDeposit, ERC7540AdminRedeem { return super._claimableRedeemRequest(requestId, controller); } + function _consumeClaimableDeposit( + uint256 assets, + address controller + ) internal virtual override(ERC7540, ERC7540AdminDeposit) returns (uint256) { + return super._consumeClaimableDeposit(assets, controller); + } + + function _consumeClaimableMint( + uint256 shares, + address controller + ) internal virtual override(ERC7540, ERC7540AdminDeposit) returns (uint256) { + return super._consumeClaimableMint(shares, controller); + } + + function _consumeClaimableRedeem( + uint256 shares, + address controller + ) internal virtual override(ERC7540, ERC7540AdminRedeem) returns (uint256) { + return super._consumeClaimableRedeem(shares, controller); + } + + function _consumeClaimableWithdraw( + uint256 assets, + address controller + ) internal virtual override(ERC7540, ERC7540AdminRedeem) returns (uint256) { + return super._consumeClaimableWithdraw(assets, controller); + } + function _asyncMaxDeposit( address owner ) internal view virtual override(ERC7540, ERC7540AdminDeposit) returns (uint256) { diff --git a/contracts/mocks/token/ERC7540DelayMock.sol b/contracts/mocks/token/ERC7540DelayMock.sol index d5d80c7e..0342cc28 100644 --- a/contracts/mocks/token/ERC7540DelayMock.sol +++ b/contracts/mocks/token/ERC7540DelayMock.sol @@ -28,15 +28,6 @@ abstract contract ERC7540DelayMock is ERC7540DelayDeposit, ERC7540DelayRedeem { return super._requestDeposit(assets, controller, owner, requestId); } - function _deposit( - address caller, - address receiver, - uint256 assets, - uint256 shares - ) internal virtual override(ERC7540, ERC7540DelayDeposit) { - super._deposit(caller, receiver, assets, shares); - } - function _requestRedeem( uint256 shares, address controller, @@ -46,16 +37,6 @@ abstract contract ERC7540DelayMock is ERC7540DelayDeposit, ERC7540DelayRedeem { return super._requestRedeem(shares, controller, owner, requestId); } - function _withdraw( - address caller, - address receiver, - address owner, - uint256 assets, - uint256 shares - ) internal virtual override(ERC7540, ERC7540DelayRedeem) { - super._withdraw(caller, receiver, owner, assets, shares); - } - function _pendingDepositRequest( uint256 requestId, address controller @@ -63,18 +44,18 @@ abstract contract ERC7540DelayMock is ERC7540DelayDeposit, ERC7540DelayRedeem { return super._pendingDepositRequest(requestId, controller); } - function _claimableDepositRequest( + function _pendingRedeemRequest( uint256 requestId, address controller - ) internal view virtual override(ERC7540, ERC7540DelayDeposit) returns (uint256) { - return super._claimableDepositRequest(requestId, controller); + ) internal view virtual override(ERC7540, ERC7540DelayRedeem) returns (uint256) { + return super._pendingRedeemRequest(requestId, controller); } - function _pendingRedeemRequest( + function _claimableDepositRequest( uint256 requestId, address controller - ) internal view virtual override(ERC7540, ERC7540DelayRedeem) returns (uint256) { - return super._pendingRedeemRequest(requestId, controller); + ) internal view virtual override(ERC7540, ERC7540DelayDeposit) returns (uint256) { + return super._claimableDepositRequest(requestId, controller); } function _claimableRedeemRequest( @@ -84,6 +65,34 @@ abstract contract ERC7540DelayMock is ERC7540DelayDeposit, ERC7540DelayRedeem { return super._claimableRedeemRequest(requestId, controller); } + function _consumeClaimableDeposit( + uint256 assets, + address controller + ) internal virtual override(ERC7540, ERC7540DelayDeposit) returns (uint256) { + return super._consumeClaimableDeposit(assets, controller); + } + + function _consumeClaimableMint( + uint256 shares, + address controller + ) internal virtual override(ERC7540, ERC7540DelayDeposit) returns (uint256) { + return super._consumeClaimableMint(shares, controller); + } + + function _consumeClaimableRedeem( + uint256 shares, + address controller + ) internal virtual override(ERC7540, ERC7540DelayRedeem) returns (uint256) { + return super._consumeClaimableRedeem(shares, controller); + } + + function _consumeClaimableWithdraw( + uint256 assets, + address controller + ) internal virtual override(ERC7540, ERC7540DelayRedeem) returns (uint256) { + return super._consumeClaimableWithdraw(assets, controller); + } + function _asyncMaxDeposit( address owner ) internal view virtual override(ERC7540, ERC7540DelayDeposit) returns (uint256) { diff --git a/contracts/mocks/token/ERC7540EpochMock.sol b/contracts/mocks/token/ERC7540EpochMock.sol index a98ebcaf..cfc8e650 100644 --- a/contracts/mocks/token/ERC7540EpochMock.sol +++ b/contracts/mocks/token/ERC7540EpochMock.sol @@ -15,34 +15,6 @@ abstract contract ERC7540EpochMock is ERC7540EpochDeposit, ERC7540EpochRedeem { return super._isRedeemAsync(); } - function _consumeClaimableDeposit( - uint256 assets, - address controller - ) internal virtual override(ERC7540, ERC7540EpochDeposit) returns (uint256) { - return super._consumeClaimableDeposit(assets, controller); - } - - function _consumeClaimableMint( - uint256 shares, - address controller - ) internal virtual override(ERC7540, ERC7540EpochDeposit) returns (uint256) { - return super._consumeClaimableMint(shares, controller); - } - - function _consumeClaimableRedeem( - uint256 shares, - address controller - ) internal virtual override(ERC7540, ERC7540EpochRedeem) returns (uint256) { - return super._consumeClaimableRedeem(shares, controller); - } - - function _consumeClaimableWithdraw( - uint256 assets, - address controller - ) internal virtual override(ERC7540, ERC7540EpochRedeem) returns (uint256) { - return super._consumeClaimableWithdraw(assets, controller); - } - function _requestDeposit( uint256 assets, address controller, @@ -68,18 +40,18 @@ abstract contract ERC7540EpochMock is ERC7540EpochDeposit, ERC7540EpochRedeem { return super._pendingDepositRequest(requestId, controller); } - function _claimableDepositRequest( + function _pendingRedeemRequest( uint256 requestId, address controller - ) internal view virtual override(ERC7540, ERC7540EpochDeposit) returns (uint256) { - return super._claimableDepositRequest(requestId, controller); + ) internal view virtual override(ERC7540, ERC7540EpochRedeem) returns (uint256) { + return super._pendingRedeemRequest(requestId, controller); } - function _pendingRedeemRequest( + function _claimableDepositRequest( uint256 requestId, address controller - ) internal view virtual override(ERC7540, ERC7540EpochRedeem) returns (uint256) { - return super._pendingRedeemRequest(requestId, controller); + ) internal view virtual override(ERC7540, ERC7540EpochDeposit) returns (uint256) { + return super._claimableDepositRequest(requestId, controller); } function _claimableRedeemRequest( @@ -89,6 +61,34 @@ abstract contract ERC7540EpochMock is ERC7540EpochDeposit, ERC7540EpochRedeem { return super._claimableRedeemRequest(requestId, controller); } + function _consumeClaimableDeposit( + uint256 assets, + address controller + ) internal virtual override(ERC7540, ERC7540EpochDeposit) returns (uint256) { + return super._consumeClaimableDeposit(assets, controller); + } + + function _consumeClaimableMint( + uint256 shares, + address controller + ) internal virtual override(ERC7540, ERC7540EpochDeposit) returns (uint256) { + return super._consumeClaimableMint(shares, controller); + } + + function _consumeClaimableRedeem( + uint256 shares, + address controller + ) internal virtual override(ERC7540, ERC7540EpochRedeem) returns (uint256) { + return super._consumeClaimableRedeem(shares, controller); + } + + function _consumeClaimableWithdraw( + uint256 assets, + address controller + ) internal virtual override(ERC7540, ERC7540EpochRedeem) returns (uint256) { + return super._consumeClaimableWithdraw(assets, controller); + } + function _asyncMaxDeposit( address owner ) internal view virtual override(ERC7540, ERC7540EpochDeposit) returns (uint256) { diff --git a/contracts/token/ERC20/extensions/ERC7540.sol b/contracts/token/ERC20/extensions/ERC7540.sol index 6de2869a..4e875203 100644 --- a/contracts/token/ERC20/extensions/ERC7540.sol +++ b/contracts/token/ERC20/extensions/ERC7540.sol @@ -272,10 +272,6 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { return shares; } - function _consumeClaimableDeposit(uint256 assets, address controller) internal virtual returns (uint256) { - return Math.mulDiv(assets, maxMint(controller), maxDeposit(controller), Math.Rounding.Floor); - } - /// @inheritdoc IERC4626 function mint(uint256 shares, address receiver) public virtual override returns (uint256 assets) { return mint(shares, receiver, _msgSender()); @@ -298,10 +294,6 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { return assets; } - function _consumeClaimableMint(uint256 shares, address controller) internal virtual returns (uint256) { - return Math.mulDiv(shares, maxDeposit(controller), maxMint(controller), Math.Rounding.Ceil); - } - function requestRedeem(uint256 shares, address controller, address owner) public virtual returns (uint256) { return _requestRedeem(shares, controller, owner, 0); // 0 is default requestId } @@ -324,10 +316,6 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { return shares; } - function _consumeClaimableWithdraw(uint256 assets, address controller) internal virtual returns (uint256) { - return Math.mulDiv(assets, maxRedeem(controller), maxWithdraw(controller), Math.Rounding.Ceil); - } - /// @inheritdoc IERC4626 function redeem( uint256 shares, @@ -344,10 +332,6 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { return assets; } - function _consumeClaimableRedeem(uint256 shares, address controller) internal virtual returns (uint256) { - return Math.mulDiv(shares, maxWithdraw(controller), maxRedeem(controller), Math.Rounding.Floor); - } - /** * @dev Internal conversion function (from assets to shares) with support for rounding direction. */ @@ -518,6 +502,22 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { revert NotImplemented(); } + function _consumeClaimableDeposit(uint256 /*assets*/, address /*controller*/) internal virtual returns (uint256) { + revert NotImplemented(); + } + + function _consumeClaimableMint(uint256 /*shares*/, address /*controller*/) internal virtual returns (uint256) { + revert NotImplemented(); + } + + function _consumeClaimableWithdraw(uint256 /*assets*/, address /*controller*/) internal virtual returns (uint256) { + revert NotImplemented(); + } + + function _consumeClaimableRedeem(uint256 /*shares*/, address /*controller*/) internal virtual returns (uint256) { + revert NotImplemented(); + } + function _asyncMaxDeposit(address /*owner*/) internal view virtual returns (uint256) { revert NotImplemented(); } diff --git a/contracts/token/ERC20/extensions/ERC7540AdminDeposit.sol b/contracts/token/ERC20/extensions/ERC7540AdminDeposit.sol index 50abf36e..fc90e29b 100644 --- a/contracts/token/ERC20/extensions/ERC7540AdminDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540AdminDeposit.sol @@ -45,10 +45,18 @@ abstract contract ERC7540AdminDeposit is ERC7540 { emit DepositClaimable(controller, 0, assets, shares); } - function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal virtual override { - _deposits[receiver].claimableAssets = Math.saturatingSub(_deposits[receiver].claimableAssets, assets); - _deposits[receiver].claimableShares = Math.saturatingSub(_deposits[receiver].claimableShares, shares); - super._deposit(caller, receiver, assets, shares); + function _consumeClaimableDeposit(uint256 assets, address controller) internal virtual override returns (uint256) { + uint256 shares = Math.mulDiv(assets, maxMint(controller), maxDeposit(controller), Math.Rounding.Floor); + _deposits[controller].claimableAssets = Math.saturatingSub(_deposits[controller].claimableAssets, assets); + _deposits[controller].claimableShares = Math.saturatingSub(_deposits[controller].claimableShares, shares); + return shares; + } + + function _consumeClaimableMint(uint256 shares, address controller) internal virtual override returns (uint256) { + uint256 assets = Math.mulDiv(shares, maxDeposit(controller), maxMint(controller), Math.Rounding.Ceil); + _deposits[controller].claimableAssets = Math.saturatingSub(_deposits[controller].claimableAssets, assets); + _deposits[controller].claimableShares = Math.saturatingSub(_deposits[controller].claimableShares, shares); + return assets; } function _pendingDepositRequest( diff --git a/contracts/token/ERC20/extensions/ERC7540AdminRedeem.sol b/contracts/token/ERC20/extensions/ERC7540AdminRedeem.sol index 0e44dfdc..93bd690b 100644 --- a/contracts/token/ERC20/extensions/ERC7540AdminRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540AdminRedeem.sol @@ -44,16 +44,18 @@ abstract contract ERC7540AdminRedeem is ERC7540 { emit RedeemClaimable(controller, 0, assets, shares); } - function _withdraw( - address caller, - address receiver, - address owner, - uint256 assets, - uint256 shares - ) internal virtual override { - _redeems[receiver].claimableAssets = Math.saturatingSub(_redeems[receiver].claimableAssets, assets); - _redeems[receiver].claimableShares = Math.saturatingSub(_redeems[receiver].claimableShares, shares); - super._withdraw(caller, receiver, owner, assets, shares); + function _consumeClaimableWithdraw(uint256 assets, address controller) internal virtual override returns (uint256) { + uint256 shares = Math.mulDiv(assets, maxRedeem(controller), maxWithdraw(controller), Math.Rounding.Ceil); + _redeems[controller].claimableAssets = Math.saturatingSub(_redeems[controller].claimableAssets, assets); + _redeems[controller].claimableShares = Math.saturatingSub(_redeems[controller].claimableShares, shares); + return shares; + } + + function _consumeClaimableRedeem(uint256 shares, address controller) internal virtual override returns (uint256) { + uint256 assets = Math.mulDiv(shares, maxWithdraw(controller), maxRedeem(controller), Math.Rounding.Floor); + _redeems[controller].claimableAssets = Math.saturatingSub(_redeems[controller].claimableAssets, assets); + _redeems[controller].claimableShares = Math.saturatingSub(_redeems[controller].claimableShares, shares); + return assets; } function _pendingRedeemRequest( diff --git a/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol b/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol index 1af75f36..2ca60d06 100644 --- a/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol @@ -40,9 +40,16 @@ abstract contract ERC7540DelayDeposit is ERC7540 { return super._requestDeposit(assets, controller, owner, timepoint); } - function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal virtual override { - _claimedDeposits[receiver] += assets; - super._deposit(caller, receiver, assets, shares); + function _consumeClaimableDeposit(uint256 assets, address controller) internal virtual override returns (uint256) { + uint256 shares = Math.mulDiv(assets, maxMint(controller), maxDeposit(controller), Math.Rounding.Floor); + _claimedDeposits[controller] += assets; + return shares; + } + + function _consumeClaimableMint(uint256 shares, address controller) internal virtual override returns (uint256) { + uint256 assets = Math.mulDiv(shares, maxDeposit(controller), maxMint(controller), Math.Rounding.Ceil); + _claimedDeposits[controller] += assets; + return assets; } function _pendingDepositRequest( diff --git a/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol b/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol index 8b62e5a1..8bea0af5 100644 --- a/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol @@ -39,15 +39,16 @@ abstract contract ERC7540DelayRedeem is ERC7540 { return super._requestRedeem(shares, controller, owner, timepoint); } - function _withdraw( - address caller, - address receiver, - address owner, - uint256 assets, - uint256 shares - ) internal virtual override { - _claimedRedeems[owner] += shares; - super._withdraw(caller, receiver, owner, assets, shares); + function _consumeClaimableWithdraw(uint256 assets, address controller) internal virtual override returns (uint256) { + uint256 shares = Math.mulDiv(assets, maxRedeem(controller), maxWithdraw(controller), Math.Rounding.Ceil); + _claimedRedeems[controller] += shares; + return shares; + } + + function _consumeClaimableRedeem(uint256 shares, address controller) internal virtual override returns (uint256) { + uint256 assets = Math.mulDiv(shares, maxWithdraw(controller), maxRedeem(controller), Math.Rounding.Floor); + _claimedRedeems[controller] += shares; + return assets; } function _pendingRedeemRequest( From 241048fea7757a2c7d00cc79fac5eb7bb3cdc9bd Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Mon, 20 Apr 2026 16:37:28 +0200 Subject: [PATCH 23/60] Add ERC7540Delay tests and SupportsInterface behavior helper Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ERC20/extensions/ERC7540Delay.test.js | 371 ++++++++++++++++++ .../SupportsInterface.behavior.js | 182 +++++++++ 2 files changed, 553 insertions(+) create mode 100644 test/token/ERC20/extensions/ERC7540Delay.test.js create mode 100644 test/utils/introspection/SupportsInterface.behavior.js diff --git a/test/token/ERC20/extensions/ERC7540Delay.test.js b/test/token/ERC20/extensions/ERC7540Delay.test.js new file mode 100644 index 00000000..4cb939e2 --- /dev/null +++ b/test/token/ERC20/extensions/ERC7540Delay.test.js @@ -0,0 +1,371 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const time = require('@openzeppelin/contracts/test/helpers/time'); +const { shouldSupportInterfaces, INTERFACE_IDS } = require('../../../utils/introspection/SupportsInterface.behavior'); + +const name = 'Vault Shares'; +const symbol = 'vSHR'; +const tokenName = 'Asset Token'; +const tokenSymbol = 'AST'; +const initialBalance = ethers.parseEther('1000'); +const amount = ethers.parseEther('100'); +const delay = 3600n; + +async function fixture() { + const [owner, controller, receiver, operator, other] = await ethers.getSigners(); + + const token = await ethers.deployContract('$ERC20', [tokenName, tokenSymbol]); + const mock = await ethers.deployContract('$ERC7540DelayMock', [name, symbol, token]); + + await token.$_mint(owner, initialBalance); + await token.connect(owner).approve(mock, ethers.MaxUint256); + await mock.connect(owner).setOperator(operator, true); + await mock.connect(controller).setOperator(operator, true); + + return { owner, controller, receiver, operator, other, token, mock }; +} + +describe('ERC7540Delay', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + describe('metadata', function () { + it('token', async function () { + await expect(this.mock.asset()).to.eventually.equal(this.token.target); + }); + + 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('reports default delay', async function () { + await expect(this.mock.depositDelay(this.owner)).to.eventually.equal(delay); + await expect(this.mock.redeemDelay(this.owner)).to.eventually.equal(delay); + }); + + describe('supports ERC-7540 interfaces', function () { + expect(INTERFACE_IDS.ERC7540Operator).to.equal('0xe3bc4e65'); + expect(INTERFACE_IDS.ERC7540Deposit).to.equal('0xce3bbe50'); + expect(INTERFACE_IDS.ERC7540Redeem).to.equal('0x620ee8e4'); + + shouldSupportInterfaces(['ERC7540Operator', 'ERC7540Deposit', 'ERC7540Redeem']); + }); + }); + + describe('deposit flow', function () { + describe('requestDeposit', function () { + it('transfers tokens and emits DepositRequest with timepoint-based requestId', async function () { + const assetsBefore = await this.mock.totalAssets(); + + const tx = this.mock.connect(this.owner).requestDeposit(amount, this.controller, this.owner); + const requestId = (await time.clockFromReceipt.timestamp(tx)) + delay; + + await expect(tx) + .to.emit(this.mock, 'DepositRequest') + .withArgs(this.controller, this.owner, requestId, this.owner, amount); + + await expect(tx).to.changeTokenBalances(this.token, [this.owner, this.mock], [-amount, amount]); + + await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore); + + await expect(this.mock.pendingDepositRequest(requestId, this.controller)).to.eventually.equal(amount); + await expect(this.mock.claimableDepositRequest(requestId, this.controller)).to.eventually.equal(0n); + + await time.increaseTo.timestamp(requestId); + + await expect(this.mock.pendingDepositRequest(requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableDepositRequest(requestId, this.controller)).to.eventually.equal(amount); + }); + + it('operator can trigger request deposit on behalf of owner', async function () { + const tx = this.mock.connect(this.operator).requestDeposit(amount, this.controller, this.owner); + const requestId = (await time.clockFromReceipt.timestamp(tx)) + delay; + + await expect(tx) + .to.emit(this.mock, 'DepositRequest') + .withArgs(this.controller, this.owner, requestId, this.operator, amount); + + await expect(tx).to.changeTokenBalances(this.token, [this.owner, this.mock], [-amount, amount]); + }); + + it('reverts when caller is neither owner nor operator of owner', async function () { + await expect(this.mock.connect(this.other).requestDeposit(amount, this.controller, this.owner)) + .to.be.revertedWithCustomError(this.mock, 'ERC7540InvalidOperator') + .withArgs(this.owner, this.other); + }); + + it('totalAssets excludes in-flight deposits', async function () { + await this.mock.connect(this.owner).requestDeposit(amount, this.controller, this.owner); + await expect(this.mock.totalAssets()).to.eventually.equal(0n); + }); + }); + + describe('claim', function () { + beforeEach(async function () { + const tx = this.mock.connect(this.operator).requestDeposit(amount, this.controller, this.owner); + this.requestId = (await time.clockFromReceipt.timestamp(tx)) + delay; + await time.increaseTo.timestamp(this.requestId); + }); + + describe('via deposit()', function () { + it('mints shares 1:1 to receiver and emits Deposit', async function () { + await expect(this.mock.pendingDepositRequest(this.requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableDepositRequest(this.requestId, this.controller)).to.eventually.equal(amount); + await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(amount); + + const assetsBefore = await this.mock.totalAssets(); + const supplyBefore = await this.mock.totalSupply(); + + const tx = this.mock + .connect(this.controller) + .deposit(amount, this.receiver, ethers.Typed.address(this.controller)); + + await expect(tx).to.emit(this.mock, 'Deposit').withArgs(this.controller, this.receiver, amount, amount); + + await expect(tx).to.changeTokenBalance(this.mock, this.receiver, amount); + + await expect(this.mock.pendingDepositRequest(this.requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableDepositRequest(this.requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(0n); + await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore + amount); + await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore + amount); + }); + + it('operator can trigger deposit on behalf of controller', async function () { + const tx = this.mock + .connect(this.operator) + .deposit(amount, this.receiver, ethers.Typed.address(this.controller)); + + await expect(tx).to.emit(this.mock, 'Deposit').withArgs(this.operator, this.receiver, amount, amount); + + await expect(tx).to.changeTokenBalance(this.mock, this.receiver, amount); + }); + + it('reverts when caller is neither owner nor operator of owner', async function () { + await expect( + this.mock.connect(this.other).deposit(amount, this.receiver, ethers.Typed.address(this.controller)), + ) + .to.be.revertedWithCustomError(this.mock, 'ERC7540InvalidOperator') + .withArgs(this.controller, this.other); + }); + }); + + describe('via mint()', function () { + it('mints exactly the requested shares and emits Deposit', async function () { + await expect(this.mock.pendingDepositRequest(this.requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableDepositRequest(this.requestId, this.controller)).to.eventually.equal(amount); + await expect(this.mock.maxMint(this.controller)).to.eventually.equal(amount); + + const assetsBefore = await this.mock.totalAssets(); + const supplyBefore = await this.mock.totalSupply(); + + const tx = this.mock + .connect(this.controller) + .mint(amount, this.receiver, ethers.Typed.address(this.controller)); + + await expect(tx).to.emit(this.mock, 'Deposit').withArgs(this.controller, this.receiver, amount, amount); + + await expect(tx).to.changeTokenBalance(this.mock, this.receiver, amount); + + await expect(this.mock.pendingDepositRequest(this.requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableDepositRequest(this.requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxMint(this.controller)).to.eventually.equal(0n); + await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore + amount); + await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore + amount); + }); + + it('operator can trigger mint on behalf of controller', async function () { + const tx = this.mock + .connect(this.operator) + .mint(amount, this.receiver, ethers.Typed.address(this.controller)); + + await expect(tx).to.emit(this.mock, 'Deposit').withArgs(this.operator, this.receiver, amount, amount); + + await expect(tx).to.changeTokenBalance(this.mock, this.receiver, amount); + }); + + it('reverts when caller is neither owner nor operator of owner', async function () { + await expect(this.mock.connect(this.other).mint(amount, this.receiver, ethers.Typed.address(this.controller))) + .to.be.revertedWithCustomError(this.mock, 'ERC7540InvalidOperator') + .withArgs(this.controller, this.other); + }); + }); + }); + }); + + describe('redeem flow', function () { + beforeEach(async function () { + await this.token.$_mint(this.mock, initialBalance); + await this.mock.$_mint(this.owner, initialBalance); + }); + + describe('requestRedeem', function () { + it('burns shares, emits RedeemRequest, keeps totalSupply stable via pending counter', async function () { + const supplyBefore = await this.mock.totalSupply(); + + const tx = this.mock.connect(this.owner).requestRedeem(amount, this.controller, this.owner); + const requestId = (await time.clockFromReceipt.timestamp(tx)) + delay; + + await expect(tx) + .to.emit(this.mock, 'RedeemRequest') + .withArgs(this.controller, this.owner, requestId, this.owner, amount); + + await expect(tx).to.changeTokenBalance(this.mock, this.owner, -amount); + + await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore); + + await expect(this.mock.pendingRedeemRequest(requestId, this.controller)).to.eventually.equal(amount); + await expect(this.mock.claimableRedeemRequest(requestId, this.controller)).to.eventually.equal(0n); + + await time.increaseTo.timestamp(requestId); + + await expect(this.mock.pendingRedeemRequest(requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableRedeemRequest(requestId, this.controller)).to.eventually.equal(amount); + }); + + it('operator can trigger request deposit on behalf of owner', async function () { + const tx = this.mock.connect(this.operator).requestRedeem(amount, this.controller, this.owner); + const requestId = (await time.clockFromReceipt.timestamp(tx)) + delay; + + await expect(tx) + .to.emit(this.mock, 'RedeemRequest') + .withArgs(this.controller, this.owner, requestId, this.operator, amount); + + await expect(tx).to.changeTokenBalance(this.mock, this.owner, -amount); + }); + + it('spends allowance when caller is neither owner nor operator', async function () { + await this.mock.connect(this.owner).approve(this.other, amount); + + const tx = this.mock.connect(this.other).requestRedeem(amount, this.controller, this.owner); + const requestId = (await time.clockFromReceipt.timestamp(tx)) + delay; + + await expect(tx) + .to.emit(this.mock, 'RedeemRequest') + .withArgs(this.controller, this.owner, requestId, this.other, amount); + + await expect(this.mock.allowance(this.owner, this.other)).to.eventually.equal(0n); + }); + + it('revert of caller is neither owner nor operator and has no allowance', async function () { + const tx = this.mock.connect(this.other).requestRedeem(amount, this.controller, this.owner); + + await expect(tx) + .to.be.revertedWithCustomError(this.mock, 'ERC20InsufficientAllowance') + .withArgs(this.other, 0n, amount); + }); + }); + + describe('claim', function () { + beforeEach(async function () { + const tx = this.mock.connect(this.operator).requestRedeem(amount, this.controller, this.owner); + this.requestId = (await time.clockFromReceipt.timestamp(tx)) + delay; + await time.increaseTo.timestamp(this.requestId); + }); + + describe('via redeem()', function () { + it('transfers tokens to receiver and emits Withdraw', async function () { + await expect(this.mock.pendingRedeemRequest(this.requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableRedeemRequest(this.requestId, this.controller)).to.eventually.equal(amount); + await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(amount); + + const assetsBefore = await this.mock.totalAssets(); + const supplyBefore = await this.mock.totalSupply(); + + const tx = this.mock.connect(this.controller).redeem(amount, this.receiver, this.controller); + + await expect(tx) + .to.emit(this.mock, 'Withdraw') + .withArgs(this.controller, this.receiver, this.controller, amount, amount); + + await expect(tx).to.changeTokenBalances(this.token, [this.mock, this.receiver], [-amount, amount]); + + await expect(this.mock.pendingRedeemRequest(this.requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableRedeemRequest(this.requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(0n); + await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore - amount); + await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore - amount); + }); + + it('operator can trigger redeem on behalf of controller', async function () { + const tx = this.mock.connect(this.operator).redeem(amount, this.receiver, this.controller); + + await expect(tx) + .to.emit(this.mock, 'Withdraw') + .withArgs(this.operator, this.receiver, this.controller, amount, amount); + + await expect(tx).to.changeTokenBalances(this.token, [this.mock, this.receiver], [-amount, amount]); + }); + + it('reverts when caller is neither owner nor operator of owner', async function () { + await expect(this.mock.connect(this.other).redeem(amount, this.receiver, this.controller)) + .to.be.revertedWithCustomError(this.mock, 'ERC7540InvalidOperator') + .withArgs(this.controller, this.other); + }); + }); + + describe('via withdraw()', function () { + it('transfers exactly the requested tokens and emits Withdraw', async function () { + await expect(this.mock.pendingRedeemRequest(this.requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableRedeemRequest(this.requestId, this.controller)).to.eventually.equal(amount); + await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(amount); + + const assetsBefore = await this.mock.totalAssets(); + const supplyBefore = await this.mock.totalSupply(); + + const tx = this.mock.connect(this.controller).withdraw(amount, this.receiver, this.controller); + + await expect(tx) + .to.emit(this.mock, 'Withdraw') + .withArgs(this.controller, this.receiver, this.controller, amount, amount); + + await expect(tx).to.changeTokenBalances(this.token, [this.mock, this.receiver], [-amount, amount]); + + await expect(this.mock.pendingRedeemRequest(this.requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableRedeemRequest(this.requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(0n); + await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore - amount); + await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore - amount); + }); + + it('operator can trigger withdraw on behalf of controller', async function () { + const tx = this.mock.connect(this.operator).withdraw(amount, this.receiver, this.controller); + + await expect(tx) + .to.emit(this.mock, 'Withdraw') + .withArgs(this.operator, this.receiver, this.controller, amount, amount); + + await expect(tx).to.changeTokenBalances(this.token, [this.mock, this.receiver], [-amount, amount]); + }); + + it('reverts when caller is neither owner nor operator of owner', async function () { + await expect(this.mock.connect(this.other).withdraw(amount, this.receiver, this.controller)) + .to.be.revertedWithCustomError(this.mock, 'ERC7540InvalidOperator') + .withArgs(this.controller, this.other); + }); + }); + }); + }); + + describe('operators', function () { + for (const status of [true, false]) { + it(`setOperator to ${status} emits event and updates status`, async function () { + await expect(this.mock.connect(this.owner).setOperator(this.operator, status)) + .to.emit(this.mock, 'OperatorSet') + .withArgs(this.owner, this.operator, status); + + await expect(this.mock.isOperator(this.owner, this.operator)).to.eventually.equal(status); + }); + } + }); +}); diff --git a/test/utils/introspection/SupportsInterface.behavior.js b/test/utils/introspection/SupportsInterface.behavior.js new file mode 100644 index 00000000..533d246c --- /dev/null +++ b/test/utils/introspection/SupportsInterface.behavior.js @@ -0,0 +1,182 @@ +const { expect } = require('chai'); +const { interfaceId } = require('@openzeppelin/contracts/test/helpers/methods'); +const { mapValues } = require('@openzeppelin/contracts/test/helpers/iterate'); + +const INVALID_ID = '0xffffffff'; +const GOVERNOR_INTERFACE = [ + 'name()', + 'version()', + 'COUNTING_MODE()', + 'hashProposal(address[],uint256[],bytes[],bytes32)', + 'state(uint256)', + 'proposalThreshold()', + 'proposalSnapshot(uint256)', + 'proposalDeadline(uint256)', + 'proposalProposer(uint256)', + 'proposalEta(uint256)', + 'proposalNeedsQueuing(uint256)', + 'votingDelay()', + 'votingPeriod()', + 'quorum(uint256)', + 'getVotes(address,uint256)', + 'getVotesWithParams(address,uint256,bytes)', + 'hasVoted(uint256,address)', + 'propose(address[],uint256[],bytes[],string)', + 'queue(address[],uint256[],bytes[],bytes32)', + 'execute(address[],uint256[],bytes[],bytes32)', + 'cancel(address[],uint256[],bytes[],bytes32)', + 'castVote(uint256,uint8)', + 'castVoteWithReason(uint256,uint8,string)', + 'castVoteWithReasonAndParams(uint256,uint8,string,bytes)', + 'castVoteBySig(uint256,uint8,address,bytes)', + 'castVoteWithReasonAndParamsBySig(uint256,uint8,address,string,bytes,bytes)', +]; +const SIGNATURES = { + ERC165: ['supportsInterface(bytes4)'], + ERC721: [ + 'balanceOf(address)', + 'ownerOf(uint256)', + 'approve(address,uint256)', + 'getApproved(uint256)', + 'setApprovalForAll(address,bool)', + 'isApprovedForAll(address,address)', + 'transferFrom(address,address,uint256)', + 'safeTransferFrom(address,address,uint256)', + 'safeTransferFrom(address,address,uint256,bytes)', + ], + ERC721Enumerable: ['totalSupply()', 'tokenOfOwnerByIndex(address,uint256)', 'tokenByIndex(uint256)'], + ERC721Metadata: ['name()', 'symbol()', 'tokenURI(uint256)'], + ERC1155: [ + 'balanceOf(address,uint256)', + 'balanceOfBatch(address[],uint256[])', + 'setApprovalForAll(address,bool)', + 'isApprovedForAll(address,address)', + 'safeTransferFrom(address,address,uint256,uint256,bytes)', + 'safeBatchTransferFrom(address,address,uint256[],uint256[],bytes)', + ], + ERC1155MetadataURI: ['uri(uint256)'], + ERC1155Receiver: [ + 'onERC1155Received(address,address,uint256,uint256,bytes)', + 'onERC1155BatchReceived(address,address,uint256[],uint256[],bytes)', + ], + ERC1363: [ + 'transferAndCall(address,uint256)', + 'transferAndCall(address,uint256,bytes)', + 'transferFromAndCall(address,address,uint256)', + 'transferFromAndCall(address,address,uint256,bytes)', + 'approveAndCall(address,uint256)', + 'approveAndCall(address,uint256,bytes)', + ], + AccessControl: [ + 'hasRole(bytes32,address)', + 'getRoleAdmin(bytes32)', + 'grantRole(bytes32,address)', + 'revokeRole(bytes32,address)', + 'renounceRole(bytes32,address)', + ], + AccessControlEnumerable: ['getRoleMember(bytes32,uint256)', 'getRoleMemberCount(bytes32)'], + AccessControlDefaultAdminRules: [ + 'defaultAdminDelay()', + 'pendingDefaultAdminDelay()', + 'defaultAdmin()', + 'pendingDefaultAdmin()', + 'defaultAdminDelayIncreaseWait()', + 'changeDefaultAdminDelay(uint48)', + 'rollbackDefaultAdminDelay()', + 'beginDefaultAdminTransfer(address)', + 'acceptDefaultAdminTransfer()', + 'cancelDefaultAdminTransfer()', + ], + Governor: GOVERNOR_INTERFACE, + Governor_5_3: GOVERNOR_INTERFACE.concat('getProposalId(address[],uint256[],bytes[],bytes32)'), + ERC2981: ['royaltyInfo(uint256,uint256)'], + ERC6909: [ + 'balanceOf(address,uint256)', + 'allowance(address,address,uint256)', + 'isOperator(address,address)', + 'transfer(address,uint256,uint256)', + 'transferFrom(address,address,uint256,uint256)', + 'approve(address,uint256,uint256)', + 'setOperator(address,bool)', + ], + ERC6909TokenSupply: ['totalSupply(uint256)'], + ERC6909Metadata: ['name(uint256)', 'symbol(uint256)', 'decimals(uint256)'], + ERC6909ContentURI: ['contractURI()', 'tokenURI(uint256)'], + ERC7540Operator: ['setOperator(address,bool)', 'isOperator(address,address)'], + ERC7540Deposit: [ + 'requestDeposit(uint256,address,address)', + 'pendingDepositRequest(uint256,address)', + 'claimableDepositRequest(uint256,address)', + 'deposit(uint256,address,address)', + 'mint(uint256,address,address)', + ], + ERC7540Redeem: [ + 'requestRedeem(uint256,address,address)', + 'pendingRedeemRequest(uint256,address)', + 'claimableRedeemRequest(uint256,address)', + ], +}; + +const INTERFACE_IDS = mapValues(SIGNATURES, interfaceId); + +function shouldSupportInterfaces(interfaces = [], signatures = SIGNATURES) { + // case where only signatures are provided + if (!Array.isArray(interfaces)) { + signatures = interfaces; + interfaces = Object.keys(interfaces); + } + + interfaces.unshift('ERC165'); + signatures.ERC165 = SIGNATURES.ERC165; + const interfaceIds = mapValues(signatures, interfaceId, ([name]) => interfaces.includes(name)); + + describe('ERC165', function () { + beforeEach(function () { + this.contractUnderTest = this.mock || this.token; + }); + + describe('when the interfaceId is supported', function () { + it('uses less than 30k gas', async function () { + for (const k of interfaces) { + const interfaceId = interfaceIds[k] ?? k; + expect(await this.contractUnderTest.supportsInterface.estimateGas(interfaceId)).to.lte(30_000n); + } + }); + + it('returns true', async function () { + for (const k of interfaces) { + const interfaceId = interfaceIds[k] ?? k; + expect(await this.contractUnderTest.supportsInterface(interfaceId), `does not support ${k}`).to.be.true; + } + }); + }); + + describe('when the interfaceId is not supported', function () { + it('uses less than 30k', async function () { + expect(await this.contractUnderTest.supportsInterface.estimateGas(INVALID_ID)).to.lte(30_000n); + }); + + it('returns false', async function () { + expect(await this.contractUnderTest.supportsInterface(INVALID_ID), `supports ${INVALID_ID}`).to.be.false; + }); + }); + + it('all interface functions are in ABI', async function () { + for (const k of interfaces) { + // skip interfaces for which we don't have a function list + if (signatures[k] === undefined) continue; + + // Check the presence of each function in the contract's interface + for (const fnSig of signatures[k]) { + expect(this.contractUnderTest.interface.hasFunction(fnSig), `did not find ${fnSig}`).to.be.true; + } + } + }); + }); +} + +module.exports = { + SIGNATURES, + INTERFACE_IDS, + shouldSupportInterfaces, +}; From ed0962aba3519ebc435436d70db0110e8c470aa4 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Mon, 20 Apr 2026 22:58:36 +0200 Subject: [PATCH 24/60] Add docs --- contracts/token/ERC20/extensions/ERC7540.sol | 522 +++++++++++++++--- .../ERC20/extensions/ERC7540AdminDeposit.sol | 41 +- .../ERC20/extensions/ERC7540AdminRedeem.sol | 42 +- .../ERC20/extensions/ERC7540DelayDeposit.sol | 38 +- .../ERC20/extensions/ERC7540DelayRedeem.sol | 39 +- .../ERC20/extensions/ERC7540EpochDeposit.sol | 66 ++- .../ERC20/extensions/ERC7540EpochRedeem.sol | 66 ++- 7 files changed, 727 insertions(+), 87 deletions(-) diff --git a/contracts/token/ERC20/extensions/ERC7540.sol b/contracts/token/ERC20/extensions/ERC7540.sol index 4e875203..8cc68e20 100644 --- a/contracts/token/ERC20/extensions/ERC7540.sol +++ b/contracts/token/ERC20/extensions/ERC7540.sol @@ -9,9 +9,53 @@ import {IERC165, ERC165} from "@openzeppelin/contracts/utils/introspection/ERC16 import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {LowLevelCall} from "@openzeppelin/contracts/utils/LowLevelCall.sol"; import {Memory} from "@openzeppelin/contracts/utils/Memory.sol"; - import {IERC7540, IERC7540Operator, IERC7540Deposit, IERC7540Redeem} from "../../../interfaces/IERC7540.sol"; +/** + * @dev Implementation of the ERC-7540 "Asynchronous ERC-4626 Tokenized Vaults" as defined in + * https://eips.ethereum.org/EIPS/eip-7540[ERC-7540]. + * + * This abstract contract provides a single base for building vaults with asynchronous deposit and/or redemption + * flows on top of ERC-4626. It integrates operator management ({IERC7540Operator}), the full ERC-4626 vault + * interface, and routing logic that delegates to either synchronous (standard ERC-4626) or asynchronous paths + * depending on the return value of {_isDepositAsync} and {_isRedeemAsync}. + * + * Subcontracts choose their async behavior by overriding two `internal pure` boolean selectors: + * + * * {_isDepositAsync}: when `true`, the deposit side uses the Request lifecycle (Pending -> Claimable -> Claimed). + * * {_isRedeemAsync}: when `true`, the redeem side uses the Request lifecycle. + * + * Each async path requires a fulfillment strategy that implements the virtual hooks declared in this contract + * (e.g. {_consumeClaimableDeposit}, {_pendingDepositRequest}, {_asyncMaxDeposit}, etc.). + * + * Deposit and redeem strategies are independent: a vault can combine any deposit strategy with any redeem + * strategy (e.g. delay-based deposits with admin-based redeems). + * + * Share custody during the async lifecycle is configurable via {_depositShareOrigin} and + * {_redeemShareDestination}. When these return `address(0)` (the default), shares are minted/burned + * at claim time. When they return a non-zero address, shares are pre-minted to (or transferred to) + * that address at fulfillment time and then transferred to the receiver on claim. + * + * [NOTE] + * ==== + * When implementing a custom fulfillment strategy, the following virtual hooks MUST be overridden for each + * async side enabled: + * + * * Async deposits: {_pendingDepositRequest}, {_claimableDepositRequest}, {_consumeClaimableDeposit}, + * {_consumeClaimableMint}, {_asyncMaxDeposit}, {_asyncMaxMint}. + * + * * Async redeems: {_pendingRedeemRequest}, {_claimableRedeemRequest}, {_consumeClaimableWithdraw}, + * {_consumeClaimableRedeem}, {_asyncMaxWithdraw}, {_asyncMaxRedeem}. + * ==== + * + * [CAUTION] + * ==== + * ERC-7540 introduces operator permissions that allow operators to manage requests on behalf of controllers. + * An operator approved by a controller can request deposits using the controller's assets, request redemptions + * using the controller's shares, and claim assets or shares on behalf of the controller. Users should only + * approve operators they fully trust with both their assets and shares. + * ==== + */ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { using Math for uint256; @@ -22,49 +66,74 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { uint256 private _totalPendingDepositAssets; uint256 private _totalPendingRedeemShares; - /** - * @dev Attempted to deposit more assets than the max amount for `receiver`. - */ + /// @dev Attempted to deposit more assets than the max amount for `receiver`. error ERC4626ExceededMaxDeposit(address receiver, uint256 assets, uint256 max); - /** - * @dev Attempted to mint more shares than the max amount for `receiver`. - */ + /// @dev Attempted to mint more shares than the max amount for `receiver`. error ERC4626ExceededMaxMint(address receiver, uint256 shares, uint256 max); - /** - * @dev Attempted to withdraw more assets than the max amount for `owner`. - */ + /// @dev Attempted to withdraw more assets than the max amount for `owner`. error ERC4626ExceededMaxWithdraw(address owner, uint256 assets, uint256 max); - /** - * @dev Attempted to redeem more shares than the max amount for `owner`. - */ + /// @dev Attempted to redeem more shares than the max amount for `owner`. error ERC4626ExceededMaxRedeem(address owner, uint256 shares, uint256 max); - /// @dev The operator is not the caller or an operator of the controller + /// @dev The `operator` is not the caller or an approved operator of the `controller`. error ERC7540InvalidOperator(address controller, address operator); - error ERC7540DepositIsSync(); + /// @dev A deposit Request was attempted but {_isDepositAsync} returns `false`. + error ERC7540SyncDeposit(); + + /// @dev A synchronous deposit preview was attempted but {_isDepositAsync} returns `true`. error ERC7540DepositIsAsync(); - error ERC7540RedeemIsSync(); - error ERC7540RedeemIsAsync(); - error NotImplemented(); + + /// @dev A redeem Request was attempted but {_isRedeemAsync} returns `false`. + error ERC7540SyncRedeem(); + + /// @dev A synchronous redeem preview was attempted but {_isRedeemAsync} returns `true`. + error ERC7540AsyncRedeem(); + + /// @dev Neither {_isDepositAsync} nor {_isRedeemAsync} returns `true`. + error ERC7540MissingAsync(); + + /// @dev A virtual hook was called that must be implemented by a fulfillment strategy extension. + error ERC7540NotImplemented(); /** - * @dev Set the underlying asset contract. This must be an ERC20-compatible contract (ERC-20 or ERC-777). + * @dev Sets the underlying asset contract. This must be an ERC-20-compatible contract. + * + * Caches the asset's `decimals()` value at construction time. If the call fails (e.g. the asset has not + * been created yet), a default of 18 is used. + * + * Requirements: + * + * * At least one of {_isDepositAsync} or {_isRedeemAsync} must return `true`. + * + * NOTE: Either {_isDepositAsync} or {_isRedeemAsync} must return `true`. Use {ERC4626} otherwise. */ constructor(IERC20 asset_) { - require(_isDepositAsync() || _isRedeemAsync(), "ERC7540: async deposit or redeem required"); + require(_isDepositAsync() || _isRedeemAsync(), ERC7540MissingAsync()); (bool success, uint8 assetDecimals) = _tryGetAssetDecimals(asset_); _underlyingDecimals = success ? assetDecimals : 18; _asset = asset_; } + /** + * @dev Returns `true` if the deposit flow is asynchronous (Request-based). When `false`, {deposit} and + * {mint} behave as standard synchronous ERC-4626 operations. + * + * Override to return `true` in extensions that provide an async deposit fulfillment strategy. + */ function _isDepositAsync() internal pure virtual returns (bool) { return false; } + /** + * @dev Returns `true` if the redeem flow is asynchronous (Request-based). When `false`, {withdraw} and + * {redeem} behave as standard synchronous ERC-4626 operations. + * + * Override to return `true` in extensions that provide an async redeem fulfillment strategy. + */ function _isRedeemAsync() internal pure virtual returns (bool) { return false; } @@ -86,7 +155,12 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { : (false, 0); } - /// @inheritdoc IERC165 + /** + * @dev See {IERC165-supportsInterface}. + * + * Reports support for {IERC7540Operator} unconditionally. Support for {IERC7540Deposit} and + * {IERC7540Redeem} is conditional on the corresponding async selector returning `true`. + */ function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { return interfaceId == type(IERC7540Operator).interfaceId || @@ -95,7 +169,12 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { super.supportsInterface(interfaceId); } - /// @dev See {_checkOperatorOrController}. + /** + * @dev Modifier that enforces operator-or-controller authorization. + * + * When `async` is `false` the check is skipped, which allows the standard ERC-4626 flow where + * `msg.sender` is the caller and no controller/operator distinction exists. + */ modifier onlyOperatorOrController(bool async, address controller, address operator) { _checkOperatorOrController(async, controller, operator); _; @@ -113,16 +192,20 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { } /** - * @dev Set the `operator` status for the `controller` to the `approved` value + * @dev Sets the `operator` approval status for `controller` to `approved`. * - * Emits an {OperatorSet} event if the approval status changes. + * Emits an {IERC7540Operator-OperatorSet} event. */ function _setOperator(address controller, address operator, bool approved) internal { _isOperator[controller][operator] = approved; emit OperatorSet(controller, operator, approved); } - /// @dev Reverts if the `operator` is not the caller or an operator of the `controller` + /** + * @dev Reverts with {ERC7540InvalidOperator} if `operator` is not the `controller` and is not + * approved as an operator for `controller`. When `async` is `false` the check is a no-op, + * preserving standard ERC-4626 authorization semantics. + */ function _checkOperatorOrController(bool async, address controller, address operator) internal view virtual { require( !async || controller == operator || isOperator(controller, operator), @@ -146,44 +229,81 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { return address(_asset); } - /// @inheritdoc IERC4626 + /** + * @dev See {IERC4626-totalAssets}. + * + * Pending deposit assets are subtracted from the vault's token balance, since they have not yet been + * converted into shares and must not be treated as yield for outstanding shareholders. + */ function totalAssets() public view virtual override returns (uint256) { return IERC20(asset()).balanceOf(address(this)) - totalPendingDepositAssets(); } - /// @inheritdoc IERC20 + /** + * @dev See {IERC20-totalSupply}. + * + * Adds {totalPendingRedeemShares} to the ERC-20 supply. When shares are burned at request time + * (i.e. {_redeemShareDestination} returns `address(0)`), pending redeem shares are removed from + * the on-chain supply but still logically outstanding until claimed; this override compensates. + */ function totalSupply() public view virtual override(IERC20, ERC20) returns (uint256) { return super.totalSupply() + totalPendingRedeemShares(); } - /// @inheritdoc IERC7540Deposit + /** + * @dev See {IERC7540Deposit-pendingDepositRequest}. + * + * Requirements: + * + * * {_isDepositAsync} must return `true`. + */ function pendingDepositRequest(uint256 requestId, address controller) public view returns (uint256) { - require(_isDepositAsync(), "ERC7540: deposit must be async"); + require(_isDepositAsync(), ERC7540SyncDeposit()); return _pendingDepositRequest(requestId, controller); } - /// @inheritdoc IERC7540Deposit + /** + * @dev See {IERC7540Deposit-claimableDepositRequest}. + * + * Requirements: + * + * * {_isDepositAsync} must return `true`. + */ function claimableDepositRequest(uint256 requestId, address controller) public view returns (uint256) { - require(_isDepositAsync(), "ERC7540: deposit must be async"); + require(_isDepositAsync(), ERC7540SyncDeposit()); return _claimableDepositRequest(requestId, controller); } - /// @inheritdoc IERC7540Redeem + /** + * @dev See {IERC7540Redeem-pendingRedeemRequest}. + * + * Requirements: + * + * * {_isRedeemAsync} must return `true`. + */ function pendingRedeemRequest(uint256 requestId, address controller) public view returns (uint256) { - require(_isRedeemAsync(), "ERC7540: redeem must be async"); + require(_isRedeemAsync(), ERC7540SyncRedeem()); return _pendingRedeemRequest(requestId, controller); } - /// @inheritdoc IERC7540Redeem + /** + * @dev See {IERC7540Redeem-claimableRedeemRequest}. + * + * Requirements: + * + * * {_isRedeemAsync} must return `true`. + */ function claimableRedeemRequest(uint256 requestId, address controller) public view returns (uint256) { - require(_isRedeemAsync(), "ERC7540: redeem must be async"); + require(_isRedeemAsync(), ERC7540SyncRedeem()); return _claimableRedeemRequest(requestId, controller); } + /// @dev Returns the total amount of underlying assets currently pending in deposit Requests. function totalPendingDepositAssets() public view virtual returns (uint256) { return _totalPendingDepositAssets; } + /// @dev Returns the total amount of vault shares currently pending in redeem Requests. function totalPendingRedeemShares() public view virtual returns (uint256) { return _totalPendingRedeemShares; } @@ -198,64 +318,129 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { return _convertToAssets(shares, Math.Rounding.Floor); } - /// @inheritdoc IERC4626 + /** + * @dev See {IERC4626-maxDeposit}. + * + * When the deposit flow is synchronous, returns `type(uint256).max` (no vault-imposed limit). + * When async, delegates to {_asyncMaxDeposit} which must be provided by the fulfillment strategy. + */ function maxDeposit(address owner) public view virtual returns (uint256) { return _isDepositAsync() ? _asyncMaxDeposit(owner) : type(uint256).max; } - /// @inheritdoc IERC4626 + /** + * @dev See {IERC4626-maxMint}. + * + * When the deposit flow is synchronous, returns `type(uint256).max`. + * When async, delegates to {_asyncMaxMint}. + */ function maxMint(address owner) public view virtual returns (uint256) { return _isDepositAsync() ? _asyncMaxMint(owner) : type(uint256).max; } - /// @inheritdoc IERC4626 + /** + * @dev See {IERC4626-maxWithdraw}. + * + * When the redeem flow is synchronous, returns the asset-equivalent of the owner's share balance. + * When async, delegates to {_asyncMaxWithdraw}. + */ function maxWithdraw(address owner) public view virtual returns (uint256) { return _isRedeemAsync() ? _asyncMaxWithdraw(owner) : previewRedeem(maxRedeem(owner)); } - /// @inheritdoc IERC4626 + /** + * @dev See {IERC4626-maxRedeem}. + * + * When the redeem flow is synchronous, returns the owner's share balance. + * When async, delegates to {_asyncMaxRedeem}. + */ function maxRedeem(address owner) public view virtual returns (uint256) { return _isRedeemAsync() ? _asyncMaxRedeem(owner) : balanceOf(owner); } - /// @inheritdoc IERC4626 + /** + * @dev See {IERC4626-previewDeposit}. + * + * MUST revert when {_isDepositAsync} returns `true`, per the ERC-7540 specification which + * mandates that preview functions revert for async flows. + */ function previewDeposit(uint256 assets) public view virtual returns (uint256) { require(!_isDepositAsync(), ERC7540DepositIsAsync()); return _convertToShares(assets, Math.Rounding.Floor); } - /// @inheritdoc IERC4626 + /** + * @dev See {IERC4626-previewMint}. + * + * MUST revert when {_isDepositAsync} returns `true`. + */ function previewMint(uint256 shares) public view virtual returns (uint256) { require(!_isDepositAsync(), ERC7540DepositIsAsync()); return _convertToAssets(shares, Math.Rounding.Ceil); } - /// @inheritdoc IERC4626 + /** + * @dev See {IERC4626-previewWithdraw}. + * + * MUST revert when {_isRedeemAsync} returns `true`. + */ function previewWithdraw(uint256 assets) public view virtual returns (uint256) { - require(!_isRedeemAsync(), ERC7540RedeemIsAsync()); + require(!_isRedeemAsync(), ERC7540AsyncRedeem()); return _convertToShares(assets, Math.Rounding.Ceil); } - /// @inheritdoc IERC4626 + /** + * @dev See {IERC4626-previewRedeem}. + * + * MUST revert when {_isRedeemAsync} returns `true`. + */ function previewRedeem(uint256 shares) public view virtual returns (uint256) { - require(!_isRedeemAsync(), ERC7540RedeemIsAsync()); + require(!_isRedeemAsync(), ERC7540AsyncRedeem()); return _convertToAssets(shares, Math.Rounding.Floor); } + /** + * @dev See {IERC7540Deposit-requestDeposit}. + * + * Transfers `assets` from `owner` into the vault and submits a deposit Request for `controller`. + * The Request enters Pending state. Uses `requestId = 0` by default; override {_requestDeposit} + * to use non-zero request IDs. + * + * Requirements: + * + * * {_isDepositAsync} must return `true`. + * * `owner` must be `msg.sender` or `msg.sender` must be an approved operator of `owner`. + * * `owner` must have approved the vault for at least `assets` of the underlying token. + */ function requestDeposit( uint256 assets, address controller, address owner ) public virtual onlyOperatorOrController(_isDepositAsync(), owner, _msgSender()) returns (uint256) { - return _requestDeposit(assets, controller, owner, 0); // 0 is the default requestId + return _requestDeposit(assets, controller, owner, 0); } - /// @inheritdoc IERC4626 + /** + * @dev See {IERC4626-deposit}. Calls the three-argument overload with `msg.sender` as `controller`. + */ function deposit(uint256 assets, address receiver) public virtual override returns (uint256) { return deposit(assets, receiver, _msgSender()); } - /// @inheritdoc IERC7540Deposit + /** + * @dev See {IERC7540Deposit-deposit}. + * + * When async, claims `assets` worth of shares from a Claimable deposit Request controlled by `controller`, + * and transfers the resulting shares to `receiver`. When sync, behaves as standard ERC-4626 deposit. + * + * NOTE: When {_isDepositAsync} is `false`, the `controller` parameter is ignored and `receiver` is used + * for limit checks, matching standard ERC-4626 semantics. + * + * Requirements: + * + * * `assets` must not exceed {maxDeposit} for the relevant account. + * * When async, `msg.sender` must be `controller` or an approved operator of `controller`. + */ function deposit( uint256 assets, address receiver, @@ -272,12 +457,26 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { return shares; } - /// @inheritdoc IERC4626 + /** + * @dev See {IERC4626-mint}. Calls the three-argument overload with `msg.sender` as `controller`. + */ function mint(uint256 shares, address receiver) public virtual override returns (uint256 assets) { return mint(shares, receiver, _msgSender()); } - /// @inheritdoc IERC7540Deposit + /** + * @dev See {IERC7540Deposit-mint}. + * + * When async, claims exactly `shares` from a Claimable deposit Request controlled by `controller`, + * and transfers them to `receiver`. When sync, behaves as standard ERC-4626 mint. + * + * NOTE: When {_isDepositAsync} is `false`, the `controller` parameter is ignored. + * + * Requirements: + * + * * `shares` must not exceed {maxMint} for the relevant account. + * * When async, `msg.sender` must be `controller` or an approved operator of `controller`. + */ function mint( uint256 shares, address receiver, @@ -294,11 +493,39 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { return assets; } + /** + * @dev See {IERC7540Redeem-requestRedeem}. + * + * Assumes control of `shares` from `owner` and submits a redeem Request for `controller`. + * The Request enters Pending state. Uses `requestId = 0` by default. + * + * Authorization for a `msg.sender` not equal to `owner` may come from either ERC-20 approval + * over the shares of `owner` or from operator approval (see {IERC7540Operator}). This is + * consistent with the approach described in ERC-6909. + * + * Requirements: + * + * * {_isRedeemAsync} must return `true`. + */ function requestRedeem(uint256 shares, address controller, address owner) public virtual returns (uint256) { - return _requestRedeem(shares, controller, owner, 0); // 0 is default requestId + return _requestRedeem(shares, controller, owner, 0); } - /// @inheritdoc IERC4626 + /** + * @dev See {IERC4626-withdraw}. + * + * When async, claims `assets` from a Claimable redeem Request controlled by `ownerOrController`, + * and transfers the underlying assets to `receiver`. When sync, behaves as standard ERC-4626 withdraw. + * + * NOTE: Per ERC-7540, when async the `ownerOrController` parameter acts as the `controller` + * (replacing the traditional ERC-4626 `owner` role). + * + * Requirements: + * + * * `assets` must not exceed {maxWithdraw} for `ownerOrController`. + * * When async, `msg.sender` must be `ownerOrController` or an approved operator. + * * When sync, `msg.sender` must be `ownerOrController` or have sufficient ERC-20 allowance. + */ function withdraw( uint256 assets, address receiver, @@ -316,7 +543,21 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { return shares; } - /// @inheritdoc IERC4626 + /** + * @dev See {IERC4626-redeem}. + * + * When async, claims `shares` from a Claimable redeem Request controlled by `ownerOrController`, + * and transfers the corresponding underlying assets to `receiver`. When sync, behaves as standard + * ERC-4626 redeem. + * + * NOTE: Per ERC-7540, when async the `ownerOrController` parameter acts as the `controller`. + * + * Requirements: + * + * * `shares` must not exceed {maxRedeem} for `ownerOrController`. + * * When async, `msg.sender` must be `ownerOrController` or an approved operator. + * * When sync, `msg.sender` must be `ownerOrController` or have sufficient ERC-20 allowance. + */ function redeem( uint256 shares, address receiver, @@ -334,6 +575,10 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { /** * @dev Internal conversion function (from assets to shares) with support for rounding direction. + * + * Uses virtual shares and virtual assets to mitigate the inflation attack vector described in + * https://docs.openzeppelin.com/contracts/5.x/erc4626#inflation-attack[ERC-4626 security considerations]. + * The offset is configurable via {_decimalsOffset}. */ function _convertToShares(uint256 assets, Math.Rounding rounding) internal view virtual returns (uint256) { return assets.mulDiv(totalSupply() + 10 ** _decimalsOffset(), totalAssets() + 1, rounding); @@ -346,13 +591,29 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { return shares.mulDiv(totalAssets() + 1, totalSupply() + 10 ** _decimalsOffset(), rounding); } + /** + * @dev Internal handler for deposit Requests. Increments {totalPendingDepositAssets}, transfers + * assets from `owner` into the vault, and emits {IERC7540Deposit-DepositRequest}. + * + * Strategy extensions (e.g. {ERC7540AdminDeposit}) should override this to record per-controller + * pending state before calling `super._requestDeposit(...)`. + * + * NOTE: Pending accounting is updated before {_transferIn} to follow Checks-Effects-Interactions. + * Assets with transfer hooks (e.g. ERC-777) may observe {totalAssets} temporarily understated + * during the transfer, since `_totalPendingDepositAssets` is already incremented while the + * token balance has not yet increased. + * + * Requirements: + * + * * {_isDepositAsync} must return `true`, otherwise reverts with {ERC7540SyncDeposit}. + */ function _requestDeposit( uint256 assets, address controller, address owner, uint256 requestId ) internal virtual returns (uint256) { - require(_isDepositAsync(), ERC7540DepositIsSync()); + require(_isDepositAsync(), ERC7540SyncDeposit()); _totalPendingDepositAssets += assets; @@ -363,14 +624,35 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { return requestId; } + /** + * @dev Mints shares to {_depositShareOrigin} as part of deposit fulfillment when using the + * pre-mint share custody model. Decrements {totalPendingDepositAssets} by `assets`. + * + * IMPORTANT: This function requires {_depositShareOrigin} to return a non-zero address. + * When {_depositShareOrigin} returns `address(0)`, shares are minted directly at claim time + * inside {_deposit} and this function must not be called. + */ function _mintSharesOnDepositFulfill(uint256 assets, uint256 shares) internal virtual { - require(_depositShareOrigin() != address(0), "TODO: shares minted on claim"); + // TODO: support the mint-on-claim model (shares minted at claim time when _depositShareOrigin is address(0)) + require(_depositShareOrigin() != address(0), "ERC7540: not yet supported"); _totalPendingDepositAssets -= assets; _mint(_depositShareOrigin(), shares); } /** - * @dev Deposit/mint common workflow. + * @dev Common workflow for deposit and mint claim operations. + * + * Handles three cases depending on the vault configuration: + * + * 1. **Synchronous** ({_isDepositAsync} returns `false`): transfers assets from `caller` into the vault + * and mints new shares to `receiver`. Standard ERC-4626 behavior. + * 2. **Async, mint-on-claim** ({_depositShareOrigin} returns `address(0)`): decrements + * `_totalPendingDepositAssets` and mints new shares to `receiver`. No asset transfer occurs + * since assets were already transferred during {requestDeposit}. + * 3. **Async, pre-minted** ({_depositShareOrigin} returns non-zero): transfers pre-minted shares + * from the share origin address to `receiver`. + * + * Emits {IERC4626-Deposit}. */ function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal virtual { // If asset() is ERC-777, `transferFrom` can trigger a reentrancy BEFORE the transfer happens through the @@ -393,13 +675,31 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { emit Deposit(caller, receiver, assets, shares); } + /** + * @dev Internal handler for redeem Requests. Assumes control of `shares` from `owner` and + * emits {IERC7540Redeem-RedeemRequest}. + * + * Share custody depends on {_redeemShareDestination}: + * + * * `address(0)` (default): shares are burned immediately and `_totalPendingRedeemShares` is + * incremented to keep {totalSupply} accurate. + * * Non-zero address: shares are transferred to that address and held until fulfillment. + * + * Authorization for a `msg.sender` not equal to `owner` is checked via operator status first; + * if that fails, ERC-20 allowance is spent via {ERC20-_spendAllowance}. This dual-authorization + * approach is consistent with ERC-6909 semantics. + * + * Requirements: + * + * * {_isRedeemAsync} must return `true`, otherwise reverts with {ERC7540SyncRedeem}. + */ function _requestRedeem( uint256 shares, address controller, address owner, uint256 requestId ) internal virtual returns (uint256) { - require(_isRedeemAsync(), ERC7540RedeemIsSync()); + require(_isRedeemAsync(), ERC7540SyncRedeem()); address sender = _msgSender(); if (owner != sender && !isOperator(owner, sender)) { @@ -416,14 +716,32 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { return requestId; } + /** + * @dev Burns shares from {_redeemShareDestination} as part of redeem fulfillment when using + * the escrow share custody model. Increments {totalPendingRedeemShares} by `shares`. + * + * IMPORTANT: This function requires {_redeemShareDestination} to return a non-zero address. + * When {_redeemShareDestination} returns `address(0)`, shares were already burned at request + * time inside {_requestRedeem} and this function must not be called. + */ function _burnSharesOnRedeemFulfill(uint256 /*assets*/, uint256 shares) internal virtual { - require(_redeemShareDestination() != address(0), "TODO: shares minted on claim"); + // TODO: support the burn-on-request model (shares already burned when _redeemShareDestination is address(0)) + require(_redeemShareDestination() != address(0), "ERC7540: not yet supported"); _totalPendingRedeemShares += shares; _burn(_redeemShareDestination(), shares); } /** - * @dev Withdraw/redeem common workflow. + * @dev Common workflow for withdraw and redeem claim operations. + * + * Handles two cases depending on the vault configuration: + * + * 1. **Synchronous** ({_isRedeemAsync} returns `false`): spends ERC-20 allowance if `caller` is not + * `owner`, burns shares from `owner`, and transfers underlying assets to `receiver`. + * 2. **Async**: decrements `_totalPendingRedeemShares` (shares were already burned/escrowed during + * the Request or fulfillment phase) and transfers underlying assets to `receiver`. + * + * Emits {IERC4626-Withdraw}. */ function _withdraw( address caller, @@ -452,85 +770,143 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { emit Withdraw(caller, receiver, owner, assets, shares); } - /// @dev Performs a transfer in of underlying assets. The default implementation uses `SafeERC20`. Used by {_deposit}. + /** + * @dev Performs a transfer-in of underlying assets from `from` to the vault. + * The default implementation uses {SafeERC20-safeTransferFrom}. Used by {_deposit} (sync) and + * {_requestDeposit} (async). + */ function _transferIn(address from, uint256 assets) internal virtual { SafeERC20.safeTransferFrom(IERC20(asset()), from, address(this), assets); } - /// @dev Performs a transfer out of underlying assets. The default implementation uses `SafeERC20`. Used by {_withdraw}. + /** + * @dev Performs a transfer-out of underlying assets from the vault to `to`. + * The default implementation uses {SafeERC20-safeTransfer}. Used by {_withdraw}. + */ function _transferOut(address to, uint256 assets) internal virtual { SafeERC20.safeTransfer(IERC20(asset()), to, assets); } + /** + * @dev Returns the decimal offset used for virtual shares/assets in {_convertToShares} and + * {_convertToAssets}. Defaults to 0. Increase to strengthen inflation-attack protection + * at the cost of share-price granularity. + * + * See https://docs.openzeppelin.com/contracts/5.x/erc4626#inflation-attack[ERC-4626 security considerations]. + */ function _decimalsOffset() internal view virtual returns (uint8) { return 0; } + /** + * @dev Returns the address from which shares are transferred to the receiver on deposit claim. + * + * * `address(0)` (default): shares are minted directly to the receiver at claim time. Pending + * deposit assets are tracked via {totalPendingDepositAssets} and decremented in {_deposit}. + * * Non-zero address: shares are pre-minted to this address during fulfillment (via + * {_mintSharesOnDepositFulfill}) and transferred to the receiver on claim. + */ function _depositShareOrigin() internal view virtual returns (address) { return address(0); } + /** + * @dev Returns the address to which shares are transferred (escrowed) on redeem request. + * + * * `address(0)` (default): shares are burned immediately at request time. Pending redeem shares + * are tracked via {totalPendingRedeemShares} so that {totalSupply} remains accurate. + * * Non-zero address: shares are transferred to this address on request and burned during + * fulfillment (via {_burnSharesOnRedeemFulfill}). + */ function _redeemShareDestination() internal view virtual returns (address) { return address(0); } + // ============================================================== + // VIRTUAL HOOKS FOR STRATEGY EXTENSIONS + // ============================================================== + + /// @dev Returns the amount of assets in Pending state for `controller` with the given `requestId`. function _pendingDepositRequest( uint256 /*requestId*/, address /*controller*/ ) internal view virtual returns (uint256) { - revert NotImplemented(); + revert ERC7540NotImplemented(); } + /// @dev Returns the amount of assets in Claimable state for `controller` with the given `requestId`. function _claimableDepositRequest( uint256 /*requestId*/, address /*controller*/ ) internal view virtual returns (uint256) { - revert NotImplemented(); + revert ERC7540NotImplemented(); } + /// @dev Returns the amount of shares in Pending state for `controller` with the given `requestId`. function _pendingRedeemRequest( uint256 /*requestId*/, address /*controller*/ ) internal view virtual returns (uint256) { - revert NotImplemented(); + revert ERC7540NotImplemented(); } + /// @dev Returns the amount of shares in Claimable state for `controller` with the given `requestId`. function _claimableRedeemRequest( uint256 /*requestId*/, address /*controller*/ ) internal view virtual returns (uint256) { - revert NotImplemented(); + revert ERC7540NotImplemented(); } + /** + * @dev Consumes `assets` worth of a Claimable deposit for `controller` and returns the corresponding + * number of shares. Called by {deposit} (three-argument overload) in async mode. + */ function _consumeClaimableDeposit(uint256 /*assets*/, address /*controller*/) internal virtual returns (uint256) { - revert NotImplemented(); + revert ERC7540NotImplemented(); } + /** + * @dev Consumes `shares` worth of a Claimable deposit for `controller` and returns the corresponding + * number of assets. Called by {mint} (three-argument overload) in async mode. + */ function _consumeClaimableMint(uint256 /*shares*/, address /*controller*/) internal virtual returns (uint256) { - revert NotImplemented(); + revert ERC7540NotImplemented(); } + /** + * @dev Consumes `assets` worth of a Claimable redeem for `controller` and returns the corresponding + * number of shares. Called by {withdraw} in async mode. + */ function _consumeClaimableWithdraw(uint256 /*assets*/, address /*controller*/) internal virtual returns (uint256) { - revert NotImplemented(); + revert ERC7540NotImplemented(); } + /** + * @dev Consumes `shares` worth of a Claimable redeem for `controller` and returns the corresponding + * number of assets. Called by {redeem} in async mode. + */ function _consumeClaimableRedeem(uint256 /*shares*/, address /*controller*/) internal virtual returns (uint256) { - revert NotImplemented(); + revert ERC7540NotImplemented(); } + /// @dev Returns the maximum assets that can be claimed via {deposit} for an async `owner`. function _asyncMaxDeposit(address /*owner*/) internal view virtual returns (uint256) { - revert NotImplemented(); + revert ERC7540NotImplemented(); } + /// @dev Returns the maximum shares that can be claimed via {mint} for an async `owner`. function _asyncMaxMint(address /*owner*/) internal view virtual returns (uint256) { - revert NotImplemented(); + revert ERC7540NotImplemented(); } + /// @dev Returns the maximum assets that can be claimed via {withdraw} for an async `owner`. function _asyncMaxWithdraw(address /*owner*/) internal view virtual returns (uint256) { - revert NotImplemented(); + revert ERC7540NotImplemented(); } + /// @dev Returns the maximum shares that can be claimed via {redeem} for an async `owner`. function _asyncMaxRedeem(address /*owner*/) internal view virtual returns (uint256) { - revert NotImplemented(); + revert ERC7540NotImplemented(); } } diff --git a/contracts/token/ERC20/extensions/ERC7540AdminDeposit.sol b/contracts/token/ERC20/extensions/ERC7540AdminDeposit.sol index fc90e29b..bbd4e3b1 100644 --- a/contracts/token/ERC20/extensions/ERC7540AdminDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540AdminDeposit.sol @@ -5,7 +5,25 @@ pragma solidity ^0.8.27; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {ERC7540} from "./ERC7540.sol"; +/** + * @dev Admin-controlled (operator-triggered) fulfillment strategy for asynchronous deposits. + * + * Extends {ERC7540} with a deposit flow where a privileged caller explicitly transitions requests + * from Pending to Claimable by calling {_fulfillDeposit}. The caller provides both the `assets` + * amount and the corresponding `shares`, giving the fulfiller explicit control over the exchange rate. + * + * This is the most flexible fulfillment model. Epoch-based batch settlement, FIFO queues, and + * cross-chain oracle-gated settlement can all be composed on top. Production equivalents include + * USDai, Nest (Plume), MetaVault, SukukFi, and Centrifuge. + * + * All requests share `requestId = 0` (per-controller accounting only). + */ abstract contract ERC7540AdminDeposit is ERC7540 { + /** + * @dev Struct containing the per-controller state for a deposit request. + * When a request becomes claimable via {_fulfillDeposit}, the exchange rate is locked + * in the `claimableAssets` / `claimableShares` pair. + */ struct PendingDeposit { uint256 pendingAssets; uint256 claimableAssets; @@ -17,13 +35,15 @@ abstract contract ERC7540AdminDeposit is ERC7540 { /// @dev Emitted when a deposit request transitions from Pending to Claimable. event DepositClaimable(address indexed controller, uint256 indexed requestId, uint256 assets, uint256 shares); - /// @dev The amount of assets requested is greater than the amount of assets pending. + /// @dev The `assets` to fulfill exceeds the `pendingAssets` for the controller. error ERC7540DepositInsufficientPendingAssets(uint256 assets, uint256 pendingAssets); + /// @inheritdoc ERC7540 function _isDepositAsync() internal pure virtual override returns (bool) { return true; } + /// @dev Records per-controller pending state before delegating to {ERC7540-_requestDeposit}. function _requestDeposit( uint256 assets, address controller, @@ -34,6 +54,19 @@ abstract contract ERC7540AdminDeposit is ERC7540 { return super._requestDeposit(assets, controller, owner, requestId); } + /** + * @dev Fulfills a pending deposit request by transitioning it from Pending to Claimable state. + * + * The caller provides both `assets` and `shares`, locking the exchange rate at fulfillment time. + * The fulfiller should ensure the vault's `totalAssets()` already reflects the deposited assets + * (e.g. after deploying them to a yield source) to avoid diluting existing holders. + * + * Emits a {DepositClaimable} event. + * + * Requirements: + * + * * `assets` must not exceed the pending deposit amount for the `controller`. + */ function _fulfillDeposit(uint256 assets, uint256 shares, address controller) internal virtual { uint256 pendingAssets = pendingDepositRequest(0, controller); require(assets <= pendingAssets, ERC7540DepositInsufficientPendingAssets(assets, pendingAssets)); @@ -45,6 +78,7 @@ abstract contract ERC7540AdminDeposit is ERC7540 { emit DepositClaimable(controller, 0, assets, shares); } + /// @dev Consumes `assets` from the claimable deposit and returns the proportional shares (rounded down). function _consumeClaimableDeposit(uint256 assets, address controller) internal virtual override returns (uint256) { uint256 shares = Math.mulDiv(assets, maxMint(controller), maxDeposit(controller), Math.Rounding.Floor); _deposits[controller].claimableAssets = Math.saturatingSub(_deposits[controller].claimableAssets, assets); @@ -52,6 +86,7 @@ abstract contract ERC7540AdminDeposit is ERC7540 { return shares; } + /// @dev Consumes `shares` from the claimable deposit and returns the proportional assets (rounded up). function _consumeClaimableMint(uint256 shares, address controller) internal virtual override returns (uint256) { uint256 assets = Math.mulDiv(shares, maxDeposit(controller), maxMint(controller), Math.Rounding.Ceil); _deposits[controller].claimableAssets = Math.saturatingSub(_deposits[controller].claimableAssets, assets); @@ -59,6 +94,7 @@ abstract contract ERC7540AdminDeposit is ERC7540 { return assets; } + /// @inheritdoc ERC7540 function _pendingDepositRequest( uint256 /*requestId*/, address controller @@ -66,6 +102,7 @@ abstract contract ERC7540AdminDeposit is ERC7540 { return _deposits[controller].pendingAssets; } + /// @inheritdoc ERC7540 function _claimableDepositRequest( uint256 /*requestId*/, address controller @@ -73,10 +110,12 @@ abstract contract ERC7540AdminDeposit is ERC7540 { return _deposits[controller].claimableAssets; } + /// @inheritdoc ERC7540 function _asyncMaxDeposit(address owner) internal view virtual override returns (uint256) { return _deposits[owner].claimableAssets; } + /// @inheritdoc ERC7540 function _asyncMaxMint(address owner) internal view virtual override returns (uint256) { return _deposits[owner].claimableShares; } diff --git a/contracts/token/ERC20/extensions/ERC7540AdminRedeem.sol b/contracts/token/ERC20/extensions/ERC7540AdminRedeem.sol index 93bd690b..51549ce0 100644 --- a/contracts/token/ERC20/extensions/ERC7540AdminRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540AdminRedeem.sol @@ -5,24 +5,45 @@ pragma solidity ^0.8.27; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {ERC7540} from "./ERC7540.sol"; +/** + * @dev Admin-controlled (operator-triggered) fulfillment strategy for asynchronous redemptions. + * + * Extends {ERC7540} with a redeem flow where a privileged caller explicitly transitions requests + * from Pending to Claimable by calling {_fulfillRedeem}. The caller provides both the `shares` + * amount and the corresponding `assets`, giving the fulfiller explicit control over the exchange rate. + * + * The fulfiller must ensure the vault holds enough underlying assets before calling {_fulfillRedeem}. + * Asset sourcing (unwinding positions, bridging cross-chain, etc.) is application-specific and is + * not part of this contract. + * + * All requests share `requestId = 0` (per-controller accounting only). + */ abstract contract ERC7540AdminRedeem is ERC7540 { + /** + * @dev Struct containing the per-controller state for a redeem request. + * When a request becomes claimable via {_fulfillRedeem}, the exchange rate is locked + * in the `claimableShares` / `claimableAssets` pair. + */ struct PendingRedeem { uint256 pendingShares; uint256 claimableShares; uint256 claimableAssets; } + mapping(address controller => PendingRedeem) private _redeems; /// @dev Emitted when a redeem request transitions from Pending to Claimable. event RedeemClaimable(address indexed controller, uint256 indexed requestId, uint256 assets, uint256 shares); - /// @dev The amount of shares requested is greater than the amount of shares pending. + /// @dev The `shares` to fulfill exceeds the `pendingShares` for the controller. error ERC7540RedeemInsufficientPendingShares(uint256 shares, uint256 pendingShares); + /// @inheritdoc ERC7540 function _isRedeemAsync() internal pure virtual override returns (bool) { return true; } + /// @dev Records per-controller pending state before delegating to {ERC7540-_requestRedeem}. function _requestRedeem( uint256 shares, address controller, @@ -33,6 +54,19 @@ abstract contract ERC7540AdminRedeem is ERC7540 { return super._requestRedeem(shares, controller, owner, requestId); } + /** + * @dev Fulfills a pending redeem request by transitioning it from Pending to Claimable state. + * + * The caller provides both `shares` and `assets`, locking the exchange rate at fulfillment time. + * The fulfiller must ensure the vault holds enough underlying assets to cover the `assets` amount + * before calling this function. + * + * Emits a {RedeemClaimable} event. + * + * Requirements: + * + * * `shares` must not exceed the pending redeem amount for the `controller`. + */ function _fulfillRedeem(uint256 shares, uint256 assets, address controller) internal virtual { uint256 pendingShares = pendingRedeemRequest(0, controller); require(shares <= pendingShares, ERC7540RedeemInsufficientPendingShares(shares, pendingShares)); @@ -44,6 +78,7 @@ abstract contract ERC7540AdminRedeem is ERC7540 { emit RedeemClaimable(controller, 0, assets, shares); } + /// @dev Consumes `assets` from the claimable redeem and returns the proportional shares (rounded up). function _consumeClaimableWithdraw(uint256 assets, address controller) internal virtual override returns (uint256) { uint256 shares = Math.mulDiv(assets, maxRedeem(controller), maxWithdraw(controller), Math.Rounding.Ceil); _redeems[controller].claimableAssets = Math.saturatingSub(_redeems[controller].claimableAssets, assets); @@ -51,6 +86,7 @@ abstract contract ERC7540AdminRedeem is ERC7540 { return shares; } + /// @dev Consumes `shares` from the claimable redeem and returns the proportional assets (rounded down). function _consumeClaimableRedeem(uint256 shares, address controller) internal virtual override returns (uint256) { uint256 assets = Math.mulDiv(shares, maxWithdraw(controller), maxRedeem(controller), Math.Rounding.Floor); _redeems[controller].claimableAssets = Math.saturatingSub(_redeems[controller].claimableAssets, assets); @@ -58,6 +94,7 @@ abstract contract ERC7540AdminRedeem is ERC7540 { return assets; } + /// @inheritdoc ERC7540 function _pendingRedeemRequest( uint256 /*requestId*/, address controller @@ -65,6 +102,7 @@ abstract contract ERC7540AdminRedeem is ERC7540 { return _redeems[controller].pendingShares; } + /// @inheritdoc ERC7540 function _claimableRedeemRequest( uint256 /*requestId*/, address controller @@ -72,10 +110,12 @@ abstract contract ERC7540AdminRedeem is ERC7540 { return _redeems[controller].claimableShares; } + /// @inheritdoc ERC7540 function _asyncMaxWithdraw(address owner) internal view virtual override returns (uint256) { return _redeems[owner].claimableAssets; } + /// @inheritdoc ERC7540 function _asyncMaxRedeem(address owner) internal view virtual override returns (uint256) { return _redeems[owner].claimableShares; } diff --git a/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol b/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol index 2ca60d06..4f7ccd4b 100644 --- a/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol @@ -7,6 +7,23 @@ import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import {Checkpoints} from "@openzeppelin/contracts/utils/structs/Checkpoints.sol"; import {ERC7540} from "./ERC7540.sol"; +/** + * @dev Time-delay fulfillment strategy for asynchronous deposits. + * + * Extends {ERC7540} with a deposit flow where requests become **permissionlessly claimable** after a + * configurable waiting period. No privileged fulfiller is needed — once the delay elapses, the + * controller (or any keeper) can claim. The exchange rate is computed at claim time using the vault's + * live {convertToShares}. + * + * Production equivalents (redeem side): BeefySonic, MagmaV2, Tangle. + * + * Requests are tracked using {Checkpoints-Trace208}, storing cumulative deposit amounts keyed by + * their maturity timepoint. The `requestId` returned by {requestDeposit} equals the absolute + * timestamp at which the request becomes claimable (`clock() + depositDelay(controller)`). + * + * Override {depositDelay} to customize the waiting period (default: 1 hour) and {clock} to + * change the time source (default: `block.timestamp`). + */ abstract contract ERC7540DelayDeposit is ERC7540 { using SafeCast for uint256; using Checkpoints for Checkpoints.Trace208; @@ -14,23 +31,30 @@ abstract contract ERC7540DelayDeposit is ERC7540 { mapping(address controller => Checkpoints.Trace208 trace) private _deposits; mapping(address controller => uint256) private _claimedDeposits; + /// @dev Returns the current clock value. Defaults to `block.timestamp`. function clock() public view virtual returns (uint48) { return uint48(block.timestamp); } + /// @dev Returns the delay duration before a deposit request becomes claimable. Defaults to 1 hour. function depositDelay(address /*controller*/) public view virtual returns (uint48) { return 1 hours; } + /// @inheritdoc ERC7540 function _isDepositAsync() internal pure virtual override returns (bool) { return true; } + /** + * @dev Pushes a new cumulative checkpoint at the maturity timepoint and delegates to + * {ERC7540-_requestDeposit} with the timepoint as `requestId`. + */ function _requestDeposit( uint256 assets, address controller, address owner, - uint256 /* requestId */ // discarded and replaced by timepoint based ids + uint256 /* requestId */ ) internal virtual override returns (uint256) { uint48 timepoint = clock() + depositDelay(controller); uint256 latest = _deposits[controller].latest(); @@ -40,18 +64,24 @@ abstract contract ERC7540DelayDeposit is ERC7540 { return super._requestDeposit(assets, controller, owner, timepoint); } + /// @dev Consumes `assets` from claimable deposits, returns proportional shares (rounded down). function _consumeClaimableDeposit(uint256 assets, address controller) internal virtual override returns (uint256) { uint256 shares = Math.mulDiv(assets, maxMint(controller), maxDeposit(controller), Math.Rounding.Floor); _claimedDeposits[controller] += assets; return shares; } + /// @dev Consumes `shares` from claimable deposits, returns proportional assets (rounded up). function _consumeClaimableMint(uint256 shares, address controller) internal virtual override returns (uint256) { uint256 assets = Math.mulDiv(shares, maxDeposit(controller), maxMint(controller), Math.Rounding.Ceil); _claimedDeposits[controller] += assets; return assets; } + /** + * @dev Returns the assets in Pending state for a specific `requestId` (timepoint). + * A request is pending only if its timepoint is strictly in the future. + */ function _pendingDepositRequest( uint256 requestId, address controller @@ -63,6 +93,10 @@ abstract contract ERC7540DelayDeposit is ERC7540 { : 0; } + /** + * @dev Returns the assets in Claimable state for a specific `requestId` (timepoint). + * A request is claimable once its timepoint has elapsed and the assets haven't been claimed yet. + */ function _claimableDepositRequest( uint256 requestId, address controller @@ -77,10 +111,12 @@ abstract contract ERC7540DelayDeposit is ERC7540 { ); } + /// @dev Returns the total claimable assets across all matured timepoints for `owner`. function _asyncMaxDeposit(address owner) internal view virtual override returns (uint256) { return _deposits[owner].latest() - _claimedDeposits[owner]; } + /// @dev Returns the share-equivalent of {_asyncMaxDeposit} (rounded down). function _asyncMaxMint(address owner) internal view virtual override returns (uint256) { return _convertToShares(_asyncMaxDeposit(owner), Math.Rounding.Floor); } diff --git a/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol b/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol index 8bea0af5..c9956be9 100644 --- a/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol @@ -7,6 +7,24 @@ import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import {Checkpoints} from "@openzeppelin/contracts/utils/structs/Checkpoints.sol"; import {ERC7540} from "./ERC7540.sol"; +/** + * @dev Time-delay fulfillment strategy for asynchronous redemptions. + * + * Extends {ERC7540} with a redeem flow where requests become **permissionlessly claimable** after a + * configurable waiting period. No privileged fulfiller is needed — once the delay elapses, the + * controller (or any keeper) can claim. The exchange rate is computed at claim time using the vault's + * live {convertToAssets}. + * + * Production equivalents: BeefySonic (protocol-dictated SFC unbonding), MagmaV2 + * (admin-configurable delay), Tangle (protocol-dictated). + * + * Requests are tracked using {Checkpoints-Trace208}, storing cumulative redeem amounts keyed by + * their maturity timepoint. The `requestId` returned by {requestRedeem} equals the absolute + * timestamp at which the request becomes claimable (`clock() + redeemDelay(controller)`). + * + * Override {redeemDelay} to customize the waiting period (default: 1 hour) and {clock} to + * change the time source (default: `block.timestamp`). + */ abstract contract ERC7540DelayRedeem is ERC7540 { using SafeCast for uint256; using Checkpoints for Checkpoints.Trace208; @@ -14,23 +32,30 @@ abstract contract ERC7540DelayRedeem is ERC7540 { mapping(address controller => Checkpoints.Trace208) private _redeems; mapping(address controller => uint256) private _claimedRedeems; + /// @dev Returns the current clock value. Defaults to `block.timestamp`. function clock() public view virtual returns (uint48) { return uint48(block.timestamp); } + /// @dev Returns the delay duration before a redeem request becomes claimable. Defaults to 1 hour. function redeemDelay(address /*controller*/) public view virtual returns (uint48) { return 1 hours; } + /// @inheritdoc ERC7540 function _isRedeemAsync() internal pure virtual override returns (bool) { return true; } + /** + * @dev Pushes a new cumulative checkpoint at the maturity timepoint and delegates to + * {ERC7540-_requestRedeem} with the timepoint as `requestId`. + */ function _requestRedeem( uint256 shares, address controller, address owner, - uint256 /* requestId */ // discarded and replaced by timepoint based ids + uint256 /* requestId */ ) internal virtual override returns (uint256) { uint48 timepoint = clock() + redeemDelay(controller); uint256 latest = _redeems[controller].latest(); @@ -39,18 +64,24 @@ abstract contract ERC7540DelayRedeem is ERC7540 { return super._requestRedeem(shares, controller, owner, timepoint); } + /// @dev Consumes `assets` from claimable redeems, returns proportional shares (rounded up). function _consumeClaimableWithdraw(uint256 assets, address controller) internal virtual override returns (uint256) { uint256 shares = Math.mulDiv(assets, maxRedeem(controller), maxWithdraw(controller), Math.Rounding.Ceil); _claimedRedeems[controller] += shares; return shares; } + /// @dev Consumes `shares` from claimable redeems, returns proportional assets (rounded down). function _consumeClaimableRedeem(uint256 shares, address controller) internal virtual override returns (uint256) { uint256 assets = Math.mulDiv(shares, maxWithdraw(controller), maxRedeem(controller), Math.Rounding.Floor); _claimedRedeems[controller] += shares; return assets; } + /** + * @dev Returns the shares in Pending state for a specific `requestId` (timepoint). + * A request is pending only if its timepoint is strictly in the future. + */ function _pendingRedeemRequest( uint256 requestId, address controller @@ -62,6 +93,10 @@ abstract contract ERC7540DelayRedeem is ERC7540 { : 0; } + /** + * @dev Returns the shares in Claimable state for a specific `requestId` (timepoint). + * A request is claimable once its timepoint has elapsed and the shares haven't been claimed yet. + */ function _claimableRedeemRequest( uint256 requestId, address controller @@ -76,10 +111,12 @@ abstract contract ERC7540DelayRedeem is ERC7540 { ); } + /// @dev Returns the asset-equivalent of {_asyncMaxRedeem} (rounded down). function _asyncMaxWithdraw(address owner) internal view virtual override returns (uint256) { return _convertToAssets(_asyncMaxRedeem(owner), Math.Rounding.Floor); } + /// @dev Returns the total claimable shares across all matured timepoints for `owner`. function _asyncMaxRedeem(address owner) internal view virtual override returns (uint256) { return _redeems[owner].latest() - _claimedRedeems[owner]; } diff --git a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol index 44450e56..7c50a2d0 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol @@ -7,11 +7,32 @@ 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: Cove, Amphor, Lagoon (epoch-based settlement). + * + * 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 + * {_requestQueueLimit} 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. + */ 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; @@ -21,15 +42,17 @@ abstract contract ERC7540EpochDeposit is ERC7540 { mapping(uint256 epochId => EpochDepositMetadata) private _epochs; mapping(address account => DoubleEndedQueue.Bytes32Deque) private _memberOf; + /// @inheritdoc ERC7540 function _isDepositAsync() internal pure virtual override returns (bool) { return true; } - /// @dev Returns the current epoch. + /// @dev Returns the current epoch ID. Defaults to `block.timestamp / 1 weeks`. function currentDepositEpoch() public view virtual returns (uint256) { return block.timestamp / 1 weeks; } + /// @dev A request is pending if its epoch has not yet been fulfilled (`totalShares == 0`). function _pendingDepositRequest( uint256 requestId, address controller @@ -38,6 +61,7 @@ abstract contract ERC7540EpochDeposit is ERC7540 { return details.totalShares == 0 ? details.requests[controller] : 0; } + /// @dev A request is claimable if its epoch has been fulfilled (`totalShares > 0`). function _claimableDepositRequest( uint256 requestId, address controller @@ -46,6 +70,7 @@ abstract contract ERC7540EpochDeposit is ERC7540 { return details.totalShares == 0 ? 0 : details.requests[controller]; } + /// @dev Sums claimable assets across all fulfilled epochs the `owner` participates in. function _asyncMaxDeposit(address owner) internal view virtual override returns (uint256 assets) { uint256 result = 0; for (uint256 i = 0; i < _memberOf[owner].length(); ++i) { @@ -55,6 +80,7 @@ abstract contract ERC7540EpochDeposit is ERC7540 { return result; } + /// @dev Sums claimable shares across all fulfilled epochs the `owner` participates in. function _asyncMaxMint(address owner) internal view virtual override returns (uint256 shares) { uint256 result = 0; for (uint256 i = 0; i < _memberOf[owner].length(); ++i) { @@ -69,11 +95,19 @@ abstract contract ERC7540EpochDeposit is ERC7540 { return result; } + /** + * @dev Records the request in the current epoch and enqueues the epoch ID for `controller` + * if not already present. + * + * Requirements: + * + * * The controller's epoch queue must not exceed {_requestQueueLimit}. + */ function _requestDeposit( uint256 assets, address controller, address owner, - uint256 /* requestId */ // discarded and replaced by timepoint based ids + uint256 /* requestId */ ) internal virtual override returns (uint256) { uint256 epochId = currentDepositEpoch(); _epochs[epochId].totalAssets += assets; @@ -81,8 +115,9 @@ abstract contract ERC7540EpochDeposit is ERC7540 { (bool success, bytes32 lastEpochId) = _memberOf[controller].tryBack(); if (!success || lastEpochId != bytes32(epochId)) { - // Limit the number of pending epochs per account to 32 to avoid O(n) loop in _asyncMaxWithdraw and _asyncMaxRedeem being a concern. - // User that have reached the limit should execute pending (fulfilled) request to cleanup the queue. + // 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() < _requestQueueLimit()); _memberOf[controller].pushBack(bytes32(epochId)); @@ -91,11 +126,22 @@ abstract contract ERC7540EpochDeposit is ERC7540 { return super._requestDeposit(assets, controller, owner, epochId); } + /// @dev Returns the total assets available to fulfill for `epochId`, or 0 if already fulfilled or still current. function _assetsToFulfillDeposit(uint256 epochId) internal view virtual returns (uint256) { return epochId < currentDepositEpoch() && _epochs[epochId].totalShares == 0 ? _epochs[epochId].totalAssets : 0; } - /// Note: when epoch transition is manual, caller should bump the epoch before calling _fulfill + /** + * @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. + * + * 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()); // TODO: too early @@ -106,6 +152,11 @@ abstract contract ERC7540EpochDeposit is ERC7540 { // TODO: emit event } + /** + * @dev Iterates through the controller's epoch queue front-to-back, consuming assets + * and converting them to shares at each epoch's locked rate. Fully consumed epochs + * are dequeued. + */ function _consumeClaimableDeposit(uint256 assets, address controller) internal virtual override returns (uint256) { uint256 shares = 0; @@ -130,6 +181,7 @@ abstract contract ERC7540EpochDeposit is ERC7540 { return shares; } + /// @dev Same as {_consumeClaimableDeposit} but iterates by shares instead of assets. function _consumeClaimableMint(uint256 shares, address controller) internal virtual override returns (uint256) { uint256 assets = 0; @@ -158,6 +210,10 @@ abstract contract ERC7540EpochDeposit is ERC7540 { return assets; } + /** + * @dev Maximum number of epoch entries in a controller's queue. Defaults to 32. + * Prevents unbounded iteration in {_asyncMaxDeposit} and {_asyncMaxMint}. + */ function _requestQueueLimit() internal view virtual returns (uint256) { return 32; } diff --git a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol index 46570f97..adf68e32 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol @@ -7,11 +7,32 @@ 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: Cove, Nashpoint, Amphor, Lagoon (epoch-based settlement). + * + * 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 + * {_requestQueueLimit} 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. + */ 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; @@ -21,15 +42,17 @@ abstract contract ERC7540EpochRedeem is ERC7540 { mapping(uint256 epochId => EpochRedeemMetadata) private _epochs; mapping(address account => DoubleEndedQueue.Bytes32Deque) private _memberOf; + /// @inheritdoc ERC7540 function _isRedeemAsync() internal pure virtual override returns (bool) { return true; } - /// @dev Returns the current epoch. + /// @dev Returns the current epoch ID. Defaults to `block.timestamp / 1 weeks`. function currentRedeemEpoch() public view virtual returns (uint256) { return block.timestamp / 1 weeks; } + /// @dev A request is pending if its epoch has not yet been fulfilled (`totalAssets == 0`). function _pendingRedeemRequest( uint256 requestId, address controller @@ -38,6 +61,7 @@ abstract contract ERC7540EpochRedeem is ERC7540 { return details.totalAssets == 0 ? details.requests[controller] : 0; } + /// @dev A request is claimable if its epoch has been fulfilled (`totalAssets > 0`). function _claimableRedeemRequest( uint256 requestId, address controller @@ -46,6 +70,7 @@ abstract contract ERC7540EpochRedeem is ERC7540 { return details.totalAssets == 0 ? 0 : details.requests[controller]; } + /// @dev Sums claimable assets across all fulfilled epochs the `owner` participates in. function _asyncMaxWithdraw(address owner) internal view virtual override returns (uint256 assets) { uint256 result = 0; for (uint256 i = 0; i < _memberOf[owner].length(); ++i) { @@ -60,6 +85,7 @@ abstract contract ERC7540EpochRedeem is ERC7540 { return result; } + /// @dev Sums claimable shares across all fulfilled epochs the `owner` participates in. function _asyncMaxRedeem(address owner) internal view virtual override returns (uint256 shares) { uint256 result = 0; for (uint256 i = 0; i < _memberOf[owner].length(); ++i) { @@ -69,11 +95,19 @@ abstract contract ERC7540EpochRedeem is ERC7540 { return result; } + /** + * @dev Records the request in the current epoch and enqueues the epoch ID for `controller` + * if not already present. + * + * Requirements: + * + * * The controller's epoch queue must not exceed {_requestQueueLimit}. + */ function _requestRedeem( uint256 shares, address controller, address owner, - uint256 /* requestId */ // discarded and replaced by timepoint based ids + uint256 /* requestId */ ) internal virtual override returns (uint256) { uint256 epochId = currentRedeemEpoch(); _epochs[epochId].totalShares += shares; @@ -81,8 +115,9 @@ abstract contract ERC7540EpochRedeem is ERC7540 { (bool success, bytes32 lastEpochId) = _memberOf[controller].tryBack(); if (!success || lastEpochId != bytes32(epochId)) { - // Limit the number of pending epochs per account to 32 to avoid O(n) loop in _asyncMaxWithdraw and _asyncMaxRedeem being a concern. - // User that have reached the limit should execute pending (fulfilled) request to cleanup the queue. + // 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() < _requestQueueLimit()); _memberOf[controller].pushBack(bytes32(epochId)); @@ -91,11 +126,22 @@ abstract contract ERC7540EpochRedeem is ERC7540 { return super._requestRedeem(shares, controller, owner, epochId); } + /// @dev Returns the total shares available to fulfill for `epochId`, or 0 if already fulfilled or still current. function _sharesToFulfillRedeem(uint256 epochId) internal view virtual returns (uint256) { return epochId < currentRedeemEpoch() && _epochs[epochId].totalAssets == 0 ? _epochs[epochId].totalShares : 0; } - /// Note: when epoch transition is manual, caller should bump the epoch before calling _fulfill + /** + * @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. + * + * 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()); // TODO: too early @@ -106,6 +152,11 @@ abstract contract ERC7540EpochRedeem is ERC7540 { // TODO: emit event } + /** + * @dev Iterates through the controller's epoch queue front-to-back, consuming assets + * and converting them to shares at each epoch's locked rate. Fully consumed epochs + * are dequeued. + */ function _consumeClaimableWithdraw(uint256 assets, address controller) internal virtual override returns (uint256) { uint256 shares = 0; @@ -134,6 +185,7 @@ abstract contract ERC7540EpochRedeem is ERC7540 { return shares; } + /// @dev Same as {_consumeClaimableWithdraw} but iterates by shares instead of assets. function _consumeClaimableRedeem(uint256 shares, address controller) internal virtual override returns (uint256) { uint256 assets = 0; @@ -158,6 +210,10 @@ abstract contract ERC7540EpochRedeem is ERC7540 { return assets; } + /** + * @dev Maximum number of epoch entries in a controller's queue. Defaults to 32. + * Prevents unbounded iteration in {_asyncMaxWithdraw} and {_asyncMaxRedeem}. + */ function _requestQueueLimit() internal view virtual returns (uint256) { return 32; } From d15844ddfa3a904b84359ad795410052e2c3553d Mon Sep 17 00:00:00 2001 From: ernestognw Date: Mon, 20 Apr 2026 23:17:32 +0200 Subject: [PATCH 25/60] Update production references --- contracts/token/ERC20/extensions/ERC7540AdminDeposit.sol | 9 +++++++-- contracts/token/ERC20/extensions/ERC7540AdminRedeem.sol | 6 ++++++ contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol | 5 ++++- contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol | 6 ++++-- contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol | 5 ++++- contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol | 6 +++++- 6 files changed, 30 insertions(+), 7 deletions(-) diff --git a/contracts/token/ERC20/extensions/ERC7540AdminDeposit.sol b/contracts/token/ERC20/extensions/ERC7540AdminDeposit.sol index bbd4e3b1..b3e9f252 100644 --- a/contracts/token/ERC20/extensions/ERC7540AdminDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540AdminDeposit.sol @@ -13,8 +13,13 @@ import {ERC7540} from "./ERC7540.sol"; * amount and the corresponding `shares`, giving the fulfiller explicit control over the exchange rate. * * This is the most flexible fulfillment model. Epoch-based batch settlement, FIFO queues, and - * cross-chain oracle-gated settlement can all be composed on top. Production equivalents include - * USDai, Nest (Plume), MetaVault, SukukFi, and Centrifuge. + * cross-chain oracle-gated settlement can all be composed on top. + * + * Production equivalents include + * https://github.com/usdai-foundation/usdai-contracts/blob/main/src/StakedUSDai.sol[USDai], + * https://github.com/plumenetwork/nest-protocol/blob/main/contracts/NestVaultCore.sol[Nest (Plume)], + * https://github.com/turingcapitalgroup/metaVault/blob/main/src/MetaVault.sol[MetaVault], and + * https://github.com/centrifuge/protocol/blob/main/src/vaults/AsyncVault.sol[Centrifuge]. * * All requests share `requestId = 0` (per-controller accounting only). */ diff --git a/contracts/token/ERC20/extensions/ERC7540AdminRedeem.sol b/contracts/token/ERC20/extensions/ERC7540AdminRedeem.sol index 51549ce0..c39dc537 100644 --- a/contracts/token/ERC20/extensions/ERC7540AdminRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540AdminRedeem.sol @@ -16,6 +16,12 @@ import {ERC7540} from "./ERC7540.sol"; * Asset sourcing (unwinding positions, bridging cross-chain, etc.) is application-specific and is * not part of this contract. * + * Production equivalents include + * https://github.com/usdai-foundation/usdai-contracts/blob/main/src/StakedUSDai.sol[USDai], + * https://github.com/plumenetwork/nest-protocol/blob/main/contracts/NestVaultCore.sol[Nest (Plume)], + * https://github.com/turingcapitalgroup/metaVault/blob/main/src/MetaVault.sol[MetaVault], and + * https://github.com/centrifuge/protocol/blob/main/src/vaults/AsyncVault.sol[Centrifuge]. + * * All requests share `requestId = 0` (per-controller accounting only). */ abstract contract ERC7540AdminRedeem is ERC7540 { diff --git a/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol b/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol index 4f7ccd4b..3d5b801c 100644 --- a/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol @@ -15,7 +15,10 @@ import {ERC7540} from "./ERC7540.sol"; * controller (or any keeper) can claim. The exchange rate is computed at claim time using the vault's * live {convertToShares}. * - * Production equivalents (redeem side): BeefySonic, MagmaV2, Tangle. + * Production equivalents (redeem side): + * https://github.com/beefyfinance/beefy-sonic/blob/main/contracts/BeefySonic.sol[BeefySonic], + * https://github.com/MagmaStaking/contracts-public/blob/live/src/MagmaV2.sol[MagmaV2], + * https://github.com/tangle-network/tnt-core/blob/main/src/staking/LiquidDelegationVault.sol[Tangle]. * * Requests are tracked using {Checkpoints-Trace208}, storing cumulative deposit amounts keyed by * their maturity timepoint. The `requestId` returned by {requestDeposit} equals the absolute diff --git a/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol b/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol index c9956be9..fcb9d9d5 100644 --- a/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol @@ -15,8 +15,10 @@ import {ERC7540} from "./ERC7540.sol"; * controller (or any keeper) can claim. The exchange rate is computed at claim time using the vault's * live {convertToAssets}. * - * Production equivalents: BeefySonic (protocol-dictated SFC unbonding), MagmaV2 - * (admin-configurable delay), Tangle (protocol-dictated). + * Production equivalents: + * https://github.com/beefyfinance/beefy-sonic/blob/main/contracts/BeefySonic.sol[BeefySonic] (protocol-dictated SFC unbonding), + * https://github.com/MagmaStaking/contracts-public/blob/live/src/MagmaV2.sol[MagmaV2] (admin-configurable delay), + * https://github.com/tangle-network/tnt-core/blob/main/src/staking/LiquidDelegationVault.sol[Tangle] (protocol-dictated). * * Requests are tracked using {Checkpoints-Trace208}, storing cumulative redeem amounts keyed by * their maturity timepoint. The `requestId` returned by {requestRedeem} equals the absolute diff --git a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol index 7c50a2d0..d8614301 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol @@ -14,7 +14,10 @@ import {ERC7540} from "./ERC7540.sol"; * 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: Cove, Amphor, Lagoon (epoch-based settlement). + * 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 diff --git a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol index adf68e32..05ce48fe 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol @@ -14,7 +14,11 @@ import {ERC7540} from "./ERC7540.sol"; * 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: Cove, Nashpoint, Amphor, Lagoon (epoch-based settlement). + * Production equivalents: + * https://github.com/Storm-Labs-Inc/cove-contracts-core/blob/master/src/BasketToken.sol[Cove], + * https://github.com/nashpoint/nashpoint-smart-contracts/blob/main/src/Node.sol[Nashpoint], + * 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 From 7c1782e9a6a36af1302301afe12017dcda08486f Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Tue, 21 Apr 2026 10:40:24 +0200 Subject: [PATCH 26/60] improve tests --- .../ERC20/extensions/ERC7540Delay.test.js | 251 +++++++++++------- 1 file changed, 160 insertions(+), 91 deletions(-) diff --git a/test/token/ERC20/extensions/ERC7540Delay.test.js b/test/token/ERC20/extensions/ERC7540Delay.test.js index 4cb939e2..0648b0c5 100644 --- a/test/token/ERC20/extensions/ERC7540Delay.test.js +++ b/test/token/ERC20/extensions/ERC7540Delay.test.js @@ -3,14 +3,18 @@ const { expect } = require('chai'); const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); const time = require('@openzeppelin/contracts/test/helpers/time'); +const { batchInBlock } = require('@openzeppelin/contracts/test/helpers/txpool'); const { shouldSupportInterfaces, INTERFACE_IDS } = require('../../../utils/introspection/SupportsInterface.behavior'); const name = 'Vault Shares'; const symbol = 'vSHR'; const tokenName = 'Asset Token'; const tokenSymbol = 'AST'; -const initialBalance = ethers.parseEther('1000'); -const amount = ethers.parseEther('100'); +// initial vault distribution +const initialAssets = ethers.parseEther('17000000'); +const initialShares = ethers.parseEther('42000000'); +// other +const balance = ethers.parseEther('1000'); const delay = 3600n; async function fixture() { @@ -19,7 +23,10 @@ async function fixture() { const token = await ethers.deployContract('$ERC20', [tokenName, tokenSymbol]); const mock = await ethers.deployContract('$ERC7540DelayMock', [name, symbol, token]); - await token.$_mint(owner, initialBalance); + await token.$_mint(mock, initialAssets); + await mock.$_mint(owner, initialShares); + + await token.$_mint(owner, balance); await token.connect(owner).approve(mock, ethers.MaxUint256); await mock.connect(owner).setOperator(operator, true); await mock.connect(controller).setOperator(operator, true); @@ -63,97 +70,127 @@ describe('ERC7540Delay', function () { }); describe('deposit flow', function () { + const assets = ethers.parseEther('100'); + const shares = (assets * initialShares) / initialAssets; + describe('requestDeposit', function () { it('transfers tokens and emits DepositRequest with timepoint-based requestId', async function () { const assetsBefore = await this.mock.totalAssets(); + const supplyBefore = await this.mock.totalSupply(); - const tx = this.mock.connect(this.owner).requestDeposit(amount, this.controller, this.owner); + // perform request deposit, and extract requestId from timing + const tx = this.mock.connect(this.owner).requestDeposit(assets, this.controller, this.owner); const requestId = (await time.clockFromReceipt.timestamp(tx)) + delay; + // check event is emitted and tokens are deposited await expect(tx) .to.emit(this.mock, 'DepositRequest') - .withArgs(this.controller, this.owner, requestId, this.owner, amount); - - await expect(tx).to.changeTokenBalances(this.token, [this.owner, this.mock], [-amount, amount]); + .withArgs(this.controller, this.owner, requestId, this.owner, assets); + await expect(tx).to.changeTokenBalances(this.token, [this.owner, this.mock], [-assets, assets]); + await expect(tx).to.changeTokenBalances(this.mock, [this.controller], [0n]); + // totalAssets excludes in-flight deposits await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore); + await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore); - await expect(this.mock.pendingDepositRequest(requestId, this.controller)).to.eventually.equal(amount); + // check pending deposit is registered + await expect(this.mock.pendingDepositRequest(requestId, this.controller)).to.eventually.equal(assets); await expect(this.mock.claimableDepositRequest(requestId, this.controller)).to.eventually.equal(0n); + // move forward await time.increaseTo.timestamp(requestId); + // check deposit becomes claimable automatically await expect(this.mock.pendingDepositRequest(requestId, this.controller)).to.eventually.equal(0n); - await expect(this.mock.claimableDepositRequest(requestId, this.controller)).to.eventually.equal(amount); + await expect(this.mock.claimableDepositRequest(requestId, this.controller)).to.eventually.equal(assets); }); it('operator can trigger request deposit on behalf of owner', async function () { - const tx = this.mock.connect(this.operator).requestDeposit(amount, this.controller, this.owner); + const tx = this.mock.connect(this.operator).requestDeposit(assets, this.controller, this.owner); const requestId = (await time.clockFromReceipt.timestamp(tx)) + delay; await expect(tx) .to.emit(this.mock, 'DepositRequest') - .withArgs(this.controller, this.owner, requestId, this.operator, amount); + .withArgs(this.controller, this.owner, requestId, this.operator, assets); + await expect(tx).to.changeTokenBalances(this.token, [this.owner, this.mock], [-assets, assets]); + await expect(tx).to.changeTokenBalances(this.mock, [this.controller], [0n]); + }); - await expect(tx).to.changeTokenBalances(this.token, [this.owner, this.mock], [-amount, amount]); + it('two deposit request in the same block are merged', async function () { + const [tx1, tx2] = await batchInBlock( + [ + () => + this.mock.connect(this.operator).requestDeposit(17n, this.controller, this.owner, { gasLimit: 200000n }), + () => + this.mock.connect(this.operator).requestDeposit(42n, this.controller, this.owner, { gasLimit: 200000n }), + ], + ethers.provider, + ); + const requestId1 = (await time.clockFromReceipt.timestamp(tx1)) + delay; + const requestId2 = (await time.clockFromReceipt.timestamp(tx2)) + delay; + expect(requestId1).to.equal(requestId2); + + await expect(this.mock.pendingDepositRequest(requestId1, this.controller)).to.eventually.equal(59n); // 17 + 42 + await expect(this.mock.claimableDepositRequest(requestId1, this.controller)).to.eventually.equal(0n); + + await time.increaseTo.timestamp(requestId1); + + await expect(this.mock.pendingDepositRequest(requestId1, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableDepositRequest(requestId1, this.controller)).to.eventually.equal(59n); }); it('reverts when caller is neither owner nor operator of owner', async function () { - await expect(this.mock.connect(this.other).requestDeposit(amount, this.controller, this.owner)) + await expect(this.mock.connect(this.other).requestDeposit(assets, this.controller, this.owner)) .to.be.revertedWithCustomError(this.mock, 'ERC7540InvalidOperator') .withArgs(this.owner, this.other); }); - - it('totalAssets excludes in-flight deposits', async function () { - await this.mock.connect(this.owner).requestDeposit(amount, this.controller, this.owner); - await expect(this.mock.totalAssets()).to.eventually.equal(0n); - }); }); describe('claim', function () { beforeEach(async function () { - const tx = this.mock.connect(this.operator).requestDeposit(amount, this.controller, this.owner); + const tx = this.mock.connect(this.operator).requestDeposit(assets, this.controller, this.owner); this.requestId = (await time.clockFromReceipt.timestamp(tx)) + delay; await time.increaseTo.timestamp(this.requestId); }); describe('via deposit()', function () { it('mints shares 1:1 to receiver and emits Deposit', async function () { + // assets are ready to be claimed await expect(this.mock.pendingDepositRequest(this.requestId, this.controller)).to.eventually.equal(0n); - await expect(this.mock.claimableDepositRequest(this.requestId, this.controller)).to.eventually.equal(amount); - await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(amount); + await expect(this.mock.claimableDepositRequest(this.requestId, this.controller)).to.eventually.equal(assets); + await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(assets); const assetsBefore = await this.mock.totalAssets(); const supplyBefore = await this.mock.totalSupply(); + // perform deposit, check event is emitted and shares are minted const tx = this.mock .connect(this.controller) - .deposit(amount, this.receiver, ethers.Typed.address(this.controller)); - - await expect(tx).to.emit(this.mock, 'Deposit').withArgs(this.controller, this.receiver, amount, amount); + .deposit(assets, this.receiver, ethers.Typed.address(this.controller)); - await expect(tx).to.changeTokenBalance(this.mock, this.receiver, amount); + await expect(tx).to.emit(this.mock, 'Deposit').withArgs(this.controller, this.receiver, assets, shares); + await expect(tx).to.changeTokenBalance(this.mock, this.receiver, shares); + // claimable assets are released, totalAssets and totalSupply are updated await expect(this.mock.pendingDepositRequest(this.requestId, this.controller)).to.eventually.equal(0n); await expect(this.mock.claimableDepositRequest(this.requestId, this.controller)).to.eventually.equal(0n); await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(0n); - await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore + amount); - await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore + amount); + await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore + assets); + await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore + shares); }); it('operator can trigger deposit on behalf of controller', async function () { const tx = this.mock .connect(this.operator) - .deposit(amount, this.receiver, ethers.Typed.address(this.controller)); + .deposit(assets, this.receiver, ethers.Typed.address(this.controller)); - await expect(tx).to.emit(this.mock, 'Deposit').withArgs(this.operator, this.receiver, amount, amount); - - await expect(tx).to.changeTokenBalance(this.mock, this.receiver, amount); + await expect(tx).to.emit(this.mock, 'Deposit').withArgs(this.operator, this.receiver, assets, shares); + await expect(tx).to.changeTokenBalance(this.mock, this.receiver, shares); }); it('reverts when caller is neither owner nor operator of owner', async function () { await expect( - this.mock.connect(this.other).deposit(amount, this.receiver, ethers.Typed.address(this.controller)), + this.mock.connect(this.other).deposit(assets, this.receiver, ethers.Typed.address(this.controller)), ) .to.be.revertedWithCustomError(this.mock, 'ERC7540InvalidOperator') .withArgs(this.controller, this.other); @@ -162,40 +199,41 @@ describe('ERC7540Delay', function () { describe('via mint()', function () { it('mints exactly the requested shares and emits Deposit', async function () { + // assets are ready to be claimed await expect(this.mock.pendingDepositRequest(this.requestId, this.controller)).to.eventually.equal(0n); - await expect(this.mock.claimableDepositRequest(this.requestId, this.controller)).to.eventually.equal(amount); - await expect(this.mock.maxMint(this.controller)).to.eventually.equal(amount); + await expect(this.mock.claimableDepositRequest(this.requestId, this.controller)).to.eventually.equal(assets); + await expect(this.mock.maxMint(this.controller)).to.eventually.equal(shares); const assetsBefore = await this.mock.totalAssets(); const supplyBefore = await this.mock.totalSupply(); + // perform mint, check event is emitted and shares are minted const tx = this.mock .connect(this.controller) - .mint(amount, this.receiver, ethers.Typed.address(this.controller)); - - await expect(tx).to.emit(this.mock, 'Deposit').withArgs(this.controller, this.receiver, amount, amount); + .mint(shares, this.receiver, ethers.Typed.address(this.controller)); - await expect(tx).to.changeTokenBalance(this.mock, this.receiver, amount); + await expect(tx).to.emit(this.mock, 'Deposit').withArgs(this.controller, this.receiver, assets, shares); + await expect(tx).to.changeTokenBalance(this.mock, this.receiver, shares); + // claimable assets are released, totalAssets and totalSupply are updated await expect(this.mock.pendingDepositRequest(this.requestId, this.controller)).to.eventually.equal(0n); await expect(this.mock.claimableDepositRequest(this.requestId, this.controller)).to.eventually.equal(0n); await expect(this.mock.maxMint(this.controller)).to.eventually.equal(0n); - await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore + amount); - await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore + amount); + await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore + assets); + await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore + shares); }); it('operator can trigger mint on behalf of controller', async function () { const tx = this.mock .connect(this.operator) - .mint(amount, this.receiver, ethers.Typed.address(this.controller)); - - await expect(tx).to.emit(this.mock, 'Deposit').withArgs(this.operator, this.receiver, amount, amount); + .mint(shares, this.receiver, ethers.Typed.address(this.controller)); - await expect(tx).to.changeTokenBalance(this.mock, this.receiver, amount); + await expect(tx).to.emit(this.mock, 'Deposit').withArgs(this.operator, this.receiver, assets, shares); + await expect(tx).to.changeTokenBalance(this.mock, this.receiver, shares); }); it('reverts when caller is neither owner nor operator of owner', async function () { - await expect(this.mock.connect(this.other).mint(amount, this.receiver, ethers.Typed.address(this.controller))) + await expect(this.mock.connect(this.other).mint(shares, this.receiver, ethers.Typed.address(this.controller))) .to.be.revertedWithCustomError(this.mock, 'ERC7540InvalidOperator') .withArgs(this.controller, this.other); }); @@ -204,111 +242,141 @@ describe('ERC7540Delay', function () { }); describe('redeem flow', function () { - beforeEach(async function () { - await this.token.$_mint(this.mock, initialBalance); - await this.mock.$_mint(this.owner, initialBalance); - }); + const shares = ethers.parseEther('100'); + const assets = (shares * initialAssets) / initialShares; describe('requestRedeem', function () { it('burns shares, emits RedeemRequest, keeps totalSupply stable via pending counter', async function () { + const assetsBefore = await this.mock.totalAssets(); const supplyBefore = await this.mock.totalSupply(); - const tx = this.mock.connect(this.owner).requestRedeem(amount, this.controller, this.owner); + // perform request redeem, and extract requestId from timing + const tx = this.mock.connect(this.owner).requestRedeem(shares, this.controller, this.owner); const requestId = (await time.clockFromReceipt.timestamp(tx)) + delay; + // check event is emitted and shares are burned await expect(tx) .to.emit(this.mock, 'RedeemRequest') - .withArgs(this.controller, this.owner, requestId, this.owner, amount); - - await expect(tx).to.changeTokenBalance(this.mock, this.owner, -amount); + .withArgs(this.controller, this.owner, requestId, this.owner, shares); + await expect(tx).to.changeTokenBalances(this.token, [this.controller, this.mock], [0n, 0n]); + await expect(tx).to.changeTokenBalances(this.mock, [this.owner], [-shares]); + // totalSupply includes shares for in-flight redeem + await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore); await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore); - await expect(this.mock.pendingRedeemRequest(requestId, this.controller)).to.eventually.equal(amount); + // check pending redeem is registered + await expect(this.mock.pendingRedeemRequest(requestId, this.controller)).to.eventually.equal(shares); await expect(this.mock.claimableRedeemRequest(requestId, this.controller)).to.eventually.equal(0n); + // move forward await time.increaseTo.timestamp(requestId); + // check redeem becomes claimable automatically await expect(this.mock.pendingRedeemRequest(requestId, this.controller)).to.eventually.equal(0n); - await expect(this.mock.claimableRedeemRequest(requestId, this.controller)).to.eventually.equal(amount); + await expect(this.mock.claimableRedeemRequest(requestId, this.controller)).to.eventually.equal(shares); }); it('operator can trigger request deposit on behalf of owner', async function () { - const tx = this.mock.connect(this.operator).requestRedeem(amount, this.controller, this.owner); + const tx = this.mock.connect(this.operator).requestRedeem(shares, this.controller, this.owner); const requestId = (await time.clockFromReceipt.timestamp(tx)) + delay; await expect(tx) .to.emit(this.mock, 'RedeemRequest') - .withArgs(this.controller, this.owner, requestId, this.operator, amount); - - await expect(tx).to.changeTokenBalance(this.mock, this.owner, -amount); + .withArgs(this.controller, this.owner, requestId, this.operator, shares); + await expect(tx).to.changeTokenBalances(this.token, [this.controller, this.mock], [0n, 0n]); + await expect(tx).to.changeTokenBalances(this.mock, [this.owner], [-shares]); }); it('spends allowance when caller is neither owner nor operator', async function () { - await this.mock.connect(this.owner).approve(this.other, amount); + await this.mock.connect(this.owner).approve(this.other, shares); - const tx = this.mock.connect(this.other).requestRedeem(amount, this.controller, this.owner); + const tx = this.mock.connect(this.other).requestRedeem(shares, this.controller, this.owner); const requestId = (await time.clockFromReceipt.timestamp(tx)) + delay; await expect(tx) .to.emit(this.mock, 'RedeemRequest') - .withArgs(this.controller, this.owner, requestId, this.other, amount); + .withArgs(this.controller, this.owner, requestId, this.other, shares); await expect(this.mock.allowance(this.owner, this.other)).to.eventually.equal(0n); }); + it('two redeem request in the same block are merged', async function () { + const [tx1, tx2] = await batchInBlock( + [ + () => + this.mock.connect(this.operator).requestRedeem(17n, this.controller, this.owner, { gasLimit: 200000n }), + () => + this.mock.connect(this.operator).requestRedeem(42n, this.controller, this.owner, { gasLimit: 200000n }), + ], + ethers.provider, + ); + const requestId1 = (await time.clockFromReceipt.timestamp(tx1)) + delay; + const requestId2 = (await time.clockFromReceipt.timestamp(tx2)) + delay; + expect(requestId1).to.equal(requestId2); + + await expect(this.mock.pendingRedeemRequest(requestId1, this.controller)).to.eventually.equal(59n); // 17 + 42 + await expect(this.mock.claimableRedeemRequest(requestId1, this.controller)).to.eventually.equal(0n); + + await time.increaseTo.timestamp(requestId1); + + await expect(this.mock.pendingRedeemRequest(requestId1, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableRedeemRequest(requestId1, this.controller)).to.eventually.equal(59n); + }); + it('revert of caller is neither owner nor operator and has no allowance', async function () { - const tx = this.mock.connect(this.other).requestRedeem(amount, this.controller, this.owner); + const tx = this.mock.connect(this.other).requestRedeem(shares, this.controller, this.owner); await expect(tx) .to.be.revertedWithCustomError(this.mock, 'ERC20InsufficientAllowance') - .withArgs(this.other, 0n, amount); + .withArgs(this.other, 0n, shares); }); }); describe('claim', function () { beforeEach(async function () { - const tx = this.mock.connect(this.operator).requestRedeem(amount, this.controller, this.owner); + const tx = this.mock.connect(this.operator).requestRedeem(shares, this.controller, this.owner); this.requestId = (await time.clockFromReceipt.timestamp(tx)) + delay; await time.increaseTo.timestamp(this.requestId); }); describe('via redeem()', function () { it('transfers tokens to receiver and emits Withdraw', async function () { + // shares are ready to be claimed await expect(this.mock.pendingRedeemRequest(this.requestId, this.controller)).to.eventually.equal(0n); - await expect(this.mock.claimableRedeemRequest(this.requestId, this.controller)).to.eventually.equal(amount); - await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(amount); + await expect(this.mock.claimableRedeemRequest(this.requestId, this.controller)).to.eventually.equal(shares); + await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(shares); const assetsBefore = await this.mock.totalAssets(); const supplyBefore = await this.mock.totalSupply(); - const tx = this.mock.connect(this.controller).redeem(amount, this.receiver, this.controller); + // perform redeem, check event is emitted and assets are released + const tx = this.mock.connect(this.controller).redeem(shares, this.receiver, this.controller); await expect(tx) .to.emit(this.mock, 'Withdraw') - .withArgs(this.controller, this.receiver, this.controller, amount, amount); - - await expect(tx).to.changeTokenBalances(this.token, [this.mock, this.receiver], [-amount, amount]); + .withArgs(this.controller, this.receiver, this.controller, assets, shares); + await expect(tx).to.changeTokenBalances(this.token, [this.mock, this.receiver], [-assets, assets]); + // claimable shares are deducted, totalAssets and totalSupply are updated await expect(this.mock.pendingRedeemRequest(this.requestId, this.controller)).to.eventually.equal(0n); await expect(this.mock.claimableRedeemRequest(this.requestId, this.controller)).to.eventually.equal(0n); await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(0n); - await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore - amount); - await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore - amount); + await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore - assets); + await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore - shares); }); it('operator can trigger redeem on behalf of controller', async function () { - const tx = this.mock.connect(this.operator).redeem(amount, this.receiver, this.controller); + const tx = this.mock.connect(this.operator).redeem(shares, this.receiver, this.controller); await expect(tx) .to.emit(this.mock, 'Withdraw') - .withArgs(this.operator, this.receiver, this.controller, amount, amount); - - await expect(tx).to.changeTokenBalances(this.token, [this.mock, this.receiver], [-amount, amount]); + .withArgs(this.operator, this.receiver, this.controller, assets, shares); + await expect(tx).to.changeTokenBalances(this.token, [this.mock, this.receiver], [-assets, assets]); }); it('reverts when caller is neither owner nor operator of owner', async function () { - await expect(this.mock.connect(this.other).redeem(amount, this.receiver, this.controller)) + await expect(this.mock.connect(this.other).redeem(assets, this.receiver, this.controller)) .to.be.revertedWithCustomError(this.mock, 'ERC7540InvalidOperator') .withArgs(this.controller, this.other); }); @@ -316,40 +384,41 @@ describe('ERC7540Delay', function () { describe('via withdraw()', function () { it('transfers exactly the requested tokens and emits Withdraw', async function () { + // shares are ready to be claimed await expect(this.mock.pendingRedeemRequest(this.requestId, this.controller)).to.eventually.equal(0n); - await expect(this.mock.claimableRedeemRequest(this.requestId, this.controller)).to.eventually.equal(amount); - await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(amount); + await expect(this.mock.claimableRedeemRequest(this.requestId, this.controller)).to.eventually.equal(shares); + await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(assets); const assetsBefore = await this.mock.totalAssets(); const supplyBefore = await this.mock.totalSupply(); - const tx = this.mock.connect(this.controller).withdraw(amount, this.receiver, this.controller); + // perform withdraw, check event is emitted and assets are released + const tx = this.mock.connect(this.controller).withdraw(assets, this.receiver, this.controller); await expect(tx) .to.emit(this.mock, 'Withdraw') - .withArgs(this.controller, this.receiver, this.controller, amount, amount); - - await expect(tx).to.changeTokenBalances(this.token, [this.mock, this.receiver], [-amount, amount]); + .withArgs(this.controller, this.receiver, this.controller, assets, shares); + await expect(tx).to.changeTokenBalances(this.token, [this.mock, this.receiver], [-assets, assets]); + // claimable shares are deducted, totalAssets and totalSupply are updated await expect(this.mock.pendingRedeemRequest(this.requestId, this.controller)).to.eventually.equal(0n); await expect(this.mock.claimableRedeemRequest(this.requestId, this.controller)).to.eventually.equal(0n); await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(0n); - await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore - amount); - await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore - amount); + await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore - assets); + await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore - shares); }); it('operator can trigger withdraw on behalf of controller', async function () { - const tx = this.mock.connect(this.operator).withdraw(amount, this.receiver, this.controller); + const tx = this.mock.connect(this.operator).withdraw(assets, this.receiver, this.controller); await expect(tx) .to.emit(this.mock, 'Withdraw') - .withArgs(this.operator, this.receiver, this.controller, amount, amount); - - await expect(tx).to.changeTokenBalances(this.token, [this.mock, this.receiver], [-amount, amount]); + .withArgs(this.operator, this.receiver, this.controller, assets, shares); + await expect(tx).to.changeTokenBalances(this.token, [this.mock, this.receiver], [-assets, assets]); }); it('reverts when caller is neither owner nor operator of owner', async function () { - await expect(this.mock.connect(this.other).withdraw(amount, this.receiver, this.controller)) + await expect(this.mock.connect(this.other).withdraw(assets, this.receiver, this.controller)) .to.be.revertedWithCustomError(this.mock, 'ERC7540InvalidOperator') .withArgs(this.controller, this.other); }); From 63498ed5554d4f5640f38cf6d10c382af90d331d Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Tue, 21 Apr 2026 12:50:26 +0200 Subject: [PATCH 27/60] fix bug --- .../token/ERC20/extensions/ERC7540DelayDeposit.sol | 2 +- .../token/ERC20/extensions/ERC7540DelayRedeem.sol | 2 +- test/token/ERC20/extensions/ERC7540Delay.test.js | 12 ++++++++++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol b/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol index 3d5b801c..3fdb174e 100644 --- a/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol @@ -116,7 +116,7 @@ abstract contract ERC7540DelayDeposit is ERC7540 { /// @dev Returns the total claimable assets across all matured timepoints for `owner`. function _asyncMaxDeposit(address owner) internal view virtual override returns (uint256) { - return _deposits[owner].latest() - _claimedDeposits[owner]; + return _deposits[owner].upperLookup(clock()) - _claimedDeposits[owner]; } /// @dev Returns the share-equivalent of {_asyncMaxDeposit} (rounded down). diff --git a/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol b/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol index fcb9d9d5..54e457ce 100644 --- a/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol @@ -120,6 +120,6 @@ abstract contract ERC7540DelayRedeem is ERC7540 { /// @dev Returns the total claimable shares across all matured timepoints for `owner`. function _asyncMaxRedeem(address owner) internal view virtual override returns (uint256) { - return _redeems[owner].latest() - _claimedRedeems[owner]; + return _redeems[owner].upperLookup(clock()) - _claimedRedeems[owner]; } } diff --git a/test/token/ERC20/extensions/ERC7540Delay.test.js b/test/token/ERC20/extensions/ERC7540Delay.test.js index 0648b0c5..a940d4ee 100644 --- a/test/token/ERC20/extensions/ERC7540Delay.test.js +++ b/test/token/ERC20/extensions/ERC7540Delay.test.js @@ -41,7 +41,7 @@ describe('ERC7540Delay', function () { describe('metadata', function () { it('token', async function () { - await expect(this.mock.asset()).to.eventually.equal(this.token.target); + await expect(this.mock.asset()).to.eventually.equal(this.token); }); it('name, symbol, decimals', async function () { @@ -96,6 +96,8 @@ describe('ERC7540Delay', function () { // check pending deposit is registered await expect(this.mock.pendingDepositRequest(requestId, this.controller)).to.eventually.equal(assets); await expect(this.mock.claimableDepositRequest(requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxMint(this.controller)).to.eventually.equal(0n); // move forward await time.increaseTo.timestamp(requestId); @@ -103,6 +105,8 @@ describe('ERC7540Delay', function () { // check deposit becomes claimable automatically await expect(this.mock.pendingDepositRequest(requestId, this.controller)).to.eventually.equal(0n); await expect(this.mock.claimableDepositRequest(requestId, this.controller)).to.eventually.equal(assets); + await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(assets); + await expect(this.mock.maxMint(this.controller)).to.eventually.equal(shares); }); it('operator can trigger request deposit on behalf of owner', async function () { @@ -246,7 +250,7 @@ describe('ERC7540Delay', function () { const assets = (shares * initialAssets) / initialShares; describe('requestRedeem', function () { - it('burns shares, emits RedeemRequest, keeps totalSupply stable via pending counter', async function () { + it('burns shares and emits RedeemRequest with timepoint-based requestId', async function () { const assetsBefore = await this.mock.totalAssets(); const supplyBefore = await this.mock.totalSupply(); @@ -268,6 +272,8 @@ describe('ERC7540Delay', function () { // check pending redeem is registered await expect(this.mock.pendingRedeemRequest(requestId, this.controller)).to.eventually.equal(shares); await expect(this.mock.claimableRedeemRequest(requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(0n); // move forward await time.increaseTo.timestamp(requestId); @@ -275,6 +281,8 @@ describe('ERC7540Delay', function () { // check redeem becomes claimable automatically await expect(this.mock.pendingRedeemRequest(requestId, this.controller)).to.eventually.equal(0n); await expect(this.mock.claimableRedeemRequest(requestId, this.controller)).to.eventually.equal(shares); + await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(shares); + await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(assets); }); it('operator can trigger request deposit on behalf of owner', async function () { From 0c952d26cebe9fed8717748ec241b16d3fb65034 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Tue, 21 Apr 2026 13:11:34 +0200 Subject: [PATCH 28/60] add ERC7540Admin tests --- .../ERC20/extensions/ERC7540Admin.test.js | 498 ++++++++++++++++++ .../ERC20/extensions/ERC7540Delay.test.js | 8 +- 2 files changed, 502 insertions(+), 4 deletions(-) create mode 100644 test/token/ERC20/extensions/ERC7540Admin.test.js diff --git a/test/token/ERC20/extensions/ERC7540Admin.test.js b/test/token/ERC20/extensions/ERC7540Admin.test.js new file mode 100644 index 00000000..69194326 --- /dev/null +++ b/test/token/ERC20/extensions/ERC7540Admin.test.js @@ -0,0 +1,498 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { shouldSupportInterfaces, INTERFACE_IDS } = require('../../../utils/introspection/SupportsInterface.behavior'); + +const name = 'Vault Shares'; +const symbol = 'vSHR'; +const tokenName = 'Asset Token'; +const tokenSymbol = 'AST'; +// initial vault distribution +const initialAssets = ethers.parseEther('17000000'); +const initialShares = ethers.parseEther('42000000'); +// other +const balance = ethers.parseEther('1000'); + +async function fixture() { + const [owner, controller, receiver, operator, other] = await ethers.getSigners(); + + const token = await ethers.deployContract('$ERC20', [tokenName, tokenSymbol]); + const mock = await ethers.deployContract('$ERC7540AdminMock', [name, symbol, token]); + + await token.$_mint(mock, initialAssets); + await mock.$_mint(owner, initialShares); + + await token.$_mint(owner, balance); + await token.connect(owner).approve(mock, ethers.MaxUint256); + await mock.connect(owner).setOperator(operator, true); + await mock.connect(controller).setOperator(operator, true); + + return { owner, controller, receiver, operator, other, token, mock }; +} + +describe('ERC7540Admin', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + 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); + }); + + describe('supports ERC-7540 interfaces', function () { + expect(INTERFACE_IDS.ERC7540Operator).to.equal('0xe3bc4e65'); + expect(INTERFACE_IDS.ERC7540Deposit).to.equal('0xce3bbe50'); + expect(INTERFACE_IDS.ERC7540Redeem).to.equal('0x620ee8e4'); + + shouldSupportInterfaces(['ERC7540Operator', 'ERC7540Deposit', 'ERC7540Redeem']); + }); + }); + + describe('deposit flow', function () { + const assets = ethers.parseEther('100'); + const shares = (assets * initialShares) / initialAssets; + + describe('requestDeposit', function () { + it('transfers tokens, marks as pending, emits DepositRequest with requestId 0', async function () { + const assetsBefore = await this.mock.totalAssets(); + const supplyBefore = await this.mock.totalSupply(); + + const tx = this.mock.connect(this.owner).requestDeposit(assets, this.controller, this.owner); + + await expect(tx) + .to.emit(this.mock, 'DepositRequest') + .withArgs(this.controller, this.owner, 0n, this.owner, assets); + await expect(tx).to.changeTokenBalances(this.token, [this.owner, this.mock], [-assets, assets]); + await expect(tx).to.changeTokenBalances(this.mock, [this.controller], [0n]); + + await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore); + await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore); + + await expect(this.mock.pendingDepositRequest(0n, this.controller)).to.eventually.equal(assets); + await expect(this.mock.claimableDepositRequest(0n, this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxMint(this.controller)).to.eventually.equal(0n); + }); + + it('operator can trigger request deposit on behalf of owner', async function () { + const tx = this.mock.connect(this.operator).requestDeposit(assets, this.controller, this.owner); + + await expect(tx) + .to.emit(this.mock, 'DepositRequest') + .withArgs(this.controller, this.owner, 0n, this.operator, assets); + await expect(tx).to.changeTokenBalances(this.token, [this.owner, this.mock], [-assets, assets]); + await expect(tx).to.changeTokenBalances(this.mock, [this.controller], [0n]); + }); + + it('reverts when caller is neither owner nor operator of owner', async function () { + await expect(this.mock.connect(this.other).requestDeposit(assets, this.controller, this.owner)) + .to.be.revertedWithCustomError(this.mock, 'ERC7540InvalidOperator') + .withArgs(this.owner, this.other); + }); + + it('accumulates pending across multiple requests', async function () { + await this.mock.connect(this.owner).requestDeposit(17n, this.controller, this.owner); + await this.mock.connect(this.owner).requestDeposit(42n, this.controller, this.owner); + + await expect(this.mock.pendingDepositRequest(0n, this.controller)).to.eventually.equal(17n + 42n); + }); + }); + + describe('fulfillDeposit', function () { + beforeEach(async function () { + await this.mock.connect(this.owner).requestDeposit(assets, this.controller, this.owner); + }); + + it('transitions pending to claimable and emits DepositClaimable', async function () { + const assetsBefore = await this.mock.totalAssets(); + const supplyBefore = await this.mock.totalSupply(); + + await expect(this.mock.pendingDepositRequest(0n, this.controller)).to.eventually.equal(assets); + await expect(this.mock.claimableDepositRequest(0n, this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxMint(this.controller)).to.eventually.equal(0n); + + await expect(this.mock.$_fulfillDeposit(assets, shares, this.controller)) + .to.emit(this.mock, 'DepositClaimable') + .withArgs(this.controller, 0n, assets, shares); + + await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore); + await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore); + + await expect(this.mock.pendingDepositRequest(0n, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableDepositRequest(0n, this.controller)).to.eventually.equal(assets); + await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(assets); + await expect(this.mock.maxMint(this.controller)).to.eventually.equal(shares); + }); + + it('supports admin-determined share ratio', async function () { + await expect(this.mock.pendingDepositRequest(0n, this.controller)).to.eventually.equal(assets); + await expect(this.mock.claimableDepositRequest(0n, this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxMint(this.controller)).to.eventually.equal(0n); + + await expect(this.mock.$_fulfillDeposit(assets, 42n, this.controller)) + .to.emit(this.mock, 'DepositClaimable') + .withArgs(this.controller, 0n, assets, 42n); + + await expect(this.mock.pendingDepositRequest(0n, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableDepositRequest(0n, this.controller)).to.eventually.equal(assets); + await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(assets); + await expect(this.mock.maxMint(this.controller)).to.eventually.equal(42n); + }); + + it('can be partially fulfilled', async function () { + await expect(this.mock.pendingDepositRequest(0n, this.controller)).to.eventually.equal(assets); + await expect(this.mock.claimableDepositRequest(0n, this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxMint(this.controller)).to.eventually.equal(0n); + + await this.mock.$_fulfillDeposit(17n, 42n, this.controller); + + await expect(this.mock.pendingDepositRequest(0n, this.controller)).to.eventually.equal(assets - 17n); + await expect(this.mock.claimableDepositRequest(0n, this.controller)).to.eventually.equal(17n); + await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(17n); + await expect(this.mock.maxMint(this.controller)).to.eventually.equal(42n); + }); + + it('reverts when fulfilling more than pending', async function () { + await expect(this.mock.$_fulfillDeposit(assets + 1n, shares, this.controller)) + .to.be.revertedWithCustomError(this.mock, 'ERC7540DepositInsufficientPendingAssets') + .withArgs(assets + 1n, assets); + }); + }); + + describe('claim', function () { + beforeEach(async function () { + await this.mock.connect(this.owner).requestDeposit(assets, this.controller, this.owner); + await this.mock.$_fulfillDeposit(assets, shares, this.controller); + }); + + describe('via deposit()', function () { + it('mints shares to receiver and emits Deposit', async function () { + await expect(this.mock.pendingDepositRequest(0n, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableDepositRequest(0n, this.controller)).to.eventually.equal(assets); + await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(assets); + + const assetsBefore = await this.mock.totalAssets(); + const supplyBefore = await this.mock.totalSupply(); + + const tx = this.mock + .connect(this.controller) + .deposit(assets, this.receiver, ethers.Typed.address(this.controller)); + + await expect(tx).to.emit(this.mock, 'Deposit').withArgs(this.controller, this.receiver, assets, shares); + await expect(tx).to.changeTokenBalance(this.mock, this.receiver, shares); + + await expect(this.mock.pendingDepositRequest(0n, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableDepositRequest(0n, this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(0n); + await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore + assets); + await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore + shares); + }); + + it('operator can trigger deposit on behalf of controller', async function () { + const tx = this.mock + .connect(this.operator) + .deposit(assets, this.receiver, ethers.Typed.address(this.controller)); + + await expect(tx).to.emit(this.mock, 'Deposit').withArgs(this.operator, this.receiver, assets, shares); + await expect(tx).to.changeTokenBalance(this.mock, this.receiver, shares); + }); + + it('reverts when caller is neither owner nor operator of owner', async function () { + await expect( + this.mock.connect(this.other).deposit(assets, this.receiver, ethers.Typed.address(this.controller)), + ) + .to.be.revertedWithCustomError(this.mock, 'ERC7540InvalidOperator') + .withArgs(this.controller, this.other); + }); + }); + + describe('via mint()', function () { + it('mints exactly the requested shares and emits Deposit', async function () { + await expect(this.mock.pendingDepositRequest(0n, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableDepositRequest(0n, this.controller)).to.eventually.equal(assets); + await expect(this.mock.maxMint(this.controller)).to.eventually.equal(shares); + + const assetsBefore = await this.mock.totalAssets(); + const supplyBefore = await this.mock.totalSupply(); + + const tx = this.mock + .connect(this.controller) + .mint(shares, this.receiver, ethers.Typed.address(this.controller)); + + await expect(tx).to.emit(this.mock, 'Deposit').withArgs(this.controller, this.receiver, assets, shares); + + await expect(tx).to.changeTokenBalance(this.mock, this.receiver, shares); + + await expect(this.mock.pendingDepositRequest(0n, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableDepositRequest(0n, this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxMint(this.controller)).to.eventually.equal(0n); + await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore + assets); + await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore + shares); + }); + + it('operator can trigger mint on behalf of controller', async function () { + const tx = this.mock + .connect(this.operator) + .mint(shares, this.receiver, ethers.Typed.address(this.controller)); + + await expect(tx).to.emit(this.mock, 'Deposit').withArgs(this.operator, this.receiver, assets, shares); + await expect(tx).to.changeTokenBalance(this.mock, this.receiver, shares); + }); + + it('reverts when caller is neither owner nor operator of owner', async function () { + await expect(this.mock.connect(this.other).mint(shares, this.receiver, ethers.Typed.address(this.controller))) + .to.be.revertedWithCustomError(this.mock, 'ERC7540InvalidOperator') + .withArgs(this.controller, this.other); + }); + }); + }); + }); + + describe('redeem flow', function () { + const shares = ethers.parseEther('100'); + const assets = (shares * initialAssets) / initialShares; + + describe('requestRedeem', function () { + it('burns shares, marks as pending, emits RedeemRequest with requestId 0', async function () { + const assetsBefore = await this.mock.totalAssets(); + const supplyBefore = await this.mock.totalSupply(); + + // perform request redeem, and extract requestId from timing + const tx = this.mock.connect(this.owner).requestRedeem(shares, this.controller, this.owner); + + // check event is emitted and shares are burned + await expect(tx) + .to.emit(this.mock, 'RedeemRequest') + .withArgs(this.controller, this.owner, 0n, this.owner, shares); + await expect(tx).to.changeTokenBalances(this.token, [this.controller, this.mock], [0n, 0n]); + await expect(tx).to.changeTokenBalances(this.mock, [this.owner], [-shares]); + + // totalSupply includes shares for in-flight redeem + await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore); + await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore); + + // check pending redeem is registered + await expect(this.mock.pendingRedeemRequest(0n, this.controller)).to.eventually.equal(shares); + await expect(this.mock.claimableRedeemRequest(0n, this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(0n); + }); + + it('operator can trigger request redeem on behalf of owner', async function () { + const tx = this.mock.connect(this.operator).requestRedeem(shares, this.controller, this.owner); + + await expect(tx) + .to.emit(this.mock, 'RedeemRequest') + .withArgs(this.controller, this.owner, 0n, this.operator, shares); + await expect(tx).to.changeTokenBalances(this.token, [this.controller, this.mock], [0n, 0n]); + await expect(tx).to.changeTokenBalances(this.mock, [this.owner], [-shares]); + }); + + it('spends allowance when caller is neither owner nor operator', async function () { + await this.mock.connect(this.owner).approve(this.other, shares); + + const tx = this.mock.connect(this.other).requestRedeem(shares, this.controller, this.owner); + + await expect(tx) + .to.emit(this.mock, 'RedeemRequest') + .withArgs(this.controller, this.owner, 0n, this.other, shares); + + await expect(this.mock.allowance(this.owner, this.other)).to.eventually.equal(0n); + }); + + it('revert of caller is neither owner nor operator and has no allowance', async function () { + const tx = this.mock.connect(this.other).requestRedeem(shares, this.controller, this.owner); + + await expect(tx) + .to.be.revertedWithCustomError(this.mock, 'ERC20InsufficientAllowance') + .withArgs(this.other, 0n, shares); + }); + + it('accumulates pending across multiple requests', async function () { + await this.mock.connect(this.owner).requestRedeem(17n, this.controller, this.owner); + await this.mock.connect(this.owner).requestRedeem(42n, this.controller, this.owner); + + await expect(this.mock.pendingRedeemRequest(0n, this.controller)).to.eventually.equal(17n + 42n); + }); + }); + + describe('fulfillRedeem', function () { + beforeEach(async function () { + await this.mock.connect(this.owner).requestRedeem(shares, this.controller, this.owner); + }); + + it('transitions pending to claimable and emits RedeemClaimable', async function () { + const assetsBefore = await this.mock.totalAssets(); + const supplyBefore = await this.mock.totalSupply(); + + await expect(this.mock.pendingRedeemRequest(0n, this.controller)).to.eventually.equal(shares); + await expect(this.mock.claimableRedeemRequest(0n, this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(0n); + + await expect(this.mock.$_fulfillRedeem(shares, assets, this.controller)) + .to.emit(this.mock, 'RedeemClaimable') + .withArgs(this.controller, 0n, assets, shares); + + await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore); + await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore); + + await expect(this.mock.pendingRedeemRequest(0n, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableRedeemRequest(0n, this.controller)).to.eventually.equal(shares); + await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(assets); + await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(shares); + }); + + it('supports admin-determined asset ratio', async function () { + await expect(this.mock.pendingRedeemRequest(0n, this.controller)).to.eventually.equal(shares); + await expect(this.mock.claimableRedeemRequest(0n, this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(0n); + + await expect(this.mock.$_fulfillRedeem(shares, 42n, this.controller)) + .to.emit(this.mock, 'RedeemClaimable') + .withArgs(this.controller, 0n, 42n, shares); + + await expect(this.mock.pendingRedeemRequest(0n, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableRedeemRequest(0n, this.controller)).to.eventually.equal(shares); + await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(42n); + await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(shares); + }); + + it('can be partially fulfilled', async function () { + await expect(this.mock.pendingRedeemRequest(0n, this.controller)).to.eventually.equal(shares); + await expect(this.mock.claimableRedeemRequest(0n, this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(0n); + + await expect(this.mock.$_fulfillRedeem(42n, 17n, this.controller)) + .to.emit(this.mock, 'RedeemClaimable') + .withArgs(this.controller, 0n, 17n, 42n); + + await expect(this.mock.pendingRedeemRequest(0n, this.controller)).to.eventually.equal(shares - 42n); + await expect(this.mock.claimableRedeemRequest(0n, this.controller)).to.eventually.equal(42n); + await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(17n); + await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(42n); + }); + + it('reverts when fulfilling more than pending', async function () { + await expect(this.mock.$_fulfillRedeem(shares + 1n, assets, this.controller)) + .to.be.revertedWithCustomError(this.mock, 'ERC7540RedeemInsufficientPendingShares') + .withArgs(shares + 1n, shares); + }); + }); + + describe('claim', function () { + beforeEach(async function () { + await this.mock.connect(this.owner).requestRedeem(shares, this.controller, this.owner); + await this.mock.$_fulfillRedeem(shares, assets, this.controller); + }); + + describe('via redeem()', function () { + it('transfers tokens to receiver and emits Withdraw', async function () { + await expect(this.mock.pendingRedeemRequest(0n, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableRedeemRequest(0n, this.controller)).to.eventually.equal(shares); + await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(shares); + + const assetsBefore = await this.mock.totalAssets(); + const supplyBefore = await this.mock.totalSupply(); + + const tx = this.mock.connect(this.controller).redeem(shares, this.receiver, this.controller); + + await expect(tx) + .to.emit(this.mock, 'Withdraw') + .withArgs(this.controller, this.receiver, this.controller, assets, shares); + await expect(tx).to.changeTokenBalances(this.token, [this.mock, this.receiver], [-assets, assets]); + + await expect(this.mock.pendingRedeemRequest(0n, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableRedeemRequest(0n, this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(0n); + await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore - assets); + await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore - shares); + }); + + it('operator can trigger redeem on behalf of controller', async function () { + const tx = this.mock.connect(this.operator).redeem(shares, this.receiver, this.controller); + + await expect(tx) + .to.emit(this.mock, 'Withdraw') + .withArgs(this.operator, this.receiver, this.controller, assets, shares); + await expect(tx).to.changeTokenBalances(this.token, [this.mock, this.receiver], [-assets, assets]); + }); + + it('reverts when caller is neither owner nor operator of owner', async function () { + await expect(this.mock.connect(this.other).redeem(shares, this.receiver, this.controller)) + .to.be.revertedWithCustomError(this.mock, 'ERC7540InvalidOperator') + .withArgs(this.controller, this.other); + }); + }); + + describe('via withdraw()', function () { + it('transfers exactly the requested tokens and emits Withdraw', async function () { + await expect(this.mock.pendingRedeemRequest(0n, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableRedeemRequest(0n, this.controller)).to.eventually.equal(shares); + await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(assets); + + const assetsBefore = await this.mock.totalAssets(); + const supplyBefore = await this.mock.totalSupply(); + + const tx = this.mock.connect(this.controller).withdraw(assets, this.receiver, this.controller); + + await expect(tx) + .to.emit(this.mock, 'Withdraw') + .withArgs(this.controller, this.receiver, this.controller, assets, shares); + await expect(tx).to.changeTokenBalances(this.token, [this.mock, this.receiver], [-assets, assets]); + + await expect(this.mock.pendingRedeemRequest(0n, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableRedeemRequest(0n, this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(0n); + await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore - assets); + await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore - shares); + }); + + it('operator can trigger withdraw on behalf of controller', async function () { + const tx = this.mock.connect(this.operator).withdraw(assets, this.receiver, this.controller); + + await expect(tx) + .to.emit(this.mock, 'Withdraw') + .withArgs(this.operator, this.receiver, this.controller, assets, shares); + await expect(tx).to.changeTokenBalances(this.token, [this.mock, this.receiver], [-assets, assets]); + }); + + it('reverts when caller is neither owner nor operator of owner', async function () { + await expect(this.mock.connect(this.other).withdraw(assets, this.receiver, this.controller)) + .to.be.revertedWithCustomError(this.mock, 'ERC7540InvalidOperator') + .withArgs(this.controller, this.other); + }); + }); + }); + }); + + describe('operators', function () { + for (const status of [true, false]) { + it(`setOperator to ${status} emits event and updates status`, async function () { + await expect(this.mock.connect(this.owner).setOperator(this.operator, status)) + .to.emit(this.mock, 'OperatorSet') + .withArgs(this.owner, this.operator, status); + + await expect(this.mock.isOperator(this.owner, this.operator)).to.eventually.equal(status); + }); + } + }); +}); diff --git a/test/token/ERC20/extensions/ERC7540Delay.test.js b/test/token/ERC20/extensions/ERC7540Delay.test.js index a940d4ee..4e736e99 100644 --- a/test/token/ERC20/extensions/ERC7540Delay.test.js +++ b/test/token/ERC20/extensions/ERC7540Delay.test.js @@ -134,13 +134,13 @@ describe('ERC7540Delay', function () { const requestId2 = (await time.clockFromReceipt.timestamp(tx2)) + delay; expect(requestId1).to.equal(requestId2); - await expect(this.mock.pendingDepositRequest(requestId1, this.controller)).to.eventually.equal(59n); // 17 + 42 + await expect(this.mock.pendingDepositRequest(requestId1, this.controller)).to.eventually.equal(17n + 42n); await expect(this.mock.claimableDepositRequest(requestId1, this.controller)).to.eventually.equal(0n); await time.increaseTo.timestamp(requestId1); await expect(this.mock.pendingDepositRequest(requestId1, this.controller)).to.eventually.equal(0n); - await expect(this.mock.claimableDepositRequest(requestId1, this.controller)).to.eventually.equal(59n); + await expect(this.mock.claimableDepositRequest(requestId1, this.controller)).to.eventually.equal(17n + 42n); }); it('reverts when caller is neither owner nor operator of owner', async function () { @@ -323,13 +323,13 @@ describe('ERC7540Delay', function () { const requestId2 = (await time.clockFromReceipt.timestamp(tx2)) + delay; expect(requestId1).to.equal(requestId2); - await expect(this.mock.pendingRedeemRequest(requestId1, this.controller)).to.eventually.equal(59n); // 17 + 42 + await expect(this.mock.pendingRedeemRequest(requestId1, this.controller)).to.eventually.equal(17n + 42n); await expect(this.mock.claimableRedeemRequest(requestId1, this.controller)).to.eventually.equal(0n); await time.increaseTo.timestamp(requestId1); await expect(this.mock.pendingRedeemRequest(requestId1, this.controller)).to.eventually.equal(0n); - await expect(this.mock.claimableRedeemRequest(requestId1, this.controller)).to.eventually.equal(59n); + await expect(this.mock.claimableRedeemRequest(requestId1, this.controller)).to.eventually.equal(17n + 42n); }); it('revert of caller is neither owner nor operator and has no allowance', async function () { From 9d024dfe8ba1123418027aa8309ba26b74db0bef Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Tue, 21 Apr 2026 14:10:58 +0200 Subject: [PATCH 29/60] use a behavior --- .../ERC20/extensions/ERC7540.behavior.js | 538 ++++++++++++++++++ .../ERC20/extensions/ERC7540Admin.test.js | 475 +--------------- .../ERC20/extensions/ERC7540Delay.test.js | 422 +------------- 3 files changed, 569 insertions(+), 866 deletions(-) create mode 100644 test/token/ERC20/extensions/ERC7540.behavior.js diff --git a/test/token/ERC20/extensions/ERC7540.behavior.js b/test/token/ERC20/extensions/ERC7540.behavior.js new file mode 100644 index 00000000..148837fb --- /dev/null +++ b/test/token/ERC20/extensions/ERC7540.behavior.js @@ -0,0 +1,538 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); + +const { batchInBlock } = require('@openzeppelin/contracts/test/helpers/txpool'); +const { shouldSupportInterfaces, INTERFACE_IDS } = require('../../../utils/introspection/SupportsInterface.behavior'); + +function shouldBehaveLikeERC7540Operator() { + before(async function () { + [this.owner, this.controller, this.receiver, this.operator, this.other] = await ethers.getSigners(); + }); + + describe('supports ERC-7540 operator interface', function () { + expect(INTERFACE_IDS.ERC7540Operator).to.equal('0xe3bc4e65'); + shouldSupportInterfaces(['ERC7540Operator']); + }); + + describe('Should behave like ERC7540Operator', function () { + for (const status of [true, false]) { + it(`setOperator to ${status} emits event and updates status`, async function () { + await expect(this.mock.connect(this.owner).setOperator(this.operator, status)) + .to.emit(this.mock, 'OperatorSet') + .withArgs(this.owner, this.operator, status); + + await expect(this.mock.isOperator(this.owner, this.operator)).to.eventually.equal(status); + }); + } + }); +} + +function shouldBehaveLikeERC7540Deposit({ initialAssets, initialShares, balance, supportCustomFulfill } = {}) { + initialAssets ??= ethers.parseEther('17000000'); + initialShares ??= ethers.parseEther('42000000'); + balance ??= ethers.parseEther('1000'); + supportCustomFulfill ??= true; + + before(async function () { + [this.owner, this.controller, this.receiver, this.operator, this.other] = await ethers.getSigners(); + }); + + beforeEach(async function () { + await this.token.$_mint(this.mock, initialAssets); + await this.mock.$_mint(this.owner, initialShares); + + await this.token.$_mint(this.owner, 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); + }); + + describe('supports ERC-7540 operator interface', function () { + expect(INTERFACE_IDS.ERC7540Deposit).to.equal('0xce3bbe50'); + shouldSupportInterfaces(['ERC7540Deposit']); + }); + + describe('Should behave like ERC7540Deposit', function () { + const assets = ethers.parseEther('100'); + const shares = (assets * initialShares) / initialAssets; + + describe('requestDeposit', function () { + it('transfers tokens, marks as pending, emits DepositRequest with requestId 0', async function () { + const assetsBefore = await this.mock.totalAssets(); + const supplyBefore = await this.mock.totalSupply(); + + const tx = this.mock.connect(this.owner).requestDeposit(assets, this.controller, this.owner); + const requestId = await this.getRequestId(tx); + + await expect(tx) + .to.emit(this.mock, 'DepositRequest') + .withArgs(this.controller, this.owner, requestId, this.owner, assets); + await expect(tx).to.changeTokenBalances(this.token, [this.owner, this.mock], [-assets, assets]); + await expect(tx).to.changeTokenBalances(this.mock, [this.controller], [0n]); + + await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore); + await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore); + + await expect(this.mock.pendingDepositRequest(requestId, this.controller)).to.eventually.equal(assets); + await expect(this.mock.claimableDepositRequest(requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxMint(this.controller)).to.eventually.equal(0n); + }); + + it('operator can trigger request deposit on behalf of owner', async function () { + const tx = this.mock.connect(this.operator).requestDeposit(assets, this.controller, this.owner); + const requestId = await this.getRequestId(tx); + + await expect(tx) + .to.emit(this.mock, 'DepositRequest') + .withArgs(this.controller, this.owner, requestId, this.operator, assets); + await expect(tx).to.changeTokenBalances(this.token, [this.owner, this.mock], [-assets, assets]); + await expect(tx).to.changeTokenBalances(this.mock, [this.controller], [0n]); + }); + + it('reverts when caller is neither owner nor operator of owner', async function () { + await expect(this.mock.connect(this.other).requestDeposit(assets, this.controller, this.owner)) + .to.be.revertedWithCustomError(this.mock, 'ERC7540InvalidOperator') + .withArgs(this.owner, this.other); + }); + + it('accumulates pending across multiple requests', async function () { + const [tx1, tx2] = await batchInBlock( + [ + () => this.mock.connect(this.owner).requestDeposit(17n, this.controller, this.owner, { gasLimit: 200000n }), + () => this.mock.connect(this.owner).requestDeposit(42n, this.controller, this.owner, { gasLimit: 200000n }), + ], + ethers.provider, + ); + + const requestId1 = await this.getRequestId(tx1); + const requestId2 = await this.getRequestId(tx2); + expect(requestId1).to.equal(requestId2); + + await expect(this.mock.pendingDepositRequest(requestId1, this.controller)).to.eventually.equal(17n + 42n); + await expect(this.mock.claimableDepositRequest(requestId1, this.controller)).to.eventually.equal(0n); + }); + }); + + supportCustomFulfill && + describe('fulfillDeposit', function () { + beforeEach(async function () { + this.requestId = await this.mock + .connect(this.owner) + .requestDeposit(assets, this.controller, this.owner) + .then(this.getRequestId); + }); + + it('transitions pending to claimable and emits DepositClaimable', async function () { + const assetsBefore = await this.mock.totalAssets(); + const supplyBefore = await this.mock.totalSupply(); + + await expect(this.mock.pendingDepositRequest(this.requestId, this.controller)).to.eventually.equal(assets); + await expect(this.mock.claimableDepositRequest(this.requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxMint(this.controller)).to.eventually.equal(0n); + + await this.fulfillDeposit(this.requestId, assets, shares, this.controller); + + await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore); + await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore); + + await expect(this.mock.pendingDepositRequest(this.requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableDepositRequest(this.requestId, this.controller)).to.eventually.equal(assets); + await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(assets); + await expect(this.mock.maxMint(this.controller)).to.eventually.equal(shares); + }); + + it('supports admin-determined share ratio', async function () { + await expect(this.mock.pendingDepositRequest(this.requestId, this.controller)).to.eventually.equal(assets); + await expect(this.mock.claimableDepositRequest(this.requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxMint(this.controller)).to.eventually.equal(0n); + + await this.fulfillDeposit(this.requestId, assets, 42n, this.controller); + + await expect(this.mock.pendingDepositRequest(0n, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableDepositRequest(0n, this.controller)).to.eventually.equal(assets); + await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(assets); + await expect(this.mock.maxMint(this.controller)).to.eventually.equal(42n); + }); + + it('can be partially fulfilled', async function () { + await expect(this.mock.pendingDepositRequest(0n, this.controller)).to.eventually.equal(assets); + await expect(this.mock.claimableDepositRequest(0n, this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxMint(this.controller)).to.eventually.equal(0n); + + await this.fulfillDeposit(this.requestId, 17n, 42n, this.controller); + + await expect(this.mock.pendingDepositRequest(0n, this.controller)).to.eventually.equal(assets - 17n); + await expect(this.mock.claimableDepositRequest(0n, this.controller)).to.eventually.equal(17n); + await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(17n); + await expect(this.mock.maxMint(this.controller)).to.eventually.equal(42n); + }); + + it('reverts when fulfilling more than pending', async function () { + await expect(this.fulfillDeposit(this.requestId, assets + 1n, shares, this.controller)) + .to.be.revertedWithCustomError(this.mock, 'ERC7540DepositInsufficientPendingAssets') + .withArgs(assets + 1n, assets); + }); + }); + + describe('claim', function () { + beforeEach(async function () { + (this.requestId = await this.mock + .connect(this.owner) + .requestDeposit(assets, this.controller, this.owner) + .then(this.getRequestId)), + await this.fulfillDeposit(this.requestId, assets, shares, this.controller); + }); + + describe('via deposit()', function () { + it('mints shares to receiver and emits Deposit', async function () { + await expect(this.mock.pendingDepositRequest(this.requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableDepositRequest(this.requestId, this.controller)).to.eventually.equal(assets); + await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(assets); + + const assetsBefore = await this.mock.totalAssets(); + const supplyBefore = await this.mock.totalSupply(); + + const tx = this.mock + .connect(this.controller) + .deposit(assets, this.receiver, ethers.Typed.address(this.controller)); + + await expect(tx).to.emit(this.mock, 'Deposit').withArgs(this.controller, this.receiver, assets, shares); + await expect(tx).to.changeTokenBalance(this.mock, this.receiver, shares); + + await expect(this.mock.pendingDepositRequest(this.requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableDepositRequest(this.requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(0n); + await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore + assets); + await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore + shares); + }); + + it('operator can trigger deposit on behalf of controller', async function () { + const tx = this.mock + .connect(this.operator) + .deposit(assets, this.receiver, ethers.Typed.address(this.controller)); + + await expect(tx).to.emit(this.mock, 'Deposit').withArgs(this.operator, this.receiver, assets, shares); + await expect(tx).to.changeTokenBalance(this.mock, this.receiver, shares); + }); + + it('reverts when caller is neither owner nor operator of owner', async function () { + await expect( + this.mock.connect(this.other).deposit(assets, this.receiver, ethers.Typed.address(this.controller)), + ) + .to.be.revertedWithCustomError(this.mock, 'ERC7540InvalidOperator') + .withArgs(this.controller, this.other); + }); + }); + + describe('via mint()', function () { + it('mints exactly the requested shares and emits Deposit', async function () { + await expect(this.mock.pendingDepositRequest(this.requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableDepositRequest(this.requestId, this.controller)).to.eventually.equal(assets); + await expect(this.mock.maxMint(this.controller)).to.eventually.equal(shares); + + const assetsBefore = await this.mock.totalAssets(); + const supplyBefore = await this.mock.totalSupply(); + + const tx = this.mock + .connect(this.controller) + .mint(shares, this.receiver, ethers.Typed.address(this.controller)); + + await expect(tx).to.emit(this.mock, 'Deposit').withArgs(this.controller, this.receiver, assets, shares); + + await expect(tx).to.changeTokenBalance(this.mock, this.receiver, shares); + + await expect(this.mock.pendingDepositRequest(this.requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableDepositRequest(this.requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxMint(this.controller)).to.eventually.equal(0n); + await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore + assets); + await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore + shares); + }); + + it('operator can trigger mint on behalf of controller', async function () { + const tx = this.mock + .connect(this.operator) + .mint(shares, this.receiver, ethers.Typed.address(this.controller)); + + await expect(tx).to.emit(this.mock, 'Deposit').withArgs(this.operator, this.receiver, assets, shares); + await expect(tx).to.changeTokenBalance(this.mock, this.receiver, shares); + }); + + it('reverts when caller is neither owner nor operator of owner', async function () { + await expect(this.mock.connect(this.other).mint(shares, this.receiver, ethers.Typed.address(this.controller))) + .to.be.revertedWithCustomError(this.mock, 'ERC7540InvalidOperator') + .withArgs(this.controller, this.other); + }); + }); + }); + }); +} + +function shouldBehaveLikeERC7540Redeem({ initialAssets, initialShares, balance, supportCustomFulfill } = {}) { + initialAssets ??= ethers.parseEther('17000000'); + initialShares ??= ethers.parseEther('42000000'); + balance ??= ethers.parseEther('1000'); + supportCustomFulfill ??= true; + + before(async function () { + [this.owner, this.controller, this.receiver, this.operator, this.other] = await ethers.getSigners(); + }); + + beforeEach(async function () { + await this.token.$_mint(this.mock, initialAssets); + await this.mock.$_mint(this.owner, initialShares); + + await this.token.$_mint(this.owner, 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); + }); + + describe('supports ERC-7540 operator interface', function () { + expect(INTERFACE_IDS.ERC7540Redeem).to.equal('0x620ee8e4'); + shouldSupportInterfaces(['ERC7540Redeem']); + }); + + describe('Should behave like ERC7540Redeem', function () { + const shares = ethers.parseEther('100'); + const assets = (shares * initialAssets) / initialShares; + + describe('requestRedeem', function () { + it('burns shares, marks as pending, emits RedeemRequest with requestId 0', async function () { + const assetsBefore = await this.mock.totalAssets(); + const supplyBefore = await this.mock.totalSupply(); + + // perform request redeem, and extract requestId from timing + const tx = this.mock.connect(this.owner).requestRedeem(shares, this.controller, this.owner); + const requestId = await this.getRequestId(tx); + + // check event is emitted and shares are burned + await expect(tx) + .to.emit(this.mock, 'RedeemRequest') + .withArgs(this.controller, this.owner, requestId, this.owner, shares); + await expect(tx).to.changeTokenBalances(this.token, [this.controller, this.mock], [0n, 0n]); + await expect(tx).to.changeTokenBalances(this.mock, [this.owner], [-shares]); + + // totalSupply includes shares for in-flight redeem + await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore); + await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore); + + // check pending redeem is registered + await expect(this.mock.pendingRedeemRequest(requestId, this.controller)).to.eventually.equal(shares); + await expect(this.mock.claimableRedeemRequest(requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(0n); + }); + + it('operator can trigger request redeem on behalf of owner', async function () { + const tx = this.mock.connect(this.operator).requestRedeem(shares, this.controller, this.owner); + const requestId = await this.getRequestId(tx); + + await expect(tx) + .to.emit(this.mock, 'RedeemRequest') + .withArgs(this.controller, this.owner, requestId, this.operator, shares); + await expect(tx).to.changeTokenBalances(this.token, [this.controller, this.mock], [0n, 0n]); + await expect(tx).to.changeTokenBalances(this.mock, [this.owner], [-shares]); + }); + + it('spends allowance when caller is neither owner nor operator', async function () { + await this.mock.connect(this.owner).approve(this.other, shares); + + const tx = this.mock.connect(this.other).requestRedeem(shares, this.controller, this.owner); + const requestId = await this.getRequestId(tx); + + await expect(tx) + .to.emit(this.mock, 'RedeemRequest') + .withArgs(this.controller, this.owner, requestId, this.other, shares); + + await expect(this.mock.allowance(this.owner, this.other)).to.eventually.equal(0n); + }); + + it('revert of caller is neither owner nor operator and has no allowance', async function () { + await expect(this.mock.connect(this.other).requestRedeem(shares, this.controller, this.owner)) + .to.be.revertedWithCustomError(this.mock, 'ERC20InsufficientAllowance') + .withArgs(this.other, 0n, shares); + }); + + it('accumulates pending across multiple requests', async function () { + const [tx1, tx2] = await batchInBlock( + [ + () => + this.mock.connect(this.operator).requestDeposit(17n, this.controller, this.owner, { gasLimit: 200000n }), + () => + this.mock.connect(this.operator).requestDeposit(42n, this.controller, this.owner, { gasLimit: 200000n }), + ], + ethers.provider, + ); + + const requestId1 = await this.getRequestId(tx1); + const requestId2 = await this.getRequestId(tx2); + expect(requestId1).to.equal(requestId2); + + await expect(this.mock.pendingDepositRequest(requestId1, this.controller)).to.eventually.equal(17n + 42n); + await expect(this.mock.claimableDepositRequest(requestId1, this.controller)).to.eventually.equal(0n); + }); + }); + + supportCustomFulfill && + describe('fulfillRedeem', function () { + beforeEach(async function () { + this.requestId = await this.mock + .connect(this.owner) + .requestRedeem(shares, this.controller, this.owner) + .then(this.getRequestId); + }); + + it('transitions pending to claimable and emits RedeemClaimable', async function () { + const assetsBefore = await this.mock.totalAssets(); + const supplyBefore = await this.mock.totalSupply(); + + await expect(this.mock.pendingRedeemRequest(this.requestId, this.controller)).to.eventually.equal(shares); + await expect(this.mock.claimableRedeemRequest(this.requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(0n); + + await this.fulfillRedeem(this.requestId, assets, shares, this.controller); + + await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore); + await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore); + + await expect(this.mock.pendingRedeemRequest(0n, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableRedeemRequest(0n, this.controller)).to.eventually.equal(shares); + await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(assets); + await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(shares); + }); + + it('supports admin-determined asset ratio', async function () { + await expect(this.mock.pendingRedeemRequest(this.requestId, this.controller)).to.eventually.equal(shares); + await expect(this.mock.claimableRedeemRequest(this.requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(0n); + + await this.fulfillRedeem(this.requestId, 17n, shares, this.controller); + + await expect(this.mock.pendingRedeemRequest(this.requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableRedeemRequest(this.requestId, this.controller)).to.eventually.equal(shares); + await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(17n); + await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(shares); + }); + + it('can be partially fulfilled', async function () { + await expect(this.mock.pendingRedeemRequest(0n, this.controller)).to.eventually.equal(shares); + await expect(this.mock.claimableRedeemRequest(0n, this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(0n); + + await this.fulfillRedeem(this.requestId, 17n, 42n, this.controller); + + await expect(this.mock.pendingRedeemRequest(0n, this.controller)).to.eventually.equal(shares - 42n); + await expect(this.mock.claimableRedeemRequest(0n, this.controller)).to.eventually.equal(42n); + await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(17n); + await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(42n); + }); + + it('reverts when fulfilling more than pending', async function () { + await expect(this.fulfillRedeem(this.requestId, assets, shares + 1n, this.controller)) + .to.be.revertedWithCustomError(this.mock, 'ERC7540RedeemInsufficientPendingShares') + .withArgs(shares + 1n, shares); + }); + }); + + describe('claim', function () { + beforeEach(async function () { + this.requestId = await this.mock + .connect(this.owner) + .requestRedeem(shares, this.controller, this.owner) + .then(this.getRequestId); + await this.fulfillRedeem(this.requestId, assets, shares, this.controller); + }); + + describe('via redeem()', function () { + it('transfers tokens to receiver and emits Withdraw', async function () { + await expect(this.mock.pendingRedeemRequest(this.requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableRedeemRequest(this.requestId, this.controller)).to.eventually.equal(shares); + await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(shares); + + const assetsBefore = await this.mock.totalAssets(); + const supplyBefore = await this.mock.totalSupply(); + + const tx = this.mock.connect(this.controller).redeem(shares, this.receiver, this.controller); + + await expect(tx) + .to.emit(this.mock, 'Withdraw') + .withArgs(this.controller, this.receiver, this.controller, assets, shares); + await expect(tx).to.changeTokenBalances(this.token, [this.mock, this.receiver], [-assets, assets]); + + await expect(this.mock.pendingRedeemRequest(this.requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableRedeemRequest(this.requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(0n); + await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore - assets); + await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore - shares); + }); + + it('operator can trigger redeem on behalf of controller', async function () { + const tx = this.mock.connect(this.operator).redeem(shares, this.receiver, this.controller); + + await expect(tx) + .to.emit(this.mock, 'Withdraw') + .withArgs(this.operator, this.receiver, this.controller, assets, shares); + await expect(tx).to.changeTokenBalances(this.token, [this.mock, this.receiver], [-assets, assets]); + }); + + it('reverts when caller is neither owner nor operator of owner', async function () { + await expect(this.mock.connect(this.other).redeem(shares, this.receiver, this.controller)) + .to.be.revertedWithCustomError(this.mock, 'ERC7540InvalidOperator') + .withArgs(this.controller, this.other); + }); + }); + + describe('via withdraw()', function () { + it('transfers exactly the requested tokens and emits Withdraw', async function () { + await expect(this.mock.pendingRedeemRequest(this.requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableRedeemRequest(this.requestId, this.controller)).to.eventually.equal(shares); + await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(assets); + + const assetsBefore = await this.mock.totalAssets(); + const supplyBefore = await this.mock.totalSupply(); + + const tx = this.mock.connect(this.controller).withdraw(assets, this.receiver, this.controller); + + await expect(tx) + .to.emit(this.mock, 'Withdraw') + .withArgs(this.controller, this.receiver, this.controller, assets, shares); + await expect(tx).to.changeTokenBalances(this.token, [this.mock, this.receiver], [-assets, assets]); + + await expect(this.mock.pendingRedeemRequest(this.requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.claimableRedeemRequest(this.requestId, this.controller)).to.eventually.equal(0n); + await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(0n); + await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore - assets); + await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore - shares); + }); + + it('operator can trigger withdraw on behalf of controller', async function () { + const tx = this.mock.connect(this.operator).withdraw(assets, this.receiver, this.controller); + + await expect(tx) + .to.emit(this.mock, 'Withdraw') + .withArgs(this.operator, this.receiver, this.controller, assets, shares); + await expect(tx).to.changeTokenBalances(this.token, [this.mock, this.receiver], [-assets, assets]); + }); + + it('reverts when caller is neither owner nor operator of owner', async function () { + await expect(this.mock.connect(this.other).withdraw(assets, this.receiver, this.controller)) + .to.be.revertedWithCustomError(this.mock, 'ERC7540InvalidOperator') + .withArgs(this.controller, this.other); + }); + }); + }); + }); +} + +module.exports = { + shouldBehaveLikeERC7540Operator, + shouldBehaveLikeERC7540Deposit, + shouldBehaveLikeERC7540Redeem, +}; diff --git a/test/token/ERC20/extensions/ERC7540Admin.test.js b/test/token/ERC20/extensions/ERC7540Admin.test.js index 69194326..be74484d 100644 --- a/test/token/ERC20/extensions/ERC7540Admin.test.js +++ b/test/token/ERC20/extensions/ERC7540Admin.test.js @@ -2,38 +2,32 @@ const { ethers } = require('hardhat'); const { expect } = require('chai'); const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); -const { shouldSupportInterfaces, INTERFACE_IDS } = require('../../../utils/introspection/SupportsInterface.behavior'); +const { + shouldBehaveLikeERC7540Operator, + shouldBehaveLikeERC7540Deposit, + shouldBehaveLikeERC7540Redeem, +} = require('./ERC7540.behavior'); const name = 'Vault Shares'; const symbol = 'vSHR'; const tokenName = 'Asset Token'; const tokenSymbol = 'AST'; -// initial vault distribution -const initialAssets = ethers.parseEther('17000000'); -const initialShares = ethers.parseEther('42000000'); -// other -const balance = ethers.parseEther('1000'); async function fixture() { - const [owner, controller, receiver, operator, other] = await ethers.getSigners(); - const token = await ethers.deployContract('$ERC20', [tokenName, tokenSymbol]); const mock = await ethers.deployContract('$ERC7540AdminMock', [name, symbol, token]); - - await token.$_mint(mock, initialAssets); - await mock.$_mint(owner, initialShares); - - await token.$_mint(owner, balance); - await token.connect(owner).approve(mock, ethers.MaxUint256); - await mock.connect(owner).setOperator(operator, true); - await mock.connect(controller).setOperator(operator, true); - - return { owner, controller, receiver, operator, other, token, mock }; + return { token, mock }; } describe('ERC7540Admin', function () { beforeEach(async function () { Object.assign(this, await loadFixture(fixture)); + + this.getRequestId = () => 0n; + this.fulfillDeposit = (requestId, assets, shares, controller) => + this.mock.$_fulfillDeposit(assets, shares, controller); + this.fulfillRedeem = (requestId, assets, shares, controller) => + this.mock.$_fulfillRedeem(shares, assets, controller); }); describe('metadata', function () { @@ -51,448 +45,9 @@ describe('ERC7540Admin', function () { await expect(this.mock.$_isDepositAsync()).to.eventually.equal(true); await expect(this.mock.$_isRedeemAsync()).to.eventually.equal(true); }); - - describe('supports ERC-7540 interfaces', function () { - expect(INTERFACE_IDS.ERC7540Operator).to.equal('0xe3bc4e65'); - expect(INTERFACE_IDS.ERC7540Deposit).to.equal('0xce3bbe50'); - expect(INTERFACE_IDS.ERC7540Redeem).to.equal('0x620ee8e4'); - - shouldSupportInterfaces(['ERC7540Operator', 'ERC7540Deposit', 'ERC7540Redeem']); - }); - }); - - describe('deposit flow', function () { - const assets = ethers.parseEther('100'); - const shares = (assets * initialShares) / initialAssets; - - describe('requestDeposit', function () { - it('transfers tokens, marks as pending, emits DepositRequest with requestId 0', async function () { - const assetsBefore = await this.mock.totalAssets(); - const supplyBefore = await this.mock.totalSupply(); - - const tx = this.mock.connect(this.owner).requestDeposit(assets, this.controller, this.owner); - - await expect(tx) - .to.emit(this.mock, 'DepositRequest') - .withArgs(this.controller, this.owner, 0n, this.owner, assets); - await expect(tx).to.changeTokenBalances(this.token, [this.owner, this.mock], [-assets, assets]); - await expect(tx).to.changeTokenBalances(this.mock, [this.controller], [0n]); - - await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore); - await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore); - - await expect(this.mock.pendingDepositRequest(0n, this.controller)).to.eventually.equal(assets); - await expect(this.mock.claimableDepositRequest(0n, this.controller)).to.eventually.equal(0n); - await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(0n); - await expect(this.mock.maxMint(this.controller)).to.eventually.equal(0n); - }); - - it('operator can trigger request deposit on behalf of owner', async function () { - const tx = this.mock.connect(this.operator).requestDeposit(assets, this.controller, this.owner); - - await expect(tx) - .to.emit(this.mock, 'DepositRequest') - .withArgs(this.controller, this.owner, 0n, this.operator, assets); - await expect(tx).to.changeTokenBalances(this.token, [this.owner, this.mock], [-assets, assets]); - await expect(tx).to.changeTokenBalances(this.mock, [this.controller], [0n]); - }); - - it('reverts when caller is neither owner nor operator of owner', async function () { - await expect(this.mock.connect(this.other).requestDeposit(assets, this.controller, this.owner)) - .to.be.revertedWithCustomError(this.mock, 'ERC7540InvalidOperator') - .withArgs(this.owner, this.other); - }); - - it('accumulates pending across multiple requests', async function () { - await this.mock.connect(this.owner).requestDeposit(17n, this.controller, this.owner); - await this.mock.connect(this.owner).requestDeposit(42n, this.controller, this.owner); - - await expect(this.mock.pendingDepositRequest(0n, this.controller)).to.eventually.equal(17n + 42n); - }); - }); - - describe('fulfillDeposit', function () { - beforeEach(async function () { - await this.mock.connect(this.owner).requestDeposit(assets, this.controller, this.owner); - }); - - it('transitions pending to claimable and emits DepositClaimable', async function () { - const assetsBefore = await this.mock.totalAssets(); - const supplyBefore = await this.mock.totalSupply(); - - await expect(this.mock.pendingDepositRequest(0n, this.controller)).to.eventually.equal(assets); - await expect(this.mock.claimableDepositRequest(0n, this.controller)).to.eventually.equal(0n); - await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(0n); - await expect(this.mock.maxMint(this.controller)).to.eventually.equal(0n); - - await expect(this.mock.$_fulfillDeposit(assets, shares, this.controller)) - .to.emit(this.mock, 'DepositClaimable') - .withArgs(this.controller, 0n, assets, shares); - - await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore); - await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore); - - await expect(this.mock.pendingDepositRequest(0n, this.controller)).to.eventually.equal(0n); - await expect(this.mock.claimableDepositRequest(0n, this.controller)).to.eventually.equal(assets); - await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(assets); - await expect(this.mock.maxMint(this.controller)).to.eventually.equal(shares); - }); - - it('supports admin-determined share ratio', async function () { - await expect(this.mock.pendingDepositRequest(0n, this.controller)).to.eventually.equal(assets); - await expect(this.mock.claimableDepositRequest(0n, this.controller)).to.eventually.equal(0n); - await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(0n); - await expect(this.mock.maxMint(this.controller)).to.eventually.equal(0n); - - await expect(this.mock.$_fulfillDeposit(assets, 42n, this.controller)) - .to.emit(this.mock, 'DepositClaimable') - .withArgs(this.controller, 0n, assets, 42n); - - await expect(this.mock.pendingDepositRequest(0n, this.controller)).to.eventually.equal(0n); - await expect(this.mock.claimableDepositRequest(0n, this.controller)).to.eventually.equal(assets); - await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(assets); - await expect(this.mock.maxMint(this.controller)).to.eventually.equal(42n); - }); - - it('can be partially fulfilled', async function () { - await expect(this.mock.pendingDepositRequest(0n, this.controller)).to.eventually.equal(assets); - await expect(this.mock.claimableDepositRequest(0n, this.controller)).to.eventually.equal(0n); - await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(0n); - await expect(this.mock.maxMint(this.controller)).to.eventually.equal(0n); - - await this.mock.$_fulfillDeposit(17n, 42n, this.controller); - - await expect(this.mock.pendingDepositRequest(0n, this.controller)).to.eventually.equal(assets - 17n); - await expect(this.mock.claimableDepositRequest(0n, this.controller)).to.eventually.equal(17n); - await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(17n); - await expect(this.mock.maxMint(this.controller)).to.eventually.equal(42n); - }); - - it('reverts when fulfilling more than pending', async function () { - await expect(this.mock.$_fulfillDeposit(assets + 1n, shares, this.controller)) - .to.be.revertedWithCustomError(this.mock, 'ERC7540DepositInsufficientPendingAssets') - .withArgs(assets + 1n, assets); - }); - }); - - describe('claim', function () { - beforeEach(async function () { - await this.mock.connect(this.owner).requestDeposit(assets, this.controller, this.owner); - await this.mock.$_fulfillDeposit(assets, shares, this.controller); - }); - - describe('via deposit()', function () { - it('mints shares to receiver and emits Deposit', async function () { - await expect(this.mock.pendingDepositRequest(0n, this.controller)).to.eventually.equal(0n); - await expect(this.mock.claimableDepositRequest(0n, this.controller)).to.eventually.equal(assets); - await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(assets); - - const assetsBefore = await this.mock.totalAssets(); - const supplyBefore = await this.mock.totalSupply(); - - const tx = this.mock - .connect(this.controller) - .deposit(assets, this.receiver, ethers.Typed.address(this.controller)); - - await expect(tx).to.emit(this.mock, 'Deposit').withArgs(this.controller, this.receiver, assets, shares); - await expect(tx).to.changeTokenBalance(this.mock, this.receiver, shares); - - await expect(this.mock.pendingDepositRequest(0n, this.controller)).to.eventually.equal(0n); - await expect(this.mock.claimableDepositRequest(0n, this.controller)).to.eventually.equal(0n); - await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(0n); - await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore + assets); - await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore + shares); - }); - - it('operator can trigger deposit on behalf of controller', async function () { - const tx = this.mock - .connect(this.operator) - .deposit(assets, this.receiver, ethers.Typed.address(this.controller)); - - await expect(tx).to.emit(this.mock, 'Deposit').withArgs(this.operator, this.receiver, assets, shares); - await expect(tx).to.changeTokenBalance(this.mock, this.receiver, shares); - }); - - it('reverts when caller is neither owner nor operator of owner', async function () { - await expect( - this.mock.connect(this.other).deposit(assets, this.receiver, ethers.Typed.address(this.controller)), - ) - .to.be.revertedWithCustomError(this.mock, 'ERC7540InvalidOperator') - .withArgs(this.controller, this.other); - }); - }); - - describe('via mint()', function () { - it('mints exactly the requested shares and emits Deposit', async function () { - await expect(this.mock.pendingDepositRequest(0n, this.controller)).to.eventually.equal(0n); - await expect(this.mock.claimableDepositRequest(0n, this.controller)).to.eventually.equal(assets); - await expect(this.mock.maxMint(this.controller)).to.eventually.equal(shares); - - const assetsBefore = await this.mock.totalAssets(); - const supplyBefore = await this.mock.totalSupply(); - - const tx = this.mock - .connect(this.controller) - .mint(shares, this.receiver, ethers.Typed.address(this.controller)); - - await expect(tx).to.emit(this.mock, 'Deposit').withArgs(this.controller, this.receiver, assets, shares); - - await expect(tx).to.changeTokenBalance(this.mock, this.receiver, shares); - - await expect(this.mock.pendingDepositRequest(0n, this.controller)).to.eventually.equal(0n); - await expect(this.mock.claimableDepositRequest(0n, this.controller)).to.eventually.equal(0n); - await expect(this.mock.maxMint(this.controller)).to.eventually.equal(0n); - await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore + assets); - await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore + shares); - }); - - it('operator can trigger mint on behalf of controller', async function () { - const tx = this.mock - .connect(this.operator) - .mint(shares, this.receiver, ethers.Typed.address(this.controller)); - - await expect(tx).to.emit(this.mock, 'Deposit').withArgs(this.operator, this.receiver, assets, shares); - await expect(tx).to.changeTokenBalance(this.mock, this.receiver, shares); - }); - - it('reverts when caller is neither owner nor operator of owner', async function () { - await expect(this.mock.connect(this.other).mint(shares, this.receiver, ethers.Typed.address(this.controller))) - .to.be.revertedWithCustomError(this.mock, 'ERC7540InvalidOperator') - .withArgs(this.controller, this.other); - }); - }); - }); }); - describe('redeem flow', function () { - const shares = ethers.parseEther('100'); - const assets = (shares * initialAssets) / initialShares; - - describe('requestRedeem', function () { - it('burns shares, marks as pending, emits RedeemRequest with requestId 0', async function () { - const assetsBefore = await this.mock.totalAssets(); - const supplyBefore = await this.mock.totalSupply(); - - // perform request redeem, and extract requestId from timing - const tx = this.mock.connect(this.owner).requestRedeem(shares, this.controller, this.owner); - - // check event is emitted and shares are burned - await expect(tx) - .to.emit(this.mock, 'RedeemRequest') - .withArgs(this.controller, this.owner, 0n, this.owner, shares); - await expect(tx).to.changeTokenBalances(this.token, [this.controller, this.mock], [0n, 0n]); - await expect(tx).to.changeTokenBalances(this.mock, [this.owner], [-shares]); - - // totalSupply includes shares for in-flight redeem - await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore); - await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore); - - // check pending redeem is registered - await expect(this.mock.pendingRedeemRequest(0n, this.controller)).to.eventually.equal(shares); - await expect(this.mock.claimableRedeemRequest(0n, this.controller)).to.eventually.equal(0n); - await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(0n); - await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(0n); - }); - - it('operator can trigger request redeem on behalf of owner', async function () { - const tx = this.mock.connect(this.operator).requestRedeem(shares, this.controller, this.owner); - - await expect(tx) - .to.emit(this.mock, 'RedeemRequest') - .withArgs(this.controller, this.owner, 0n, this.operator, shares); - await expect(tx).to.changeTokenBalances(this.token, [this.controller, this.mock], [0n, 0n]); - await expect(tx).to.changeTokenBalances(this.mock, [this.owner], [-shares]); - }); - - it('spends allowance when caller is neither owner nor operator', async function () { - await this.mock.connect(this.owner).approve(this.other, shares); - - const tx = this.mock.connect(this.other).requestRedeem(shares, this.controller, this.owner); - - await expect(tx) - .to.emit(this.mock, 'RedeemRequest') - .withArgs(this.controller, this.owner, 0n, this.other, shares); - - await expect(this.mock.allowance(this.owner, this.other)).to.eventually.equal(0n); - }); - - it('revert of caller is neither owner nor operator and has no allowance', async function () { - const tx = this.mock.connect(this.other).requestRedeem(shares, this.controller, this.owner); - - await expect(tx) - .to.be.revertedWithCustomError(this.mock, 'ERC20InsufficientAllowance') - .withArgs(this.other, 0n, shares); - }); - - it('accumulates pending across multiple requests', async function () { - await this.mock.connect(this.owner).requestRedeem(17n, this.controller, this.owner); - await this.mock.connect(this.owner).requestRedeem(42n, this.controller, this.owner); - - await expect(this.mock.pendingRedeemRequest(0n, this.controller)).to.eventually.equal(17n + 42n); - }); - }); - - describe('fulfillRedeem', function () { - beforeEach(async function () { - await this.mock.connect(this.owner).requestRedeem(shares, this.controller, this.owner); - }); - - it('transitions pending to claimable and emits RedeemClaimable', async function () { - const assetsBefore = await this.mock.totalAssets(); - const supplyBefore = await this.mock.totalSupply(); - - await expect(this.mock.pendingRedeemRequest(0n, this.controller)).to.eventually.equal(shares); - await expect(this.mock.claimableRedeemRequest(0n, this.controller)).to.eventually.equal(0n); - await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(0n); - await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(0n); - - await expect(this.mock.$_fulfillRedeem(shares, assets, this.controller)) - .to.emit(this.mock, 'RedeemClaimable') - .withArgs(this.controller, 0n, assets, shares); - - await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore); - await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore); - - await expect(this.mock.pendingRedeemRequest(0n, this.controller)).to.eventually.equal(0n); - await expect(this.mock.claimableRedeemRequest(0n, this.controller)).to.eventually.equal(shares); - await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(assets); - await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(shares); - }); - - it('supports admin-determined asset ratio', async function () { - await expect(this.mock.pendingRedeemRequest(0n, this.controller)).to.eventually.equal(shares); - await expect(this.mock.claimableRedeemRequest(0n, this.controller)).to.eventually.equal(0n); - await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(0n); - await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(0n); - - await expect(this.mock.$_fulfillRedeem(shares, 42n, this.controller)) - .to.emit(this.mock, 'RedeemClaimable') - .withArgs(this.controller, 0n, 42n, shares); - - await expect(this.mock.pendingRedeemRequest(0n, this.controller)).to.eventually.equal(0n); - await expect(this.mock.claimableRedeemRequest(0n, this.controller)).to.eventually.equal(shares); - await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(42n); - await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(shares); - }); - - it('can be partially fulfilled', async function () { - await expect(this.mock.pendingRedeemRequest(0n, this.controller)).to.eventually.equal(shares); - await expect(this.mock.claimableRedeemRequest(0n, this.controller)).to.eventually.equal(0n); - await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(0n); - await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(0n); - - await expect(this.mock.$_fulfillRedeem(42n, 17n, this.controller)) - .to.emit(this.mock, 'RedeemClaimable') - .withArgs(this.controller, 0n, 17n, 42n); - - await expect(this.mock.pendingRedeemRequest(0n, this.controller)).to.eventually.equal(shares - 42n); - await expect(this.mock.claimableRedeemRequest(0n, this.controller)).to.eventually.equal(42n); - await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(17n); - await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(42n); - }); - - it('reverts when fulfilling more than pending', async function () { - await expect(this.mock.$_fulfillRedeem(shares + 1n, assets, this.controller)) - .to.be.revertedWithCustomError(this.mock, 'ERC7540RedeemInsufficientPendingShares') - .withArgs(shares + 1n, shares); - }); - }); - - describe('claim', function () { - beforeEach(async function () { - await this.mock.connect(this.owner).requestRedeem(shares, this.controller, this.owner); - await this.mock.$_fulfillRedeem(shares, assets, this.controller); - }); - - describe('via redeem()', function () { - it('transfers tokens to receiver and emits Withdraw', async function () { - await expect(this.mock.pendingRedeemRequest(0n, this.controller)).to.eventually.equal(0n); - await expect(this.mock.claimableRedeemRequest(0n, this.controller)).to.eventually.equal(shares); - await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(shares); - - const assetsBefore = await this.mock.totalAssets(); - const supplyBefore = await this.mock.totalSupply(); - - const tx = this.mock.connect(this.controller).redeem(shares, this.receiver, this.controller); - - await expect(tx) - .to.emit(this.mock, 'Withdraw') - .withArgs(this.controller, this.receiver, this.controller, assets, shares); - await expect(tx).to.changeTokenBalances(this.token, [this.mock, this.receiver], [-assets, assets]); - - await expect(this.mock.pendingRedeemRequest(0n, this.controller)).to.eventually.equal(0n); - await expect(this.mock.claimableRedeemRequest(0n, this.controller)).to.eventually.equal(0n); - await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(0n); - await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore - assets); - await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore - shares); - }); - - it('operator can trigger redeem on behalf of controller', async function () { - const tx = this.mock.connect(this.operator).redeem(shares, this.receiver, this.controller); - - await expect(tx) - .to.emit(this.mock, 'Withdraw') - .withArgs(this.operator, this.receiver, this.controller, assets, shares); - await expect(tx).to.changeTokenBalances(this.token, [this.mock, this.receiver], [-assets, assets]); - }); - - it('reverts when caller is neither owner nor operator of owner', async function () { - await expect(this.mock.connect(this.other).redeem(shares, this.receiver, this.controller)) - .to.be.revertedWithCustomError(this.mock, 'ERC7540InvalidOperator') - .withArgs(this.controller, this.other); - }); - }); - - describe('via withdraw()', function () { - it('transfers exactly the requested tokens and emits Withdraw', async function () { - await expect(this.mock.pendingRedeemRequest(0n, this.controller)).to.eventually.equal(0n); - await expect(this.mock.claimableRedeemRequest(0n, this.controller)).to.eventually.equal(shares); - await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(assets); - - const assetsBefore = await this.mock.totalAssets(); - const supplyBefore = await this.mock.totalSupply(); - - const tx = this.mock.connect(this.controller).withdraw(assets, this.receiver, this.controller); - - await expect(tx) - .to.emit(this.mock, 'Withdraw') - .withArgs(this.controller, this.receiver, this.controller, assets, shares); - await expect(tx).to.changeTokenBalances(this.token, [this.mock, this.receiver], [-assets, assets]); - - await expect(this.mock.pendingRedeemRequest(0n, this.controller)).to.eventually.equal(0n); - await expect(this.mock.claimableRedeemRequest(0n, this.controller)).to.eventually.equal(0n); - await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(0n); - await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore - assets); - await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore - shares); - }); - - it('operator can trigger withdraw on behalf of controller', async function () { - const tx = this.mock.connect(this.operator).withdraw(assets, this.receiver, this.controller); - - await expect(tx) - .to.emit(this.mock, 'Withdraw') - .withArgs(this.operator, this.receiver, this.controller, assets, shares); - await expect(tx).to.changeTokenBalances(this.token, [this.mock, this.receiver], [-assets, assets]); - }); - - it('reverts when caller is neither owner nor operator of owner', async function () { - await expect(this.mock.connect(this.other).withdraw(assets, this.receiver, this.controller)) - .to.be.revertedWithCustomError(this.mock, 'ERC7540InvalidOperator') - .withArgs(this.controller, this.other); - }); - }); - }); - }); - - describe('operators', function () { - for (const status of [true, false]) { - it(`setOperator to ${status} emits event and updates status`, async function () { - await expect(this.mock.connect(this.owner).setOperator(this.operator, status)) - .to.emit(this.mock, 'OperatorSet') - .withArgs(this.owner, this.operator, status); - - await expect(this.mock.isOperator(this.owner, this.operator)).to.eventually.equal(status); - }); - } - }); + shouldBehaveLikeERC7540Operator(); + shouldBehaveLikeERC7540Deposit({ supportCustomFulfill: true }); + shouldBehaveLikeERC7540Redeem({ supportCustomFulfill: true }); }); diff --git a/test/token/ERC20/extensions/ERC7540Delay.test.js b/test/token/ERC20/extensions/ERC7540Delay.test.js index 4e736e99..ea56e141 100644 --- a/test/token/ERC20/extensions/ERC7540Delay.test.js +++ b/test/token/ERC20/extensions/ERC7540Delay.test.js @@ -3,40 +3,31 @@ const { expect } = require('chai'); const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); const time = require('@openzeppelin/contracts/test/helpers/time'); -const { batchInBlock } = require('@openzeppelin/contracts/test/helpers/txpool'); -const { shouldSupportInterfaces, INTERFACE_IDS } = require('../../../utils/introspection/SupportsInterface.behavior'); +const { + shouldBehaveLikeERC7540Operator, + shouldBehaveLikeERC7540Deposit, + shouldBehaveLikeERC7540Redeem, +} = require('./ERC7540.behavior'); const name = 'Vault Shares'; const symbol = 'vSHR'; const tokenName = 'Asset Token'; const tokenSymbol = 'AST'; -// initial vault distribution -const initialAssets = ethers.parseEther('17000000'); -const initialShares = ethers.parseEther('42000000'); -// other -const balance = ethers.parseEther('1000'); const delay = 3600n; async function fixture() { - const [owner, controller, receiver, operator, other] = await ethers.getSigners(); - const token = await ethers.deployContract('$ERC20', [tokenName, tokenSymbol]); const mock = await ethers.deployContract('$ERC7540DelayMock', [name, symbol, token]); - - await token.$_mint(mock, initialAssets); - await mock.$_mint(owner, initialShares); - - await token.$_mint(owner, balance); - await token.connect(owner).approve(mock, ethers.MaxUint256); - await mock.connect(owner).setOperator(operator, true); - await mock.connect(controller).setOperator(operator, true); - - return { owner, controller, receiver, operator, other, token, mock }; + return { token, mock }; } describe('ERC7540Delay', function () { beforeEach(async function () { Object.assign(this, await loadFixture(fixture)); + + this.getRequestId = tx => time.clockFromReceipt.timestamp(tx).then(timestamp => timestamp + delay); + this.fulfillDeposit = requestId => time.increaseTo.timestamp(requestId); + this.fulfillRedeem = requestId => time.increaseTo.timestamp(requestId); }); describe('metadata', function () { @@ -50,399 +41,18 @@ describe('ERC7540Delay', function () { 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('reports default delay', async function () { await expect(this.mock.depositDelay(this.owner)).to.eventually.equal(delay); await expect(this.mock.redeemDelay(this.owner)).to.eventually.equal(delay); }); - describe('supports ERC-7540 interfaces', function () { - expect(INTERFACE_IDS.ERC7540Operator).to.equal('0xe3bc4e65'); - expect(INTERFACE_IDS.ERC7540Deposit).to.equal('0xce3bbe50'); - expect(INTERFACE_IDS.ERC7540Redeem).to.equal('0x620ee8e4'); - - shouldSupportInterfaces(['ERC7540Operator', 'ERC7540Deposit', 'ERC7540Redeem']); - }); - }); - - describe('deposit flow', function () { - const assets = ethers.parseEther('100'); - const shares = (assets * initialShares) / initialAssets; - - describe('requestDeposit', function () { - it('transfers tokens and emits DepositRequest with timepoint-based requestId', async function () { - const assetsBefore = await this.mock.totalAssets(); - const supplyBefore = await this.mock.totalSupply(); - - // perform request deposit, and extract requestId from timing - const tx = this.mock.connect(this.owner).requestDeposit(assets, this.controller, this.owner); - const requestId = (await time.clockFromReceipt.timestamp(tx)) + delay; - - // check event is emitted and tokens are deposited - await expect(tx) - .to.emit(this.mock, 'DepositRequest') - .withArgs(this.controller, this.owner, requestId, this.owner, assets); - await expect(tx).to.changeTokenBalances(this.token, [this.owner, this.mock], [-assets, assets]); - await expect(tx).to.changeTokenBalances(this.mock, [this.controller], [0n]); - - // totalAssets excludes in-flight deposits - await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore); - await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore); - - // check pending deposit is registered - await expect(this.mock.pendingDepositRequest(requestId, this.controller)).to.eventually.equal(assets); - await expect(this.mock.claimableDepositRequest(requestId, this.controller)).to.eventually.equal(0n); - await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(0n); - await expect(this.mock.maxMint(this.controller)).to.eventually.equal(0n); - - // move forward - await time.increaseTo.timestamp(requestId); - - // check deposit becomes claimable automatically - await expect(this.mock.pendingDepositRequest(requestId, this.controller)).to.eventually.equal(0n); - await expect(this.mock.claimableDepositRequest(requestId, this.controller)).to.eventually.equal(assets); - await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(assets); - await expect(this.mock.maxMint(this.controller)).to.eventually.equal(shares); - }); - - it('operator can trigger request deposit on behalf of owner', async function () { - const tx = this.mock.connect(this.operator).requestDeposit(assets, this.controller, this.owner); - const requestId = (await time.clockFromReceipt.timestamp(tx)) + delay; - - await expect(tx) - .to.emit(this.mock, 'DepositRequest') - .withArgs(this.controller, this.owner, requestId, this.operator, assets); - await expect(tx).to.changeTokenBalances(this.token, [this.owner, this.mock], [-assets, assets]); - await expect(tx).to.changeTokenBalances(this.mock, [this.controller], [0n]); - }); - - it('two deposit request in the same block are merged', async function () { - const [tx1, tx2] = await batchInBlock( - [ - () => - this.mock.connect(this.operator).requestDeposit(17n, this.controller, this.owner, { gasLimit: 200000n }), - () => - this.mock.connect(this.operator).requestDeposit(42n, this.controller, this.owner, { gasLimit: 200000n }), - ], - ethers.provider, - ); - const requestId1 = (await time.clockFromReceipt.timestamp(tx1)) + delay; - const requestId2 = (await time.clockFromReceipt.timestamp(tx2)) + delay; - expect(requestId1).to.equal(requestId2); - - await expect(this.mock.pendingDepositRequest(requestId1, this.controller)).to.eventually.equal(17n + 42n); - await expect(this.mock.claimableDepositRequest(requestId1, this.controller)).to.eventually.equal(0n); - - await time.increaseTo.timestamp(requestId1); - - await expect(this.mock.pendingDepositRequest(requestId1, this.controller)).to.eventually.equal(0n); - await expect(this.mock.claimableDepositRequest(requestId1, this.controller)).to.eventually.equal(17n + 42n); - }); - - it('reverts when caller is neither owner nor operator of owner', async function () { - await expect(this.mock.connect(this.other).requestDeposit(assets, this.controller, this.owner)) - .to.be.revertedWithCustomError(this.mock, 'ERC7540InvalidOperator') - .withArgs(this.owner, this.other); - }); - }); - - describe('claim', function () { - beforeEach(async function () { - const tx = this.mock.connect(this.operator).requestDeposit(assets, this.controller, this.owner); - this.requestId = (await time.clockFromReceipt.timestamp(tx)) + delay; - await time.increaseTo.timestamp(this.requestId); - }); - - describe('via deposit()', function () { - it('mints shares 1:1 to receiver and emits Deposit', async function () { - // assets are ready to be claimed - await expect(this.mock.pendingDepositRequest(this.requestId, this.controller)).to.eventually.equal(0n); - await expect(this.mock.claimableDepositRequest(this.requestId, this.controller)).to.eventually.equal(assets); - await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(assets); - - const assetsBefore = await this.mock.totalAssets(); - const supplyBefore = await this.mock.totalSupply(); - - // perform deposit, check event is emitted and shares are minted - const tx = this.mock - .connect(this.controller) - .deposit(assets, this.receiver, ethers.Typed.address(this.controller)); - - await expect(tx).to.emit(this.mock, 'Deposit').withArgs(this.controller, this.receiver, assets, shares); - await expect(tx).to.changeTokenBalance(this.mock, this.receiver, shares); - - // claimable assets are released, totalAssets and totalSupply are updated - await expect(this.mock.pendingDepositRequest(this.requestId, this.controller)).to.eventually.equal(0n); - await expect(this.mock.claimableDepositRequest(this.requestId, this.controller)).to.eventually.equal(0n); - await expect(this.mock.maxDeposit(this.controller)).to.eventually.equal(0n); - await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore + assets); - await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore + shares); - }); - - it('operator can trigger deposit on behalf of controller', async function () { - const tx = this.mock - .connect(this.operator) - .deposit(assets, this.receiver, ethers.Typed.address(this.controller)); - - await expect(tx).to.emit(this.mock, 'Deposit').withArgs(this.operator, this.receiver, assets, shares); - await expect(tx).to.changeTokenBalance(this.mock, this.receiver, shares); - }); - - it('reverts when caller is neither owner nor operator of owner', async function () { - await expect( - this.mock.connect(this.other).deposit(assets, this.receiver, ethers.Typed.address(this.controller)), - ) - .to.be.revertedWithCustomError(this.mock, 'ERC7540InvalidOperator') - .withArgs(this.controller, this.other); - }); - }); - - describe('via mint()', function () { - it('mints exactly the requested shares and emits Deposit', async function () { - // assets are ready to be claimed - await expect(this.mock.pendingDepositRequest(this.requestId, this.controller)).to.eventually.equal(0n); - await expect(this.mock.claimableDepositRequest(this.requestId, this.controller)).to.eventually.equal(assets); - await expect(this.mock.maxMint(this.controller)).to.eventually.equal(shares); - - const assetsBefore = await this.mock.totalAssets(); - const supplyBefore = await this.mock.totalSupply(); - - // perform mint, check event is emitted and shares are minted - const tx = this.mock - .connect(this.controller) - .mint(shares, this.receiver, ethers.Typed.address(this.controller)); - - await expect(tx).to.emit(this.mock, 'Deposit').withArgs(this.controller, this.receiver, assets, shares); - await expect(tx).to.changeTokenBalance(this.mock, this.receiver, shares); - - // claimable assets are released, totalAssets and totalSupply are updated - await expect(this.mock.pendingDepositRequest(this.requestId, this.controller)).to.eventually.equal(0n); - await expect(this.mock.claimableDepositRequest(this.requestId, this.controller)).to.eventually.equal(0n); - await expect(this.mock.maxMint(this.controller)).to.eventually.equal(0n); - await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore + assets); - await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore + shares); - }); - - it('operator can trigger mint on behalf of controller', async function () { - const tx = this.mock - .connect(this.operator) - .mint(shares, this.receiver, ethers.Typed.address(this.controller)); - - await expect(tx).to.emit(this.mock, 'Deposit').withArgs(this.operator, this.receiver, assets, shares); - await expect(tx).to.changeTokenBalance(this.mock, this.receiver, shares); - }); - - it('reverts when caller is neither owner nor operator of owner', async function () { - await expect(this.mock.connect(this.other).mint(shares, this.receiver, ethers.Typed.address(this.controller))) - .to.be.revertedWithCustomError(this.mock, 'ERC7540InvalidOperator') - .withArgs(this.controller, this.other); - }); - }); - }); - }); - - describe('redeem flow', function () { - const shares = ethers.parseEther('100'); - const assets = (shares * initialAssets) / initialShares; - - describe('requestRedeem', function () { - it('burns shares and emits RedeemRequest with timepoint-based requestId', async function () { - const assetsBefore = await this.mock.totalAssets(); - const supplyBefore = await this.mock.totalSupply(); - - // perform request redeem, and extract requestId from timing - const tx = this.mock.connect(this.owner).requestRedeem(shares, this.controller, this.owner); - const requestId = (await time.clockFromReceipt.timestamp(tx)) + delay; - - // check event is emitted and shares are burned - await expect(tx) - .to.emit(this.mock, 'RedeemRequest') - .withArgs(this.controller, this.owner, requestId, this.owner, shares); - await expect(tx).to.changeTokenBalances(this.token, [this.controller, this.mock], [0n, 0n]); - await expect(tx).to.changeTokenBalances(this.mock, [this.owner], [-shares]); - - // totalSupply includes shares for in-flight redeem - await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore); - await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore); - - // check pending redeem is registered - await expect(this.mock.pendingRedeemRequest(requestId, this.controller)).to.eventually.equal(shares); - await expect(this.mock.claimableRedeemRequest(requestId, this.controller)).to.eventually.equal(0n); - await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(0n); - await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(0n); - - // move forward - await time.increaseTo.timestamp(requestId); - - // check redeem becomes claimable automatically - await expect(this.mock.pendingRedeemRequest(requestId, this.controller)).to.eventually.equal(0n); - await expect(this.mock.claimableRedeemRequest(requestId, this.controller)).to.eventually.equal(shares); - await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(shares); - await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(assets); - }); - - it('operator can trigger request deposit on behalf of owner', async function () { - const tx = this.mock.connect(this.operator).requestRedeem(shares, this.controller, this.owner); - const requestId = (await time.clockFromReceipt.timestamp(tx)) + delay; - - await expect(tx) - .to.emit(this.mock, 'RedeemRequest') - .withArgs(this.controller, this.owner, requestId, this.operator, shares); - await expect(tx).to.changeTokenBalances(this.token, [this.controller, this.mock], [0n, 0n]); - await expect(tx).to.changeTokenBalances(this.mock, [this.owner], [-shares]); - }); - - it('spends allowance when caller is neither owner nor operator', async function () { - await this.mock.connect(this.owner).approve(this.other, shares); - - const tx = this.mock.connect(this.other).requestRedeem(shares, this.controller, this.owner); - const requestId = (await time.clockFromReceipt.timestamp(tx)) + delay; - - await expect(tx) - .to.emit(this.mock, 'RedeemRequest') - .withArgs(this.controller, this.owner, requestId, this.other, shares); - - await expect(this.mock.allowance(this.owner, this.other)).to.eventually.equal(0n); - }); - - it('two redeem request in the same block are merged', async function () { - const [tx1, tx2] = await batchInBlock( - [ - () => - this.mock.connect(this.operator).requestRedeem(17n, this.controller, this.owner, { gasLimit: 200000n }), - () => - this.mock.connect(this.operator).requestRedeem(42n, this.controller, this.owner, { gasLimit: 200000n }), - ], - ethers.provider, - ); - const requestId1 = (await time.clockFromReceipt.timestamp(tx1)) + delay; - const requestId2 = (await time.clockFromReceipt.timestamp(tx2)) + delay; - expect(requestId1).to.equal(requestId2); - - await expect(this.mock.pendingRedeemRequest(requestId1, this.controller)).to.eventually.equal(17n + 42n); - await expect(this.mock.claimableRedeemRequest(requestId1, this.controller)).to.eventually.equal(0n); - - await time.increaseTo.timestamp(requestId1); - - await expect(this.mock.pendingRedeemRequest(requestId1, this.controller)).to.eventually.equal(0n); - await expect(this.mock.claimableRedeemRequest(requestId1, this.controller)).to.eventually.equal(17n + 42n); - }); - - it('revert of caller is neither owner nor operator and has no allowance', async function () { - const tx = this.mock.connect(this.other).requestRedeem(shares, this.controller, this.owner); - - await expect(tx) - .to.be.revertedWithCustomError(this.mock, 'ERC20InsufficientAllowance') - .withArgs(this.other, 0n, shares); - }); - }); - - describe('claim', function () { - beforeEach(async function () { - const tx = this.mock.connect(this.operator).requestRedeem(shares, this.controller, this.owner); - this.requestId = (await time.clockFromReceipt.timestamp(tx)) + delay; - await time.increaseTo.timestamp(this.requestId); - }); - - describe('via redeem()', function () { - it('transfers tokens to receiver and emits Withdraw', async function () { - // shares are ready to be claimed - await expect(this.mock.pendingRedeemRequest(this.requestId, this.controller)).to.eventually.equal(0n); - await expect(this.mock.claimableRedeemRequest(this.requestId, this.controller)).to.eventually.equal(shares); - await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(shares); - - const assetsBefore = await this.mock.totalAssets(); - const supplyBefore = await this.mock.totalSupply(); - - // perform redeem, check event is emitted and assets are released - const tx = this.mock.connect(this.controller).redeem(shares, this.receiver, this.controller); - - await expect(tx) - .to.emit(this.mock, 'Withdraw') - .withArgs(this.controller, this.receiver, this.controller, assets, shares); - await expect(tx).to.changeTokenBalances(this.token, [this.mock, this.receiver], [-assets, assets]); - - // claimable shares are deducted, totalAssets and totalSupply are updated - await expect(this.mock.pendingRedeemRequest(this.requestId, this.controller)).to.eventually.equal(0n); - await expect(this.mock.claimableRedeemRequest(this.requestId, this.controller)).to.eventually.equal(0n); - await expect(this.mock.maxRedeem(this.controller)).to.eventually.equal(0n); - await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore - assets); - await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore - shares); - }); - - it('operator can trigger redeem on behalf of controller', async function () { - const tx = this.mock.connect(this.operator).redeem(shares, this.receiver, this.controller); - - await expect(tx) - .to.emit(this.mock, 'Withdraw') - .withArgs(this.operator, this.receiver, this.controller, assets, shares); - await expect(tx).to.changeTokenBalances(this.token, [this.mock, this.receiver], [-assets, assets]); - }); - - it('reverts when caller is neither owner nor operator of owner', async function () { - await expect(this.mock.connect(this.other).redeem(assets, this.receiver, this.controller)) - .to.be.revertedWithCustomError(this.mock, 'ERC7540InvalidOperator') - .withArgs(this.controller, this.other); - }); - }); - - describe('via withdraw()', function () { - it('transfers exactly the requested tokens and emits Withdraw', async function () { - // shares are ready to be claimed - await expect(this.mock.pendingRedeemRequest(this.requestId, this.controller)).to.eventually.equal(0n); - await expect(this.mock.claimableRedeemRequest(this.requestId, this.controller)).to.eventually.equal(shares); - await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(assets); - - const assetsBefore = await this.mock.totalAssets(); - const supplyBefore = await this.mock.totalSupply(); - - // perform withdraw, check event is emitted and assets are released - const tx = this.mock.connect(this.controller).withdraw(assets, this.receiver, this.controller); - - await expect(tx) - .to.emit(this.mock, 'Withdraw') - .withArgs(this.controller, this.receiver, this.controller, assets, shares); - await expect(tx).to.changeTokenBalances(this.token, [this.mock, this.receiver], [-assets, assets]); - - // claimable shares are deducted, totalAssets and totalSupply are updated - await expect(this.mock.pendingRedeemRequest(this.requestId, this.controller)).to.eventually.equal(0n); - await expect(this.mock.claimableRedeemRequest(this.requestId, this.controller)).to.eventually.equal(0n); - await expect(this.mock.maxWithdraw(this.controller)).to.eventually.equal(0n); - await expect(this.mock.totalAssets()).to.eventually.equal(assetsBefore - assets); - await expect(this.mock.totalSupply()).to.eventually.equal(supplyBefore - shares); - }); - - it('operator can trigger withdraw on behalf of controller', async function () { - const tx = this.mock.connect(this.operator).withdraw(assets, this.receiver, this.controller); - - await expect(tx) - .to.emit(this.mock, 'Withdraw') - .withArgs(this.operator, this.receiver, this.controller, assets, shares); - await expect(tx).to.changeTokenBalances(this.token, [this.mock, this.receiver], [-assets, assets]); - }); - - it('reverts when caller is neither owner nor operator of owner', async function () { - await expect(this.mock.connect(this.other).withdraw(assets, this.receiver, this.controller)) - .to.be.revertedWithCustomError(this.mock, 'ERC7540InvalidOperator') - .withArgs(this.controller, this.other); - }); - }); + 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); }); }); - describe('operators', function () { - for (const status of [true, false]) { - it(`setOperator to ${status} emits event and updates status`, async function () { - await expect(this.mock.connect(this.owner).setOperator(this.operator, status)) - .to.emit(this.mock, 'OperatorSet') - .withArgs(this.owner, this.operator, status); - - await expect(this.mock.isOperator(this.owner, this.operator)).to.eventually.equal(status); - }); - } - }); + shouldBehaveLikeERC7540Operator(); + shouldBehaveLikeERC7540Deposit({ supportCustomFulfill: false }); + shouldBehaveLikeERC7540Redeem({ supportCustomFulfill: false }); }); From d1492e157a4a384cc2153564aef1f0091bfa08ce Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Tue, 21 Apr 2026 14:29:25 +0200 Subject: [PATCH 30/60] add test --- .../ERC20/extensions/ERC7540Core.test.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 test/token/ERC20/extensions/ERC7540Core.test.js diff --git a/test/token/ERC20/extensions/ERC7540Core.test.js b/test/token/ERC20/extensions/ERC7540Core.test.js new file mode 100644 index 00000000..a5dadb0f --- /dev/null +++ b/test/token/ERC20/extensions/ERC7540Core.test.js @@ -0,0 +1,19 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); + +const name = 'Vault Shares'; +const symbol = 'vSHR'; +const tokenName = 'Asset Token'; +const tokenSymbol = 'AST'; + +describe('ERC7540Core', function () { + it('construction fails if no async mechanism is enabled', async function () { + const token = await ethers.deployContract('$ERC20', [tokenName, tokenSymbol]); + const factory = await ethers.getContractFactory('$ERC7540'); + + await expect(ethers.deployContract('$ERC7540', [name, symbol, token])).to.be.revertedWithCustomError( + factory, + 'ERC7540MissingAsync', + ); + }); +}); From 99c4e46b2f3bbdc6fcea7d8852faf8c1d583c56e Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Tue, 21 Apr 2026 14:49:33 +0200 Subject: [PATCH 31/60] regenerate package-lock.json --- package-lock.json | 90 +++++++++++++++++++++++------------------------ 1 file changed, 45 insertions(+), 45 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3dde46e8..f0b710ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ }, "lib/@openzeppelin-contracts": { "name": "openzeppelin-solidity", - "version": "5.5.0", + "version": "5.6.1", "dev": true, "license": "MIT", "devDependencies": { @@ -39,7 +39,7 @@ "glob": "^13.0.0", "globals": "^16.0.0", "graphlib": "^2.1.8", - "hardhat": "^2.24.3", + "hardhat": "^2.28.0", "hardhat-exposed": "^0.3.15", "hardhat-gas-reporter": "^2.1.0", "hardhat-ignore-warnings": "^0.2.11", @@ -1887,92 +1887,92 @@ } }, "node_modules/@nomicfoundation/edr": { - "version": "0.11.3", - "resolved": "https://registry.npmjs.org/@nomicfoundation/edr/-/edr-0.11.3.tgz", - "integrity": "sha512-kqILRkAd455Sd6v8mfP3C1/0tCOynJWY+Ir+k/9Boocu2kObCrsFgG+ZWB7fSBVdd9cPVSNrnhWS+V+PEo637g==", + "version": "0.12.0-next.23", + "resolved": "https://registry.npmjs.org/@nomicfoundation/edr/-/edr-0.12.0-next.23.tgz", + "integrity": "sha512-F2/6HZh8Q9RsgkOIkRrckldbhPjIZY7d4mT9LYuW68miwGQ5l7CkAgcz9fRRiurA0+YJhtsbx/EyrD9DmX9BOw==", "dev": true, "license": "MIT", "dependencies": { - "@nomicfoundation/edr-darwin-arm64": "0.11.3", - "@nomicfoundation/edr-darwin-x64": "0.11.3", - "@nomicfoundation/edr-linux-arm64-gnu": "0.11.3", - "@nomicfoundation/edr-linux-arm64-musl": "0.11.3", - "@nomicfoundation/edr-linux-x64-gnu": "0.11.3", - "@nomicfoundation/edr-linux-x64-musl": "0.11.3", - "@nomicfoundation/edr-win32-x64-msvc": "0.11.3" + "@nomicfoundation/edr-darwin-arm64": "0.12.0-next.23", + "@nomicfoundation/edr-darwin-x64": "0.12.0-next.23", + "@nomicfoundation/edr-linux-arm64-gnu": "0.12.0-next.23", + "@nomicfoundation/edr-linux-arm64-musl": "0.12.0-next.23", + "@nomicfoundation/edr-linux-x64-gnu": "0.12.0-next.23", + "@nomicfoundation/edr-linux-x64-musl": "0.12.0-next.23", + "@nomicfoundation/edr-win32-x64-msvc": "0.12.0-next.23" }, "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/@nomicfoundation/edr-darwin-arm64": { - "version": "0.11.3", - "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-darwin-arm64/-/edr-darwin-arm64-0.11.3.tgz", - "integrity": "sha512-w0tksbdtSxz9nuzHKsfx4c2mwaD0+l5qKL2R290QdnN9gi9AV62p9DHkOgfBdyg6/a6ZlnQqnISi7C9avk/6VA==", + "version": "0.12.0-next.23", + "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-darwin-arm64/-/edr-darwin-arm64-0.12.0-next.23.tgz", + "integrity": "sha512-Amh7mRoDzZyJJ4efqoePqdoZOzharmSOttZuJDlVE5yy07BoE8hL6ZRpa5fNYn0LCqn/KoWs8OHANWxhKDGhvQ==", "dev": true, "license": "MIT", "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/@nomicfoundation/edr-darwin-x64": { - "version": "0.11.3", - "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-darwin-x64/-/edr-darwin-x64-0.11.3.tgz", - "integrity": "sha512-QR4jAFrPbOcrO7O2z2ESg+eUeIZPe2bPIlQYgiJ04ltbSGW27FblOzdd5+S3RoOD/dsZGKAvvy6dadBEl0NgoA==", + "version": "0.12.0-next.23", + "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-darwin-x64/-/edr-darwin-x64-0.12.0-next.23.tgz", + "integrity": "sha512-9wn489FIQm7m0UCD+HhktjWx6vskZzeZD9oDc2k9ZvbBzdXwPp5tiDqUBJ+eQpByAzCDfteAJwRn2lQCE0U+Iw==", "dev": true, "license": "MIT", "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/@nomicfoundation/edr-linux-arm64-gnu": { - "version": "0.11.3", - "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-linux-arm64-gnu/-/edr-linux-arm64-gnu-0.11.3.tgz", - "integrity": "sha512-Ktjv89RZZiUmOFPspuSBVJ61mBZQ2+HuLmV67InNlh9TSUec/iDjGIwAn59dx0bF/LOSrM7qg5od3KKac4LJDQ==", + "version": "0.12.0-next.23", + "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-linux-arm64-gnu/-/edr-linux-arm64-gnu-0.12.0-next.23.tgz", + "integrity": "sha512-nlk5EejSzEUfEngv0Jkhqq3/wINIfF2ED9wAofc22w/V1DV99ASh9l3/e/MIHOQFecIZ9MDqt0Em9/oDyB1Uew==", "dev": true, "license": "MIT", "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/@nomicfoundation/edr-linux-arm64-musl": { - "version": "0.11.3", - "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-linux-arm64-musl/-/edr-linux-arm64-musl-0.11.3.tgz", - "integrity": "sha512-B3sLJx1rL2E9pfdD4mApiwOZSrX0a/KQSBWdlq1uAhFKqkl00yZaY4LejgZndsJAa4iKGQJlGnw4HCGeVt0+jA==", + "version": "0.12.0-next.23", + "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-linux-arm64-musl/-/edr-linux-arm64-musl-0.12.0-next.23.tgz", + "integrity": "sha512-SJuPBp3Rc6vM92UtVTUxZQ/QlLhLfwTftt2XUiYohmGKB3RjGzpgduEFMCA0LEnucUckU6UHrJNFHiDm77C4PQ==", "dev": true, "license": "MIT", "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/@nomicfoundation/edr-linux-x64-gnu": { - "version": "0.11.3", - "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-linux-x64-gnu/-/edr-linux-x64-gnu-0.11.3.tgz", - "integrity": "sha512-D/4cFKDXH6UYyKPu6J3Y8TzW11UzeQI0+wS9QcJzjlrrfKj0ENW7g9VihD1O2FvXkdkTjcCZYb6ai8MMTCsaVw==", + "version": "0.12.0-next.23", + "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-linux-x64-gnu/-/edr-linux-x64-gnu-0.12.0-next.23.tgz", + "integrity": "sha512-NU+Qs3u7Qt6t3bJFdmmjd5CsvgI2bPPzO31KifM2Ez96/jsXYho5debtTQnimlb5NAqiHTSlxjh/F8ROcptmeQ==", "dev": true, "license": "MIT", "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/@nomicfoundation/edr-linux-x64-musl": { - "version": "0.11.3", - "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-linux-x64-musl/-/edr-linux-x64-musl-0.11.3.tgz", - "integrity": "sha512-ergXuIb4nIvmf+TqyiDX5tsE49311DrBky6+jNLgsGDTBaN1GS3OFwFS8I6Ri/GGn6xOaT8sKu3q7/m+WdlFzg==", + "version": "0.12.0-next.23", + "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-linux-x64-musl/-/edr-linux-x64-musl-0.12.0-next.23.tgz", + "integrity": "sha512-F78fZA2h6/ssiCSZOovlgIu0dUeI7ItKPsDDF3UUlIibef052GCXmliMinC90jVPbrjUADMd1BUwjfI0Z8OllQ==", "dev": true, "license": "MIT", "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/@nomicfoundation/edr-win32-x64-msvc": { - "version": "0.11.3", - "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-win32-x64-msvc/-/edr-win32-x64-msvc-0.11.3.tgz", - "integrity": "sha512-snvEf+WB3OV0wj2A7kQ+ZQqBquMcrozSLXcdnMdEl7Tmn+KDCbmFKBt3Tk0X3qOU4RKQpLPnTxdM07TJNVtung==", + "version": "0.12.0-next.23", + "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-win32-x64-msvc/-/edr-win32-x64-msvc-0.12.0-next.23.tgz", + "integrity": "sha512-IfJZQJn7d/YyqhmguBIGoCKjE9dKjbu6V6iNEPApfwf5JyyjHYyyfkLU4rf7hygj57bfH4sl1jtQ6r8HnT62lw==", "dev": true, "license": "MIT", "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/@nomicfoundation/hardhat-chai-matchers": { @@ -5787,15 +5787,15 @@ } }, "node_modules/hardhat": { - "version": "2.26.1", - "resolved": "https://registry.npmjs.org/hardhat/-/hardhat-2.26.1.tgz", - "integrity": "sha512-CXWuUaTtehxiHPCdlitntctfeYRgujmXkNX5gnrD5jdA6HhRQt+WWBZE/gHXbE29y/wDmmUL2d652rI0ctjqjw==", + "version": "2.28.6", + "resolved": "https://registry.npmjs.org/hardhat/-/hardhat-2.28.6.tgz", + "integrity": "sha512-zQze7qe+8ltwHvhX5NQ8sN1N37WWZGw8L63y+2XcPxGwAjc/SMF829z3NS6o1krX0sryhAsVBK/xrwUqlsot4Q==", "dev": true, "license": "MIT", "dependencies": { "@ethereumjs/util": "^9.1.0", "@ethersproject/abi": "^5.1.2", - "@nomicfoundation/edr": "^0.11.3", + "@nomicfoundation/edr": "0.12.0-next.23", "@nomicfoundation/solidity-analyzer": "^0.1.0", "@sentry/node": "^5.18.1", "adm-zip": "^0.4.16", From 438bab68071c6d3690186dc064b33d888077aa87 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Tue, 21 Apr 2026 15:05:30 +0200 Subject: [PATCH 32/60] Use unimplemented function & add Sync module to enable syncrhonious behavior --- contracts/mocks/token/ERC7540AdminMock.sol | 88 ------------------- contracts/mocks/token/ERC7540DelayMock.sol | 88 ------------------- contracts/mocks/token/ERC7540EpochMock.sol | 88 ------------------- contracts/mocks/token/ERC7540SyncMock.sol | 9 ++ contracts/token/ERC20/extensions/ERC7540.sol | 87 ++++++------------ .../ERC20/extensions/ERC7540SyncDeposit.sol | 54 ++++++++++++ .../ERC20/extensions/ERC7540SyncRedeem.sol | 60 +++++++++++++ ...RC7540Core.test.js => ERC7540Sync.test.js} | 6 +- 8 files changed, 154 insertions(+), 326 deletions(-) create mode 100644 contracts/mocks/token/ERC7540SyncMock.sol create mode 100644 contracts/token/ERC20/extensions/ERC7540SyncDeposit.sol create mode 100644 contracts/token/ERC20/extensions/ERC7540SyncRedeem.sol rename test/token/ERC20/extensions/{ERC7540Core.test.js => ERC7540Sync.test.js} (64%) diff --git a/contracts/mocks/token/ERC7540AdminMock.sol b/contracts/mocks/token/ERC7540AdminMock.sol index 21e9860e..f58340c8 100644 --- a/contracts/mocks/token/ERC7540AdminMock.sol +++ b/contracts/mocks/token/ERC7540AdminMock.sol @@ -7,14 +7,6 @@ import {ERC7540AdminDeposit} from "../../token/ERC20/extensions/ERC7540AdminDepo import {ERC7540AdminRedeem} from "../../token/ERC20/extensions/ERC7540AdminRedeem.sol"; abstract contract ERC7540AdminMock is ERC7540AdminDeposit, ERC7540AdminRedeem { - function _isDepositAsync() internal pure virtual override(ERC7540, ERC7540AdminDeposit) returns (bool) { - return super._isDepositAsync(); - } - - function _isRedeemAsync() internal pure virtual override(ERC7540, ERC7540AdminRedeem) returns (bool) { - return super._isRedeemAsync(); - } - function _requestDeposit( uint256 assets, address controller, @@ -32,84 +24,4 @@ abstract contract ERC7540AdminMock is ERC7540AdminDeposit, ERC7540AdminRedeem { ) internal virtual override(ERC7540, ERC7540AdminRedeem) returns (uint256) { return super._requestRedeem(shares, controller, owner, requestId); } - - function _pendingDepositRequest( - uint256 requestId, - address controller - ) internal view virtual override(ERC7540, ERC7540AdminDeposit) returns (uint256) { - return super._pendingDepositRequest(requestId, controller); - } - - function _pendingRedeemRequest( - uint256 requestId, - address controller - ) internal view virtual override(ERC7540, ERC7540AdminRedeem) returns (uint256) { - return super._pendingRedeemRequest(requestId, controller); - } - - function _claimableDepositRequest( - uint256 requestId, - address controller - ) internal view virtual override(ERC7540, ERC7540AdminDeposit) returns (uint256) { - return super._claimableDepositRequest(requestId, controller); - } - - function _claimableRedeemRequest( - uint256 requestId, - address controller - ) internal view virtual override(ERC7540, ERC7540AdminRedeem) returns (uint256) { - return super._claimableRedeemRequest(requestId, controller); - } - - function _consumeClaimableDeposit( - uint256 assets, - address controller - ) internal virtual override(ERC7540, ERC7540AdminDeposit) returns (uint256) { - return super._consumeClaimableDeposit(assets, controller); - } - - function _consumeClaimableMint( - uint256 shares, - address controller - ) internal virtual override(ERC7540, ERC7540AdminDeposit) returns (uint256) { - return super._consumeClaimableMint(shares, controller); - } - - function _consumeClaimableRedeem( - uint256 shares, - address controller - ) internal virtual override(ERC7540, ERC7540AdminRedeem) returns (uint256) { - return super._consumeClaimableRedeem(shares, controller); - } - - function _consumeClaimableWithdraw( - uint256 assets, - address controller - ) internal virtual override(ERC7540, ERC7540AdminRedeem) returns (uint256) { - return super._consumeClaimableWithdraw(assets, controller); - } - - function _asyncMaxDeposit( - address owner - ) internal view virtual override(ERC7540, ERC7540AdminDeposit) returns (uint256) { - return super._asyncMaxDeposit(owner); - } - - function _asyncMaxMint( - address owner - ) internal view virtual override(ERC7540, ERC7540AdminDeposit) returns (uint256) { - return super._asyncMaxMint(owner); - } - - function _asyncMaxWithdraw( - address owner - ) internal view virtual override(ERC7540, ERC7540AdminRedeem) returns (uint256) { - return super._asyncMaxWithdraw(owner); - } - - function _asyncMaxRedeem( - address owner - ) internal view virtual override(ERC7540, ERC7540AdminRedeem) returns (uint256) { - return super._asyncMaxRedeem(owner); - } } diff --git a/contracts/mocks/token/ERC7540DelayMock.sol b/contracts/mocks/token/ERC7540DelayMock.sol index 0342cc28..b4fe7c86 100644 --- a/contracts/mocks/token/ERC7540DelayMock.sol +++ b/contracts/mocks/token/ERC7540DelayMock.sol @@ -11,14 +11,6 @@ abstract contract ERC7540DelayMock is ERC7540DelayDeposit, ERC7540DelayRedeem { return super.clock(); } - function _isDepositAsync() internal pure virtual override(ERC7540, ERC7540DelayDeposit) returns (bool) { - return super._isDepositAsync(); - } - - function _isRedeemAsync() internal pure virtual override(ERC7540, ERC7540DelayRedeem) returns (bool) { - return super._isRedeemAsync(); - } - function _requestDeposit( uint256 assets, address controller, @@ -36,84 +28,4 @@ abstract contract ERC7540DelayMock is ERC7540DelayDeposit, ERC7540DelayRedeem { ) internal virtual override(ERC7540, ERC7540DelayRedeem) returns (uint256) { return super._requestRedeem(shares, controller, owner, requestId); } - - function _pendingDepositRequest( - uint256 requestId, - address controller - ) internal view virtual override(ERC7540, ERC7540DelayDeposit) returns (uint256) { - return super._pendingDepositRequest(requestId, controller); - } - - function _pendingRedeemRequest( - uint256 requestId, - address controller - ) internal view virtual override(ERC7540, ERC7540DelayRedeem) returns (uint256) { - return super._pendingRedeemRequest(requestId, controller); - } - - function _claimableDepositRequest( - uint256 requestId, - address controller - ) internal view virtual override(ERC7540, ERC7540DelayDeposit) returns (uint256) { - return super._claimableDepositRequest(requestId, controller); - } - - function _claimableRedeemRequest( - uint256 requestId, - address controller - ) internal view virtual override(ERC7540, ERC7540DelayRedeem) returns (uint256) { - return super._claimableRedeemRequest(requestId, controller); - } - - function _consumeClaimableDeposit( - uint256 assets, - address controller - ) internal virtual override(ERC7540, ERC7540DelayDeposit) returns (uint256) { - return super._consumeClaimableDeposit(assets, controller); - } - - function _consumeClaimableMint( - uint256 shares, - address controller - ) internal virtual override(ERC7540, ERC7540DelayDeposit) returns (uint256) { - return super._consumeClaimableMint(shares, controller); - } - - function _consumeClaimableRedeem( - uint256 shares, - address controller - ) internal virtual override(ERC7540, ERC7540DelayRedeem) returns (uint256) { - return super._consumeClaimableRedeem(shares, controller); - } - - function _consumeClaimableWithdraw( - uint256 assets, - address controller - ) internal virtual override(ERC7540, ERC7540DelayRedeem) returns (uint256) { - return super._consumeClaimableWithdraw(assets, controller); - } - - function _asyncMaxDeposit( - address owner - ) internal view virtual override(ERC7540, ERC7540DelayDeposit) returns (uint256) { - return super._asyncMaxDeposit(owner); - } - - function _asyncMaxMint( - address owner - ) internal view virtual override(ERC7540, ERC7540DelayDeposit) returns (uint256) { - return super._asyncMaxMint(owner); - } - - function _asyncMaxWithdraw( - address owner - ) internal view virtual override(ERC7540, ERC7540DelayRedeem) returns (uint256) { - return super._asyncMaxWithdraw(owner); - } - - function _asyncMaxRedeem( - address owner - ) internal view virtual override(ERC7540, ERC7540DelayRedeem) returns (uint256) { - return super._asyncMaxRedeem(owner); - } } diff --git a/contracts/mocks/token/ERC7540EpochMock.sol b/contracts/mocks/token/ERC7540EpochMock.sol index cfc8e650..c03ece67 100644 --- a/contracts/mocks/token/ERC7540EpochMock.sol +++ b/contracts/mocks/token/ERC7540EpochMock.sol @@ -7,14 +7,6 @@ import {ERC7540EpochDeposit} from "../../token/ERC20/extensions/ERC7540EpochDepo import {ERC7540EpochRedeem} from "../../token/ERC20/extensions/ERC7540EpochRedeem.sol"; abstract contract ERC7540EpochMock is ERC7540EpochDeposit, ERC7540EpochRedeem { - function _isDepositAsync() internal pure virtual override(ERC7540, ERC7540EpochDeposit) returns (bool) { - return super._isDepositAsync(); - } - - function _isRedeemAsync() internal pure virtual override(ERC7540, ERC7540EpochRedeem) returns (bool) { - return super._isRedeemAsync(); - } - function _requestDeposit( uint256 assets, address controller, @@ -33,86 +25,6 @@ abstract contract ERC7540EpochMock is ERC7540EpochDeposit, ERC7540EpochRedeem { return super._requestRedeem(shares, controller, owner, requestId); } - function _pendingDepositRequest( - uint256 requestId, - address controller - ) internal view virtual override(ERC7540, ERC7540EpochDeposit) returns (uint256) { - return super._pendingDepositRequest(requestId, controller); - } - - function _pendingRedeemRequest( - uint256 requestId, - address controller - ) internal view virtual override(ERC7540, ERC7540EpochRedeem) returns (uint256) { - return super._pendingRedeemRequest(requestId, controller); - } - - function _claimableDepositRequest( - uint256 requestId, - address controller - ) internal view virtual override(ERC7540, ERC7540EpochDeposit) returns (uint256) { - return super._claimableDepositRequest(requestId, controller); - } - - function _claimableRedeemRequest( - uint256 requestId, - address controller - ) internal view virtual override(ERC7540, ERC7540EpochRedeem) returns (uint256) { - return super._claimableRedeemRequest(requestId, controller); - } - - function _consumeClaimableDeposit( - uint256 assets, - address controller - ) internal virtual override(ERC7540, ERC7540EpochDeposit) returns (uint256) { - return super._consumeClaimableDeposit(assets, controller); - } - - function _consumeClaimableMint( - uint256 shares, - address controller - ) internal virtual override(ERC7540, ERC7540EpochDeposit) returns (uint256) { - return super._consumeClaimableMint(shares, controller); - } - - function _consumeClaimableRedeem( - uint256 shares, - address controller - ) internal virtual override(ERC7540, ERC7540EpochRedeem) returns (uint256) { - return super._consumeClaimableRedeem(shares, controller); - } - - function _consumeClaimableWithdraw( - uint256 assets, - address controller - ) internal virtual override(ERC7540, ERC7540EpochRedeem) returns (uint256) { - return super._consumeClaimableWithdraw(assets, controller); - } - - function _asyncMaxDeposit( - address owner - ) internal view virtual override(ERC7540, ERC7540EpochDeposit) returns (uint256) { - return super._asyncMaxDeposit(owner); - } - - function _asyncMaxMint( - address owner - ) internal view virtual override(ERC7540, ERC7540EpochDeposit) returns (uint256) { - return super._asyncMaxMint(owner); - } - - function _asyncMaxWithdraw( - address owner - ) internal view virtual override(ERC7540, ERC7540EpochRedeem) returns (uint256) { - return super._asyncMaxWithdraw(owner); - } - - function _asyncMaxRedeem( - address owner - ) internal view virtual override(ERC7540, ERC7540EpochRedeem) returns (uint256) { - return super._asyncMaxRedeem(owner); - } - function _requestQueueLimit() internal view diff --git a/contracts/mocks/token/ERC7540SyncMock.sol b/contracts/mocks/token/ERC7540SyncMock.sol new file mode 100644 index 00000000..67f2709d --- /dev/null +++ b/contracts/mocks/token/ERC7540SyncMock.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {ERC7540} from "../../token/ERC20/extensions/ERC7540.sol"; +import {ERC7540SyncDeposit} from "../../token/ERC20/extensions/ERC7540SyncDeposit.sol"; +import {ERC7540SyncRedeem} from "../../token/ERC20/extensions/ERC7540SyncRedeem.sol"; + +abstract contract ERC7540SyncMock is ERC7540SyncDeposit, ERC7540SyncRedeem {} diff --git a/contracts/token/ERC20/extensions/ERC7540.sol b/contracts/token/ERC20/extensions/ERC7540.sol index 8cc68e20..a70e735f 100644 --- a/contracts/token/ERC20/extensions/ERC7540.sol +++ b/contracts/token/ERC20/extensions/ERC7540.sol @@ -96,9 +96,6 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { /// @dev Neither {_isDepositAsync} nor {_isRedeemAsync} returns `true`. error ERC7540MissingAsync(); - /// @dev A virtual hook was called that must be implemented by a fulfillment strategy extension. - error ERC7540NotImplemented(); - /** * @dev Sets the underlying asset contract. This must be an ERC-20-compatible contract. * @@ -118,26 +115,6 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { _asset = asset_; } - /** - * @dev Returns `true` if the deposit flow is asynchronous (Request-based). When `false`, {deposit} and - * {mint} behave as standard synchronous ERC-4626 operations. - * - * Override to return `true` in extensions that provide an async deposit fulfillment strategy. - */ - function _isDepositAsync() internal pure virtual returns (bool) { - return false; - } - - /** - * @dev Returns `true` if the redeem flow is asynchronous (Request-based). When `false`, {withdraw} and - * {redeem} behave as standard synchronous ERC-4626 operations. - * - * Override to return `true` in extensions that provide an async redeem fulfillment strategy. - */ - function _isRedeemAsync() internal pure virtual returns (bool) { - return false; - } - /** * @dev Attempts to fetch the asset decimals. A return value of false indicates that the attempt failed in some way. */ @@ -826,87 +803,79 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { // VIRTUAL HOOKS FOR STRATEGY EXTENSIONS // ============================================================== + /** + * @dev Returns `true` if the deposit flow is asynchronous (Request-based). When `false`, {deposit} and + * {mint} behave as standard synchronous ERC-4626 operations. + * + * Override to return `true` in extensions that provide an async deposit fulfillment strategy. + */ + function _isDepositAsync() internal pure virtual returns (bool); + + /** + * @dev Returns `true` if the redeem flow is asynchronous (Request-based). When `false`, {withdraw} and + * {redeem} behave as standard synchronous ERC-4626 operations. + * + * Override to return `true` in extensions that provide an async redeem fulfillment strategy. + */ + function _isRedeemAsync() internal pure virtual returns (bool); + /// @dev Returns the amount of assets in Pending state for `controller` with the given `requestId`. function _pendingDepositRequest( uint256 /*requestId*/, address /*controller*/ - ) internal view virtual returns (uint256) { - revert ERC7540NotImplemented(); - } + ) internal view virtual returns (uint256); /// @dev Returns the amount of assets in Claimable state for `controller` with the given `requestId`. function _claimableDepositRequest( uint256 /*requestId*/, address /*controller*/ - ) internal view virtual returns (uint256) { - revert ERC7540NotImplemented(); - } + ) internal view virtual returns (uint256); /// @dev Returns the amount of shares in Pending state for `controller` with the given `requestId`. function _pendingRedeemRequest( uint256 /*requestId*/, address /*controller*/ - ) internal view virtual returns (uint256) { - revert ERC7540NotImplemented(); - } + ) internal view virtual returns (uint256); /// @dev Returns the amount of shares in Claimable state for `controller` with the given `requestId`. function _claimableRedeemRequest( uint256 /*requestId*/, address /*controller*/ - ) internal view virtual returns (uint256) { - revert ERC7540NotImplemented(); - } + ) internal view virtual returns (uint256); /** * @dev Consumes `assets` worth of a Claimable deposit for `controller` and returns the corresponding * number of shares. Called by {deposit} (three-argument overload) in async mode. */ - function _consumeClaimableDeposit(uint256 /*assets*/, address /*controller*/) internal virtual returns (uint256) { - revert ERC7540NotImplemented(); - } + function _consumeClaimableDeposit(uint256 /*assets*/, address /*controller*/) internal virtual returns (uint256); /** * @dev Consumes `shares` worth of a Claimable deposit for `controller` and returns the corresponding * number of assets. Called by {mint} (three-argument overload) in async mode. */ - function _consumeClaimableMint(uint256 /*shares*/, address /*controller*/) internal virtual returns (uint256) { - revert ERC7540NotImplemented(); - } + function _consumeClaimableMint(uint256 /*shares*/, address /*controller*/) internal virtual returns (uint256); /** * @dev Consumes `assets` worth of a Claimable redeem for `controller` and returns the corresponding * number of shares. Called by {withdraw} in async mode. */ - function _consumeClaimableWithdraw(uint256 /*assets*/, address /*controller*/) internal virtual returns (uint256) { - revert ERC7540NotImplemented(); - } + function _consumeClaimableWithdraw(uint256 /*assets*/, address /*controller*/) internal virtual returns (uint256); /** * @dev Consumes `shares` worth of a Claimable redeem for `controller` and returns the corresponding * number of assets. Called by {redeem} in async mode. */ - function _consumeClaimableRedeem(uint256 /*shares*/, address /*controller*/) internal virtual returns (uint256) { - revert ERC7540NotImplemented(); - } + function _consumeClaimableRedeem(uint256 /*shares*/, address /*controller*/) internal virtual returns (uint256); /// @dev Returns the maximum assets that can be claimed via {deposit} for an async `owner`. - function _asyncMaxDeposit(address /*owner*/) internal view virtual returns (uint256) { - revert ERC7540NotImplemented(); - } + function _asyncMaxDeposit(address /*owner*/) internal view virtual returns (uint256); /// @dev Returns the maximum shares that can be claimed via {mint} for an async `owner`. - function _asyncMaxMint(address /*owner*/) internal view virtual returns (uint256) { - revert ERC7540NotImplemented(); - } + function _asyncMaxMint(address /*owner*/) internal view virtual returns (uint256); /// @dev Returns the maximum assets that can be claimed via {withdraw} for an async `owner`. - function _asyncMaxWithdraw(address /*owner*/) internal view virtual returns (uint256) { - revert ERC7540NotImplemented(); - } + function _asyncMaxWithdraw(address /*owner*/) internal view virtual returns (uint256); /// @dev Returns the maximum shares that can be claimed via {redeem} for an async `owner`. - function _asyncMaxRedeem(address /*owner*/) internal view virtual returns (uint256) { - revert ERC7540NotImplemented(); - } + function _asyncMaxRedeem(address /*owner*/) internal view virtual returns (uint256); } diff --git a/contracts/token/ERC20/extensions/ERC7540SyncDeposit.sol b/contracts/token/ERC20/extensions/ERC7540SyncDeposit.sol new file mode 100644 index 00000000..99bef749 --- /dev/null +++ b/contracts/token/ERC20/extensions/ERC7540SyncDeposit.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {ERC7540} from "./ERC7540.sol"; + +abstract contract ERC7540SyncDeposit is ERC7540 { + /// @inheritdoc ERC7540 + function _isDepositAsync() internal pure virtual override returns (bool) { + return false; + } + + /// @dev Consumes `assets` from the claimable deposit and returns the proportional shares (rounded down). + function _consumeClaimableDeposit( + uint256 /*assets*/, + address /*controller*/ + ) internal virtual override returns (uint256) { + revert(); + } + + /// @dev Consumes `shares` from the claimable deposit and returns the proportional assets (rounded up). + function _consumeClaimableMint( + uint256 /*shares*/, + address /*controller*/ + ) internal virtual override returns (uint256) { + revert(); + } + + /// @inheritdoc ERC7540 + function _pendingDepositRequest( + uint256 /*requestId*/, + address /*controller*/ + ) internal view virtual override returns (uint256) { + revert(); + } + + /// @inheritdoc ERC7540 + function _claimableDepositRequest( + uint256 /*requestId*/, + address /*controller*/ + ) internal view virtual override returns (uint256) { + revert(); + } + + /// @inheritdoc ERC7540 + function _asyncMaxDeposit(address /*owner*/) internal view virtual override returns (uint256) { + revert(); + } + + /// @inheritdoc ERC7540 + function _asyncMaxMint(address /*owner*/) internal view virtual override returns (uint256) { + revert(); + } +} diff --git a/contracts/token/ERC20/extensions/ERC7540SyncRedeem.sol b/contracts/token/ERC20/extensions/ERC7540SyncRedeem.sol new file mode 100644 index 00000000..48670df9 --- /dev/null +++ b/contracts/token/ERC20/extensions/ERC7540SyncRedeem.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {ERC7540} from "./ERC7540.sol"; + +/** + * @dev Module for enabling synchronous behavior (ERC-4626) for the redeem flow of an ERC-7540 vault. + * + * Note that an ERC-7540 vault is required to have at least on flow operating in asynchronous mode, so this module + * cannot combined with {ERC7540SyncDeposit}. + */ +abstract contract ERC7540SyncRedeem is ERC7540 { + /// @inheritdoc ERC7540 + function _isRedeemAsync() internal pure virtual override returns (bool) { + return false; + } + + /// @dev Consumes `assets` from the claimable redeem and returns the proportional shares (rounded up). + function _consumeClaimableWithdraw( + uint256 /*assets*/, + address /*controller*/ + ) internal virtual override returns (uint256) { + revert(); + } + + /// @dev Consumes `shares` from the claimable redeem and returns the proportional assets (rounded down). + function _consumeClaimableRedeem( + uint256 /*shares*/, + address /*controller*/ + ) internal virtual override returns (uint256) { + revert(); + } + + /// @inheritdoc ERC7540 + function _pendingRedeemRequest( + uint256 /*requestId*/, + address /*controller*/ + ) internal view virtual override returns (uint256) { + revert(); + } + + /// @inheritdoc ERC7540 + function _claimableRedeemRequest( + uint256 /*requestId*/, + address /*controller*/ + ) internal view virtual override returns (uint256) { + revert(); + } + + /// @inheritdoc ERC7540 + function _asyncMaxWithdraw(address /*owner*/) internal view virtual override returns (uint256) { + revert(); + } + + /// @inheritdoc ERC7540 + function _asyncMaxRedeem(address /*owner*/) internal view virtual override returns (uint256) { + revert(); + } +} diff --git a/test/token/ERC20/extensions/ERC7540Core.test.js b/test/token/ERC20/extensions/ERC7540Sync.test.js similarity index 64% rename from test/token/ERC20/extensions/ERC7540Core.test.js rename to test/token/ERC20/extensions/ERC7540Sync.test.js index a5dadb0f..3dcc8aa0 100644 --- a/test/token/ERC20/extensions/ERC7540Core.test.js +++ b/test/token/ERC20/extensions/ERC7540Sync.test.js @@ -6,12 +6,12 @@ const symbol = 'vSHR'; const tokenName = 'Asset Token'; const tokenSymbol = 'AST'; -describe('ERC7540Core', function () { +describe('ERC7540Sync', function () { it('construction fails if no async mechanism is enabled', async function () { const token = await ethers.deployContract('$ERC20', [tokenName, tokenSymbol]); - const factory = await ethers.getContractFactory('$ERC7540'); + const factory = await ethers.getContractFactory('$ERC7540SyncMock'); - await expect(ethers.deployContract('$ERC7540', [name, symbol, token])).to.be.revertedWithCustomError( + await expect(ethers.deployContract('$ERC7540SyncMock', [name, symbol, token])).to.be.revertedWithCustomError( factory, 'ERC7540MissingAsync', ); From a80627b369d6b490fd0c750dd61c6eb2f99d5392 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Tue, 21 Apr 2026 15:07:30 +0200 Subject: [PATCH 33/60] Update contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ernesto García --- contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol b/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol index 3fdb174e..087b63f5 100644 --- a/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol @@ -31,7 +31,7 @@ abstract contract ERC7540DelayDeposit is ERC7540 { using SafeCast for uint256; using Checkpoints for Checkpoints.Trace208; - mapping(address controller => Checkpoints.Trace208 trace) private _deposits; + mapping(address controller => Checkpoints.Trace208) private _deposits; mapping(address controller => uint256) private _claimedDeposits; /// @dev Returns the current clock value. Defaults to `block.timestamp`. From 68ccfc06d994d0a9b8e18f5dfa4d20d1bb5fc741 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Tue, 21 Apr 2026 15:05:30 +0200 Subject: [PATCH 34/60] Use unimplemented function & add Sync module to enable syncrhonious behavior --- contracts/mocks/token/ERC7540AdminMock.sol | 88 ------------------- contracts/mocks/token/ERC7540DelayMock.sol | 88 ------------------- contracts/mocks/token/ERC7540EpochMock.sol | 88 ------------------- contracts/mocks/token/ERC7540SyncMock.sol | 9 ++ contracts/token/ERC20/extensions/ERC7540.sol | 87 ++++++------------ .../ERC20/extensions/ERC7540SyncDeposit.sol | 60 +++++++++++++ .../ERC20/extensions/ERC7540SyncRedeem.sol | 60 +++++++++++++ ...RC7540Core.test.js => ERC7540Sync.test.js} | 6 +- 8 files changed, 160 insertions(+), 326 deletions(-) create mode 100644 contracts/mocks/token/ERC7540SyncMock.sol create mode 100644 contracts/token/ERC20/extensions/ERC7540SyncDeposit.sol create mode 100644 contracts/token/ERC20/extensions/ERC7540SyncRedeem.sol rename test/token/ERC20/extensions/{ERC7540Core.test.js => ERC7540Sync.test.js} (64%) diff --git a/contracts/mocks/token/ERC7540AdminMock.sol b/contracts/mocks/token/ERC7540AdminMock.sol index 21e9860e..f58340c8 100644 --- a/contracts/mocks/token/ERC7540AdminMock.sol +++ b/contracts/mocks/token/ERC7540AdminMock.sol @@ -7,14 +7,6 @@ import {ERC7540AdminDeposit} from "../../token/ERC20/extensions/ERC7540AdminDepo import {ERC7540AdminRedeem} from "../../token/ERC20/extensions/ERC7540AdminRedeem.sol"; abstract contract ERC7540AdminMock is ERC7540AdminDeposit, ERC7540AdminRedeem { - function _isDepositAsync() internal pure virtual override(ERC7540, ERC7540AdminDeposit) returns (bool) { - return super._isDepositAsync(); - } - - function _isRedeemAsync() internal pure virtual override(ERC7540, ERC7540AdminRedeem) returns (bool) { - return super._isRedeemAsync(); - } - function _requestDeposit( uint256 assets, address controller, @@ -32,84 +24,4 @@ abstract contract ERC7540AdminMock is ERC7540AdminDeposit, ERC7540AdminRedeem { ) internal virtual override(ERC7540, ERC7540AdminRedeem) returns (uint256) { return super._requestRedeem(shares, controller, owner, requestId); } - - function _pendingDepositRequest( - uint256 requestId, - address controller - ) internal view virtual override(ERC7540, ERC7540AdminDeposit) returns (uint256) { - return super._pendingDepositRequest(requestId, controller); - } - - function _pendingRedeemRequest( - uint256 requestId, - address controller - ) internal view virtual override(ERC7540, ERC7540AdminRedeem) returns (uint256) { - return super._pendingRedeemRequest(requestId, controller); - } - - function _claimableDepositRequest( - uint256 requestId, - address controller - ) internal view virtual override(ERC7540, ERC7540AdminDeposit) returns (uint256) { - return super._claimableDepositRequest(requestId, controller); - } - - function _claimableRedeemRequest( - uint256 requestId, - address controller - ) internal view virtual override(ERC7540, ERC7540AdminRedeem) returns (uint256) { - return super._claimableRedeemRequest(requestId, controller); - } - - function _consumeClaimableDeposit( - uint256 assets, - address controller - ) internal virtual override(ERC7540, ERC7540AdminDeposit) returns (uint256) { - return super._consumeClaimableDeposit(assets, controller); - } - - function _consumeClaimableMint( - uint256 shares, - address controller - ) internal virtual override(ERC7540, ERC7540AdminDeposit) returns (uint256) { - return super._consumeClaimableMint(shares, controller); - } - - function _consumeClaimableRedeem( - uint256 shares, - address controller - ) internal virtual override(ERC7540, ERC7540AdminRedeem) returns (uint256) { - return super._consumeClaimableRedeem(shares, controller); - } - - function _consumeClaimableWithdraw( - uint256 assets, - address controller - ) internal virtual override(ERC7540, ERC7540AdminRedeem) returns (uint256) { - return super._consumeClaimableWithdraw(assets, controller); - } - - function _asyncMaxDeposit( - address owner - ) internal view virtual override(ERC7540, ERC7540AdminDeposit) returns (uint256) { - return super._asyncMaxDeposit(owner); - } - - function _asyncMaxMint( - address owner - ) internal view virtual override(ERC7540, ERC7540AdminDeposit) returns (uint256) { - return super._asyncMaxMint(owner); - } - - function _asyncMaxWithdraw( - address owner - ) internal view virtual override(ERC7540, ERC7540AdminRedeem) returns (uint256) { - return super._asyncMaxWithdraw(owner); - } - - function _asyncMaxRedeem( - address owner - ) internal view virtual override(ERC7540, ERC7540AdminRedeem) returns (uint256) { - return super._asyncMaxRedeem(owner); - } } diff --git a/contracts/mocks/token/ERC7540DelayMock.sol b/contracts/mocks/token/ERC7540DelayMock.sol index 0342cc28..b4fe7c86 100644 --- a/contracts/mocks/token/ERC7540DelayMock.sol +++ b/contracts/mocks/token/ERC7540DelayMock.sol @@ -11,14 +11,6 @@ abstract contract ERC7540DelayMock is ERC7540DelayDeposit, ERC7540DelayRedeem { return super.clock(); } - function _isDepositAsync() internal pure virtual override(ERC7540, ERC7540DelayDeposit) returns (bool) { - return super._isDepositAsync(); - } - - function _isRedeemAsync() internal pure virtual override(ERC7540, ERC7540DelayRedeem) returns (bool) { - return super._isRedeemAsync(); - } - function _requestDeposit( uint256 assets, address controller, @@ -36,84 +28,4 @@ abstract contract ERC7540DelayMock is ERC7540DelayDeposit, ERC7540DelayRedeem { ) internal virtual override(ERC7540, ERC7540DelayRedeem) returns (uint256) { return super._requestRedeem(shares, controller, owner, requestId); } - - function _pendingDepositRequest( - uint256 requestId, - address controller - ) internal view virtual override(ERC7540, ERC7540DelayDeposit) returns (uint256) { - return super._pendingDepositRequest(requestId, controller); - } - - function _pendingRedeemRequest( - uint256 requestId, - address controller - ) internal view virtual override(ERC7540, ERC7540DelayRedeem) returns (uint256) { - return super._pendingRedeemRequest(requestId, controller); - } - - function _claimableDepositRequest( - uint256 requestId, - address controller - ) internal view virtual override(ERC7540, ERC7540DelayDeposit) returns (uint256) { - return super._claimableDepositRequest(requestId, controller); - } - - function _claimableRedeemRequest( - uint256 requestId, - address controller - ) internal view virtual override(ERC7540, ERC7540DelayRedeem) returns (uint256) { - return super._claimableRedeemRequest(requestId, controller); - } - - function _consumeClaimableDeposit( - uint256 assets, - address controller - ) internal virtual override(ERC7540, ERC7540DelayDeposit) returns (uint256) { - return super._consumeClaimableDeposit(assets, controller); - } - - function _consumeClaimableMint( - uint256 shares, - address controller - ) internal virtual override(ERC7540, ERC7540DelayDeposit) returns (uint256) { - return super._consumeClaimableMint(shares, controller); - } - - function _consumeClaimableRedeem( - uint256 shares, - address controller - ) internal virtual override(ERC7540, ERC7540DelayRedeem) returns (uint256) { - return super._consumeClaimableRedeem(shares, controller); - } - - function _consumeClaimableWithdraw( - uint256 assets, - address controller - ) internal virtual override(ERC7540, ERC7540DelayRedeem) returns (uint256) { - return super._consumeClaimableWithdraw(assets, controller); - } - - function _asyncMaxDeposit( - address owner - ) internal view virtual override(ERC7540, ERC7540DelayDeposit) returns (uint256) { - return super._asyncMaxDeposit(owner); - } - - function _asyncMaxMint( - address owner - ) internal view virtual override(ERC7540, ERC7540DelayDeposit) returns (uint256) { - return super._asyncMaxMint(owner); - } - - function _asyncMaxWithdraw( - address owner - ) internal view virtual override(ERC7540, ERC7540DelayRedeem) returns (uint256) { - return super._asyncMaxWithdraw(owner); - } - - function _asyncMaxRedeem( - address owner - ) internal view virtual override(ERC7540, ERC7540DelayRedeem) returns (uint256) { - return super._asyncMaxRedeem(owner); - } } diff --git a/contracts/mocks/token/ERC7540EpochMock.sol b/contracts/mocks/token/ERC7540EpochMock.sol index cfc8e650..c03ece67 100644 --- a/contracts/mocks/token/ERC7540EpochMock.sol +++ b/contracts/mocks/token/ERC7540EpochMock.sol @@ -7,14 +7,6 @@ import {ERC7540EpochDeposit} from "../../token/ERC20/extensions/ERC7540EpochDepo import {ERC7540EpochRedeem} from "../../token/ERC20/extensions/ERC7540EpochRedeem.sol"; abstract contract ERC7540EpochMock is ERC7540EpochDeposit, ERC7540EpochRedeem { - function _isDepositAsync() internal pure virtual override(ERC7540, ERC7540EpochDeposit) returns (bool) { - return super._isDepositAsync(); - } - - function _isRedeemAsync() internal pure virtual override(ERC7540, ERC7540EpochRedeem) returns (bool) { - return super._isRedeemAsync(); - } - function _requestDeposit( uint256 assets, address controller, @@ -33,86 +25,6 @@ abstract contract ERC7540EpochMock is ERC7540EpochDeposit, ERC7540EpochRedeem { return super._requestRedeem(shares, controller, owner, requestId); } - function _pendingDepositRequest( - uint256 requestId, - address controller - ) internal view virtual override(ERC7540, ERC7540EpochDeposit) returns (uint256) { - return super._pendingDepositRequest(requestId, controller); - } - - function _pendingRedeemRequest( - uint256 requestId, - address controller - ) internal view virtual override(ERC7540, ERC7540EpochRedeem) returns (uint256) { - return super._pendingRedeemRequest(requestId, controller); - } - - function _claimableDepositRequest( - uint256 requestId, - address controller - ) internal view virtual override(ERC7540, ERC7540EpochDeposit) returns (uint256) { - return super._claimableDepositRequest(requestId, controller); - } - - function _claimableRedeemRequest( - uint256 requestId, - address controller - ) internal view virtual override(ERC7540, ERC7540EpochRedeem) returns (uint256) { - return super._claimableRedeemRequest(requestId, controller); - } - - function _consumeClaimableDeposit( - uint256 assets, - address controller - ) internal virtual override(ERC7540, ERC7540EpochDeposit) returns (uint256) { - return super._consumeClaimableDeposit(assets, controller); - } - - function _consumeClaimableMint( - uint256 shares, - address controller - ) internal virtual override(ERC7540, ERC7540EpochDeposit) returns (uint256) { - return super._consumeClaimableMint(shares, controller); - } - - function _consumeClaimableRedeem( - uint256 shares, - address controller - ) internal virtual override(ERC7540, ERC7540EpochRedeem) returns (uint256) { - return super._consumeClaimableRedeem(shares, controller); - } - - function _consumeClaimableWithdraw( - uint256 assets, - address controller - ) internal virtual override(ERC7540, ERC7540EpochRedeem) returns (uint256) { - return super._consumeClaimableWithdraw(assets, controller); - } - - function _asyncMaxDeposit( - address owner - ) internal view virtual override(ERC7540, ERC7540EpochDeposit) returns (uint256) { - return super._asyncMaxDeposit(owner); - } - - function _asyncMaxMint( - address owner - ) internal view virtual override(ERC7540, ERC7540EpochDeposit) returns (uint256) { - return super._asyncMaxMint(owner); - } - - function _asyncMaxWithdraw( - address owner - ) internal view virtual override(ERC7540, ERC7540EpochRedeem) returns (uint256) { - return super._asyncMaxWithdraw(owner); - } - - function _asyncMaxRedeem( - address owner - ) internal view virtual override(ERC7540, ERC7540EpochRedeem) returns (uint256) { - return super._asyncMaxRedeem(owner); - } - function _requestQueueLimit() internal view diff --git a/contracts/mocks/token/ERC7540SyncMock.sol b/contracts/mocks/token/ERC7540SyncMock.sol new file mode 100644 index 00000000..67f2709d --- /dev/null +++ b/contracts/mocks/token/ERC7540SyncMock.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {ERC7540} from "../../token/ERC20/extensions/ERC7540.sol"; +import {ERC7540SyncDeposit} from "../../token/ERC20/extensions/ERC7540SyncDeposit.sol"; +import {ERC7540SyncRedeem} from "../../token/ERC20/extensions/ERC7540SyncRedeem.sol"; + +abstract contract ERC7540SyncMock is ERC7540SyncDeposit, ERC7540SyncRedeem {} diff --git a/contracts/token/ERC20/extensions/ERC7540.sol b/contracts/token/ERC20/extensions/ERC7540.sol index 8cc68e20..a70e735f 100644 --- a/contracts/token/ERC20/extensions/ERC7540.sol +++ b/contracts/token/ERC20/extensions/ERC7540.sol @@ -96,9 +96,6 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { /// @dev Neither {_isDepositAsync} nor {_isRedeemAsync} returns `true`. error ERC7540MissingAsync(); - /// @dev A virtual hook was called that must be implemented by a fulfillment strategy extension. - error ERC7540NotImplemented(); - /** * @dev Sets the underlying asset contract. This must be an ERC-20-compatible contract. * @@ -118,26 +115,6 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { _asset = asset_; } - /** - * @dev Returns `true` if the deposit flow is asynchronous (Request-based). When `false`, {deposit} and - * {mint} behave as standard synchronous ERC-4626 operations. - * - * Override to return `true` in extensions that provide an async deposit fulfillment strategy. - */ - function _isDepositAsync() internal pure virtual returns (bool) { - return false; - } - - /** - * @dev Returns `true` if the redeem flow is asynchronous (Request-based). When `false`, {withdraw} and - * {redeem} behave as standard synchronous ERC-4626 operations. - * - * Override to return `true` in extensions that provide an async redeem fulfillment strategy. - */ - function _isRedeemAsync() internal pure virtual returns (bool) { - return false; - } - /** * @dev Attempts to fetch the asset decimals. A return value of false indicates that the attempt failed in some way. */ @@ -826,87 +803,79 @@ abstract contract ERC7540 is ERC165, ERC20, IERC4626, IERC7540 { // VIRTUAL HOOKS FOR STRATEGY EXTENSIONS // ============================================================== + /** + * @dev Returns `true` if the deposit flow is asynchronous (Request-based). When `false`, {deposit} and + * {mint} behave as standard synchronous ERC-4626 operations. + * + * Override to return `true` in extensions that provide an async deposit fulfillment strategy. + */ + function _isDepositAsync() internal pure virtual returns (bool); + + /** + * @dev Returns `true` if the redeem flow is asynchronous (Request-based). When `false`, {withdraw} and + * {redeem} behave as standard synchronous ERC-4626 operations. + * + * Override to return `true` in extensions that provide an async redeem fulfillment strategy. + */ + function _isRedeemAsync() internal pure virtual returns (bool); + /// @dev Returns the amount of assets in Pending state for `controller` with the given `requestId`. function _pendingDepositRequest( uint256 /*requestId*/, address /*controller*/ - ) internal view virtual returns (uint256) { - revert ERC7540NotImplemented(); - } + ) internal view virtual returns (uint256); /// @dev Returns the amount of assets in Claimable state for `controller` with the given `requestId`. function _claimableDepositRequest( uint256 /*requestId*/, address /*controller*/ - ) internal view virtual returns (uint256) { - revert ERC7540NotImplemented(); - } + ) internal view virtual returns (uint256); /// @dev Returns the amount of shares in Pending state for `controller` with the given `requestId`. function _pendingRedeemRequest( uint256 /*requestId*/, address /*controller*/ - ) internal view virtual returns (uint256) { - revert ERC7540NotImplemented(); - } + ) internal view virtual returns (uint256); /// @dev Returns the amount of shares in Claimable state for `controller` with the given `requestId`. function _claimableRedeemRequest( uint256 /*requestId*/, address /*controller*/ - ) internal view virtual returns (uint256) { - revert ERC7540NotImplemented(); - } + ) internal view virtual returns (uint256); /** * @dev Consumes `assets` worth of a Claimable deposit for `controller` and returns the corresponding * number of shares. Called by {deposit} (three-argument overload) in async mode. */ - function _consumeClaimableDeposit(uint256 /*assets*/, address /*controller*/) internal virtual returns (uint256) { - revert ERC7540NotImplemented(); - } + function _consumeClaimableDeposit(uint256 /*assets*/, address /*controller*/) internal virtual returns (uint256); /** * @dev Consumes `shares` worth of a Claimable deposit for `controller` and returns the corresponding * number of assets. Called by {mint} (three-argument overload) in async mode. */ - function _consumeClaimableMint(uint256 /*shares*/, address /*controller*/) internal virtual returns (uint256) { - revert ERC7540NotImplemented(); - } + function _consumeClaimableMint(uint256 /*shares*/, address /*controller*/) internal virtual returns (uint256); /** * @dev Consumes `assets` worth of a Claimable redeem for `controller` and returns the corresponding * number of shares. Called by {withdraw} in async mode. */ - function _consumeClaimableWithdraw(uint256 /*assets*/, address /*controller*/) internal virtual returns (uint256) { - revert ERC7540NotImplemented(); - } + function _consumeClaimableWithdraw(uint256 /*assets*/, address /*controller*/) internal virtual returns (uint256); /** * @dev Consumes `shares` worth of a Claimable redeem for `controller` and returns the corresponding * number of assets. Called by {redeem} in async mode. */ - function _consumeClaimableRedeem(uint256 /*shares*/, address /*controller*/) internal virtual returns (uint256) { - revert ERC7540NotImplemented(); - } + function _consumeClaimableRedeem(uint256 /*shares*/, address /*controller*/) internal virtual returns (uint256); /// @dev Returns the maximum assets that can be claimed via {deposit} for an async `owner`. - function _asyncMaxDeposit(address /*owner*/) internal view virtual returns (uint256) { - revert ERC7540NotImplemented(); - } + function _asyncMaxDeposit(address /*owner*/) internal view virtual returns (uint256); /// @dev Returns the maximum shares that can be claimed via {mint} for an async `owner`. - function _asyncMaxMint(address /*owner*/) internal view virtual returns (uint256) { - revert ERC7540NotImplemented(); - } + function _asyncMaxMint(address /*owner*/) internal view virtual returns (uint256); /// @dev Returns the maximum assets that can be claimed via {withdraw} for an async `owner`. - function _asyncMaxWithdraw(address /*owner*/) internal view virtual returns (uint256) { - revert ERC7540NotImplemented(); - } + function _asyncMaxWithdraw(address /*owner*/) internal view virtual returns (uint256); /// @dev Returns the maximum shares that can be claimed via {redeem} for an async `owner`. - function _asyncMaxRedeem(address /*owner*/) internal view virtual returns (uint256) { - revert ERC7540NotImplemented(); - } + function _asyncMaxRedeem(address /*owner*/) internal view virtual returns (uint256); } diff --git a/contracts/token/ERC20/extensions/ERC7540SyncDeposit.sol b/contracts/token/ERC20/extensions/ERC7540SyncDeposit.sol new file mode 100644 index 00000000..63314174 --- /dev/null +++ b/contracts/token/ERC20/extensions/ERC7540SyncDeposit.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {ERC7540} from "./ERC7540.sol"; + +/** + * @dev Module for enabling synchronous behavior (ERC-4626) for the deposit flow of an ERC-7540 vault. + * + * Note that an ERC-7540 vault is required to have at least on flow operating in asynchronous mode, so this module + * cannot combined with {ERC7540SyncRedeem}. + */ +abstract contract ERC7540SyncDeposit is ERC7540 { + /// @inheritdoc ERC7540 + function _isDepositAsync() internal pure virtual override returns (bool) { + return false; + } + + /// @dev Consumes `assets` from the claimable deposit and returns the proportional shares (rounded down). + function _consumeClaimableDeposit( + uint256 /*assets*/, + address /*controller*/ + ) internal virtual override returns (uint256) { + revert(); + } + + /// @dev Consumes `shares` from the claimable deposit and returns the proportional assets (rounded up). + function _consumeClaimableMint( + uint256 /*shares*/, + address /*controller*/ + ) internal virtual override returns (uint256) { + revert(); + } + + /// @inheritdoc ERC7540 + function _pendingDepositRequest( + uint256 /*requestId*/, + address /*controller*/ + ) internal view virtual override returns (uint256) { + revert(); + } + + /// @inheritdoc ERC7540 + function _claimableDepositRequest( + uint256 /*requestId*/, + address /*controller*/ + ) internal view virtual override returns (uint256) { + revert(); + } + + /// @inheritdoc ERC7540 + function _asyncMaxDeposit(address /*owner*/) internal view virtual override returns (uint256) { + revert(); + } + + /// @inheritdoc ERC7540 + function _asyncMaxMint(address /*owner*/) internal view virtual override returns (uint256) { + revert(); + } +} diff --git a/contracts/token/ERC20/extensions/ERC7540SyncRedeem.sol b/contracts/token/ERC20/extensions/ERC7540SyncRedeem.sol new file mode 100644 index 00000000..48670df9 --- /dev/null +++ b/contracts/token/ERC20/extensions/ERC7540SyncRedeem.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {ERC7540} from "./ERC7540.sol"; + +/** + * @dev Module for enabling synchronous behavior (ERC-4626) for the redeem flow of an ERC-7540 vault. + * + * Note that an ERC-7540 vault is required to have at least on flow operating in asynchronous mode, so this module + * cannot combined with {ERC7540SyncDeposit}. + */ +abstract contract ERC7540SyncRedeem is ERC7540 { + /// @inheritdoc ERC7540 + function _isRedeemAsync() internal pure virtual override returns (bool) { + return false; + } + + /// @dev Consumes `assets` from the claimable redeem and returns the proportional shares (rounded up). + function _consumeClaimableWithdraw( + uint256 /*assets*/, + address /*controller*/ + ) internal virtual override returns (uint256) { + revert(); + } + + /// @dev Consumes `shares` from the claimable redeem and returns the proportional assets (rounded down). + function _consumeClaimableRedeem( + uint256 /*shares*/, + address /*controller*/ + ) internal virtual override returns (uint256) { + revert(); + } + + /// @inheritdoc ERC7540 + function _pendingRedeemRequest( + uint256 /*requestId*/, + address /*controller*/ + ) internal view virtual override returns (uint256) { + revert(); + } + + /// @inheritdoc ERC7540 + function _claimableRedeemRequest( + uint256 /*requestId*/, + address /*controller*/ + ) internal view virtual override returns (uint256) { + revert(); + } + + /// @inheritdoc ERC7540 + function _asyncMaxWithdraw(address /*owner*/) internal view virtual override returns (uint256) { + revert(); + } + + /// @inheritdoc ERC7540 + function _asyncMaxRedeem(address /*owner*/) internal view virtual override returns (uint256) { + revert(); + } +} diff --git a/test/token/ERC20/extensions/ERC7540Core.test.js b/test/token/ERC20/extensions/ERC7540Sync.test.js similarity index 64% rename from test/token/ERC20/extensions/ERC7540Core.test.js rename to test/token/ERC20/extensions/ERC7540Sync.test.js index a5dadb0f..3dcc8aa0 100644 --- a/test/token/ERC20/extensions/ERC7540Core.test.js +++ b/test/token/ERC20/extensions/ERC7540Sync.test.js @@ -6,12 +6,12 @@ const symbol = 'vSHR'; const tokenName = 'Asset Token'; const tokenSymbol = 'AST'; -describe('ERC7540Core', function () { +describe('ERC7540Sync', function () { it('construction fails if no async mechanism is enabled', async function () { const token = await ethers.deployContract('$ERC20', [tokenName, tokenSymbol]); - const factory = await ethers.getContractFactory('$ERC7540'); + const factory = await ethers.getContractFactory('$ERC7540SyncMock'); - await expect(ethers.deployContract('$ERC7540', [name, symbol, token])).to.be.revertedWithCustomError( + await expect(ethers.deployContract('$ERC7540SyncMock', [name, symbol, token])).to.be.revertedWithCustomError( factory, 'ERC7540MissingAsync', ); From 82766b2e947da890bfb72d4b986ed22c6703c30b Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Tue, 21 Apr 2026 15:16:29 +0200 Subject: [PATCH 35/60] Use IERC6372 in the Delay modules --- .../ERC20/extensions/ERC7540DelayDeposit.sol | 21 ++++++++++++++++--- .../ERC20/extensions/ERC7540DelayRedeem.sol | 21 ++++++++++++++++--- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol b/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol index 087b63f5..c0fb2029 100644 --- a/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol @@ -2,9 +2,11 @@ pragma solidity ^0.8.27; +import {IERC6372} from "@openzeppelin/contracts/interfaces/IERC6372.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import {Checkpoints} from "@openzeppelin/contracts/utils/structs/Checkpoints.sol"; +import {Time} from "@openzeppelin/contracts/utils/types/Time.sol"; import {ERC7540} from "./ERC7540.sol"; /** @@ -27,16 +29,29 @@ import {ERC7540} from "./ERC7540.sol"; * Override {depositDelay} to customize the waiting period (default: 1 hour) and {clock} to * change the time source (default: `block.timestamp`). */ -abstract contract ERC7540DelayDeposit is ERC7540 { +abstract contract ERC7540DelayDeposit is ERC7540, IERC6372 { using SafeCast for uint256; using Checkpoints for Checkpoints.Trace208; mapping(address controller => Checkpoints.Trace208) private _deposits; mapping(address controller => uint256) private _claimedDeposits; - /// @dev Returns the current clock value. Defaults to `block.timestamp`. + /// @dev The clock was incorrectly modified. + error ERC6372InconsistentClock(); + + /// @inheritdoc IERC6372 function clock() public view virtual returns (uint48) { - return uint48(block.timestamp); + return Time.timestamp(); + } + + /// @inheritdoc IERC6372 + // solhint-disable-next-line func-name-mixedcase + function CLOCK_MODE() public view virtual returns (string memory) { + // Check that the clock was not modified + if (clock() != Time.timestamp()) { + revert ERC6372InconsistentClock(); + } + return "mode=timestamp"; } /// @dev Returns the delay duration before a deposit request becomes claimable. Defaults to 1 hour. diff --git a/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol b/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol index 54e457ce..e0c334da 100644 --- a/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol @@ -2,9 +2,11 @@ pragma solidity ^0.8.27; +import {IERC6372} from "@openzeppelin/contracts/interfaces/IERC6372.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import {Checkpoints} from "@openzeppelin/contracts/utils/structs/Checkpoints.sol"; +import {Time} from "@openzeppelin/contracts/utils/types/Time.sol"; import {ERC7540} from "./ERC7540.sol"; /** @@ -27,16 +29,29 @@ import {ERC7540} from "./ERC7540.sol"; * Override {redeemDelay} to customize the waiting period (default: 1 hour) and {clock} to * change the time source (default: `block.timestamp`). */ -abstract contract ERC7540DelayRedeem is ERC7540 { +abstract contract ERC7540DelayRedeem is ERC7540, IERC6372 { using SafeCast for uint256; using Checkpoints for Checkpoints.Trace208; mapping(address controller => Checkpoints.Trace208) private _redeems; mapping(address controller => uint256) private _claimedRedeems; - /// @dev Returns the current clock value. Defaults to `block.timestamp`. + /// @dev The clock was incorrectly modified. + error ERC6372InconsistentClock(); + + /// @inheritdoc IERC6372 function clock() public view virtual returns (uint48) { - return uint48(block.timestamp); + return Time.timestamp(); + } + + /// @inheritdoc IERC6372 + // solhint-disable-next-line func-name-mixedcase + function CLOCK_MODE() public view virtual returns (string memory) { + // Check that the clock was not modified + if (clock() != Time.timestamp()) { + revert ERC6372InconsistentClock(); + } + return "mode=timestamp"; } /// @dev Returns the delay duration before a redeem request becomes claimable. Defaults to 1 hour. From 02e962e717a301e7295985e5a59db555cd5b2f11 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 22 Apr 2026 12:15:10 +0200 Subject: [PATCH 36/60] Add overridden CLOCK_MODE to mock --- contracts/mocks/token/ERC7540DelayMock.sol | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/contracts/mocks/token/ERC7540DelayMock.sol b/contracts/mocks/token/ERC7540DelayMock.sol index b4fe7c86..5c87ee1b 100644 --- a/contracts/mocks/token/ERC7540DelayMock.sol +++ b/contracts/mocks/token/ERC7540DelayMock.sol @@ -11,6 +11,16 @@ abstract contract ERC7540DelayMock is ERC7540DelayDeposit, ERC7540DelayRedeem { return super.clock(); } + function CLOCK_MODE() + public + view + virtual + override(ERC7540DelayDeposit, ERC7540DelayRedeem) + returns (string memory) + { + return super.CLOCK_MODE(); + } + function _requestDeposit( uint256 assets, address controller, From 37a10d7f9660e53264ce46f4cf860467e854b55b Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 22 Apr 2026 12:18:36 +0200 Subject: [PATCH 37/60] Add notes to _asyncMaxWithdraw and _asyncMaxDeposit --- .../token/ERC20/extensions/ERC7540EpochDeposit.sol | 10 +++++++++- .../token/ERC20/extensions/ERC7540EpochRedeem.sol | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol index d8614301..be65f565 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol @@ -73,7 +73,15 @@ abstract contract ERC7540EpochDeposit is ERC7540 { return details.totalShares == 0 ? 0 : details.requests[controller]; } - /// @dev Sums claimable assets across all fulfilled epochs the `owner` participates in. + /** + * @dev Sums claimable assets across all fulfilled epochs the `owner` participates in. + * + * NOTE: This function iterates over the `owner`'s epoch queue, which is O(n) in the number of + * epochs the owner participates in. This is bounded by {_requestQueueLimit} (default 32) and is + * per-account; 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) { diff --git a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol index 05ce48fe..9f5679b1 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol @@ -74,7 +74,15 @@ abstract contract ERC7540EpochRedeem is ERC7540 { return details.totalAssets == 0 ? 0 : details.requests[controller]; } - /// @dev Sums claimable assets across all fulfilled epochs the `owner` participates in. + /** + * @dev Sums claimable assets across all fulfilled epochs the `owner` participates in. + * + * NOTE: This function iterates over the `owner`'s epoch queue, which is O(n) in the number of + * epochs the owner participates in. This is bounded by {_requestQueueLimit} (default 32) and is + * per-account — 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) { From 8cf1a2e8aeedb8b6b47b466b56286e0ba60dbbf9 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 22 Apr 2026 12:24:56 +0200 Subject: [PATCH 38/60] Remove epoch strategy to separate PR The epoch-based fulfillment strategy (ERC7540EpochDeposit, ERC7540EpochRedeem, and ERC7540EpochMock) is too complex for this initial PR. Move it to a follow-up PR targeting this branch. Co-Authored-By: Claude Opus 4.6 (1M context) --- contracts/mocks/token/ERC7540EpochMock.sol | 37 --- .../ERC20/extensions/ERC7540EpochDeposit.sol | 231 ----------------- .../ERC20/extensions/ERC7540EpochRedeem.sol | 232 ------------------ 3 files changed, 500 deletions(-) delete mode 100644 contracts/mocks/token/ERC7540EpochMock.sol delete mode 100644 contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol delete mode 100644 contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol diff --git a/contracts/mocks/token/ERC7540EpochMock.sol b/contracts/mocks/token/ERC7540EpochMock.sol deleted file mode 100644 index c03ece67..00000000 --- a/contracts/mocks/token/ERC7540EpochMock.sol +++ /dev/null @@ -1,37 +0,0 @@ -// 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); - } - - function _requestQueueLimit() - internal - view - virtual - override(ERC7540EpochDeposit, ERC7540EpochRedeem) - returns (uint256) - { - return super._requestQueueLimit(); - } -} diff --git a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol deleted file mode 100644 index be65f565..00000000 --- a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol +++ /dev/null @@ -1,231 +0,0 @@ -// 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 - * {_requestQueueLimit} 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. - */ -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; - - /// @inheritdoc ERC7540 - function _isDepositAsync() internal pure virtual override returns (bool) { - return true; - } - - /// @dev Returns the current epoch ID. Defaults to `block.timestamp / 1 weeks`. - function currentDepositEpoch() public view virtual returns (uint256) { - return block.timestamp / 1 weeks; - } - - /// @dev A request is pending if its epoch has not yet been fulfilled (`totalShares == 0`). - function _pendingDepositRequest( - uint256 requestId, - address controller - ) internal view virtual override returns (uint256) { - EpochDepositMetadata storage details = _epochs[requestId]; - return details.totalShares == 0 ? details.requests[controller] : 0; - } - - /// @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) { - EpochDepositMetadata storage details = _epochs[requestId]; - return details.totalShares == 0 ? 0 : details.requests[controller]; - } - - /** - * @dev Sums claimable assets across all fulfilled epochs the `owner` participates in. - * - * NOTE: This function iterates over the `owner`'s epoch queue, which is O(n) in the number of - * epochs the owner participates in. This is bounded by {_requestQueueLimit} (default 32) and is - * per-account; 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)); - result += _claimableDepositRequest(epochId, owner); - } - return result; - } - - /// @dev Sums claimable shares across all fulfilled epochs the `owner` participates in. - 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)); - result += Math.mulDiv( - _claimableDepositRequest(epochId, owner), - _epochs[epochId].totalShares, - _epochs[epochId].totalAssets, - Math.Rounding.Floor - ); - } - return result; - } - - /** - * @dev Records the request in the current epoch and enqueues the epoch ID for `controller` - * if not already present. - * - * Requirements: - * - * * The controller's epoch queue must not exceed {_requestQueueLimit}. - */ - function _requestDeposit( - uint256 assets, - address controller, - address owner, - uint256 /* requestId */ - ) internal virtual override returns (uint256) { - uint256 epochId = currentDepositEpoch(); - _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() < _requestQueueLimit()); - - _memberOf[controller].pushBack(bytes32(epochId)); - } - - return super._requestDeposit(assets, controller, owner, epochId); - } - - /// @dev Returns the total assets available to fulfill for `epochId`, or 0 if already fulfilled or still current. - function _assetsToFulfillDeposit(uint256 epochId) internal view virtual returns (uint256) { - return epochId < currentDepositEpoch() && _epochs[epochId].totalShares == 0 ? _epochs[epochId].totalAssets : 0; - } - - /** - * @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. - * - * 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()); // TODO: too early - - EpochDepositMetadata storage details = _epochs[epochId]; - require(details.totalAssets > 0 && details.totalShares == 0); // TODO: invalid resolve - - details.totalShares = totalShares; - // TODO: emit event - } - - /** - * @dev Iterates through the controller's epoch queue front-to-back, consuming assets - * and converting them to shares at each epoch's locked rate. Fully consumed epochs - * are dequeued. - */ - function _consumeClaimableDeposit(uint256 assets, address controller) internal virtual override returns (uint256) { - uint256 shares = 0; - - while (assets > 0) { - uint256 epochId = uint256(_memberOf[controller].front()); - - EpochDepositMetadata storage details = _epochs[epochId]; - - uint256 requested = details.requests[controller]; - if (requested <= assets) _memberOf[controller].popFront(); - - uint256 batchAssets = requested.min(assets); - details.requests[controller] -= batchAssets; // May need saturatingSub for rounding handling - details.totalAssets -= batchAssets; // May need saturatingSub for rounding handling - assets -= batchAssets; // May need saturatingSub for rounding handling - - uint256 batchShares = batchAssets.mulDiv(details.totalShares, details.totalAssets, Math.Rounding.Floor); - details.totalShares -= batchShares; // May need saturatingSub for rounding handling - shares += batchShares; - } - - return shares; - } - - /// @dev Same as {_consumeClaimableDeposit} but iterates by shares instead of assets. - function _consumeClaimableMint(uint256 shares, address controller) internal virtual override returns (uint256) { - uint256 assets = 0; - - while (shares > 0) { - uint256 epochId = uint256(_memberOf[controller].front()); - - EpochDepositMetadata storage details = _epochs[epochId]; - - uint256 requested = details.requests[controller].mulDiv( - details.totalShares, - details.totalAssets, - Math.Rounding.Ceil - ); - if (requested <= shares) _memberOf[controller].popFront(); - - uint256 batchShares = requested.min(shares); - details.totalShares -= batchShares; // May need saturatingSub for rounding handling - shares -= batchShares; // May need saturatingSub for rounding handling - - uint256 batchAssets = batchShares.mulDiv(details.totalAssets, details.totalShares, Math.Rounding.Floor); - details.requests[controller] -= batchAssets; // May need saturatingSub for rounding handling - details.totalAssets -= batchAssets; // May need saturatingSub for rounding handling - 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 _requestQueueLimit() internal view virtual returns (uint256) { - return 32; - } -} diff --git a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol deleted file mode 100644 index 9f5679b1..00000000 --- a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol +++ /dev/null @@ -1,232 +0,0 @@ -// 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/nashpoint/nashpoint-smart-contracts/blob/main/src/Node.sol[Nashpoint], - * 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 - * {_requestQueueLimit} 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. - */ -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; - - /// @inheritdoc ERC7540 - function _isRedeemAsync() internal pure virtual override returns (bool) { - return true; - } - - /// @dev Returns the current epoch ID. Defaults to `block.timestamp / 1 weeks`. - function currentRedeemEpoch() public view virtual returns (uint256) { - return block.timestamp / 1 weeks; - } - - /// @dev A request is pending if its epoch has not yet been fulfilled (`totalAssets == 0`). - function _pendingRedeemRequest( - uint256 requestId, - address controller - ) internal view virtual override returns (uint256) { - EpochRedeemMetadata storage details = _epochs[requestId]; - return details.totalAssets == 0 ? details.requests[controller] : 0; - } - - /// @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) { - EpochRedeemMetadata storage details = _epochs[requestId]; - return details.totalAssets == 0 ? 0 : details.requests[controller]; - } - - /** - * @dev Sums claimable assets across all fulfilled epochs the `owner` participates in. - * - * NOTE: This function iterates over the `owner`'s epoch queue, which is O(n) in the number of - * epochs the owner participates in. This is bounded by {_requestQueueLimit} (default 32) and is - * per-account — 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)); - result += Math.mulDiv( - _claimableRedeemRequest(epochId, owner), - _epochs[epochId].totalAssets, - _epochs[epochId].totalShares, - Math.Rounding.Floor - ); - } - return result; - } - - /// @dev Sums claimable shares across all fulfilled epochs the `owner` participates in. - 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)); - result += _claimableRedeemRequest(epochId, owner); - } - return result; - } - - /** - * @dev Records the request in the current epoch and enqueues the epoch ID for `controller` - * if not already present. - * - * Requirements: - * - * * The controller's epoch queue must not exceed {_requestQueueLimit}. - */ - function _requestRedeem( - uint256 shares, - address controller, - address owner, - uint256 /* requestId */ - ) internal virtual override returns (uint256) { - uint256 epochId = currentRedeemEpoch(); - _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() < _requestQueueLimit()); - - _memberOf[controller].pushBack(bytes32(epochId)); - } - - return super._requestRedeem(shares, controller, owner, epochId); - } - - /// @dev Returns the total shares available to fulfill for `epochId`, or 0 if already fulfilled or still current. - function _sharesToFulfillRedeem(uint256 epochId) internal view virtual returns (uint256) { - return epochId < currentRedeemEpoch() && _epochs[epochId].totalAssets == 0 ? _epochs[epochId].totalShares : 0; - } - - /** - * @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. - * - * 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()); // TODO: too early - - EpochRedeemMetadata storage details = _epochs[epochId]; - require(details.totalShares > 0 && details.totalAssets == 0); // TODO: invalid resolve - - details.totalAssets = totalAssets; - // TODO: emit event - } - - /** - * @dev Iterates through the controller's epoch queue front-to-back, consuming assets - * and converting them to shares at each epoch's locked rate. Fully consumed epochs - * are dequeued. - */ - function _consumeClaimableWithdraw(uint256 assets, address controller) internal virtual override returns (uint256) { - uint256 shares = 0; - - while (assets > 0) { - uint256 epochId = uint256(_memberOf[controller].front()); - - EpochRedeemMetadata storage details = _epochs[epochId]; - - uint256 requested = details.requests[controller].mulDiv( - details.totalAssets, - details.totalShares, - Math.Rounding.Ceil - ); - if (requested <= assets) _memberOf[controller].popFront(); - - uint256 batchAssets = requested.min(assets); - details.totalAssets -= batchAssets; // May need saturatingSub for rounding handling - assets -= batchAssets; // May need saturatingSub for rounding handling - - uint256 batchShares = batchAssets.mulDiv(details.totalShares, details.totalAssets, Math.Rounding.Floor); - details.requests[controller] -= batchShares; // May need saturatingSub for rounding handling - details.totalShares -= batchShares; // May need saturatingSub for rounding handling - shares += batchShares; - } - - return shares; - } - - /// @dev Same as {_consumeClaimableWithdraw} but iterates by shares instead of assets. - function _consumeClaimableRedeem(uint256 shares, address controller) internal virtual override returns (uint256) { - uint256 assets = 0; - - while (shares > 0) { - uint256 epochId = uint256(_memberOf[controller].front()); - - EpochRedeemMetadata storage details = _epochs[epochId]; - - uint256 requested = details.requests[controller]; - if (requested <= shares) _memberOf[controller].popFront(); - - uint256 batchShares = requested.min(shares); - details.requests[controller] -= batchShares; // May need saturatingSub for rounding handling - details.totalShares -= batchShares; // May need saturatingSub for rounding handling - shares -= batchShares; // May need saturatingSub for rounding handling - - uint256 batchAssets = batchShares.mulDiv(details.totalAssets, details.totalShares, Math.Rounding.Floor); - details.totalAssets -= batchAssets; // May need saturatingSub for rounding handling - 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 _requestQueueLimit() internal view virtual returns (uint256) { - return 32; - } -} From 46133201f39471e6aeed2ef3f4c2fb88a4a6cce0 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 22 Apr 2026 12:34:04 +0200 Subject: [PATCH 39/60] Revert "Remove epoch strategy to separate PR" This reverts commit 8cf1a2e8aeedb8b6b47b466b56286e0ba60dbbf9. --- contracts/mocks/token/ERC7540EpochMock.sol | 37 +++ .../ERC20/extensions/ERC7540EpochDeposit.sol | 231 +++++++++++++++++ .../ERC20/extensions/ERC7540EpochRedeem.sol | 232 ++++++++++++++++++ 3 files changed, 500 insertions(+) create mode 100644 contracts/mocks/token/ERC7540EpochMock.sol create mode 100644 contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol create mode 100644 contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol diff --git a/contracts/mocks/token/ERC7540EpochMock.sol b/contracts/mocks/token/ERC7540EpochMock.sol new file mode 100644 index 00000000..c03ece67 --- /dev/null +++ b/contracts/mocks/token/ERC7540EpochMock.sol @@ -0,0 +1,37 @@ +// 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); + } + + function _requestQueueLimit() + internal + view + virtual + override(ERC7540EpochDeposit, ERC7540EpochRedeem) + returns (uint256) + { + return super._requestQueueLimit(); + } +} diff --git a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol new file mode 100644 index 00000000..be65f565 --- /dev/null +++ b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol @@ -0,0 +1,231 @@ +// 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 + * {_requestQueueLimit} 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. + */ +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; + + /// @inheritdoc ERC7540 + function _isDepositAsync() internal pure virtual override returns (bool) { + return true; + } + + /// @dev Returns the current epoch ID. Defaults to `block.timestamp / 1 weeks`. + function currentDepositEpoch() public view virtual returns (uint256) { + return block.timestamp / 1 weeks; + } + + /// @dev A request is pending if its epoch has not yet been fulfilled (`totalShares == 0`). + function _pendingDepositRequest( + uint256 requestId, + address controller + ) internal view virtual override returns (uint256) { + EpochDepositMetadata storage details = _epochs[requestId]; + return details.totalShares == 0 ? details.requests[controller] : 0; + } + + /// @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) { + EpochDepositMetadata storage details = _epochs[requestId]; + return details.totalShares == 0 ? 0 : details.requests[controller]; + } + + /** + * @dev Sums claimable assets across all fulfilled epochs the `owner` participates in. + * + * NOTE: This function iterates over the `owner`'s epoch queue, which is O(n) in the number of + * epochs the owner participates in. This is bounded by {_requestQueueLimit} (default 32) and is + * per-account; 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)); + result += _claimableDepositRequest(epochId, owner); + } + return result; + } + + /// @dev Sums claimable shares across all fulfilled epochs the `owner` participates in. + 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)); + result += Math.mulDiv( + _claimableDepositRequest(epochId, owner), + _epochs[epochId].totalShares, + _epochs[epochId].totalAssets, + Math.Rounding.Floor + ); + } + return result; + } + + /** + * @dev Records the request in the current epoch and enqueues the epoch ID for `controller` + * if not already present. + * + * Requirements: + * + * * The controller's epoch queue must not exceed {_requestQueueLimit}. + */ + function _requestDeposit( + uint256 assets, + address controller, + address owner, + uint256 /* requestId */ + ) internal virtual override returns (uint256) { + uint256 epochId = currentDepositEpoch(); + _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() < _requestQueueLimit()); + + _memberOf[controller].pushBack(bytes32(epochId)); + } + + return super._requestDeposit(assets, controller, owner, epochId); + } + + /// @dev Returns the total assets available to fulfill for `epochId`, or 0 if already fulfilled or still current. + function _assetsToFulfillDeposit(uint256 epochId) internal view virtual returns (uint256) { + return epochId < currentDepositEpoch() && _epochs[epochId].totalShares == 0 ? _epochs[epochId].totalAssets : 0; + } + + /** + * @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. + * + * 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()); // TODO: too early + + EpochDepositMetadata storage details = _epochs[epochId]; + require(details.totalAssets > 0 && details.totalShares == 0); // TODO: invalid resolve + + details.totalShares = totalShares; + // TODO: emit event + } + + /** + * @dev Iterates through the controller's epoch queue front-to-back, consuming assets + * and converting them to shares at each epoch's locked rate. Fully consumed epochs + * are dequeued. + */ + function _consumeClaimableDeposit(uint256 assets, address controller) internal virtual override returns (uint256) { + uint256 shares = 0; + + while (assets > 0) { + uint256 epochId = uint256(_memberOf[controller].front()); + + EpochDepositMetadata storage details = _epochs[epochId]; + + uint256 requested = details.requests[controller]; + if (requested <= assets) _memberOf[controller].popFront(); + + uint256 batchAssets = requested.min(assets); + details.requests[controller] -= batchAssets; // May need saturatingSub for rounding handling + details.totalAssets -= batchAssets; // May need saturatingSub for rounding handling + assets -= batchAssets; // May need saturatingSub for rounding handling + + uint256 batchShares = batchAssets.mulDiv(details.totalShares, details.totalAssets, Math.Rounding.Floor); + details.totalShares -= batchShares; // May need saturatingSub for rounding handling + shares += batchShares; + } + + return shares; + } + + /// @dev Same as {_consumeClaimableDeposit} but iterates by shares instead of assets. + function _consumeClaimableMint(uint256 shares, address controller) internal virtual override returns (uint256) { + uint256 assets = 0; + + while (shares > 0) { + uint256 epochId = uint256(_memberOf[controller].front()); + + EpochDepositMetadata storage details = _epochs[epochId]; + + uint256 requested = details.requests[controller].mulDiv( + details.totalShares, + details.totalAssets, + Math.Rounding.Ceil + ); + if (requested <= shares) _memberOf[controller].popFront(); + + uint256 batchShares = requested.min(shares); + details.totalShares -= batchShares; // May need saturatingSub for rounding handling + shares -= batchShares; // May need saturatingSub for rounding handling + + uint256 batchAssets = batchShares.mulDiv(details.totalAssets, details.totalShares, Math.Rounding.Floor); + details.requests[controller] -= batchAssets; // May need saturatingSub for rounding handling + details.totalAssets -= batchAssets; // May need saturatingSub for rounding handling + 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 _requestQueueLimit() 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..9f5679b1 --- /dev/null +++ b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol @@ -0,0 +1,232 @@ +// 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/nashpoint/nashpoint-smart-contracts/blob/main/src/Node.sol[Nashpoint], + * 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 + * {_requestQueueLimit} 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. + */ +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; + + /// @inheritdoc ERC7540 + function _isRedeemAsync() internal pure virtual override returns (bool) { + return true; + } + + /// @dev Returns the current epoch ID. Defaults to `block.timestamp / 1 weeks`. + function currentRedeemEpoch() public view virtual returns (uint256) { + return block.timestamp / 1 weeks; + } + + /// @dev A request is pending if its epoch has not yet been fulfilled (`totalAssets == 0`). + function _pendingRedeemRequest( + uint256 requestId, + address controller + ) internal view virtual override returns (uint256) { + EpochRedeemMetadata storage details = _epochs[requestId]; + return details.totalAssets == 0 ? details.requests[controller] : 0; + } + + /// @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) { + EpochRedeemMetadata storage details = _epochs[requestId]; + return details.totalAssets == 0 ? 0 : details.requests[controller]; + } + + /** + * @dev Sums claimable assets across all fulfilled epochs the `owner` participates in. + * + * NOTE: This function iterates over the `owner`'s epoch queue, which is O(n) in the number of + * epochs the owner participates in. This is bounded by {_requestQueueLimit} (default 32) and is + * per-account — 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)); + result += Math.mulDiv( + _claimableRedeemRequest(epochId, owner), + _epochs[epochId].totalAssets, + _epochs[epochId].totalShares, + Math.Rounding.Floor + ); + } + return result; + } + + /// @dev Sums claimable shares across all fulfilled epochs the `owner` participates in. + 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)); + result += _claimableRedeemRequest(epochId, owner); + } + return result; + } + + /** + * @dev Records the request in the current epoch and enqueues the epoch ID for `controller` + * if not already present. + * + * Requirements: + * + * * The controller's epoch queue must not exceed {_requestQueueLimit}. + */ + function _requestRedeem( + uint256 shares, + address controller, + address owner, + uint256 /* requestId */ + ) internal virtual override returns (uint256) { + uint256 epochId = currentRedeemEpoch(); + _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() < _requestQueueLimit()); + + _memberOf[controller].pushBack(bytes32(epochId)); + } + + return super._requestRedeem(shares, controller, owner, epochId); + } + + /// @dev Returns the total shares available to fulfill for `epochId`, or 0 if already fulfilled or still current. + function _sharesToFulfillRedeem(uint256 epochId) internal view virtual returns (uint256) { + return epochId < currentRedeemEpoch() && _epochs[epochId].totalAssets == 0 ? _epochs[epochId].totalShares : 0; + } + + /** + * @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. + * + * 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()); // TODO: too early + + EpochRedeemMetadata storage details = _epochs[epochId]; + require(details.totalShares > 0 && details.totalAssets == 0); // TODO: invalid resolve + + details.totalAssets = totalAssets; + // TODO: emit event + } + + /** + * @dev Iterates through the controller's epoch queue front-to-back, consuming assets + * and converting them to shares at each epoch's locked rate. Fully consumed epochs + * are dequeued. + */ + function _consumeClaimableWithdraw(uint256 assets, address controller) internal virtual override returns (uint256) { + uint256 shares = 0; + + while (assets > 0) { + uint256 epochId = uint256(_memberOf[controller].front()); + + EpochRedeemMetadata storage details = _epochs[epochId]; + + uint256 requested = details.requests[controller].mulDiv( + details.totalAssets, + details.totalShares, + Math.Rounding.Ceil + ); + if (requested <= assets) _memberOf[controller].popFront(); + + uint256 batchAssets = requested.min(assets); + details.totalAssets -= batchAssets; // May need saturatingSub for rounding handling + assets -= batchAssets; // May need saturatingSub for rounding handling + + uint256 batchShares = batchAssets.mulDiv(details.totalShares, details.totalAssets, Math.Rounding.Floor); + details.requests[controller] -= batchShares; // May need saturatingSub for rounding handling + details.totalShares -= batchShares; // May need saturatingSub for rounding handling + shares += batchShares; + } + + return shares; + } + + /// @dev Same as {_consumeClaimableWithdraw} but iterates by shares instead of assets. + function _consumeClaimableRedeem(uint256 shares, address controller) internal virtual override returns (uint256) { + uint256 assets = 0; + + while (shares > 0) { + uint256 epochId = uint256(_memberOf[controller].front()); + + EpochRedeemMetadata storage details = _epochs[epochId]; + + uint256 requested = details.requests[controller]; + if (requested <= shares) _memberOf[controller].popFront(); + + uint256 batchShares = requested.min(shares); + details.requests[controller] -= batchShares; // May need saturatingSub for rounding handling + details.totalShares -= batchShares; // May need saturatingSub for rounding handling + shares -= batchShares; // May need saturatingSub for rounding handling + + uint256 batchAssets = batchShares.mulDiv(details.totalAssets, details.totalShares, Math.Rounding.Floor); + details.totalAssets -= batchAssets; // May need saturatingSub for rounding handling + 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 _requestQueueLimit() internal view virtual returns (uint256) { + return 32; + } +} From aaaa6c4b45e9cdeadb14607ea58dde8552a80293 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Wed, 29 Apr 2026 13:29:49 +0200 Subject: [PATCH 40/60] Delete test/utils/introspection/SupportsInterface.behavior.js --- .../SupportsInterface.behavior.js | 182 ------------------ 1 file changed, 182 deletions(-) delete mode 100644 test/utils/introspection/SupportsInterface.behavior.js diff --git a/test/utils/introspection/SupportsInterface.behavior.js b/test/utils/introspection/SupportsInterface.behavior.js deleted file mode 100644 index 533d246c..00000000 --- a/test/utils/introspection/SupportsInterface.behavior.js +++ /dev/null @@ -1,182 +0,0 @@ -const { expect } = require('chai'); -const { interfaceId } = require('@openzeppelin/contracts/test/helpers/methods'); -const { mapValues } = require('@openzeppelin/contracts/test/helpers/iterate'); - -const INVALID_ID = '0xffffffff'; -const GOVERNOR_INTERFACE = [ - 'name()', - 'version()', - 'COUNTING_MODE()', - 'hashProposal(address[],uint256[],bytes[],bytes32)', - 'state(uint256)', - 'proposalThreshold()', - 'proposalSnapshot(uint256)', - 'proposalDeadline(uint256)', - 'proposalProposer(uint256)', - 'proposalEta(uint256)', - 'proposalNeedsQueuing(uint256)', - 'votingDelay()', - 'votingPeriod()', - 'quorum(uint256)', - 'getVotes(address,uint256)', - 'getVotesWithParams(address,uint256,bytes)', - 'hasVoted(uint256,address)', - 'propose(address[],uint256[],bytes[],string)', - 'queue(address[],uint256[],bytes[],bytes32)', - 'execute(address[],uint256[],bytes[],bytes32)', - 'cancel(address[],uint256[],bytes[],bytes32)', - 'castVote(uint256,uint8)', - 'castVoteWithReason(uint256,uint8,string)', - 'castVoteWithReasonAndParams(uint256,uint8,string,bytes)', - 'castVoteBySig(uint256,uint8,address,bytes)', - 'castVoteWithReasonAndParamsBySig(uint256,uint8,address,string,bytes,bytes)', -]; -const SIGNATURES = { - ERC165: ['supportsInterface(bytes4)'], - ERC721: [ - 'balanceOf(address)', - 'ownerOf(uint256)', - 'approve(address,uint256)', - 'getApproved(uint256)', - 'setApprovalForAll(address,bool)', - 'isApprovedForAll(address,address)', - 'transferFrom(address,address,uint256)', - 'safeTransferFrom(address,address,uint256)', - 'safeTransferFrom(address,address,uint256,bytes)', - ], - ERC721Enumerable: ['totalSupply()', 'tokenOfOwnerByIndex(address,uint256)', 'tokenByIndex(uint256)'], - ERC721Metadata: ['name()', 'symbol()', 'tokenURI(uint256)'], - ERC1155: [ - 'balanceOf(address,uint256)', - 'balanceOfBatch(address[],uint256[])', - 'setApprovalForAll(address,bool)', - 'isApprovedForAll(address,address)', - 'safeTransferFrom(address,address,uint256,uint256,bytes)', - 'safeBatchTransferFrom(address,address,uint256[],uint256[],bytes)', - ], - ERC1155MetadataURI: ['uri(uint256)'], - ERC1155Receiver: [ - 'onERC1155Received(address,address,uint256,uint256,bytes)', - 'onERC1155BatchReceived(address,address,uint256[],uint256[],bytes)', - ], - ERC1363: [ - 'transferAndCall(address,uint256)', - 'transferAndCall(address,uint256,bytes)', - 'transferFromAndCall(address,address,uint256)', - 'transferFromAndCall(address,address,uint256,bytes)', - 'approveAndCall(address,uint256)', - 'approveAndCall(address,uint256,bytes)', - ], - AccessControl: [ - 'hasRole(bytes32,address)', - 'getRoleAdmin(bytes32)', - 'grantRole(bytes32,address)', - 'revokeRole(bytes32,address)', - 'renounceRole(bytes32,address)', - ], - AccessControlEnumerable: ['getRoleMember(bytes32,uint256)', 'getRoleMemberCount(bytes32)'], - AccessControlDefaultAdminRules: [ - 'defaultAdminDelay()', - 'pendingDefaultAdminDelay()', - 'defaultAdmin()', - 'pendingDefaultAdmin()', - 'defaultAdminDelayIncreaseWait()', - 'changeDefaultAdminDelay(uint48)', - 'rollbackDefaultAdminDelay()', - 'beginDefaultAdminTransfer(address)', - 'acceptDefaultAdminTransfer()', - 'cancelDefaultAdminTransfer()', - ], - Governor: GOVERNOR_INTERFACE, - Governor_5_3: GOVERNOR_INTERFACE.concat('getProposalId(address[],uint256[],bytes[],bytes32)'), - ERC2981: ['royaltyInfo(uint256,uint256)'], - ERC6909: [ - 'balanceOf(address,uint256)', - 'allowance(address,address,uint256)', - 'isOperator(address,address)', - 'transfer(address,uint256,uint256)', - 'transferFrom(address,address,uint256,uint256)', - 'approve(address,uint256,uint256)', - 'setOperator(address,bool)', - ], - ERC6909TokenSupply: ['totalSupply(uint256)'], - ERC6909Metadata: ['name(uint256)', 'symbol(uint256)', 'decimals(uint256)'], - ERC6909ContentURI: ['contractURI()', 'tokenURI(uint256)'], - ERC7540Operator: ['setOperator(address,bool)', 'isOperator(address,address)'], - ERC7540Deposit: [ - 'requestDeposit(uint256,address,address)', - 'pendingDepositRequest(uint256,address)', - 'claimableDepositRequest(uint256,address)', - 'deposit(uint256,address,address)', - 'mint(uint256,address,address)', - ], - ERC7540Redeem: [ - 'requestRedeem(uint256,address,address)', - 'pendingRedeemRequest(uint256,address)', - 'claimableRedeemRequest(uint256,address)', - ], -}; - -const INTERFACE_IDS = mapValues(SIGNATURES, interfaceId); - -function shouldSupportInterfaces(interfaces = [], signatures = SIGNATURES) { - // case where only signatures are provided - if (!Array.isArray(interfaces)) { - signatures = interfaces; - interfaces = Object.keys(interfaces); - } - - interfaces.unshift('ERC165'); - signatures.ERC165 = SIGNATURES.ERC165; - const interfaceIds = mapValues(signatures, interfaceId, ([name]) => interfaces.includes(name)); - - describe('ERC165', function () { - beforeEach(function () { - this.contractUnderTest = this.mock || this.token; - }); - - describe('when the interfaceId is supported', function () { - it('uses less than 30k gas', async function () { - for (const k of interfaces) { - const interfaceId = interfaceIds[k] ?? k; - expect(await this.contractUnderTest.supportsInterface.estimateGas(interfaceId)).to.lte(30_000n); - } - }); - - it('returns true', async function () { - for (const k of interfaces) { - const interfaceId = interfaceIds[k] ?? k; - expect(await this.contractUnderTest.supportsInterface(interfaceId), `does not support ${k}`).to.be.true; - } - }); - }); - - describe('when the interfaceId is not supported', function () { - it('uses less than 30k', async function () { - expect(await this.contractUnderTest.supportsInterface.estimateGas(INVALID_ID)).to.lte(30_000n); - }); - - it('returns false', async function () { - expect(await this.contractUnderTest.supportsInterface(INVALID_ID), `supports ${INVALID_ID}`).to.be.false; - }); - }); - - it('all interface functions are in ABI', async function () { - for (const k of interfaces) { - // skip interfaces for which we don't have a function list - if (signatures[k] === undefined) continue; - - // Check the presence of each function in the contract's interface - for (const fnSig of signatures[k]) { - expect(this.contractUnderTest.interface.hasFunction(fnSig), `did not find ${fnSig}`).to.be.true; - } - } - }); - }); -} - -module.exports = { - SIGNATURES, - INTERFACE_IDS, - shouldSupportInterfaces, -}; From 2f3cc7dbbc9473010012cd868b5518778fb38e5b Mon Sep 17 00:00:00 2001 From: ernestognw Date: Mon, 11 May 2026 14:31:54 -0600 Subject: [PATCH 41/60] Fix CodeRabbit review --- .../ERC20/extensions/ERC7540EpochDeposit.sol | 24 ++++++++++++++----- .../ERC20/extensions/ERC7540EpochRedeem.sol | 24 ++++++++++++++----- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol index be65f565..cbe91e38 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol @@ -26,6 +26,12 @@ import {ERC7540} from "./ERC7540.sol"; * Each account tracks its epoch memberships via a {DoubleEndedQueue} capped at * {_requestQueueLimit} 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: Distribution within an epoch is exact. The sum of shares paid across all calls to + * {_consumeClaimableDeposit} and {_consumeClaimableMint} equals the fulfilled `totalShares`. + * Each claim is floor-rounded against the remaining `totalAssets` and `totalShares`, so any + * sub-unit residue accumulates and is absorbed by the final claim in the epoch rather than + * left stranded in the contract. */ abstract contract ERC7540EpochDeposit is ERC7540 { using Math for uint256; @@ -148,6 +154,12 @@ abstract contract ERC7540EpochDeposit is ERC7540 { * * 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`. + * * Requirements: * * * `epochId` must be a past epoch (less than {currentDepositEpoch}). @@ -180,12 +192,12 @@ abstract contract ERC7540EpochDeposit is ERC7540 { if (requested <= assets) _memberOf[controller].popFront(); uint256 batchAssets = requested.min(assets); + uint256 batchShares = batchAssets.mulDiv(details.totalShares, details.totalAssets, Math.Rounding.Floor); + details.requests[controller] -= batchAssets; // May need saturatingSub for rounding handling details.totalAssets -= batchAssets; // May need saturatingSub for rounding handling - assets -= batchAssets; // May need saturatingSub for rounding handling - - uint256 batchShares = batchAssets.mulDiv(details.totalShares, details.totalAssets, Math.Rounding.Floor); details.totalShares -= batchShares; // May need saturatingSub for rounding handling + assets -= batchAssets; // May need saturatingSub for rounding handling shares += batchShares; } @@ -209,12 +221,12 @@ abstract contract ERC7540EpochDeposit is ERC7540 { if (requested <= shares) _memberOf[controller].popFront(); uint256 batchShares = requested.min(shares); - details.totalShares -= batchShares; // May need saturatingSub for rounding handling - shares -= batchShares; // May need saturatingSub for rounding handling - uint256 batchAssets = batchShares.mulDiv(details.totalAssets, details.totalShares, Math.Rounding.Floor); + details.requests[controller] -= batchAssets; // May need saturatingSub for rounding handling details.totalAssets -= batchAssets; // May need saturatingSub for rounding handling + details.totalShares -= batchShares; // May need saturatingSub for rounding handling + shares -= batchShares; // May need saturatingSub for rounding handling assets += batchAssets; } diff --git a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol index 9f5679b1..a513f4e6 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol @@ -27,6 +27,12 @@ import {ERC7540} from "./ERC7540.sol"; * Each account tracks its epoch memberships via a {DoubleEndedQueue} capped at * {_requestQueueLimit} 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: Distribution within an epoch is exact. The sum of assets paid across all calls to + * {_consumeClaimableWithdraw} and {_consumeClaimableRedeem} equals the fulfilled `totalAssets`. + * Each claim is floor-rounded against the remaining `totalShares` and `totalAssets`, so any + * sub-unit residue accumulates and is absorbed by the final claim in the epoch rather than + * left stranded in the contract. */ abstract contract ERC7540EpochRedeem is ERC7540 { using Math for uint256; @@ -149,6 +155,12 @@ abstract contract ERC7540EpochRedeem is ERC7540 { * * 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`. + * * Requirements: * * * `epochId` must be a past epoch (less than {currentRedeemEpoch}). @@ -185,12 +197,12 @@ abstract contract ERC7540EpochRedeem is ERC7540 { if (requested <= assets) _memberOf[controller].popFront(); uint256 batchAssets = requested.min(assets); - details.totalAssets -= batchAssets; // May need saturatingSub for rounding handling - assets -= batchAssets; // May need saturatingSub for rounding handling - uint256 batchShares = batchAssets.mulDiv(details.totalShares, details.totalAssets, Math.Rounding.Floor); + details.requests[controller] -= batchShares; // May need saturatingSub for rounding handling + details.totalAssets -= batchAssets; // May need saturatingSub for rounding handling details.totalShares -= batchShares; // May need saturatingSub for rounding handling + assets -= batchAssets; // May need saturatingSub for rounding handling shares += batchShares; } @@ -210,12 +222,12 @@ abstract contract ERC7540EpochRedeem is ERC7540 { if (requested <= shares) _memberOf[controller].popFront(); uint256 batchShares = requested.min(shares); + uint256 batchAssets = batchShares.mulDiv(details.totalAssets, details.totalShares, Math.Rounding.Floor); + details.requests[controller] -= batchShares; // May need saturatingSub for rounding handling details.totalShares -= batchShares; // May need saturatingSub for rounding handling - shares -= batchShares; // May need saturatingSub for rounding handling - - uint256 batchAssets = batchShares.mulDiv(details.totalAssets, details.totalShares, Math.Rounding.Floor); details.totalAssets -= batchAssets; // May need saturatingSub for rounding handling + shares -= batchShares; // May need saturatingSub for rounding handling assets += batchAssets; } From 425c3b481d9a00879e3da38d890185b17e66bd86 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Mon, 11 May 2026 14:37:04 -0600 Subject: [PATCH 42/60] Replace `_fulfill*` bare requires with typed errors and emit fulfillment events Splits the combined require in `_fulfillDeposit` / `_fulfillRedeem` into three typed errors (`*TooEarly`, `*EmptyEpoch`, `*AlreadyFulfilled`) so callers can distinguish failure modes, and emits `Epoch{Deposit,Redeem}Fulfilled` on success. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ERC20/extensions/ERC7540EpochDeposit.sol | 19 ++++++++++++++++--- .../ERC20/extensions/ERC7540EpochRedeem.sol | 19 ++++++++++++++++--- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol index cbe91e38..16d3b266 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol @@ -51,6 +51,18 @@ abstract contract ERC7540EpochDeposit is ERC7540 { 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 EpochDepositFulfilled(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); + /// @inheritdoc ERC7540 function _isDepositAsync() internal pure virtual override returns (bool) { return true; @@ -166,13 +178,14 @@ abstract contract ERC7540EpochDeposit is ERC7540 { * * The epoch must have pending assets and must not have been fulfilled already. */ function _fulfillDeposit(uint256 epochId, uint256 totalShares) internal virtual { - require(epochId < currentDepositEpoch()); // TODO: too early + require(epochId < currentDepositEpoch(), ERC7540EpochDepositTooEarly(epochId)); EpochDepositMetadata storage details = _epochs[epochId]; - require(details.totalAssets > 0 && details.totalShares == 0); // TODO: invalid resolve + require(details.totalAssets > 0, ERC7540EpochDepositEmptyEpoch(epochId)); + require(details.totalShares == 0, ERC7540EpochDepositAlreadyFulfilled(epochId)); details.totalShares = totalShares; - // TODO: emit event + emit EpochDepositFulfilled(epochId, details.totalAssets, totalShares); } /** diff --git a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol index a513f4e6..29dd83f3 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol @@ -52,6 +52,18 @@ abstract contract ERC7540EpochRedeem is ERC7540 { 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 EpochRedeemFulfilled(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); + /// @inheritdoc ERC7540 function _isRedeemAsync() internal pure virtual override returns (bool) { return true; @@ -167,13 +179,14 @@ abstract contract ERC7540EpochRedeem is ERC7540 { * * The epoch must have pending shares and must not have been fulfilled already. */ function _fulfillRedeem(uint256 epochId, uint256 totalAssets) internal virtual { - require(epochId < currentRedeemEpoch()); // TODO: too early + require(epochId < currentRedeemEpoch(), ERC7540EpochRedeemTooEarly(epochId)); EpochRedeemMetadata storage details = _epochs[epochId]; - require(details.totalShares > 0 && details.totalAssets == 0); // TODO: invalid resolve + require(details.totalShares > 0, ERC7540EpochRedeemEmptyEpoch(epochId)); + require(details.totalAssets == 0, ERC7540EpochRedeemAlreadyFulfilled(epochId)); details.totalAssets = totalAssets; - // TODO: emit event + emit EpochRedeemFulfilled(epochId, details.totalShares, totalAssets); } /** From c95444e67137ed655c2090b415a443a36953fb2f Mon Sep 17 00:00:00 2001 From: ernestognw Date: Mon, 11 May 2026 14:37:57 -0600 Subject: [PATCH 43/60] Prefix event --- contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol | 4 ++-- contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol index 16d3b266..8facaed9 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol @@ -52,7 +52,7 @@ abstract contract ERC7540EpochDeposit is ERC7540 { mapping(address account => DoubleEndedQueue.Bytes32Deque) private _memberOf; /// @dev Emitted when a deposit epoch transitions from Pending to Claimable via {_fulfillDeposit}. - event EpochDepositFulfilled(uint256 indexed epochId, uint256 totalAssets, uint256 totalShares); + 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); @@ -185,7 +185,7 @@ abstract contract ERC7540EpochDeposit is ERC7540 { require(details.totalShares == 0, ERC7540EpochDepositAlreadyFulfilled(epochId)); details.totalShares = totalShares; - emit EpochDepositFulfilled(epochId, details.totalAssets, totalShares); + emit ERC7540EpochDepositFulfilled(epochId, details.totalAssets, totalShares); } /** diff --git a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol index 29dd83f3..ef30674f 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol @@ -53,7 +53,7 @@ abstract contract ERC7540EpochRedeem is ERC7540 { mapping(address account => DoubleEndedQueue.Bytes32Deque) private _memberOf; /// @dev Emitted when a redeem epoch transitions from Pending to Claimable via {_fulfillRedeem}. - event EpochRedeemFulfilled(uint256 indexed epochId, uint256 totalShares, uint256 totalAssets); + 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); @@ -186,7 +186,7 @@ abstract contract ERC7540EpochRedeem is ERC7540 { require(details.totalAssets == 0, ERC7540EpochRedeemAlreadyFulfilled(epochId)); details.totalAssets = totalAssets; - emit EpochRedeemFulfilled(epochId, details.totalShares, totalAssets); + emit ERC7540EpochRedeemFulfilled(epochId, details.totalShares, totalAssets); } /** From 8d69a3baec171dab4917574654815e9868970c1e Mon Sep 17 00:00:00 2001 From: ernestognw Date: Mon, 11 May 2026 16:49:22 -0600 Subject: [PATCH 44/60] Check safety of operations --- .../ERC20/extensions/ERC7540EpochDeposit.sol | 23 +++++++++++-------- .../ERC20/extensions/ERC7540EpochRedeem.sol | 23 +++++++++++-------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol index 8facaed9..e6792e29 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol @@ -207,10 +207,10 @@ abstract contract ERC7540EpochDeposit is ERC7540 { uint256 batchAssets = requested.min(assets); uint256 batchShares = batchAssets.mulDiv(details.totalShares, details.totalAssets, Math.Rounding.Floor); - details.requests[controller] -= batchAssets; // May need saturatingSub for rounding handling - details.totalAssets -= batchAssets; // May need saturatingSub for rounding handling - details.totalShares -= batchShares; // May need saturatingSub for rounding handling - assets -= batchAssets; // May need saturatingSub for rounding handling + details.requests[controller] -= batchAssets; // batchAssets <= requested == details.requests[controller] + 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; } @@ -234,12 +234,17 @@ abstract contract ERC7540EpochDeposit is ERC7540 { if (requested <= shares) _memberOf[controller].popFront(); uint256 batchShares = requested.min(shares); - uint256 batchAssets = batchShares.mulDiv(details.totalAssets, details.totalShares, Math.Rounding.Floor); + // When `requested <= shares` we pop the controller's epoch entry and drain their full claim. + // Computing batchAssets from batchShares uses floor while `requested` was ceiled, so it can + // land up to 1 wei above the stored request. Cap to `details.requests[controller]`. + uint256 batchAssets = batchShares.mulDiv(details.totalAssets, details.totalShares, Math.Rounding.Floor).min( + details.requests[controller] + ); - details.requests[controller] -= batchAssets; // May need saturatingSub for rounding handling - details.totalAssets -= batchAssets; // May need saturatingSub for rounding handling - details.totalShares -= batchShares; // May need saturatingSub for rounding handling - shares -= batchShares; // May need saturatingSub for rounding handling + details.requests[controller] -= batchAssets; // batchAssets <= details.requests[controller] (via .min cap above) + details.totalAssets -= batchAssets; // batchAssets <= details.totalAssets (invariant: requests[c] <= totalAssets) + details.totalShares -= batchShares; // batchShares <= requested <= details.totalShares (since requests[c] <= totalAssets => ceil(r*S/A) <= S) + shares -= batchShares; // batchShares <= shares (via .min) assets += batchAssets; } diff --git a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol index ef30674f..14c0badd 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol @@ -210,12 +210,17 @@ abstract contract ERC7540EpochRedeem is ERC7540 { if (requested <= assets) _memberOf[controller].popFront(); uint256 batchAssets = requested.min(assets); - uint256 batchShares = batchAssets.mulDiv(details.totalShares, details.totalAssets, Math.Rounding.Floor); + // When `requested <= assets` we pop the controller's epoch entry and drain their full claim. + // Computing batchShares from batchAssets uses floor while `requested` was ceiled, so it can + // land up to 1 wei above the stored request. Cap to `details.requests[controller]`. + uint256 batchShares = batchAssets.mulDiv(details.totalShares, details.totalAssets, Math.Rounding.Floor).min( + details.requests[controller] + ); - details.requests[controller] -= batchShares; // May need saturatingSub for rounding handling - details.totalAssets -= batchAssets; // May need saturatingSub for rounding handling - details.totalShares -= batchShares; // May need saturatingSub for rounding handling - assets -= batchAssets; // May need saturatingSub for rounding handling + details.requests[controller] -= batchShares; // batchShares <= details.requests[controller] (via .min cap above) + details.totalAssets -= batchAssets; // batchAssets <= requested <= details.totalAssets (since requests[c] <= totalShares => ceil(r*A/S) <= A) + details.totalShares -= batchShares; // batchShares <= details.totalShares (invariant: requests[c] <= totalShares) + assets -= batchAssets; // batchAssets <= assets (via .min) shares += batchShares; } @@ -237,10 +242,10 @@ abstract contract ERC7540EpochRedeem is ERC7540 { uint256 batchShares = requested.min(shares); uint256 batchAssets = batchShares.mulDiv(details.totalAssets, details.totalShares, Math.Rounding.Floor); - details.requests[controller] -= batchShares; // May need saturatingSub for rounding handling - details.totalShares -= batchShares; // May need saturatingSub for rounding handling - details.totalAssets -= batchAssets; // May need saturatingSub for rounding handling - shares -= batchShares; // May need saturatingSub for rounding handling + details.requests[controller] -= batchShares; // batchShares <= requested == details.requests[controller] + 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; } From 2157f6911a936823e07282811d40e90bad2c0bbf Mon Sep 17 00:00:00 2001 From: ernestognw Date: Mon, 11 May 2026 18:14:26 -0600 Subject: [PATCH 45/60] Handle drained epochs in consume loops and refine sentinel views Folds the drained-epoch case (`totalAssets == 0` for deposit / `totalShares == 0` for redeem) into the normal consume flow rather than short-circuiting with a separate branch: * Ternaries on `requested` and the cross-dim mulDiv yield 0 in the drained state, so the iteration pops the queue without consuming user input or hitting a division-by-zero. * The storage decrement on `requests[controller]` subtracts the full stored slot when fully claimed, clearing stuck dust in the same line that does the normal-case decrement. * `saturatingSub` absorbs the 1-wei ceil/floor excess in the cross-dim paths into the shared totals, so `totalAssets`/`totalShares` reach 0 cleanly and the {_fulfill*} `EmptyEpoch` guard stays unambiguous. `_pending{Deposit,Redeem}Request` now additionally check the opposite total to distinguish "pending" from "fully-claimed post-fulfillment". `_asyncMax{Mint,Withdraw}` skip drained epochs to avoid mulDiv revert. NOTE in both files documents the residual-dust bound, the example case that triggers it, why this isn't an ERC-4626 inflation attack (the per-epoch totals are donation-proof), and {_decimalsOffset} as the knob for finer per-claim granularity. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ERC20/extensions/ERC7540EpochDeposit.sol | 79 +++++++++++++------ .../ERC20/extensions/ERC7540EpochRedeem.sol | 79 +++++++++++++------ 2 files changed, 108 insertions(+), 50 deletions(-) diff --git a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol index e6792e29..89afeefd 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol @@ -27,11 +27,14 @@ import {ERC7540} from "./ERC7540.sol"; * {_requestQueueLimit} 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: Distribution within an epoch is exact. The sum of shares paid across all calls to - * {_consumeClaimableDeposit} and {_consumeClaimableMint} equals the fulfilled `totalShares`. - * Each claim is floor-rounded against the remaining `totalAssets` and `totalShares`, so any - * sub-unit residue accumulates and is absorbed by the final claim in the epoch rather than - * left stranded in the contract. + * 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; @@ -73,13 +76,18 @@ abstract contract ERC7540EpochDeposit is ERC7540 { return block.timestamp / 1 weeks; } - /// @dev A request is pending if its epoch has not yet been fulfilled (`totalShares == 0`). + /** + * @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) { EpochDepositMetadata storage details = _epochs[requestId]; - return details.totalShares == 0 ? details.requests[controller] : 0; + // `details.totalAssets > 0` distinguishes the pending state from a fully-claimed + // post-fulfillment state where both totals reach 0. + return (details.totalShares == 0 && details.totalAssets > 0) ? details.requests[controller] : 0; } /// @dev A request is claimable if its epoch has been fulfilled (`totalShares > 0`). @@ -114,10 +122,16 @@ abstract contract ERC7540EpochDeposit is ERC7540 { uint256 result = 0; for (uint256 i = 0; i < _memberOf[owner].length(); ++i) { uint256 epochId = uint256(_memberOf[owner].at(i)); + uint256 totalAssets = _epochs[epochId].totalAssets; + // 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. Skip such epochs. + if (totalAssets == 0) continue; result += Math.mulDiv( _claimableDepositRequest(epochId, owner), _epochs[epochId].totalShares, - _epochs[epochId].totalAssets, + totalAssets, Math.Rounding.Floor ); } @@ -201,13 +215,22 @@ abstract contract ERC7540EpochDeposit is ERC7540 { EpochDepositMetadata storage details = _epochs[epochId]; - uint256 requested = details.requests[controller]; + // `totalAssets == 0` indicates a fully-claimed epoch (over-claimed by other controllers via + // the share-driven path). Treating `requested` as 0 lets the loop pop the queue entry + // and skip the divide-by-zero without consuming user input. + bool isFullyClaimed = details.totalAssets == 0; + uint256 requested = isFullyClaimed ? 0 : details.requests[controller]; if (requested <= assets) _memberOf[controller].popFront(); uint256 batchAssets = requested.min(assets); - uint256 batchShares = batchAssets.mulDiv(details.totalShares, details.totalAssets, Math.Rounding.Floor); + uint256 batchShares = isFullyClaimed + ? 0 + : batchAssets.mulDiv(details.totalShares, details.totalAssets, Math.Rounding.Floor); - details.requests[controller] -= batchAssets; // batchAssets <= requested == details.requests[controller] + details.requests[controller] = details.requests[controller].saturatingSub( + // If fully claimed, subtract the full stored slot to clear stuck dust + isFullyClaimed ? details.requests[controller] : batchAssets + ); 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) @@ -226,24 +249,30 @@ abstract contract ERC7540EpochDeposit is ERC7540 { EpochDepositMetadata storage details = _epochs[epochId]; - uint256 requested = details.requests[controller].mulDiv( - details.totalShares, - details.totalAssets, - Math.Rounding.Ceil - ); + // `totalAssets == 0` indicates a fully-claimed epoch. Treating `requested` as 0 lets the + // iteration pop the queue entry without consuming user input or attempting a + // divide-by-zero. See {_consumeClaimableDeposit} for the dust handling rationale. + bool isFullyClaimed = details.totalAssets == 0; + uint256 requested = isFullyClaimed + ? 0 + : details.requests[controller].mulDiv(details.totalShares, details.totalAssets, Math.Rounding.Ceil); if (requested <= shares) _memberOf[controller].popFront(); uint256 batchShares = requested.min(shares); - // When `requested <= shares` we pop the controller's epoch entry and drain their full claim. - // Computing batchAssets from batchShares uses floor while `requested` was ceiled, so it can - // land up to 1 wei above the stored request. Cap to `details.requests[controller]`. - uint256 batchAssets = batchShares.mulDiv(details.totalAssets, details.totalShares, Math.Rounding.Floor).min( - details.requests[controller] + // `requested` is ceil-rounded so batchAssets recomputed via floor can exceed the + // stored request by 1 wei. Saturating subtraction absorbs the excess into the shared + // totals, so `totalAssets` reduces to 0 cleanly when fully claimed and the + // {_fulfillDeposit} sentinel stays unambiguous. + uint256 batchAssets = details.totalShares == 0 + ? 0 + : batchShares.mulDiv(details.totalAssets, details.totalShares, Math.Rounding.Floor); + + details.requests[controller] = details.requests[controller].saturatingSub( + // If fully claimed, subtract the full stored slot to clear stuck dust + isFullyClaimed ? details.requests[controller] : batchAssets ); - - details.requests[controller] -= batchAssets; // batchAssets <= details.requests[controller] (via .min cap above) - details.totalAssets -= batchAssets; // batchAssets <= details.totalAssets (invariant: requests[c] <= totalAssets) - details.totalShares -= batchShares; // batchShares <= requested <= details.totalShares (since requests[c] <= totalAssets => ceil(r*S/A) <= S) + details.totalAssets = details.totalAssets.saturatingSub(batchAssets); + details.totalShares = details.totalShares.saturatingSub(batchShares); shares -= batchShares; // batchShares <= shares (via .min) assets += batchAssets; } diff --git a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol index 14c0badd..a824b721 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol @@ -28,11 +28,14 @@ import {ERC7540} from "./ERC7540.sol"; * {_requestQueueLimit} 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: Distribution within an epoch is exact. The sum of assets paid across all calls to - * {_consumeClaimableWithdraw} and {_consumeClaimableRedeem} equals the fulfilled `totalAssets`. - * Each claim is floor-rounded against the remaining `totalShares` and `totalAssets`, so any - * sub-unit residue accumulates and is absorbed by the final claim in the epoch rather than - * left stranded in the contract. + * 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; @@ -74,13 +77,18 @@ abstract contract ERC7540EpochRedeem is ERC7540 { return block.timestamp / 1 weeks; } - /// @dev A request is pending if its epoch has not yet been fulfilled (`totalAssets == 0`). + /** + * @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) { EpochRedeemMetadata storage details = _epochs[requestId]; - return details.totalAssets == 0 ? details.requests[controller] : 0; + // `details.totalShares > 0` distinguishes the pending state from a fully-claimed + // post-fulfillment state where both totals reach 0. + return (details.totalAssets == 0 && details.totalShares > 0) ? details.requests[controller] : 0; } /// @dev A request is claimable if its epoch has been fulfilled (`totalAssets > 0`). @@ -105,10 +113,16 @@ abstract contract ERC7540EpochRedeem is ERC7540 { uint256 result = 0; for (uint256 i = 0; i < _memberOf[owner].length(); ++i) { uint256 epochId = uint256(_memberOf[owner].at(i)); + uint256 totalShares = _epochs[epochId].totalShares; + // 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. Skip such epochs. + if (totalShares == 0) continue; result += Math.mulDiv( _claimableRedeemRequest(epochId, owner), _epochs[epochId].totalAssets, - _epochs[epochId].totalShares, + totalShares, Math.Rounding.Floor ); } @@ -202,24 +216,30 @@ abstract contract ERC7540EpochRedeem is ERC7540 { EpochRedeemMetadata storage details = _epochs[epochId]; - uint256 requested = details.requests[controller].mulDiv( - details.totalAssets, - details.totalShares, - Math.Rounding.Ceil - ); + // `totalShares == 0` indicates a fully-claimed epoch. Treating `requested` as 0 lets the + // iteration pop the queue entry without consuming user input or attempting a + // divide-by-zero. See {_consumeClaimableRedeem} for the dust handling rationale. + bool isFullyClaimed = details.totalShares == 0; + uint256 requested = isFullyClaimed + ? 0 + : details.requests[controller].mulDiv(details.totalAssets, details.totalShares, Math.Rounding.Ceil); if (requested <= assets) _memberOf[controller].popFront(); uint256 batchAssets = requested.min(assets); - // When `requested <= assets` we pop the controller's epoch entry and drain their full claim. - // Computing batchShares from batchAssets uses floor while `requested` was ceiled, so it can - // land up to 1 wei above the stored request. Cap to `details.requests[controller]`. - uint256 batchShares = batchAssets.mulDiv(details.totalShares, details.totalAssets, Math.Rounding.Floor).min( - details.requests[controller] + // `requested` is ceil-rounded so batchShares recomputed via floor can exceed the + // stored request by 1 wei. Saturating subtraction absorbs the excess into the shared + // totals, so `totalShares` reduces to 0 cleanly when fully claimed and the + // {_fulfillRedeem} sentinel stays unambiguous. + uint256 batchShares = details.totalAssets == 0 + ? 0 + : batchAssets.mulDiv(details.totalShares, details.totalAssets, Math.Rounding.Floor); + + details.requests[controller] = details.requests[controller].saturatingSub( + // If fully claimed, subtract the full stored slot to clear stuck dust + isFullyClaimed ? details.requests[controller] : batchShares ); - - details.requests[controller] -= batchShares; // batchShares <= details.requests[controller] (via .min cap above) - details.totalAssets -= batchAssets; // batchAssets <= requested <= details.totalAssets (since requests[c] <= totalShares => ceil(r*A/S) <= A) - details.totalShares -= batchShares; // batchShares <= details.totalShares (invariant: requests[c] <= totalShares) + details.totalAssets = details.totalAssets.saturatingSub(batchAssets); + details.totalShares = details.totalShares.saturatingSub(batchShares); assets -= batchAssets; // batchAssets <= assets (via .min) shares += batchShares; } @@ -236,13 +256,22 @@ abstract contract ERC7540EpochRedeem is ERC7540 { EpochRedeemMetadata storage details = _epochs[epochId]; - uint256 requested = details.requests[controller]; + // `totalShares == 0` indicates a fully-claimed epoch (over-claimed by other controllers via + // the asset-driven path). Treating `requested` as 0 lets the loop pop the queue entry + // and skip the divide-by-zero without consuming user input. + bool isFullyClaimed = details.totalShares == 0; + uint256 requested = isFullyClaimed ? 0 : details.requests[controller]; if (requested <= shares) _memberOf[controller].popFront(); uint256 batchShares = requested.min(shares); - uint256 batchAssets = batchShares.mulDiv(details.totalAssets, details.totalShares, Math.Rounding.Floor); + uint256 batchAssets = isFullyClaimed + ? 0 + : batchShares.mulDiv(details.totalAssets, details.totalShares, Math.Rounding.Floor); - details.requests[controller] -= batchShares; // batchShares <= requested == details.requests[controller] + details.requests[controller] = details.requests[controller].saturatingSub( + // If fully claimed, subtract the full stored slot to clear stuck dust + isFullyClaimed ? details.requests[controller] : batchShares + ); 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) From e512444d3c0ece73a94bd3c5ee42fdda13d9cc52 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Fri, 15 May 2026 13:28:30 -0600 Subject: [PATCH 46/60] nits --- contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol | 3 ++- contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol index 89afeefd..3b7b423b 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol @@ -16,6 +16,7 @@ import {ERC7540} from "./ERC7540.sol"; * * Production equivalents: * https://github.com/Storm-Labs-Inc/cove-contracts-core/blob/master/src/BasketToken.sol[Cove], + * https://github.com/nashpoint/nashpoint-smart-contracts/blob/main/src/Node.sol[Nashpoint], * https://github.com/AmphorProtocol/asynchronous-vault/tree/main[Amphor], * https://github.com/hopperlabsxyz/lagoon-v0/blob/main/src/v0.5.0/ERC7540.sol[Lagoon]. * @@ -78,7 +79,7 @@ abstract contract ERC7540EpochDeposit is ERC7540 { /** * @dev A request is pending if its epoch has not yet been fulfilled (`totalShares == 0`) and - * still has assets queued (`totalAssets > 0`) + * still has assets queued (`totalAssets > 0`). */ function _pendingDepositRequest( uint256 requestId, diff --git a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol index a824b721..3798fc9b 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol @@ -105,7 +105,7 @@ abstract contract ERC7540EpochRedeem is ERC7540 { * * NOTE: This function iterates over the `owner`'s epoch queue, which is O(n) in the number of * epochs the owner participates in. This is bounded by {_requestQueueLimit} (default 32) and is - * per-account — an attacker creating many small requests can only inflate their own queue, not + * per-account; 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). */ From e7519a4c3bbf9100b3e10b9aab0b3e5ff0c6118d Mon Sep 17 00:00:00 2001 From: ernestognw Date: Fri, 15 May 2026 14:14:37 -0600 Subject: [PATCH 47/60] Refactor + add getters --- .../ERC20/extensions/ERC7540EpochDeposit.sol | 145 ++++++++++-------- .../ERC20/extensions/ERC7540EpochRedeem.sol | 145 ++++++++++-------- 2 files changed, 162 insertions(+), 128 deletions(-) diff --git a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol index 3b7b423b..7a15183c 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol @@ -77,6 +77,24 @@ abstract contract ERC7540EpochDeposit is ERC7540 { return block.timestamp / 1 weeks; } + /** + * @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 A request is pending if its epoch has not yet been fulfilled (`totalShares == 0`) and * still has assets queued (`totalAssets > 0`). @@ -85,10 +103,18 @@ abstract contract ERC7540EpochDeposit is ERC7540 { uint256 requestId, address controller ) internal view virtual override returns (uint256) { - EpochDepositMetadata storage details = _epochs[requestId]; - // `details.totalAssets > 0` distinguishes the pending state from a fully-claimed - // post-fulfillment state where both totals reach 0. - return (details.totalShares == 0 && details.totalAssets > 0) ? details.requests[controller] : 0; + 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`). @@ -96,8 +122,7 @@ abstract contract ERC7540EpochDeposit is ERC7540 { uint256 requestId, address controller ) internal view virtual override returns (uint256) { - EpochDepositMetadata storage details = _epochs[requestId]; - return details.totalShares == 0 ? 0 : details.requests[controller]; + return totalDepositShares(requestId) == 0 ? 0 : _epochs[requestId].requests[controller]; } /** @@ -123,22 +148,39 @@ abstract contract ERC7540EpochDeposit is ERC7540 { uint256 result = 0; for (uint256 i = 0; i < _memberOf[owner].length(); ++i) { uint256 epochId = uint256(_memberOf[owner].at(i)); - uint256 totalAssets = _epochs[epochId].totalAssets; - // 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. Skip such epochs. - if (totalAssets == 0) continue; - result += Math.mulDiv( - _claimableDepositRequest(epochId, owner), - _epochs[epochId].totalShares, - totalAssets, - Math.Rounding.Floor - ); + 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. @@ -170,11 +212,6 @@ abstract contract ERC7540EpochDeposit is ERC7540 { return super._requestDeposit(assets, controller, owner, epochId); } - /// @dev Returns the total assets available to fulfill for `epochId`, or 0 if already fulfilled or still current. - function _assetsToFulfillDeposit(uint256 epochId) internal view virtual returns (uint256) { - return epochId < currentDepositEpoch() && _epochs[epochId].totalShares == 0 ? _epochs[epochId].totalAssets : 0; - } - /** * @dev Fulfills a past epoch by setting its `totalShares`. All requests within the epoch * become claimable at the rate `totalShares / totalAssets`. @@ -195,12 +232,12 @@ abstract contract ERC7540EpochDeposit is ERC7540 { function _fulfillDeposit(uint256 epochId, uint256 totalShares) internal virtual { require(epochId < currentDepositEpoch(), ERC7540EpochDepositTooEarly(epochId)); - EpochDepositMetadata storage details = _epochs[epochId]; - require(details.totalAssets > 0, ERC7540EpochDepositEmptyEpoch(epochId)); - require(details.totalShares == 0, ERC7540EpochDepositAlreadyFulfilled(epochId)); + uint256 totalAssets = totalDepositAssets(epochId); + require(totalAssets > 0, ERC7540EpochDepositEmptyEpoch(epochId)); + require(totalDepositShares(epochId) == 0, ERC7540EpochDepositAlreadyFulfilled(epochId)); - details.totalShares = totalShares; - emit ERC7540EpochDepositFulfilled(epochId, details.totalAssets, totalShares); + _epochs[epochId].totalShares = totalShares; + emit ERC7540EpochDepositFulfilled(epochId, totalAssets, totalShares); } /** @@ -214,24 +251,15 @@ abstract contract ERC7540EpochDeposit is ERC7540 { while (assets > 0) { uint256 epochId = uint256(_memberOf[controller].front()); - EpochDepositMetadata storage details = _epochs[epochId]; - - // `totalAssets == 0` indicates a fully-claimed epoch (over-claimed by other controllers via - // the share-driven path). Treating `requested` as 0 lets the loop pop the queue entry - // and skip the divide-by-zero without consuming user input. - bool isFullyClaimed = details.totalAssets == 0; - uint256 requested = isFullyClaimed ? 0 : details.requests[controller]; + uint256 requested = _pendingAvailableDepositRequest(epochId, controller); if (requested <= assets) _memberOf[controller].popFront(); uint256 batchAssets = requested.min(assets); - uint256 batchShares = isFullyClaimed - ? 0 - : batchAssets.mulDiv(details.totalShares, details.totalAssets, Math.Rounding.Floor); - - details.requests[controller] = details.requests[controller].saturatingSub( - // If fully claimed, subtract the full stored slot to clear stuck dust - isFullyClaimed ? details.requests[controller] : batchAssets - ); + uint256 batchShares = _convertToDepositShares(epochId, batchAssets, Math.Rounding.Floor); + + EpochDepositMetadata storage details = _epochs[epochId]; + // Using `requested` (zero in the fully-claimed state) clears stuck dust. + details.requests[controller] = requested.saturatingSub(batchAssets); 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) @@ -248,30 +276,19 @@ abstract contract ERC7540EpochDeposit is ERC7540 { while (shares > 0) { uint256 epochId = uint256(_memberOf[controller].front()); - EpochDepositMetadata storage details = _epochs[epochId]; - - // `totalAssets == 0` indicates a fully-claimed epoch. Treating `requested` as 0 lets the - // iteration pop the queue entry without consuming user input or attempting a - // divide-by-zero. See {_consumeClaimableDeposit} for the dust handling rationale. - bool isFullyClaimed = details.totalAssets == 0; - uint256 requested = isFullyClaimed - ? 0 - : details.requests[controller].mulDiv(details.totalShares, details.totalAssets, Math.Rounding.Ceil); + uint256 requestedAssets = _pendingAvailableDepositRequest(epochId, controller); + uint256 requested = _convertToDepositShares(epochId, requestedAssets, Math.Rounding.Ceil); if (requested <= shares) _memberOf[controller].popFront(); uint256 batchShares = requested.min(shares); - // `requested` is ceil-rounded so batchAssets recomputed via floor can exceed the - // stored request by 1 wei. Saturating subtraction absorbs the excess into the shared - // totals, so `totalAssets` reduces to 0 cleanly when fully claimed and the - // {_fulfillDeposit} sentinel stays unambiguous. - uint256 batchAssets = details.totalShares == 0 - ? 0 - : batchShares.mulDiv(details.totalAssets, details.totalShares, Math.Rounding.Floor); - - details.requests[controller] = details.requests[controller].saturatingSub( - // If fully claimed, subtract the full stored slot to clear stuck dust - isFullyClaimed ? details.requests[controller] : batchAssets - ); + uint256 batchAssets = _convertToDepositAssets(epochId, batchShares, Math.Rounding.Floor); + + EpochDepositMetadata storage details = _epochs[epochId]; + // Using `requestedAssets` (zero in the fully-claimed state) clears stuck dust + // The saturation absorbs the 1-wei ceil/floor excess in `batchAssets` when fully + // claimed, so `totalAssets` reduces to 0 cleanly and the {_fulfillDeposit} sentinel + // stays unambiguous. + details.requests[controller] = requestedAssets.saturatingSub(batchAssets); details.totalAssets = details.totalAssets.saturatingSub(batchAssets); details.totalShares = details.totalShares.saturatingSub(batchShares); shares -= batchShares; // batchShares <= shares (via .min) diff --git a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol index 3798fc9b..66f978d1 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol @@ -77,6 +77,24 @@ abstract contract ERC7540EpochRedeem is ERC7540 { return block.timestamp / 1 weeks; } + /** + * @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 A request is pending if its epoch has not yet been fulfilled (`totalAssets == 0`) and * still has shares queued (`totalShares > 0`). @@ -85,10 +103,18 @@ abstract contract ERC7540EpochRedeem is ERC7540 { uint256 requestId, address controller ) internal view virtual override returns (uint256) { - EpochRedeemMetadata storage details = _epochs[requestId]; - // `details.totalShares > 0` distinguishes the pending state from a fully-claimed - // post-fulfillment state where both totals reach 0. - return (details.totalAssets == 0 && details.totalShares > 0) ? details.requests[controller] : 0; + 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`). @@ -96,8 +122,7 @@ abstract contract ERC7540EpochRedeem is ERC7540 { uint256 requestId, address controller ) internal view virtual override returns (uint256) { - EpochRedeemMetadata storage details = _epochs[requestId]; - return details.totalAssets == 0 ? 0 : details.requests[controller]; + return totalRedeemAssets(requestId) == 0 ? 0 : _epochs[requestId].requests[controller]; } /** @@ -113,18 +138,7 @@ abstract contract ERC7540EpochRedeem is ERC7540 { uint256 result = 0; for (uint256 i = 0; i < _memberOf[owner].length(); ++i) { uint256 epochId = uint256(_memberOf[owner].at(i)); - uint256 totalShares = _epochs[epochId].totalShares; - // 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. Skip such epochs. - if (totalShares == 0) continue; - result += Math.mulDiv( - _claimableRedeemRequest(epochId, owner), - _epochs[epochId].totalAssets, - totalShares, - Math.Rounding.Floor - ); + result += _convertToRedeemAssets(epochId, _claimableRedeemRequest(epochId, owner), Math.Rounding.Floor); } return result; } @@ -139,6 +153,34 @@ abstract contract ERC7540EpochRedeem is ERC7540 { 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. @@ -170,11 +212,6 @@ abstract contract ERC7540EpochRedeem is ERC7540 { return super._requestRedeem(shares, controller, owner, epochId); } - /// @dev Returns the total shares available to fulfill for `epochId`, or 0 if already fulfilled or still current. - function _sharesToFulfillRedeem(uint256 epochId) internal view virtual returns (uint256) { - return epochId < currentRedeemEpoch() && _epochs[epochId].totalAssets == 0 ? _epochs[epochId].totalShares : 0; - } - /** * @dev Fulfills a past epoch by setting its `totalAssets`. All requests within the epoch * become claimable at the rate `totalAssets / totalShares`. @@ -195,12 +232,12 @@ abstract contract ERC7540EpochRedeem is ERC7540 { function _fulfillRedeem(uint256 epochId, uint256 totalAssets) internal virtual { require(epochId < currentRedeemEpoch(), ERC7540EpochRedeemTooEarly(epochId)); - EpochRedeemMetadata storage details = _epochs[epochId]; - require(details.totalShares > 0, ERC7540EpochRedeemEmptyEpoch(epochId)); - require(details.totalAssets == 0, ERC7540EpochRedeemAlreadyFulfilled(epochId)); + uint256 totalShares = totalRedeemShares(epochId); + require(totalShares > 0, ERC7540EpochRedeemEmptyEpoch(epochId)); + require(totalRedeemAssets(epochId) == 0, ERC7540EpochRedeemAlreadyFulfilled(epochId)); - details.totalAssets = totalAssets; - emit ERC7540EpochRedeemFulfilled(epochId, details.totalShares, totalAssets); + _epochs[epochId].totalAssets = totalAssets; + emit ERC7540EpochRedeemFulfilled(epochId, totalShares, totalAssets); } /** @@ -214,30 +251,19 @@ abstract contract ERC7540EpochRedeem is ERC7540 { while (assets > 0) { uint256 epochId = uint256(_memberOf[controller].front()); - EpochRedeemMetadata storage details = _epochs[epochId]; - - // `totalShares == 0` indicates a fully-claimed epoch. Treating `requested` as 0 lets the - // iteration pop the queue entry without consuming user input or attempting a - // divide-by-zero. See {_consumeClaimableRedeem} for the dust handling rationale. - bool isFullyClaimed = details.totalShares == 0; - uint256 requested = isFullyClaimed - ? 0 - : details.requests[controller].mulDiv(details.totalAssets, details.totalShares, Math.Rounding.Ceil); + uint256 requestedShares = _pendingAvailableRedeemRequest(epochId, controller); + uint256 requested = _convertToRedeemAssets(epochId, requestedShares, Math.Rounding.Ceil); if (requested <= assets) _memberOf[controller].popFront(); uint256 batchAssets = requested.min(assets); - // `requested` is ceil-rounded so batchShares recomputed via floor can exceed the - // stored request by 1 wei. Saturating subtraction absorbs the excess into the shared - // totals, so `totalShares` reduces to 0 cleanly when fully claimed and the - // {_fulfillRedeem} sentinel stays unambiguous. - uint256 batchShares = details.totalAssets == 0 - ? 0 - : batchAssets.mulDiv(details.totalShares, details.totalAssets, Math.Rounding.Floor); - - details.requests[controller] = details.requests[controller].saturatingSub( - // If fully claimed, subtract the full stored slot to clear stuck dust - isFullyClaimed ? details.requests[controller] : batchShares - ); + uint256 batchShares = _convertToRedeemShares(epochId, batchAssets, Math.Rounding.Floor); + + EpochRedeemMetadata storage details = _epochs[epochId]; + // Using `requestedShares` (zero in the fully-claimed state) clears stuck dust. + // The saturation absorbs the 1-wei ceil/floor excess in `batchShares` when fully + // claimed, so `totalShares` reduces to 0 cleanly and the {_fulfillRedeem} sentinel + // stays unambiguous. + details.requests[controller] = requestedShares.saturatingSub(batchShares); details.totalAssets = details.totalAssets.saturatingSub(batchAssets); details.totalShares = details.totalShares.saturatingSub(batchShares); assets -= batchAssets; // batchAssets <= assets (via .min) @@ -254,24 +280,15 @@ abstract contract ERC7540EpochRedeem is ERC7540 { while (shares > 0) { uint256 epochId = uint256(_memberOf[controller].front()); - EpochRedeemMetadata storage details = _epochs[epochId]; - - // `totalShares == 0` indicates a fully-claimed epoch (over-claimed by other controllers via - // the asset-driven path). Treating `requested` as 0 lets the loop pop the queue entry - // and skip the divide-by-zero without consuming user input. - bool isFullyClaimed = details.totalShares == 0; - uint256 requested = isFullyClaimed ? 0 : details.requests[controller]; + uint256 requested = _pendingAvailableRedeemRequest(epochId, controller); if (requested <= shares) _memberOf[controller].popFront(); uint256 batchShares = requested.min(shares); - uint256 batchAssets = isFullyClaimed - ? 0 - : batchShares.mulDiv(details.totalAssets, details.totalShares, Math.Rounding.Floor); - - details.requests[controller] = details.requests[controller].saturatingSub( - // If fully claimed, subtract the full stored slot to clear stuck dust - isFullyClaimed ? details.requests[controller] : batchShares - ); + uint256 batchAssets = _convertToRedeemAssets(epochId, batchShares, Math.Rounding.Floor); + + EpochRedeemMetadata storage details = _epochs[epochId]; + // Using `requested` (zero in the fully-claimed state) clears stuck dust. + details.requests[controller] = requested.saturatingSub(batchShares); 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) From 724c3024c4ddd0fd08a040d3031a276e73ae5008 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Fri, 15 May 2026 14:25:33 -0600 Subject: [PATCH 48/60] RemoveNashpoint from examples --- contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol | 1 - contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol | 1 - 2 files changed, 2 deletions(-) diff --git a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol index 7a15183c..8b1d0f02 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol @@ -16,7 +16,6 @@ import {ERC7540} from "./ERC7540.sol"; * * Production equivalents: * https://github.com/Storm-Labs-Inc/cove-contracts-core/blob/master/src/BasketToken.sol[Cove], - * https://github.com/nashpoint/nashpoint-smart-contracts/blob/main/src/Node.sol[Nashpoint], * https://github.com/AmphorProtocol/asynchronous-vault/tree/main[Amphor], * https://github.com/hopperlabsxyz/lagoon-v0/blob/main/src/v0.5.0/ERC7540.sol[Lagoon]. * diff --git a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol index 66f978d1..d81409e0 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol @@ -16,7 +16,6 @@ import {ERC7540} from "./ERC7540.sol"; * * Production equivalents: * https://github.com/Storm-Labs-Inc/cove-contracts-core/blob/master/src/BasketToken.sol[Cove], - * https://github.com/nashpoint/nashpoint-smart-contracts/blob/main/src/Node.sol[Nashpoint], * https://github.com/AmphorProtocol/asynchronous-vault/tree/main[Amphor], * https://github.com/hopperlabsxyz/lagoon-v0/blob/main/src/v0.5.0/ERC7540.sol[Lagoon]. * From cfe5081f444c87c11645fdd8f68f910d228138f4 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Fri, 15 May 2026 14:46:28 -0600 Subject: [PATCH 49/60] Add tests --- .../extensions/ERC7540EpochDeposit.test.js | 435 ++++++++++++++++++ .../extensions/ERC7540EpochRedeem.test.js | 410 +++++++++++++++++ 2 files changed, 845 insertions(+) create mode 100644 test/token/ERC20/extensions/ERC7540EpochDeposit.test.js create mode 100644 test/token/ERC20/extensions/ERC7540EpochRedeem.test.js diff --git a/test/token/ERC20/extensions/ERC7540EpochDeposit.test.js b/test/token/ERC20/extensions/ERC7540EpochDeposit.test.js new file mode 100644 index 00000000..348f39a4 --- /dev/null +++ b/test/token/ERC20/extensions/ERC7540EpochDeposit.test.js @@ -0,0 +1,435 @@ +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. +async function advancePast(epochId) { + const now = await time.clock.timestamp(); + const target = (BigInt(epochId) + 1n) * 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); + + // 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`', async function () { + const now = await time.clock.timestamp(); + await expect(this.mock.currentDepositEpoch()).to.eventually.equal(now / week); + }); + }); + + shouldBehaveLikeERC7540Operator(); + shouldBehaveLikeERC7540Deposit({ supportCustomFulfill: false }); + 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; + // 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 + 1n) * 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('queue limit', function () { + it('enforces `_requestQueueLimit` 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 * week); + } + + // The 33rd distinct epoch should revert (bare require — no custom error) + await expect(this.mock.connect(user).requestDeposit(1n, user, user)).to.be.reverted; + }); + + 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); + }); + }); + + 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('saturating sub absorbs ceil/floor excess; drained-state dust is hidden from views', async function () { + // Pathological tiny-totals scenario to trigger Case A overshoot in the share-driven + // path. Uses the internal `$_consumeClaimableMint` to bypass the public `maxMint` + // guard so we can force the rounding excess that saturating sub is designed to absorb. + // + // Setup: r_alice=2, r_bob=3, totalAssets=5. Fulfill S=3. + // Alice Case A: requested=ceil(2*3/5)=2, batchAssets uncapped=floor(2*5/3)=3 (overshoot by 1) + // Sat-sub: r_alice 2->0, totalAssets 5->2, totalShares 3->1. + // Bob Case B: requested=ceil(3*1/2)=2 > shares=1, batchAssets=floor(1*2/1)=2. + // Sat-sub: r_bob saturates 3->1 (dust), totalAssets 2->0, totalShares 1->0 (drained). + 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 full claim absorbs the overshoot + await this.mock.$_consumeClaimableMint(2n, alice); + await expect(this.mock.totalDepositAssets(epochId)).to.eventually.equal(2n); + await expect(this.mock.totalDepositShares(epochId)).to.eventually.equal(1n); + + // Bob's partial claim drains the pool and leaves dust in `requests[bob]` + 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); + + // The 1-wei dust in bob's slot is invisible through every 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..3c32e63e --- /dev/null +++ b/test/token/ERC20/extensions/ERC7540EpochRedeem.test.js @@ -0,0 +1,410 @@ +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. +async function advancePast(epochId) { + const now = await time.clock.timestamp(); + const target = (BigInt(epochId) + 1n) * 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); + + 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`', async function () { + const now = await time.clock.timestamp(); + await expect(this.mock.currentRedeemEpoch()).to.eventually.equal(now / week); + }); + }); + + shouldBehaveLikeERC7540Operator(); + shouldBehaveLikeERC7540Redeem({ supportCustomFulfill: false }); + 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; + 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 + 1n) * 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('queue limit', function () { + it('enforces `_requestQueueLimit` 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 * week); + } + + await expect(this.mock.connect(user).requestRedeem(1n, user, user)).to.be.reverted; + }); + + 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('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('saturating sub absorbs ceil/floor excess; drained-state dust is hidden from views', async function () { + // Pathological tiny-totals scenario to trigger Case A overshoot in the asset-driven + // path. Uses the internal `$_consumeClaimableWithdraw` to bypass the public + // `maxWithdraw` guard so we can force the rounding excess that saturating sub absorbs. + // + // Setup: r_alice=2, r_bob=3, totalShares=5. Fulfill totalAssets=3. + // Alice Case A (assets=2): requested=ceil(2*3/5)=2, batchShares uncapped=floor(2*5/3)=3 (overshoot by 1) + // Sat-sub: r_alice 2->0, totalShares 5->2, totalAssets 3->1. + // Bob Case B (assets=1): requested=ceil(3*1/2)=2 > assets=1, batchShares=floor(1*2/1)=2. + // Sat-sub: r_bob saturates 3->1 (dust), totalShares 2->0, totalAssets 1->0 (drained). + 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); + + await this.mock.$_consumeClaimableWithdraw(2n, alice); + await expect(this.mock.totalRedeemShares(epochId)).to.eventually.equal(2n); + await expect(this.mock.totalRedeemAssets(epochId)).to.eventually.equal(1n); + + 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); + + // The 1-wei dust in bob's slot is invisible through every 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); + }); + }); +}); From a2e4a551e93a71dbf8e33e353949e26597481936 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Fri, 15 May 2026 14:51:51 -0600 Subject: [PATCH 50/60] Docs --- CHANGELOG.md | 4 + contracts/token/README.adoc | 6 ++ docs/modules/ROOT/pages/erc7540.adoc | 120 ++++++++++++++++++++++++++- 3 files changed, 129 insertions(+), 1 deletion(-) 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/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 88bd0584..bad21834 100644 --- a/docs/modules/ROOT/pages/erc7540.adoc +++ b/docs/modules/ROOT/pages/erc7540.adoc @@ -111,6 +111,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. @@ -294,6 +306,112 @@ 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]. + +=== Epoch identification + +By default `currentDepositEpoch()` and `currentRedeemEpoch()` return `block.timestamp / 1 weeks`. 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 `_requestQueueLimit()` 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. + +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. The contract uses saturating subtraction to absorb the excess into the shared totals so they drain to 0 cleanly when fully claimed — 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); + } + + function _requestQueueLimit() + internal view override(ERC7540EpochDeposit, ERC7540EpochRedeem) returns (uint256) + { + return super._requestQueueLimit(); + } +} +---- + +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 @@ -455,7 +573,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. (For the batched-epoch pattern, see the <> section above.) [[security]] == Security considerations From ada0745cd65bcfccb8e0977cfd1858094679d776 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Fri, 15 May 2026 16:13:08 -0600 Subject: [PATCH 51/60] Update docs --- docs/modules/ROOT/pages/erc7540.adoc | 102 ++++++++++++--------------- 1 file changed, 45 insertions(+), 57 deletions(-) diff --git a/docs/modules/ROOT/pages/erc7540.adoc b/docs/modules/ROOT/pages/erc7540.adoc index bad21834..141fdba1 100644 --- a/docs/modules/ROOT/pages/erc7540.adoc +++ b/docs/modules/ROOT/pages/erc7540.adoc @@ -39,16 +39,12 @@ NOTE: An ERC-7540 vault must have at least one async side (deposit or redeem). I Every async request transitions through three states: -.... - requestDeposit() Fulfillment deposit() / mint() - requestRedeem() withdraw() / redeem() - ┌─────────┐ ┌─────────┐ ┌───────────┐ ┌─────────┐ - │ (none) │ ──────────────► │ Pending │ ──────────► │ Claimable │ ───────────────► │ Claimed │ - └─────────┘ └─────────┘ └───────────┘ └─────────┘ - Assets/shares Ready to claim Shares/assets - locked in vault delivered to - receiver -.... +```mermaid +flowchart LR + None(["(none)"]) -->|"requestDeposit() / requestRedeem()"| Pending["Pending
Assets/shares locked in vault"] + Pending -->|Fulfillment| Claimable["Claimable
Ready to claim"] + Claimable -->|"deposit() / mint() / withdraw() / redeem()"| Claimed["Claimed
Shares/assets delivered to receiver"] +``` * **Pending**: Assets (for deposits) or shares (for redeems) are locked in the vault. The request is not yet ready to be claimed. * **Claimable**: The request has been fulfilled and the controller can claim at any time. The exchange rate is strategy-dependent: it may be fixed at fulfillment time (e.g. admin strategy) or determined at claim time from the live vault conversion (e.g. delay strategy). @@ -60,27 +56,14 @@ CAUTION: Requests must NOT skip the Claimable state, even if fulfillment happens The implementation is split into a base contract and strategy extensions: -.... - ┌──────────────────────────────────────────────────────────┐ - │ ERC7540 (base) │ - │ │ - │ Routing logic (sync vs async) │ - │ Operator management │ - │ ERC-4626 interface │ - │ totalAssets / totalSupply adjustments │ - │ 14 virtual hooks for strategies to implement │ - └────────────────────────┬─────────────────────────────────┘ - │ - ┌────────────────────────┼─────────────────────────────────┐ - │ │ │ - ┌─────────▼──────────┐ ┌──────────▼──────────┐ ┌──────────────────▼───┐ - │ Admin strategy │ │ Delay strategy │ │ Sync strategy │ - │ │ │ │ │ │ - │ Privileged caller │ │ Time-based, no │ │ Standard ERC-4626 │ - │ fulfills with │ │ privileged caller │ │ (no async lifecycle)│ - │ explicit rate │ │ needed │ │ │ - └────────────────────┘ └─────────────────────┘ └──────────────────────┘ -.... +```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
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)"] +``` Each strategy comes in a deposit and redeem variant. You combine exactly one deposit strategy with one redeem strategy: @@ -238,17 +221,12 @@ The `requestId` returned by the delay strategy is the absolute timestamp at whic The delay strategy uses `Checkpoints.Trace208` to track cumulative deposit/redeem amounts keyed by their maturity timepoint. Multiple requests accumulate and mature independently: -.... - Time ──────────────────────────────────────────────────────► - - t=100 t=120 t=160 t=180 - request 500 request 300 500 claimable 800 claimable - maturity=160 maturity=180 300 still pending (all matured) - - Checkpoints: - key=160 → value=500 (cumulative) - key=180 → value=800 (cumulative) -.... +```mermaid +flowchart LR + T100["t=100
request 500
maturity=160"] --> T120["t=120
request 300
maturity=180"] --> T160["t=160
500 claimable
300 still pending"] --> T180["t=180
800 claimable
(all matured)"] + CP["Checkpoints (cumulative)
key=160 → value=500
key=180 → value=800"] + T120 -.-> CP +``` The total claimable amount at any time `T` is: @@ -313,6 +291,17 @@ The epoch strategy batches all requests submitted in the same time window — an 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`. 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. @@ -524,26 +513,23 @@ During the async lifecycle, shares and assets must be held somewhere between req The base `ERC7540` contract overrides both to keep the share price accurate during the async lifecycle: -.... -totalAssets() = asset.balanceOf(vault) - _totalPendingDepositAssets - │ │ - │ └─ Assets received but not yet converted to shares. - │ Must not inflate the perceived yield. - └─ All assets in the vault, including pending ones. - -totalSupply() = ERC20.totalSupply() + _totalPendingRedeemShares - │ │ - │ └─ Shares already burned/escrowed but logically still - │ outstanding (request not yet settled). - └─ The on-chain ERC-20 supply. -.... +```mermaid +flowchart TD + TA["totalAssets() = asset.balanceOf(vault) − _totalPendingDepositAssets"] + TA --> TA1["asset.balanceOf(vault)
All assets in the vault,
including pending ones"] + TA --> TA2["_totalPendingDepositAssets
Assets received but not yet
converted to shares.
Must not inflate the perceived yield."] + + TS["totalSupply() = ERC20.totalSupply() + _totalPendingRedeemShares"] + TS --> TS1["ERC20.totalSupply()
The on-chain ERC-20 supply"] + TS --> TS2["_totalPendingRedeemShares
Shares already burned/escrowed
but logically still outstanding
(request not yet settled)"] +``` This ensures `convertToShares` / `convertToAssets` reflect the real exchange rate at all times, even while requests are in flight. [[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: @@ -573,7 +559,7 @@ Each async side (deposit or redeem) requires 7 hooks: | Return the maximum claimable amount (in shares) for `mint()` / `redeem()` |=== -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. (For the batched-epoch pattern, see the <> section above.) +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 @@ -593,3 +579,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 (`_requestQueueLimit`, 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. From e32361faa1340d2a6538908ed9f54b2b2ac395d1 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Fri, 15 May 2026 16:18:05 -0600 Subject: [PATCH 52/60] up --- contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol index 8b1d0f02..a018cad1 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol @@ -288,8 +288,8 @@ abstract contract ERC7540EpochDeposit is ERC7540 { // claimed, so `totalAssets` reduces to 0 cleanly and the {_fulfillDeposit} sentinel // stays unambiguous. details.requests[controller] = requestedAssets.saturatingSub(batchAssets); - details.totalAssets = details.totalAssets.saturatingSub(batchAssets); - details.totalShares = details.totalShares.saturatingSub(batchShares); + details.totalAssets = totalDepositAssets(epochId).saturatingSub(batchAssets); + details.totalShares = totalDepositShares(epochId).saturatingSub(batchShares); shares -= batchShares; // batchShares <= shares (via .min) assets += batchAssets; } From 5c57cbfce35c5313906ba206de9400081a39d94c Mon Sep 17 00:00:00 2001 From: ernestognw Date: Fri, 15 May 2026 16:20:34 -0600 Subject: [PATCH 53/60] up --- contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol index d81409e0..2e7e5b1e 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol @@ -263,8 +263,8 @@ abstract contract ERC7540EpochRedeem is ERC7540 { // claimed, so `totalShares` reduces to 0 cleanly and the {_fulfillRedeem} sentinel // stays unambiguous. details.requests[controller] = requestedShares.saturatingSub(batchShares); - details.totalAssets = details.totalAssets.saturatingSub(batchAssets); - details.totalShares = details.totalShares.saturatingSub(batchShares); + details.totalAssets = totalRedeemAssets(epochId).saturatingSub(batchAssets); + details.totalShares = totalRedeemShares(epochId).saturatingSub(batchShares); assets -= batchAssets; // batchAssets <= assets (via .min) shares += batchShares; } From 7a532008fa8e6028910134f4926cc14aaedb9468 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 27 May 2026 08:00:31 -0600 Subject: [PATCH 54/60] Add epoch getters --- .../ERC20/extensions/ERC7540EpochDeposit.sol | 17 ++++ .../ERC20/extensions/ERC7540EpochRedeem.sol | 17 ++++ lib/@openzeppelin-contracts | 2 +- .../extensions/ERC7540EpochDeposit.test.js | 80 +++++++++++++++++++ .../extensions/ERC7540EpochRedeem.test.js | 76 ++++++++++++++++++ 5 files changed, 191 insertions(+), 1 deletion(-) diff --git a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol index a018cad1..61b02904 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol @@ -94,6 +94,23 @@ abstract contract ERC7540EpochDeposit is ERC7540 { 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`). diff --git a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol index 2e7e5b1e..7f95b043 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol @@ -94,6 +94,23 @@ abstract contract ERC7540EpochRedeem is ERC7540 { 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`). 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/test/token/ERC20/extensions/ERC7540EpochDeposit.test.js b/test/token/ERC20/extensions/ERC7540EpochDeposit.test.js index 348f39a4..d92a883e 100644 --- a/test/token/ERC20/extensions/ERC7540EpochDeposit.test.js +++ b/test/token/ERC20/extensions/ERC7540EpochDeposit.test.js @@ -317,6 +317,86 @@ describe('ERC7540EpochDeposit', function () { }); }); + 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 + 1n) * week); + await this.mock.connect(user).requestDeposit(100n, user, user); + await time.increaseTo.timestamp((e0 + 2n) * 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 + 1n) * 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 + 1)) * 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 `_requestQueueLimit` per controller', async function () { const [, user] = await ethers.getSigners(); diff --git a/test/token/ERC20/extensions/ERC7540EpochRedeem.test.js b/test/token/ERC20/extensions/ERC7540EpochRedeem.test.js index 3c32e63e..de8f2a47 100644 --- a/test/token/ERC20/extensions/ERC7540EpochRedeem.test.js +++ b/test/token/ERC20/extensions/ERC7540EpochRedeem.test.js @@ -302,6 +302,82 @@ describe('ERC7540EpochRedeem', function () { }); }); + 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 + 1n) * week); + await this.mock.connect(user).requestRedeem(100n, user, user); + await time.increaseTo.timestamp((e0 + 2n) * 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 + 1n) * 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 + 1)) * 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 `_requestQueueLimit` per controller', async function () { const [, user] = await ethers.getSigners(); From 416ee79cfda1e4d3c9cbed010312f90be512800c Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 27 May 2026 08:02:57 -0600 Subject: [PATCH 55/60] up --- package-lock.json | 374 +++++++++++++++++++--------------------------- 1 file changed, 153 insertions(+), 221 deletions(-) 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": { From e09cdb3f3b87e7e7ecc2dfed3a48d315999b0011 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Thu, 16 Jul 2026 16:45:47 -0600 Subject: [PATCH 56/60] Review --- .../ERC20/extensions/ERC7540EpochDeposit.sol | 42 +++++++--- .../ERC20/extensions/ERC7540EpochRedeem.sol | 42 +++++++--- .../extensions/ERC7540EpochDeposit.test.js | 79 ++++++++++++++++++- .../extensions/ERC7540EpochRedeem.test.js | 76 +++++++++++++++++- 4 files changed, 214 insertions(+), 25 deletions(-) diff --git a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol index 61b02904..4cb54c12 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol @@ -66,6 +66,9 @@ abstract contract ERC7540EpochDeposit is ERC7540 { /// @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 {_requestQueueLimit}. + error ERC7540EpochDepositQueueLimitExceeded(address controller); + /// @inheritdoc ERC7540 function _isDepositAsync() internal pure virtual override returns (bool) { return true; @@ -142,28 +145,31 @@ abstract contract ERC7540EpochDeposit is ERC7540 { } /** - * @dev Sums claimable assets across all fulfilled epochs the `owner` participates in. + * @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: This function iterates over the `owner`'s epoch queue, which is O(n) in the number of - * epochs the owner participates in. This is bounded by {_requestQueueLimit} (default 32) and is - * per-account; 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). + * NOTE: O(n) in `owner`'s epochs, bounded by {_requestQueueLimit} (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. + /// @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; @@ -220,7 +226,10 @@ abstract contract ERC7540EpochDeposit is ERC7540 { // 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() < _requestQueueLimit()); + require( + _memberOf[controller].length() < _requestQueueLimit(), + ERC7540EpochDepositQueueLimitExceeded(controller) + ); _memberOf[controller].pushBack(bytes32(epochId)); } @@ -240,6 +249,11 @@ abstract contract ERC7540EpochDeposit is ERC7540 { * long as derived contracts preserve the no-side-effect semantics of this function — if not, * derived contracts should restrict `totalShares != 0`. * + * 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}). @@ -257,15 +271,20 @@ abstract contract ERC7540EpochDeposit is ERC7540 { } /** - * @dev Iterates through the controller's epoch queue front-to-back, consuming assets - * and converting them to shares at each epoch's locked rate. Fully consumed epochs - * are dequeued. + * @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 override to revert when + * the oldest epoch is Pending. */ 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(); @@ -291,6 +310,7 @@ abstract contract ERC7540EpochDeposit is ERC7540 { 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); diff --git a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol index 7f95b043..6f5bb8d9 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol @@ -66,6 +66,9 @@ abstract contract ERC7540EpochRedeem is ERC7540 { /// @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 {_requestQueueLimit}. + error ERC7540EpochRedeemQueueLimitExceeded(address controller); + /// @inheritdoc ERC7540 function _isRedeemAsync() internal pure virtual override returns (bool) { return true; @@ -142,28 +145,31 @@ abstract contract ERC7540EpochRedeem is ERC7540 { } /** - * @dev Sums claimable assets across all fulfilled epochs the `owner` participates in. + * @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: This function iterates over the `owner`'s epoch queue, which is O(n) in the number of - * epochs the owner participates in. This is bounded by {_requestQueueLimit} (default 32) and is - * per-account; 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). + * NOTE: O(n) in `owner`'s epochs, bounded by {_requestQueueLimit} (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. + /// @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; @@ -220,7 +226,10 @@ abstract contract ERC7540EpochRedeem is ERC7540 { // 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() < _requestQueueLimit()); + require( + _memberOf[controller].length() < _requestQueueLimit(), + ERC7540EpochRedeemQueueLimitExceeded(controller) + ); _memberOf[controller].pushBack(bytes32(epochId)); } @@ -240,6 +249,11 @@ abstract contract ERC7540EpochRedeem is ERC7540 { * long as derived contracts preserve the no-side-effect semantics of this function — if not, * derived contracts should restrict `totalAssets != 0`. * + * 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}). @@ -257,15 +271,20 @@ abstract contract ERC7540EpochRedeem is ERC7540 { } /** - * @dev Iterates through the controller's epoch queue front-to-back, consuming assets - * and converting them to shares at each epoch's locked rate. Fully consumed epochs - * are dequeued. + * @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 override to revert + * when the oldest epoch is Pending. */ 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); @@ -295,6 +314,7 @@ abstract contract ERC7540EpochRedeem is ERC7540 { 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(); diff --git a/test/token/ERC20/extensions/ERC7540EpochDeposit.test.js b/test/token/ERC20/extensions/ERC7540EpochDeposit.test.js index d92a883e..10d5f3a4 100644 --- a/test/token/ERC20/extensions/ERC7540EpochDeposit.test.js +++ b/test/token/ERC20/extensions/ERC7540EpochDeposit.test.js @@ -411,8 +411,10 @@ describe('ERC7540EpochDeposit', function () { await time.increaseTo.timestamp(epoch * week); } - // The 33rd distinct epoch should revert (bare require — no custom error) - await expect(this.mock.connect(user).requestDeposit(1n, user, user)).to.be.reverted; + // 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 () { @@ -430,6 +432,79 @@ describe('ERC7540EpochDeposit', function () { }); }); + 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 + 1n) * 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(); diff --git a/test/token/ERC20/extensions/ERC7540EpochRedeem.test.js b/test/token/ERC20/extensions/ERC7540EpochRedeem.test.js index de8f2a47..1d9228a8 100644 --- a/test/token/ERC20/extensions/ERC7540EpochRedeem.test.js +++ b/test/token/ERC20/extensions/ERC7540EpochRedeem.test.js @@ -391,7 +391,9 @@ describe('ERC7540EpochRedeem', function () { await time.increaseTo.timestamp(epoch * week); } - await expect(this.mock.connect(user).requestRedeem(1n, user, user)).to.be.reverted; + await expect(this.mock.connect(user).requestRedeem(1n, user, user)) + .to.be.revertedWithCustomError(this.mock, 'ERC7540EpochRedeemQueueLimitExceeded') + .withArgs(user); }); it('multiple requests in the same epoch share one queue slot', async function () { @@ -407,6 +409,78 @@ describe('ERC7540EpochRedeem', function () { }); }); + 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 + 1n) * 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(); From 76d74444feeb429a9a57589ce1bd7a1545961432 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Mon, 20 Jul 2026 14:08:01 -0600 Subject: [PATCH 57/60] up --- contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol | 2 +- contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol index 4cb54c12..605f7d31 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol @@ -276,7 +276,7 @@ abstract contract ERC7540EpochDeposit is ERC7540 { * 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 override to revert when + * NOTE: Wrappers wanting stricter FIFO semantics should consider overriding to revert when * the oldest epoch is Pending. */ function _consumeClaimableDeposit(uint256 assets, address controller) internal virtual override returns (uint256) { diff --git a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol index 6f5bb8d9..eaec7324 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol @@ -276,8 +276,8 @@ abstract contract ERC7540EpochRedeem is ERC7540 { * 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 override to revert - * when the oldest epoch is Pending. + * NOTE: Wrappers wanting stricter FIFO semantics should consider overriding to revert when + * the oldest epoch is Pending. */ function _consumeClaimableWithdraw(uint256 assets, address controller) internal virtual override returns (uint256) { uint256 shares = 0; From 51eeb0491cc8391d37f838e8bdc8a2b763223143 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Mon, 20 Jul 2026 15:02:29 -0600 Subject: [PATCH 58/60] Auditor3 review --- .../modules/ERC7579MultisigStorage.sol | 15 +++- .../ERC20/extensions/ERC7540EpochDeposit.sol | 81 ++++++++++++------- .../ERC20/extensions/ERC7540EpochRedeem.sol | 81 ++++++++++++------- .../ERC20/extensions/ERC7540.behavior.js | 24 +++++- .../extensions/ERC7540EpochDeposit.test.js | 75 +++++++++++------ .../extensions/ERC7540EpochRedeem.test.js | 71 ++++++++++------ 6 files changed, 239 insertions(+), 108 deletions(-) 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/token/ERC20/extensions/ERC7540EpochDeposit.sol b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol index 605f7d31..35254469 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol @@ -74,9 +74,10 @@ abstract contract ERC7540EpochDeposit is ERC7540 { return true; } - /// @dev Returns the current epoch ID. Defaults to `block.timestamp / 1 weeks`. + /// @dev Returns the current epoch ID. Defaults to `block.timestamp / 1 weeks + 1`. function currentDepositEpoch() public view virtual returns (uint256) { - return block.timestamp / 1 weeks; + // +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; } /** @@ -205,10 +206,13 @@ abstract contract ERC7540EpochDeposit is ERC7540 { /** * @dev Records the request in the current epoch and enqueues the epoch ID for `controller` - * if not already present. + * 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 {_requestQueueLimit}. */ function _requestDeposit( @@ -217,21 +221,24 @@ abstract contract ERC7540EpochDeposit is ERC7540 { address owner, uint256 /* requestId */ ) internal virtual override returns (uint256) { + _checkOperatorOrController(_isDepositAsync(), controller, _msgSender()); uint256 epochId = currentDepositEpoch(); - _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() < _requestQueueLimit(), - ERC7540EpochDepositQueueLimitExceeded(controller) - ); - - _memberOf[controller].pushBack(bytes32(epochId)); + 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() < _requestQueueLimit(), + ERC7540EpochDepositQueueLimitExceeded(controller) + ); + + _memberOf[controller].pushBack(bytes32(epochId)); + } } return super._requestDeposit(assets, controller, owner, epochId); @@ -247,7 +254,9 @@ abstract contract ERC7540EpochDeposit is ERC7540 { * 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`. + * 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 @@ -278,6 +287,12 @@ abstract contract ERC7540EpochDeposit is ERC7540 { * * 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; @@ -293,8 +308,7 @@ abstract contract ERC7540EpochDeposit is ERC7540 { uint256 batchShares = _convertToDepositShares(epochId, batchAssets, Math.Rounding.Floor); EpochDepositMetadata storage details = _epochs[epochId]; - // Using `requested` (zero in the fully-claimed state) clears stuck dust. - details.requests[controller] = requested.saturatingSub(batchAssets); + 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) @@ -304,7 +318,15 @@ abstract contract ERC7540EpochDeposit is ERC7540 { return shares; } - /// @dev Same as {_consumeClaimableDeposit} but iterates by shares instead of assets. + /** + * @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; @@ -317,16 +339,17 @@ abstract contract ERC7540EpochDeposit is ERC7540 { if (requested <= shares) _memberOf[controller].popFront(); uint256 batchShares = requested.min(shares); - uint256 batchAssets = _convertToDepositAssets(epochId, batchShares, Math.Rounding.Floor); + // 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]; - // Using `requestedAssets` (zero in the fully-claimed state) clears stuck dust - // The saturation absorbs the 1-wei ceil/floor excess in `batchAssets` when fully - // claimed, so `totalAssets` reduces to 0 cleanly and the {_fulfillDeposit} sentinel - // stays unambiguous. - details.requests[controller] = requestedAssets.saturatingSub(batchAssets); - details.totalAssets = totalDepositAssets(epochId).saturatingSub(batchAssets); - details.totalShares = totalDepositShares(epochId).saturatingSub(batchShares); + 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; } diff --git a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol index eaec7324..8f0aa7c5 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol @@ -74,9 +74,10 @@ abstract contract ERC7540EpochRedeem is ERC7540 { return true; } - /// @dev Returns the current epoch ID. Defaults to `block.timestamp / 1 weeks`. + /// @dev Returns the current epoch ID. Defaults to `block.timestamp / 1 weeks + 1`. function currentRedeemEpoch() public view virtual returns (uint256) { - return block.timestamp / 1 weeks; + // +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; } /** @@ -205,10 +206,13 @@ abstract contract ERC7540EpochRedeem is ERC7540 { /** * @dev Records the request in the current epoch and enqueues the epoch ID for `controller` - * if not already present. + * 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 {_requestQueueLimit}. */ function _requestRedeem( @@ -217,21 +221,24 @@ abstract contract ERC7540EpochRedeem is ERC7540 { address owner, uint256 /* requestId */ ) internal virtual override returns (uint256) { + _checkOperatorOrController(_isRedeemAsync(), controller, _msgSender()); uint256 epochId = currentRedeemEpoch(); - _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() < _requestQueueLimit(), - ERC7540EpochRedeemQueueLimitExceeded(controller) - ); - - _memberOf[controller].pushBack(bytes32(epochId)); + 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() < _requestQueueLimit(), + ERC7540EpochRedeemQueueLimitExceeded(controller) + ); + + _memberOf[controller].pushBack(bytes32(epochId)); + } } return super._requestRedeem(shares, controller, owner, epochId); @@ -247,7 +254,9 @@ abstract contract ERC7540EpochRedeem is ERC7540 { * 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`. + * 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 @@ -278,6 +287,12 @@ abstract contract ERC7540EpochRedeem is ERC7540 { * * 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; @@ -291,16 +306,17 @@ abstract contract ERC7540EpochRedeem is ERC7540 { if (requested <= assets) _memberOf[controller].popFront(); uint256 batchAssets = requested.min(assets); - uint256 batchShares = _convertToRedeemShares(epochId, batchAssets, Math.Rounding.Floor); + // 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]; - // Using `requestedShares` (zero in the fully-claimed state) clears stuck dust. - // The saturation absorbs the 1-wei ceil/floor excess in `batchShares` when fully - // claimed, so `totalShares` reduces to 0 cleanly and the {_fulfillRedeem} sentinel - // stays unambiguous. - details.requests[controller] = requestedShares.saturatingSub(batchShares); - details.totalAssets = totalRedeemAssets(epochId).saturatingSub(batchAssets); - details.totalShares = totalRedeemShares(epochId).saturatingSub(batchShares); + 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; } @@ -308,7 +324,15 @@ abstract contract ERC7540EpochRedeem is ERC7540 { return shares; } - /// @dev Same as {_consumeClaimableWithdraw} but iterates by shares instead of assets. + /** + * @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; @@ -323,8 +347,7 @@ abstract contract ERC7540EpochRedeem is ERC7540 { uint256 batchAssets = _convertToRedeemAssets(epochId, batchShares, Math.Rounding.Floor); EpochRedeemMetadata storage details = _epochs[epochId]; - // Using `requested` (zero in the fully-claimed state) clears stuck dust. - details.requests[controller] = requested.saturatingSub(batchShares); + 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) 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 index 10d5f3a4..2124e1c3 100644 --- a/test/token/ERC20/extensions/ERC7540EpochDeposit.test.js +++ b/test/token/ERC20/extensions/ERC7540EpochDeposit.test.js @@ -19,9 +19,11 @@ async function fixture() { } // 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) + 1n) * week; + const target = BigInt(epochId) * week; if (now < target) await time.increaseTo.timestamp(target); } @@ -29,7 +31,7 @@ describe('ERC7540EpochDeposit', function () { beforeEach(async function () { Object.assign(this, await loadFixture(fixture)); - this.getRequestId = tx => time.clockFromReceipt.timestamp(tx).then(timestamp => timestamp / week); + 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) => { @@ -58,14 +60,14 @@ describe('ERC7540EpochDeposit', function () { await expect(this.mock.$_isRedeemAsync()).to.eventually.equal(true); }); - it('default epoch matches `block.timestamp / 1 weeks`', async function () { + 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); + await expect(this.mock.currentDepositEpoch()).to.eventually.equal(now / week + 1n); }); }); shouldBehaveLikeERC7540Operator(); - shouldBehaveLikeERC7540Deposit({ supportCustomFulfill: false }); + shouldBehaveLikeERC7540Deposit({ supportCustomFulfill: false, gateOnController: true }); shouldBehaveLikeERC7575(); describe('epoch state and getters', function () { @@ -79,7 +81,7 @@ describe('ERC7540EpochDeposit', function () { 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; + 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); }); @@ -256,7 +258,7 @@ describe('ERC7540EpochDeposit', function () { const epochA = await this.getRequestId(txA); // Advance one week -> Epoch B - await time.increaseTo.timestamp((epochA + 1n) * week); + 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); @@ -334,9 +336,9 @@ describe('ERC7540EpochDeposit', function () { 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 + 1n) * week); + await time.increaseTo.timestamp(e0 * week); await this.mock.connect(user).requestDeposit(100n, user, user); - await time.increaseTo.timestamp((e0 + 2n) * week); + 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([ @@ -357,7 +359,7 @@ describe('ERC7540EpochDeposit', function () { 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 + 1n) * week); + await time.increaseTo.timestamp(e0 * week); await this.mock.connect(user).requestDeposit(100n, user, user); await advancePast(e0 + 1n); @@ -375,7 +377,7 @@ describe('ERC7540EpochDeposit', 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 + 1)) * week); + await time.increaseTo.timestamp((e0 + BigInt(i)) * week); } const all = [e0, e0 + 1n, e0 + 2n, e0 + 3n]; @@ -408,7 +410,7 @@ describe('ERC7540EpochDeposit', function () { for (let i = 0; i < 32; i++) { await this.mock.connect(user).requestDeposit(1n, user, user); epoch = epoch + 1n; - await time.increaseTo.timestamp(epoch * week); + await time.increaseTo.timestamp((epoch - 1n) * week); } // The 33rd distinct epoch should revert with the queue-limit error @@ -430,6 +432,28 @@ describe('ERC7540EpochDeposit', function () { 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 () { @@ -443,7 +467,7 @@ describe('ERC7540EpochDeposit', function () { .connect(user) .requestDeposit(100n, user, user) .then(tx => this.getRequestId(tx)); - await time.increaseTo.timestamp((this.epochA + 1n) * week); + await time.increaseTo.timestamp(this.epochA * week); this.epochB = await this.mock .connect(user) .requestDeposit(50n, user, user) @@ -526,16 +550,17 @@ describe('ERC7540EpochDeposit', function () { await expect(this.mock.balanceOf(user)).to.eventually.equal(1n); }); - it('saturating sub absorbs ceil/floor excess; drained-state dust is hidden from views', async function () { - // Pathological tiny-totals scenario to trigger Case A overshoot in the share-driven - // path. Uses the internal `$_consumeClaimableMint` to bypass the public `maxMint` - // guard so we can force the rounding excess that saturating sub is designed to absorb. + 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 Case A: requested=ceil(2*3/5)=2, batchAssets uncapped=floor(2*5/3)=3 (overshoot by 1) - // Sat-sub: r_alice 2->0, totalAssets 5->2, totalShares 3->1. - // Bob Case B: requested=ceil(3*1/2)=2 > shares=1, batchAssets=floor(1*2/1)=2. - // Sat-sub: r_bob saturates 3->1 (dust), totalAssets 2->0, totalShares 1->0 (drained). + // 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); @@ -548,17 +573,17 @@ describe('ERC7540EpochDeposit', function () { await advancePast(epochId); await this.mock.$_fulfillDeposit(epochId, 3n); - // Alice's full claim absorbs the overshoot + // 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(2n); + await expect(this.mock.totalDepositAssets(epochId)).to.eventually.equal(3n); await expect(this.mock.totalDepositShares(epochId)).to.eventually.equal(1n); - // Bob's partial claim drains the pool and leaves dust in `requests[bob]` + // 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); - // The 1-wei dust in bob's slot is invisible through every public view + // 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); diff --git a/test/token/ERC20/extensions/ERC7540EpochRedeem.test.js b/test/token/ERC20/extensions/ERC7540EpochRedeem.test.js index 1d9228a8..95cc5b31 100644 --- a/test/token/ERC20/extensions/ERC7540EpochRedeem.test.js +++ b/test/token/ERC20/extensions/ERC7540EpochRedeem.test.js @@ -19,9 +19,11 @@ async function fixture() { } // 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) + 1n) * week; + const target = BigInt(epochId) * week; if (now < target) await time.increaseTo.timestamp(target); } @@ -29,7 +31,7 @@ describe('ERC7540EpochRedeem', function () { beforeEach(async function () { Object.assign(this, await loadFixture(fixture)); - this.getRequestId = tx => time.clockFromReceipt.timestamp(tx).then(timestamp => timestamp / week); + this.getRequestId = tx => time.clockFromReceipt.timestamp(tx).then(timestamp => timestamp / week + 1n); this.fulfillDeposit = async (requestId, _assets, shares) => { await advancePast(requestId); @@ -57,14 +59,14 @@ describe('ERC7540EpochRedeem', function () { await expect(this.mock.$_isRedeemAsync()).to.eventually.equal(true); }); - it('default epoch matches `block.timestamp / 1 weeks`', async function () { + 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); + await expect(this.mock.currentRedeemEpoch()).to.eventually.equal(now / week + 1n); }); }); shouldBehaveLikeERC7540Operator(); - shouldBehaveLikeERC7540Redeem({ supportCustomFulfill: false }); + shouldBehaveLikeERC7540Redeem({ supportCustomFulfill: false, gateOnController: true }); shouldBehaveLikeERC7575(); describe('epoch state and getters', function () { @@ -78,7 +80,7 @@ describe('ERC7540EpochRedeem', function () { 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; + const expected = (await time.clockFromReceipt.timestamp(tx)) / week + 1n; await expect(tx).to.emit(this.mock, 'RedeemRequest').withArgs(user, user, expected, user, 100n); }); @@ -248,7 +250,7 @@ describe('ERC7540EpochRedeem', function () { const txA = await this.mock.connect(user).requestRedeem(100n, user, user); const epochA = await this.getRequestId(txA); - await time.increaseTo.timestamp((epochA + 1n) * week); + 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); @@ -319,9 +321,9 @@ describe('ERC7540EpochRedeem', function () { 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 + 1n) * week); + await time.increaseTo.timestamp(e0 * week); await this.mock.connect(user).requestRedeem(100n, user, user); - await time.increaseTo.timestamp((e0 + 2n) * week); + 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]); @@ -338,7 +340,7 @@ describe('ERC7540EpochRedeem', function () { 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 + 1n) * week); + await time.increaseTo.timestamp(e0 * week); await this.mock.connect(user).requestRedeem(100n, user, user); await advancePast(e0 + 1n); @@ -356,7 +358,7 @@ describe('ERC7540EpochRedeem', 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 + 1)) * week); + await time.increaseTo.timestamp((e0 + BigInt(i)) * week); } const all = [e0, e0 + 1n, e0 + 2n, e0 + 3n]; @@ -388,7 +390,7 @@ describe('ERC7540EpochRedeem', function () { for (let i = 0; i < 32; i++) { await this.mock.connect(user).requestRedeem(1n, user, user); epoch = epoch + 1n; - await time.increaseTo.timestamp(epoch * week); + await time.increaseTo.timestamp((epoch - 1n) * week); } await expect(this.mock.connect(user).requestRedeem(1n, user, user)) @@ -396,6 +398,26 @@ describe('ERC7540EpochRedeem', function () { .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); @@ -420,7 +442,7 @@ describe('ERC7540EpochRedeem', function () { .connect(user) .requestRedeem(100n, user, user) .then(tx => this.getRequestId(tx)); - await time.increaseTo.timestamp((this.epochA + 1n) * week); + await time.increaseTo.timestamp(this.epochA * week); this.epochB = await this.mock .connect(user) .requestRedeem(50n, user, user) @@ -501,16 +523,17 @@ describe('ERC7540EpochRedeem', function () { await expect(this.token.balanceOf(user)).to.eventually.equal(1n); }); - it('saturating sub absorbs ceil/floor excess; drained-state dust is hidden from views', async function () { - // Pathological tiny-totals scenario to trigger Case A overshoot in the asset-driven - // path. Uses the internal `$_consumeClaimableWithdraw` to bypass the public - // `maxWithdraw` guard so we can force the rounding excess that saturating sub absorbs. + 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 Case A (assets=2): requested=ceil(2*3/5)=2, batchShares uncapped=floor(2*5/3)=3 (overshoot by 1) - // Sat-sub: r_alice 2->0, totalShares 5->2, totalAssets 3->1. - // Bob Case B (assets=1): requested=ceil(3*1/2)=2 > assets=1, batchShares=floor(1*2/1)=2. - // Sat-sub: r_bob saturates 3->1 (dust), totalShares 2->0, totalAssets 1->0 (drained). + // 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]) { @@ -523,15 +546,17 @@ describe('ERC7540EpochRedeem', function () { 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(2n); + 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); - // The 1-wei dust in bob's slot is invisible through every public view + // 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); From cc6c1e5a9e24469c7ad71cb4d37eb811d319311a Mon Sep 17 00:00:00 2001 From: ernestognw Date: Mon, 20 Jul 2026 15:14:31 -0600 Subject: [PATCH 59/60] Rename _requestQueueLimit to dissambiguate --- contracts/mocks/token/ERC7540EpochMock.sol | 10 ---------- .../token/ERC20/extensions/ERC7540EpochDeposit.sol | 12 ++++++------ .../token/ERC20/extensions/ERC7540EpochRedeem.sol | 12 ++++++------ docs/modules/ROOT/pages/erc7540.adoc | 9 ++------- .../ERC20/extensions/ERC7540EpochDeposit.test.js | 2 +- .../ERC20/extensions/ERC7540EpochRedeem.test.js | 2 +- 6 files changed, 16 insertions(+), 31 deletions(-) diff --git a/contracts/mocks/token/ERC7540EpochMock.sol b/contracts/mocks/token/ERC7540EpochMock.sol index c03ece67..6bf9dd17 100644 --- a/contracts/mocks/token/ERC7540EpochMock.sol +++ b/contracts/mocks/token/ERC7540EpochMock.sol @@ -24,14 +24,4 @@ abstract contract ERC7540EpochMock is ERC7540EpochDeposit, ERC7540EpochRedeem { ) internal virtual override(ERC7540, ERC7540EpochRedeem) returns (uint256) { return super._requestRedeem(shares, controller, owner, requestId); } - - function _requestQueueLimit() - internal - view - virtual - override(ERC7540EpochDeposit, ERC7540EpochRedeem) - returns (uint256) - { - return super._requestQueueLimit(); - } } diff --git a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol index 35254469..b9cf3734 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol @@ -24,7 +24,7 @@ import {ERC7540} from "./ERC7540.sol"; * manually-bumped epoch counters. * * Each account tracks its epoch memberships via a {DoubleEndedQueue} capped at - * {_requestQueueLimit} entries (default: 32) to bound the O(n) loops in {_asyncMaxDeposit} + * {_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 @@ -66,7 +66,7 @@ abstract contract ERC7540EpochDeposit is ERC7540 { /// @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 {_requestQueueLimit}. + /// @dev Attempted to enqueue an epoch for `controller` past {_depositRequestQueueLimit}. error ERC7540EpochDepositQueueLimitExceeded(address controller); /// @inheritdoc ERC7540 @@ -150,7 +150,7 @@ abstract contract ERC7540EpochDeposit is ERC7540 { * 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 {_requestQueueLimit} (default 32). Per-account, + * 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). @@ -213,7 +213,7 @@ abstract contract ERC7540EpochDeposit is ERC7540 { * * * `_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 {_requestQueueLimit}. + * * The controller's epoch queue must not exceed {_depositRequestQueueLimit}. */ function _requestDeposit( uint256 assets, @@ -233,7 +233,7 @@ abstract contract ERC7540EpochDeposit is ERC7540 { // _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() < _requestQueueLimit(), + _memberOf[controller].length() < _depositRequestQueueLimit(), ERC7540EpochDepositQueueLimitExceeded(controller) ); @@ -361,7 +361,7 @@ abstract contract ERC7540EpochDeposit is ERC7540 { * @dev Maximum number of epoch entries in a controller's queue. Defaults to 32. * Prevents unbounded iteration in {_asyncMaxDeposit} and {_asyncMaxMint}. */ - function _requestQueueLimit() internal view virtual returns (uint256) { + 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 index 8f0aa7c5..632468b8 100644 --- a/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol +++ b/contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol @@ -24,7 +24,7 @@ import {ERC7540} from "./ERC7540.sol"; * manually-bumped epoch counters. * * Each account tracks its epoch memberships via a {DoubleEndedQueue} capped at - * {_requestQueueLimit} entries (default: 32) to bound the O(n) loops in {_asyncMaxWithdraw} + * {_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 @@ -66,7 +66,7 @@ abstract contract ERC7540EpochRedeem is ERC7540 { /// @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 {_requestQueueLimit}. + /// @dev Attempted to enqueue an epoch for `controller` past {_redeemRequestQueueLimit}. error ERC7540EpochRedeemQueueLimitExceeded(address controller); /// @inheritdoc ERC7540 @@ -150,7 +150,7 @@ abstract contract ERC7540EpochRedeem is ERC7540 { * 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 {_requestQueueLimit} (default 32). Per-account, + * 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). @@ -213,7 +213,7 @@ abstract contract ERC7540EpochRedeem is ERC7540 { * * * `_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 {_requestQueueLimit}. + * * The controller's epoch queue must not exceed {_redeemRequestQueueLimit}. */ function _requestRedeem( uint256 shares, @@ -233,7 +233,7 @@ abstract contract ERC7540EpochRedeem is ERC7540 { // _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() < _requestQueueLimit(), + _memberOf[controller].length() < _redeemRequestQueueLimit(), ERC7540EpochRedeemQueueLimitExceeded(controller) ); @@ -361,7 +361,7 @@ abstract contract ERC7540EpochRedeem is ERC7540 { * @dev Maximum number of epoch entries in a controller's queue. Defaults to 32. * Prevents unbounded iteration in {_asyncMaxWithdraw} and {_asyncMaxRedeem}. */ - function _requestQueueLimit() internal view virtual returns (uint256) { + function _redeemRequestQueueLimit() internal view virtual returns (uint256) { return 32; } } diff --git a/docs/modules/ROOT/pages/erc7540.adoc b/docs/modules/ROOT/pages/erc7540.adoc index 141fdba1..3352575c 100644 --- a/docs/modules/ROOT/pages/erc7540.adoc +++ b/docs/modules/ROOT/pages/erc7540.adoc @@ -342,7 +342,7 @@ 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 `_requestQueueLimit()` 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. +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. Users that hit the queue limit must claim fulfilled epochs to free up slots before submitting new requests. @@ -391,11 +391,6 @@ contract NAVVault is ERC7540EpochDeposit, ERC7540EpochRedeem, Ownable { return super._requestRedeem(shares, controller, owner, requestId); } - function _requestQueueLimit() - internal view override(ERC7540EpochDeposit, ERC7540EpochRedeem) returns (uint256) - { - return super._requestQueueLimit(); - } } ---- @@ -580,4 +575,4 @@ In the *admin strategy*, the admin has full control over the exchange rate at fu 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 (`_requestQueueLimit`, 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. +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/test/token/ERC20/extensions/ERC7540EpochDeposit.test.js b/test/token/ERC20/extensions/ERC7540EpochDeposit.test.js index 2124e1c3..2c6ef7c0 100644 --- a/test/token/ERC20/extensions/ERC7540EpochDeposit.test.js +++ b/test/token/ERC20/extensions/ERC7540EpochDeposit.test.js @@ -400,7 +400,7 @@ describe('ERC7540EpochDeposit', function () { }); describe('queue limit', function () { - it('enforces `_requestQueueLimit` per controller', async 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); diff --git a/test/token/ERC20/extensions/ERC7540EpochRedeem.test.js b/test/token/ERC20/extensions/ERC7540EpochRedeem.test.js index 95cc5b31..123e4df6 100644 --- a/test/token/ERC20/extensions/ERC7540EpochRedeem.test.js +++ b/test/token/ERC20/extensions/ERC7540EpochRedeem.test.js @@ -381,7 +381,7 @@ describe('ERC7540EpochRedeem', function () { }); describe('queue limit', function () { - it('enforces `_requestQueueLimit` per controller', async 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); From 7b4ce4e9d07f8b6dbf9cf3d605bb316dfe269ee6 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Mon, 20 Jul 2026 15:21:30 -0600 Subject: [PATCH 60/60] docs --- docs/modules/ROOT/pages/erc7540.adoc | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/modules/ROOT/pages/erc7540.adoc b/docs/modules/ROOT/pages/erc7540.adoc index 3352575c..0bfc5e27 100644 --- a/docs/modules/ROOT/pages/erc7540.adoc +++ b/docs/modules/ROOT/pages/erc7540.adoc @@ -304,7 +304,7 @@ flowchart TD === Epoch identification -By default `currentDepositEpoch()` and `currentRedeemEpoch()` return `block.timestamp / 1 weeks`. 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. +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. @@ -344,11 +344,13 @@ vault.redeem(950e18, receiver, controller); // receiver gets 1010 USDC 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. The contract uses saturating subtraction to absorb the excess into the shared totals so they drain to 0 cleanly when fully claimed — at realistic ERC-20 decimals this dust is sub-unit and economically immaterial. +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.