Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
64a8c30
Add core logic and safe module
shahthepro Mar 10, 2026
17c663b
Update core logic
shahthepro Mar 12, 2026
38ebdb9
Add defender action
shahthepro Mar 12, 2026
923944d
Rename methods, add readme file
shahthepro Mar 12, 2026
d50c610
Push discord notification and rollup config changes
shahthepro Mar 16, 2026
3754149
Fix AMO balance
shahthepro Mar 16, 2026
024277b
Merge branch 'master' into shah/auto-rebalancer
shahthepro Mar 18, 2026
d15d24e
Add whitelist to module and a few code changes
shahthepro Mar 18, 2026
1de24f3
tweaks to rebalancer
shahthepro Mar 18, 2026
c649822
Refactor and simplify rebalancer
shahthepro Mar 18, 2026
614c9d8
More refactor
shahthepro Mar 19, 2026
6cd5f76
Bug fixes
shahthepro Mar 19, 2026
54bc1d1
prettify
shahthepro Mar 19, 2026
1ddc3ab
Cleanup
shahthepro Mar 19, 2026
55d8f2d
prettify
shahthepro Mar 19, 2026
7c0bcde
Fix comment
shahthepro Mar 19, 2026
e0fe58b
Add suspicious APY threshold
shahthepro Mar 19, 2026
8c5073c
Merge branch 'master' into shah/auto-rebalancer
shahthepro Mar 19, 2026
cfe03d4
Fix string comparison
shahthepro Mar 20, 2026
b93ed30
Merge branch 'master' into shah/auto-rebalancer
shahthepro Mar 20, 2026
cb54213
Merge branch 'master' into shah/auto-rebalancer
shahthepro Mar 24, 2026
12e5859
simplify budget calc
shahthepro Mar 24, 2026
55a63e3
Rename local var
shahthepro Mar 24, 2026
b8c3299
Rename to cappedAmount
shahthepro Mar 24, 2026
4a0a253
simplify contract
shahthepro Mar 24, 2026
59dbebc
Nicka/auto rebalancer (#2855)
naddison36 Mar 25, 2026
e1d3a4d
Add hyperEVM to config
shahthepro Mar 25, 2026
f486511
HyperEVM API fixes
shahthepro Mar 26, 2026
33ddd41
Limit by available liquidity
shahthepro Mar 26, 2026
e5c1493
Rename optimal to ideal
shahthepro Mar 26, 2026
aa34cce
Merge branch 'master' into shah/auto-rebalancer
shahthepro Apr 6, 2026
5c1618f
Merge branch 'master' into shah/auto-rebalancer
shahthepro Apr 6, 2026
86f7da3
Rename dep file
shahthepro Apr 7, 2026
475184c
Add comment
shahthepro Apr 7, 2026
d0a9ec4
change hardcoded dry mode to ENV var
shahthepro Apr 7, 2026
d40897c
Fix module tests
shahthepro Apr 7, 2026
ce3541c
Consider APY impact for deposits (#2857)
shahthepro Apr 7, 2026
eca4974
Change column names and surface spot apy
shahthepro Apr 8, 2026
1465591
Merge branch 'master' into shah/auto-rebalancer
shahthepro Apr 8, 2026
c9b9779
Add daily limit to RebalancerModule
shahthepro Apr 8, 2026
a4f7718
Address CR comments
shahthepro Apr 9, 2026
358d2f3
Change 0 behaviour
shahthepro Apr 9, 2026
bc30392
slither
shahthepro Apr 9, 2026
bf24aa9
fix surplus bug and add more tests
shahthepro Apr 9, 2026
4e6e456
simplify code
shahthepro Apr 9, 2026
a6329cf
Switch to using morpho-utils
shahthepro Apr 11, 2026
5c56b1d
Add withdrawal impact rules
shahthepro Apr 12, 2026
1940e2a
Add more tests
shahthepro Apr 12, 2026
7ded512
Simplify printed allocations
shahthepro Apr 12, 2026
d9b5d1d
Bump morpho-utils version
shahthepro Apr 12, 2026
7ba80e1
skip invoking contract in dry mode
shahthepro Apr 12, 2026
277842c
Fix columns in printed message
shahthepro Apr 12, 2026
6db029d
Bug fixes
shahthepro Apr 12, 2026
3431ad7
Add more tests
shahthepro Apr 13, 2026
0c5fc59
add more tests
shahthepro Apr 13, 2026
c7e9bd1
clean up code
shahthepro Apr 13, 2026
581b37f
fix discord message
shahthepro Apr 13, 2026
06b42b3
Fix discord message
shahthepro Apr 13, 2026
e10b3ea
cleanup code
shahthepro Apr 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions contracts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,7 @@ pnpm hardhat setActionVars --id b1d831f1-29d4-4943-bb2e-8e625b76e82c
pnpm hardhat setActionVars --id 6567d7c6-7ec7-44bd-b95b-470dd1ff780b
pnpm hardhat setActionVars --id 6a633bb0-aff8-4b37-aaae-b4c6f244ed87
pnpm hardhat setActionVars --id 076c59e4-4150-42c7-9ba0-9962069ac353
pnpm hardhat setActionVars --id ca80b55c-f3f7-4e03-a2f5-ea444645f8d9
pnpm hardhat setActionVars --id f74f24b4-d98b-4181-89cc-6608369b6f91
pnpm hardhat setActionVars --id aa194c13-0dbf-49d2-8e87-70e61f3d71a8
pnpm hardhat setActionVars --id 65b53496-e426-4850-8349-059e63eb2120
Expand All @@ -374,6 +375,7 @@ pnpm hardhat updateAction --id b1d831f1-29d4-4943-bb2e-8e625b76e82c --file claim
pnpm hardhat updateAction --id 6567d7c6-7ec7-44bd-b95b-470dd1ff780b --file manageBribeOnSonic
pnpm hardhat updateAction --id 6a633bb0-aff8-4b37-aaae-b4c6f244ed87 --file managePassThrough
pnpm hardhat updateAction --id 076c59e4-4150-42c7-9ba0-9962069ac353 --file manageBribes
pnpm hardhat updateAction --id ca80b55c-f3f7-4e03-a2f5-ea444645f8d9 --file ousdRebalancer
pnpm hardhat updateAction --id f74f24b4-d98b-4181-89cc-6608369b6f91 --file updateVotemarketEpochs
pnpm hardhat updateAction --id aa194c13-0dbf-49d2-8e87-70e61f3d71a8 --file manageMerklBribes # Mainnet
pnpm hardhat updateAction --id 65b53496-e426-4850-8349-059e63eb2120 --file manageMerklBribes # Base
Expand Down
335 changes: 335 additions & 0 deletions contracts/contracts/automation/RebalancerModule.sol
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 {
Comment thread
clement-ux marked this conversation as resolved.
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();
Comment thread
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");
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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 allowStrategy time) caps the worst case at $X per call. Defaulting the value to 0 forces the Safe to set it explicitly — strategy is whitelisted but unusable until a cap is set, which is the safer fail mode.

Same check works for both withdrawals and deposits, one cap per strategy is enough.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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:

// This maps days to amount that has been moved in that day. Day can be just a normalized block number (block.number / 7200) - where 7200 is the amount of blocks per day.
mapping(uint256 => uint256) public amountMovedPerDay.
// amount of TVL allowed to rebalance per day e.g. 0.6 * 10e18
uint256 pctTVLAllowedToMove;

This is a somewhat simplified cap which sanity checks the rebalancer, so it is limited so that it can not re-locate more than 60% of the TVL daily 

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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 set the value too high, it essentially disables that protection (since rebalancer will always bypass it)
  • If we set it too low, it'll prevent rebalancer from running as it is intended to (ex: if HyperEVM is giving 20% APY, we want max allocation in that strategy, if we can deposit only 500k or x% of the TVL there because of these limitations, it kinda defeats the purpose of the rebalancer). We are also planning to run the rebalancer more frequently, so the limit we configure might get used as soon as it resets, it'll be essentially same as running a rebalancer only one or two times a day. If the rebalancer takes two or more days to move funds to a higher-APY strategy, we are missing out on yield. Not to mention any huge pending withdrawal can also get affected because of this.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Member

@sparrowDom sparrowDom Apr 8, 2026

Choose a reason for hiding this comment

The 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?
Ideally some value we never need to maintain and similar to Vault Value checker we have some peace of mind that there is an additional safe-guard where someone getting API keys to KMS can't just rebalance back and forth 40 times potentially draining user principal.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

say 200% of the TVL?

I think that's far more better. We can start with that limit and try to change it depending on how things work

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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;
}
}
Loading
Loading