diff --git a/contracts/scripts/defender-actions/ousdRebalancer.js b/contracts/scripts/defender-actions/ousdRebalancer.js index ff20a27452..d75db81ca1 100644 --- a/contracts/scripts/defender-actions/ousdRebalancer.js +++ b/contracts/scripts/defender-actions/ousdRebalancer.js @@ -60,9 +60,14 @@ const buildDiscordMessage = ({ a.withdrawableLiquidity != null ? 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`; return ` ${a.name.padEnd(20)} ${formatUSDC(a.balance).padStart( 9 - )} ${avail.padStart(9)} ${(a.apy * 100).toFixed(2)}% APY`; + )} ${avail.padStart(9)} ${apyStr}`; }); currentLines.push( ` ${"Vault idle".padEnd(20)} ${formatUSDC(state.vaultBalance).padStart(9)}` @@ -137,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 new file mode 100644 index 0000000000..50513ae6cf --- /dev/null +++ b/contracts/test/rebalancer/rebalancer.base.fork-test.js @@ -0,0 +1,17 @@ +const { expect } = require("chai"); + +const { fetchMorphoApys } = require("../../utils/morpho-apy"); +const addresses = require("../../utils/addresses"); + +describe("ForkTest: Rebalancer APY — Base", function () { + 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)}%`); + }); +}); 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..abf435400e --- /dev/null +++ b/contracts/test/rebalancer/rebalancer.hyperevm.fork-test.js @@ -0,0 +1,17 @@ +const { expect } = require("chai"); + +const { fetchMorphoApys } = require("../../utils/morpho-apy"); +const addresses = require("../../utils/addresses"); + +describe("ForkTest: Rebalancer APY — HyperEVM", function () { + 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)}%`); + }); +}); diff --git a/contracts/test/utils/rebalancer.js b/contracts/test/rebalancer/rebalancer.js similarity index 83% rename from contracts/test/utils/rebalancer.js rename to contracts/test/rebalancer/rebalancer.js index 9e20d307f4..a109951b5f 100644 --- a/contracts/test/utils/rebalancer.js +++ b/contracts/test/rebalancer/rebalancer.js @@ -24,17 +24,22 @@ function makeStrategy( isCrossChain = false, isDefault = false, isTransferPending = false, - morphoVaultAddress, + metaMorphoVaultAddress, + minAllocationBps, + maxAllocationBps = 9500, } = {} ) { 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, + minAllocationBps: + minAllocationBps != null ? minAllocationBps : isDefault ? 500 : 0, + maxAllocationBps, balance: usdc(balanceUsdc), }; } @@ -43,11 +48,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, }), ]; } @@ -58,7 +63,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 +75,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 +93,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 +109,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 +145,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 +161,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", () => { @@ -255,7 +260,7 @@ describe("Rebalancer: buildExecutableActions", () => { isCrossChain, isDefault, isTransferPending, - morphoVaultAddress: `0xVault_${name}`, + metaMorphoVaultAddress: `0xVault_${name}`, balance: balanceBN, targetBalance: targetBN, delta, @@ -270,7 +275,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 +285,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,28 +300,28 @@ 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", () => { - // 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 - const result = buildExecutableActions(allocs, ZERO, usdc(0)); + // 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", () => { + it("should allow withdrawal when APY spread is sufficient", async () => { const allocs = [ makeAllocation("Ethereum Morpho", 700000, 500000, 0.03, { isDefault: true, @@ -326,12 +331,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 +347,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 +363,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 +379,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 +394,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 +410,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 +420,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 +432,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 +448,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 +462,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 +472,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,16 +490,16 @@ 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 + // Pass 1 filtered due to insufficient liquidity const allocs = [ makeAllocation("Ethereum Morpho", 700000, 500000, 0.05, { isDefault: true, @@ -503,8 +508,9 @@ describe("Rebalancer: buildExecutableActions", () => { isCrossChain: true, }), ]; - // APY spread = 0.004 < 0.005 → filtered in Pass 1 - const result = buildExecutableActions(allocs, usdc(50000), usdc(0)); + // 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); // max(200K overallocation, 50K shortfall) = 200K, capped at balance (700K) @@ -512,17 +518,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 +541,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 +556,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 +572,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 +603,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 +612,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 +623,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 +635,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 +646,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 +664,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 +674,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 +697,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,19 +711,21 @@ describe("Rebalancer: buildExecutableActions", () => { // Budget reconciliation (Pass B) - it("deposit trimmed to vault surplus when withdraw is filtered by APY spread", () => { - // 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 = 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 +738,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 +753,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 +773,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 +781,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,24 +797,26 @@ 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", () => { - // Same setup but vault surplus = 10K < crossChainMinAmount (25K) + it("deposit discarded when trimmed amount falls below cross-chain min", async () => { + // 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 = 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/test/rebalancer/rebalancer.mainnet.fork-test.js b/contracts/test/rebalancer/rebalancer.mainnet.fork-test.js new file mode 100644 index 0000000000..304debde34 --- /dev/null +++ b/contracts/test/rebalancer/rebalancer.mainnet.fork-test.js @@ -0,0 +1,17 @@ +const { expect } = require("chai"); + +const { fetchMorphoApys } = require("../../utils/morpho-apy"); +const addresses = require("../../utils/addresses"); + +describe("ForkTest: Rebalancer APY — Ethereum", function () { + 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)}%`); + }); +}); diff --git a/contracts/utils/addresses.js b/contracts/utils/addresses.js index 9cf441b3fc..78eec1b5b0 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"; @@ -400,6 +406,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..8dbed50c14 --- /dev/null +++ b/contracts/utils/morpho-apy.js @@ -0,0 +1,175 @@ +const log = require("./logger")("utils:morpho-apy"); + +const DEFAULT_SUBSQUID_URL = + "https://origin.squids.live/origin-squid:prod/api/graphql"; + +const MORPHO_GRAPHQL_URL = "https://api.morpho.org/graphql"; + +// ─── Generic GraphQL helpers ────────────────────────────────────────────────── + +/** + * POST a GraphQL query to the Origin Subsquid server. + * URL is read from ORIGIN_SUBSQUID_SERVER env var, with a hardcoded default. + * + * @param {string} query - GraphQL query string + * @returns {Promise} parsed `data` field from the response + */ +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}`); + } + + const json = await response.json(); + if (json.errors) { + throw new Error( + `Subsquid GraphQL error: ${json.errors.map((e) => e.message).join(", ")}` + ); + } + return json.data; +} + +// ─── Subsquid APY (authoritative — used for rebalancer decisions) ───────────── + +/** + * Fetch the current vault APY from the Origin Subsquid indexer. + * + * @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 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 apy != null ? Number(apy) : 0; +} + +/** + * Simulate a deposit and measure APY impact via the Origin Subsquid indexer. + * + * @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 }>} + */ +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` + ); + return { + currentApy: result.currentApy, + newApy: result.newApy, + impactBps: result.impactBps, + }; +} + +// ─── Morpho API (display-only — NOT used for rebalancer decisions) ──────────── + +/** + * Fetch vault netApy from Morpho's own GraphQL API. + * This is display-only (shown as "API: X.XX%" in the allocations table). + * + * @param {string} vaultAddress - MetaMorpho V1.1 vault address + * @param {number} chainId + * @returns {Promise} APY as a decimal + */ +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; + } +} + +// ─── Combined fetcher ───────────────────────────────────────────────────────── + +/** + * Fetch APYs for multiple vaults in parallel. + * Returns both subsquid (authoritative) and Morpho API (display-only) APYs. + * + * @param {Array} vaults - objects with { metaMorphoVaultAddress, morphoChainId } + * @returns {Promise<{ apys: Object, graphqlApys: Object }>} + */ +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), + ]); + + return { + addr: v.metaMorphoVaultAddress, + subsquidApy, + morphoApy, + }; + }) + ); + + const apys = {}; + const graphqlApys = {}; + for (const { addr, subsquidApy, morphoApy } of entries) { + apys[addr] = subsquidApy; + graphqlApys[addr] = morphoApy; + } + return { apys, graphqlApys }; +} + +module.exports = { fetchMorphoApys, fetchSubsquidDepositImpact }; diff --git a/contracts/utils/morpho-apy.md b/contracts/utils/morpho-apy.md new file mode 100644 index 0000000000..221e7cf4f8 --- /dev/null +++ b/contracts/utils/morpho-apy.md @@ -0,0 +1,270 @@ +# 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`). + +**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 c1a936ef54..d337157419 100644 --- a/contracts/utils/rebalancer-config.js +++ b/contracts/utils/rebalancer-config.js @@ -5,44 +5,49 @@ 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 – MetaMorpho V1 vault for APY lookup via the Morpho GraphQL API. - * Must be the inner MetaMorpho V1 vault, not a VaultV2 wrapper — - * the Morpho API does not index VaultV2. - * Derived via: VaultV2(outerVaultAddr).adapters(0) → adapter; - * adapter.morphoVaultV1() - * 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 + * minAllocationBps – Minimum allocation in basis points (e.g. 500 = 5%) + * maxAllocationBps – Maximum allocation in basis points (e.g. 9500 = 95%) */ 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, + minAllocationBps: 500, // ≥5% + maxAllocationBps: 10000, // 100% — default strategy can hold everything }, { name: "Base Morpho", address: addresses.mainnet.CrossChainMasterStrategy, - // MetaMorpho V1 vault on Base for APY lookup (VaultV2 is not indexed by Morpho API) - morphoVaultAddress: addresses.base.MorphoOusdV1Vault, + metaMorphoVaultAddress: addresses.base.MorphoOusdV1Vault, morphoChainId: 8453, isCrossChain: true, isDefault: false, + minAllocationBps: 0, + maxAllocationBps: 9500, // ≤95% }, { name: "HyperEVM Morpho", address: addresses.mainnet.CrossChainHyperEVMMasterStrategy, - // MetaMorpho V1 vault on HyperEVM for APY lookup (VaultV2 is not indexed by Morpho API) - morphoVaultAddress: addresses.hyperevm.MorphoOusdV1Vault, + metaMorphoVaultAddress: addresses.hyperevm.MorphoOusdV1Vault, morphoChainId: 999, isCrossChain: true, isDefault: false, + minAllocationBps: 0, + maxAllocationBps: 9500, // ≤95% }, ]; @@ -50,13 +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, // 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 8ff62bc799..f613c4a91e 100644 --- a/contracts/utils/rebalancer.js +++ b/contracts/utils/rebalancer.js @@ -7,11 +7,19 @@ const { ousdMorphoStrategiesConfig, ousdConstraints, } = require("./rebalancer-config"); +const { fetchMorphoApys, fetchSubsquidDepositImpact } = require("./morpho-apy"); 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"; @@ -42,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. @@ -90,7 +101,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, @@ -107,53 +118,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(morphoVaultAddress, morphoChainId) { - const query = `{ - vaultByAddress(address: "${morphoVaultAddress}", 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 ${morphoVaultAddress}: ${e.message}`); - return 0; - } -} - -/** - * Fetch APYs for multiple vaults in parallel. - * @param {Array} vaults - objects with morphoVaultAddress and morphoChainId - * @returns {object} map of morphoVaultAddress -> apy (float, e.g. 0.05 = 5%) - */ -async function fetchMorphoApys(vaults) { - const entries = await Promise.all( - vaults.map(async (v) => [ - v.morphoVaultAddress, - await _fetchMorphoVaultApy(v.morphoVaultAddress, v.morphoChainId), - ]) - ); - - const results = {}; - for (const [addr, apy] of entries) { - results[addr] = apy; - } - return results; -} - /** * Compute total capital minus reserved amounts (shortfall + minVaultBalance). */ @@ -174,30 +138,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; @@ -216,38 +189,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; @@ -256,7 +229,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, @@ -264,11 +237,13 @@ function _buildAllocationRow(s, targetBalance, apy) { isCrossChain: s.isCrossChain, isTransferPending: s.isTransferPending, isDefault: s.isDefault, - morphoVaultAddress: s.morphoVaultAddress, + metaMorphoVaultAddress: s.metaMorphoVaultAddress, + morphoChainId: s.morphoChainId, balance: s.balance, // Populated later by fetchMaxWithdrawals; null means unknown (no constraint applied) withdrawableLiquidity: null, apy, + graphqlApy, targetBalance, delta, action: delta.gt(0) @@ -285,26 +260,33 @@ function _buildAllocationRow(s, targetBalance, apy) { * 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 - * @param {object} params.apys - morphoVaultAddress -> apy (float) + * @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] + * @param {object} [params.depositCapacities] - from discoverDepositCapacities() * @returns {Array} allocation results */ function computeIdealAllocation({ strategies, apys, + graphqlApys = {}, vaultBalance, shortfall, constraints: overrides = {}, + depositCapacities = {}, }) { const constraints = { ...ousdConstraints, ...overrides }; - const strategyApyOf = (s) => apys[s.morphoVaultAddress] || 0; + const strategyApyOf = (s) => apys[s.metaMorphoVaultAddress] || 0; + const strategyGraphqlApyOf = (s) => + graphqlApys[s.metaMorphoVaultAddress] || 0; const deployableCapital = _computeDeployableCapital( strategies, vaultBalance, @@ -314,35 +296,159 @@ function computeIdealAllocation({ if (deployableCapital.isZero()) { return strategies.map((s) => - _buildAllocationRow(s, BigNumber.from(0), strategyApyOf(s)) + _buildAllocationRow( + s, + BigNumber.from(0), + strategyApyOf(s), + strategyGraphqlApyOf(s) + ) ); } 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(s, adjusted[s.address], strategyApyOf(s)) + _buildAllocationRow( + s, + adjusted[s.address], + strategyApyOf(s), + strategyGraphqlApyOf(s) + ) ); } /** - * 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 }} */ -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 _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, + }; + } + + 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).gte(effectiveStep)) { + const mid = lo.add(hi).div(2).div(effectiveStep).mul(effectiveStep); + 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(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; +} + +/** + * 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 } } + */ +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}`); + // 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, + }; + } + }) + ); + return capacities; } /** @@ -356,18 +462,20 @@ 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) { 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; @@ -388,9 +496,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"; } } @@ -400,9 +505,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). */ -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 @@ -418,6 +535,15 @@ 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) @@ -435,11 +561,21 @@ 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)) { @@ -447,13 +583,43 @@ 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; + } + + // B. Post-impact APY spread check (withdrawal-funded deposits only) + // Vault surplus gets no spread check — any positive APY beats 0% idle + if ( + highestWithdrawalApy != null && + !surplusBudget.gte(amt) && + constraints.minApySpread + ) { + const postDepositApy = capacity?.postDepositApy; + if (postDepositApy != null) { + const spread = postDepositApy - highestWithdrawalApy; + if (spread < constraints.minApySpread) { + deposit.action = ACTION_NONE; + deposit.reason = + `post-impact spread ${(spread * 100).toFixed(2)}% ` + + `< min ${(constraints.minApySpread * 100).toFixed(2)}%`; + continue; + } + } } budget = budget.sub(amt); + surplusBudget = surplusBudget.sub(amt).lt(0) + ? BigNumber.from(0) + : surplusBudget.sub(amt); } return result; @@ -552,36 +718,147 @@ 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-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 + ); + + 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 * @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} [depositCapacities] - from discoverDepositCapacities() + * @returns {Promise} */ -function buildExecutableActions( +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 = _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); - // 3. Fallback: cover shortfall if no withdrawals were approved + // 4. Fallback: cover shortfall if no withdrawals were approved const hasApprovedWithdrawals = result.some( (a) => a.action === ACTION_WITHDRAW ); @@ -589,7 +866,7 @@ 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)) { @@ -685,6 +962,15 @@ function printAllocationTable({ const recTarget = 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)}%`; + 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)}`, @@ -694,7 +980,8 @@ function printAllocationTable({ : "n/a", target: `${fmtUsd(recTarget)}${pct(recTarget)}`, delta: `${sign(recDelta)}${fmtUsd(recDelta.abs())}`, - apy: `${(a.apy * 100).toFixed(2)}%`, + apy: apyStr, + impact, }; }); const vaultRow = { @@ -704,6 +991,7 @@ function printAllocationTable({ target: `${fmtUsd(vaultTarget)}${pct(vaultTarget)}`, delta: `${vaultDeltaSign}${fmtUsd(vaultDelta.abs())}`, apy: "—", + impact: "—", }; const allRows = [...formattedRows, vaultRow]; const COL = { @@ -719,6 +1007,10 @@ function printAllocationTable({ ), 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( @@ -730,7 +1022,7 @@ function printAllocationTable({ COL.target )}${COL_SEP}${"Delta".padStart(COL.delta)}${COL_SEP}${"APY".padStart( COL.apy - )}` + )}${COL_SEP}${"Impact".padStart(COL.impact)}` ); console.log( "-".repeat( @@ -740,7 +1032,8 @@ function printAllocationTable({ COL.target + COL.delta + COL.apy + - COL_SEP.length * 5 + COL.impact + + COL_SEP.length * 6 ) ); @@ -751,7 +1044,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)}` ); } @@ -761,7 +1055,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( @@ -772,7 +1067,8 @@ function printAllocationTable({ COL.target + COL.delta + COL.apy + - COL_SEP.length * 5 + COL.impact + + COL_SEP.length * 6 ) ); console.log( @@ -785,7 +1081,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 { @@ -803,6 +1099,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( @@ -851,7 +1149,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 ` + @@ -872,9 +1170,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) @@ -896,7 +1194,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( @@ -904,13 +1204,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( @@ -925,9 +1242,10 @@ async function fetchMaxWithdrawals(strategies, providers = {}) { /** * Main entry: read state, fetch APYs, compute allocations, print table. - * @param {object|import('ethers').providers.Provider} providers - Either a map of - * chainId → provider (e.g. { 1: mainnetProvider, 8453: baseProvider, 999: hyperevmProvider }) - * or a single mainnet provider for backwards compatibility. + * + * @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(providers) { // Accept either a providers map { [chainId]: provider } or a legacy single provider @@ -940,8 +1258,8 @@ async function buildRebalancePlan(providers) { const state = await readOnChainState(providerMap[1] || providerMap); log("Fetching Morpho APYs..."); - const apys = await fetchMorphoApys( - state.strategies.filter((s) => s.morphoVaultAddress) + const { apys, graphqlApys } = await fetchMorphoApys( + state.strategies.filter((s) => s.metaMorphoVaultAddress) ); log("Fetching withdrawable liquidity..."); @@ -957,12 +1275,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 @@ -970,7 +1307,8 @@ async function buildRebalancePlan(providers) { const row = _buildAllocationRow( s, s.balance, - apys[s.morphoVaultAddress] || 0 + apys[s.metaMorphoVaultAddress] || 0, + graphqlApys[s.metaMorphoVaultAddress] || 0 ); row.reason = "APY exceeds threshold"; return row; @@ -985,10 +1323,12 @@ async function buildRebalancePlan(providers) { } } - const executableActions = buildExecutableActions( + const executableActions = await buildExecutableActions( idealActions, state.shortfall, - state.vaultBalance + state.vaultBalance, + {}, + depositCapacities ); const actions = sortActions(executableActions); @@ -1000,13 +1340,13 @@ async function buildRebalancePlan(providers) { warnings, }); - return { actions, idealActions, state, apys, warnings }; + return { actions, idealActions, state, apys, graphqlApys, warnings }; } module.exports = { readOnChainState, - fetchMorphoApys, fetchMaxWithdrawals, + discoverDepositCapacities, computeIdealAllocation, buildExecutableActions, sortActions, diff --git a/contracts/utils/rebalancer.md b/contracts/utils/rebalancer.md index a7b4393a7f..e95b5e227b 100644 --- a/contracts/utils/rebalancer.md +++ b/contracts/utils/rebalancer.md @@ -62,7 +62,7 @@ Cross-chain amounts are capped at 10 M USDC (CCTP bridge limit). |-------|-------------| | `name` | Human-readable label | | `address` | Strategy address on mainnet | -| `morphoVaultAddress` | MetaMorpho V1 vault address used for APY lookup via the Morpho GraphQL API. Must be the inner MetaMorpho V1 vault, **not** a VaultV2 wrapper — the Morpho API does not index VaultV2. Derived via: `VaultV2(outerVaultAddr).adapters(0)` → adapter; `adapter.morphoVaultV1()`. | +| `metaMorphoVaultAddress` | The inner MetaMorpho V1.1 vault. All OUSD Morpho deployments are VaultV2 wrappers; this is the underlying vault that actually holds Morpho Blue positions and has `supplyQueueLength()`. Used for on-chain APY reads and Morpho GraphQL API lookups. Derived via: `VaultV2(outerVaultAddr).adapters(0)` → adapter; `adapter.morphoVaultV1()`. | | `morphoChainId` | Chain where the Morpho vault lives (1 = Ethereum, 8453 = Base, 999 = HyperEVM) | | `isCrossChain` | If it's a CrossChain strategy using CCTP | | `isDefault` | Fallback strategy — exactly one per config | @@ -84,3 +84,4 @@ the recommended (feasible) target balance after all constraints are applied. | `minVaultBalance` | $3 K USDC | Idle reserve always kept in the vault | | `minApySpread` | 0.5 % | Minimum APY improvement required to trigger a withdrawal | | `maxApyThreshold` | 50 % | APY above this is treated as suspicious — strategy is frozen in place | +| `maxApyImpactBps` | 50 bps | Skip a deposit if it would reduce the vault's on-chain APY by more than this amount |