Skip to content

Automated Rebalancer#2835

Open
shahthepro wants to merge 30 commits intomasterfrom
shah/auto-rebalancer
Open

Automated Rebalancer#2835
shahthepro wants to merge 30 commits intomasterfrom
shah/auto-rebalancer

Conversation

@shahthepro
Copy link
Copy Markdown
Collaborator

@shahthepro shahthepro commented Mar 10, 2026

Code Changes

  • Add a new Safe Module RebalancerModule with methods to process deposits, withdrawals or both (in withdrawals -> deposits order). It will be replacing the existing AutoWithdrawalModule
  • Add a script for Rebalancer's core logic, Check readme file for the core logic
  • Add a hardhat tasks planRebalance that prints the optimal allocations and recommended actions
  • Add a Defender script that uses the Rebalancer to find recommended actions and execute it using the Module
  • Adds unit tests for the Module and the Rebalancer logic

Executing Hardhat task

$ npx hardhat planRebalance --network mainnet

=== OUSD Rebalancer Status ===

Total vault value    : 7,574,123.35 OUSD
  (excl. Curve AMO)  : 4,302,431.61 USDC
  Curve AMO balance  : 3,271,692.73 USDC
Withdrawal shortfall : 0.00 USDC

--- Allocations ---

Strategy                              Current       Target (optimal)              Delta      APY
------------------------------------------------------------------------------------------------
Ethereum Morpho *                1,935,732.45 (45.0%)           1,289,829.48 (30.0%)        -645,902.97    3.48%
Base Morpho                      2,327,877.52 (54.1%)           3,009,602.13 (70.0%)        +681,724.61    6.54%
Vault (idle)                        38,821.64 (0.9%)               3,000.00 (0.1%)         -35,821.64        —
------------------------------------------------------------------------------------------------
Total                            4,302,431.61
  * = default strategy

--- Actions for Optimal Allocation ---

  WITHDRAW $      645,902.97  from  Ethereum Morpho
  DEPOSIT  $      681,724.61  to    Base Morpho

--- Recommended Actions ---

  WITHDRAW $      645,902.97  from  Ethereum Morpho
  DEPOSIT  $      681,724.61  to    Base Morpho

Pending Things

  • Monitoring: Discord Notifications Done in d50c610

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 10, 2026

Codecov Report

❌ Patch coverage is 83.33333% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 42.23%. Comparing base (59773d0) to head (024277b).

Files with missing lines Patch % Lines
...ontracts/contracts/automation/RebalancerModule.sol 83.33% 9 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2835      +/-   ##
==========================================
- Coverage   48.07%   42.23%   -5.84%     
==========================================
  Files         111      112       +1     
  Lines        4826     4880      +54     
  Branches     1334     1348      +14     
==========================================
- Hits         2320     2061     -259     
- Misses       2502     2816     +314     
+ Partials        4        3       -1     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@shahthepro shahthepro changed the title [WIP] Automated Rebalancer Automated Rebalancer Mar 12, 2026
Copy link
Copy Markdown
Member

@sparrowDom sparrowDom left a comment

Choose a reason for hiding this comment

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

Not done yet left comments inline

Copy link
Copy Markdown
Collaborator

@naddison36 naddison36 left a comment

Choose a reason for hiding this comment

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

I think we need to whitelist what strategies the RebalancerModule can automatically deposit/withdraw to/from. I'm mostly worried about AMO strategies being accidentally being called without a vault value checker.

Copy link
Copy Markdown
Member

@sparrowDom sparrowDom left a comment

Choose a reason for hiding this comment

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

some more comments added

Copy link
Copy Markdown
Member

@sparrowDom sparrowDom left a comment

Choose a reason for hiding this comment

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

Left some comments inline: This code is much easier to follow now. Thanks it is a great improvement

];

// Return the action amount, capping cross-chain moves at the bridge limit
const actionAmount = (a) => {
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.

nit: maybe cappedAmount would be a better name

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.

Done: b8c3299

// All withdrawals are same-chain: freed USDC lands in the vault immediately,
// so withdrawals and deposits can be batched into a single transaction.
await executeTx(() =>
rebalancerModule.processWithdrawalsAndDeposits(
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.

nit: the on-chain contract could have just the processWithdrawalsAndDeposits function exposed. And have empty arrays passed when there would be no deposits or no withdrawals.

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.

That's a good point, the safe module already behaves that way when you call that processWithdrawalsAndDeposits method. Will drop the other two methods

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.

It simplified the code even further, thank you: 4a0a253

shortfall,
constraints
) {
const totalRebalancable = strategies.reduce(
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.

Should minDefaultStrategyBps be excluded from totalRebalancable?

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.

I believe that'll complicate the script further. If we are subtracting that here, we should also subtract it from the defaultStrategy's balance when we query it. Otherewise, if the default strategy has less than the amount we subtract here, it might end with us having a few more conditional statements (like subtracting it from other strategies). I'm not sure if that's gonna work

/**
* Compute total capital minus reserved amounts (shortfall + minVaultBalance).
*/
function _computeDeployableCapital(
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.

This code is much better readable from the last time. Great improvement 👍


const sorted = [...strategies]
.filter((s) => s.address !== defaultStrategy.address)
.sort((a, b) => (targets[b.address].gt(targets[a.address]) ? 1 : -1));
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.

So the sort mechanics here is to sort by the amount to be deposited. Would it make sense to sort by the APY strategy is earning instead?

Otherwise we might always deduct from the strategy having the largest amount deposited which might also be the one earning the highest APY.

Copy link
Copy Markdown
Collaborator Author

@shahthepro shahthepro Mar 24, 2026

Choose a reason for hiding this comment

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

I thought of doing it by APY at first. But then if we are allocating it by APY, the lower APY strategies will have less liquidity. So, it means that they may not have enough liquidity to fund from a single strategy. So, it'll involve multiple withdraws from lower APY strategy, which would only make it gas expensive

.filter((a) => a.action === ACTION_DEPOSIT)
.sort((a, b) => b.apy - a.apy);

for (const c of deposits) {
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.

nit: instead of c this could be named deposit

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.

}

const amt = c.delta.gt(budget) ? budget : c.delta;
budget = budget.sub(amt);
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.

Shouldn't this budget subtraction happen after the additional if statement checks below that result in a continue? Budget is reduced even when the action can be invalidated by setting it to ACTION_NONE

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.

Done: 12e5859

const hasApprovedWithdrawals = result.some(
(a) => a.action === ACTION_WITHDRAW
);
if (!hasApprovedWithdrawals && shortfall.gt(0)) {
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.

could it be that there are approved withdrawals that don't withdraw enough to cover the shortfall?

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.

Right now, the rebalancer cannot directly move between strategies. So, if it has to move between strategies, it has to withdraw to Vault first and then to the other strategy (either in a single run or across multiple runs). So, if there's a withdraw action, it'll always cover the withdrawal shortfall

Copy link
Copy Markdown
Collaborator

@naddison36 naddison36 left a comment

Choose a reason for hiding this comment

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

I think the biggest issue with the current approach is it doesn't take into account the impact of reallocating funds. The danger is it will reallocate based on the current net APY, after the reallocation the APYs change and the funds are reallocated back. We don't want to end up in a situation where funds are reallocated back and forth.

const ACTION_NONE = "none";

// Human-readable ABIs for contracts we interact with
const vaultAbi = [
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.

nit: this can be imported from an existing ABI file

const vaultAbi = require("../abi/vault.json");

"function withdrawalQueueMetadata() external view returns (tuple(uint128 queued, uint128 claimable, uint128 claimed, uint128 nextWithdrawalIndex))",
];

const strategyAbi = [
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.

nit: I think it would be cleaner if the cross chain strategy ABI was added to the ../abi/ folder. Then the following could be used

const strategyAbi = require("../abi/crossChainStrategy.json");

"function isTransferPending() external view returns (bool)",
];

const erc20Abi = [
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.

nit: this can be replaced with

const erc20Abi = require("../abi/erc20.json");

]);

// Reserve any available vault balance for pending withdrawals
let shortfall = queueMeta.queued.sub(queueMeta.claimable);
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.

this should be moved into its own util but no need to do as part of this PR.
I'll do it in a separate JS refactoring PR.
Leave it as is for now

state.strategies.filter((s) => s.morphoVaultAddress)
);

// Exclude strategies with suspiciously high APY
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.

a nice feature. It'd be interesting to see historical APYs to know how high they can get. A 50% APY seems very high, especially if its an average over 6 hours.
I expect a spike to 50% for some number of blocks would be possible.

Is the 50% used from gut feel?

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.

Yea. The config for everything is just rough values, not final ones. We probably have to sync with everyone to decide on those constraints before we deploy

}

/**
* Fetch APYs for multiple vaults in parallel.
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.

This is a critical function. Let's make it very clear what APY we are getting

 * Fetch a single vault's current net APY after fees from the Morpho GraphQL API.
 * The APY is a weighted average based on the liquidity allocated in each market.

| Field | Value | Meaning |
|-------|-------|---------|
| `minDefaultStrategyBps` | 2000 | Default strategy always gets ≥ 20 % of deployable capital |
| `maxPerStrategyBps` | 7000 | No single strategy gets > 70 % |
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.

70% seems too low given we only have two strategies at the moment.
If Rebalancer was running now, 700k would be moved from Ethereum earning 4.23% to Base earning 3.64%.

Even if we had 5 Morpho strategies, if one was earning 5% and all the others were only earning 1%, would we really want 30% allocated to the vaults earning 1%?

I'd say the maxPerStrategyBps constraint is dropped and minVaultBalance is used to ensure not everything is allocated to a single vault.
Alternatively, increase maxPerStrategyBps to 90% or even 95%.

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.

Yep. Like I mentioned in the other comment, the values in the config file aren't finalised. Can change it

naddison36 and others added 5 commits March 25, 2026 09:42
* Updated comments on what the Morpho APY is

* Made it clear the strategies are only for Morpho

* Fixed alignment of Allocations data in planBalance output
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants