diff --git a/script/DeployDummyDelayedStrategy.sol b/script/DeployDummyDelayedStrategy.sol new file mode 100644 index 00000000..d09a1f26 --- /dev/null +++ b/script/DeployDummyDelayedStrategy.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "forge-std/Script.sol"; +import "@balmy/earn-core/vault/EarnVault.sol"; +import "src/global-registry/GlobalEarnRegistry.sol"; + +import "src/strategies/instances/erc4626/ERC4626DelayedStrategy.sol"; +import "src/delayed-withdrawal-adapter/ERC4626DelayedWithdrawalAdapter.sol"; + +contract DeployDummyDelayedStrategy is Script { + function run() external { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + address deployer = 0xB86dA339B88D9697fa3ACC55EDd378e002676E01; + address admin = vm.envAddress("GOVERNOR"); + vm.startBroadcast(deployerPrivateKey); + + IEarnVault vault = IEarnVault(0x63a8aE714568EC8f8Ec14472674c68582b0B0458); + IGlobalEarnRegistry globalRegistry = IGlobalEarnRegistry(0x7DA14784E8F1fb71c23Ad6c6ac7f063Fdf098F38); + + ERC4626DelayedWithdrawalAdapter delayedWithdrawalAdapter = new ERC4626DelayedWithdrawalAdapter( + globalRegistry, + 0xBb287E6017d3DEb0e2E65061e8684eab21060123, // Yearn v3 USDT-A + 7200 // 7200 seconds are 2 hours + ); + ERC4626DelayedStrategy delayedStrategy = new ERC4626DelayedStrategy( + globalRegistry, + vault, + 0xBb287E6017d3DEb0e2E65061e8684eab21060123, + "Delayed Yearn v3 USDT-A", + delayedWithdrawalAdapter + ); + + vault.STRATEGY_REGISTRY().registerStrategy(deployer, delayedStrategy); + vm.stopBroadcast(); + } +} diff --git a/src/delayed-withdrawal-adapter/ERC4626DelayedWithdrawalAdapter.sol b/src/delayed-withdrawal-adapter/ERC4626DelayedWithdrawalAdapter.sol new file mode 100644 index 00000000..831c0767 --- /dev/null +++ b/src/delayed-withdrawal-adapter/ERC4626DelayedWithdrawalAdapter.sol @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.22; + +import { + IDelayedWithdrawalAdapter, + IDelayedWithdrawalManager, + IEarnVault +} from "src/interfaces/IDelayedWithdrawalAdapter.sol"; +import { IEarnStrategy, StrategyId } from "@balmy/earn-core/interfaces/IEarnStrategy.sol"; +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; +import { SafeERC20, IERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { IGlobalEarnRegistry } from "src/interfaces/IGlobalEarnRegistry.sol"; +import { IERC4626, IERC20 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; + +struct Request { + uint256 amount; + uint256 deadline; +} + +contract ERC4626DelayedWithdrawalAdapter is IDelayedWithdrawalAdapter { + using Math for uint256; + using SafeERC20 for IERC20; + + /// @notice The id for the Delayed Withdrawal Manager + bytes32 public constant DELAYED_WITHDRAWAL_MANAGER = keccak256("DELAYED_WITHDRAWAL_MANAGER"); + + IGlobalEarnRegistry public immutable registry; + + address internal immutable _farmToken; + uint256 internal immutable _delay; + mapping(uint256 positionId => Request[] requestIds) internal _pendingWithdrawals; + + constructor(IGlobalEarnRegistry _registry, address farmToken_, uint256 delay_) { + registry = _registry; + _farmToken = farmToken_; + _delay = delay_; + } + + function supportsInterface(bytes4 interfaceId) external pure override returns (bool) { + return interfaceId == type(IDelayedWithdrawalAdapter).interfaceId; + } + + function estimatedPendingFunds(uint256 positionId, address) external view override returns (uint256 pendingAmount) { + Request[] memory requests = _pendingWithdrawals[positionId]; + // slither-disable-next-line incorrect-equality + if (requests.length == 0) { + return 0; + } + + for (uint256 i; i < requests.length; ++i) { + if (requests[i].deadline > block.timestamp) { + pendingAmount += IERC4626(_farmToken).previewRedeem(requests[i].amount); + } + } + } + + function withdrawableFunds(uint256 positionId, address) external view override returns (uint256 withdrawableAmount) { + Request[] memory requests = _pendingWithdrawals[positionId]; + // slither-disable-next-line incorrect-equality + if (requests.length == 0) { + return 0; + } + + for (uint256 i; i < requests.length; ++i) { + if (requests[i].deadline <= block.timestamp) { + withdrawableAmount += IERC4626(_farmToken).previewRedeem(requests[i].amount); + } + } + } + + function initiateDelayedWithdrawal(uint256 positionId, address, uint256 amount) external override { + IDelayedWithdrawalManager delayedWithdrawalManager = manager(); + IEarnVault vault_ = delayedWithdrawalManager.VAULT(); + StrategyId strategyId = vault_.positionsStrategy(positionId); + IEarnStrategy strategy = vault_.STRATEGY_REGISTRY().getStrategy(strategyId); + if (msg.sender != address(strategy)) { + revert UnauthorizedPositionStrategy(); + } + Request[] storage requests = _pendingWithdrawals[positionId]; + bool needsToRegister = requests.length == 0; + requests.push(Request({ amount: amount, deadline: block.timestamp + _delay })); + if (needsToRegister) { + delayedWithdrawalManager.registerDelayedWithdraw(positionId, (IERC4626(_farmToken).asset())); + } + } + + // slither-disable-start assembly + function withdraw( + uint256 positionId, + address, + address recipient + ) + external + override + onlyManager + returns (uint256 withdrawn, uint256 stillPending) + { + Request[] memory requests = _pendingWithdrawals[positionId]; + if (requests.length == 0) { + return (0, 0); + } + uint256 numberOfRequestsPending; + uint256 farmTokenAmountToWithdraw; + for (uint256 i; i < requests.length; ++i) { + if (requests[i].deadline > block.timestamp) { + stillPending += IERC4626(_farmToken).previewRedeem(requests[i].amount); + if (numberOfRequestsPending != i) { + requests[numberOfRequestsPending] = requests[i]; + } + ++numberOfRequestsPending; + } else { + farmTokenAmountToWithdraw += requests[i].amount; + } + } + + if (numberOfRequestsPending != requests.length) { + // Resize the array + // solhint-disable-next-line no-inline-assembly + assembly { + mstore(requests, numberOfRequestsPending) + } + } + _pendingWithdrawals[positionId] = requests; + withdrawn = IERC4626(_farmToken).redeem(farmTokenAmountToWithdraw, recipient, address(this)); + } + + function manager() public view returns (IDelayedWithdrawalManager) { + return IDelayedWithdrawalManager(registry.getAddressOrFail(DELAYED_WITHDRAWAL_MANAGER)); + } + + function vault() public view override returns (IEarnVault) { + return manager().VAULT(); + } + + modifier onlyManager() { + if (msg.sender != address(manager())) revert UnauthorizedDelayedWithdrawalManager(); + _; + } +} diff --git a/src/strategies/instances/erc4626/ERC4626DelayedStrategy.sol b/src/strategies/instances/erc4626/ERC4626DelayedStrategy.sol new file mode 100644 index 00000000..16364b4c --- /dev/null +++ b/src/strategies/instances/erc4626/ERC4626DelayedStrategy.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.22; + +import { + IEarnStrategy, StrategyId, IEarnVault, SpecialWithdrawalCode +} from "@balmy/earn-core/interfaces/IEarnStrategy.sol"; +import { IDelayedWithdrawalAdapter } from "src/delayed-withdrawal-manager/DelayedWithdrawalManager.sol"; +import { IGlobalEarnRegistry } from "src/interfaces/IGlobalEarnRegistry.sol"; +import { ExternalFees } from "../../layers/fees/ExternalFees.sol"; +import { ExternalTOSCreationValidation } from "../../layers/creation-validation/ExternalTOSCreationValidation.sol"; +import { ExternalLiquidityMining } from "../../layers/liquidity-mining/ExternalLiquidityMining.sol"; +import { BaseDelayedStrategy } from "../base/BaseDelayedStrategy.sol"; +import { IERC4626, IERC20 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import { ERC4626DelayedConnector } from "../../layers/connector/ERC4626DelayedConnector.sol"; + +contract ERC4626DelayedStrategy is + BaseDelayedStrategy, + ERC4626DelayedConnector, + ExternalLiquidityMining, + ExternalFees, + ExternalTOSCreationValidation +{ + /// @inheritdoc IEarnStrategy + string public description; + + // slither-disable-next-line naming-convention + IDelayedWithdrawalAdapter internal immutable __delayedWithdrawalAdapter; + IGlobalEarnRegistry internal immutable _globalRegistry; + IEarnVault internal immutable _vault; + address internal immutable _farmToken; + + constructor( + // General + IGlobalEarnRegistry globalRegistry_, + IEarnVault vault_, + address farmToken_, + string memory description_, + IDelayedWithdrawalAdapter delayedWithdrawalAdapter_ + ) { + _globalRegistry = globalRegistry_; + _vault = vault_; + _farmToken = farmToken_; + description = description_; + __delayedWithdrawalAdapter = delayedWithdrawalAdapter_; + maxApproveVault(); + } + + // slither-disable-next-line naming-convention,dead-code + function _fees_underlying_asset() internal view override returns (address asset) { + return _connector_asset(); + } + + function globalRegistry() + public + view + override(ExternalFees, ExternalLiquidityMining, ExternalTOSCreationValidation) + returns (IGlobalEarnRegistry) + { + return _globalRegistry; + } + + function strategyId() + public + view + override(BaseDelayedStrategy, ExternalFees, ExternalLiquidityMining, ExternalTOSCreationValidation) + returns (StrategyId) + { + return BaseDelayedStrategy.strategyId(); + } + + function _earnVault() internal view virtual override returns (IEarnVault) { + return _vault; + } + + function ERC4626Vault() public view virtual override returns (IERC4626) { + return IERC4626(_farmToken); + } + + function _asset() internal view virtual override returns (IERC20) { + return IERC20(ERC4626Vault().asset()); + } + + function _delayedWithdrawalAdapter() internal view virtual override returns (IDelayedWithdrawalAdapter) { + return __delayedWithdrawalAdapter; + } +} diff --git a/src/strategies/layers/connector/ERC4626DelayedConnector.sol b/src/strategies/layers/connector/ERC4626DelayedConnector.sol new file mode 100644 index 00000000..afefec81 --- /dev/null +++ b/src/strategies/layers/connector/ERC4626DelayedConnector.sol @@ -0,0 +1,293 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.22; + +import { IERC4626, IERC20 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; +import { Initializable } from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; +import { StrategyId, IEarnStrategy, SpecialWithdrawalCode } from "@balmy/earn-core/interfaces/IEarnStrategy.sol"; +import { IDelayedWithdrawalAdapter } from "src/delayed-withdrawal-manager/DelayedWithdrawalManager.sol"; + +import { SpecialWithdrawal } from "@balmy/earn-core/types/SpecialWithdrawals.sol"; +import { BaseConnector } from "./base/BaseConnector.sol"; + +abstract contract ERC4626DelayedConnector is BaseConnector, Initializable { + using SafeERC20 for IERC20; + using SafeERC20 for IERC4626; + using Math for uint256; + + /// @notice Returns the address of the ERC4626 vault + // slither-disable-next-line naming-convention + function ERC4626Vault() public view virtual returns (IERC4626); + function _asset() internal view virtual returns (IERC20); + + function _delayedWithdrawalAdapter() internal view virtual returns (IDelayedWithdrawalAdapter); + + /// @notice Performs a max approve to the vault, so that we can deposit without any worries + function maxApproveVault() public { + _asset().forceApprove(address(ERC4626Vault()), type(uint256).max); + } + + // slither-disable-next-line naming-convention,dead-code + function _connector_init() internal { + maxApproveVault(); + } + + // slither-disable-next-line naming-convention,dead-code + function _connector_asset() internal view override returns (address) { + return address(_asset()); + } + + // slither-disable-next-line naming-convention,dead-code + function _connector_allTokens() internal view override returns (address[] memory tokens) { + tokens = new address[](1); + tokens[0] = _connector_asset(); + } + + // slither-disable-next-line naming-convention,dead-code + function _connector_supportedWithdrawals() + internal + view + virtual + override + returns (IEarnStrategy.WithdrawalType[] memory withdrawalTypes) + { + withdrawalTypes = new IEarnStrategy.WithdrawalType[](1); + withdrawalTypes[0] = IEarnStrategy.WithdrawalType.DELAYED; + } + + // slither-disable-next-line naming-convention,dead-code + function _connector_isDepositTokenSupported(address depositToken) internal view virtual override returns (bool) { + return depositToken == _connector_asset() || depositToken == address(ERC4626Vault()); + } + + // slither-disable-next-line naming-convention,dead-code + function _connector_supportedDepositTokens() internal view virtual override returns (address[] memory supported) { + supported = new address[](2); + supported[0] = _connector_asset(); + supported[1] = address(ERC4626Vault()); + } + + // slither-disable-next-line naming-convention,dead-code + function _connector_maxDeposit(address depositToken) internal view virtual override returns (uint256) { + IERC4626 vault = ERC4626Vault(); + if (depositToken == _connector_asset()) { + return vault.maxDeposit(address(this)); + } else if (depositToken == address(vault)) { + return type(uint256).max; + } else { + revert InvalidDepositToken(depositToken); + } + } + + // slither-disable-next-line naming-convention,dead-code + function _connector_totalBalances() + internal + view + virtual + override + returns (address[] memory tokens, uint256[] memory balances) + { + IERC4626 vault = ERC4626Vault(); + tokens = new address[](1); + tokens[0] = _connector_asset(); + balances = new uint256[](1); + balances[0] = vault.previewRedeem(vault.balanceOf(address(this))); + } + + // slither-disable-next-line naming-convention,dead-code + function _connector_isSpecialWithdrawalSupported(SpecialWithdrawalCode withdrawalCode) + internal + view + virtual + override + returns (bool) + { + return withdrawalCode == SpecialWithdrawal.WITHDRAW_ASSET_FARM_TOKEN_BY_AMOUNT + || withdrawalCode == SpecialWithdrawal.WITHDRAW_ASSET_FARM_TOKEN_BY_ASSET_AMOUNT; + } + + // slither-disable-next-line naming-convention,dead-code + function _connector_supportedSpecialWithdrawals() + internal + view + virtual + override + returns (SpecialWithdrawalCode[] memory codes) + { + codes = new SpecialWithdrawalCode[](2); + codes[0] = SpecialWithdrawal.WITHDRAW_ASSET_FARM_TOKEN_BY_AMOUNT; + codes[1] = SpecialWithdrawal.WITHDRAW_ASSET_FARM_TOKEN_BY_ASSET_AMOUNT; + } + + // slither-disable-next-line naming-convention,dead-code + function _connector_maxWithdraw() + internal + view + virtual + override + returns (address[] memory tokens, uint256[] memory withdrawable) + { + tokens = new address[](1); + tokens[0] = _connector_asset(); + withdrawable = new uint256[](1); + withdrawable[0] = ERC4626Vault().maxWithdraw(address(this)); + } + + // slither-disable-next-line naming-convention,dead-code + function _connector_delayedWithdrawalAdapter(address token) + internal + view + virtual + override + returns (IDelayedWithdrawalAdapter) + // solhint-disable-next-line no-empty-blocks + { + if (token == _connector_asset()) { + return _delayedWithdrawalAdapter(); + } + return IDelayedWithdrawalAdapter(address(0)); + } + + // slither-disable-next-line naming-convention,dead-code + function _connector_assetYieldCoefficient() internal view override returns (uint256 coefficient, uint256 multiplier) { + multiplier = 1e18; + IERC4626 vault = ERC4626Vault(); + uint256 shares = vault.totalSupply(); + if (shares == 0) { + return (multiplier, multiplier); + } + uint256 assets = vault.totalAssets(); + coefficient = assets.mulDiv(multiplier, shares, Math.Rounding.Floor); + } + + // slither-disable-next-line naming-convention,dead-code + function _connector_rewardEmissionsPerSecondPerAsset() + internal + pure + override + returns (uint256[] memory, uint256[] memory) + { + return (new uint256[](0), new uint256[](0)); + } + + // slither-disable-next-line naming-convention,dead-code + function _connector_totalAssetsInFarm() internal view override returns (uint256) { + return ERC4626Vault().totalAssets(); + } + + // slither-disable-next-line naming-convention,dead-code + function _connector_deposit( + address depositToken, + uint256 depositAmount + ) + internal + virtual + override + returns (uint256 assetsDeposited) + { + IERC4626 vault = ERC4626Vault(); + if (depositToken == _connector_asset()) { + uint256 shares = vault.deposit(depositAmount, address(this)); + // Note: there might be slippage or a deposit fee, so we will re-calculate the amount of assets deposited + // based on the amount of shares minted + return vault.previewRedeem(shares); + } else if (depositToken == address(vault)) { + return vault.previewRedeem(depositAmount); + } else { + revert InvalidDepositToken(depositToken); + } + } + + // slither-disable-next-line naming-convention,dead-code + function _connector_withdraw( + uint256 positionId, + address[] memory tokens, + uint256[] memory toWithdraw, + address + ) + internal + virtual + override + returns (IEarnStrategy.WithdrawalType[] memory withdrawalTypes) + { + IERC4626 vault = ERC4626Vault(); + // Note: we assume params are consistent and valid because they were validated by the EarnVault + uint256 shares = vault.previewWithdraw(toWithdraw[0]); + IERC20(address(vault)).safeTransfer(address(_connector_delayedWithdrawalAdapter(tokens[0])), shares); + + _connector_delayedWithdrawalAdapter(tokens[0]).initiateDelayedWithdrawal(positionId, tokens[0], shares); + + withdrawalTypes = _connector_supportedWithdrawals(); + } + + // slither-disable-next-line naming-convention,dead-code + function _connector_specialWithdraw( + uint256, + SpecialWithdrawalCode withdrawalCode, + uint256[] calldata toWithdraw, + bytes calldata, + address recipient + ) + internal + override + returns ( + uint256[] memory balanceChanges, + address[] memory actualWithdrawnTokens, + uint256[] memory actualWithdrawnAmounts, + bytes memory result + ) + { + IERC4626 vault = ERC4626Vault(); + balanceChanges = new uint256[](1); + actualWithdrawnTokens = new address[](1); + actualWithdrawnAmounts = new uint256[](1); + result = ""; + + if (withdrawalCode == SpecialWithdrawal.WITHDRAW_ASSET_FARM_TOKEN_BY_AMOUNT) { + uint256 shares = toWithdraw[0]; + uint256 assets = vault.previewRedeem(shares); + vault.safeTransfer(recipient, shares); + balanceChanges[0] = assets; + actualWithdrawnTokens[0] = address(vault); + actualWithdrawnAmounts[0] = shares; + } else if (withdrawalCode == SpecialWithdrawal.WITHDRAW_ASSET_FARM_TOKEN_BY_ASSET_AMOUNT) { + uint256 assets = toWithdraw[0]; + uint256 shares = vault.previewWithdraw(assets); + vault.safeTransfer(recipient, shares); + balanceChanges[0] = assets; + actualWithdrawnTokens[0] = address(vault); + actualWithdrawnAmounts[0] = shares; + } else { + revert InvalidSpecialWithdrawalCode(withdrawalCode); + } + } + + // slither-disable-next-line naming-convention,dead-code + function _connector_migrateToNewStrategy( + IEarnStrategy newStrategy, + bytes calldata + ) + internal + virtual + override + returns (bytes memory) + { + IERC4626 vault = ERC4626Vault(); + uint256 balance = vault.balanceOf(address(this)); + vault.safeTransfer(address(newStrategy), balance); + return abi.encode(balance); + } + + // slither-disable-next-line naming-convention,dead-code + function _connector_strategyRegistered( + StrategyId strategyId, + IEarnStrategy oldStrategy, + bytes calldata migrationData + ) + internal + virtual + override + // solhint-disable-next-line no-empty-blocks + { } +} diff --git a/test/integration/strategies/layers/connector/ERC4626DelayedConnectorTest.t.sol b/test/integration/strategies/layers/connector/ERC4626DelayedConnectorTest.t.sol new file mode 100644 index 00000000..a6a9d6f4 --- /dev/null +++ b/test/integration/strategies/layers/connector/ERC4626DelayedConnectorTest.t.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.22; + +import { IERC4626, IERC20 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import { BaseConnectorInstance } from "./base/BaseConnectorTest.t.sol"; +import { BaseConnectorDelayedWithdrawalTest } from "./base/BaseConnectorDelayedWithdrawalTest.t.sol"; +import { BaseConnectorFarmTokenTest } from "./base/BaseConnectorFarmTokenTest.t.sol"; +import { IDelayedWithdrawalAdapter } from "src/interfaces/IDelayedWithdrawalAdapter.sol"; +import { ERC4626DelayedConnector } from "src/strategies/layers/connector/ERC4626DelayedConnector.sol"; +import { ERC4626DelayedWithdrawalAdapterMock } from + "../../../../mocks/delayed-withdrawal-adapter/ERC4626DelayedWithdrawalAdapterMock.sol"; + +contract ERC4626DelayedConnectorTest is BaseConnectorDelayedWithdrawalTest, BaseConnectorFarmTokenTest { + address internal constant FARM_TOKEN_HOLDER = 0x18451C199Dea9563DE64A87b43045b0554E05ECD; + address internal constant FARM_TOKEN = 0x305F25377d0a39091e99B975558b1bdfC3975654; + + bytes32 public constant DELAYED_WITHDRAWAL_MANAGER = keccak256("DELAYED_WITHDRAWAL_MANAGER"); + + // solhint-disable-next-line no-empty-blocks + function _setUp() internal override { } + + function _configureFork() internal override { + uint256 polygonFork = vm.createFork(vm.rpcUrl("polygon")); + vm.selectFork(polygonFork); + } + + function _buildNewConnector() internal override returns (BaseConnectorInstance) { + return new ERC4626DelayedConnectorInstance(new ERC4626DelayedWithdrawalAdapterMock(FARM_TOKEN), FARM_TOKEN); + } + + function _farmToken() internal view virtual override returns (address) { + return address(FARM_TOKEN); + } + + function _setBalance(address asset, address account, uint256 amount) internal override { + if (asset == address(FARM_TOKEN)) { + // We need to set the balance of the account to 0 + uint256 balance = IERC20(asset).balanceOf(account); + if (balance > amount) { + vm.prank(account); + IERC20(asset).transfer(FARM_TOKEN_HOLDER, balance - amount); + } else if (balance < amount) { + vm.prank(FARM_TOKEN_HOLDER); + IERC20(asset).transfer(account, amount - balance); + } + } else { + return super._setBalance(asset, account, amount); + } + } +} + +contract ERC4626DelayedConnectorInstance is BaseConnectorInstance, ERC4626DelayedConnector { + IDelayedWithdrawalAdapter internal immutable __delayedWithdrawalAdapter; + address internal immutable farmToken; + + constructor(IDelayedWithdrawalAdapter ___delayedWithdrawalAdapter, address farmToken_) initializer { + farmToken = farmToken_; + __delayedWithdrawalAdapter = ___delayedWithdrawalAdapter; + _connector_init(); + } + + function _delayedWithdrawalAdapter() internal view override returns (IDelayedWithdrawalAdapter) { + return __delayedWithdrawalAdapter; + } + + function ERC4626Vault() public view virtual override returns (IERC4626) { + return IERC4626(farmToken); + } + + function _asset() internal view virtual override returns (IERC20) { + return IERC20(ERC4626Vault().asset()); + } +} diff --git a/test/integration/strategies/layers/connector/base/BaseConnectorDelayedWithdrawalTest.t.sol b/test/integration/strategies/layers/connector/base/BaseConnectorDelayedWithdrawalTest.t.sol index 0b205ca0..9383e1f0 100644 --- a/test/integration/strategies/layers/connector/base/BaseConnectorDelayedWithdrawalTest.t.sol +++ b/test/integration/strategies/layers/connector/base/BaseConnectorDelayedWithdrawalTest.t.sol @@ -54,6 +54,7 @@ abstract contract BaseConnectorDelayedWithdrawalTest is BaseConnectorTest { // Check remaining balances (, uint256[] memory balancesAfter) = connector.totalBalances(); + for (uint256 i; i < tokens.length; ++i) { assertAlmostEq(adapters[i].estimatedPendingFunds(0, tokens[i]), toWithdraw[i], 1); assertAlmostEq(toWithdraw[i], balancesBefore[i] - balancesAfter[i], 1); diff --git a/test/mocks/delayed-withdrawal-adapter/ERC4626DelayedWithdrawalAdapterMock.sol b/test/mocks/delayed-withdrawal-adapter/ERC4626DelayedWithdrawalAdapterMock.sol new file mode 100644 index 00000000..209148db --- /dev/null +++ b/test/mocks/delayed-withdrawal-adapter/ERC4626DelayedWithdrawalAdapterMock.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.22; + +import { DelayedWithdrawalAdapterDead } from "./DelayedWithdrawalAdapterDead.sol"; +import { IERC4626, IERC20 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; + +contract ERC4626DelayedWithdrawalAdapterMock is DelayedWithdrawalAdapterDead { + mapping(uint256 positionId => mapping(address token => uint256 withdrawn)) private _withdrawnFunds; + mapping(uint256 positionId => mapping(address token => uint256 pending)) private _pendingFunds; + address private farmToken; + + constructor(address _farmToken) DelayedWithdrawalAdapterDead() { + farmToken = _farmToken; + } + + function estimatedPendingFunds(uint256 positionId, address token) public view virtual override returns (uint256) { + return IERC4626(farmToken).previewRedeem(_pendingFunds[positionId][token]); + } + + function withdrawableFunds(uint256 positionId, address token) public view virtual override returns (uint256) { + return IERC4626(farmToken).previewRedeem(_pendingFunds[positionId][token] - _withdrawnFunds[positionId][token]); + } + + function initiateDelayedWithdrawal(uint256 positionId, address token, uint256 shares) external virtual override { + _pendingFunds[positionId][token] += shares; + } + + function withdraw( + uint256 positionId, + address token, + address + ) + external + virtual + override + returns (uint256 withdrawn, uint256 stillPending) + { + _withdrawnFunds[positionId][token] += IERC4626(farmToken).previewWithdraw(_pendingFunds[positionId][token]); + withdrawn = withdrawableFunds(positionId, token); + stillPending = estimatedPendingFunds(positionId, token); + } +} diff --git a/test/mocks/strategies/ERC4626DelayedStrategyMock.sol b/test/mocks/strategies/ERC4626DelayedStrategyMock.sol new file mode 100644 index 00000000..952d7a78 --- /dev/null +++ b/test/mocks/strategies/ERC4626DelayedStrategyMock.sol @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.22; + +import { + IEarnStrategy, + SpecialWithdrawalCode, + StrategyId, + IEarnVault, + IEarnStrategyRegistry, + IERC165 +} from "@balmy/earn-core/interfaces/IEarnStrategy.sol"; +import { IEarnBalmyStrategy } from "src/interfaces/IEarnBalmyStrategy.sol"; +import { IDelayedWithdrawalAdapter } from "src/delayed-withdrawal-manager/DelayedWithdrawalManager.sol"; +import { ERC4626DelayedConnector } from "src/strategies/layers/connector/ERC4626DelayedConnector.sol"; +import { IERC4626, IERC20 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; + +contract ERC4626DelayedStrategyMock is IEarnBalmyStrategy, ERC4626DelayedConnector { + /// @inheritdoc IEarnStrategy + IEarnVault public immutable vault; + /// @inheritdoc IEarnStrategy + string public description; + + // slither-disable-next-line naming-convention + IDelayedWithdrawalAdapter internal immutable __delayedWithdrawalAdapter; + IERC4626 internal immutable __erc4626Vault; + + constructor( + // General + IEarnVault vault_, + IERC4626 erc4626Vault_, + string memory description_, + IDelayedWithdrawalAdapter delayedWithdrawalAdapter_ + ) + initializer + { + vault = vault_; + description = description_; + __delayedWithdrawalAdapter = delayedWithdrawalAdapter_; + __erc4626Vault = erc4626Vault_; + } + + receive() external payable { } + + /// @inheritdoc IEarnStrategy + function registry() public view returns (IEarnStrategyRegistry) { + return vault.STRATEGY_REGISTRY(); + } + + /// @inheritdoc IERC165 + function supportsInterface(bytes4 interfaceId) public pure override(IERC165) returns (bool) { + return interfaceId == type(IEarnBalmyStrategy).interfaceId || interfaceId == type(IEarnStrategy).interfaceId + || interfaceId == type(IERC165).interfaceId; + } + + /// @inheritdoc IEarnStrategy + function asset() external view returns (address) { + return _connector_asset(); + } + + /// @inheritdoc IEarnStrategy + function allTokens() external view returns (address[] memory) { + return _connector_allTokens(); + } + + /// @inheritdoc IEarnStrategy + function isDepositTokenSupported(address depositToken) external view returns (bool) { + return _connector_isDepositTokenSupported(depositToken); + } + + /// @inheritdoc IEarnStrategy + function supportedDepositTokens() external view returns (address[] memory) { + return _connector_supportedDepositTokens(); + } + + /// @inheritdoc IEarnStrategy + function maxDeposit(address depositToken) external view returns (uint256) { + return _connector_maxDeposit(depositToken); + } + + /// @inheritdoc IEarnStrategy + function supportedWithdrawals() external view returns (WithdrawalType[] memory) { + return _connector_supportedWithdrawals(); + } + + /// @inheritdoc IEarnStrategy + function isSpecialWithdrawalSupported(SpecialWithdrawalCode withdrawalCode) external view returns (bool) { + return _connector_isSpecialWithdrawalSupported(withdrawalCode); + } + + /// @inheritdoc IEarnStrategy + function supportedSpecialWithdrawals() external view returns (SpecialWithdrawalCode[] memory) { + return _connector_supportedSpecialWithdrawals(); + } + + /// @inheritdoc IEarnStrategy + function maxWithdraw() external view returns (address[] memory, uint256[] memory) { + return _connector_maxWithdraw(); + } + + /// @inheritdoc IEarnBalmyStrategy + function delayedWithdrawalAdapter(address token) external view returns (IDelayedWithdrawalAdapter) { + return _connector_delayedWithdrawalAdapter(token); + } + + /// @inheritdoc IEarnStrategy + function fees() external pure returns (IEarnStrategy.FeeType[] memory types, uint16[] memory bps) { + types = new IEarnStrategy.FeeType[](0); + bps = new uint16[](0); + } + + function totalBalances() external view returns (address[] memory tokens, uint256[] memory balances) { + return _connector_totalBalances(); + } + + /// @inheritdoc IEarnStrategy + function deposited(address depositToken, uint256 depositAmount) external payable returns (uint256 assetsDeposited) { + return _connector_deposit(depositToken, depositAmount); + } + + /// @inheritdoc IEarnStrategy + // solhint-disable-next-line no-empty-blocks + function validatePositionCreation(address sender, bytes calldata creationData) external view { } + + /// @inheritdoc IEarnStrategy + function withdraw( + uint256 positionId, + address[] memory tokens, + uint256[] memory toWithdraw, + address recipient + ) + external + returns (IEarnStrategy.WithdrawalType[] memory) + { + return _connector_withdraw(positionId, tokens, toWithdraw, recipient); + } + + /// @inheritdoc IEarnStrategy + function specialWithdraw( + uint256 positionId, + SpecialWithdrawalCode withdrawalCode, + uint256[] calldata toWithdraw, + bytes calldata withdrawalData, + address recipient + ) + external + returns ( + uint256[] memory balanceChanges, + address[] memory actualWithdrawnTokens, + uint256[] memory actualWithdrawnAmounts, + bytes memory result + ) + { + return _connector_specialWithdraw(positionId, withdrawalCode, toWithdraw, withdrawalData, recipient); + } + + /// @inheritdoc IEarnStrategy + function migrateToNewStrategy( + IEarnStrategy newStrategy, + bytes calldata migrationData + ) + external + returns (bytes memory) + { + return _connector_migrateToNewStrategy(newStrategy, migrationData); + } + + /// @inheritdoc IEarnStrategy + function strategyRegistered( + StrategyId strategyId, + IEarnStrategy oldStrategy, + bytes calldata migrationResultData + ) + external + { + _connector_strategyRegistered(strategyId, oldStrategy, migrationResultData); + } + + function _delayedWithdrawalAdapter() internal view virtual override returns (IDelayedWithdrawalAdapter) { + return __delayedWithdrawalAdapter; + } + + function assetYieldCoefficient() external pure override returns (uint256, uint256) { + return (1e18, 1e18); + } + + function rewardEmissionsPerSecondPerAsset() external pure override returns (uint256[] memory, uint256[] memory) { + return (new uint256[](0), new uint256[](0)); + } + + function ERC4626Vault() public view virtual override returns (IERC4626) { + return __erc4626Vault; + } + + function _asset() internal view virtual override returns (IERC20) { + return IERC20(__erc4626Vault.asset()); + } +} diff --git a/test/unit/delayed-withdrawal-adapter/ERC4626DelayedWithdrawalAdapter.t.sol b/test/unit/delayed-withdrawal-adapter/ERC4626DelayedWithdrawalAdapter.t.sol new file mode 100644 index 00000000..1c226b8c --- /dev/null +++ b/test/unit/delayed-withdrawal-adapter/ERC4626DelayedWithdrawalAdapter.t.sol @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.22; + +import { PRBTest } from "@prb/test/PRBTest.sol"; +import { ERC4626DelayedWithdrawalAdapter } from "src/delayed-withdrawal-adapter/ERC4626DelayedWithdrawalAdapter.sol"; + +import { + IDelayedWithdrawalManager, + DelayedWithdrawalManager +} from "src/delayed-withdrawal-manager/DelayedWithdrawalManager.sol"; +import { LiquidityMiningManager } from "src/liquidity-mining-manager/LiquidityMiningManager.sol"; + +import { EarnVault, IEarnVault, StrategyId, IEarnNFTDescriptor } from "@balmy/earn-core/vault/EarnVault.sol"; +import { + EarnStrategyRegistry, IEarnStrategyRegistry +} from "@balmy/earn-core/strategy-registry/EarnStrategyRegistry.sol"; +import { CommonUtils } from "../../utils/CommonUtils.sol"; +import { Token } from "@balmy/earn-core/libraries/Token.sol"; +import { IEarnBalmyStrategy } from "src/interfaces/IEarnBalmyStrategy.sol"; +import { PermissionUtils } from "@balmy/nft-permissions-test/PermissionUtils.sol"; +import { GlobalEarnRegistry } from "src/global-registry/GlobalEarnRegistry.sol"; +import { IERC4626, IERC20 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import { SafeERC20, IERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import { ERC4626DelayedStrategyMock } from "../../mocks/strategies/ERC4626DelayedStrategyMock.sol"; +import { FeeManager, Fees } from "src/fee-manager/FeeManager.sol"; +import { TOSManager } from "src/tos-manager/TOSManager.sol"; + +contract ERC4626DelayedWithdrawalAdapterTest is PRBTest { + // solhint-disable-next-line const-name-snakecase + address internal constant _FARM_TOKEN = 0x305F25377d0a39091e99B975558b1bdfC3975654; + // solhint-disable-next-line const-name-snakecase + address internal constant _FARM_TOKEN_HOLDER = 0x18451C199Dea9563DE64A87b43045b0554E05ECD; + + using SafeERC20 for IERC20; + + address private owner = address(this); + ERC4626DelayedWithdrawalAdapter private erc4626DelayedWithdrawalAdapter; + IEarnVault private vault; + address[] private tokens = new address[](1); + IEarnBalmyStrategy private strategy; + StrategyId private strategyId; + GlobalEarnRegistry private globalRegistry; + uint256 private position; + + bytes32 public constant DELAYED_WITHDRAWAL_MANAGER = keccak256("DELAYED_WITHDRAWAL_MANAGER"); + bytes32 public constant LIQUIDITY_MINING_MANAGER = keccak256("LIQUIDITY_MINING_MANAGER"); + bytes32 public constant FEE_MANAGER = keccak256("FEE_MANAGER"); + bytes32 public constant TOS_MANAGER = keccak256("TOS_MANAGER"); + + function setUp() public virtual { + uint256 polygonFork = vm.createFork(vm.rpcUrl("polygon")); + vm.selectFork(polygonFork); + vm.rollFork(63_000_000); + + globalRegistry = new GlobalEarnRegistry(address(this)); + + IEarnStrategyRegistry strategyRegistry = new EarnStrategyRegistry(); + IEarnNFTDescriptor nftDescriptor = IEarnNFTDescriptor(address(this)); + vault = new EarnVault(strategyRegistry, address(this), CommonUtils.arrayOf(address(this)), nftDescriptor); + uint104 amountToDeposit1 = 100_000; + + tokens[0] = IERC4626(_FARM_TOKEN).asset(); + + globalRegistry.setAddress(DELAYED_WITHDRAWAL_MANAGER, address(new DelayedWithdrawalManager(vault))); + erc4626DelayedWithdrawalAdapter = new ERC4626DelayedWithdrawalAdapter(globalRegistry, _FARM_TOKEN, 100); + + strategy = new ERC4626DelayedStrategyMock( + vault, IERC4626(_FARM_TOKEN), "ERC4626DelayedStrategy", erc4626DelayedWithdrawalAdapter + ); + strategyId = strategyRegistry.registerStrategy(owner, strategy); + + vm.mockCall( + address(vault), abi.encodeWithSelector(vault.positionsStrategy.selector, position), abi.encode(strategyId) + ); + } + + function testFork_estimatedPendingFunds_AreZeroWithoutInitiatedWithdraw() public { + uint256 pendingAmount = erc4626DelayedWithdrawalAdapter.estimatedPendingFunds(position, owner); + assertEq(pendingAmount, 0); + } + + function testFork_withdrawableFunds_AreZeroWithoutInitiatedWithdraw() public { + uint256 pendingAmount = erc4626DelayedWithdrawalAdapter.estimatedPendingFunds(position, owner); + assertEq(pendingAmount, 0); + } + + function testFork_initiateDelayedWithdrawal() public { + uint256 amount = 1000; + uint256 amountToTransfer = IERC4626(_FARM_TOKEN).previewWithdraw(uint256(amount)); + vm.prank(_FARM_TOKEN_HOLDER); + IERC20(_FARM_TOKEN).transfer(address(erc4626DelayedWithdrawalAdapter), amountToTransfer); + vm.startPrank(address(strategy)); + + erc4626DelayedWithdrawalAdapter.initiateDelayedWithdrawal(position, owner, amountToTransfer); + uint256 pendingAmount = erc4626DelayedWithdrawalAdapter.estimatedPendingFunds(position, owner); + assertAlmostEq(pendingAmount, amount, 2); + + vm.stopPrank(); + } + + function testFork_withdraw() public { + uint256 amount = 1000; + + // Send farmToken to the adapter, and initiate a withdrawal with this balance + uint256 amountToTransfer = IERC4626(_FARM_TOKEN).previewWithdraw(uint256(amount)); + vm.prank(_FARM_TOKEN_HOLDER); + IERC20(_FARM_TOKEN).transfer(address(erc4626DelayedWithdrawalAdapter), amountToTransfer); + + vm.startPrank(address(strategy)); + erc4626DelayedWithdrawalAdapter.initiateDelayedWithdrawal(position, owner, amountToTransfer); + + // Force the delayed to be finalized + vm.warp(block.timestamp + 100); + + uint256 withdrawableFunds = erc4626DelayedWithdrawalAdapter.withdrawableFunds(position, owner); + assertNotEq(withdrawableFunds, 0); + vm.startPrank(address(erc4626DelayedWithdrawalAdapter.manager())); + erc4626DelayedWithdrawalAdapter.withdraw(position, IERC4626(_FARM_TOKEN).asset(), address(strategy)); + uint256 pendingAmount = erc4626DelayedWithdrawalAdapter.estimatedPendingFunds(position, owner); + uint256 strategyBalance = IERC20(IERC4626(_FARM_TOKEN).asset()).balanceOf(address(strategy)); + assertAlmostEq(withdrawableFunds, strategyBalance, 1); + assertEq(pendingAmount, 0); + + withdrawableFunds = erc4626DelayedWithdrawalAdapter.withdrawableFunds(position, owner); + assertEq(withdrawableFunds, 0); + + vm.stopPrank(); + } + + function testFork_withdraw_multiple() public { + uint256 amount = 1000; + + // Send farmToken to the adapter, and initiate a withdrawal with this balance + uint256 amountToTransfer = IERC4626(_FARM_TOKEN).previewWithdraw(uint256(amount)); + vm.prank(_FARM_TOKEN_HOLDER); + IERC20(_FARM_TOKEN).transfer(address(erc4626DelayedWithdrawalAdapter), amountToTransfer); + + vm.startPrank(address(strategy)); + + erc4626DelayedWithdrawalAdapter.initiateDelayedWithdrawal(position, owner, amountToTransfer / 2); + erc4626DelayedWithdrawalAdapter.initiateDelayedWithdrawal(position, owner, amountToTransfer / 2); + + // Force the delayed to be finalized + vm.warp(block.timestamp + 100); + + uint256 withdrawableFunds = erc4626DelayedWithdrawalAdapter.withdrawableFunds(position, owner); + assertNotEq(withdrawableFunds, 0); + vm.startPrank(address(erc4626DelayedWithdrawalAdapter.manager())); + erc4626DelayedWithdrawalAdapter.withdraw(position, IERC4626(_FARM_TOKEN).asset(), address(strategy)); + uint256 pendingAmount = erc4626DelayedWithdrawalAdapter.estimatedPendingFunds(position, owner); + uint256 strategyBalance = IERC20(IERC4626(_FARM_TOKEN).asset()).balanceOf(address(strategy)); + assertAlmostEq(withdrawableFunds, strategyBalance, 1); + assertEq(pendingAmount, 0); + vm.stopPrank(); + } +}