Skip to content

Automated Rebalancer#2835

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

Automated Rebalancer#2835
shahthepro wants to merge 40 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
> npx hardhat planRebalance --network mainnet

=== OUSD Rebalancer Status ===

Total rebalancable capital : 4,375,696.44 USDC
Withdrawal shortfall       : 0.00 USDC

--- Allocations ---

Strategy                        Current        Avail.         Target (rec.)   Delta  1h APY  Spot APY  Impact
-------------------------------------------------------------------------------------------------------------
Ethereum Morpho *  3,775,576.16 (86.3%)  1,685,960.71  3,775,576.16 (86.3%)   +0.00   4.47%     4.19%       —
Base Morpho          587,193.71 (13.4%)    336,733.85    587,193.71 (13.4%)   +0.00   4.35%     4.35%       —
HyperEVM Morpho        10,022.27 (0.2%)     10,022.52      10,022.27 (0.2%)   +0.00   4.82%     4.84%       —
Vault (idle)            2,904.30 (0.1%)             —       3,000.00 (0.1%)  +95.70       —         —       —
-------------------------------------------------------------------------------------------------------------
Total                      4,375,696.44
  * = default strategy

--- Actions for max APY ---

  DEPOSIT  $      487,098.01  to    Ethereum Morpho [Not recommended: post-impact spread -0.57% < min 0.50%]
  WITHDRAW $      587,193.71  from  Base Morpho [Not recommended: no approved deposits to fund]
  DEPOSIT  $      100,000.00  to    HyperEVM Morpho [Not recommended: post-impact spread 0.11% < min 0.50%]

--- Recommended Actions ---

  No actions required.

Pending Things

  • Monitoring: Discord Notifications Done in d50c610

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 10, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 51.64%. Comparing base (10b029e) to head (c9b9779).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2835      +/-   ##
==========================================
+ Coverage   49.21%   51.64%   +2.43%     
==========================================
  Files         112      113       +1     
  Lines        4844     4914      +70     
  Branches     1343     1361      +18     
==========================================
+ Hits         2384     2538     +154     
+ Misses       2456     2372      -84     
  Partials        4        4              

☔ 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

Copy link
Copy Markdown
Collaborator

@clement-ux clement-ux left a comment

Choose a reason for hiding this comment

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

A few comments before this lands.

rebalancerModule
.connect(stranger)
.allowStrategy("0x0000000000000000000000000000000000000099")
).to.be.revertedWith("Caller is not the Safe");
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.

I think this assertion is wrong. Actual revert string in AbstractSafeModule.sol:16 is "Caller is not the safe contract" (lowercase s, plus contract). With waffle's substring matching this isn't a match either way, so the test should be failing — unless I'm missing something. Same thing on line 455. Line 345 has the right string.

Copy link
Copy Markdown
Collaborator

@clement-ux clement-ux left a comment

Choose a reason for hiding this comment

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

Two defense-in-depth ideas to bound the blast radius if the off-chain script goes wrong (or the relayer key is compromised). Cheap to add now, much harder later.

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

* Consider APY impact for deposits

* Fix config issues

* Bug fixes

* Bug fix

* bug fix

* few more tweaks

* Switch to using subsquid server

* APY Impact solver

* Fix available liquidity

* Address CR comments
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.

4 participants