From 9c10ef9df1b1d1d6fbcd2f646fbac799c967e18e Mon Sep 17 00:00:00 2001 From: 0xadrii Date: Fri, 26 Jul 2024 20:45:46 +0200 Subject: [PATCH] feat: YieldBox tests wip --- cache/fuzz/failures | 18 + cache/test-failures | 1 + contracts/AssetRegister.sol | 2 +- contracts/ERC1155.sol | 2 +- contracts/NativeTokenFactory.sol | 8 +- contracts/OZOwnable.sol | 2 +- contracts/YieldBox.sol | 388 +++++++--- contracts/YieldBoxPermit.sol | 6 +- contracts/strategies/BaseStrategy.sol | 19 +- contracts/strategies/ERC20WithoutStrategy.sol | 2 +- .../strategies/ERC721WithoutStrategy.sol | 35 + foundry.toml | 6 + script/Counter.s.sol | 19 - src/Counter.sol | 14 - test/Base.t.sol | 263 +++++++ test/Counter.t.sol | 24 - test/mocks/ERC20/ERC20MissingReturn.sol | 71 ++ test/mocks/ERC20/ERC20Mock.sol | 10 + test/mocks/ERC20/WrappedNativeMock.sol | 66 ++ test/unit/concrete/yieldbox/YieldBox.t.sol | 179 +++++ .../concrete/yieldbox/batchTransfer.t.sol | 355 +++++++++ .../unit/concrete/yieldbox/batchTransfer.tree | 18 + test/unit/concrete/yieldbox/constructor.t.sol | 44 ++ test/unit/concrete/yieldbox/constructor.tree | 7 + .../unit/concrete/yieldbox/depositAsset.t.sol | 675 ++++++++++++++++++ test/unit/concrete/yieldbox/depositAsset.tree | 35 + .../concrete/yieldbox/depositETHAsset.t.sol | 213 ++++++ .../concrete/yieldbox/depositETHAsset.tree | 20 + test/unit/concrete/yieldbox/transfer.t.sol | 307 ++++++++ test/unit/concrete/yieldbox/transfer.tree | 20 + .../concrete/yieldbox/transferMultiple.t.sol | 426 +++++++++++ .../concrete/yieldbox/transferMultiple.tree | 20 + test/unit/concrete/yieldbox/withdraw.t.sol | 626 ++++++++++++++++ test/unit/concrete/yieldbox/withdraw.tree | 22 + test/utils/Constants.sol | 26 + test/utils/Errors.sol | 19 + test/utils/Events.sol | 58 ++ test/utils/Types.sol | 18 + test/utils/Utils.sol | 13 + 39 files changed, 3904 insertions(+), 153 deletions(-) create mode 100644 cache/fuzz/failures create mode 100644 cache/test-failures create mode 100644 contracts/strategies/ERC721WithoutStrategy.sol delete mode 100644 script/Counter.s.sol delete mode 100644 src/Counter.sol create mode 100644 test/Base.t.sol delete mode 100644 test/Counter.t.sol create mode 100644 test/mocks/ERC20/ERC20MissingReturn.sol create mode 100644 test/mocks/ERC20/ERC20Mock.sol create mode 100644 test/mocks/ERC20/WrappedNativeMock.sol create mode 100644 test/unit/concrete/yieldbox/YieldBox.t.sol create mode 100644 test/unit/concrete/yieldbox/batchTransfer.t.sol create mode 100644 test/unit/concrete/yieldbox/batchTransfer.tree create mode 100644 test/unit/concrete/yieldbox/constructor.t.sol create mode 100644 test/unit/concrete/yieldbox/constructor.tree create mode 100644 test/unit/concrete/yieldbox/depositAsset.t.sol create mode 100644 test/unit/concrete/yieldbox/depositAsset.tree create mode 100644 test/unit/concrete/yieldbox/depositETHAsset.t.sol create mode 100644 test/unit/concrete/yieldbox/depositETHAsset.tree create mode 100644 test/unit/concrete/yieldbox/transfer.t.sol create mode 100644 test/unit/concrete/yieldbox/transfer.tree create mode 100644 test/unit/concrete/yieldbox/transferMultiple.t.sol create mode 100644 test/unit/concrete/yieldbox/transferMultiple.tree create mode 100644 test/unit/concrete/yieldbox/withdraw.t.sol create mode 100644 test/unit/concrete/yieldbox/withdraw.tree create mode 100644 test/utils/Constants.sol create mode 100644 test/utils/Errors.sol create mode 100644 test/utils/Events.sol create mode 100644 test/utils/Types.sol create mode 100644 test/utils/Utils.sol diff --git a/cache/fuzz/failures b/cache/fuzz/failures new file mode 100644 index 0000000..89ed2cb --- /dev/null +++ b/cache/fuzz/failures @@ -0,0 +1,18 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 9040c3260c9c57c024a243facc80b429ca054b11c110918dbb1543b82ac153ac # shrinks to 0xc25f3d2d0000000000000000000000000000000000000000000000000000000000001236 +cc 69dddd67435b265e7f01fa24a20f3e7965cfcbf303f1788dad39a2a4c3fa8423 # shrinks to 0xc25f3d2d0000000000000000000000000000000000000000000000000026cfe865801d54 +cc 03331ec39ab18b1e2abcac3fc460b97b5e44e0a6feb3c5a26f95c46c6157d1a1 # shrinks to 0x4c968046000000000000000000000049d881b0c940ef11e0badcaf5dbffdbb200d061b39 +cc b141feffdc218718993b4033ddc744a0733409376d537029cbee7f684e4114bf # shrinks to 0x3abb63771951b487797142ed68f7f9f1bc353651fb04c123ecf6934b58d9760bef393531 +cc 6980e13f435e4b7404dc6c693ea15fb9dc45722a34838ef931b329b2f4f79712 # shrinks to 0x5075f177000000000000000000000000000000000000000000000000ffffffffffffffff +cc 94be2481c895c7331df01fa6c24da158168904c48e1aadc71d98ee21aa99580f # shrinks to 0xf420ce1b0000000000000000000000000000000000000000000000000000000000000000 +cc 0d9d7c7fd825d3fdeb89a6eaee0e26d9f47024aad409215f901569c770d871bd # shrinks to 0x104eedbf00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000 +cc 8b27d8b63357129a444c733398b2e9596d090a0a00196789936c58166bb53659 # shrinks to 0x80ca6f6d0000000000000000000000000000000000000000000000000000015a17e7deb7000000000000000000000000000000000000000000000000000000000000001b +cc d7f5a4ef4590b69dc183603db010449221ac0404245a117ff5be78aaf33e6916 # shrinks to 0x0097a1480000000000000000000000000000000000000000000000000000000000000000 +cc 28378ff8238f0f55f3ec47c2219e9ca36e013036f44843f2efba5c32d90682e4 # shrinks to 0x620872930000000000000000000000000000000000000000000000286200f69c0e65347d +cc 315ee59773a931c51a86c2679800f9d8fc6e7e4d1e8f2dc9446ac25487b6f4ff # shrinks to 0x3255e7dc00000000000000000000000000000000000000000000001769366df606516aa1 +cc ad036b659b802c36284fb0f1be6f370e2eef10c3fe28534b9a31dd7b01261f6b # shrinks to 0x691a05500000000000000000000000000000000000000000000000000000000000000007 diff --git a/cache/test-failures b/cache/test-failures new file mode 100644 index 0000000..6317b60 --- /dev/null +++ b/cache/test-failures @@ -0,0 +1 @@ +test_batchTransfer_whenValueIsSmallerOrEqualToBalance \ No newline at end of file diff --git a/contracts/AssetRegister.sol b/contracts/AssetRegister.sol index 20dcf89..ea08828 100644 --- a/contracts/AssetRegister.sol +++ b/contracts/AssetRegister.sol @@ -37,7 +37,7 @@ contract AssetRegister is ERC1155 { constructor() { assets.push(Asset(TokenType.None, address(0), NO_STRATEGY, 0)); } - + function assetCount() public view returns (uint256) { return assets.length; } diff --git a/contracts/ERC1155.sol b/contracts/ERC1155.sol index 4aebd51..29bed04 100644 --- a/contracts/ERC1155.sol +++ b/contracts/ERC1155.sol @@ -62,7 +62,7 @@ contract ERC1155 is IERC1155 { } function _transferSingle( - address from, + address from, address to, uint256 id, uint256 value diff --git a/contracts/NativeTokenFactory.sol b/contracts/NativeTokenFactory.sol index 41b543e..5a633c0 100644 --- a/contracts/NativeTokenFactory.sol +++ b/contracts/NativeTokenFactory.sol @@ -46,13 +46,13 @@ contract NativeTokenFactory is AssetRegister { /// If 'from' is msg.sender, it's allowed. /// If 'msg.sender' is an address (an operator) that is approved by 'from', it's allowed. modifier allowed(address _from, uint256 _id) { - _requireTransferAllowed( + _requireTransferAllowed( _from, isApprovedForAsset[_from][msg.sender][_id] - ); + ); _; - } - + } + /// @notice Only allows the `owner` to execute the function. /// @param tokenId The `tokenId` that the sender has to be owner of. modifier onlyOwner(uint256 tokenId) { diff --git a/contracts/OZOwnable.sol b/contracts/OZOwnable.sol index 49f1536..29ecb32 100644 --- a/contracts/OZOwnable.sol +++ b/contracts/OZOwnable.sol @@ -30,7 +30,7 @@ abstract contract OZOwnable is Context { constructor() { _transferOwnership(_msgSender()); } - + /** * @dev Throws if called by any account other than the owner. */ diff --git a/contracts/YieldBox.sol b/contracts/YieldBox.sol index 8ce943a..00ea804 100644 --- a/contracts/YieldBox.sol +++ b/contracts/YieldBox.sol @@ -94,6 +94,7 @@ contract YieldBox is // *** ERRORS ******** // // ******************* // error InvalidTokenType(); + error InvalidZeroAmounts(); error NotWrapped(); error AmountTooLow(); error RefundFailed(); @@ -111,9 +112,12 @@ contract YieldBox is YieldBoxURIBuilder public immutable uriBuilder; Pearlmit public pearlmit; - constructor(IWrappedNative wrappedNative_, YieldBoxURIBuilder uriBuilder_, Pearlmit pearlmit_, address owner_) - YieldBoxPermit("YieldBox") - { + constructor( + IWrappedNative wrappedNative_, + YieldBoxURIBuilder uriBuilder_, + Pearlmit pearlmit_, + address owner_ + ) YieldBoxPermit("YieldBox") { wrappedNative = wrappedNative_; uriBuilder = uriBuilder_; pearlmit = pearlmit_; @@ -126,7 +130,9 @@ contract YieldBox is /// @dev Returns the total balance of `token` the strategy contract holds, /// plus the total amount this contract thinks the strategy holds. - function _tokenBalanceOf(Asset storage asset) internal view returns (uint256 amount) { + function _tokenBalanceOf( + Asset storage asset + ) internal view returns (uint256 amount) { return asset.strategy.currentBalance(); } @@ -142,7 +148,13 @@ contract YieldBox is /// @param share Token amount represented in shares to deposit. Takes precedence over `amount`. /// @return amountOut The amount deposited. /// @return shareOut The deposited amount repesented in shares. - function depositAsset(uint256 assetId, address from, address to, uint256 amount, uint256 share) + function depositAsset( + uint256 assetId, + address from, + address to, + uint256 amount, + uint256 share + ) public allowed(from, assetId) returns (uint256 amountOut, uint256 shareOut) @@ -150,7 +162,10 @@ contract YieldBox is // Checks Asset storage asset = assets[assetId]; if (asset.tokenType == TokenType.Native) revert InvalidTokenType(); - if (asset.tokenType == TokenType.ERC721) revert InvalidTokenType(); + if (asset.tokenType == TokenType.ERC721) revert InvalidTokenType(); // @audit-issue [LOW-01] - Should check for asset type `NONE` + if (asset.tokenType == TokenType.None) revert InvalidTokenType(); + + if (share == 0 && amount == 0) revert InvalidZeroAmounts(); // Effects uint256 totalAmount = _tokenBalanceOf(asset); @@ -166,38 +181,71 @@ contract YieldBox is // Interactions if (asset.tokenType == TokenType.ERC20) { - (uint256 allowedAmount,) = pearlmit.allowance(from, address(this), 20, asset.contractAddress, 0); + (uint256 allowedAmount, ) = pearlmit.allowance( + from, + address(this), + 20, + asset.contractAddress, + 0 + ); // Check whether the tokens are Pearlmit approved if (allowedAmount >= amount) { // If approved, use the Pearlmit transfer function - bool isErr = pearlmit.transferFromERC20(from, address(asset.strategy), asset.contractAddress, amount); + bool isErr = pearlmit.transferFromERC20( + from, + address(asset.strategy), + asset.contractAddress, + amount + ); if (isErr) revert PearlmitTransferFailed(); } else { // If not approved through Pearlmit, use the token transfer function // For ERC20 tokens, use the safe helper function to deal with broken ERC20 implementations. This actually calls transferFrom on the ERC20 contract. - IERC20(asset.contractAddress).safeTransferFrom(from, address(asset.strategy), amount); + IERC20(asset.contractAddress).safeTransferFrom( + from, + address(asset.strategy), + amount + ); } } else { // ERC1155 // When depositing yieldBox tokens into the yieldBox, things can be simplified if (asset.contractAddress == address(this)) { - _transferSingle(from, address(asset.strategy), asset.tokenId, amount); + _transferSingle( + from, + address(asset.strategy), + asset.tokenId, + amount + ); } else { - (uint256 allowedAmount,) = - pearlmit.allowance(from, address(this), 1155, asset.contractAddress, asset.tokenId); + (uint256 allowedAmount, ) = pearlmit.allowance( + from, + address(this), + 1155, + asset.contractAddress, + asset.tokenId + ); // Check whether the tokens are Pearlmit approved if (allowedAmount >= amount) { // If approved, use the Pearlmit transfer function bool isErr = pearlmit.transferFromERC1155( - from, address(asset.strategy), asset.contractAddress, asset.tokenId, amount + from, + address(asset.strategy), + asset.contractAddress, + asset.tokenId, + amount ); if (isErr) revert PearlmitTransferFailed(); } else { // If not approved through Pearlmit, use the token transfer function IERC1155(asset.contractAddress).safeTransferFrom( - from, address(asset.strategy), asset.tokenId, amount, "" + from, + address(asset.strategy), + asset.tokenId, + amount, + "" ); } } @@ -205,7 +253,17 @@ contract YieldBox is asset.strategy.deposited(amount); - emit Deposited(msg.sender, from, to, assetId, amount, share, amountOut, shareOut, false); + emit Deposited( + msg.sender, + from, + to, + assetId, + amount, + share, + amountOut, + shareOut, + false + ); return (amount, share); } @@ -216,7 +274,11 @@ contract YieldBox is /// @param to which account to push the tokens. /// @return amountOut The amount deposited. /// @return shareOut The deposited amount repesented in shares. - function depositNFTAsset(uint256 assetId, address from, address to) + function depositNFTAsset( + uint256 assetId, + address from, + address to + ) public allowed(from, assetId) returns (uint256 amountOut, uint256 shareOut) @@ -229,17 +291,31 @@ contract YieldBox is _mint(to, assetId, 1); // Interactions - (uint256 allowedAmount,) = pearlmit.allowance(from, address(this), 721, asset.contractAddress, asset.tokenId); + (uint256 allowedAmount, ) = pearlmit.allowance( + from, + address(this), + 721, + asset.contractAddress, + asset.tokenId + ); // Check whether the tokens are Pearlmit approved if (allowedAmount > 0) { // If approved, use the Pearlmit transfer function - bool isErr = - pearlmit.transferFromERC721(from, address(asset.strategy), asset.contractAddress, asset.tokenId); + bool isErr = pearlmit.transferFromERC721( + from, + address(asset.strategy), + asset.contractAddress, + asset.tokenId + ); if (isErr) revert PearlmitTransferFailed(); } else { // If not approved through Pearlmit, use the token transfer function - IERC721(asset.contractAddress).safeTransferFrom(from, address(asset.strategy), asset.tokenId); + IERC721(asset.contractAddress).safeTransferFrom( + from, + address(asset.strategy), + asset.tokenId + ); } asset.strategy.deposited(1); @@ -255,11 +331,11 @@ contract YieldBox is /// @param amount ETH amount to deposit. /// @return amountOut The amount deposited. /// @return shareOut The deposited amount repesented in shares. - function depositETHAsset(uint256 assetId, address to, uint256 amount) - public - payable - returns (uint256 amountOut, uint256 shareOut) - { + function depositETHAsset( + uint256 assetId, + address to, + uint256 amount + ) public payable returns (uint256 amountOut, uint256 shareOut) { // Checks Asset storage asset = assets[assetId]; if (asset.tokenType != TokenType.ERC20) revert InvalidTokenType(); @@ -269,7 +345,11 @@ contract YieldBox is if (msg.value < amount) revert AmountTooLow(); // Effects - uint256 share = amount._toShares(totalSupply[assetId], _tokenBalanceOf(asset), false); + uint256 share = amount._toShares( + totalSupply[assetId], + _tokenBalanceOf(asset), + false + ); _mint(to, assetId, share); @@ -277,12 +357,25 @@ contract YieldBox is wrappedNative.deposit{value: amount}(); // Strategies always receive wrappedNative (supporting both wrapped and raw native tokens adds too much complexity) wrappedNative.safeTransfer(address(asset.strategy), amount); + asset.strategy.deposited(amount); - emit Deposited(msg.sender, msg.sender, to, assetId, amount, share, amountOut, shareOut, false); + emit Deposited( + msg.sender, + msg.sender, + to, + assetId, + amount, + share, + amountOut, + shareOut, + false + ); if (msg.value > amount) { - (bool success,) = msg.sender.call{value: msg.value - amount}(new bytes(0)); + (bool success, ) = msg.sender.call{value: msg.value - amount}( + new bytes(0) + ); if (!success) revert RefundFailed(); } @@ -295,7 +388,13 @@ contract YieldBox is /// @param to which user to push the tokens. /// @param amount of tokens. Either one of `amount` or `share` needs to be supplied. /// @param share Like above, but `share` takes precedence over `amount`. - function withdraw(uint256 assetId, address from, address to, uint256 amount, uint256 share) + function withdraw( + uint256 assetId, + address from, + address to, + uint256 amount, + uint256 share + ) public allowed(from, assetId) returns (uint256 amountOut, uint256 shareOut) @@ -317,10 +416,12 @@ contract YieldBox is /// @param assetId The id of the asset. /// @param from which user to pull the tokens. /// @param to which user to push the tokens. - function _withdrawNFT(Asset storage asset, uint256 assetId, address from, address to) - internal - returns (uint256 amountOut, uint256 shareOut) - { + function _withdrawNFT( + Asset storage asset, + uint256 assetId, + address from, + address to + ) internal returns (uint256 amountOut, uint256 shareOut) { _burn(from, assetId, 1); // Interactions @@ -348,6 +449,7 @@ contract YieldBox is ) internal returns (uint256 amountOut, uint256 shareOut) { // Effects uint256 totalAmount = _tokenBalanceOf(asset); + if (share == 0) { // value of the share paid could be lower than the amount paid due to rounding, in that case, add a share (Always round up) share = amount._toShares(totalSupply[assetId], totalAmount, true); @@ -361,7 +463,16 @@ contract YieldBox is // Interactions asset.strategy.withdraw(to, amount); - emit Withdraw(msg.sender, from, to, assetId, amount, share, amountOut, shareOut); + emit Withdraw( + msg.sender, + from, + to, + assetId, + amount, + share, + amountOut, + shareOut + ); return (amount, share); } @@ -371,34 +482,48 @@ contract YieldBox is /// @param to which user to push the tokens. /// @param assetId The id of the asset. /// @param share The amount of `token` in shares. - function transfer(address from, address to, uint256 assetId, uint256 share) public allowed(from, assetId) { + function transfer( + address from, + address to, + uint256 assetId, + uint256 share + ) public allowed(from, assetId) { _transferSingle(from, to, assetId, share); } - function batchTransfer(address from, address to, uint256[] calldata assetIds_, uint256[] calldata shares_) public { + function batchTransfer( + address from, + address to, + uint256[] calldata assetIds_, + uint256[] calldata shares_ + ) public { uint256 len = assetIds_.length; unchecked { for (uint256 i; i < len; i++) { - _requireTransferAllowed(from, isApprovedForAsset[from][msg.sender][assetIds_[i]]); + _requireTransferAllowed( + from, + isApprovedForAsset[from][msg.sender][assetIds_[i]] + ); } } _transferBatch(from, to, assetIds_, shares_); } - function _transferBatch(address from, address to, uint256[] calldata ids, uint256[] calldata values) - internal - override - { + function _transferBatch( + address from, + address to, + uint256[] calldata ids, + uint256[] calldata values + ) internal override { if (to == address(0)) revert ZeroAddress(); uint256 len = ids.length; - unchecked { - for (uint256 i; i < len; i++) { - balanceOf[from][ids[i]] -= values[i]; - balanceOf[to][ids[i]] += values[i]; - } + + for (uint256 i; i < len; i++) { + balanceOf[from][ids[i]] -= values[i]; + balanceOf[to][ids[i]] += values[i]; } emit TransferBatch(msg.sender, from, to, ids, values); @@ -409,19 +534,20 @@ contract YieldBox is /// @param from which user to pull the tokens. /// @param tos The receivers of the tokens. /// @param shares The amount of `token` in shares for each receiver in `tos`. - function transferMultiple(address from, address[] calldata tos, uint256 assetId, uint256[] calldata shares) - public - allowed(from, assetId) - { + function transferMultiple( + address from, + address[] calldata tos, + uint256 assetId, + uint256[] calldata shares + ) public allowed(from, assetId) { uint256 len = tos.length; uint256 _totalShares; - unchecked { - for (uint256 i; i < len; i++) { - if (tos[i] == address(0)) revert ZeroAddress(); - balanceOf[tos[i]][assetId] += shares[i]; - _totalShares += shares[i]; - emit TransferSingle(msg.sender, from, tos[i], assetId, shares[i]); - } + + for (uint256 i; i < len; i++) { + if (tos[i] == address(0)) revert ZeroAddress(); + balanceOf[tos[i]][assetId] += shares[i]; + _totalShares += shares[i]; + emit TransferSingle(msg.sender, from, tos[i], assetId, shares[i]); } balanceOf[from][assetId] -= _totalShares; } @@ -429,7 +555,10 @@ contract YieldBox is /// @notice Update approval status for an operator /// @param operator The address approved to perform actions on your behalf /// @param approved True/False - function setApprovalForAll(address operator, bool approved) external override { + function setApprovalForAll( + address operator, + bool approved + ) external override { // Checks if (operator == address(0)) revert NotSet(); if (operator == address(this)) revert ForbiddenAction(); @@ -442,7 +571,11 @@ contract YieldBox is /// @param _owner The YieldBox account owner /// @param operator The address approved to perform actions on your behalf /// @param approved True/False - function _setApprovalForAll(address _owner, address operator, bool approved) internal override { + function _setApprovalForAll( + address _owner, + address operator, + bool approved + ) internal override { isApprovedForAll[_owner][operator] = approved; emit ApprovalForAll(_owner, operator, approved); } @@ -451,7 +584,11 @@ contract YieldBox is /// @param operator The address approved to perform actions on your behalf /// @param assetId The asset id to update approval status for /// @param approved True/False - function setApprovalForAsset(address operator, uint256 assetId, bool approved) external override { + function setApprovalForAsset( + address operator, + uint256 assetId, + bool approved + ) external override { // Checks if (operator == address(0)) revert NotSet(); if (operator == address(this)) revert ForbiddenAction(); @@ -465,7 +602,12 @@ contract YieldBox is /// @param operator The address approved to perform actions on your behalf /// @param assetId The asset id to update approval status for /// @param approved True/False - function _setApprovalForAsset(address _owner, address operator, uint256 assetId, bool approved) internal override { + function _setApprovalForAsset( + address _owner, + address operator, + uint256 assetId, + bool approved + ) internal override { if (assetId >= assetCount()) revert AssetNotValid(); isApprovedForAsset[_owner][operator][assetId] = approved; emit ApprovalForAsset(_owner, operator, assetId, approved); @@ -473,8 +615,16 @@ contract YieldBox is // This functionality has been split off into a separate contract. This is only a view function, so gas usage isn't a huge issue. // This keeps the YieldBox contract smaller, so it can be optimized more. - function uri(uint256 assetId) external view override returns (string memory) { - return uriBuilder.uri(assets[assetId], nativeTokens[assetId], totalSupply[assetId], owner[assetId]); + function uri( + uint256 assetId + ) external view override returns (string memory) { + return + uriBuilder.uri( + assets[assetId], + nativeTokens[assetId], + totalSupply[assetId], + owner[assetId] + ); } function name(uint256 assetId) external view returns (string memory) { @@ -486,7 +636,11 @@ contract YieldBox is } function decimals(uint256 assetId) external view returns (uint8) { - return uriBuilder.decimals(assets[assetId], nativeTokens[assetId].decimals); + return + uriBuilder.decimals( + assets[assetId], + nativeTokens[assetId].decimals + ); } // Helper functions @@ -495,7 +649,9 @@ contract YieldBox is /// @param assetId The regierestered asset id /// @return totalShare The total amount for asset represented in shares /// @return totalAmount The total amount for asset - function assetTotals(uint256 assetId) external view returns (uint256 totalShare, uint256 totalAmount) { + function assetTotals( + uint256 assetId + ) external view returns (uint256 totalShare, uint256 totalAmount) { totalShare = totalSupply[assetId]; totalAmount = _tokenBalanceOf(assets[assetId]); } @@ -505,11 +661,22 @@ contract YieldBox is /// @param amount The `token` amount. /// @param roundUp If the result `share` should be rounded up. /// @return share The token amount represented in shares. - function toShare(uint256 assetId, uint256 amount, bool roundUp) external view returns (uint256 share) { - if (assets[assetId].tokenType == TokenType.Native || assets[assetId].tokenType == TokenType.ERC721) { + function toShare( + uint256 assetId, + uint256 amount, + bool roundUp + ) external view returns (uint256 share) { + if ( + assets[assetId].tokenType == TokenType.Native || + assets[assetId].tokenType == TokenType.ERC721 + ) { share = amount; } else { - share = amount._toShares(totalSupply[assetId], _tokenBalanceOf(assets[assetId]), roundUp); + share = amount._toShares( + totalSupply[assetId], + _tokenBalanceOf(assets[assetId]), + roundUp + ); } } @@ -518,22 +685,43 @@ contract YieldBox is /// @param share The amount of shares. /// @param roundUp If the result should be rounded up. /// @return amount The share amount back into native representation. - function toAmount(uint256 assetId, uint256 share, bool roundUp) external view returns (uint256 amount) { - if (assets[assetId].tokenType == TokenType.Native || assets[assetId].tokenType == TokenType.ERC721) { + function toAmount( + uint256 assetId, + uint256 share, + bool roundUp + ) external view returns (uint256 amount) { + if ( + assets[assetId].tokenType == TokenType.Native || + assets[assetId].tokenType == TokenType.ERC721 + ) { amount = share; } else { - amount = share._toAmount(totalSupply[assetId], _tokenBalanceOf(assets[assetId]), roundUp); + amount = share._toAmount( + totalSupply[assetId], + _tokenBalanceOf(assets[assetId]), + roundUp + ); } } /// @dev Helper function represent the balance in `token` amount for a `user` for an `asset`. /// @param user The `user` to get the amount for. /// @param assetId The id of the asset. - function amountOf(address user, uint256 assetId) external view returns (uint256 amount) { - if (assets[assetId].tokenType == TokenType.Native || assets[assetId].tokenType == TokenType.ERC721) { + function amountOf( + address user, + uint256 assetId + ) external view returns (uint256 amount) { + if ( + assets[assetId].tokenType == TokenType.Native || + assets[assetId].tokenType == TokenType.ERC721 + ) { amount = balanceOf[user][assetId]; } else { - amount = balanceOf[user][assetId]._toAmount(totalSupply[assetId], _tokenBalanceOf(assets[assetId]), false); + amount = balanceOf[user][assetId]._toAmount( + totalSupply[assetId], + _tokenBalanceOf(assets[assetId]), + false + ); } } @@ -560,11 +748,33 @@ contract YieldBox is ) public returns (uint256 amountOut, uint256 shareOut) { if (tokenType == TokenType.Native) { // If native token, register it as an ERC1155 asset (as that's what it is) - return depositAsset( - registerAsset(TokenType.ERC1155, address(this), strategy, tokenId), from, to, amount, share - ); + return + depositAsset( + registerAsset( + TokenType.ERC1155, + address(this), + strategy, + tokenId + ), + from, + to, + amount, + share + ); } else { - return depositAsset(registerAsset(tokenType, contractAddress, strategy, tokenId), from, to, amount, share); + return + depositAsset( + registerAsset( + tokenType, + contractAddress, + strategy, + tokenId + ), + from, + to, + amount, + share + ); } } @@ -573,12 +783,22 @@ contract YieldBox is /// @param amount amount to deposit. /// @return amountOut The amount deposited. /// @return shareOut The deposited amount repesented in shares. - function depositETH(IStrategy strategy, address to, uint256 amount) - public - payable - returns (uint256 amountOut, uint256 shareOut) - { - return depositETHAsset(registerAsset(TokenType.ERC20, address(wrappedNative), strategy, 0), to, amount); + function depositETH( + IStrategy strategy, + address to, + uint256 amount + ) public payable returns (uint256 amountOut, uint256 shareOut) { + return + depositETHAsset( + registerAsset( + TokenType.ERC20, + address(wrappedNative), + strategy, + 0 + ), + to, + amount + ); } // ******************* // diff --git a/contracts/YieldBoxPermit.sol b/contracts/YieldBoxPermit.sol index 8c1c40d..fe5c90d 100644 --- a/contracts/YieldBoxPermit.sol +++ b/contracts/YieldBoxPermit.sol @@ -18,11 +18,11 @@ import "./interfaces/IYieldBox.sol"; * presenting a message signed by the account. By not relying on `{IERC721-approve}`, the token holder account doesn't * need to send a transaction, and thus is not required to hold Ether at all. */ -abstract contract YieldBoxPermit is EIP712 { +abstract contract YieldBoxPermit is EIP712 { using Counters for Counters.Counter; mapping(address => Counters.Counter) private _nonces; - + bytes32 private constant _PERMIT_TYPEHASH = keccak256( "Permit(address owner,address spender,uint256 assetId,uint256 nonce,uint256 deadline)" @@ -47,7 +47,7 @@ abstract contract YieldBoxPermit is EIP712 { * It's a good idea to use the same `name` that is defined as the ERC721 token name. */ constructor(string memory name) EIP712(name, "1") {} - + function permit( address owner, address spender, diff --git a/contracts/strategies/BaseStrategy.sol b/contracts/strategies/BaseStrategy.sol index 1296639..bc94b1e 100644 --- a/contracts/strategies/BaseStrategy.sol +++ b/contracts/strategies/BaseStrategy.sol @@ -6,7 +6,7 @@ import "@boringcrypto/boring-solidity/contracts/interfaces/IERC20.sol"; import "@boringcrypto/boring-solidity/contracts/libraries/BoringERC20.sol"; import "../enums/YieldBoxTokenType.sol"; import "../interfaces/IStrategy.sol"; - + // solhint-disable const-name-snakecase // solhint-disable no-empty-blocks @@ -78,3 +78,20 @@ abstract contract BaseERC1155Strategy is BaseStrategy { tokenId = _tokenId; } } + +abstract contract BaseERC721Strategy is BaseStrategy { + TokenType public constant tokenType = TokenType.ERC721; + uint256 public immutable tokenId; + address public immutable contractAddress; + + constructor( + IYieldBox _yieldBox, + address _contractAddress, + uint256 _tokenId + ) BaseStrategy(_yieldBox) { + contractAddress = _contractAddress; + tokenId = _tokenId; + } +} + + diff --git a/contracts/strategies/ERC20WithoutStrategy.sol b/contracts/strategies/ERC20WithoutStrategy.sol index 53cefe3..8484cc3 100644 --- a/contracts/strategies/ERC20WithoutStrategy.sol +++ b/contracts/strategies/ERC20WithoutStrategy.sol @@ -13,7 +13,7 @@ import "./BaseStrategy.sol"; contract ERC20WithoutStrategy is BaseERC20Strategy { using BoringERC20 for IERC20; - + constructor( IYieldBox _yieldBox, IERC20 token diff --git a/contracts/strategies/ERC721WithoutStrategy.sol b/contracts/strategies/ERC721WithoutStrategy.sol new file mode 100644 index 0000000..246448b --- /dev/null +++ b/contracts/strategies/ERC721WithoutStrategy.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.9; +pragma experimental ABIEncoderV2; + +import "@boringcrypto/boring-solidity/contracts/interfaces/IERC20.sol"; +import "@boringcrypto/boring-solidity/contracts/libraries/BoringERC20.sol"; +import "../enums/YieldBoxTokenType.sol"; +import "../BoringMath.sol"; +import "./BaseStrategy.sol"; + +// solhint-disable const-name-snakecase +// solhint-disable no-empty-blocks + +contract ERC721WithoutStrategy is BaseERC721Strategy { + using BoringERC20 for IERC20; + + constructor( + IYieldBox _yieldBox, + address _contractAddress, + uint256 _tokenId + ) BaseERC721Strategy(_yieldBox, _contractAddress, _tokenId) {} + + string public constant override name = "No strategy"; + string public constant override description = "No strategy"; + + function _currentBalance() internal view override returns (uint256 amount) { + return IERC20(contractAddress).safeBalanceOf(address(this)); + } + + function _deposited(uint256 amount) internal override {} + + function _withdraw(address to, uint256 amount) internal override { + IERC20(contractAddress).safeTransfer(to, amount); + } +} diff --git a/foundry.toml b/foundry.toml index 66aad21..d1f07cc 100644 --- a/foundry.toml +++ b/foundry.toml @@ -10,6 +10,12 @@ evm_version='paris' optimizer = true optimizer_runs = 1000 +[profile.default.fuzz] +max_test_rejects = 1_000_000 +seed = "0xee1d0f7d9556539a9c0e26aed5e63556" +runs = 1000 + + remappings = [ 'tap-utils/=lib/tap-utils/contracts/', diff --git a/script/Counter.s.sol b/script/Counter.s.sol deleted file mode 100644 index cdc1fe9..0000000 --- a/script/Counter.s.sol +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Script, console} from "forge-std/Script.sol"; -import {Counter} from "../src/Counter.sol"; - -contract CounterScript is Script { - Counter public counter; - - function setUp() public {} - - function run() public { - vm.startBroadcast(); - - counter = new Counter(); - - vm.stopBroadcast(); - } -} diff --git a/src/Counter.sol b/src/Counter.sol deleted file mode 100644 index aded799..0000000 --- a/src/Counter.sol +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -contract Counter { - uint256 public number; - - function setNumber(uint256 newNumber) public { - number = newNumber; - } - - function increment() public { - number++; - } -} diff --git a/test/Base.t.sol b/test/Base.t.sol new file mode 100644 index 0000000..c7f2ce9 --- /dev/null +++ b/test/Base.t.sol @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.22; + +// Utilities +import {Users} from "./utils/Types.sol"; +import {Utils} from "./utils/Utils.sol"; +import {Events} from "./utils/Events.sol"; +import {Errors} from "./utils/Errors.sol"; +import {Constants} from "./utils/Constants.sol"; + +// Mocks +import {ERC20Mock} from "./mocks/erc20/ERC20Mock.sol"; +import {WrappedNativeMock} from "./mocks/erc20/WrappedNativeMock.sol"; +import {ERC20MissingReturn} from "./mocks/erc20/ERC20MissingReturn.sol"; + +// Contracts +import {YieldBox, Pearlmit} from "contracts/YieldBox.sol"; +import {YieldBoxURIBuilder} from "contracts/YieldBoxURIBuilder.sol"; + +// Interfaces +import "contracts/interfaces/IWrappedNative.sol"; + +/// @notice Base test contract with common logic needed by all tests. +abstract contract BaseTest is Utils, Events, Errors, Constants { + ///////////////////////////////////////////////////////////////////// + // STORAGE // + ///////////////////////////////////////////////////////////////////// + + /// @notice Protocol users + Users internal users; + address[] internal userAddresses; + + /// @notice Tokens + ERC20Mock internal dai; + WrappedNativeMock internal wrappedNative; + ERC20MissingReturn internal usdt; + + /// @notice Protocol contracts + YieldBox internal yieldBox; + YieldBoxURIBuilder internal yieldBoxUriBuilder; + Pearlmit internal pearlmit; + + ///////////////////////////////////////////////////////////////////// + // SETUP // + ///////////////////////////////////////////////////////////////////// + + function setUp() public virtual { + // Deploy mock tokens. + dai = new ERC20Mock("Dai Stablecoin", "DAI"); + usdt = new ERC20MissingReturn("Tether USD", "USDT", 6); + wrappedNative = new WrappedNativeMock(); + + // Label the base test contracts. + vm.label({account: address(dai), newLabel: "DAI"}); + vm.label({account: address(usdt), newLabel: "USDT"}); + vm.label({account: address(wrappedNative), newLabel: "WETH"}); + + // Deploy protocol. + _deployYieldBox(); + + // Create users. + _initializeUsers(); + } + + ///////////////////////////////////////////////////////////////////// + // MODIFIERS // + ///////////////////////////////////////////////////////////////////// + + /// @notice Modifier to approve an operator in YB via Pearlmit. + modifier whenApprovedViaPearlmit( + address _from, + address _operator, + uint256 _amount, + uint256 _expiration + ) { + _approveViaPearlmit({ + from: _from, + operator: _operator, + amount: _amount, + expiration: _expiration + }); + _; + } + + /// @notice Modifier to approve an operator via regular ERC20. + modifier whenApprovedViaERC20( + address _from, + address _operator, + uint256 _amount + ) { + _approveViaERC20({from: _from, operator: _operator, amount: _amount}); + _; + } + + /// @notice Modifier to approve an operator for a specific asset ID via YB. + modifier whenYieldBoxApprovedForAssetID( + address _from, + address _operator, + uint256 _assetId + ) { + _approveYieldBoxAssetId(_from, _operator, _assetId); + _; + } + + /// @notice Modifier to approve an operator for a specific asset ID via YB. + modifier whenYieldBoxApprovedForMultipleAssetIDs( + address _from, + address _operator + ) { + for(uint256 i = 1; i <= 3; i++){ + _approveYieldBoxAssetId(_from, _operator, i); + } + + _; + } + + /// @notice Modifier to approve an operator for all via YB. + modifier whenYieldBoxApprovedForAll(address _from, address _operator) { + _approveYieldBoxForAll(_from, _operator); + _; + } + + /// @notice Modifier to changea user's prank. + modifier resetPrank(address user) { + _resetPrank(user); + _; + } + + /// @notice Modifier to verify a value is not zero. + modifier assumeNoZeroValue(uint256 value) { + vm.assume(value != 0); + _; + } + + /// @notice Modifier to verify a value is greater than or equal to a certain number. + modifier assumeGtE(uint256 value, uint256 toCompare) { + vm.assume(value >= toCompare); + _; + } + + ///////////////////////////////////////////////////////////////////// + // INTERNAL HELPERS // + ///////////////////////////////////////////////////////////////////// + + /// @notice Initializes test users. + function _initializeUsers() internal { + // Create users + users.owner = _createUser("owner"); + users.alice = _createUser("alice"); + users.bob = _createUser("bob"); + users.charlie = _createUser("charlie"); + users.david = _createUser("david"); + users.eve = _createUser("eve"); + + // Fill users array + userAddresses.push(users.alice); + userAddresses.push(users.bob); + userAddresses.push(users.charlie); + userAddresses.push(users.david); + userAddresses.push(users.eve); + } + + /// @notice Deploys YieldBox together with its required contracts. + function _deployYieldBox() internal { + // Deploy Pearlmit + pearlmit = new Pearlmit("Pearlmit", "1.0", users.owner, 0); + + // Deploy YieldBox URI builder + yieldBoxUriBuilder = new YieldBoxURIBuilder(); + + // Deploy YieldBox + yieldBox = new YieldBox( + IWrappedNative(address(wrappedNative)), + yieldBoxUriBuilder, + pearlmit, + users.owner + ); + } + + /// @notice Approves all YieldBox contracts to spend assets from the address passed using Pearlmit. + function _approveViaPearlmit( + address from, + address operator, + uint256 amount, + uint256 expiration + ) internal { + // Reset prank + _resetPrank({msgSender: from}); + + // Set approvals to pearlmit + dai.approve(address(pearlmit), amount); + wrappedNative.approve(address(pearlmit), amount); + usdt.approve(address(pearlmit), amount); + + // Approve via pearlmit + pearlmit.approve( + TOKEN_TYPE_ERC20, + address(dai), + 0, + operator, + uint200(amount), + uint48(expiration) + ); + pearlmit.approve( + TOKEN_TYPE_ERC20, + address(wrappedNative), + 0, + operator, + uint200(amount), + uint48(expiration) + ); + pearlmit.approve( + TOKEN_TYPE_ERC20, + address(usdt), + 0, + operator, + uint200(amount), + uint48(expiration) + ); + } + + /// @notice Approves all YieldBox contracts to spend assets from the address passed using regular ERC20. + function _approveViaERC20( + address from, + address operator, + uint256 amount + ) internal { + // Reset prank + _resetPrank({msgSender: from}); + // Set approvals to pearlmit + dai.approve(address(operator), amount); + wrappedNative.approve(address(operator), amount); + usdt.approve(address(operator), amount); + } + + /// @notice Approves a YieldBox asset ID to an `operator` given a `from` address. + function _approveYieldBoxAssetId( + address from, + address operator, + uint256 assetId + ) internal { + _resetPrank({msgSender: from}); + yieldBox.setApprovalForAsset(operator, assetId, true); + } + + /// @notice Approves all YieldBox assets to an `operator` given a `from` address. + function _approveYieldBoxForAll(address from, address operator) internal { + _resetPrank({msgSender: from}); + yieldBox.setApprovalForAll(operator, true); + } + + /// @notice Generates a user, labels its address, funds it with test assets, and approves the protocol contracts. + function _createUser( + string memory name + ) internal returns (address payable) { + address payable user = payable(makeAddr(name)); + vm.deal({account: user, newBalance: type(uint128).max}); + deal({token: address(dai), to: user, give: type(uint128).max}); + deal({token: address(wrappedNative), to: user, give: type(uint128).max}); + deal({token: address(usdt), to: user, give: type(uint128).max}); + return user; + } +} diff --git a/test/Counter.t.sol b/test/Counter.t.sol deleted file mode 100644 index 54b724f..0000000 --- a/test/Counter.t.sol +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Test, console} from "forge-std/Test.sol"; -import {Counter} from "../src/Counter.sol"; - -contract CounterTest is Test { - Counter public counter; - - function setUp() public { - counter = new Counter(); - counter.setNumber(0); - } - - function test_Increment() public { - counter.increment(); - assertEq(counter.number(), 1); - } - - function testFuzz_SetNumber(uint256 x) public { - counter.setNumber(x); - assertEq(counter.number(), x); - } -} diff --git a/test/mocks/ERC20/ERC20MissingReturn.sol b/test/mocks/ERC20/ERC20MissingReturn.sol new file mode 100644 index 0000000..65cb8ab --- /dev/null +++ b/test/mocks/ERC20/ERC20MissingReturn.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity >=0.8.22; + +/// @notice An implementation of ERC-20 that does not return a boolean in {transfer} and {transferFrom}. +/// @dev See https://medium.com/coinmonks/missing-return-value-bug-at-least-130-tokens-affected-d67bf08521ca/. +contract ERC20MissingReturn { + uint8 public decimals; + string public name; + string public symbol; + uint256 public totalSupply; + + mapping(address owner => mapping(address spender => uint256 allowance)) internal _allowances; + mapping(address account => uint256 balance) internal _balances; + + event Transfer(address indexed from, address indexed to, uint256 amount); + + event Approval(address indexed owner, address indexed spender, uint256 amount); + + constructor(string memory name_, string memory symbol_, uint8 decimals_) { + name = name_; + symbol = symbol_; + decimals = decimals_; + } + + function allowance(address owner, address spender) public view returns (uint256) { + return _allowances[owner][spender]; + } + + function balanceOf(address account) public view returns (uint256) { + return _balances[account]; + } + + function approve(address spender, uint256 value) public returns (bool) { + _approve(msg.sender, spender, value); + return true; + } + + function burn(address holder, uint256 amount) public { + _balances[holder] -= amount; + totalSupply -= amount; + emit Transfer(holder, address(0), amount); + } + + function mint(address beneficiary, uint256 amount) public { + _balances[beneficiary] += amount; + totalSupply += amount; + emit Transfer(address(0), beneficiary, amount); + } + + function _approve(address owner, address spender, uint256 value) internal virtual { + _allowances[owner][spender] = value; + emit Approval(owner, spender, value); + } + + /// @dev This function does not return a value, although the ERC-20 standard mandates that it should. + function transfer(address to, uint256 amount) public { + _transfer(msg.sender, to, amount); + } + + /// @dev This function does not return a value, although the ERC-20 standard mandates that it should. + function transferFrom(address from, address to, uint256 amount) public { + _transfer(from, to, amount); + _approve(from, msg.sender, _allowances[from][msg.sender] - amount); + } + + function _transfer(address from, address to, uint256 amount) internal virtual { + _balances[from] = _balances[from] - amount; + _balances[to] = _balances[to] + amount; + emit Transfer(from, to, amount); + } +} \ No newline at end of file diff --git a/test/mocks/ERC20/ERC20Mock.sol b/test/mocks/ERC20/ERC20Mock.sol new file mode 100644 index 0000000..06ac867 --- /dev/null +++ b/test/mocks/ERC20/ERC20Mock.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity >=0.8.22; + +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract ERC20Mock is ERC20 { + + constructor(string memory name, string memory symbol) ERC20(name, symbol) { } + +} \ No newline at end of file diff --git a/test/mocks/ERC20/WrappedNativeMock.sol b/test/mocks/ERC20/WrappedNativeMock.sol new file mode 100644 index 0000000..b1f76e1 --- /dev/null +++ b/test/mocks/ERC20/WrappedNativeMock.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.18; + +contract WrappedNativeMock { + string public name = "Wrapped Ether"; + string public symbol = "WETH"; + uint8 public decimals = 18; + + event Approval(address indexed src, address indexed guy, uint wad); + event Transfer(address indexed src, address indexed dst, uint wad); + event Deposit(address indexed dst, uint wad); + event Withdrawal(address indexed src, uint wad); + + mapping(address => uint) public balanceOf; + mapping(address => mapping(address => uint)) public allowance; + + receive() external payable { + deposit(); + } + function deposit() public payable { + balanceOf[msg.sender] += msg.value; + emit Deposit(msg.sender, msg.value); + } + function withdraw(uint wad) public { + require(balanceOf[msg.sender] >= wad); + balanceOf[msg.sender] -= wad; + payable(address(msg.sender)).transfer(wad); + emit Withdrawal(msg.sender, wad); + } + + function totalSupply() public view returns (uint) { + return address(this).balance; + } + + function approve(address guy, uint wad) public returns (bool) { + allowance[msg.sender][guy] = wad; + emit Approval(msg.sender, guy, wad); + return true; + } + + function transfer(address dst, uint wad) public returns (bool) { + return transferFrom(msg.sender, dst, wad); + } + + function transferFrom( + address src, + address dst, + uint wad + ) public returns (bool) { + require(balanceOf[src] >= wad); + + if ( + src != msg.sender && allowance[src][msg.sender] != type(uint256).max + ) { + require(allowance[src][msg.sender] >= wad); + allowance[src][msg.sender] -= wad; + } + + balanceOf[src] -= wad; + balanceOf[dst] += wad; + + emit Transfer(src, dst, wad); + + return true; + } +} diff --git a/test/unit/concrete/yieldbox/YieldBox.t.sol b/test/unit/concrete/yieldbox/YieldBox.t.sol new file mode 100644 index 0000000..23a18fc --- /dev/null +++ b/test/unit/concrete/yieldbox/YieldBox.t.sol @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.22; + +// Utilities +import {BaseTest} from "../../../Base.t.sol"; + +import {TokenType} from "contracts/enums/YieldBoxTokenType.sol"; +import {ERC20WithoutStrategy} from "contracts/strategies/ERC20WithoutStrategy.sol"; +import {IYieldBox} from "contracts/interfaces/IYieldBox.sol"; +import {IStrategy} from "contracts/interfaces/IStrategy.sol"; + +import {IERC20} from "@boringcrypto/boring-solidity/contracts/interfaces/IERC20.sol"; + +contract YieldBoxUnitConcreteTest is BaseTest { + ///////////////////////////////////////////////////////////////////// + // STORAGE // + ///////////////////////////////////////////////////////////////////// + + // Asset ID's + uint256 public DAI_ASSET_ID; + uint256 public WRAPPED_NATIVE_ASSET_ID; + uint256 public USDT_ASSET_ID; + + // Strategies + ERC20WithoutStrategy daiStrategy; + ERC20WithoutStrategy wrappedNativeStrategy; + ERC20WithoutStrategy usdtStrategy; + + ///////////////////////////////////////////////////////////////////// + // YIELDBOX CUSTOM SETUP // + ///////////////////////////////////////////////////////////////////// + + function setUp() public virtual override { + super.setUp(); + + // Create strategies + daiStrategy = new ERC20WithoutStrategy( + IYieldBox(address(yieldBox)), + IERC20(address(dai)) + ); + wrappedNativeStrategy = new ERC20WithoutStrategy( + IYieldBox(address(yieldBox)), + IERC20(address(wrappedNative)) + ); + usdtStrategy = new ERC20WithoutStrategy( + IYieldBox(address(yieldBox)), + IERC20(address(usdt)) + ); + + // Register assets in YieldBox + + // Register DAI + DAI_ASSET_ID = yieldBox.registerAsset( + TokenType.ERC20, + address(dai), + IStrategy(address(daiStrategy)), + 0 // `tokenId` is 0 for ERC20 assets + ); + + // Register Wrapped native + WRAPPED_NATIVE_ASSET_ID = yieldBox.registerAsset( + TokenType.ERC20, + address(wrappedNative), + IStrategy(address(wrappedNativeStrategy)), + 0 + ); + + // Register USDT + USDT_ASSET_ID = yieldBox.registerAsset( + TokenType.ERC20, + address(usdt), + IStrategy(address(usdtStrategy)), + 0 + ); + + // Prank impartial user + _resetPrank({msgSender: users.alice}); + } + + ///////////////////////////////////////////////////////////////////// + // MODIFIERS // + ///////////////////////////////////////////////////////////////////// + + modifier whenDeposited( + uint256 _assetId, + address _from, + address _to, + uint256 _amount, + uint256 _share + ) { + _whenDeposited({ + assetId: _assetId, + from: _from, + to: _to, + amount: _amount, + share: _share + }); + _; + } + + modifier whenDepositedAll( + address _from, + address _to, + uint256 _amount, + uint256 _share + ) { + for (uint256 assetID = 1; assetID <= 3; assetID++) { + _whenDeposited({ + assetId: assetID, + from: _from, + to: _to, + amount: _amount, + share: _share + }); + } + + _; + } + + modifier simulateYieldBoxDeposits( + uint256 _assetId, + uint256 _amount, + uint256 _share + ) { + _simulateYieldBoxDeposits({ + assetId: _assetId, + amount: _amount, + share: _share + }); + // Reset prank to default impartial user + _resetPrank(users.alice); + _; + } + + ///////////////////////////////////////////////////////////////////// + // INTERNAL HELPERS // + ///////////////////////////////////////////////////////////////////// + function _whenDeposited( + uint256 assetId, + address from, + address to, + uint256 amount, + uint256 share + ) internal { + // Approve yieldBox to transfer `from` assets + _approveViaPearlmit({ + from: from, + operator: address(yieldBox), + amount: amount == 0 ? share : amount, + expiration: type(uint48).max + }); + + // Deposit assets in YieldBox + yieldBox.depositAsset({ + assetId: assetId, + from: from, + to: to, + amount: amount, + share: share + }); + } + + function _simulateYieldBoxDeposits( + uint256 assetId, + uint256 amount, + uint256 share + ) internal { + // Skip alice address + for (uint i = 1; i < userAddresses.length; i++) { + _whenDeposited({ + assetId: assetId, + from: userAddresses[i], + to: userAddresses[i], + amount: amount, + share: share + }); + } + } +} diff --git a/test/unit/concrete/yieldbox/batchTransfer.t.sol b/test/unit/concrete/yieldbox/batchTransfer.t.sol new file mode 100644 index 0000000..a662986 --- /dev/null +++ b/test/unit/concrete/yieldbox/batchTransfer.t.sol @@ -0,0 +1,355 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.22; + +import {YieldBoxUnitConcreteTest} from "./YieldBox.t.sol"; + +// Contracts +import {YieldBox, Pearlmit} from "contracts/YieldBox.sol"; +import {YieldBoxRebase} from "contracts/YieldBoxRebase.sol"; +import {TokenType} from "contracts/enums/YieldBoxTokenType.sol"; +import {ERC721WithoutStrategy} from "contracts/strategies/ERC721WithoutStrategy.sol"; +import {IYieldBox} from "contracts/interfaces/IYieldBox.sol"; +import {IStrategy} from "contracts/interfaces/IStrategy.sol"; + +// Interfaces +import "contracts/interfaces/IWrappedNative.sol"; + +contract batchTransfer is YieldBoxUnitConcreteTest { + ///////////////////////////////////////////////////////////////////// + // STRUCTS // + ///////////////////////////////////////////////////////////////////// + struct StateBeforeBatchTransfer { + uint256[] assetIds; + uint256[] amounts; + uint256[] previousBalances; + } + + ///////////////////////////////////////////////////////////////////// + // INTERNAL HELPERS // + ///////////////////////////////////////////////////////////////////// + function _buildBatchTransferRequiredData( + uint256[] memory assetIds, + uint256[] memory amounts, + uint256 amount + ) internal view returns (address[] memory, uint256[] memory) { + // Build array of asset Id's + assetIds[0] = DAI_ASSET_ID; + assetIds[1] = WRAPPED_NATIVE_ASSET_ID; + assetIds[2] = USDT_ASSET_ID; + + // Build array of `amounts` + amounts[0] = amount / 3; + amounts[1] = amount / 3; + amounts[2] = amount / 3; + } + + function _assertExpectedBalances( + uint256[] memory previousBalances, + address to, + uint256[] memory amounts, + bool positive + ) internal view returns (address[] memory, uint256[] memory) { + for (uint256 i = 0; i < 3; i++) { + assertEq( + yieldBox.balanceOf(to, i + 1), // assetId is represented by i + 1 + positive + ? previousBalances[i] + amounts[i] + : previousBalances[i] - amounts[i] + ); + } + } + + function _fetchMultipleBalances( + uint256[] memory previousBalances, + address to + ) internal view returns (uint256[] memory) { + for (uint256 i; i < 3; i++) { + previousBalances[i] = yieldBox.balanceOf(to, i + 1); + } + } + + function _triggerExpectEmit( + address operator, + address from, + address to, + uint256[] memory amounts + ) internal { + uint256[] memory ids = new uint256[](3); + ids[0] = DAI_ASSET_ID; + ids[1] = WRAPPED_NATIVE_ASSET_ID; + ids[2] = USDT_ASSET_ID; + vm.expectEmit(); + emit TransferBatch(operator, from, to, ids, amounts); + } + + ///////////////////////////////////////////////////////////////////// + // SETUP // + ///////////////////////////////////////////////////////////////////// + function setUp() public override { + super.setUp(); + } + + /// @notice Tests the scenario where `from` is not allowed to batchTransfer. + /// @dev `from not being allowed implies the following: + /// - `from` is different from `msg.sender` + /// - `from` has not approved `msg.sender` for the given asset ID's + function test_batchTransferRevertWhen_CallerIsNotAllowed( + uint64 depositAmount + ) + public + assumeGtE(depositAmount, 3) + whenDepositedAll(users.alice, users.alice, 0, depositAmount) + { + // Prank malicious user. No approvals have been performed. + _resetPrank({msgSender: users.eve}); + + // Initialize required data + StateBeforeBatchTransfer memory stateBeforeBatchTransfer; + stateBeforeBatchTransfer.assetIds = new uint256[](3); + stateBeforeBatchTransfer.amounts = new uint256[](3); + + _buildBatchTransferRequiredData( + stateBeforeBatchTransfer.assetIds, + stateBeforeBatchTransfer.amounts, + depositAmount + ); + + // Try to transfer assets on behalf of impartial user + vm.expectRevert("Transfer not allowed"); + yieldBox.batchTransfer( + users.alice, + users.bob, + stateBeforeBatchTransfer.assetIds, + stateBeforeBatchTransfer.amounts + ); + } + + /// @notice Tests the scenario where one of `assetId`'s is not registered + function test_batchTransferRevertWhen_AssetIdNotRegistered( + uint64 depositAmount, + uint16 rand + ) public assumeGtE(depositAmount, 3) { + // Initialize required data + StateBeforeBatchTransfer memory stateBeforeBatchTransfer; + stateBeforeBatchTransfer.assetIds = new uint256[](3); + stateBeforeBatchTransfer.amounts = new uint256[](3); + + _buildBatchTransferRequiredData( + stateBeforeBatchTransfer.assetIds, + stateBeforeBatchTransfer.amounts, + depositAmount + ); + + // Force one of assetId's to be unsupported + stateBeforeBatchTransfer.assetIds[rand % 3] = 5; // 5 as assetId is unsupported + + // Try to transfer invalid asset ID. + vm.expectRevert(); + yieldBox.batchTransfer( + users.alice, + users.bob, + stateBeforeBatchTransfer.assetIds, + stateBeforeBatchTransfer.amounts + ); + } + + /// @notice Tests the scenario where `to` is address(0) + function test_batchTransferRevertWhen_ToIsZeroAddress( + uint64 depositAmount + ) public assumeGtE(depositAmount, 3) { + // Initialize required data + StateBeforeBatchTransfer memory stateBeforeBatchTransfer; + stateBeforeBatchTransfer.assetIds = new uint256[](3); + stateBeforeBatchTransfer.amounts = new uint256[](3); + + _buildBatchTransferRequiredData( + stateBeforeBatchTransfer.assetIds, + stateBeforeBatchTransfer.amounts, + depositAmount + ); + + // Try to transfer to address(0) + vm.expectRevert(ZeroAddress.selector); + yieldBox.batchTransfer( + users.alice, + address(0), + stateBeforeBatchTransfer.assetIds, + stateBeforeBatchTransfer.amounts + ); + } + + /// @notice Tests the scenario where shares exceeds `from` balance + function test_batchTransferRevertWhen_SharesExceedsBalance( + uint64 depositAmount, + uint16 rand + ) + public + assumeGtE(depositAmount, 3) + whenDepositedAll(users.alice, users.alice, 0, depositAmount) + { + // Initialize required data + StateBeforeBatchTransfer memory stateBeforeBatchTransfer; + stateBeforeBatchTransfer.assetIds = new uint256[](3); + stateBeforeBatchTransfer.amounts = new uint256[](3); + + _buildBatchTransferRequiredData( + stateBeforeBatchTransfer.assetIds, + stateBeforeBatchTransfer.amounts, + depositAmount + ); + + // Force one of amounts to exceed balance be unsupported + stateBeforeBatchTransfer.amounts[rand % 3] = uint256(depositAmount) + 1; + + // Try to transfer more assets than held. + vm.expectRevert(); + yieldBox.batchTransfer( + users.alice, + users.bob, + stateBeforeBatchTransfer.assetIds, + stateBeforeBatchTransfer.amounts + ); + } + + /// @notice Tests the scenario where shares are smaller or equal to `from` balance + function test_batchTransfer_whenValueIsSmallerOrEqualToBalance( + uint64 depositAmount + ) + public + assumeGtE(depositAmount, 3) + whenDepositedAll(users.alice, users.alice, 0, depositAmount) + { + // Initialize required data + StateBeforeBatchTransfer memory stateBeforeBatchTransferBob; + stateBeforeBatchTransferBob.assetIds = new uint256[](3); + stateBeforeBatchTransferBob.amounts = new uint256[](3); + stateBeforeBatchTransferBob.previousBalances = new uint256[](3); + + StateBeforeBatchTransfer memory stateBeforeBatchTransferAlice; + stateBeforeBatchTransferAlice.assetIds = new uint256[](3); + stateBeforeBatchTransferAlice.amounts = new uint256[](3); + stateBeforeBatchTransferAlice.previousBalances = new uint256[](3); + + _buildBatchTransferRequiredData( + stateBeforeBatchTransferBob.assetIds, + stateBeforeBatchTransferBob.amounts, + depositAmount + ); + + // Fetch previous balances + _fetchMultipleBalances( + stateBeforeBatchTransferBob.previousBalances, + users.bob + ); + + _fetchMultipleBalances( + stateBeforeBatchTransferAlice.previousBalances, + users.alice + ); + + // it should emit a `TransferBatch` event for each iteration + _triggerExpectEmit({ + operator: users.alice, + from: users.alice, + to: users.bob, + amounts: stateBeforeBatchTransferBob.amounts + }); + + // Transfer assets + yieldBox.batchTransfer( + users.alice, + users.bob, + stateBeforeBatchTransferBob.assetIds, + stateBeforeBatchTransferBob.amounts + ); + + // it should increment `balanceOf` each assed ID by its respective `shares` + _assertExpectedBalances( + stateBeforeBatchTransferBob.previousBalances, + users.bob, + stateBeforeBatchTransferBob.amounts, + true + ); + + // it should decrement `balanceOf` `from` by the total accumulated `_totalShares` + _assertExpectedBalances( + stateBeforeBatchTransferAlice.previousBalances, + users.alice, + stateBeforeBatchTransferBob.amounts, + false + ); + } + + /// @notice Tests the scenario where shares are smaller or equal to `from` balance via approval by asset ID + function test_batchTransfer_whenValueIsSmallerOrEqualToBalanceViaApprovedForAssetId( + uint64 depositAmount + ) + public + assumeGtE(depositAmount, 3) + whenDepositedAll(users.alice, users.alice, 0, depositAmount) + whenYieldBoxApprovedForMultipleAssetIDs(users.alice, users.owner) + { + // Initialize required data + StateBeforeBatchTransfer memory stateBeforeBatchTransferBob; + stateBeforeBatchTransferBob.assetIds = new uint256[](3); + stateBeforeBatchTransferBob.amounts = new uint256[](3); + stateBeforeBatchTransferBob.previousBalances = new uint256[](3); + + StateBeforeBatchTransfer memory stateBeforeBatchTransferAlice; + stateBeforeBatchTransferAlice.assetIds = new uint256[](3); + stateBeforeBatchTransferAlice.amounts = new uint256[](3); + stateBeforeBatchTransferAlice.previousBalances = new uint256[](3); + + _buildBatchTransferRequiredData( + stateBeforeBatchTransferBob.assetIds, + stateBeforeBatchTransferBob.amounts, + depositAmount + ); + + // Fetch previous balances + _fetchMultipleBalances( + stateBeforeBatchTransferBob.previousBalances, + users.bob + ); + + _fetchMultipleBalances( + stateBeforeBatchTransferAlice.previousBalances, + users.alice + ); + + // Owner becomes operator + _resetPrank(users.owner); + + // it should emit a `TransferBatch` event for each iteration + _triggerExpectEmit({ + operator: users.owner, + from: users.alice, + to: users.bob, + amounts: stateBeforeBatchTransferBob.amounts + }); + + // Transfer assets + yieldBox.batchTransfer( + users.alice, + users.bob, + stateBeforeBatchTransferBob.assetIds, + stateBeforeBatchTransferBob.amounts + ); + + // it should increment `balanceOf` each assed ID by its respective `shares` + _assertExpectedBalances( + stateBeforeBatchTransferBob.previousBalances, + users.bob, + stateBeforeBatchTransferBob.amounts, + true + ); + + // it should decrement `balanceOf` `from` by the total accumulated `_totalShares` + _assertExpectedBalances( + stateBeforeBatchTransferAlice.previousBalances, + users.alice, + stateBeforeBatchTransferBob.amounts, + false + ); + } +} diff --git a/test/unit/concrete/yieldbox/batchTransfer.tree b/test/unit/concrete/yieldbox/batchTransfer.tree new file mode 100644 index 0000000..925c6b6 --- /dev/null +++ b/test/unit/concrete/yieldbox/batchTransfer.tree @@ -0,0 +1,18 @@ +batchTransfer.t.sol +├── when `from` is not `msg.sender` +│ ├── when `msg.sender` is not approved by `from` for asset +│ │ └── it continues execution +│ └── it continues execution +└── it CONTINUES - iterate `ids` + ├── when asset is not registered in YieldBox + │ └── it should revert + └── when asset is registered in YieldBox + └── when to is address(0) + │ └── it should revert + └── when `to` is not address(0) + └── when `_totalShares` is bigger than `balanceOf` `from` + │ └── it should revert + └── when `_totalShares` is smaller or equal than `balanceOf` `from` + ├── it should increment `balanceOf` `to` by the respective `shares` + ├── it should decrement `balanceOf` `from` by the total accumulated `_totalShares` + └── it should emit a `TransferBatch` event diff --git a/test/unit/concrete/yieldbox/constructor.t.sol b/test/unit/concrete/yieldbox/constructor.t.sol new file mode 100644 index 0000000..4f73940 --- /dev/null +++ b/test/unit/concrete/yieldbox/constructor.t.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.22; + +import {YieldBoxUnitConcreteTest} from "./YieldBox.t.sol"; + +// Contracts +import {YieldBox, Pearlmit} from "contracts/YieldBox.sol"; + +// Interfaces +import "contracts/interfaces/IWrappedNative.sol"; + +contract ConstructorYB is YieldBoxUnitConcreteTest { + function setUp() public override { + super.setUp(); + } + + function test_constructorYieldBox() public { + + // It should emit an `OwnershipTransferred` event + // Ownership is transferred from deployer to specified `owner_` + vm.expectEmit(); + emit OwnershipTransferred(address(users.alice), address(users.owner)); + + // Deploy YieldBox + YieldBox yieldBoxTest = new YieldBox( + IWrappedNative(address(wrappedNative)), // `wrappedNative_` + yieldBoxUriBuilder, // `uriBuilder_` + pearlmit, // `pearlmit_` + users.owner // `owner_` + ); + + // `wrappedNative` should be set to `wrappedNative_` + assertEq(address(yieldBoxTest.wrappedNative()), address(wrappedNative)); + + // `uriBuilder` should be set to `uriBuilder_` + assertEq(address(yieldBoxTest.uriBuilder()), address(yieldBoxUriBuilder)); + + // `pearlmit` should be set to `pearlmit_` + assertEq(address(yieldBoxTest.pearlmit()), address(pearlmit)); + + // contract owner should be set to `owner_` + assertEq(address(yieldBoxTest.contractOwner()), address(users.owner)); + } +} diff --git a/test/unit/concrete/yieldbox/constructor.tree b/test/unit/concrete/yieldbox/constructor.tree new file mode 100644 index 0000000..e8cc54e --- /dev/null +++ b/test/unit/concrete/yieldbox/constructor.tree @@ -0,0 +1,7 @@ +constructor.t.sol +└── when deployed + ├── `wrappedNative` should be set to `wrappedNative_` + ├── `uriBuilder` should be set to `uriBuilder_` + ├── `pearlmit` should be set to `pearlmit_` + ├── contract owner should be set to `owner_` + └── it should emit an `OwnershipTransferred` event \ No newline at end of file diff --git a/test/unit/concrete/yieldbox/depositAsset.t.sol b/test/unit/concrete/yieldbox/depositAsset.t.sol new file mode 100644 index 0000000..3dd295d --- /dev/null +++ b/test/unit/concrete/yieldbox/depositAsset.t.sol @@ -0,0 +1,675 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.22; + +import {YieldBoxUnitConcreteTest} from "./YieldBox.t.sol"; + +// Contracts +import {YieldBox, Pearlmit} from "contracts/YieldBox.sol"; +import {YieldBoxRebase} from "contracts/YieldBoxRebase.sol"; +import {TokenType} from "contracts/enums/YieldBoxTokenType.sol"; +import {ERC721WithoutStrategy} from "contracts/strategies/ERC721WithoutStrategy.sol"; +import {IYieldBox} from "contracts/interfaces/IYieldBox.sol"; +import {IStrategy} from "contracts/interfaces/IStrategy.sol"; + +// Interfaces +import "contracts/interfaces/IWrappedNative.sol"; + +contract depositAsset is YieldBoxUnitConcreteTest { + ///////////////////////////////////////////////////////////////////// + // STRUCTS // + ///////////////////////////////////////////////////////////////////// + + struct StateBeforeDeposit { + uint256 totalShare; + uint256 totalAmount; + uint256 expectedShare; + uint256 expectedAmount; + } + + ///////////////////////////////////////////////////////////////////// + // SETUP // + ///////////////////////////////////////////////////////////////////// + function setUp() public override { + super.setUp(); + } + + /// @notice Tests the scenario where `from` is not allowed + /// @dev `from not being allowed implies the following: + /// - `from` is different from `msg.sender` + /// - `from` has not approved `msg.sender` for the given asset ID + /// - `from` has not approved `msg.sender` for all assets + function test_depositAssetRevertWhen_CallerIsNotAllowed() public { + // Prank malicious user. No approvals have been performed. + _resetPrank({msgSender: users.eve}); + + // Try to deposit on behalf of impartial user + vm.expectRevert("Transfer not allowed"); + yieldBox.depositAsset( + 1, // `assetId` + users.alice, // `from` + users.eve, // `to` + 1e18, // `amount` + 0 // `share` + ); + } + + /// @notice Tests the scenario where asset is not registered in YieldBox + function test_depositAssetRevertWhen_AssetIsNotRegistered() public { + // Only assets with ID 1, 2 and 3 have been registered. + // Try to deposit an asset not registered in YieldBox. For assets not existing in YieldBox, the expected error + // is a panic (out-of-bounds) error. `expectRevert` does not include a custom way to handle such errors, so we + // use an `expectRevert` without a reason. + vm.expectRevert(); + yieldBox.depositAsset( + 4, // `assetId` + users.alice, // `from` + users.eve, // `to` + 1e18, // `amount` + 0 // `share` + ); + + // Asset with ID 0 is set as default in YieldBox, set with TokenType `None`. It can't be considered a valid asset. + vm.expectRevert(InvalidTokenType.selector); + yieldBox.depositAsset( + 0, // `assetId` + users.alice, // `from` + users.eve, // `to` + 1e18, // `amount` + 0 // `share` + ); + } + + /// @notice Tests the scenario where asset is not an ERC1155 nor an ERC20 + function test_depositAssetRevertWhen_AssetIsNotERC1155NorERC20() public { + // Create mock strategy + ERC721WithoutStrategy erc721Strategy = new ERC721WithoutStrategy( + IYieldBox(address(yieldBox)), + address(dai), // mock, + 1 + ); + + // Register assets with token type ERC721 and Native and try to deposit them + + // ERC721's arent't allowed + uint256 erc721AssetId = yieldBox.registerAsset( + TokenType.ERC721, + address(dai), + IStrategy(address(erc721Strategy)), + 1 + ); + + vm.expectRevert(InvalidTokenType.selector); + yieldBox.depositAsset( + erc721AssetId, // `assetId` + users.alice, // `from` + users.eve, // `to` + 1e18, // `amount` + 0 // `share` + ); + + // Natives arent't allowed + uint256 nativeAssetId = yieldBox.createToken("native", "nat", 18, ""); + vm.expectRevert(InvalidTokenType.selector); + yieldBox.depositAsset( + nativeAssetId, // `assetId` + users.alice, // `from` + users.eve, // `to` + 1e18, // `amount` + 0 // `share` + ); + } + + /// @notice Tests the scenario where shares receiver is address(0) + function test_depositAssetRevertWhen_ToIsAddressZero() public { + vm.expectRevert("No 0 address"); + yieldBox.depositAsset( + DAI_ASSET_ID, // `assetId` + users.alice, // `from` + address(0), // `to` + 1e18, // `amount` + 0 // `share` + ); + } + + /// @notice Tests the scenario where transfer is performed via pearlmit and pearlmit transfer fails + /// @dev Precondition: Impartial user has approved the transfer via Pearlmit + function test_depositAssetRevertWhen_PearlmitTransferFails() + public + whenApprovedViaPearlmit( + users.alice, + address(yieldBox), + type(uint256).max, + block.timestamp + ) + { + // Force pearlmit transfer failure by removing ERC20 (dai) approval from impartial user + dai.approve(address(pearlmit), 0); + + // Pearlmit transfer must fail + vm.expectRevert(PearlmitTransferFailed.selector); + yieldBox.depositAsset( + DAI_ASSET_ID, // `assetId` + users.alice, // `from` + users.alice, // `to` + 1e18, // `amount` + 0 // `share` + ); + } + + /// @notice Tests the scenario where transfer is performed directly via ERC20 and transfer fails + function test_depositAssetRevertWhen_ERC20TransferFails() public { + // Initial scenario where no approval is set to pearlmit (hence ERC20 transfer is performed). + // Approval to yieldbox for the ERC20 is 0 by default, so transfer must fail by default unless an explicit approval is set. + + // ERC20 transfer must fail due to the lack of approval. + vm.expectRevert("BoringERC20: TransferFrom failed"); + yieldBox.depositAsset( + DAI_ASSET_ID, // `assetId` + users.alice, // `from` + users.alice, // `to` + 1e18, // `amount` + 0 // `share` + ); + } + + /// @notice Tests the scenario where the amount to deposit is 0 + /// @dev Precondition: Impartial user has approved the transfer via Pearlmit + function test_depositAssetRevertWhen_ZeroDeposit() + public + whenApprovedViaPearlmit( + users.alice, + address(yieldBox), + type(uint256).max, + block.timestamp + ) + { + // ERC20 transfer must fail due to zero amount being deposited. + vm.expectRevert(InvalidZeroAmounts.selector); + yieldBox.depositAsset( + DAI_ASSET_ID, // `assetId` + users.alice, // `from` + users.alice, // `to` + 0, // `amount` + 0 // `share` + ); + } + + /// @notice Tests happy path when depositing amount without approvals + /// @dev Precondition: Impartial user has approved the transfer via Pearlmit + function test_depositAsset_AmountGreaterThanZero( + uint256 depositAmount + ) + public + whenApprovedViaPearlmit( + users.alice, + address(yieldBox), + type(uint256).max, + block.timestamp + ) + { + vm.assume(depositAmount > 0 && depositAmount <= LARGE_AMOUNT); + + StateBeforeDeposit memory stateBeforeDeposit; + + ( + stateBeforeDeposit.totalShare, + stateBeforeDeposit.totalAmount + ) = yieldBox.assetTotals(DAI_ASSET_ID); + + // Compute expected amount of shares + stateBeforeDeposit.expectedShare = YieldBoxRebase._toShares({ + amount: depositAmount, + totalShares_: stateBeforeDeposit.totalShare, + totalAmount: stateBeforeDeposit.totalAmount, + roundUp: false // it should round down + }); + + // It should emit a `TransferSingle` event + vm.expectEmit(); + emit TransferSingle( + users.alice, + address(0), + users.alice, + DAI_ASSET_ID, + stateBeforeDeposit.expectedShare + ); + + // It should emit a `Deposited` event + vm.expectEmit(); + emit Deposited( + users.alice, + users.alice, + users.alice, + DAI_ASSET_ID, + depositAmount, + stateBeforeDeposit.expectedShare, + 0, + 0, + false + ); + // Perform 1 wei deposit specifying `amount` + yieldBox.depositAsset( + DAI_ASSET_ID, // `assetId` + users.alice, // `from` + users.alice, // `to` + depositAmount, // `amount` + 0 // `share` + ); + + // `balanceOf` of `to` should be incremented by `share` + assertEq( + yieldBox.balanceOf(users.alice, DAI_ASSET_ID), + stateBeforeDeposit.expectedShare + ); + + // `totalSupply` should be incremented by `share` + assertEq( + yieldBox.totalSupply(DAI_ASSET_ID), + stateBeforeDeposit.expectedShare + ); + + // `strategy`'s balance should be incremented by `amount` + assertEq(dai.balanceOf(address(daiStrategy)), depositAmount); + } + + /// @notice Tests happy path when depositing amount via an operator for a given asset ID + /// @dev Precondition: Alice has approved the transfer via Pearlmit + /// @dev Precondition: Alice has approved Bob for asset ID + function test_depositAsset_AmountGreaterThanZeroViaApprovedAssetID( + uint256 depositAmount + ) + public + whenApprovedViaPearlmit( + users.alice, + address(yieldBox), + type(uint256).max, + block.timestamp + ) + whenYieldBoxApprovedForAssetID(users.alice, users.bob, DAI_ASSET_ID) + resetPrank(users.bob) + { + vm.assume(depositAmount > 0 && depositAmount <= LARGE_AMOUNT); + + StateBeforeDeposit memory stateBeforeDeposit; + + ( + stateBeforeDeposit.totalShare, + stateBeforeDeposit.totalAmount + ) = yieldBox.assetTotals(DAI_ASSET_ID); + + // Compute expected amount of shares + stateBeforeDeposit.expectedShare = YieldBoxRebase._toShares({ + amount: depositAmount, + totalShares_: stateBeforeDeposit.totalShare, + totalAmount: stateBeforeDeposit.totalAmount, + roundUp: false // it should round down + }); + + // It should emit a `TransferSingle` event + vm.expectEmit(); + emit TransferSingle( + users.bob, + address(0), + users.bob, + DAI_ASSET_ID, + stateBeforeDeposit.expectedShare + ); + + // It should emit a `Deposited` event + vm.expectEmit(); + emit Deposited( + users.bob, + users.alice, + users.bob, + DAI_ASSET_ID, + depositAmount, + stateBeforeDeposit.expectedShare, + 0, + 0, + false + ); + + // Perform 1 wei deposit specifying `amount` + yieldBox.depositAsset( + DAI_ASSET_ID, // `assetId` + users.alice, // `from` + users.bob, // `to` + depositAmount, // `amount` + 0 // `share` + ); + + // `balanceOf` of `to` should be incremented by `share` + assertEq( + yieldBox.balanceOf(users.bob, DAI_ASSET_ID), + stateBeforeDeposit.expectedShare + ); + + // `totalSupply` should be incremented by `share` + assertEq( + yieldBox.totalSupply(DAI_ASSET_ID), + stateBeforeDeposit.expectedShare + ); + + // `strategy`'s balance should be incremented by `amount` + assertEq(dai.balanceOf(address(daiStrategy)), depositAmount); + } + + /// @notice Tests happy path when depositing amount via an operator for all + /// @dev Precondition: Alice has approved the transfer via Pearlmit + /// @dev Precondition: Alice has approved Bob for all + function test_depositAsset_AmountGreaterThanZeroViaApprovedForAll( + uint256 depositAmount + ) + public + whenApprovedViaPearlmit( + users.alice, + address(yieldBox), + type(uint256).max, + block.timestamp + ) + whenYieldBoxApprovedForAll(users.alice, users.bob) + resetPrank(users.bob) + { + vm.assume(depositAmount > 0 && depositAmount <= LARGE_AMOUNT); + + StateBeforeDeposit memory stateBeforeDeposit; + + ( + stateBeforeDeposit.totalShare, + stateBeforeDeposit.totalAmount + ) = yieldBox.assetTotals(DAI_ASSET_ID); + + // Compute expected amount of shares + stateBeforeDeposit.expectedShare = YieldBoxRebase._toShares({ + amount: depositAmount, + totalShares_: stateBeforeDeposit.totalShare, + totalAmount: stateBeforeDeposit.totalAmount, + roundUp: false // it should round down + }); + + // It should emit a `TransferSingle` event + vm.expectEmit(); + emit TransferSingle( + users.bob, + address(0), + users.bob, + DAI_ASSET_ID, + stateBeforeDeposit.expectedShare + ); + + // It should emit a `Deposited` event + vm.expectEmit(); + emit Deposited( + users.bob, + users.alice, + users.bob, + DAI_ASSET_ID, + depositAmount, + stateBeforeDeposit.expectedShare, + 0, + 0, + false + ); + + // Perform 1 wei deposit specifying `amount` + yieldBox.depositAsset( + DAI_ASSET_ID, // `assetId` + users.alice, // `from` + users.bob, // `to` + depositAmount, // `amount` + 0 // `share` + ); + + // `balanceOf` of `to` should be incremented by `share` + assertEq( + yieldBox.balanceOf(users.bob, DAI_ASSET_ID), + stateBeforeDeposit.expectedShare + ); + + // `totalSupply` should be incremented by `share` + assertEq( + yieldBox.totalSupply(DAI_ASSET_ID), + stateBeforeDeposit.expectedShare + ); + + // `strategy`'s balance should be incremented by `amount` + assertEq(dai.balanceOf(address(daiStrategy)), depositAmount); + } + + /// @notice Tests happy path when depositing shares + /// @dev Precondition: Impartial user has approved the transfer via Pearlmit + function test_depositAsset_SharesGreaterThanZero( + uint256 depositShares + ) + public + whenApprovedViaPearlmit( + users.alice, + address(yieldBox), + type(uint256).max, + block.timestamp + ) + { + // Bound depositShares amount + vm.assume(depositShares > 0 && depositShares <= LARGE_AMOUNT); + + StateBeforeDeposit memory stateBeforeDeposit; + + ( + stateBeforeDeposit.totalShare, + stateBeforeDeposit.totalAmount + ) = yieldBox.assetTotals(DAI_ASSET_ID); + + // Compute expected amount of asset + stateBeforeDeposit.expectedAmount = YieldBoxRebase._toAmount({ + share: depositShares, + totalShares_: stateBeforeDeposit.totalShare, + totalAmount: stateBeforeDeposit.totalAmount, + roundUp: true // it should round down + }); + + // It should emit a `TransferSingle` event + vm.expectEmit(); + emit TransferSingle( + users.alice, + address(0), + users.alice, + DAI_ASSET_ID, + depositShares + ); + + // It should emit a `Deposited` event + vm.expectEmit(); + emit Deposited( + users.alice, + users.alice, + users.alice, + DAI_ASSET_ID, + stateBeforeDeposit.expectedAmount, + depositShares, + 0, + 0, + false + ); + + // Perform 1 wei deposit specifying `shares` + yieldBox.depositAsset( + DAI_ASSET_ID, // `assetId` + users.alice, // `from` + users.alice, // `to` + 0, // `amount` + depositShares // `share` + ); + + // `balanceOf` of `to` should be incremented by `share` + assertEq(yieldBox.balanceOf(users.alice, DAI_ASSET_ID), depositShares); + + // `totalSupply` should be incremented by `share` + assertEq(yieldBox.totalSupply(DAI_ASSET_ID), depositShares); + + // `strategy`'s balance should be incremented by `amount` + assertEq( + dai.balanceOf(address(daiStrategy)), + stateBeforeDeposit.expectedAmount + ); + } + + /// @notice Tests happy path when depositing shares via an operator for a given asset ID + /// @dev Precondition: Alice has approved the transfer via Pearlmit + /// @dev Precondition: Alice has approved Bob for asset ID + function test_depositAsset_SharesGreaterThanZeroViaApprovedAssetID( + uint256 depositShares + ) + public + whenApprovedViaPearlmit( + users.alice, + address(yieldBox), + type(uint256).max, + block.timestamp + ) + whenYieldBoxApprovedForAssetID(users.alice, users.bob, DAI_ASSET_ID) + resetPrank(users.bob) + { + // Bound depositShares amount + vm.assume(depositShares > 0 && depositShares <= LARGE_AMOUNT); + + StateBeforeDeposit memory stateBeforeDeposit; + + ( + stateBeforeDeposit.totalShare, + stateBeforeDeposit.totalAmount + ) = yieldBox.assetTotals(DAI_ASSET_ID); + + // Compute expected amount of asset + stateBeforeDeposit.expectedAmount = YieldBoxRebase._toAmount({ + share: depositShares, + totalShares_: stateBeforeDeposit.totalShare, + totalAmount: stateBeforeDeposit.totalAmount, + roundUp: true // it should round down + }); + + // It should emit a `TransferSingle` event + vm.expectEmit(); + emit TransferSingle( + users.bob, + address(0), + users.bob, + DAI_ASSET_ID, + depositShares + ); + + // It should emit a `Deposited` event + vm.expectEmit(); + emit Deposited( + users.bob, + users.alice, + users.bob, + DAI_ASSET_ID, + stateBeforeDeposit.expectedAmount, + depositShares, + 0, + 0, + false + ); + + // Perform 1 wei deposit specifying `shares` + yieldBox.depositAsset( + DAI_ASSET_ID, // `assetId` + users.alice, // `from` + users.bob, // `to` + 0, // `amount` + depositShares // `share` + ); + + // `balanceOf` of `to` should be incremented by `share` + assertEq(yieldBox.balanceOf(users.bob, DAI_ASSET_ID), depositShares); + + // `totalSupply` should be incremented by `share` + assertEq(yieldBox.totalSupply(DAI_ASSET_ID), depositShares); + + // `strategy`'s balance should be incremented by `amount` + assertEq( + dai.balanceOf(address(daiStrategy)), + stateBeforeDeposit.expectedAmount + ); + } + + /// @notice Tests happy path when depositing shares via an operator for all + /// @dev Precondition: Alice has approved the transfer via Pearlmit + /// @dev Precondition: Alice has approved Bob for all + function test_depositAsset_SharesGreaterThanZeroViaApprovedForAll( + uint256 depositShares + ) + public + whenApprovedViaPearlmit( + users.alice, + address(yieldBox), + type(uint256).max, + block.timestamp + ) + whenYieldBoxApprovedForAll(users.alice, users.bob) + resetPrank(users.bob) + { + // Bound depositShares amount + vm.assume(depositShares > 0 && depositShares <= LARGE_AMOUNT); + + StateBeforeDeposit memory stateBeforeDeposit; + + ( + stateBeforeDeposit.totalShare, + stateBeforeDeposit.totalAmount + ) = yieldBox.assetTotals(DAI_ASSET_ID); + + // Compute expected amount of asset + stateBeforeDeposit.expectedAmount = YieldBoxRebase._toAmount({ + share: depositShares, + totalShares_: stateBeforeDeposit.totalShare, + totalAmount: stateBeforeDeposit.totalAmount, + roundUp: true // it should round down + }); + + // It should emit a `TransferSingle` event + vm.expectEmit(); + emit TransferSingle( + users.bob, + address(0), + users.bob, + DAI_ASSET_ID, + depositShares + ); + + // It should emit a `Deposited` event + vm.expectEmit(); + emit Deposited( + users.bob, + users.alice, + users.bob, + DAI_ASSET_ID, + stateBeforeDeposit.expectedAmount, + depositShares, + 0, + 0, + false + ); + + // Perform 1 wei deposit specifying `shares` + yieldBox.depositAsset( + DAI_ASSET_ID, // `assetId` + users.alice, // `from` + users.bob, // `to` + 0, // `amount` + depositShares // `share` + ); + + // `balanceOf` of `to` should be incremented by `share` + assertEq(yieldBox.balanceOf(users.bob, DAI_ASSET_ID), depositShares); + + // `totalSupply` should be incremented by `share` + assertEq(yieldBox.totalSupply(DAI_ASSET_ID), depositShares); + + // `strategy`'s balance should be incremented by `amount` + assertEq( + dai.balanceOf(address(daiStrategy)), + stateBeforeDeposit.expectedAmount + ); + } +} diff --git a/test/unit/concrete/yieldbox/depositAsset.tree b/test/unit/concrete/yieldbox/depositAsset.tree new file mode 100644 index 0000000..664606c --- /dev/null +++ b/test/unit/concrete/yieldbox/depositAsset.tree @@ -0,0 +1,35 @@ +depositAsset.t.sol +├── when `from` is not `msg.sender` +│ ├── when `msg.sender` is not approved by `from` for asset +│ │ ├── when `msg.sender` is not approved by `from` for all +│ │ │ └── it should revert +│ │ └── it continues execution +│ └── it continues execution +└── it CONTINUES + ├── when asset is not registered in YieldBox + │ └── it should revert + └── when asset is registered in YieldBox + ├── when asset is not an ERC1155 or ERC20 + │ └── it should revert + └── when asset is an ERC1155 or ERC20 + ├── when `to` is address(0) + │ └── it should revert + └──when `to` is not address(0) + ├── when asset is ERC20 + │ ├── when transfer is performed via pearlmit and pearlmit transfer fails + │ │ └── it should revert + │ └── when ERC20 regular transfer fails + │ └── it should revert + ├── when amount of assets supplied is 0 + │ └── it should revert + └── when amount of assets supplied is greater than 0 + ├── when `share` is 0 + │ └── it should compute `share` rounding down `amount` supplied + ├── when `amount` is 0 + │ └── it should compute `amount` rounding up `share` supplied + ├── it should increment `balanceOf` of `to` by `share` + ├── it should increment `totalSupply` by `share` + ├── it should emit a `TransferSingle` event + ├── it should increment `strategy`'s balance by `amount` + └── it should emit a `Deposited` event + \ No newline at end of file diff --git a/test/unit/concrete/yieldbox/depositETHAsset.t.sol b/test/unit/concrete/yieldbox/depositETHAsset.t.sol new file mode 100644 index 0000000..6dcb4bb --- /dev/null +++ b/test/unit/concrete/yieldbox/depositETHAsset.t.sol @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.22; + +import {YieldBoxUnitConcreteTest} from "./YieldBox.t.sol"; + +// Contracts +import {YieldBox, Pearlmit} from "contracts/YieldBox.sol"; +import {YieldBoxRebase} from "contracts/YieldBoxRebase.sol"; +import {TokenType} from "contracts/enums/YieldBoxTokenType.sol"; +import {ERC721WithoutStrategy} from "contracts/strategies/ERC721WithoutStrategy.sol"; +import {IYieldBox} from "contracts/interfaces/IYieldBox.sol"; +import {IStrategy} from "contracts/interfaces/IStrategy.sol"; + +// Interfaces +import "contracts/interfaces/IWrappedNative.sol"; + +contract depositETHAsset is YieldBoxUnitConcreteTest { + function setUp() public override { + super.setUp(); + } + + /// @notice Tests the scenario where `assetId` is not ERC20 + function test_depositETHAssetRevertWhen_AssetIsNotERC20(uint64 depositAmount) public { + + // Try to deposit an incorrect asset + vm.expectRevert(InvalidTokenType.selector); + yieldBox.depositETHAsset{value: depositAmount}( + 0, // assetId 0 is not ERC20 + users.alice, + depositAmount + ); + + // Create mock strategy + ERC721WithoutStrategy erc721Strategy = new ERC721WithoutStrategy( + IYieldBox(address(yieldBox)), + address(dai), // mock, + 1 + ); + + // Register asset with token type ERC721 + + uint256 erc721AssetId = yieldBox.registerAsset( + TokenType.ERC721, + address(dai), + IStrategy(address(erc721Strategy)), + 1 + ); + + // Try to deposit an incorrect asset + vm.expectRevert(InvalidTokenType.selector); + yieldBox.depositETHAsset{value: depositAmount}( + erc721AssetId, + users.alice, + depositAmount + ); + } + + /// @notice Tests the scenario where `contractAddress` from asset is not `wrappedNative` + function test_depositETHAssetRevertWhen_AssetIsNotWrappedNative(uint64 depositAmount) public { + + // Try to deposit an incorrect asset + vm.expectRevert(NotWrapped.selector); + yieldBox.depositETHAsset{value: depositAmount}( + DAI_ASSET_ID, + users.alice, + depositAmount + ); + } + + /// @notice Tests the scenario where `amount` is greater than the passed value + function test_depositETHAssetRevertWhen_AmountIsGreaterThanValue() public { + // Try to deposit a low amount + vm.expectRevert(AmountTooLow.selector); + yieldBox.depositETHAsset{value: WEI_AMOUNT}( + WRAPPED_NATIVE_ASSET_ID, + users.alice, + MEDIUM_AMOUNT + ); + } + + /// @notice Tests the scenario where asset is correct and amount is not greater than value + function test_depositETHAsset_AssetIsSupported(uint64 depositAmount) public { + + (uint256 totalShare, uint256 totalAmount) = yieldBox.assetTotals( + WRAPPED_NATIVE_ASSET_ID + ); + + // Compute expected amount of shares + uint256 expectedShare = YieldBoxRebase._toShares({ + amount: depositAmount, + totalShares_: totalShare, + totalAmount: totalAmount, + roundUp: false + }); + + // It should emit a `TransferSingle` event + vm.expectEmit(); + emit TransferSingle( + users.alice, + address(0), + users.alice, + WRAPPED_NATIVE_ASSET_ID, + expectedShare + ); + + + // It should emit a `Deposited` event + vm.expectEmit(); + emit Deposited( + users.alice, + users.alice, + users.alice, + WRAPPED_NATIVE_ASSET_ID, + depositAmount, + expectedShare, + 0, + 0, + false + ); + + yieldBox.depositETHAsset{value: depositAmount}( + WRAPPED_NATIVE_ASSET_ID, + users.alice, + depositAmount + ); + + // Shares should be minted to `to` + assertEq(yieldBox.balanceOf(users.alice, WRAPPED_NATIVE_ASSET_ID), expectedShare); + + // Strategy's wrapped native balance should have increased by deposited amount + assertEq(wrappedNative.balanceOf(address(wrappedNativeStrategy)), depositAmount); + + } + + /// @notice Tests the scenario where value is greater than passed amount and refund fails + function test_depositETHAssetRevertWhen_ValueRefundFails(uint64 depositAmount, uint64 specifiedAmount) public { + + vm.assume(specifiedAmount != 0 && specifiedAmount < depositAmount); + // We prank DAI as it is a contract without `receive` function. Hence, it can't receive ETH so we force the low-level call to fail + _resetPrank(address(dai)); + deal(address(dai), depositAmount); + + // Force refunds to a receiver that can't receive ether. We force a refund by transferring `MEDIUM_AMOUNT` of value, but only + // settnig `WEI_AMOUNT` as amount. + vm.expectRevert(RefundFailed.selector); + yieldBox.depositETHAsset{value: depositAmount}( + WRAPPED_NATIVE_ASSET_ID, + users.alice, + WEI_AMOUNT + ); + } + + /// @notice Tests the scenario where asset is correct and amount is not greater than value + function test_depositETHAsset_ExcessOfValueIsRefunded(uint64 depositAmount) public { + + (uint256 totalShare, uint256 totalAmount) = yieldBox.assetTotals( + WRAPPED_NATIVE_ASSET_ID + ); + + // Compute expected amount of shares + uint256 expectedShare = YieldBoxRebase._toShares({ + amount: depositAmount, + totalShares_: totalShare, + totalAmount: totalAmount, + roundUp: false + }); + + // It should emit a `TransferSingle` event + vm.expectEmit(); + emit TransferSingle( + users.alice, + address(0), + users.alice, + WRAPPED_NATIVE_ASSET_ID, + expectedShare + ); + + // It should emit a `Deposited` event + vm.expectEmit(); + emit Deposited( + users.alice, + users.alice, + users.alice, + WRAPPED_NATIVE_ASSET_ID, + depositAmount, + expectedShare, + 0, + 0, + false + ); + + uint256 callerBalanceBeforeDeposit = users.alice.balance; + + // We send more value than the submitted by `amount` + yieldBox.depositETHAsset{value: uint256(depositAmount) + 1}( + WRAPPED_NATIVE_ASSET_ID, + users.alice, + depositAmount + ); + + + // Shares should be minted to `to` + assertEq(yieldBox.balanceOf(users.alice, WRAPPED_NATIVE_ASSET_ID), expectedShare); + + // Strategy's wrapped native balance should have increased by deposited amount + assertEq(wrappedNative.balanceOf(address(wrappedNativeStrategy)), depositAmount); + + + + // Caller balance has only decreased by amount specified by `amount` parameter + assertEq(users.alice.balance, callerBalanceBeforeDeposit - depositAmount); + } +} diff --git a/test/unit/concrete/yieldbox/depositETHAsset.tree b/test/unit/concrete/yieldbox/depositETHAsset.tree new file mode 100644 index 0000000..2ed966c --- /dev/null +++ b/test/unit/concrete/yieldbox/depositETHAsset.tree @@ -0,0 +1,20 @@ +depositETHAsset.t.sol +├── when `assetId` is not ERC20 +│ └── it should revert +└── when `assetId` is ERC20 + ├── when `assetId`'s `contractAddress` is not `wrappedNative` + │ └── it should revert + └── when `assetId`'s `contractAddress` is `wrappedNative` + ├── when `amount` is greater than `msg.value` + │ └── it should revert + └── when `amount` is not greater than `msg.value` + ├── it should round `shares` down + ├── it should emit `TransferSingle` event + ├── it should mint converted `shares` to `to` + ├── it should increase `strategy`'s `wrappedNative` balance by `amount` + ├── it should emit `Deposited` event + └── when assets are refunded + ├── when low-level call fails + │ └── it should revert + └── when low-level call fails + └── it should return excess of assets to caller \ No newline at end of file diff --git a/test/unit/concrete/yieldbox/transfer.t.sol b/test/unit/concrete/yieldbox/transfer.t.sol new file mode 100644 index 0000000..14e09cb --- /dev/null +++ b/test/unit/concrete/yieldbox/transfer.t.sol @@ -0,0 +1,307 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.22; + +import {YieldBoxUnitConcreteTest} from "./YieldBox.t.sol"; + +// Contracts +import {YieldBox, Pearlmit} from "contracts/YieldBox.sol"; +import {YieldBoxRebase} from "contracts/YieldBoxRebase.sol"; +import {TokenType} from "contracts/enums/YieldBoxTokenType.sol"; +import {ERC721WithoutStrategy} from "contracts/strategies/ERC721WithoutStrategy.sol"; +import {IYieldBox} from "contracts/interfaces/IYieldBox.sol"; +import {IStrategy} from "contracts/interfaces/IStrategy.sol"; + +// Interfaces +import "contracts/interfaces/IWrappedNative.sol"; + +contract transfer is YieldBoxUnitConcreteTest { + + ///////////////////////////////////////////////////////////////////// + // STRUCT // + ///////////////////////////////////////////////////////////////////// + struct StateBeforeTransfer { + uint256 aliceBalanceBeforeTransfer; + uint256 bobBalanceBeforeTransfer; + } + + + ///////////////////////////////////////////////////////////////////// + // SETUP // + ///////////////////////////////////////////////////////////////////// + function setUp() public override { + super.setUp(); + } + + /// @notice Tests the scenario where `from` is not allowed to transfer. + /// @dev `from not being allowed implies the following: + /// - `from` is different from `msg.sender` + /// - `from` has not approved `msg.sender` for the given asset ID + /// - `from` has not approved `msg.sender` for all assets + function test_transferRevertWhen_CallerIsNotAllowed() + public + whenDeposited(DAI_ASSET_ID, users.alice, users.alice, MEDIUM_AMOUNT, 0) + { + uint256 balanceOfAfterDeposit = yieldBox.balanceOf( + users.alice, + DAI_ASSET_ID + ); + + // Prank malicious user. No approvals have been performed. + _resetPrank({msgSender: users.eve}); + + // Try to transfer assets on behalf of impartial user + vm.expectRevert("Transfer not allowed"); + yieldBox.transfer( + users.alice, + users.eve, + DAI_ASSET_ID, + balanceOfAfterDeposit + ); + } + + /// @notice Tests the scenario where `asset`is not registered in YieldBox. + /// @dev Trying to transfer assets not registered relies on users not being able to have a positive balance of + /// an asset that still has not been added to the protocol. Hence, zero-value transfers of non-registered assets + /// are still allowed, although behave as a no-op. + function test_transferRevertWhen_AssetIsNotRegistered( + uint256 transferAmount + ) public assumeNoZeroValue(transferAmount) { + // Try to transfer asset not registered in YieldBox. + vm.expectRevert(); + yieldBox.transfer(users.alice, users.bob, 5, transferAmount); + } + + /// @notice Tests the scenario where `to` is address(0) + function test_transferRevertWhen_ToIsZeroAddress( + uint64 depositAmount + ) + public + assumeNoZeroValue(depositAmount) + whenDeposited(DAI_ASSET_ID, users.alice, users.alice, depositAmount, 0) + { + uint256 balanceOfAfterDeposit = yieldBox.balanceOf( + users.alice, + DAI_ASSET_ID + ); + + // Try to transfer assets to address(0) + vm.expectRevert("No 0 address"); + yieldBox.transfer( + users.alice, + address(0), + DAI_ASSET_ID, + balanceOfAfterDeposit + ); + } + + /// @notice Tests the scenario where amount transferred exceeds balance. + function test_transferRevertWhen_ValueIsGreaterThanBalance( + uint64 depositAmount, + uint16 addition + ) + public + assumeNoZeroValue(addition) + assumeNoZeroValue(depositAmount) + whenDeposited(DAI_ASSET_ID, users.alice, users.alice, depositAmount, 0) + { + uint256 balanceOfAfterDeposit = yieldBox.balanceOf( + users.alice, + DAI_ASSET_ID + ); + + // Try to transfer more assets than held. The expected error + // is a panic (underflow) error. `expectRevert` does not include a custom way to handle such errors, so we + // use an `expectRevert` without a reason. + vm.expectRevert(); + yieldBox.transfer( + users.alice, + users.alice, + DAI_ASSET_ID, + balanceOfAfterDeposit + uint256(addition) + ); + } + + /// @notice Tests the scenario where amount transferred exceeds balance, on behalf of shares owner, by approving asset. + function test_transferRevertWhen_ValueIsGreaterThanBalanceWhenApprovedForAssetID( + uint32 depositAmount + ) + public + assumeNoZeroValue(depositAmount) + whenDeposited(DAI_ASSET_ID, users.alice, users.alice, depositAmount, 0) + whenYieldBoxApprovedForAssetID(users.alice, users.bob, DAI_ASSET_ID) + { + { + uint256 balanceOfAfterDeposit = yieldBox.balanceOf( + users.alice, + DAI_ASSET_ID + ); + + // PRank different user + _resetPrank(users.bob); + + // Try to transfer more assets than held. The expected error + // is a panic (underflow) error. `expectRevert` does not include a custom way to handle such errors, so we + // use an `expectRevert` without a reason. + vm.expectRevert(); + yieldBox.transfer( + users.alice, + users.bob, + DAI_ASSET_ID, + balanceOfAfterDeposit + 1 + ); + } + } + + /// @notice Tests the scenario where amount transferred exceeds balance, on behalf of shares owner by approving all. + function test_transferRevertWhen_ValueIsGreaterThanBalanceWhenApprovedForAll( + uint32 depositAmount + ) + public + assumeNoZeroValue(depositAmount) + whenDeposited(DAI_ASSET_ID, users.alice, users.alice, depositAmount, 0) + whenYieldBoxApprovedForAll(users.alice, users.bob) + { + { + uint256 balanceOfAfterDeposit = yieldBox.balanceOf( + users.alice, + DAI_ASSET_ID + ); + + // PRank different user + _resetPrank(users.bob); + + // Try to transfer more assets than held. The expected error + // is a panic (underflow) error. `expectRevert` does not include a custom way to handle such errors, so we + // use an `expectRevert` without a reason. + vm.expectRevert(); + yieldBox.transfer( + users.alice, + users.bob, + DAI_ASSET_ID, + balanceOfAfterDeposit + 1 + ); + } + } + + /// @notice Tests the happy path, where amount transferred is smaller or equal to share balance. + function test_transferWhen_ValueIsSmallerOrEqualToBalance( + uint64 depositAmount, + uint64 transferAmount + ) + public + assumeNoZeroValue(depositAmount) + assumeNoZeroValue(transferAmount) + whenDeposited(DAI_ASSET_ID, users.alice, users.alice, 0, depositAmount) + { + + // Ensure amount to transfer is valid. + vm.assume(transferAmount <= depositAmount); + + // Fetch previous balances + StateBeforeTransfer memory stateBeforeTransfer; + + stateBeforeTransfer.aliceBalanceBeforeTransfer = yieldBox.balanceOf(users.alice, DAI_ASSET_ID); + stateBeforeTransfer.bobBalanceBeforeTransfer = yieldBox.balanceOf(users.bob, DAI_ASSET_ID); + + // It should emit `TransferSingle` event + vm.expectEmit(); + emit TransferSingle(users.alice, users.alice, users.bob, DAI_ASSET_ID, transferAmount); + + // Transfer assets. + yieldBox.transfer( + users.alice, + users.bob, + DAI_ASSET_ID, + transferAmount + ); + + // It should decrement `balanceOf` `from` by `value` + assertEq(yieldBox.balanceOf(users.alice, DAI_ASSET_ID), stateBeforeTransfer.aliceBalanceBeforeTransfer - transferAmount); + + // It should increment `balanceOf` `to` by `value` + assertEq(yieldBox.balanceOf(users.bob, DAI_ASSET_ID), stateBeforeTransfer.bobBalanceBeforeTransfer + transferAmount); + } + + /// @notice Tests the happy path, where amount transferred is smaller or equal to share balance via asset ID approval. + /// @dev Operator is set to `users.charlie`. + function test_transferWhen_ValueIsSmallerOrEqualToBalanceWhenApprovedForAssetID( + uint64 transferAmount + ) + public + assumeNoZeroValue(transferAmount) + whenDeposited(DAI_ASSET_ID, users.alice, users.alice, 0, MEDIUM_AMOUNT) + whenYieldBoxApprovedForAssetID(users.alice, users.charlie, DAI_ASSET_ID) + { + // Set operator + _resetPrank(users.charlie); + + + // Ensure amount to transfer is valid. + vm.assume(transferAmount <= MEDIUM_AMOUNT); + + // Fetch previous balances + StateBeforeTransfer memory stateBeforeTransfer; + + stateBeforeTransfer.aliceBalanceBeforeTransfer = yieldBox.balanceOf(users.alice, DAI_ASSET_ID); + stateBeforeTransfer.bobBalanceBeforeTransfer = yieldBox.balanceOf(users.bob, DAI_ASSET_ID); + + // It should emit `TransferSingle` event. Operator is set to Charlie. + vm.expectEmit(); + emit TransferSingle(users.charlie, users.alice, users.bob, DAI_ASSET_ID, transferAmount); + + // Transfer assets. + yieldBox.transfer( + users.alice, + users.bob, + DAI_ASSET_ID, + transferAmount + ); + + // It should decrement `balanceOf` `from` by `value` + assertEq(yieldBox.balanceOf(users.alice, DAI_ASSET_ID), stateBeforeTransfer.aliceBalanceBeforeTransfer - transferAmount); + + // It should increment `balanceOf` `to` by `value` + assertEq(yieldBox.balanceOf(users.bob, DAI_ASSET_ID), stateBeforeTransfer.bobBalanceBeforeTransfer + transferAmount); + } + + /// @notice Tests the happy path, where amount transferred is smaller or equal to share balance via all approval. + /// @dev Operator is set to `users.charlie`. + function test_transferWhen_ValueIsSmallerOrEqualToBalanceWhenApprovedForAll( + uint64 transferAmount + ) + public + assumeNoZeroValue(transferAmount) + whenDeposited(DAI_ASSET_ID, users.alice, users.alice, 0, MEDIUM_AMOUNT) + whenYieldBoxApprovedForAll(users.alice, users.charlie) + { + // Set operator + _resetPrank(users.charlie); + + // Ensure amount to transfer is valid. + vm.assume(transferAmount <= MEDIUM_AMOUNT); + + // Fetch previous balances + StateBeforeTransfer memory stateBeforeTransfer; + + stateBeforeTransfer.aliceBalanceBeforeTransfer = yieldBox.balanceOf(users.alice, DAI_ASSET_ID); + stateBeforeTransfer.bobBalanceBeforeTransfer = yieldBox.balanceOf(users.bob, DAI_ASSET_ID); + + // It should emit `TransferSingle` event. Operator is set to Charlie. + vm.expectEmit(); + emit TransferSingle(users.charlie, users.alice, users.bob, DAI_ASSET_ID, transferAmount); + + // Transfer assets. + yieldBox.transfer( + users.alice, + users.bob, + DAI_ASSET_ID, + transferAmount + ); + + // It should decrement `balanceOf` `from` by `value` + assertEq(yieldBox.balanceOf(users.alice, DAI_ASSET_ID), stateBeforeTransfer.aliceBalanceBeforeTransfer - transferAmount); + + // It should increment `balanceOf` `to` by `value` + assertEq(yieldBox.balanceOf(users.bob, DAI_ASSET_ID), stateBeforeTransfer.bobBalanceBeforeTransfer + transferAmount); + } +} diff --git a/test/unit/concrete/yieldbox/transfer.tree b/test/unit/concrete/yieldbox/transfer.tree new file mode 100644 index 0000000..959e1b6 --- /dev/null +++ b/test/unit/concrete/yieldbox/transfer.tree @@ -0,0 +1,20 @@ +transfer.t.sol +├── when `from` is not `msg.sender` +│ ├── when `msg.sender` is not approved by `from` for asset +│ │ ├── when `msg.sender` is not approved by `from` for all +│ │ │ └── it should revert +│ │ └── it continues execution +│ └── it continues execution +└── it CONTINUES + ├── when asset is not registered in YieldBox + │ └── it should revert + └── when asset is registered in YieldBox + └── when `to` is address(0) + │ └── it should revert + └── when `to` is not address(0) + └── when `value` transferred is greater than `from` `balanceOf` + └── it should revert + └── when `value` transferred is smaller or equal to `from`'s `balanceOf` + ├── it should decrement `balanceOf` `from` by `value` + ├── it should increment `balanceOf` `to` by `value` + └── it should emit `TransferSingle` event \ No newline at end of file diff --git a/test/unit/concrete/yieldbox/transferMultiple.t.sol b/test/unit/concrete/yieldbox/transferMultiple.t.sol new file mode 100644 index 0000000..9ca49ba --- /dev/null +++ b/test/unit/concrete/yieldbox/transferMultiple.t.sol @@ -0,0 +1,426 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.22; + +import {YieldBoxUnitConcreteTest} from "./YieldBox.t.sol"; + +// Contracts +import {YieldBox, Pearlmit} from "contracts/YieldBox.sol"; +import {YieldBoxRebase} from "contracts/YieldBoxRebase.sol"; +import {TokenType} from "contracts/enums/YieldBoxTokenType.sol"; +import {ERC721WithoutStrategy} from "contracts/strategies/ERC721WithoutStrategy.sol"; +import {IYieldBox} from "contracts/interfaces/IYieldBox.sol"; +import {IStrategy} from "contracts/interfaces/IStrategy.sol"; + +// Interfaces +import "contracts/interfaces/IWrappedNative.sol"; + +contract transferMultiple is YieldBoxUnitConcreteTest { + + ///////////////////////////////////////////////////////////////////// + // STRUCTS // + ///////////////////////////////////////////////////////////////////// + struct StateBeforeTransferMultiple { + address[] tos; + uint256[] amounts; + uint256[] previousBalances; + } + + ///////////////////////////////////////////////////////////////////// + // INTERNAL HELPERS // + ///////////////////////////////////////////////////////////////////// + function _buildTransferMultipleRequiredData( + address[] memory tos, + uint256[] memory amounts, + uint256 amount + ) internal view returns (address[] memory, uint256[] memory) { + // Build array of `tos` + tos[0] = users.bob; + tos[1] = users.charlie; + tos[2] = users.david; + tos[3] = users.eve; + + // Build array of `amounts` + amounts[0] = amount / 4; + amounts[1] = amount / 4; + amounts[2] = amount / 4; + amounts[3] = amount / 4; + } + + function _assertExpectedBalances( + uint256[] memory previousBalances, + address[] memory tos, + uint256[] memory amounts + ) internal view returns (address[] memory, uint256[] memory) { + for (uint256 i; i < tos.length; i++) { + assertEq( + yieldBox.balanceOf(tos[i], DAI_ASSET_ID), + previousBalances[i] + amounts[i] + ); + } + } + + function _triggerMultipleExpectEmits( + address operator, + address from, + address[] memory tos, + uint256[] memory amounts + ) internal { + for (uint256 i; i < tos.length; i++) { + vm.expectEmit(); + emit TransferSingle( + operator, + from, + tos[i], + DAI_ASSET_ID, + amounts[i] + ); + } + } + + function _fetchMultipleBalances( + uint256[] memory previousBalances, + address[] memory tos + ) internal view returns (uint256[] memory) { + for (uint256 i; i < 4; i++) { + previousBalances[i] = yieldBox.balanceOf(tos[i], DAI_ASSET_ID); + } + } + + ///////////////////////////////////////////////////////////////////// + // SETUP // + ///////////////////////////////////////////////////////////////////// + function setUp() public override { + super.setUp(); + } + + /// @notice Tests the scenario where `from` is not allowed to transferMultiple. + /// @dev `from not being allowed implies the following: + /// - `from` is different from `msg.sender` + /// - `from` has not approved `msg.sender` for the given asset ID + /// - `from` has not approved `msg.sender` for all assets + function test_transfeMultipleRevertWhen_CallerIsNotAllowed( + uint64 depositAmount + ) + public + assumeGtE(depositAmount, 5) + whenDeposited(DAI_ASSET_ID, users.alice, users.alice, 0, depositAmount) + { + // Prank malicious user. No approvals have been performed. + _resetPrank({msgSender: users.eve}); + + StateBeforeTransferMultiple memory stateBeforeTransferMultiple; + + // Initialize required data + stateBeforeTransferMultiple.tos = new address[](4); + stateBeforeTransferMultiple.amounts = new uint256[](4); + + // Build required data + _buildTransferMultipleRequiredData( + stateBeforeTransferMultiple.tos, + stateBeforeTransferMultiple.amounts, + depositAmount + ); + + // Try to transfer assets on behalf of impartial user + vm.expectRevert("Transfer not allowed"); + yieldBox.transferMultiple( + users.alice, + stateBeforeTransferMultiple.tos, + DAI_ASSET_ID, + stateBeforeTransferMultiple.amounts + ); + } + + /// @notice Tests the scenario where `from` is not allowed to transferMultiple. + /// @dev `from not being allowed implies the following: + /// - `from` is different from `msg.sender` + /// - `from` has not approved `msg.sender` for the given asset ID + /// - `from` has not approved `msg.sender` for all assets + function test_transfeMultipleRevertWhen__AssetIsNotRegistered( + uint64 depositAmount + ) public assumeGtE(depositAmount, 5) { + StateBeforeTransferMultiple memory stateBeforeTransferMultiple; + + // Fill required data + stateBeforeTransferMultiple.tos = new address[](4); + stateBeforeTransferMultiple.amounts = new uint256[](4); + + // Build required data + _buildTransferMultipleRequiredData( + stateBeforeTransferMultiple.tos, + stateBeforeTransferMultiple.amounts, + depositAmount + ); + // Try to transfer asset not registered in YieldBox. + vm.expectRevert(); + yieldBox.transferMultiple( + users.alice, + stateBeforeTransferMultiple.tos, + 5, // invalid asset ID + stateBeforeTransferMultiple.amounts + ); + } + + /// @notice Tests the scenario where any of `tos` is address(0) + function test_transfeMultipleRevertWhen__AssetIsNotRegistered( + uint64 depositAmount, + uint8 rand + ) public assumeGtE(depositAmount, 5) { + StateBeforeTransferMultiple memory stateBeforeTransferMultiple; + + // Fill required data + stateBeforeTransferMultiple.tos = new address[](4); + stateBeforeTransferMultiple.amounts = new uint256[](4); + + // Build required data + _buildTransferMultipleRequiredData( + stateBeforeTransferMultiple.tos, + stateBeforeTransferMultiple.amounts, + depositAmount + ); + + // Set one of `tos` to address(0) + stateBeforeTransferMultiple.tos[rand % 4] = address(0); + + // Try to transfer shares to zero address. + vm.expectRevert(ZeroAddress.selector); + yieldBox.transferMultiple( + users.alice, + stateBeforeTransferMultiple.tos, + DAI_ASSET_ID, // invalid asset ID + stateBeforeTransferMultiple.amounts + ); + } + + /// @notice Tests the scenario where `_totalShares` is bigger than `balanceOf` `from` + function test_transfeMultipleRevertWhen__AmountIsBiggerThanBalanceOfFrom( + uint64 depositAmount, + uint8 rand + ) public assumeGtE(depositAmount, 5) { + StateBeforeTransferMultiple memory stateBeforeTransferMultiple; + + // Fill required data + stateBeforeTransferMultiple.tos = new address[](4); + stateBeforeTransferMultiple.amounts = new uint256[](4); + + // Build required data + _buildTransferMultipleRequiredData( + stateBeforeTransferMultiple.tos, + stateBeforeTransferMultiple.amounts, + depositAmount + ); + + // Inflate amounts + stateBeforeTransferMultiple.amounts[rand % 4] = + stateBeforeTransferMultiple.amounts[rand % 4] * + 5; + + // Try to transfer asset not registered in YieldBox. + vm.expectRevert(); + yieldBox.transferMultiple( + users.alice, + stateBeforeTransferMultiple.tos, + DAI_ASSET_ID, // invalid asset ID + stateBeforeTransferMultiple.amounts + ); + } + + /// @notice Tests the scenario where `_totalShares` is bigger than `balanceOf` `from` + function test_transferMultipleWhen_ValueIsSmallerOrEqualToBalance( + uint64 depositAmount + ) + public + assumeGtE(depositAmount, 5) + whenDeposited(DAI_ASSET_ID, users.alice, users.alice, 0, depositAmount) + { + StateBeforeTransferMultiple memory stateBeforeTransferMultiple; + + // Fill required data + stateBeforeTransferMultiple.tos = new address[](4); + stateBeforeTransferMultiple.amounts = new uint256[](4); + stateBeforeTransferMultiple.previousBalances = new uint256[](4); + + // Build required data + _buildTransferMultipleRequiredData( + stateBeforeTransferMultiple.tos, + stateBeforeTransferMultiple.amounts, + depositAmount + ); + + // Fetch previous state + _fetchMultipleBalances( + stateBeforeTransferMultiple.previousBalances, + stateBeforeTransferMultiple.tos + ); + + uint256 aliceBalanceBefore = yieldBox.balanceOf( + users.alice, + DAI_ASSET_ID + ); + + // it should emit a `TransferSingle` event for each iteration + _triggerMultipleExpectEmits({ + operator: users.alice, + from: users.alice, + tos: stateBeforeTransferMultiple.tos, + amounts: stateBeforeTransferMultiple.amounts + }); + + // Try to transfer asset not registered in YieldBox. + yieldBox.transferMultiple( + users.alice, + stateBeforeTransferMultiple.tos, + DAI_ASSET_ID, // invalid asset ID + stateBeforeTransferMultiple.amounts + ); + + // it should increment `balanceOf` each `to` by its respective `shares` + _assertExpectedBalances( + stateBeforeTransferMultiple.previousBalances, + stateBeforeTransferMultiple.tos, + stateBeforeTransferMultiple.amounts + ); + + // it should decrement `balanceOf` `from` by the total accumulated `_totalShares` + assertEq( + yieldBox.balanceOf(users.alice, DAI_ASSET_ID), + aliceBalanceBefore - stateBeforeTransferMultiple.amounts[0] * 4 // all transferred amounts are equal + ); + } + + /// @notice Tests the scenario where `_totalShares` is bigger than `balanceOf` `from` via asset ID approval. + function test_transferMultipleWhen_ValueIsSmallerOrEqualToBalanceViaApprovedForAssetID( + uint64 depositAmount + ) + public + assumeGtE(depositAmount, 5) + whenDeposited(DAI_ASSET_ID, users.alice, users.alice, 0, depositAmount) + whenYieldBoxApprovedForAssetID(users.alice, users.owner, DAI_ASSET_ID) + { + StateBeforeTransferMultiple memory stateBeforeTransferMultiple; + + // Fill required data + stateBeforeTransferMultiple.tos = new address[](4); + stateBeforeTransferMultiple.amounts = new uint256[](4); + stateBeforeTransferMultiple.previousBalances = new uint256[](4); + + // Build required data + _buildTransferMultipleRequiredData( + stateBeforeTransferMultiple.tos, + stateBeforeTransferMultiple.amounts, + depositAmount + ); + + // Fetch previous state + _fetchMultipleBalances( + stateBeforeTransferMultiple.previousBalances, + stateBeforeTransferMultiple.tos + ); + + uint256 aliceBalanceBefore = yieldBox.balanceOf( + users.alice, + DAI_ASSET_ID + ); + + // Owner becomes operator + _resetPrank(users.owner); + + // it should emit a `TransferSingle` event for each iteration + _triggerMultipleExpectEmits({ + operator: users.owner, + from: users.alice, + tos: stateBeforeTransferMultiple.tos, + amounts: stateBeforeTransferMultiple.amounts + }); + + // Try to transfer asset not registered in YieldBox. + yieldBox.transferMultiple( + users.alice, + stateBeforeTransferMultiple.tos, + DAI_ASSET_ID, // invalid asset ID + stateBeforeTransferMultiple.amounts + ); + + // it should increment `balanceOf` each `to` by its respective `shares` + _assertExpectedBalances( + stateBeforeTransferMultiple.previousBalances, + stateBeforeTransferMultiple.tos, + stateBeforeTransferMultiple.amounts + ); + + // it should decrement `balanceOf` `from` by the total accumulated `_totalShares` + assertEq( + yieldBox.balanceOf(users.alice, DAI_ASSET_ID), + aliceBalanceBefore - stateBeforeTransferMultiple.amounts[0] * 4 // all transferred amounts are equal + ); + } + + /// @notice Tests the scenario where `_totalShares` is bigger than `balanceOf` `from` via approval for all. + function test_transferMultipleWhen_ValueIsSmallerOrEqualToBalanceViaApprovedForAll( + uint64 depositAmount + ) + public + assumeGtE(depositAmount, 5) + whenDeposited(DAI_ASSET_ID, users.alice, users.alice, 0, depositAmount) + whenYieldBoxApprovedForAll(users.alice, users.owner) + { + StateBeforeTransferMultiple memory stateBeforeTransferMultiple; + + // Fill required data + stateBeforeTransferMultiple.tos = new address[](4); + stateBeforeTransferMultiple.amounts = new uint256[](4); + stateBeforeTransferMultiple.previousBalances = new uint256[](4); + + // Build required data + _buildTransferMultipleRequiredData( + stateBeforeTransferMultiple.tos, + stateBeforeTransferMultiple.amounts, + depositAmount + ); + + // Fetch previous state + _fetchMultipleBalances( + stateBeforeTransferMultiple.previousBalances, + stateBeforeTransferMultiple.tos + ); + + uint256 aliceBalanceBefore = yieldBox.balanceOf( + users.alice, + DAI_ASSET_ID + ); + + // Owner becomes operator + _resetPrank(users.owner); + + // it should emit a `TransferSingle` event for each iteration + _triggerMultipleExpectEmits({ + operator: users.owner, + from: users.alice, + tos: stateBeforeTransferMultiple.tos, + amounts: stateBeforeTransferMultiple.amounts + }); + + // Try to transfer asset not registered in YieldBox. + yieldBox.transferMultiple( + users.alice, + stateBeforeTransferMultiple.tos, + DAI_ASSET_ID, // invalid asset ID + stateBeforeTransferMultiple.amounts + ); + + // it should increment `balanceOf` each `to` by its respective `shares` + _assertExpectedBalances( + stateBeforeTransferMultiple.previousBalances, + stateBeforeTransferMultiple.tos, + stateBeforeTransferMultiple.amounts + ); + + // it should decrement `balanceOf` `from` by the total accumulated `_totalShares` + assertEq( + yieldBox.balanceOf(users.alice, DAI_ASSET_ID), + aliceBalanceBefore - stateBeforeTransferMultiple.amounts[0] * 4 // all transferred amounts are equal + ); + + + } +} diff --git a/test/unit/concrete/yieldbox/transferMultiple.tree b/test/unit/concrete/yieldbox/transferMultiple.tree new file mode 100644 index 0000000..b8ff8b5 --- /dev/null +++ b/test/unit/concrete/yieldbox/transferMultiple.tree @@ -0,0 +1,20 @@ +transferMultiple.t.sol +├── when `from` is not `msg.sender` +│ ├── when `msg.sender` is not approved by `from` for asset +│ │ ├── when `msg.sender` is not approved by `from` for all +│ │ │ └── it should revert +│ │ └── it continues execution +│ └── it continues execution +└── it CONTINUES + ├── when asset is not registered in YieldBox + │ └── it should revert + └── when asset is registered in YieldBox + └── when any of `tos` is address(0) + │ └── it should revert + └── when all `tos` are not address(0) + └── when `_totalShares` is bigger than `balanceOf` `from` + │ └── it should revert + └── when `_totalShares` is smaller or equal than `balanceOf` `from` + ├── it should increment `balanceOf` each `to` by the respective `shares` + ├── it should emit a `TransferSingle` event for each iteration + └── it should decrement `balanceOf` `from` by the total accumulated `_totalShares` diff --git a/test/unit/concrete/yieldbox/withdraw.t.sol b/test/unit/concrete/yieldbox/withdraw.t.sol new file mode 100644 index 0000000..d43883b --- /dev/null +++ b/test/unit/concrete/yieldbox/withdraw.t.sol @@ -0,0 +1,626 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.22; + +import {YieldBoxUnitConcreteTest} from "./YieldBox.t.sol"; + +// Contracts +import {YieldBox, Pearlmit} from "contracts/YieldBox.sol"; +import {YieldBoxRebase} from "contracts/YieldBoxRebase.sol"; +import {TokenType} from "contracts/enums/YieldBoxTokenType.sol"; +import {ERC721WithoutStrategy} from "contracts/strategies/ERC721WithoutStrategy.sol"; +import {IYieldBox} from "contracts/interfaces/IYieldBox.sol"; +import {IStrategy} from "contracts/interfaces/IStrategy.sol"; + +// Interfaces +import "contracts/interfaces/IWrappedNative.sol"; + +contract withdraw is YieldBoxUnitConcreteTest { + ///////////////////////////////////////////////////////////////////// + // STRUCTS // + ///////////////////////////////////////////////////////////////////// + + /// @notice Tracks state prior to withdrawing. + struct StateBeforeWithdrawal { + uint256 userShareBalanceBeforeWithdrawal; + uint256 totalSupplyBeforeWithdrawal; + uint256 strategyAssetBalanceBeforeWithdrawal; + uint256 totalShare; + uint256 totalAmount; + uint256 expectedShares; + uint256 expectedAmount; + } + + ///////////////////////////////////////////////////////////////////// + // SETUP // + ///////////////////////////////////////////////////////////////////// + function setUp() public override { + super.setUp(); + } + + /// @notice Tests the scenario where `from` is not allowed + /// @dev `from not being allowed implies the following: + /// - `from` is different from `msg.sender` + /// - `from` has not approved `msg.sender` for the given asset ID + /// - `from` has not approved `msg.sender` for all assets + function test_withdrawRevertWhen_CallerIsNotAllowed( + uint64 withdrawAmount + ) + public + assumeNoZeroValue(withdrawAmount) + whenDeposited(DAI_ASSET_ID, users.alice, users.alice, 0, withdrawAmount) + { + // Prank malicious user + _resetPrank({msgSender: users.eve}); + + // Try to withdraw on behalf of impartial user without approval. + vm.expectRevert("Transfer not allowed"); + yieldBox.withdraw( + DAI_ASSET_ID, // `assetId` + users.alice, // `from` + users.eve, // `to` + withdrawAmount, // `amount` + 0 // `share` + ); + } + + /// @notice Tests the scenario where `asset` to withdraw is native + function test_withdrawRevertWhen_AssetIsNative( + uint64 withdrawAmount + ) + public + assumeNoZeroValue(withdrawAmount) + whenDeposited(DAI_ASSET_ID, users.alice, users.alice, 0, withdrawAmount) + { + // Create native asset + uint256 nativeAssetId = yieldBox.createToken("native", "nat", 18, ""); + + // Try to withdraw on behalf of impartial user + vm.expectRevert(InvalidTokenType.selector); + yieldBox.withdraw( + nativeAssetId, // `assetId` + users.alice, // `from` + users.alice, // `to` + 0, // `amount` + withdrawAmount // `share` + ); + } + + /// @notice Tests the scenario where shares are properly computed given a certain amount. + function test_withdraw_SharesAreCorrectGivenAmount( + uint64 depositAmount, + uint64 withdrawAmount + ) + public + assumeNoZeroValue(depositAmount) + assumeNoZeroValue(withdrawAmount) + whenDeposited(DAI_ASSET_ID, users.alice, users.alice, withdrawAmount, 0) + + { + // Bound withdrawal amount to the max amount deposited + vm.assume(withdrawAmount <= depositAmount); + + // Fetch data prior to withdrawing + StateBeforeWithdrawal memory stateBeforeWithdrawal; + + (stateBeforeWithdrawal.totalShare, stateBeforeWithdrawal.totalAmount) = yieldBox.assetTotals( + DAI_ASSET_ID + ); + + // Compute expected shares to withdraw (rounding up for withdrawals) + stateBeforeWithdrawal.expectedShares = YieldBoxRebase._toShares({ + amount: withdrawAmount, + totalShares_: stateBeforeWithdrawal.totalShare, + totalAmount: stateBeforeWithdrawal.totalAmount, + roundUp: true + }); + + + // Fetch impartial user's share balance + stateBeforeWithdrawal.userShareBalanceBeforeWithdrawal = yieldBox + .balanceOf(users.alice, DAI_ASSET_ID); + + // Fetch total supply + stateBeforeWithdrawal.totalSupplyBeforeWithdrawal = yieldBox + .totalSupply(DAI_ASSET_ID); + + // Fetch strategy asset balance + stateBeforeWithdrawal.strategyAssetBalanceBeforeWithdrawal = dai + .balanceOf(address(daiStrategy)); + + // It should emit a `TransferSingle` event + vm.expectEmit(); + emit TransferSingle( + users.alice, + users.alice, + address(0), + DAI_ASSET_ID, + stateBeforeWithdrawal.expectedShares + ); + + vm.expectEmit(); + emit Withdraw( + users.alice, + users.alice, + users.alice, + DAI_ASSET_ID, + withdrawAmount, + stateBeforeWithdrawal.expectedShares, + 0, + 0 + ); + + // Trigger withdrawal + yieldBox.withdraw( + DAI_ASSET_ID, // `assetId` + users.alice, // `from` + users.alice, // `to` + withdrawAmount, // `amount` + 0 // `share` + ); + + // It should decrement `balanceOf` of `to` by `share` + assertEq( + stateBeforeWithdrawal.userShareBalanceBeforeWithdrawal, + yieldBox.balanceOf(users.alice, DAI_ASSET_ID) + + stateBeforeWithdrawal.expectedShares + ); + + // It should decrement `totalSupply` by `share` + assertEq( + stateBeforeWithdrawal.totalSupplyBeforeWithdrawal, + yieldBox.totalSupply(DAI_ASSET_ID) + stateBeforeWithdrawal.expectedShares + ); + + // It should decrement `strategy`'s balance by `amount` + assertEq( + stateBeforeWithdrawal.strategyAssetBalanceBeforeWithdrawal, + dai.balanceOf(address(daiStrategy)) + withdrawAmount + ); + } + + /// @notice Tests the scenario where shares are properly computed given a certain amount managed via an operator for a given asset ID. + /// @dev Precondition: Alice has deposited assets. + /// @dev Precondition: Alice has approved Bob for asset ID + function test_withdraw_SharesAreCorrectGivenAmountViaApprovedAssetID( + ) + public + whenYieldBoxApprovedForAssetID(users.alice, users.bob, DAI_ASSET_ID) + whenDeposited(DAI_ASSET_ID, users.alice, users.alice, LARGE_AMOUNT, 0) + resetPrank(users.bob) + { + + // Fetch data prior to withdrawing + StateBeforeWithdrawal memory stateBeforeWithdrawal; + + (stateBeforeWithdrawal.totalShare, stateBeforeWithdrawal.totalAmount) = yieldBox.assetTotals( + DAI_ASSET_ID + ); + + // Compute expected shares to withdraw (rounding up for withdrawals) + stateBeforeWithdrawal.expectedShares = YieldBoxRebase._toShares({ + amount: MEDIUM_AMOUNT, + totalShares_: stateBeforeWithdrawal.totalShare, + totalAmount: stateBeforeWithdrawal.totalAmount, + roundUp: true + }); + + + // Fetch impartial user's share balance + stateBeforeWithdrawal.userShareBalanceBeforeWithdrawal = yieldBox + .balanceOf(users.alice, DAI_ASSET_ID); + + // Fetch total supply + stateBeforeWithdrawal.totalSupplyBeforeWithdrawal = yieldBox + .totalSupply(DAI_ASSET_ID); + + // Fetch strategy asset balance + stateBeforeWithdrawal.strategyAssetBalanceBeforeWithdrawal = dai + .balanceOf(address(daiStrategy)); + + // It should emit a `TransferSingle` event + vm.expectEmit(); + emit TransferSingle( + users.bob, + users.alice, + address(0), + DAI_ASSET_ID, + stateBeforeWithdrawal.expectedShares + ); + + vm.expectEmit(); + emit Withdraw( + users.bob, + users.alice, + users.bob, + DAI_ASSET_ID, + MEDIUM_AMOUNT, + stateBeforeWithdrawal.expectedShares, + 0, + 0 + ); + + // Trigger withdrawal + yieldBox.withdraw( + DAI_ASSET_ID, // `assetId` + users.alice, // `from` + users.bob, // `to` + MEDIUM_AMOUNT, // `amount` + 0 // `share` + ); + + // It should decrement `balanceOf` of `from` by `share` + assertEq( + stateBeforeWithdrawal.userShareBalanceBeforeWithdrawal, + yieldBox.balanceOf(users.alice, DAI_ASSET_ID) + + stateBeforeWithdrawal.expectedShares + ); + + // It should decrement `totalSupply` by `share` + assertEq( + stateBeforeWithdrawal.totalSupplyBeforeWithdrawal, + yieldBox.totalSupply(DAI_ASSET_ID) + stateBeforeWithdrawal.expectedShares + ); + + // It should decrement `strategy`'s balance by `amount` + assertEq( + stateBeforeWithdrawal.strategyAssetBalanceBeforeWithdrawal, + dai.balanceOf(address(daiStrategy)) + MEDIUM_AMOUNT + ); + } + + /// @notice Tests the scenario where shares are properly computed given a certain amount managed via an operator for all. + /// @dev Precondition: Alice has deposited assets. + /// @dev Precondition: Alice has approved Bob for all + function test_withdraw_SharesAreCorrectGivenAmountViaApprovedForAll( + ) + public + whenYieldBoxApprovedForAll(users.alice, users.bob) + whenDeposited(DAI_ASSET_ID, users.alice, users.alice, LARGE_AMOUNT, 0) + resetPrank(users.bob) + { + + // Fetch data prior to withdrawing + StateBeforeWithdrawal memory stateBeforeWithdrawal; + + (stateBeforeWithdrawal.totalShare, stateBeforeWithdrawal.totalAmount) = yieldBox.assetTotals( + DAI_ASSET_ID + ); + + // Compute expected shares to withdraw (rounding up for withdrawals) + stateBeforeWithdrawal.expectedShares = YieldBoxRebase._toShares({ + amount: MEDIUM_AMOUNT, + totalShares_: stateBeforeWithdrawal.totalShare, + totalAmount: stateBeforeWithdrawal.totalAmount, + roundUp: true + }); + + + // Fetch impartial user's share balance + stateBeforeWithdrawal.userShareBalanceBeforeWithdrawal = yieldBox + .balanceOf(users.alice, DAI_ASSET_ID); + + // Fetch total supply + stateBeforeWithdrawal.totalSupplyBeforeWithdrawal = yieldBox + .totalSupply(DAI_ASSET_ID); + + // Fetch strategy asset balance + stateBeforeWithdrawal.strategyAssetBalanceBeforeWithdrawal = dai + .balanceOf(address(daiStrategy)); + + // It should emit a `TransferSingle` event + vm.expectEmit(); + emit TransferSingle( + users.bob, + users.alice, + address(0), + DAI_ASSET_ID, + stateBeforeWithdrawal.expectedShares + ); + + vm.expectEmit(); + emit Withdraw( + users.bob, + users.alice, + users.bob, + DAI_ASSET_ID, + MEDIUM_AMOUNT, + stateBeforeWithdrawal.expectedShares, + 0, + 0 + ); + + // Trigger withdrawal + yieldBox.withdraw( + DAI_ASSET_ID, // `assetId` + users.alice, // `from` + users.bob, // `to` + MEDIUM_AMOUNT, // `amount` + 0 // `share` + ); + + // It should decrement `balanceOf` of `from` by `share` + assertEq( + stateBeforeWithdrawal.userShareBalanceBeforeWithdrawal, + yieldBox.balanceOf(users.alice, DAI_ASSET_ID) + + stateBeforeWithdrawal.expectedShares + ); + + // It should decrement `totalSupply` by `share` + assertEq( + stateBeforeWithdrawal.totalSupplyBeforeWithdrawal, + yieldBox.totalSupply(DAI_ASSET_ID) + stateBeforeWithdrawal.expectedShares + ); + + // It should decrement `strategy`'s balance by `amount` + assertEq( + stateBeforeWithdrawal.strategyAssetBalanceBeforeWithdrawal, + dai.balanceOf(address(daiStrategy)) + MEDIUM_AMOUNT + ); + } + + /// @notice Tests the scenario where amount is properly computed given certain shares. + function test_withdraw_AmountIsCorrectGivenShares( + uint64 depositAmount, + uint64 withdrawShares + ) + public + assumeNoZeroValue(depositAmount) + assumeNoZeroValue(withdrawShares) + whenDeposited(DAI_ASSET_ID, users.alice, users.alice, 0, depositAmount) + { + // Bound withdrawal amount to the max amount deposited + vm.assume(withdrawShares <= depositAmount); + + // Fetch data prior to withdrawing + StateBeforeWithdrawal memory stateBeforeWithdrawal; + + (stateBeforeWithdrawal.totalShare, stateBeforeWithdrawal.totalAmount) = yieldBox.assetTotals( + DAI_ASSET_ID + ); + + // Compute expected amount to withdraw (rounding down for withdrawals) + stateBeforeWithdrawal.expectedAmount = YieldBoxRebase._toAmount({ + share: withdrawShares, + totalShares_: stateBeforeWithdrawal.totalShare, + totalAmount: stateBeforeWithdrawal.totalAmount, + roundUp: false + }); + + // Fetch impartial user's share balance + stateBeforeWithdrawal.userShareBalanceBeforeWithdrawal = yieldBox + .balanceOf(users.alice, DAI_ASSET_ID); + + // Fetch total supply + stateBeforeWithdrawal.totalSupplyBeforeWithdrawal = yieldBox + .totalSupply(DAI_ASSET_ID); + + // Fetch strategy asset balance + stateBeforeWithdrawal.strategyAssetBalanceBeforeWithdrawal = dai + .balanceOf(address(daiStrategy)); + + // It should emit a `TransferSingle` event + vm.expectEmit(); + emit TransferSingle( + users.alice, + users.alice, + address(0), + DAI_ASSET_ID, + withdrawShares + ); + + vm.expectEmit(); + emit Withdraw( + users.alice, + users.alice, + users.alice, + DAI_ASSET_ID, + stateBeforeWithdrawal.expectedAmount, + withdrawShares, + 0, + 0 + ); + + // Trigger withdrawal + yieldBox.withdraw( + DAI_ASSET_ID, // `assetId` + users.alice, // `from` + users.alice, // `to` + 0, // `amount` + withdrawShares // `share` + ); + + // It should decrement `balanceOf` of `to` by `share` + assertEq( + stateBeforeWithdrawal.userShareBalanceBeforeWithdrawal, + yieldBox.balanceOf(users.alice, DAI_ASSET_ID) + uint256(withdrawShares) + ); + + // It should decrement `totalSupply` by `share` + assertEq( + stateBeforeWithdrawal.totalSupplyBeforeWithdrawal, + yieldBox.totalSupply(DAI_ASSET_ID) + uint256(withdrawShares) + ); + + // It should decrement `strategy`'s balance by `amount` + assertEq( + stateBeforeWithdrawal.strategyAssetBalanceBeforeWithdrawal, + dai.balanceOf(address(daiStrategy)) + uint256(stateBeforeWithdrawal.expectedAmount) + ); + } + + /// @notice Tests the scenario where amount is properly computed given certain shares. + /// @dev Precondition: Alice has deposited assets. + /// @dev Precondition: Alice has approved Bob for asset ID + function test_withdraw_AmountIsCorrectGivenSharesViaApprovedAssetID( + ) + public + whenDeposited(DAI_ASSET_ID, users.alice, users.alice, 0, LARGE_AMOUNT) + whenYieldBoxApprovedForAssetID(users.alice, users.bob, DAI_ASSET_ID) + resetPrank(users.bob) + { + + // Fetch data prior to withdrawing + StateBeforeWithdrawal memory stateBeforeWithdrawal; + + (stateBeforeWithdrawal.totalShare, stateBeforeWithdrawal.totalAmount) = yieldBox.assetTotals( + DAI_ASSET_ID + ); + + // Compute expected amount to withdraw (rounding down for withdrawals) + stateBeforeWithdrawal.expectedAmount = YieldBoxRebase._toAmount({ + share: MEDIUM_AMOUNT, + totalShares_: stateBeforeWithdrawal.totalShare, + totalAmount: stateBeforeWithdrawal.totalAmount, + roundUp: false + }); + + // Fetch impartial user's share balance + stateBeforeWithdrawal.userShareBalanceBeforeWithdrawal = yieldBox + .balanceOf(users.alice, DAI_ASSET_ID); + + // Fetch total supply + stateBeforeWithdrawal.totalSupplyBeforeWithdrawal = yieldBox + .totalSupply(DAI_ASSET_ID); + + // Fetch strategy asset balance + stateBeforeWithdrawal.strategyAssetBalanceBeforeWithdrawal = dai + .balanceOf(address(daiStrategy)); + + // It should emit a `TransferSingle` event + vm.expectEmit(); + emit TransferSingle( + users.bob, + users.alice, + address(0), + DAI_ASSET_ID, + MEDIUM_AMOUNT + ); + + vm.expectEmit(); + emit Withdraw( + users.bob, + users.alice, + users.bob, + DAI_ASSET_ID, + stateBeforeWithdrawal.expectedAmount, + MEDIUM_AMOUNT, + 0, + 0 + ); + + // Trigger withdrawal + yieldBox.withdraw( + DAI_ASSET_ID, // `assetId` + users.alice, // `from` + users.bob, // `to` + 0, // `amount` + MEDIUM_AMOUNT // `share` + ); + + // It should decrement `balanceOf` of `from` by `share` + assertEq( + stateBeforeWithdrawal.userShareBalanceBeforeWithdrawal, + yieldBox.balanceOf(users.alice, DAI_ASSET_ID) + uint256(MEDIUM_AMOUNT) + ); + + // It should decrement `totalSupply` by `share` + assertEq( + stateBeforeWithdrawal.totalSupplyBeforeWithdrawal, + yieldBox.totalSupply(DAI_ASSET_ID) + uint256(MEDIUM_AMOUNT) + ); + + // It should decrement `strategy`'s balance by `amount` + assertEq( + stateBeforeWithdrawal.strategyAssetBalanceBeforeWithdrawal, + dai.balanceOf(address(daiStrategy)) + uint256(stateBeforeWithdrawal.expectedAmount) + ); + } + + /// @notice Tests the scenario where amount is properly computed given certain shares. + /// @dev Precondition: Alice has deposited assets. + /// @dev Precondition: Alice has approved Bob for all + function test_withdraw_AmountIsCorrectGivenSharesViaApprovedForAll( + ) + public + whenDeposited(DAI_ASSET_ID, users.alice, users.alice, 0, LARGE_AMOUNT) + whenYieldBoxApprovedForAll(users.alice, users.bob) + resetPrank(users.bob) + { + + // Fetch data prior to withdrawing + StateBeforeWithdrawal memory stateBeforeWithdrawal; + + (stateBeforeWithdrawal.totalShare, stateBeforeWithdrawal.totalAmount) = yieldBox.assetTotals( + DAI_ASSET_ID + ); + + // Compute expected amount to withdraw (rounding down for withdrawals) + stateBeforeWithdrawal.expectedAmount = YieldBoxRebase._toAmount({ + share: MEDIUM_AMOUNT, + totalShares_: stateBeforeWithdrawal.totalShare, + totalAmount: stateBeforeWithdrawal.totalAmount, + roundUp: false + }); + + // Fetch impartial user's share balance + stateBeforeWithdrawal.userShareBalanceBeforeWithdrawal = yieldBox + .balanceOf(users.alice, DAI_ASSET_ID); + + // Fetch total supply + stateBeforeWithdrawal.totalSupplyBeforeWithdrawal = yieldBox + .totalSupply(DAI_ASSET_ID); + + // Fetch strategy asset balance + stateBeforeWithdrawal.strategyAssetBalanceBeforeWithdrawal = dai + .balanceOf(address(daiStrategy)); + + // It should emit a `TransferSingle` event + vm.expectEmit(); + emit TransferSingle( + users.bob, + users.alice, + address(0), + DAI_ASSET_ID, + MEDIUM_AMOUNT + ); + + vm.expectEmit(); + emit Withdraw( + users.bob, + users.alice, + users.bob, + DAI_ASSET_ID, + stateBeforeWithdrawal.expectedAmount, + MEDIUM_AMOUNT, + 0, + 0 + ); + + // Trigger withdrawal + yieldBox.withdraw( + DAI_ASSET_ID, // `assetId` + users.alice, // `from` + users.bob, // `to` + 0, // `amount` + MEDIUM_AMOUNT // `share` + ); + + // It should decrement `balanceOf` of `from` by `share` + assertEq( + stateBeforeWithdrawal.userShareBalanceBeforeWithdrawal, + yieldBox.balanceOf(users.alice, DAI_ASSET_ID) + uint256(MEDIUM_AMOUNT) + ); + + // It should decrement `totalSupply` by `share` + assertEq( + stateBeforeWithdrawal.totalSupplyBeforeWithdrawal, + yieldBox.totalSupply(DAI_ASSET_ID) + uint256(MEDIUM_AMOUNT) + ); + + // It should decrement `strategy`'s balance by `amount` + assertEq( + stateBeforeWithdrawal.strategyAssetBalanceBeforeWithdrawal, + dai.balanceOf(address(daiStrategy)) + uint256(stateBeforeWithdrawal.expectedAmount) + ); + } +} diff --git a/test/unit/concrete/yieldbox/withdraw.tree b/test/unit/concrete/yieldbox/withdraw.tree new file mode 100644 index 0000000..875150a --- /dev/null +++ b/test/unit/concrete/yieldbox/withdraw.tree @@ -0,0 +1,22 @@ +withdraw.t.sol +depositAsset.t.sol +├── when `from` is not `msg.sender` +│ ├── when `msg.sender` is not approved by `from` for asset +│ │ ├── when `msg.sender` is not approved by `from` for all +│ │ │ └── it should revert +│ │ └── it continues execution +│ └── it continues execution +└── it CONTINUES + ├── when `asset` type is Native + │ └── it should revert + ├── when `asset` type is ERC20 + │ ├── when `share` is zero + │ │ └── it should compute `share` rounding up `amount` supplied + │ └── when `share` is not zero + │ └── it should compute `amount` rounding down `share` supplied + ├── it should decrement `balanceOf` of `to` by `share` + ├── it should decrement `totalSupply` by `share` + ├── it should emit a `TransferSingle` event + ├── it should decrement `strategy`'s balance by `amount` + └── it should emit a `Withdraw` event + \ No newline at end of file diff --git a/test/utils/Constants.sol b/test/utils/Constants.sol new file mode 100644 index 0000000..7e6843a --- /dev/null +++ b/test/utils/Constants.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.22; + +abstract contract Constants { + + ///////////////////////////////////////////////////////////////////// + // GLOBAL // + ///////////////////////////////////////////////////////////////////// + uint256 public constant WEI_AMOUNT = 1; + uint256 public constant SMALL_AMOUNT = 10e18; + uint256 public constant MEDIUM_AMOUNT = 100e18; + uint256 public constant LARGE_AMOUNT = 1000e18; + + ///////////////////////////////////////////////////////////////////// + // PERMIT C // + ///////////////////////////////////////////////////////////////////// + + /// @dev Constant value representing the ERC721 token type for signatures and transfer hooks + uint256 constant TOKEN_TYPE_ERC721 = 721; + /// @dev Constant value representing the ERC1155 token type for signatures and transfer hooks + uint256 constant TOKEN_TYPE_ERC1155 = 1155; + /// @dev Constant value representing the ERC20 token type for signatures and transfer hooks + uint256 constant TOKEN_TYPE_ERC20 = 20; + + +} \ No newline at end of file diff --git a/test/utils/Errors.sol b/test/utils/Errors.sol new file mode 100644 index 0000000..9f51cb7 --- /dev/null +++ b/test/utils/Errors.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.22; + +abstract contract Errors { + + ///////////////////////////////////////////////////////////////////// + // YIELDBOX // + ///////////////////////////////////////////////////////////////////// + + error InvalidTokenType(); + error PearlmitTransferFailed(); + error InvalidZeroAmounts(); + error NotWrapped(); + error AmountTooLow(); + error RefundFailed(); + error ZeroAddress(); + + +} \ No newline at end of file diff --git a/test/utils/Events.sol b/test/utils/Events.sol new file mode 100644 index 0000000..56e35f6 --- /dev/null +++ b/test/utils/Events.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.22; + +abstract contract Events { + ///////////////////////////////////////////////////////////////////// + // OZ OWNABLE // + ///////////////////////////////////////////////////////////////////// + + event OwnershipTransferred( + address indexed previousOwner, + address indexed newOwner + ); + + ///////////////////////////////////////////////////////////////////// + // YIELDBOX - ERC155 // + ///////////////////////////////////////////////////////////////////// + event TransferSingle( + address indexed _operator, + address indexed _from, + address indexed _to, + uint256 _id, + uint256 _value + ); + + event TransferBatch( + address indexed _operator, + address indexed _from, + address indexed _to, + uint256[] _ids, + uint256[] _values + ); + + ///////////////////////////////////////////////////////////////////// + // YIELDBOX - GLOBAL // + ///////////////////////////////////////////////////////////////////// + event Deposited( + address indexed sender, + address indexed from, + address indexed to, + uint256 assetId, + uint256 amountIn, + uint256 shareIn, + uint256 amountOut, + uint256 shareOut, + bool isNFT + ); + + event Withdraw( + address indexed sender, + address indexed from, + address indexed to, + uint256 assetId, + uint256 amountIn, + uint256 shareIn, + uint256 amountOut, + uint256 shareOut + ); +} diff --git a/test/utils/Types.sol b/test/utils/Types.sol new file mode 100644 index 0000000..c3df2e2 --- /dev/null +++ b/test/utils/Types.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.22; + +struct Users { + // Default owner for all Yieldbox contracts + address payable owner; + // Impartial user 1. + address payable alice; + // Impartial user 2. + address payable bob; + // Impartial user 3. + address payable charlie; + // Impartial user 4. + address payable david; + // Malicious user. + address payable eve; + +} \ No newline at end of file diff --git a/test/utils/Utils.sol b/test/utils/Utils.sol new file mode 100644 index 0000000..2d88892 --- /dev/null +++ b/test/utils/Utils.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.22; + +import {Test} from 'forge-std/Test.sol'; + +abstract contract Utils is Test { + + /// @dev Stops the active prank and sets a new one. + function _resetPrank(address msgSender) internal { + vm.stopPrank(); + vm.startPrank(msgSender); + } +}