From 4ca87e82d8483bd049ef5cf6e8d58b24016d12e1 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 19 Sep 2026 16:53:24 +0200 Subject: [PATCH 1/6] fix(rebalancer): resume in-flight runs before sizing a fresh one The opportunistic in-range path quoted, sized, and balance-checked a fresh USDC->BRLA->USDC run before the state machine got a chance to resume a run that died mid-flow. With the in-flight USDC no longer in the wallet, that balance check failed on every cron run and the stuck state was never resumed. The out-of-range path had its own resume check; hoist a single one to the top of checkForRebalancing for both Base flows. Also stop selecting the profitable USDC->BRLA amount when the Base USDC balance cannot fund it: fall back to the standard amount instead of crashing after two quote round-trips. --- apps/rebalancer/src/index.ts | 102 ++++++++++-------- .../security-spec/07-operations/rebalancer.md | 6 +- 2 files changed, 61 insertions(+), 47 deletions(-) diff --git a/apps/rebalancer/src/index.ts b/apps/rebalancer/src/index.ts index 9ce970ed0..081000894 100644 --- a/apps/rebalancer/src/index.ts +++ b/apps/rebalancer/src/index.ts @@ -13,7 +13,11 @@ import { type RebalancingCostPolicyDecision, shouldTriggerOpportunisticUsdcToBrla } from "./rebalance/usdc-brla-usdc-base/guards.ts"; -import { checkInitialUsdcBalanceOnBase, compareRoutesUpfront } from "./rebalance/usdc-brla-usdc-base/steps.ts"; +import { + checkInitialUsdcBalanceOnBase, + compareRoutesUpfront, + getUsdcBalanceOnBaseRaw +} from "./rebalance/usdc-brla-usdc-base/steps.ts"; import { getBaseNablaCoverageRatio } from "./services/indexer"; import { BrlaToUsdcBaseRebalancePhase, @@ -268,6 +272,15 @@ async function selectUsdcToBrlaPolicyAmount(coverageDeviationBps: number): Promi return { amountUsdcRaw: standardAmountRaw, policyDecision: standardPolicyDecision }; } + const baseUsdcRaw = await getUsdcBalanceOnBaseRaw(); + if (Big(baseUsdcRaw).lt(profitableAmountRaw)) { + console.log( + `Base USDC balance ${Big(baseUsdcRaw).div(1e6).toFixed(6)} USDC cannot fund the profitable amount ` + + `${config.rebalancingProfitableUsdToBrlAmount} USDC. Using standard amount ${standardAmountSelection.amountUsdc} USDC.` + ); + return { amountUsdcRaw: standardAmountRaw, policyDecision: standardPolicyDecision }; + } + console.log( `Evaluating USDC->BRLA rebalance amounts independently: standard ${standardAmountSelection.amountUsdc} USDC, ` + `profitable ${config.rebalancingProfitableUsdToBrlAmount} USDC.` @@ -343,61 +356,62 @@ async function evaluateBrlaToUsdcPolicy( } async function runUsdcToBrla(coverageDeviationBps: number) { + 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 amountUsdcRaw = toUsdcRaw(manualAmount || config.rebalancingUsdToBrlAmount); + const amountUsdcRaw = toUsdcRaw(manualAmount || config.rebalancingBrlToUsdAmount); - const stateManager = new UsdcBaseStateManager(); - const state = await stateManager.getState(); - const isResuming = !forceRestart && state && state.currentPhase !== UsdcBaseRebalancePhase.Idle; + const policyDecision = await evaluateBrlaToUsdcPolicy(amountUsdcRaw, coverageDeviationBps); + if (!policyDecision.shouldExecute) return; - if (!isResuming) { - const selectedAmount = await selectUsdcToBrlaPolicyAmount(coverageDeviationBps); - const policyDecision = selectedAmount.policyDecision; - if (!policyDecision.shouldExecute) return; - await executeUsdcToBrlaRebalance(selectedAmount.amountUsdcRaw, coverageDeviationBps, policyDecision); - return; - } + const dailyLimitEvaluation = await evaluateCurrentRunDailyLimit(amountUsdcRaw, policyDecision.profitable); + if (dailyLimitEvaluation.decision?.shouldSkip) return; - await rebalanceUsdcBrlaUsdcBase(amountUsdcRaw, forceRestart, forcedRoute); + 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 + }); } -async function runBrlaToUsdc(coverageDeviationBps: number) { +// A run that died mid-flow holds funds in transit. Resume it before quoting, sizing, or +// balance-checking a fresh run: those checks assume the in-flight USDC is still in the wallet. +async function resumeInFlightRebalance(): Promise { + if (forceRestart) return false; 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; + + const usdcBaseState = await new UsdcBaseStateManager().getState(); + if (usdcBaseState && usdcBaseState.currentPhase !== UsdcBaseRebalancePhase.Idle) { + await rebalanceUsdcBrlaUsdcBase(toUsdcRaw(manualAmount || config.rebalancingUsdToBrlAmount), false, forcedRoute); + return true; } - await rebalanceBrlaToUsdcBase(amountUsdcRaw, forceRestart); + const brlaToUsdcState = await new BrlaToUsdcBaseStateManager().getState(); + if (brlaToUsdcState && brlaToUsdcState.currentPhase !== BrlaToUsdcBaseRebalancePhase.Idle) { + await rebalanceBrlaToUsdcBase(toUsdcRaw(manualAmount || config.rebalancingBrlToUsdAmount), false); + return true; + } + + return false; } async function checkForRebalancing() { + if (await resumeInFlightRebalance()) return; + const config = getConfig(); const coverage = await getBaseNablaCoverageRatio(); diff --git a/docs/security-spec/07-operations/rebalancer.md b/docs/security-spec/07-operations/rebalancer.md index ce2751083..ac842d407 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 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. 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. From 58fa8b00126843b46084348ea845f963552da9c6 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 19 Sep 2026 16:53:24 +0200 Subject: [PATCH 2/6] feat(rebalancer): add reset script for a stuck USDC base state Dry-run by default: prints the persisted USDC->BRLA->USDC state from Supabase Storage. With --confirm it resets the phase to idle while keeping history, for runs whose funds were reconciled manually. --- apps/rebalancer/README.md | 11 +++++++++ apps/rebalancer/package.json | 1 + .../src/scripts/resetUsdcBaseState.ts | 23 +++++++++++++++++++ 3 files changed, 35 insertions(+) create mode 100644 apps/rebalancer/src/scripts/resetUsdcBaseState.ts diff --git a/apps/rebalancer/README.md b/apps/rebalancer/README.md index 298a4ce82..22fbe382b 100644 --- a/apps/rebalancer/README.md +++ b/apps/rebalancer/README.md @@ -47,4 +47,15 @@ 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), inspect and reset the USDC → BRLA → USDC state after reconciling the funds: + +```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/scripts/resetUsdcBaseState.ts b/apps/rebalancer/src/scripts/resetUsdcBaseState.ts new file mode 100644 index 000000000..1123888d4 --- /dev/null +++ b/apps/rebalancer/src/scripts/resetUsdcBaseState.ts @@ -0,0 +1,23 @@ +// 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. Reconcile the abandoned run's funds manually using the state printed above."); From ef6cc3c222e5744215c17fb5e7751dfb855976a4 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 19 Sep 2026 18:41:43 +0200 Subject: [PATCH 3/6] fix(rebalancer): keep dry-run read-only and read coverage before resuming Resuming a persisted run in dry-run mode would write state and move funds during an invocation the spec defines as read-only, and resuming before the coverage read let funds move when the indexer read would have failed. Both were latent on the out-of-range paths before the resume was hoisted. --- apps/rebalancer/src/index.ts | 12 ++++++++++-- docs/security-spec/07-operations/rebalancer.md | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/rebalancer/src/index.ts b/apps/rebalancer/src/index.ts index 081000894..a377f08c6 100644 --- a/apps/rebalancer/src/index.ts +++ b/apps/rebalancer/src/index.ts @@ -396,12 +396,20 @@ async function resumeInFlightRebalance(): Promise { const usdcBaseState = await new UsdcBaseStateManager().getState(); if (usdcBaseState && usdcBaseState.currentPhase !== UsdcBaseRebalancePhase.Idle) { + if (config.rebalancingCostPolicy.mode === "dry-run") { + console.log(`Dry-run mode: not resuming USDC->BRLA->USDC run at phase ${usdcBaseState.currentPhase}.`); + return false; + } await rebalanceUsdcBrlaUsdcBase(toUsdcRaw(manualAmount || config.rebalancingUsdToBrlAmount), false, forcedRoute); return true; } const brlaToUsdcState = await new BrlaToUsdcBaseStateManager().getState(); if (brlaToUsdcState && brlaToUsdcState.currentPhase !== BrlaToUsdcBaseRebalancePhase.Idle) { + if (config.rebalancingCostPolicy.mode === "dry-run") { + console.log(`Dry-run mode: not resuming BRLA->USDC run at phase ${brlaToUsdcState.currentPhase}.`); + return false; + } await rebalanceBrlaToUsdcBase(toUsdcRaw(manualAmount || config.rebalancingBrlToUsdAmount), false); return true; } @@ -410,13 +418,13 @@ async function resumeInFlightRebalance(): Promise { } async function checkForRebalancing() { - if (await resumeInFlightRebalance()) return; - const config = getConfig(); const coverage = await getBaseNablaCoverageRatio(); if (!coverage) throw new Error("Failed to fetch Base Nabla coverage ratio."); + if (await resumeInFlightRebalance()) return; + const lowerBound = 1 - config.rebalancingThresholdBrlaToUsdc; const upperBound = 1 + config.rebalancingThresholdUsdcToBrla; diff --git a/docs/security-spec/07-operations/rebalancer.md b/docs/security-spec/07-operations/rebalancer.md index ac842d407..5a931680f 100644 --- a/docs/security-spec/07-operations/rebalancer.md +++ b/docs/security-spec/07-operations/rebalancer.md @@ -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. A non-idle state file is resumed 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. +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 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. From aab69b804d64f673fe91a2ee1d1299492f4308f2 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 19 Sep 2026 18:41:43 +0200 Subject: [PATCH 4/6] docs(rebalancer): sequence the state reset after pausing the cron The reset script and a live run write the same Supabase object without locking, and the script's closing message contradicted the README's reconcile-first order. --- apps/rebalancer/README.md | 4 +++- apps/rebalancer/src/scripts/resetUsdcBaseState.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/rebalancer/README.md b/apps/rebalancer/README.md index 22fbe382b..4e8316e58 100644 --- a/apps/rebalancer/README.md +++ b/apps/rebalancer/README.md @@ -51,7 +51,9 @@ Passing `--legacy` exits with an error; it cannot start the retired Pendulum/Moo 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), inspect and reset the USDC → BRLA → USDC state after reconciling the funds: +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 diff --git a/apps/rebalancer/src/scripts/resetUsdcBaseState.ts b/apps/rebalancer/src/scripts/resetUsdcBaseState.ts index 1123888d4..56e38ef4c 100644 --- a/apps/rebalancer/src/scripts/resetUsdcBaseState.ts +++ b/apps/rebalancer/src/scripts/resetUsdcBaseState.ts @@ -20,4 +20,6 @@ if (!confirm) { } await stateManager.saveState(createUsdcBaseRebalanceState(null, UsdcBaseRebalancePhase.Idle)); -console.log("State reset to idle. Reconcile the abandoned run's funds manually using the state printed above."); +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." +); From ed4f56c059fb08e351631b2a01fada56073e1336 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 19 Sep 2026 18:57:00 +0200 Subject: [PATCH 5/6] fix(rebalancer): end the invocation when dry-run pauses an in-flight run Continuing into the fresh evaluation quoted and sized a run that assumes the in-flight USDC is still in the wallet, and logged "no rebalancing needed" while a run was paused. --- apps/rebalancer/src/index.ts | 10 ++++++---- docs/security-spec/07-operations/rebalancer.md | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/rebalancer/src/index.ts b/apps/rebalancer/src/index.ts index a377f08c6..b62703163 100644 --- a/apps/rebalancer/src/index.ts +++ b/apps/rebalancer/src/index.ts @@ -397,8 +397,10 @@ async function resumeInFlightRebalance(): Promise { const usdcBaseState = await new UsdcBaseStateManager().getState(); if (usdcBaseState && usdcBaseState.currentPhase !== UsdcBaseRebalancePhase.Idle) { if (config.rebalancingCostPolicy.mode === "dry-run") { - console.log(`Dry-run mode: not resuming USDC->BRLA->USDC run at phase ${usdcBaseState.currentPhase}.`); - return false; + console.log( + `Dry-run mode: USDC->BRLA->USDC run paused at phase ${usdcBaseState.currentPhase}. Skipping fresh evaluation.` + ); + return true; } await rebalanceUsdcBrlaUsdcBase(toUsdcRaw(manualAmount || config.rebalancingUsdToBrlAmount), false, forcedRoute); return true; @@ -407,8 +409,8 @@ async function resumeInFlightRebalance(): Promise { const brlaToUsdcState = await new BrlaToUsdcBaseStateManager().getState(); if (brlaToUsdcState && brlaToUsdcState.currentPhase !== BrlaToUsdcBaseRebalancePhase.Idle) { if (config.rebalancingCostPolicy.mode === "dry-run") { - console.log(`Dry-run mode: not resuming BRLA->USDC run at phase ${brlaToUsdcState.currentPhase}.`); - return false; + console.log(`Dry-run mode: BRLA->USDC run paused at phase ${brlaToUsdcState.currentPhase}. Skipping fresh evaluation.`); + return true; } await rebalanceBrlaToUsdcBase(toUsdcRaw(manualAmount || config.rebalancingBrlToUsdAmount), false); return true; diff --git a/docs/security-spec/07-operations/rebalancer.md b/docs/security-spec/07-operations/rebalancer.md index 5a931680f..a21e471c5 100644 --- a/docs/security-spec/07-operations/rebalancer.md +++ b/docs/security-spec/07-operations/rebalancer.md @@ -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. 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 run stays paused until an executing mode is restored. +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. From c290abdbeda462c37eb2ed122a3aea99094c0e20 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 19 Sep 2026 19:05:15 +0200 Subject: [PATCH 6/6] fix(rebalancer): skip the off-mode balance read and test the cycle The affordability gate read the wallet balance even when the policy mode is off, which the spec defines as returning before any balance read. The resume-before-fresh-evaluation ordering, the dry-run pause, the --restart bypass and the profitable-amount gate had no automated coverage because index.ts runs the cycle at import. Move that orchestration into rebalanceCycle.ts behind injected dependencies so a later refactor cannot silently reorder it. --- apps/rebalancer/src/index.ts | 111 +++--------- apps/rebalancer/src/rebalanceCycle.test.ts | 192 +++++++++++++++++++++ apps/rebalancer/src/rebalanceCycle.ts | 116 +++++++++++++ 3 files changed, 335 insertions(+), 84 deletions(-) create mode 100644 apps/rebalancer/src/rebalanceCycle.test.ts create mode 100644 apps/rebalancer/src/rebalanceCycle.ts diff --git a/apps/rebalancer/src/index.ts b/apps/rebalancer/src/index.ts index b62703163..8e516dcf3 100644 --- a/apps/rebalancer/src/index.ts +++ b/apps/rebalancer/src/index.ts @@ -18,14 +18,9 @@ import { compareRoutesUpfront, getUsdcBalanceOnBaseRaw } from "./rebalance/usdc-brla-usdc-base/steps.ts"; +import { resumeInFlightRebalance, runRebalanceCycle, shouldQuoteProfitableAmount } from "./rebalanceCycle.ts"; import { getBaseNablaCoverageRatio } from "./services/indexer"; -import { - BrlaToUsdcBaseRebalancePhase, - BrlaToUsdcBaseStateManager, - UsdcBaseRebalancePhase, - UsdcBaseStateManager, - type WinningRoute -} from "./services/stateManager.ts"; +import { BrlaToUsdcBaseStateManager, UsdcBaseStateManager, type WinningRoute } from "./services/stateManager.ts"; import { getConfig } from "./utils/config.ts"; const args = process.argv.slice(2); @@ -110,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: { @@ -268,16 +255,13 @@ async function selectUsdcToBrlaPolicyAmount(coverageDeviationBps: number): Promi } const profitableAmountRaw = toUsdcRaw(config.rebalancingProfitableUsdToBrlAmount); - if (profitableAmountRaw === standardAmountRaw) { - return { amountUsdcRaw: standardAmountRaw, policyDecision: standardPolicyDecision }; - } - - const baseUsdcRaw = await getUsdcBalanceOnBaseRaw(); - if (Big(baseUsdcRaw).lt(profitableAmountRaw)) { - console.log( - `Base USDC balance ${Big(baseUsdcRaw).div(1e6).toFixed(6)} USDC cannot fund the profitable amount ` + - `${config.rebalancingProfitableUsdToBrlAmount} USDC. Using standard amount ${standardAmountSelection.amountUsdc} USDC.` - ); + const quoteProfitableAmount = await shouldQuoteProfitableAmount({ + getBaseUsdcRaw: getUsdcBalanceOnBaseRaw, + mode: config.rebalancingCostPolicy.mode, + profitableAmountRaw, + standardAmountRaw + }); + if (!quoteProfitableAmount) { return { amountUsdcRaw: standardAmountRaw, policyDecision: standardPolicyDecision }; } @@ -388,68 +372,27 @@ async function runBrlaToUsdc(coverageDeviationBps: number) { }); } -// A run that died mid-flow holds funds in transit. Resume it before quoting, sizing, or -// balance-checking a fresh run: those checks assume the in-flight USDC is still in the wallet. -async function resumeInFlightRebalance(): Promise { - if (forceRestart) return false; - const config = getConfig(); - - const usdcBaseState = await new UsdcBaseStateManager().getState(); - if (usdcBaseState && usdcBaseState.currentPhase !== UsdcBaseRebalancePhase.Idle) { - if (config.rebalancingCostPolicy.mode === "dry-run") { - console.log( - `Dry-run mode: USDC->BRLA->USDC run paused at phase ${usdcBaseState.currentPhase}. Skipping fresh evaluation.` - ); - return true; - } - await rebalanceUsdcBrlaUsdcBase(toUsdcRaw(manualAmount || config.rebalancingUsdToBrlAmount), false, forcedRoute); - return true; - } - - const brlaToUsdcState = await new BrlaToUsdcBaseStateManager().getState(); - if (brlaToUsdcState && brlaToUsdcState.currentPhase !== BrlaToUsdcBaseRebalancePhase.Idle) { - if (config.rebalancingCostPolicy.mode === "dry-run") { - console.log(`Dry-run mode: BRLA->USDC run paused at phase ${brlaToUsdcState.currentPhase}. Skipping fresh evaluation.`); - return true; - } - await rebalanceBrlaToUsdcBase(toUsdcRaw(manualAmount || config.rebalancingBrlToUsdAmount), false); - return true; - } - - return false; -} - async function checkForRebalancing() { const config = getConfig(); - const coverage = await getBaseNablaCoverageRatio(); - - if (!coverage) throw new Error("Failed to fetch Base Nabla coverage ratio."); - - if (await resumeInFlightRebalance()) return; - - const lowerBound = 1 - config.rebalancingThresholdBrlaToUsdc; - const upperBound = 1 + config.rebalancingThresholdUsdcToBrla; - - 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; - } - if (coverage.brlaCoverageRatio < lowerBound) { - const deviationBps = calculateCoverageDeviationBps(coverage.brlaCoverageRatio, lowerBound); - console.log( - `BRLA coverage ${coverage.brlaCoverageRatio} < ${lowerBound}. Evaluating BRLA->USDC (${deviationBps} bps deviation).` - ); - await runBrlaToUsdc(deviationBps); - return; - } - - const deviationBps = calculateCoverageDeviationBps(coverage.brlaCoverageRatio, upperBound); - console.log( - `BRLA coverage ${coverage.brlaCoverageRatio} > ${upperBound}. Evaluating USDC->BRLA (${deviationBps} bps deviation).` - ); - await runUsdcToBrla(deviationBps); + 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); +}