-
Notifications
You must be signed in to change notification settings - Fork 105
Automated Rebalancer #2835
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Automated Rebalancer #2835
Changes from all commits
64a8c30
17c663b
38ebdb9
923944d
d50c610
3754149
024277b
d15d24e
1de24f3
c649822
614c9d8
6cd5f76
54bc1d1
1ddc3ab
55d8f2d
7c0bcde
e0fe58b
8c5073c
cfe03d4
b93ed30
cb54213
12e5859
55a63e3
b8c3299
4a0a253
59dbebc
e1d3a4d
f486511
33ddd41
e5c1493
aa34cce
5c1618f
86f7da3
475184c
d0a9ec4
d40897c
ce3541c
eca4974
1465591
c9b9779
a4f7718
358d2f3
bc30392
bf24aa9
4e6e456
a6329cf
5c56b1d
1940e2a
7ded512
d9b5d1d
7ba80e1
277842c
6db029d
3431ad7
0c5fc59
c7e9bd1
581b37f
06b42b3
e10b3ea
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,335 @@ | ||
| // SPDX-License-Identifier: BUSL-1.1 | ||
| pragma solidity ^0.8.0; | ||
|
|
||
| import { AbstractSafeModule } from "./AbstractSafeModule.sol"; | ||
|
|
||
| import { IVault } from "../interfaces/IVault.sol"; | ||
| import { VaultStorage } from "../vault/VaultStorage.sol"; | ||
|
|
||
| /** | ||
| * @title Rebalancer Module | ||
| * @notice A Gnosis Safe module that automates OUSD vault rebalancing by | ||
| * withdrawing from overallocated strategies and depositing to | ||
| * underallocated strategies. | ||
| * | ||
| * @dev The Safe (Guardian multisig) must: | ||
| * 1. Deploy this module | ||
| * 2. Call `safe.enableModule(address(this))` to authorize it | ||
| * | ||
| * An off-chain operator (e.g. Defender Action) calls | ||
| * `processWithdrawalsAndDeposits` periodically with computed strategy/amount | ||
| * arrays. Either array may be empty. All intelligence (APY fetching, target | ||
| * allocation, constraint enforcement) lives off-chain. This contract is a | ||
| * dumb executor. | ||
| * | ||
| * The function uses soft failures: if a single strategy call fails via the | ||
| * Safe, the module emits an event and continues to the next strategy rather | ||
| * than reverting the entire batch. | ||
| * | ||
| * The Safe retains full control via `setPaused`. | ||
| */ | ||
| contract RebalancerModule is AbstractSafeModule { | ||
| // ───────────────────────────────────────────────────────── Immutables ── | ||
|
|
||
| /// @notice The vault whose strategies are being rebalanced. | ||
| IVault public immutable vault; | ||
|
|
||
| /// @notice The vault's base asset (e.g. USDC for OUSD). | ||
| address public immutable asset; | ||
|
|
||
| // ────────────────────────────────────────────────────── Mutable config ── | ||
|
|
||
| /// @notice When true, processWithdrawalsAndDeposits is blocked. | ||
| bool public paused; | ||
|
|
||
| /// @notice Strategies that this module is permitted to withdraw from or deposit into. | ||
| mapping(address => bool) public isAllowedStrategy; | ||
|
|
||
| /// @notice Cumulative amount moved (withdrawals + deposits) per calendar day. | ||
| /// Day key = block.timestamp / 1 days (i.e. days since Unix epoch). | ||
| mapping(uint256 => uint256) public amountMovedPerDay; | ||
|
|
||
| /// @notice Max percentage of vault TVL that can be moved in a single day. | ||
| /// In basis points (e.g. 20000 = 200%). | ||
| uint256 public maxDailyMovementBps; | ||
|
|
||
| // ─────────────────────────────────────────────────────────── Events ── | ||
|
|
||
| /// @notice Emitted after processWithdrawals completes (even if some failed). | ||
| event WithdrawalsProcessed( | ||
| address[] strategies, | ||
| uint256[] amounts, | ||
| uint256 remainingShortfall | ||
| ); | ||
|
|
||
| /// @notice Emitted after processDeposits completes (even if some failed). | ||
| event DepositsProcessed(address[] strategies, uint256[] amounts); | ||
|
|
||
| /// @notice Emitted when a single withdrawFromStrategy call fails via the Safe. | ||
| event WithdrawalFailed(address indexed strategy, uint256 attemptedAmount); | ||
|
|
||
| /// @notice Emitted when a single depositToStrategy call fails via the Safe. | ||
| event DepositFailed(address indexed strategy, uint256 attemptedAmount); | ||
|
|
||
| /// @notice Emitted when the paused state changes. | ||
| event PausedStateChanged(bool paused); | ||
|
|
||
| /// @notice Emitted when a strategy is added to the whitelist. | ||
| event StrategyAllowed(address indexed strategy); | ||
|
|
||
| /// @notice Emitted when a strategy is removed from the whitelist. | ||
| event StrategyRevoked(address indexed strategy); | ||
|
|
||
| /// @notice Emitted when the daily movement limit is updated. | ||
| event MaxDailyMovementBpsSet(uint256 maxDailyMovementBps); | ||
|
|
||
| // ─────────────────────────────────────────────────────── Constructor ── | ||
|
|
||
| /** | ||
| * @param _safeContract Address of the Gnosis Safe (Guardian multisig). | ||
| * @param _operator Address of the off-chain operator (e.g. Defender relayer). | ||
| * @param _vault Address of the OUSD vault. | ||
| */ | ||
| constructor( | ||
| address _safeContract, | ||
| address _operator, | ||
| address _vault | ||
| ) AbstractSafeModule(_safeContract) { | ||
| require(_vault != address(0), "Invalid vault"); | ||
|
|
||
| vault = IVault(_vault); | ||
| asset = IVault(_vault).asset(); | ||
| maxDailyMovementBps = 20000; // 200% | ||
|
|
||
| _grantRole(OPERATOR_ROLE, _operator); | ||
| } | ||
|
|
||
| // ──────────────────────────────────────────────────────── Modifiers ── | ||
|
|
||
| modifier whenNotPaused() { | ||
| require(!paused, "Module is paused"); | ||
| _; | ||
| } | ||
|
|
||
| // ──────────────────────────────────────────────── Core automation ── | ||
|
|
||
| // slither-disable-start reentrancy-no-eth | ||
| /** | ||
| * @notice Withdraw from overallocated strategies then deposit to underallocated | ||
| * ones. Either array may be empty — the contract loops over zero entries | ||
| * without reverting. | ||
| * | ||
| * @param _withdrawStrategies Strategies to withdraw from. | ||
| * @param _withdrawAmounts Amounts to withdraw from each strategy. | ||
| * @param _depositStrategies Strategies to deposit into. | ||
| * @param _depositAmounts Amounts to deposit into each strategy. | ||
| */ | ||
| function processWithdrawalsAndDeposits( | ||
| address[] calldata _withdrawStrategies, | ||
| uint256[] calldata _withdrawAmounts, | ||
| address[] calldata _depositStrategies, | ||
| uint256[] calldata _depositAmounts | ||
| ) external onlyOperator whenNotPaused { | ||
| require( | ||
| _withdrawStrategies.length == _withdrawAmounts.length, | ||
| "Withdraw array length mismatch" | ||
| ); | ||
| require( | ||
| _depositStrategies.length == _depositAmounts.length, | ||
| "Deposit array length mismatch" | ||
| ); | ||
| // This is a permissionless call; no Safe exec needed. | ||
| vault.addWithdrawalQueueLiquidity(); | ||
|
clement-ux marked this conversation as resolved.
|
||
| uint256 _limit = dailyLimit(); | ||
| _executeWithdrawals(_withdrawStrategies, _withdrawAmounts, _limit); | ||
| _executeDeposits(_depositStrategies, _depositAmounts, _limit); | ||
| emit WithdrawalsProcessed( | ||
| _withdrawStrategies, | ||
| _withdrawAmounts, | ||
| pendingShortfall() | ||
| ); | ||
| emit DepositsProcessed(_depositStrategies, _depositAmounts); | ||
| } | ||
|
|
||
| // ─────────────────────────────────────── Guardian controls ── | ||
|
|
||
| /** | ||
| * @notice Pause or unpause the module. | ||
| * @param _paused True to pause, false to unpause. | ||
| */ | ||
| function setPaused(bool _paused) external onlySafe { | ||
| paused = _paused; | ||
| emit PausedStateChanged(_paused); | ||
| } | ||
|
|
||
| /** | ||
| * @notice Add a strategy to the whitelist, allowing the operator to move | ||
| * funds into or out of it. | ||
| * @param _strategy Strategy address to allow. | ||
| */ | ||
| function allowStrategy(address _strategy) external onlySafe { | ||
| require(_strategy != address(0), "Invalid strategy"); | ||
| isAllowedStrategy[_strategy] = true; | ||
| emit StrategyAllowed(_strategy); | ||
| } | ||
|
|
||
| /** | ||
| * @notice Remove a strategy from the whitelist. | ||
| * @param _strategy Strategy address to revoke. | ||
| */ | ||
| function revokeStrategy(address _strategy) external onlySafe { | ||
| isAllowedStrategy[_strategy] = false; | ||
| emit StrategyRevoked(_strategy); | ||
| } | ||
|
|
||
| /** | ||
| * @notice Set the maximum percentage of vault TVL that can be moved per day. | ||
| * @param _maxDailyMovementBps Limit in basis points (e.g. 20000 = 200%). | ||
| * Set to 0 for unlimited daily movement. | ||
| */ | ||
| function setMaxDailyMovementBps(uint256 _maxDailyMovementBps) | ||
| external | ||
| onlySafe | ||
| { | ||
| maxDailyMovementBps = _maxDailyMovementBps; | ||
| emit MaxDailyMovementBpsSet(_maxDailyMovementBps); | ||
| } | ||
|
|
||
| // ──────────────────────────────────────────────────────── View helpers ── | ||
|
|
||
| /** | ||
| * @notice The current unmet shortfall in the vault's withdrawal queue. | ||
| * @dev This is a raw read of `queued - claimable`. It does NOT account for | ||
| * idle vault asset that `addWithdrawalQueueLiquidity()` would absorb. | ||
| * For a fully up-to-date figure, call `vault.addWithdrawalQueueLiquidity()` | ||
| * first (which is what `processWithdrawals` does). | ||
| * @return shortfall Queue shortfall in asset units (vault asset decimals). | ||
| */ | ||
| function pendingShortfall() public view returns (uint256 shortfall) { | ||
| VaultStorage.WithdrawalQueueMetadata memory meta = vault | ||
| .withdrawalQueueMetadata(); | ||
| shortfall = meta.queued - meta.claimable; | ||
| } | ||
|
|
||
| /** | ||
| * @notice The daily movement limit based on current vault TVL. | ||
| * @dev vault.totalValue() includes AMO (Automated Market Operations) | ||
| * value. Excluding AMO would add significant complexity for minimal | ||
| * accuracy gain, so the limit is slightly more generous than intended. | ||
| * Additionally, if the vault's TVL changes significantly mid-day (e.g. | ||
| * large mint/redeem), the limit will reflect the TVL at call time — | ||
| * this is acceptable since the limit is a safety backstop, not a | ||
| * precise cap. | ||
| * If maxDailyMovementBps is set to 0, this returns type(uint256).max | ||
| * as a sentinel value to represent an unlimited cap. | ||
| * @return limit Amount in asset units (vault asset decimals). | ||
| */ | ||
| function dailyLimit() public view returns (uint256 limit) { | ||
| if (maxDailyMovementBps == 0) { | ||
| return type(uint256).max; | ||
| } | ||
| limit = (vault.totalValue() * maxDailyMovementBps) / 10000; | ||
| } | ||
|
|
||
| /** | ||
| * @notice The remaining amount that can be moved today before hitting the | ||
| * daily movement limit. | ||
| * @return remaining Amount in asset units (vault asset decimals). | ||
| */ | ||
| function remainingDailyLimit() public view returns (uint256 remaining) { | ||
| uint256 limit = dailyLimit(); | ||
| uint256 used = amountMovedPerDay[block.timestamp / 1 days]; | ||
| remaining = used >= limit ? 0 : limit - used; | ||
| } | ||
|
|
||
| // ──────────────────────────────────────────────── Internal helpers ── | ||
|
|
||
| /// @dev Track cumulative daily movement and revert if the limit is exceeded. | ||
| function _trackMovement(uint256 _amount, uint256 _dailyLimit) internal { | ||
| uint256 dayKey = block.timestamp / 1 days; | ||
| amountMovedPerDay[dayKey] += _amount; | ||
|
|
||
| require( | ||
| amountMovedPerDay[dayKey] <= _dailyLimit, | ||
| "Daily movement limit exceeded" | ||
| ); | ||
| } | ||
|
|
||
| /// @dev Execute withdrawFromStrategy for each (strategy, amount) pair via the Safe. | ||
| function _executeWithdrawals( | ||
| address[] calldata _strategies, | ||
| uint256[] calldata _amounts, | ||
| uint256 _dailyLimit | ||
| ) internal { | ||
| address[] memory assets = _toAddressArray(asset); | ||
| for (uint256 i = 0; i < _strategies.length; i++) { | ||
| if (_amounts[i] == 0) continue; | ||
| require(isAllowedStrategy[_strategies[i]], "Strategy not allowed"); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Worth adding a per-strategy max amount cap right next to this whitelist check. Something like: mapping(address => uint256) public maxAmountPerStrategy;
function setMaxAmountPerStrategy(address strategy, uint256 max) external onlySafe {
maxAmountPerStrategy[strategy] = max;
emit MaxAmountSet(strategy, max);
}
// here in both _executeWithdrawals and _executeDeposits:
require(_amounts[i] <= maxAmountPerStrategy[_strategies[i]], "Amount exceeds max");Rationale: today nothing on-chain stops the operator from passing a number 10x bigger than the planner intended, whether from a script bug or a compromised relayer. A per-strategy cap (set by the Safe at Same check works for both withdrawals and deposits, one cap per strategy is enough.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I do like the idea of having caps, to prevent a compromised stolen API key from doing too much damage. On the other hand I would prefer implementation where complexity is minimally increased and there is no maintenance overhead to caps (increasing / decreasing absolute configured values). What if we set a general per rebalancer cap where the relabalancer is limited in the % of the TVL that it is allowed to atomatically move in a day? Pseudocode:
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sharing some of comments from Discord yesterday. I do understand the idea of having a limit either at tx level or day level. But there're other things that can easily complicate in either approach. In both approaches,
If we are unsure about the Rebalancer logic in production, we can just have it post the recommended actions on Discord (there's already an action for it) and do it manually for the test week
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Having said that, if you guys still feel strongly about having these limits, I can put up a PR
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I see your reservations. I do like the idea of having additional sanity check, but not that it would impede the normal operations. @shahthepro do you think there is a setting that is sane and would never be an obstacle for the rebalancer? say 200% of the TVL?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I think that's far more better. We can start with that limit and try to change it depending on how things work
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @clement-ux @sparrowDom I have added the daily limit: c9b9779 |
||
| bool success = safeContract.execTransactionFromModule( | ||
| address(vault), | ||
| 0, | ||
| abi.encodeWithSelector( | ||
| IVault.withdrawFromStrategy.selector, | ||
| _strategies[i], | ||
| assets, | ||
| _toUint256Array(_amounts[i]) | ||
| ), | ||
| 0 | ||
| ); | ||
| if (success) { | ||
| _trackMovement(_amounts[i], _dailyLimit); | ||
| } else { | ||
| emit WithdrawalFailed(_strategies[i], _amounts[i]); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// @dev Execute depositToStrategy for each (strategy, amount) pair via the Safe. | ||
| function _executeDeposits( | ||
| address[] calldata _strategies, | ||
| uint256[] calldata _amounts, | ||
| uint256 _dailyLimit | ||
| ) internal { | ||
| address[] memory assets = _toAddressArray(asset); | ||
| for (uint256 i = 0; i < _strategies.length; i++) { | ||
| if (_amounts[i] == 0) continue; | ||
| require(isAllowedStrategy[_strategies[i]], "Strategy not allowed"); | ||
| bool success = safeContract.execTransactionFromModule( | ||
| address(vault), | ||
| 0, | ||
| abi.encodeWithSelector( | ||
| IVault.depositToStrategy.selector, | ||
| _strategies[i], | ||
| assets, | ||
| _toUint256Array(_amounts[i]) | ||
| ), | ||
| 0 | ||
| ); | ||
| if (success) { | ||
| _trackMovement(_amounts[i], _dailyLimit); | ||
| } else { | ||
| emit DepositFailed(_strategies[i], _amounts[i]); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // slither-disable-end reentrancy-no-eth | ||
|
|
||
| function _toAddressArray(address _addr) | ||
| internal | ||
| pure | ||
| returns (address[] memory arr) | ||
| { | ||
| arr = new address[](1); | ||
| arr[0] = _addr; | ||
| } | ||
|
|
||
| function _toUint256Array(uint256 _val) | ||
| internal | ||
| pure | ||
| returns (uint256[] memory arr) | ||
| { | ||
| arr = new uint256[](1); | ||
| arr[0] = _val; | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.