From 3ffa6fb76b5a1d0ebf39dc2dabd7687cbef4069f Mon Sep 17 00:00:00 2001 From: Shahul Hameed <10547529+shahthepro@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:20:17 +0400 Subject: [PATCH 01/10] Consider APY impact for deposits --- .../defender-actions/ousdRebalancer.js | 18 +- contracts/tasks/rebalancer.js | 10 +- contracts/test/utils/rebalancer.js | 124 +++--- contracts/utils/addresses.js | 10 + contracts/utils/morpho-apy.js | 407 ++++++++++++++++++ contracts/utils/rebalancer-config.js | 1 + contracts/utils/rebalancer.js | 161 +++++-- 7 files changed, 637 insertions(+), 94 deletions(-) create mode 100644 contracts/utils/morpho-apy.js diff --git a/contracts/scripts/defender-actions/ousdRebalancer.js b/contracts/scripts/defender-actions/ousdRebalancer.js index 8466708a3b..d365d70330 100644 --- a/contracts/scripts/defender-actions/ousdRebalancer.js +++ b/contracts/scripts/defender-actions/ousdRebalancer.js @@ -56,9 +56,14 @@ const buildDiscordMessage = ({ // Current allocations (from on-chain state) const currentLines = allActions.map((a) => { - return ` ${a.name.padEnd(20)} ${formatUSDC(a.balance).padStart(9)} ${( - a.apy * 100 - ).toFixed(2)}% APY`; + const apyStr = a.graphqlApy + ? `${(a.apy * 100).toFixed(2)}% APY (API: ${(a.graphqlApy * 100).toFixed( + 2 + )}%)` + : `${(a.apy * 100).toFixed(2)}% APY`; + return ` ${a.name.padEnd(20)} ${formatUSDC(a.balance).padStart( + 9 + )} ${apyStr}`; }); currentLines.push( ` ${"Vault idle".padEnd(20)} ${formatUSDC(state.vaultBalance).padStart(9)}` @@ -133,8 +138,13 @@ const handler = async (event) => { const webhookUrl = event.secrets?.DISCORD_WEBHOOK_URL; + // Build Base provider for cross-chain APY reads + const baseProvider = new ethers.providers.JsonRpcProvider( + event.secrets.BASE_PROVIDER_URL + ); + // Compute off-chain recommendations (also prints the allocation table to logs) - const plan = await buildRebalancePlan(provider); + const plan = await buildRebalancePlan({ 1: provider, 8453: baseProvider }); const { actions: allActions } = plan; const actions = allActions.filter((a) => a.action !== ACTION_NONE); diff --git a/contracts/tasks/rebalancer.js b/contracts/tasks/rebalancer.js index f86a7c9445..433f3750bf 100644 --- a/contracts/tasks/rebalancer.js +++ b/contracts/tasks/rebalancer.js @@ -1,9 +1,17 @@ +const { ethers } = require("ethers"); + const { getSigner } = require("../utils/signers"); const { buildRebalancePlan } = require("../utils/rebalancer"); async function rebalancerTask() { const signer = await getSigner(); - await buildRebalancePlan(signer); + const baseProvider = new ethers.providers.JsonRpcProvider( + process.env.BASE_PROVIDER_URL + ); + await buildRebalancePlan({ + 1: signer.provider || signer, + 8453: baseProvider, + }); } module.exports = { diff --git a/contracts/test/utils/rebalancer.js b/contracts/test/utils/rebalancer.js index 9305bfe1ed..f272f79612 100644 --- a/contracts/test/utils/rebalancer.js +++ b/contracts/test/utils/rebalancer.js @@ -270,7 +270,7 @@ describe("Rebalancer: buildExecutableActions", () => { // Standard filtering - it("should skip withdrawals below minMoveAmount", () => { + it("should skip withdrawals below minMoveAmount", async () => { const allocs = [ makeAllocation("Ethereum Morpho", 500100, 500000, 0.04, { isDefault: true, @@ -280,12 +280,12 @@ describe("Rebalancer: buildExecutableActions", () => { }), ]; // delta = -100 USDC < $5K minMoveAmount - const result = buildExecutableActions(allocs, ZERO, usdc(0)); + const result = await buildExecutableActions(allocs, ZERO, usdc(0)); expect(result[0].action).to.equal(ACTION_NONE); expect(result[0].reason).to.equal("below min move"); }); - it("should skip cross-chain moves below crossChainMinAmount", () => { + it("should skip cross-chain moves below crossChainMinAmount", async () => { const allocs = [ makeAllocation("Ethereum Morpho", 400000, 410000, 0.07, { isDefault: true, @@ -295,12 +295,12 @@ describe("Rebalancer: buildExecutableActions", () => { }), ]; // Base overallocated by 10K, which is < $25K crossChainMinAmount - const result = buildExecutableActions(allocs, ZERO, usdc(0)); + const result = await buildExecutableActions(allocs, ZERO, usdc(0)); expect(result[1].action).to.equal(ACTION_NONE); expect(result[1].reason).to.equal("below cross-chain min"); }); - it("should skip withdrawals where APY spread is too small", () => { + it("should skip withdrawals where APY spread is too small", async () => { // Both strategies at similar APY — not worth withdrawing from the lower one const allocs = [ makeAllocation("Ethereum Morpho", 700000, 500000, 0.05, { @@ -311,12 +311,12 @@ describe("Rebalancer: buildExecutableActions", () => { }), ]; // maxApy = 0.054, ETH apy = 0.05, spread = 0.004 < 0.005 minApySpread - const result = buildExecutableActions(allocs, ZERO, usdc(0)); + const result = await buildExecutableActions(allocs, ZERO, usdc(0)); expect(result[0].action).to.equal(ACTION_NONE); expect(result[0].reason).to.equal("APY spread too small"); }); - it("should allow withdrawal when APY spread is sufficient", () => { + it("should allow withdrawal when APY spread is sufficient", async () => { const allocs = [ makeAllocation("Ethereum Morpho", 700000, 500000, 0.03, { isDefault: true, @@ -326,12 +326,12 @@ describe("Rebalancer: buildExecutableActions", () => { }), ]; // spread = 0.03, > 0.005 threshold, delta = 200K > minMoveAmount - const result = buildExecutableActions(allocs, ZERO, usdc(0)); + const result = await buildExecutableActions(allocs, ZERO, usdc(0)); expect(result[0].action).to.equal(ACTION_WITHDRAW); expect(result[0].reason).to.be.undefined; }); - it("should approve cross-chain withdrawal when amount and APY spread are sufficient", () => { + it("should approve cross-chain withdrawal when amount and APY spread are sufficient", async () => { const allocs = [ makeAllocation("Ethereum Morpho", 300000, 500000, 0.07, { isDefault: true, @@ -342,13 +342,13 @@ describe("Rebalancer: buildExecutableActions", () => { ]; // Base overallocated by 200K ≥ crossChainMinAmount (25K) // spread = maxApy(0.07) - baseApy(0.03) = 0.04 > 0.005 - const result = buildExecutableActions(allocs, ZERO, usdc(0)); + const result = await buildExecutableActions(allocs, ZERO, usdc(0)); const baseRow = result.find((a) => a.isCrossChain); expect(baseRow.action).to.equal(ACTION_WITHDRAW); expect(baseRow.reason).to.be.undefined; }); - it("cross-chain withdraw below minMoveAmount hits minMove check first", () => { + it("cross-chain withdraw below minMoveAmount hits minMove check first", async () => { const allocs = [ makeAllocation("Ethereum Morpho", 500000, 500000, 0.07, { isDefault: true, @@ -358,13 +358,13 @@ describe("Rebalancer: buildExecutableActions", () => { }), ]; // Base overallocated by 3K — below minMoveAmount (5K), so minMove fires before crossChainMin - const result = buildExecutableActions(allocs, ZERO, usdc(0)); + const result = await buildExecutableActions(allocs, ZERO, usdc(0)); const baseRow = result.find((a) => a.isCrossChain); expect(baseRow.action).to.equal(ACTION_NONE); expect(baseRow.reason).to.equal("below min move"); }); - it("should skip cross-chain deposits when transfer is pending", () => { + it("should skip cross-chain deposits when transfer is pending", async () => { const allocs = [ makeAllocation("Ethereum Morpho", 700000, 500000, 0.04, { isDefault: true, @@ -374,12 +374,12 @@ describe("Rebalancer: buildExecutableActions", () => { isTransferPending: true, }), ]; - const result = buildExecutableActions(allocs, ZERO, usdc(0)); + const result = await buildExecutableActions(allocs, ZERO, usdc(0)); expect(result[1].action).to.equal(ACTION_NONE); expect(result[1].reason).to.equal("transfer pending"); }); - it("deposit blocked when budget is zero (no approved withdrawals, no vault surplus)", () => { + it("deposit blocked when budget is zero (no approved withdrawals, no vault surplus)", async () => { const allocs = [ makeAllocation("Ethereum Morpho", 500000, 500000, 0.05, { isDefault: true, @@ -389,13 +389,13 @@ describe("Rebalancer: buildExecutableActions", () => { }), ]; // ETH at target → no withdrawal; vaultBalance = 0 → surplus = 0 → depositBudget = 0 - const result = buildExecutableActions(allocs, ZERO, usdc(0)); + const result = await buildExecutableActions(allocs, ZERO, usdc(0)); const baseRow = result.find((a) => a.isCrossChain); expect(baseRow.action).to.equal(ACTION_NONE); expect(baseRow.reason).to.equal("insufficient vault funds"); }); - it("approved withdrawal fully funds the deposit (both sides approved)", () => { + it("approved withdrawal fully funds the deposit (both sides approved)", async () => { const allocs = [ makeAllocation("Ethereum Morpho", 700000, 500000, 0.03, { isDefault: true, @@ -405,7 +405,7 @@ describe("Rebalancer: buildExecutableActions", () => { }), ]; // ETH withdrawal 200K approved → budget 200K; Base deposit 200K fully funded - const result = buildExecutableActions(allocs, ZERO, usdc(0)); + const result = await buildExecutableActions(allocs, ZERO, usdc(0)); const ethRow = result.find((a) => a.isDefault); const baseRow = result.find((a) => a.isCrossChain); expect(ethRow.action).to.equal(ACTION_WITHDRAW); @@ -415,7 +415,7 @@ describe("Rebalancer: buildExecutableActions", () => { expect(baseRow.reason).to.be.undefined; }); - it("non-cross-chain deposit trimmed below minMoveAmount is discarded", () => { + it("non-cross-chain deposit trimmed below minMoveAmount is discarded", async () => { // Base withdrawal approved (200K) → budget 200K; ETH deposit delta 1K < minMoveAmount (5K) const allocs = [ makeAllocation("Ethereum Morpho", 499000, 500000, 0.07, { @@ -427,13 +427,13 @@ describe("Rebalancer: buildExecutableActions", () => { ]; // Base withdrawal: spread = 0.07 - 0.03 = 0.04 > 0.005, amount 200K > 25K → approved // ETH deposit: delta 1K → trimmed to min(1K, 200K) = 1K < minMoveAmount → discarded - const result = buildExecutableActions(allocs, ZERO, usdc(0)); + const result = await buildExecutableActions(allocs, ZERO, usdc(0)); const ethRow = result.find((a) => a.isDefault); expect(ethRow.action).to.equal(ACTION_NONE); expect(ethRow.reason).to.equal("below min move"); }); - it("higher-APY deposit is funded first when budget is scarce", () => { + it("higher-APY deposit is funded first when budget is scarce", async () => { // ETH at target; two non-cross-chain deposits; vault surplus = 60K covers only the first const allocs = [ makeAllocation("Ethereum Morpho", 500000, 500000, 0.05, { @@ -443,7 +443,7 @@ describe("Rebalancer: buildExecutableActions", () => { makeAllocation("Strategy Low", 400000, 500000, 0.03, {}), ]; // vaultBalance = 63K → surplus = 63K - 0 - 3K = 60K = depositBudget - const result = buildExecutableActions(allocs, ZERO, usdc(63000)); + const result = await buildExecutableActions(allocs, ZERO, usdc(63000)); const highRow = result.find((a) => a.name === "Strategy High"); const lowRow = result.find((a) => a.name === "Strategy Low"); // High APY (0.07) funded first, trimmed to 60K @@ -457,7 +457,7 @@ describe("Rebalancer: buildExecutableActions", () => { // Pass 1 applies the same rules to the default strategy too - it("overallocated default with no shortfall: normal minMoveAmount check applies", () => { + it("overallocated default with no shortfall: normal minMoveAmount check applies", async () => { const allocs = [ makeAllocation("Ethereum Morpho", 502000, 500000, 0.03, { isDefault: true, @@ -467,14 +467,14 @@ describe("Rebalancer: buildExecutableActions", () => { }), ]; // delta = 2K < minMoveAmount, no shortfall → filtered out in Pass 1 - const result = buildExecutableActions(allocs, ZERO, usdc(0)); + const result = await buildExecutableActions(allocs, ZERO, usdc(0)); expect(result[0].action).to.equal(ACTION_NONE); expect(result[0].reason).to.equal("below min move"); }); // Shortfall fallback (Pass 2) — only runs when no withdraw was approved in Pass 1 - it("fallback: overallocated default filtered by minMove + shortfall → uses max(delta, shortfall)", () => { + it("fallback: overallocated default filtered by minMove + shortfall → uses max(delta, shortfall)", async () => { // delta = -2K (filtered in Pass 1 for minMoveAmount), shortfall = 50K // Pass 2 sees default was overallocated and shortfall > delta → uses shortfall amount const allocs = [ @@ -485,14 +485,14 @@ describe("Rebalancer: buildExecutableActions", () => { isCrossChain: true, }), ]; - const result = buildExecutableActions(allocs, usdc(50000), usdc(0)); + const result = await buildExecutableActions(allocs, usdc(50000), usdc(0)); const defaultRow = result.find((a) => a.isDefault); expect(defaultRow.action).to.equal(ACTION_WITHDRAW); expect(defaultRow.delta.abs()).to.equal(usdc(50000)); expect(defaultRow.reason).to.include("fallback"); }); - it("fallback: overallocated default with delta > shortfall → uses delta amount", () => { + it("fallback: overallocated default with delta > shortfall → uses delta amount", async () => { // delta = -200K, shortfall = 50K → max(200K, 50K) = 200K // But Pass 1 filtered it due to APY spread too small const allocs = [ @@ -504,7 +504,7 @@ describe("Rebalancer: buildExecutableActions", () => { }), ]; // APY spread = 0.004 < 0.005 → filtered in Pass 1 - const result = buildExecutableActions(allocs, usdc(50000), usdc(0)); + const result = await buildExecutableActions(allocs, usdc(50000), usdc(0)); const defaultRow = result.find((a) => a.isDefault); expect(defaultRow.action).to.equal(ACTION_WITHDRAW); // max(200K overallocation, 50K shortfall) = 200K, capped at balance (700K) @@ -512,17 +512,17 @@ describe("Rebalancer: buildExecutableActions", () => { expect(defaultRow.reason).to.include("fallback"); }); - it("fallback: overallocated default withdrawal capped at balance", () => { + it("fallback: overallocated default withdrawal capped at balance", async () => { const allocs = [ makeAllocation("Ethereum Morpho", 30000, 0, 0.04, { isDefault: true }), ]; // shortfall (100K) > balance (30K) → cap at balance - const result = buildExecutableActions(allocs, usdc(100000), usdc(0)); + const result = await buildExecutableActions(allocs, usdc(100000), usdc(0)); const defaultRow = result.find((a) => a.isDefault); expect(defaultRow.delta.abs()).to.equal(usdc(30000)); }); - it("fallback: underallocated default + small shortfall → withdraws shortfall amount", () => { + it("fallback: underallocated default + small shortfall → withdraws shortfall amount", async () => { // APY says deposit more to ETH (underallocated), but shortfall < crossChainMinAmount // Pass 2 picks up: underallocated + small shortfall → withdraw min(balance, shortfall) // Base is overallocated by 10K which is < crossChainMinAmount (25K) → filtered in Pass 1 @@ -535,14 +535,14 @@ describe("Rebalancer: buildExecutableActions", () => { }), ]; // shortfall = 10K < 25K crossChainMinAmount; Base filtered (cross-chain min); no withdraw approved - const result = buildExecutableActions(allocs, usdc(10000), usdc(0)); + const result = await buildExecutableActions(allocs, usdc(10000), usdc(0)); const defaultRow = result.find((a) => a.isDefault); expect(defaultRow.action).to.equal(ACTION_WITHDRAW); expect(defaultRow.delta.abs()).to.equal(usdc(10000)); expect(defaultRow.reason).to.include("fallback"); }); - it("fallback: underallocated default + large shortfall + insufficient balance → skips", () => { + it("fallback: underallocated default + large shortfall + insufficient balance → skips", async () => { // Default at its target (action: none), shortfall large, balance (10K) < shortfall (100K) // shortfall (100K) >= crossChainMinAmount (25K) AND balance (10K) < shortfall → skip this round const allocs = [ @@ -550,13 +550,13 @@ describe("Rebalancer: buildExecutableActions", () => { isDefault: true, }), ]; - const result = buildExecutableActions(allocs, usdc(100000), usdc(0)); + const result = await buildExecutableActions(allocs, usdc(100000), usdc(0)); const defaultRow = result.find((a) => a.isDefault); // Default can't cover → fallback skips it. No cross-chain with sufficient balance either. expect(defaultRow.action).to.equal(ACTION_NONE); }); - it("fallback: withdraws shortfall from default when all withdrawals filtered in Pass 1", () => { + it("fallback: withdraws shortfall from default when all withdrawals filtered in Pass 1", async () => { const allocs = [ makeAllocation("Ethereum Morpho", 500000, 500000, 0.05, { isDefault: true, @@ -566,27 +566,27 @@ describe("Rebalancer: buildExecutableActions", () => { }), ]; // Both at target → action "none" from computeAllocation; shortfall exists - const result = buildExecutableActions(allocs, usdc(80000), usdc(0)); + const result = await buildExecutableActions(allocs, usdc(80000), usdc(0)); const defaultRow = result.find((a) => a.isDefault); expect(defaultRow.action).to.equal(ACTION_WITHDRAW); expect(defaultRow.reason).to.include("fallback"); expect(defaultRow.delta.abs()).to.equal(usdc(80000)); }); - it("fallback: withdraws from lowest-APY cross-chain when default has no balance", () => { + it("fallback: withdraws from lowest-APY cross-chain when default has no balance", async () => { const allocs = [ makeAllocation("Ethereum Morpho", 0, 0, 0.05, { isDefault: true }), makeAllocation("Base Morpho", 500000, 500000, 0.04, { isCrossChain: true, }), ]; - const result = buildExecutableActions(allocs, usdc(80000), usdc(0)); + const result = await buildExecutableActions(allocs, usdc(80000), usdc(0)); const crossChainRow = result.find((a) => a.isCrossChain); expect(crossChainRow.action).to.equal(ACTION_WITHDRAW); expect(crossChainRow.reason).to.include("fallback"); }); - it("shortfall fallback does not fire when a rebalancing withdrawal is already approved", () => { + it("shortfall fallback does not fire when a rebalancing withdrawal is already approved", async () => { const allocs = [ makeAllocation("Ethereum Morpho", 700000, 500000, 0.03, { isDefault: true, @@ -597,7 +597,7 @@ describe("Rebalancer: buildExecutableActions", () => { ]; // ETH withdrawal 200K approved in Pass A (spread ok); shortfall = 50K also exists // hasWithdraw = true → shortfall fallback must NOT fire - const result = buildExecutableActions(allocs, usdc(50000), usdc(0)); + const result = await buildExecutableActions(allocs, usdc(50000), usdc(0)); const ethRow = result.find((a) => a.isDefault); expect(ethRow.action).to.equal(ACTION_WITHDRAW); expect(ethRow.reason).to.be.undefined; // pure rebalancing, not a fallback @@ -606,7 +606,7 @@ describe("Rebalancer: buildExecutableActions", () => { expect(withdrawals).to.have.length(1); }); - it("shortfall fallback picks lowest-APY cross-chain when multiple are available", () => { + it("shortfall fallback picks lowest-APY cross-chain when multiple are available", async () => { const allocs = [ makeAllocation("Ethereum Morpho", 0, 0, 0.05, { isDefault: true }), makeAllocation("Base High APY", 500000, 500000, 0.06, { @@ -617,7 +617,7 @@ describe("Rebalancer: buildExecutableActions", () => { }), ]; // Default has no balance; both cross-chain have 500K > crossChainMinAmount (25K) - const result = buildExecutableActions(allocs, usdc(80000), usdc(0)); + const result = await buildExecutableActions(allocs, usdc(80000), usdc(0)); const highRow = result.find((a) => a.name === "Base High APY"); const lowRow = result.find((a) => a.name === "Base Low APY"); // Lowest APY (0.04) is selected for the fallback withdrawal @@ -629,7 +629,7 @@ describe("Rebalancer: buildExecutableActions", () => { // Fallback: no deposit actions but vault has surplus - it("fallback: deposits vault surplus to default when no deposit action qualified", () => { + it("fallback: deposits vault surplus to default when no deposit action qualified", async () => { const allocs = [ makeAllocation("Ethereum Morpho", 500000, 500000, 0.05, { isDefault: true, @@ -640,14 +640,14 @@ describe("Rebalancer: buildExecutableActions", () => { ]; // No actions, vault has 50K surplus beyond shortfall + minVaultBalance const surplus = usdc(50000 + 3000); // surplus above minVaultBalance - const result = buildExecutableActions(allocs, ZERO, surplus); + const result = await buildExecutableActions(allocs, ZERO, surplus); const defaultRow = result.find((a) => a.isDefault); expect(defaultRow.action).to.equal(ACTION_DEPOSIT); expect(defaultRow.reason).to.include("surplus fallback"); expect(defaultRow.delta).to.equal(usdc(50000)); // 50K surplus (3K is minVaultBalance) }); - it("surplus fallback does not fire when a deposit is already approved in Pass B", () => { + it("surplus fallback does not fire when a deposit is already approved in Pass B", async () => { const allocs = [ makeAllocation("Ethereum Morpho", 700000, 500000, 0.03, { isDefault: true, @@ -658,7 +658,7 @@ describe("Rebalancer: buildExecutableActions", () => { ]; // ETH withdrawal 200K + vault surplus 47K → budget 247K; Base deposit 200K approved in Pass B // hasDeposit = true → surplus fallback must NOT fire - const result = buildExecutableActions(allocs, ZERO, usdc(50000)); + const result = await buildExecutableActions(allocs, ZERO, usdc(50000)); const deposits = result.filter((a) => a.action === ACTION_DEPOSIT); expect(deposits).to.have.length(1); expect(deposits[0].name).to.equal("Base Morpho"); @@ -668,19 +668,19 @@ describe("Rebalancer: buildExecutableActions", () => { expect(surplusDeposit).to.be.undefined; }); - it("surplus fallback does not fire when vault balance is consumed by shortfall+minVault", () => { + it("surplus fallback does not fire when vault balance is consumed by shortfall+minVault", async () => { const allocs = [ makeAllocation("Ethereum Morpho", 500000, 500000, 0.05, { isDefault: true, }), ]; // vaultBalance = 2K, shortfall = 0 → surplus = 2K - 0 - 3K = -1K ≤ 0 → no fallback - const result = buildExecutableActions(allocs, ZERO, usdc(2000)); + const result = await buildExecutableActions(allocs, ZERO, usdc(2000)); const ethRow = result.find((a) => a.isDefault); expect(ethRow.action).to.equal(ACTION_NONE); }); - it("budget uses only net vault surplus after shortfall deduction", () => { + it("budget uses only net vault surplus after shortfall deduction", async () => { // ETH withdrawal 200K approved; vault = 60K but shortfall = 50K → net surplus = 7K // Budget = 200K + 7K = 207K; Base deposit 300K trimmed to 207K const allocs = [ @@ -691,7 +691,11 @@ describe("Rebalancer: buildExecutableActions", () => { isCrossChain: true, }), ]; - const result = buildExecutableActions(allocs, usdc(50000), usdc(60000)); + const result = await buildExecutableActions( + allocs, + usdc(50000), + usdc(60000) + ); const baseRow = result.find((a) => a.isCrossChain); expect(baseRow.action).to.equal(ACTION_DEPOSIT); // 200K (withdrawal) + 7K (net vault surplus) = 207K, not 200K + 57K = 257K @@ -701,7 +705,7 @@ describe("Rebalancer: buildExecutableActions", () => { // Budget reconciliation (Pass B) - it("deposit trimmed to vault surplus when withdraw is filtered by APY spread", () => { + it("deposit trimmed to vault surplus when withdraw is filtered by APY spread", async () => { // ETH overallocated by 200K but APY spread is 0.004 < 0.005 → filtered in Pass A // Base underallocated by 200K, wants deposit; only vault surplus (50K) is available const allocs = [ @@ -713,7 +717,7 @@ describe("Rebalancer: buildExecutableActions", () => { }), ]; // vaultBalance = 53K → surplus = 53K - 0 (shortfall) - 3K (minVaultBalance) = 50K - const result = buildExecutableActions(allocs, ZERO, usdc(53000)); + const result = await buildExecutableActions(allocs, ZERO, usdc(53000)); const baseRow = result.find((a) => a.isCrossChain); // ETH withdraw filtered → deposit budget = vaultSurplus = 50K expect(baseRow.action).to.equal(ACTION_DEPOSIT); @@ -726,7 +730,7 @@ describe("Rebalancer: buildExecutableActions", () => { // Excluded strategies (APY exceeds threshold) — frozen in place - it("excluded strategy passes through buildExecutableActions unchanged", () => { + it("excluded strategy passes through buildExecutableActions unchanged", async () => { const allocs = [ makeAllocation("Ethereum Morpho", 500000, 500000, 0.05, { isDefault: true, @@ -741,14 +745,14 @@ describe("Rebalancer: buildExecutableActions", () => { allocs[1].action = ACTION_NONE; allocs[1].reason = "APY exceeds threshold"; - const result = buildExecutableActions(allocs, ZERO, usdc(0)); + const result = await buildExecutableActions(allocs, ZERO, usdc(0)); const baseRow = result.find((a) => a.isCrossChain); expect(baseRow.action).to.equal(ACTION_NONE); expect(baseRow.reason).to.equal("APY exceeds threshold"); expect(baseRow.delta).to.equal(ZERO); }); - it("excluded strategy is not picked for shortfall fallback", () => { + it("excluded strategy is not picked for shortfall fallback", async () => { const allocs = [ makeAllocation("Ethereum Morpho", 0, 0, 0.05, { isDefault: true }), makeAllocation("Base Morpho", 500000, 500000, 0.6, { @@ -761,7 +765,7 @@ describe("Rebalancer: buildExecutableActions", () => { allocs[1].action = ACTION_NONE; allocs[1].reason = "APY exceeds threshold"; - const result = buildExecutableActions(allocs, usdc(80000), usdc(0)); + const result = await buildExecutableActions(allocs, usdc(80000), usdc(0)); const baseRow = result.find((a) => a.isCrossChain); // Base stays frozen — shortfall fallback cannot pick it because its delta is 0 // and it has no withdrawal action for the fallback to consider @@ -769,7 +773,7 @@ describe("Rebalancer: buildExecutableActions", () => { expect(baseRow.reason).to.equal("APY exceeds threshold"); }); - it("excluded default strategy does not receive surplus deposit fallback", () => { + it("excluded default strategy does not receive surplus deposit fallback", async () => { const allocs = [ makeAllocation("Ethereum Morpho", 500000, 500000, 0.6, { isDefault: true, @@ -785,13 +789,13 @@ describe("Rebalancer: buildExecutableActions", () => { allocs[0].reason = "APY exceeds threshold"; // Vault surplus exists but default is frozen — surplus fallback skips it - const result = buildExecutableActions(allocs, ZERO, usdc(53000)); + const result = await buildExecutableActions(allocs, ZERO, usdc(53000)); const ethRow = result.find((a) => a.isDefault); expect(ethRow.action).to.equal(ACTION_NONE); expect(ethRow.reason).to.equal("APY exceeds threshold"); }); - it("deposit discarded when trimmed amount falls below cross-chain min", () => { + it("deposit discarded when trimmed amount falls below cross-chain min", async () => { // Same setup but vault surplus = 10K < crossChainMinAmount (25K) const allocs = [ makeAllocation("Ethereum Morpho", 700000, 500000, 0.05, { @@ -802,7 +806,7 @@ describe("Rebalancer: buildExecutableActions", () => { }), ]; // vaultBalance = 13K → surplus = 10K < 25K → deposit to cross-chain discarded - const result = buildExecutableActions(allocs, ZERO, usdc(13000)); + const result = await buildExecutableActions(allocs, ZERO, usdc(13000)); const baseRow = result.find((a) => a.isCrossChain); expect(baseRow.action).to.equal(ACTION_NONE); expect(baseRow.reason).to.include("cross-chain min"); diff --git a/contracts/utils/addresses.js b/contracts/utils/addresses.js index 25493eddad..83230dcfb6 100644 --- a/contracts/utils/addresses.js +++ b/contracts/utils/addresses.js @@ -227,6 +227,12 @@ addresses.mainnet.MorphoOUSDv2Adapter = addresses.mainnet.MorphoOUSDv2Vault = "0xFB154c729A16802c4ad1E8f7FF539a8b9f49c960"; +// Morpho Blue singleton (same address on mainnet and Base) +addresses.mainnet.MorphoBlue = "0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb"; +// Morpho Adaptive Curve IRM +addresses.mainnet.MorphoAdaptiveCurveIRM = + "0x870aC11D48B15DB9a138Cf899d20F13F79Ba00BC"; + addresses.mainnet.UniswapOracle = "0xc15169Bad17e676b3BaDb699DEe327423cE6178e"; addresses.mainnet.CompensationClaims = "0x9C94df9d594BA1eb94430C006c269C314B1A8281"; @@ -396,6 +402,10 @@ addresses.arbitrumOne.WOETHProxy = "0xD8724322f44E5c58D7A815F542036fb17DbbF839"; addresses.arbitrumOne.admin = "0xfD1383fb4eE74ED9D83F2cbC67507bA6Eac2896a"; // Base +addresses.base.MorphoBlue = "0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb"; +addresses.base.MorphoAdaptiveCurveIRM = + "0x46415998764C29aB2a25CbeA6254146D50D22687"; + addresses.base.HarvesterProxy = "0x247872f58f2fF11f9E8f89C1C48e460CfF0c6b29"; addresses.base.BridgedWOETH = "0xD8724322f44E5c58D7A815F542036fb17DbbF839"; addresses.base.AERO = "0x940181a94A35A4569E4529A3CDfB74e38FD98631"; diff --git a/contracts/utils/morpho-apy.js b/contracts/utils/morpho-apy.js new file mode 100644 index 0000000000..81e1fb11e2 --- /dev/null +++ b/contracts/utils/morpho-apy.js @@ -0,0 +1,407 @@ +const { ethers, BigNumber } = require("ethers"); + +const addresses = require("./addresses"); +const log = require("./logger")("utils:morpho-apy"); + +// ─── Constants (Adaptive Curve IRM) ────────────────────────────────────────── + +const SECONDS_PER_YEAR = 31_536_000; +const WAD = 1e18; +const TARGET_UTILIZATION = 0.9; +const CURVE_STEEPNESS = 4; + +// ─── Minimal ABIs ──────────────────────────────────────────────────────────── + +const morphoBlueAbi = [ + // market(bytes32) → (totalSupplyAssets, totalSupplyShares, totalBorrowAssets, totalBorrowShares, lastUpdate, fee) + "function market(bytes32 id) external view returns (uint128, uint128, uint128, uint128, uint128, uint128)", + // position(bytes32, address) → (supplyShares, borrowShares, collateral) + "function position(bytes32 id, address user) external view returns (uint256, uint128, uint128)", + // idToMarketParams(bytes32) → (loanToken, collateralToken, oracle, irm, lltv) + "function idToMarketParams(bytes32 id) external view returns (address, address, address, address, uint256)", +]; + +const irmAbi = [ + "function rateAtTarget(bytes32 id) external view returns (int256)", +]; + +const metaMorphoAbi = [ + "function supplyQueue(uint256 index) external view returns (bytes32)", + "function supplyQueueLength() external view returns (uint256)", + // config(bytes32) → (cap, enabled, removableAt) + "function config(bytes32 id) external view returns (uint184, bool, uint64)", +]; + +const erc20Abi = ["function decimals() external view returns (uint8)"]; + +// ─── Address helpers ───────────────────────────────────────────────────────── + +function _morphoBlueAddress(chainId) { + if (chainId === 1) return addresses.mainnet.MorphoBlue; + if (chainId === 8453) return addresses.base.MorphoBlue; + throw new Error(`No MorphoBlue address for chainId ${chainId}`); +} + +// ─── Pure IRM math (ported from Python gist) ───────────────────────────────── +// +// All math uses Number (64-bit double), identical to Python's float. +// On-chain BigNumber values are normalized to whole-token units at the boundary +// so utilization ratios are safe regardless of token decimals. + +/** + * Distance from target utilization, normalized to [-1, 1]. + * @param {number} util - Utilization ratio in [0, 1] + */ +function calculateError(util) { + if (util > TARGET_UTILIZATION) { + return (util - TARGET_UTILIZATION) / (1 - TARGET_UTILIZATION); + } + return (util - TARGET_UTILIZATION) / TARGET_UTILIZATION; +} + +/** + * IRM rate multiplier at a given utilization. + * Below target: linear from 1/STEEPNESS to 1. + * Above target: linear from 1 to STEEPNESS. + * @param {number} util - Utilization ratio in [0, 1] + */ +function calculateCurve(util) { + const error = calculateError(util); + if (util <= TARGET_UTILIZATION) { + return (1 - 1 / CURVE_STEEPNESS) * error + 1; + } + return (CURVE_STEEPNESS - 1) * error + 1; +} + +/** + * Estimate borrow and supply APY for a single Morpho market. + * + * All amounts are in normalized (whole-token) units — the caller divides + * raw on-chain values by 10^decimals before passing them here. + * + * @param {number} depositAmount Whole-token amount to add to supply (0 for current state) + * @param {number} totalSupply Current total supply (whole tokens) + * @param {number} totalBorrows Current total borrows (whole tokens) + * @param {number} fee Fee in WAD (raw Number from on-chain) + * @param {number} rateAtTarget On-chain rateAtTarget (WAD, per-second) + * @returns {{ borrowApy: number, supplyApy: number }} + */ +function estimateMarketApy( + depositAmount, + totalSupply, + totalBorrows, + fee, + rateAtTarget +) { + const newSupply = totalSupply + depositAmount; + if (newSupply <= 0) return { borrowApy: 0, supplyApy: 0 }; + + let utilization = totalBorrows / newSupply; + utilization = Math.min(utilization, 0.9999); + if (utilization < 0.0001) utilization = 0; + + const ratePerSecond = rateAtTarget / WAD; + const curve = calculateCurve(utilization); + const borrowRate = ratePerSecond * curve; + let borrowApy = Math.exp(borrowRate * SECONDS_PER_YEAR) - 1; + + const feePct = fee ? fee / WAD : 0; + let supplyApy = borrowApy * utilization * (1 - feePct); + + // Clamp to sane range + borrowApy = Math.max(0, Math.min(borrowApy, 8)); + supplyApy = Math.max(0, Math.min(supplyApy, 8)); + + return { borrowApy, supplyApy }; +} + +// ─── On-chain reading ──────────────────────────────────────────────────────── + +/** + * Fetch the vault's supply queue markets with their on-chain data. + * + * For each market in the vault's supplyQueue, reads: + * - The vault's supply position (supplyShares → supplyAssets via totalSupply/totalShares) + * - The market's total supply/borrow/fee + * - The IRM's rateAtTarget (read from the market's own IRM, not a hardcoded address) + * - The vault's cap for that market + * + * @param {object} provider + * @param {number} chainId + * @param {string} vaultAddress MetaMorpho vault address + * @returns {Promise>} + */ +async function fetchVaultMarkets(provider, chainId, vaultAddress) { + const vault = new ethers.Contract(vaultAddress, metaMorphoAbi, provider); + const morpho = new ethers.Contract( + _morphoBlueAddress(chainId), + morphoBlueAbi, + provider + ); + + // Get supply queue length, then read all market IDs + const queueLength = await vault.supplyQueueLength(); + const len = queueLength.toNumber(); + console.log( + `[morpho-apy] supplyQueueLength=${len} for vault ${vaultAddress}` + ); + + if (len === 0) return []; + + // Read all market IDs in the supply queue + const marketIds = await Promise.all( + Array.from({ length: len }, (_, i) => vault.supplyQueue(i)) + ); + + // For each market, fetch data in parallel + const markets = await Promise.all( + marketIds.map(async (marketId) => { + // First fetch: config, position, market state, and market params + const [config, position, marketState, params] = await Promise.all([ + vault.config(marketId), + morpho.position(marketId, vaultAddress), + morpho.market(marketId), + morpho.idToMarketParams(marketId), + ]); + + const cap = BigNumber.from(config[0]); + const loanToken = params[0]; + const marketIrm = params[3]; // IRM address for this specific market + + // Compute vault's supply assets from its supply shares + // supplyAssets = supplyShares * totalSupplyAssets / totalSupplyShares + const vaultSupplyShares = BigNumber.from(position[0]); + const totalSupplyAssets = BigNumber.from(marketState[0]); + const totalSupplyShares = BigNumber.from(marketState[1]); + const totalBorrowAssets = BigNumber.from(marketState[2]); + const fee = BigNumber.from(marketState[5]); + + let vaultSupplyAssets = BigNumber.from(0); + if (!totalSupplyShares.isZero()) { + vaultSupplyAssets = vaultSupplyShares + .mul(totalSupplyAssets) + .div(totalSupplyShares); + } + + // Fetch rateAtTarget from the market's own IRM (not a hardcoded address) + let rateAtTarget = BigNumber.from(0); + if (marketIrm !== ethers.constants.AddressZero) { + try { + const marketIrmContract = new ethers.Contract( + marketIrm, + irmAbi, + provider + ); + rateAtTarget = BigNumber.from( + await marketIrmContract.rateAtTarget(marketId) + ); + } catch (err) { + console.error( + `[morpho-apy] rateAtTarget failed for market ${marketId} ` + + `(IRM: ${marketIrm}): ${err.message}` + ); + } + } + + const erc20 = new ethers.Contract(loanToken, erc20Abi, provider); + const decimals = await erc20.decimals(); + + console.log( + `[morpho-apy] Market ${marketId.slice(0, 10)}… ` + + `supply=${totalSupplyAssets} borrow=${totalBorrowAssets} ` + + `fee=${fee} rate=${rateAtTarget} ` + + `vaultAlloc=${vaultSupplyAssets}` + ); + + return { + marketId, + cap, + vaultSupplyAssets, + totalSupplyAssets, + totalBorrowAssets, + fee, + rateAtTarget, + decimals, + }; + }) + ); + + return markets; +} + +/** + * Compute the current weighted supply APY of a MetaMorpho vault. + * + * Vault APY = Σ(marketSupplyAPY_i × weight_i) / Σ(weight_i) + * + * Weight is the vault's allocation in each market when available. + * For V1 vaults with 0 allocation (funds migrated to V2), falls back to + * total market supply as weight — the market-level APY is still valid for + * any depositor. + * + * @param {object} provider + * @param {number} chainId + * @param {string} vaultAddress + * @returns {Promise} APY as a decimal (0.05 = 5%) + */ +async function estimateVaultApy(provider, chainId, vaultAddress) { + console.log( + `[morpho-apy] estimateVaultApy called: chain=${chainId} vault=${vaultAddress}` + ); + const markets = await fetchVaultMarkets(provider, chainId, vaultAddress); + + if (markets.length === 0) { + log(` No markets in supply queue — returning 0`); + return 0; + } + + let weightedApy = 0; + let totalWeight = 0; + + for (const m of markets) { + const scale = Math.pow(10, m.decimals); + const supply = Number(m.totalSupplyAssets.toString()) / scale; + const borrows = Number(m.totalBorrowAssets.toString()) / scale; + const fee = Number(m.fee.toString()); + const rate = Number(m.rateAtTarget.toString()); + + if (supply <= 0) continue; + + const { supplyApy, borrowApy } = estimateMarketApy( + 0, + supply, + borrows, + fee, + rate + ); + + // Weight by vault allocation if available, otherwise by total market supply. + // V1 vaults may have 0 allocation (funds migrated to V2) but the market-level + // APY is still valid for any depositor. + const vaultAlloc = Number(m.vaultSupplyAssets.toString()) / scale; + const weight = vaultAlloc > 0 ? vaultAlloc : supply; + + console.log( + `[morpho-apy] Market ${m.marketId.slice(0, 10)}… ` + + `util=${((borrows / supply) * 100).toFixed(1)}% ` + + `supplyAPY=${(supplyApy * 100).toFixed(2)}% ` + + `borrowAPY=${(borrowApy * 100).toFixed(2)}% ` + + `weight=${weight.toFixed(2)}${ + vaultAlloc > 0 ? " (vault)" : " (market)" + }` + ); + weightedApy += supplyApy * weight; + totalWeight += weight; + } + + if (totalWeight <= 0) return 0; + const vaultApy = weightedApy / totalWeight; + console.log( + `[morpho-apy] Vault APY = ${(vaultApy * 100).toFixed(4)}% ` + + `(${markets.length} markets, ${totalWeight.toFixed(2)} total weight)` + ); + return vaultApy; +} + +/** + * Estimate the vault APY after a hypothetical deposit, and the impact in bps. + * + * MetaMorpho fills markets in supplyQueue order up to each market's cap. + * This simulates that flow: the deposit is distributed across markets in queue + * order, adding to each market's supply until the cap is hit, then moving on. + * + * @param {object} provider + * @param {number} chainId + * @param {string} vaultAddress + * @param {BigNumber} depositAmount Raw asset amount (e.g. USDC with 6 decimals) + * @returns {Promise<{ currentApy: number, newApy: number, impactBps: number }>} + */ +async function estimateDepositImpact( + provider, + chainId, + vaultAddress, + depositAmount +) { + const markets = await fetchVaultMarkets(provider, chainId, vaultAddress); + if (markets.length === 0) { + return { currentApy: 0, newApy: 0, impactBps: 0 }; + } + + // Use first market's decimals (all markets in a vault share the same loan token) + const scale = Math.pow(10, markets[0].decimals); + let remaining = Number(depositAmount.toString()) / scale; + + let currentWeighted = 0; + let newWeighted = 0; + let totalWeight = 0; + + for (const m of markets) { + const mScale = Math.pow(10, m.decimals); + const supply = Number(m.totalSupplyAssets.toString()) / mScale; + const borrows = Number(m.totalBorrowAssets.toString()) / mScale; + const fee = Number(m.fee.toString()); + const rate = Number(m.rateAtTarget.toString()); + const vaultAlloc = Number(m.vaultSupplyAssets.toString()) / mScale; + const cap = Number(m.cap.toString()) / mScale; + const weight = vaultAlloc > 0 ? vaultAlloc : supply; + + if (supply <= 0) continue; + + // Current APY for this market + const { supplyApy: currentApy } = estimateMarketApy( + 0, + supply, + borrows, + fee, + rate + ); + currentWeighted += currentApy * weight; + + // How much of the deposit goes into this market? + const headroom = Math.max(0, cap - supply); + const depositHere = Math.min(remaining, headroom); + remaining -= depositHere; + + // New APY after deposit + const { supplyApy: newApy } = estimateMarketApy( + depositHere, + supply, + borrows, + fee, + rate + ); + const newWeight = weight + depositHere; + newWeighted += newApy * newWeight; + totalWeight += weight; + } + + if (totalWeight <= 0) { + return { currentApy: 0, newApy: 0, impactBps: 0 }; + } + + const currentApy = currentWeighted / totalWeight; + const newTotalWeight = totalWeight + Number(depositAmount.toString()) / scale; + const newApy = newWeighted / newTotalWeight; + const impactBps = Math.round((currentApy - newApy) * 10000); + + log( + `Deposit impact: ${(currentApy * 100).toFixed(2)}% → ${( + newApy * 100 + ).toFixed(2)}% (${impactBps}bps)` + ); + + return { currentApy, newApy, impactBps }; +} + +module.exports = { + estimateMarketApy, + estimateVaultApy, + estimateDepositImpact, + // Expose for unit testing + calculateError, + calculateCurve, +}; diff --git a/contracts/utils/rebalancer-config.js b/contracts/utils/rebalancer-config.js index a225033568..5c434d2030 100644 --- a/contracts/utils/rebalancer-config.js +++ b/contracts/utils/rebalancer-config.js @@ -44,6 +44,7 @@ const ousdConstraints = { minVaultBalance: 3000000000, // $3K in USDC (6 decimals) minApySpread: 0.005, // 0.5% minimum APY spread to trigger rebalancing maxApyThreshold: 0.5, // 50% — APY above this is treated as suspicious + maxApyImpactBps: 50, // Skip deposit if it would drop vault APY by > 0.5% }; module.exports = { ousdMorphoStrategiesConfig, ousdConstraints }; diff --git a/contracts/utils/rebalancer.js b/contracts/utils/rebalancer.js index 68b085edd3..fd85ef5ce1 100644 --- a/contracts/utils/rebalancer.js +++ b/contracts/utils/rebalancer.js @@ -7,6 +7,7 @@ const { ousdMorphoStrategiesConfig, ousdConstraints, } = require("./rebalancer-config"); +const { estimateVaultApy, estimateDepositImpact } = require("./morpho-apy"); const log = require("./logger")("utils:rebalancer"); @@ -125,22 +126,51 @@ async function _fetchMorphoVaultApy(morphoVaultAddress, morphoChainId) { /** * Fetch APYs for multiple vaults in parallel. + * Returns both on-chain (authoritative) and GraphQL (display-only) APYs. + * * @param {Array} vaults - objects with morphoVaultAddress and morphoChainId - * @returns {object} map of morphoVaultAddress -> apy (float, e.g. 0.05 = 5%) + * @param {object} providers - { [chainId]: ethersProvider } + * @returns {object} { apys: { addr: number }, graphqlApys: { addr: number } } */ -async function fetchMorphoApys(vaults) { +async function fetchMorphoApys(vaults, providers) { const entries = await Promise.all( - vaults.map(async (v) => [ - v.morphoVaultAddress, - await _fetchMorphoVaultApy(v.morphoVaultAddress, v.morphoChainId), - ]) + vaults.map(async (v) => { + const provider = providers[v.morphoChainId]; + + // Fetch on-chain and GraphQL APYs in parallel + const onChainApyPromise = provider + ? estimateVaultApy( + provider, + v.morphoChainId, + v.morphoVaultAddress + ).catch((err) => { + console.error( + `[rebalancer] On-chain APY failed for ${v.morphoVaultAddress} on chain ${v.morphoChainId}: ${err.message}` + ); + return 0; + }) + : Promise.resolve(0); + + const [onChainApy, graphqlApy] = await Promise.all([ + onChainApyPromise, + _fetchMorphoVaultApy(v.morphoVaultAddress, v.morphoChainId), + ]); + + return { + addr: v.morphoVaultAddress, + onChainApy, + graphqlApy, + }; + }) ); - const results = {}; - for (const [addr, apy] of entries) { - results[addr] = apy; + const apys = {}; + const graphqlApys = {}; + for (const { addr, onChainApy, graphqlApy } of entries) { + apys[addr] = onChainApy; + graphqlApys[addr] = graphqlApy; } - return results; + return { apys, graphqlApys }; } /** @@ -245,7 +275,7 @@ function _enforceDefaultMinimum( /** * Build an allocation row for a strategy given its computed target balance. */ -function _buildAllocationRow(s, targetBalance, apy) { +function _buildAllocationRow(s, targetBalance, apy, graphqlApy = 0) { const delta = targetBalance.sub(s.balance); return { name: s.name, @@ -254,8 +284,10 @@ function _buildAllocationRow(s, targetBalance, apy) { isTransferPending: s.isTransferPending, isDefault: s.isDefault, morphoVaultAddress: s.morphoVaultAddress, + morphoChainId: s.morphoChainId, balance: s.balance, apy, + graphqlApy, targetBalance, delta, action: delta.gt(0) @@ -277,7 +309,8 @@ function _buildAllocationRow(s, targetBalance, apy) { * * @param {object} params * @param {Array} params.strategies - * @param {object} params.apys - morphoVaultAddress -> apy (float) + * @param {object} params.apys - morphoVaultAddress -> apy (float, on-chain) + * @param {object} [params.graphqlApys] - morphoVaultAddress -> apy (float, GraphQL display-only) * @param {BigNumber} params.vaultBalance * @param {BigNumber} params.shortfall * @param {object} [params.constraints] @@ -286,12 +319,14 @@ function _buildAllocationRow(s, targetBalance, apy) { function computeOptimalAllocation({ strategies, apys, + graphqlApys = {}, vaultBalance, shortfall, constraints: overrides = {}, }) { const constraints = { ...ousdConstraints, ...overrides }; const strategyApyOf = (s) => apys[s.morphoVaultAddress] || 0; + const strategyGraphqlApyOf = (s) => graphqlApys[s.morphoVaultAddress] || 0; const deployableCapital = _computeDeployableCapital( strategies, vaultBalance, @@ -301,7 +336,12 @@ function computeOptimalAllocation({ if (deployableCapital.isZero()) { return strategies.map((s) => - _buildAllocationRow(s, BigNumber.from(0), strategyApyOf(s)) + _buildAllocationRow( + s, + BigNumber.from(0), + strategyApyOf(s), + strategyGraphqlApyOf(s) + ) ); } @@ -318,7 +358,12 @@ function computeOptimalAllocation({ constraints ); return strategies.map((s) => - _buildAllocationRow(s, adjusted[s.address], strategyApyOf(s)) + _buildAllocationRow( + s, + adjusted[s.address], + strategyApyOf(s), + strategyGraphqlApyOf(s) + ) ); } @@ -371,8 +416,17 @@ function _filterWithdrawals(result, constraints) { * Compute deposit budget, then distribute across deposits in APY-descending order. * Budget = approved withdrawal total + vault surplus above reserves. * Infeasible deposits are set to ACTION_NONE with a reason. + * + * @param {object} [providers] - { [chainId]: ethersProvider } for APY impact checks. + * Skipped when process.env.IS_TEST is set. */ -function _filterDeposits(result, vaultBalance, shortfall, constraints) { +async function _filterDeposits( + result, + vaultBalance, + shortfall, + constraints, + providers = {} +) { // Budget = approved withdrawals + vault surplus above reserves const approvedWithdrawals = result.filter( (a) => a.action === ACTION_WITHDRAW @@ -423,6 +477,34 @@ function _filterDeposits(result, vaultBalance, shortfall, constraints) { deposit.reason = "trimmed to available vault funds"; } + // APY impact check: skip if deposit would drop vault APY by more than maxApyImpactBps. + // Skipped in unit tests (IS_TEST=true) since it requires live provider calls. + if ( + !process.env.IS_TEST && + constraints.maxApyImpactBps && + deposit.morphoVaultAddress && + deposit.morphoChainId + ) { + const provider = providers[deposit.morphoChainId]; + if (provider) { + try { + const { impactBps } = await estimateDepositImpact( + provider, + deposit.morphoChainId, + deposit.morphoVaultAddress, + amt + ); + if (impactBps > constraints.maxApyImpactBps) { + deposit.action = ACTION_NONE; + deposit.reason = `APY impact ${impactBps}bps > max ${constraints.maxApyImpactBps}bps`; + continue; + } + } catch (err) { + log(`APY impact check failed for ${deposit.name}: ${err.message}`); + } + } + } + budget = budget.sub(amt); } @@ -534,13 +616,15 @@ function _deploySurplus(result, surplus) { * @param {BigNumber} shortfall - vault withdrawal shortfall (after addWithdrawalQueueLiquidity offset) * @param {BigNumber} vaultBalance - vault idle USDC (after addWithdrawalQueueLiquidity offset) * @param {object} [constraintOverrides] - * @returns {Array} + * @param {object} [providers] - { [chainId]: ethersProvider } forwarded to _filterDeposits + * @returns {Promise} */ -function buildExecutableActions( +async function buildExecutableActions( allocations, shortfall = BigNumber.from(0), vaultBalance = BigNumber.from(0), - constraintOverrides = {} + constraintOverrides = {}, + providers = {} ) { const constraints = { ...ousdConstraints, ...constraintOverrides }; let result = allocations.map((a) => ({ ...a })); @@ -549,7 +633,13 @@ function buildExecutableActions( result = _filterWithdrawals(result, constraints); // 2. Distribute available budget across deposits (highest APY first) - result = _filterDeposits(result, vaultBalance, shortfall, constraints); + result = await _filterDeposits( + result, + vaultBalance, + shortfall, + constraints, + providers + ); // 3. Fallback: cover shortfall if no withdrawals were approved const hasApprovedWithdrawals = result.some( @@ -653,7 +743,11 @@ function printAllocationTable({ current: `${fmtUsd(a.balance)}${pct(a.balance)}`, target: `${fmtUsd(a.targetBalance)}${pct(a.targetBalance)}`, delta: `${sign(a.delta)}${fmtUsd(a.delta.abs())}`, - apy: `${(a.apy * 100).toFixed(2)}%`, + apy: a.graphqlApy + ? `${(a.apy * 100).toFixed(2)}% (API: ${(a.graphqlApy * 100).toFixed( + 2 + )}%)` + : `${(a.apy * 100).toFixed(2)}%`, })); const vaultRow = { name: "Vault (idle)", @@ -820,14 +914,19 @@ function _filterExcludedStrategies(strategies, apys, constraints) { /** * Main entry: read state, fetch APYs, compute allocations, print table. + * + * @param {object} providers - { [chainId]: ethersProvider } + * providers[1] is used for mainnet vault/strategy reads. + * providers[cfg.morphoChainId] is used for on-chain Morpho APY reads. */ -async function buildRebalancePlan(provider) { +async function buildRebalancePlan(providers) { log("Reading on-chain state..."); - const state = await readOnChainState(provider); + const state = await readOnChainState(providers[1]); - log("Fetching Morpho APYs..."); - const apys = await fetchMorphoApys( - state.strategies.filter((s) => s.morphoVaultAddress) + log("Fetching Morpho APYs (on-chain + GraphQL)..."); + const { apys, graphqlApys } = await fetchMorphoApys( + state.strategies.filter((s) => s.morphoVaultAddress), + providers ); // Exclude strategies with suspiciously high APY @@ -841,6 +940,7 @@ async function buildRebalancePlan(provider) { const optimalActive = computeOptimalAllocation({ strategies: active, apys, + graphqlApys, vaultBalance: state.vaultBalance, shortfall: state.shortfall, }); @@ -850,7 +950,8 @@ async function buildRebalancePlan(provider) { const row = _buildAllocationRow( s, s.balance, - apys[s.morphoVaultAddress] || 0 + apys[s.morphoVaultAddress] || 0, + graphqlApys[s.morphoVaultAddress] || 0 ); row.reason = "APY exceeds threshold"; return row; @@ -858,10 +959,12 @@ async function buildRebalancePlan(provider) { const optimalActions = [...optimalActive, ...optimalExcluded]; - const executableActions = buildExecutableActions( + const executableActions = await buildExecutableActions( optimalActions, state.shortfall, - state.vaultBalance + state.vaultBalance, + {}, + providers ); const actions = sortActions(executableActions); @@ -873,7 +976,7 @@ async function buildRebalancePlan(provider) { warnings, }); - return { actions, optimalActions, state, apys, warnings }; + return { actions, optimalActions, state, apys, graphqlApys, warnings }; } module.exports = { From b3c764cd9f3abe9caf643463fa119e5a5822269d Mon Sep 17 00:00:00 2001 From: Shahul Hameed <10547529+shahthepro@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:03:21 +0400 Subject: [PATCH 02/10] Fix config issues --- .../defender-actions/ousdRebalancer.js | 18 +- contracts/tasks/rebalancer.js | 19 +- contracts/test/utils/rebalancer.js | 12 +- contracts/utils/addresses.js | 12 +- contracts/utils/morpho-apy.js | 371 +++++++----------- contracts/utils/rebalancer-config.js | 24 +- contracts/utils/rebalancer.js | 43 +- 7 files changed, 223 insertions(+), 276 deletions(-) diff --git a/contracts/scripts/defender-actions/ousdRebalancer.js b/contracts/scripts/defender-actions/ousdRebalancer.js index d365d70330..52db3f1c8e 100644 --- a/contracts/scripts/defender-actions/ousdRebalancer.js +++ b/contracts/scripts/defender-actions/ousdRebalancer.js @@ -138,13 +138,21 @@ const handler = async (event) => { const webhookUrl = event.secrets?.DISCORD_WEBHOOK_URL; - // Build Base provider for cross-chain APY reads - const baseProvider = new ethers.providers.JsonRpcProvider( - event.secrets.BASE_PROVIDER_URL - ); + // Build chain providers for cross-chain APY reads + const providers = { 1: provider }; + if (event.secrets.BASE_PROVIDER_URL) { + providers[8453] = new ethers.providers.JsonRpcProvider( + event.secrets.BASE_PROVIDER_URL + ); + } + if (event.secrets.HYPEREVM_PROVIDER_URL) { + providers[999] = new ethers.providers.JsonRpcProvider( + event.secrets.HYPEREVM_PROVIDER_URL + ); + } // Compute off-chain recommendations (also prints the allocation table to logs) - const plan = await buildRebalancePlan({ 1: provider, 8453: baseProvider }); + const plan = await buildRebalancePlan(providers); const { actions: allActions } = plan; const actions = allActions.filter((a) => a.action !== ACTION_NONE); diff --git a/contracts/tasks/rebalancer.js b/contracts/tasks/rebalancer.js index 433f3750bf..045ae97794 100644 --- a/contracts/tasks/rebalancer.js +++ b/contracts/tasks/rebalancer.js @@ -5,13 +5,18 @@ const { buildRebalancePlan } = require("../utils/rebalancer"); async function rebalancerTask() { const signer = await getSigner(); - const baseProvider = new ethers.providers.JsonRpcProvider( - process.env.BASE_PROVIDER_URL - ); - await buildRebalancePlan({ - 1: signer.provider || signer, - 8453: baseProvider, - }); + const providers = { 1: signer.provider || signer }; + if (process.env.BASE_PROVIDER_URL) { + providers[8453] = new ethers.providers.JsonRpcProvider( + process.env.BASE_PROVIDER_URL + ); + } + if (process.env.HYPEREVM_PROVIDER_URL) { + providers[999] = new ethers.providers.JsonRpcProvider( + process.env.HYPEREVM_PROVIDER_URL + ); + } + await buildRebalancePlan(providers); } module.exports = { diff --git a/contracts/test/utils/rebalancer.js b/contracts/test/utils/rebalancer.js index f272f79612..9754b041ce 100644 --- a/contracts/test/utils/rebalancer.js +++ b/contracts/test/utils/rebalancer.js @@ -24,14 +24,14 @@ function makeStrategy( isCrossChain = false, isDefault = false, isTransferPending = false, - morphoVaultAddress, + metaMorphoVaultAddress, } = {} ) { return { name, address: `0x${name.replace(/\s/g, "").toLowerCase()}`, - morphoVaultAddress: - morphoVaultAddress || `0xMorpho_${name.replace(/\s/g, "")}`, + metaMorphoVaultAddress: + metaMorphoVaultAddress || `0xMorpho_${name.replace(/\s/g, "")}`, isCrossChain, isDefault, isTransferPending, @@ -43,11 +43,11 @@ function twoStrategies(ethBalance, baseBalance) { return [ makeStrategy("Ethereum Morpho", ethBalance, { isDefault: true, - morphoVaultAddress: ETH_VAULT, + metaMorphoVaultAddress: ETH_VAULT, }), makeStrategy("Base Morpho", baseBalance, { isCrossChain: true, - morphoVaultAddress: BASE_VAULT, + metaMorphoVaultAddress: BASE_VAULT, }), ]; } @@ -255,7 +255,7 @@ describe("Rebalancer: buildExecutableActions", () => { isCrossChain, isDefault, isTransferPending, - morphoVaultAddress: `0xVault_${name}`, + metaMorphoVaultAddress: `0xVault_${name}`, balance: balanceBN, targetBalance: targetBN, delta, diff --git a/contracts/utils/addresses.js b/contracts/utils/addresses.js index 83230dcfb6..552106a55d 100644 --- a/contracts/utils/addresses.js +++ b/contracts/utils/addresses.js @@ -470,6 +470,10 @@ addresses.base.MerklDistributor = "0x8BB4C975Ff3c250e0ceEA271728547f3802B36Fd"; addresses.base.USDC = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"; addresses.base.MorphoOusdV2Vault = "0x2Ba14b2e1E7D2189D3550b708DFCA01f899f33c1"; +// Derived via: VaultV2(MorphoOusdV2Vault).adapters(0) → adapter; adapter.morphoVaultV1() +addresses.base.MorphoOusdV2Adapter = + "0xFE4ccb1f0d9634F3191cA45B7f3413c4ca85086E"; +addresses.base.MorphoOusdV1Vault = "0x581Cc9a73Ec7431723A4a80699B8f801205841F1"; // Sonic addresses.sonic.wS = "0x039e2fB66102314Ce7b64Ce5Ce3E5183bc94aD38"; @@ -718,9 +722,15 @@ addresses.CCTPMessageTransmitterV2 = // HyperEVM contracts addresses.hyperevm.USDC = "0xb88339CB7199b77E23DB6E890353E22632Ba630f"; -// Morpho V2 vault address on HyperEVM once deployed +// MorphoBlue singleton — same deterministic address on all chains +addresses.hyperevm.MorphoBlue = "0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb"; addresses.hyperevm.MorphoOusdV2Vault = "0xE90959cbE7E56b5eBFF9AD12de611A4976F2d2B1"; +// Derived via: VaultV2(MorphoOusdV2Vault).adapters(0) → adapter; adapter.morphoVaultV1() +addresses.hyperevm.MorphoOusdV2Adapter = + "0xF912d9489DEc1593D888eb680a4074f84c44413c"; +addresses.hyperevm.MorphoOusdV1Vault = + "0x0fb7e41A0A85Eb0BcA55172b73942cc6685e2B2E"; addresses.hyperevm.strategist = addresses.multichainStrategist; addresses.hyperevm.admin = "0x92A19381444A001d62cE67BaFF066fA1111d7202"; addresses.hyperevm.timelock = "0x77121911A387c9e4Eae46345E0f831A6da8a1364"; diff --git a/contracts/utils/morpho-apy.js b/contracts/utils/morpho-apy.js index 81e1fb11e2..7a5bdd5f9e 100644 --- a/contracts/utils/morpho-apy.js +++ b/contracts/utils/morpho-apy.js @@ -3,21 +3,21 @@ const { ethers, BigNumber } = require("ethers"); const addresses = require("./addresses"); const log = require("./logger")("utils:morpho-apy"); -// ─── Constants (Adaptive Curve IRM) ────────────────────────────────────────── +// ─── IRM constants ──────────────────────────────────────────────────────────── -const SECONDS_PER_YEAR = 31_536_000; +const SECONDS_PER_YEAR = 365 * 24 * 3600; // 31_536_000 const WAD = 1e18; -const TARGET_UTILIZATION = 0.9; -const CURVE_STEEPNESS = 4; +const TARGET_UTIL = 0.9; +const STEEPNESS = 4; -// ─── Minimal ABIs ──────────────────────────────────────────────────────────── +// ─── ABIs ───────────────────────────────────────────────────────────────────── const morphoBlueAbi = [ - // market(bytes32) → (totalSupplyAssets, totalSupplyShares, totalBorrowAssets, totalBorrowShares, lastUpdate, fee) + // Returns (totalSupplyAssets, totalSupplyShares, totalBorrowAssets, totalBorrowShares, lastUpdate, fee) "function market(bytes32 id) external view returns (uint128, uint128, uint128, uint128, uint128, uint128)", - // position(bytes32, address) → (supplyShares, borrowShares, collateral) + // Returns (supplyShares, borrowShares, collateral) "function position(bytes32 id, address user) external view returns (uint256, uint128, uint128)", - // idToMarketParams(bytes32) → (loanToken, collateralToken, oracle, irm, lltv) + // Returns (loanToken, collateralToken, oracle, irm, lltv) "function idToMarketParams(bytes32 id) external view returns (address, address, address, address, uint256)", ]; @@ -28,111 +28,89 @@ const irmAbi = [ const metaMorphoAbi = [ "function supplyQueue(uint256 index) external view returns (bytes32)", "function supplyQueueLength() external view returns (uint256)", - // config(bytes32) → (cap, enabled, removableAt) + // Returns (cap, enabled, removableAt) "function config(bytes32 id) external view returns (uint184, bool, uint64)", ]; const erc20Abi = ["function decimals() external view returns (uint8)"]; -// ─── Address helpers ───────────────────────────────────────────────────────── +// ─── Address helpers ────────────────────────────────────────────────────────── function _morphoBlueAddress(chainId) { - if (chainId === 1) return addresses.mainnet.MorphoBlue; - if (chainId === 8453) return addresses.base.MorphoBlue; - throw new Error(`No MorphoBlue address for chainId ${chainId}`); + const map = { + 1: addresses.mainnet.MorphoBlue, + 8453: addresses.base.MorphoBlue, + 999: addresses.hyperevm.MorphoBlue, + }; + const addr = map[chainId]; + if (!addr) throw new Error(`No MorphoBlue address for chainId ${chainId}`); + return addr; } -// ─── Pure IRM math (ported from Python gist) ───────────────────────────────── -// -// All math uses Number (64-bit double), identical to Python's float. -// On-chain BigNumber values are normalized to whole-token units at the boundary -// so utilization ratios are safe regardless of token decimals. +// ─── IRM math ───────────────────────────────────────────────────────────────── /** - * Distance from target utilization, normalized to [-1, 1]. - * @param {number} util - Utilization ratio in [0, 1] + * Compute the Morpho Adaptive Curve IRM speed multiplier. + * Below target: linear decrease. Above target: steeper linear increase. */ -function calculateError(util) { - if (util > TARGET_UTILIZATION) { - return (util - TARGET_UTILIZATION) / (1 - TARGET_UTILIZATION); +function _curve(util) { + const err = (util - TARGET_UTIL) / TARGET_UTIL; + if (err < 0) { + return STEEPNESS * err; } - return (util - TARGET_UTILIZATION) / TARGET_UTILIZATION; + return (STEEPNESS * (util - TARGET_UTIL)) / (1 - TARGET_UTIL); } /** - * IRM rate multiplier at a given utilization. - * Below target: linear from 1/STEEPNESS to 1. - * Above target: linear from 1 to STEEPNESS. - * @param {number} util - Utilization ratio in [0, 1] - */ -function calculateCurve(util) { - const error = calculateError(util); - if (util <= TARGET_UTILIZATION) { - return (1 - 1 / CURVE_STEEPNESS) * error + 1; - } - return (CURVE_STEEPNESS - 1) * error + 1; -} - -/** - * Estimate borrow and supply APY for a single Morpho market. + * Estimate the supply and borrow APY of a single Morpho Blue market. * - * All amounts are in normalized (whole-token) units — the caller divides - * raw on-chain values by 10^decimals before passing them here. + * All numeric inputs are plain JS Numbers in token-unit scale (not WAD). + * fee and rateAtTarget are raw on-chain values (WAD-scaled integers as numbers). * - * @param {number} depositAmount Whole-token amount to add to supply (0 for current state) - * @param {number} totalSupply Current total supply (whole tokens) - * @param {number} totalBorrows Current total borrows (whole tokens) - * @param {number} fee Fee in WAD (raw Number from on-chain) - * @param {number} rateAtTarget On-chain rateAtTarget (WAD, per-second) - * @returns {{ borrowApy: number, supplyApy: number }} + * @param {number} depositAmt - additional deposit to simulate (token units) + * @param {number} totalSupply - current total supply assets (token units) + * @param {number} totalBorrows - current total borrow assets (token units) + * @param {number} fee - protocol fee as a WAD integer (e.g. 0.1e18 = 10%) + * @param {number} rateAtTarget - IRM rateAtTarget per second as a WAD integer + * @returns {{ supplyApy: number, borrowApy: number }} */ function estimateMarketApy( - depositAmount, + depositAmt, totalSupply, totalBorrows, fee, rateAtTarget ) { - const newSupply = totalSupply + depositAmount; - if (newSupply <= 0) return { borrowApy: 0, supplyApy: 0 }; - - let utilization = totalBorrows / newSupply; - utilization = Math.min(utilization, 0.9999); - if (utilization < 0.0001) utilization = 0; - - const ratePerSecond = rateAtTarget / WAD; - const curve = calculateCurve(utilization); - const borrowRate = ratePerSecond * curve; - let borrowApy = Math.exp(borrowRate * SECONDS_PER_YEAR) - 1; + const supply = totalSupply + depositAmt; + if (supply <= 0 || rateAtTarget <= 0) return { supplyApy: 0, borrowApy: 0 }; - const feePct = fee ? fee / WAD : 0; - let supplyApy = borrowApy * utilization * (1 - feePct); + const util = Math.min(totalBorrows / supply, 0.9999); + const ratePerSec = rateAtTarget / WAD; + const borrowRate = ratePerSec * Math.exp(_curve(util)); + const borrowApy = Math.exp(borrowRate * SECONDS_PER_YEAR) - 1; + const supplyApy = borrowApy * util * (1 - fee / WAD); - // Clamp to sane range - borrowApy = Math.max(0, Math.min(borrowApy, 8)); - supplyApy = Math.max(0, Math.min(supplyApy, 8)); - - return { borrowApy, supplyApy }; + return { supplyApy, borrowApy }; } -// ─── On-chain reading ──────────────────────────────────────────────────────── +// ─── On-chain reads ─────────────────────────────────────────────────────────── /** - * Fetch the vault's supply queue markets with their on-chain data. - * - * For each market in the vault's supplyQueue, reads: - * - The vault's supply position (supplyShares → supplyAssets via totalSupply/totalShares) - * - The market's total supply/borrow/fee - * - The IRM's rateAtTarget (read from the market's own IRM, not a hardcoded address) - * - The vault's cap for that market + * Fetch all markets in a MetaMorpho vault's supply queue with their current + * on-chain state and the vault's position in each market. * * @param {object} provider * @param {number} chainId - * @param {string} vaultAddress MetaMorpho vault address + * @param {string} vaultAddress * @returns {Promise>} */ async function fetchVaultMarkets(provider, chainId, vaultAddress) { @@ -143,24 +121,16 @@ async function fetchVaultMarkets(provider, chainId, vaultAddress) { provider ); - // Get supply queue length, then read all market IDs - const queueLength = await vault.supplyQueueLength(); - const len = queueLength.toNumber(); - console.log( - `[morpho-apy] supplyQueueLength=${len} for vault ${vaultAddress}` - ); - + const queueLen = await vault.supplyQueueLength(); + const len = queueLen.toNumber(); if (len === 0) return []; - // Read all market IDs in the supply queue const marketIds = await Promise.all( Array.from({ length: len }, (_, i) => vault.supplyQueue(i)) ); - // For each market, fetch data in parallel const markets = await Promise.all( marketIds.map(async (marketId) => { - // First fetch: config, position, market state, and market params const [config, position, marketState, params] = await Promise.all([ vault.config(marketId), morpho.position(marketId, vaultAddress), @@ -170,10 +140,9 @@ async function fetchVaultMarkets(provider, chainId, vaultAddress) { const cap = BigNumber.from(config[0]); const loanToken = params[0]; - const marketIrm = params[3]; // IRM address for this specific market + const marketIrm = params[3]; - // Compute vault's supply assets from its supply shares - // supplyAssets = supplyShares * totalSupplyAssets / totalSupplyShares + // Vault's supply position, derived from shares const vaultSupplyShares = BigNumber.from(position[0]); const totalSupplyAssets = BigNumber.from(marketState[0]); const totalSupplyShares = BigNumber.from(marketState[1]); @@ -187,22 +156,20 @@ async function fetchVaultMarkets(provider, chainId, vaultAddress) { .div(totalSupplyShares); } - // Fetch rateAtTarget from the market's own IRM (not a hardcoded address) + // Read rateAtTarget from the market's own IRM (each market can use a + // different IRM — do not rely on a hardcoded address). + // Wrapped in try/catch: markets not using AdaptiveCurveIRM will revert. let rateAtTarget = BigNumber.from(0); if (marketIrm !== ethers.constants.AddressZero) { try { - const marketIrmContract = new ethers.Contract( - marketIrm, - irmAbi, - provider - ); + const irmContract = new ethers.Contract(marketIrm, irmAbi, provider); rateAtTarget = BigNumber.from( - await marketIrmContract.rateAtTarget(marketId) + await irmContract.rateAtTarget(marketId) ); } catch (err) { - console.error( - `[morpho-apy] rateAtTarget failed for market ${marketId} ` + - `(IRM: ${marketIrm}): ${err.message}` + log( + `rateAtTarget failed for market ${marketId.slice(0, 10)}… ` + + `(IRM ${marketIrm}): ${err.message}` ); } } @@ -210,11 +177,11 @@ async function fetchVaultMarkets(provider, chainId, vaultAddress) { const erc20 = new ethers.Contract(loanToken, erc20Abi, provider); const decimals = await erc20.decimals(); - console.log( - `[morpho-apy] Market ${marketId.slice(0, 10)}… ` + + log( + ` market ${marketId.slice(0, 10)}… ` + `supply=${totalSupplyAssets} borrow=${totalBorrowAssets} ` + `fee=${fee} rate=${rateAtTarget} ` + - `vaultAlloc=${vaultSupplyAssets}` + `vaultAlloc=${vaultSupplyAssets} cap=${cap}` ); return { @@ -233,91 +200,91 @@ async function fetchVaultMarkets(provider, chainId, vaultAddress) { return markets; } +// ─── APY helpers ────────────────────────────────────────────────────────────── + /** - * Compute the current weighted supply APY of a MetaMorpho vault. + * Compute weighted supply APY across a set of markets. * - * Vault APY = Σ(marketSupplyAPY_i × weight_i) / Σ(weight_i) + * Weight = vaultSupplyAssets when any market has a non-zero vault position. + * Fallback to totalSupplyAssets when all vaultSupplyAssets are zero (e.g. a V1 + * vault whose funds migrated to a V2 wrapper — supply queue is valid but the + * vault itself holds no shares). * - * Weight is the vault's allocation in each market when available. - * For V1 vaults with 0 allocation (funds migrated to V2), falls back to - * total market supply as weight — the market-level APY is still valid for - * any depositor. - * - * @param {object} provider - * @param {number} chainId - * @param {string} vaultAddress - * @returns {Promise} APY as a decimal (0.05 = 5%) + * @param {Array} markets - from fetchVaultMarkets + * @param {object} depositSim - optional { [marketId]: BigNumber addedSupply } */ -async function estimateVaultApy(provider, chainId, vaultAddress) { - console.log( - `[morpho-apy] estimateVaultApy called: chain=${chainId} vault=${vaultAddress}` - ); - const markets = await fetchVaultMarkets(provider, chainId, vaultAddress); +function _weightedApy(markets, depositSim = {}) { + const anyVaultAlloc = markets.some((m) => m.vaultSupplyAssets.gt(0)); - if (markets.length === 0) { - log(` No markets in supply queue — returning 0`); - return 0; - } - - let weightedApy = 0; + let weightedSum = 0; let totalWeight = 0; for (const m of markets) { const scale = Math.pow(10, m.decimals); - const supply = Number(m.totalSupplyAssets.toString()) / scale; + const simSupplyBn = depositSim[m.marketId] + ? m.totalSupplyAssets.add(depositSim[m.marketId]) + : m.totalSupplyAssets; + + const supply = Number(simSupplyBn.toString()) / scale; const borrows = Number(m.totalBorrowAssets.toString()) / scale; const fee = Number(m.fee.toString()); const rate = Number(m.rateAtTarget.toString()); - if (supply <= 0) continue; + const { supplyApy } = estimateMarketApy(0, supply, borrows, fee, rate); - const { supplyApy, borrowApy } = estimateMarketApy( - 0, - supply, - borrows, - fee, - rate - ); + const rawWeight = anyVaultAlloc ? m.vaultSupplyAssets : m.totalSupplyAssets; + const weight = Number(rawWeight.toString()) / scale; - // Weight by vault allocation if available, otherwise by total market supply. - // V1 vaults may have 0 allocation (funds migrated to V2) but the market-level - // APY is still valid for any depositor. - const vaultAlloc = Number(m.vaultSupplyAssets.toString()) / scale; - const weight = vaultAlloc > 0 ? vaultAlloc : supply; + if (weight <= 0) continue; - console.log( - `[morpho-apy] Market ${m.marketId.slice(0, 10)}… ` + - `util=${((borrows / supply) * 100).toFixed(1)}% ` + + log( + ` market ${m.marketId.slice(0, 10)}… ` + + `util=${supply > 0 ? ((borrows / supply) * 100).toFixed(1) : 0}% ` + `supplyAPY=${(supplyApy * 100).toFixed(2)}% ` + - `borrowAPY=${(borrowApy * 100).toFixed(2)}% ` + - `weight=${weight.toFixed(2)}${ - vaultAlloc > 0 ? " (vault)" : " (market)" - }` + `weight=${weight.toFixed(2)}` ); - weightedApy += supplyApy * weight; + + weightedSum += supplyApy * weight; totalWeight += weight; } - if (totalWeight <= 0) return 0; - const vaultApy = weightedApy / totalWeight; - console.log( - `[morpho-apy] Vault APY = ${(vaultApy * 100).toFixed(4)}% ` + - `(${markets.length} markets, ${totalWeight.toFixed(2)} total weight)` - ); - return vaultApy; + return totalWeight > 0 ? weightedSum / totalWeight : 0; } +// ─── Public API ─────────────────────────────────────────────────────────────── + /** - * Estimate the vault APY after a hypothetical deposit, and the impact in bps. + * Estimate the current weighted supply APY of a MetaMorpho vault by reading + * market state and IRM rates directly from chain. * - * MetaMorpho fills markets in supplyQueue order up to each market's cap. - * This simulates that flow: the deposit is distributed across markets in queue - * order, adding to each market's supply until the cap is hit, then moving on. + * @param {object} provider + * @param {number} chainId - 1, 8453, or 999 + * @param {string} vaultAddress + * @returns {Promise} APY as a decimal (0.035 = 3.5%) + */ +async function estimateVaultApy(provider, chainId, vaultAddress) { + log(`estimateVaultApy called: chain=${chainId} vault=${vaultAddress}`); + const markets = await fetchVaultMarkets(provider, chainId, vaultAddress); + + if (markets.length === 0) { + log(` no markets in supply queue — returning 0`); + return 0; + } + + log(` supplyQueueLength=${markets.length} for vault ${vaultAddress}`); + const apy = _weightedApy(markets); + log(` vault APY = ${(apy * 100).toFixed(2)}%`); + return apy; +} + +/** + * Simulate depositing `depositAmount` into a MetaMorpho vault and measure the + * resulting APY impact. The vault fills markets in supply-queue order up to each cap. * * @param {object} provider * @param {number} chainId * @param {string} vaultAddress - * @param {BigNumber} depositAmount Raw asset amount (e.g. USDC with 6 decimals) + * @param {BigNumber} depositAmount - in the loan token's native decimals (USDC = 6) * @returns {Promise<{ currentApy: number, newApy: number, impactBps: number }>} */ async function estimateDepositImpact( @@ -327,81 +294,35 @@ async function estimateDepositImpact( depositAmount ) { const markets = await fetchVaultMarkets(provider, chainId, vaultAddress); - if (markets.length === 0) { - return { currentApy: 0, newApy: 0, impactBps: 0 }; - } + const currentApy = _weightedApy(markets); - // Use first market's decimals (all markets in a vault share the same loan token) - const scale = Math.pow(10, markets[0].decimals); - let remaining = Number(depositAmount.toString()) / scale; - - let currentWeighted = 0; - let newWeighted = 0; - let totalWeight = 0; + // Simulate deposit flowing through supply queue in order, filling up to cap + const sim = {}; // marketId → BigNumber increase in totalSupplyAssets + let remaining = depositAmount; for (const m of markets) { - const mScale = Math.pow(10, m.decimals); - const supply = Number(m.totalSupplyAssets.toString()) / mScale; - const borrows = Number(m.totalBorrowAssets.toString()) / mScale; - const fee = Number(m.fee.toString()); - const rate = Number(m.rateAtTarget.toString()); - const vaultAlloc = Number(m.vaultSupplyAssets.toString()) / mScale; - const cap = Number(m.cap.toString()) / mScale; - const weight = vaultAlloc > 0 ? vaultAlloc : supply; - - if (supply <= 0) continue; - - // Current APY for this market - const { supplyApy: currentApy } = estimateMarketApy( - 0, - supply, - borrows, - fee, - rate - ); - currentWeighted += currentApy * weight; - - // How much of the deposit goes into this market? - const headroom = Math.max(0, cap - supply); - const depositHere = Math.min(remaining, headroom); - remaining -= depositHere; - - // New APY after deposit - const { supplyApy: newApy } = estimateMarketApy( - depositHere, - supply, - borrows, - fee, - rate - ); - const newWeight = weight + depositHere; - newWeighted += newApy * newWeight; - totalWeight += weight; - } - - if (totalWeight <= 0) { - return { currentApy: 0, newApy: 0, impactBps: 0 }; + if (remaining.isZero()) break; + const available = m.cap.gt(m.vaultSupplyAssets) + ? m.cap.sub(m.vaultSupplyAssets) + : BigNumber.from(0); + if (available.isZero()) continue; + + const fill = remaining.lt(available) ? remaining : available; + sim[m.marketId] = fill; + remaining = remaining.sub(fill); } - const currentApy = currentWeighted / totalWeight; - const newTotalWeight = totalWeight + Number(depositAmount.toString()) / scale; - const newApy = newWeighted / newTotalWeight; + const newApy = _weightedApy(markets, sim); const impactBps = Math.round((currentApy - newApy) * 10000); log( - `Deposit impact: ${(currentApy * 100).toFixed(2)}% → ${( - newApy * 100 - ).toFixed(2)}% (${impactBps}bps)` + `estimateDepositImpact: deposit=${depositAmount} ` + + `currentAPY=${(currentApy * 100).toFixed(2)}% ` + + `newAPY=${(newApy * 100).toFixed(2)}% ` + + `impact=${impactBps}bps` ); return { currentApy, newApy, impactBps }; } -module.exports = { - estimateMarketApy, - estimateVaultApy, - estimateDepositImpact, - // Expose for unit testing - calculateError, - calculateCurve, -}; +module.exports = { estimateVaultApy, estimateDepositImpact, estimateMarketApy }; diff --git a/contracts/utils/rebalancer-config.js b/contracts/utils/rebalancer-config.js index 420e501d6e..585c0acda9 100644 --- a/contracts/utils/rebalancer-config.js +++ b/contracts/utils/rebalancer-config.js @@ -5,19 +5,22 @@ const addresses = require("./addresses"); * Each entry describes one Morpho strategy the rebalancer can move funds to/from. * * Fields: - * name – Human-readable label - * address – Strategy proxy address (on mainnet) - * morphoVaultAddress – Morpho vault used for APY lookup via the Morpho API - * morphoChainId – Chain where that vault lives (1 = Ethereum, 8453 = Base, 999 = HyperEVM) - * isCrossChain – True for strategies that bridge via CCTP - * isDefault – Fallback strategy; exactly one entry must have this set + * name – Human-readable label + * address – Strategy proxy address (on mainnet) + * metaMorphoVaultAddress – The inner MetaMorpho V1.1 vault (has supplyQueueLength). + * All OUSD vaults are VaultV2; this is the inner vault that + * VaultV2 delegates to. Find it with: + * VaultV2(outerVaultAddr).adapters(0) → adapterAddr + * Adapter(adapterAddr).morphoVaultV1() → metaMorphoVaultAddress + * morphoChainId – Chain where that vault lives (1 = Ethereum, 8453 = Base, 999 = HyperEVM) + * isCrossChain – True for strategies that bridge via CCTP + * isDefault – Fallback strategy; exactly one entry must have this set */ const ousdMorphoStrategiesConfig = [ { name: "Ethereum Morpho", address: addresses.mainnet.MorphoOUSDv2StrategyProxy, - // Morpho V1 vault address for APY lookup (the V2 wrapper is not in Morpho's API) - morphoVaultAddress: addresses.mainnet.MorphoOUSDv1Vault, + metaMorphoVaultAddress: addresses.mainnet.MorphoOUSDv1Vault, morphoChainId: 1, isCrossChain: false, isDefault: true, @@ -25,8 +28,7 @@ const ousdMorphoStrategiesConfig = [ { name: "Base Morpho", address: addresses.mainnet.CrossChainMasterStrategy, - // Morpho V1 vault on Base for APY lookup - morphoVaultAddress: "0x581Cc9a73Ec7431723A4a80699B8f801205841F1", + metaMorphoVaultAddress: addresses.base.MorphoOusdV1Vault, morphoChainId: 8453, isCrossChain: true, isDefault: false, @@ -34,7 +36,7 @@ const ousdMorphoStrategiesConfig = [ { name: "HyperEVM Morpho", address: addresses.mainnet.CrossChainHyperEVMMasterStrategy, - morphoVaultAddress: addresses.hyperevm.MorphoOusdV2Vault, + metaMorphoVaultAddress: addresses.hyperevm.MorphoOusdV1Vault, morphoChainId: 999, isCrossChain: true, isDefault: false, diff --git a/contracts/utils/rebalancer.js b/contracts/utils/rebalancer.js index fd85ef5ce1..3a6f58b937 100644 --- a/contracts/utils/rebalancer.js +++ b/contracts/utils/rebalancer.js @@ -80,7 +80,7 @@ async function readOnChainState(provider) { return { name: cfg.name, address: cfg.address, - morphoVaultAddress: cfg.morphoVaultAddress, + metaMorphoVaultAddress: cfg.metaMorphoVaultAddress, morphoChainId: cfg.morphoChainId, isCrossChain: cfg.isCrossChain, isDefault: cfg.isDefault || false, @@ -102,9 +102,9 @@ async function readOnChainState(provider) { * The APY is a weighted average based on the liquidity allocated in each market. * Returns a numeric APY (e.g. 0.05 = 5%) or 0 on failure. */ -async function _fetchMorphoVaultApy(morphoVaultAddress, morphoChainId) { +async function _fetchMorphoVaultApy(metaMorphoVaultAddress, morphoChainId) { const query = `{ - vaultByAddress(address: "${morphoVaultAddress}", chainId: ${morphoChainId}) { + vaultByAddress(address: "${metaMorphoVaultAddress}", chainId: ${morphoChainId}) { state { netApy } } }`; @@ -119,7 +119,7 @@ async function _fetchMorphoVaultApy(morphoVaultAddress, morphoChainId) { const netApy = data?.data?.vaultByAddress?.state?.netApy; return netApy != null ? Number(netApy) : 0; } catch (e) { - log(`Failed to fetch APY for ${morphoVaultAddress}: ${e.message}`); + log(`Failed to fetch APY for ${metaMorphoVaultAddress}: ${e.message}`); return 0; } } @@ -128,7 +128,7 @@ async function _fetchMorphoVaultApy(morphoVaultAddress, morphoChainId) { * Fetch APYs for multiple vaults in parallel. * Returns both on-chain (authoritative) and GraphQL (display-only) APYs. * - * @param {Array} vaults - objects with morphoVaultAddress and morphoChainId + * @param {Array} vaults - objects with metaMorphoVaultAddress and morphoChainId * @param {object} providers - { [chainId]: ethersProvider } * @returns {object} { apys: { addr: number }, graphqlApys: { addr: number } } */ @@ -142,10 +142,10 @@ async function fetchMorphoApys(vaults, providers) { ? estimateVaultApy( provider, v.morphoChainId, - v.morphoVaultAddress + v.metaMorphoVaultAddress ).catch((err) => { - console.error( - `[rebalancer] On-chain APY failed for ${v.morphoVaultAddress} on chain ${v.morphoChainId}: ${err.message}` + log( + `on-chain APY failed for ${v.metaMorphoVaultAddress} on chain ${v.morphoChainId}: ${err.message}` ); return 0; }) @@ -153,11 +153,11 @@ async function fetchMorphoApys(vaults, providers) { const [onChainApy, graphqlApy] = await Promise.all([ onChainApyPromise, - _fetchMorphoVaultApy(v.morphoVaultAddress, v.morphoChainId), + _fetchMorphoVaultApy(v.metaMorphoVaultAddress, v.morphoChainId), ]); return { - addr: v.morphoVaultAddress, + addr: v.metaMorphoVaultAddress, onChainApy, graphqlApy, }; @@ -283,7 +283,7 @@ function _buildAllocationRow(s, targetBalance, apy, graphqlApy = 0) { isCrossChain: s.isCrossChain, isTransferPending: s.isTransferPending, isDefault: s.isDefault, - morphoVaultAddress: s.morphoVaultAddress, + metaMorphoVaultAddress: s.metaMorphoVaultAddress, morphoChainId: s.morphoChainId, balance: s.balance, apy, @@ -309,8 +309,8 @@ function _buildAllocationRow(s, targetBalance, apy, graphqlApy = 0) { * * @param {object} params * @param {Array} params.strategies - * @param {object} params.apys - morphoVaultAddress -> apy (float, on-chain) - * @param {object} [params.graphqlApys] - morphoVaultAddress -> apy (float, GraphQL display-only) + * @param {object} params.apys - metaMorphoVaultAddress -> apy (float, on-chain) + * @param {object} [params.graphqlApys] - metaMorphoVaultAddress -> apy (float, GraphQL display-only) * @param {BigNumber} params.vaultBalance * @param {BigNumber} params.shortfall * @param {object} [params.constraints] @@ -325,8 +325,9 @@ function computeOptimalAllocation({ constraints: overrides = {}, }) { const constraints = { ...ousdConstraints, ...overrides }; - const strategyApyOf = (s) => apys[s.morphoVaultAddress] || 0; - const strategyGraphqlApyOf = (s) => graphqlApys[s.morphoVaultAddress] || 0; + const strategyApyOf = (s) => apys[s.metaMorphoVaultAddress] || 0; + const strategyGraphqlApyOf = (s) => + graphqlApys[s.metaMorphoVaultAddress] || 0; const deployableCapital = _computeDeployableCapital( strategies, vaultBalance, @@ -482,7 +483,7 @@ async function _filterDeposits( if ( !process.env.IS_TEST && constraints.maxApyImpactBps && - deposit.morphoVaultAddress && + deposit.metaMorphoVaultAddress && deposit.morphoChainId ) { const provider = providers[deposit.morphoChainId]; @@ -491,7 +492,7 @@ async function _filterDeposits( const { impactBps } = await estimateDepositImpact( provider, deposit.morphoChainId, - deposit.morphoVaultAddress, + deposit.metaMorphoVaultAddress, amt ); if (impactBps > constraints.maxApyImpactBps) { @@ -895,7 +896,7 @@ function _filterExcludedStrategies(strategies, apys, constraints) { const excluded = []; const warnings = []; for (const s of strategies) { - const apy = apys[s.morphoVaultAddress] || 0; + const apy = apys[s.metaMorphoVaultAddress] || 0; if (apy > constraints.maxApyThreshold) { const msg = `${s.name} APY ${(apy * 100).toFixed(0)}% exceeds ` + @@ -925,7 +926,7 @@ async function buildRebalancePlan(providers) { log("Fetching Morpho APYs (on-chain + GraphQL)..."); const { apys, graphqlApys } = await fetchMorphoApys( - state.strategies.filter((s) => s.morphoVaultAddress), + state.strategies.filter((s) => s.metaMorphoVaultAddress), providers ); @@ -950,8 +951,8 @@ async function buildRebalancePlan(providers) { const row = _buildAllocationRow( s, s.balance, - apys[s.morphoVaultAddress] || 0, - graphqlApys[s.morphoVaultAddress] || 0 + apys[s.metaMorphoVaultAddress] || 0, + graphqlApys[s.metaMorphoVaultAddress] || 0 ); row.reason = "APY exceeds threshold"; return row; From 2d343e2cb836d41e7b73a9d8a0ed34e7ab3c0d54 Mon Sep 17 00:00:00 2001 From: Shahul Hameed <10547529+shahthepro@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:23:31 +0400 Subject: [PATCH 03/10] Bug fixes --- .../rebalancer/rebalancer.base.fork-test.js | 16 +++++++++++ .../rebalancer.hyperevm.fork-test.js | 16 +++++++++++ .../test/{utils => rebalancer}/rebalancer.js | 0 .../rebalancer.mainnet.fork-test.js | 16 +++++++++++ contracts/utils/addresses.js | 4 +-- contracts/utils/morpho-apy.js | 27 ++++++++++--------- contracts/utils/rebalancer.js | 5 ++-- 7 files changed, 68 insertions(+), 16 deletions(-) create mode 100644 contracts/test/rebalancer/rebalancer.base.fork-test.js create mode 100644 contracts/test/rebalancer/rebalancer.hyperevm.fork-test.js rename contracts/test/{utils => rebalancer}/rebalancer.js (100%) create mode 100644 contracts/test/rebalancer/rebalancer.mainnet.fork-test.js diff --git a/contracts/test/rebalancer/rebalancer.base.fork-test.js b/contracts/test/rebalancer/rebalancer.base.fork-test.js new file mode 100644 index 0000000000..8183cf067a --- /dev/null +++ b/contracts/test/rebalancer/rebalancer.base.fork-test.js @@ -0,0 +1,16 @@ +const { ethers } = require("hardhat"); +const { expect } = require("chai"); + +const { estimateVaultApy } = require("../../utils/morpho-apy"); +const addresses = require("../../utils/addresses"); + +describe("ForkTest: Rebalancer APY — Base", function () { + it("should return non-zero on-chain APY for Base MetaMorpho V1 vault", async () => { + const apy = await estimateVaultApy( + ethers.provider, + 8453, + addresses.base.MorphoOusdV1Vault + ); + expect(apy).to.be.gt(0, `Expected APY > 0, got ${(apy * 100).toFixed(4)}%`); + }); +}); diff --git a/contracts/test/rebalancer/rebalancer.hyperevm.fork-test.js b/contracts/test/rebalancer/rebalancer.hyperevm.fork-test.js new file mode 100644 index 0000000000..6e576b7d6c --- /dev/null +++ b/contracts/test/rebalancer/rebalancer.hyperevm.fork-test.js @@ -0,0 +1,16 @@ +const { ethers } = require("hardhat"); +const { expect } = require("chai"); + +const { estimateVaultApy } = require("../../utils/morpho-apy"); +const addresses = require("../../utils/addresses"); + +describe("ForkTest: Rebalancer APY — HyperEVM", function () { + it("should return non-zero on-chain APY for HyperEVM MetaMorpho V1 vault", async () => { + const apy = await estimateVaultApy( + ethers.provider, + 999, + addresses.hyperevm.MorphoOusdV1Vault + ); + expect(apy).to.be.gt(0, `Expected APY > 0, got ${(apy * 100).toFixed(4)}%`); + }); +}); diff --git a/contracts/test/utils/rebalancer.js b/contracts/test/rebalancer/rebalancer.js similarity index 100% rename from contracts/test/utils/rebalancer.js rename to contracts/test/rebalancer/rebalancer.js diff --git a/contracts/test/rebalancer/rebalancer.mainnet.fork-test.js b/contracts/test/rebalancer/rebalancer.mainnet.fork-test.js new file mode 100644 index 0000000000..2ed538e56e --- /dev/null +++ b/contracts/test/rebalancer/rebalancer.mainnet.fork-test.js @@ -0,0 +1,16 @@ +const { ethers } = require("hardhat"); +const { expect } = require("chai"); + +const { estimateVaultApy } = require("../../utils/morpho-apy"); +const addresses = require("../../utils/addresses"); + +describe("ForkTest: Rebalancer APY — Ethereum", function () { + it("should return non-zero on-chain APY for Ethereum MetaMorpho V1 vault", async () => { + const apy = await estimateVaultApy( + ethers.provider, + 1, + addresses.mainnet.MorphoOUSDv1Vault + ); + expect(apy).to.be.gt(0, `Expected APY > 0, got ${(apy * 100).toFixed(4)}%`); + }); +}); diff --git a/contracts/utils/addresses.js b/contracts/utils/addresses.js index 552106a55d..1a4df33ea7 100644 --- a/contracts/utils/addresses.js +++ b/contracts/utils/addresses.js @@ -722,8 +722,8 @@ addresses.CCTPMessageTransmitterV2 = // HyperEVM contracts addresses.hyperevm.USDC = "0xb88339CB7199b77E23DB6E890353E22632Ba630f"; -// MorphoBlue singleton — same deterministic address on all chains -addresses.hyperevm.MorphoBlue = "0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb"; +// MorphoBlue on HyperEVM is deployed at a different address than other chains +addresses.hyperevm.MorphoBlue = "0x68e37dE8d93d3496ae143F2E900490f6280C57cD"; addresses.hyperevm.MorphoOusdV2Vault = "0xE90959cbE7E56b5eBFF9AD12de611A4976F2d2B1"; // Derived via: VaultV2(MorphoOusdV2Vault).adapters(0) → adapter; adapter.morphoVaultV1() diff --git a/contracts/utils/morpho-apy.js b/contracts/utils/morpho-apy.js index 7a5bdd5f9e..1385e564bb 100644 --- a/contracts/utils/morpho-apy.js +++ b/contracts/utils/morpho-apy.js @@ -158,20 +158,10 @@ async function fetchVaultMarkets(provider, chainId, vaultAddress) { // Read rateAtTarget from the market's own IRM (each market can use a // different IRM — do not rely on a hardcoded address). - // Wrapped in try/catch: markets not using AdaptiveCurveIRM will revert. let rateAtTarget = BigNumber.from(0); if (marketIrm !== ethers.constants.AddressZero) { - try { - const irmContract = new ethers.Contract(marketIrm, irmAbi, provider); - rateAtTarget = BigNumber.from( - await irmContract.rateAtTarget(marketId) - ); - } catch (err) { - log( - `rateAtTarget failed for market ${marketId.slice(0, 10)}… ` + - `(IRM ${marketIrm}): ${err.message}` - ); - } + const irmContract = new ethers.Contract(marketIrm, irmAbi, provider); + rateAtTarget = BigNumber.from(await irmContract.rateAtTarget(marketId)); } const erc20 = new ethers.Contract(loanToken, erc20Abi, provider); @@ -216,6 +206,19 @@ async function fetchVaultMarkets(provider, chainId, vaultAddress) { function _weightedApy(markets, depositSim = {}) { const anyVaultAlloc = markets.some((m) => m.vaultSupplyAssets.gt(0)); + const suspiciousMarkets = markets.filter( + (m) => m.rateAtTarget.isZero() && m.totalBorrowAssets.gt(0) + ); + if (suspiciousMarkets.length > 0) { + log( + `WARNING: ${suspiciousMarkets.length} market(s) have active borrows but ` + + `rateAtTarget=0 — IRM may not implement rateAtTarget(). ` + + `Market IDs: ${suspiciousMarkets + .map((m) => m.marketId.slice(0, 10)) + .join(", ")}` + ); + } + let weightedSum = 0; let totalWeight = 0; diff --git a/contracts/utils/rebalancer.js b/contracts/utils/rebalancer.js index 3a6f58b937..eb53cfe557 100644 --- a/contracts/utils/rebalancer.js +++ b/contracts/utils/rebalancer.js @@ -144,8 +144,9 @@ async function fetchMorphoApys(vaults, providers) { v.morphoChainId, v.metaMorphoVaultAddress ).catch((err) => { - log( - `on-chain APY failed for ${v.metaMorphoVaultAddress} on chain ${v.morphoChainId}: ${err.message}` + console.error( + `[rebalancer] On-chain APY failed for ${v.metaMorphoVaultAddress} ` + + `on chain ${v.morphoChainId}: ${err.message}` ); return 0; }) From 271c44d48cf72bac77d7a07f2c0b1d465c465fc3 Mon Sep 17 00:00:00 2001 From: Shahul Hameed <10547529+shahthepro@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:26:27 +0400 Subject: [PATCH 04/10] Bug fix --- contracts/utils/morpho-apy.js | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/contracts/utils/morpho-apy.js b/contracts/utils/morpho-apy.js index 1385e564bb..f8d5375a36 100644 --- a/contracts/utils/morpho-apy.js +++ b/contracts/utils/morpho-apy.js @@ -204,8 +204,6 @@ async function fetchVaultMarkets(provider, chainId, vaultAddress) { * @param {object} depositSim - optional { [marketId]: BigNumber addedSupply } */ function _weightedApy(markets, depositSim = {}) { - const anyVaultAlloc = markets.some((m) => m.vaultSupplyAssets.gt(0)); - const suspiciousMarkets = markets.filter( (m) => m.rateAtTarget.isZero() && m.totalBorrowAssets.gt(0) ); @@ -235,8 +233,7 @@ function _weightedApy(markets, depositSim = {}) { const { supplyApy } = estimateMarketApy(0, supply, borrows, fee, rate); - const rawWeight = anyVaultAlloc ? m.vaultSupplyAssets : m.totalSupplyAssets; - const weight = Number(rawWeight.toString()) / scale; + const weight = Number(m.vaultSupplyAssets.toString()) / scale; if (weight <= 0) continue; @@ -251,7 +248,15 @@ function _weightedApy(markets, depositSim = {}) { totalWeight += weight; } - return totalWeight > 0 ? weightedSum / totalWeight : 0; + if (totalWeight <= 0) { + throw new Error( + `Vault has ${markets.length} market(s) in its supply queue but zero ` + + `supply position in all of them. Check that the vault address passed ` + + `to fetchVaultMarkets is the MetaMorpho V1 vault, not a VaultV2 wrapper.` + ); + } + + return weightedSum / totalWeight; } // ─── Public API ─────────────────────────────────────────────────────────────── From a13c33b037c688074aaa7230a965a1d8fd2d2391 Mon Sep 17 00:00:00 2001 From: Shahul Hameed <10547529+shahthepro@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:43:28 +0400 Subject: [PATCH 05/10] bug fix --- contracts/utils/morpho-apy.js | 55 +++++++++++++++++++++++++---------- 1 file changed, 39 insertions(+), 16 deletions(-) diff --git a/contracts/utils/morpho-apy.js b/contracts/utils/morpho-apy.js index f8d5375a36..823885f32c 100644 --- a/contracts/utils/morpho-apy.js +++ b/contracts/utils/morpho-apy.js @@ -28,6 +28,8 @@ const irmAbi = [ const metaMorphoAbi = [ "function supplyQueue(uint256 index) external view returns (bytes32)", "function supplyQueueLength() external view returns (uint256)", + "function withdrawQueue(uint256 index) external view returns (bytes32)", + "function withdrawQueueLength() external view returns (uint256)", // Returns (cap, enabled, removableAt) "function config(bytes32 id) external view returns (uint184, bool, uint64)", ]; @@ -96,14 +98,17 @@ function estimateMarketApy( // ─── On-chain reads ─────────────────────────────────────────────────────────── /** - * Fetch all markets in a MetaMorpho vault's supply queue with their current - * on-chain state and the vault's position in each market. + * Fetch all markets in a MetaMorpho vault's supply AND withdrawal queues with + * their current on-chain state and the vault's position in each market. + * Returns the union of both queues (deduplicated). Each market includes an + * `inSupplyQueue` flag so callers can filter for deposit simulation. * * @param {object} provider * @param {number} chainId * @param {string} vaultAddress * @returns {Promise vault.supplyQueue(i)) - ); + // Fetch both queue lengths in parallel — active positions may be in either + const [supplyLen, withdrawLen] = await Promise.all([ + vault.supplyQueueLength().then((n) => n.toNumber()), + vault.withdrawQueueLength().then((n) => n.toNumber()), + ]); + + if (supplyLen === 0 && withdrawLen === 0) return []; + + // Fetch both queues in parallel + const [supplyIds, withdrawIds] = await Promise.all([ + Promise.all(Array.from({ length: supplyLen }, (_, i) => vault.supplyQueue(i))), + Promise.all( + Array.from({ length: withdrawLen }, (_, i) => vault.withdrawQueue(i)) + ), + ]); + + // Union: deduplicate by market ID, track supply-queue membership for deposit sim + const supplySet = new Set(supplyIds); + const allIds = [...supplyIds]; + for (const id of withdrawIds) { + if (!supplySet.has(id)) allIds.push(id); + } const markets = await Promise.all( - marketIds.map(async (marketId) => { + allIds.map(async (marketId) => { const [config, position, marketState, params] = await Promise.all([ vault.config(marketId), morpho.position(marketId, vaultAddress), @@ -171,11 +191,13 @@ async function fetchVaultMarkets(provider, chainId, vaultAddress) { ` market ${marketId.slice(0, 10)}… ` + `supply=${totalSupplyAssets} borrow=${totalBorrowAssets} ` + `fee=${fee} rate=${rateAtTarget} ` + - `vaultAlloc=${vaultSupplyAssets} cap=${cap}` + `vaultAlloc=${vaultSupplyAssets} cap=${cap} ` + + `inSupplyQueue=${supplySet.has(marketId)}` ); return { marketId, + inSupplyQueue: supplySet.has(marketId), cap, vaultSupplyAssets, totalSupplyAssets, @@ -250,9 +272,9 @@ function _weightedApy(markets, depositSim = {}) { if (totalWeight <= 0) { throw new Error( - `Vault has ${markets.length} market(s) in its supply queue but zero ` + - `supply position in all of them. Check that the vault address passed ` + - `to fetchVaultMarkets is the MetaMorpho V1 vault, not a VaultV2 wrapper.` + `Vault has ${markets.length} market(s) across supply+withdrawal queues but zero ` + + `supply position in all of them. Check that the vault address is the MetaMorpho ` + + `V1 vault (not a VaultV2 wrapper) and that it has deployed funds.` ); } @@ -304,11 +326,12 @@ async function estimateDepositImpact( const markets = await fetchVaultMarkets(provider, chainId, vaultAddress); const currentApy = _weightedApy(markets); - // Simulate deposit flowing through supply queue in order, filling up to cap + // Simulate deposit flowing through supply queue in order, filling up to cap. + // Only supply-queue markets receive new deposits (MetaMorpho ignores the rest). const sim = {}; // marketId → BigNumber increase in totalSupplyAssets let remaining = depositAmount; - for (const m of markets) { + for (const m of markets.filter((m) => m.inSupplyQueue)) { if (remaining.isZero()) break; const available = m.cap.gt(m.vaultSupplyAssets) ? m.cap.sub(m.vaultSupplyAssets) From 51272c31cf7d426ded045de84a0463de865c40e8 Mon Sep 17 00:00:00 2001 From: Shahul Hameed <10547529+shahthepro@users.noreply.github.com> Date: Fri, 27 Mar 2026 13:17:15 +0400 Subject: [PATCH 06/10] few more tweaks --- .../defender-actions/ousdRebalancer.js | 8 +- contracts/utils/morpho-apy.js | 4 +- contracts/utils/rebalancer.js | 143 ++++++++++++++++-- 3 files changed, 137 insertions(+), 18 deletions(-) diff --git a/contracts/scripts/defender-actions/ousdRebalancer.js b/contracts/scripts/defender-actions/ousdRebalancer.js index ae00a8f376..5c885535ee 100644 --- a/contracts/scripts/defender-actions/ousdRebalancer.js +++ b/contracts/scripts/defender-actions/ousdRebalancer.js @@ -61,9 +61,13 @@ const buildDiscordMessage = ({ ? formatUSDC(a.withdrawableLiquidity) : " n/a "; const apyStr = a.graphqlApy - ? `${(a.apy * 100).toFixed(2)}% APY (API: ${(a.graphqlApy * 100).toFixed(2)}%)` + ? `${(a.apy * 100).toFixed(2)}% APY (API: ${(a.graphqlApy * 100).toFixed( + 2 + )}%)` : `${(a.apy * 100).toFixed(2)}% APY`; - return ` ${a.name.padEnd(20)} ${formatUSDC(a.balance).padStart(9)} ${avail.padStart(9)} ${apyStr}`; + return ` ${a.name.padEnd(20)} ${formatUSDC(a.balance).padStart( + 9 + )} ${avail.padStart(9)} ${apyStr}`; }); currentLines.push( ` ${"Vault idle".padEnd(20)} ${formatUSDC(state.vaultBalance).padStart(9)}` diff --git a/contracts/utils/morpho-apy.js b/contracts/utils/morpho-apy.js index 823885f32c..450fdbb9e9 100644 --- a/contracts/utils/morpho-apy.js +++ b/contracts/utils/morpho-apy.js @@ -136,7 +136,9 @@ async function fetchVaultMarkets(provider, chainId, vaultAddress) { // Fetch both queues in parallel const [supplyIds, withdrawIds] = await Promise.all([ - Promise.all(Array.from({ length: supplyLen }, (_, i) => vault.supplyQueue(i))), + Promise.all( + Array.from({ length: supplyLen }, (_, i) => vault.supplyQueue(i)) + ), Promise.all( Array.from({ length: withdrawLen }, (_, i) => vault.withdrawQueue(i)) ), diff --git a/contracts/utils/rebalancer.js b/contracts/utils/rebalancer.js index c578fb25e8..a05c598faa 100644 --- a/contracts/utils/rebalancer.js +++ b/contracts/utils/rebalancer.js @@ -526,9 +526,10 @@ async function _filterDeposits( deposit.metaMorphoVaultAddress, amt ); + deposit.impactBps = impactBps; if (impactBps > constraints.maxApyImpactBps) { deposit.action = ACTION_NONE; - deposit.reason = `APY impact ${impactBps}bps > max ${constraints.maxApyImpactBps}bps`; + deposit.reason = `APY impact ${(impactBps / 100).toFixed(2)}% > max ${(constraints.maxApyImpactBps / 100).toFixed(2)}%`; continue; } } catch (err) { @@ -636,12 +637,104 @@ function _deploySurplus(result, surplus) { return result; } +/** + * After deposit filtering, cancel or reduce withdrawals whose total exceeds + * what is actually needed: approved deposits + vault deficit. + * + * vault deficit = max(0, shortfall + minVaultBalance − vaultBalance) + * + * Safety invariant: a withdrawal is only cancelled when its full amount falls + * within the excess (amt ≤ excess). If trimming would put it below minMoveAmount + * but amt > excess, the withdrawal is left unchanged and a small residual excess + * (< minMoveAmount) is accepted rather than over-cancelling and leaving deposits + * without budget. + * + * @param {Array} result - allocations array + * @param {BigNumber} vaultBalance + * @param {BigNumber} shortfall + * @param {object} constraints - merged constraints + * @returns {Array} + */ +function _trimExcessWithdrawals(result, vaultBalance, shortfall, constraints) { + const totalApprovedDeposits = result + .filter((a) => a.action === ACTION_DEPOSIT) + .reduce((sum, a) => sum.add(a.delta.abs()), BigNumber.from(0)); + + const vaultTarget = shortfall.add( + BigNumber.from(constraints.minVaultBalance) + ); + const vaultDeficit = vaultTarget.gt(vaultBalance) + ? vaultTarget.sub(vaultBalance) + : BigNumber.from(0); + + const totalNeeded = totalApprovedDeposits.add(vaultDeficit); + + const withdrawals = result.filter((a) => a.action === ACTION_WITHDRAW); + const totalApprovedWithdrawals = withdrawals.reduce( + (sum, a) => sum.add(a.delta.abs()), + BigNumber.from(0) + ); + + let excess = totalApprovedWithdrawals.sub(totalNeeded); + if (excess.lte(0)) return result; + + const vaultSurplus = vaultBalance.gt(vaultTarget) + ? vaultBalance.sub(vaultTarget) + : BigNumber.from(0); + + // Process smallest withdrawal first — prefer cancelling small withdrawals + // over trimming large ones. + const sorted = [...withdrawals].sort((a, b) => + a.delta.abs().lt(b.delta.abs()) ? -1 : 1 + ); + + let runningTotal = totalApprovedWithdrawals; + + for (const w of sorted) { + if (excess.lte(0)) break; + const amt = w.delta.abs(); + + if (amt.lte(excess)) { + // Entire withdrawal is within excess — safe to cancel (deposits stay funded). + excess = excess.sub(amt); + runningTotal = runningTotal.sub(amt); + w.action = ACTION_NONE; + w.reason = "no approved deposits to fund"; + } else { + // Only part of this withdrawal is excess. + const newAmt = amt.sub(excess); + if (newAmt.gte(BigNumber.from(constraints.minMoveAmount))) { + // Safe to trim. + w.delta = newAmt.mul(-1); + w.targetBalance = w.balance.sub(newAmt); + w.reason = "trimmed to match approved deposits"; + runningTotal = runningTotal.sub(excess); + excess = BigNumber.from(0); + } else { + // Trimming goes below minMoveAmount. Cancel only if remaining withdrawals + // + vault surplus still cover all approved deposits. + const budgetAfterCancel = runningTotal.sub(amt).add(vaultSurplus); + if (budgetAfterCancel.gte(totalApprovedDeposits)) { + runningTotal = runningTotal.sub(amt); + w.action = ACTION_NONE; + w.reason = "no approved deposits to fund"; + excess = BigNumber.from(0); + } + // else: cancelling would under-fund deposits — leave as-is + } + } + } + + return result; +} + /** * Filter allocations: withdraw pass → budget calculation → deposit pass → fallbacks. * * Pass A (withdrawals): filter overallocated strategies by feasibility. * Budget: approved withdrawals + vault surplus = max depositable. * Pass B (deposits): allocate from budget in APY-desc order, apply feasibility checks. + * Pass C (trim): cancel/reduce withdrawals that no longer have deposits to fund. * Pass 2 (fallbacks): shortfall and surplus fallbacks run after both passes. * * @param {Array} allocations - output of computeIdealAllocation @@ -673,7 +766,10 @@ async function buildExecutableActions( providers ); - // 3. Fallback: cover shortfall if no withdrawals were approved + // 3. Cancel/trim withdrawals that exceed what approved deposits + vault deficit need + result = _trimExcessWithdrawals(result, vaultBalance, shortfall, constraints); + + // 4. Fallback: cover shortfall if no withdrawals were approved const hasApprovedWithdrawals = result.some( (a) => a.action === ACTION_WITHDRAW ); @@ -681,7 +777,7 @@ async function buildExecutableActions( result = _coverShortfall(result, shortfall, constraints); } - // 4. Fallback: deploy vault surplus if no deposits were approved + // 5. Fallback: deploy vault surplus if no deposits were approved const hasApprovedDeposits = result.some((a) => a.action === ACTION_DEPOSIT); const surplus = _computeVaultSurplus(vaultBalance, shortfall, constraints); if (!hasApprovedDeposits && surplus.gt(0)) { @@ -778,8 +874,14 @@ function printAllocationTable({ rec && rec.action !== ACTION_NONE ? rec.targetBalance : a.balance; const recDelta = recTarget.sub(a.balance); const apyStr = a.graphqlApy - ? `${(a.apy * 100).toFixed(2)}% (API: ${(a.graphqlApy * 100).toFixed(2)}%)` + ? `${(a.apy * 100).toFixed(2)}% (API: ${(a.graphqlApy * 100).toFixed( + 2 + )}%)` : `${(a.apy * 100).toFixed(2)}%`; + const impact = + rec?.action === ACTION_DEPOSIT && rec?.impactBps != null + ? `${(rec.impactBps / 100).toFixed(2)}%` + : "—"; return { name: `${a.name}${a.isDefault ? " *" : ""}`, current: `${fmtUsd(a.balance)}${pct(a.balance)}`, @@ -790,6 +892,7 @@ function printAllocationTable({ target: `${fmtUsd(recTarget)}${pct(recTarget)}`, delta: `${sign(recDelta)}${fmtUsd(recDelta.abs())}`, apy: apyStr, + impact, }; }); const vaultRow = { @@ -799,6 +902,7 @@ function printAllocationTable({ target: `${fmtUsd(vaultTarget)}${pct(vaultTarget)}`, delta: `${vaultDeltaSign}${fmtUsd(vaultDelta.abs())}`, apy: "—", + impact: "—", }; const allRows = [...formattedRows, vaultRow]; const COL = { @@ -807,26 +911,29 @@ function printAllocationTable({ "Current".length, ...allRows.map((row) => row.current.length) ), - avail: Math.max( - "Avail.".length, - ...allRows.map((row) => row.avail.length) - ), + avail: Math.max("Avail.".length, ...allRows.map((row) => row.avail.length)), target: Math.max( "Target (rec.)".length, ...allRows.map((row) => row.target.length) ), delta: Math.max("Delta".length, ...allRows.map((row) => row.delta.length)), apy: Math.max("APY".length, ...allRows.map((row) => row.apy.length)), + impact: Math.max( + "Impact".length, + ...allRows.map((row) => row.impact.length) + ), }; console.log( `${"Strategy".padEnd(COL.name)}${COL_SEP}${"Current".padStart( COL.current - )}${COL_SEP}${"Avail.".padStart(COL.avail)}${COL_SEP}${"Target (rec.)".padStart( + )}${COL_SEP}${"Avail.".padStart( + COL.avail + )}${COL_SEP}${"Target (rec.)".padStart( COL.target )}${COL_SEP}${"Delta".padStart(COL.delta)}${COL_SEP}${"APY".padStart( COL.apy - )}` + )}${COL_SEP}${"Impact".padStart(COL.impact)}` ); console.log( "-".repeat( @@ -836,7 +943,8 @@ function printAllocationTable({ COL.target + COL.delta + COL.apy + - COL_SEP.length * 5 + COL.impact + + COL_SEP.length * 6 ) ); @@ -847,7 +955,8 @@ function printAllocationTable({ `${row.avail.padStart(COL.avail)}${COL_SEP}` + `${row.target.padStart(COL.target)}${COL_SEP}` + `${row.delta.padStart(COL.delta)}${COL_SEP}` + - `${row.apy.padStart(COL.apy)}` + `${row.apy.padStart(COL.apy)}${COL_SEP}` + + `${row.impact.padStart(COL.impact)}` ); } @@ -857,7 +966,8 @@ function printAllocationTable({ `${vaultRow.avail.padStart(COL.avail)}${COL_SEP}` + `${vaultRow.target.padStart(COL.target)}${COL_SEP}` + `${vaultRow.delta.padStart(COL.delta)}${COL_SEP}` + - `${vaultRow.apy.padStart(COL.apy)}` + `${vaultRow.apy.padStart(COL.apy)}${COL_SEP}` + + `${vaultRow.impact.padStart(COL.impact)}` ); console.log( @@ -868,7 +978,8 @@ function printAllocationTable({ COL.target + COL.delta + COL.apy + - COL_SEP.length * 5 + COL.impact + + COL_SEP.length * 6 ) ); console.log( @@ -881,7 +992,7 @@ function printAllocationTable({ // ── Section 1: All ideal allocation changes ────────────────────────────── const rawChanges = tableRows.filter((a) => !a.delta.isZero()); - console.log("--- Actions for Ideal Allocation ---\n"); + console.log("--- Actions for max APY ---\n"); if (rawChanges.length === 0) { console.log(" All strategies at target.\n"); } else { @@ -899,6 +1010,8 @@ function printAllocationTable({ suffix = ` [Infeasible unless adjusted to ${fmtUsd( filtered.delta.abs() )}]`; + } else if (raw.delta.gt(0) && filtered?.impactBps != null) { + suffix = ` (APY impact: ${(filtered.impactBps / 100).toFixed(2)}%)`; } console.log( From 2ffbc183b22c944249c333d26bf9ca2f56205896 Mon Sep 17 00:00:00 2001 From: Shahul Hameed <10547529+shahthepro@users.noreply.github.com> Date: Sun, 5 Apr 2026 17:21:58 +0530 Subject: [PATCH 07/10] Switch to using subsquid server --- .../defender-actions/ousdRebalancer.js | 7 +- .../rebalancer/rebalancer.base.fork-test.js | 20 +- .../rebalancer.hyperevm.fork-test.js | 20 +- contracts/test/rebalancer/rebalancer.js | 22 +- .../rebalancer.mainnet.fork-test.js | 20 +- contracts/utils/morpho-apy.js | 457 ++++++------------ contracts/utils/morpho-apy.md | 264 ++++++++++ contracts/utils/rebalancer.js | 148 +----- 8 files changed, 478 insertions(+), 480 deletions(-) create mode 100644 contracts/utils/morpho-apy.md diff --git a/contracts/scripts/defender-actions/ousdRebalancer.js b/contracts/scripts/defender-actions/ousdRebalancer.js index 5c885535ee..d75db81ca1 100644 --- a/contracts/scripts/defender-actions/ousdRebalancer.js +++ b/contracts/scripts/defender-actions/ousdRebalancer.js @@ -142,7 +142,12 @@ const handler = async (event) => { const webhookUrl = event.secrets?.DISCORD_WEBHOOK_URL; - // Build chain providers for cross-chain APY reads + // Configure subsquid endpoint for APY reads + process.env.ORIGIN_SUBSQUID_SERVER = + event.secrets?.ORIGIN_SUBSQUID_SERVER || + "https://origin.squids.live/origin-squid:prod/api/graphql"; + + // Build chain providers for on-chain reads (balances, max withdrawals) const providers = { 1: provider }; if (event.secrets.BASE_PROVIDER_URL) { providers[8453] = new ethers.providers.JsonRpcProvider( diff --git a/contracts/test/rebalancer/rebalancer.base.fork-test.js b/contracts/test/rebalancer/rebalancer.base.fork-test.js index 8183cf067a..2de88231e6 100644 --- a/contracts/test/rebalancer/rebalancer.base.fork-test.js +++ b/contracts/test/rebalancer/rebalancer.base.fork-test.js @@ -1,16 +1,20 @@ -const { ethers } = require("hardhat"); const { expect } = require("chai"); -const { estimateVaultApy } = require("../../utils/morpho-apy"); +const { fetchMorphoApys } = require("../../utils/morpho-apy"); const addresses = require("../../utils/addresses"); describe("ForkTest: Rebalancer APY — Base", function () { - it("should return non-zero on-chain APY for Base MetaMorpho V1 vault", async () => { - const apy = await estimateVaultApy( - ethers.provider, - 8453, - addresses.base.MorphoOusdV1Vault + it("should return non-zero APY for Base MetaMorpho V1 vault", async () => { + const { apys } = await fetchMorphoApys([ + { + metaMorphoVaultAddress: addresses.base.MorphoOusdV1Vault, + morphoChainId: 8453, + }, + ]); + const apy = apys[addresses.base.MorphoOusdV1Vault]; + expect(apy).to.be.gt( + 0, + `Expected APY > 0, got ${(apy * 100).toFixed(4)}%` ); - expect(apy).to.be.gt(0, `Expected APY > 0, got ${(apy * 100).toFixed(4)}%`); }); }); diff --git a/contracts/test/rebalancer/rebalancer.hyperevm.fork-test.js b/contracts/test/rebalancer/rebalancer.hyperevm.fork-test.js index 6e576b7d6c..1e57312352 100644 --- a/contracts/test/rebalancer/rebalancer.hyperevm.fork-test.js +++ b/contracts/test/rebalancer/rebalancer.hyperevm.fork-test.js @@ -1,16 +1,20 @@ -const { ethers } = require("hardhat"); const { expect } = require("chai"); -const { estimateVaultApy } = require("../../utils/morpho-apy"); +const { fetchMorphoApys } = require("../../utils/morpho-apy"); const addresses = require("../../utils/addresses"); describe("ForkTest: Rebalancer APY — HyperEVM", function () { - it("should return non-zero on-chain APY for HyperEVM MetaMorpho V1 vault", async () => { - const apy = await estimateVaultApy( - ethers.provider, - 999, - addresses.hyperevm.MorphoOusdV1Vault + it("should return non-zero APY for HyperEVM MetaMorpho V1 vault", async () => { + const { apys } = await fetchMorphoApys([ + { + metaMorphoVaultAddress: addresses.hyperevm.MorphoOusdV1Vault, + morphoChainId: 999, + }, + ]); + const apy = apys[addresses.hyperevm.MorphoOusdV1Vault]; + expect(apy).to.be.gt( + 0, + `Expected APY > 0, got ${(apy * 100).toFixed(4)}%` ); - expect(apy).to.be.gt(0, `Expected APY > 0, got ${(apy * 100).toFixed(4)}%`); }); }); diff --git a/contracts/test/rebalancer/rebalancer.js b/contracts/test/rebalancer/rebalancer.js index 4c7e6e3822..bde0c7ca2e 100644 --- a/contracts/test/rebalancer/rebalancer.js +++ b/contracts/test/rebalancer/rebalancer.js @@ -58,7 +58,7 @@ function twoStrategies(ethBalance, baseBalance) { describe("Rebalancer: computeIdealAllocation", () => { it("should give highest APY strategy the max allocation (sort-and-fill)", () => { - // Base has higher APY → gets maxPerStrategyBps (70%), ETH gets 30% + // Base has higher APY → gets maxPerStrategyBps (95%), ETH gets 5% const strategies = twoStrategies(500000, 500000); const result = computeIdealAllocation({ strategies, @@ -70,10 +70,10 @@ describe("Rebalancer: computeIdealAllocation", () => { const total = result[0].targetBalance.add(result[1].targetBalance); // deployable = 1M - 0 (shortfall) - 3K (minVaultBalance) ≈ 997K expect(total).to.be.closeTo(usdc(997000), usdc(1)); - // Base gets the 70% cap since it has higher APY + // Base gets the 95% cap since it has higher APY const basePct = result[1].targetBalance.mul(10000).div(total).toNumber() / 100; - expect(basePct).to.be.closeTo(70, 0.1); + expect(basePct).to.be.closeTo(95, 0.1); }); it("should give highest APY strategy the max allocation when ETH has higher APY", () => { @@ -88,7 +88,7 @@ describe("Rebalancer: computeIdealAllocation", () => { const total = result[0].targetBalance.add(result[1].targetBalance); const ethPct = result[0].targetBalance.mul(10000).div(total).toNumber() / 100; - expect(ethPct).to.be.closeTo(70, 0.1); + expect(ethPct).to.be.closeTo(95, 0.1); }); it("should enforce minimum for default strategy when it has lower APY", () => { @@ -104,12 +104,12 @@ describe("Rebalancer: computeIdealAllocation", () => { const total = result[0].targetBalance.add(result[1].targetBalance); const ethPct = result[0].targetBalance.mul(10000).div(total).toNumber() / 100; - // Default (ETH) must get at least 20% - expect(ethPct).to.be.gte(20); - // Base cannot exceed 70% (capped, remainder goes to ETH) + // Default (ETH) must get at least 5% (minDefaultStrategyBps = 500) + expect(ethPct).to.be.gte(5); + // Base cannot exceed 95% (capped, remainder goes to ETH) const basePct = result[1].targetBalance.mul(10000).div(total).toNumber() / 100; - expect(basePct).to.be.lte(70.1); + expect(basePct).to.be.lte(95.1); }); it("should reserve shortfall + minVaultBalance from deployable capital", () => { @@ -140,8 +140,8 @@ describe("Rebalancer: computeIdealAllocation", () => { const total = result[0].targetBalance.add(result[1].targetBalance); const ethPct = result[0].targetBalance.mul(10000).div(total).toNumber() / 100; - // ETH fills first to 70% (maxPerStrategyBps), Base gets remaining 30% - expect(ethPct).to.be.closeTo(70, 0.1); + // ETH fills first to 95% (maxPerStrategyBps), Base gets remaining 5% + expect(ethPct).to.be.closeTo(95, 0.1); }); it("should give first strategy the max cap when APYs are zero", () => { @@ -156,7 +156,7 @@ describe("Rebalancer: computeIdealAllocation", () => { const total = result[0].targetBalance.add(result[1].targetBalance); const ethPct = result[0].targetBalance.mul(10000).div(total).toNumber() / 100; - expect(ethPct).to.be.closeTo(70, 0.1); + expect(ethPct).to.be.closeTo(95, 0.1); }); it("should set correct action for over/under allocated strategies", () => { diff --git a/contracts/test/rebalancer/rebalancer.mainnet.fork-test.js b/contracts/test/rebalancer/rebalancer.mainnet.fork-test.js index 2ed538e56e..a7cc9c101d 100644 --- a/contracts/test/rebalancer/rebalancer.mainnet.fork-test.js +++ b/contracts/test/rebalancer/rebalancer.mainnet.fork-test.js @@ -1,16 +1,20 @@ -const { ethers } = require("hardhat"); const { expect } = require("chai"); -const { estimateVaultApy } = require("../../utils/morpho-apy"); +const { fetchMorphoApys } = require("../../utils/morpho-apy"); const addresses = require("../../utils/addresses"); describe("ForkTest: Rebalancer APY — Ethereum", function () { - it("should return non-zero on-chain APY for Ethereum MetaMorpho V1 vault", async () => { - const apy = await estimateVaultApy( - ethers.provider, - 1, - addresses.mainnet.MorphoOUSDv1Vault + it("should return non-zero APY for Ethereum MetaMorpho V1 vault", async () => { + const { apys } = await fetchMorphoApys([ + { + metaMorphoVaultAddress: addresses.mainnet.MorphoOUSDv1Vault, + morphoChainId: 1, + }, + ]); + const apy = apys[addresses.mainnet.MorphoOUSDv1Vault]; + expect(apy).to.be.gt( + 0, + `Expected APY > 0, got ${(apy * 100).toFixed(4)}%` ); - expect(apy).to.be.gt(0, `Expected APY > 0, got ${(apy * 100).toFixed(4)}%`); }); }); diff --git a/contracts/utils/morpho-apy.js b/contracts/utils/morpho-apy.js index 450fdbb9e9..6664029192 100644 --- a/contracts/utils/morpho-apy.js +++ b/contracts/utils/morpho-apy.js @@ -1,361 +1,176 @@ -const { ethers, BigNumber } = require("ethers"); - -const addresses = require("./addresses"); const log = require("./logger")("utils:morpho-apy"); -// ─── IRM constants ──────────────────────────────────────────────────────────── - -const SECONDS_PER_YEAR = 365 * 24 * 3600; // 31_536_000 -const WAD = 1e18; -const TARGET_UTIL = 0.9; -const STEEPNESS = 4; - -// ─── ABIs ───────────────────────────────────────────────────────────────────── - -const morphoBlueAbi = [ - // Returns (totalSupplyAssets, totalSupplyShares, totalBorrowAssets, totalBorrowShares, lastUpdate, fee) - "function market(bytes32 id) external view returns (uint128, uint128, uint128, uint128, uint128, uint128)", - // Returns (supplyShares, borrowShares, collateral) - "function position(bytes32 id, address user) external view returns (uint256, uint128, uint128)", - // Returns (loanToken, collateralToken, oracle, irm, lltv) - "function idToMarketParams(bytes32 id) external view returns (address, address, address, address, uint256)", -]; - -const irmAbi = [ - "function rateAtTarget(bytes32 id) external view returns (int256)", -]; - -const metaMorphoAbi = [ - "function supplyQueue(uint256 index) external view returns (bytes32)", - "function supplyQueueLength() external view returns (uint256)", - "function withdrawQueue(uint256 index) external view returns (bytes32)", - "function withdrawQueueLength() external view returns (uint256)", - // Returns (cap, enabled, removableAt) - "function config(bytes32 id) external view returns (uint184, bool, uint64)", -]; +const DEFAULT_SUBSQUID_URL = + "https://origin.squids.live/origin-squid:prod/api/graphql"; -const erc20Abi = ["function decimals() external view returns (uint8)"]; +const MORPHO_GRAPHQL_URL = "https://api.morpho.org/graphql"; -// ─── Address helpers ────────────────────────────────────────────────────────── - -function _morphoBlueAddress(chainId) { - const map = { - 1: addresses.mainnet.MorphoBlue, - 8453: addresses.base.MorphoBlue, - 999: addresses.hyperevm.MorphoBlue, - }; - const addr = map[chainId]; - if (!addr) throw new Error(`No MorphoBlue address for chainId ${chainId}`); - return addr; -} - -// ─── IRM math ───────────────────────────────────────────────────────────────── +// ─── Generic GraphQL helpers ────────────────────────────────────────────────── /** - * Compute the Morpho Adaptive Curve IRM speed multiplier. - * Below target: linear decrease. Above target: steeper linear increase. - */ -function _curve(util) { - const err = (util - TARGET_UTIL) / TARGET_UTIL; - if (err < 0) { - return STEEPNESS * err; - } - return (STEEPNESS * (util - TARGET_UTIL)) / (1 - TARGET_UTIL); -} - -/** - * Estimate the supply and borrow APY of a single Morpho Blue market. + * POST a GraphQL query to the Origin Subsquid server. + * URL is read from ORIGIN_SUBSQUID_SERVER env var, with a hardcoded default. * - * All numeric inputs are plain JS Numbers in token-unit scale (not WAD). - * fee and rateAtTarget are raw on-chain values (WAD-scaled integers as numbers). - * - * @param {number} depositAmt - additional deposit to simulate (token units) - * @param {number} totalSupply - current total supply assets (token units) - * @param {number} totalBorrows - current total borrow assets (token units) - * @param {number} fee - protocol fee as a WAD integer (e.g. 0.1e18 = 10%) - * @param {number} rateAtTarget - IRM rateAtTarget per second as a WAD integer - * @returns {{ supplyApy: number, borrowApy: number }} + * @param {string} query - GraphQL query string + * @returns {Promise} parsed `data` field from the response */ -function estimateMarketApy( - depositAmt, - totalSupply, - totalBorrows, - fee, - rateAtTarget -) { - const supply = totalSupply + depositAmt; - if (supply <= 0 || rateAtTarget <= 0) return { supplyApy: 0, borrowApy: 0 }; - - const util = Math.min(totalBorrows / supply, 0.9999); - const ratePerSec = rateAtTarget / WAD; - const borrowRate = ratePerSec * Math.exp(_curve(util)); - const borrowApy = Math.exp(borrowRate * SECONDS_PER_YEAR) - 1; - const supplyApy = borrowApy * util * (1 - fee / WAD); +async function _fetchFromSubsquid(query) { + const url = process.env.ORIGIN_SUBSQUID_SERVER || DEFAULT_SUBSQUID_URL; + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query }), + }); + + if (!response.ok) { + throw new Error(`Subsquid request failed: ${response.status}`); + } - return { supplyApy, borrowApy }; + const json = await response.json(); + if (json.errors) { + throw new Error( + `Subsquid GraphQL error: ${json.errors.map((e) => e.message).join(", ")}` + ); + } + return json.data; } -// ─── On-chain reads ─────────────────────────────────────────────────────────── +// ─── Subsquid APY (authoritative — used for rebalancer decisions) ───────────── /** - * Fetch all markets in a MetaMorpho vault's supply AND withdrawal queues with - * their current on-chain state and the vault's position in each market. - * Returns the union of both queues (deduplicated). Each market includes an - * `inSupplyQueue` flag so callers can filter for deposit simulation. + * Fetch the current vault APY from the Origin Subsquid indexer. * - * @param {object} provider - * @param {number} chainId - * @param {string} vaultAddress - * @returns {Promise>} + * @param {string} vaultAddress - MetaMorpho V1.1 vault address + * @param {number} chainId - 1, 8453, or 999 + * @returns {Promise} APY as a decimal (0.035 = 3.5%) */ -async function fetchVaultMarkets(provider, chainId, vaultAddress) { - const vault = new ethers.Contract(vaultAddress, metaMorphoAbi, provider); - const morpho = new ethers.Contract( - _morphoBlueAddress(chainId), - morphoBlueAbi, - provider - ); - - // Fetch both queue lengths in parallel — active positions may be in either - const [supplyLen, withdrawLen] = await Promise.all([ - vault.supplyQueueLength().then((n) => n.toNumber()), - vault.withdrawQueueLength().then((n) => n.toNumber()), - ]); - - if (supplyLen === 0 && withdrawLen === 0) return []; - - // Fetch both queues in parallel - const [supplyIds, withdrawIds] = await Promise.all([ - Promise.all( - Array.from({ length: supplyLen }, (_, i) => vault.supplyQueue(i)) - ), - Promise.all( - Array.from({ length: withdrawLen }, (_, i) => vault.withdrawQueue(i)) - ), - ]); - - // Union: deduplicate by market ID, track supply-queue membership for deposit sim - const supplySet = new Set(supplyIds); - const allIds = [...supplyIds]; - for (const id of withdrawIds) { - if (!supplySet.has(id)) allIds.push(id); - } - - const markets = await Promise.all( - allIds.map(async (marketId) => { - const [config, position, marketState, params] = await Promise.all([ - vault.config(marketId), - morpho.position(marketId, vaultAddress), - morpho.market(marketId), - morpho.idToMarketParams(marketId), - ]); - - const cap = BigNumber.from(config[0]); - const loanToken = params[0]; - const marketIrm = params[3]; - - // Vault's supply position, derived from shares - const vaultSupplyShares = BigNumber.from(position[0]); - const totalSupplyAssets = BigNumber.from(marketState[0]); - const totalSupplyShares = BigNumber.from(marketState[1]); - const totalBorrowAssets = BigNumber.from(marketState[2]); - const fee = BigNumber.from(marketState[5]); - - let vaultSupplyAssets = BigNumber.from(0); - if (!totalSupplyShares.isZero()) { - vaultSupplyAssets = vaultSupplyShares - .mul(totalSupplyAssets) - .div(totalSupplyShares); - } - - // Read rateAtTarget from the market's own IRM (each market can use a - // different IRM — do not rely on a hardcoded address). - let rateAtTarget = BigNumber.from(0); - if (marketIrm !== ethers.constants.AddressZero) { - const irmContract = new ethers.Contract(marketIrm, irmAbi, provider); - rateAtTarget = BigNumber.from(await irmContract.rateAtTarget(marketId)); - } - - const erc20 = new ethers.Contract(loanToken, erc20Abi, provider); - const decimals = await erc20.decimals(); - - log( - ` market ${marketId.slice(0, 10)}… ` + - `supply=${totalSupplyAssets} borrow=${totalBorrowAssets} ` + - `fee=${fee} rate=${rateAtTarget} ` + - `vaultAlloc=${vaultSupplyAssets} cap=${cap} ` + - `inSupplyQueue=${supplySet.has(marketId)}` - ); - - return { - marketId, - inSupplyQueue: supplySet.has(marketId), - cap, - vaultSupplyAssets, - totalSupplyAssets, - totalBorrowAssets, - fee, - rateAtTarget, - decimals, - }; - }) +async function fetchSubsquidVaultApy(vaultAddress, chainId) { + const data = await _fetchFromSubsquid(`{ + morphoVaultApy( + chainId: ${chainId}, + vaultAddress: "${vaultAddress.toLowerCase()}" + ) + }`); + const apy = data?.morphoVaultApy; + log( + `subsquid APY for ${vaultAddress} on chain ${chainId}: ${ + apy != null ? (apy * 100).toFixed(2) + "%" : "null" + }` ); - - return markets; + return apy != null ? Number(apy) : 0; } -// ─── APY helpers ────────────────────────────────────────────────────────────── - /** - * Compute weighted supply APY across a set of markets. - * - * Weight = vaultSupplyAssets when any market has a non-zero vault position. - * Fallback to totalSupplyAssets when all vaultSupplyAssets are zero (e.g. a V1 - * vault whose funds migrated to a V2 wrapper — supply queue is valid but the - * vault itself holds no shares). + * Simulate a deposit and measure APY impact via the Origin Subsquid indexer. * - * @param {Array} markets - from fetchVaultMarkets - * @param {object} depositSim - optional { [marketId]: BigNumber addedSupply } + * @param {string} vaultAddress - MetaMorpho V1.1 vault address + * @param {number} chainId - 1, 8453, or 999 + * @param {BigNumber} depositAmount - in the loan token's native decimals + * @returns {Promise<{ currentApy: number, newApy: number, impactBps: number }>} */ -function _weightedApy(markets, depositSim = {}) { - const suspiciousMarkets = markets.filter( - (m) => m.rateAtTarget.isZero() && m.totalBorrowAssets.gt(0) +async function fetchSubsquidDepositImpact( + vaultAddress, + chainId, + depositAmount +) { + const data = await _fetchFromSubsquid(`{ + morphoDepositImpact( + chainId: ${chainId}, + vaultAddress: "${vaultAddress.toLowerCase()}", + depositAmount: "${depositAmount.toString()}" + ) { + currentApy + newApy + impactBps + } + }`); + const result = data?.morphoDepositImpact; + log( + `subsquid deposit impact for ${vaultAddress} on chain ${chainId}: ` + + `current=${(result.currentApy * 100).toFixed(2)}% ` + + `new=${(result.newApy * 100).toFixed(2)}% ` + + `impact=${result.impactBps}bps` ); - if (suspiciousMarkets.length > 0) { - log( - `WARNING: ${suspiciousMarkets.length} market(s) have active borrows but ` + - `rateAtTarget=0 — IRM may not implement rateAtTarget(). ` + - `Market IDs: ${suspiciousMarkets - .map((m) => m.marketId.slice(0, 10)) - .join(", ")}` - ); - } - - let weightedSum = 0; - let totalWeight = 0; - - for (const m of markets) { - const scale = Math.pow(10, m.decimals); - const simSupplyBn = depositSim[m.marketId] - ? m.totalSupplyAssets.add(depositSim[m.marketId]) - : m.totalSupplyAssets; - - const supply = Number(simSupplyBn.toString()) / scale; - const borrows = Number(m.totalBorrowAssets.toString()) / scale; - const fee = Number(m.fee.toString()); - const rate = Number(m.rateAtTarget.toString()); - - const { supplyApy } = estimateMarketApy(0, supply, borrows, fee, rate); - - const weight = Number(m.vaultSupplyAssets.toString()) / scale; - - if (weight <= 0) continue; - - log( - ` market ${m.marketId.slice(0, 10)}… ` + - `util=${supply > 0 ? ((borrows / supply) * 100).toFixed(1) : 0}% ` + - `supplyAPY=${(supplyApy * 100).toFixed(2)}% ` + - `weight=${weight.toFixed(2)}` - ); - - weightedSum += supplyApy * weight; - totalWeight += weight; - } - - if (totalWeight <= 0) { - throw new Error( - `Vault has ${markets.length} market(s) across supply+withdrawal queues but zero ` + - `supply position in all of them. Check that the vault address is the MetaMorpho ` + - `V1 vault (not a VaultV2 wrapper) and that it has deployed funds.` - ); - } - - return weightedSum / totalWeight; + return { + currentApy: result.currentApy, + newApy: result.newApy, + impactBps: result.impactBps, + }; } -// ─── Public API ─────────────────────────────────────────────────────────────── +// ─── Morpho API (display-only — NOT used for rebalancer decisions) ──────────── /** - * Estimate the current weighted supply APY of a MetaMorpho vault by reading - * market state and IRM rates directly from chain. + * Fetch vault netApy from Morpho's own GraphQL API. + * This is display-only (shown as "API: X.XX%" in the allocations table). * - * @param {object} provider - * @param {number} chainId - 1, 8453, or 999 - * @param {string} vaultAddress - * @returns {Promise} APY as a decimal (0.035 = 3.5%) + * @param {string} vaultAddress - MetaMorpho V1.1 vault address + * @param {number} chainId + * @returns {Promise} APY as a decimal */ -async function estimateVaultApy(provider, chainId, vaultAddress) { - log(`estimateVaultApy called: chain=${chainId} vault=${vaultAddress}`); - const markets = await fetchVaultMarkets(provider, chainId, vaultAddress); - - if (markets.length === 0) { - log(` no markets in supply queue — returning 0`); +async function _fetchMorphoVaultApy(vaultAddress, chainId) { + const query = `{ + vaultByAddress(address: "${vaultAddress}", chainId: ${chainId}) { + state { netApy } + } + }`; + + try { + const response = await fetch(MORPHO_GRAPHQL_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query }), + }); + const data = await response.json(); + const netApy = data?.data?.vaultByAddress?.state?.netApy; + return netApy != null ? Number(netApy) : 0; + } catch (e) { + log(`Failed to fetch Morpho API APY for ${vaultAddress}: ${e.message}`); return 0; } - - log(` supplyQueueLength=${markets.length} for vault ${vaultAddress}`); - const apy = _weightedApy(markets); - log(` vault APY = ${(apy * 100).toFixed(2)}%`); - return apy; } +// ─── Combined fetcher ───────────────────────────────────────────────────────── + /** - * Simulate depositing `depositAmount` into a MetaMorpho vault and measure the - * resulting APY impact. The vault fills markets in supply-queue order up to each cap. + * Fetch APYs for multiple vaults in parallel. + * Returns both subsquid (authoritative) and Morpho API (display-only) APYs. * - * @param {object} provider - * @param {number} chainId - * @param {string} vaultAddress - * @param {BigNumber} depositAmount - in the loan token's native decimals (USDC = 6) - * @returns {Promise<{ currentApy: number, newApy: number, impactBps: number }>} + * @param {Array} vaults - objects with { metaMorphoVaultAddress, morphoChainId } + * @returns {Promise<{ apys: Object, graphqlApys: Object }>} */ -async function estimateDepositImpact( - provider, - chainId, - vaultAddress, - depositAmount -) { - const markets = await fetchVaultMarkets(provider, chainId, vaultAddress); - const currentApy = _weightedApy(markets); - - // Simulate deposit flowing through supply queue in order, filling up to cap. - // Only supply-queue markets receive new deposits (MetaMorpho ignores the rest). - const sim = {}; // marketId → BigNumber increase in totalSupplyAssets - let remaining = depositAmount; - - for (const m of markets.filter((m) => m.inSupplyQueue)) { - if (remaining.isZero()) break; - const available = m.cap.gt(m.vaultSupplyAssets) - ? m.cap.sub(m.vaultSupplyAssets) - : BigNumber.from(0); - if (available.isZero()) continue; - - const fill = remaining.lt(available) ? remaining : available; - sim[m.marketId] = fill; - remaining = remaining.sub(fill); - } - - const newApy = _weightedApy(markets, sim); - const impactBps = Math.round((currentApy - newApy) * 10000); +async function fetchMorphoApys(vaults) { + const entries = await Promise.all( + vaults.map(async (v) => { + const [subsquidApy, morphoApy] = await Promise.all([ + fetchSubsquidVaultApy( + v.metaMorphoVaultAddress, + v.morphoChainId + ).catch((err) => { + console.error( + `[morpho-apy] Subsquid APY failed for ${v.metaMorphoVaultAddress} ` + + `on chain ${v.morphoChainId}: ${err.message}` + ); + return 0; + }), + _fetchMorphoVaultApy(v.metaMorphoVaultAddress, v.morphoChainId), + ]); - log( - `estimateDepositImpact: deposit=${depositAmount} ` + - `currentAPY=${(currentApy * 100).toFixed(2)}% ` + - `newAPY=${(newApy * 100).toFixed(2)}% ` + - `impact=${impactBps}bps` + return { + addr: v.metaMorphoVaultAddress, + subsquidApy, + morphoApy, + }; + }) ); - return { currentApy, newApy, impactBps }; + const apys = {}; + const graphqlApys = {}; + for (const { addr, subsquidApy, morphoApy } of entries) { + apys[addr] = subsquidApy; + graphqlApys[addr] = morphoApy; + } + return { apys, graphqlApys }; } -module.exports = { estimateVaultApy, estimateDepositImpact, estimateMarketApy }; +module.exports = { fetchMorphoApys, fetchSubsquidDepositImpact }; diff --git a/contracts/utils/morpho-apy.md b/contracts/utils/morpho-apy.md new file mode 100644 index 0000000000..7da6a98fd3 --- /dev/null +++ b/contracts/utils/morpho-apy.md @@ -0,0 +1,264 @@ +# Morpho Vault APY & Deposit Impact — API Reference + +This document explains how to fetch APY data and simulate deposit impact for MetaMorpho +vaults used by the OUSD rebalancer. All data is served by the Origin Subsquid indexer. + +--- + +## Table of Contents + +1. [GraphQL API](#1-graphql-api) + - [Endpoint & Configuration](#11-endpoint--configuration) + - [morphoVaultApy — Current Vault APY](#12-morphovaultapy--current-vault-apy) + - [morphoVaultApyAverage — Time-Weighted Average](#13-morphovaultapyaverage--time-weighted-average) + - [morphoDepositImpact — Simulate Deposit](#14-morphodepositimpact--simulate-deposit) + - [Raw Indexed Data](#15-raw-indexed-data) +2. [Supported Vaults](#2-supported-vaults) +3. [How the Indexer Computes APY (Reference)](#3-how-the-indexer-computes-apy-reference) + - [Architecture](#31-architecture) + - [Morpho Adaptive Curve IRM](#32-morpho-adaptive-curve-irm) + - [Weighted Vault APY](#33-weighted-vault-apy) + - [Deposit Impact Simulation](#34-deposit-impact-simulation) + - [Key Design Decisions](#35-key-design-decisions) + +--- + +## 1. GraphQL API + +### 1.1 Endpoint & Configuration + +``` +https://origin.squids.live/origin-squid:prod/api/graphql +``` + +In the OUSD rebalancer, the URL is read from the `ORIGIN_SUBSQUID_SERVER` environment +variable (with the above URL as the default fallback). In OpenZeppelin Defender, set +this as a secret named `ORIGIN_SUBSQUID_SERVER`. + +### 1.2 `morphoVaultApy` — Current Vault APY + +Returns the current instantaneous supply APY for a MetaMorpho vault. + +```graphql +query { + morphoVaultApy( + chainId: 999, + vaultAddress: "0x0fb7e41a0a85eb0bca55172b73942cc6685e2b2e" + ) +} +``` + +**Arguments:** +| Name | Type | Description | +|---|---|---| +| `chainId` | `Int!` | Chain ID (1 = Ethereum, 8453 = Base, 999 = HyperEVM) | +| `vaultAddress` | `String!` | MetaMorpho V1.1 vault address (lowercase) | + +**Returns:** `Float!` — APY as a decimal (e.g. `0.0479` = 4.79%) + +### 1.3 `morphoVaultApyAverage` — Time-Weighted Average + +Returns the time-weighted average APY over a configurable window. + +```graphql +query { + morphoVaultApyAverage( + chainId: 999, + timeWindow: "6h", + vaultAddress: "0x0fb7e41a0a85eb0bca55172b73942cc6685e2b2e" + ) { + averageApy + timeWindowHours + } +} +``` + +**Arguments:** +| Name | Type | Description | +|---|---|---| +| `chainId` | `Int!` | Chain ID | +| `vaultAddress` | `String!` | MetaMorpho V1.1 vault address (lowercase) | +| `timeWindow` | `String!` | Duration string: `"1h"`, `"6h"`, `"24h"`, `"7d"`, etc. | + +**Returns:** +| Field | Type | Description | +|---|---|---| +| `averageApy` | `Float!` | Time-weighted average APY as a decimal | +| `timeWindowHours` | `Float!` | Actual window in hours (e.g. `168` for `"7d"`) | + +### 1.4 `morphoDepositImpact` — Simulate Deposit + +Simulates depositing into a vault and returns the resulting APY change. + +```graphql +query { + morphoDepositImpact( + chainId: 999, + vaultAddress: "0x0fb7e41a0a85eb0bca55172b73942cc6685e2b2e", + depositAmount: "10000000000000" + ) { + currentApy + newApy + impactBps + } +} +``` + +**Arguments:** +| Name | Type | Description | +|---|---|---| +| `chainId` | `Int!` | Chain ID | +| `vaultAddress` | `String!` | MetaMorpho V1.1 vault address (lowercase) | +| `depositAmount` | `String!` | Amount in **native token decimals** as a string (e.g. `"10000000000000"` = 10M USDC with 6 decimals) | + +**Returns:** +| Field | Type | Description | +|---|---|---| +| `currentApy` | `Float!` | Pre-deposit APY as a decimal | +| `newApy` | `Float!` | Post-deposit APY as a decimal | +| `impactBps` | `Int!` | APY decrease in basis points (always >= 0; a deposit can only dilute yield) | + +### 1.5 Raw Indexed Data + +The indexer also exposes historical snapshots for granular analysis: + +**`morphoVaultApies`** — historical vault APY snapshots: +- Fields: `id`, `chainId`, `timestamp`, `blockNumber`, `vaultAddress`, `apy` + +**`morphoMarketStates`** — per-market state over time: +- Fields: `id`, `chainId`, `timestamp`, `blockNumber`, `marketId`, `totalSupplyAssets`, `totalSupplyShares`, `totalBorrowAssets`, `totalBorrowShares`, `lastUpdate`, `fee` + +Both support standard filtering (`where`), pagination (`limit`, `offset`), and ordering (`orderBy`). + +--- + +## 2. Supported Vaults + +### Morpho Blue (singleton per chain) + +| Chain | chainId | Address | +|---|---|---| +| Ethereum | 1 | `0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb` | +| Base | 8453 | `0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb` | +| HyperEVM | 999 | `0x68e37dE8d93d3496ae143F2E900490f6280C57cD` | + +> HyperEVM uses a non-canonical MorphoBlue deployment. + +### MetaMorpho V1.1 Vaults (pass these as `vaultAddress`) + +| Chain | chainId | MetaMorpho V1.1 Vault | +|---|---|---| +| Ethereum | 1 | `0x5B8b9FA8e4145eE06025F642cAdB1B47e5F39F04` | +| Base | 8453 | `0x581Cc9a73Ec7431723A4a80699B8f801205841F1` | +| HyperEVM | 999 | `0x0fb7e41A0A85Eb0BcA55172b73942cc6685e2B2E` | + +### Outer VaultV2 Wrappers (context only) + +The APY API reads directly from the MetaMorpho V1.1 vaults above. The VaultV2 wrappers +and adapters are listed for reference, in case you need to derive the V1 vault address: + +``` +VaultV2(outerVaultAddr).adapters(0) -> adapterAddr +Adapter(adapterAddr).morphoVaultV1() -> metaMorphoVaultAddress +``` + +| Chain | VaultV2 | Adapter | +|---|---|---| +| Ethereum | `0xFB154c729A16802c4ad1E8f7FF539a8b9f49c960` | `0xD8F093dCE8504F10Ac798A978eF9E0C230B2f5fF` | +| Base | `0x2Ba14b2e1E7D2189D3550b708DFCA01f899f33c1` | `0xFE4ccb1f0d9634F3191cA45B7f3413c4ca85086E` | +| HyperEVM | `0xE90959cbE7E56b5eBFF9AD12de611A4976F2d2B1` | `0xF912d9489DEc1593D888eb680a4074f84c44413c` | + +--- + +## 3. How the Indexer Computes APY (Reference) + +This section documents the math behind the indexer for anyone who needs to understand +or verify the numbers. You do not need to implement this — use the GraphQL API above. + +### 3.1 Architecture + +``` +MetaMorpho V1.1 vault (has supplyQueue + withdrawQueue) + +-- Morpho Blue market A (loanToken, IRM, utilisation -> yield) + +-- Morpho Blue market B + +-- ... +``` + +The vault's APY is a **position-weighted average** of the supply APY across every Morpho +Blue market where the vault has deployed funds. + +Both the `supplyQueue` and `withdrawQueue` are scanned. A market removed from the supply +queue may still have a large active position in the withdraw queue — ignoring it would +undercount the vault's APY. + +### 3.2 Morpho Adaptive Curve IRM + +Each Morpho Blue market uses an Adaptive Curve IRM. The key on-chain value is +`rateAtTarget` (per second, WAD-scaled `int256`) — the borrow rate at 90% utilisation. + +``` +TARGET_UTIL = 0.9 +STEEPNESS = 4 +WAD = 1e18 +SECONDS_PER_YEAR = 365 * 24 * 3600 + +curve(util): + if util < TARGET_UTIL: STEEPNESS * (util - TARGET_UTIL) / TARGET_UTIL + else: STEEPNESS * (util - TARGET_UTIL) / (1 - TARGET_UTIL) + +ratePerSec = rateAtTarget / WAD +borrowRate = ratePerSec * exp(curve(util)) +borrowApy = exp(borrowRate * SECONDS_PER_YEAR) - 1 // continuous compounding +supplyApy = borrowApy * util * (1 - fee / WAD) // suppliers earn util% minus protocol fee +``` + +Notes: +- `util` is clamped to `min(borrows/supply, 0.9999)` to handle floating-point edge cases + where accumulated interest causes `borrows > supply`. +- `fee` is the **Morpho Blue protocol fee**, not the MetaMorpho vault's performance fee. + The returned APY is gross before vault-level fees. +- `rateAtTarget` can be negative for distressed markets — treated as 0 APY. +- Each market has its **own IRM contract** (`morpho.idToMarketParams(id)[3]`). Some older + IRMs return `rateAtTarget=0` despite active borrows — these are logged as suspicious + and contribute 0 APY to the weighted average. + +### 3.3 Weighted Vault APY + +``` +vaultApy = SUM(supplyApy_i * vaultSupplyAssets_i) / SUM(vaultSupplyAssets_i) +``` + +- Weight = vault's supply position in each market (shares converted to assets). +- Markets with zero vault position are skipped. +- Throws if the vault has no position in any market (misconfiguration). +- Returns 0 if both queues are empty (vault not yet deployed). + +### 3.4 Deposit Impact Simulation + +New deposits flow through the **supply queue in order**, filling each market up to its +cap. The simulation: + +1. Iterates supply-queue markets in order +2. For each market: `available = cap - vaultSupplyAssets` +3. Fills `min(remaining, available)` into each market +4. Computes `newApy = weightedApy(markets, simulatedExtraSupply)` +5. `impactBps = round((currentApy - newApy) * 10000)` + +`impactBps` is always >= 0 — a deposit can only add supply, which lowers utilisation +and therefore lowers rates. + +### 3.5 Key Design Decisions + +**BigNumber -> JS Number conversion**: On-chain values are `ethers.BigNumber`. The IRM +math requires floating-point (exponentials, fractions). Conversion uses +`Number(bn.toString()) / scale` (not `bn.toNumber()`, which throws for values above +`Number.MAX_SAFE_INTEGER`). Precision loss is acceptable for APY percentage calculations. + +**Both queues**: Supply queue + withdraw queue are unioned and deduplicated. Each market +carries an `inSupplyQueue` flag. APY weighting uses all markets; deposit simulation uses +only supply-queue markets. + +**Vault-level fees not included**: The `fee` from `morpho.market(id)` is the Morpho Blue +protocol fee. MetaMorpho vaults can charge an additional performance fee on yield. The +indexer computes gross supply APY. For net-of-fees display, use Morpho's own API +(`api.morpho.org/graphql` → `vaultByAddress.state.netApy`). diff --git a/contracts/utils/rebalancer.js b/contracts/utils/rebalancer.js index a05c598faa..4b0ede6cbe 100644 --- a/contracts/utils/rebalancer.js +++ b/contracts/utils/rebalancer.js @@ -7,7 +7,10 @@ const { ousdMorphoStrategiesConfig, ousdConstraints, } = require("./rebalancer-config"); -const { estimateVaultApy, estimateDepositImpact } = require("./morpho-apy"); +const { + fetchMorphoApys, + fetchSubsquidDepositImpact, +} = require("./morpho-apy"); const log = require("./logger")("utils:rebalancer"); @@ -108,83 +111,6 @@ async function readOnChainState(provider) { }; } -/** - * 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. - * Returns a numeric APY (e.g. 0.05 = 5%) or 0 on failure. - */ -async function _fetchMorphoVaultApy(metaMorphoVaultAddress, morphoChainId) { - const query = `{ - vaultByAddress(address: "${metaMorphoVaultAddress}", chainId: ${morphoChainId}) { - state { netApy } - } - }`; - - try { - const response = await fetch("https://api.morpho.org/graphql", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ query }), - }); - const data = await response.json(); - const netApy = data?.data?.vaultByAddress?.state?.netApy; - return netApy != null ? Number(netApy) : 0; - } catch (e) { - log(`Failed to fetch APY for ${metaMorphoVaultAddress}: ${e.message}`); - return 0; - } -} - -/** - * Fetch APYs for multiple vaults in parallel. - * Returns both on-chain (authoritative) and GraphQL (display-only) APYs. - * - * @param {Array} vaults - objects with metaMorphoVaultAddress and morphoChainId - * @param {object} providers - { [chainId]: ethersProvider } - * @returns {object} { apys: { addr: number }, graphqlApys: { addr: number } } - */ -async function fetchMorphoApys(vaults, providers) { - const entries = await Promise.all( - vaults.map(async (v) => { - const provider = providers[v.morphoChainId]; - - // Fetch on-chain and GraphQL APYs in parallel - const onChainApyPromise = provider - ? estimateVaultApy( - provider, - v.morphoChainId, - v.metaMorphoVaultAddress - ).catch((err) => { - console.error( - `[rebalancer] On-chain APY failed for ${v.metaMorphoVaultAddress} ` + - `on chain ${v.morphoChainId}: ${err.message}` - ); - return 0; - }) - : Promise.resolve(0); - - const [onChainApy, graphqlApy] = await Promise.all([ - onChainApyPromise, - _fetchMorphoVaultApy(v.metaMorphoVaultAddress, v.morphoChainId), - ]); - - return { - addr: v.metaMorphoVaultAddress, - onChainApy, - graphqlApy, - }; - }) - ); - - const apys = {}; - const graphqlApys = {}; - for (const { addr, onChainApy, graphqlApy } of entries) { - apys[addr] = onChainApy; - graphqlApys[addr] = graphqlApy; - } - return { apys, graphqlApys }; -} - /** * Compute total capital minus reserved amounts (shortfall + minVaultBalance). */ @@ -414,7 +340,7 @@ function _filterWithdrawals(result, constraints) { let amt = w.delta.abs(); // Cap to available liquidity first so subsequent size checks use the capped amount - if (w.withdrawableLiquidity !== null) { + if (w.withdrawableLiquidity != null) { const liq = w.withdrawableLiquidity; if (liq.lt(constraints.minMoveAmount)) { w.action = ACTION_NONE; @@ -449,16 +375,8 @@ function _filterWithdrawals(result, constraints) { * Budget = approved withdrawal total + vault surplus above reserves. * Infeasible deposits are set to ACTION_NONE with a reason. * - * @param {object} [providers] - { [chainId]: ethersProvider } for APY impact checks. - * Skipped when process.env.IS_TEST is set. */ -async function _filterDeposits( - result, - vaultBalance, - shortfall, - constraints, - providers = {} -) { +async function _filterDeposits(result, vaultBalance, shortfall, constraints) { // Budget = approved withdrawals + vault surplus above reserves const approvedWithdrawals = result.filter( (a) => a.action === ACTION_WITHDRAW @@ -510,31 +428,27 @@ async function _filterDeposits( } // APY impact check: skip if deposit would drop vault APY by more than maxApyImpactBps. - // Skipped in unit tests (IS_TEST=true) since it requires live provider calls. + // Skipped in unit tests (IS_TEST=true) since it requires live API calls. if ( !process.env.IS_TEST && constraints.maxApyImpactBps && deposit.metaMorphoVaultAddress && deposit.morphoChainId ) { - const provider = providers[deposit.morphoChainId]; - if (provider) { - try { - const { impactBps } = await estimateDepositImpact( - provider, - deposit.morphoChainId, - deposit.metaMorphoVaultAddress, - amt - ); - deposit.impactBps = impactBps; - if (impactBps > constraints.maxApyImpactBps) { - deposit.action = ACTION_NONE; - deposit.reason = `APY impact ${(impactBps / 100).toFixed(2)}% > max ${(constraints.maxApyImpactBps / 100).toFixed(2)}%`; - continue; - } - } catch (err) { - log(`APY impact check failed for ${deposit.name}: ${err.message}`); + try { + const { impactBps } = await fetchSubsquidDepositImpact( + deposit.metaMorphoVaultAddress, + deposit.morphoChainId, + amt + ); + deposit.impactBps = impactBps; + if (impactBps > constraints.maxApyImpactBps) { + deposit.action = ACTION_NONE; + deposit.reason = `APY impact ${(impactBps / 100).toFixed(2)}% > max ${(constraints.maxApyImpactBps / 100).toFixed(2)}%`; + continue; } + } catch (err) { + log(`APY impact check failed for ${deposit.name}: ${err.message}`); } } @@ -741,15 +655,13 @@ function _trimExcessWithdrawals(result, vaultBalance, shortfall, constraints) { * @param {BigNumber} shortfall - vault withdrawal shortfall (after addWithdrawalQueueLiquidity offset) * @param {BigNumber} vaultBalance - vault idle USDC (after addWithdrawalQueueLiquidity offset) * @param {object} [constraintOverrides] - * @param {object} [providers] - { [chainId]: ethersProvider } forwarded to _filterDeposits * @returns {Promise} */ async function buildExecutableActions( allocations, shortfall = BigNumber.from(0), vaultBalance = BigNumber.from(0), - constraintOverrides = {}, - providers = {} + constraintOverrides = {} ) { const constraints = { ...ousdConstraints, ...constraintOverrides }; let result = allocations.map((a) => ({ ...a })); @@ -758,13 +670,7 @@ async function buildExecutableActions( result = _filterWithdrawals(result, constraints); // 2. Distribute available budget across deposits (highest APY first) - result = await _filterDeposits( - result, - vaultBalance, - shortfall, - constraints, - providers - ); + result = await _filterDeposits(result, vaultBalance, shortfall, constraints); // 3. Cancel/trim withdrawals that exceed what approved deposits + vault deficit need result = _trimExcessWithdrawals(result, vaultBalance, shortfall, constraints); @@ -1149,10 +1055,9 @@ async function buildRebalancePlan(providers) { log("Reading on-chain state..."); const state = await readOnChainState(providerMap[1] || providerMap); - log("Fetching Morpho APYs (on-chain + GraphQL)..."); + log("Fetching Morpho APYs..."); const { apys, graphqlApys } = await fetchMorphoApys( - state.strategies.filter((s) => s.metaMorphoVaultAddress), - providerMap + state.strategies.filter((s) => s.metaMorphoVaultAddress) ); log("Fetching withdrawable liquidity..."); @@ -1201,9 +1106,7 @@ async function buildRebalancePlan(providers) { const executableActions = await buildExecutableActions( idealActions, state.shortfall, - state.vaultBalance, - {}, - providerMap + state.vaultBalance ); const actions = sortActions(executableActions); @@ -1220,7 +1123,6 @@ async function buildRebalancePlan(providers) { module.exports = { readOnChainState, - fetchMorphoApys, fetchMaxWithdrawals, computeIdealAllocation, buildExecutableActions, From e09bb1ddef076c16a5e3180413d07eed39af6d7e Mon Sep 17 00:00:00 2001 From: Shahul Hameed <10547529+shahthepro@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:42:43 +0530 Subject: [PATCH 08/10] APY Impact solver --- .../rebalancer/rebalancer.base.fork-test.js | 5 +- .../rebalancer.hyperevm.fork-test.js | 5 +- contracts/test/rebalancer/rebalancer.js | 36 +- .../rebalancer.mainnet.fork-test.js | 5 +- contracts/utils/morpho-apy.js | 19 +- contracts/utils/morpho-apy.md | 6 + contracts/utils/rebalancer-config.js | 15 +- contracts/utils/rebalancer.js | 340 +++++++++++++----- 8 files changed, 303 insertions(+), 128 deletions(-) diff --git a/contracts/test/rebalancer/rebalancer.base.fork-test.js b/contracts/test/rebalancer/rebalancer.base.fork-test.js index 2de88231e6..50513ae6cf 100644 --- a/contracts/test/rebalancer/rebalancer.base.fork-test.js +++ b/contracts/test/rebalancer/rebalancer.base.fork-test.js @@ -12,9 +12,6 @@ describe("ForkTest: Rebalancer APY — Base", function () { }, ]); const apy = apys[addresses.base.MorphoOusdV1Vault]; - expect(apy).to.be.gt( - 0, - `Expected APY > 0, got ${(apy * 100).toFixed(4)}%` - ); + expect(apy).to.be.gt(0, `Expected APY > 0, got ${(apy * 100).toFixed(4)}%`); }); }); diff --git a/contracts/test/rebalancer/rebalancer.hyperevm.fork-test.js b/contracts/test/rebalancer/rebalancer.hyperevm.fork-test.js index 1e57312352..abf435400e 100644 --- a/contracts/test/rebalancer/rebalancer.hyperevm.fork-test.js +++ b/contracts/test/rebalancer/rebalancer.hyperevm.fork-test.js @@ -12,9 +12,6 @@ describe("ForkTest: Rebalancer APY — HyperEVM", function () { }, ]); const apy = apys[addresses.hyperevm.MorphoOusdV1Vault]; - expect(apy).to.be.gt( - 0, - `Expected APY > 0, got ${(apy * 100).toFixed(4)}%` - ); + expect(apy).to.be.gt(0, `Expected APY > 0, got ${(apy * 100).toFixed(4)}%`); }); }); diff --git a/contracts/test/rebalancer/rebalancer.js b/contracts/test/rebalancer/rebalancer.js index bde0c7ca2e..a109951b5f 100644 --- a/contracts/test/rebalancer/rebalancer.js +++ b/contracts/test/rebalancer/rebalancer.js @@ -25,6 +25,8 @@ function makeStrategy( isDefault = false, isTransferPending = false, metaMorphoVaultAddress, + minAllocationBps, + maxAllocationBps = 9500, } = {} ) { return { @@ -35,6 +37,9 @@ function makeStrategy( isCrossChain, isDefault, isTransferPending, + minAllocationBps: + minAllocationBps != null ? minAllocationBps : isDefault ? 500 : 0, + maxAllocationBps, balance: usdc(balanceUsdc), }; } @@ -300,20 +305,20 @@ describe("Rebalancer: buildExecutableActions", () => { expect(result[1].reason).to.equal("below cross-chain min"); }); - it("should skip withdrawals where APY spread is too small", async () => { - // Both strategies at similar APY — not worth withdrawing from the lower one + it("should skip withdrawals with insufficient liquidity", async () => { const allocs = [ - makeAllocation("Ethereum Morpho", 700000, 500000, 0.05, { + makeAllocation("Ethereum Morpho", 700000, 500000, 0.03, { isDefault: true, }), - makeAllocation("Base Morpho", 300000, 500000, 0.054, { + makeAllocation("Base Morpho", 300000, 500000, 0.06, { isCrossChain: true, }), ]; - // maxApy = 0.054, ETH apy = 0.05, spread = 0.004 < 0.005 minApySpread + // Set liquidity below minMoveAmount ($5K) + allocs[0].withdrawableLiquidity = usdc(1); const result = await buildExecutableActions(allocs, ZERO, usdc(0)); expect(result[0].action).to.equal(ACTION_NONE); - expect(result[0].reason).to.equal("APY spread too small"); + expect(result[0].reason).to.include("insufficient liquidity"); }); it("should allow withdrawal when APY spread is sufficient", async () => { @@ -494,7 +499,7 @@ describe("Rebalancer: buildExecutableActions", () => { it("fallback: overallocated default with delta > shortfall → uses delta amount", async () => { // delta = -200K, shortfall = 50K → max(200K, 50K) = 200K - // But Pass 1 filtered it due to APY spread too small + // Pass 1 filtered due to insufficient liquidity const allocs = [ makeAllocation("Ethereum Morpho", 700000, 500000, 0.05, { isDefault: true, @@ -503,7 +508,8 @@ describe("Rebalancer: buildExecutableActions", () => { isCrossChain: true, }), ]; - // APY spread = 0.004 < 0.005 → filtered in Pass 1 + // Liquidity too low → filtered in Pass 1 + allocs[0].withdrawableLiquidity = usdc(1); const result = await buildExecutableActions(allocs, usdc(50000), usdc(0)); const defaultRow = result.find((a) => a.isDefault); expect(defaultRow.action).to.equal(ACTION_WITHDRAW); @@ -705,17 +711,19 @@ describe("Rebalancer: buildExecutableActions", () => { // Budget reconciliation (Pass B) - it("deposit trimmed to vault surplus when withdraw is filtered by APY spread", async () => { - // ETH overallocated by 200K but APY spread is 0.004 < 0.005 → filtered in Pass A + it("deposit trimmed to vault surplus when withdraw is filtered by liquidity", async () => { + // ETH overallocated by 200K but no liquidity → filtered in Pass A // Base underallocated by 200K, wants deposit; only vault surplus (50K) is available const allocs = [ makeAllocation("Ethereum Morpho", 700000, 500000, 0.05, { isDefault: true, }), - makeAllocation("Base Morpho", 300000, 500000, 0.054, { + makeAllocation("Base Morpho", 300000, 500000, 0.06, { isCrossChain: true, }), ]; + // Liquidity too low → filtered in Pass A + allocs[0].withdrawableLiquidity = usdc(1); // vaultBalance = 53K → surplus = 53K - 0 (shortfall) - 3K (minVaultBalance) = 50K const result = await buildExecutableActions(allocs, ZERO, usdc(53000)); const baseRow = result.find((a) => a.isCrossChain); @@ -796,15 +804,17 @@ describe("Rebalancer: buildExecutableActions", () => { }); it("deposit discarded when trimmed amount falls below cross-chain min", async () => { - // Same setup but vault surplus = 10K < crossChainMinAmount (25K) + // ETH withdrawal filtered by liquidity; vault surplus = 10K < crossChainMinAmount (25K) const allocs = [ makeAllocation("Ethereum Morpho", 700000, 500000, 0.05, { isDefault: true, }), - makeAllocation("Base Morpho", 300000, 500000, 0.054, { + makeAllocation("Base Morpho", 300000, 500000, 0.06, { isCrossChain: true, }), ]; + // Liquidity too low → filtered in Pass A + allocs[0].withdrawableLiquidity = usdc(1); // vaultBalance = 13K → surplus = 10K < 25K → deposit to cross-chain discarded const result = await buildExecutableActions(allocs, ZERO, usdc(13000)); const baseRow = result.find((a) => a.isCrossChain); diff --git a/contracts/test/rebalancer/rebalancer.mainnet.fork-test.js b/contracts/test/rebalancer/rebalancer.mainnet.fork-test.js index a7cc9c101d..304debde34 100644 --- a/contracts/test/rebalancer/rebalancer.mainnet.fork-test.js +++ b/contracts/test/rebalancer/rebalancer.mainnet.fork-test.js @@ -12,9 +12,6 @@ describe("ForkTest: Rebalancer APY — Ethereum", function () { }, ]); const apy = apys[addresses.mainnet.MorphoOUSDv1Vault]; - expect(apy).to.be.gt( - 0, - `Expected APY > 0, got ${(apy * 100).toFixed(4)}%` - ); + expect(apy).to.be.gt(0, `Expected APY > 0, got ${(apy * 100).toFixed(4)}%`); }); }); diff --git a/contracts/utils/morpho-apy.js b/contracts/utils/morpho-apy.js index 6664029192..8dbed50c14 100644 --- a/contracts/utils/morpho-apy.js +++ b/contracts/utils/morpho-apy.js @@ -143,16 +143,15 @@ async function fetchMorphoApys(vaults) { const entries = await Promise.all( vaults.map(async (v) => { const [subsquidApy, morphoApy] = await Promise.all([ - fetchSubsquidVaultApy( - v.metaMorphoVaultAddress, - v.morphoChainId - ).catch((err) => { - console.error( - `[morpho-apy] Subsquid APY failed for ${v.metaMorphoVaultAddress} ` + - `on chain ${v.morphoChainId}: ${err.message}` - ); - return 0; - }), + fetchSubsquidVaultApy(v.metaMorphoVaultAddress, v.morphoChainId).catch( + (err) => { + console.error( + `[morpho-apy] Subsquid APY failed for ${v.metaMorphoVaultAddress} ` + + `on chain ${v.morphoChainId}: ${err.message}` + ); + return 0; + } + ), _fetchMorphoVaultApy(v.metaMorphoVaultAddress, v.morphoChainId), ]); diff --git a/contracts/utils/morpho-apy.md b/contracts/utils/morpho-apy.md index 7da6a98fd3..221e7cf4f8 100644 --- a/contracts/utils/morpho-apy.md +++ b/contracts/utils/morpho-apy.md @@ -262,3 +262,9 @@ only supply-queue markets. protocol fee. MetaMorpho vaults can charge an additional performance fee on yield. The indexer computes gross supply APY. For net-of-fees display, use Morpho's own API (`api.morpho.org/graphql` → `vaultByAddress.state.netApy`). + +**Withdrawal APY impact not modeled**: When the rebalancer withdraws from a Morpho vault, +the remaining depositors see slightly higher APY (less supply competing for the same borrow +demand). This effect is intentionally not modeled — the rebalancer uses pre-withdrawal APYs +for source strategies. This is a conservative simplification: if the move looks worthwhile +using current APYs, it's at least as worthwhile after the source APY improves. diff --git a/contracts/utils/rebalancer-config.js b/contracts/utils/rebalancer-config.js index 8d5a77bf9f..d337157419 100644 --- a/contracts/utils/rebalancer-config.js +++ b/contracts/utils/rebalancer-config.js @@ -15,6 +15,8 @@ const addresses = require("./addresses"); * morphoChainId – Chain where that vault lives (1 = Ethereum, 8453 = Base, 999 = HyperEVM) * isCrossChain – True for strategies that bridge via CCTP * isDefault – Fallback strategy; exactly one entry must have this set + * minAllocationBps – Minimum allocation in basis points (e.g. 500 = 5%) + * maxAllocationBps – Maximum allocation in basis points (e.g. 9500 = 95%) */ const ousdMorphoStrategiesConfig = [ { @@ -24,6 +26,8 @@ const ousdMorphoStrategiesConfig = [ morphoChainId: 1, isCrossChain: false, isDefault: true, + minAllocationBps: 500, // ≥5% + maxAllocationBps: 10000, // 100% — default strategy can hold everything }, { name: "Base Morpho", @@ -32,6 +36,8 @@ const ousdMorphoStrategiesConfig = [ morphoChainId: 8453, isCrossChain: true, isDefault: false, + minAllocationBps: 0, + maxAllocationBps: 9500, // ≤95% }, { name: "HyperEVM Morpho", @@ -40,6 +46,8 @@ const ousdMorphoStrategiesConfig = [ morphoChainId: 999, isCrossChain: true, isDefault: false, + minAllocationBps: 0, + maxAllocationBps: 9500, // ≤95% }, ]; @@ -47,14 +55,13 @@ const ousdMorphoStrategiesConfig = [ * Rebalancing constraints for OUSD. */ const ousdConstraints = { - minDefaultStrategyBps: 500, // Default strategy always gets ≥ 5% of deployable - maxPerStrategyBps: 9500, // No single strategy gets > 95% minMoveAmount: 5000000000, // $5K in USDC (6 decimals) crossChainMinAmount: 25000000000, // $25K in USDC (6 decimals) minVaultBalance: 3000000000, // $3K in USDC (6 decimals) - minApySpread: 0.005, // 0.5% minimum APY spread to trigger rebalancing + minApySpread: 0.005, // 0.5% — post-deposit spread check (destination vs source) maxApyThreshold: 0.5, // 50% — APY above this is treated as suspicious - maxApyImpactBps: 50, // Skip deposit if it would drop vault APY by > 0.5% + maxApyImpactBps: 50, // Max APY degradation per deposit (0.5%) + depositStepSize: 100000000000, // $100K USDC — binary search granularity }; module.exports = { ousdMorphoStrategiesConfig, ousdConstraints }; diff --git a/contracts/utils/rebalancer.js b/contracts/utils/rebalancer.js index 4b0ede6cbe..d3a08691c3 100644 --- a/contracts/utils/rebalancer.js +++ b/contracts/utils/rebalancer.js @@ -7,10 +7,7 @@ const { ousdMorphoStrategiesConfig, ousdConstraints, } = require("./rebalancer-config"); -const { - fetchMorphoApys, - fetchSubsquidDepositImpact, -} = require("./morpho-apy"); +const { fetchMorphoApys, fetchSubsquidDepositImpact } = require("./morpho-apy"); const log = require("./logger")("utils:rebalancer"); @@ -131,30 +128,39 @@ function _computeDeployableCapital( } /** - * Greedy fill: sort strategies by APY descending, fill each up to maxPerStrategyBps. + * Greedy fill: sort strategies by APY descending, fill each up to its per-strategy + * maxAllocationBps (and deposit capacity if known). * Returns a plain object { [address]: BigNumber } of target balances. */ function _greedyFillByApy( strategies, deployableCapital, - constraints, - strategyApyOf + strategyApyOf, + depositCapacities = {} ) { const sorted = [...strategies].sort( (a, b) => strategyApyOf(b) - strategyApyOf(a) ); - const maxPerStrategyAmt = deployableCapital - .mul(constraints.maxPerStrategyBps) - .div(10000); const targets = {}; for (const s of strategies) targets[s.address] = BigNumber.from(0); let remaining = deployableCapital; for (const s of sorted) { - const alloc = remaining.lt(maxPerStrategyAmt) - ? remaining - : maxPerStrategyAmt; + const maxBps = s.maxAllocationBps != null ? s.maxAllocationBps : 10000; + const maxAllocationAmt = deployableCapital.mul(maxBps).div(10000); + + // Cap to deposit capacity: current balance + maxDeposit + const cap = depositCapacities[s.metaMorphoVaultAddress]; + let effectiveMax = maxAllocationAmt; + if (cap && cap.maxDeposit) { + const capacityCap = s.balance.add(cap.maxDeposit); + if (capacityCap.lt(effectiveMax)) { + effectiveMax = capacityCap; + } + } + + const alloc = remaining.lt(effectiveMax) ? remaining : effectiveMax; targets[s.address] = alloc; remaining = remaining.sub(alloc); if (remaining.isZero()) break; @@ -173,38 +179,38 @@ function _greedyFillByApy( } /** - * Ensure default strategy has at least minDefaultStrategyBps of deployable capital. - * Claws back deficit from highest-allocated non-default strategies. + * Ensure every strategy meets its minAllocationBps. + * Claws back deficit from highest-allocated strategies that are above their own minimum. */ -function _enforceDefaultMinimum( - targets, - strategies, - deployableCapital, - constraints -) { - const defaultStrategy = strategies.find((s) => s.isDefault); - if (!defaultStrategy) return targets; - - const minAmt = deployableCapital - .mul(constraints.minDefaultStrategyBps) - .div(10000); - const current = targets[defaultStrategy.address]; - if (current.gte(minAmt)) return targets; - - const deficit = minAmt.sub(current); - targets[defaultStrategy.address] = minAmt; - - const sorted = [...strategies] - .filter((s) => s.address !== defaultStrategy.address) - .sort((a, b) => (targets[b.address].gt(targets[a.address]) ? 1 : -1)); +function _enforceStrategyMinimums(targets, strategies, deployableCapital) { + // Collect strategies that are below their minimum + const belowMin = strategies.filter((s) => { + const minBps = s.minAllocationBps || 0; + if (minBps === 0) return false; + const minAmt = deployableCapital.mul(minBps).div(10000); + return targets[s.address].lt(minAmt); + }); - let toReduce = deficit; - for (const s of sorted) { - const available = targets[s.address]; - const take = available.lt(toReduce) ? available : toReduce; - targets[s.address] = available.sub(take); - toReduce = toReduce.sub(take); - if (toReduce.isZero()) break; + for (const under of belowMin) { + const minAmt = deployableCapital.mul(under.minAllocationBps).div(10000); + const deficit = minAmt.sub(targets[under.address]); + targets[under.address] = minAmt; + + // Claw back from highest-allocated strategies (excluding those at/below their own min) + const sorted = [...strategies] + .filter((s) => s.address !== under.address) + .sort((a, b) => (targets[b.address].gt(targets[a.address]) ? 1 : -1)); + + let toReduce = deficit; + for (const s of sorted) { + const sMinAmt = deployableCapital.mul(s.minAllocationBps || 0).div(10000); + const available = targets[s.address].sub(sMinAmt); + if (available.lte(0)) continue; + const take = available.lt(toReduce) ? available : toReduce; + targets[s.address] = targets[s.address].sub(take); + toReduce = toReduce.sub(take); + if (toReduce.isZero()) break; + } } return targets; @@ -244,8 +250,9 @@ function _buildAllocationRow(s, targetBalance, apy, graphqlApy = 0) { * Total capital = sum(rebalancableBalances) + vaultBalance - shortfall - minVaultBalance * Shortfall + minVaultBalance are pre-reserved for the vault, excluded from the allocation pie. * - * Allocation: sort strategies by APY descending, fill each up to maxPerStrategyBps. - * Default strategy is guaranteed at least minDefaultStrategyBps. + * Allocation: sort strategies by APY descending, fill each up to per-strategy maxAllocationBps. + * Each strategy is guaranteed at least its minAllocationBps. + * Deposit capacity (from discoverDepositCapacities) further constrains allocation. * * @param {object} params * @param {Array} params.strategies @@ -254,6 +261,7 @@ function _buildAllocationRow(s, targetBalance, apy, graphqlApy = 0) { * @param {BigNumber} params.vaultBalance * @param {BigNumber} params.shortfall * @param {object} [params.constraints] + * @param {object} [params.depositCapacities] - from discoverDepositCapacities() * @returns {Array} allocation results */ function computeIdealAllocation({ @@ -263,6 +271,7 @@ function computeIdealAllocation({ vaultBalance, shortfall, constraints: overrides = {}, + depositCapacities = {}, }) { const constraints = { ...ousdConstraints, ...overrides }; const strategyApyOf = (s) => apys[s.metaMorphoVaultAddress] || 0; @@ -289,14 +298,13 @@ function computeIdealAllocation({ const targets = _greedyFillByApy( strategies, deployableCapital, - constraints, - strategyApyOf + strategyApyOf, + depositCapacities ); - const adjusted = _enforceDefaultMinimum( + const adjusted = _enforceStrategyMinimums( targets, strategies, - deployableCapital, - constraints + deployableCapital ); return strategies.map((s) => _buildAllocationRow( @@ -309,13 +317,104 @@ function computeIdealAllocation({ } /** - * Highest APY across all strategies (used for APY spread check). + * Binary search for the maximum deposit amount where impactBps ≤ maxApyImpactBps. + * Rounds to depositStepSize increments. ~4-5 API calls per strategy. + * + * @param {string} vaultAddress - MetaMorpho vault address + * @param {number} chainId + * @param {BigNumber} maxAmt - upper bound (from allocation cap) + * @param {object} constraints + * @returns {{ maxDeposit: BigNumber, impactBps: number, postDepositApy: number }} + */ +async function _findMaxDeposit(vaultAddress, chainId, maxAmt, constraints) { + const step = BigNumber.from(constraints.depositStepSize); + + // Fast path: check full amount first + const full = await fetchSubsquidDepositImpact(vaultAddress, chainId, maxAmt); + if (full.impactBps <= constraints.maxApyImpactBps) { + return { + maxDeposit: maxAmt, + impactBps: full.impactBps, + postDepositApy: full.newApy, + }; + } + + // Binary search + let lo = BigNumber.from(constraints.minMoveAmount); + let hi = maxAmt; + let best = { maxDeposit: BigNumber.from(0), impactBps: 0, postDepositApy: 0 }; + + while (hi.sub(lo).gt(step)) { + const mid = lo.add(hi).div(2).div(step).mul(step); // round to step + if (mid.lt(lo)) break; + + const { impactBps, newApy } = await fetchSubsquidDepositImpact( + vaultAddress, + chainId, + mid + ); + if (impactBps <= constraints.maxApyImpactBps) { + best = { maxDeposit: mid, impactBps, postDepositApy: newApy }; + lo = mid.add(step); + } else { + hi = mid; + } + } + return best; +} + +/** + * Discover the maximum deposit amount per strategy that keeps APY impact within + * the threshold. Called before allocation so capacities can be factored in. + * + * @param {Array} strategies - strategy config objects with balance + * @param {BigNumber} deployableCapital + * @param {object} constraints + * @returns {object} { [metaMorphoVaultAddress]: { maxDeposit, postDepositApy, impactBps } } */ -function _computeMaxApy(allocations) { - const apys = allocations - .filter((a) => Number.isFinite(a.apy)) - .map((a) => a.apy); - return apys.length > 0 ? Math.max(...apys) : 0; +async function discoverDepositCapacities( + strategies, + deployableCapital, + constraints +) { + const capacities = {}; + await Promise.all( + strategies.map(async (s) => { + if (!s.metaMorphoVaultAddress || !s.morphoChainId) return; + try { + const maxBps = s.maxAllocationBps != null ? s.maxAllocationBps : 10000; + const maxPossible = deployableCapital + .mul(maxBps) + .div(10000) + .sub(s.balance); + if (maxPossible.lt(constraints.minMoveAmount)) { + capacities[s.metaMorphoVaultAddress] = { + maxDeposit: BigNumber.from(0), + postDepositApy: 0, + impactBps: 0, + }; + return; + } + const result = await _findMaxDeposit( + s.metaMorphoVaultAddress, + s.morphoChainId, + maxPossible, + constraints + ); + capacities[s.metaMorphoVaultAddress] = result; + log( + `Deposit capacity for ${s.name}: ${fmtUsd(result.maxDeposit)} ` + + `(impact ${result.impactBps}bps, post-deposit APY ${( + result.postDepositApy * 100 + ).toFixed(2)}%)` + ); + } catch (err) { + log(`Deposit capacity discovery failed for ${s.name}: ${err.message}`); + // No capacity constraint applied — fallback to full amount + } + }) + ); + return capacities; } /** @@ -329,11 +428,13 @@ function _computeVaultSurplus(vaultBalance, shortfall, constraints) { } /** - * Filter withdrawals by feasibility: min move amount, cross-chain min, APY spread. + * Filter withdrawals by feasibility: min move amount, cross-chain min, liquidity. * Infeasible withdrawals are set to ACTION_NONE with a reason. + * + * Note: APY spread check has moved to _filterDeposits (post-impact spread). + * Per-strategy minAllocationBps prevents over-withdrawing from important strategies. */ function _filterWithdrawals(result, constraints) { - const maxApy = _computeMaxApy(result); const withdrawals = result.filter((a) => a.action === ACTION_WITHDRAW); for (const w of withdrawals) { @@ -361,9 +462,6 @@ function _filterWithdrawals(result, constraints) { } else if (w.isCrossChain && amt.lt(constraints.crossChainMinAmount)) { w.action = ACTION_NONE; w.reason = "below cross-chain min"; - } else if (maxApy - w.apy < constraints.minApySpread) { - w.action = ACTION_NONE; - w.reason = "APY spread too small"; } } @@ -373,10 +471,21 @@ function _filterWithdrawals(result, constraints) { /** * Compute deposit budget, then distribute across deposits in APY-descending order. * Budget = approved withdrawal total + vault surplus above reserves. - * Infeasible deposits are set to ACTION_NONE with a reason. * + * Two checks replace the old binary accept/reject: + * A. Deposit capacity cap — caps amount to what the strategy can absorb without + * exceeding maxApyImpactBps. Remaining budget spills to the next strategy. + * B. Post-impact APY spread — for withdrawal-funded deposits, verifies the + * destination's post-deposit APY still beats the source by minApySpread. + * Vault-surplus-funded deposits skip this check (any positive APY > 0% idle). */ -async function _filterDeposits(result, vaultBalance, shortfall, constraints) { +async function _filterDeposits( + result, + vaultBalance, + shortfall, + constraints, + depositCapacities = {} +) { // Budget = approved withdrawals + vault surplus above reserves const approvedWithdrawals = result.filter( (a) => a.action === ACTION_WITHDRAW @@ -392,6 +501,15 @@ async function _filterDeposits(result, vaultBalance, shortfall, constraints) { ); let budget = withdrawTotal.add(vaultSurplus); + // Track surplus budget separately for spread check exemption + let surplusBudget = vaultSurplus; + + // Highest APY among approved withdrawal sources (for post-impact spread check) + const highestWithdrawalApy = + approvedWithdrawals.length > 0 + ? Math.max(...approvedWithdrawals.map((w) => w.apy)) + : null; + // Distribute to deposits in APY-descending order const deposits = result .filter((a) => a.action === ACTION_DEPOSIT) @@ -409,11 +527,21 @@ async function _filterDeposits(result, vaultBalance, shortfall, constraints) { continue; } - const amt = deposit.delta.gt(budget) ? budget : deposit.delta; + let amt = deposit.delta.gt(budget) ? budget : deposit.delta; + + // A. Cap to deposit capacity (from upfront discovery) + const capacity = depositCapacities[deposit.metaMorphoVaultAddress]; + if (capacity && capacity.maxDeposit.gt(0) && amt.gt(capacity.maxDeposit)) { + amt = capacity.maxDeposit; + deposit.reason = `capped to deposit capacity: ${fmtUsd(amt)}`; + } if (amt.lt(constraints.minMoveAmount)) { deposit.action = ACTION_NONE; - deposit.reason = "below min move"; + deposit.reason = + capacity && capacity.maxDeposit.isZero() + ? "APY impact too high even at minimum amount" + : "below min move"; continue; } if (deposit.isCrossChain && amt.lt(constraints.crossChainMinAmount)) { @@ -421,38 +549,43 @@ async function _filterDeposits(result, vaultBalance, shortfall, constraints) { deposit.reason = "below cross-chain min"; continue; } + + // Update delta/target if amount was trimmed if (amt.lt(deposit.delta)) { deposit.delta = amt; deposit.targetBalance = deposit.balance.add(amt); - deposit.reason = "trimmed to available vault funds"; + if (!deposit.reason) deposit.reason = "trimmed to available vault funds"; + } + + // Store impact data from capacity discovery + if (capacity) { + deposit.impactBps = capacity.impactBps; } - // APY impact check: skip if deposit would drop vault APY by more than maxApyImpactBps. - // Skipped in unit tests (IS_TEST=true) since it requires live API calls. + // B. Post-impact APY spread check (withdrawal-funded deposits only) + // Vault surplus gets no spread check — any positive APY beats 0% idle if ( - !process.env.IS_TEST && - constraints.maxApyImpactBps && - deposit.metaMorphoVaultAddress && - deposit.morphoChainId + highestWithdrawalApy != null && + !surplusBudget.gte(amt) && + constraints.minApySpread ) { - try { - const { impactBps } = await fetchSubsquidDepositImpact( - deposit.metaMorphoVaultAddress, - deposit.morphoChainId, - amt - ); - deposit.impactBps = impactBps; - if (impactBps > constraints.maxApyImpactBps) { + const postDepositApy = capacity?.postDepositApy; + if (postDepositApy != null) { + const spread = postDepositApy - highestWithdrawalApy; + if (spread < constraints.minApySpread) { deposit.action = ACTION_NONE; - deposit.reason = `APY impact ${(impactBps / 100).toFixed(2)}% > max ${(constraints.maxApyImpactBps / 100).toFixed(2)}%`; + deposit.reason = + `post-impact spread ${(spread * 100).toFixed(2)}% ` + + `< min ${(constraints.minApySpread * 100).toFixed(2)}%`; continue; } - } catch (err) { - log(`APY impact check failed for ${deposit.name}: ${err.message}`); } } budget = budget.sub(amt); + surplusBudget = surplusBudget.sub(amt).lt(0) + ? BigNumber.from(0) + : surplusBudget.sub(amt); } return result; @@ -655,22 +788,30 @@ function _trimExcessWithdrawals(result, vaultBalance, shortfall, constraints) { * @param {BigNumber} shortfall - vault withdrawal shortfall (after addWithdrawalQueueLiquidity offset) * @param {BigNumber} vaultBalance - vault idle USDC (after addWithdrawalQueueLiquidity offset) * @param {object} [constraintOverrides] + * @param {object} [depositCapacities] - from discoverDepositCapacities() * @returns {Promise} */ async function buildExecutableActions( allocations, shortfall = BigNumber.from(0), vaultBalance = BigNumber.from(0), - constraintOverrides = {} + constraintOverrides = {}, + depositCapacities = {} ) { const constraints = { ...ousdConstraints, ...constraintOverrides }; let result = allocations.map((a) => ({ ...a })); - // 1. Filter withdrawals by feasibility (min move, cross-chain min, APY spread) + // 1. Filter withdrawals by feasibility (min move, cross-chain min, liquidity) result = _filterWithdrawals(result, constraints); - // 2. Distribute available budget across deposits (highest APY first) - result = await _filterDeposits(result, vaultBalance, shortfall, constraints); + // 2. Distribute available budget across deposits (highest APY first, capacity-aware) + result = await _filterDeposits( + result, + vaultBalance, + shortfall, + constraints, + depositCapacities + ); // 3. Cancel/trim withdrawals that exceed what approved deposits + vault deficit need result = _trimExcessWithdrawals(result, vaultBalance, shortfall, constraints); @@ -1073,13 +1214,31 @@ async function buildRebalancePlan(providers) { ousdConstraints ); - // Compute ideal (unconstrained) allocation for active strategies only + // Discover deposit capacities (binary search for max deposit per strategy) + log("Discovering deposit capacities..."); + const deployableCapital = _computeDeployableCapital( + active, + state.vaultBalance, + state.shortfall, + ousdConstraints + ); + let depositCapacities = {}; + if (!process.env.IS_TEST) { + depositCapacities = await discoverDepositCapacities( + active, + deployableCapital, + ousdConstraints + ); + } + + // Compute ideal allocation for active strategies (capacity-aware) const idealActive = computeIdealAllocation({ strategies: active, apys, graphqlApys, vaultBalance: state.vaultBalance, shortfall: state.shortfall, + depositCapacities, }); // Build frozen rows for excluded strategies @@ -1106,7 +1265,9 @@ async function buildRebalancePlan(providers) { const executableActions = await buildExecutableActions( idealActions, state.shortfall, - state.vaultBalance + state.vaultBalance, + {}, + depositCapacities ); const actions = sortActions(executableActions); @@ -1124,6 +1285,7 @@ async function buildRebalancePlan(providers) { module.exports = { readOnChainState, fetchMaxWithdrawals, + discoverDepositCapacities, computeIdealAllocation, buildExecutableActions, sortActions, From ff2e8aa844fa1044c1ee6aa748a12a626c564a1b Mon Sep 17 00:00:00 2001 From: Shahul Hameed <10547529+shahthepro@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:31:49 +0530 Subject: [PATCH 09/10] Fix available liquidity --- contracts/utils/rebalancer.js | 47 ++++++++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/contracts/utils/rebalancer.js b/contracts/utils/rebalancer.js index d3a08691c3..824e652a70 100644 --- a/contracts/utils/rebalancer.js +++ b/contracts/utils/rebalancer.js @@ -13,6 +13,13 @@ const log = require("./logger")("utils:rebalancer"); const USDC_DECIMALS = 6; +// USDC token address per chain (used by fetchMaxWithdrawals for cross-chain liquidity reads) +const USDC_BY_CHAIN = { + 1: addresses.mainnet.USDC, + 8453: addresses.base.USDC, + 999: addresses.hyperevm.USDC, +}; + // Action constants shared with Defender Actions and tests const ACTION_DEPOSIT = "deposit"; const ACTION_WITHDRAW = "withdraw"; @@ -43,6 +50,9 @@ const platformAddressAbi = [ const erc4626MaxWithdrawAbi = [ "function maxWithdraw(address owner) external view returns (uint256)", ]; +const liquidityAdapterAbi = [ + "function liquidityAdapter() external view returns (address)", +]; /** * Read on-chain state: Morpho strategy balances, vault idle USDC, withdrawal queue. @@ -1128,9 +1138,9 @@ function _filterExcludedStrategies(strategies, apys, constraints) { * Fetch the immediately withdrawable amount for each strategy. * * - Same-chain (Ethereum Morpho V2): call strategy.maxWithdraw() on the mainnet provider. - * - Cross-chain: the master and remote strategy share the same address (CREATE2 deployment). - * Use the remote-chain provider, call remoteStrategy.platformAddress() to get the Morpho - * V2 vault address, then call vault.maxWithdraw(strategyAddress) (ERC-4626). + * - Cross-chain: replicate MorphoV2VaultUtils.maxWithdrawableAssets() — sum USDC idle on + * VaultV2 + MetaMorphoV1.1.maxWithdraw(adapter). VaultV2's ERC-4626 maxWithdraw(owner) + * does not traverse the adapter chain, so we must query each layer separately. * - If the required provider is unavailable, the entry is omitted and no constraint is applied. * * @param {Array} strategies - strategy config objects (from ousdMorphoStrategiesConfig) @@ -1152,7 +1162,9 @@ async function fetchMaxWithdrawals(strategies, providers = {}) { ); results[s.address] = await contract.maxWithdraw(); } else { - // Master and remote strategy share the same address via CREATE2 + // Cross-chain: replicate MorphoV2VaultUtils.maxWithdrawableAssets() + // VaultV2's ERC-4626 maxWithdraw(owner) doesn't traverse the adapter + // chain, so we manually sum idle USDC + adapter's MetaMorpho V1.1 liquidity. const provider = providers[s.morphoChainId]; if (!provider) return; const remoteStrategy = new ethers.Contract( @@ -1160,13 +1172,30 @@ async function fetchMaxWithdrawals(strategies, providers = {}) { platformAddressAbi, provider ); - const vaultAddress = await remoteStrategy.platformAddress(); - const vault = new ethers.Contract( - vaultAddress, + const vaultV2Addr = await remoteStrategy.platformAddress(); + + // 1. USDC idle on VaultV2 + const usdcAddr = USDC_BY_CHAIN[s.morphoChainId]; + const usdcOnVault = await new ethers.Contract( + usdcAddr, + erc20Abi, + provider + ).balanceOf(vaultV2Addr); + + // 2. Adapter's available liquidity from MetaMorpho V1.1 + const adapter = await new ethers.Contract( + vaultV2Addr, + liquidityAdapterAbi, + provider + ).liquidityAdapter(); + const adapterLiquidity = await new ethers.Contract( + s.metaMorphoVaultAddress, erc4626MaxWithdrawAbi, provider - ); - results[s.address] = await vault.maxWithdraw(s.address); + ).maxWithdraw(adapter); + + // 3. Total available = idle + adapter liquidity (matches on-chain logic) + results[s.address] = usdcOnVault.add(adapterLiquidity); } } catch (err) { console.error( From 644d18006825863982c469e06b0874ef2b7d2bfb Mon Sep 17 00:00:00 2001 From: Shahul Hameed <10547529+shahthepro@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:24:01 +0530 Subject: [PATCH 10/10] Address CR comments --- contracts/utils/rebalancer.js | 48 +++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/contracts/utils/rebalancer.js b/contracts/utils/rebalancer.js index 824e652a70..f613c4a91e 100644 --- a/contracts/utils/rebalancer.js +++ b/contracts/utils/rebalancer.js @@ -349,13 +349,19 @@ async function _findMaxDeposit(vaultAddress, chainId, maxAmt, constraints) { }; } - // Binary search - let lo = BigNumber.from(constraints.minMoveAmount); + const minMove = BigNumber.from(constraints.minMoveAmount); + + // For narrow ranges (maxAmt < 2×step), step-aligned rounding collapses mid to 0. + // Fall back to minMoveAmount as the step so the search can still make progress. + const effectiveStep = maxAmt.lt(step.mul(2)) ? minMove : step; + + // Binary search for the largest deposit within the APY impact threshold + let lo = minMove; let hi = maxAmt; let best = { maxDeposit: BigNumber.from(0), impactBps: 0, postDepositApy: 0 }; - while (hi.sub(lo).gt(step)) { - const mid = lo.add(hi).div(2).div(step).mul(step); // round to step + while (hi.sub(lo).gte(effectiveStep)) { + const mid = lo.add(hi).div(2).div(effectiveStep).mul(effectiveStep); if (mid.lt(lo)) break; const { impactBps, newApy } = await fetchSubsquidDepositImpact( @@ -365,11 +371,23 @@ async function _findMaxDeposit(vaultAddress, chainId, maxAmt, constraints) { ); if (impactBps <= constraints.maxApyImpactBps) { best = { maxDeposit: mid, impactBps, postDepositApy: newApy }; - lo = mid.add(step); + lo = mid.add(effectiveStep); } else { hi = mid; } } + + // Probe minMoveAmount if search found nothing — verifies feasibility at the floor + if (best.maxDeposit.isZero()) { + const { impactBps, newApy } = await fetchSubsquidDepositImpact( + vaultAddress, + chainId, + minMove + ); + if (impactBps <= constraints.maxApyImpactBps) { + best = { maxDeposit: minMove, impactBps, postDepositApy: newApy }; + } + } return best; } @@ -420,7 +438,13 @@ async function discoverDepositCapacities( ); } catch (err) { log(`Deposit capacity discovery failed for ${s.name}: ${err.message}`); - // No capacity constraint applied — fallback to full amount + // Fail-closed: if we can't determine capacity, don't allow deposits. + // A partial Subsquid outage should not bypass the APY impact guard. + capacities[s.metaMorphoVaultAddress] = { + maxDeposit: BigNumber.from(0), + postDepositApy: 0, + impactBps: 0, + }; } }) ); @@ -739,8 +763,16 @@ function _trimExcessWithdrawals(result, vaultBalance, shortfall, constraints) { ? vaultBalance.sub(vaultTarget) : BigNumber.from(0); - // Process smallest withdrawal first — prefer cancelling small withdrawals - // over trimming large ones. + // Process smallest-first: cancelling a small withdrawal entirely (1 fewer bridge tx) + // is cheaper than trimming a large one (which still requires the bridge). With two + // approved withdrawals (e.g. Base $30K, HyperEVM $300K) and excess = $30K, + // smallest-first cancels the $30K entirely → 1 bridge tx. Largest-first would trim + // the $300K to $270K → 2 bridge txs. + // + // Safety: in the full-cancel branch (amt ≤ excess), no budget check is needed because + // excess = totalWithdrawals − totalNeeded, so cancelling any single withdrawal whose + // amount ≤ excess cannot underfund approved deposits. The partial-trim and cancel-below- + // minMoveAmount branches do check budgetAfterCancel. const sorted = [...withdrawals].sort((a, b) => a.delta.abs().lt(b.delta.abs()) ? -1 : 1 );