diff --git a/brownie/addresses.py b/brownie/addresses.py index dd267d9545..0af0621d46 100644 --- a/brownie/addresses.py +++ b/brownie/addresses.py @@ -213,7 +213,7 @@ ## Safe Modules ETHEREUM_BRIDGE_HELPER_MODULE = "0x630C1763D38AbE76301F58909fa174E7B84A7ECD" -BASE_BRIDGE_HELPER_MODULE = "0x362DBD4Ff662b2E2b05b9cEDC91da2Dd2c655b26" +BASE_BRIDGE_HELPER_MODULE = "0xe3B3b4Fc77505EcfAACf6dD21619a8Cc12fcc501" PLUME_BRIDGE_HELPER_MODULE = "0xAc58C88349e00509FEc216E1B61d13b43315E18D" ## Morpho V2 (Base) Crosschain strategy diff --git a/brownie/world_base.py b/brownie/world_base.py index 5e2c1614cc..505b5c0262 100644 --- a/brownie/world_base.py +++ b/brownie/world_base.py @@ -23,6 +23,7 @@ aero_helper = load_contract('aerodrome_slipstream_sugar_helper', AERODROME_SUGAR_HELPER_BASE) amo_pool = load_contract('aerodrome_slipstream_pool', AERODROME_WETH_OETHB_POOL_BASE) curve_pool = load_contract('curve_pool_base', CURVE_POOL_BASE) +base_curve_amo_strat = load_contract('ousd_curve_amo_strat', OETHB_CURVE_AMO_STRATEGY) aerodrome_voter = load_contract('aerodrome_voter', AERO_VOTER_BASE) diff --git a/contracts/scripts/defender-actions/ousdRebalancer.js b/contracts/scripts/defender-actions/ousdRebalancer.js index 9b1df9a867..91ffdb9b76 100644 --- a/contracts/scripts/defender-actions/ousdRebalancer.js +++ b/contracts/scripts/defender-actions/ousdRebalancer.js @@ -79,6 +79,7 @@ const handler = async (event) => { warnings: plan.warnings, compact: true, baselineMarkets: plan.baselineMarkets, + portfolioApy: plan.portfolioApy, }); await postToDiscord(webhookUrl, `${header}\n\`\`\`\n${table}\n\`\`\``); } catch (err) { diff --git a/contracts/test/rebalancer/rebalancer.js b/contracts/test/rebalancer/rebalancer.js index dd87ab1b47..8f53d4223b 100644 --- a/contracts/test/rebalancer/rebalancer.js +++ b/contracts/test/rebalancer/rebalancer.js @@ -8,6 +8,9 @@ const { computeImpactAwareAllocation, buildExecutableActions, formatAllocationTable, + computePortfolioApy, + _applyPortfolioSpreadGate, + _markShortfallWithdrawals, ACTION_DEPOSIT, ACTION_WITHDRAW, ACTION_NONE, @@ -903,9 +906,7 @@ describe("Rebalancer: buildExecutableActions", () => { const deposits = result.filter((a) => a.action === ACTION_DEPOSIT); expect(deposits).to.have.length(1); expect(deposits[0].name).to.equal("Base Morpho"); - const surplusDeposit = result.find( - (a) => a.reason && a.reason.includes("surplus fallback") - ); + const surplusDeposit = result.find((a) => a.isVaultSurplus); expect(surplusDeposit).to.be.undefined; }); @@ -1079,9 +1080,7 @@ describe("Rebalancer: buildExecutableActions", () => { const deposits = result.filter((a) => a.action === ACTION_DEPOSIT); expect(deposits).to.have.length(1); expect(deposits[0].name).to.equal("Base Morpho"); - const surplusDeposit = result.find( - (a) => a.reason && a.reason.includes("surplus fallback") - ); + const surplusDeposit = result.find((a) => a.isVaultSurplus); expect(surplusDeposit).to.be.undefined; }); @@ -1099,9 +1098,7 @@ describe("Rebalancer: buildExecutableActions", () => { const ethRow = result.find((a) => a.isDefault); expect(ethRow.action).to.equal(ACTION_DEPOSIT); expect(ethRow.delta).to.equal(usdc(10000)); - const surplusFallback = result.find( - (a) => a.reason && a.reason.includes("surplus fallback") - ); + const surplusFallback = result.find((a) => a.isVaultSurplus); expect(surplusFallback).to.be.undefined; }); @@ -1529,6 +1526,27 @@ describe("Rebalancer: buildExecutableActions", () => { const baseRow = result.find((a) => a.isCrossChain); expect(baseRow.action).to.equal(ACTION_DEPOSIT); }); + + // ── Shortfall marking on normal-path withdrawals ──────── + + it("marks normal-path default withdrawal as isShortfall when it covers the vault deficit", async () => { + // Default overallocated → withdrawal is approved via the normal path. + // Shortfall > 0, vaultBalance = 0 → deficit = shortfall. + // Gate preservation depends on isShortfall; the normal-path withdrawal + // must be flagged even though _coverShortfall was not invoked. + const allocs = [ + makeAllocation("Ethereum Morpho", 700000, 500000, 0.05, { + isDefault: true, + }), + makeAllocation("Base Morpho", 300000, 500000, 0.04, { + isCrossChain: true, + }), + ]; + const result = await buildExecutableActions(allocs, usdc(100000), usdc(0)); + const ethRow = result.find((a) => a.isDefault); + expect(ethRow.action).to.equal(ACTION_WITHDRAW); + expect(ethRow.isShortfall).to.be.true; + }); }); // ───────────────────────────────────────────────────────── @@ -1940,3 +1958,415 @@ describe("Rebalancer: computeImpactAwareAllocation", () => { expect(ethRow.targetBalance.gte(usdc(50000))).to.be.true; }); }); + +// ───────────────────────────────────────────────────────── +// computePortfolioApy + _applyPortfolioSpreadGate +// ───────────────────────────────────────────────────────── + +// Minimal action row for gate/portfolio tests — only the fields the helpers read. +function makeAction({ + name, + balance, + targetBalance, + apy, + expectedApy, + action = ACTION_NONE, + reason = null, + isShortfall = false, + isVaultSurplus = false, +}) { + const balBn = usdc(balance); + const tgtBn = usdc(targetBalance != null ? targetBalance : balance); + return { + name, + address: `0x${name.replace(/\s/g, "").toLowerCase()}`, + balance: balBn, + targetBalance: tgtBn, + delta: tgtBn.sub(balBn), + apy, + expectedApy, + action, + reason, + isShortfall, + isVaultSurplus, + }; +} + +describe("Rebalancer: computePortfolioApy", () => { + it("weights strategy APYs by balance", () => { + const actions = [ + makeAction({ name: "A", balance: 600000, apy: 0.05 }), + makeAction({ name: "B", balance: 400000, apy: 0.03 }), + ]; + const totalCapital = actions.reduce((s, a) => s.add(a.balance), ZERO); + const apy = computePortfolioApy(actions, totalCapital, { + useTarget: false, + }); + // 600k*0.05 + 400k*0.03 = 30k + 12k = 42k; / 1M = 0.042 + expect(apy).to.be.closeTo(0.042, 1e-9); + }); + + it("includes idle vault in the denominator at 0% yield", () => { + const actions = [makeAction({ name: "A", balance: 500000, apy: 0.1 })]; + // 500k idle vault + 500k strategy @ 10% → 0.05 + const totalCapital = usdc(500000).add(actions[0].balance); + const apy = computePortfolioApy(actions, totalCapital, { + useTarget: false, + }); + expect(apy).to.be.closeTo(0.05, 1e-9); + }); + + it("useTarget=true uses expectedApy and targetBalance", () => { + const actions = [ + makeAction({ + name: "A", + balance: 500000, + targetBalance: 700000, + apy: 0.05, + expectedApy: 0.04, + action: ACTION_DEPOSIT, + }), + makeAction({ + name: "B", + balance: 500000, + targetBalance: 300000, + apy: 0.03, + expectedApy: 0.035, + action: ACTION_WITHDRAW, + }), + ]; + const totalCapital = usdc(1000000); + const apy = computePortfolioApy(actions, totalCapital, { useTarget: true }); + // 700k*0.04 + 300k*0.035 = 28k + 10.5k = 38.5k; / 1M = 0.0385 + expect(apy).to.be.closeTo(0.0385, 1e-9); + }); + + it("falls back to apy when expectedApy is missing", () => { + const actions = [ + makeAction({ + name: "A", + balance: 500000, + targetBalance: 500000, + apy: 0.05, + }), + ]; + const totalCapital = usdc(500000); + const apy = computePortfolioApy(actions, totalCapital, { useTarget: true }); + expect(apy).to.be.closeTo(0.05, 1e-9); + }); + + it("returns 0 for zero totalCapital", () => { + expect(computePortfolioApy([], ZERO, { useTarget: false })).to.equal(0); + }); +}); + +describe("Rebalancer: _applyPortfolioSpreadGate", () => { + const constraints = { minApySpread: 0.005 }; // 50 bps + + it("drops yield-motivated actions when spread below threshold", () => { + const actions = [ + makeAction({ + name: "Src", + balance: 500000, + targetBalance: 400000, + apy: 0.04, + expectedApy: 0.042, + action: ACTION_WITHDRAW, + reason: "move to higher APY", + }), + makeAction({ + name: "Dst", + balance: 500000, + targetBalance: 600000, + apy: 0.045, + expectedApy: 0.044, + action: ACTION_DEPOSIT, + reason: null, + }), + ]; + const totalCapital = usdc(1000000); + const warnings = []; + const res = _applyPortfolioSpreadGate( + actions, + totalCapital, + constraints, + warnings + ); + + // Lift is < 50 bps, so both actions are cancelled. + expect(res.gated).to.be.true; + expect(actions[0].action).to.equal(ACTION_NONE); + expect(actions[1].action).to.equal(ACTION_NONE); + expect(actions[0].delta.eq(ZERO)).to.be.true; + expect(actions[0].targetBalance.eq(actions[0].balance)).to.be.true; + expect(warnings.length).to.equal(1); + expect(warnings[0]).to.match(/yield-motivated actions dropped/); + // After-APY is recomputed post-drop and should equal before. + expect(res.after).to.be.closeTo(res.before, 1e-9); + }); + + it("preserves shortfall withdrawals when the gate fires", () => { + const actions = [ + makeAction({ + name: "Src", + balance: 500000, + targetBalance: 400000, + apy: 0.04, + expectedApy: 0.042, + action: ACTION_WITHDRAW, + reason: "rebalance", + }), + makeAction({ + name: "Dst", + balance: 500000, + targetBalance: 500000, + apy: 0.045, + expectedApy: 0.045, + action: ACTION_WITHDRAW, + reason: "shortfall fallback", + isShortfall: true, + }), + ]; + const totalCapital = usdc(1000000); + const warnings = []; + _applyPortfolioSpreadGate(actions, totalCapital, constraints, warnings); + + expect(actions[0].action).to.equal(ACTION_NONE); + expect(actions[1].action).to.equal(ACTION_WITHDRAW); + expect(actions[1].reason).to.equal("shortfall fallback"); + }); + + it("preserves vault-surplus deposits when the gate fires", () => { + const actions = [ + makeAction({ + name: "Src", + balance: 500000, + targetBalance: 400000, + apy: 0.04, + expectedApy: 0.041, + action: ACTION_WITHDRAW, + reason: "rebalance", + }), + makeAction({ + name: "Default", + balance: 500000, + targetBalance: 600000, + apy: 0.045, + expectedApy: 0.044, + action: ACTION_DEPOSIT, + reason: "vault surplus fallback", + isVaultSurplus: true, + }), + ]; + const totalCapital = usdc(1000000); + const warnings = []; + _applyPortfolioSpreadGate(actions, totalCapital, constraints, warnings); + + expect(actions[0].action).to.equal(ACTION_NONE); + expect(actions[1].action).to.equal(ACTION_DEPOSIT); + expect(actions[1].reason).to.equal("vault surplus fallback"); + }); + + it("preserves the surplus-netted-against-withdrawal branch", () => { + const actions = [ + makeAction({ + name: "Default", + balance: 500000, + targetBalance: 550000, + apy: 0.045, + expectedApy: 0.044, + action: ACTION_DEPOSIT, + reason: "vault surplus (net of cancelled withdrawal)", + isVaultSurplus: true, + }), + ]; + const totalCapital = usdc(500000); + const warnings = []; + _applyPortfolioSpreadGate(actions, totalCapital, constraints, warnings); + expect(actions[0].action).to.equal(ACTION_DEPOSIT); + }); + + it("no-op when spread meets threshold", () => { + const actions = [ + makeAction({ + name: "Src", + balance: 500000, + targetBalance: 300000, + apy: 0.02, + expectedApy: 0.02, + action: ACTION_WITHDRAW, + }), + makeAction({ + name: "Dst", + balance: 500000, + targetBalance: 700000, + apy: 0.08, + expectedApy: 0.08, + action: ACTION_DEPOSIT, + }), + ]; + const totalCapital = usdc(1000000); + const warnings = []; + const res = _applyPortfolioSpreadGate( + actions, + totalCapital, + constraints, + warnings + ); + // Before = 0.5*0.02 + 0.5*0.08 = 0.05 + // After = 0.3*0.02 + 0.7*0.08 = 0.062 → +120 bps, above threshold + expect(res.gated).to.be.false; + expect(actions[0].action).to.equal(ACTION_WITHDRAW); + expect(actions[1].action).to.equal(ACTION_DEPOSIT); + expect(warnings.length).to.equal(0); + }); + + it("does not fire when minApySpread is not set", () => { + const actions = [ + makeAction({ + name: "Src", + balance: 500000, + targetBalance: 400000, + apy: 0.04, + expectedApy: 0.04, + action: ACTION_WITHDRAW, + reason: "rebalance", + }), + ]; + const totalCapital = usdc(500000); + const warnings = []; + const res = _applyPortfolioSpreadGate(actions, totalCapital, {}, warnings); + expect(res.gated).to.be.false; + expect(actions[0].action).to.equal(ACTION_WITHDRAW); + }); +}); + +// ───────────────────────────────────────────────────────── +// _markShortfallWithdrawals +// ───────────────────────────────────────────────────────── + +function makeWithdrawAction(name, balance, withdrawAmount, apy, flags = {}) { + const balBn = usdc(balance); + const amtBn = usdc(withdrawAmount); + return { + name, + address: `0x${name.replace(/\s/g, "").toLowerCase()}`, + balance: balBn, + targetBalance: balBn.sub(amtBn), + delta: amtBn.mul(-1), + apy, + action: ACTION_WITHDRAW, + isDefault: !!flags.isDefault, + isCrossChain: !!flags.isCrossChain, + isShortfall: false, + isVaultSurplus: false, + }; +} + +describe("Rebalancer: _markShortfallWithdrawals", () => { + const constraints = { minVaultBalance: 0 }; + + it("flags the default-strategy withdrawal when it fully covers the deficit", () => { + const actions = [ + makeWithdrawAction("Default", 500000, 100000, 0.05, { isDefault: true }), + makeWithdrawAction("CrossChain", 500000, 100000, 0.03, { + isCrossChain: true, + }), + ]; + // vaultBalance=0, shortfall=50K → deficit=50K, default withdrawal=100K covers it + _markShortfallWithdrawals(actions, ZERO, usdc(50000), constraints); + expect(actions[0].isShortfall).to.be.true; + expect(actions[1].isShortfall).to.be.false; + }); + + it("walks cross-chain by lowest APY after default", () => { + const actions = [ + makeWithdrawAction("Default", 100000, 20000, 0.05, { isDefault: true }), + makeWithdrawAction("HighApyCC", 300000, 120000, 0.08, { + isCrossChain: true, + }), + makeWithdrawAction("LowApyCC", 300000, 120000, 0.03, { + isCrossChain: true, + }), + ]; + // deficit=130K: default covers 20K, lowest-APY cross-chain covers the rest + _markShortfallWithdrawals(actions, ZERO, usdc(130000), constraints); + expect(actions[0].isShortfall).to.be.true; + expect(actions[2].isShortfall).to.be.true; + expect(actions[1].isShortfall).to.be.false; + }); + + it("does nothing when vault balance already covers the target", () => { + const actions = [ + makeWithdrawAction("Default", 500000, 100000, 0.05, { isDefault: true }), + ]; + // vaultBalance=200K >= shortfall=50K → deficit=0 + _markShortfallWithdrawals(actions, usdc(200000), usdc(50000), constraints); + expect(actions[0].isShortfall).to.be.false; + }); + + it("is a no-op when no approved withdrawals exist", () => { + const actions = [ + { + ...makeWithdrawAction("Default", 500000, 100000, 0.05, { + isDefault: true, + }), + action: ACTION_NONE, + }, + ]; + _markShortfallWithdrawals(actions, ZERO, usdc(50000), constraints); + expect(actions[0].isShortfall).to.be.false; + }); + + it("respects minVaultBalance when computing the deficit", () => { + const actions = [ + makeWithdrawAction("Default", 500000, 30000, 0.05, { isDefault: true }), + ]; + // vaultBalance=100K, shortfall=50K, minVaultBalance=60K → target=110K, deficit=10K + _markShortfallWithdrawals(actions, usdc(100000), usdc(50000), { + minVaultBalance: usdc(60000), + }); + expect(actions[0].isShortfall).to.be.true; + }); +}); + +// ───────────────────────────────────────────────────────── +// End-to-end gate regression (mirrors the production failure) +// ───────────────────────────────────────────────────────── + +describe("Rebalancer: shortfall funding survives the spread gate", () => { + it("preserves normal-path withdrawals that cover the vault deficit", async () => { + // Ethereum Morpho overallocated at 14% APY; Base/HyperEVM underfunded at + // lower APY. Rebalance withdraws from ETH → APY lift turns negative. With + // shortfall > 0, the withdrawal must survive the spread gate. + const allocs = [ + makeAllocation("Ethereum Morpho", 500000, 400000, 0.14, { + isDefault: true, + }), + makeAllocation("Base Morpho", 500000, 600000, 0.04, { + isCrossChain: true, + }), + ]; + const actions = await buildExecutableActions(allocs, usdc(100000), usdc(0)); + + const totalCapital = actions.reduce((sum, a) => sum.add(a.balance), ZERO); + const warnings = []; + const res = _applyPortfolioSpreadGate( + actions, + totalCapital, + { minApySpread: 0.001 }, + warnings + ); + + const ethRow = actions.find((a) => a.isDefault); + const baseRow = actions.find((a) => a.isCrossChain); + + // Gate fires because APY lift is negative. + expect(res.gated).to.be.true; + // ETH withdrawal covers the shortfall → flagged and preserved. + expect(ethRow.action).to.equal(ACTION_WITHDRAW); + expect(ethRow.isShortfall).to.be.true; + // Base deposit is yield-motivated → dropped. + expect(baseRow.action).to.equal(ACTION_NONE); + }); +}); diff --git a/contracts/utils/rebalancer-config.js b/contracts/utils/rebalancer-config.js index 58c929ed5d..358f958346 100644 --- a/contracts/utils/rebalancer-config.js +++ b/contracts/utils/rebalancer-config.js @@ -60,11 +60,9 @@ const ousdConstraints = { minMoveAmount: 5000000000, // $5K in USDC (6 decimals) crossChainMinAmount: 25000000000, // $25K in USDC (6 decimals) minVaultBalance: 0, // no minimum vault reserve - minApySpread: 0.005, // 0.5% — post-deposit spread check (destination vs source) + minApySpread: 0.001, // 0.1% — minimum portfolio APY lift required to run yield-motivated actions maxApyThreshold: 0.5, // 50% — APY above this is treated as suspicious - maxApyImpactBps: 50, // Max APY degradation per deposit (0.5%) - maxWithdrawalApyImpactBps: 50, // Max APY increase on source per withdrawal (0.5%) - depositStepSize: 100000000000, // $100K USDC — binary search granularity + maxWithdrawalApyImpactBps: 1000, // Max APY increase on source per withdrawal (0.5%) withdrawalStepSize: 100000000000, // $100K USDC — binary search granularity maxSpotBelowAvgBps: 200, // Block deposits if spot APY is significantly below the average apyAverageWindow: "1h", // Time window for the average APY used in allocation decisions diff --git a/contracts/utils/rebalancer.js b/contracts/utils/rebalancer.js index 7c0fdabd77..5a3e8afe37 100644 --- a/contracts/utils/rebalancer.js +++ b/contracts/utils/rebalancer.js @@ -671,6 +671,98 @@ function _clearWithdrawalFields(w) { delete w.markets; } +/** + * Weighted-average portfolio APY across strategies + idle vault (at 0%). + * + * Denominator is `totalCapital` (vault + strategies), passed in so before/after + * share the same base — the balance-conservation identity + * `vaultTarget + Σ targetBalance = vaultBalance + Σ balance` means this is safe. + * + * @param {Array} actions - allocation rows (with balance, targetBalance, apy, expectedApy) + * @param {BigNumber} totalCapital - vault + sum of strategy balances + * @param {object} [opts] + * @param {boolean} [opts.useTarget=false] - if true, weight by targetBalance and use expectedApy ?? apy + * @returns {number} weighted APY as a decimal (e.g. 0.0514 for 5.14%) + */ +function computePortfolioApy( + actions, + totalCapital, + { useTarget = false } = {} +) { + const total = parseFloat(formatUnits(totalCapital, USDC_DECIMALS)); + if (!total) return 0; + let weighted = 0; + for (const a of actions) { + const balBn = useTarget ? a.targetBalance : a.balance; + const bal = parseFloat(formatUnits(balBn, USDC_DECIMALS)); + const apy = useTarget + ? a.expectedApy != null + ? a.expectedApy + : a.apy + : a.apy; + weighted += bal * (apy || 0); + } + return weighted / total; +} + +/** + * Portfolio-level minApySpread gate. If the rebalance lifts weighted portfolio APY + * by less than `constraints.minApySpread`, cancel every yield-motivated action. + * Actions flagged `isShortfall` (from `_coverShortfall`) or `isVaultSurplus` + * (from `_deployRemainingSurplus`) are preserved regardless — they are + * operational, not yield-motivated. + * + * Mutates actions in place. Returns the before/after/delta triple for display. + */ +function _applyPortfolioSpreadGate( + actions, + totalCapital, + constraints, + warnings +) { + const before = computePortfolioApy(actions, totalCapital, { + useTarget: false, + }); + const afterInitial = computePortfolioApy(actions, totalCapital, { + useTarget: true, + }); + const deltaBps = Math.round((afterInitial - before) * 10000); + const minSpreadBps = Math.round((constraints.minApySpread || 0) * 10000); + + let gated = false; + if (constraints.minApySpread != null && deltaBps < minSpreadBps) { + const droppedNames = []; + for (const a of actions) { + if (a.action === ACTION_NONE) continue; + if (a.isShortfall || a.isVaultSurplus) continue; + droppedNames.push(a.name); + a.action = ACTION_NONE; + a.delta = BigNumber.from(0); + a.targetBalance = a.balance; + a.reason = `portfolio APY lift ${deltaBps}bps < minApySpread ${minSpreadBps}bps — dropped`; + _clearWithdrawalFields(a); + } + if (droppedNames.length > 0) { + gated = true; + warnings.push( + `Portfolio APY lift ${deltaBps}bps < minApySpread ${minSpreadBps}bps — ` + + `yield-motivated actions dropped: ${droppedNames.join(", ")}` + ); + } + } + + const after = gated + ? computePortfolioApy(actions, totalCapital, { useTarget: true }) + : afterInitial; + + return { + before, + after, + deltaBps: Math.round((after - before) * 10000), + gated, + }; +} + /** * Vault surplus above reserves (shortfall + minVaultBalance). */ @@ -783,6 +875,9 @@ function _resolveDeposit(deposit, budget, surplusBudget, constraints) { /** * Deploy remaining vault surplus to the default strategy. + * + * Sets `isVaultSurplus = true` on the resulting action so the portfolio-APY gate + * preserves it (surplus deployment is not yield-motivated). */ function _deployRemainingSurplus(result, surplus) { const defaultStrategy = result.find( @@ -790,6 +885,8 @@ function _deployRemainingSurplus(result, surplus) { ); if (!defaultStrategy) return; + defaultStrategy.isVaultSurplus = true; + if (defaultStrategy.action === ACTION_DEPOSIT) { defaultStrategy.delta = defaultStrategy.delta.add(surplus); defaultStrategy.targetBalance = defaultStrategy.targetBalance.add(surplus); @@ -921,6 +1018,49 @@ function _computeShortfallWithdrawAmount( return null; } +/** + * Mark approved withdrawals funding the vault deficit as `isShortfall = true` + * so the portfolio spread gate preserves them. Walks withdrawals in priority + * order — default (same-chain) first, then cross-chain sorted by lowest APY — + * flagging each until the deficit is covered. + * + * Whole-withdrawal flagging (no splitting): if a withdrawal only partially + * covers the remaining deficit, the full withdrawal is flagged. Any excess + * lands in the vault and is redeployed on the next rebalance as vault surplus. + * Splitting would require re-checking minMoveAmount / crossChainMinAmount / + * liquidity invariants mid-plan; the worst case here is one extra rebalance + * cycle to redeploy the leftover. + */ +function _markShortfallWithdrawals( + result, + vaultBalance, + shortfall, + constraints +) { + const vaultTarget = shortfall.add( + BigNumber.from(constraints.minVaultBalance) + ); + let deficit = vaultTarget.gt(vaultBalance) + ? vaultTarget.sub(vaultBalance) + : BigNumber.from(0); + if (deficit.lte(0)) return result; + + const withdrawals = result.filter((a) => a.action === ACTION_WITHDRAW); + const defaults = withdrawals.filter((w) => w.isDefault); + const crossChain = withdrawals + .filter((w) => !w.isDefault) + .sort((a, b) => a.apy - b.apy); + const ordered = [...defaults, ...crossChain]; + + for (const w of ordered) { + if (deficit.lte(0)) break; + w.isShortfall = true; + deficit = deficit.sub(w.delta.abs()); + } + + return result; +} + /** * Cover a withdrawal shortfall when no rebalancing withdrawals were approved. * Tries the default (same-chain) strategy first, then lowest-APY cross-chain. @@ -941,6 +1081,7 @@ function _coverShortfall(result, shortfall, constraints) { defaultStrategy.targetBalance = defaultStrategy.balance.sub(amt); defaultStrategy.action = ACTION_WITHDRAW; defaultStrategy.reason = "shortfall fallback"; + defaultStrategy.isShortfall = true; return result; } } @@ -962,6 +1103,7 @@ function _coverShortfall(result, shortfall, constraints) { s.targetBalance = s.balance.sub(amt); s.action = ACTION_WITHDRAW; s.reason = "shortfall fallback (cross-chain)"; + s.isShortfall = true; } return result; @@ -1163,7 +1305,16 @@ async function buildExecutableActions( // 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 + // 4. Mark approved withdrawals funding the vault deficit as isShortfall so + // the portfolio spread gate preserves them. + result = _markShortfallWithdrawals( + result, + vaultBalance, + shortfall, + constraints + ); + + // 5. Fallback: cover shortfall if no withdrawals were approved const hasApprovedWithdrawals = result.some( (a) => a.action === ACTION_WITHDRAW ); @@ -1183,8 +1334,7 @@ async function buildExecutableActions( */ function sortActions(allocations) { const priority = (a) => { - if (a.action === ACTION_WITHDRAW && a.reason?.includes("shortfall")) - return 0; + if (a.action === ACTION_WITHDRAW && a.isShortfall) return 0; if (a.action === ACTION_WITHDRAW) return 1; if (a.action === ACTION_DEPOSIT) return 2; return 3; @@ -1240,6 +1390,7 @@ function formatAllocationTable({ warnings = [], compact = false, baselineMarkets = [], + portfolioApy = null, }) { const COL_SEP = " "; const constraints = { ...ousdConstraints, ...overrides }; @@ -1260,6 +1411,20 @@ function formatAllocationTable({ lines.push(""); lines.push(`Total rebalancable capital : ${fmtUsd(totalCapital)} USDC`); lines.push(`Withdrawal shortfall : ${fmtUsd(shortfall)} USDC`); + if (portfolioApy) { + const pctStr = (v) => `${(v * 100).toFixed(2)}%`; + const hasAction = actions.some((a) => a.action !== ACTION_NONE); + if (hasAction) { + const delta = portfolioApy.deltaBps; + const deltaSign = delta >= 0 ? "+" : ""; + lines.push( + `Portfolio APY (before→after): ${pctStr(portfolioApy.before)} → ` + + `${pctStr(portfolioApy.after)} (${deltaSign}${delta} bps)` + ); + } else { + lines.push(`Portfolio APY : ${pctStr(portfolioApy.before)}`); + } + } // ── Allocations table ──────────────────────────────────────────────────── lines.push(""); @@ -1732,6 +1897,19 @@ async function buildRebalancePlan(simulation) { await _computeActualImpacts(actions); } + // Portfolio-level APY spread gate: if the rebalance doesn't lift weighted + // portfolio APY by at least minApySpread, drop yield-motivated actions. + const totalCapital = actions.reduce( + (sum, a) => sum.add(a.balance), + state.vaultBalance + ); + const portfolioApy = _applyPortfolioSpreadGate( + actions, + totalCapital, + ousdConstraints, + warnings + ); + console.log( formatAllocationTable({ actions, @@ -1740,6 +1918,7 @@ async function buildRebalancePlan(simulation) { shortfall: state.shortfall, warnings, baselineMarkets, + portfolioApy, }) ); @@ -1752,6 +1931,7 @@ async function buildRebalancePlan(simulation) { warnings, withdrawalCapacities, baselineMarkets, + portfolioApy, }; } @@ -1764,6 +1944,9 @@ module.exports = { fmtUsd, formatAllocationTable, buildRebalancePlan, + computePortfolioApy, + _applyPortfolioSpreadGate, + _markShortfallWithdrawals, ousdMorphoStrategiesConfig, ousdConstraints, ACTION_DEPOSIT, diff --git a/contracts/utils/rebalancer.md b/contracts/utils/rebalancer.md index 2ca68038a4..6b6c0b1f21 100644 --- a/contracts/utils/rebalancer.md +++ b/contracts/utils/rebalancer.md @@ -152,13 +152,35 @@ Cross-chain amounts are capped at 10 M USDC (CCTP bridge limit). | `minMoveAmount` | $5K USDC | Minimum size for any rebalancing move | | `crossChainMinAmount` | $25K USDC | Minimum for a cross-chain transfer (bridge overhead) | | `minVaultBalance` | $0 USDC | Idle reserve always kept in the vault | -| `minApySpread` | 0.5% | Minimum APY improvement required to trigger a withdrawal | +| `minApySpread` | 0.1% | Minimum portfolio APY lift (weighted, before vs after) required to run yield-motivated actions. Shortfall withdrawals and vault-surplus deposits are exempt. | | `maxApyThreshold` | 50% | APY above this is treated as suspicious — strategy is frozen in place | -| `maxApyImpactBps` | 50 bps | Max APY degradation per deposit (used by legacy capacity discovery) | -| `maxWithdrawalApyImpactBps` | 50 bps | Max APY increase on source per withdrawal | +| `maxWithdrawalApyImpactBps` | 1000 bps | Max APY increase on source per withdrawal | | `maxSpotBelowAvgBps` | 200 bps | Block deposits when spot APY diverges below avg | +| `withdrawalStepSize` | $100K USDC | Binary-search granularity for withdrawal capacity discovery | +| `apyAverageWindow` | "1h" | Time window used for the authoritative APY (Subsquid-sourced) | | `allocationChunkSize` | $50K USDC | Step-wise allocation granularity | +### Portfolio APY gate (`minApySpread`) + +After allocation and per-action impact computation, the rebalancer computes the +weighted-average portfolio APY before and after the proposed actions: + +``` +before = Σ (balance × apy) / totalCapital +after = Σ (targetBalance × expApy) / totalCapital +``` + +where `expApy = expectedApy ?? apy` and `totalCapital = vaultBalance + Σ balance` +(idle vault counts toward the denominator at 0% yield on both sides). + +If `after − before < minApySpread`, all yield-motivated actions are cancelled and a +warning is emitted. Actions whose `reason` contains `"shortfall"` (from +`_coverShortfall`) or `"vault surplus"` (from `_deployRemainingSurplus`) are +preserved regardless — they are operational, not yield-motivated. + +The before/after/Δ are shown in the table header and returned from +`buildRebalancePlan` as `portfolioApy: { before, after, deltaBps, gated }`. + ## Files - `utils/rebalancer.js` - core logic (allocation, filtering, formatting)