diff --git a/.changeset/lazy-birds-push.md b/.changeset/lazy-birds-push.md new file mode 100644 index 00000000000..0ae7adb212e --- /dev/null +++ b/.changeset/lazy-birds-push.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +ERC-7540: Asynchronous ERC-4626 Tokenized Vaults diff --git a/contracts/interfaces/IERC7540.sol b/contracts/interfaces/IERC7540.sol new file mode 100644 index 00000000000..6b311aad0e8 --- /dev/null +++ b/contracts/interfaces/IERC7540.sol @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {IERC4626} from "../interfaces/IERC4626.sol"; + +/** + * @dev Interface for the ERC-7540 Asynchronous Tokenized Vaults standard. + * https://eips.ethereum.org/EIPS/eip-7540[ERC-7540] + */ +interface IERC7540 is IERC4626 { + struct Request { + uint256 amount; + uint256 claimable; + } + + // Events + event DepositRequest( + address indexed controller, + address indexed owner, + uint256 indexed requestId, + address sender, + uint256 assets + ); + + event RedeemRequest( + address indexed controller, + address indexed owner, + uint256 indexed requestId, + address sender, + uint256 shares + ); + + event OperatorSet(address indexed controller, address indexed operator, bool approved); + + /** + * @dev Indicates an error related to the current `shares` of a `sender`. + * @param sender Address whose tokens are being transferred. + * @param shares Current shares for the interacting account. + */ + error ERC7540ZeroSharesNotAllowed(address sender, uint256 shares); + + /** + * @dev Indicates an error related to the current `assets` of a `sender`. + * @param sender Address whose tokens are being transferred. + * @param assets Current assets for the interacting account. + */ + error ERC7540ZeroAssetsNotAllowed(address sender, uint256 assets); + + /** + * @dev Indicates an error related to the current `assets` of a `sender`. + * @param sender Address whose tokens are being transferred. + * @param owner Address of the owner. + */ + error ERC7540Unauthorized(address sender, address owner); + + /** + * @dev Indicates an error related to the current insufficient `claimable amount` of a `sender`. + * @param shares Current shares for the interacting account. + * @param amount Amount to be claimed. + */ + error ERC7540InsufficientClaimable(uint256 shares, uint256 amount); + + // Methods + + /** + * @dev Initiates a deposit request. + * @param assets The amount of assets to deposit. + * @param controller The address of the controller managing the request. + * @param owner The owner of the assets. + * @return requestId The unique identifier for this deposit request. + */ + function requestDeposit(uint256 assets, address controller, address owner) external returns (uint256 requestId); + + /** + * @dev Initiates a redeem request. + * @param shares The amount of shares to redeem. + * @param controller The address of the controller managing the request. + * @param owner The owner of the shares. + * @return requestId The unique identifier for this redeem request. + */ + function requestRedeem(uint256 shares, address controller, address owner) external returns (uint256 requestId); + + /** + * @dev Gets the pending deposit request amount for a given controller and requestId. + * @param requestId The unique identifier for the request. + * @param controller The address of the controller. + * @return assets The amount of assets in the pending state. + */ + function pendingDepositRequest(uint256 requestId, address controller) external view returns (uint256 assets); + + /** + * @dev Gets the pending redeem request amount for a given controller and requestId. + * @param requestId The unique identifier for the request. + * @param controller The address of the controller. + * @return shares The amount of shares in the pending state. + */ + function pendingRedeemRequest(uint256 requestId, address controller) external view returns (uint256 shares); + + /** + * @dev Gets the claimable deposit request amount for a given controller and requestId. + * @param requestId The unique identifier for the request. + * @param controller The address of the controller. + * @return assets The amount of assets in the claimable state. + */ + function claimableDepositRequest(uint256 requestId, address controller) external view returns (uint256 assets); + + /** + * @dev Gets the claimable redeem request amount for a given controller and requestId. + * @param requestId The unique identifier for the request. + * @param controller The address of the controller. + * @return shares The amount of shares in the claimable state. + */ + function claimableRedeemRequest(uint256 requestId, address controller) external view returns (uint256 shares); + + /** + * @dev Sets or revokes an operator for the given controller. + * @param operator The address of the operator. + * @param approved The approval status of the operator. + * @return success Whether the operation was successful. + */ + function setOperator(address operator, bool approved) external returns (bool success); + + /** + * @dev Checks if an operator is approved for a controller. + * @param controller The address of the controller. + * @param operator The address of the operator. + * @return status Whether the operator is approved. + */ + function isOperator(address controller, address operator) external view returns (bool status); +} diff --git a/contracts/mocks/docs/ERC7540Fees.sol b/contracts/mocks/docs/ERC7540Fees.sol new file mode 100644 index 00000000000..87c5de76a51 --- /dev/null +++ b/contracts/mocks/docs/ERC7540Fees.sol @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {IERC4626} from "../../interfaces/IERC4626.sol"; +import {IERC7540} from "../../interfaces/IERC7540.sol"; +import {IERC20} from "../../token/ERC20/IERC20.sol"; +import {ERC7540} from "../../token/ERC20/extensions/ERC7540.sol"; +import {ERC4626} from "../../token/ERC20/extensions/ERC4626.sol"; +import {SafeERC20} from "../../token/ERC20/utils/SafeERC20.sol"; +import {Math} from "../../utils/math/Math.sol"; + +/** + * @dev ERC-7540 vault with entry/exit fees expressed in basis points. + * + * NOTE: This contract charges fees in terms of assets, not shares. The fees are calculated + * based on the amount of assets being deposited or withdrawn, not the shares being minted or redeemed. + * This is an opinionated design decision that should be taken into account when integrating. + * + * WARNING: This contract is for demonstration purposes and has not been audited. Use it with caution. + */ +abstract contract ERC7540Fees is ERC7540 { + using SafeERC20 for IERC20; + using Math for uint256; + + uint256 private constant BASIS_POINT_SCALE = 1e4; + + // function previewDeposit(uint256 assets) public view virtual override(ERC7540) returns (uint256) { + // uint256 fee = _feeOnTotal(assets, _entryFeeBasisPoints()); + // return super.previewDeposit(assets - fee); + // } + + // function previewRedeem(uint256 shares) public view virtual override(ERC7540) returns (uint256) { + // uint256 assets = super.previewRedeem(shares); + // uint256 fee = _feeOnTotal(assets, _exitFeeBasisPoints()); + // return assets - fee; + // } + + function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal virtual override { + uint256 fee = _feeOnTotal(assets, _entryFeeBasisPoints()); + address recipient = _entryFeeRecipient(); + + super._deposit(caller, receiver, assets - fee, shares); + + if (fee > 0 && recipient != address(this)) { + IERC20(asset()).safeTransfer(recipient, fee); + } + } + + function _withdraw( + address caller, + address receiver, + address owner, + uint256 assets, + uint256 shares + ) internal virtual override { + uint256 fee = _feeOnRaw(assets, _exitFeeBasisPoints()); + address recipient = _exitFeeRecipient(); + + super._withdraw(caller, receiver, owner, assets - fee, shares); + + if (fee > 0 && recipient != address(this)) { + IERC20(asset()).safeTransfer(recipient, fee); + } + } + + function _entryFeeBasisPoints() internal view virtual returns (uint256) { + return 0; // replace with e.g. 100 for 1% + } + + function _exitFeeBasisPoints() internal view virtual returns (uint256) { + return 0; // replace with e.g. 100 for 1% + } + + function _entryFeeRecipient() internal view virtual returns (address) { + return address(0); // replace with e.g. a treasury address + } + + function _exitFeeRecipient() internal view virtual returns (address) { + return address(0); // replace with e.g. a treasury address + } + + function _feeOnRaw(uint256 assets, uint256 feeBasisPoints) private pure returns (uint256) { + return assets.mulDiv(feeBasisPoints, BASIS_POINT_SCALE, Math.Rounding.Ceil); + } + + function _feeOnTotal(uint256 assets, uint256 feeBasisPoints) private pure returns (uint256) { + return assets.mulDiv(feeBasisPoints, feeBasisPoints + BASIS_POINT_SCALE, Math.Rounding.Ceil); + } +} diff --git a/contracts/mocks/token/ERC7540FeesMock.sol b/contracts/mocks/token/ERC7540FeesMock.sol new file mode 100644 index 00000000000..c9717fdc313 --- /dev/null +++ b/contracts/mocks/token/ERC7540FeesMock.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {ERC7540Fees} from "../docs/ERC7540Fees.sol"; +import {ERC7540} from "../../token/ERC20/extensions/ERC7540.sol"; +import {IERC20} from "../../token/ERC20/IERC20.sol"; + +abstract contract ERC7540FeesMock is ERC7540Fees { + uint256 private immutable _entryFeeBasisPointValue; + address private immutable _entryFeeRecipientValue; + uint256 private immutable _exitFeeBasisPointValue; + address private immutable _exitFeeRecipientValue; + + constructor( + IERC20 asset, + uint256 entryFeeBasisPoints, + address entryFeeRecipient, + uint256 exitFeeBasisPoints, + address exitFeeRecipient + ) ERC7540(asset) { + _entryFeeBasisPointValue = entryFeeBasisPoints; + _entryFeeRecipientValue = entryFeeRecipient; + _exitFeeBasisPointValue = exitFeeBasisPoints; + _exitFeeRecipientValue = exitFeeRecipient; + } + + function _entryFeeBasisPoints() internal view virtual override returns (uint256) { + return _entryFeeBasisPointValue; + } + + function _entryFeeRecipient() internal view virtual override returns (address) { + return _entryFeeRecipientValue; + } + + function _exitFeeBasisPoints() internal view virtual override returns (uint256) { + return _exitFeeBasisPointValue; + } + + function _exitFeeRecipient() internal view virtual override returns (address) { + return _exitFeeRecipientValue; + } +} diff --git a/contracts/mocks/token/ERC7540LimitsMock.sol b/contracts/mocks/token/ERC7540LimitsMock.sol new file mode 100644 index 00000000000..4b8d48dc802 --- /dev/null +++ b/contracts/mocks/token/ERC7540LimitsMock.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {IERC20} from "../../token/ERC20/IERC20.sol"; +import {IERC4626} from "../../interfaces/IERC4626.sol"; +import {ERC7540} from "../../token/ERC20/extensions/ERC7540.sol"; +import {ERC4626} from "../../token/ERC20/extensions/ERC4626.sol"; + +abstract contract ERC7540LimitsMock is ERC7540 { + uint256 private immutable _maxDeposit; + uint256 private immutable _maxMint; + + constructor(IERC20 asset, uint256 maxDepositValue, uint256 maxMintValue) ERC7540(asset) { + _maxDeposit = maxDepositValue; + _maxMint = maxMintValue; + } + + function maxDeposit(address) public view override(ERC7540) returns (uint256) { + return _maxDeposit; + } + + function maxMint(address) public view override(ERC4626, IERC4626) returns (uint256) { + return _maxMint; + } +} diff --git a/contracts/mocks/token/ERC7540Mock.sol b/contracts/mocks/token/ERC7540Mock.sol new file mode 100644 index 00000000000..69914dd1929 --- /dev/null +++ b/contracts/mocks/token/ERC7540Mock.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {IERC20} from "../../token/ERC20/IERC20.sol"; +import {ERC20} from "../../token/ERC20/ERC20.sol"; +import {ERC7540} from "../../token/ERC20/extensions/ERC7540.sol"; + +contract ERC7540Mock is ERC7540 { + constructor(IERC20 asset) ERC20("ERC7540Mock", "E7540M") ERC7540(asset) {} + + function _processPendingRequests(uint256 requestId, address controller) internal view override { + // ToDo + } +} diff --git a/contracts/mocks/token/ERC7540OffsetMock.sol b/contracts/mocks/token/ERC7540OffsetMock.sol new file mode 100644 index 00000000000..d37f5fa3f4d --- /dev/null +++ b/contracts/mocks/token/ERC7540OffsetMock.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {IERC20} from "../../token/ERC20/IERC20.sol"; +import {ERC7540} from "../../token/ERC20/extensions/ERC7540.sol"; + +abstract contract ERC7540OffsetMock is ERC7540 { + uint8 private immutable _offset; + + constructor(IERC20 asset, uint8 offset) ERC7540(asset) { + _offset = offset; + } + + function _decimalsOffset() internal view override returns (uint8) { + return _offset; + } +} diff --git a/contracts/token/ERC20/extensions/ERC7540.sol b/contracts/token/ERC20/extensions/ERC7540.sol new file mode 100644 index 00000000000..592f45d06c6 --- /dev/null +++ b/contracts/token/ERC20/extensions/ERC7540.sol @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {IERC7540} from "../../../interfaces/IERC7540.sol"; +import {IERC4626} from "../../../interfaces/IERC4626.sol"; +import {IERC20} from "../../../interfaces/IERC20.sol"; +import {ERC4626} from "./ERC4626.sol"; +import {IERC165} from "../../../interfaces/IERC165.sol"; +import {SafeERC20} from "../utils/SafeERC20.sol"; +import {Math} from "../../../utils/math/Math.sol"; + +/** + * @dev Abstract implementation of the ERC-7540 standard, extending ERC-4626. + */ +abstract contract ERC7540 is ERC4626, IERC7540, IERC165 { + using SafeERC20 for IERC20; + using Math for uint256; + + uint256 public constant VESTING_DURATION = 48 hours; + + // Mappings to track pending and claimable requests + mapping(address => mapping(uint256 => Request)) private _pendingDepositRequests; + mapping(address => mapping(uint256 => Request)) private _pendingRedeemRequests; + mapping(address => mapping(address => bool)) private _operators; + mapping(uint256 => uint256) private _vestingTimestamps; + + /** + * @dev Set the underlying asset contract. + */ + constructor(IERC20 asset) ERC4626(asset) {} + + /** + * @dev Creates a new deposit request. + */ + function requestDeposit( + uint256 assets, + address controller, + address owner + ) public virtual override returns (uint256 requestId) { + address sender = _msgSender(); + + if (assets == 0) { + revert ERC7540ZeroAssetsNotAllowed(sender, assets); + } + + if (owner != sender && !isOperator(owner, sender)) { + revert ERC7540Unauthorized(sender, owner); + } + + requestId = _generateRequestId(controller, assets); + + _pendingDepositRequests[controller][requestId].amount += assets; + + IERC20(asset()).safeTransferFrom(owner, address(this), assets); + + emit DepositRequest(controller, owner, requestId, sender, assets); + } + + /** + * @dev Creates a new redeem request. + */ + function requestRedeem( + uint256 shares, + address controller, + address owner + ) public virtual override returns (uint256 requestId) { + address sender = _msgSender(); + + if (shares == 0) { + revert ERC7540ZeroSharesNotAllowed(sender, shares); + } + + if (owner != sender && !isOperator(owner, sender)) { + revert ERC7540Unauthorized(sender, owner); + } + + requestId = _generateRequestId(controller, shares); + + _burn(owner, shares); + + _pendingRedeemRequests[controller][requestId].amount += shares; + + // Set vesting unlock time + _vestingTimestamps[requestId] = block.timestamp + VESTING_DURATION; + + emit RedeemRequest(controller, owner, requestId, sender, shares); + } + + /* + * @dev Overrides maxDeposit to allow deposits up to claimable request amount + */ + function maxDeposit(address controller) public view virtual override(ERC4626, IERC4626) returns (uint256) { + return _pendingDepositRequests[controller][0].claimable; + } + + /* + * @dev Overrides maxRedeem to allow redemptions up to claimable request amount + */ + function maxRedeem(address controller) public view virtual override(ERC4626, IERC4626) returns (uint256) { + return _pendingRedeemRequests[controller][0].claimable; + } + + /** + * @dev Gets the pending deposit request amount. + */ + function pendingDepositRequest(uint256 requestId, address controller) external view override returns (uint256) { + return _pendingDepositRequests[controller][requestId].amount; + } + + /** + * @dev Gets the pending redeem request amount. + */ + function pendingRedeemRequest(uint256 requestId, address controller) external view override returns (uint256) { + return _pendingRedeemRequests[controller][requestId].amount; + } + + /** + * @dev Gets the claimable deposit request amount. + */ + function claimableDepositRequest(uint256 requestId, address controller) external view override returns (uint256) { + return _pendingDepositRequests[controller][requestId].claimable; + } + + /** + * @dev Gets the claimable redeem request amount. + */ + function claimableRedeemRequest(uint256 requestId, address controller) external view override returns (uint256) { + return _pendingRedeemRequests[controller][requestId].claimable; + } + + /** + * @dev Implements ERC-7540 deposit by allowing users to deposit through calling ERC4626 deposit. + */ + function deposit(uint256 assets, address receiver, address controller) public virtual returns (uint256 shares) { + address sender = _msgSender(); + + if (sender != controller && !isOperator(controller, sender)) { + revert ERC7540Unauthorized(sender, controller); + } + + Request storage request = _pendingDepositRequests[controller][0]; + if (request.claimable < assets) { + revert ERC7540InsufficientClaimable(assets, request.claimable); + } + + request.claimable -= assets; + + uint256 maxAssets = maxDeposit(receiver); + if (assets > maxAssets) { + revert ERC4626ExceededMaxDeposit(receiver, assets, maxAssets); + } + + shares = super.deposit(assets, receiver); + + return shares; + } + + /** + * @dev Implements ERC-7540 claim by allowing users to redeem claimable shares. + */ + function redeem( + uint256 shares, + address receiver, + address controller + ) public virtual override(ERC4626, IERC4626) returns (uint256 assets) { + address sender = _msgSender(); + if (sender != controller && !isOperator(controller, sender)) { + revert ERC7540Unauthorized(sender, controller); + } + + Request storage request = _pendingRedeemRequests[controller][0]; + if (request.claimable < shares) { + revert ERC7540InsufficientClaimable(shares, request.claimable); + } + + request.claimable -= shares; + + uint256 maxShares = maxRedeem(controller); + if (shares > maxShares) { + revert ERC4626ExceededMaxRedeem(controller, shares, maxShares); + } + + assets = super.redeem(shares, receiver, controller); + + return assets; + } + + /** + * @dev Sets or revokes an operator for the given controller. + */ + function setOperator(address operator, bool approved) external override returns (bool) { + address sender = _msgSender(); + _operators[sender][operator] = approved; + emit OperatorSet(sender, operator, approved); + return true; + } + + /** + * @dev Checks if an operator is approved for a controller. + */ + function isOperator(address controller, address operator) public view override returns (bool) { + return _operators[controller][operator]; + } + + /** + * @dev Implements ERC-165 interface detection. + */ + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return + interfaceId == type(IERC165).interfaceId || + interfaceId == 0xe3bc4e65 || // ERC-7540 operator methods + interfaceId == 0x2f0a18c5 || // ERC-7575 interface + interfaceId == 0xce3bbe50 || // Asynchronous deposit Vault + interfaceId == 0x620ee8e4; // Asynchronous redemption Vault + } + + /** + * @dev Internal function to generates a request ID. Requests created within the same block, + * for the same controller, input, and sender, are cumulative. + * + * Using only `block.number` ensures consistent behavior on L2s, where + * `block.timestamp` might vary slightly between operators. This approach + * defines "fungibility" of requests generated within the same block and + * with identical parameters. + */ + function _generateRequestId(address controller, uint256 input) internal virtual returns (uint256) { + address sender = _msgSender(); + return uint256(keccak256(abi.encodePacked(block.number, sender, controller, input))); + } + + /** + * @dev Abstract function for transitioning requests from Pending to Claimable. + */ + function _processPendingRequests(uint256 requestId, address controller) internal virtual { + Request storage depositRequest = _pendingDepositRequests[controller][requestId]; + if (depositRequest.amount > 0) { + depositRequest.claimable += depositRequest.amount; + depositRequest.amount = 0; + } + + Request storage redeemRequest = _pendingRedeemRequests[controller][requestId]; + if (redeemRequest.amount > 0) { + redeemRequest.claimable += redeemRequest.amount; + redeemRequest.amount = 0; + } + } +} diff --git a/test/token/ERC20/extensions/ERC7540.test.js b/test/token/ERC20/extensions/ERC7540.test.js new file mode 100644 index 00000000000..4a6af2f6863 --- /dev/null +++ b/test/token/ERC20/extensions/ERC7540.test.js @@ -0,0 +1,183 @@ +const { ethers } = require("hardhat"); +const { expect } = require("chai"); +const { loadFixture } = require("@nomicfoundation/hardhat-network-helpers"); + +const name = "TestToken"; +const symbol = "TTKN"; +const decimals = 18n; +const initialSupply = ethers.parseUnits("1000000", decimals); + +async function fixture() { + const [deployer, holder, recipient, controller, operator, other] = await ethers.getSigners(); + + const AssetToken = await ethers.getContractFactory("ERC20Mock"); + const assetToken = await AssetToken.deploy(name, symbol, decimals); + await assetToken.mint(holder.address, initialSupply); + + const ERC7540Vault = await ethers.getContractFactory("ERC7540Mock"); + const vault = await ERC7540Vault.deploy(assetToken.address); + + return { deployer, holder, recipient, controller, operator, other, assetToken, vault }; +} + +describe("ERC7540", function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + describe("Deployment", function () { + it("Should initialize the correct asset token", async function () { + expect(await this.vault.asset()).to.equal(this.assetToken.target); + }); + + it("Should set the correct initial decimals", async function () { + expect(await this.vault.decimals()).to.equal(decimals); + }); + }); + + describe("Request Deposit", function () { + it("Should create a deposit request", async function () { + const depositAmount = ethers.parseUnits("100", decimals); + await this.assetToken.connect(this.holder).approve(this.vault.target, depositAmount); + + const requestId = await this.vault + .connect(this.holder) + .requestDeposit(depositAmount, this.controller.address, this.holder.address); + + expect(await this.vault.pendingDepositRequest(requestId, this.controller.address)).to.equal(depositAmount); + }); + + it("Should revert if deposit amount is zero", async function () { + await expect( + this.vault.connect(this.holder).requestDeposit(0, this.controller.address, this.holder.address) + ).to.be.revertedWithCustomError(this.vault, "ERC7540ZeroAssetsNotAllowed"); + }); + + it("Should revert if sender is not authorized", async function () { + const depositAmount = ethers.parseUnits("100", decimals); + await this.assetToken.connect(this.holder).approve(this.vault.target, depositAmount); + + await expect( + this.vault.connect(this.other).requestDeposit(depositAmount, this.controller.address, this.holder.address) + ).to.be.revertedWithCustomError(this.vault, "ERC7540Unauthorized"); + }); + }); + + describe("Request Redeem", function () { + it("Should create a redeem request", async function () { + const redeemAmount = ethers.parseUnits("50", decimals); + await this.assetToken.connect(this.holder).approve(this.vault.target, redeemAmount); + await this.vault.connect(this.holder).requestDeposit(redeemAmount, this.controller.address, this.holder.address); + + const requestId = await this.vault + .connect(this.holder) + .requestRedeem(redeemAmount, this.controller.address, this.holder.address); + + expect(await this.vault.pendingRedeemRequest(requestId, this.controller.address)).to.equal(redeemAmount); + }); + + it("Should revert if redeem amount is zero", async function () { + await expect( + this.vault.connect(this.holder).requestRedeem(0, this.controller.address, this.holder.address) + ).to.be.revertedWithCustomError(this.vault, "ERC7540ZeroSharesNotAllowed"); + }); + + it("Should revert if sender is not authorized", async function () { + const redeemAmount = ethers.parseUnits("50", decimals); + await this.assetToken.connect(this.holder).approve(this.vault.target, redeemAmount); + await this.vault.connect(this.holder).requestDeposit(redeemAmount, this.controller.address, this.holder.address); + + await expect( + this.vault.connect(this.other).requestRedeem(redeemAmount, this.controller.address, this.holder.address) + ).to.be.revertedWithCustomError(this.vault, "ERC7540Unauthorized"); + }); + }); + + describe("Claim Deposits and Redemptions", function () { + beforeEach(async function () { + this.depositAmount = ethers.parseUnits("200", decimals); + await this.assetToken.connect(this.holder).approve(this.vault.target, this.depositAmount); + await this.vault.connect(this.holder).requestDeposit(this.depositAmount, this.controller.address, this.holder.address); + }); + + it("Should allow a controller to claim a deposit", async function () { + await expect( + this.vault.connect(this.controller).deposit(this.depositAmount, this.holder.address, this.controller.address) + ).to.not.be.reverted; + }); + + it("Should revert if the controller tries to claim more than claimable amount", async function () { + await expect( + this.vault.connect(this.controller).deposit(this.depositAmount + 1n, this.holder.address, this.controller.address) + ).to.be.revertedWithCustomError(this.vault, "ERC7540InsufficientClaimable"); + }); + }); + + describe("Operator Permissions", function () { + it("Should allow a user to set an operator", async function () { + await expect(this.vault.connect(this.holder).setOperator(this.operator.address, true)) + .to.emit(this.vault, "OperatorSet") + .withArgs(this.holder.address, this.operator.address, true); + + expect(await this.vault.isOperator(this.holder.address, this.operator.address)).to.be.true; + }); + + it("Should allow an operator to perform actions on behalf of the owner", async function () { + await this.vault.connect(this.holder).setOperator(this.operator.address, true); + + const depositAmount = ethers.parseUnits("50", decimals); + await this.assetToken.connect(this.holder).approve(this.vault.target, depositAmount); + + await expect( + this.vault.connect(this.operator).requestDeposit(depositAmount, this.controller.address, this.holder.address) + ).to.not.be.reverted; + }); + + it("Should revert if an unapproved operator tries to perform an action", async function () { + await expect( + this.vault.connect(this.other).requestDeposit(100, this.controller.address, this.holder.address) + ).to.be.revertedWithCustomError(this.vault, "ERC7540Unauthorized"); + }); + }); + + describe("ERC-165 Interface Support", function () { + it("Should support ERC-165", async function () { + expect(await this.vault.supportsInterface("0x01ffc9a7")).to.be.true; // ERC-165 ID + }); + + it("Should support ERC-7540 operator methods", async function () { + expect(await this.vault.supportsInterface("0xe3bc4e65")).to.be.true; + }); + + it("Should support ERC-7575", async function () { + expect(await this.vault.supportsInterface("0x2f0a18c5")).to.be.true; + }); + + it("Should support async deposit vault", async function () { + expect(await this.vault.supportsInterface("0xce3bbe50")).to.be.true; + }); + + it("Should support async redemption vault", async function () { + expect(await this.vault.supportsInterface("0x620ee8e4")).to.be.true; + }); + + it("Should return false for unsupported interfaces", async function () { + expect(await this.vault.supportsInterface("0x00000000")).to.be.false; + }); + }); + + describe("Reentrancy and Edge Cases", function () { + it("Should prevent reentrancy attacks", async function () { + // Implement tests to check for reentrancy if using a vulnerable function + }); + + it("Should handle large request amounts", async function () { + const largeAmount = ethers.parseUnits("1000000000", decimals); + await this.assetToken.connect(this.holder).approve(this.vault.target, largeAmount); + + await expect( + this.vault.connect(this.holder).requestDeposit(largeAmount, this.controller.address, this.holder.address) + ).to.not.be.reverted; + }); + }); +});