diff --git a/nest/script/DeployNestContracts.s.sol b/nest/script/DeployNestContracts.s.sol index 4a949eb..64e6a6a 100644 --- a/nest/script/DeployNestContracts.s.sol +++ b/nest/script/DeployNestContracts.s.sol @@ -1,7 +1,6 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.25; -import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { Script } from "forge-std/Script.sol"; import { Test } from "forge-std/Test.sol"; @@ -13,6 +12,8 @@ import { NestStaking } from "../src/NestStaking.sol"; import { IComponentToken } from "../src/interfaces/IComponentToken.sol"; import { AggregateTokenProxy } from "../src/proxy/AggregateTokenProxy.sol"; import { NestStakingProxy } from "../src/proxy/NestStakingProxy.sol"; + +import { pUSDProxy } from "../src/proxy/pUSDProxy.sol"; import { pUSD } from "../src/token/pUSD.sol"; // Concrete implementation of ComponentToken @@ -43,7 +44,6 @@ contract DeployNestContracts is Script, Test { function run() external { vm.startBroadcast(NEST_ADMIN_ADDRESS); - ERC1967Proxy pUSDProxy = ERC1967Proxy(payable(PUSD_ADDRESS)); AggregateToken aggregateToken = new AggregateToken(); AggregateTokenProxy aggregateTokenProxy = new AggregateTokenProxy( @@ -52,9 +52,9 @@ contract DeployNestContracts is Script, Test { AggregateToken.initialize, ( NEST_ADMIN_ADDRESS, - "Nest Insto Vault", - "NIV", - IComponentToken(address(pUSDProxy)), + "Nest RWA Vault", + "nRWA", + IComponentToken(PUSD_ADDRESS), 1e17, // ask price 1e17 // bid price ) diff --git a/nest/script/UpgradeNestContracts.s.sol b/nest/script/UpgradeNestContracts.s.sol index 382e451..ca1bb8f 100644 --- a/nest/script/UpgradeNestContracts.s.sol +++ b/nest/script/UpgradeNestContracts.s.sol @@ -14,11 +14,13 @@ import { AggregateTokenProxy } from "../src/proxy/AggregateTokenProxy.sol"; contract UpgradeNestContracts is Script, Test { address private constant NEST_ADMIN_ADDRESS = 0xb015762405De8fD24d29A6e0799c12e0Ea81c1Ff; + address private constant BORING_VAULT_ADDRESS = 0xe644F07B1316f28a7F134998e021eA9f7135F351; + UUPSUpgradeable private constant AGGREGATE_TOKEN_PROXY = UUPSUpgradeable(payable(0x659619AEdf381c3739B0375082C2d61eC1fD8835)); // Add the component token addresses - address private constant ASSET_TOKEN = 0xF66DFD0A9304D3D6ba76Ac578c31C84Dc0bd4A00; + address private constant ASSET_TOKEN = 0x2DEc3B6AdFCCC094C31a2DCc83a43b5042220Ea2; // LiquidContinuousMultiTokenVault - Credbull address private constant COMPONENT_TOKEN = 0x4B1fC984F324D2A0fDD5cD83925124b61175f5C6; diff --git a/nest/script/UpgradepUSD.s.sol b/nest/script/UpgradepUSD.s.sol index ce2a86c..f4979c1 100644 --- a/nest/script/UpgradepUSD.s.sol +++ b/nest/script/UpgradepUSD.s.sol @@ -5,7 +5,6 @@ import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils import { ERC1967Utils } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Utils.sol"; import { Script } from "forge-std/Script.sol"; -import { Test } from "forge-std/Test.sol"; import { console2 } from "forge-std/console2.sol"; import { pUSDProxy } from "../src/proxy/pUSDProxy.sol"; @@ -13,7 +12,7 @@ import { pUSD } from "../src/token/pUSD.sol"; import { IERC20 } from "@openzeppelin/contracts/interfaces/IERC20.sol"; -contract UpgradePUSD is Script, Test { +contract UpgradePUSD is Script { // Constants address private constant ADMIN_ADDRESS = 0xb015762405De8fD24d29A6e0799c12e0Ea81c1Ff; @@ -35,6 +34,9 @@ contract UpgradePUSD is Script, Test { uint256 public currentTotalSupply; bool public isConnected; + // small hack to be excluded from coverage report + function test() public { } + function setUp() public { // Try to read implementation slot from proxy, this only works with RPC try vm.load(PUSD_PROXY, ERC1967Utils.IMPLEMENTATION_SLOT) returns (bytes32 implementation) { @@ -56,12 +58,13 @@ contract UpgradePUSD is Script, Test { console2.log("Vault:", currentVault); console2.log("Total Supply:", currentTotalSupply); } else { - vm.skip(true); + //TODO: Check this again + vm.skip(false); isConnected = false; } } catch { console2.log("No implementation found - skipping"); - vm.skip(true); + vm.skip(false); isConnected = false; } } diff --git a/nest/src/AggregateToken.sol b/nest/src/AggregateToken.sol index 2435f1c..881c334 100644 --- a/nest/src/AggregateToken.sol +++ b/nest/src/AggregateToken.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.25; +import { ERC4626Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol"; import { IERC20 } from "@openzeppelin/contracts/interfaces/IERC20.sol"; import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; import { ERC1155Holder } from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; @@ -55,6 +56,9 @@ contract AggregateToken is ComponentToken, IAggregateToken, ERC1155Holder { /// @notice Emitted when the AggregateToken contract is unpaused for deposits event Unpaused(); + /// @notice Emitted when the asset token is updated + event AssetTokenUpdated(IERC20 indexed oldAsset, IERC20 indexed newAsset); + // Errors /** @@ -63,6 +67,9 @@ contract AggregateToken is ComponentToken, IAggregateToken, ERC1155Holder { */ error ComponentTokenAlreadyListed(IComponentToken componentToken); + /// @notice Emitted when a ComponentToken is removed from the component token list + event ComponentTokenRemoved(IComponentToken indexed componentToken); + /** * @notice Indicates a failure because the ComponentToken is not in the component token list * @param componentToken ComponentToken that is not in the component token list @@ -124,7 +131,7 @@ contract AggregateToken is ComponentToken, IAggregateToken, ERC1155Holder { uint256 askPrice, uint256 bidPrice ) public initializer { - super.initialize(owner, name, symbol, IERC20(address(asset_)), false, false); + super.initialize(owner, name, symbol, IERC20(address(asset_)), false, true); AggregateTokenStorage storage $ = _getAggregateTokenStorage(); $.componentTokenList.push(asset_); @@ -166,19 +173,72 @@ contract AggregateToken is ComponentToken, IAggregateToken, ERC1155Holder { uint256 assets, address receiver, address controller - ) public override(ComponentToken, IComponentToken) returns (uint256 shares) { + ) public override(ComponentToken, IComponentToken) nonReentrant returns (uint256 shares) { if (_getAggregateTokenStorage().paused) { revert DepositPaused(); } return super.deposit(assets, receiver, controller); } + /** + * @inheritdoc ERC4626Upgradeable + * @dev Overridden to add pause check before deposit + * @param assets Amount of assets to deposit + * @param receiver Address that will receive the shares + * @return shares Amount of shares minted + */ + function deposit( + uint256 assets, + address receiver + ) public override(ERC4626Upgradeable, IERC4626) nonReentrant returns (uint256 shares) { + if (_getAggregateTokenStorage().paused) { + revert DepositPaused(); + } + return super.deposit(assets, receiver); + } + + /** + * @inheritdoc ComponentToken + * @dev Overridden to add pause check before minting + * @param shares Amount of shares to mint + * @param receiver Address that will receive the shares + * @param controller Address that controls the minting + * @return assets Amount of assets deposited + */ + function mint( + uint256 shares, + address receiver, + address controller + ) public override(ComponentToken) nonReentrant returns (uint256 assets) { + if (_getAggregateTokenStorage().paused) { + revert DepositPaused(); + } + return super.mint(shares, receiver, controller); + } + + /** + * @inheritdoc ERC4626Upgradeable + * @dev Overridden to add pause check before minting + * @param shares Amount of shares to mint + * @param receiver Address that will receive the shares + * @return assets Amount of assets deposited + */ + function mint( + uint256 shares, + address receiver + ) public override(ERC4626Upgradeable, IERC4626) nonReentrant returns (uint256 assets) { + if (_getAggregateTokenStorage().paused) { + revert DepositPaused(); + } + return super.mint(shares, receiver); + } + /// @inheritdoc IComponentToken function redeem( uint256 shares, address receiver, address controller - ) public override(ComponentToken, IComponentToken) returns (uint256 assets) { + ) public override(ComponentToken, IComponentToken) nonReentrant returns (uint256 assets) { return super.redeem(shares, receiver, controller); } @@ -223,6 +283,41 @@ contract AggregateToken is ComponentToken, IAggregateToken, ERC1155Holder { emit ComponentTokenListed(componentToken); } + /** + * @notice Remove a ComponentToken from the component token list + * @dev Only the owner can call this function. The ComponentToken must have zero balance to be removed. + * @param componentToken ComponentToken to remove + */ + function removeComponentToken( + IComponentToken componentToken + ) external nonReentrant onlyRole(ADMIN_ROLE) { + AggregateTokenStorage storage $ = _getAggregateTokenStorage(); + + // Check if component token exists + if (!$.componentTokenMap[componentToken]) { + revert ComponentTokenNotListed(componentToken); + } + + // Check if it's the current asset + if (address(componentToken) == asset()) { + revert ComponentTokenIsAsset(componentToken); + } + + // Remove from mapping + $.componentTokenMap[componentToken] = false; + + // Remove from array by finding and replacing with last element + for (uint256 i = 0; i < $.componentTokenList.length; i++) { + if ($.componentTokenList[i] == componentToken) { + $.componentTokenList[i] = $.componentTokenList[$.componentTokenList.length - 1]; + $.componentTokenList.pop(); + break; + } + } + + emit ComponentTokenUnlisted(componentToken); + } + /** * @notice Buy ComponentToken using `asset` * @dev Only the owner can call this function, will revert if diff --git a/nest/src/ComponentToken.sol b/nest/src/ComponentToken.sol index 7681775..2080f3c 100644 --- a/nest/src/ComponentToken.sol +++ b/nest/src/ComponentToken.sol @@ -10,7 +10,8 @@ import { ReentrancyGuardUpgradeable } from "@openzeppelin/contracts-upgradeable/ import { IAccessControl } from "@openzeppelin/contracts/access/IAccessControl.sol"; import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; + +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { ERC165 } from "@openzeppelin/contracts/utils/introspection/ERC165.sol"; import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; @@ -110,6 +111,46 @@ abstract contract ComponentToken is */ error Unauthorized(address sender, address authorizedUser); + /** + * @notice Indicates a failure because the operation was called in async mode + * @dev This error is thrown when trying to perform a synchronous operation while async mode is enabled + */ + error AsyncOperationsEnabled(); + + /** + * @notice Indicates a failure because the operation was called in sync mode + * @dev This error is thrown when trying to perform an asynchronous operation while async mode is disabled + */ + error AsyncOperationsDisabled(); + + /** + * @notice Indicates a failure because there are no claimable deposits for the controller + * @dev This error is thrown when trying to claim a deposit but either the assets + * or shares amount in the request is zero + */ + error NoClaimableDeposit(); + + /** + * @notice Indicates a failure because there are no claimable redemptions for the controller + * @dev This error is thrown when trying to claim a redemption but either the assets + * or shares amount in the request is zero + */ + error NoClaimableRedeem(); + + /** + * @notice Indicates a failure because the deposit amount doesn't match the claimable amount + * @param provided Amount of assets provided for deposit + * @param required Amount of assets required (claimable amount) + */ + error InvalidDepositAmount(uint256 provided, uint256 required); + + /** + * @notice Indicates a failure because the redeem amount doesn't match the claimable amount + * @param provided Amount of shares provided for redemption + * @param required Amount of shares required (claimable amount) + */ + error InvalidRedeemAmount(uint256 provided, uint256 required); + /** * @notice Indicates a failure because the controller does not have enough requested * @param controller Address of the controller who does not have enough requested @@ -255,9 +296,7 @@ abstract contract ComponentToken is revert Unimplemented(); } - if (!IERC20(asset()).transferFrom(owner, address(this), assets)) { - revert InsufficientBalance(IERC20(asset()), owner, assets); - } + SafeERC20.safeTransferFrom(IERC20(asset()), owner, address(this), assets); $.pendingDepositRequest[controller] += assets; emit DepositRequest(controller, owner, REQUEST_ID, owner, assets); @@ -304,23 +343,30 @@ abstract contract ComponentToken is } ComponentTokenStorage storage $ = _getComponentTokenStorage(); + if ($.asyncDeposit) { - if ($.claimableDepositRequest[controller] < assets) { - revert InsufficientRequestBalance(controller, assets, 1); - } + // For async deposits, we must use the full claimable amount + uint256 claimableAssets = $.claimableDepositRequest[controller]; shares = $.sharesDepositRequest[controller]; - $.claimableDepositRequest[controller] -= assets; - $.sharesDepositRequest[controller] -= shares; - } else { - if (!IERC20(asset()).transferFrom(controller, address(this), assets)) { - revert InsufficientBalance(IERC20(asset()), controller, assets); + + if (claimableAssets == 0 || shares == 0) { + revert NoClaimableDeposit(); } + if (assets != claimableAssets) { + revert InvalidDepositAmount(assets, claimableAssets); + } + + // Reset state atomically + $.claimableDepositRequest[controller] = 0; + $.sharesDepositRequest[controller] = 0; + } else { + SafeERC20.safeTransferFrom(IERC20(asset()), controller, address(this), assets); shares = convertToShares(assets); } _mint(receiver, shares); - emit Deposit(controller, receiver, assets, shares); + return shares; } /// @inheritdoc IERC7540 @@ -337,23 +383,30 @@ abstract contract ComponentToken is } ComponentTokenStorage storage $ = _getComponentTokenStorage(); - assets = convertToAssets(shares); - if ($.asyncDeposit) { - if ($.claimableDepositRequest[controller] < assets) { - revert InsufficientRequestBalance(controller, assets, 1); + // Check shares directly instead of converting to assets + if ($.sharesDepositRequest[controller] < shares) { + revert InsufficientRequestBalance(controller, shares, 1); } - $.claimableDepositRequest[controller] -= assets; - $.sharesDepositRequest[controller] -= shares; - } else { - if (!IERC20(asset()).transferFrom(controller, address(this), assets)) { - revert InsufficientBalance(IERC20(asset()), controller, assets); + + // Get the pre-calculated values + uint256 claimableShares = $.sharesDepositRequest[controller]; + + // Verify shares match exactly + if (shares != claimableShares) { + revert InvalidDepositAmount(shares, claimableShares); } - } + assets = $.claimableDepositRequest[controller]; + $.claimableDepositRequest[controller] = 0; + $.sharesDepositRequest[controller] = 0; + } else { + assets = previewMint(shares); + SafeERC20.safeTransferFrom(IERC20(asset()), controller, address(this), assets); + } _mint(receiver, shares); - - emit Deposit(controller, receiver, assets, shares); + emit Deposit(msg.sender, receiver, assets, shares); + return assets; } /// @inheritdoc IComponentToken @@ -407,7 +460,14 @@ abstract contract ComponentToken is emit RedeemNotified(controller, assets, shares); } - /// @inheritdoc IERC7540 + /** + * @notice Fulfill a synchronous request to redeem assets by transferring assets to the receiver + * @dev This function can only be called when async redemptions are disabled + * @param shares Amount of shares to redeem + * @param receiver Address to receive the assets + * @param controller Controller of the request + * @return assets Amount of assets sent to the receiver + */ function redeem( uint256 shares, address receiver, @@ -421,23 +481,74 @@ abstract contract ComponentToken is } ComponentTokenStorage storage $ = _getComponentTokenStorage(); + if ($.asyncRedeem) { - if ($.claimableRedeemRequest[controller] < shares) { - revert InsufficientRequestBalance(controller, shares, 3); - } + // For async redemptions, we must use the full claimable amount + uint256 claimableShares = $.claimableRedeemRequest[controller]; assets = $.assetsRedeemRequest[controller]; - $.claimableRedeemRequest[controller] -= shares; - $.assetsRedeemRequest[controller] -= assets; + + if (claimableShares == 0 || assets == 0) { + revert NoClaimableRedeem(); + } + if (shares != claimableShares) { + revert InvalidRedeemAmount(shares, claimableShares); + } + + // Reset state atomically + $.claimableRedeemRequest[controller] = 0; + $.assetsRedeemRequest[controller] = 0; } else { + // For sync redemptions, process normally _burn(controller, shares); assets = convertToAssets(shares); } + SafeERC20.safeTransfer(IERC20(asset()), receiver, assets); + + emit Withdraw(controller, receiver, controller, assets, shares); + return assets; + } + + /** + * @notice Claim an approved asynchronous redeem request and transfer assets to the receiver + * @dev This function can only be called when async redemptions are enabled + * and will revert if there are no claimable redemptions for the controller. + * All state for the request is atomically reset after a successful claim. + * @param receiver Address to receive the redeemed assets + * @param controller Controller of the redeem request + * @return assets Amount of assets sent to the receiver + * @return shares Amount of shares that were redeemed + */ + function claimRedeem( + address receiver, + address controller + ) public virtual nonReentrant returns (uint256 assets, uint256 shares) { + if (msg.sender != controller) { + revert Unauthorized(msg.sender, controller); + } + + ComponentTokenStorage storage $ = _getComponentTokenStorage(); + if (!$.asyncRedeem) { + revert AsyncOperationsDisabled(); + } + + shares = $.claimableRedeemRequest[controller]; + assets = $.assetsRedeemRequest[controller]; + + if (shares == 0 || assets == 0) { + revert NoClaimableRedeem(); + } + + // Reset state atomically + $.claimableRedeemRequest[controller] = 0; + $.assetsRedeemRequest[controller] = 0; + if (!IERC20(asset()).transfer(receiver, assets)) { revert InsufficientBalance(IERC20(asset()), address(this), assets); } emit Withdraw(controller, receiver, controller, assets, shares); + return (assets, shares); } /// @inheritdoc IERC7540 @@ -454,23 +565,31 @@ abstract contract ComponentToken is } ComponentTokenStorage storage $ = _getComponentTokenStorage(); - shares = convertToShares(assets); - if ($.asyncRedeem) { - if ($.claimableRedeemRequest[controller] < shares) { - revert InsufficientRequestBalance(controller, shares, 3); + if ($.assetsRedeemRequest[controller] < assets) { + revert InsufficientRequestBalance(controller, assets, 3); } - $.claimableRedeemRequest[controller] -= shares; - $.assetsRedeemRequest[controller] -= assets; - } else { - _burn(controller, shares); - } + // Get the pre-calculated values + uint256 claimableAssets = $.assetsRedeemRequest[controller]; + shares = $.claimableRedeemRequest[controller]; - if (!IERC20(asset()).transfer(receiver, assets)) { - revert InsufficientBalance(IERC20(asset()), address(this), assets); - } + // Verify assets match exactly + if (assets != claimableAssets) { + revert InvalidRedeemAmount(assets, claimableAssets); + } - emit Withdraw(controller, receiver, controller, assets, shares); + // Reset state atomically + $.claimableRedeemRequest[controller] = 0; + $.assetsRedeemRequest[controller] = 0; + + // No _burn needed here as shares were already burned in requestRedeem + SafeERC20.safeTransfer(IERC20(asset()), receiver, assets); + emit Withdraw(controller, receiver, controller, assets, shares); + } else { + shares = previewWithdraw(assets); + _withdraw(controller, receiver, controller, assets, shares); + } + return shares; } // Getter View Functions @@ -529,7 +648,7 @@ abstract contract ComponentToken is if (_getComponentTokenStorage().asyncDeposit) { revert Unimplemented(); } - assets = super.previewDeposit(shares); + assets = convertToAssets(shares); } /** @@ -556,7 +675,7 @@ abstract contract ComponentToken is if (_getComponentTokenStorage().asyncRedeem) { revert Unimplemented(); } - shares = super.previewWithdraw(assets); + shares = convertToShares(assets); } /// @inheritdoc IERC7540 diff --git a/nest/src/interfaces/IComponentToken.sol b/nest/src/interfaces/IComponentToken.sol index 1df421a..0d7e129 100644 --- a/nest/src/interfaces/IComponentToken.sol +++ b/nest/src/interfaces/IComponentToken.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.25; interface IComponentToken { - // Events + // User Functions /** * @notice Emitted when the owner of some assets submits a request to buy shares @@ -29,28 +29,6 @@ interface IComponentToken { address indexed controller, address indexed owner, uint256 indexed requestId, address sender, uint256 shares ); - /** - * @notice Emitted when a deposit request is complete - * @param sender Controller of the request - * @param owner Source of the assets to deposit - * @param assets Amount of `asset` that has been deposited - * @param shares Amount of shares that has been received in exchange - */ - // event Deposit(address indexed sender, address indexed owner, uint256 assets, uint256 shares); - - /** - * @notice Emitted when a redeem request is complete - * @param sender Controller of the request - * @param receiver Address to receive the assets - * @param owner Source of the shares to redeem - * @param assets Amount of `asset` that has been received in exchange - * @param shares Amount of shares that has been redeemed - */ - // event Withdraw(address indexed sender, address indexed receiver, address indexed owner, uint256 assets, uint256 - // shares); - - // User Functions - /** * @notice Transfer assets from the owner into the vault and submit a request to buy shares * @param assets Amount of `asset` to deposit diff --git a/nest/src/token/BoringVaultAdapter.sol b/nest/src/token/BoringVaultAdapter.sol index 81e4f0d..931dbc0 100644 --- a/nest/src/token/BoringVaultAdapter.sol +++ b/nest/src/token/BoringVaultAdapter.sol @@ -46,6 +46,7 @@ abstract contract BoringVaultAdapter is error InvalidSender(); error InvalidController(); error InvalidVault(); + error InvalidAccountant(); error AssetNotSupported(); error TellerPaused(); @@ -360,12 +361,18 @@ abstract contract BoringVaultAdapter is */ function previewDeposit( uint256 assets - ) public view virtual override(ComponentToken) returns (uint256) { + ) public view override(ComponentToken) returns (uint256 shares) { BoringVaultAdapterStorage storage $ = _getBoringVaultAdapterStorage(); - return $.boringVault.lens.previewDeposit( - IERC20(address($.asset)), assets, $.boringVault.vault, $.boringVault.accountant - ); + try $.boringVault.vault.decimals() returns (uint8 shareDecimals) { + try $.boringVault.accountant.getRateInQuote(ERC20(asset())) returns (uint256 rate) { + shares = assets.mulDivDown(10 ** shareDecimals, rate); + } catch { + revert InvalidAccountant(); // Or could create a new error like `InvalidAccountant` + } + } catch { + revert InvalidVault(); + } } /** @@ -448,8 +455,7 @@ abstract contract BoringVaultAdapter is function balanceOf( address account ) public view override(IERC20, ERC20Upgradeable) returns (uint256) { - BoringVaultAdapterStorage storage $ = _getBoringVaultAdapterStorage(); - return $.boringVault.lens.balanceOf(account, $.boringVault.vault); + return super.balanceOf(account); } /** @@ -460,8 +466,7 @@ abstract contract BoringVaultAdapter is function assetsOf( address account ) public view virtual override(ComponentToken) returns (uint256) { - BoringVaultAdapterStorage storage $ = _getBoringVaultAdapterStorage(); - return $.boringVault.lens.balanceOfInAssets(account, $.boringVault.vault, $.boringVault.accountant); + return super.assetsOf(account); } // ========== METADATA OVERRIDES ========== diff --git a/nest/test/AggregateToken.t.sol b/nest/test/AggregateToken.t.sol new file mode 100644 index 0000000..8a34ad4 --- /dev/null +++ b/nest/test/AggregateToken.t.sol @@ -0,0 +1,309 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; + +import { AggregateToken } from "../src/AggregateToken.sol"; +import { IComponentToken } from "../src/interfaces/IComponentToken.sol"; +import { AggregateTokenProxy } from "../src/proxy/AggregateTokenProxy.sol"; +import { Test } from "forge-std/Test.sol"; +import { console } from "forge-std/console.sol"; + +import { MockInvalidToken } from "../src/mocks/MockInvalidToken.sol"; +import { MockUSDC } from "../src/mocks/MockUSDC.sol"; + +contract AggregateTokenTest is Test { + + AggregateToken public token; + MockUSDC public usdc; + MockUSDC public newUsdc; + address public owner; + address public user1; + address public user2; + + // Events + event AssetTokenUpdated(IERC20 indexed oldAsset, IERC20 indexed newAsset); + event ComponentTokenListed(IComponentToken indexed componentToken); + event ComponentTokenUnlisted(IComponentToken indexed componentToken); + event ComponentTokenBought( + address indexed buyer, IComponentToken indexed componentToken, uint256 componentTokenAmount, uint256 assets + ); + event ComponentTokenSold( + address indexed seller, IComponentToken indexed componentToken, uint256 componentTokenAmount, uint256 assets + ); + event Paused(); + event Unpaused(); + event ComponentTokenRemoved(IComponentToken indexed componentToken); + + event ComponentTokenBuyRequested( + address indexed buyer, IComponentToken indexed componentToken, uint256 assets, uint256 requestId + ); + + event ComponentTokenSellRequested( + address indexed seller, IComponentToken indexed componentToken, uint256 componentTokenAmount, uint256 requestId + ); + + function setUp() public { + owner = makeAddr("owner"); + user1 = makeAddr("user1"); + user2 = makeAddr("user2"); + + // Deploy tokens + usdc = new MockUSDC(); + newUsdc = new MockUSDC(); + + // Deploy through proxy + AggregateToken impl = new AggregateToken(); + ERC1967Proxy proxy = new AggregateTokenProxy( + address(impl), + abi.encodeCall( + AggregateToken.initialize, + ( + owner, + "Aggregate Token", + "AGG", + IComponentToken(address(usdc)), + 1e18, // 1:1 askPrice + 1e18 // 1:1 bidPrice + ) + ) + ); + token = AggregateToken(address(proxy)); + + // Setup initial balances and approvals + usdc.mint(user1, 1000e6); + vm.prank(user1); + usdc.approve(address(token), type(uint256).max); + } + + function testAddComponentToken() public { + vm.startPrank(owner); + + // Should succeed first time + token.addComponentToken(IComponentToken(address(newUsdc))); + + // Should fail second time + vm.expectRevert( + abi.encodeWithSelector( + AggregateToken.ComponentTokenAlreadyListed.selector, IComponentToken(address(newUsdc)) + ) + ); + token.addComponentToken(IComponentToken(address(newUsdc))); + + vm.stopPrank(); + } + + function testRemoveComponentToken() public { + vm.startPrank(owner); + + // Add a token first + token.addComponentToken(IComponentToken(address(newUsdc))); + + // Should fail when trying to remove current asset + vm.expectRevert( + abi.encodeWithSelector(AggregateToken.ComponentTokenIsAsset.selector, IComponentToken(address(usdc))) + ); + token.removeComponentToken(IComponentToken(address(usdc))); + + // Should succeed with non-asset token + token.removeComponentToken(IComponentToken(address(newUsdc))); + + // Should fail when trying to remove non-existent token + vm.expectRevert( + abi.encodeWithSelector(AggregateToken.ComponentTokenNotListed.selector, IComponentToken(address(newUsdc))) + ); + token.removeComponentToken(IComponentToken(address(newUsdc))); + + vm.stopPrank(); + } + + function testPauseUnpause() public { + vm.startPrank(owner); + + // Should start unpaused + assertFalse(token.isPaused()); + + // Should pause + vm.expectEmit(address(token)); + emit Paused(); + token.pause(); + assertTrue(token.isPaused()); + + // Should fail when already paused + vm.expectRevert(AggregateToken.AlreadyPaused.selector); + token.pause(); + + // Should unpause + vm.expectEmit(address(token)); + emit Unpaused(); + token.unpause(); + assertFalse(token.isPaused()); + + // Should fail when already unpaused + vm.expectRevert(AggregateToken.NotPaused.selector); + token.unpause(); + + vm.stopPrank(); + } + + function testSetPrices() public { + // Grant price updater role + bytes32 priceUpdaterRole = token.PRICE_UPDATER_ROLE(); + vm.startPrank(owner); + token.grantRole(priceUpdaterRole, owner); + + // Test ask price + token.setAskPrice(2e18); + assertEq(token.getAskPrice(), 2e18); + + // Test bid price + token.setBidPrice(1.5e18); + assertEq(token.getBidPrice(), 1.5e18); + + vm.stopPrank(); + } + + function testConversion() public { + // Test convertToShares + assertEq(token.convertToShares(2e18), 2e18); // With askPrice = 1e18, 2 assets = 2 shares + + // Test convertToAssets + assertEq(token.convertToAssets(2e18), 2e18); // With bidPrice = 1e18, 2 shares = 2 assets + + // Test with different prices + vm.startPrank(owner); + token.grantRole(token.PRICE_UPDATER_ROLE(), owner); + token.setAskPrice(2e18); // 2:1 ratio + token.setBidPrice(0.5e18); // 1:2 ratio + vm.stopPrank(); + + assertEq(token.convertToShares(2e18), 1e18); // 2 assets = 1 share at 2:1 ratio + assertEq(token.convertToAssets(2e18), 1e18); // 2 shares = 1 asset at 1:2 ratio + } + + function testDeposit() public { + // Test deposit when paused + vm.prank(owner); + token.pause(); + + vm.expectRevert(AggregateToken.DepositPaused.selector); + token.deposit(1e18, address(this), address(this)); + + // Test successful deposit + vm.prank(owner); + token.unpause(); + + vm.startPrank(user1); + usdc.approve(address(token), 1e18); + uint256 shares = token.deposit(1e18, user1, user1); + assertEq(shares, 1e18); + assertEq(token.balanceOf(user1), 1e18); + vm.stopPrank(); + } + + function testRedeem() public { + // Setup: First deposit some tokens + vm.startPrank(user1); + usdc.approve(address(token), 1e18); + token.deposit(1e18, user1, user1); + + // Test redeem + uint256 assets = token.redeem(1e18, user1, user1); + assertEq(assets, 1e18); + assertEq(token.balanceOf(user1), 0); + assertEq(usdc.balanceOf(user1), 1000e6); // Back to original balance + vm.stopPrank(); + } + + function testTotalAssets() public { + assertEq(token.totalAssets(), 0); + + // Deposit some assets + vm.startPrank(user1); + usdc.approve(address(token), 1e18); + token.deposit(1e18, user1, user1); + assertEq(token.totalAssets(), 1e18); + vm.stopPrank(); + } + + function testApproveComponentToken() public { + vm.startPrank(owner); + + // Test ComponentTokenNotListed error + vm.expectRevert( + abi.encodeWithSelector(AggregateToken.ComponentTokenNotListed.selector, IComponentToken(address(newUsdc))) + ); + token.approveComponentToken(IComponentToken(address(newUsdc)), 1e6); + + // Test successful approval + token.addComponentToken(IComponentToken(address(newUsdc))); + token.approveComponentToken(IComponentToken(address(newUsdc)), 1e6); + assertEq(usdc.allowance(address(token), address(newUsdc)), 1e6); + + vm.stopPrank(); + } + + function testComponentTokenOperations() public { + vm.startPrank(owner); + + // Test buyComponentToken + token.addComponentToken(IComponentToken(address(newUsdc))); + vm.expectEmit(address(token)); + emit ComponentTokenBought(owner, IComponentToken(address(newUsdc)), 1e18, 1e18); + token.buyComponentToken(IComponentToken(address(newUsdc)), 1e18); + + // Test sellComponentToken + vm.expectEmit(address(token)); + emit ComponentTokenSold(owner, IComponentToken(address(newUsdc)), 1e18, 1e18); + token.sellComponentToken(IComponentToken(address(newUsdc)), 1e18); + + // Test requestBuyComponentToken + vm.expectEmit(address(token)); + emit ComponentTokenBuyRequested(owner, IComponentToken(address(newUsdc)), 1e18, 0); + token.requestBuyComponentToken(IComponentToken(address(newUsdc)), 1e18); + + // Test requestSellComponentToken + vm.expectEmit(address(token)); + emit ComponentTokenSellRequested(owner, IComponentToken(address(newUsdc)), 1e18, 0); + token.requestSellComponentToken(IComponentToken(address(newUsdc)), 1e18); + + vm.stopPrank(); + } + + function testGetters() public { + // Test getComponentTokenList + IComponentToken[] memory list = token.getComponentTokenList(); + assertEq(list.length, 1); + assertEq(address(list[0]), address(usdc)); + + // Test getComponentToken + assertTrue(token.getComponentToken(IComponentToken(address(usdc)))); + assertFalse(token.getComponentToken(IComponentToken(address(newUsdc)))); + } + /* + function testSupportsInterface() public { + // Test standard interfaces + assertTrue(token.supportsInterface(type(IERC20).interfaceId)); + assertTrue(token.supportsInterface(type(IERC4626).interfaceId)); + assertTrue(token.supportsInterface(type(IAccessControl).interfaceId)); + + // Test custom interfaces + assertTrue(token.supportsInterface(type(IComponentToken).interfaceId)); + assertTrue(token.supportsInterface(type(IAggregateToken).interfaceId)); + } + */ + // Helper function for access control error message + + function accessControlErrorMessage(address account, bytes32 role) internal pure returns (bytes memory) { + return abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(account), + " is missing role ", + Strings.toHexString(uint256(role), 32) + ); + } + +}