From b64cdfad22a18b4489e74af28baf6b50886f6f1b Mon Sep 17 00:00:00 2001 From: Ardit Marku Date: Tue, 24 Mar 2026 20:42:02 +0200 Subject: [PATCH 1/2] Add recovery mechanism for accidental user transfers of ERC20 tokens --- solidity/src/FlowYieldVaultsRequests.sol | 36 +++- solidity/test/FlowYieldVaultsRequests.t.sol | 176 ++++++++++++++++++++ 2 files changed, 205 insertions(+), 7 deletions(-) diff --git a/solidity/src/FlowYieldVaultsRequests.sol b/solidity/src/FlowYieldVaultsRequests.sol index 972b91a..1e9f3bc 100644 --- a/solidity/src/FlowYieldVaultsRequests.sol +++ b/solidity/src/FlowYieldVaultsRequests.sol @@ -458,6 +458,16 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step { uint256 amount ); + /// @notice Emitted when the contract owner recovers user tokens + /// @param to User who received the tokens + /// @param tokenAddress ERC20 token claimed + /// @param amount Amount recovered + event TokensRecovered( + address indexed to, + address indexed tokenAddress, + uint256 amount + ); + // ============================================ // Modifiers // ============================================ @@ -513,17 +523,29 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step { } } - // ============================================ - // Receive Function - // ============================================ - - /// @notice Allows contract to receive native $FLOW - receive() external payable {} - // ============================================ // External Functions - Admin // ============================================ + /// @notice Recovery mechanism for accidental user transfers of ERC20 tokens + /// @dev Tokens sent directly to the contract outside the intended request + /// flows (including accidental transfers, airdrops etc) are only + /// recoverable through this function here. + /// @param to The recipient address to transfer funds to. + /// @param tokenAddress The token to transfer. + /// @param amount The amount of tokens to transfer (in wei). + function recoverTokens( + address to, + address tokenAddress, + uint256 amount + ) external onlyOwner { + IERC20(tokenAddress).safeTransfer( + to, + amount + ); + emit TokensRecovered(to, tokenAddress, amount); + } + /// @notice Updates the authorized COA address /// @param _coa New COA address function setAuthorizedCOA(address _coa) external onlyOwner { diff --git a/solidity/test/FlowYieldVaultsRequests.t.sol b/solidity/test/FlowYieldVaultsRequests.t.sol index 8008f55..e3a6f9a 100644 --- a/solidity/test/FlowYieldVaultsRequests.t.sol +++ b/solidity/test/FlowYieldVaultsRequests.t.sol @@ -2,8 +2,17 @@ pragma solidity 0.8.20; import "forge-std/Test.sol"; +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "../src/FlowYieldVaultsRequests.sol"; +contract MockDAI is ERC20 { + constructor() ERC20("Mock DAI", "DAI") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + contract FlowYieldVaultsRequestsTestHelper is FlowYieldVaultsRequests { constructor(address coaAddress, address wflowAddress) FlowYieldVaultsRequests(coaAddress, wflowAddress) {} @@ -33,6 +42,7 @@ contract FlowYieldVaultsRequestsTest is Test { // Events for testing (from FlowYieldVaultsRequests) event BalanceUpdated(address indexed user, address indexed tokenAddress, uint256 newBalance); event RefundClaimed(address indexed user, address indexed tokenAddress, uint256 amount); + event TokensRecovered(address indexed to, address indexed tokenAddress, uint256 amount); // Errors from OpenZeppelin Ownable error OwnableUnauthorizedAccount(address account); @@ -41,11 +51,16 @@ contract FlowYieldVaultsRequestsTest is Test { string constant VAULT_ID = "A.0ae53cb6e3f42a79.FlowToken.Vault"; string constant STRATEGY_ID = "A.045a1763c93006ca.MockStrategies.TracerStrategy"; + MockDAI dai; + function setUp() public { vm.deal(user, 100 ether); vm.deal(user2, 100 ether); c = new FlowYieldVaultsRequestsTestHelper(coa, WFLOW); c.testRegisterYieldVaultId(42, user, NATIVE_FLOW); + + dai = new MockDAI(); + dai.mint(user2, 1 ether); } function _startProcessingBatch(uint256 requestId) internal { @@ -1451,4 +1466,165 @@ contract FlowYieldVaultsRequestsTest is Test { )); uint256 closeReq = c.closeYieldVault(100); } + + function test_CreateYieldVault_MsgValueValidation() public { + vm.startPrank(user); + + vm.expectRevert(FlowYieldVaultsRequests.MsgValueMustEqualAmount.selector); + c.createYieldVault{value: 5 ether}(NATIVE_FLOW, 1 ether, VAULT_ID, STRATEGY_ID); + + vm.expectRevert(FlowYieldVaultsRequests.MsgValueMustBeZero.selector); + c.createYieldVault{value: 5 ether}(WFLOW, 5 ether, VAULT_ID, STRATEGY_ID); + + vm.stopPrank(); + } + + function test_CreateYieldVault_RevertTokenNotSupported() public { + deal(address(dai), user2, 20 ether); + assertEq(dai.balanceOf(user2), 20 ether); + + vm.prank(user2); + vm.expectRevert(abi.encodeWithSelector( + FlowYieldVaultsRequests.TokenNotSupported.selector, + address(dai) + )); + c.createYieldVault(address(dai), 5 ether, VAULT_ID, STRATEGY_ID); + } + + function test_DepositToYieldVault_MsgValueValidation() public { + vm.prank(user); + vm.expectRevert(FlowYieldVaultsRequests.MsgValueMustEqualAmount.selector); + c.depositToYieldVault{value: 5 ether}(42, NATIVE_FLOW, 1 ether); + + c.testRegisterYieldVaultId(101, user, WFLOW); + vm.expectRevert(FlowYieldVaultsRequests.MsgValueMustBeZero.selector); + c.depositToYieldVault{value: 5 ether}(101, WFLOW, 5 ether); + } + + function test_DepositToYieldVault_RevertTokenNotSupported() public { + deal(address(dai), user2, 20 ether); + assertEq(dai.balanceOf(user2), 20 ether); + + c.testRegisterYieldVaultId(101, user2, address(dai)); + + vm.prank(user2); + vm.expectRevert(abi.encodeWithSelector( + FlowYieldVaultsRequests.TokenNotSupported.selector, + address(dai) + )); + c.depositToYieldVault{value: 5 ether}(101, address(dai), 1 ether); + } + + function test_CompleteProcessing_MsgValueValidation() public { + vm.prank(user); + uint256 req1 = c.createYieldVault{value: 5 ether}(NATIVE_FLOW, 5 ether, VAULT_ID, STRATEGY_ID); + + vm.prank(coa); + _startProcessingBatch(req1); + + uint64 sentinelYieldVaultId = c.NO_YIELDVAULT_ID(); + vm.prank(coa); + vm.expectRevert(FlowYieldVaultsRequests.MsgValueMustEqualAmount.selector); + c.completeProcessing{value: 3 ether}(req1, false, sentinelYieldVaultId, "Failed"); + + c.testRegisterYieldVaultId(101, user2, address(dai)); + c.setTokenConfig(address(dai), true, 0.5 ether, false); + + vm.startPrank(user2); + deal(address(dai), user2, 20 ether); + dai.approve(address(c), 5 ether); + uint256 req2 = c.depositToYieldVault(101, address(dai), 5 ether); + vm.stopPrank(); + + vm.prank(coa); + _startProcessingBatch(req2); + + vm.prank(coa); + vm.expectRevert(FlowYieldVaultsRequests.MsgValueMustBeZero.selector); + c.completeProcessing{value: 5 ether}(req2, false, 101, "Failed"); + } + + function test_CompleteProcessing_RefundNativeFunds() public { + assertEq(user.balance, 100 ether); + assertEq(address(c).balance, 0 ether); + + vm.prank(user); + uint256 reqId = c.createYieldVault{value: 5 ether}(NATIVE_FLOW, 5 ether, VAULT_ID, STRATEGY_ID); + assertEq(user.balance, 95 ether); + assertEq(address(c).balance, 5 ether); + + vm.prank(coa); + _startProcessingBatch(reqId); + assertEq(coa.balance, 5 ether); + assertEq(address(c).balance, 0 ether); + + uint64 sentinelYieldVaultId = c.NO_YIELDVAULT_ID(); + vm.prank(coa); + c.completeProcessing{value: 5 ether}(reqId, false, sentinelYieldVaultId, "Failed"); + assertEq(coa.balance, 0 ether); + assertEq(address(c).balance, 5 ether); + + vm.prank(user); + c.claimRefund(NATIVE_FLOW); + assertEq(coa.balance, 0 ether); + assertEq(address(c).balance, 0 ether); + assertEq(user.balance, 100 ether); + } + + function test_CompleteProcessing_RefundERC20Tokens() public { + vm.prank(c.owner()); + c.testRegisterYieldVaultId(101, user, address(dai)); + c.setTokenConfig(address(dai), true, 0.5 ether, false); + + deal(address(dai), user, 20 ether); + assertEq(dai.balanceOf(user), 20 ether); + assertEq(dai.balanceOf(address(c)), 0 ether); + + vm.prank(c.owner()); + c.setTokenConfig(address(dai), true, 0.5 ether, false); + + vm.startPrank(user); + dai.approve(address(c), 5 ether); + uint256 reqId = c.createYieldVault(address(dai), 5 ether, VAULT_ID, STRATEGY_ID); + assertEq(dai.balanceOf(user), 15 ether); + assertEq(dai.balanceOf(address(c)), 5 ether); + vm.stopPrank(); + + vm.prank(coa); + _startProcessingBatch(reqId); + assertEq(dai.balanceOf(coa), 5 ether); + assertEq(dai.balanceOf(address(c)), 0 ether); + + vm.startPrank(coa); + dai.approve(address(c), 5 ether); + c.completeProcessing(reqId, false, 101, "Failed"); + assertEq(dai.balanceOf(coa), 0 ether); + assertEq(dai.balanceOf(address(c)), 5 ether); + vm.stopPrank(); + + vm.prank(user); + c.claimRefund(address(dai)); + assertEq(dai.balanceOf(coa), 0 ether); + assertEq(dai.balanceOf(address(c)), 0 ether); + assertEq(dai.balanceOf(user), 20 ether); + } + + function test_RecoverTokens() public { + deal(address(dai), user2, 20 ether); + assertEq(dai.balanceOf(user2), 20 ether); + + vm.startPrank(user2); + dai.transfer(address(c), 5 ether); + assertEq(dai.balanceOf(user2), 15 ether); + assertEq(dai.balanceOf(address(c)), 5 ether); + vm.stopPrank(); + + vm.startPrank(c.owner()); + vm.expectEmit(true, true, true, true); + emit TokensRecovered(user2, address(dai), 5 ether); + c.recoverTokens(user2, address(dai), 5 ether); + assertEq(dai.balanceOf(user2), 20 ether); + assertEq(dai.balanceOf(address(c)), 0 ether); + vm.stopPrank(); + } } From d141e8b5c8134469dbc54b953203f1425ebd9e54 Mon Sep 17 00:00:00 2001 From: Ardit Marku Date: Wed, 25 Mar 2026 14:23:59 +0200 Subject: [PATCH 2/2] Proper calculation of available amount to be recovered --- solidity/src/FlowYieldVaultsRequests.sol | 36 ++- solidity/test/FlowYieldVaultsRequests.t.sol | 229 +++++++++++++++++++- 2 files changed, 256 insertions(+), 9 deletions(-) diff --git a/solidity/src/FlowYieldVaultsRequests.sol b/solidity/src/FlowYieldVaultsRequests.sol index 1e9f3bc..9e7982d 100644 --- a/solidity/src/FlowYieldVaultsRequests.sol +++ b/solidity/src/FlowYieldVaultsRequests.sol @@ -167,6 +167,9 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step { /// @notice Claimable refunds from cancelled/dropped/failed requests: user => token => amount mapping(address => mapping(address => uint256)) public claimableRefunds; + /// @notice Total balance of each token accounted for in contract (escrowed/claimable) + mapping(address => uint256) public totalAccountedBalance; + /// @notice All requests indexed by request ID mapping(uint256 => Request) public requests; @@ -282,6 +285,15 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step { /// @notice No refund available for the specified token error NoRefundAvailable(address token); + /// @notice Recovery user address cannot be zero + error InvalidRecoveryUserAddress(); + + /// @notice ERC20 token address cannot be the native $FLOW token + error InvalidRecoveryTokenAddress(); + + /// @notice The requested recovery amount exceeds the available excess amount + error InsufficientRecoveryAmount(uint256 available, uint256 requested); + // ============================================ // Events // ============================================ @@ -538,11 +550,21 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step { address to, address tokenAddress, uint256 amount - ) external onlyOwner { - IERC20(tokenAddress).safeTransfer( - to, - amount - ); + ) external onlyOwner nonReentrant { + if (to == address(0) || to == address(this)) + revert InvalidRecoveryUserAddress(); + if (isNativeFlow(tokenAddress)) revert InvalidRecoveryTokenAddress(); + if (amount == 0) revert AmountMustBeGreaterThanZero(); + + uint256 contractBalance = IERC20(tokenAddress).balanceOf(address(this)); + uint256 excess = contractBalance > totalAccountedBalance[tokenAddress] + ? contractBalance - totalAccountedBalance[tokenAddress] + : 0; + if (amount > excess) { + revert InsufficientRecoveryAmount(excess, amount); + } + + IERC20(tokenAddress).safeTransfer(to, amount); emit TokensRecovered(to, tokenAddress, amount); } @@ -1023,6 +1045,7 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step { address(this), request.amount ); + totalAccountedBalance[request.tokenAddress] += request.amount; } // Credit refunded funds to claimable refunds for later claim claimableRefunds[request.user][request.tokenAddress] += request @@ -1527,6 +1550,7 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step { authorizedCOA, request.amount ); + totalAccountedBalance[request.tokenAddress] -= request.amount; } emit FundsWithdrawn( authorizedCOA, @@ -1588,6 +1612,7 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step { address(this), amount ); + totalAccountedBalance[tokenAddress] += amount; } } @@ -1647,6 +1672,7 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step { } else { // ERC20: use SafeERC20 for safe token transfer IERC20(tokenAddress).safeTransfer(to, amount); + totalAccountedBalance[tokenAddress] -= amount; } } diff --git a/solidity/test/FlowYieldVaultsRequests.t.sol b/solidity/test/FlowYieldVaultsRequests.t.sol index e3a6f9a..cde4aa5 100644 --- a/solidity/test/FlowYieldVaultsRequests.t.sol +++ b/solidity/test/FlowYieldVaultsRequests.t.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.20; import "forge-std/Test.sol"; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "@openzeppelin/contracts/interfaces/draft-IERC6093.sol"; import "../src/FlowYieldVaultsRequests.sol"; contract MockDAI is ERC20 { @@ -1497,6 +1498,7 @@ contract FlowYieldVaultsRequestsTest is Test { c.depositToYieldVault{value: 5 ether}(42, NATIVE_FLOW, 1 ether); c.testRegisterYieldVaultId(101, user, WFLOW); + vm.prank(user); vm.expectRevert(FlowYieldVaultsRequests.MsgValueMustBeZero.selector); c.depositToYieldVault{value: 5 ether}(101, WFLOW, 5 ether); } @@ -1542,6 +1544,11 @@ contract FlowYieldVaultsRequestsTest is Test { vm.prank(coa); vm.expectRevert(FlowYieldVaultsRequests.MsgValueMustBeZero.selector); c.completeProcessing{value: 5 ether}(req2, false, 101, "Failed"); + + // Success case must also reject non-zero msg.value (the `else` branch) + vm.prank(coa); + vm.expectRevert(FlowYieldVaultsRequests.MsgValueMustBeZero.selector); + c.completeProcessing{value: 1 ether}(req2, true, 101, "Success"); } function test_CompleteProcessing_RefundNativeFunds() public { @@ -1580,9 +1587,6 @@ contract FlowYieldVaultsRequestsTest is Test { assertEq(dai.balanceOf(user), 20 ether); assertEq(dai.balanceOf(address(c)), 0 ether); - vm.prank(c.owner()); - c.setTokenConfig(address(dai), true, 0.5 ether, false); - vm.startPrank(user); dai.approve(address(c), 5 ether); uint256 reqId = c.createYieldVault(address(dai), 5 ether, VAULT_ID, STRATEGY_ID); @@ -1597,7 +1601,7 @@ contract FlowYieldVaultsRequestsTest is Test { vm.startPrank(coa); dai.approve(address(c), 5 ether); - c.completeProcessing(reqId, false, 101, "Failed"); + c.completeProcessing(reqId, false, c.NO_YIELDVAULT_ID(), "Failed"); assertEq(dai.balanceOf(coa), 0 ether); assertEq(dai.balanceOf(address(c)), 5 ether); vm.stopPrank(); @@ -1627,4 +1631,221 @@ contract FlowYieldVaultsRequestsTest is Test { assertEq(dai.balanceOf(address(c)), 0 ether); vm.stopPrank(); } + + function test_RecoverTokens_RevertInsufficientRecoveryAmount() public { + deal(address(dai), user2, 20 ether); + assertEq(dai.balanceOf(user2), 20 ether); + + vm.startPrank(user2); + dai.transfer(address(c), 5 ether); + assertEq(dai.balanceOf(user2), 15 ether); + assertEq(dai.balanceOf(address(c)), 5 ether); + vm.stopPrank(); + + vm.prank(c.owner()); + vm.expectRevert(abi.encodeWithSelector( + FlowYieldVaultsRequests.InsufficientRecoveryAmount.selector, + 5 ether, + 25 ether + )); + c.recoverTokens(user2, address(dai), 25 ether); + } + + function test_RecoverTokens_RevertInvalidRecoveryUserAddress() public { + vm.prank(c.owner()); + vm.expectRevert(FlowYieldVaultsRequests.InvalidRecoveryUserAddress.selector); + c.recoverTokens(address(0), address(dai), 25 ether); + } + + function test_RecoverTokens_RevertInvalidRecoveryTokenAddress() public { + vm.prank(c.owner()); + vm.expectRevert(FlowYieldVaultsRequests.InvalidRecoveryTokenAddress.selector); + c.recoverTokens(user2, NATIVE_FLOW, 25 ether); + } + + function test_RecoverTokens_WithPendingUserBalanceAndNoExcessAmount() public { + vm.prank(c.owner()); + c.testRegisterYieldVaultId(101, user, address(dai)); + c.setTokenConfig(address(dai), true, 0.5 ether, false); + + deal(address(dai), user, 20 ether); + assertEq(dai.balanceOf(user), 20 ether); + assertEq(dai.balanceOf(address(c)), 0 ether); + + vm.startPrank(user); + dai.approve(address(c), 5 ether); + uint256 reqId = c.createYieldVault(address(dai), 5 ether, VAULT_ID, STRATEGY_ID); + assertEq(dai.balanceOf(user), 15 ether); + assertEq(dai.balanceOf(address(c)), 5 ether); + vm.stopPrank(); + + vm.prank(c.owner()); + vm.expectRevert(abi.encodeWithSelector( + FlowYieldVaultsRequests.InsufficientRecoveryAmount.selector, + 0 ether, + 5 ether + )); + c.recoverTokens(user, address(dai), 5 ether); + } + + function test_RecoverTokens_WithCancelledRequestAndNoExcessAmount() public { + vm.prank(c.owner()); + c.testRegisterYieldVaultId(101, user, address(dai)); + c.setTokenConfig(address(dai), true, 0.5 ether, false); + + deal(address(dai), user, 20 ether); + assertEq(dai.balanceOf(user), 20 ether); + assertEq(dai.balanceOf(address(c)), 0 ether); + + vm.startPrank(user); + dai.approve(address(c), 5 ether); + uint256 reqId = c.createYieldVault(address(dai), 5 ether, VAULT_ID, STRATEGY_ID); + assertEq(dai.balanceOf(user), 15 ether); + assertEq(dai.balanceOf(address(c)), 5 ether); + + c.cancelRequest(reqId); + vm.stopPrank(); + + vm.prank(c.owner()); + vm.expectRevert(abi.encodeWithSelector( + FlowYieldVaultsRequests.InsufficientRecoveryAmount.selector, + 0 ether, + 5 ether + )); + c.recoverTokens(user, address(dai), 5 ether); + } + + function test_RecoverTokens_WithClaimableRefundAndNoExcessAmount() public { + vm.prank(c.owner()); + c.testRegisterYieldVaultId(101, user, address(dai)); + c.setTokenConfig(address(dai), true, 0.5 ether, false); + + deal(address(dai), user, 20 ether); + assertEq(dai.balanceOf(user), 20 ether); + assertEq(dai.balanceOf(address(c)), 0 ether); + + vm.startPrank(user); + dai.approve(address(c), 5 ether); + uint256 reqId = c.createYieldVault(address(dai), 5 ether, VAULT_ID, STRATEGY_ID); + assertEq(dai.balanceOf(user), 15 ether); + assertEq(dai.balanceOf(address(c)), 5 ether); + vm.stopPrank(); + + vm.prank(coa); + _startProcessingBatch(reqId); + assertEq(dai.balanceOf(coa), 5 ether); + assertEq(dai.balanceOf(address(c)), 0 ether); + + vm.startPrank(coa); + dai.approve(address(c), 5 ether); + c.completeProcessing(reqId, false, c.NO_YIELDVAULT_ID(), "Failed"); + assertEq(dai.balanceOf(coa), 0 ether); + assertEq(dai.balanceOf(address(c)), 5 ether); + vm.stopPrank(); + + vm.prank(c.owner()); + vm.expectRevert(abi.encodeWithSelector( + FlowYieldVaultsRequests.InsufficientRecoveryAmount.selector, + 0 ether, + 5 ether + )); + c.recoverTokens(user, address(dai), 5 ether); + } + + function test_RecoverTokens_WithProcessingRequestAndExcessAmount() public { + vm.prank(c.owner()); + c.testRegisterYieldVaultId(101, user, address(dai)); + c.setTokenConfig(address(dai), true, 0.5 ether, false); + + deal(address(dai), user, 20 ether); + assertEq(dai.balanceOf(user), 20 ether); + assertEq(dai.balanceOf(address(c)), 0 ether); + + vm.startPrank(user); + dai.approve(address(c), 5 ether); + uint256 reqId = c.createYieldVault(address(dai), 5 ether, VAULT_ID, STRATEGY_ID); + assertEq(dai.balanceOf(user), 15 ether); + assertEq(dai.balanceOf(address(c)), 5 ether); + vm.stopPrank(); + + vm.prank(coa); + _startProcessingBatch(reqId); + assertEq(dai.balanceOf(coa), 5 ether); + assertEq(dai.balanceOf(address(c)), 0 ether); + + vm.prank(user); + dai.transfer(address(c), 3 ether); + + vm.prank(c.owner()); + c.recoverTokens(user, address(dai), 3 ether); + assertEq(dai.balanceOf(address(c)), 0 ether); + assertEq(dai.balanceOf(user), 15 ether); + } + + function test_RecoverTokens_RevertWhenAccountedExceedsAmount() public { + c.testRegisterYieldVaultId(101, user, address(dai)); + c.setTokenConfig(address(dai), true, 0.5 ether, false); + deal(address(dai), user, 20 ether); + vm.startPrank(user); + dai.approve(address(c), 5 ether); + c.createYieldVault(address(dai), 5 ether, VAULT_ID, STRATEGY_ID); + vm.stopPrank(); + + // contract has a balance of 5 DAI via the above createYieldVault, + // but there is no excess balance for the given token. + vm.prank(c.owner()); + vm.expectRevert(abi.encodeWithSelector( + FlowYieldVaultsRequests.InsufficientRecoveryAmount.selector, + 0, + 3 ether + )); + c.recoverTokens(user, address(dai), 3 ether); + } + + function test_RecoverTokens_WithAccountedLessThanAmount() public { + c.testRegisterYieldVaultId(101, user, address(dai)); + c.setTokenConfig(address(dai), true, 0.5 ether, false); + deal(address(dai), user, 20 ether); + vm.startPrank(user); + dai.approve(address(c), 5 ether); + c.createYieldVault(address(dai), 5 ether, VAULT_ID, STRATEGY_ID); + vm.stopPrank(); + + vm.prank(user); + dai.transfer(address(c), 8 ether); + assertEq(dai.balanceOf(address(c)), 13 ether); + + // contract has 5 ether in pendingUserBalances via createYieldVault + vm.prank(c.owner()); + c.recoverTokens(user, address(dai), 8 ether); + // user still has 5 ether in pendingUserBalances via createYieldVault, + // even after the stray token recovery + assertEq(c.getUserPendingBalance(user, address(dai)), 5 ether); + assertEq(dai.balanceOf(address(c)), 5 ether); + } + + function test_RecoverTokens_RevertWhenNotOwner() public { + deal(address(dai), user2, 5 ether); + vm.prank(user2); + dai.transfer(address(c), 5 ether); + + vm.prank(user2); // non-owner + vm.expectRevert(abi.encodeWithSelector( + OwnableUnauthorizedAccount.selector, + user2 + )); + c.recoverTokens(user2, address(dai), 5 ether); + } + + function test_RecoverTokens_RevertInvalidRecoveryUserAddress_ContractSelf() public { + vm.prank(c.owner()); + vm.expectRevert(FlowYieldVaultsRequests.InvalidRecoveryUserAddress.selector); + c.recoverTokens(address(c), address(dai), 1 ether); + } + + function test_RecoverTokens_RevertAmountIsZero() public { + vm.prank(c.owner()); + vm.expectRevert(FlowYieldVaultsRequests.AmountMustBeGreaterThanZero.selector); + c.recoverTokens(user2, address(dai), 0); + } }