diff --git a/apps/rebalancer/README.md b/apps/rebalancer/README.md index 298a4ce82..4e8316e58 100644 --- a/apps/rebalancer/README.md +++ b/apps/rebalancer/README.md @@ -47,4 +47,17 @@ bun run start Passing `--legacy` exits with an error; it cannot start the retired Pendulum/Moonbeam flow. +## Resetting a stuck run + +Each Base flow persists its phase in Supabase Storage and resumes it on the next cron run before +anything new is quoted. If a run can no longer complete (for example its funds were moved back +manually), reconcile the funds first, then suspend the Render cron job and make sure no +rebalancer process is running: the script and a live run write the same Supabase object +without locking. Then inspect and reset the USDC → BRLA → USDC state: + +```bash +bun run reset:usdc-base-state # prints the persisted state, changes nothing +bun run reset:usdc-base-state --confirm # resets it to idle, keeping history +``` + This project was created using `bun init` in bun v1.2.6. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/apps/rebalancer/package.json b/apps/rebalancer/package.json index 874f3122f..e3f69b3da 100644 --- a/apps/rebalancer/package.json +++ b/apps/rebalancer/package.json @@ -33,6 +33,7 @@ "dev": "bun run src/index.ts", "format": "biome check --write --unsafe --no-errors-on-unmatched", "prepare": "husky", + "reset:usdc-base-state": "bun run src/scripts/resetUsdcBaseState.ts", "serve": "bun dist/index.js", "start": "bun run build && bun run serve", "test": "bun test", diff --git a/apps/rebalancer/src/index.ts b/apps/rebalancer/src/index.ts index 9ce970ed0..8e516dcf3 100644 --- a/apps/rebalancer/src/index.ts +++ b/apps/rebalancer/src/index.ts @@ -13,15 +13,14 @@ import { type RebalancingCostPolicyDecision, shouldTriggerOpportunisticUsdcToBrla } from "./rebalance/usdc-brla-usdc-base/guards.ts"; -import { checkInitialUsdcBalanceOnBase, compareRoutesUpfront } from "./rebalance/usdc-brla-usdc-base/steps.ts"; -import { getBaseNablaCoverageRatio } from "./services/indexer"; import { - BrlaToUsdcBaseRebalancePhase, - BrlaToUsdcBaseStateManager, - UsdcBaseRebalancePhase, - UsdcBaseStateManager, - type WinningRoute -} from "./services/stateManager.ts"; + checkInitialUsdcBalanceOnBase, + compareRoutesUpfront, + getUsdcBalanceOnBaseRaw +} from "./rebalance/usdc-brla-usdc-base/steps.ts"; +import { resumeInFlightRebalance, runRebalanceCycle, shouldQuoteProfitableAmount } from "./rebalanceCycle.ts"; +import { getBaseNablaCoverageRatio } from "./services/indexer"; +import { BrlaToUsdcBaseStateManager, UsdcBaseStateManager, type WinningRoute } from "./services/stateManager.ts"; import { getConfig } from "./utils/config.ts"; const args = process.argv.slice(2); @@ -106,14 +105,6 @@ async function evaluateCurrentRunDailyLimit( return { dailyVolume, decision: dailyLimitDecision }; } -function calculateCoverageDeviationBps(coverageRatio: number, triggerBound: number): number { - return Number( - Big(Math.abs(coverageRatio - triggerBound)) - .mul(10_000) - .toFixed(2) - ); -} - function getQuoteForRoute( route: Exclude, quotes: { @@ -264,7 +255,13 @@ async function selectUsdcToBrlaPolicyAmount(coverageDeviationBps: number): Promi } const profitableAmountRaw = toUsdcRaw(config.rebalancingProfitableUsdToBrlAmount); - if (profitableAmountRaw === standardAmountRaw) { + const quoteProfitableAmount = await shouldQuoteProfitableAmount({ + getBaseUsdcRaw: getUsdcBalanceOnBaseRaw, + mode: config.rebalancingCostPolicy.mode, + profitableAmountRaw, + standardAmountRaw + }); + if (!quoteProfitableAmount) { return { amountUsdcRaw: standardAmountRaw, policyDecision: standardPolicyDecision }; } @@ -343,89 +340,59 @@ async function evaluateBrlaToUsdcPolicy( } async function runUsdcToBrla(coverageDeviationBps: number) { - const config = getConfig(); - const amountUsdcRaw = toUsdcRaw(manualAmount || config.rebalancingUsdToBrlAmount); - - const stateManager = new UsdcBaseStateManager(); - const state = await stateManager.getState(); - const isResuming = !forceRestart && state && state.currentPhase !== UsdcBaseRebalancePhase.Idle; - - if (!isResuming) { - const selectedAmount = await selectUsdcToBrlaPolicyAmount(coverageDeviationBps); - const policyDecision = selectedAmount.policyDecision; - if (!policyDecision.shouldExecute) return; - await executeUsdcToBrlaRebalance(selectedAmount.amountUsdcRaw, coverageDeviationBps, policyDecision); - return; - } - - await rebalanceUsdcBrlaUsdcBase(amountUsdcRaw, forceRestart, forcedRoute); + const selectedAmount = await selectUsdcToBrlaPolicyAmount(coverageDeviationBps); + const policyDecision = selectedAmount.policyDecision; + if (!policyDecision.shouldExecute) return; + await executeUsdcToBrlaRebalance(selectedAmount.amountUsdcRaw, coverageDeviationBps, policyDecision); } async function runBrlaToUsdc(coverageDeviationBps: number) { const config = getConfig(); - const amountUsdc = manualAmount || config.rebalancingBrlToUsdAmount; - const amountUsdcRaw = multiplyByPowerOfTen(new Big(amountUsdc), 6).toFixed(0, 0); - - const stateManager = new BrlaToUsdcBaseStateManager(); - const state = await stateManager.getState(); - const isResuming = !forceRestart && state && state.currentPhase !== BrlaToUsdcBaseRebalancePhase.Idle; - - if (!isResuming) { - const policyDecision = await evaluateBrlaToUsdcPolicy(amountUsdcRaw, coverageDeviationBps); - if (!policyDecision.shouldExecute) return; - - const dailyLimitEvaluation = await evaluateCurrentRunDailyLimit(amountUsdcRaw, policyDecision.profitable); - if (dailyLimitEvaluation.decision?.shouldSkip) return; - - const rebalancerUsdcBalance = await checkInitialUsdcBalanceOnBase(amountUsdcRaw); - if (config.rebalancingBrlToUsdMinBalance && rebalancerUsdcBalance.lt(config.rebalancingBrlToUsdMinBalance)) { - throw new Error( - `Rebalancer USDC balance ${rebalancerUsdcBalance} is below the minimum required balance of ${config.rebalancingBrlToUsdMinBalance} to perform rebalancing.` - ); - } - await rebalanceBrlaToUsdcBase(amountUsdcRaw, forceRestart, { - config: config.rebalancingCostPolicy, - dailyLimitDecision: dailyLimitEvaluation.decision, - dailyVolume: dailyLimitEvaluation.dailyVolume, - decision: policyDecision.decision, - deviationBps: coverageDeviationBps, - fallbackRequiresProfit: policyDecision.profitable - }); - return; - } - - await rebalanceBrlaToUsdcBase(amountUsdcRaw, forceRestart); -} - -async function checkForRebalancing() { - const config = getConfig(); - const coverage = await getBaseNablaCoverageRatio(); - - if (!coverage) throw new Error("Failed to fetch Base Nabla coverage ratio."); + const amountUsdcRaw = toUsdcRaw(manualAmount || config.rebalancingBrlToUsdAmount); - const lowerBound = 1 - config.rebalancingThresholdBrlaToUsdc; - const upperBound = 1 + config.rebalancingThresholdUsdcToBrla; + const policyDecision = await evaluateBrlaToUsdcPolicy(amountUsdcRaw, coverageDeviationBps); + if (!policyDecision.shouldExecute) return; - if (coverage.brlaCoverageRatio >= lowerBound && coverage.brlaCoverageRatio <= upperBound) { - if (await tryOpportunisticUsdcToBrla()) return; - console.log(`BRLA coverage ${coverage.brlaCoverageRatio} in range [${lowerBound}, ${upperBound}]. No rebalancing needed.`); - return; - } + const dailyLimitEvaluation = await evaluateCurrentRunDailyLimit(amountUsdcRaw, policyDecision.profitable); + if (dailyLimitEvaluation.decision?.shouldSkip) return; - if (coverage.brlaCoverageRatio < lowerBound) { - const deviationBps = calculateCoverageDeviationBps(coverage.brlaCoverageRatio, lowerBound); - console.log( - `BRLA coverage ${coverage.brlaCoverageRatio} < ${lowerBound}. Evaluating BRLA->USDC (${deviationBps} bps deviation).` + const rebalancerUsdcBalance = await checkInitialUsdcBalanceOnBase(amountUsdcRaw); + if (config.rebalancingBrlToUsdMinBalance && rebalancerUsdcBalance.lt(config.rebalancingBrlToUsdMinBalance)) { + throw new Error( + `Rebalancer USDC balance ${rebalancerUsdcBalance} is below the minimum required balance of ${config.rebalancingBrlToUsdMinBalance} to perform rebalancing.` ); - await runBrlaToUsdc(deviationBps); - return; } + await rebalanceBrlaToUsdcBase(amountUsdcRaw, forceRestart, { + config: config.rebalancingCostPolicy, + dailyLimitDecision: dailyLimitEvaluation.decision, + dailyVolume: dailyLimitEvaluation.dailyVolume, + decision: policyDecision.decision, + deviationBps: coverageDeviationBps, + fallbackRequiresProfit: policyDecision.profitable + }); +} - const deviationBps = calculateCoverageDeviationBps(coverage.brlaCoverageRatio, upperBound); - console.log( - `BRLA coverage ${coverage.brlaCoverageRatio} > ${upperBound}. Evaluating USDC->BRLA (${deviationBps} bps deviation).` - ); - await runUsdcToBrla(deviationBps); +async function checkForRebalancing() { + const config = getConfig(); + + await runRebalanceCycle({ + lowerBound: 1 - config.rebalancingThresholdBrlaToUsdc, + readCoverage: getBaseNablaCoverageRatio, + resumeInFlight: () => + resumeInFlightRebalance({ + forceRestart, + getBrlaToUsdcState: () => new BrlaToUsdcBaseStateManager().getState(), + getUsdcBaseState: () => new UsdcBaseStateManager().getState(), + mode: config.rebalancingCostPolicy.mode, + resumeBrlaToUsdc: () => rebalanceBrlaToUsdcBase(toUsdcRaw(manualAmount || config.rebalancingBrlToUsdAmount), false), + resumeUsdcBase: () => + rebalanceUsdcBrlaUsdcBase(toUsdcRaw(manualAmount || config.rebalancingUsdToBrlAmount), false, forcedRoute) + }), + runBrlaToUsdc, + runUsdcToBrla, + tryOpportunisticUsdcToBrla, + upperBound: 1 + config.rebalancingThresholdUsdcToBrla + }); } console.log("Using Base rebalancing flow."); diff --git a/apps/rebalancer/src/rebalanceCycle.test.ts b/apps/rebalancer/src/rebalanceCycle.test.ts new file mode 100644 index 000000000..d2c87f4fc --- /dev/null +++ b/apps/rebalancer/src/rebalanceCycle.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, mock, test } from "bun:test"; +import type { RebalancingPolicyMode } from "./rebalance/usdc-brla-usdc-base/guards.ts"; +import { + calculateCoverageDeviationBps, + type InFlightResumeDeps, + type RebalanceCycleDeps, + resumeInFlightRebalance, + runRebalanceCycle, + shouldQuoteProfitableAmount +} from "./rebalanceCycle.ts"; +import { BrlaToUsdcBaseRebalancePhase, UsdcBaseRebalancePhase } from "./services/stateManager.ts"; + +const idleUsdc = { currentPhase: UsdcBaseRebalancePhase.Idle }; +const stuckUsdc = { currentPhase: UsdcBaseRebalancePhase.SquidRouterApproveAndSwap }; +const idleBrla = { currentPhase: BrlaToUsdcBaseRebalancePhase.Idle }; +const stuckBrla = { currentPhase: BrlaToUsdcBaseRebalancePhase.NablaSwapBrlaToUsdc }; + +function resumeDeps(overrides: Partial = {}) { + return { + forceRestart: false, + getBrlaToUsdcState: mock(async (): Promise<{ currentPhase: BrlaToUsdcBaseRebalancePhase } | undefined> => idleBrla), + getUsdcBaseState: mock(async (): Promise<{ currentPhase: UsdcBaseRebalancePhase } | undefined> => idleUsdc), + mode: "auto" as RebalancingPolicyMode, + resumeBrlaToUsdc: mock(async () => {}), + resumeUsdcBase: mock(async () => {}), + ...overrides + }; +} + +describe("resumeInFlightRebalance", () => { + test("resumes a stuck USDC->BRLA->USDC run before anything fresh", async () => { + const deps = resumeDeps({ getUsdcBaseState: mock(async () => stuckUsdc) }); + + expect(await resumeInFlightRebalance(deps)).toBe(true); + expect(deps.resumeUsdcBase).toHaveBeenCalledTimes(1); + expect(deps.getBrlaToUsdcState).not.toHaveBeenCalled(); + }); + + test("resumes a stuck BRLA->USDC run when the other flow is idle", async () => { + const deps = resumeDeps({ getBrlaToUsdcState: mock(async () => stuckBrla) }); + + expect(await resumeInFlightRebalance(deps)).toBe(true); + expect(deps.resumeBrlaToUsdc).toHaveBeenCalledTimes(1); + expect(deps.resumeUsdcBase).not.toHaveBeenCalled(); + }); + + test("reports nothing to resume when both flows are idle or missing", async () => { + const deps = resumeDeps({ getUsdcBaseState: mock(async () => undefined) }); + + expect(await resumeInFlightRebalance(deps)).toBe(false); + expect(deps.resumeUsdcBase).not.toHaveBeenCalled(); + expect(deps.resumeBrlaToUsdc).not.toHaveBeenCalled(); + }); + + test("dry-run leaves a stuck run paused and still ends the cycle", async () => { + const stuckFlows: Partial[] = [ + { getUsdcBaseState: mock(async () => stuckUsdc) }, + { getBrlaToUsdcState: mock(async () => stuckBrla) } + ]; + + for (const stuckFlow of stuckFlows) { + const deps = resumeDeps({ mode: "dry-run", ...stuckFlow }); + + expect(await resumeInFlightRebalance(deps)).toBe(true); + expect(deps.resumeUsdcBase).not.toHaveBeenCalled(); + expect(deps.resumeBrlaToUsdc).not.toHaveBeenCalled(); + } + }); + + test("--restart bypasses resumption without reading state", async () => { + const deps = resumeDeps({ forceRestart: true, getUsdcBaseState: mock(async () => stuckUsdc) }); + + expect(await resumeInFlightRebalance(deps)).toBe(false); + expect(deps.getUsdcBaseState).not.toHaveBeenCalled(); + expect(deps.resumeUsdcBase).not.toHaveBeenCalled(); + }); +}); + +function cycleDeps(calls: string[], overrides: Partial = {}) { + return { + lowerBound: 0.99, + readCoverage: mock(async () => { + calls.push("coverage"); + return { brlaCoverageRatio: 1 }; + }), + resumeInFlight: mock(async () => { + calls.push("resume"); + return false; + }), + runBrlaToUsdc: mock(async (_deviationBps: number) => { + calls.push("brlaToUsdc"); + }), + runUsdcToBrla: mock(async (_deviationBps: number) => { + calls.push("usdcToBrla"); + }), + tryOpportunisticUsdcToBrla: mock(async () => { + calls.push("opportunistic"); + return false; + }), + upperBound: 1.01, + ...overrides + }; +} + +describe("runRebalanceCycle", () => { + test("reads coverage first and stops after resuming an in-flight run", async () => { + const calls: string[] = []; + const deps = cycleDeps(calls, { + resumeInFlight: mock(async () => { + calls.push("resume"); + return true; + }) + }); + + await runRebalanceCycle(deps); + + expect(calls).toEqual(["coverage", "resume"]); + }); + + test("does not resume when coverage is unavailable", async () => { + const calls: string[] = []; + const deps = cycleDeps(calls, { readCoverage: mock(async () => null) }); + + await expect(runRebalanceCycle(deps)).rejects.toThrow("Failed to fetch Base Nabla coverage ratio."); + expect(deps.resumeInFlight).not.toHaveBeenCalled(); + }); + + test("evaluates the opportunistic path only after nothing was resumed", async () => { + const calls: string[] = []; + + await runRebalanceCycle(cycleDeps(calls)); + + expect(calls).toEqual(["coverage", "resume", "opportunistic"]); + }); + + test("routes low coverage to BRLA->USDC with the deviation in bps", async () => { + const calls: string[] = []; + const deps = cycleDeps(calls, { readCoverage: mock(async () => ({ brlaCoverageRatio: 0.98 })) }); + + await runRebalanceCycle(deps); + + expect(deps.runBrlaToUsdc).toHaveBeenCalledWith(100); + expect(deps.tryOpportunisticUsdcToBrla).not.toHaveBeenCalled(); + expect(calls).toEqual(["resume", "brlaToUsdc"]); + }); + + test("routes high coverage to USDC->BRLA with the deviation in bps", async () => { + const calls: string[] = []; + const deps = cycleDeps(calls, { readCoverage: mock(async () => ({ brlaCoverageRatio: 1.02 })) }); + + await runRebalanceCycle(deps); + + expect(deps.runUsdcToBrla).toHaveBeenCalledWith(100); + expect(calls).toEqual(["resume", "usdcToBrla"]); + }); + + test("measures the deviation from the crossed bound", () => { + expect(calculateCoverageDeviationBps(1.0738, 1.01)).toBe(638); + }); +}); + +describe("shouldQuoteProfitableAmount", () => { + const amounts = { profitableAmountRaw: "2000000000", standardAmountRaw: "1000000000" }; + + test("off mode never reads the wallet balance", async () => { + const getBaseUsdcRaw = mock(async () => "5000000000"); + + expect(await shouldQuoteProfitableAmount({ ...amounts, getBaseUsdcRaw, mode: "off" })).toBe(false); + expect(getBaseUsdcRaw).not.toHaveBeenCalled(); + }); + + test("skips a profitable amount equal to the standard amount without reading the balance", async () => { + const getBaseUsdcRaw = mock(async () => "5000000000"); + + expect( + await shouldQuoteProfitableAmount({ ...amounts, getBaseUsdcRaw, mode: "auto", profitableAmountRaw: "1000000000" }) + ).toBe(false); + expect(getBaseUsdcRaw).not.toHaveBeenCalled(); + }); + + test("falls back to the standard amount when the balance cannot fund the profitable amount", async () => { + const getBaseUsdcRaw = mock(async () => "1032947559"); + + expect(await shouldQuoteProfitableAmount({ ...amounts, getBaseUsdcRaw, mode: "auto" })).toBe(false); + }); + + test("quotes the profitable amount when the balance covers it", async () => { + const getBaseUsdcRaw = mock(async () => "2500000000"); + + expect(await shouldQuoteProfitableAmount({ ...amounts, getBaseUsdcRaw, mode: "auto" })).toBe(true); + }); +}); diff --git a/apps/rebalancer/src/rebalanceCycle.ts b/apps/rebalancer/src/rebalanceCycle.ts new file mode 100644 index 000000000..d78dc6308 --- /dev/null +++ b/apps/rebalancer/src/rebalanceCycle.ts @@ -0,0 +1,116 @@ +import Big from "big.js"; +import type { RebalancingPolicyMode } from "./rebalance/usdc-brla-usdc-base/guards.ts"; +import { BrlaToUsdcBaseRebalancePhase, UsdcBaseRebalancePhase } from "./services/stateManager.ts"; + +export interface InFlightResumeDeps { + forceRestart: boolean; + mode: RebalancingPolicyMode; + getUsdcBaseState: () => Promise<{ currentPhase: UsdcBaseRebalancePhase } | undefined>; + getBrlaToUsdcState: () => Promise<{ currentPhase: BrlaToUsdcBaseRebalancePhase } | undefined>; + resumeUsdcBase: () => Promise; + resumeBrlaToUsdc: () => Promise; +} + +// A run that died mid-flow holds funds in transit. It must be resumed, or in dry-run left paused, +// before any fresh quote, sizing, or balance check: those assume the in-flight USDC is still in the wallet. +export async function resumeInFlightRebalance(deps: InFlightResumeDeps): Promise { + if (deps.forceRestart) return false; + + const usdcBaseState = await deps.getUsdcBaseState(); + if (usdcBaseState && usdcBaseState.currentPhase !== UsdcBaseRebalancePhase.Idle) { + if (deps.mode === "dry-run") { + console.log( + `Dry-run mode: USDC->BRLA->USDC run paused at phase ${usdcBaseState.currentPhase}. Skipping fresh evaluation.` + ); + return true; + } + await deps.resumeUsdcBase(); + return true; + } + + const brlaToUsdcState = await deps.getBrlaToUsdcState(); + if (brlaToUsdcState && brlaToUsdcState.currentPhase !== BrlaToUsdcBaseRebalancePhase.Idle) { + if (deps.mode === "dry-run") { + console.log(`Dry-run mode: BRLA->USDC run paused at phase ${brlaToUsdcState.currentPhase}. Skipping fresh evaluation.`); + return true; + } + await deps.resumeBrlaToUsdc(); + return true; + } + + return false; +} + +export interface ProfitableAmountQuoteDeps { + mode: RebalancingPolicyMode; + standardAmountRaw: string; + profitableAmountRaw: string; + getBaseUsdcRaw: () => Promise; +} + +// Off mode never touches the chain, and a profitable amount the wallet cannot fund would only +// burn a second SquidRouter quote before failing the balance check. +export async function shouldQuoteProfitableAmount(deps: ProfitableAmountQuoteDeps): Promise { + if (deps.mode === "off") return false; + if (deps.profitableAmountRaw === deps.standardAmountRaw) return false; + + const baseUsdcRaw = await deps.getBaseUsdcRaw(); + if (Big(baseUsdcRaw).lt(deps.profitableAmountRaw)) { + console.log( + `Base USDC balance ${toUsdc(baseUsdcRaw)} USDC cannot fund the profitable amount ${toUsdc(deps.profitableAmountRaw)} USDC. ` + + `Using standard amount ${toUsdc(deps.standardAmountRaw)} USDC.` + ); + return false; + } + + return true; +} + +function toUsdc(raw: string): string { + return Big(raw).div(1e6).toFixed(6); +} + +export interface RebalanceCycleDeps { + lowerBound: number; + upperBound: number; + readCoverage: () => Promise<{ brlaCoverageRatio: number } | null | undefined>; + resumeInFlight: () => Promise; + tryOpportunisticUsdcToBrla: () => Promise; + runBrlaToUsdc: (deviationBps: number) => Promise; + runUsdcToBrla: (deviationBps: number) => Promise; +} + +export function calculateCoverageDeviationBps(coverageRatio: number, triggerBound: number): number { + return Number( + Big(Math.abs(coverageRatio - triggerBound)) + .mul(10_000) + .toFixed(2) + ); +} + +export async function runRebalanceCycle(deps: RebalanceCycleDeps): Promise { + const coverage = await deps.readCoverage(); + if (!coverage) throw new Error("Failed to fetch Base Nabla coverage ratio."); + + if (await deps.resumeInFlight()) return; + + const { lowerBound, upperBound } = deps; + const ratio = coverage.brlaCoverageRatio; + + if (ratio >= lowerBound && ratio <= upperBound) { + if (await deps.tryOpportunisticUsdcToBrla()) return; + console.log(`BRLA coverage ${ratio} in range [${lowerBound}, ${upperBound}]. No rebalancing needed.`); + return; + } + + if (ratio < lowerBound) { + const deviationBps = calculateCoverageDeviationBps(ratio, lowerBound); + console.log(`BRLA coverage ${ratio} < ${lowerBound}. Evaluating BRLA->USDC (${deviationBps} bps deviation).`); + await deps.runBrlaToUsdc(deviationBps); + return; + } + + const deviationBps = calculateCoverageDeviationBps(ratio, upperBound); + console.log(`BRLA coverage ${ratio} > ${upperBound}. Evaluating USDC->BRLA (${deviationBps} bps deviation).`); + await deps.runUsdcToBrla(deviationBps); +} diff --git a/apps/rebalancer/src/scripts/resetUsdcBaseState.ts b/apps/rebalancer/src/scripts/resetUsdcBaseState.ts new file mode 100644 index 000000000..56e38ef4c --- /dev/null +++ b/apps/rebalancer/src/scripts/resetUsdcBaseState.ts @@ -0,0 +1,25 @@ +// Resets a stuck USDC->BRLA->USDC (Base) rebalance state back to Idle, keeping history. +// Dry-run by default: prints the persisted state. Pass --confirm to overwrite it. +// Usage: bun run reset:usdc-base-state [--confirm] +import { createUsdcBaseRebalanceState, UsdcBaseRebalancePhase, UsdcBaseStateManager } from "../services/stateManager.ts"; + +const confirm = process.argv.includes("--confirm"); +const stateManager = new UsdcBaseStateManager(); +const state = await stateManager.getState(); + +console.log("Current USDC->BRLA->USDC (Base) state:", JSON.stringify(state, null, 2)); + +if (!state || state.currentPhase === UsdcBaseRebalancePhase.Idle) { + console.log("State is already idle. Nothing to reset."); + process.exit(0); +} + +if (!confirm) { + console.log(`State is stuck at phase "${state.currentPhase}". Re-run with --confirm to reset it to idle.`); + process.exit(0); +} + +await stateManager.saveState(createUsdcBaseRebalanceState(null, UsdcBaseRebalancePhase.Idle)); +console.log( + "State reset to idle. The state printed above is the last record of the abandoned run; keep it if the funds are not fully reconciled yet." +); diff --git a/docs/security-spec/07-operations/rebalancer.md b/docs/security-spec/07-operations/rebalancer.md index ce2751083..a21e471c5 100644 --- a/docs/security-spec/07-operations/rebalancer.md +++ b/docs/security-spec/07-operations/rebalancer.md @@ -4,7 +4,7 @@ The rebalancer is a standalone service (`apps/rebalancer/`) that monitors token coverage ratios and automatically moves liquidity across chains when ratios indicate a pool imbalance. Its primary function is ensuring the platform has sufficient tokens to service ramp operations without manual intervention. -The default Base rebalancer is cost-aware. A coverage-ratio breach makes a fresh cron run eligible for evaluation, but execution still depends on the configured urgency band and projected round-trip cost. Mild and moderate imbalances can be skipped when route quotes are unfavorable; severe imbalances tolerate higher configured cost. When coverage is already inside the configured bounds, the USDC → BRLA → USDC flow may still run opportunistically, but only if its projected route cost is below `REBALANCING_OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS` (default 10 bps). `REBALANCING_HARD_MAX_COST_BPS` remains a hard projected-cost cap in every mode. `REBALANCING_DAILY_BRIDGE_LIMIT_USD` caps non-profitable fresh Base runs, but a quote that projects profit may bypass the daily cap while still being recorded in history after completion. When a separate profitable USDC → BRLA → USDC amount is configured, the flow evaluates that larger amount with its own fresh quotes and executes it only when that larger quote projects profit. +The default Base rebalancer is cost-aware. A coverage-ratio breach makes a fresh cron run eligible for evaluation, but execution still depends on the configured urgency band and projected round-trip cost. Mild and moderate imbalances can be skipped when route quotes are unfavorable; severe imbalances tolerate higher configured cost. When coverage is already inside the configured bounds, the USDC → BRLA → USDC flow may still run opportunistically, but only if its projected route cost is below `REBALANCING_OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS` (default 10 bps). `REBALANCING_HARD_MAX_COST_BPS` remains a hard projected-cost cap in every mode. `REBALANCING_DAILY_BRIDGE_LIMIT_USD` caps non-profitable fresh Base runs, but a quote that projects profit may bypass the daily cap while still being recorded in history after completion. When a separate profitable USDC → BRLA → USDC amount is configured, the flow evaluates that larger amount with its own fresh quotes and executes it only when that larger quote projects profit and the rebalancer's Base USDC balance covers it. **Current implementation:** Two active Base paths plus one dormant compatibility implementation: @@ -41,7 +41,7 @@ bun run start [amount] [--restart] [--route=squidrouter|avenia|nabla-main] - `REBALANCING_MAX_COST_BPS_MILD` / `REBALANCING_MAX_COST_BPS_MODERATE` / `REBALANCING_MAX_COST_BPS_SEVERE` — maximum projected round-trip cost per urgency band. - `REBALANCING_HARD_MAX_COST_BPS` — final projected-cost ceiling enforced even in `always` mode. - `REBALANCING_OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS` — maximum projected route cost for in-range opportunistic USDC → BRLA → USDC execution (default 10 bps). -- `REBALANCING_USD_TO_BRL_AMOUNT` / `REBALANCING_PROFITABLE_USD_TO_BRL_AMOUNT` — standard and projected-profitable USDC → BRLA → USDC sizing. The profitable amount defaults to the standard amount when unset and is used only when a fresh quote for that larger size projects profit. +- `REBALANCING_USD_TO_BRL_AMOUNT` / `REBALANCING_PROFITABLE_USD_TO_BRL_AMOUNT` — standard and projected-profitable USDC → BRLA → USDC sizing. The profitable amount defaults to the standard amount when unset and is used only when the Base USDC balance covers it and a fresh quote for that larger size projects profit. --- @@ -154,7 +154,7 @@ security review. ### Base flow invariants 11. **Daily bridge limit MUST be enforced for paid current runs** — Total requested USDC amount recorded by Base-flow histories per calendar day (UTC), including the amount about to be rebalanced, must not exceed `REBALANCING_DAILY_BRIDGE_LIMIT_USD` for non-profitable fresh Base runs. The limit decision must run after quote/cost-policy evaluation and before fresh state writes or transactions for paid runs. Projected-profitable current runs bypass the cap entirely, but completed profitable runs must still be recorded in history so they count toward later paid-run checks. -12. **Cost policy MUST run before fresh-run side effects** — For Base flows, route/two-leg quotes and the cost-policy decision must happen before `startNewRebalance`, approvals, swaps, transfers, ticket creation, or history writes. Resumed runs continue the already-started state and do not recompute a fresh skip decision. +12. **Cost policy MUST run before fresh-run side effects** — For Base flows, route/two-leg quotes and the cost-policy decision must happen before `startNewRebalance`, approvals, swaps, transfers, ticket creation, or history writes. Resumed runs continue the already-started state and do not recompute a fresh skip decision. A non-idle state file is resumed after the coverage read but before any fresh quote, sizing, daily-limit, or Base USDC balance check, including on the opportunistic in-range path, because the in-flight USDC is no longer in the wallet. `dry-run` mode does not resume it either: the invocation ends without a fresh evaluation and the run stays paused until an executing mode is restored. 13. **Severity bands MUST be monotonic** — Moderate deviation must be less than or equal to severe deviation. Mild cost tolerance must be less than or equal to moderate, moderate less than or equal to severe, and severe less than or equal to `REBALANCING_HARD_MAX_COST_BPS`. 14. **Mild/moderate imbalances MUST be skippable when cost exceeds tolerance** — In `auto` mode, fresh Base rebalances must skip when projected round-trip cost exceeds the configured limit for the current band. 15. **Opportunistic in-range rebalances MUST stay below the configured projected-cost cap** — When coverage is already inside `[lowerBound, upperBound]`, only USDC → BRLA → USDC may run opportunistically. It must use the normal cost-policy quote, daily-limit/profit decision, Base USDC balance check, route selection, hard max-cost cap, and state machine; it must skip when projected route cost is greater than or equal to `REBALANCING_OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS` (default 10 bps). If an opportunistic Avenia route later falls back to SquidRouter, the preflight SquidRouter quote must independently satisfy the normal cost policy, the configured opportunistic cap, and the profitable-quote requirement when the original current quote skipped the daily bridge limit because it was projected profitable.