diff --git a/test/foundry/src/concrete/erc20PriceOracle/ERC20PriceOracleReceiptVault.baseWithdraw.t.sol b/test/foundry/src/concrete/erc20PriceOracle/ERC20PriceOracleReceiptVault.baseWithdraw.t.sol new file mode 100644 index 00000000..636f3249 --- /dev/null +++ b/test/foundry/src/concrete/erc20PriceOracle/ERC20PriceOracleReceiptVault.baseWithdraw.t.sol @@ -0,0 +1,368 @@ +// SPDX-License-Identifier: CAL +pragma solidity =0.8.25; + +import {ERC20PriceOracleReceiptVault} from "../../../../../contracts/concrete/vault/ERC20PriceOracleReceiptVault.sol"; +import {ERC20PriceOracleReceiptVaultTest, Vm} from "test/foundry/abstract/ERC20PriceOracleReceiptVaultTest.sol"; +import {TwoPriceOracle} from "../../../../../contracts/oracle/price/TwoPriceOracle.sol"; +import { + LibFixedPointDecimalArithmeticOpenZeppelin, + Math +} from "rain.math.fixedpoint/lib/LibFixedPointDecimalArithmeticOpenZeppelin.sol"; +import {IERC20} from "forge-std/interfaces/IERC20.sol"; +import {Receipt as ReceiptContract} from "../../../../../contracts/concrete/receipt/Receipt.sol"; +import {ZeroAssetsAmount, ZeroReceiver, ZeroOwner} from "../../../../../contracts/abstract/ReceiptVault.sol"; + +contract ERC20PriceOracleReceiptVaultBaseWithdrawTest is ERC20PriceOracleReceiptVaultTest { + using LibFixedPointDecimalArithmeticOpenZeppelin for uint256; + + event WithdrawWithReceipt( + address sender, + address receiver, + address owner, + uint256 assets, + uint256 shares, + uint256 id, + bytes receiptInformation + ); + + /// Checks that balance owner balance changes after wirthdraw + function checkBalanceChange( + ERC20PriceOracleReceiptVault vault, + address receiver, + address owner, + uint256 id, + uint256 assets, + ReceiptContract receipt, + bytes memory data + ) internal { + uint256 initialBalanceOwner = receipt.balanceOf(owner, id); + uint256 shares = assets.fixedPointMul(id, Math.Rounding.Up); + + vault.setWithdrawId(id); + // Set up the event expectation for WithdrawWithReceipt + vm.expectEmit(true, true, true, true); + emit WithdrawWithReceipt(owner, receiver, owner, assets, shares, id, data); + + // Call withdraw function + vault.withdraw(assets, receiver, owner); + + uint256 balanceAfterOwner = receipt.balanceOf(owner, id); + assertEq(balanceAfterOwner, initialBalanceOwner - shares); + } + + /// Checks that balance owner balance does not change after wirthdraw revert + function checkNoBalanceChange( + ERC20PriceOracleReceiptVault vault, + address receiver, + address owner, + uint256 id, + uint256 assets, + ReceiptContract receipt, + bytes memory expectedRevertData + ) internal { + uint256 initialBalanceOwner; + uint256 balanceAfterOwner; + vault.setWithdrawId(id); + + if (owner != address(0)) { + initialBalanceOwner = receipt.balanceOf(owner, id); + } + + // Check if expectedRevertData is provided + if (expectedRevertData.length > 0) { + vm.expectRevert(expectedRevertData); + } else { + vm.expectRevert(); + } + // Call withdraw function + vault.withdraw(assets, receiver, owner); + + if (owner != address(0)) { + balanceAfterOwner = receipt.balanceOf(owner, id); + } + assertEq(balanceAfterOwner, initialBalanceOwner); + } + + /// Test base Withdraw function + function testBaseWithdraw( + uint256 fuzzedKeyAlice, + string memory assetName, + uint256 timestamp, + uint256 assets, + uint8 xauDecimals, + uint8 usdDecimals, + uint80 answeredInRound + ) external { + // Ensure the fuzzed key is within the valid range for secp256 + address alice = vm.addr((fuzzedKeyAlice % (SECP256K1_ORDER - 1)) + 1); + // Use common decimal bounds for price feeds + // Use 0-20 so we at least have some coverage higher than 18 + usdDecimals = uint8(bound(usdDecimals, 0, 20)); + xauDecimals = uint8(bound(xauDecimals, 0, 20)); + timestamp = bound(timestamp, 0, type(uint32).max); + + vm.warp(timestamp); + TwoPriceOracle twoPriceOracle = createTwoPriceOracle(usdDecimals, usdDecimals, timestamp, answeredInRound); + vm.startPrank(alice); + // Start recording logs + vm.recordLogs(); + ERC20PriceOracleReceiptVault vault = createVault(address(twoPriceOracle), assetName, assetName); + ReceiptContract receipt = getReceipt(); + + vm.mockCall(address(iAsset), abi.encodeWithSelector(IERC20.totalSupply.selector), abi.encode(1e18)); + // Ensure Alice has enough balance and allowance + vm.mockCall(address(iAsset), abi.encodeWithSelector(IERC20.balanceOf.selector, alice), abi.encode(assets)); + + uint256 totalSupply = iAsset.totalSupply(); + // Getting ZeroSharesAmount if bounded from 1 + assets = bound(assets, 2, totalSupply); + vm.mockCall( + address(iAsset), + abi.encodeWithSelector(IERC20.transferFrom.selector, alice, vault, assets), + abi.encode(true) + ); + + uint256 oraclePrice = twoPriceOracle.price(); + + vault.deposit(assets, alice, oraclePrice, bytes("")); + uint256 availableReceiptBalance = receipt.balanceOf(alice, oraclePrice); + checkBalanceChange(vault, alice, alice, oraclePrice, availableReceiptBalance, receipt, bytes("")); + } + + /// Test Withdraw function reverts on zero assets + function testBaseWithdrawRevertsOnZeroAssets( + uint256 fuzzedKeyAlice, + string memory assetName, + uint256 timestamp, + uint256 assets, + uint8 xauDecimals, + uint8 usdDecimals, + uint80 answeredInRound + ) external { + // Ensure the fuzzed key is within the valid range for secp256 + address alice = vm.addr((fuzzedKeyAlice % (SECP256K1_ORDER - 1)) + 1); + // Use common decimal bounds for price feeds + // Use 0-20 so we at least have some coverage higher than 18 + usdDecimals = uint8(bound(usdDecimals, 0, 20)); + xauDecimals = uint8(bound(xauDecimals, 0, 20)); + timestamp = bound(timestamp, 0, type(uint32).max); + + vm.warp(timestamp); + TwoPriceOracle twoPriceOracle = createTwoPriceOracle(usdDecimals, usdDecimals, timestamp, answeredInRound); + vm.startPrank(alice); + // Start recording logs + vm.recordLogs(); + ERC20PriceOracleReceiptVault vault = createVault(address(twoPriceOracle), assetName, assetName); + ReceiptContract receipt = getReceipt(); + + vm.mockCall(address(iAsset), abi.encodeWithSelector(IERC20.totalSupply.selector), abi.encode(1e18)); + // Ensure Alice has enough balance and allowance + vm.mockCall(address(iAsset), abi.encodeWithSelector(IERC20.balanceOf.selector, alice), abi.encode(assets)); + + uint256 totalSupply = iAsset.totalSupply(); + // Getting ZeroSharesAmount if bounded from 1 + assets = bound(assets, 2, totalSupply); + vm.mockCall( + address(iAsset), + abi.encodeWithSelector(IERC20.transferFrom.selector, alice, vault, assets), + abi.encode(true) + ); + + uint256 oraclePrice = twoPriceOracle.price(); + + vault.deposit(assets, alice, oraclePrice, bytes("")); + + checkNoBalanceChange( + vault, alice, alice, oraclePrice, 0, receipt, abi.encodeWithSelector(ZeroAssetsAmount.selector) + ); + } + + /// Test Withdraw function reverts on zero receiver + function testBaseWithdrawRevertsOnZeroReceiver( + uint256 fuzzedKeyAlice, + string memory assetName, + uint256 timestamp, + uint256 assets, + uint8 xauDecimals, + uint8 usdDecimals, + uint80 answeredInRound + ) external { + // Ensure the fuzzed key is within the valid range for secp256 + address alice = vm.addr((fuzzedKeyAlice % (SECP256K1_ORDER - 1)) + 1); + // Use common decimal bounds for price feeds + // Use 0-20 so we at least have some coverage higher than 18 + usdDecimals = uint8(bound(usdDecimals, 0, 20)); + xauDecimals = uint8(bound(xauDecimals, 0, 20)); + timestamp = bound(timestamp, 0, type(uint32).max); + + vm.warp(timestamp); + TwoPriceOracle twoPriceOracle = createTwoPriceOracle(usdDecimals, usdDecimals, timestamp, answeredInRound); + vm.startPrank(alice); + // Start recording logs + vm.recordLogs(); + ERC20PriceOracleReceiptVault vault = createVault(address(twoPriceOracle), assetName, assetName); + ReceiptContract receipt = getReceipt(); + + vm.mockCall(address(iAsset), abi.encodeWithSelector(IERC20.totalSupply.selector), abi.encode(1e18)); + // Ensure Alice has enough balance and allowance + vm.mockCall(address(iAsset), abi.encodeWithSelector(IERC20.balanceOf.selector, alice), abi.encode(assets)); + + uint256 totalSupply = iAsset.totalSupply(); + // Getting ZeroSharesAmount if bounded from 1 + assets = bound(assets, 2, totalSupply); + vm.mockCall( + address(iAsset), + abi.encodeWithSelector(IERC20.transferFrom.selector, alice, vault, assets), + abi.encode(true) + ); + + uint256 oraclePrice = twoPriceOracle.price(); + + vault.deposit(assets, alice, oraclePrice, bytes("")); + uint256 availableReceiptBalance = receipt.balanceOf(alice, oraclePrice); + + checkNoBalanceChange( + vault, + address(0), + alice, + oraclePrice, + availableReceiptBalance, + receipt, + abi.encodeWithSelector(ZeroReceiver.selector) + ); + } + + /// Test Withdraw function reverts on zero owner + function testBaseWithdrawRevertsOnZeroOwner( + uint256 fuzzedKeyAlice, + string memory assetName, + uint256 timestamp, + uint256 assets, + uint8 xauDecimals, + uint8 usdDecimals, + uint80 answeredInRound + ) external { + // Ensure the fuzzed key is within the valid range for secp256 + address alice = vm.addr((fuzzedKeyAlice % (SECP256K1_ORDER - 1)) + 1); + // Use common decimal bounds for price feeds + // Use 0-20 so we at least have some coverage higher than 18 + usdDecimals = uint8(bound(usdDecimals, 0, 20)); + xauDecimals = uint8(bound(xauDecimals, 0, 20)); + timestamp = bound(timestamp, 0, type(uint32).max); + + vm.warp(timestamp); + TwoPriceOracle twoPriceOracle = createTwoPriceOracle(usdDecimals, usdDecimals, timestamp, answeredInRound); + vm.startPrank(alice); + // Start recording logs + vm.recordLogs(); + ERC20PriceOracleReceiptVault vault = createVault(address(twoPriceOracle), assetName, assetName); + ReceiptContract receipt = getReceipt(); + + vm.mockCall(address(iAsset), abi.encodeWithSelector(IERC20.totalSupply.selector), abi.encode(1e18)); + // Ensure Alice has enough balance and allowance + vm.mockCall(address(iAsset), abi.encodeWithSelector(IERC20.balanceOf.selector, alice), abi.encode(assets)); + + uint256 totalSupply = iAsset.totalSupply(); + // Getting ZeroSharesAmount if bounded from 1 + assets = bound(assets, 2, totalSupply); + vm.mockCall( + address(iAsset), + abi.encodeWithSelector(IERC20.transferFrom.selector, alice, vault, assets), + abi.encode(true) + ); + + uint256 oraclePrice = twoPriceOracle.price(); + + vault.deposit(assets, alice, oraclePrice, bytes("")); + uint256 availableReceiptBalance = receipt.balanceOf(alice, oraclePrice); + + checkNoBalanceChange(vault, alice, address(0), oraclePrice, availableReceiptBalance, receipt, bytes("")); + } + + /// Test PreviewWithdraw returns correct shares + function testBasePreviewWithdraw( + uint256 fuzzedKeyAlice, + string memory assetName, + uint256 timestamp, + uint256 assets, + uint8 xauDecimals, + uint8 usdDecimals, + uint80 answeredInRound + ) external { + // Ensure the fuzzed key is within the valid range for secp256 + address alice = vm.addr((fuzzedKeyAlice % (SECP256K1_ORDER - 1)) + 1); + // Use common decimal bounds for price feeds + // Use 0-20 so we at least have some coverage higher than 18 + usdDecimals = uint8(bound(usdDecimals, 0, 20)); + xauDecimals = uint8(bound(xauDecimals, 0, 20)); + timestamp = bound(timestamp, 0, type(uint32).max); + + vm.warp(timestamp); + TwoPriceOracle twoPriceOracle = createTwoPriceOracle(usdDecimals, usdDecimals, timestamp, answeredInRound); + + // Prank as Alice to grant role + vm.startPrank(alice); + ERC20PriceOracleReceiptVault vault = createVault(address(twoPriceOracle), assetName, assetName); + + uint256 oraclePrice = twoPriceOracle.price(); + + vault.setWithdrawId(oraclePrice); + + // Call withdraw function + uint256 expectedShares = assets.fixedPointMul(oraclePrice, Math.Rounding.Up); + uint256 shares = vault.previewWithdraw(assets); + + assertEq(shares, expectedShares); + // Stop the prank + vm.stopPrank(); + } + + /// Test Withdraw function with more than assets deposied + function testBaseWithdrawMoreThanAssets( + uint256 fuzzedKeyAlice, + string memory assetName, + uint256 timestamp, + uint256 assets, + uint256 assetsToWithdraw, + uint8 xauDecimals, + uint8 usdDecimals, + uint80 answeredInRound + ) external { + // Ensure the fuzzed key is within the valid range for secp256 + address alice = vm.addr((fuzzedKeyAlice % (SECP256K1_ORDER - 1)) + 1); + // Use common decimal bounds for price feeds + // Use 0-20 so we at least have some coverage higher than 18 + usdDecimals = uint8(bound(usdDecimals, 0, 20)); + xauDecimals = uint8(bound(xauDecimals, 0, 20)); + timestamp = bound(timestamp, 0, type(uint32).max); + + vm.warp(timestamp); + TwoPriceOracle twoPriceOracle = createTwoPriceOracle(usdDecimals, usdDecimals, timestamp, answeredInRound); + vm.startPrank(alice); + // Start recording logs + vm.recordLogs(); + ERC20PriceOracleReceiptVault vault = createVault(address(twoPriceOracle), assetName, assetName); + ReceiptContract receipt = getReceipt(); + + vm.mockCall(address(iAsset), abi.encodeWithSelector(IERC20.totalSupply.selector), abi.encode(1e18)); + // Ensure Alice has enough balance and allowance + vm.mockCall(address(iAsset), abi.encodeWithSelector(IERC20.balanceOf.selector, alice), abi.encode(assets)); + + uint256 totalSupply = iAsset.totalSupply(); + // Getting ZeroSharesAmount if bounded from 1 + assets = bound(assets, 2, totalSupply); + vm.mockCall( + address(iAsset), + abi.encodeWithSelector(IERC20.transferFrom.selector, alice, vault, assets), + abi.encode(true) + ); + + uint256 oraclePrice = twoPriceOracle.price(); + + vault.deposit(assets, alice, oraclePrice, bytes("")); + + // Make sure assetsToWithdraw is more than assets + assetsToWithdraw = bound(assetsToWithdraw, assets + 1, type(uint64).max); + checkNoBalanceChange(vault, alice, alice, oraclePrice, assetsToWithdraw, receipt, bytes("")); + } +}