Skip to content

Commit f96c1cb

Browse files
committed
Scalable usdc spot strategy
1 parent a28966f commit f96c1cb

File tree

3 files changed

+332
-153
lines changed

3 files changed

+332
-153
lines changed

spot-vaults/contracts/_utils/AlphaVaultHelpers.sol

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ library AlphaVaultHelpers {
4444
if (percToRemove <= 0) {
4545
return;
4646
}
47-
4847
int24 _fullLower = vault.fullLower();
4948
int24 _fullUpper = vault.fullUpper();
5049
int24 _baseLower = vault.baseLower();
@@ -61,8 +60,12 @@ library AlphaVaultHelpers {
6160
);
6261
// docs: https://learn.charm.fi/charm/technical-references/core/alphaprovault#emergencyburn
6362
// We remove the calculated percentage of base and full range liquidity.
64-
vault.emergencyBurn(_fullLower, _fullUpper, fullLiquidityToBurn);
65-
vault.emergencyBurn(_baseLower, _baseUpper, baseLiquidityToBurn);
63+
if (fullLiquidityToBurn > 0) {
64+
vault.emergencyBurn(_fullLower, _fullUpper, fullLiquidityToBurn);
65+
}
66+
if (baseLiquidityToBurn > 0) {
67+
vault.emergencyBurn(_baseLower, _baseUpper, baseLiquidityToBurn);
68+
}
6669
}
6770

6871
/// @dev A low-level method, which interacts directly with the vault and executes
Lines changed: 108 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,26 @@
11
// SPDX-License-Identifier: BUSL-1.1
22
pragma solidity ^0.8.24;
33

4+
import { Math } from "@openzeppelin/contracts/utils/math/Math.sol";
5+
import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol";
46
import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";
57
import { AlphaVaultHelpers } from "../_utils/AlphaVaultHelpers.sol";
8+
import { Range } from "../_interfaces/types/CommonTypes.sol";
69

710
import { IMetaOracle } from "../_interfaces/IMetaOracle.sol";
811
import { IAlphaProVault } from "../_interfaces/external/IAlphaProVault.sol";
912
import { IUniswapV3Pool } from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol";
1013

1114
/// @title UsdcSpotManager
1215
/// @notice This contract is a programmatic manager for the USDC-SPOT Charm AlphaProVault.
16+
/// @dev The vault provides highly concentrated liquidity when the market-price of SPOT is within
17+
/// the "active" zone as defined by the owner. When price moves outside the
18+
/// zone, the vault moves all assets into a full range position (infinite band).
1319
contract UsdcSpotManager is Ownable {
1420
//-------------------------------------------------------------------------
1521
// Libraries
1622
using AlphaVaultHelpers for IAlphaProVault;
23+
using Math for uint256;
1724

1825
//-------------------------------------------------------------------------
1926
// Constants & Immutables
@@ -22,6 +29,10 @@ contract UsdcSpotManager is Ownable {
2229
uint256 public constant DECIMALS = 18;
2330
uint256 public constant ONE = (10 ** DECIMALS);
2431

32+
/// @dev Vault parameter to set max full range weight (100%).
33+
uint24 public constant VAULT_MAX_FRW = (10 ** 6);
34+
int24 public constant POOL_MAX_TICK = 880000;
35+
2536
/// @notice The USDC-SPOT charm alpha vault.
2637
IAlphaProVault public immutable VAULT;
2738

@@ -37,6 +48,17 @@ contract UsdcSpotManager is Ownable {
3748
/// @notice The recorded deviation factor at the time of the last successful rebalance operation.
3849
uint256 public prevDeviation;
3950

51+
/// @notice The lower and upper deviation factor within which
52+
/// SPOT's price is considered to be in the active zone.
53+
Range public activeZoneDeviation;
54+
55+
/// @notice The width of concentrated liquidity band,
56+
/// SPOT's price is in the active zone.
57+
uint256 public concBandDeviationWidth;
58+
59+
/// @notice The maximum USDC balance of the vault's full range position.
60+
uint256 public fullRangeMaxUsdcBal;
61+
4062
//-----------------------------------------------------------------------------
4163
// Constructor and Initializer
4264

@@ -50,6 +72,12 @@ contract UsdcSpotManager is Ownable {
5072
updateOracle(oracle_);
5173

5274
prevDeviation = 0;
75+
activeZoneDeviation = Range({
76+
lower: ((ONE * 95) / 100), // 0.95 or 95%
77+
upper: ((ONE * 105) / 100) // 1.05 or 105%
78+
});
79+
concBandDeviationWidth = ((ONE * 5) / 100); // 0.05 or 5%
80+
fullRangeMaxUsdcBal = 250000 * (10 ** 6); // 250k USDC
5381
}
5482

5583
//--------------------------------------------------------------------------
@@ -62,18 +90,6 @@ contract UsdcSpotManager is Ownable {
6290
oracle = oracle_;
6391
}
6492

65-
/// @notice Updates the vault's liquidity range parameters.
66-
function setLiquidityRanges(
67-
int24 baseThreshold,
68-
uint24 fullRangeWeight,
69-
int24 limitThreshold
70-
) external onlyOwner {
71-
// Update liquidity parameters on the vault.
72-
VAULT.setBaseThreshold(baseThreshold);
73-
VAULT.setFullRangeWeight(fullRangeWeight);
74-
VAULT.setLimitThreshold(limitThreshold);
75-
}
76-
7793
/// @notice Forwards the given calldata to the vault.
7894
/// @param callData The calldata to pass to the vault.
7995
/// @return The data returned by the vault method call.
@@ -87,25 +103,43 @@ contract UsdcSpotManager is Ownable {
87103
return r;
88104
}
89105

106+
/// @notice Updates the active zone definition.
107+
function updateActiveZone(Range memory activeZoneDeviation_) external onlyOwner {
108+
activeZoneDeviation = activeZoneDeviation_;
109+
}
110+
111+
/// @notice Updates the width of the concentrated liquidity band.
112+
function updateConcentratedBand(uint256 concBandDeviationWidth_) external onlyOwner {
113+
concBandDeviationWidth = concBandDeviationWidth_;
114+
}
115+
116+
/// @notice Updates the amount of liquidity in the full range liquidity band.
117+
function updateFullRangeLiquidity(uint256 fullRangeMaxUsdcBal_) external onlyOwner {
118+
fullRangeMaxUsdcBal = fullRangeMaxUsdcBal_;
119+
}
120+
90121
//--------------------------------------------------------------------------
91122
// External write methods
92123

93124
/// @notice Executes vault rebalance.
94125
function rebalance() public {
95126
(uint256 deviation, bool deviationValid) = oracle.spotPriceDeviation();
127+
bool withinActiveZone = (deviationValid && activeZone(deviation));
128+
bool withinActiveZoneBefore = activeZone(prevDeviation);
129+
bool shouldForceRebalance = ((!withinActiveZone && withinActiveZoneBefore) ||
130+
(withinActiveZone && !withinActiveZoneBefore));
131+
132+
// Set liquidity parameters.
133+
withinActiveZone ? _setupActiveZoneLiq(deviation) : _resetLiq();
96134

97135
// Execute rebalance.
98136
// NOTE: the vault.rebalance() will revert if enough time has not elapsed.
99137
// We thus override with a force rebalance.
100138
// https://learn.charm.fi/charm/technical-references/core/alphaprovault#rebalance
101-
(deviationValid && shouldForceRebalance(deviation, prevDeviation))
102-
? VAULT.forceRebalance()
103-
: VAULT.rebalance();
139+
shouldForceRebalance ? VAULT.forceRebalance() : VAULT.rebalance();
104140

105141
// Trim positions after rebalance.
106-
if (!deviationValid || shouldRemoveLimitRange(deviation)) {
107-
VAULT.removeLimitLiquidity(POOL);
108-
}
142+
_trimLiquidity(withinActiveZone);
109143

110144
// Update valid rebalance state.
111145
if (deviationValid) {
@@ -116,25 +150,15 @@ contract UsdcSpotManager is Ownable {
116150
//-----------------------------------------------------------------------------
117151
// External/Public view methods
118152

119-
/// @notice Checks if a rebalance has to be forced.
120-
function shouldForceRebalance(
121-
uint256 deviation,
122-
uint256 prevDeviation_
123-
) public pure returns (bool) {
124-
// We rebalance if the deviation factor has crossed ONE (in either direction).
125-
return ((deviation <= ONE && prevDeviation_ > ONE) ||
126-
(deviation >= ONE && prevDeviation_ < ONE));
127-
}
128-
129-
/// @notice Checks if limit range liquidity needs to be removed.
130-
function shouldRemoveLimitRange(uint256 deviation) public view returns (bool) {
131-
// We only activate the limit range liquidity, when
132-
// the vault sells SPOT and deviation is above ONE, or when
133-
// the vault buys SPOT and deviation is below ONE
134-
bool extraSpot = isOverweightSpot();
135-
bool activeLimitRange = ((deviation >= ONE && extraSpot) ||
136-
(deviation <= ONE && !extraSpot));
137-
return (!activeLimitRange);
153+
/// @notice Based on the given deviation factor,
154+
/// calculates if the pool needs to be in the active zone.
155+
function activeZone(uint256 deviation) public view returns (bool) {
156+
Range memory concBandDeviation = Range({
157+
lower: (activeZoneDeviation.lower + concBandDeviationWidth / 2),
158+
upper: (activeZoneDeviation.upper - concBandDeviationWidth / 2)
159+
});
160+
return (concBandDeviation.lower <= deviation &&
161+
deviation <= concBandDeviation.upper);
138162
}
139163

140164
/// @notice Checks the vault is overweight SPOT and looking to sell the extra SPOT for USDC.
@@ -144,8 +168,55 @@ contract UsdcSpotManager is Ownable {
144168
return VAULT.isUnderweightToken0();
145169
}
146170

171+
/// @notice Calculates the Univ3 tick equivalent of the given deviation factor.
172+
function deviationToTicks(uint256 deviation) public pure returns (int24) {
173+
// 2% ~ 200 ticks -> (POOL.tickSpacing())
174+
// NOTE: width can't be zero, we set the minimum possible to 200.
175+
uint256 t = deviation.mulDiv(10000, ONE);
176+
t -= (t % 200);
177+
return (t >= 200 ? SafeCast.toInt24(SafeCast.toInt256(t)) : int24(200));
178+
}
179+
147180
/// @return Number of decimals representing 1.0.
148181
function decimals() external pure returns (uint8) {
149182
return uint8(DECIMALS);
150183
}
184+
185+
/// @dev Configures the vault to provide concentrated liquidity in the active zone.
186+
function _setupActiveZoneLiq(uint256 deviation) private {
187+
(uint256 usdcBal, ) = VAULT.getTotalAmounts();
188+
uint256 fullRangeWeight = uint256(VAULT_MAX_FRW).mulDiv(
189+
fullRangeMaxUsdcBal,
190+
usdcBal
191+
);
192+
uint256 limitBandDeviationWidth = isOverweightSpot()
193+
? activeZoneDeviation.upper - deviation
194+
: deviation - activeZoneDeviation.lower;
195+
VAULT.setFullRangeWeight(SafeCast.toUint24(fullRangeWeight));
196+
197+
// IMPORTANT: The deviation percentage, to ticks conversion isn't precise.
198+
// In the worst case, there might be liquidity up to 2% outside the active zone definition.
199+
VAULT.setBaseThreshold(deviationToTicks(concBandDeviationWidth));
200+
VAULT.setLimitThreshold(deviationToTicks(limitBandDeviationWidth));
201+
}
202+
203+
/// @dev Resets the vault to provide full range liquidity.
204+
function _resetLiq() private {
205+
VAULT.setFullRangeWeight(VAULT_MAX_FRW);
206+
VAULT.setBaseThreshold(POOL_MAX_TICK);
207+
VAULT.setLimitThreshold(POOL_MAX_TICK);
208+
}
209+
210+
/// @dev Removes excess liquidity from the vault after rebalance.
211+
function _trimLiquidity(bool withinActiveZone) private {
212+
// If we are in the active zone, we keep all the liquidity.
213+
if (withinActiveZone) {
214+
return;
215+
}
216+
// Otherwise, we trim positions.
217+
(uint256 usdcBal, ) = VAULT.getTotalAmounts();
218+
uint256 percToRemove = (ONE - ONE.mulDiv(fullRangeMaxUsdcBal, usdcBal));
219+
VAULT.trimLiquidity(POOL, percToRemove, ONE);
220+
VAULT.removeLimitLiquidity(POOL);
221+
}
151222
}

0 commit comments

Comments
 (0)