Skip to content

Commit 0816ff3

Browse files
CheyenneAtapouravniculaeyan-manabsisDhairyaSethi
authored
feat: Max withdraw on amount exceeding supplied (#727)
Co-authored-by: Alexandru Niculae <[email protected]> Co-authored-by: YBM <[email protected]> Co-authored-by: Francesc Armengol Carrasco <[email protected]> Co-authored-by: DhairyaSethi <[email protected]>
1 parent e426872 commit 0816ff3

File tree

9 files changed

+101
-72
lines changed

9 files changed

+101
-72
lines changed

snapshots/Spoke.Operations.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"usingAsCollateral: 0 borrows, enable": "54024",
2323
"usingAsCollateral: 1 borrow, disable": "153486",
2424
"usingAsCollateral: 1 borrow, enable": "24786",
25-
"withdraw: 0 borrows, full": "133631",
26-
"withdraw: 0 borrows, partial": "135734",
27-
"withdraw: 1 borrow, partial": "252502"
25+
"withdraw: 0 borrows, full": "133493",
26+
"withdraw: 0 borrows, partial": "135596",
27+
"withdraw: 1 borrow, partial": "252364"
2828
}

src/contracts/Spoke.sol

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -218,14 +218,11 @@ contract Spoke is ISpoke, Multicall, AccessManaged, EIP712 {
218218
) external onlyPositionManager(onBehalfOf) {
219219
DataTypes.Reserve storage reserve = _reserves[reserveId];
220220
DataTypes.UserPosition storage userPosition = _userPositions[onBehalfOf][reserveId];
221+
_validateWithdraw(reserve);
221222
uint256 assetId = reserve.assetId;
222223
IHubBase hub = reserve.hub;
223224

224-
// If uint256.max is passed, withdraw all user's supplied assets
225-
if (amount == type(uint256).max) {
226-
amount = hub.previewRemoveByShares(assetId, userPosition.suppliedShares);
227-
}
228-
_validateWithdraw(reserve, userPosition, amount);
225+
amount = MathUtils.min(amount, hub.previewRemoveByShares(assetId, userPosition.suppliedShares));
229226

230227
uint256 withdrawnShares = hub.remove(assetId, amount, msg.sender);
231228

@@ -648,18 +645,9 @@ contract Spoke is ISpoke, Multicall, AccessManaged, EIP712 {
648645
require(!reserve.frozen, ReserveFrozen());
649646
}
650647

651-
function _validateWithdraw(
652-
DataTypes.Reserve storage reserve,
653-
DataTypes.UserPosition storage userPosition,
654-
uint256 amount
655-
) internal view {
648+
function _validateWithdraw(DataTypes.Reserve storage reserve) internal view {
656649
require(address(reserve.hub) != address(0), ReserveNotListed());
657650
require(!reserve.paused, ReservePaused());
658-
uint256 suppliedAmount = reserve.hub.previewRemoveByShares(
659-
reserve.assetId,
660-
userPosition.suppliedShares
661-
);
662-
require(amount <= suppliedAmount, InsufficientSupply(suppliedAmount));
663651
}
664652

665653
function _validateBorrow(DataTypes.Reserve storage reserve) internal view {

src/contracts/TreasurySpoke.sol

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ pragma solidity ^0.8.0;
55
import {Ownable} from 'src/dependencies/openzeppelin/Ownable.sol';
66
import {SafeERC20} from 'src/dependencies/openzeppelin/SafeERC20.sol';
77
import {IERC20} from 'src/dependencies/openzeppelin/IERC20.sol';
8-
import {IHub} from 'src/interfaces/IHub.sol';
8+
import {MathUtils} from 'src/libraries/math/MathUtils.sol';
9+
import {IHubBase} from 'src/interfaces/IHubBase.sol';
910
import {ITreasurySpoke, ISpokeBase} from 'src/interfaces/ITreasurySpoke.sol';
1011

1112
/**
@@ -19,7 +20,7 @@ contract TreasurySpoke is ITreasurySpoke, Ownable {
1920
using SafeERC20 for IERC20;
2021

2122
/// @inheritdoc ITreasurySpoke
22-
IHub public immutable HUB;
23+
IHubBase public immutable HUB;
2324

2425
/**
2526
* @dev Constructor
@@ -29,7 +30,7 @@ contract TreasurySpoke is ITreasurySpoke, Ownable {
2930
constructor(address owner_, address hub_) Ownable(owner_) {
3031
require(hub_ != address(0), InvalidAddress());
3132

32-
HUB = IHub(hub_);
33+
HUB = IHubBase(hub_);
3334
}
3435

3536
/// @inheritdoc ITreasurySpoke
@@ -39,11 +40,8 @@ contract TreasurySpoke is ITreasurySpoke, Ownable {
3940

4041
/// @inheritdoc ITreasurySpoke
4142
function withdraw(uint256 reserveId, uint256 amount, address) external onlyOwner {
42-
// If uint256.max is passed, withdraw all supplied assets
43-
if (amount == type(uint256).max) {
44-
amount = HUB.getSpokeAddedAssets(reserveId, address(this));
45-
}
46-
43+
// If amount to withdraw is greater than total supplied, withdraw all supplied assets
44+
amount = MathUtils.min(amount, HUB.getSpokeAddedAssets(reserveId, address(this)));
4745
HUB.remove(reserveId, amount, msg.sender);
4846
}
4947

src/interfaces/ISpoke.sol

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,6 @@ interface ISpoke is ISpokeBase, IMulticall, IAccessManaged {
105105
error AssetNotListed();
106106
error ReserveExists();
107107
error ReserveNotListed();
108-
error InsufficientSupply(uint256 supply);
109108
error ReserveNotBorrowable();
110109
error ReservePaused();
111110
error ReserveFrozen();

src/interfaces/ITreasurySpoke.sol

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// Copyright (c) 2025 Aave Labs
33
pragma solidity ^0.8.0;
44

5-
import {IHub} from 'src/interfaces/IHub.sol';
5+
import {IHubBase} from 'src/interfaces/IHubBase.sol';
66
import {ISpokeBase} from 'src/interfaces/ISpokeBase.sol';
77

88
/**
@@ -61,5 +61,5 @@ interface ITreasurySpoke is ISpokeBase {
6161
* @notice Returns the address of the associated Hub.
6262
* @return The address of the Hub.
6363
*/
64-
function HUB() external view returns (IHub);
64+
function HUB() external view returns (IHubBase);
6565
}

tests/Base.t.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1254,7 +1254,7 @@ abstract contract Base is Test {
12541254
return IERC20(underlying);
12551255
}
12561256

1257-
function getWithdrawalLimit(
1257+
function getTotalWithdrawable(
12581258
ISpoke spoke,
12591259
uint256 reserveId,
12601260
address user

tests/unit/Spoke/Spoke.Multicall.t.sol

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,11 +233,11 @@ contract SpokeMulticall is SpokeBase {
233233
function test_multicall_forwards_first_revert() public {
234234
bytes[] memory calls = new bytes[](3);
235235
calls[0] = abi.encodeCall(ISpokeBase.supply, (_daiReserveId(spoke1), 120e18, alice));
236-
calls[1] = abi.encodeCall(ISpokeBase.withdraw, (_daiReserveId(spoke1), 121e18, alice));
236+
calls[1] = abi.encodeCall(ISpokeBase.withdraw, (_daiReserveId(spoke1), 0, alice));
237237
calls[2] = abi.encodeCall(ISpoke.setUsingAsCollateral, (_daiReserveId(spoke1), true, alice));
238238

239239
vm.prank(alice);
240-
vm.expectRevert(abi.encodeWithSelector(ISpoke.InsufficientSupply.selector, 120e18));
240+
vm.expectRevert(IHub.InvalidAmount.selector);
241241
spoke1.multicall(calls);
242242
}
243243
}

tests/unit/Spoke/Spoke.Withdraw.Validation.t.sol

Lines changed: 21 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,15 @@ contract SpokeWithdrawValidationTest is SpokeBase {
2626
spoke1.withdraw(reserveId, amount, bob);
2727
}
2828

29-
function test_withdraw_revertsWith_InsufficientSupply_zero_supplied() public {
29+
/// @dev Test passes 1 as amount with no supplied assets.
30+
/// @dev The spoke contract changes the calling amount to the total user supplied, but since it's zero, it reverts.
31+
function test_withdraw_revertsWith_InvalidAmount_zero_supplied() public {
3032
uint256 reserveId = _daiReserveId(spoke1);
3133
uint256 amount = 1;
3234

3335
assertEq(spoke1.getUserSuppliedAssets(reserveId, alice), 0);
3436

35-
vm.expectRevert(abi.encodeWithSelector(ISpoke.InsufficientSupply.selector, 0));
37+
vm.expectRevert(IHub.InvalidAmount.selector);
3638
vm.prank(alice);
3739
spoke1.withdraw(reserveId, amount, alice);
3840
}
@@ -43,42 +45,11 @@ contract SpokeWithdrawValidationTest is SpokeBase {
4345

4446
assertEq(spoke1.getUserSuppliedAssets(reserveId, alice), 0);
4547

46-
vm.expectRevert(abi.encodeWithSelector(ISpoke.InsufficientSupply.selector, 0));
48+
vm.expectRevert(IHub.InvalidAmount.selector);
4749
vm.prank(alice);
4850
spoke1.withdraw(reserveId, amount, alice);
4951
}
5052

51-
// Withdraw reverts when there is not enough avaulable liquidity
52-
function test_withdraw_revertsWith_InsufficientSupply_with_supply() public {
53-
uint256 amount = 100e18;
54-
uint256 reserveId = _daiReserveId(spoke1);
55-
56-
// User spoke supply
57-
Utils.supply({
58-
spoke: spoke1,
59-
reserveId: reserveId,
60-
caller: alice,
61-
amount: amount,
62-
onBehalfOf: alice
63-
});
64-
65-
uint256 withdrawalLimit = getWithdrawalLimit(spoke1, reserveId, alice);
66-
assertGt(withdrawalLimit, 0);
67-
68-
vm.expectRevert(abi.encodeWithSelector(ISpoke.InsufficientSupply.selector, withdrawalLimit));
69-
vm.prank(alice);
70-
spoke1.withdraw(reserveId, withdrawalLimit + 1, alice);
71-
72-
// skip time but no index increase with no borrow
73-
skip(365 days);
74-
// withdrawal limit remains constant
75-
assertEq(withdrawalLimit, getWithdrawalLimit(spoke1, reserveId, alice));
76-
77-
vm.expectRevert(abi.encodeWithSelector(ISpoke.InsufficientSupply.selector, withdrawalLimit));
78-
vm.prank(alice);
79-
spoke1.withdraw(reserveId, withdrawalLimit + 1, alice);
80-
}
81-
8253
// Withdrawal limit increases due to debt interest, but still cannot withdraw more than available liquidity
8354
function test_withdraw_revertsWith_InsufficientSupply_with_debt() public {
8455
uint256 supplyAmount = 100e18;
@@ -103,18 +74,23 @@ contract SpokeWithdrawValidationTest is SpokeBase {
10374
onBehalfOf: alice
10475
});
10576

106-
vm.expectRevert(abi.encodeWithSelector(ISpoke.InsufficientSupply.selector, supplyAmount));
77+
vm.expectRevert(
78+
abi.encodeWithSelector(IHub.InsufficientLiquidity.selector, supplyAmount - borrowAmount)
79+
);
10780
vm.prank(alice);
10881
spoke1.withdraw({reserveId: reserveId, amount: supplyAmount + 1, onBehalfOf: alice});
10982

11083
// accrue interest
11184
skip(365 days);
11285

113-
uint256 newWithdrawalLimit = getWithdrawalLimit(spoke1, reserveId, alice);
86+
uint256 newWithdrawalLimit = getTotalWithdrawable(spoke1, reserveId, alice);
11487
// newWithdrawalLimit with accrued interest should be greater than supplyAmount
11588
assertGt(newWithdrawalLimit, supplyAmount);
11689

117-
vm.expectRevert(abi.encodeWithSelector(ISpoke.InsufficientSupply.selector, newWithdrawalLimit));
90+
// Interest added on both sides, so can ignore
91+
vm.expectRevert(
92+
abi.encodeWithSelector(IHub.InsufficientLiquidity.selector, supplyAmount - borrowAmount)
93+
);
11894
vm.prank(alice);
11995
spoke1.withdraw({reserveId: reserveId, amount: newWithdrawalLimit + 1, onBehalfOf: alice});
12096
}
@@ -152,18 +128,23 @@ contract SpokeWithdrawValidationTest is SpokeBase {
152128
onBehalfOf: alice
153129
});
154130

155-
vm.expectRevert(abi.encodeWithSelector(ISpoke.InsufficientSupply.selector, supplyAmount));
131+
vm.expectRevert(
132+
abi.encodeWithSelector(IHub.InsufficientLiquidity.selector, supplyAmount - borrowAmount)
133+
);
156134
vm.prank(alice);
157135
spoke1.withdraw({reserveId: reserveId, amount: supplyAmount + 1, onBehalfOf: alice});
158136

159137
// debt accrues
160138
skip(skipTime);
161139

162-
uint256 newWithdrawalLimit = getWithdrawalLimit(spoke1, reserveId, alice);
140+
uint256 newWithdrawalLimit = getTotalWithdrawable(spoke1, reserveId, alice);
163141
// newWithdrawalLimit with accrued interest should be greater than supplyAmount
164142
vm.assume(newWithdrawalLimit > supplyAmount);
165143

166-
vm.expectRevert(abi.encodeWithSelector(ISpoke.InsufficientSupply.selector, newWithdrawalLimit));
144+
// Interest added on both sides, so can ignore
145+
vm.expectRevert(
146+
abi.encodeWithSelector(IHub.InsufficientLiquidity.selector, supplyAmount - borrowAmount)
147+
);
167148
vm.prank(alice);
168149
spoke1.withdraw({reserveId: reserveId, amount: newWithdrawalLimit + 1, onBehalfOf: alice});
169150
}

tests/unit/Spoke/Spoke.Withdraw.t.sol

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,35 @@ contract SpokeWithdrawTest is SpokeBase {
168168
_checkSupplyRateIncreasing(addExRate, getAddExRate(daiAssetId), 'after withdraw');
169169
}
170170

171+
function test_withdraw_fuzz_all_greater_than_supplied(uint256 supplyAmount) public {
172+
supplyAmount = bound(supplyAmount, 1, MAX_SUPPLY_AMOUNT);
173+
Utils.supply({
174+
spoke: spoke1,
175+
reserveId: _daiReserveId(spoke1),
176+
caller: bob,
177+
amount: supplyAmount,
178+
onBehalfOf: bob
179+
});
180+
181+
_checkSuppliedAmounts(
182+
daiAssetId,
183+
_daiReserveId(spoke1),
184+
spoke1,
185+
bob,
186+
supplyAmount,
187+
'after supply'
188+
);
189+
190+
uint256 addExRate = getAddExRate(daiAssetId);
191+
192+
// Withdraw all supplied assets
193+
vm.prank(bob);
194+
spoke1.withdraw(_daiReserveId(spoke1), supplyAmount + 1, bob);
195+
196+
_checkSuppliedAmounts(daiAssetId, _daiReserveId(spoke1), spoke1, bob, 0, 'after withdraw');
197+
_checkSupplyRateIncreasing(addExRate, getAddExRate(daiAssetId), 'after withdraw');
198+
}
199+
171200
function test_withdraw_fuzz_all_with_interest(uint256 supplyAmount, uint256 borrowAmount) public {
172201
supplyAmount = bound(supplyAmount, 2, MAX_SUPPLY_AMOUNT);
173202
borrowAmount = bound(borrowAmount, 1, supplyAmount / 2);
@@ -838,4 +867,38 @@ contract SpokeWithdrawTest is SpokeBase {
838867
assertGe(hub1.convertToAddedAssets(daiAssetId, MAX_SUPPLY_AMOUNT), supplyExchangeRatio);
839868
assertGe(hub1.convertToDrawnAssets(daiAssetId, MAX_SUPPLY_AMOUNT), debtExchangeRatio);
840869
}
870+
871+
/// @dev Withdraw exceeding supplied amount withdraws everything
872+
function test_withdraw_max_greater_than_supplied() public {
873+
uint256 amount = 100e18;
874+
uint256 reserveId = _daiReserveId(spoke1);
875+
876+
// User spoke supply
877+
Utils.supply({
878+
spoke: spoke1,
879+
reserveId: reserveId,
880+
caller: alice,
881+
amount: amount,
882+
onBehalfOf: alice
883+
});
884+
885+
uint256 withdrawable = getTotalWithdrawable(spoke1, reserveId, alice);
886+
assertGt(withdrawable, 0);
887+
888+
uint256 addExRateBefore = getAddExRate(daiAssetId);
889+
890+
// skip time but no index increase with no borrow
891+
skip(365 days);
892+
// withdrawable remains constant
893+
assertEq(withdrawable, getTotalWithdrawable(spoke1, reserveId, alice));
894+
895+
vm.prank(alice);
896+
spoke1.withdraw(reserveId, withdrawable + 1, alice);
897+
898+
assertEq(getTotalWithdrawable(spoke1, reserveId, alice), 0);
899+
_checkSuppliedAmounts(daiAssetId, reserveId, spoke1, alice, 0, 'after withdraw');
900+
901+
// Check supply rate monotonically increasing after withdraw
902+
_checkSupplyRateIncreasing(addExRateBefore, getAddExRate(daiAssetId), 'after withdraw');
903+
}
841904
}

0 commit comments

Comments
 (0)