|
| 1 | +// SPDX-License-Identifier: BUSL-1.1 |
| 2 | +pragma solidity ^0.8.0; |
| 3 | + |
| 4 | +import { AbstractSafeModule } from "./AbstractSafeModule.sol"; |
| 5 | + |
| 6 | +import { IVault } from "../interfaces/IVault.sol"; |
| 7 | +import { VaultStorage } from "../vault/VaultStorage.sol"; |
| 8 | + |
| 9 | +/** |
| 10 | + * @title Rebalancer Module |
| 11 | + * @notice A Gnosis Safe module that automates OUSD vault rebalancing by |
| 12 | + * withdrawing from overallocated strategies and depositing to |
| 13 | + * underallocated strategies. |
| 14 | + * |
| 15 | + * @dev The Safe (Guardian multisig) must: |
| 16 | + * 1. Deploy this module |
| 17 | + * 2. Call `safe.enableModule(address(this))` to authorize it |
| 18 | + * |
| 19 | + * An off-chain operator (e.g. Defender Action) calls |
| 20 | + * `processWithdrawalsAndDeposits` periodically with computed strategy/amount |
| 21 | + * arrays. Either array may be empty. All intelligence (APY fetching, target |
| 22 | + * allocation, constraint enforcement) lives off-chain. This contract is a |
| 23 | + * dumb executor. |
| 24 | + * |
| 25 | + * The function uses soft failures: if a single strategy call fails via the |
| 26 | + * Safe, the module emits an event and continues to the next strategy rather |
| 27 | + * than reverting the entire batch. |
| 28 | + * |
| 29 | + * The Safe retains full control via `setPaused`. |
| 30 | + */ |
| 31 | +contract RebalancerModule is AbstractSafeModule { |
| 32 | + // ───────────────────────────────────────────────────────── Immutables ── |
| 33 | + |
| 34 | + /// @notice The vault whose strategies are being rebalanced. |
| 35 | + IVault public immutable vault; |
| 36 | + |
| 37 | + /// @notice The vault's base asset (e.g. USDC for OUSD). |
| 38 | + address public immutable asset; |
| 39 | + |
| 40 | + // ────────────────────────────────────────────────────── Mutable config ── |
| 41 | + |
| 42 | + /// @notice When true, processWithdrawalsAndDeposits is blocked. |
| 43 | + bool public paused; |
| 44 | + |
| 45 | + /// @notice Strategies that this module is permitted to withdraw from or deposit into. |
| 46 | + mapping(address => bool) public isAllowedStrategy; |
| 47 | + |
| 48 | + /// @notice Cumulative amount moved (withdrawals + deposits) per calendar day. |
| 49 | + /// Day key = block.timestamp / 1 days (i.e. days since Unix epoch). |
| 50 | + mapping(uint256 => uint256) public amountMovedPerDay; |
| 51 | + |
| 52 | + /// @notice Max percentage of vault TVL that can be moved in a single day. |
| 53 | + /// In basis points (e.g. 20000 = 200%). |
| 54 | + uint256 public maxDailyMovementBps; |
| 55 | + |
| 56 | + // ─────────────────────────────────────────────────────────── Events ── |
| 57 | + |
| 58 | + /// @notice Emitted after processWithdrawals completes (even if some failed). |
| 59 | + event WithdrawalsProcessed( |
| 60 | + address[] strategies, |
| 61 | + uint256[] amounts, |
| 62 | + uint256 remainingShortfall |
| 63 | + ); |
| 64 | + |
| 65 | + /// @notice Emitted after processDeposits completes (even if some failed). |
| 66 | + event DepositsProcessed(address[] strategies, uint256[] amounts); |
| 67 | + |
| 68 | + /// @notice Emitted when a single withdrawFromStrategy call fails via the Safe. |
| 69 | + event WithdrawalFailed(address indexed strategy, uint256 attemptedAmount); |
| 70 | + |
| 71 | + /// @notice Emitted when a single depositToStrategy call fails via the Safe. |
| 72 | + event DepositFailed(address indexed strategy, uint256 attemptedAmount); |
| 73 | + |
| 74 | + /// @notice Emitted when the paused state changes. |
| 75 | + event PausedStateChanged(bool paused); |
| 76 | + |
| 77 | + /// @notice Emitted when a strategy is added to the whitelist. |
| 78 | + event StrategyAllowed(address indexed strategy); |
| 79 | + |
| 80 | + /// @notice Emitted when a strategy is removed from the whitelist. |
| 81 | + event StrategyRevoked(address indexed strategy); |
| 82 | + |
| 83 | + /// @notice Emitted when the daily movement limit is updated. |
| 84 | + event MaxDailyMovementBpsSet(uint256 maxDailyMovementBps); |
| 85 | + |
| 86 | + // ─────────────────────────────────────────────────────── Constructor ── |
| 87 | + |
| 88 | + /** |
| 89 | + * @param _safeContract Address of the Gnosis Safe (Guardian multisig). |
| 90 | + * @param _operator Address of the off-chain operator (e.g. Defender relayer). |
| 91 | + * @param _vault Address of the OUSD vault. |
| 92 | + */ |
| 93 | + constructor( |
| 94 | + address _safeContract, |
| 95 | + address _operator, |
| 96 | + address _vault |
| 97 | + ) AbstractSafeModule(_safeContract) { |
| 98 | + require(_vault != address(0), "Invalid vault"); |
| 99 | + |
| 100 | + vault = IVault(_vault); |
| 101 | + asset = IVault(_vault).asset(); |
| 102 | + maxDailyMovementBps = 20000; // 200% |
| 103 | + |
| 104 | + _grantRole(OPERATOR_ROLE, _operator); |
| 105 | + } |
| 106 | + |
| 107 | + // ──────────────────────────────────────────────────────── Modifiers ── |
| 108 | + |
| 109 | + modifier whenNotPaused() { |
| 110 | + require(!paused, "Module is paused"); |
| 111 | + _; |
| 112 | + } |
| 113 | + |
| 114 | + // ──────────────────────────────────────────────── Core automation ── |
| 115 | + |
| 116 | + // slither-disable-start reentrancy-no-eth |
| 117 | + /** |
| 118 | + * @notice Withdraw from overallocated strategies then deposit to underallocated |
| 119 | + * ones. Either array may be empty — the contract loops over zero entries |
| 120 | + * without reverting. |
| 121 | + * |
| 122 | + * @param _withdrawStrategies Strategies to withdraw from. |
| 123 | + * @param _withdrawAmounts Amounts to withdraw from each strategy. |
| 124 | + * @param _depositStrategies Strategies to deposit into. |
| 125 | + * @param _depositAmounts Amounts to deposit into each strategy. |
| 126 | + */ |
| 127 | + function processWithdrawalsAndDeposits( |
| 128 | + address[] calldata _withdrawStrategies, |
| 129 | + uint256[] calldata _withdrawAmounts, |
| 130 | + address[] calldata _depositStrategies, |
| 131 | + uint256[] calldata _depositAmounts |
| 132 | + ) external onlyOperator whenNotPaused { |
| 133 | + require( |
| 134 | + _withdrawStrategies.length == _withdrawAmounts.length, |
| 135 | + "Withdraw array length mismatch" |
| 136 | + ); |
| 137 | + require( |
| 138 | + _depositStrategies.length == _depositAmounts.length, |
| 139 | + "Deposit array length mismatch" |
| 140 | + ); |
| 141 | + // This is a permissionless call; no Safe exec needed. |
| 142 | + vault.addWithdrawalQueueLiquidity(); |
| 143 | + uint256 _limit = dailyLimit(); |
| 144 | + _executeWithdrawals(_withdrawStrategies, _withdrawAmounts, _limit); |
| 145 | + _executeDeposits(_depositStrategies, _depositAmounts, _limit); |
| 146 | + emit WithdrawalsProcessed( |
| 147 | + _withdrawStrategies, |
| 148 | + _withdrawAmounts, |
| 149 | + pendingShortfall() |
| 150 | + ); |
| 151 | + emit DepositsProcessed(_depositStrategies, _depositAmounts); |
| 152 | + } |
| 153 | + |
| 154 | + // ─────────────────────────────────────── Guardian controls ── |
| 155 | + |
| 156 | + /** |
| 157 | + * @notice Pause or unpause the module. |
| 158 | + * @param _paused True to pause, false to unpause. |
| 159 | + */ |
| 160 | + function setPaused(bool _paused) external onlySafe { |
| 161 | + paused = _paused; |
| 162 | + emit PausedStateChanged(_paused); |
| 163 | + } |
| 164 | + |
| 165 | + /** |
| 166 | + * @notice Add a strategy to the whitelist, allowing the operator to move |
| 167 | + * funds into or out of it. |
| 168 | + * @param _strategy Strategy address to allow. |
| 169 | + */ |
| 170 | + function allowStrategy(address _strategy) external onlySafe { |
| 171 | + require(_strategy != address(0), "Invalid strategy"); |
| 172 | + isAllowedStrategy[_strategy] = true; |
| 173 | + emit StrategyAllowed(_strategy); |
| 174 | + } |
| 175 | + |
| 176 | + /** |
| 177 | + * @notice Remove a strategy from the whitelist. |
| 178 | + * @param _strategy Strategy address to revoke. |
| 179 | + */ |
| 180 | + function revokeStrategy(address _strategy) external onlySafe { |
| 181 | + isAllowedStrategy[_strategy] = false; |
| 182 | + emit StrategyRevoked(_strategy); |
| 183 | + } |
| 184 | + |
| 185 | + /** |
| 186 | + * @notice Set the maximum percentage of vault TVL that can be moved per day. |
| 187 | + * @param _maxDailyMovementBps Limit in basis points (e.g. 20000 = 200%). |
| 188 | + * Set to 0 for unlimited daily movement. |
| 189 | + */ |
| 190 | + function setMaxDailyMovementBps(uint256 _maxDailyMovementBps) |
| 191 | + external |
| 192 | + onlySafe |
| 193 | + { |
| 194 | + maxDailyMovementBps = _maxDailyMovementBps; |
| 195 | + emit MaxDailyMovementBpsSet(_maxDailyMovementBps); |
| 196 | + } |
| 197 | + |
| 198 | + // ──────────────────────────────────────────────────────── View helpers ── |
| 199 | + |
| 200 | + /** |
| 201 | + * @notice The current unmet shortfall in the vault's withdrawal queue. |
| 202 | + * @dev This is a raw read of `queued - claimable`. It does NOT account for |
| 203 | + * idle vault asset that `addWithdrawalQueueLiquidity()` would absorb. |
| 204 | + * For a fully up-to-date figure, call `vault.addWithdrawalQueueLiquidity()` |
| 205 | + * first (which is what `processWithdrawals` does). |
| 206 | + * @return shortfall Queue shortfall in asset units (vault asset decimals). |
| 207 | + */ |
| 208 | + function pendingShortfall() public view returns (uint256 shortfall) { |
| 209 | + VaultStorage.WithdrawalQueueMetadata memory meta = vault |
| 210 | + .withdrawalQueueMetadata(); |
| 211 | + shortfall = meta.queued - meta.claimable; |
| 212 | + } |
| 213 | + |
| 214 | + /** |
| 215 | + * @notice The daily movement limit based on current vault TVL. |
| 216 | + * @dev vault.totalValue() includes AMO (Automated Market Operations) |
| 217 | + * value. Excluding AMO would add significant complexity for minimal |
| 218 | + * accuracy gain, so the limit is slightly more generous than intended. |
| 219 | + * Additionally, if the vault's TVL changes significantly mid-day (e.g. |
| 220 | + * large mint/redeem), the limit will reflect the TVL at call time — |
| 221 | + * this is acceptable since the limit is a safety backstop, not a |
| 222 | + * precise cap. |
| 223 | + * If maxDailyMovementBps is set to 0, this returns type(uint256).max |
| 224 | + * as a sentinel value to represent an unlimited cap. |
| 225 | + * @return limit Amount in asset units (vault asset decimals). |
| 226 | + */ |
| 227 | + function dailyLimit() public view returns (uint256 limit) { |
| 228 | + if (maxDailyMovementBps == 0) { |
| 229 | + return type(uint256).max; |
| 230 | + } |
| 231 | + limit = (vault.totalValue() * maxDailyMovementBps) / 10000; |
| 232 | + } |
| 233 | + |
| 234 | + /** |
| 235 | + * @notice The remaining amount that can be moved today before hitting the |
| 236 | + * daily movement limit. |
| 237 | + * @return remaining Amount in asset units (vault asset decimals). |
| 238 | + */ |
| 239 | + function remainingDailyLimit() public view returns (uint256 remaining) { |
| 240 | + uint256 limit = dailyLimit(); |
| 241 | + uint256 used = amountMovedPerDay[block.timestamp / 1 days]; |
| 242 | + remaining = used >= limit ? 0 : limit - used; |
| 243 | + } |
| 244 | + |
| 245 | + // ──────────────────────────────────────────────── Internal helpers ── |
| 246 | + |
| 247 | + /// @dev Track cumulative daily movement and revert if the limit is exceeded. |
| 248 | + function _trackMovement(uint256 _amount, uint256 _dailyLimit) internal { |
| 249 | + uint256 dayKey = block.timestamp / 1 days; |
| 250 | + amountMovedPerDay[dayKey] += _amount; |
| 251 | + |
| 252 | + require( |
| 253 | + amountMovedPerDay[dayKey] <= _dailyLimit, |
| 254 | + "Daily movement limit exceeded" |
| 255 | + ); |
| 256 | + } |
| 257 | + |
| 258 | + /// @dev Execute withdrawFromStrategy for each (strategy, amount) pair via the Safe. |
| 259 | + function _executeWithdrawals( |
| 260 | + address[] calldata _strategies, |
| 261 | + uint256[] calldata _amounts, |
| 262 | + uint256 _dailyLimit |
| 263 | + ) internal { |
| 264 | + address[] memory assets = _toAddressArray(asset); |
| 265 | + for (uint256 i = 0; i < _strategies.length; i++) { |
| 266 | + if (_amounts[i] == 0) continue; |
| 267 | + require(isAllowedStrategy[_strategies[i]], "Strategy not allowed"); |
| 268 | + bool success = safeContract.execTransactionFromModule( |
| 269 | + address(vault), |
| 270 | + 0, |
| 271 | + abi.encodeWithSelector( |
| 272 | + IVault.withdrawFromStrategy.selector, |
| 273 | + _strategies[i], |
| 274 | + assets, |
| 275 | + _toUint256Array(_amounts[i]) |
| 276 | + ), |
| 277 | + 0 |
| 278 | + ); |
| 279 | + if (success) { |
| 280 | + _trackMovement(_amounts[i], _dailyLimit); |
| 281 | + } else { |
| 282 | + emit WithdrawalFailed(_strategies[i], _amounts[i]); |
| 283 | + } |
| 284 | + } |
| 285 | + } |
| 286 | + |
| 287 | + /// @dev Execute depositToStrategy for each (strategy, amount) pair via the Safe. |
| 288 | + function _executeDeposits( |
| 289 | + address[] calldata _strategies, |
| 290 | + uint256[] calldata _amounts, |
| 291 | + uint256 _dailyLimit |
| 292 | + ) internal { |
| 293 | + address[] memory assets = _toAddressArray(asset); |
| 294 | + for (uint256 i = 0; i < _strategies.length; i++) { |
| 295 | + if (_amounts[i] == 0) continue; |
| 296 | + require(isAllowedStrategy[_strategies[i]], "Strategy not allowed"); |
| 297 | + bool success = safeContract.execTransactionFromModule( |
| 298 | + address(vault), |
| 299 | + 0, |
| 300 | + abi.encodeWithSelector( |
| 301 | + IVault.depositToStrategy.selector, |
| 302 | + _strategies[i], |
| 303 | + assets, |
| 304 | + _toUint256Array(_amounts[i]) |
| 305 | + ), |
| 306 | + 0 |
| 307 | + ); |
| 308 | + if (success) { |
| 309 | + _trackMovement(_amounts[i], _dailyLimit); |
| 310 | + } else { |
| 311 | + emit DepositFailed(_strategies[i], _amounts[i]); |
| 312 | + } |
| 313 | + } |
| 314 | + } |
| 315 | + |
| 316 | + // slither-disable-end reentrancy-no-eth |
| 317 | + |
| 318 | + function _toAddressArray(address _addr) |
| 319 | + internal |
| 320 | + pure |
| 321 | + returns (address[] memory arr) |
| 322 | + { |
| 323 | + arr = new address[](1); |
| 324 | + arr[0] = _addr; |
| 325 | + } |
| 326 | + |
| 327 | + function _toUint256Array(uint256 _val) |
| 328 | + internal |
| 329 | + pure |
| 330 | + returns (uint256[] memory arr) |
| 331 | + { |
| 332 | + arr = new uint256[](1); |
| 333 | + arr[0] = _val; |
| 334 | + } |
| 335 | +} |
0 commit comments