Skip to content

Commit 224f740

Browse files
committed
Proper calculation of available amount to be recovered
1 parent b64cdfa commit 224f740

2 files changed

Lines changed: 247 additions & 9 deletions

File tree

solidity/src/FlowYieldVaultsRequests.sol

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,9 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step {
167167
/// @notice Claimable refunds from cancelled/dropped/failed requests: user => token => amount
168168
mapping(address => mapping(address => uint256)) public claimableRefunds;
169169

170+
/// @notice Total balance of each token accounted for in contract (escrowed/claimable)
171+
mapping(address => uint256) public totalAccountedBalance;
172+
170173
/// @notice All requests indexed by request ID
171174
mapping(uint256 => Request) public requests;
172175

@@ -282,6 +285,15 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step {
282285
/// @notice No refund available for the specified token
283286
error NoRefundAvailable(address token);
284287

288+
/// @notice Recovery user address cannot be zero
289+
error InvalidRecoveryUserAddress();
290+
291+
/// @notice ERC20 token address cannot be the native $FLOW token
292+
error InvalidRecoveryTokenAddress();
293+
294+
/// @notice The requested recovery amount exceeds the available excess amount
295+
error InsufficientRecoveryAmount(uint256 available, uint256 requested);
296+
285297
// ============================================
286298
// Events
287299
// ============================================
@@ -538,11 +550,19 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step {
538550
address to,
539551
address tokenAddress,
540552
uint256 amount
541-
) external onlyOwner {
542-
IERC20(tokenAddress).safeTransfer(
543-
to,
544-
amount
545-
);
553+
) external onlyOwner nonReentrant {
554+
if (to == address(0) || to == address(this))
555+
revert InvalidRecoveryUserAddress();
556+
if (isNativeFlow(tokenAddress)) revert InvalidRecoveryTokenAddress();
557+
if (amount == 0) revert AmountMustBeGreaterThanZero();
558+
559+
uint256 contractBalance = IERC20(tokenAddress).balanceOf(address(this));
560+
uint256 excess = contractBalance - totalAccountedBalance[tokenAddress];
561+
if (amount > excess) {
562+
revert InsufficientRecoveryAmount(excess, amount);
563+
}
564+
565+
IERC20(tokenAddress).safeTransfer(to, amount);
546566
emit TokensRecovered(to, tokenAddress, amount);
547567
}
548568

@@ -1023,6 +1043,7 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step {
10231043
address(this),
10241044
request.amount
10251045
);
1046+
totalAccountedBalance[request.tokenAddress] += request.amount;
10261047
}
10271048
// Credit refunded funds to claimable refunds for later claim
10281049
claimableRefunds[request.user][request.tokenAddress] += request
@@ -1527,6 +1548,7 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step {
15271548
authorizedCOA,
15281549
request.amount
15291550
);
1551+
totalAccountedBalance[request.tokenAddress] -= request.amount;
15301552
}
15311553
emit FundsWithdrawn(
15321554
authorizedCOA,
@@ -1588,6 +1610,7 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step {
15881610
address(this),
15891611
amount
15901612
);
1613+
totalAccountedBalance[tokenAddress] += amount;
15911614
}
15921615
}
15931616

@@ -1647,6 +1670,7 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step {
16471670
} else {
16481671
// ERC20: use SafeERC20 for safe token transfer
16491672
IERC20(tokenAddress).safeTransfer(to, amount);
1673+
totalAccountedBalance[tokenAddress] -= amount;
16501674
}
16511675
}
16521676

solidity/test/FlowYieldVaultsRequests.t.sol

Lines changed: 218 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ pragma solidity 0.8.20;
33

44
import "forge-std/Test.sol";
55
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
6+
import "@openzeppelin/contracts/interfaces/draft-IERC6093.sol";
67
import "../src/FlowYieldVaultsRequests.sol";
78

89
contract MockDAI is ERC20 {
@@ -1542,6 +1543,11 @@ contract FlowYieldVaultsRequestsTest is Test {
15421543
vm.prank(coa);
15431544
vm.expectRevert(FlowYieldVaultsRequests.MsgValueMustBeZero.selector);
15441545
c.completeProcessing{value: 5 ether}(req2, false, 101, "Failed");
1546+
1547+
// Success case must also reject non-zero msg.value (the `else` branch)
1548+
vm.prank(coa);
1549+
vm.expectRevert(FlowYieldVaultsRequests.MsgValueMustBeZero.selector);
1550+
c.completeProcessing{value: 1 ether}(req2, true, 101, "Success");
15451551
}
15461552

15471553
function test_CompleteProcessing_RefundNativeFunds() public {
@@ -1580,9 +1586,6 @@ contract FlowYieldVaultsRequestsTest is Test {
15801586
assertEq(dai.balanceOf(user), 20 ether);
15811587
assertEq(dai.balanceOf(address(c)), 0 ether);
15821588

1583-
vm.prank(c.owner());
1584-
c.setTokenConfig(address(dai), true, 0.5 ether, false);
1585-
15861589
vm.startPrank(user);
15871590
dai.approve(address(c), 5 ether);
15881591
uint256 reqId = c.createYieldVault(address(dai), 5 ether, VAULT_ID, STRATEGY_ID);
@@ -1597,7 +1600,7 @@ contract FlowYieldVaultsRequestsTest is Test {
15971600

15981601
vm.startPrank(coa);
15991602
dai.approve(address(c), 5 ether);
1600-
c.completeProcessing(reqId, false, 101, "Failed");
1603+
c.completeProcessing(reqId, false, c.NO_YIELDVAULT_ID(), "Failed");
16011604
assertEq(dai.balanceOf(coa), 0 ether);
16021605
assertEq(dai.balanceOf(address(c)), 5 ether);
16031606
vm.stopPrank();
@@ -1627,4 +1630,215 @@ contract FlowYieldVaultsRequestsTest is Test {
16271630
assertEq(dai.balanceOf(address(c)), 0 ether);
16281631
vm.stopPrank();
16291632
}
1633+
1634+
function test_RecoverTokens_RevertInsufficientRecoveryAmount() public {
1635+
deal(address(dai), user2, 20 ether);
1636+
assertEq(dai.balanceOf(user2), 20 ether);
1637+
1638+
vm.startPrank(user2);
1639+
dai.transfer(address(c), 5 ether);
1640+
assertEq(dai.balanceOf(user2), 15 ether);
1641+
assertEq(dai.balanceOf(address(c)), 5 ether);
1642+
vm.stopPrank();
1643+
1644+
vm.prank(c.owner());
1645+
vm.expectRevert(abi.encodeWithSelector(
1646+
FlowYieldVaultsRequests.InsufficientRecoveryAmount.selector,
1647+
5 ether,
1648+
25 ether
1649+
));
1650+
c.recoverTokens(user2, address(dai), 25 ether);
1651+
}
1652+
1653+
function test_RecoverTokens_RevertInvalidRecoveryUserAddress() public {
1654+
vm.prank(c.owner());
1655+
vm.expectRevert(FlowYieldVaultsRequests.InvalidRecoveryUserAddress.selector);
1656+
c.recoverTokens(address(0), address(dai), 25 ether);
1657+
}
1658+
1659+
function test_RecoverTokens_RevertInvalidRecoveryTokenAddress() public {
1660+
vm.prank(c.owner());
1661+
vm.expectRevert(FlowYieldVaultsRequests.InvalidRecoveryTokenAddress.selector);
1662+
c.recoverTokens(user2, NATIVE_FLOW, 25 ether);
1663+
}
1664+
1665+
function test_RecoverTokens_WithPendingUserBalanceAndNoExcessAmount() public {
1666+
vm.prank(c.owner());
1667+
c.testRegisterYieldVaultId(101, user, address(dai));
1668+
c.setTokenConfig(address(dai), true, 0.5 ether, false);
1669+
1670+
deal(address(dai), user, 20 ether);
1671+
assertEq(dai.balanceOf(user), 20 ether);
1672+
assertEq(dai.balanceOf(address(c)), 0 ether);
1673+
1674+
vm.startPrank(user);
1675+
dai.approve(address(c), 5 ether);
1676+
uint256 reqId = c.createYieldVault(address(dai), 5 ether, VAULT_ID, STRATEGY_ID);
1677+
assertEq(dai.balanceOf(user), 15 ether);
1678+
assertEq(dai.balanceOf(address(c)), 5 ether);
1679+
vm.stopPrank();
1680+
1681+
vm.prank(c.owner());
1682+
vm.expectRevert(abi.encodeWithSelector(
1683+
FlowYieldVaultsRequests.InsufficientRecoveryAmount.selector,
1684+
0 ether,
1685+
5 ether
1686+
));
1687+
c.recoverTokens(user, address(dai), 5 ether);
1688+
}
1689+
1690+
function test_RecoverTokens_WithCancelledRequestAndNoExcessAmount() public {
1691+
vm.prank(c.owner());
1692+
c.testRegisterYieldVaultId(101, user, address(dai));
1693+
c.setTokenConfig(address(dai), true, 0.5 ether, false);
1694+
1695+
deal(address(dai), user, 20 ether);
1696+
assertEq(dai.balanceOf(user), 20 ether);
1697+
assertEq(dai.balanceOf(address(c)), 0 ether);
1698+
1699+
vm.startPrank(user);
1700+
dai.approve(address(c), 5 ether);
1701+
uint256 reqId = c.createYieldVault(address(dai), 5 ether, VAULT_ID, STRATEGY_ID);
1702+
assertEq(dai.balanceOf(user), 15 ether);
1703+
assertEq(dai.balanceOf(address(c)), 5 ether);
1704+
1705+
c.cancelRequest(reqId);
1706+
vm.stopPrank();
1707+
1708+
vm.prank(c.owner());
1709+
vm.expectRevert(abi.encodeWithSelector(
1710+
FlowYieldVaultsRequests.InsufficientRecoveryAmount.selector,
1711+
0 ether,
1712+
5 ether
1713+
));
1714+
c.recoverTokens(user, address(dai), 5 ether);
1715+
}
1716+
1717+
function test_RecoverTokens_WithClaimableRefundAndNoExcessAmount() public {
1718+
vm.prank(c.owner());
1719+
c.testRegisterYieldVaultId(101, user, address(dai));
1720+
c.setTokenConfig(address(dai), true, 0.5 ether, false);
1721+
1722+
deal(address(dai), user, 20 ether);
1723+
assertEq(dai.balanceOf(user), 20 ether);
1724+
assertEq(dai.balanceOf(address(c)), 0 ether);
1725+
1726+
vm.startPrank(user);
1727+
dai.approve(address(c), 5 ether);
1728+
uint256 reqId = c.createYieldVault(address(dai), 5 ether, VAULT_ID, STRATEGY_ID);
1729+
assertEq(dai.balanceOf(user), 15 ether);
1730+
assertEq(dai.balanceOf(address(c)), 5 ether);
1731+
vm.stopPrank();
1732+
1733+
vm.prank(coa);
1734+
_startProcessingBatch(reqId);
1735+
assertEq(dai.balanceOf(coa), 5 ether);
1736+
assertEq(dai.balanceOf(address(c)), 0 ether);
1737+
1738+
vm.startPrank(coa);
1739+
dai.approve(address(c), 5 ether);
1740+
c.completeProcessing(reqId, false, c.NO_YIELDVAULT_ID(), "Failed");
1741+
assertEq(dai.balanceOf(coa), 0 ether);
1742+
assertEq(dai.balanceOf(address(c)), 5 ether);
1743+
vm.stopPrank();
1744+
1745+
vm.prank(c.owner());
1746+
vm.expectRevert(abi.encodeWithSelector(
1747+
FlowYieldVaultsRequests.InsufficientRecoveryAmount.selector,
1748+
0 ether,
1749+
5 ether
1750+
));
1751+
c.recoverTokens(user, address(dai), 5 ether);
1752+
}
1753+
1754+
function test_RecoverTokens_WithProcessingRequestAndExcessAmount() public {
1755+
vm.prank(c.owner());
1756+
c.testRegisterYieldVaultId(101, user, address(dai));
1757+
c.setTokenConfig(address(dai), true, 0.5 ether, false);
1758+
1759+
deal(address(dai), user, 20 ether);
1760+
assertEq(dai.balanceOf(user), 20 ether);
1761+
assertEq(dai.balanceOf(address(c)), 0 ether);
1762+
1763+
vm.startPrank(user);
1764+
dai.approve(address(c), 5 ether);
1765+
uint256 reqId = c.createYieldVault(address(dai), 5 ether, VAULT_ID, STRATEGY_ID);
1766+
assertEq(dai.balanceOf(user), 15 ether);
1767+
assertEq(dai.balanceOf(address(c)), 5 ether);
1768+
vm.stopPrank();
1769+
1770+
vm.prank(coa);
1771+
_startProcessingBatch(reqId);
1772+
assertEq(dai.balanceOf(coa), 5 ether);
1773+
assertEq(dai.balanceOf(address(c)), 0 ether);
1774+
1775+
vm.prank(user);
1776+
dai.transfer(address(c), 3 ether);
1777+
1778+
vm.prank(c.owner());
1779+
c.recoverTokens(user, address(dai), 3 ether);
1780+
assertEq(dai.balanceOf(address(c)), 0 ether);
1781+
assertEq(dai.balanceOf(user), 15 ether);
1782+
}
1783+
1784+
function test_RecoverTokens_RevertWhenAccountedExceedsAmount() public {
1785+
c.testRegisterYieldVaultId(101, user, address(dai));
1786+
c.setTokenConfig(address(dai), true, 0.5 ether, false);
1787+
deal(address(dai), user, 20 ether);
1788+
vm.startPrank(user);
1789+
dai.approve(address(c), 5 ether);
1790+
c.createYieldVault(address(dai), 5 ether, VAULT_ID, STRATEGY_ID);
1791+
vm.stopPrank();
1792+
1793+
// contract has a balance of 5 DAI via the above createYieldVault,
1794+
// but there is no excess balance for the given token.
1795+
vm.prank(c.owner());
1796+
vm.expectRevert(abi.encodeWithSelector(
1797+
FlowYieldVaultsRequests.InsufficientRecoveryAmount.selector,
1798+
0,
1799+
3 ether
1800+
));
1801+
c.recoverTokens(user, address(dai), 3 ether);
1802+
}
1803+
1804+
function test_RecoverTokens_WithAccountedLessThanAmount() public {
1805+
c.testRegisterYieldVaultId(101, user, address(dai));
1806+
c.setTokenConfig(address(dai), true, 0.5 ether, false);
1807+
deal(address(dai), user, 20 ether);
1808+
vm.startPrank(user);
1809+
dai.approve(address(c), 5 ether);
1810+
c.createYieldVault(address(dai), 5 ether, VAULT_ID, STRATEGY_ID);
1811+
vm.stopPrank();
1812+
1813+
vm.prank(user);
1814+
dai.transfer(address(c), 8 ether);
1815+
assertEq(dai.balanceOf(address(c)), 13 ether);
1816+
1817+
// contract has 5 ether in pendingUserBalances via createYieldVault
1818+
vm.prank(c.owner());
1819+
c.recoverTokens(user, address(dai), 8 ether);
1820+
// user still has 5 ether in pendingUserBalances via createYieldVault,
1821+
// even after the stray token recovery
1822+
assertEq(c.getUserPendingBalance(user, address(dai)), 5 ether);
1823+
assertEq(dai.balanceOf(address(c)), 5 ether);
1824+
}
1825+
1826+
function test_RecoverTokens_RevertWhenNotOwner() public {
1827+
deal(address(dai), user2, 5 ether);
1828+
vm.prank(user2);
1829+
dai.transfer(address(c), 5 ether);
1830+
1831+
vm.prank(user2); // non-owner
1832+
vm.expectRevert(abi.encodeWithSelector(
1833+
OwnableUnauthorizedAccount.selector,
1834+
user2
1835+
));
1836+
c.recoverTokens(user2, address(dai), 5 ether);
1837+
}
1838+
1839+
function test_RecoverTokens_RevertInvalidRecoveryUserAddress_ContractSelf() public {
1840+
vm.prank(c.owner());
1841+
vm.expectRevert(FlowYieldVaultsRequests.InvalidRecoveryUserAddress.selector);
1842+
c.recoverTokens(address(c), address(dai), 1 ether);
1843+
}
16301844
}

0 commit comments

Comments
 (0)