From 1aa9f305295f7ff2ac96d5208c994e511f3bfcf5 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 1 Jun 2026 15:13:09 -0300 Subject: [PATCH 001/131] add new rebalancing flow --- apps/rebalancer/.env.example | 5 +- apps/rebalancer/README.md | 5 +- apps/rebalancer/src/index.ts | 66 ++- .../src/rebalance/brla-to-axlusdc/index.ts | 20 +- .../src/rebalance/brla-to-axlusdc/steps.ts | 1 - .../rebalance/usdc-brla-usdc-base/index.ts | 252 +++++++++ .../rebalance/usdc-brla-usdc-base/steps.ts | 534 ++++++++++++++++++ apps/rebalancer/src/services/indexer/index.ts | 66 +++ apps/rebalancer/src/services/stateManager.ts | 191 +++++-- apps/rebalancer/src/utils/config.ts | 30 +- apps/rebalancer/src/utils/nonce.ts | 18 + .../src/services/brla/brlaApiService.ts | 2 +- packages/shared/src/services/brla/types.ts | 1 + 13 files changed, 1121 insertions(+), 70 deletions(-) create mode 100644 apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts create mode 100644 apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts create mode 100644 apps/rebalancer/src/utils/nonce.ts diff --git a/apps/rebalancer/.env.example b/apps/rebalancer/.env.example index dd128076d..4965a0ab4 100644 --- a/apps/rebalancer/.env.example +++ b/apps/rebalancer/.env.example @@ -1,7 +1,8 @@ ALCHEMY_API_KEY=your_api_key_here +EVM_ACCOUNT_SECRET="your_secret_here" + +# Only required for legacy flow (--legacy flag) PENDULUM_ACCOUNT_SECRET="your_secret_here" -MOONBEAM_ACCOUNT_SECRET="your_secret_here" -POLYGON_ACCOUNT_SECRET="your_secret_here" BRLA_LOGIN_USERNAME=test@test.com BRLA_LOGIN_PASSWORD=your_password_here diff --git a/apps/rebalancer/README.md b/apps/rebalancer/README.md index eb188f411..7a07594b0 100644 --- a/apps/rebalancer/README.md +++ b/apps/rebalancer/README.md @@ -13,9 +13,10 @@ Then, open the `.env` file and add your Alchemy API key. ``` ALCHEMY_API_KEY=your_alchemy_api_key +EVM_ACCOUNT_SECRET=xxx + +# Only required for legacy flow (--legacy flag) PENDULUM_ACCOUNT_SECRET=xxx -MOONBEAM_ACCOUNT_SECRET=xxx -POLYGON_ACCOUNT_SECRET=xxx ``` ## Installation diff --git a/apps/rebalancer/src/index.ts b/apps/rebalancer/src/index.ts index fff8a238f..83b88b903 100644 --- a/apps/rebalancer/src/index.ts +++ b/apps/rebalancer/src/index.ts @@ -1,24 +1,39 @@ import { cryptoWaitReady } from "@polkadot/util-crypto"; +import { multiplyByPowerOfTen } from "@vortexfi/shared"; +import Big from "big.js"; import { rebalanceBrlaToUsdcAxl } from "./rebalance/brla-to-axlusdc"; import { checkInitialPendulumBalance } from "./rebalance/brla-to-axlusdc/steps.ts"; -import { getSwapPoolsWithCoverageRatio } from "./services/indexer"; -import { phaseOrder, RebalancePhase, StateManager } from "./services/stateManager.ts"; +import { rebalanceUsdcBrlaUsdcBase } from "./rebalance/usdc-brla-usdc-base"; +import { checkInitialUsdcBalanceOnBase } from "./rebalance/usdc-brla-usdc-base/steps.ts"; +import { getBaseNablaCoverageRatio, getSwapPoolsWithCoverageRatio } from "./services/indexer"; +import { + BrlaToAxlUsdcStateManager, + RebalancePhase, + UsdcBaseRebalancePhase, + UsdcBaseStateManager +} from "./services/stateManager.ts"; import { getConfig, getPendulumAccount } from "./utils/config.ts"; const args = process.argv.slice(2); const forceRestart = args.includes("--restart"); +const useLegacy = args.includes("--legacy"); const manualAmount = args.find(arg => !arg.startsWith("--")) || null; +const routeArg = args.find(arg => arg.startsWith("--route=")); +const forcedRoute = routeArg ? (routeArg.split("=")[1] as "squidrouter" | "avenia") : undefined; +if (forcedRoute && !["squidrouter", "avenia"].includes(forcedRoute)) { + console.error("Invalid --route value. Must be 'squidrouter' or 'avenia'."); + process.exit(1); +} -async function checkForRebalancing() { +async function checkForRebalancingLegacy() { const config = getConfig(); const amountAxlUsdc = manualAmount || config.rebalancingUsdToBrlAmount; if (forceRestart) { - console.log("Force restart enabled. Starting rebalancing regardless of coverage ratios."); + console.log("Force restart enabled. Starting legacy rebalancing regardless of coverage ratios."); } else { const swapPoolsWithCoverage = await getSwapPoolsWithCoverageRatio(); - // For now, we can only handle automatic rebalancing for USDC.axl and BRLA const brlaPool = swapPoolsWithCoverage.find(pool => pool.pool.token.symbol === "BRLA"); if (!brlaPool) { console.log("No BRLA swap pool found."); @@ -38,11 +53,10 @@ async function checkForRebalancing() { } } - // Proceed with rebalancing await cryptoWaitReady(); const pendulumAccount = getPendulumAccount(); - const stateManager = new StateManager(); + const stateManager = new BrlaToAxlUsdcStateManager(); const state = await stateManager.getState(); const isResuming = !forceRestart && state && state.currentPhase !== RebalancePhase.Idle; @@ -58,7 +72,43 @@ async function checkForRebalancing() { await rebalanceBrlaToUsdcAxl(amountAxlUsdc, forceRestart); } -checkForRebalancing() +async function checkForRebalancing() { + const config = getConfig(); + const amountUsdc = manualAmount || config.rebalancingUsdToBrlAmount; + const amountUsdcRaw = multiplyByPowerOfTen(new Big(amountUsdc), 6).toFixed(0, 0); + + if (forceRestart) { + console.log("Force restart enabled. Starting rebalancing regardless of coverage ratios."); + } else { + const coverage = await getBaseNablaCoverageRatio(); + if (!coverage) { + console.log("Failed to fetch Base Nabla coverage ratio."); + return; + } + + if (coverage.brlaCoverageRatio >= 1 + config.rebalancingThreshold) { + console.log(`Base Nabla BRLA coverage ratio ${coverage.brlaCoverageRatio} requires rebalancing.`); + } else { + console.log(`Base Nabla BRLA coverage ratio ${coverage.brlaCoverageRatio} does not require rebalancing.`); + return; + } + } + + const stateManager = new UsdcBaseStateManager(); + const state = await stateManager.getState(); + const isResuming = !forceRestart && state && state.currentPhase !== UsdcBaseRebalancePhase.Idle; + + if (!isResuming) { + await checkInitialUsdcBalanceOnBase(amountUsdcRaw); + } + + await rebalanceUsdcBrlaUsdcBase(amountUsdcRaw, forceRestart, forcedRoute); +} + +const rebalanceFn = useLegacy ? checkForRebalancingLegacy : checkForRebalancing; +console.log(`Using ${useLegacy ? "legacy" : "new"} rebalancing flow.`); + +rebalanceFn() .then(() => { console.log("Rebalancing process completed successfully."); process.exit(0); diff --git a/apps/rebalancer/src/rebalance/brla-to-axlusdc/index.ts b/apps/rebalancer/src/rebalance/brla-to-axlusdc/index.ts index f43bcc961..9124454ab 100644 --- a/apps/rebalancer/src/rebalance/brla-to-axlusdc/index.ts +++ b/apps/rebalancer/src/rebalance/brla-to-axlusdc/index.ts @@ -1,7 +1,7 @@ import { multiplyByPowerOfTen, SlackNotifier } from "@vortexfi/shared"; import Big from "big.js"; import { brlaMoonbeamTokenDetails, usdcTokenDetails } from "../../constants.ts"; -import { phaseOrder, RebalancePhase, StateManager } from "../../services/stateManager.ts"; +import { BrlaToAxlUsdcStateManager, phaseOrder, RebalancePhase } from "../../services/stateManager.ts"; import { getMoonbeamEvmClients, getPendulumAccount } from "../../utils/config.ts"; import { checkInitialPendulumBalance, @@ -20,7 +20,7 @@ import { export async function rebalanceBrlaToUsdcAxl(amountAxlUsdc: string, forceRestart = false) { console.log(`Starting rebalance from BRLA to USDC.axl with amount: ${amountAxlUsdc}`); - const stateManager = new StateManager(); + const stateManager = new BrlaToAxlUsdcStateManager(); let state = await stateManager.getState(); console.log("Fetched rebalance state from storage.", state); @@ -45,7 +45,7 @@ export async function rebalanceBrlaToUsdcAxl(amountAxlUsdc: string, forceRestart const moonbeamAccountAddress = moonbeamWalletClient.account.address; // Step 1: Check initial balance - if (currentOrder <= 1) { + if (currentOrder <= phaseOrder[RebalancePhase.CheckInitialPendulumBalance]) { if (!state.amountAxlUsdc) throw new Error("State corrupted: amountAxlUsdc missing for step 1"); state.initialBalance = await checkInitialPendulumBalance(pendulumAccount.address, state.amountAxlUsdc); @@ -55,7 +55,7 @@ export async function rebalanceBrlaToUsdcAxl(amountAxlUsdc: string, forceRestart } // Step 2: Swap USDC.axl to BRLA on Pendulum - if (currentOrder <= 2) { + if (currentOrder <= phaseOrder[RebalancePhase.SwapAxlusdcToBrla]) { if (!state.amountAxlUsdc) throw new Error("State corrupted: amountAxlUsdc missing for step 2"); state.brlaAmount = Big((await swapAxlusdcToBrla(state.amountAxlUsdc)).toFixed(2, 0)); @@ -66,7 +66,7 @@ export async function rebalanceBrlaToUsdcAxl(amountAxlUsdc: string, forceRestart } // Step 3: Send BRLA to Moonbeam via XCM - if (currentOrder <= 3) { + if (currentOrder <= phaseOrder[RebalancePhase.SendBrlaToMoonbeam]) { if (!state.brlaAmount) throw new Error("State corrupted: brlaAmount missing for step 3"); await sendBrlaToMoonbeam(state.brlaAmount, brlaMoonbeamTokenDetails.pendulumRepresentative); @@ -77,7 +77,7 @@ export async function rebalanceBrlaToUsdcAxl(amountAxlUsdc: string, forceRestart } // Step 4: Wait for BRLA to appear on the internal Avenia balance. - if (currentOrder <= 4) { + if (currentOrder <= phaseOrder[RebalancePhase.PollForSufficientBalance]) { if (!state.brlaAmount) throw new Error("State corrupted: brlaAmount missing for step 4"); await pollForSufficientBalance(state.brlaAmount); @@ -88,7 +88,7 @@ export async function rebalanceBrlaToUsdcAxl(amountAxlUsdc: string, forceRestart } // Step 5: Swap BRLA to USDC.e using Avenia, deposits swapped amount on polygon. - if (currentOrder <= 5) { + if (currentOrder <= phaseOrder[RebalancePhase.SwapBrlaToUsdcOnBrlaApiService]) { if (!state.brlaAmount) throw new Error("State corrupted: brlaAmount missing for step 5"); const quote = await swapBrlaToUsdcOnBrlaApiService(state.brlaAmount, moonbeamAccountAddress as `0x${string}`); @@ -99,7 +99,7 @@ export async function rebalanceBrlaToUsdcAxl(amountAxlUsdc: string, forceRestart } // Step 6: Swap and transfer USDC.e from Polygon to USDC.axl on Moonbeam using SquidRouter - if (currentOrder <= 6) { + if (currentOrder <= phaseOrder[RebalancePhase.TransferUsdcToMoonbeamWithSquidrouter]) { if (!state.brlaToUsdcAmountUsd) throw new Error("State corrupted: brlaToUsdcAmountUsd missing for step 6"); const usdcAmountRaw = state.usdcAmountRaw || multiplyByPowerOfTen(state.brlaToUsdcAmountUsd, usdcTokenDetails.decimals).toFixed(0, 0); @@ -113,7 +113,7 @@ export async function rebalanceBrlaToUsdcAxl(amountAxlUsdc: string, forceRestart } // Step 7: Trigger XCM from Moonbeam to send USDC.axl back to Pendulum - if (currentOrder <= 7) { + if (currentOrder <= phaseOrder[RebalancePhase.TriggerXcmFromMoonbeam]) { if (!state.squidRouterReceiverId) throw new Error("State corrupted: squidRouterReceiverId missing for step 7"); // Wait for 30 seconds to ensure the SquidRouter transaction is processed await new Promise(resolve => setTimeout(resolve, 30000)); @@ -125,7 +125,7 @@ export async function rebalanceBrlaToUsdcAxl(amountAxlUsdc: string, forceRestart } // Step 8: Wait for USDC.axl to arrive on Pendulum - if (currentOrder <= 8) { + if (currentOrder <= phaseOrder[RebalancePhase.WaitForAxlUsdcOnPendulum]) { if (!state.initialBalance) throw new Error("State corrupted: initialBalance missing for step 8"); await waitForAxlUsdcOnPendulum(pendulumAccount.address, state.initialBalance); diff --git a/apps/rebalancer/src/rebalance/brla-to-axlusdc/steps.ts b/apps/rebalancer/src/rebalance/brla-to-axlusdc/steps.ts index 64672bf1a..5da763fd6 100644 --- a/apps/rebalancer/src/rebalance/brla-to-axlusdc/steps.ts +++ b/apps/rebalancer/src/rebalance/brla-to-axlusdc/steps.ts @@ -23,7 +23,6 @@ import { getStatusAxelarScan, getTokenOutAmount, MOONBEAM_RECEIVER_CONTRACT_ADDRESS, - multiplyByPowerOfTen, Networks, OnchainSwapQuoteParams, PendulumTokenDetails, diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts new file mode 100644 index 000000000..006d2737f --- /dev/null +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts @@ -0,0 +1,252 @@ +import { BrlaApiService, multiplyByPowerOfTen, SlackNotifier } from "@vortexfi/shared"; +import Big from "big.js"; +import { UsdcBaseRebalancePhase, UsdcBaseStateManager, usdcBasePhaseOrder } from "../../services/stateManager.ts"; +import { getBaseEvmClients, getPolygonEvmClients } from "../../utils/config.ts"; +import { NonceManager } from "../../utils/nonce.ts"; +import { + aveniaCreateSwapToUsdcBaseTicket, + aveniaTransferBrlaToPolygon, + checkInitialUsdcBalanceOnBase, + checkTicketStatusPaid, + compareRates, + nablaApproveAndSwapOnBase, + squidRouterApproveAndSwap, + transferBrlaToAveniaOnBase, + verifyFinalUsdcBalanceOnBase, + waitBrlaOnPolygon, + waitForBrlaOnAvenia, + waitUsdcOnBase +} from "./steps.ts"; + +export async function rebalanceUsdcBrlaUsdcBase( + usdcAmountRaw: string, + forceRestart = false, + forcedRoute?: "squidrouter" | "avenia" +) { + console.log(`Starting USDC->BRLA->USDC rebalance on Base with amount: ${usdcAmountRaw} (raw USDC)`); + + const stateManager = new UsdcBaseStateManager(); + let state = await stateManager.getState(); + console.log("Fetched rebalance state from storage.", state); + + const isResuming = !forceRestart && state && state.currentPhase !== UsdcBaseRebalancePhase.Idle; + if (isResuming) { + console.log(`Resuming rebalance from phase: ${state?.currentPhase}`); + } else { + state = await stateManager.startNewRebalance(usdcAmountRaw); + } + + if (!state) { + throw new Error("State is undefined after initialization."); + } + + const { publicClient: basePublicClient, walletClient: baseWalletClient } = getBaseEvmClients(); + const baseAddress = baseWalletClient.account.address as `0x${string}`; + const baseNonce = await NonceManager.create(basePublicClient, baseAddress); + + const currentOrder = usdcBasePhaseOrder[state.currentPhase]; + console.log(`Current phase order: ${currentOrder}`); + + if (currentOrder <= usdcBasePhaseOrder[UsdcBaseRebalancePhase.CheckInitialUsdcBalance]) { + if (!state.usdcAmountRaw) throw new Error("State corrupted: usdcAmountRaw missing for step 1"); + + const initialBalance = await checkInitialUsdcBalanceOnBase(state.usdcAmountRaw); + state.initialUsdcBalance = initialBalance.toString(); + state.currentPhase = UsdcBaseRebalancePhase.NablaApprove; + await stateManager.saveState(state); + } + + if (currentOrder <= usdcBasePhaseOrder[UsdcBaseRebalancePhase.NablaApprove]) { + if (!state.usdcAmountRaw) throw new Error("State corrupted: usdcAmountRaw missing for step 2"); + + const result = await nablaApproveAndSwapOnBase(state.usdcAmountRaw, baseNonce); + + state.nablaApproveHash = result.approveHash; + state.nablaSwapHash = result.swapHash; + state.brlaAmountRaw = result.brlaAmountRaw; + state.brlaAmountDecimal = result.brlaAmountDecimal.toString(); + + console.log(`Nabla swap completed. BRLA received: ${result.brlaAmountDecimal.toFixed(4)}`); + state.currentPhase = UsdcBaseRebalancePhase.TransferBrlaToAvenia; + await stateManager.saveState(state); + } + + if (currentOrder <= usdcBasePhaseOrder[UsdcBaseRebalancePhase.TransferBrlaToAvenia]) { + if (!state.brlaAmountRaw) throw new Error("State corrupted: brlaAmountRaw missing for step 3"); + + state.brlaTransferHash = await transferBrlaToAveniaOnBase(state.brlaAmountRaw, baseNonce); + + console.log(`BRLA transferred to Avenia on Base. Tx: ${state.brlaTransferHash}`); + state.currentPhase = UsdcBaseRebalancePhase.WaitForBrlaOnAvenia; + await stateManager.saveState(state); + } + + if (currentOrder <= usdcBasePhaseOrder[UsdcBaseRebalancePhase.WaitForBrlaOnAvenia]) { + if (!state.brlaAmountDecimal) throw new Error("State corrupted: brlaAmountDecimal missing for step 4"); + + const actualBrlaBalance = await waitForBrlaOnAvenia(Big(state.brlaAmountDecimal)); + + state.brlaAmountDecimal = actualBrlaBalance; + state.brlaAmountRaw = multiplyByPowerOfTen(Big(actualBrlaBalance), 18).toFixed(0, 0); + + console.log("BRLA confirmed on Avenia internal balance."); + state.currentPhase = UsdcBaseRebalancePhase.CompareRates; + await stateManager.saveState(state); + } + + if (currentOrder <= usdcBasePhaseOrder[UsdcBaseRebalancePhase.CompareRates]) { + if (!state.brlaAmountDecimal) throw new Error("State corrupted: brlaAmountDecimal missing for step 5"); + + if (forcedRoute) { + console.log(`Forced route: ${forcedRoute}. Running quotes without comparison.`); + } + + const rateComparison = await compareRates(Big(state.brlaAmountDecimal)); + + state.winningRoute = forcedRoute ?? rateComparison.winningRoute; + state.squidRouterQuoteUsdc = rateComparison.squidRouterQuoteUsdc; + state.aveniaQuoteUsdc = rateComparison.aveniaQuoteUsdc; + + console.log(`Rate comparison complete. Winner: ${state.winningRoute}`); + + if (state.winningRoute === "squidrouter") { + state.currentPhase = UsdcBaseRebalancePhase.AveniaTransferToPolygon; + } else { + state.currentPhase = UsdcBaseRebalancePhase.AveniaSwapToUsdcBase; + } + await stateManager.saveState(state); + } + + if (state.winningRoute === "avenia") { + if (currentOrder <= usdcBasePhaseOrder[UsdcBaseRebalancePhase.AveniaSwapToUsdcBase]) { + if (!state.brlaAmountDecimal) throw new Error("State corrupted: brlaAmountDecimal missing for avenia step 1"); + + const brlaApiService = BrlaApiService.getInstance(); + + if (!state.aveniaTicketId) { + try { + const result = await aveniaCreateSwapToUsdcBaseTicket(Big(state.brlaAmountDecimal), baseAddress); + state.aveniaTicketId = result.ticketId; + state.aveniaQuoteUsdc = result.outputAmount; + await stateManager.saveState(state); + } catch (error) { + console.error("Avenia swap ticket creation failed. Falling back to SquidRouter route.", error); + state.winningRoute = "squidrouter"; + state.currentPhase = UsdcBaseRebalancePhase.AveniaTransferToPolygon; + await stateManager.saveState(state); + } + } + + if (state.winningRoute === "avenia" && state.aveniaTicketId) { + console.log(`Checking status for Avenia swap ticket ${state.aveniaTicketId}...`); + const paidTicket = await checkTicketStatusPaid(brlaApiService, state.aveniaTicketId); + state.aveniaQuoteUsdc = paidTicket.quote.outputAmount; + console.log(`Avenia swap completed. USDC output: ${state.aveniaQuoteUsdc}`); + + state.currentPhase = UsdcBaseRebalancePhase.WaitUsdcOnBaseFromAvenia; + await stateManager.saveState(state); + } + } + + if (state.winningRoute === "avenia") { + if (currentOrder <= usdcBasePhaseOrder[UsdcBaseRebalancePhase.WaitUsdcOnBaseFromAvenia]) { + if (!state.aveniaQuoteUsdc) throw new Error("State corrupted: aveniaQuoteUsdc missing for avenia step 2"); + + const aveniaUsdcRaw = multiplyByPowerOfTen(Big(state.aveniaQuoteUsdc), 6).toFixed(0, 0); + await waitUsdcOnBase(aveniaUsdcRaw); + + console.log("USDC from Avenia confirmed on Base."); + state.currentPhase = UsdcBaseRebalancePhase.VerifyFinalBalance; + await stateManager.saveState(state); + } + } + } + + if (state.winningRoute === "squidrouter") { + const { publicClient: polygonPublicClient, walletClient: polygonWalletClient } = getPolygonEvmClients(); + const polygonNonce = await NonceManager.create(polygonPublicClient, polygonWalletClient.account.address as `0x${string}`); + + if (currentOrder <= usdcBasePhaseOrder[UsdcBaseRebalancePhase.AveniaTransferToPolygon]) { + if (!state.brlaAmountDecimal) throw new Error("State corrupted: brlaAmountDecimal missing for squid step 1"); + + if (!state.aveniaTicketId) { + const ticketId = await aveniaTransferBrlaToPolygon(Big(state.brlaAmountDecimal)); + state.aveniaTicketId = ticketId; + await stateManager.saveState(state); + } + + const brlaApiService = BrlaApiService.getInstance(); + await checkTicketStatusPaid(brlaApiService, state.aveniaTicketId); + + console.log("BRLA transferred to Polygon via Avenia."); + state.currentPhase = UsdcBaseRebalancePhase.WaitBrlaOnPolygon; + await stateManager.saveState(state); + } + + if (currentOrder <= usdcBasePhaseOrder[UsdcBaseRebalancePhase.WaitBrlaOnPolygon]) { + if (!state.brlaAmountRaw) throw new Error("State corrupted: brlaAmountRaw missing for squid step 2"); + + await waitBrlaOnPolygon(state.brlaAmountRaw); + + console.log("BRLA confirmed on Polygon."); + state.currentPhase = UsdcBaseRebalancePhase.SquidRouterApproveAndSwap; + await stateManager.saveState(state); + } + + if (currentOrder <= usdcBasePhaseOrder[UsdcBaseRebalancePhase.SquidRouterApproveAndSwap]) { + if (!state.brlaAmountRaw) throw new Error("State corrupted: brlaAmountRaw missing for squid step 3"); + + const result = await squidRouterApproveAndSwap(state.brlaAmountRaw, baseAddress, polygonNonce); + + state.squidRouterSwapHash = result.swapHash; + console.log(`SquidRouter swap completed. Tx: ${result.swapHash}`); + state.currentPhase = UsdcBaseRebalancePhase.WaitUsdcOnBaseFromSquid; + await stateManager.saveState(state); + } + + if (currentOrder <= usdcBasePhaseOrder[UsdcBaseRebalancePhase.WaitUsdcOnBaseFromSquid]) { + if (!state.squidRouterQuoteUsdc) throw new Error("State corrupted: squidRouterQuoteUsdc missing for squid step 4"); + + await waitUsdcOnBase(state.squidRouterQuoteUsdc); + + console.log("USDC from SquidRouter confirmed on Base."); + state.currentPhase = UsdcBaseRebalancePhase.VerifyFinalBalance; + await stateManager.saveState(state); + } + } + + if (currentOrder <= usdcBasePhaseOrder[UsdcBaseRebalancePhase.VerifyFinalBalance]) { + const finalBalance = await verifyFinalUsdcBalanceOnBase(); + state.finalUsdcBalance = finalBalance.toString(); + + state.currentPhase = UsdcBaseRebalancePhase.Idle; + await stateManager.saveState(state); + } + + if (!state.initialUsdcBalance) throw new Error("State corrupted: initialUsdcBalance missing at completion"); + if (!state.usdcAmountRaw) throw new Error("State corrupted: usdcAmountRaw missing at completion"); + if (!state.brlaAmountDecimal) throw new Error("State corrupted: brlaAmountDecimal missing at completion"); + if (!state.finalUsdcBalance) throw new Error("State corrupted: finalUsdcBalance missing at completion"); + + const initialUsdcDecimal = Big(state.initialUsdcBalance); + const finalUsdcDecimal = Big(state.finalUsdcBalance); + const brlaDecimal = Big(state.brlaAmountDecimal); + const cost = initialUsdcDecimal.minus(finalUsdcDecimal); + const costRelative = initialUsdcDecimal.gt(0) ? cost.div(initialUsdcDecimal).toFixed(4, 0) : "N/A"; + + console.log( + `Rebalance completed! Initial: ${initialUsdcDecimal.toFixed(6)} USDC, Final: ${finalUsdcDecimal.toFixed(6)} USDC` + ); + console.log(`Route taken: ${state.winningRoute}`); + console.log(`Cost: absolute: ${cost.toFixed(6)} USDC | relative: ${costRelative}`); + + const slackNotifier = new SlackNotifier(); + await slackNotifier.sendMessage({ + text: + "USDC->BRLA->USDC rebalance on Base completed!\n" + + `Route: ${state.winningRoute}\n` + + `Initial: ${initialUsdcDecimal.toFixed(6)} USDC, Final: ${finalUsdcDecimal.toFixed(6)} USDC\n` + + `BRLA swapped: ${brlaDecimal.toFixed(4)}\n` + + `Cost: ${cost.toFixed(6)} USDC (${costRelative})` + }); +} diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts new file mode 100644 index 000000000..8111af19d --- /dev/null +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts @@ -0,0 +1,534 @@ +import { + AveniaFeeType, + AveniaPaymentMethod, + AveniaTicketStatus, + BrlaApiService, + BrlaCurrency, + checkEvmBalancePeriodically, + createNablaTransactionsForOnrampOnEVM, + createTransactionDataFromRoute, + EphemeralAccountType, + ERC20_BRLA_BASE, + EvmClientManager, + getNablaBasePool, + getNetworkId, + getRoute, + getStatusAxelarScan, + multiplyByPowerOfTen, + Networks +} from "@vortexfi/shared"; +import Big from "big.js"; +import { encodeFunctionData, erc20Abi } from "viem"; +import { base, polygon } from "viem/chains"; +import { getBaseEvmClients, getConfig, getPolygonEvmClients } from "../../utils/config.ts"; +import { NonceManager } from "../../utils/nonce.ts"; +import { waitForTransactionConfirmation } from "../../utils/transactions.ts"; + +const USDC_BASE: `0x${string}` = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +const BRLA_POLYGON: `0x${string}` = "0xe6a537a407488807f0bbeb0038b79004f19dddfb"; +const NABLA_SWAP_DEADLINE_MINUTES = 60 * 24 * 7; +const AMM_MINIMUM_OUTPUT_HARD_MARGIN = 0.05; + +export async function checkTicketStatusPaid(brlaApiService: BrlaApiService, ticketId: string) { + const pollInterval = 5000; + const timeout = 5 * 60 * 1000; + const startTime = Date.now(); + let lastError: Error | undefined; + + while (Date.now() - startTime < timeout) { + try { + const ticket = await brlaApiService.getAveniaSwapTicket(ticketId); + if (ticket && ticket.status) { + if (ticket.status === AveniaTicketStatus.PAID) { + return ticket; + } + if (ticket.status === AveniaTicketStatus.FAILED) { + throw new Error("Ticket status is FAILED"); + } + } + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + console.warn(`Polling for ticket ${ticketId} status failed with error. Retrying...`, lastError); + } + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } + + if (lastError) { + throw new Error(`Polling for ticket status timed out with an error: ${lastError.message}`); + } + throw new Error("Polling for ticket status timed out."); +} + +export async function checkInitialUsdcBalanceOnBase(usdcAmountRaw: string): Promise { + const { publicClient, walletClient } = getBaseEvmClients(); + const address = walletClient.account.address; + + const balance = await publicClient.readContract({ + abi: erc20Abi, + address: USDC_BASE, + args: [address], + functionName: "balanceOf" + }); + + const balanceDecimal = multiplyByPowerOfTen(Big(balance.toString()), -6); + console.log(`Current USDC balance on Base (${address}): ${balanceDecimal.toFixed(6)} USDC`); + + const requiredAmount = multiplyByPowerOfTen(Big(usdcAmountRaw), -6); + if (balanceDecimal.lt(requiredAmount)) { + throw new Error(`Insufficient USDC on Base. Have: ${balanceDecimal.toFixed(6)}, need: ${requiredAmount.toFixed(6)}`); + } + + return balanceDecimal; +} + +export async function nablaApproveAndSwapOnBase( + usdcAmountRaw: string, + baseNonce: NonceManager +): Promise<{ + brlaAmountRaw: string; + brlaAmountDecimal: Big; + approveHash: string; + swapHash: string; +}> { + console.log(`Starting Nabla swap of ${usdcAmountRaw} USDC (raw) to BRLA on Base...`); + + const { walletClient, publicClient } = getBaseEvmClients(); + const executorAddress = walletClient.account.address; + + const { router } = getNablaBasePool(USDC_BASE, ERC20_BRLA_BASE); + + // Snapshot pre-swap BRLA balance — account may have leftovers from prior runs + const brlaBalanceBefore = await publicClient.readContract({ + abi: erc20Abi, + address: ERC20_BRLA_BASE, + args: [executorAddress], + functionName: "balanceOf" + }); + + const evmClientManager = EvmClientManager.getInstance(); + const quoteAbi = [ + { + inputs: [ + { name: "_amountIn", type: "uint256" }, + { name: "_tokenPath", type: "address[]" }, + { name: "_routerPath", type: "address[]" } + ], + name: "quoteSwapExactTokensForTokens", + outputs: [{ name: "amountOut_", type: "uint256" }], + stateMutability: "view", + type: "function" + } + ] as const; + + const { quoter } = getNablaBasePool(USDC_BASE, ERC20_BRLA_BASE); + const expectedOutputRaw = await evmClientManager.readContractWithRetry(Networks.Base, { + abi: quoteAbi, + address: quoter, + args: [BigInt(usdcAmountRaw), [USDC_BASE, ERC20_BRLA_BASE], [router]], + functionName: "quoteSwapExactTokensForTokens" + }); + + const expectedOutputDecimal = multiplyByPowerOfTen(Big(expectedOutputRaw.toString()), -18); + console.log(`Expected BRLA output: ${expectedOutputDecimal.toFixed(4)}`); + + const nablaHardMinimumOutputRaw = Big(expectedOutputRaw.toString()) + .mul(1 - AMM_MINIMUM_OUTPUT_HARD_MARGIN) + .toFixed(0, 0); + + const { approve, swap } = await createNablaTransactionsForOnrampOnEVM( + usdcAmountRaw, + { address: executorAddress, type: EphemeralAccountType.EVM }, + USDC_BASE, + ERC20_BRLA_BASE, + nablaHardMinimumOutputRaw, + NABLA_SWAP_DEADLINE_MINUTES, + router + ); + + console.log("Sending Nabla approve transaction on Base..."); + const { maxFeePerGas: approveFee, maxPriorityFeePerGas: approveTip } = await publicClient.estimateFeesPerGas(); + const approveHash = await walletClient.sendTransaction({ + account: walletClient.account, + chain: base, + data: approve.data, + gas: BigInt(approve.gas), + maxFeePerGas: approveFee, + maxPriorityFeePerGas: approveTip, + nonce: baseNonce.next(), + to: approve.to, + value: BigInt(approve.value) + }); + console.log(`Approve tx sent: ${approveHash}`); + await waitForTransactionConfirmation(approveHash, publicClient); + console.log("Nabla approval confirmed."); + + console.log("Sending Nabla swap transaction on Base..."); + const { maxFeePerGas: swapFee, maxPriorityFeePerGas: swapTip } = await publicClient.estimateFeesPerGas(); + const swapHash = await walletClient.sendTransaction({ + account: walletClient.account, + chain: base, + data: swap.data, + gas: BigInt(swap.gas), + maxFeePerGas: swapFee, + maxPriorityFeePerGas: swapTip, + nonce: baseNonce.next(), + to: swap.to, + value: BigInt(swap.value) + }); + console.log(`Swap tx sent: ${swapHash}`); + await waitForTransactionConfirmation(swapHash, publicClient); + console.log("Nabla swap confirmed."); + + // Delay to let the RPC sync the post-swap state before reading the balance + await new Promise(resolve => setTimeout(resolve, 5_000)); + + const brlaBalanceAfter = await publicClient.readContract({ + abi: erc20Abi, + address: ERC20_BRLA_BASE, + args: [executorAddress], + functionName: "balanceOf" + }); + + const brlaReceivedRaw = brlaBalanceAfter - brlaBalanceBefore; + const brlaAmountRaw = brlaReceivedRaw.toString(); + const brlaAmountDecimal = multiplyByPowerOfTen(Big(brlaAmountRaw), -18); + console.log(`Received ${brlaAmountDecimal.toFixed(4)} BRLA on Base (pre: ${brlaBalanceBefore}, post: ${brlaBalanceAfter})`); + + return { + approveHash, + brlaAmountDecimal, + brlaAmountRaw, + swapHash + }; +} + +export async function transferBrlaToAveniaOnBase(brlaAmountRaw: string, baseNonce: NonceManager): Promise { + const { brlaBusinessAccountAddress } = getConfig(); + const { walletClient, publicClient } = getBaseEvmClients(); + + console.log(`Transferring ${brlaAmountRaw} BRLA (raw) to Avenia account ${brlaBusinessAccountAddress} on Base...`); + + const data = encodeFunctionData({ + abi: erc20Abi, + args: [brlaBusinessAccountAddress as `0x${string}`, BigInt(brlaAmountRaw)], + functionName: "transfer" + }); + + const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); + + const txHash = await walletClient.sendTransaction({ + account: walletClient.account, + chain: base, + data, + gas: 100000n, + maxFeePerGas, + maxPriorityFeePerGas, + nonce: baseNonce.next(), + to: ERC20_BRLA_BASE, + value: 0n + }); + + console.log(`BRLA transfer tx sent: ${txHash}`); + await waitForTransactionConfirmation(txHash, publicClient); + console.log("BRLA transfer to Avenia confirmed on Base."); + + return txHash; +} + +export async function waitForBrlaOnAvenia(brlaAmountDecimal: Big): Promise { + const pollInterval = 5000; + const timeout = 10 * 60 * 1000; + const startTime = Date.now(); + const brlaApiService = BrlaApiService.getInstance(); + + console.log(`Waiting for ~${brlaAmountDecimal.toFixed(4)} BRLA to appear on Avenia main account balance...`); + + while (Date.now() - startTime < timeout) { + try { + const balanceResponse = await brlaApiService.getMainAccountBalance(); + if (balanceResponse && balanceResponse.balances && balanceResponse.balances.BRLA !== undefined) { + const balanceDecimal = Big(balanceResponse.balances.BRLA); + if (balanceDecimal.div(brlaAmountDecimal).gte(0.998)) { + console.log(`Sufficient BRLA balance found on Avenia: ${balanceResponse.balances.BRLA}`); + return String(balanceResponse.balances.BRLA); + } + console.log( + `Insufficient BRLA balance. Needed: ${brlaAmountDecimal.toFixed(12)}, have: ${balanceDecimal.toFixed(12)}. Retrying...` + ); + } + } catch (error) { + console.log("Polling for Avenia balance failed with error. Retrying...", error); + } + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } + + throw new Error(`Avenia BRLA balance check timed out after 10 minutes. Needed ~${brlaAmountDecimal.toFixed(4)} BRLA.`); +} + +export async function compareRates(brlaAmountDecimal: Big): Promise<{ + winningRoute: "squidrouter" | "avenia"; + squidRouterQuoteUsdc: string | null; + aveniaQuoteUsdc: string | null; +}> { + console.log("Comparing SquidRouter vs Avenia rates for BRLA -> USDC..."); + + const { walletClient: baseWalletClient } = getBaseEvmClients(); + const baseAddress = baseWalletClient.account.address; + const brlaAmountRaw = multiplyByPowerOfTen(brlaAmountDecimal, 18).toFixed(0, 0); + + let squidRouterQuoteUsdc: string | null = null; + let aveniaQuoteUsdc: string | null = null; + + try { + const { walletClient: polygonWalletClient } = getPolygonEvmClients(); + const polygonAddress = polygonWalletClient.account.address; + + const routeResult = await getRoute( + { + bypassGuardrails: true, + enableExpress: true, + fromAddress: polygonAddress, + fromAmount: brlaAmountRaw, + fromChain: getNetworkId(Networks.Polygon).toString(), + fromToken: BRLA_POLYGON, + slippage: 4, + toAddress: baseAddress, + toChain: getNetworkId(Networks.Base).toString(), + toToken: USDC_BASE + }, + { useCache: true } + ); + + squidRouterQuoteUsdc = routeResult.data.route.estimate.toAmount; + console.log(`SquidRouter quote: ${squidRouterQuoteUsdc} USDC (raw, 6 decimals)`); + } catch (error) { + console.warn("SquidRouter quote failed:", error); + } + + try { + const brlaApiService = BrlaApiService.getInstance(); + const aveniaQuote = await brlaApiService.createOnchainSwapQuote( + { + inputAmount: brlaAmountDecimal.toFixed(12, 0), + inputCurrency: BrlaCurrency.BRLA, + outputCurrency: BrlaCurrency.USDC, + outputPaymentMethod: AveniaPaymentMethod.BASE + }, + { useCache: true } + ); + + aveniaQuoteUsdc = aveniaQuote.outputAmount; + console.log(`Avenia quote: ${aveniaQuoteUsdc} USDC`); + } catch (error) { + console.warn("Avenia quote failed:", error); + } + + if (!squidRouterQuoteUsdc && !aveniaQuoteUsdc) { + throw new Error("Both SquidRouter and Avenia quotes failed. Cannot proceed."); + } + + if (!squidRouterQuoteUsdc) { + console.log("SquidRouter unavailable, using Avenia."); + return { aveniaQuoteUsdc, squidRouterQuoteUsdc, winningRoute: "avenia" }; + } + + if (!aveniaQuoteUsdc) { + console.log("Avenia unavailable, using SquidRouter."); + return { aveniaQuoteUsdc, squidRouterQuoteUsdc, winningRoute: "squidrouter" }; + } + + const squidUsdcDecimal = multiplyByPowerOfTen(Big(squidRouterQuoteUsdc), -6); + const aveniaUsdcDecimal = Big(aveniaQuoteUsdc); + + console.log(`SquidRouter: ${squidUsdcDecimal.toFixed(6)} USDC | Avenia: ${aveniaUsdcDecimal.toFixed(6)} USDC`); + + const winningRoute = squidUsdcDecimal.gt(aveniaUsdcDecimal) ? "squidrouter" : "avenia"; + console.log(`Winner: ${winningRoute}`); + + return { aveniaQuoteUsdc, squidRouterQuoteUsdc, winningRoute }; +} + +export async function aveniaTransferBrlaToPolygon(brlaAmountDecimal: Big): Promise { + console.log(`Requesting Avenia to transfer ${brlaAmountDecimal.toFixed(4)} BRLA from internal balance to Polygon...`); + + const brlaApiService = BrlaApiService.getInstance(); + + const quote = await brlaApiService.createOnchainSwapQuote({ + inputAmount: brlaAmountDecimal.toFixed(12, 0), + inputCurrency: BrlaCurrency.BRLA, + outputCurrency: BrlaCurrency.BRLA, + outputPaymentMethod: AveniaPaymentMethod.POLYGON + }); + console.log("Avenia BRLA->BRLA (Polygon) quote:", quote); + + const { walletClient: polygonWalletClient } = getPolygonEvmClients(); + const polygonAddress = polygonWalletClient.account.address; + + const ticket = await brlaApiService.createOnchainSwapTicket({ + quoteToken: quote.quoteToken, + ticketBlockchainOutput: { + walletAddress: polygonAddress, + walletChain: AveniaPaymentMethod.POLYGON + } + }); + console.log(`Avenia transfer ticket created: ${ticket.id}`); + return ticket.id; +} + +export async function waitBrlaOnPolygon(brlaAmountRaw: string): Promise { + const { walletClient: polygonWalletClient } = getPolygonEvmClients(); + const polygonAddress = polygonWalletClient.account.address; + + console.log(`Waiting for BRLA to arrive on Polygon (${polygonAddress})...`); + + await checkEvmBalancePeriodically(BRLA_POLYGON, polygonAddress, brlaAmountRaw, 1_000, 10 * 60 * 1_000, Networks.Polygon); + + console.log("BRLA arrived on Polygon."); +} + +export async function squidRouterApproveAndSwap( + brlaAmountRaw: string, + baseReceiverAddress: `0x${string}`, + polygonNonce: NonceManager +): Promise<{ swapHash: string; toAmountUsd: string }> { + console.log("Executing SquidRouter swap: Polygon BRLA -> Base USDC..."); + + const { walletClient: polygonWalletClient, publicClient: polygonPublicClient } = getPolygonEvmClients(); + const polygonAddress = polygonWalletClient.account.address; + + const routeResult = await getRoute({ + bypassGuardrails: true, + enableExpress: true, + fromAddress: polygonAddress, + fromAmount: brlaAmountRaw, + fromChain: getNetworkId(Networks.Polygon).toString(), + fromToken: BRLA_POLYGON, + slippage: 4, + toAddress: baseReceiverAddress, + toChain: getNetworkId(Networks.Base).toString(), + toToken: USDC_BASE + }); + + const route = routeResult.data.route; + console.log(`SquidRouter route obtained. Expected output: ${route.estimate.toAmount} USDC (raw)`); + + const { approveData, swapData } = await createTransactionDataFromRoute({ + inputTokenErc20Address: BRLA_POLYGON, + publicClient: polygonPublicClient, + rawAmount: brlaAmountRaw, + route + }); + + console.log("Sending SquidRouter approve transaction on Polygon..."); + const { maxFeePerGas: approveFee, maxPriorityFeePerGas: approveTip } = await polygonPublicClient.estimateFeesPerGas(); + const approveHash = await polygonWalletClient.sendTransaction({ + account: polygonWalletClient.account, + chain: polygon, + data: approveData.data, + gas: BigInt(approveData.gas), + maxFeePerGas: approveFee * 5n, + maxPriorityFeePerGas: approveTip * 5n, + nonce: polygonNonce.next(), + to: approveData.to, + value: BigInt(approveData.value) + }); + console.log(`Approve tx: ${approveHash}`); + await waitForTransactionConfirmation(approveHash, polygonPublicClient); + + console.log("Sending SquidRouter swap transaction on Polygon..."); + const { maxFeePerGas: swapFee, maxPriorityFeePerGas: swapTip } = await polygonPublicClient.estimateFeesPerGas(); + const swapHash = await polygonWalletClient.sendTransaction({ + account: polygonWalletClient.account, + chain: polygon, + data: swapData.data, + gas: BigInt(swapData.gas), + maxFeePerGas: swapFee * 5n, + maxPriorityFeePerGas: swapTip * 5n, + nonce: polygonNonce.next(), + to: swapData.to, + value: BigInt(swapData.value) + }); + console.log(`Swap tx: ${swapHash}`); + await waitForTransactionConfirmation(swapHash, polygonPublicClient); + + console.log("Waiting for Axelar to execute the cross-chain swap..."); + let isExecuted = false; + const axelarTimeout = 30 * 60 * 1000; + const axelarStartTime = Date.now(); + + while (!isExecuted && Date.now() - axelarStartTime < axelarTimeout) { + const axelarScanStatus = await getStatusAxelarScan(swapHash); + if (axelarScanStatus && (axelarScanStatus.status === "executed" || axelarScanStatus.status === "express_executed")) { + isExecuted = true; + console.log(`Axelar execution confirmed: ${axelarScanStatus.status}`); + break; + } + console.log("Waiting for Axelar execution..."); + await new Promise(resolve => setTimeout(resolve, 10000)); + } + + if (!isExecuted) { + throw new Error("Axelar execution timed out after 30 minutes"); + } + + return { swapHash, toAmountUsd: route.estimate.toAmountUSD }; +} + +export async function waitUsdcOnBase(expectedUsdcRaw: string): Promise { + const { walletClient } = getBaseEvmClients(); + const baseAddress = walletClient.account.address; + + console.log(`Waiting for USDC to arrive on Base (${baseAddress})...`); + + await checkEvmBalancePeriodically(USDC_BASE, baseAddress, expectedUsdcRaw, 1_000, 30 * 60 * 1_000, Networks.Base); + + console.log("USDC arrived on Base."); +} + +export async function aveniaCreateSwapToUsdcBaseTicket( + brlaAmountDecimal: Big, + baseReceiverAddress: `0x${string}` +): Promise<{ + ticketId: string; + outputAmount: string; +}> { + console.log(`Creating Avenia swap ticket: BRLA -> USDC on Base for ${brlaAmountDecimal.toFixed(4)} BRLA...`); + + const brlaApiService = BrlaApiService.getInstance(); + + const quote = await brlaApiService.createOnchainSwapQuote({ + inputAmount: brlaAmountDecimal.toFixed(12, 0), + inputCurrency: BrlaCurrency.BRLA, + outputCurrency: BrlaCurrency.USDC, + outputPaymentMethod: AveniaPaymentMethod.BASE + }); + console.log("Avenia BRLA->USDC (Base) quote:", quote); + + const ticket = await brlaApiService.createOnchainSwapTicket({ + quoteToken: quote.quoteToken, + ticketBlockchainOutput: { + walletAddress: baseReceiverAddress, + walletChain: AveniaPaymentMethod.BASE + } + }); + console.log(`Avenia swap ticket created: ${ticket.id}`); + + return { outputAmount: quote.outputAmount, ticketId: ticket.id }; +} + +export async function verifyFinalUsdcBalanceOnBase(): Promise { + const { publicClient, walletClient } = getBaseEvmClients(); + const address = walletClient.account.address; + + const balance = await publicClient.readContract({ + abi: erc20Abi, + address: USDC_BASE, + args: [address], + functionName: "balanceOf" + }); + + const balanceDecimal = multiplyByPowerOfTen(Big(balance.toString()), -6); + console.log(`Final USDC balance on Base (${address}): ${balanceDecimal.toFixed(6)} USDC`); + + return balanceDecimal; +} diff --git a/apps/rebalancer/src/services/indexer/index.ts b/apps/rebalancer/src/services/indexer/index.ts index 1c9e05888..7572b493b 100644 --- a/apps/rebalancer/src/services/indexer/index.ts +++ b/apps/rebalancer/src/services/indexer/index.ts @@ -1,6 +1,72 @@ +import { ERC20_BRLA_BASE, EvmClientManager, NABLA_ROUTER_BASE_BRLA, Networks } from "@vortexfi/shared"; import { getConfig } from "../../utils/config.ts"; import { fetchLatestBlockFromIndexer, fetchNablaInstance } from "./graphql.ts"; +const SWAP_POOL_ABI = [ + { + inputs: [], + name: "reserve", + outputs: [{ type: "uint256" }], + stateMutability: "view", + type: "function" + }, + { + inputs: [], + name: "totalLiabilities", + outputs: [{ type: "uint256" }], + stateMutability: "view", + type: "function" + } +] as const; + +const ROUTER_ABI = [ + { + inputs: [{ name: "asset", type: "address" }], + name: "poolByAsset", + outputs: [{ type: "address" }], + stateMutability: "view", + type: "function" + } +] as const; + +export async function getBaseNablaCoverageRatio(): Promise< + { brlaCoverageRatio: number; usdcCoverageRatio: number } | undefined +> { + try { + const evmClientManager = EvmClientManager.getInstance(); + const baseClient = evmClientManager.getClient(Networks.Base); + + const brlaPoolAddress = (await baseClient.readContract({ + abi: ROUTER_ABI, + address: NABLA_ROUTER_BASE_BRLA, + args: [ERC20_BRLA_BASE], + functionName: "poolByAsset" + })) as `0x${string}`; + + const [brlaReserve, brlaLiabilities] = await Promise.all([ + baseClient.readContract({ + abi: SWAP_POOL_ABI, + address: brlaPoolAddress, + functionName: "reserve" + }) as Promise, + baseClient.readContract({ + abi: SWAP_POOL_ABI, + address: brlaPoolAddress, + functionName: "totalLiabilities" + }) as Promise + ]); + + const brlaCoverageRatio = brlaLiabilities > 0n ? Number(brlaReserve) / Number(brlaLiabilities) : 0; + + console.log(`Base Nabla BRLA pool coverage ratio: ${brlaCoverageRatio}`); + + return { brlaCoverageRatio, usdcCoverageRatio: 0 }; + } catch (error) { + console.error("Failed to fetch Base Nabla coverage ratio:", error); + return undefined; + } +} + /// This function retrieves all swap pools from the Nabla instance and checks their coverage ratios. /// If the coverage ratio of a pool is below the specified threshold, it adds that pool to the list of non-sufficient pools. /// @param coverageRatioThreshold - The threshold for the coverage ratio to consider it sufficient. Default is 0.5. diff --git a/apps/rebalancer/src/services/stateManager.ts b/apps/rebalancer/src/services/stateManager.ts index f4ce27db5..20987ea8c 100644 --- a/apps/rebalancer/src/services/stateManager.ts +++ b/apps/rebalancer/src/services/stateManager.ts @@ -2,6 +2,56 @@ import { createClient } from "@supabase/supabase-js"; import Big from "big.js"; import { getConfig } from "../utils/config"; +export class StateManager { + private supabase; + private filename: string; + + constructor(filename: string) { + const config = getConfig(); + if (!config.supabaseUrl || !config.supabaseServiceKey) { + throw new Error("Missing SUPABASE_URL or SUPABASE_SERVICE_KEY environment variables"); + } + this.filename = filename; + this.supabase = createClient(config.supabaseUrl, config.supabaseServiceKey); + } + + async getState(): Promise { + try { + const { data, error } = await this.supabase.storage.from("rebalancer_state").download(this.filename); + + if (error) { + if (error.statusCode === "404" || error.message?.includes("not found")) { + return undefined; + } + throw error; + } + + const stateText = await data.text(); + return JSON.parse(stateText) as T; + } catch (error) { + console.error("Error getting rebalance state:", error); + return undefined; + } + } + + async saveState(state: T): Promise { + state.updatedTime = new Date().toISOString(); + const stateString = JSON.stringify(state); + + const { error } = await this.supabase.storage.from("rebalancer_state").upload(this.filename, stateString, { + cacheControl: "3600", + contentType: "application/json", + upsert: true + }); + + if (error) { + throw error; + } + } +} + +// --- BRLA-to-axlUSDC (Pendulum) rebalance flow --- + export enum RebalancePhase { Idle = "idle", CheckInitialPendulumBalance = "checkInitialPendulumBalance", @@ -50,33 +100,16 @@ export interface RebalanceStateParsed { updatedTime: string; } -export class StateManager { - private supabase; +export class BrlaToAxlUsdcStateManager { + private inner: StateManager; constructor() { - const config = getConfig(); - this.supabase = createClient(config.supabaseUrl!, config.supabaseServiceKey!); - } - - private async getRawState(): Promise { - try { - const { data, error } = await this.supabase.storage.from("rebalancer_state").download("rebalancer_state.json"); - - if (error) throw error; - - const stateText = await data.text(); - return JSON.parse(stateText); - } catch (error: any) { - console.error("Error getting rebalance state:", error); - return undefined; - } + this.inner = new StateManager("rebalancer_state.json"); } async getState(): Promise { - const rawState = await this.getRawState(); - if (!rawState) { - return undefined; - } + const rawState = await this.inner.getState(); + if (!rawState) return undefined; return { ...rawState, @@ -91,24 +124,12 @@ export class StateManager { brlaAmount: state.brlaAmount ? state.brlaAmount.toString() : null, initialBalance: state.initialBalance ? state.initialBalance.toString() : null }; - rawState.updatedTime = new Date().toISOString(); - - const stateString = JSON.stringify(rawState); - - const { data, error } = await this.supabase.storage.from("rebalancer_state").upload("rebalancer_state.json", stateString, { - cacheControl: "3600", - contentType: "application/json", // overwrites the file if it exists - upsert: true - }); - - if (error) { - throw error; - } + await this.inner.saveState(rawState); } async startNewRebalance(amountAxlUsdc: string): Promise { const state: RebalanceStateParsed = { - amountAxlUsdc: amountAxlUsdc, + amountAxlUsdc, brlaAmount: null, brlaToUsdcAmountUsd: null, currentPhase: RebalancePhase.CheckInitialPendulumBalance, @@ -122,3 +143,101 @@ export class StateManager { return state; } } + +// --- USDC->BRLA->USDC (Base) rebalance flow --- + +export enum UsdcBaseRebalancePhase { + Idle = "idle", + CheckInitialUsdcBalance = "checkInitialUsdcBalance", + NablaApprove = "nablaApprove", + NablaSwap = "nablaSwap", + TransferBrlaToAvenia = "transferBrlaToAvenia", + WaitForBrlaOnAvenia = "waitForBrlaOnAvenia", + CompareRates = "compareRates", + AveniaTransferToPolygon = "aveniaTransferToPolygon", + WaitBrlaOnPolygon = "waitBrlaOnPolygon", + SquidRouterApproveAndSwap = "squidRouterApproveAndSwap", + WaitUsdcOnBaseFromSquid = "waitUsdcOnBaseFromSquid", + AveniaSwapToUsdcBase = "aveniaSwapToUsdcBase", + WaitUsdcOnBaseFromAvenia = "waitUsdcOnBaseFromAvenia", + VerifyFinalBalance = "verifyFinalBalance" +} + +export const usdcBasePhaseOrder: Record = { + [UsdcBaseRebalancePhase.Idle]: 0, + [UsdcBaseRebalancePhase.CheckInitialUsdcBalance]: 1, + [UsdcBaseRebalancePhase.NablaApprove]: 2, + [UsdcBaseRebalancePhase.NablaSwap]: 3, + [UsdcBaseRebalancePhase.TransferBrlaToAvenia]: 4, + [UsdcBaseRebalancePhase.WaitForBrlaOnAvenia]: 5, + [UsdcBaseRebalancePhase.CompareRates]: 6, + [UsdcBaseRebalancePhase.AveniaTransferToPolygon]: 7, + [UsdcBaseRebalancePhase.WaitBrlaOnPolygon]: 8, + [UsdcBaseRebalancePhase.SquidRouterApproveAndSwap]: 9, + [UsdcBaseRebalancePhase.WaitUsdcOnBaseFromSquid]: 10, + [UsdcBaseRebalancePhase.AveniaSwapToUsdcBase]: 7, + [UsdcBaseRebalancePhase.WaitUsdcOnBaseFromAvenia]: 8, + [UsdcBaseRebalancePhase.VerifyFinalBalance]: 11 +}; + +export type WinningRoute = "squidrouter" | "avenia" | null; + +export interface UsdcBaseRebalanceState { + currentPhase: UsdcBaseRebalancePhase; + initialUsdcBalance: string | null; + usdcAmountRaw: string | null; + brlaAmountRaw: string | null; + brlaAmountDecimal: string | null; + nablaApproveHash: string | null; + nablaSwapHash: string | null; + brlaTransferHash: string | null; + winningRoute: WinningRoute; + squidRouterQuoteUsdc: string | null; + aveniaQuoteUsdc: string | null; + squidRouterSwapHash: string | null; + aveniaTicketId: string | null; + aveniaQuoteToken: string | null; + finalUsdcBalance: string | null; + startingTime: string; + updatedTime: string; +} + +export class UsdcBaseStateManager { + private inner: StateManager; + + constructor() { + this.inner = new StateManager("rebalancer_state_usdc_base.json"); + } + + async getState(): Promise { + return this.inner.getState(); + } + + async saveState(state: UsdcBaseRebalanceState): Promise { + await this.inner.saveState(state); + } + + async startNewRebalance(usdcAmountRaw: string): Promise { + const state: UsdcBaseRebalanceState = { + aveniaQuoteToken: null, + aveniaQuoteUsdc: null, + aveniaTicketId: null, + brlaAmountDecimal: null, + brlaAmountRaw: null, + brlaTransferHash: null, + currentPhase: UsdcBaseRebalancePhase.CheckInitialUsdcBalance, + finalUsdcBalance: null, + initialUsdcBalance: null, + nablaApproveHash: null, + nablaSwapHash: null, + squidRouterQuoteUsdc: null, + squidRouterSwapHash: null, + startingTime: new Date().toISOString(), + updatedTime: new Date().toISOString(), + usdcAmountRaw, + winningRoute: null + }; + await this.saveState(state); + return state; + } +} diff --git a/apps/rebalancer/src/utils/config.ts b/apps/rebalancer/src/utils/config.ts index 27b18c469..c6dfd01ee 100644 --- a/apps/rebalancer/src/utils/config.ts +++ b/apps/rebalancer/src/utils/config.ts @@ -1,11 +1,9 @@ import { Keyring } from "@polkadot/api"; import { BRLA_BASE_URL, EvmClientManager, Networks } from "@vortexfi/shared"; -import { mnemonicToAccount } from "viem/accounts"; +import { mnemonicToAccount, privateKeyToAccount } from "viem/accounts"; export function getConfig() { - if (!process.env.PENDULUM_ACCOUNT_SECRET) throw new Error("Missing PENDULUM_ACCOUNT_SECRET environment variable"); - if (!process.env.MOONBEAM_ACCOUNT_SECRET) throw new Error("Missing MOONBEAM_ACCOUNT_SECRET environment variable"); - if (!process.env.POLYGON_ACCOUNT_SECRET) throw new Error("Missing POLYGON_ACCOUNT_SECRET environment variable"); + if (!process.env.EVM_ACCOUNT_SECRET) throw new Error("Missing EVM_ACCOUNT_SECRET environment variable"); return { alchemyApiKey: process.env.ALCHEMY_API_KEY, @@ -13,13 +11,13 @@ export function getConfig() { brlaBusinessAccountAddress: process.env.BRLA_BUSINESS_ACCOUNT_ADDRESS || "0xDF5Fb34B90e5FDF612372dA0c774A516bF5F08b2", + evmAccountSecret: process.env.EVM_ACCOUNT_SECRET, + indexerFreshnessThresholdMinutes: process.env.INDEXER_FRESHNESS_THRESHOLD_MINUTES ? Number(process.env.INDEXER_FRESHNESS_THRESHOLD_MINUTES) : 5, - moonbeamAccountSecret: process.env.MOONBEAM_ACCOUNT_SECRET, pendulumAccountSecret: process.env.PENDULUM_ACCOUNT_SECRET, - polygonAccountSecret: process.env.POLYGON_ACCOUNT_SECRET, /// The threshold above and below the optimal coverage ratio at which the rebalancing will be triggered. rebalancingThreshold: Number(process.env.REBALANCING_THRESHOLD) || 0.25, @@ -34,6 +32,7 @@ export function getConfig() { export function getPendulumAccount() { const config = getConfig(); + if (!config.pendulumAccountSecret) throw new Error("Missing PENDULUM_ACCOUNT_SECRET environment variable"); const keyring = new Keyring({ type: "sr25519" }); return keyring.addFromUri(config.pendulumAccountSecret); @@ -42,21 +41,32 @@ export function getPendulumAccount() { export function getMoonbeamEvmClients() { const config = getConfig(); - const moonbeamExecutorAccount = mnemonicToAccount(config.moonbeamAccountSecret as `0x${string}`); + const evmExecutorAccount = mnemonicToAccount(config.evmAccountSecret as `0x${string}`); const evmClientManager = EvmClientManager.getInstance(); return { publicClient: evmClientManager.getClient(Networks.Moonbeam), - walletClient: evmClientManager.getWalletClient(Networks.Moonbeam, moonbeamExecutorAccount) + walletClient: evmClientManager.getWalletClient(Networks.Moonbeam, evmExecutorAccount) }; } export function getPolygonEvmClients() { const config = getConfig(); - const polygonExecutorAccount = mnemonicToAccount(config.polygonAccountSecret as `0x${string}`); + const evmExecutorAccount = mnemonicToAccount(config.evmAccountSecret as `0x${string}`); const evmClientManager = EvmClientManager.getInstance(); return { publicClient: evmClientManager.getClient(Networks.Polygon), - walletClient: evmClientManager.getWalletClient(Networks.Polygon, polygonExecutorAccount) + walletClient: evmClientManager.getWalletClient(Networks.Polygon, evmExecutorAccount) + }; +} + +export function getBaseEvmClients() { + const config = getConfig(); + + const evmExecutorAccount = mnemonicToAccount(config.evmAccountSecret as `0x${string}`); + const evmClientManager = EvmClientManager.getInstance(); + return { + publicClient: evmClientManager.getClient(Networks.Base), + walletClient: evmClientManager.getWalletClient(Networks.Base, evmExecutorAccount) }; } diff --git a/apps/rebalancer/src/utils/nonce.ts b/apps/rebalancer/src/utils/nonce.ts new file mode 100644 index 000000000..7dd19041f --- /dev/null +++ b/apps/rebalancer/src/utils/nonce.ts @@ -0,0 +1,18 @@ +import type { PublicClient } from "viem"; + +export class NonceManager { + private nonce: number; + + constructor(startingNonce: number) { + this.nonce = startingNonce; + } + + static async create(client: PublicClient, address: `0x${string}`): Promise { + const nonce = await client.getTransactionCount({ address }); + return new NonceManager(nonce); + } + + next(): number { + return this.nonce++; + } +} diff --git a/packages/shared/src/services/brla/brlaApiService.ts b/packages/shared/src/services/brla/brlaApiService.ts index 72a4333a6..6743548c3 100644 --- a/packages/shared/src/services/brla/brlaApiService.ts +++ b/packages/shared/src/services/brla/brlaApiService.ts @@ -283,7 +283,7 @@ export class BrlaApiService { inputPaymentMethod: AveniaPaymentMethod.INTERNAL, // Fixed. We know it comes from the our balance inputThirdParty: String(false), outputCurrency: quoteParams.outputCurrency, - outputPaymentMethod: AveniaPaymentMethod.POLYGON, + outputPaymentMethod: quoteParams.outputPaymentMethod ?? AveniaPaymentMethod.POLYGON, outputThirdParty: String(false) // Fixed. We know it goes to our Moonbeam account. }).toString(); const cacheKey = `onchainSwap:${query}`; diff --git a/packages/shared/src/services/brla/types.ts b/packages/shared/src/services/brla/types.ts index 42d3d2ee8..f6d934f75 100644 --- a/packages/shared/src/services/brla/types.ts +++ b/packages/shared/src/services/brla/types.ts @@ -98,6 +98,7 @@ export interface OnchainSwapQuoteParams { inputCurrency: BrlaCurrency; inputAmount: string; outputCurrency: BrlaCurrency; + outputPaymentMethod?: AveniaPaymentMethod; } export enum AveniaTicketStatus { From 0babd167fecbd7edc66b1a5bf18b467307fbd44d Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 1 Jun 2026 15:25:31 -0300 Subject: [PATCH 002/131] fix crash recovery for squidrouter phase --- .../src/rebalance/brla-to-axlusdc/steps.ts | 1 + .../rebalance/usdc-brla-usdc-base/index.ts | 2 +- .../rebalance/usdc-brla-usdc-base/steps.ts | 132 ++++++++++-------- 3 files changed, 74 insertions(+), 61 deletions(-) diff --git a/apps/rebalancer/src/rebalance/brla-to-axlusdc/steps.ts b/apps/rebalancer/src/rebalance/brla-to-axlusdc/steps.ts index 5da763fd6..64672bf1a 100644 --- a/apps/rebalancer/src/rebalance/brla-to-axlusdc/steps.ts +++ b/apps/rebalancer/src/rebalance/brla-to-axlusdc/steps.ts @@ -23,6 +23,7 @@ import { getStatusAxelarScan, getTokenOutAmount, MOONBEAM_RECEIVER_CONTRACT_ADDRESS, + multiplyByPowerOfTen, Networks, OnchainSwapQuoteParams, PendulumTokenDetails, diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts index 006d2737f..d7045c04e 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts @@ -196,7 +196,7 @@ export async function rebalanceUsdcBrlaUsdcBase( if (currentOrder <= usdcBasePhaseOrder[UsdcBaseRebalancePhase.SquidRouterApproveAndSwap]) { if (!state.brlaAmountRaw) throw new Error("State corrupted: brlaAmountRaw missing for squid step 3"); - const result = await squidRouterApproveAndSwap(state.brlaAmountRaw, baseAddress, polygonNonce); + const result = await squidRouterApproveAndSwap(state.brlaAmountRaw, baseAddress, polygonNonce, state, stateManager); state.squidRouterSwapHash = result.swapHash; console.log(`SquidRouter swap completed. Tx: ${result.swapHash}`); diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts index 8111af19d..a287579d5 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts @@ -20,6 +20,7 @@ import { import Big from "big.js"; import { encodeFunctionData, erc20Abi } from "viem"; import { base, polygon } from "viem/chains"; +import { UsdcBaseRebalanceState, UsdcBaseStateManager } from "../../services/stateManager.ts"; import { getBaseEvmClients, getConfig, getPolygonEvmClients } from "../../utils/config.ts"; import { NonceManager } from "../../utils/nonce.ts"; import { waitForTransactionConfirmation } from "../../utils/transactions.ts"; @@ -359,7 +360,6 @@ export async function aveniaTransferBrlaToPolygon(brlaAmountDecimal: Big): Promi outputCurrency: BrlaCurrency.BRLA, outputPaymentMethod: AveniaPaymentMethod.POLYGON }); - console.log("Avenia BRLA->BRLA (Polygon) quote:", quote); const { walletClient: polygonWalletClient } = getPolygonEvmClients(); const polygonAddress = polygonWalletClient.account.address; @@ -389,67 +389,80 @@ export async function waitBrlaOnPolygon(brlaAmountRaw: string): Promise { export async function squidRouterApproveAndSwap( brlaAmountRaw: string, baseReceiverAddress: `0x${string}`, - polygonNonce: NonceManager + polygonNonce: NonceManager, + state: UsdcBaseRebalanceState, + stateManager: UsdcBaseStateManager ): Promise<{ swapHash: string; toAmountUsd: string }> { - console.log("Executing SquidRouter swap: Polygon BRLA -> Base USDC..."); + let swapHash = state.squidRouterSwapHash; + let toAmountUsd = "0"; - const { walletClient: polygonWalletClient, publicClient: polygonPublicClient } = getPolygonEvmClients(); - const polygonAddress = polygonWalletClient.account.address; - - const routeResult = await getRoute({ - bypassGuardrails: true, - enableExpress: true, - fromAddress: polygonAddress, - fromAmount: brlaAmountRaw, - fromChain: getNetworkId(Networks.Polygon).toString(), - fromToken: BRLA_POLYGON, - slippage: 4, - toAddress: baseReceiverAddress, - toChain: getNetworkId(Networks.Base).toString(), - toToken: USDC_BASE - }); + if (!swapHash) { + console.log("Executing SquidRouter swap: Polygon BRLA -> Base USDC..."); - const route = routeResult.data.route; - console.log(`SquidRouter route obtained. Expected output: ${route.estimate.toAmount} USDC (raw)`); - - const { approveData, swapData } = await createTransactionDataFromRoute({ - inputTokenErc20Address: BRLA_POLYGON, - publicClient: polygonPublicClient, - rawAmount: brlaAmountRaw, - route - }); + const { walletClient: polygonWalletClient, publicClient: polygonPublicClient } = getPolygonEvmClients(); + const polygonAddress = polygonWalletClient.account.address; - console.log("Sending SquidRouter approve transaction on Polygon..."); - const { maxFeePerGas: approveFee, maxPriorityFeePerGas: approveTip } = await polygonPublicClient.estimateFeesPerGas(); - const approveHash = await polygonWalletClient.sendTransaction({ - account: polygonWalletClient.account, - chain: polygon, - data: approveData.data, - gas: BigInt(approveData.gas), - maxFeePerGas: approveFee * 5n, - maxPriorityFeePerGas: approveTip * 5n, - nonce: polygonNonce.next(), - to: approveData.to, - value: BigInt(approveData.value) - }); - console.log(`Approve tx: ${approveHash}`); - await waitForTransactionConfirmation(approveHash, polygonPublicClient); - - console.log("Sending SquidRouter swap transaction on Polygon..."); - const { maxFeePerGas: swapFee, maxPriorityFeePerGas: swapTip } = await polygonPublicClient.estimateFeesPerGas(); - const swapHash = await polygonWalletClient.sendTransaction({ - account: polygonWalletClient.account, - chain: polygon, - data: swapData.data, - gas: BigInt(swapData.gas), - maxFeePerGas: swapFee * 5n, - maxPriorityFeePerGas: swapTip * 5n, - nonce: polygonNonce.next(), - to: swapData.to, - value: BigInt(swapData.value) - }); - console.log(`Swap tx: ${swapHash}`); - await waitForTransactionConfirmation(swapHash, polygonPublicClient); + const routeResult = await getRoute({ + bypassGuardrails: true, + enableExpress: true, + fromAddress: polygonAddress, + fromAmount: brlaAmountRaw, + fromChain: getNetworkId(Networks.Polygon).toString(), + fromToken: BRLA_POLYGON, + slippage: 4, + toAddress: baseReceiverAddress, + toChain: getNetworkId(Networks.Base).toString(), + toToken: USDC_BASE + }); + + const route = routeResult.data.route; + toAmountUsd = route.estimate.toAmountUSD; + console.log(`SquidRouter route obtained. Expected output: ${route.estimate.toAmount} USDC (raw)`); + + const { approveData, swapData } = await createTransactionDataFromRoute({ + inputTokenErc20Address: BRLA_POLYGON, + publicClient: polygonPublicClient, + rawAmount: brlaAmountRaw, + route + }); + + console.log("Sending SquidRouter approve transaction on Polygon..."); + const { maxFeePerGas: approveFee, maxPriorityFeePerGas: approveTip } = await polygonPublicClient.estimateFeesPerGas(); + const approveHash = await polygonWalletClient.sendTransaction({ + account: polygonWalletClient.account, + chain: polygon, + data: approveData.data, + gas: BigInt(approveData.gas), + maxFeePerGas: approveFee * 5n, + maxPriorityFeePerGas: approveTip * 5n, + nonce: polygonNonce.next(), + to: approveData.to, + value: BigInt(approveData.value) + }); + console.log(`Approve tx: ${approveHash}`); + await waitForTransactionConfirmation(approveHash, polygonPublicClient); + + console.log("Sending SquidRouter swap transaction on Polygon..."); + const { maxFeePerGas: swapFee, maxPriorityFeePerGas: swapTip } = await polygonPublicClient.estimateFeesPerGas(); + swapHash = await polygonWalletClient.sendTransaction({ + account: polygonWalletClient.account, + chain: polygon, + data: swapData.data, + gas: BigInt(swapData.gas), + maxFeePerGas: swapFee * 5n, + maxPriorityFeePerGas: swapTip * 5n, + nonce: polygonNonce.next(), + to: swapData.to, + value: BigInt(swapData.value) + }); + console.log(`Swap tx: ${swapHash}`); + await waitForTransactionConfirmation(swapHash, polygonPublicClient); + + state.squidRouterSwapHash = swapHash; + await stateManager.saveState(state); + } else { + console.log(`Resuming SquidRouter swap with existing swap tx: ${swapHash}`); + } console.log("Waiting for Axelar to execute the cross-chain swap..."); let isExecuted = false; @@ -471,7 +484,7 @@ export async function squidRouterApproveAndSwap( throw new Error("Axelar execution timed out after 30 minutes"); } - return { swapHash, toAmountUsd: route.estimate.toAmountUSD }; + return { swapHash, toAmountUsd }; } export async function waitUsdcOnBase(expectedUsdcRaw: string): Promise { @@ -502,7 +515,6 @@ export async function aveniaCreateSwapToUsdcBaseTicket( outputCurrency: BrlaCurrency.USDC, outputPaymentMethod: AveniaPaymentMethod.BASE }); - console.log("Avenia BRLA->USDC (Base) quote:", quote); const ticket = await brlaApiService.createOnchainSwapTicket({ quoteToken: quote.quoteToken, From 60ba8474f586e6f776e6cdf7d59b11bd0f875e08 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 1 Jun 2026 15:41:18 -0300 Subject: [PATCH 003/131] pass slack token explicitely --- apps/rebalancer/src/rebalance/brla-to-axlusdc/index.ts | 2 +- apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/rebalancer/src/rebalance/brla-to-axlusdc/index.ts b/apps/rebalancer/src/rebalance/brla-to-axlusdc/index.ts index 9124454ab..cb63e5774 100644 --- a/apps/rebalancer/src/rebalance/brla-to-axlusdc/index.ts +++ b/apps/rebalancer/src/rebalance/brla-to-axlusdc/index.ts @@ -155,7 +155,7 @@ export async function rebalanceBrlaToUsdcAxl(amountAxlUsdc: string, forceRestart `Rebalancing cost: absolute: ${rebalancingCost.toFixed(6)} | relative: ${Big(1).sub(finalBalance.div(state.initialBalance)).toFixed(4, 0)}` ); - const slackNotifier = new SlackNotifier(); + const slackNotifier = new SlackNotifier(process.env.SLACK_WEB_HOOK_TOKEN); await slackNotifier.sendMessage({ text: `Rebalance from BRLA to USDC.axl completed successfully! Initial balance: ${state.initialBalance.toFixed(4, 0)}, final balance: ${finalBalance.toFixed(4, 0)}\n` + diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts index d7045c04e..7be02a967 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts @@ -240,7 +240,7 @@ export async function rebalanceUsdcBrlaUsdcBase( console.log(`Route taken: ${state.winningRoute}`); console.log(`Cost: absolute: ${cost.toFixed(6)} USDC | relative: ${costRelative}`); - const slackNotifier = new SlackNotifier(); + const slackNotifier = new SlackNotifier(process.env.SLACK_WEB_HOOK_TOKEN); await slackNotifier.sendMessage({ text: "USDC->BRLA->USDC rebalance on Base completed!\n" + From 35cbbe10a7bb0c3ed33cbe6bc808c86c50bcc06f Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 1 Jun 2026 17:44:38 -0300 Subject: [PATCH 004/131] add better state management --- .../rebalance/usdc-brla-usdc-base/index.ts | 10 +- .../rebalance/usdc-brla-usdc-base/steps.ts | 178 +++++++++++------- 2 files changed, 111 insertions(+), 77 deletions(-) diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts index 7be02a967..0fd119201 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts @@ -59,10 +59,8 @@ export async function rebalanceUsdcBrlaUsdcBase( if (currentOrder <= usdcBasePhaseOrder[UsdcBaseRebalancePhase.NablaApprove]) { if (!state.usdcAmountRaw) throw new Error("State corrupted: usdcAmountRaw missing for step 2"); - const result = await nablaApproveAndSwapOnBase(state.usdcAmountRaw, baseNonce); + const result = await nablaApproveAndSwapOnBase(state.usdcAmountRaw, baseNonce, state, stateManager); - state.nablaApproveHash = result.approveHash; - state.nablaSwapHash = result.swapHash; state.brlaAmountRaw = result.brlaAmountRaw; state.brlaAmountDecimal = result.brlaAmountDecimal.toString(); @@ -74,7 +72,7 @@ export async function rebalanceUsdcBrlaUsdcBase( if (currentOrder <= usdcBasePhaseOrder[UsdcBaseRebalancePhase.TransferBrlaToAvenia]) { if (!state.brlaAmountRaw) throw new Error("State corrupted: brlaAmountRaw missing for step 3"); - state.brlaTransferHash = await transferBrlaToAveniaOnBase(state.brlaAmountRaw, baseNonce); + state.brlaTransferHash = await transferBrlaToAveniaOnBase(state.brlaAmountRaw, baseNonce, state, stateManager); console.log(`BRLA transferred to Avenia on Base. Tx: ${state.brlaTransferHash}`); state.currentPhase = UsdcBaseRebalancePhase.WaitForBrlaOnAvenia; @@ -148,6 +146,10 @@ export async function rebalanceUsdcBrlaUsdcBase( } } + if (!state.winningRoute) { + throw new Error(`State corrupted: winningRoute is null at phase ${state.currentPhase}. Cannot proceed.`); + } + if (state.winningRoute === "avenia") { if (currentOrder <= usdcBasePhaseOrder[UsdcBaseRebalancePhase.WaitUsdcOnBaseFromAvenia]) { if (!state.aveniaQuoteUsdc) throw new Error("State corrupted: aveniaQuoteUsdc missing for avenia step 2"); diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts index a287579d5..3b59f340b 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts @@ -84,7 +84,9 @@ export async function checkInitialUsdcBalanceOnBase(usdcAmountRaw: string): Prom export async function nablaApproveAndSwapOnBase( usdcAmountRaw: string, - baseNonce: NonceManager + baseNonce: NonceManager, + state: UsdcBaseRebalanceState, + stateManager: UsdcBaseStateManager ): Promise<{ brlaAmountRaw: string; brlaAmountDecimal: Big; @@ -106,79 +108,94 @@ export async function nablaApproveAndSwapOnBase( functionName: "balanceOf" }); - const evmClientManager = EvmClientManager.getInstance(); - const quoteAbi = [ - { - inputs: [ - { name: "_amountIn", type: "uint256" }, - { name: "_tokenPath", type: "address[]" }, - { name: "_routerPath", type: "address[]" } - ], - name: "quoteSwapExactTokensForTokens", - outputs: [{ name: "amountOut_", type: "uint256" }], - stateMutability: "view", - type: "function" - } - ] as const; - - const { quoter } = getNablaBasePool(USDC_BASE, ERC20_BRLA_BASE); - const expectedOutputRaw = await evmClientManager.readContractWithRetry(Networks.Base, { - abi: quoteAbi, - address: quoter, - args: [BigInt(usdcAmountRaw), [USDC_BASE, ERC20_BRLA_BASE], [router]], - functionName: "quoteSwapExactTokensForTokens" - }); + let approveHash: string; + let swapHash: string; - const expectedOutputDecimal = multiplyByPowerOfTen(Big(expectedOutputRaw.toString()), -18); - console.log(`Expected BRLA output: ${expectedOutputDecimal.toFixed(4)}`); - - const nablaHardMinimumOutputRaw = Big(expectedOutputRaw.toString()) - .mul(1 - AMM_MINIMUM_OUTPUT_HARD_MARGIN) - .toFixed(0, 0); - - const { approve, swap } = await createNablaTransactionsForOnrampOnEVM( - usdcAmountRaw, - { address: executorAddress, type: EphemeralAccountType.EVM }, - USDC_BASE, - ERC20_BRLA_BASE, - nablaHardMinimumOutputRaw, - NABLA_SWAP_DEADLINE_MINUTES, - router - ); - - console.log("Sending Nabla approve transaction on Base..."); - const { maxFeePerGas: approveFee, maxPriorityFeePerGas: approveTip } = await publicClient.estimateFeesPerGas(); - const approveHash = await walletClient.sendTransaction({ - account: walletClient.account, - chain: base, - data: approve.data, - gas: BigInt(approve.gas), - maxFeePerGas: approveFee, - maxPriorityFeePerGas: approveTip, - nonce: baseNonce.next(), - to: approve.to, - value: BigInt(approve.value) - }); - console.log(`Approve tx sent: ${approveHash}`); - await waitForTransactionConfirmation(approveHash, publicClient); - console.log("Nabla approval confirmed."); + if (!state.nablaApproveHash || !state.nablaSwapHash) { + const evmClientManager = EvmClientManager.getInstance(); + const quoteAbi = [ + { + inputs: [ + { name: "_amountIn", type: "uint256" }, + { name: "_tokenPath", type: "address[]" }, + { name: "_routerPath", type: "address[]" } + ], + name: "quoteSwapExactTokensForTokens", + outputs: [{ name: "amountOut_", type: "uint256" }], + stateMutability: "view", + type: "function" + } + ] as const; + + const { quoter } = getNablaBasePool(USDC_BASE, ERC20_BRLA_BASE); + const expectedOutputRaw = await evmClientManager.readContractWithRetry(Networks.Base, { + abi: quoteAbi, + address: quoter, + args: [BigInt(usdcAmountRaw), [USDC_BASE, ERC20_BRLA_BASE], [router]], + functionName: "quoteSwapExactTokensForTokens" + }); - console.log("Sending Nabla swap transaction on Base..."); - const { maxFeePerGas: swapFee, maxPriorityFeePerGas: swapTip } = await publicClient.estimateFeesPerGas(); - const swapHash = await walletClient.sendTransaction({ - account: walletClient.account, - chain: base, - data: swap.data, - gas: BigInt(swap.gas), - maxFeePerGas: swapFee, - maxPriorityFeePerGas: swapTip, - nonce: baseNonce.next(), - to: swap.to, - value: BigInt(swap.value) - }); - console.log(`Swap tx sent: ${swapHash}`); - await waitForTransactionConfirmation(swapHash, publicClient); - console.log("Nabla swap confirmed."); + const expectedOutputDecimal = multiplyByPowerOfTen(Big(expectedOutputRaw.toString()), -18); + console.log(`Expected BRLA output: ${expectedOutputDecimal.toFixed(4)}`); + + const nablaHardMinimumOutputRaw = Big(expectedOutputRaw.toString()) + .mul(1 - AMM_MINIMUM_OUTPUT_HARD_MARGIN) + .toFixed(0, 0); + + const { approve, swap } = await createNablaTransactionsForOnrampOnEVM( + usdcAmountRaw, + { address: executorAddress, type: EphemeralAccountType.EVM }, + USDC_BASE, + ERC20_BRLA_BASE, + nablaHardMinimumOutputRaw, + NABLA_SWAP_DEADLINE_MINUTES, + router + ); + + console.log("Sending Nabla approve transaction on Base..."); + const { maxFeePerGas: approveFee, maxPriorityFeePerGas: approveTip } = await publicClient.estimateFeesPerGas(); + approveHash = await walletClient.sendTransaction({ + account: walletClient.account, + chain: base, + data: approve.data, + gas: BigInt(approve.gas), + maxFeePerGas: approveFee, + maxPriorityFeePerGas: approveTip, + nonce: baseNonce.next(), + to: approve.to, + value: BigInt(approve.value) + }); + console.log(`Approve tx sent: ${approveHash}`); + await waitForTransactionConfirmation(approveHash, publicClient); + console.log("Nabla approval confirmed."); + + state.nablaApproveHash = approveHash; + await stateManager.saveState(state); + + console.log("Sending Nabla swap transaction on Base..."); + const { maxFeePerGas: swapFee, maxPriorityFeePerGas: swapTip } = await publicClient.estimateFeesPerGas(); + swapHash = await walletClient.sendTransaction({ + account: walletClient.account, + chain: base, + data: swap.data, + gas: BigInt(swap.gas), + maxFeePerGas: swapFee, + maxPriorityFeePerGas: swapTip, + nonce: baseNonce.next(), + to: swap.to, + value: BigInt(swap.value) + }); + console.log(`Swap tx sent: ${swapHash}`); + await waitForTransactionConfirmation(swapHash, publicClient); + console.log("Nabla swap confirmed."); + + state.nablaSwapHash = swapHash; + await stateManager.saveState(state); + } else { + approveHash = state.nablaApproveHash; + swapHash = state.nablaSwapHash; + console.log(`Resuming Nabla swap with existing approve tx: ${approveHash}, swap tx: ${swapHash}`); + } // Delay to let the RPC sync the post-swap state before reading the balance await new Promise(resolve => setTimeout(resolve, 5_000)); @@ -191,6 +208,11 @@ export async function nablaApproveAndSwapOnBase( }); const brlaReceivedRaw = brlaBalanceAfter - brlaBalanceBefore; + if (brlaReceivedRaw < 0n) { + throw new Error( + `BRLA balance decreased after swap (pre: ${brlaBalanceBefore}, post: ${brlaBalanceAfter}). Possible external interference.` + ); + } const brlaAmountRaw = brlaReceivedRaw.toString(); const brlaAmountDecimal = multiplyByPowerOfTen(Big(brlaAmountRaw), -18); console.log(`Received ${brlaAmountDecimal.toFixed(4)} BRLA on Base (pre: ${brlaBalanceBefore}, post: ${brlaBalanceAfter})`); @@ -203,10 +225,20 @@ export async function nablaApproveAndSwapOnBase( }; } -export async function transferBrlaToAveniaOnBase(brlaAmountRaw: string, baseNonce: NonceManager): Promise { +export async function transferBrlaToAveniaOnBase( + brlaAmountRaw: string, + baseNonce: NonceManager, + state: UsdcBaseRebalanceState, + stateManager: UsdcBaseStateManager +): Promise { const { brlaBusinessAccountAddress } = getConfig(); const { walletClient, publicClient } = getBaseEvmClients(); + if (state.brlaTransferHash) { + console.log(`Resuming BRLA transfer with existing tx: ${state.brlaTransferHash}`); + return state.brlaTransferHash; + } + console.log(`Transferring ${brlaAmountRaw} BRLA (raw) to Avenia account ${brlaBusinessAccountAddress} on Base...`); const data = encodeFunctionData({ From 83b3fe271732317960537ffc46b5d437b8c03a0b Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Tue, 2 Jun 2026 13:41:08 -0300 Subject: [PATCH 005/131] improve slack message --- .../src/rebalance/usdc-brla-usdc-base/index.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts index 0fd119201..61641cad8 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts @@ -232,9 +232,9 @@ export async function rebalanceUsdcBrlaUsdcBase( const initialUsdcDecimal = Big(state.initialUsdcBalance); const finalUsdcDecimal = Big(state.finalUsdcBalance); - const brlaDecimal = Big(state.brlaAmountDecimal); + const usdcRebalanced = Big(state.usdcAmountRaw).div(10 ** 6); const cost = initialUsdcDecimal.minus(finalUsdcDecimal); - const costRelative = initialUsdcDecimal.gt(0) ? cost.div(initialUsdcDecimal).toFixed(4, 0) : "N/A"; + const costRelative = usdcRebalanced.gt(0) ? cost.div(usdcRebalanced).toFixed(4, 0) : "N/A"; console.log( `Rebalance completed! Initial: ${initialUsdcDecimal.toFixed(6)} USDC, Final: ${finalUsdcDecimal.toFixed(6)} USDC` @@ -245,10 +245,9 @@ export async function rebalanceUsdcBrlaUsdcBase( const slackNotifier = new SlackNotifier(process.env.SLACK_WEB_HOOK_TOKEN); await slackNotifier.sendMessage({ text: - "USDC->BRLA->USDC rebalance on Base completed!\n" + - `Route: ${state.winningRoute}\n` + - `Initial: ${initialUsdcDecimal.toFixed(6)} USDC, Final: ${finalUsdcDecimal.toFixed(6)} USDC\n` + - `BRLA swapped: ${brlaDecimal.toFixed(4)}\n` + - `Cost: ${cost.toFixed(6)} USDC (${costRelative})` + "✅ USDC->BRLA->USDC rebalance on Base completed!\n" + + `🛤️ Route: ${state.winningRoute}\n` + + `💰 USDC rebalanced: ${usdcRebalanced.toFixed(6)}\n` + + `📉 Cost - Absolute: ${cost.toFixed(6)} USDC | Relative: ${costRelative}` }); } From a19482d195786fb6069c75dfd7e556e3c5c4f104 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Tue, 2 Jun 2026 15:40:15 -0300 Subject: [PATCH 006/131] store rebalancing history for daily cap --- apps/rebalancer/.env.example | 3 + apps/rebalancer/src/index.ts | 19 ++++++ .../rebalance/usdc-brla-usdc-base/index.ts | 14 ++++- apps/rebalancer/src/services/stateManager.ts | 63 ++++++++++++++++--- apps/rebalancer/src/utils/config.ts | 1 + 5 files changed, 92 insertions(+), 8 deletions(-) diff --git a/apps/rebalancer/.env.example b/apps/rebalancer/.env.example index 4965a0ab4..ad9592a1f 100644 --- a/apps/rebalancer/.env.example +++ b/apps/rebalancer/.env.example @@ -13,3 +13,6 @@ REBALANCING_AMOUNT_USD_TO_BRL=100 SUPABASE_URL=your_supabase_url_here SUPABASE_SERVICE_KEY=your_supabase_service_key_here + +# Maximum total USD bridged per day before the rebalancer stops (default 1M) +REBALANCING_DAILY_BRIDGE_LIMIT_USD=1000000 diff --git a/apps/rebalancer/src/index.ts b/apps/rebalancer/src/index.ts index 83b88b903..cd608b340 100644 --- a/apps/rebalancer/src/index.ts +++ b/apps/rebalancer/src/index.ts @@ -99,6 +99,25 @@ async function checkForRebalancing() { const isResuming = !forceRestart && state && state.currentPhase !== UsdcBaseRebalancePhase.Idle; if (!isResuming) { + const history = await stateManager.getHistory(); + const todayStart = new Date(); + todayStart.setUTCHours(0, 0, 0, 0); + + const bridgedToday = history + .filter(e => new Date(e.startingTime) >= todayStart) + .reduce((sum, e) => sum.plus(Big(e.initialAmount)), Big(0)); + + const dailyLimitRaw = multiplyByPowerOfTen(Big(config.rebalancingDailyBridgeLimitUsd), 6); + console.log( + `Bridged $${bridgedToday.div(1e6).toFixed(2)} today. Daily bridge limit is $${config.rebalancingDailyBridgeLimitUsd}.` + ); + if (bridgedToday.gte(dailyLimitRaw)) { + console.log( + `Daily bridge limit reached: bridged $${bridgedToday.div(1e6).toFixed(2)} today, limit is $${config.rebalancingDailyBridgeLimitUsd}. Skipping.` + ); + return; + } + await checkInitialUsdcBalanceOnBase(amountUsdcRaw); } diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts index 61641cad8..688e145f6 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts @@ -242,12 +242,24 @@ export async function rebalanceUsdcBrlaUsdcBase( console.log(`Route taken: ${state.winningRoute}`); console.log(`Cost: absolute: ${cost.toFixed(6)} USDC | relative: ${costRelative}`); + await stateManager.addHistoryEntry({ + cost: cost.toFixed(6), + costRelative, + endingTime: new Date().toISOString(), + initialAmount: state.usdcAmountRaw, + startingTime: state.startingTime + }); + const slackNotifier = new SlackNotifier(process.env.SLACK_WEB_HOOK_TOKEN); await slackNotifier.sendMessage({ text: + "--------------------------------------------------" + + "\n" + "✅ USDC->BRLA->USDC rebalance on Base completed!\n" + `🛤️ Route: ${state.winningRoute}\n` + `💰 USDC rebalanced: ${usdcRebalanced.toFixed(6)}\n` + - `📉 Cost - Absolute: ${cost.toFixed(6)} USDC | Relative: ${costRelative}` + `📉 Cost - Absolute: ${cost.toFixed(6)} USDC | Relative: ${costRelative}` + + "\n" + + "--------------------------------------------------" }); } diff --git a/apps/rebalancer/src/services/stateManager.ts b/apps/rebalancer/src/services/stateManager.ts index 20987ea8c..0802f5df2 100644 --- a/apps/rebalancer/src/services/stateManager.ts +++ b/apps/rebalancer/src/services/stateManager.ts @@ -2,7 +2,7 @@ import { createClient } from "@supabase/supabase-js"; import Big from "big.js"; import { getConfig } from "../utils/config"; -export class StateManager { +export class StateManager { private supabase; private filename: string; @@ -35,7 +35,9 @@ export class StateManager { - state.updatedTime = new Date().toISOString(); + if (state && typeof state === "object" && "updatedTime" in state) { + (state as { updatedTime: string }).updatedTime = new Date().toISOString(); + } const stateString = JSON.stringify(state); const { error } = await this.supabase.storage.from("rebalancer_state").upload(this.filename, stateString, { @@ -202,22 +204,69 @@ export interface UsdcBaseRebalanceState { updatedTime: string; } +export interface RebalanceHistoryEntry { + initialAmount: string; + startingTime: string; + endingTime: string; + cost: string; + costRelative: string; +} + +export interface UsdcBaseRebalanceContainer { + state: UsdcBaseRebalanceState; + history: RebalanceHistoryEntry[]; +} + export class UsdcBaseStateManager { - private inner: StateManager; + private inner: StateManager; constructor() { - this.inner = new StateManager("rebalancer_state_usdc_base.json"); + this.inner = new StateManager("rebalancer_state_usdc_base.json"); + } + + // Handles migration from old flat UsdcBaseRebalanceState to new UsdcBaseRebalanceContainer. + private async getContainer(): Promise { + const raw = await this.inner.getState(); + if (!raw) return undefined; + + if ("currentPhase" in raw && !("state" in raw)) { + return { history: [], state: raw as unknown as UsdcBaseRebalanceState }; + } + + return raw; } async getState(): Promise { - return this.inner.getState(); + const container = await this.getContainer(); + return container?.state; + } + + async getHistory(): Promise { + const container = await this.getContainer(); + return container?.history ?? []; } async saveState(state: UsdcBaseRebalanceState): Promise { - await this.inner.saveState(state); + const existing = await this.getContainer(); + const history = existing?.history ?? []; + state.updatedTime = new Date().toISOString(); + await this.inner.saveState({ history, state }); + } + + async addHistoryEntry(entry: RebalanceHistoryEntry): Promise { + const existing = await this.getContainer(); + if (!existing?.state) { + throw new Error("Cannot add history entry: no existing state found."); + } + existing.history.push(entry); + existing.state.updatedTime = new Date().toISOString(); + await this.inner.saveState(existing); } async startNewRebalance(usdcAmountRaw: string): Promise { + const existing = await this.getContainer(); + const history = existing?.history ?? []; + const state: UsdcBaseRebalanceState = { aveniaQuoteToken: null, aveniaQuoteUsdc: null, @@ -237,7 +286,7 @@ export class UsdcBaseStateManager { usdcAmountRaw, winningRoute: null }; - await this.saveState(state); + await this.inner.saveState({ history, state }); return state; } } diff --git a/apps/rebalancer/src/utils/config.ts b/apps/rebalancer/src/utils/config.ts index c6dfd01ee..f2b790c1a 100644 --- a/apps/rebalancer/src/utils/config.ts +++ b/apps/rebalancer/src/utils/config.ts @@ -18,6 +18,7 @@ export function getConfig() { : 5, pendulumAccountSecret: process.env.PENDULUM_ACCOUNT_SECRET, + rebalancingDailyBridgeLimitUsd: Number(process.env.REBALANCING_DAILY_BRIDGE_LIMIT_USD) || 10_000, /// The threshold above and below the optimal coverage ratio at which the rebalancing will be triggered. rebalancingThreshold: Number(process.env.REBALANCING_THRESHOLD) || 0.25, From c94a54bf8f5bd5b7423755aede3700dedf6064c4 Mon Sep 17 00:00:00 2001 From: gianfra-t <96739519+gianfra-t@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:44:56 -0300 Subject: [PATCH 007/131] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/rebalancer/src/services/stateManager.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/rebalancer/src/services/stateManager.ts b/apps/rebalancer/src/services/stateManager.ts index 0802f5df2..3d7722e61 100644 --- a/apps/rebalancer/src/services/stateManager.ts +++ b/apps/rebalancer/src/services/stateManager.ts @@ -17,10 +17,9 @@ export class StateManager { async getState(): Promise { try { - const { data, error } = await this.supabase.storage.from("rebalancer_state").download(this.filename); - if (error) { - if (error.statusCode === "404" || error.message?.includes("not found")) { + const statusCode = (error as any).statusCode; + if (statusCode === 404 || statusCode === "404" || error.message?.includes("not found")) { return undefined; } throw error; From fdf11f6d0422fe57151177e579b262cac1f2203b Mon Sep 17 00:00:00 2001 From: gianfra-t <96739519+gianfra-t@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:45:11 -0300 Subject: [PATCH 008/131] Potential fix for pull request finding 'Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts index 3b59f340b..becc616c8 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts @@ -1,5 +1,4 @@ import { - AveniaFeeType, AveniaPaymentMethod, AveniaTicketStatus, BrlaApiService, From ff23f5ac1e906b0418fd147e1a329d87285ec9c5 Mon Sep 17 00:00:00 2001 From: gianfra-t <96739519+gianfra-t@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:45:20 -0300 Subject: [PATCH 009/131] Potential fix for pull request finding 'Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- apps/rebalancer/src/utils/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/rebalancer/src/utils/config.ts b/apps/rebalancer/src/utils/config.ts index f2b790c1a..9acb608f4 100644 --- a/apps/rebalancer/src/utils/config.ts +++ b/apps/rebalancer/src/utils/config.ts @@ -1,6 +1,6 @@ import { Keyring } from "@polkadot/api"; import { BRLA_BASE_URL, EvmClientManager, Networks } from "@vortexfi/shared"; -import { mnemonicToAccount, privateKeyToAccount } from "viem/accounts"; +import { mnemonicToAccount } from "viem/accounts"; export function getConfig() { if (!process.env.EVM_ACCOUNT_SECRET) throw new Error("Missing EVM_ACCOUNT_SECRET environment variable"); From dcdde20ceed2df7ca325ab885e1a3ec10768ef04 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Tue, 2 Jun 2026 16:12:26 -0300 Subject: [PATCH 010/131] improve state handling, "force" feature flow control --- .../rebalance/usdc-brla-usdc-base/index.ts | 22 +++-- .../rebalance/usdc-brla-usdc-base/steps.ts | 90 ++++++++++--------- apps/rebalancer/src/services/indexer/index.ts | 4 +- apps/rebalancer/src/services/stateManager.ts | 48 +++++++--- apps/rebalancer/src/utils/config.ts | 6 +- 5 files changed, 107 insertions(+), 63 deletions(-) diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts index 688e145f6..58017a431 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts @@ -9,6 +9,8 @@ import { checkInitialUsdcBalanceOnBase, checkTicketStatusPaid, compareRates, + fetchAveniaQuote, + fetchSquidRouterQuote, nablaApproveAndSwapOnBase, squidRouterApproveAndSwap, transferBrlaToAveniaOnBase, @@ -96,14 +98,20 @@ export async function rebalanceUsdcBrlaUsdcBase( if (!state.brlaAmountDecimal) throw new Error("State corrupted: brlaAmountDecimal missing for step 5"); if (forcedRoute) { - console.log(`Forced route: ${forcedRoute}. Running quotes without comparison.`); - } - - const rateComparison = await compareRates(Big(state.brlaAmountDecimal)); + console.log(`Forced route: ${forcedRoute}. Fetching quote for forced route only.`); - state.winningRoute = forcedRoute ?? rateComparison.winningRoute; - state.squidRouterQuoteUsdc = rateComparison.squidRouterQuoteUsdc; - state.aveniaQuoteUsdc = rateComparison.aveniaQuoteUsdc; + state.winningRoute = forcedRoute; + if (forcedRoute === "squidrouter") { + state.squidRouterQuoteUsdc = await fetchSquidRouterQuote(Big(state.brlaAmountDecimal)); + } else { + state.aveniaQuoteUsdc = await fetchAveniaQuote(Big(state.brlaAmountDecimal)); + } + } else { + const rateComparison = await compareRates(Big(state.brlaAmountDecimal)); + state.winningRoute = rateComparison.winningRoute; + state.squidRouterQuoteUsdc = rateComparison.squidRouterQuoteUsdc; + state.aveniaQuoteUsdc = rateComparison.aveniaQuoteUsdc; + } console.log(`Rate comparison complete. Winner: ${state.winningRoute}`); diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts index becc616c8..95db36446 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts @@ -24,8 +24,8 @@ import { getBaseEvmClients, getConfig, getPolygonEvmClients } from "../../utils/ import { NonceManager } from "../../utils/nonce.ts"; import { waitForTransactionConfirmation } from "../../utils/transactions.ts"; -const USDC_BASE: `0x${string}` = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; -const BRLA_POLYGON: `0x${string}` = "0xe6a537a407488807f0bbeb0038b79004f19dddfb"; +export const USDC_BASE: `0x${string}` = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +export const BRLA_POLYGON: `0x${string}` = "0xe6a537a407488807f0bbeb0038b79004f19dddfb"; const NABLA_SWAP_DEADLINE_MINUTES = 60 * 24 * 7; const AMM_MINIMUM_OUTPUT_HARD_MARGIN = 0.05; @@ -297,6 +297,50 @@ export async function waitForBrlaOnAvenia(brlaAmountDecimal: Big): Promise { + const { walletClient: baseWalletClient } = getBaseEvmClients(); + const baseAddress = baseWalletClient.account.address; + const { walletClient: polygonWalletClient } = getPolygonEvmClients(); + const polygonAddress = polygonWalletClient.account.address; + const brlaAmountRaw = multiplyByPowerOfTen(brlaAmountDecimal, 18).toFixed(0, 0); + + const routeResult = await getRoute( + { + bypassGuardrails: true, + enableExpress: true, + fromAddress: polygonAddress, + fromAmount: brlaAmountRaw, + fromChain: getNetworkId(Networks.Polygon).toString(), + fromToken: BRLA_POLYGON, + slippage: 4, + toAddress: baseAddress, + toChain: getNetworkId(Networks.Base).toString(), + toToken: USDC_BASE + }, + { useCache: true } + ); + + const quoteUsdc = routeResult.data.route.estimate.toAmount; + console.log(`SquidRouter quote: ${quoteUsdc} USDC (raw, 6 decimals)`); + return quoteUsdc; +} + +export async function fetchAveniaQuote(brlaAmountDecimal: Big): Promise { + const brlaApiService = BrlaApiService.getInstance(); + const aveniaQuote = await brlaApiService.createOnchainSwapQuote( + { + inputAmount: brlaAmountDecimal.toFixed(12, 0), + inputCurrency: BrlaCurrency.BRLA, + outputCurrency: BrlaCurrency.USDC, + outputPaymentMethod: AveniaPaymentMethod.BASE + }, + { useCache: true } + ); + + console.log(`Avenia quote: ${aveniaQuote.outputAmount} USDC`); + return aveniaQuote.outputAmount; +} + export async function compareRates(brlaAmountDecimal: Big): Promise<{ winningRoute: "squidrouter" | "avenia"; squidRouterQuoteUsdc: string | null; @@ -304,53 +348,17 @@ export async function compareRates(brlaAmountDecimal: Big): Promise<{ }> { console.log("Comparing SquidRouter vs Avenia rates for BRLA -> USDC..."); - const { walletClient: baseWalletClient } = getBaseEvmClients(); - const baseAddress = baseWalletClient.account.address; - const brlaAmountRaw = multiplyByPowerOfTen(brlaAmountDecimal, 18).toFixed(0, 0); - let squidRouterQuoteUsdc: string | null = null; let aveniaQuoteUsdc: string | null = null; try { - const { walletClient: polygonWalletClient } = getPolygonEvmClients(); - const polygonAddress = polygonWalletClient.account.address; - - const routeResult = await getRoute( - { - bypassGuardrails: true, - enableExpress: true, - fromAddress: polygonAddress, - fromAmount: brlaAmountRaw, - fromChain: getNetworkId(Networks.Polygon).toString(), - fromToken: BRLA_POLYGON, - slippage: 4, - toAddress: baseAddress, - toChain: getNetworkId(Networks.Base).toString(), - toToken: USDC_BASE - }, - { useCache: true } - ); - - squidRouterQuoteUsdc = routeResult.data.route.estimate.toAmount; - console.log(`SquidRouter quote: ${squidRouterQuoteUsdc} USDC (raw, 6 decimals)`); + squidRouterQuoteUsdc = await fetchSquidRouterQuote(brlaAmountDecimal); } catch (error) { console.warn("SquidRouter quote failed:", error); } try { - const brlaApiService = BrlaApiService.getInstance(); - const aveniaQuote = await brlaApiService.createOnchainSwapQuote( - { - inputAmount: brlaAmountDecimal.toFixed(12, 0), - inputCurrency: BrlaCurrency.BRLA, - outputCurrency: BrlaCurrency.USDC, - outputPaymentMethod: AveniaPaymentMethod.BASE - }, - { useCache: true } - ); - - aveniaQuoteUsdc = aveniaQuote.outputAmount; - console.log(`Avenia quote: ${aveniaQuoteUsdc} USDC`); + aveniaQuoteUsdc = await fetchAveniaQuote(brlaAmountDecimal); } catch (error) { console.warn("Avenia quote failed:", error); } @@ -500,7 +508,7 @@ export async function squidRouterApproveAndSwap( const axelarTimeout = 30 * 60 * 1000; const axelarStartTime = Date.now(); - while (!isExecuted && Date.now() - axelarStartTime < axelarTimeout) { + while (Date.now() - axelarStartTime < axelarTimeout) { const axelarScanStatus = await getStatusAxelarScan(swapHash); if (axelarScanStatus && (axelarScanStatus.status === "executed" || axelarScanStatus.status === "express_executed")) { isExecuted = true; diff --git a/apps/rebalancer/src/services/indexer/index.ts b/apps/rebalancer/src/services/indexer/index.ts index 7572b493b..f5c72cfb0 100644 --- a/apps/rebalancer/src/services/indexer/index.ts +++ b/apps/rebalancer/src/services/indexer/index.ts @@ -1,4 +1,5 @@ import { ERC20_BRLA_BASE, EvmClientManager, NABLA_ROUTER_BASE_BRLA, Networks } from "@vortexfi/shared"; +import Big from "big.js"; import { getConfig } from "../../utils/config.ts"; import { fetchLatestBlockFromIndexer, fetchNablaInstance } from "./graphql.ts"; @@ -56,7 +57,8 @@ export async function getBaseNablaCoverageRatio(): Promise< }) as Promise ]); - const brlaCoverageRatio = brlaLiabilities > 0n ? Number(brlaReserve) / Number(brlaLiabilities) : 0; + const brlaCoverageRatio = + brlaLiabilities > 0n ? new Big(brlaReserve.toString()).div(new Big(brlaLiabilities.toString())).toNumber() : 0; console.log(`Base Nabla BRLA pool coverage ratio: ${brlaCoverageRatio}`); diff --git a/apps/rebalancer/src/services/stateManager.ts b/apps/rebalancer/src/services/stateManager.ts index 3d7722e61..5c2d0bb2b 100644 --- a/apps/rebalancer/src/services/stateManager.ts +++ b/apps/rebalancer/src/services/stateManager.ts @@ -16,19 +16,21 @@ export class StateManager { } async getState(): Promise { - try { - if (error) { - const statusCode = (error as any).statusCode; - if (statusCode === 404 || statusCode === "404" || error.message?.includes("not found")) { - return undefined; - } - throw error; + const { data, error } = await this.supabase.storage.from("rebalancer_state").download(this.filename); + + if (error) { + const statusCode = (error as any).statusCode; + if (statusCode === 404 || statusCode === "404" || error.message?.includes("not found")) { + return undefined; } + throw error; + } - const stateText = await data.text(); + const stateText = await data.text(); + try { return JSON.parse(stateText) as T; - } catch (error) { - console.error("Error getting rebalance state:", error); + } catch { + console.warn("Rebalancer state is not valid JSON, treating as missing."); return undefined; } } @@ -216,6 +218,28 @@ export interface UsdcBaseRebalanceContainer { history: RebalanceHistoryEntry[]; } +function createFreshState(): UsdcBaseRebalanceState { + return { + aveniaQuoteToken: null, + aveniaQuoteUsdc: null, + aveniaTicketId: null, + brlaAmountDecimal: null, + brlaAmountRaw: null, + brlaTransferHash: null, + currentPhase: UsdcBaseRebalancePhase.Idle, + finalUsdcBalance: null, + initialUsdcBalance: null, + nablaApproveHash: null, + nablaSwapHash: null, + squidRouterQuoteUsdc: null, + squidRouterSwapHash: null, + startingTime: new Date().toISOString(), + updatedTime: new Date().toISOString(), + usdcAmountRaw: null, + winningRoute: null + }; +} + export class UsdcBaseStateManager { private inner: StateManager; @@ -255,7 +279,9 @@ export class UsdcBaseStateManager { async addHistoryEntry(entry: RebalanceHistoryEntry): Promise { const existing = await this.getContainer(); if (!existing?.state) { - throw new Error("Cannot add history entry: no existing state found."); + console.warn("No existing state found for addHistoryEntry. Writing entry to fresh history."); + await this.inner.saveState({ history: [entry], state: createFreshState() }); + return; } existing.history.push(entry); existing.state.updatedTime = new Date().toISOString(); diff --git a/apps/rebalancer/src/utils/config.ts b/apps/rebalancer/src/utils/config.ts index 9acb608f4..090fc0fc4 100644 --- a/apps/rebalancer/src/utils/config.ts +++ b/apps/rebalancer/src/utils/config.ts @@ -42,7 +42,7 @@ export function getPendulumAccount() { export function getMoonbeamEvmClients() { const config = getConfig(); - const evmExecutorAccount = mnemonicToAccount(config.evmAccountSecret as `0x${string}`); + const evmExecutorAccount = mnemonicToAccount(config.evmAccountSecret); const evmClientManager = EvmClientManager.getInstance(); return { publicClient: evmClientManager.getClient(Networks.Moonbeam), @@ -53,7 +53,7 @@ export function getMoonbeamEvmClients() { export function getPolygonEvmClients() { const config = getConfig(); - const evmExecutorAccount = mnemonicToAccount(config.evmAccountSecret as `0x${string}`); + const evmExecutorAccount = mnemonicToAccount(config.evmAccountSecret); const evmClientManager = EvmClientManager.getInstance(); return { publicClient: evmClientManager.getClient(Networks.Polygon), @@ -64,7 +64,7 @@ export function getPolygonEvmClients() { export function getBaseEvmClients() { const config = getConfig(); - const evmExecutorAccount = mnemonicToAccount(config.evmAccountSecret as `0x${string}`); + const evmExecutorAccount = mnemonicToAccount(config.evmAccountSecret); const evmClientManager = EvmClientManager.getInstance(); return { publicClient: evmClientManager.getClient(Networks.Base), From 9abc3ad9a65da9edc225e4945c760731722bf526 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 3 Jun 2026 10:58:23 +0200 Subject: [PATCH 011/131] Update spec --- .../security-spec/07-operations/rebalancer.md | 174 +++++++++++++++--- .../07-operations/secret-management.md | 5 +- docs/security-spec/README.md | 5 +- 3 files changed, 154 insertions(+), 30 deletions(-) diff --git a/docs/security-spec/07-operations/rebalancer.md b/docs/security-spec/07-operations/rebalancer.md index ec6e5a955..d40ba0fd6 100644 --- a/docs/security-spec/07-operations/rebalancer.md +++ b/docs/security-spec/07-operations/rebalancer.md @@ -2,18 +2,39 @@ ## What This Does -The rebalancer is a standalone service (`apps/rebalancer/`) that monitors token coverage ratios on Pendulum and automatically moves liquidity across chains when ratios fall below threshold. Its primary function is ensuring the platform has sufficient tokens on Pendulum to service ramp operations without manual intervention. +The rebalancer is a standalone service (`apps/rebalancer/`) that monitors token coverage ratios and automatically moves liquidity across chains when ratios fall below threshold. Its primary function is ensuring the platform has sufficient tokens to service ramp operations without manual intervention. -**Current implementation:** One rebalancing path — BRLA ↔ axlUSDC, an 8-step cross-chain process that moves value from one stablecoin pool to another. +**Current implementation:** Two rebalancing paths: + +1. **BRLA ↔ axlUSDC (legacy, Pendulum)** — 8-step cross-chain process on Pendulum/Moonbeam/Polygon. Activated via `--legacy` flag. +2. **USDC → BRLA → USDC (Base)** — Default flow. Multi-step process on Base with dual-route optimization (SquidRouter vs Avenia). **Architecture:** -- `index.ts` — Entry point: checks coverage ratios, triggers rebalancing if any ratio falls below 25% (`COVERAGE_RATIO_THRESHOLD`) -- `rebalance/brla-to-axlusdc/index.ts` — Orchestrator: manages an 8-step state machine with persistence and resumability -- `rebalance/brla-to-axlusdc/steps.ts` — Individual step implementations (swaps, XCMs, API calls) -- `services/stateManager.ts` — State persistence via Supabase Storage (JSON file, not database) +- `index.ts` — Entry point: parses CLI args (`--legacy`, `--restart`, `--route=`, amount), checks coverage ratios, selects flow +- `rebalance/brla-to-axlusdc/index.ts` — Legacy orchestrator: 8-step state machine on Pendulum +- `rebalance/brla-to-axlusdc/steps.ts` — Legacy step implementations +- `rebalance/usdc-brla-usdc-base/index.ts` — Base orchestrator: multi-step state machine with dual-route branching +- `rebalance/usdc-brla-usdc-base/steps.ts` — Base step implementations (Nabla swap, Avenia transfers, SquidRouter, rate comparison) +- `services/stateManager.ts` — Generic `StateManager` base class + flow-specific managers (`BrlaToAxlUsdcStateManager`, `UsdcBaseStateManager`) +- `services/indexer/index.ts` — Nabla coverage ratio queries (Pendulum via GraphQL, Base via on-chain reads) - `utils/config.ts` — Configuration and secret loading +- `utils/nonce.ts` — `NonceManager` for sequential EVM transaction nonces +- `utils/transactions.ts` — Transaction confirmation helpers + +**CLI interface:** +``` +bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia] +``` +- No flag → Base flow (default) +- `--legacy` → Pendulum flow +- `--restart` → Force fresh state, ignore in-progress rebalance +- `--route=` → Force specific route (skip rate comparison) + +--- -**Rebalancing flow (BRLA → axlUSDC):** +### Flow 1: BRLA → axlUSDC (Legacy, Pendulum) + +**Rebalancing flow:** 1. Swap axlUSDC → BRLA on Pendulum (Nabla DEX) 2. XCM BRLA from Pendulum → Moonbeam 3. Call BRLA API to swap BRLA → USDC (off-chain settlement via BRLA provider) @@ -23,45 +44,146 @@ The rebalancer is a standalone service (`apps/rebalancer/`) that monitors token 7. Verify arrival on Pendulum 8. Clean up state -**Key secrets:** Three separate chain private keys: `PENDULUM_ACCOUNT_SECRET`, `MOONBEAM_ACCOUNT_SECRET`, `POLYGON_ACCOUNT_SECRET`. These are **distinct from the API service keys** — the rebalancer operates its own accounts. +**Key secrets:** `PENDULUM_ACCOUNT_SECRET` (sr25519), `EVM_ACCOUNT_SECRET` (mnemonic for Moonbeam + Polygon). These are **distinct from the API service keys** — the rebalancer operates its own accounts. + +--- + +### Flow 2: USDC → BRLA → USDC (Base, default) + +**Trigger condition:** Base Nabla BRLA pool coverage ratio ≥ `1 + rebalancingThreshold` (default 1.25). + +**Daily bridge limit:** Total USDC bridged per calendar day (UTC) must not exceed `REBALANCING_DAILY_BRIDGE_LIMIT_USD` (default 10,000). Checked against `UsdcBaseStateManager` history before starting. + +**Rebalancing flow (linear phase):** +1. Check initial USDC balance on Base (sufficient for requested amount) +2. Nabla approve + swap: USDC → BRLA on Base +3. Transfer BRLA to Avenia business account on Base (ERC-20 transfer) +4. Wait for BRLA to appear on Avenia internal balance (polling, 10-min timeout) + +**Rate comparison phase:** +5. Compare rates between SquidRouter and Avenia for BRLA → USDC conversion + - If `--route=` specified, fetches quote for that route only + - If both quotes fail, aborts + - If one fails, uses the other + +**Route A: Avenia (BRLA → USDC on Base, direct):** +6a. Create Avenia swap ticket (BRLA → USDC, output on Base) +7a. Poll ticket status until PAID (5-min timeout) +8a. Wait for USDC arrival on Base (balance polling, 30-min timeout) + +**Route B: SquidRouter (BRLA on Polygon → USDC on Base, cross-chain):** +6b. Request Avenia to transfer BRLA from internal balance to Polygon +7b. Poll ticket status until PAID (5-min timeout) +8b. Wait for BRLA arrival on Polygon (balance polling, 10-min timeout) +9b. SquidRouter approve + swap: BRLA on Polygon → USDC on Base +10b. Wait for Axelar cross-chain execution (30-min timeout) +11b. Wait for USDC arrival on Base (balance polling, 30-min timeout) + +**Verification:** +12. Verify final USDC balance on Base +13. Record history entry (amount, cost, cost-relative, timestamps) +14. Send Slack notification with route, amount, and cost metrics + +**Fallback:** If Avenia ticket creation fails during Route A, the flow falls back to Route B (SquidRouter). + +**Key secrets:** `EVM_ACCOUNT_SECRET` (single BIP-39 mnemonic, derives accounts for Base + Polygon). `PENDULUM_ACCOUNT_SECRET` not required for this flow. ## Security Invariants -1. **Coverage ratio check MUST precede rebalancing** — The rebalancer only triggers when a token's coverage ratio falls below `COVERAGE_RATIO_THRESHOLD` (default 0.25 / 25%). It must never rebalance preemptively or based on stale data. -2. **State persistence MUST survive process restarts** — The `stateManager` writes state to Supabase Storage as a JSON file. On restart, the rebalancer reads this file and resumes from the last completed step. -3. **Each step MUST be idempotent or guarded against re-execution** — If the process crashes mid-step and resumes, re-executing a completed step must not cause double-swaps, double-XCMs, or double-settlements. -4. **Rebalancer private keys MUST be isolated from API service keys** — The three chain keys are used only for rebalancer operations. Compromise of rebalancer keys should not affect API ramp operations, and vice versa. +### Shared (both flows) + +1. **Coverage ratio check MUST precede rebalancing** — The rebalancer only triggers when a token's coverage ratio falls below threshold. It must never rebalance preemptively or based on stale data. Legacy flow uses Pendulum indexer (GraphQL); Base flow uses on-chain Nabla contract reads. +2. **State persistence MUST survive process restarts** — Each flow has its own Supabase Storage JSON file (`rebalancer_state.json` for legacy, `rebalancer_state_usdc_base.json` for Base). On restart, the rebalancer reads the file and resumes from the last completed phase. +3. **Each phase MUST be idempotent or guarded against re-execution** — If the process crashes mid-phase and resumes, re-executing a completed phase must not cause double-swaps, double-transfers, or double-settlements. Transaction hashes are stored in state to detect already-completed phases. +4. **Rebalancer private keys MUST be isolated from API service keys** — The rebalancer keys operate separate accounts. Compromise of rebalancer keys should not affect API ramp operations, and vice versa. 5. **BRLA business account address MUST be verified** — `brlaBusinessAccountAddress` has a hardcoded default (`0xDF5Fb34B90e5FDF612372dA0c774A516bF5F08b2`). If this address is wrong, funds are sent to the wrong recipient with no recovery. -6. **Slippage MUST be bounded** — The Nabla swap step uses a 5% slippage tolerance (hardcoded). Excessive slippage could result in significant value loss per rebalance. -7. **SquidRouter gas pricing MUST not overpay excessively** — `gasMultiplier * 5n` is applied to `maxFeePerGas` for SquidRouter transactions. This aggressive multiplier ensures inclusion but could result in significant gas overpayment. -8. **Concurrent rebalancer executions MUST NOT corrupt state** — If two rebalancer instances run simultaneously, both would read the same state file and potentially execute the same steps in parallel. +6. **Concurrent rebalancer executions MUST NOT corrupt state** — If two rebalancer instances run simultaneously, both would read the same state file and potentially execute the same phases in parallel. Supabase Storage has no file locking or atomic compare-and-swap. + +### Legacy flow (BRLA ↔ axlUSDC) invariants + +7. **Slippage MUST be bounded** — The Nabla swap step uses a 5% slippage tolerance (hardcoded). Excessive slippage could result in significant value loss per rebalance. +8. **SquidRouter gas pricing MUST not overpay excessively** — `gasMultiplier * 5n` is applied to `maxFeePerGas` for SquidRouter transactions. This aggressive multiplier ensures inclusion but could result in significant gas overpayment. +9. **Axelar polling MUST have a timeout** — **F-034 (legacy):** The legacy flow's Axelar polling loop (`while (!isExecuted)`) has no timeout — it will poll indefinitely if Axelar never reports success. This is a known deficiency in the legacy flow; the Base flow fixes it with a 30-minute timeout. + +### Base flow (USDC → BRLA → USDC) invariants + +10. **Daily bridge limit MUST be enforced** — Total USDC bridged per calendar day (UTC) must not exceed `REBALANCING_DAILY_BRIDGE_LIMIT_USD`. Checked against `UsdcBaseStateManager` history entries before starting a new rebalance. Prevents runaway rebalancing from draining hot wallets. +11. **Rate comparison MUST handle provider failures gracefully** — If both SquidRouter and Avenia quotes fail, the rebalancer MUST abort (not proceed with zero information). If one fails, the other is used. If `--route=` is specified, only that route's quote is fetched. +12. **Avenia fallback to SquidRouter MUST be atomic in state** — If Avenia ticket creation fails, the flow sets `winningRoute = "squidrouter"` and `currentPhase = AveniaTransferToPolygon` in a single `saveState()` call. A crash between the failure and the save could leave the flow in an inconsistent state. +13. **NonceManager MUST be re-initialized on resume** — The `NonceManager` is created fresh at the start of each execution from `getTransactionCount()`. On resume, it must not reuse stale nonces from a previous execution. +14. **Axelar cross-chain execution MUST have a timeout** — SquidRouter's Axelar polling has a 30-minute timeout. If Axelar does not confirm execution within this window, the flow MUST throw (not poll indefinitely). This resolves F-034 for the Base flow. +15. **BRLA balance arrival check MUST use a tolerance** — `waitForBrlaOnAvenia` checks if the Avenia balance is ≥ 99.8% of the expected amount (`balanceDecimal.div(brlaAmountDecimal).gte(0.998)`). This accounts for rounding and minor fee deductions without rejecting valid arrivals. +16. **`EVM_ACCOUNT_SECRET` derives the same address on all EVM chains** — A single BIP-39 mnemonic is used for Base and Polygon. This means compromise of this one secret drains the rebalancer on ALL EVM chains. The legacy flow's separate-key model had narrower blast radius per key. ## Threat Vectors & Mitigations +### Shared threats + +| Threat | Mitigation | +|---|---| +| **⚠️ State file corruption from concurrent execution** — Two rebalancer instances read the same JSON file from Supabase Storage, both decide to rebalance, both execute phases simultaneously | **NO MITIGATION.** Supabase Storage has no file locking, no atomic compare-and-swap, no conditional writes. If the rebalancer is deployed as multiple instances or triggered concurrently, state corruption and double-execution are possible. | +| **Rebalancer key compromise** — Attacker obtains the rebalancer private key(s) | Full drain of the rebalancer's accounts on all affected chains. Legacy: three separate keys (Pendulum, Moonbeam, Polygon) — compromise of one drains one chain. Base: single `EVM_ACCOUNT_SECRET` mnemonic — compromise drains both Base and Polygon. The API service accounts are separate, so ramp operations are not directly affected (but liquidity would be depleted). | +| **Hardcoded business account address** — `brlaBusinessAccountAddress` default is wrong or points to an attacker-controlled address | Funds would be sent to the wrong address. The address should be verified against BRLA's official documentation and set via environment variable, not hardcoded. | +| **State file deletion or corruption** — Supabase Storage file is deleted or corrupted manually | The rebalancer would lose track of in-progress operations. Phases that already executed (swaps, transfers) would not be resumed, and the rebalancer would start fresh. This could leave funds stranded mid-flow. | +| **Stale coverage ratio** — The coverage ratio is checked once at startup, but by the time the multi-step rebalance completes, the ratio may have changed significantly | No re-check between phases. The rebalance amount is calculated upfront. If conditions change during the multi-step process, the rebalance may be unnecessary or insufficient. | + +### Legacy flow threats + | Threat | Mitigation | |---|---| -| **⚠️ State file corruption from concurrent execution** — Two rebalancer instances read the same JSON file from Supabase Storage, both decide to rebalance, both execute steps simultaneously | **NO MITIGATION.** Supabase Storage has no file locking, no atomic compare-and-swap, no conditional writes. If the rebalancer is deployed as multiple instances or triggered concurrently, state corruption and double-execution are possible. | -| **Rebalancer key compromise** — Attacker obtains one or more of the three chain private keys | Full drain of the rebalancer's accounts on the compromised chain(s). These are pooled accounts holding liquidity. No rate limiting at the chain level. The API service accounts are separate, so ramp operations are not directly affected (but liquidity would be depleted). | | **BRLA API manipulation** — The BRLA API returns a manipulated exchange rate for the BRLA→USDC swap | The rebalancer trusts the BRLA API response. No independent price verification is performed. A manipulated rate could result in receiving far less USDC than the BRLA value. | | **SquidRouter route manipulation** — SquidRouter API returns a malicious route for the USDC→axlUSDC swap | Same trust issue as with the BRLA API. The rebalancer trusts the route. No output verification against expected amounts. | -| **Hardcoded business account address** — `brlaBusinessAccountAddress` default is wrong or points to an attacker-controlled address | Funds would be sent to the wrong address. The address should be verified against BRLA's official documentation and set via environment variable, not hardcoded. | | **5% slippage exploitation** — An attacker manipulates the Nabla DEX pool to extract up to 5% per rebalance via sandwich attacks | 5% slippage tolerance is generous. For large rebalancing amounts, this could be significant. No MEV protection on Pendulum (though parachain MEV is less prevalent than Ethereum). | -| **State file deletion or corruption** — Supabase Storage file is deleted or corrupted manually | The rebalancer would lose track of in-progress operations. Steps that already executed (swaps, XCMs) would not be resumed, and the rebalancer would start fresh. This could leave funds stranded mid-flow. | -| **Stale coverage ratio** — The coverage ratio is checked once at startup, but by the time the 8-step rebalance completes, the ratio may have changed significantly | No re-check between steps. The rebalance amount is calculated upfront. If conditions change during the multi-step process, the rebalance may be unnecessary or insufficient. | +| **Infinite Axelar polling (F-034)** — Legacy flow's Axelar polling has no timeout; if Axelar never reports success, the process hangs indefinitely | **NO MITIGATION in legacy flow.** The process will hang until manually killed or the OS reclaims resources. The Base flow resolves this with a 30-minute timeout. | + +### Base flow threats + +| Threat | Mitigation | +|---|---| +| **Rate comparison manipulation** — Both Avenia and SquidRouter quotes are fetched and compared; an attacker could manipulate one provider's rate to force the other route | The rebalancer trusts both providers' quotes without independent verification. However, since both routes end with USDC on Base, the worst case is choosing a slightly worse rate, not fund loss. The `slippage: 4` parameter on SquidRouter provides some buffer. | +| **Avenia ticket creation failure mid-flow** — Avenia API fails after the flow committed to the Avenia route | **Mitigated.** The flow catches the error and falls back to SquidRouter by setting `winningRoute = "squidrouter"` and saving state. If the crash happens between the error and the `saveState()`, the flow would retry the Avenia route on resume (benign — creates another ticket, wastes a quote). | +| **Daily bridge limit bypass** — History entries are stored in Supabase Storage; an attacker who can modify the storage could clear history to bypass the daily limit | **Weak mitigation.** The limit is enforced client-side by reading history from Supabase. An attacker with Supabase access could also drain funds directly, so the limit bypass is a secondary concern. | +| **NonceManager stale nonce** — If the process crashes after sending a transaction but before saving the nonce, the resumed execution could reuse the same nonce | **Mitigated.** `NonceManager` is re-initialized from `getTransactionCount()` on each execution. The stored transaction hashes in state also prevent re-execution of already-completed phases. | +| **`EVM_ACCOUNT_SECRET` single-key blast radius** — One mnemonic controls all EVM chain accounts for the rebalancer | Compromise of this one secret drains rebalancer funds on Base AND Polygon. The legacy flow's three separate keys had narrower per-key blast radius. This is a deliberate simplification accepted for operational convenience. | +| **SquidRouter cross-chain timeout** — Axelar cross-chain execution could take longer than 30 minutes during network congestion | The rebalancer throws on timeout, leaving the BRLA-to-USDC swap incomplete on Polygon. Funds would be stuck as BRLA on Polygon until manual intervention or the next rebalance attempt resumes from the `SquidRouterApproveAndSwap` phase. | +| **BRLA balance tolerance (99.8%)** — `waitForBrlaOnAvenia` accepts 99.8% of expected amount as sufficient | If Avenia deducts a fee > 0.2%, the flow proceeds with slightly less BRLA than expected. The downstream rate comparison and swap would still work, but the final USDC amount would be slightly less than quoted. | ## Audit Checklist +### Shared + - [x] **FINDING**: State stored as JSON file in Supabase Storage — no locking, no atomic updates. Verify whether concurrent rebalancer instances are possible in the deployment configuration. **PASS (confirmed limitation)** — rebalancer is a one-shot CLI process (`process.exit(0/1)`); concurrency depends entirely on deployment scheduling (cron). No in-code concurrency guard. - [PARTIAL] **FINDING**: `brlaBusinessAccountAddress` has hardcoded default `0xDF5Fb34B90e5FDF612372dA0c774A516bF5F08b2` — verify this is the correct BRLA business account and that it's set via environment variable in production. **PARTIAL** — address is overridable via env var but has hardcoded default; correctness of default requires external verification. +- [x] Verify Supabase Storage write errors are handled — what happens if state cannot be persisted after a phase completes? **PASS** — errors propagate and cause process exit; no silent data loss. +- [PARTIAL] Verify the rebalancer has monitoring/alerting for: failed phases, insufficient balances, stuck state. **PARTIAL** — `process.exit(1)` on failure provides signal for external monitoring, but no built-in alerting. Slack notifications on completion provide some visibility. +- [x] Verify no rebalancer secrets are logged (check all error handlers and debug logging). **PASS** — no secret logging found. +- [x] Check whether the rebalancer runs on a schedule (cron) or is triggered manually — determines concurrency risk. **PASS** — one-shot CLI process; concurrency controlled by external scheduler. +- [x] Verify the `StateManager` handles missing or corrupted state files gracefully (fresh start vs crash). **PASS** — missing state treated as fresh start; `upsert: true` for writes; invalid JSON treated as missing with console warning. + +### Legacy flow (BRLA ↔ axlUSDC) + - [x] **FINDING**: 5% slippage tolerance hardcoded in Nabla swap — verify this is acceptable for expected rebalancing amounts. **PASS (confirmed limitation)** — 5% is generous but acceptable for the current rebalancing volumes; documented as known risk. - [x] **FINDING**: `gasMultiplier * 5n` applied to `maxFeePerGas` — verify this doesn't cause excessive gas overpayment in production. **PASS (confirmed limitation)** — aggressive multiplier ensures inclusion; overpayment risk accepted for reliability. - [x] Verify `COVERAGE_RATIO_THRESHOLD` default (0.25) is appropriate for the expected token volumes. **PASS** — 25% threshold reasonable for current volumes. -- [x] Verify the three rebalancer private keys (`PENDULUM_ACCOUNT_SECRET`, `MOONBEAM_ACCOUNT_SECRET`, `POLYGON_ACCOUNT_SECRET`) are distinct from all API service keys. **PASS** — separate env vars and accounts confirmed. +- [x] Verify the rebalancer private keys are distinct from all API service keys. **PASS** — separate env vars and accounts confirmed. - [PARTIAL] Verify step idempotency: can each of the 8 steps be safely re-executed after a crash? Check for nonce guards, balance checks, or transaction hash verification. **PARTIAL F-033** — steps 2, 3, 5, 6, 7 are NOT idempotent; crash between step execution and `saveState()` causes double-spend risk. - [PARTIAL] Verify the BRLA→USDC swap (step 3) validates the received USDC amount against expectations. **PARTIAL** — BRLA API response is trusted; no independent amount verification. - [FAIL] Verify the SquidRouter swap (step 5) validates the received axlUSDC amount against expectations. **FAIL F-034** — no output amount validation AND Axelar status polling has no timeout; infinite loop risk if Axelar never reports success. -- [x] Verify Supabase Storage write errors are handled — what happens if state cannot be persisted after a step completes? **PASS** — errors propagate and cause process exit; no silent data loss. -- [PARTIAL] Verify the rebalancer has monitoring/alerting for: failed steps, insufficient balances, stuck state. **PARTIAL** — `process.exit(1)` on failure provides signal for external monitoring, but no built-in alerting. -- [x] Verify no rebalancer secrets are logged (check all error handlers and debug logging). **PASS** — no secret logging found. -- [x] Check whether the rebalancer runs on a schedule (cron) or is triggered manually — determines concurrency risk. **PASS** — one-shot CLI process; concurrency controlled by external scheduler. -- [x] Verify the `stateManager` handles missing or corrupted state files gracefully (fresh start vs crash). **PASS** — missing state treated as fresh start; `upsert: true` for writes. + +### Base flow (USDC → BRLA → USDC) + +- [x] **FINDING**: Axelar polling has 30-minute timeout — resolves F-034 for Base flow. **PASS** — `axelarTimeout = 30 * 60 * 1000` enforced in `squidRouterApproveAndSwap()`. +- [x] **FINDING**: Daily bridge limit check — `REBALANCING_DAILY_BRIDGE_LIMIT_USD` (default 10,000) enforced against history. **PASS** — checked before starting new rebalance; prevents runaway bridging. +- [x] **FINDING**: Avenia fallback to SquidRouter — if Avenia ticket creation fails, flow falls back to SquidRouter route. **PASS** — error caught, `winningRoute` updated, state saved atomically. +- [x] **FINDING**: `EVM_ACCOUNT_SECRET` single mnemonic for all EVM chains — broader blast radius than legacy three-key model. **PASS (accepted)** — deliberate simplification; documented in invariants. +- [x] Verify rate comparison handles partial failures — what happens if one provider's quote fails? **PASS** — if both fail, throws; if one fails, uses the other; if `--route=` specified, only fetches that quote. +- [x] Verify NonceManager re-initialization on resume — does it fetch fresh nonce from chain? **PASS** — `NonceManager.create()` calls `getTransactionCount()` on each execution. +- [x] Verify BRLA balance arrival tolerance (99.8%) is appropriate. **PASS** — accounts for rounding and minor fee deductions; 0.2% tolerance is tight enough to reject significant shortfalls. +- [x] Verify `checkTicketStatusPaid` has a timeout (not infinite polling). **PASS** — 5-minute timeout with 5-second poll interval. +- [x] Verify `waitForBrlaOnAvenia` has a timeout. **PASS** — 10-minute timeout with 5-second poll interval. +- [x] Verify `waitUsdcOnBase` has a timeout. **PASS** — 30-minute timeout via `checkEvmBalancePeriodically`. +- [x] Verify `waitBrlaOnPolygon` has a timeout. **PASS** — 10-minute timeout via `checkEvmBalancePeriodically`. +- [PARTIAL] Verify the Nabla swap validates output amount against expectations. **PARTIAL** — uses `AMM_MINIMUM_OUTPUT_HARD_MARGIN` (5%) for slippage protection via `quoteSwapExactTokensForTokens`, but post-swap balance is verified by comparing pre/post BRLA balance (not against the quote). A sandwich attack could extract up to 5%. +- [x] Verify the `usdcBasePhaseOrder` overlap (AveniaTransferToPolygon and AveniaSwapToUsdcBase both at order 7) cannot cause incorrect phase transitions. **PASS** — routes are mutually exclusive, guarded by `if (state.winningRoute === "avenia")` / `if (state.winningRoute === "squidrouter")` checks. +- [x] Verify `NablaSwap` phase in enum is not reachable (dead code). **PASS** — `NablaSwap` exists in enum but orchestrator transitions from `NablaApprove` directly to `TransferBrlaToAvenia`. Benign — no security impact, but should be cleaned up. +- [x] Verify `aveniaQuoteToken` state field is not used. **PASS** — always `null`, never written to. Benign dead state field. diff --git a/docs/security-spec/07-operations/secret-management.md b/docs/security-spec/07-operations/secret-management.md index 33abb4a53..86977a0f8 100644 --- a/docs/security-spec/07-operations/secret-management.md +++ b/docs/security-spec/07-operations/secret-management.md @@ -34,9 +34,8 @@ This spec catalogs every secret, its purpose, its blast radius if compromised, a | Secret | Purpose | Blast Radius | |---|---|---| -| `PENDULUM_ACCOUNT_SECRET` | Rebalancer's Pendulum account | Drain of rebalancer Pendulum funds | -| `MOONBEAM_ACCOUNT_SECRET` | Rebalancer's Moonbeam account | Drain of rebalancer Moonbeam funds | -| `POLYGON_ACCOUNT_SECRET` | Rebalancer's Polygon account | Drain of rebalancer Polygon funds | +| `EVM_ACCOUNT_SECRET` | Single BIP-39 mnemonic for all EVM chains (Base, Polygon, Moonbeam). Used by both Base and legacy flows. | Drain of rebalancer funds on ALL EVM chains — Base, Polygon, and Moonbeam. Single point of failure for all EVM-based rebalancing. | +| `PENDULUM_ACCOUNT_SECRET` | Rebalancer's Pendulum account (sr25519 seed). Only required for legacy flow (`--legacy` flag). | Drain of rebalancer Pendulum funds. Not needed for the default Base flow. | ### Shared diff --git a/docs/security-spec/README.md b/docs/security-spec/README.md index cd7f4437e..5db920888 100644 --- a/docs/security-spec/README.md +++ b/docs/security-spec/README.md @@ -42,7 +42,7 @@ This directory contains the security specification for the Vortex cross-border p | XCM Transfers | `06-cross-chain/xcm-transfers.md` | Pendulum↔Moonbeam↔AssetHub↔Hydration | | Bridge Security | `06-cross-chain/bridge-security.md` | Spacewalk bridge trust model | | Fund Routing | `06-cross-chain/fund-routing.md` | Subsidization, fee distribution, amount integrity | -| Rebalancer | `07-operations/rebalancer.md` | Automated liquidity management | +| Rebalancer | `07-operations/rebalancer.md` | Automated liquidity management — BRLA↔axlUSDC (legacy, Pendulum) and USDC→BRLA→USDC (Base, default) | | Secret Management | `07-operations/secret-management.md` | Env vars, rotation, blast radius | | API Surface | `07-operations/api-surface.md` | Rate limiting, CORS, input validation, error handling | @@ -70,7 +70,10 @@ Every spec file uses exactly four sections: | **Monerium** | (Deprecated) EUR stablecoin issuer; previously used for EUR on-ramp via SEPA. Replaced by Mykobo. | | **Alfredpay** | Fiat payment provider supporting multiple currencies | | **Squid Router** | Cross-chain swap/routing protocol for EVM chains | +| **Axelar** | Cross-chain messaging protocol used by SquidRouter for EVM-to-EVM bridging | +| **Avenia** | BRLA's internal settlement platform; handles BRLA transfers, swaps, and PIX payouts | | **Subsidization** | When the platform tops up an ephemeral account to ensure the user receives the quoted amount | | **pk\_/sk\_** | Public key / Secret key prefixes for the dual API key system | | **PIX** | Brazilian instant payment system | | **SEPA** | Single Euro Payments Area — European bank transfer system | +| **Coverage ratio** | Reserve ÷ liabilities for a Nabla swap pool; ratio > 1 means the pool is over-collateralized and triggers rebalancing | From 5224509ebf3100176b91b6f19e2fcb423a774bdf Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 3 Jun 2026 11:03:17 +0200 Subject: [PATCH 012/131] Improve and refactor code --- .../src/rebalance/brla-to-axlusdc/steps.ts | 37 +------------------ .../rebalance/usdc-brla-usdc-base/index.ts | 2 +- .../rebalance/usdc-brla-usdc-base/steps.ts | 34 +---------------- apps/rebalancer/src/services/stateManager.ts | 25 +++++-------- apps/rebalancer/src/utils/brla.ts | 31 ++++++++++++++++ 5 files changed, 46 insertions(+), 83 deletions(-) create mode 100644 apps/rebalancer/src/utils/brla.ts diff --git a/apps/rebalancer/src/rebalance/brla-to-axlusdc/steps.ts b/apps/rebalancer/src/rebalance/brla-to-axlusdc/steps.ts index 64672bf1a..44cb9b400 100644 --- a/apps/rebalancer/src/rebalance/brla-to-axlusdc/steps.ts +++ b/apps/rebalancer/src/rebalance/brla-to-axlusdc/steps.ts @@ -5,8 +5,6 @@ import { ApiManager, AveniaFeeType, AveniaPaymentMethod, - AveniaSwapTicket, - AveniaTicketStatus, BrlaApiService, BrlaCurrency, checkEvmBalancePeriodically, @@ -34,42 +32,11 @@ import { import Big from "big.js"; import { encodeFunctionData } from "viem"; import { polygon } from "viem/chains"; -import { brlaFiatTokenDetails, brlaMoonbeamTokenDetails, usdcTokenDetails } from "../../constants.ts"; +import { brlaMoonbeamTokenDetails, usdcTokenDetails } from "../../constants.ts"; +import { checkTicketStatusPaid } from "../../utils/brla.ts"; import { getConfig, getMoonbeamEvmClients, getPendulumAccount, getPolygonEvmClients } from "../../utils/config.ts"; import { waitForTransactionConfirmation } from "../../utils/transactions.ts"; -async function checkTicketStatusPaid(brlaApiService: BrlaApiService, ticketId: string): Promise { - const pollInterval = 5000; // 5 seconds - const timeout = 5 * 60 * 1000; // 5 minutes - const startTime = Date.now(); - let lastError: any; - - while (Date.now() - startTime < timeout) { - try { - const ticket = await brlaApiService.getAveniaSwapTicket(ticketId); - if (ticket && ticket.status) { - if (ticket.status === AveniaTicketStatus.PAID) { - return ticket; - } - if (ticket.status === AveniaTicketStatus.FAILED) { - throw new Error("Ticket status is FAILED"); - } - } - } catch (error) { - lastError = error; - console.warn(`Polling for ticket ${ticketId} status failed with error. Retrying...`, lastError); - } - await new Promise(resolve => setTimeout(resolve, pollInterval)); - } - - if (lastError) { - console.error("Polling for ticket status timed out with an error: ", lastError); - throw new Error(`Polling for ticket status timed out with an error: ${lastError.message}`); - } - - throw new Error("Polling for ticket status timed out."); -} - export async function checkInitialPendulumBalance(pendulumAddress: string, requiredAmount: string): Promise { const apiManager = ApiManager.getInstance(); const pendulumNode = await apiManager.getApi("pendulum"); diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts index 58017a431..d47dff122 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts @@ -1,13 +1,13 @@ import { BrlaApiService, multiplyByPowerOfTen, SlackNotifier } from "@vortexfi/shared"; import Big from "big.js"; import { UsdcBaseRebalancePhase, UsdcBaseStateManager, usdcBasePhaseOrder } from "../../services/stateManager.ts"; +import { checkTicketStatusPaid } from "../../utils/brla.ts"; import { getBaseEvmClients, getPolygonEvmClients } from "../../utils/config.ts"; import { NonceManager } from "../../utils/nonce.ts"; import { aveniaCreateSwapToUsdcBaseTicket, aveniaTransferBrlaToPolygon, checkInitialUsdcBalanceOnBase, - checkTicketStatusPaid, compareRates, fetchAveniaQuote, fetchSquidRouterQuote, diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts index 95db36446..4b4d58303 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts @@ -1,6 +1,5 @@ import { AveniaPaymentMethod, - AveniaTicketStatus, BrlaApiService, BrlaCurrency, checkEvmBalancePeriodically, @@ -29,36 +28,6 @@ export const BRLA_POLYGON: `0x${string}` = "0xe6a537a407488807f0bbeb0038b79004f1 const NABLA_SWAP_DEADLINE_MINUTES = 60 * 24 * 7; const AMM_MINIMUM_OUTPUT_HARD_MARGIN = 0.05; -export async function checkTicketStatusPaid(brlaApiService: BrlaApiService, ticketId: string) { - const pollInterval = 5000; - const timeout = 5 * 60 * 1000; - const startTime = Date.now(); - let lastError: Error | undefined; - - while (Date.now() - startTime < timeout) { - try { - const ticket = await brlaApiService.getAveniaSwapTicket(ticketId); - if (ticket && ticket.status) { - if (ticket.status === AveniaTicketStatus.PAID) { - return ticket; - } - if (ticket.status === AveniaTicketStatus.FAILED) { - throw new Error("Ticket status is FAILED"); - } - } - } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); - console.warn(`Polling for ticket ${ticketId} status failed with error. Retrying...`, lastError); - } - await new Promise(resolve => setTimeout(resolve, pollInterval)); - } - - if (lastError) { - throw new Error(`Polling for ticket status timed out with an error: ${lastError.message}`); - } - throw new Error("Polling for ticket status timed out."); -} - export async function checkInitialUsdcBalanceOnBase(usdcAmountRaw: string): Promise { const { publicClient, walletClient } = getBaseEvmClients(); const address = walletClient.account.address; @@ -234,7 +203,8 @@ export async function transferBrlaToAveniaOnBase( const { walletClient, publicClient } = getBaseEvmClients(); if (state.brlaTransferHash) { - console.log(`Resuming BRLA transfer with existing tx: ${state.brlaTransferHash}`); + console.log(`Resuming BRLA transfer with existing tx: ${state.brlaTransferHash}. Verifying on-chain...`); + await waitForTransactionConfirmation(state.brlaTransferHash, publicClient); return state.brlaTransferHash; } diff --git a/apps/rebalancer/src/services/stateManager.ts b/apps/rebalancer/src/services/stateManager.ts index 5c2d0bb2b..624a707a2 100644 --- a/apps/rebalancer/src/services/stateManager.ts +++ b/apps/rebalancer/src/services/stateManager.ts @@ -153,7 +153,6 @@ export enum UsdcBaseRebalancePhase { Idle = "idle", CheckInitialUsdcBalance = "checkInitialUsdcBalance", NablaApprove = "nablaApprove", - NablaSwap = "nablaSwap", TransferBrlaToAvenia = "transferBrlaToAvenia", WaitForBrlaOnAvenia = "waitForBrlaOnAvenia", CompareRates = "compareRates", @@ -170,17 +169,16 @@ export const usdcBasePhaseOrder: Record = { [UsdcBaseRebalancePhase.Idle]: 0, [UsdcBaseRebalancePhase.CheckInitialUsdcBalance]: 1, [UsdcBaseRebalancePhase.NablaApprove]: 2, - [UsdcBaseRebalancePhase.NablaSwap]: 3, - [UsdcBaseRebalancePhase.TransferBrlaToAvenia]: 4, - [UsdcBaseRebalancePhase.WaitForBrlaOnAvenia]: 5, - [UsdcBaseRebalancePhase.CompareRates]: 6, - [UsdcBaseRebalancePhase.AveniaTransferToPolygon]: 7, - [UsdcBaseRebalancePhase.WaitBrlaOnPolygon]: 8, - [UsdcBaseRebalancePhase.SquidRouterApproveAndSwap]: 9, - [UsdcBaseRebalancePhase.WaitUsdcOnBaseFromSquid]: 10, - [UsdcBaseRebalancePhase.AveniaSwapToUsdcBase]: 7, - [UsdcBaseRebalancePhase.WaitUsdcOnBaseFromAvenia]: 8, - [UsdcBaseRebalancePhase.VerifyFinalBalance]: 11 + [UsdcBaseRebalancePhase.TransferBrlaToAvenia]: 3, + [UsdcBaseRebalancePhase.WaitForBrlaOnAvenia]: 4, + [UsdcBaseRebalancePhase.CompareRates]: 5, + [UsdcBaseRebalancePhase.AveniaTransferToPolygon]: 6, + [UsdcBaseRebalancePhase.WaitBrlaOnPolygon]: 7, + [UsdcBaseRebalancePhase.SquidRouterApproveAndSwap]: 8, + [UsdcBaseRebalancePhase.WaitUsdcOnBaseFromSquid]: 9, + [UsdcBaseRebalancePhase.AveniaSwapToUsdcBase]: 6, + [UsdcBaseRebalancePhase.WaitUsdcOnBaseFromAvenia]: 7, + [UsdcBaseRebalancePhase.VerifyFinalBalance]: 10 }; export type WinningRoute = "squidrouter" | "avenia" | null; @@ -199,7 +197,6 @@ export interface UsdcBaseRebalanceState { aveniaQuoteUsdc: string | null; squidRouterSwapHash: string | null; aveniaTicketId: string | null; - aveniaQuoteToken: string | null; finalUsdcBalance: string | null; startingTime: string; updatedTime: string; @@ -220,7 +217,6 @@ export interface UsdcBaseRebalanceContainer { function createFreshState(): UsdcBaseRebalanceState { return { - aveniaQuoteToken: null, aveniaQuoteUsdc: null, aveniaTicketId: null, brlaAmountDecimal: null, @@ -293,7 +289,6 @@ export class UsdcBaseStateManager { const history = existing?.history ?? []; const state: UsdcBaseRebalanceState = { - aveniaQuoteToken: null, aveniaQuoteUsdc: null, aveniaTicketId: null, brlaAmountDecimal: null, diff --git a/apps/rebalancer/src/utils/brla.ts b/apps/rebalancer/src/utils/brla.ts new file mode 100644 index 000000000..17fa04251 --- /dev/null +++ b/apps/rebalancer/src/utils/brla.ts @@ -0,0 +1,31 @@ +import { AveniaSwapTicket, AveniaTicketStatus, BrlaApiService } from "@vortexfi/shared"; + +export async function checkTicketStatusPaid(brlaApiService: BrlaApiService, ticketId: string): Promise { + const pollInterval = 5000; + const timeout = 5 * 60 * 1000; + const startTime = Date.now(); + let lastError: Error | undefined; + + while (Date.now() - startTime < timeout) { + try { + const ticket = await brlaApiService.getAveniaSwapTicket(ticketId); + if (ticket && ticket.status) { + if (ticket.status === AveniaTicketStatus.PAID) { + return ticket; + } + if (ticket.status === AveniaTicketStatus.FAILED) { + throw new Error("Ticket status is FAILED"); + } + } + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + console.warn(`Polling for ticket ${ticketId} status failed with error. Retrying...`, lastError); + } + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } + + if (lastError) { + throw new Error(`Polling for ticket status timed out with an error: ${lastError.message}`); + } + throw new Error("Polling for ticket status timed out."); +} From 01eed2db24b180f35b8b84ccec4ef540dfe68315 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 3 Jun 2026 11:29:36 +0200 Subject: [PATCH 013/131] fix base rebalancer balance checks --- apps/rebalancer/src/index.ts | 6 +- .../usdc-brla-usdc-base/guards.test.ts | 18 ++ .../rebalance/usdc-brla-usdc-base/guards.ts | 17 ++ .../rebalance/usdc-brla-usdc-base/index.ts | 46 ++++- .../rebalance/usdc-brla-usdc-base/steps.ts | 163 ++++++++++++------ apps/rebalancer/src/services/stateManager.ts | 20 ++- apps/rebalancer/src/utils/brla.test.ts | 20 +++ apps/rebalancer/src/utils/brla.ts | 25 +-- 8 files changed, 249 insertions(+), 66 deletions(-) create mode 100644 apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.test.ts create mode 100644 apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.ts create mode 100644 apps/rebalancer/src/utils/brla.test.ts diff --git a/apps/rebalancer/src/index.ts b/apps/rebalancer/src/index.ts index cd608b340..45c18ccb1 100644 --- a/apps/rebalancer/src/index.ts +++ b/apps/rebalancer/src/index.ts @@ -4,6 +4,7 @@ import Big from "big.js"; import { rebalanceBrlaToUsdcAxl } from "./rebalance/brla-to-axlusdc"; import { checkInitialPendulumBalance } from "./rebalance/brla-to-axlusdc/steps.ts"; import { rebalanceUsdcBrlaUsdcBase } from "./rebalance/usdc-brla-usdc-base"; +import { wouldExceedDailyBridgeLimit } from "./rebalance/usdc-brla-usdc-base/guards.ts"; import { checkInitialUsdcBalanceOnBase } from "./rebalance/usdc-brla-usdc-base/steps.ts"; import { getBaseNablaCoverageRatio, getSwapPoolsWithCoverageRatio } from "./services/indexer"; import { @@ -111,9 +112,10 @@ async function checkForRebalancing() { console.log( `Bridged $${bridgedToday.div(1e6).toFixed(2)} today. Daily bridge limit is $${config.rebalancingDailyBridgeLimitUsd}.` ); - if (bridgedToday.gte(dailyLimitRaw)) { + if (wouldExceedDailyBridgeLimit(bridgedToday, Big(amountUsdcRaw), dailyLimitRaw)) { + const projectedTotal = bridgedToday.plus(amountUsdcRaw); console.log( - `Daily bridge limit reached: bridged $${bridgedToday.div(1e6).toFixed(2)} today, limit is $${config.rebalancingDailyBridgeLimitUsd}. Skipping.` + `Daily bridge limit reached: projected $${projectedTotal.div(1e6).toFixed(2)} today, limit is $${config.rebalancingDailyBridgeLimitUsd}. Skipping.` ); return; } diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.test.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.test.ts new file mode 100644 index 000000000..67a3b4804 --- /dev/null +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test"; +import Big from "big.js"; +import { calculateMinimumDelta, calculateTargetBalanceRaw, wouldExceedDailyBridgeLimit } from "./guards.ts"; + +describe("USDC Base rebalance guards", () => { + test("calculates arrival target from the starting balance plus expected delta", () => { + expect(calculateTargetBalanceRaw("500000000", "100000000", "1")).toBe("600000000"); + }); + + test("supports tolerated delta checks without treating the total balance as the received amount", () => { + expect(calculateMinimumDelta(Big("100"), "0.998").toString()).toBe("99.8"); + }); + + test("daily bridge limit includes the amount about to be rebalanced", () => { + expect(wouldExceedDailyBridgeLimit(Big("9500000000"), Big("600000000"), Big("10000000000"))).toBe(true); + expect(wouldExceedDailyBridgeLimit(Big("9000000000"), Big("1000000000"), Big("10000000000"))).toBe(false); + }); +}); diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.ts new file mode 100644 index 000000000..28a3f9737 --- /dev/null +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.ts @@ -0,0 +1,17 @@ +import Big from "big.js"; + +export const DEFAULT_ARRIVAL_TOLERANCE = "0.998"; + +export function calculateMinimumDelta(expectedDelta: Big, tolerance = DEFAULT_ARRIVAL_TOLERANCE): Big { + return expectedDelta.mul(tolerance); +} + +export function calculateTargetBalanceRaw(startingBalanceRaw: string, expectedDeltaRaw: string, tolerance = "1"): string { + return Big(startingBalanceRaw) + .plus(calculateMinimumDelta(Big(expectedDeltaRaw), tolerance)) + .toFixed(0, 0); +} + +export function wouldExceedDailyBridgeLimit(bridgedTodayRaw: Big, requestedAmountRaw: Big, dailyLimitRaw: Big): boolean { + return bridgedTodayRaw.plus(requestedAmountRaw).gt(dailyLimitRaw); +} diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts index d47dff122..de3e45f91 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts @@ -11,6 +11,9 @@ import { compareRates, fetchAveniaQuote, fetchSquidRouterQuote, + getAveniaBrlaBalanceDecimal, + getBrlaBalanceOnPolygonRaw, + getUsdcBalanceOnBaseRaw, nablaApproveAndSwapOnBase, squidRouterApproveAndSwap, transferBrlaToAveniaOnBase, @@ -74,6 +77,11 @@ export async function rebalanceUsdcBrlaUsdcBase( if (currentOrder <= usdcBasePhaseOrder[UsdcBaseRebalancePhase.TransferBrlaToAvenia]) { if (!state.brlaAmountRaw) throw new Error("State corrupted: brlaAmountRaw missing for step 3"); + if (!state.aveniaBrlaBalanceBeforeTransfer) { + state.aveniaBrlaBalanceBeforeTransfer = await getAveniaBrlaBalanceDecimal(); + await stateManager.saveState(state); + } + state.brlaTransferHash = await transferBrlaToAveniaOnBase(state.brlaAmountRaw, baseNonce, state, stateManager); console.log(`BRLA transferred to Avenia on Base. Tx: ${state.brlaTransferHash}`); @@ -83,8 +91,14 @@ export async function rebalanceUsdcBrlaUsdcBase( if (currentOrder <= usdcBasePhaseOrder[UsdcBaseRebalancePhase.WaitForBrlaOnAvenia]) { if (!state.brlaAmountDecimal) throw new Error("State corrupted: brlaAmountDecimal missing for step 4"); + if (!state.aveniaBrlaBalanceBeforeTransfer) { + throw new Error("State corrupted: aveniaBrlaBalanceBeforeTransfer missing for step 4"); + } - const actualBrlaBalance = await waitForBrlaOnAvenia(Big(state.brlaAmountDecimal)); + const actualBrlaBalance = await waitForBrlaOnAvenia( + Big(state.brlaAmountDecimal), + Big(state.aveniaBrlaBalanceBeforeTransfer) + ); state.brlaAmountDecimal = actualBrlaBalance; state.brlaAmountRaw = multiplyByPowerOfTen(Big(actualBrlaBalance), 18).toFixed(0, 0); @@ -131,6 +145,11 @@ export async function rebalanceUsdcBrlaUsdcBase( if (!state.aveniaTicketId) { try { + if (!state.baseUsdcBalanceBeforeAveniaSwapRaw) { + state.baseUsdcBalanceBeforeAveniaSwapRaw = await getUsdcBalanceOnBaseRaw(); + await stateManager.saveState(state); + } + const result = await aveniaCreateSwapToUsdcBaseTicket(Big(state.brlaAmountDecimal), baseAddress); state.aveniaTicketId = result.ticketId; state.aveniaQuoteUsdc = result.outputAmount; @@ -161,9 +180,12 @@ export async function rebalanceUsdcBrlaUsdcBase( if (state.winningRoute === "avenia") { if (currentOrder <= usdcBasePhaseOrder[UsdcBaseRebalancePhase.WaitUsdcOnBaseFromAvenia]) { if (!state.aveniaQuoteUsdc) throw new Error("State corrupted: aveniaQuoteUsdc missing for avenia step 2"); + if (!state.baseUsdcBalanceBeforeAveniaSwapRaw) { + throw new Error("State corrupted: baseUsdcBalanceBeforeAveniaSwapRaw missing for avenia step 2"); + } const aveniaUsdcRaw = multiplyByPowerOfTen(Big(state.aveniaQuoteUsdc), 6).toFixed(0, 0); - await waitUsdcOnBase(aveniaUsdcRaw); + await waitUsdcOnBase(aveniaUsdcRaw, state.baseUsdcBalanceBeforeAveniaSwapRaw); console.log("USDC from Avenia confirmed on Base."); state.currentPhase = UsdcBaseRebalancePhase.VerifyFinalBalance; @@ -180,6 +202,11 @@ export async function rebalanceUsdcBrlaUsdcBase( if (!state.brlaAmountDecimal) throw new Error("State corrupted: brlaAmountDecimal missing for squid step 1"); if (!state.aveniaTicketId) { + if (!state.polygonBrlaBalanceBeforeTransferRaw) { + state.polygonBrlaBalanceBeforeTransferRaw = await getBrlaBalanceOnPolygonRaw(); + await stateManager.saveState(state); + } + const ticketId = await aveniaTransferBrlaToPolygon(Big(state.brlaAmountDecimal)); state.aveniaTicketId = ticketId; await stateManager.saveState(state); @@ -195,8 +222,11 @@ export async function rebalanceUsdcBrlaUsdcBase( if (currentOrder <= usdcBasePhaseOrder[UsdcBaseRebalancePhase.WaitBrlaOnPolygon]) { if (!state.brlaAmountRaw) throw new Error("State corrupted: brlaAmountRaw missing for squid step 2"); + if (!state.polygonBrlaBalanceBeforeTransferRaw) { + throw new Error("State corrupted: polygonBrlaBalanceBeforeTransferRaw missing for squid step 2"); + } - await waitBrlaOnPolygon(state.brlaAmountRaw); + await waitBrlaOnPolygon(state.brlaAmountRaw, state.polygonBrlaBalanceBeforeTransferRaw); console.log("BRLA confirmed on Polygon."); state.currentPhase = UsdcBaseRebalancePhase.SquidRouterApproveAndSwap; @@ -206,6 +236,11 @@ export async function rebalanceUsdcBrlaUsdcBase( if (currentOrder <= usdcBasePhaseOrder[UsdcBaseRebalancePhase.SquidRouterApproveAndSwap]) { if (!state.brlaAmountRaw) throw new Error("State corrupted: brlaAmountRaw missing for squid step 3"); + if (!state.baseUsdcBalanceBeforeSquidSwapRaw) { + state.baseUsdcBalanceBeforeSquidSwapRaw = await getUsdcBalanceOnBaseRaw(); + await stateManager.saveState(state); + } + const result = await squidRouterApproveAndSwap(state.brlaAmountRaw, baseAddress, polygonNonce, state, stateManager); state.squidRouterSwapHash = result.swapHash; @@ -216,8 +251,11 @@ export async function rebalanceUsdcBrlaUsdcBase( if (currentOrder <= usdcBasePhaseOrder[UsdcBaseRebalancePhase.WaitUsdcOnBaseFromSquid]) { if (!state.squidRouterQuoteUsdc) throw new Error("State corrupted: squidRouterQuoteUsdc missing for squid step 4"); + if (!state.baseUsdcBalanceBeforeSquidSwapRaw) { + throw new Error("State corrupted: baseUsdcBalanceBeforeSquidSwapRaw missing for squid step 4"); + } - await waitUsdcOnBase(state.squidRouterQuoteUsdc); + await waitUsdcOnBase(state.squidRouterQuoteUsdc, state.baseUsdcBalanceBeforeSquidSwapRaw); console.log("USDC from SquidRouter confirmed on Base."); state.currentPhase = UsdcBaseRebalancePhase.VerifyFinalBalance; diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts index 4b4d58303..88c975005 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts @@ -22,12 +22,52 @@ import { UsdcBaseRebalanceState, UsdcBaseStateManager } from "../../services/sta import { getBaseEvmClients, getConfig, getPolygonEvmClients } from "../../utils/config.ts"; import { NonceManager } from "../../utils/nonce.ts"; import { waitForTransactionConfirmation } from "../../utils/transactions.ts"; +import { calculateMinimumDelta, calculateTargetBalanceRaw, DEFAULT_ARRIVAL_TOLERANCE } from "./guards.ts"; export const USDC_BASE: `0x${string}` = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; export const BRLA_POLYGON: `0x${string}` = "0xe6a537a407488807f0bbeb0038b79004f19dddfb"; const NABLA_SWAP_DEADLINE_MINUTES = 60 * 24 * 7; const AMM_MINIMUM_OUTPUT_HARD_MARGIN = 0.05; +export async function getUsdcBalanceOnBaseRaw(): Promise { + const { publicClient, walletClient } = getBaseEvmClients(); + const balance = await publicClient.readContract({ + abi: erc20Abi, + address: USDC_BASE, + args: [walletClient.account.address], + functionName: "balanceOf" + }); + return balance.toString(); +} + +export async function getBrlaBalanceOnBaseRaw(): Promise { + const { publicClient, walletClient } = getBaseEvmClients(); + const balance = await publicClient.readContract({ + abi: erc20Abi, + address: ERC20_BRLA_BASE, + args: [walletClient.account.address], + functionName: "balanceOf" + }); + return balance.toString(); +} + +export async function getBrlaBalanceOnPolygonRaw(): Promise { + const { publicClient, walletClient } = getPolygonEvmClients(); + const balance = await publicClient.readContract({ + abi: erc20Abi, + address: BRLA_POLYGON, + args: [walletClient.account.address], + functionName: "balanceOf" + }); + return balance.toString(); +} + +export async function getAveniaBrlaBalanceDecimal(): Promise { + const brlaApiService = BrlaApiService.getInstance(); + const balanceResponse = await brlaApiService.getMainAccountBalance(); + return String(balanceResponse?.balances?.BRLA ?? "0"); +} + export async function checkInitialUsdcBalanceOnBase(usdcAmountRaw: string): Promise { const { publicClient, walletClient } = getBaseEvmClients(); const address = walletClient.account.address; @@ -68,18 +108,31 @@ export async function nablaApproveAndSwapOnBase( const { router } = getNablaBasePool(USDC_BASE, ERC20_BRLA_BASE); - // Snapshot pre-swap BRLA balance — account may have leftovers from prior runs - const brlaBalanceBefore = await publicClient.readContract({ - abi: erc20Abi, - address: ERC20_BRLA_BASE, - args: [executorAddress], - functionName: "balanceOf" - }); + if (state.nablaApproveHash && state.nablaSwapHash && state.brlaAmountRaw && state.brlaAmountDecimal) { + console.log(`Resuming Nabla swap with previously recorded BRLA output: ${state.brlaAmountDecimal}`); + return { + approveHash: state.nablaApproveHash, + brlaAmountDecimal: Big(state.brlaAmountDecimal), + brlaAmountRaw: state.brlaAmountRaw, + swapHash: state.nablaSwapHash + }; + } + + if (state.nablaSwapHash && !state.brlaBalanceBeforeNablaRaw) { + throw new Error("State corrupted: missing pre-Nabla BRLA balance baseline for completed swap."); + } + + if (!state.brlaBalanceBeforeNablaRaw) { + state.brlaBalanceBeforeNablaRaw = await getBrlaBalanceOnBaseRaw(); + await stateManager.saveState(state); + } + + const brlaBalanceBefore = BigInt(state.brlaBalanceBeforeNablaRaw); - let approveHash: string; - let swapHash: string; + let approveHash = state.nablaApproveHash; + let swapHash = state.nablaSwapHash; - if (!state.nablaApproveHash || !state.nablaSwapHash) { + if (!swapHash) { const evmClientManager = EvmClientManager.getInstance(); const quoteAbi = [ { @@ -120,26 +173,30 @@ export async function nablaApproveAndSwapOnBase( router ); - console.log("Sending Nabla approve transaction on Base..."); - const { maxFeePerGas: approveFee, maxPriorityFeePerGas: approveTip } = await publicClient.estimateFeesPerGas(); - approveHash = await walletClient.sendTransaction({ - account: walletClient.account, - chain: base, - data: approve.data, - gas: BigInt(approve.gas), - maxFeePerGas: approveFee, - maxPriorityFeePerGas: approveTip, - nonce: baseNonce.next(), - to: approve.to, - value: BigInt(approve.value) - }); - console.log(`Approve tx sent: ${approveHash}`); + if (!approveHash) { + console.log("Sending Nabla approve transaction on Base..."); + const { maxFeePerGas: approveFee, maxPriorityFeePerGas: approveTip } = await publicClient.estimateFeesPerGas(); + approveHash = await walletClient.sendTransaction({ + account: walletClient.account, + chain: base, + data: approve.data, + gas: BigInt(approve.gas), + maxFeePerGas: approveFee, + maxPriorityFeePerGas: approveTip, + nonce: baseNonce.next(), + to: approve.to, + value: BigInt(approve.value) + }); + state.nablaApproveHash = approveHash; + await stateManager.saveState(state); + console.log(`Approve tx sent: ${approveHash}`); + } else { + console.log(`Resuming Nabla approval with existing tx: ${approveHash}`); + } + await waitForTransactionConfirmation(approveHash, publicClient); console.log("Nabla approval confirmed."); - state.nablaApproveHash = approveHash; - await stateManager.saveState(state); - console.log("Sending Nabla swap transaction on Base..."); const { maxFeePerGas: swapFee, maxPriorityFeePerGas: swapTip } = await publicClient.estimateFeesPerGas(); swapHash = await walletClient.sendTransaction({ @@ -153,18 +210,20 @@ export async function nablaApproveAndSwapOnBase( to: swap.to, value: BigInt(swap.value) }); - console.log(`Swap tx sent: ${swapHash}`); - await waitForTransactionConfirmation(swapHash, publicClient); - console.log("Nabla swap confirmed."); - state.nablaSwapHash = swapHash; await stateManager.saveState(state); + console.log(`Swap tx sent: ${swapHash}`); } else { - approveHash = state.nablaApproveHash; - swapHash = state.nablaSwapHash; console.log(`Resuming Nabla swap with existing approve tx: ${approveHash}, swap tx: ${swapHash}`); } + if (!approveHash || !swapHash) { + throw new Error("State corrupted: Nabla transaction hash missing after swap step."); + } + + await waitForTransactionConfirmation(swapHash, publicClient); + console.log("Nabla swap confirmed."); + // Delay to let the RPC sync the post-swap state before reading the balance await new Promise(resolve => setTimeout(resolve, 5_000)); @@ -181,6 +240,9 @@ export async function nablaApproveAndSwapOnBase( `BRLA balance decreased after swap (pre: ${brlaBalanceBefore}, post: ${brlaBalanceAfter}). Possible external interference.` ); } + if (brlaReceivedRaw === 0n) { + throw new Error(`No BRLA balance delta detected after Nabla swap (pre: ${brlaBalanceBefore}, post: ${brlaBalanceAfter}).`); + } const brlaAmountRaw = brlaReceivedRaw.toString(); const brlaAmountDecimal = multiplyByPowerOfTen(Big(brlaAmountRaw), -18); console.log(`Received ${brlaAmountDecimal.toFixed(4)} BRLA on Base (pre: ${brlaBalanceBefore}, post: ${brlaBalanceAfter})`); @@ -230,6 +292,8 @@ export async function transferBrlaToAveniaOnBase( value: 0n }); + state.brlaTransferHash = txHash; + await stateManager.saveState(state); console.log(`BRLA transfer tx sent: ${txHash}`); await waitForTransactionConfirmation(txHash, publicClient); console.log("BRLA transfer to Avenia confirmed on Base."); @@ -237,25 +301,27 @@ export async function transferBrlaToAveniaOnBase( return txHash; } -export async function waitForBrlaOnAvenia(brlaAmountDecimal: Big): Promise { +export async function waitForBrlaOnAvenia(brlaAmountDecimal: Big, startingBrlaBalanceDecimal: Big): Promise { const pollInterval = 5000; const timeout = 10 * 60 * 1000; const startTime = Date.now(); const brlaApiService = BrlaApiService.getInstance(); + const minimumReceived = calculateMinimumDelta(brlaAmountDecimal); - console.log(`Waiting for ~${brlaAmountDecimal.toFixed(4)} BRLA to appear on Avenia main account balance...`); + console.log(`Waiting for ~${brlaAmountDecimal.toFixed(4)} BRLA delta to appear on Avenia main account balance...`); while (Date.now() - startTime < timeout) { try { const balanceResponse = await brlaApiService.getMainAccountBalance(); if (balanceResponse && balanceResponse.balances && balanceResponse.balances.BRLA !== undefined) { const balanceDecimal = Big(balanceResponse.balances.BRLA); - if (balanceDecimal.div(brlaAmountDecimal).gte(0.998)) { - console.log(`Sufficient BRLA balance found on Avenia: ${balanceResponse.balances.BRLA}`); - return String(balanceResponse.balances.BRLA); + const receivedDelta = balanceDecimal.minus(startingBrlaBalanceDecimal); + if (receivedDelta.gte(minimumReceived)) { + console.log(`Sufficient BRLA delta found on Avenia: ${receivedDelta.toString()}`); + return receivedDelta.toString(); } console.log( - `Insufficient BRLA balance. Needed: ${brlaAmountDecimal.toFixed(12)}, have: ${balanceDecimal.toFixed(12)}. Retrying...` + `Insufficient BRLA delta. Needed: ${minimumReceived.toFixed(12)}, received: ${receivedDelta.toFixed(12)}. Retrying...` ); } } catch (error) { @@ -384,13 +450,14 @@ export async function aveniaTransferBrlaToPolygon(brlaAmountDecimal: Big): Promi return ticket.id; } -export async function waitBrlaOnPolygon(brlaAmountRaw: string): Promise { +export async function waitBrlaOnPolygon(brlaAmountRaw: string, startingBrlaBalanceRaw: string): Promise { const { walletClient: polygonWalletClient } = getPolygonEvmClients(); const polygonAddress = polygonWalletClient.account.address; + const targetBalanceRaw = calculateTargetBalanceRaw(startingBrlaBalanceRaw, brlaAmountRaw, DEFAULT_ARRIVAL_TOLERANCE); - console.log(`Waiting for BRLA to arrive on Polygon (${polygonAddress})...`); + console.log(`Waiting for BRLA delta to arrive on Polygon (${polygonAddress})...`); - await checkEvmBalancePeriodically(BRLA_POLYGON, polygonAddress, brlaAmountRaw, 1_000, 10 * 60 * 1_000, Networks.Polygon); + await checkEvmBalancePeriodically(BRLA_POLYGON, polygonAddress, targetBalanceRaw, 1_000, 10 * 60 * 1_000, Networks.Polygon); console.log("BRLA arrived on Polygon."); } @@ -464,11 +531,10 @@ export async function squidRouterApproveAndSwap( to: swapData.to, value: BigInt(swapData.value) }); - console.log(`Swap tx: ${swapHash}`); - await waitForTransactionConfirmation(swapHash, polygonPublicClient); - state.squidRouterSwapHash = swapHash; await stateManager.saveState(state); + console.log(`Swap tx: ${swapHash}`); + await waitForTransactionConfirmation(swapHash, polygonPublicClient); } else { console.log(`Resuming SquidRouter swap with existing swap tx: ${swapHash}`); } @@ -496,13 +562,14 @@ export async function squidRouterApproveAndSwap( return { swapHash, toAmountUsd }; } -export async function waitUsdcOnBase(expectedUsdcRaw: string): Promise { +export async function waitUsdcOnBase(expectedUsdcRaw: string, startingUsdcBalanceRaw: string): Promise { const { walletClient } = getBaseEvmClients(); const baseAddress = walletClient.account.address; + const targetBalanceRaw = calculateTargetBalanceRaw(startingUsdcBalanceRaw, expectedUsdcRaw); - console.log(`Waiting for USDC to arrive on Base (${baseAddress})...`); + console.log(`Waiting for USDC delta to arrive on Base (${baseAddress})...`); - await checkEvmBalancePeriodically(USDC_BASE, baseAddress, expectedUsdcRaw, 1_000, 30 * 60 * 1_000, Networks.Base); + await checkEvmBalancePeriodically(USDC_BASE, baseAddress, targetBalanceRaw, 1_000, 30 * 60 * 1_000, Networks.Base); console.log("USDC arrived on Base."); } diff --git a/apps/rebalancer/src/services/stateManager.ts b/apps/rebalancer/src/services/stateManager.ts index 624a707a2..774339eda 100644 --- a/apps/rebalancer/src/services/stateManager.ts +++ b/apps/rebalancer/src/services/stateManager.ts @@ -19,8 +19,9 @@ export class StateManager { const { data, error } = await this.supabase.storage.from("rebalancer_state").download(this.filename); if (error) { - const statusCode = (error as any).statusCode; - if (statusCode === 404 || statusCode === "404" || error.message?.includes("not found")) { + const storageError = error as { statusCode?: number | string; message?: string }; + const statusCode = storageError.statusCode; + if (statusCode === 404 || statusCode === "404" || storageError.message?.includes("not found")) { return undefined; } throw error; @@ -189,13 +190,18 @@ export interface UsdcBaseRebalanceState { usdcAmountRaw: string | null; brlaAmountRaw: string | null; brlaAmountDecimal: string | null; + brlaBalanceBeforeNablaRaw: string | null; nablaApproveHash: string | null; nablaSwapHash: string | null; + aveniaBrlaBalanceBeforeTransfer: string | null; brlaTransferHash: string | null; winningRoute: WinningRoute; squidRouterQuoteUsdc: string | null; aveniaQuoteUsdc: string | null; + polygonBrlaBalanceBeforeTransferRaw: string | null; squidRouterSwapHash: string | null; + baseUsdcBalanceBeforeAveniaSwapRaw: string | null; + baseUsdcBalanceBeforeSquidSwapRaw: string | null; aveniaTicketId: string | null; finalUsdcBalance: string | null; startingTime: string; @@ -217,16 +223,21 @@ export interface UsdcBaseRebalanceContainer { function createFreshState(): UsdcBaseRebalanceState { return { + aveniaBrlaBalanceBeforeTransfer: null, aveniaQuoteUsdc: null, aveniaTicketId: null, + baseUsdcBalanceBeforeAveniaSwapRaw: null, + baseUsdcBalanceBeforeSquidSwapRaw: null, brlaAmountDecimal: null, brlaAmountRaw: null, + brlaBalanceBeforeNablaRaw: null, brlaTransferHash: null, currentPhase: UsdcBaseRebalancePhase.Idle, finalUsdcBalance: null, initialUsdcBalance: null, nablaApproveHash: null, nablaSwapHash: null, + polygonBrlaBalanceBeforeTransferRaw: null, squidRouterQuoteUsdc: null, squidRouterSwapHash: null, startingTime: new Date().toISOString(), @@ -289,16 +300,21 @@ export class UsdcBaseStateManager { const history = existing?.history ?? []; const state: UsdcBaseRebalanceState = { + aveniaBrlaBalanceBeforeTransfer: null, aveniaQuoteUsdc: null, aveniaTicketId: null, + baseUsdcBalanceBeforeAveniaSwapRaw: null, + baseUsdcBalanceBeforeSquidSwapRaw: null, brlaAmountDecimal: null, brlaAmountRaw: null, + brlaBalanceBeforeNablaRaw: null, brlaTransferHash: null, currentPhase: UsdcBaseRebalancePhase.CheckInitialUsdcBalance, finalUsdcBalance: null, initialUsdcBalance: null, nablaApproveHash: null, nablaSwapHash: null, + polygonBrlaBalanceBeforeTransferRaw: null, squidRouterQuoteUsdc: null, squidRouterSwapHash: null, startingTime: new Date().toISOString(), diff --git a/apps/rebalancer/src/utils/brla.test.ts b/apps/rebalancer/src/utils/brla.test.ts new file mode 100644 index 000000000..be686d015 --- /dev/null +++ b/apps/rebalancer/src/utils/brla.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test"; +import { AveniaTicketStatus } from "@vortexfi/shared"; +import { checkTicketStatusPaid } from "./brla.ts"; + +describe("checkTicketStatusPaid", () => { + test("throws immediately for failed tickets", async () => { + const service = { + getAveniaSwapTicket: async () => ({ + status: AveniaTicketStatus.FAILED + }) + }; + + await expect( + Promise.race([ + checkTicketStatusPaid(service as never, "ticket-1"), + new Promise((_, reject) => setTimeout(() => reject(new Error("timed out waiting for terminal failure")), 25)) + ]) + ).rejects.toThrow("FAILED"); + }); +}); diff --git a/apps/rebalancer/src/utils/brla.ts b/apps/rebalancer/src/utils/brla.ts index 17fa04251..2d4b476a7 100644 --- a/apps/rebalancer/src/utils/brla.ts +++ b/apps/rebalancer/src/utils/brla.ts @@ -1,26 +1,31 @@ import { AveniaSwapTicket, AveniaTicketStatus, BrlaApiService } from "@vortexfi/shared"; -export async function checkTicketStatusPaid(brlaApiService: BrlaApiService, ticketId: string): Promise { +type AveniaTicketReader = Pick; + +export async function checkTicketStatusPaid(brlaApiService: AveniaTicketReader, ticketId: string): Promise { const pollInterval = 5000; const timeout = 5 * 60 * 1000; const startTime = Date.now(); let lastError: Error | undefined; while (Date.now() - startTime < timeout) { + let ticket: AveniaSwapTicket; try { - const ticket = await brlaApiService.getAveniaSwapTicket(ticketId); - if (ticket && ticket.status) { - if (ticket.status === AveniaTicketStatus.PAID) { - return ticket; - } - if (ticket.status === AveniaTicketStatus.FAILED) { - throw new Error("Ticket status is FAILED"); - } - } + ticket = await brlaApiService.getAveniaSwapTicket(ticketId); } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); console.warn(`Polling for ticket ${ticketId} status failed with error. Retrying...`, lastError); + await new Promise(resolve => setTimeout(resolve, pollInterval)); + continue; + } + + if (ticket?.status === AveniaTicketStatus.PAID) { + return ticket; } + if (ticket?.status === AveniaTicketStatus.FAILED) { + throw new Error(`Ticket ${ticketId} status is FAILED`); + } + await new Promise(resolve => setTimeout(resolve, pollInterval)); } From b8ec44a8b39fdb9886c3e8d95408caa1141ec973 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 3 Jun 2026 11:30:05 +0200 Subject: [PATCH 014/131] Update rebalancer spec --- apps/rebalancer/.env.example | 4 +- .../security-spec/07-operations/rebalancer.md | 38 ++++++++++--------- .../07-operations/secret-management.md | 2 +- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/apps/rebalancer/.env.example b/apps/rebalancer/.env.example index ad9592a1f..b7632fddd 100644 --- a/apps/rebalancer/.env.example +++ b/apps/rebalancer/.env.example @@ -14,5 +14,5 @@ REBALANCING_AMOUNT_USD_TO_BRL=100 SUPABASE_URL=your_supabase_url_here SUPABASE_SERVICE_KEY=your_supabase_service_key_here -# Maximum total USD bridged per day before the rebalancer stops (default 1M) -REBALANCING_DAILY_BRIDGE_LIMIT_USD=1000000 +# Maximum total USD bridged per day before the rebalancer stops (default 10,000) +REBALANCING_DAILY_BRIDGE_LIMIT_USD=10000 diff --git a/docs/security-spec/07-operations/rebalancer.md b/docs/security-spec/07-operations/rebalancer.md index d40ba0fd6..bd93e5e95 100644 --- a/docs/security-spec/07-operations/rebalancer.md +++ b/docs/security-spec/07-operations/rebalancer.md @@ -2,7 +2,7 @@ ## What This Does -The rebalancer is a standalone service (`apps/rebalancer/`) that monitors token coverage ratios and automatically moves liquidity across chains when ratios fall below threshold. Its primary function is ensuring the platform has sufficient tokens to service ramp operations without manual intervention. +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. **Current implementation:** Two rebalancing paths: @@ -52,13 +52,13 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia] **Trigger condition:** Base Nabla BRLA pool coverage ratio ≥ `1 + rebalancingThreshold` (default 1.25). -**Daily bridge limit:** Total USDC bridged per calendar day (UTC) must not exceed `REBALANCING_DAILY_BRIDGE_LIMIT_USD` (default 10,000). Checked against `UsdcBaseStateManager` history before starting. +**Daily bridge limit:** Total USDC bridged per calendar day (UTC), including the amount about to be rebalanced, must not exceed `REBALANCING_DAILY_BRIDGE_LIMIT_USD` (default 10,000). Checked against `UsdcBaseStateManager` history before starting. **Rebalancing flow (linear phase):** 1. Check initial USDC balance on Base (sufficient for requested amount) 2. Nabla approve + swap: USDC → BRLA on Base 3. Transfer BRLA to Avenia business account on Base (ERC-20 transfer) -4. Wait for BRLA to appear on Avenia internal balance (polling, 10-min timeout) +4. Wait for BRLA delta to appear on Avenia internal balance (polling, 10-min timeout) **Rate comparison phase:** 5. Compare rates between SquidRouter and Avenia for BRLA → USDC conversion @@ -69,15 +69,15 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia] **Route A: Avenia (BRLA → USDC on Base, direct):** 6a. Create Avenia swap ticket (BRLA → USDC, output on Base) 7a. Poll ticket status until PAID (5-min timeout) -8a. Wait for USDC arrival on Base (balance polling, 30-min timeout) +8a. Wait for USDC delta arrival on Base (balance polling, 30-min timeout) **Route B: SquidRouter (BRLA on Polygon → USDC on Base, cross-chain):** 6b. Request Avenia to transfer BRLA from internal balance to Polygon 7b. Poll ticket status until PAID (5-min timeout) -8b. Wait for BRLA arrival on Polygon (balance polling, 10-min timeout) +8b. Wait for BRLA delta arrival on Polygon (balance polling, 10-min timeout) 9b. SquidRouter approve + swap: BRLA on Polygon → USDC on Base 10b. Wait for Axelar cross-chain execution (30-min timeout) -11b. Wait for USDC arrival on Base (balance polling, 30-min timeout) +11b. Wait for USDC delta arrival on Base (balance polling, 30-min timeout) **Verification:** 12. Verify final USDC balance on Base @@ -92,9 +92,9 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia] ### Shared (both flows) -1. **Coverage ratio check MUST precede rebalancing** — The rebalancer only triggers when a token's coverage ratio falls below threshold. It must never rebalance preemptively or based on stale data. Legacy flow uses Pendulum indexer (GraphQL); Base flow uses on-chain Nabla contract reads. +1. **Coverage ratio check MUST precede rebalancing** — The rebalancer only triggers when a flow-specific coverage threshold is crossed. Legacy flow uses Pendulum indexer data and triggers when BRLA is over-covered while USDC.axl is not; Base flow uses on-chain Nabla contract reads and triggers when the Base BRLA coverage ratio is at least `1 + rebalancingThreshold`. It must never rebalance preemptively or based on stale data. 2. **State persistence MUST survive process restarts** — Each flow has its own Supabase Storage JSON file (`rebalancer_state.json` for legacy, `rebalancer_state_usdc_base.json` for Base). On restart, the rebalancer reads the file and resumes from the last completed phase. -3. **Each phase MUST be idempotent or guarded against re-execution** — If the process crashes mid-phase and resumes, re-executing a completed phase must not cause double-swaps, double-transfers, or double-settlements. Transaction hashes are stored in state to detect already-completed phases. +3. **Each phase MUST be idempotent or guarded against re-execution** — If the process crashes mid-phase and resumes, re-executing a completed phase must not cause double-swaps, double-transfers, or double-settlements. Transaction hashes and pre-action balance baselines are stored in state to detect already-completed phases and verify per-run deltas. 4. **Rebalancer private keys MUST be isolated from API service keys** — The rebalancer keys operate separate accounts. Compromise of rebalancer keys should not affect API ramp operations, and vice versa. 5. **BRLA business account address MUST be verified** — `brlaBusinessAccountAddress` has a hardcoded default (`0xDF5Fb34B90e5FDF612372dA0c774A516bF5F08b2`). If this address is wrong, funds are sent to the wrong recipient with no recovery. 6. **Concurrent rebalancer executions MUST NOT corrupt state** — If two rebalancer instances run simultaneously, both would read the same state file and potentially execute the same phases in parallel. Supabase Storage has no file locking or atomic compare-and-swap. @@ -107,13 +107,14 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia] ### Base flow (USDC → BRLA → USDC) invariants -10. **Daily bridge limit MUST be enforced** — Total USDC bridged per calendar day (UTC) must not exceed `REBALANCING_DAILY_BRIDGE_LIMIT_USD`. Checked against `UsdcBaseStateManager` history entries before starting a new rebalance. Prevents runaway rebalancing from draining hot wallets. +10. **Daily bridge limit MUST be enforced** — Total USDC bridged per calendar day (UTC), including the amount about to be rebalanced, must not exceed `REBALANCING_DAILY_BRIDGE_LIMIT_USD`. Checked against `UsdcBaseStateManager` history entries before starting a new rebalance. Prevents runaway rebalancing from draining hot wallets. 11. **Rate comparison MUST handle provider failures gracefully** — If both SquidRouter and Avenia quotes fail, the rebalancer MUST abort (not proceed with zero information). If one fails, the other is used. If `--route=` is specified, only that route's quote is fetched. 12. **Avenia fallback to SquidRouter MUST be atomic in state** — If Avenia ticket creation fails, the flow sets `winningRoute = "squidrouter"` and `currentPhase = AveniaTransferToPolygon` in a single `saveState()` call. A crash between the failure and the save could leave the flow in an inconsistent state. 13. **NonceManager MUST be re-initialized on resume** — The `NonceManager` is created fresh at the start of each execution from `getTransactionCount()`. On resume, it must not reuse stale nonces from a previous execution. 14. **Axelar cross-chain execution MUST have a timeout** — SquidRouter's Axelar polling has a 30-minute timeout. If Axelar does not confirm execution within this window, the flow MUST throw (not poll indefinitely). This resolves F-034 for the Base flow. -15. **BRLA balance arrival check MUST use a tolerance** — `waitForBrlaOnAvenia` checks if the Avenia balance is ≥ 99.8% of the expected amount (`balanceDecimal.div(brlaAmountDecimal).gte(0.998)`). This accounts for rounding and minor fee deductions without rejecting valid arrivals. +15. **Balance arrival checks MUST be delta-based** — The Base flow persists pre-action balances before each arrival-producing operation and waits for `starting balance + expected delta` rather than checking absolute hot-wallet/provider balances. Avenia and Polygon BRLA arrival checks allow a 99.8% tolerance to account for rounding and minor deductions without sweeping unrelated leftover balances into the current run. 16. **`EVM_ACCOUNT_SECRET` derives the same address on all EVM chains** — A single BIP-39 mnemonic is used for Base and Polygon. This means compromise of this one secret drains the rebalancer on ALL EVM chains. The legacy flow's separate-key model had narrower blast radius per key. +17. **Terminal Avenia ticket failures MUST abort immediately** — `checkTicketStatusPaid` treats `FAILED` as terminal and throws immediately instead of retrying until timeout. ## Threat Vectors & Mitigations @@ -141,12 +142,13 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia] | Threat | Mitigation | |---|---| | **Rate comparison manipulation** — Both Avenia and SquidRouter quotes are fetched and compared; an attacker could manipulate one provider's rate to force the other route | The rebalancer trusts both providers' quotes without independent verification. However, since both routes end with USDC on Base, the worst case is choosing a slightly worse rate, not fund loss. The `slippage: 4` parameter on SquidRouter provides some buffer. | -| **Avenia ticket creation failure mid-flow** — Avenia API fails after the flow committed to the Avenia route | **Mitigated.** The flow catches the error and falls back to SquidRouter by setting `winningRoute = "squidrouter"` and saving state. If the crash happens between the error and the `saveState()`, the flow would retry the Avenia route on resume (benign — creates another ticket, wastes a quote). | -| **Daily bridge limit bypass** — History entries are stored in Supabase Storage; an attacker who can modify the storage could clear history to bypass the daily limit | **Weak mitigation.** The limit is enforced client-side by reading history from Supabase. An attacker with Supabase access could also drain funds directly, so the limit bypass is a secondary concern. | +| **Avenia ticket creation failure mid-flow** — Avenia API fails after the flow committed to the Avenia route | **Mitigated.** The flow catches ticket creation errors and falls back to SquidRouter by setting `winningRoute = "squidrouter"` and saving state. Avenia tickets that return `FAILED` after creation are terminal and abort immediately. | +| **Daily bridge limit bypass** — History entries are stored in Supabase Storage; an attacker who can modify the storage could clear history to bypass the daily limit | **Weak mitigation.** The limit is enforced client-side by reading history from Supabase and adding the current requested amount before starting. An attacker with Supabase access could also drain funds directly, so the limit bypass is a secondary concern. | | **NonceManager stale nonce** — If the process crashes after sending a transaction but before saving the nonce, the resumed execution could reuse the same nonce | **Mitigated.** `NonceManager` is re-initialized from `getTransactionCount()` on each execution. The stored transaction hashes in state also prevent re-execution of already-completed phases. | | **`EVM_ACCOUNT_SECRET` single-key blast radius** — One mnemonic controls all EVM chain accounts for the rebalancer | Compromise of this one secret drains rebalancer funds on Base AND Polygon. The legacy flow's three separate keys had narrower per-key blast radius. This is a deliberate simplification accepted for operational convenience. | | **SquidRouter cross-chain timeout** — Axelar cross-chain execution could take longer than 30 minutes during network congestion | The rebalancer throws on timeout, leaving the BRLA-to-USDC swap incomplete on Polygon. Funds would be stuck as BRLA on Polygon until manual intervention or the next rebalance attempt resumes from the `SquidRouterApproveAndSwap` phase. | -| **BRLA balance tolerance (99.8%)** — `waitForBrlaOnAvenia` accepts 99.8% of expected amount as sufficient | If Avenia deducts a fee > 0.2%, the flow proceeds with slightly less BRLA than expected. The downstream rate comparison and swap would still work, but the final USDC amount would be slightly less than quoted. | +| **Absolute balance false positives** — Hot wallets/provider accounts can contain leftovers from previous runs, so absolute balance checks could pass before the current run's funds arrive | **Mitigated for Base flow.** The flow stores pre-action baselines and waits for deltas on Avenia BRLA, Polygon BRLA, and Base USDC arrivals. | +| **BRLA balance tolerance (99.8%)** — BRLA delta checks accept 99.8% of expected amount as sufficient | If Avenia deducts a fee > 0.2%, the flow will not proceed and will time out. The tolerance prevents rounding dust from blocking valid arrivals while rejecting meaningful shortfalls. | ## Audit Checklist @@ -173,17 +175,17 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia] ### Base flow (USDC → BRLA → USDC) - [x] **FINDING**: Axelar polling has 30-minute timeout — resolves F-034 for Base flow. **PASS** — `axelarTimeout = 30 * 60 * 1000` enforced in `squidRouterApproveAndSwap()`. -- [x] **FINDING**: Daily bridge limit check — `REBALANCING_DAILY_BRIDGE_LIMIT_USD` (default 10,000) enforced against history. **PASS** — checked before starting new rebalance; prevents runaway bridging. +- [x] **FINDING**: Daily bridge limit check — `REBALANCING_DAILY_BRIDGE_LIMIT_USD` (default 10,000) enforced against history plus the current requested amount. **PASS** — checked before starting new rebalance; prevents runaway bridging beyond the cap. - [x] **FINDING**: Avenia fallback to SquidRouter — if Avenia ticket creation fails, flow falls back to SquidRouter route. **PASS** — error caught, `winningRoute` updated, state saved atomically. - [x] **FINDING**: `EVM_ACCOUNT_SECRET` single mnemonic for all EVM chains — broader blast radius than legacy three-key model. **PASS (accepted)** — deliberate simplification; documented in invariants. - [x] Verify rate comparison handles partial failures — what happens if one provider's quote fails? **PASS** — if both fail, throws; if one fails, uses the other; if `--route=` specified, only fetches that quote. - [x] Verify NonceManager re-initialization on resume — does it fetch fresh nonce from chain? **PASS** — `NonceManager.create()` calls `getTransactionCount()` on each execution. - [x] Verify BRLA balance arrival tolerance (99.8%) is appropriate. **PASS** — accounts for rounding and minor fee deductions; 0.2% tolerance is tight enough to reject significant shortfalls. -- [x] Verify `checkTicketStatusPaid` has a timeout (not infinite polling). **PASS** — 5-minute timeout with 5-second poll interval. +- [x] Verify `checkTicketStatusPaid` has a timeout and treats FAILED as terminal. **PASS** — 5-minute timeout with 5-second poll interval; FAILED tickets throw immediately. - [x] Verify `waitForBrlaOnAvenia` has a timeout. **PASS** — 10-minute timeout with 5-second poll interval. - [x] Verify `waitUsdcOnBase` has a timeout. **PASS** — 30-minute timeout via `checkEvmBalancePeriodically`. - [x] Verify `waitBrlaOnPolygon` has a timeout. **PASS** — 10-minute timeout via `checkEvmBalancePeriodically`. - [PARTIAL] Verify the Nabla swap validates output amount against expectations. **PARTIAL** — uses `AMM_MINIMUM_OUTPUT_HARD_MARGIN` (5%) for slippage protection via `quoteSwapExactTokensForTokens`, but post-swap balance is verified by comparing pre/post BRLA balance (not against the quote). A sandwich attack could extract up to 5%. -- [x] Verify the `usdcBasePhaseOrder` overlap (AveniaTransferToPolygon and AveniaSwapToUsdcBase both at order 7) cannot cause incorrect phase transitions. **PASS** — routes are mutually exclusive, guarded by `if (state.winningRoute === "avenia")` / `if (state.winningRoute === "squidrouter")` checks. -- [x] Verify `NablaSwap` phase in enum is not reachable (dead code). **PASS** — `NablaSwap` exists in enum but orchestrator transitions from `NablaApprove` directly to `TransferBrlaToAvenia`. Benign — no security impact, but should be cleaned up. -- [x] Verify `aveniaQuoteToken` state field is not used. **PASS** — always `null`, never written to. Benign dead state field. +- [x] Verify the `usdcBasePhaseOrder` overlap (`AveniaTransferToPolygon` and `AveniaSwapToUsdcBase` both at order 6; both wait phases at order 7) cannot cause incorrect phase transitions. **PASS** — routes are mutually exclusive, guarded by `if (state.winningRoute === "avenia")` / `if (state.winningRoute === "squidrouter")` checks. +- [x] Verify Base flow arrival checks are delta-based. **PASS** — Avenia BRLA, Polygon BRLA, Avenia USDC-on-Base, and SquidRouter USDC-on-Base waits all use persisted pre-action baselines plus expected deltas. +- [x] Verify Nabla swap resume cannot lose the received BRLA amount. **PASS** — pre-swap BRLA baseline and swap hash are persisted; resume computes output from the persisted baseline or reuses already recorded output. diff --git a/docs/security-spec/07-operations/secret-management.md b/docs/security-spec/07-operations/secret-management.md index 86977a0f8..a6f5ad0f7 100644 --- a/docs/security-spec/07-operations/secret-management.md +++ b/docs/security-spec/07-operations/secret-management.md @@ -49,7 +49,7 @@ This spec catalogs every secret, its purpose, its blast radius if compromised, a 2. **Secrets MUST NOT appear in logs** — Error handlers, debug logging, and request/response logging must not include secret values, private keys, or seeds. 3. **`WEBHOOK_PRIVATE_KEY` MUST be set in production** — If missing, `CryptoService` generates an ephemeral RSA keypair at startup. This key is non-persistent: webhook signatures generated before a restart cannot be verified after a restart, and vice versa. Consumers would see signature validation failures. 4. **`ADMIN_SECRET` MUST be a high-entropy value** — Used as a bearer token for admin endpoints. Compared via `safeCompare()` which has a known timing leak on length (see `01-auth/admin-auth.md`). -5. **Rebalancer keys MUST be isolated from API service keys** — The three rebalancer chain keys operate separate accounts from the API's funding keys. Compromise of one set should not grant access to the other. +5. **Rebalancer keys MUST be isolated from API service keys** — The rebalancer's `EVM_ACCOUNT_SECRET` mnemonic and legacy `PENDULUM_ACCOUNT_SECRET` operate separate accounts from the API's funding keys. Compromise of one set should not grant access to the other. 6. **`SUPABASE_SERVICE_KEY` MUST NOT be exposed to clients** — This key bypasses Row Level Security. It must only be used server-side. 7. **Database credentials (`DB_*`) MUST NOT be accessible from the public internet** — Direct PostgreSQL access should be restricted to the application server's network. 8. **No secret MUST be passed as a URL query parameter** — Query parameters are logged by proxies, CDNs, and web servers. Secrets must only travel in headers or request bodies. From 7304909791928dd5330b74ea92804ac3c90fa413 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 3 Jun 2026 11:57:49 +0200 Subject: [PATCH 015/131] Adjust notification layout --- .../rebalance/usdc-brla-usdc-base/index.ts | 18 +++++----- .../usdc-brla-usdc-base/notifications.ts | 36 +++++++++++++++++++ 2 files changed, 45 insertions(+), 9 deletions(-) create mode 100644 apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts index de3e45f91..8f8be2b58 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts @@ -4,6 +4,7 @@ import { UsdcBaseRebalancePhase, UsdcBaseStateManager, usdcBasePhaseOrder } from import { checkTicketStatusPaid } from "../../utils/brla.ts"; import { getBaseEvmClients, getPolygonEvmClients } from "../../utils/config.ts"; import { NonceManager } from "../../utils/nonce.ts"; +import { formatBaseRebalanceCompletionMessage } from "./notifications.ts"; import { aveniaCreateSwapToUsdcBaseTicket, aveniaTransferBrlaToPolygon, @@ -298,14 +299,13 @@ export async function rebalanceUsdcBrlaUsdcBase( const slackNotifier = new SlackNotifier(process.env.SLACK_WEB_HOOK_TOKEN); await slackNotifier.sendMessage({ - text: - "--------------------------------------------------" + - "\n" + - "✅ USDC->BRLA->USDC rebalance on Base completed!\n" + - `🛤️ Route: ${state.winningRoute}\n` + - `💰 USDC rebalanced: ${usdcRebalanced.toFixed(6)}\n` + - `📉 Cost - Absolute: ${cost.toFixed(6)} USDC | Relative: ${costRelative}` + - "\n" + - "--------------------------------------------------" + text: formatBaseRebalanceCompletionMessage({ + brlaReceived: Big(state.brlaAmountDecimal), + cost, + finalUsdcBalance: finalUsdcDecimal, + initialUsdcBalance: initialUsdcDecimal, + requestedUsdc: usdcRebalanced, + route: state.winningRoute + }) }); } diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts new file mode 100644 index 000000000..f19278248 --- /dev/null +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts @@ -0,0 +1,36 @@ +import Big from "big.js"; +import type { WinningRoute } from "../../services/stateManager.ts"; + +interface BaseRebalanceCompletionMessageParams { + brlaReceived: Big; + cost: Big; + finalUsdcBalance: Big; + initialUsdcBalance: Big; + requestedUsdc: Big; + route: WinningRoute; +} + +export function formatBaseRebalanceCompletionMessage(params: BaseRebalanceCompletionMessageParams): string { + const costPercent = params.requestedUsdc.gt(0) ? params.cost.div(params.requestedUsdc).mul(100).toFixed(2) : "N/A"; + + return [ + "✅ *Base rebalancer completed*", + "USDC -> BRLA -> USDC on Base", + "", + "*Execution*", + `- 🛣️ Route selected: \`${formatRoute(params.route)}\``, + `- 💵 Requested amount: \`${params.requestedUsdc.toFixed(6)} USDC\``, + `- 🪙 BRLA after Nabla swap: \`${params.brlaReceived.toFixed(6)} BRLA\``, + "", + "*Balance impact*", + `- 🏦 Base USDC balance: \`${params.initialUsdcBalance.toFixed(6)} -> ${params.finalUsdcBalance.toFixed(6)} USDC\``, + `- 📉 Net USDC cost: \`${params.cost.toFixed(6)} USDC\``, + `- 📊 Cost/requested amount: \`${costPercent === "N/A" ? costPercent : `${costPercent}%`}\`` + ].join("\n"); +} + +function formatRoute(route: WinningRoute): string { + if (route === "avenia") return "Avenia"; + if (route === "squidrouter") return "SquidRouter"; + return "Unknown"; +} From e2ec13ecf88ba42317d89e4f350ce8eacaf599c5 Mon Sep 17 00:00:00 2001 From: gianfra-t <96739519+gianfra-t@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:34:01 -0300 Subject: [PATCH 016/131] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/rebalancer/src/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/rebalancer/src/index.ts b/apps/rebalancer/src/index.ts index 45c18ccb1..4118e7a43 100644 --- a/apps/rebalancer/src/index.ts +++ b/apps/rebalancer/src/index.ts @@ -83,8 +83,7 @@ async function checkForRebalancing() { } else { const coverage = await getBaseNablaCoverageRatio(); if (!coverage) { - console.log("Failed to fetch Base Nabla coverage ratio."); - return; + throw new Error("Failed to fetch Base Nabla coverage ratio."); } if (coverage.brlaCoverageRatio >= 1 + config.rebalancingThreshold) { From 09a612e2cbac16da5d1110989ac4ee8cae16bf8f Mon Sep 17 00:00:00 2001 From: gianfra-t <96739519+gianfra-t@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:34:12 -0300 Subject: [PATCH 017/131] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/rebalancer/src/services/indexer/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/rebalancer/src/services/indexer/index.ts b/apps/rebalancer/src/services/indexer/index.ts index f5c72cfb0..4f4ff53c8 100644 --- a/apps/rebalancer/src/services/indexer/index.ts +++ b/apps/rebalancer/src/services/indexer/index.ts @@ -44,6 +44,12 @@ export async function getBaseNablaCoverageRatio(): Promise< functionName: "poolByAsset" })) as `0x${string}`; + if (brlaPoolAddress === "0x0000000000000000000000000000000000000000") { + console.error( + `No BRLA pool found on Base Nabla router (${NABLA_ROUTER_BASE_BRLA}) for asset ${ERC20_BRLA_BASE}.` + ); + return undefined; + } const [brlaReserve, brlaLiabilities] = await Promise.all([ baseClient.readContract({ abi: SWAP_POOL_ABI, From eaeac228ee50ac605b0cc93725a50c51340c83a8 Mon Sep 17 00:00:00 2001 From: gianfra-t <96739519+gianfra-t@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:34:29 -0300 Subject: [PATCH 018/131] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts index 88c975005..2f75ec785 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts @@ -18,6 +18,7 @@ import { import Big from "big.js"; import { encodeFunctionData, erc20Abi } from "viem"; import { base, polygon } from "viem/chains"; +import { brlaMoonbeamTokenDetails } from "../../constants.ts"; import { UsdcBaseRebalanceState, UsdcBaseStateManager } from "../../services/stateManager.ts"; import { getBaseEvmClients, getConfig, getPolygonEvmClients } from "../../utils/config.ts"; import { NonceManager } from "../../utils/nonce.ts"; @@ -25,7 +26,7 @@ import { waitForTransactionConfirmation } from "../../utils/transactions.ts"; import { calculateMinimumDelta, calculateTargetBalanceRaw, DEFAULT_ARRIVAL_TOLERANCE } from "./guards.ts"; export const USDC_BASE: `0x${string}` = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; -export const BRLA_POLYGON: `0x${string}` = "0xe6a537a407488807f0bbeb0038b79004f19dddfb"; +export const BRLA_POLYGON: `0x${string}` = brlaMoonbeamTokenDetails.polygonErc20Address; const NABLA_SWAP_DEADLINE_MINUTES = 60 * 24 * 7; const AMM_MINIMUM_OUTPUT_HARD_MARGIN = 0.05; From a0e0d3a191935e893571535a7d9f779be0c5cb87 Mon Sep 17 00:00:00 2001 From: gianfra-t <96739519+gianfra-t@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:40:36 -0300 Subject: [PATCH 019/131] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/rebalancer/.env.example | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/rebalancer/.env.example b/apps/rebalancer/.env.example index b7632fddd..bf61b2ece 100644 --- a/apps/rebalancer/.env.example +++ b/apps/rebalancer/.env.example @@ -1,5 +1,6 @@ ALCHEMY_API_KEY=your_api_key_here -EVM_ACCOUNT_SECRET="your_secret_here" +# BIP-39 mnemonic (12/24 words) used to derive the executor EVM account for Base/Polygon/Moonbeam +EVM_ACCOUNT_SECRET="your mnemonic here" # Only required for legacy flow (--legacy flag) PENDULUM_ACCOUNT_SECRET="your_secret_here" From 7f1a5caf4b80367364b6b48fbb941adf6cba2f3e Mon Sep 17 00:00:00 2001 From: gianfra-t <96739519+gianfra-t@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:40:58 -0300 Subject: [PATCH 020/131] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/rebalancer/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/rebalancer/README.md b/apps/rebalancer/README.md index 7a07594b0..65f85f80e 100644 --- a/apps/rebalancer/README.md +++ b/apps/rebalancer/README.md @@ -13,7 +13,7 @@ Then, open the `.env` file and add your Alchemy API key. ``` ALCHEMY_API_KEY=your_alchemy_api_key -EVM_ACCOUNT_SECRET=xxx +EVM_ACCOUNT_SECRET="your BIP-39 mnemonic (12/24 words)" # Only required for legacy flow (--legacy flag) PENDULUM_ACCOUNT_SECRET=xxx From 5d8fbbadae8ba75ab53ebf31397a9b702d0c7924 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Thu, 4 Jun 2026 13:24:14 -0300 Subject: [PATCH 021/131] add main nabla rebalance route --- apps/rebalancer/.env.example | 4 + apps/rebalancer/src/index.ts | 8 +- .../rebalance/usdc-brla-usdc-base/index.ts | 124 ++++---- .../usdc-brla-usdc-base/notifications.ts | 1 + .../rebalance/usdc-brla-usdc-base/steps.ts | 279 +++++++++++++++++- apps/rebalancer/src/services/indexer/index.ts | 4 +- apps/rebalancer/src/services/stateManager.ts | 42 ++- apps/rebalancer/src/utils/config.ts | 6 +- 8 files changed, 395 insertions(+), 73 deletions(-) diff --git a/apps/rebalancer/.env.example b/apps/rebalancer/.env.example index bf61b2ece..c7a851d53 100644 --- a/apps/rebalancer/.env.example +++ b/apps/rebalancer/.env.example @@ -17,3 +17,7 @@ SUPABASE_SERVICE_KEY=your_supabase_service_key_here # Maximum total USD bridged per day before the rebalancer stops (default 10,000) REBALANCING_DAILY_BRIDGE_LIMIT_USD=10000 + +# Main Nabla instance on Base (BRL→USDC route). Leave empty to disable this route. +MAIN_NABLA_ROUTER=0x... +MAIN_NABLA_QUOTER=0x... diff --git a/apps/rebalancer/src/index.ts b/apps/rebalancer/src/index.ts index 4118e7a43..628c83945 100644 --- a/apps/rebalancer/src/index.ts +++ b/apps/rebalancer/src/index.ts @@ -20,9 +20,9 @@ const forceRestart = args.includes("--restart"); const useLegacy = args.includes("--legacy"); const manualAmount = args.find(arg => !arg.startsWith("--")) || null; const routeArg = args.find(arg => arg.startsWith("--route=")); -const forcedRoute = routeArg ? (routeArg.split("=")[1] as "squidrouter" | "avenia") : undefined; -if (forcedRoute && !["squidrouter", "avenia"].includes(forcedRoute)) { - console.error("Invalid --route value. Must be 'squidrouter' or 'avenia'."); +const forcedRoute = routeArg ? (routeArg.split("=")[1] as "squidrouter" | "avenia" | "nabla-main") : undefined; +if (forcedRoute && !["squidrouter", "avenia", "nabla-main"].includes(forcedRoute)) { + console.error("Invalid --route value. Must be 'squidrouter', 'avenia', or 'nabla-main'."); process.exit(1); } @@ -86,7 +86,7 @@ async function checkForRebalancing() { throw new Error("Failed to fetch Base Nabla coverage ratio."); } - if (coverage.brlaCoverageRatio >= 1 + config.rebalancingThreshold) { + if (coverage.brlaCoverageRatio >= 0 + config.rebalancingThreshold) { console.log(`Base Nabla BRLA coverage ratio ${coverage.brlaCoverageRatio} requires rebalancing.`); } else { console.log(`Base Nabla BRLA coverage ratio ${coverage.brlaCoverageRatio} does not require rebalancing.`); diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts index 8f8be2b58..4a59c6088 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts @@ -9,12 +9,14 @@ import { aveniaCreateSwapToUsdcBaseTicket, aveniaTransferBrlaToPolygon, checkInitialUsdcBalanceOnBase, - compareRates, + compareRoutesUpfront, fetchAveniaQuote, + fetchMainNablaQuote, fetchSquidRouterQuote, getAveniaBrlaBalanceDecimal, getBrlaBalanceOnPolygonRaw, getUsdcBalanceOnBaseRaw, + mainNablaApproveAndSwap, nablaApproveAndSwapOnBase, squidRouterApproveAndSwap, transferBrlaToAveniaOnBase, @@ -27,7 +29,7 @@ import { export async function rebalanceUsdcBrlaUsdcBase( usdcAmountRaw: string, forceRestart = false, - forcedRoute?: "squidrouter" | "avenia" + forcedRoute?: "squidrouter" | "avenia" | "nabla-main" ) { console.log(`Starting USDC->BRLA->USDC rebalance on Base with amount: ${usdcAmountRaw} (raw USDC)`); @@ -53,17 +55,39 @@ export async function rebalanceUsdcBrlaUsdcBase( const currentOrder = usdcBasePhaseOrder[state.currentPhase]; console.log(`Current phase order: ${currentOrder}`); + // ── Step 1: Check initial USDC balance ────────────────────────────────────── if (currentOrder <= usdcBasePhaseOrder[UsdcBaseRebalancePhase.CheckInitialUsdcBalance]) { if (!state.usdcAmountRaw) throw new Error("State corrupted: usdcAmountRaw missing for step 1"); const initialBalance = await checkInitialUsdcBalanceOnBase(state.usdcAmountRaw); state.initialUsdcBalance = initialBalance.toString(); + state.currentPhase = UsdcBaseRebalancePhase.CompareRates; + await stateManager.saveState(state); + } + + // ── Step 2: Compare all 3 routes upfront (before any swap) ────────────────── + if (currentOrder <= usdcBasePhaseOrder[UsdcBaseRebalancePhase.CompareRates]) { + if (!state.usdcAmountRaw) throw new Error("State corrupted: usdcAmountRaw missing for step 2"); + + if (forcedRoute) { + console.log(`Forced route: ${forcedRoute}. Skipping full comparison.`); + state.winningRoute = forcedRoute; + } else { + const comparison = await compareRoutesUpfront(state.usdcAmountRaw); + state.winningRoute = comparison.winningRoute; + state.squidRouterQuoteUsdc = comparison.squidRouterQuoteUsdc; + state.aveniaQuoteUsdc = comparison.aveniaQuoteUsdc; + state.mainNablaQuoteUsdc = comparison.mainNablaQuoteUsdc; + } + + console.log(`Route selected: ${state.winningRoute}`); state.currentPhase = UsdcBaseRebalancePhase.NablaApprove; await stateManager.saveState(state); } + // ── Step 3: Nabla swap USDC → BRLA on Base (common to all routes) ─────────── if (currentOrder <= usdcBasePhaseOrder[UsdcBaseRebalancePhase.NablaApprove]) { - if (!state.usdcAmountRaw) throw new Error("State corrupted: usdcAmountRaw missing for step 2"); + if (!state.usdcAmountRaw) throw new Error("State corrupted: usdcAmountRaw missing for step 3"); const result = await nablaApproveAndSwapOnBase(state.usdcAmountRaw, baseNonce, state, stateManager); @@ -71,73 +95,71 @@ export async function rebalanceUsdcBrlaUsdcBase( state.brlaAmountDecimal = result.brlaAmountDecimal.toString(); console.log(`Nabla swap completed. BRLA received: ${result.brlaAmountDecimal.toFixed(4)}`); - state.currentPhase = UsdcBaseRebalancePhase.TransferBrlaToAvenia; + + if (state.winningRoute === "nabla-main") { + state.currentPhase = UsdcBaseRebalancePhase.MainNablaApproveAndSwap; + } else { + state.currentPhase = UsdcBaseRebalancePhase.TransferBrlaToAvenia; + } await stateManager.saveState(state); } - if (currentOrder <= usdcBasePhaseOrder[UsdcBaseRebalancePhase.TransferBrlaToAvenia]) { - if (!state.brlaAmountRaw) throw new Error("State corrupted: brlaAmountRaw missing for step 3"); + // ── nabla-main route: swap BRL → USDC on main Nabla (terminal) ────────────── + if (state.winningRoute === "nabla-main") { + if (currentOrder <= usdcBasePhaseOrder[UsdcBaseRebalancePhase.MainNablaApproveAndSwap]) { + if (!state.brlaAmountRaw) throw new Error("State corrupted: brlaAmountRaw missing for nabla-main step"); + + await mainNablaApproveAndSwap(state.brlaAmountRaw, baseNonce, state, stateManager); - if (!state.aveniaBrlaBalanceBeforeTransfer) { - state.aveniaBrlaBalanceBeforeTransfer = await getAveniaBrlaBalanceDecimal(); + console.log("Main Nabla swap completed. Proceeding to verify final balance."); + state.currentPhase = UsdcBaseRebalancePhase.VerifyFinalBalance; await stateManager.saveState(state); } + } - state.brlaTransferHash = await transferBrlaToAveniaOnBase(state.brlaAmountRaw, baseNonce, state, stateManager); + // ── avenia/squid routes: transfer BRLA to Avenia, then diverge ────────────── + if (state.winningRoute === "avenia" || state.winningRoute === "squidrouter") { + if (currentOrder <= usdcBasePhaseOrder[UsdcBaseRebalancePhase.TransferBrlaToAvenia]) { + if (!state.brlaAmountRaw) throw new Error("State corrupted: brlaAmountRaw missing for step 4"); - console.log(`BRLA transferred to Avenia on Base. Tx: ${state.brlaTransferHash}`); - state.currentPhase = UsdcBaseRebalancePhase.WaitForBrlaOnAvenia; - await stateManager.saveState(state); - } + if (!state.aveniaBrlaBalanceBeforeTransfer) { + state.aveniaBrlaBalanceBeforeTransfer = await getAveniaBrlaBalanceDecimal(); + await stateManager.saveState(state); + } - if (currentOrder <= usdcBasePhaseOrder[UsdcBaseRebalancePhase.WaitForBrlaOnAvenia]) { - if (!state.brlaAmountDecimal) throw new Error("State corrupted: brlaAmountDecimal missing for step 4"); - if (!state.aveniaBrlaBalanceBeforeTransfer) { - throw new Error("State corrupted: aveniaBrlaBalanceBeforeTransfer missing for step 4"); - } + state.brlaTransferHash = await transferBrlaToAveniaOnBase(state.brlaAmountRaw, baseNonce, state, stateManager); - const actualBrlaBalance = await waitForBrlaOnAvenia( - Big(state.brlaAmountDecimal), - Big(state.aveniaBrlaBalanceBeforeTransfer) - ); + console.log(`BRLA transferred to Avenia on Base. Tx: ${state.brlaTransferHash}`); + state.currentPhase = UsdcBaseRebalancePhase.WaitForBrlaOnAvenia; + await stateManager.saveState(state); + } - state.brlaAmountDecimal = actualBrlaBalance; - state.brlaAmountRaw = multiplyByPowerOfTen(Big(actualBrlaBalance), 18).toFixed(0, 0); + if (currentOrder <= usdcBasePhaseOrder[UsdcBaseRebalancePhase.WaitForBrlaOnAvenia]) { + if (!state.brlaAmountDecimal) throw new Error("State corrupted: brlaAmountDecimal missing for step 5"); + if (!state.aveniaBrlaBalanceBeforeTransfer) { + throw new Error("State corrupted: aveniaBrlaBalanceBeforeTransfer missing for step 5"); + } - console.log("BRLA confirmed on Avenia internal balance."); - state.currentPhase = UsdcBaseRebalancePhase.CompareRates; - await stateManager.saveState(state); - } + const actualBrlaBalance = await waitForBrlaOnAvenia( + Big(state.brlaAmountDecimal), + Big(state.aveniaBrlaBalanceBeforeTransfer) + ); - if (currentOrder <= usdcBasePhaseOrder[UsdcBaseRebalancePhase.CompareRates]) { - if (!state.brlaAmountDecimal) throw new Error("State corrupted: brlaAmountDecimal missing for step 5"); + state.brlaAmountDecimal = actualBrlaBalance; + state.brlaAmountRaw = multiplyByPowerOfTen(Big(actualBrlaBalance), 18).toFixed(0, 0); - if (forcedRoute) { - console.log(`Forced route: ${forcedRoute}. Fetching quote for forced route only.`); + console.log("BRLA confirmed on Avenia internal balance."); - state.winningRoute = forcedRoute; - if (forcedRoute === "squidrouter") { - state.squidRouterQuoteUsdc = await fetchSquidRouterQuote(Big(state.brlaAmountDecimal)); + if (state.winningRoute === "squidrouter") { + state.currentPhase = UsdcBaseRebalancePhase.AveniaTransferToPolygon; } else { - state.aveniaQuoteUsdc = await fetchAveniaQuote(Big(state.brlaAmountDecimal)); + state.currentPhase = UsdcBaseRebalancePhase.AveniaSwapToUsdcBase; } - } else { - const rateComparison = await compareRates(Big(state.brlaAmountDecimal)); - state.winningRoute = rateComparison.winningRoute; - state.squidRouterQuoteUsdc = rateComparison.squidRouterQuoteUsdc; - state.aveniaQuoteUsdc = rateComparison.aveniaQuoteUsdc; - } - - console.log(`Rate comparison complete. Winner: ${state.winningRoute}`); - - if (state.winningRoute === "squidrouter") { - state.currentPhase = UsdcBaseRebalancePhase.AveniaTransferToPolygon; - } else { - state.currentPhase = UsdcBaseRebalancePhase.AveniaSwapToUsdcBase; + await stateManager.saveState(state); } - await stateManager.saveState(state); } + // ── Avenia route: BRLA → USDC swap via Avenia on Base ─────────────────────── if (state.winningRoute === "avenia") { if (currentOrder <= usdcBasePhaseOrder[UsdcBaseRebalancePhase.AveniaSwapToUsdcBase]) { if (!state.brlaAmountDecimal) throw new Error("State corrupted: brlaAmountDecimal missing for avenia step 1"); @@ -195,6 +217,7 @@ export async function rebalanceUsdcBrlaUsdcBase( } } + // ── SquidRouter route: transfer BRLA to Polygon, then swap ────────────────── if (state.winningRoute === "squidrouter") { const { publicClient: polygonPublicClient, walletClient: polygonWalletClient } = getPolygonEvmClients(); const polygonNonce = await NonceManager.create(polygonPublicClient, polygonWalletClient.account.address as `0x${string}`); @@ -264,6 +287,7 @@ export async function rebalanceUsdcBrlaUsdcBase( } } + // ── Final: verify balance and report ──────────────────────────────────────── if (currentOrder <= usdcBasePhaseOrder[UsdcBaseRebalancePhase.VerifyFinalBalance]) { const finalBalance = await verifyFinalUsdcBalanceOnBase(); state.finalUsdcBalance = finalBalance.toString(); diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts index f19278248..4af870432 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts @@ -32,5 +32,6 @@ export function formatBaseRebalanceCompletionMessage(params: BaseRebalanceComple function formatRoute(route: WinningRoute): string { if (route === "avenia") return "Avenia"; if (route === "squidrouter") return "SquidRouter"; + if (route === "nabla-main") return "Main Nabla"; return "Unknown"; } diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts index 2f75ec785..9d47bed46 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts @@ -19,14 +19,14 @@ import Big from "big.js"; import { encodeFunctionData, erc20Abi } from "viem"; import { base, polygon } from "viem/chains"; import { brlaMoonbeamTokenDetails } from "../../constants.ts"; -import { UsdcBaseRebalanceState, UsdcBaseStateManager } from "../../services/stateManager.ts"; +import { UsdcBaseRebalanceState, UsdcBaseStateManager, type WinningRoute } from "../../services/stateManager.ts"; import { getBaseEvmClients, getConfig, getPolygonEvmClients } from "../../utils/config.ts"; import { NonceManager } from "../../utils/nonce.ts"; import { waitForTransactionConfirmation } from "../../utils/transactions.ts"; import { calculateMinimumDelta, calculateTargetBalanceRaw, DEFAULT_ARRIVAL_TOLERANCE } from "./guards.ts"; export const USDC_BASE: `0x${string}` = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; -export const BRLA_POLYGON: `0x${string}` = brlaMoonbeamTokenDetails.polygonErc20Address; +export const BRLA_POLYGON: `0x${string}` = brlaMoonbeamTokenDetails.polygonErc20Address as `0x${string}`; const NABLA_SWAP_DEADLINE_MINUTES = 60 * 24 * 7; const AMM_MINIMUM_OUTPUT_HARD_MARGIN = 0.05; @@ -621,3 +621,278 @@ export async function verifyFinalUsdcBalanceOnBase(): Promise { return balanceDecimal; } + +// ── Main Nabla route (BRL → USDC on a second Nabla instance on Base) ───────── + +const MAIN_NABLA_QUOTE_ABI = [ + { + inputs: [ + { name: "_amountIn", type: "uint256" }, + { name: "_tokenPath", type: "address[]" }, + { name: "_routerPath", type: "address[]" } + ], + name: "quoteSwapExactTokensForTokens", + outputs: [{ name: "amountOut_", type: "uint256" }], + stateMutability: "view", + type: "function" + } +] as const; + +function getMainNablaConfig() { + const config = getConfig(); + if (!config.mainNablaRouter || !config.mainNablaQuoter) { + throw new Error("Main Nabla route requires MAIN_NABLA_ROUTER and MAIN_NABLA_QUOTER env vars."); + } + return { + brlaToken: ERC20_BRLA_BASE, + quoter: config.mainNablaQuoter, + router: config.mainNablaRouter, + usdcToken: USDC_BASE + }; +} + +/** + * Fetches a quote from the main Nabla instance for BRL→USDC. + * Returns the expected USDC output in raw units (6 decimals assumed for USDC). + */ +export async function fetchMainNablaQuote(brlaAmountRaw: string): Promise { + const { router, quoter, brlaToken, usdcToken } = getMainNablaConfig(); + const evmClientManager = EvmClientManager.getInstance(); + + const expectedOutputRaw = await evmClientManager.readContractWithRetry(Networks.Base, { + abi: MAIN_NABLA_QUOTE_ABI, + address: quoter, + args: [BigInt(brlaAmountRaw), [brlaToken, usdcToken], [router]], + functionName: "quoteSwapExactTokensForTokens" + }); + + const expectedOutputDecimal = multiplyByPowerOfTen(Big(expectedOutputRaw.toString()), -6); + console.log(`Main Nabla quote: ${expectedOutputDecimal.toFixed(6)} USDC`); + return expectedOutputRaw.toString(); +} + +/** + * Quotes the first Nabla swap (USDC→BRLA) to estimate how much BRLA we'd get, + * then quotes all 3 return routes to compare them upfront. + */ +export async function compareRoutesUpfront(usdcAmountRaw: string): Promise<{ + winningRoute: WinningRoute; + estimatedBrlaRaw: string; + squidRouterQuoteUsdc: string | null; + aveniaQuoteUsdc: string | null; + mainNablaQuoteUsdc: string | null; +}> { + console.log("Quoting first Nabla (USDC→BRLA) to estimate BRLA output for route comparison..."); + + const evmClientManager = EvmClientManager.getInstance(); + const quoteAbi = [ + { + inputs: [ + { name: "_amountIn", type: "uint256" }, + { name: "_tokenPath", type: "address[]" }, + { name: "_routerPath", type: "address[]" } + ], + name: "quoteSwapExactTokensForTokens", + outputs: [{ name: "amountOut_", type: "uint256" }], + stateMutability: "view", + type: "function" + } + ] as const; + + const { router, quoter } = getNablaBasePool(USDC_BASE, ERC20_BRLA_BASE); + const estimatedBrlaRaw = await evmClientManager.readContractWithRetry(Networks.Base, { + abi: quoteAbi, + address: quoter, + args: [BigInt(usdcAmountRaw), [USDC_BASE, ERC20_BRLA_BASE], [router]], + functionName: "quoteSwapExactTokensForTokens" + }); + + const estimatedBrlaDecimal = multiplyByPowerOfTen(Big(estimatedBrlaRaw.toString()), -18); + console.log(`Estimated BRLA from first Nabla: ${estimatedBrlaDecimal.toFixed(6)}`); + + // Quote all 3 return routes in parallel + let squidRouterQuoteUsdc: string | null = null; + let aveniaQuoteUsdc: string | null = null; + let mainNablaQuoteUsdc: string | null = null; + + const config = getConfig(); + const mainNablaAvailable = !!(config.mainNablaRouter && config.mainNablaQuoter); + + const results = await Promise.allSettled([ + fetchSquidRouterQuote(estimatedBrlaDecimal), + fetchAveniaQuote(estimatedBrlaDecimal), + mainNablaAvailable ? fetchMainNablaQuote(estimatedBrlaRaw.toString()) : Promise.reject("not configured") + ]); + + if (results[0].status === "fulfilled") { + squidRouterQuoteUsdc = results[0].value; + } else { + console.warn("SquidRouter quote failed:", results[0].reason); + } + + if (results[1].status === "fulfilled") { + aveniaQuoteUsdc = results[1].value; + } else { + console.warn("Avenia quote failed:", results[1].reason); + } + + if (results[2].status === "fulfilled") { + mainNablaQuoteUsdc = results[2].value; + } else if (mainNablaAvailable) { + console.warn("Main Nabla quote failed:", results[2].reason); + } + + // Normalize all quotes to decimal USDC for comparison + const candidates: { route: WinningRoute; usdcDecimal: Big }[] = []; + + if (squidRouterQuoteUsdc) { + candidates.push({ route: "squidrouter", usdcDecimal: multiplyByPowerOfTen(Big(squidRouterQuoteUsdc), -6) }); + } + if (aveniaQuoteUsdc) { + candidates.push({ route: "avenia", usdcDecimal: Big(aveniaQuoteUsdc) }); + } + if (mainNablaQuoteUsdc) { + candidates.push({ route: "nabla-main", usdcDecimal: multiplyByPowerOfTen(Big(mainNablaQuoteUsdc), -6) }); + } + + if (candidates.length === 0) { + throw new Error("All route quotes failed. Cannot proceed."); + } + + candidates.sort((a, b) => (b.usdcDecimal.gt(a.usdcDecimal) ? 1 : -1)); + const winner = candidates[0] as (typeof candidates)[number]; + + console.log("Route comparison results:"); + for (const c of candidates) { + console.log(` ${c.route}: ${c.usdcDecimal.toFixed(6)} USDC ${c.route === winner.route ? "(WINNER)" : ""}`); + } + + return { + aveniaQuoteUsdc, + estimatedBrlaRaw: estimatedBrlaRaw.toString(), + mainNablaQuoteUsdc, + squidRouterQuoteUsdc, + winningRoute: winner.route + }; +} + +/** + * Approve and swap BRL→USDC on the main Nabla instance on Base. + * This is the terminal step for the nabla-main route. + */ +export async function mainNablaApproveAndSwap( + brlaAmountRaw: string, + baseNonce: NonceManager, + state: UsdcBaseRebalanceState, + stateManager: UsdcBaseStateManager +): Promise<{ approveHash: string; swapHash: string; usdcReceivedRaw: string }> { + const { router, brlaToken, usdcToken } = getMainNablaConfig(); + const { walletClient, publicClient } = getBaseEvmClients(); + const executorAddress = walletClient.account.address; + + console.log(`Starting Main Nabla swap of ${brlaAmountRaw} BRLA (raw) to USDC on Base...`); + + if (state.mainNablaApproveHash && state.mainNablaSwapHash) { + console.log("Resuming Main Nabla swap with previously recorded hashes."); + } + + if (!state.mainNablaUsdcBalanceBeforeRaw) { + state.mainNablaUsdcBalanceBeforeRaw = await getUsdcBalanceOnBaseRaw(); + await stateManager.saveState(state); + } + const usdcBalanceBefore = BigInt(state.mainNablaUsdcBalanceBeforeRaw); + + let approveHash = state.mainNablaApproveHash; + let swapHash = state.mainNablaSwapHash; + + if (!swapHash) { + const evmClientManager = EvmClientManager.getInstance(); + const { quoter } = getMainNablaConfig(); + const expectedOutputRaw = await evmClientManager.readContractWithRetry(Networks.Base, { + abi: MAIN_NABLA_QUOTE_ABI, + address: quoter, + args: [BigInt(brlaAmountRaw), [brlaToken, usdcToken], [router]], + functionName: "quoteSwapExactTokensForTokens" + }); + + const nablaHardMinimumOutputRaw = Big(expectedOutputRaw.toString()) + .mul(1 - AMM_MINIMUM_OUTPUT_HARD_MARGIN) + .toFixed(0, 0); + + const { approve, swap } = await createNablaTransactionsForOnrampOnEVM( + brlaAmountRaw, + { address: executorAddress, type: EphemeralAccountType.EVM }, + brlaToken, + usdcToken, + nablaHardMinimumOutputRaw, + NABLA_SWAP_DEADLINE_MINUTES, + router + ); + + if (!approveHash) { + console.log("Sending Main Nabla approve transaction on Base..."); + const { maxFeePerGas: approveFee, maxPriorityFeePerGas: approveTip } = await publicClient.estimateFeesPerGas(); + approveHash = await walletClient.sendTransaction({ + account: walletClient.account, + chain: base, + data: approve.data, + gas: BigInt(approve.gas), + maxFeePerGas: approveFee, + maxPriorityFeePerGas: approveTip, + nonce: baseNonce.next(), + to: approve.to, + value: BigInt(approve.value) + }); + state.mainNablaApproveHash = approveHash; + await stateManager.saveState(state); + console.log(`Main Nabla approve tx sent: ${approveHash}`); + } else { + console.log(`Resuming Main Nabla approval with existing tx: ${approveHash}`); + } + + await waitForTransactionConfirmation(approveHash, publicClient); + console.log("Main Nabla approval confirmed."); + + console.log("Sending Main Nabla swap transaction on Base..."); + const { maxFeePerGas: swapFee, maxPriorityFeePerGas: swapTip } = await publicClient.estimateFeesPerGas(); + swapHash = await walletClient.sendTransaction({ + account: walletClient.account, + chain: base, + data: swap.data, + gas: BigInt(swap.gas), + maxFeePerGas: swapFee, + maxPriorityFeePerGas: swapTip, + nonce: baseNonce.next(), + to: swap.to, + value: BigInt(swap.value) + }); + state.mainNablaSwapHash = swapHash; + await stateManager.saveState(state); + console.log(`Main Nabla swap tx sent: ${swapHash}`); + } else { + console.log(`Resuming Main Nabla swap with existing approve tx: ${approveHash}, swap tx: ${swapHash}`); + } + + if (!approveHash || !swapHash) { + throw new Error("State corrupted: Main Nabla transaction hash missing after swap step."); + } + + await waitForTransactionConfirmation(swapHash, publicClient); + console.log("Main Nabla swap confirmed."); + + // Delay to let the RPC sync post-swap state + await new Promise(resolve => setTimeout(resolve, 5_000)); + + const usdcBalanceAfter = await publicClient.readContract({ + abi: erc20Abi, + address: USDC_BASE, + args: [executorAddress], + functionName: "balanceOf" + }); + + const usdcReceivedRaw = (usdcBalanceAfter - usdcBalanceBefore).toString(); + const usdcReceivedDecimal = multiplyByPowerOfTen(Big(usdcReceivedRaw), -6); + console.log(`Received ${usdcReceivedDecimal.toFixed(6)} USDC from Main Nabla swap.`); + + return { approveHash, swapHash, usdcReceivedRaw }; +} diff --git a/apps/rebalancer/src/services/indexer/index.ts b/apps/rebalancer/src/services/indexer/index.ts index 4f4ff53c8..3ef41f780 100644 --- a/apps/rebalancer/src/services/indexer/index.ts +++ b/apps/rebalancer/src/services/indexer/index.ts @@ -45,9 +45,7 @@ export async function getBaseNablaCoverageRatio(): Promise< })) as `0x${string}`; if (brlaPoolAddress === "0x0000000000000000000000000000000000000000") { - console.error( - `No BRLA pool found on Base Nabla router (${NABLA_ROUTER_BASE_BRLA}) for asset ${ERC20_BRLA_BASE}.` - ); + console.error(`No BRLA pool found on Base Nabla router (${NABLA_ROUTER_BASE_BRLA}) for asset ${ERC20_BRLA_BASE}.`); return undefined; } const [brlaReserve, brlaLiabilities] = await Promise.all([ diff --git a/apps/rebalancer/src/services/stateManager.ts b/apps/rebalancer/src/services/stateManager.ts index 774339eda..63d36ba55 100644 --- a/apps/rebalancer/src/services/stateManager.ts +++ b/apps/rebalancer/src/services/stateManager.ts @@ -153,10 +153,13 @@ export class BrlaToAxlUsdcStateManager { export enum UsdcBaseRebalancePhase { Idle = "idle", CheckInitialUsdcBalance = "checkInitialUsdcBalance", + CompareRates = "compareRates", NablaApprove = "nablaApprove", + // nabla-main route: swap BRL->USDC on main Nabla (ends here) + MainNablaApproveAndSwap = "mainNablaApproveAndSwap", + // avenia/squid routes continue below TransferBrlaToAvenia = "transferBrlaToAvenia", WaitForBrlaOnAvenia = "waitForBrlaOnAvenia", - CompareRates = "compareRates", AveniaTransferToPolygon = "aveniaTransferToPolygon", WaitBrlaOnPolygon = "waitBrlaOnPolygon", SquidRouterApproveAndSwap = "squidRouterApproveAndSwap", @@ -169,20 +172,21 @@ export enum UsdcBaseRebalancePhase { export const usdcBasePhaseOrder: Record = { [UsdcBaseRebalancePhase.Idle]: 0, [UsdcBaseRebalancePhase.CheckInitialUsdcBalance]: 1, - [UsdcBaseRebalancePhase.NablaApprove]: 2, - [UsdcBaseRebalancePhase.TransferBrlaToAvenia]: 3, - [UsdcBaseRebalancePhase.WaitForBrlaOnAvenia]: 4, - [UsdcBaseRebalancePhase.CompareRates]: 5, - [UsdcBaseRebalancePhase.AveniaTransferToPolygon]: 6, - [UsdcBaseRebalancePhase.WaitBrlaOnPolygon]: 7, - [UsdcBaseRebalancePhase.SquidRouterApproveAndSwap]: 8, - [UsdcBaseRebalancePhase.WaitUsdcOnBaseFromSquid]: 9, - [UsdcBaseRebalancePhase.AveniaSwapToUsdcBase]: 6, - [UsdcBaseRebalancePhase.WaitUsdcOnBaseFromAvenia]: 7, - [UsdcBaseRebalancePhase.VerifyFinalBalance]: 10 + [UsdcBaseRebalancePhase.CompareRates]: 2, + [UsdcBaseRebalancePhase.NablaApprove]: 3, + [UsdcBaseRebalancePhase.MainNablaApproveAndSwap]: 4, + [UsdcBaseRebalancePhase.TransferBrlaToAvenia]: 5, + [UsdcBaseRebalancePhase.WaitForBrlaOnAvenia]: 6, + [UsdcBaseRebalancePhase.AveniaTransferToPolygon]: 7, + [UsdcBaseRebalancePhase.WaitBrlaOnPolygon]: 8, + [UsdcBaseRebalancePhase.SquidRouterApproveAndSwap]: 9, + [UsdcBaseRebalancePhase.WaitUsdcOnBaseFromSquid]: 10, + [UsdcBaseRebalancePhase.AveniaSwapToUsdcBase]: 7, + [UsdcBaseRebalancePhase.WaitUsdcOnBaseFromAvenia]: 8, + [UsdcBaseRebalancePhase.VerifyFinalBalance]: 11 }; -export type WinningRoute = "squidrouter" | "avenia" | null; +export type WinningRoute = "squidrouter" | "avenia" | "nabla-main" | null; export interface UsdcBaseRebalanceState { currentPhase: UsdcBaseRebalancePhase; @@ -198,6 +202,10 @@ export interface UsdcBaseRebalanceState { winningRoute: WinningRoute; squidRouterQuoteUsdc: string | null; aveniaQuoteUsdc: string | null; + mainNablaQuoteUsdc: string | null; + mainNablaApproveHash: string | null; + mainNablaSwapHash: string | null; + mainNablaUsdcBalanceBeforeRaw: string | null; polygonBrlaBalanceBeforeTransferRaw: string | null; squidRouterSwapHash: string | null; baseUsdcBalanceBeforeAveniaSwapRaw: string | null; @@ -235,6 +243,10 @@ function createFreshState(): UsdcBaseRebalanceState { currentPhase: UsdcBaseRebalancePhase.Idle, finalUsdcBalance: null, initialUsdcBalance: null, + mainNablaApproveHash: null, + mainNablaQuoteUsdc: null, + mainNablaSwapHash: null, + mainNablaUsdcBalanceBeforeRaw: null, nablaApproveHash: null, nablaSwapHash: null, polygonBrlaBalanceBeforeTransferRaw: null, @@ -312,6 +324,10 @@ export class UsdcBaseStateManager { currentPhase: UsdcBaseRebalancePhase.CheckInitialUsdcBalance, finalUsdcBalance: null, initialUsdcBalance: null, + mainNablaApproveHash: null, + mainNablaQuoteUsdc: null, + mainNablaSwapHash: null, + mainNablaUsdcBalanceBeforeRaw: null, nablaApproveHash: null, nablaSwapHash: null, polygonBrlaBalanceBeforeTransferRaw: null, diff --git a/apps/rebalancer/src/utils/config.ts b/apps/rebalancer/src/utils/config.ts index 090fc0fc4..bdde43c88 100644 --- a/apps/rebalancer/src/utils/config.ts +++ b/apps/rebalancer/src/utils/config.ts @@ -17,11 +17,15 @@ export function getConfig() { ? Number(process.env.INDEXER_FRESHNESS_THRESHOLD_MINUTES) : 5, + // Main Nabla instance on Base + mainNablaQuoter: process.env.MAIN_NABLA_QUOTER as `0x${string}` | undefined, + mainNablaRouter: process.env.MAIN_NABLA_ROUTER as `0x${string}` | undefined, + pendulumAccountSecret: process.env.PENDULUM_ACCOUNT_SECRET, rebalancingDailyBridgeLimitUsd: Number(process.env.REBALANCING_DAILY_BRIDGE_LIMIT_USD) || 10_000, /// The threshold above and below the optimal coverage ratio at which the rebalancing will be triggered. - rebalancingThreshold: Number(process.env.REBALANCING_THRESHOLD) || 0.25, + rebalancingThreshold: Number(process.env.REBALANCING_THRESHOLD) || 0.01, /// The amount in USD to rebalance from the USD pool to the BRL pool on Pendulum during each execution. rebalancingUsdToBrlAmount: process.env.REBALANCING_USD_TO_BRL_AMOUNT || "1", /// The minimum balance in USD that the rebalancer account on Pendulum must have to allow rebalancing to occur. From 6d4a92d7ebc15f03b2a24c138951757e0b2c7e98 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Thu, 4 Jun 2026 16:29:25 -0300 Subject: [PATCH 022/131] add brla-usdc rebalance direction --- apps/rebalancer/src/index.ts | 121 ++++-- .../src/rebalance/brla-to-usdc-base/guards.ts | 5 + .../src/rebalance/brla-to-usdc-base/index.ts | 110 ++++++ .../brla-to-usdc-base/notifications.ts | 27 ++ .../src/rebalance/brla-to-usdc-base/steps.ts | 364 ++++++++++++++++++ apps/rebalancer/src/services/stateManager.ts | 106 +++++ apps/rebalancer/src/utils/config.ts | 4 + 7 files changed, 703 insertions(+), 34 deletions(-) create mode 100644 apps/rebalancer/src/rebalance/brla-to-usdc-base/guards.ts create mode 100644 apps/rebalancer/src/rebalance/brla-to-usdc-base/index.ts create mode 100644 apps/rebalancer/src/rebalance/brla-to-usdc-base/notifications.ts create mode 100644 apps/rebalancer/src/rebalance/brla-to-usdc-base/steps.ts diff --git a/apps/rebalancer/src/index.ts b/apps/rebalancer/src/index.ts index 628c83945..c78dd43e7 100644 --- a/apps/rebalancer/src/index.ts +++ b/apps/rebalancer/src/index.ts @@ -3,12 +3,16 @@ import { multiplyByPowerOfTen } from "@vortexfi/shared"; import Big from "big.js"; import { rebalanceBrlaToUsdcAxl } from "./rebalance/brla-to-axlusdc"; import { checkInitialPendulumBalance } from "./rebalance/brla-to-axlusdc/steps.ts"; +import { rebalanceBrlaToUsdcBase } from "./rebalance/brla-to-usdc-base"; +import { checkInitialBrlaBalanceOnBase } from "./rebalance/brla-to-usdc-base/steps.ts"; import { rebalanceUsdcBrlaUsdcBase } from "./rebalance/usdc-brla-usdc-base"; import { wouldExceedDailyBridgeLimit } from "./rebalance/usdc-brla-usdc-base/guards.ts"; import { checkInitialUsdcBalanceOnBase } from "./rebalance/usdc-brla-usdc-base/steps.ts"; import { getBaseNablaCoverageRatio, getSwapPoolsWithCoverageRatio } from "./services/indexer"; import { BrlaToAxlUsdcStateManager, + BrlaToUsdcBaseRebalancePhase, + BrlaToUsdcBaseStateManager, RebalancePhase, UsdcBaseRebalancePhase, UsdcBaseStateManager @@ -73,56 +77,105 @@ async function checkForRebalancingLegacy() { await rebalanceBrlaToUsdcAxl(amountAxlUsdc, forceRestart); } -async function checkForRebalancing() { - const config = getConfig(); - const amountUsdc = manualAmount || config.rebalancingUsdToBrlAmount; - const amountUsdcRaw = multiplyByPowerOfTen(new Big(amountUsdc), 6).toFixed(0, 0); +async function getTodayBridgedUsdRaw(): Promise { + const usdcStateManager = new UsdcBaseStateManager(); + const brlaStateManager = new BrlaToUsdcBaseStateManager(); - if (forceRestart) { - console.log("Force restart enabled. Starting rebalancing regardless of coverage ratios."); - } else { - const coverage = await getBaseNablaCoverageRatio(); - if (!coverage) { - throw new Error("Failed to fetch Base Nabla coverage ratio."); - } + const [usdcHistory, brlaHistory] = await Promise.all([usdcStateManager.getHistory(), brlaStateManager.getHistory()]); - if (coverage.brlaCoverageRatio >= 0 + config.rebalancingThreshold) { - console.log(`Base Nabla BRLA coverage ratio ${coverage.brlaCoverageRatio} requires rebalancing.`); - } else { - console.log(`Base Nabla BRLA coverage ratio ${coverage.brlaCoverageRatio} does not require rebalancing.`); - return; - } + const todayStart = new Date(); + todayStart.setUTCHours(0, 0, 0, 0); + + return [...usdcHistory, ...brlaHistory] + .filter(e => new Date(e.startingTime) >= todayStart) + .reduce((sum, e) => sum.plus(Big(e.initialAmount)), Big(0)); +} + +function checkDailyLimit(bridgedToday: Big, requestedAmountRaw: Big, dailyLimitRaw: Big, dailyLimitUsd: number): boolean { + if (wouldExceedDailyBridgeLimit(bridgedToday, requestedAmountRaw, dailyLimitRaw)) { + const projectedTotal = bridgedToday.plus(requestedAmountRaw); + console.log( + `Daily bridge limit reached: projected $${projectedTotal.div(1e6).toFixed(2)}, limit $${dailyLimitUsd}. Skipping.` + ); + return true; } + return false; +} + +async function runUsdcToBrla(bridgedToday: Big, dailyLimitRaw: Big) { + const config = getConfig(); + const amountUsdc = manualAmount || config.rebalancingUsdToBrlAmount; + const amountUsdcRaw = multiplyByPowerOfTen(new Big(amountUsdc), 6).toFixed(0, 0); const stateManager = new UsdcBaseStateManager(); const state = await stateManager.getState(); const isResuming = !forceRestart && state && state.currentPhase !== UsdcBaseRebalancePhase.Idle; if (!isResuming) { - const history = await stateManager.getHistory(); - const todayStart = new Date(); - todayStart.setUTCHours(0, 0, 0, 0); + if (checkDailyLimit(bridgedToday, Big(amountUsdcRaw), dailyLimitRaw, config.rebalancingDailyBridgeLimitUsd)) return; + await checkInitialUsdcBalanceOnBase(amountUsdcRaw); + } - const bridgedToday = history - .filter(e => new Date(e.startingTime) >= todayStart) - .reduce((sum, e) => sum.plus(Big(e.initialAmount)), Big(0)); + await rebalanceUsdcBrlaUsdcBase(amountUsdcRaw, forceRestart, forcedRoute); +} - const dailyLimitRaw = multiplyByPowerOfTen(Big(config.rebalancingDailyBridgeLimitUsd), 6); - console.log( - `Bridged $${bridgedToday.div(1e6).toFixed(2)} today. Daily bridge limit is $${config.rebalancingDailyBridgeLimitUsd}.` - ); - if (wouldExceedDailyBridgeLimit(bridgedToday, Big(amountUsdcRaw), dailyLimitRaw)) { - const projectedTotal = bridgedToday.plus(amountUsdcRaw); - console.log( - `Daily bridge limit reached: projected $${projectedTotal.div(1e6).toFixed(2)} today, limit is $${config.rebalancingDailyBridgeLimitUsd}. Skipping.` +async function runBrlaToUsdc(bridgedToday: Big, dailyLimitRaw: Big) { + const config = getConfig(); + const amountBrla = manualAmount || config.rebalancingBrlToUsdAmount; + const amountBrlaRaw = multiplyByPowerOfTen(new Big(amountBrla), 18).toFixed(0, 0); + + const stateManager = new BrlaToUsdcBaseStateManager(); + const state = await stateManager.getState(); + const isResuming = !forceRestart && state && state.currentPhase !== BrlaToUsdcBaseRebalancePhase.Idle; + + if (!isResuming) { + const estimatedUsdcRaw = multiplyByPowerOfTen(new Big(amountBrla), 6).toFixed(0, 0); + if (checkDailyLimit(bridgedToday, Big(estimatedUsdcRaw), dailyLimitRaw, config.rebalancingDailyBridgeLimitUsd)) return; + + const rebalancerBrlaBalance = await checkInitialBrlaBalanceOnBase(amountBrlaRaw); + if (config.rebalancingBrlToUsdMinBalance && rebalancerBrlaBalance.lt(config.rebalancingBrlToUsdMinBalance)) { + throw new Error( + `Rebalancer BRLA balance ${rebalancerBrlaBalance} is below the minimum required balance of ${config.rebalancingBrlToUsdMinBalance} to perform BRLA->USDC rebalancing.` ); - return; } + } - await checkInitialUsdcBalanceOnBase(amountUsdcRaw); + await rebalanceBrlaToUsdcBase(amountBrlaRaw, forceRestart); +} + +async function checkForRebalancing() { + const config = getConfig(); + const coverage = await getBaseNablaCoverageRatio(); + + if (!coverage && !forceRestart) { + throw new Error("Failed to fetch Base Nabla coverage ratio."); } - await rebalanceUsdcBrlaUsdcBase(amountUsdcRaw, forceRestart, forcedRoute); + if (!forceRestart && coverage) { + const inRange = + coverage.brlaCoverageRatio >= config.rebalancingThreshold && + coverage.brlaCoverageRatio >= 1 - config.rebalancingThreshold; + if (inRange) { + console.log(`BRLA coverage ${coverage.brlaCoverageRatio} in range. No rebalancing needed.`); + return; + } + } else if (forceRestart) { + console.log("Force restart enabled."); + } + + const bridgedToday = await getTodayBridgedUsdRaw(); + const dailyLimitRaw = multiplyByPowerOfTen(Big(config.rebalancingDailyBridgeLimitUsd), 6); + console.log( + `Bridged $${bridgedToday.div(1e6).toFixed(2)} today. Daily bridge limit is $${config.rebalancingDailyBridgeLimitUsd}.` + ); + + if (coverage && coverage.brlaCoverageRatio < 1 - config.rebalancingThreshold) { + console.log(`BRLA coverage ${coverage.brlaCoverageRatio} < ${1 - config.rebalancingThreshold}. Running BRLA->USDC.`); + await runBrlaToUsdc(bridgedToday, dailyLimitRaw); + } else { + console.log("Running USDC->BRLA->USDC flow."); + await runUsdcToBrla(bridgedToday, dailyLimitRaw); + } } const rebalanceFn = useLegacy ? checkForRebalancingLegacy : checkForRebalancing; diff --git a/apps/rebalancer/src/rebalance/brla-to-usdc-base/guards.ts b/apps/rebalancer/src/rebalance/brla-to-usdc-base/guards.ts new file mode 100644 index 000000000..d71de3cf3 --- /dev/null +++ b/apps/rebalancer/src/rebalance/brla-to-usdc-base/guards.ts @@ -0,0 +1,5 @@ +import Big from "big.js"; + +export function wouldExceedDailySwapLimit(swappedTodayRaw: Big, requestedAmountRaw: Big, dailyLimitRaw: Big): boolean { + return swappedTodayRaw.plus(requestedAmountRaw).gt(dailyLimitRaw); +} diff --git a/apps/rebalancer/src/rebalance/brla-to-usdc-base/index.ts b/apps/rebalancer/src/rebalance/brla-to-usdc-base/index.ts new file mode 100644 index 000000000..6d52d25d8 --- /dev/null +++ b/apps/rebalancer/src/rebalance/brla-to-usdc-base/index.ts @@ -0,0 +1,110 @@ +import { multiplyByPowerOfTen, SlackNotifier } from "@vortexfi/shared"; +import Big from "big.js"; +import { + BrlaToUsdcBaseRebalancePhase, + BrlaToUsdcBaseStateManager, + brlaToUsdcBasePhaseOrder +} from "../../services/stateManager.ts"; +import { getBaseEvmClients } from "../../utils/config.ts"; +import { NonceManager } from "../../utils/nonce.ts"; +import { formatBrlaToUsdcBaseCompletionMessage } from "./notifications.ts"; +import { + checkInitialBrlaBalanceOnBase, + mainNablaSwapUsdcToBrlaOnBase, + nablaSwapBrlaToUsdcOnBase, + verifyFinalUsdcBalanceOnBase +} from "./steps.ts"; + +export async function rebalanceBrlaToUsdcBase(brlaAmountRaw: string, forceRestart = false) { + console.log(`Starting BRLA->USDC rebalance on Base with amount: ${brlaAmountRaw} (raw BRLA)`); + + const stateManager = new BrlaToUsdcBaseStateManager(); + let state = await stateManager.getState(); + console.log("Fetched rebalance state from storage.", state); + + const isResuming = !forceRestart && state && state.currentPhase !== BrlaToUsdcBaseRebalancePhase.Idle; + if (isResuming) { + console.log(`Resuming rebalance from phase: ${state?.currentPhase}`); + } else { + state = await stateManager.startNewRebalance(brlaAmountRaw); + } + + if (!state) { + throw new Error("State is undefined after initialization."); + } + + const { publicClient: basePublicClient, walletClient: baseWalletClient } = getBaseEvmClients(); + const baseAddress = baseWalletClient.account.address; + const baseNonce = await NonceManager.create(basePublicClient, baseAddress); + + const currentOrder = brlaToUsdcBasePhaseOrder[state.currentPhase]; + console.log(`Current phase order: ${currentOrder}`); + + if (currentOrder <= brlaToUsdcBasePhaseOrder[BrlaToUsdcBaseRebalancePhase.CheckInitialBrlaBalance]) { + if (!state.brlaAmountRaw) throw new Error("State corrupted: brlaAmountRaw missing for step 1"); + + const initialBalance = await checkInitialBrlaBalanceOnBase(state.brlaAmountRaw); + state.initialBrlaBalance = initialBalance.toString(); + state.currentPhase = BrlaToUsdcBaseRebalancePhase.NablaSwapBrlaToUsdc; + await stateManager.saveState(state); + } + + if (currentOrder <= brlaToUsdcBasePhaseOrder[BrlaToUsdcBaseRebalancePhase.NablaSwapBrlaToUsdc]) { + if (!state.brlaAmountRaw) throw new Error("State corrupted: brlaAmountRaw missing for step 2"); + + await nablaSwapBrlaToUsdcOnBase(state.brlaAmountRaw, baseNonce, state, stateManager); + + state.currentPhase = BrlaToUsdcBaseRebalancePhase.MainNablaSwapUsdcToBrla; + await stateManager.saveState(state); + } + + if (currentOrder <= brlaToUsdcBasePhaseOrder[BrlaToUsdcBaseRebalancePhase.MainNablaSwapUsdcToBrla]) { + if (!state.usdcReceivedRaw) throw new Error("State corrupted: usdcReceivedRaw missing for main nabla step"); + + await mainNablaSwapUsdcToBrlaOnBase(state.usdcReceivedRaw, baseNonce, state, stateManager); + + state.currentPhase = BrlaToUsdcBaseRebalancePhase.VerifyFinalBalance; + await stateManager.saveState(state); + } + + if (currentOrder <= brlaToUsdcBasePhaseOrder[BrlaToUsdcBaseRebalancePhase.VerifyFinalBalance]) { + const finalBalance = await verifyFinalUsdcBalanceOnBase(); + state.finalUsdcBalance = finalBalance.toString(); + + state.currentPhase = BrlaToUsdcBaseRebalancePhase.Idle; + await stateManager.saveState(state); + } + + if (!state.brlaAmountRaw) throw new Error("State corrupted: brlaAmountRaw missing at completion"); + if (!state.usdcReceivedRaw) throw new Error("State corrupted: usdcReceivedRaw missing at completion"); + if (!state.mainNablaBrlaReceivedRaw) throw new Error("State corrupted: mainNablaBrlaReceivedRaw missing at completion"); + + const brlaIn = multiplyByPowerOfTen(Big(state.brlaAmountRaw), -18); + const usdcIntermediate = multiplyByPowerOfTen(Big(state.usdcReceivedRaw), -6); + const brlaOut = multiplyByPowerOfTen(Big(state.mainNablaBrlaReceivedRaw), -18); + const cost = brlaIn.minus(brlaOut); + const costRelative = brlaIn.gt(0) ? cost.div(brlaIn).toFixed(4, 0) : "N/A"; + + console.log( + `Rebalance completed! BRLA in: ${brlaIn.toFixed(6)}, USDC intermediate: ${usdcIntermediate.toFixed(6)}, BRLA out: ${brlaOut.toFixed(6)}` + ); + console.log(`Cost: absolute: ${cost.toFixed(6)} BRLA | relative: ${costRelative}`); + + await stateManager.addHistoryEntry({ + cost: cost.toFixed(6), + costRelative, + endingTime: new Date().toISOString(), + initialAmount: state.brlaAmountRaw, + startingTime: state.startingTime + }); + + const slackNotifier = new SlackNotifier(process.env.SLACK_WEB_HOOK_TOKEN); + await slackNotifier.sendMessage({ + text: formatBrlaToUsdcBaseCompletionMessage({ + brlaIn, + brlaOut, + cost, + usdcIntermediate + }) + }); +} diff --git a/apps/rebalancer/src/rebalance/brla-to-usdc-base/notifications.ts b/apps/rebalancer/src/rebalance/brla-to-usdc-base/notifications.ts new file mode 100644 index 000000000..f66d7b389 --- /dev/null +++ b/apps/rebalancer/src/rebalance/brla-to-usdc-base/notifications.ts @@ -0,0 +1,27 @@ +import Big from "big.js"; + +interface BrlaToUsdcBaseCompletionMessageParams { + brlaIn: Big; + brlaOut: Big; + cost: Big; + usdcIntermediate: Big; +} + +export function formatBrlaToUsdcBaseCompletionMessage(params: BrlaToUsdcBaseCompletionMessageParams): string { + const costPercent = params.brlaIn.gt(0) ? params.cost.div(params.brlaIn).mul(100).toFixed(2) : "N/A"; + + return [ + "\u2705 *Base rebalancer completed (BRLA Nabla \u2192 Main Nabla)*", + "BRLA \u2192 USDC (BRLA Nabla) \u2192 BRLA (Main Nabla)", + "", + "*Execution*", + "- \uD83D\uDEE3\uFE0F Route: BRLA Nabla + Main Nabla", + `- \uD83D\uDCB5 Requested amount: \`${params.brlaIn.toFixed(6)} BRLA\``, + `- \uD83E\uDD9A USDC intermediate: \`${params.usdcIntermediate.toFixed(6)} USDC\``, + `- \uD83E\uDD9A BRLA out: \`${params.brlaOut.toFixed(6)} BRLA\``, + "", + "*Cost*", + `- \uD83D\uDCC9 Net BRLA cost: \`${params.cost.toFixed(6)} BRLA\``, + `- \uD83D\uDCCA Cost/requested amount: \`${costPercent === "N/A" ? costPercent : `${costPercent}%`}\`` + ].join("\n"); +} diff --git a/apps/rebalancer/src/rebalance/brla-to-usdc-base/steps.ts b/apps/rebalancer/src/rebalance/brla-to-usdc-base/steps.ts new file mode 100644 index 000000000..ff4f8d92c --- /dev/null +++ b/apps/rebalancer/src/rebalance/brla-to-usdc-base/steps.ts @@ -0,0 +1,364 @@ +import { + createNablaTransactionsForOnrampOnEVM, + EphemeralAccountType, + ERC20_BRLA_BASE, + EvmClientManager, + multiplyByPowerOfTen, + NABLA_QUOTER_BASE_BRLA, + NABLA_ROUTER_BASE_BRLA, + Networks +} from "@vortexfi/shared"; +import Big from "big.js"; +import { erc20Abi } from "viem"; +import { base } from "viem/chains"; +import { BrlaToUsdcBaseRebalanceState, BrlaToUsdcBaseStateManager } from "../../services/stateManager.ts"; +import { getBaseEvmClients, getConfig } from "../../utils/config.ts"; +import { NonceManager } from "../../utils/nonce.ts"; +import { waitForTransactionConfirmation } from "../../utils/transactions.ts"; + +export const USDC_BASE: `0x${string}` = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; + +const NABLA_SWAP_DEADLINE_MINUTES = 60 * 24 * 7; +const AMM_MINIMUM_OUTPUT_HARD_MARGIN = 0.05; + +const NABLA_QUOTE_ABI = [ + { + inputs: [ + { name: "_amountIn", type: "uint256" }, + { name: "_tokenPath", type: "address[]" }, + { name: "_routerPath", type: "address[]" } + ], + name: "quoteSwapExactTokensForTokens", + outputs: [{ name: "amountOut_", type: "uint256" }], + stateMutability: "view", + type: "function" + } +] as const; + +// BRLA→USDC swap uses the BRLA Nabla pool (not the main pool) +const BRLA_NABLA_ROUTER = NABLA_ROUTER_BASE_BRLA; +const BRLA_NABLA_QUOTER = NABLA_QUOTER_BASE_BRLA; + +export async function getUsdcBalanceOnBaseRaw(): Promise { + const { publicClient, walletClient } = getBaseEvmClients(); + const balance = await publicClient.readContract({ + abi: erc20Abi, + address: USDC_BASE, + args: [walletClient.account.address], + functionName: "balanceOf" + }); + return balance.toString(); +} + +export async function getBrlaBalanceOnBaseRaw(): Promise { + const { publicClient, walletClient } = getBaseEvmClients(); + const balance = await publicClient.readContract({ + abi: erc20Abi, + address: ERC20_BRLA_BASE, + args: [walletClient.account.address], + functionName: "balanceOf" + }); + return balance.toString(); +} + +export async function checkInitialBrlaBalanceOnBase(brlaAmountRaw: string): Promise { + const { walletClient } = getBaseEvmClients(); + const address = walletClient.account.address; + + const balanceRaw = await getBrlaBalanceOnBaseRaw(); + const balanceDecimal = multiplyByPowerOfTen(Big(balanceRaw), -18); + console.log(`Current BRLA balance on Base (${address}): ${balanceDecimal.toFixed(6)} BRLA`); + + const requiredAmount = multiplyByPowerOfTen(Big(brlaAmountRaw), -18); + if (balanceDecimal.lt(requiredAmount)) { + throw new Error(`Insufficient BRLA on Base. Have: ${balanceDecimal.toFixed(6)}, need: ${requiredAmount.toFixed(6)}`); + } + + return balanceDecimal; +} + +export async function nablaSwapBrlaToUsdcOnBase( + brlaAmountRaw: string, + baseNonce: NonceManager, + state: BrlaToUsdcBaseRebalanceState, + stateManager: BrlaToUsdcBaseStateManager +): Promise { + const { walletClient, publicClient } = getBaseEvmClients(); + const executorAddress = walletClient.account.address; + + console.log(`Starting BRLA Nabla swap of ${brlaAmountRaw} BRLA (raw) to USDC on Base...`); + + if (state.nablaApproveHash && state.nablaSwapHash && state.usdcReceivedRaw) { + console.log("Resuming BRLA Nabla swap with previously recorded hashes."); + return state.usdcReceivedRaw; + } + + if (state.nablaSwapHash && !state.usdcBalanceBeforeNablaRaw) { + throw new Error("State corrupted: missing pre-Nabla USDC balance baseline for completed swap."); + } + + if (!state.usdcBalanceBeforeNablaRaw) { + state.usdcBalanceBeforeNablaRaw = await getUsdcBalanceOnBaseRaw(); + await stateManager.saveState(state); + } + const usdcBalanceBefore = BigInt(state.usdcBalanceBeforeNablaRaw); + + let approveHash = state.nablaApproveHash; + let swapHash = state.nablaSwapHash; + + if (!swapHash) { + const evmClientManager = EvmClientManager.getInstance(); + const expectedOutputRaw = await evmClientManager.readContractWithRetry(Networks.Base, { + abi: NABLA_QUOTE_ABI, + address: BRLA_NABLA_QUOTER, + args: [BigInt(brlaAmountRaw), [ERC20_BRLA_BASE, USDC_BASE], [BRLA_NABLA_ROUTER]], + functionName: "quoteSwapExactTokensForTokens" + }); + + const expectedOutputDecimal = multiplyByPowerOfTen(Big(expectedOutputRaw.toString()), -6); + console.log(`Expected USDC output: ${expectedOutputDecimal.toFixed(6)}`); + + const nablaHardMinimumOutputRaw = Big(expectedOutputRaw.toString()) + .mul(1 - AMM_MINIMUM_OUTPUT_HARD_MARGIN) + .toFixed(0, 0); + + const { approve, swap } = await createNablaTransactionsForOnrampOnEVM( + brlaAmountRaw, + { address: executorAddress, type: EphemeralAccountType.EVM }, + ERC20_BRLA_BASE, + USDC_BASE, + nablaHardMinimumOutputRaw, + NABLA_SWAP_DEADLINE_MINUTES, + BRLA_NABLA_ROUTER + ); + + if (!approveHash) { + console.log("Sending BRLA Nabla approve transaction on Base..."); + const { maxFeePerGas: approveFee, maxPriorityFeePerGas: approveTip } = await publicClient.estimateFeesPerGas(); + approveHash = await walletClient.sendTransaction({ + account: walletClient.account, + chain: base, + data: approve.data, + gas: BigInt(approve.gas), + maxFeePerGas: approveFee, + maxPriorityFeePerGas: approveTip, + nonce: baseNonce.next(), + to: approve.to, + value: BigInt(approve.value) + }); + state.nablaApproveHash = approveHash; + await stateManager.saveState(state); + console.log(`Approve tx sent: ${approveHash}`); + } else { + console.log(`Resuming BRLA Nabla approval with existing tx: ${approveHash}`); + } + + await waitForTransactionConfirmation(approveHash, publicClient); + console.log("BRLA Nabla approval confirmed."); + + console.log("Sending BRLA Nabla swap transaction on Base..."); + const { maxFeePerGas: swapFee, maxPriorityFeePerGas: swapTip } = await publicClient.estimateFeesPerGas(); + swapHash = await walletClient.sendTransaction({ + account: walletClient.account, + chain: base, + data: swap.data, + gas: BigInt(swap.gas), + maxFeePerGas: swapFee, + maxPriorityFeePerGas: swapTip, + nonce: baseNonce.next(), + to: swap.to, + value: BigInt(swap.value) + }); + state.nablaSwapHash = swapHash; + await stateManager.saveState(state); + console.log(`Swap tx sent: ${swapHash}`); + } else { + console.log(`Resuming BRLA Nabla swap with existing approve tx: ${approveHash}, swap tx: ${swapHash}`); + } + + if (!approveHash || !swapHash) { + throw new Error("State corrupted: BRLA Nabla transaction hash missing after swap step."); + } + + await waitForTransactionConfirmation(swapHash, publicClient); + console.log("BRLA Nabla swap confirmed."); + + await new Promise(resolve => setTimeout(resolve, 5_000)); + + const usdcBalanceAfterRaw = await getUsdcBalanceOnBaseRaw(); + const usdcBalanceAfter = BigInt(usdcBalanceAfterRaw); + const usdcReceivedRaw = (usdcBalanceAfter - usdcBalanceBefore).toString(); + + if (BigInt(usdcReceivedRaw) <= 0n) { + throw new Error(`No USDC delta detected after BRLA Nabla swap (pre: ${usdcBalanceBefore}, post: ${usdcBalanceAfter}).`); + } + + const usdcReceivedDecimal = multiplyByPowerOfTen(Big(usdcReceivedRaw), -6); + console.log(`Received ${usdcReceivedDecimal.toFixed(6)} USDC from BRLA Nabla swap.`); + + state.usdcReceivedRaw = usdcReceivedRaw; + await stateManager.saveState(state); + + return usdcReceivedRaw; +} + +export async function verifyFinalUsdcBalanceOnBase(): Promise { + const { walletClient } = getBaseEvmClients(); + const balanceRaw = await getUsdcBalanceOnBaseRaw(); + const balanceDecimal = multiplyByPowerOfTen(Big(balanceRaw), -6); + console.log(`Final USDC balance on Base (${walletClient.account.address}): ${balanceDecimal.toFixed(6)} USDC`); + return balanceDecimal; +} + +// ── Main Nabla: USDC → BRLA swap (closes the rebalancing loop) ────────────── + +const MAIN_NABLA_QUOTE_ABI = [ + { + inputs: [ + { name: "_amountIn", type: "uint256" }, + { name: "_tokenPath", type: "address[]" }, + { name: "_routerPath", type: "address[]" } + ], + name: "quoteSwapExactTokensForTokens", + outputs: [{ name: "amountOut_", type: "uint256" }], + stateMutability: "view", + type: "function" + } +] as const; + +function getMainNablaConfig() { + const config = getConfig(); + if (!config.mainNablaRouter || !config.mainNablaQuoter) { + throw new Error("Main Nabla route requires MAIN_NABLA_ROUTER and MAIN_NABLA_QUOTER env vars."); + } + return { + quoter: config.mainNablaQuoter, + router: config.mainNablaRouter + }; +} + +export async function mainNablaSwapUsdcToBrlaOnBase( + usdcAmountRaw: string, + baseNonce: NonceManager, + state: BrlaToUsdcBaseRebalanceState, + stateManager: BrlaToUsdcBaseStateManager +): Promise { + const { router, quoter } = getMainNablaConfig(); + const { walletClient, publicClient } = getBaseEvmClients(); + const executorAddress = walletClient.account.address; + + console.log(`Starting Main Nabla swap of ${usdcAmountRaw} USDC (raw) to BRLA on Base...`); + + if (state.mainNablaApproveHash && state.mainNablaSwapHash && state.mainNablaBrlaReceivedRaw) { + console.log("Resuming Main Nabla swap with previously recorded hashes."); + return state.mainNablaBrlaReceivedRaw; + } + + if (state.mainNablaSwapHash && !state.mainNablaBrlaBalanceBeforeRaw) { + throw new Error("State corrupted: missing pre-Main-Nabla BRLA balance baseline for completed swap."); + } + + if (!state.mainNablaBrlaBalanceBeforeRaw) { + state.mainNablaBrlaBalanceBeforeRaw = await getBrlaBalanceOnBaseRaw(); + await stateManager.saveState(state); + } + const brlaBalanceBefore = BigInt(state.mainNablaBrlaBalanceBeforeRaw); + + let approveHash = state.mainNablaApproveHash; + let swapHash = state.mainNablaSwapHash; + + if (!swapHash) { + const evmClientManager = EvmClientManager.getInstance(); + const expectedOutputRaw = await evmClientManager.readContractWithRetry(Networks.Base, { + abi: MAIN_NABLA_QUOTE_ABI, + address: quoter, + args: [BigInt(usdcAmountRaw), [USDC_BASE, ERC20_BRLA_BASE], [router]], + functionName: "quoteSwapExactTokensForTokens" + }); + + const expectedOutputDecimal = multiplyByPowerOfTen(Big(expectedOutputRaw.toString()), -18); + console.log(`Expected BRLA output from Main Nabla: ${expectedOutputDecimal.toFixed(6)}`); + + const nablaHardMinimumOutputRaw = Big(expectedOutputRaw.toString()) + .mul(1 - AMM_MINIMUM_OUTPUT_HARD_MARGIN) + .toFixed(0, 0); + + const { approve, swap } = await createNablaTransactionsForOnrampOnEVM( + usdcAmountRaw, + { address: executorAddress, type: EphemeralAccountType.EVM }, + USDC_BASE, + ERC20_BRLA_BASE, + nablaHardMinimumOutputRaw, + NABLA_SWAP_DEADLINE_MINUTES, + router + ); + + if (!approveHash) { + console.log("Sending Main Nabla approve transaction on Base..."); + const { maxFeePerGas: approveFee, maxPriorityFeePerGas: approveTip } = await publicClient.estimateFeesPerGas(); + approveHash = await walletClient.sendTransaction({ + account: walletClient.account, + chain: base, + data: approve.data, + gas: BigInt(approve.gas), + maxFeePerGas: approveFee, + maxPriorityFeePerGas: approveTip, + nonce: baseNonce.next(), + to: approve.to, + value: BigInt(approve.value) + }); + state.mainNablaApproveHash = approveHash; + await stateManager.saveState(state); + console.log(`Main Nabla approve tx sent: ${approveHash}`); + } else { + console.log(`Resuming Main Nabla approval with existing tx: ${approveHash}`); + } + + await waitForTransactionConfirmation(approveHash, publicClient); + console.log("Main Nabla approval confirmed."); + + console.log("Sending Main Nabla swap transaction on Base..."); + const { maxFeePerGas: swapFee, maxPriorityFeePerGas: swapTip } = await publicClient.estimateFeesPerGas(); + swapHash = await walletClient.sendTransaction({ + account: walletClient.account, + chain: base, + data: swap.data, + gas: BigInt(swap.gas), + maxFeePerGas: swapFee, + maxPriorityFeePerGas: swapTip, + nonce: baseNonce.next(), + to: swap.to, + value: BigInt(swap.value) + }); + state.mainNablaSwapHash = swapHash; + await stateManager.saveState(state); + console.log(`Main Nabla swap tx sent: ${swapHash}`); + } else { + console.log(`Resuming Main Nabla swap with existing approve tx: ${approveHash}, swap tx: ${swapHash}`); + } + + if (!approveHash || !swapHash) { + throw new Error("State corrupted: Main Nabla transaction hash missing after swap step."); + } + + await waitForTransactionConfirmation(swapHash, publicClient); + console.log("Main Nabla swap confirmed."); + + await new Promise(resolve => setTimeout(resolve, 5_000)); + + const brlaBalanceAfterRaw = await getBrlaBalanceOnBaseRaw(); + const brlaBalanceAfter = BigInt(brlaBalanceAfterRaw); + const brlaReceivedRaw = (brlaBalanceAfter - brlaBalanceBefore).toString(); + + if (BigInt(brlaReceivedRaw) <= 0n) { + throw new Error(`No BRLA delta detected after Main Nabla swap (pre: ${brlaBalanceBefore}, post: ${brlaBalanceAfter}).`); + } + + const brlaReceivedDecimal = multiplyByPowerOfTen(Big(brlaReceivedRaw), -18); + console.log(`Received ${brlaReceivedDecimal.toFixed(6)} BRLA from Main Nabla swap.`); + + state.mainNablaBrlaReceivedRaw = brlaReceivedRaw; + await stateManager.saveState(state); + + return brlaReceivedRaw; +} diff --git a/apps/rebalancer/src/services/stateManager.ts b/apps/rebalancer/src/services/stateManager.ts index 63d36ba55..aefcd1f43 100644 --- a/apps/rebalancer/src/services/stateManager.ts +++ b/apps/rebalancer/src/services/stateManager.ts @@ -342,3 +342,109 @@ export class UsdcBaseStateManager { return state; } } + +// --- BRLA->USDC (Base) rebalance flow --- + +export enum BrlaToUsdcBaseRebalancePhase { + Idle = "idle", + CheckInitialBrlaBalance = "checkInitialBrlaBalance", + NablaSwapBrlaToUsdc = "nablaSwapBrlaToUsdc", + MainNablaSwapUsdcToBrla = "mainNablaSwapUsdcToBrla", + VerifyFinalBalance = "verifyFinalBalance" +} + +export const brlaToUsdcBasePhaseOrder: Record = { + [BrlaToUsdcBaseRebalancePhase.Idle]: 0, + [BrlaToUsdcBaseRebalancePhase.CheckInitialBrlaBalance]: 1, + [BrlaToUsdcBaseRebalancePhase.NablaSwapBrlaToUsdc]: 2, + [BrlaToUsdcBaseRebalancePhase.MainNablaSwapUsdcToBrla]: 3, + [BrlaToUsdcBaseRebalancePhase.VerifyFinalBalance]: 4 +}; + +export interface BrlaToUsdcBaseRebalanceState { + currentPhase: BrlaToUsdcBaseRebalancePhase; + brlaAmountRaw: string | null; + initialBrlaBalance: string | null; + initialUsdcBalance: string | null; + usdcBalanceBeforeNablaRaw: string | null; + nablaApproveHash: string | null; + nablaSwapHash: string | null; + usdcReceivedRaw: string | null; + mainNablaBrlaBalanceBeforeRaw: string | null; + mainNablaApproveHash: string | null; + mainNablaSwapHash: string | null; + mainNablaBrlaReceivedRaw: string | null; + finalUsdcBalance: string | null; + startingTime: string; + updatedTime: string; +} + +export interface BrlaToUsdcBaseRebalanceContainer { + state: BrlaToUsdcBaseRebalanceState; + history: RebalanceHistoryEntry[]; +} + +export class BrlaToUsdcBaseStateManager { + private inner: StateManager; + + constructor() { + this.inner = new StateManager("rebalancer_state_brla_to_usdc_base.json"); + } + + private async getContainer(): Promise { + return this.inner.getState(); + } + + async getState(): Promise { + const container = await this.getContainer(); + return container?.state; + } + + async getHistory(): Promise { + const container = await this.getContainer(); + return container?.history ?? []; + } + + async saveState(state: BrlaToUsdcBaseRebalanceState): Promise { + const existing = await this.getContainer(); + const history = existing?.history ?? []; + state.updatedTime = new Date().toISOString(); + await this.inner.saveState({ history, state }); + } + + async addHistoryEntry(entry: RebalanceHistoryEntry): Promise { + const existing = await this.getContainer(); + if (!existing?.state) { + console.warn("No existing state found for addHistoryEntry. Skipping history entry."); + return; + } + existing.history.push(entry); + existing.state.updatedTime = new Date().toISOString(); + await this.inner.saveState(existing); + } + + async startNewRebalance(brlaAmountRaw: string): Promise { + const existing = await this.getContainer(); + const history = existing?.history ?? []; + + const state: BrlaToUsdcBaseRebalanceState = { + brlaAmountRaw, + currentPhase: BrlaToUsdcBaseRebalancePhase.CheckInitialBrlaBalance, + finalUsdcBalance: null, + initialBrlaBalance: null, + initialUsdcBalance: null, + mainNablaApproveHash: null, + mainNablaBrlaBalanceBeforeRaw: null, + mainNablaBrlaReceivedRaw: null, + mainNablaSwapHash: null, + nablaApproveHash: null, + nablaSwapHash: null, + startingTime: new Date().toISOString(), + updatedTime: new Date().toISOString(), + usdcBalanceBeforeNablaRaw: null, + usdcReceivedRaw: null + }; + await this.inner.saveState({ history, state }); + return state; + } +} diff --git a/apps/rebalancer/src/utils/config.ts b/apps/rebalancer/src/utils/config.ts index bdde43c88..f943a9ca2 100644 --- a/apps/rebalancer/src/utils/config.ts +++ b/apps/rebalancer/src/utils/config.ts @@ -22,6 +22,10 @@ export function getConfig() { mainNablaRouter: process.env.MAIN_NABLA_ROUTER as `0x${string}` | undefined, pendulumAccountSecret: process.env.PENDULUM_ACCOUNT_SECRET, + /// The amount in BRLA to swap to USDC during each execution (BRLA→USDC reverse flow on Base). + rebalancingBrlToUsdAmount: process.env.REBALANCING_BRL_TO_USD_AMOUNT || "1", + /// The minimum balance in BRLA that the rebalancer account on Base must have to allow BRLA→USDC rebalancing. + rebalancingBrlToUsdMinBalance: process.env.REBALANCING_BRL_TO_USD_MIN_BALANCE || undefined, rebalancingDailyBridgeLimitUsd: Number(process.env.REBALANCING_DAILY_BRIDGE_LIMIT_USD) || 10_000, /// The threshold above and below the optimal coverage ratio at which the rebalancing will be triggered. From 2ec2c38eea02a330e983d2a8b3b24a04e6587604 Mon Sep 17 00:00:00 2001 From: gianfra-t <96739519+gianfra-t@users.noreply.github.com> Date: Fri, 5 Jun 2026 09:25:09 -0300 Subject: [PATCH 023/131] Potential fix for pull request finding 'Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts index 4a59c6088..1f4a6d209 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts @@ -10,9 +10,6 @@ import { aveniaTransferBrlaToPolygon, checkInitialUsdcBalanceOnBase, compareRoutesUpfront, - fetchAveniaQuote, - fetchMainNablaQuote, - fetchSquidRouterQuote, getAveniaBrlaBalanceDecimal, getBrlaBalanceOnPolygonRaw, getUsdcBalanceOnBaseRaw, From 1ed707403652f1c37faa10e3a999b05c0ba14355 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Fri, 5 Jun 2026 09:27:58 -0300 Subject: [PATCH 024/131] fix type issue --- apps/rebalancer/src/rebalance/brla-to-usdc-base/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/rebalancer/src/rebalance/brla-to-usdc-base/index.ts b/apps/rebalancer/src/rebalance/brla-to-usdc-base/index.ts index 6d52d25d8..054a8f28e 100644 --- a/apps/rebalancer/src/rebalance/brla-to-usdc-base/index.ts +++ b/apps/rebalancer/src/rebalance/brla-to-usdc-base/index.ts @@ -35,7 +35,7 @@ export async function rebalanceBrlaToUsdcBase(brlaAmountRaw: string, forceRestar const { publicClient: basePublicClient, walletClient: baseWalletClient } = getBaseEvmClients(); const baseAddress = baseWalletClient.account.address; - const baseNonce = await NonceManager.create(basePublicClient, baseAddress); + const baseNonce = await NonceManager.create(basePublicClient, baseAddress as `0x${string}`); const currentOrder = brlaToUsdcBasePhaseOrder[state.currentPhase]; console.log(`Current phase order: ${currentOrder}`); From 8da9773e81cccb6c5b77865afdacbba4f74ae70f Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Fri, 5 Jun 2026 12:52:42 -0300 Subject: [PATCH 025/131] re-order BRLA -> USDC rebalance steps, fixes --- apps/rebalancer/.env.example | 5 ++ apps/rebalancer/src/index.ts | 47 ++++++-------- .../src/rebalance/brla-to-axlusdc/steps.ts | 2 +- .../src/rebalance/brla-to-usdc-base/index.ts | 62 +++++++++---------- .../brla-to-usdc-base/notifications.ts | 24 +++---- .../src/rebalance/brla-to-usdc-base/steps.ts | 16 +---- .../rebalance/usdc-brla-usdc-base/index.ts | 6 +- .../rebalance/usdc-brla-usdc-base/steps.ts | 46 +++++++++----- apps/rebalancer/src/services/stateManager.ts | 20 +++--- apps/rebalancer/src/utils/config.ts | 8 ++- 10 files changed, 120 insertions(+), 116 deletions(-) diff --git a/apps/rebalancer/.env.example b/apps/rebalancer/.env.example index c7a851d53..3a04baf5a 100644 --- a/apps/rebalancer/.env.example +++ b/apps/rebalancer/.env.example @@ -18,6 +18,11 @@ SUPABASE_SERVICE_KEY=your_supabase_service_key_here # Maximum total USD bridged per day before the rebalancer stops (default 10,000) REBALANCING_DAILY_BRIDGE_LIMIT_USD=10000 +# Coverage ratio thresholds for triggering each route (default 0.01 each, falls back to REBALANCING_THRESHOLD if unset) +# REBALANCING_THRESHOLD=0.01 +# REBALANCING_THRESHOLD_BRLA_TO_USDC=0.01 +# REBALANCING_THRESHOLD_USDC_TO_BRLA=0.01 + # Main Nabla instance on Base (BRL→USDC route). Leave empty to disable this route. MAIN_NABLA_ROUTER=0x... MAIN_NABLA_QUOTER=0x... diff --git a/apps/rebalancer/src/index.ts b/apps/rebalancer/src/index.ts index c78dd43e7..9e08cb17f 100644 --- a/apps/rebalancer/src/index.ts +++ b/apps/rebalancer/src/index.ts @@ -4,7 +4,6 @@ import Big from "big.js"; import { rebalanceBrlaToUsdcAxl } from "./rebalance/brla-to-axlusdc"; import { checkInitialPendulumBalance } from "./rebalance/brla-to-axlusdc/steps.ts"; import { rebalanceBrlaToUsdcBase } from "./rebalance/brla-to-usdc-base"; -import { checkInitialBrlaBalanceOnBase } from "./rebalance/brla-to-usdc-base/steps.ts"; import { rebalanceUsdcBrlaUsdcBase } from "./rebalance/usdc-brla-usdc-base"; import { wouldExceedDailyBridgeLimit } from "./rebalance/usdc-brla-usdc-base/guards.ts"; import { checkInitialUsdcBalanceOnBase } from "./rebalance/usdc-brla-usdc-base/steps.ts"; @@ -121,46 +120,39 @@ async function runUsdcToBrla(bridgedToday: Big, dailyLimitRaw: Big) { async function runBrlaToUsdc(bridgedToday: Big, dailyLimitRaw: Big) { const config = getConfig(); - const amountBrla = manualAmount || config.rebalancingBrlToUsdAmount; - const amountBrlaRaw = multiplyByPowerOfTen(new Big(amountBrla), 18).toFixed(0, 0); + 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 estimatedUsdcRaw = multiplyByPowerOfTen(new Big(amountBrla), 6).toFixed(0, 0); - if (checkDailyLimit(bridgedToday, Big(estimatedUsdcRaw), dailyLimitRaw, config.rebalancingDailyBridgeLimitUsd)) return; + if (checkDailyLimit(bridgedToday, Big(amountUsdcRaw), dailyLimitRaw, config.rebalancingDailyBridgeLimitUsd)) return; - const rebalancerBrlaBalance = await checkInitialBrlaBalanceOnBase(amountBrlaRaw); - if (config.rebalancingBrlToUsdMinBalance && rebalancerBrlaBalance.lt(config.rebalancingBrlToUsdMinBalance)) { + const rebalancerUsdcBalance = await checkInitialUsdcBalanceOnBase(amountUsdcRaw); + if (config.rebalancingBrlToUsdMinBalance && rebalancerUsdcBalance.lt(config.rebalancingBrlToUsdMinBalance)) { throw new Error( - `Rebalancer BRLA balance ${rebalancerBrlaBalance} is below the minimum required balance of ${config.rebalancingBrlToUsdMinBalance} to perform BRLA->USDC rebalancing.` + `Rebalancer USDC balance ${rebalancerUsdcBalance} is below the minimum required balance of ${config.rebalancingBrlToUsdMinBalance} to perform rebalancing.` ); } } - await rebalanceBrlaToUsdcBase(amountBrlaRaw, forceRestart); + await rebalanceBrlaToUsdcBase(amountUsdcRaw, forceRestart); } async function checkForRebalancing() { const config = getConfig(); const coverage = await getBaseNablaCoverageRatio(); - if (!coverage && !forceRestart) { - throw new Error("Failed to fetch Base Nabla coverage ratio."); - } + if (!coverage) throw new Error("Failed to fetch Base Nabla coverage ratio."); - if (!forceRestart && coverage) { - const inRange = - coverage.brlaCoverageRatio >= config.rebalancingThreshold && - coverage.brlaCoverageRatio >= 1 - config.rebalancingThreshold; - if (inRange) { - console.log(`BRLA coverage ${coverage.brlaCoverageRatio} in range. No rebalancing needed.`); - return; - } - } else if (forceRestart) { - console.log("Force restart enabled."); + const lowerBound = 1 - config.rebalancingThresholdBrlaToUsdc; + const upperBound = 1 + config.rebalancingThresholdUsdcToBrla; + + if (coverage.brlaCoverageRatio >= lowerBound && coverage.brlaCoverageRatio <= upperBound) { + console.log(`BRLA coverage ${coverage.brlaCoverageRatio} in range [${lowerBound}, ${upperBound}]. No rebalancing needed.`); + return; } const bridgedToday = await getTodayBridgedUsdRaw(); @@ -169,13 +161,14 @@ async function checkForRebalancing() { `Bridged $${bridgedToday.div(1e6).toFixed(2)} today. Daily bridge limit is $${config.rebalancingDailyBridgeLimitUsd}.` ); - if (coverage && coverage.brlaCoverageRatio < 1 - config.rebalancingThreshold) { - console.log(`BRLA coverage ${coverage.brlaCoverageRatio} < ${1 - config.rebalancingThreshold}. Running BRLA->USDC.`); - await runBrlaToUsdc(bridgedToday, dailyLimitRaw); - } else { - console.log("Running USDC->BRLA->USDC flow."); + if (coverage.brlaCoverageRatio < lowerBound) { + console.log(`BRLA coverage ${coverage.brlaCoverageRatio} < ${lowerBound}. Running BRLA->USDC.`); await runUsdcToBrla(bridgedToday, dailyLimitRaw); + return; } + + console.log(`BRLA coverage ${coverage.brlaCoverageRatio} > ${upperBound}. Running USDC->BRLA.`); + await runUsdcToBrla(bridgedToday, dailyLimitRaw); } const rebalanceFn = useLegacy ? checkForRebalancingLegacy : checkForRebalancing; diff --git a/apps/rebalancer/src/rebalance/brla-to-axlusdc/steps.ts b/apps/rebalancer/src/rebalance/brla-to-axlusdc/steps.ts index 44cb9b400..4aa84cab0 100644 --- a/apps/rebalancer/src/rebalance/brla-to-axlusdc/steps.ts +++ b/apps/rebalancer/src/rebalance/brla-to-axlusdc/steps.ts @@ -247,7 +247,7 @@ export async function transferUsdcToMoonbeamWithSquidrouter(usdcAmountRaw: strin /// Swaps BRLA to USDC on BRLA API service and transfer them to the receiver address. export async function swapBrlaToUsdcOnBrlaApiService(brlaAmount: Big, receiverAddress: `0x${string}`) { const aveniaOnchainSwapParams: OnchainSwapQuoteParams = { - inputAmount: brlaAmount.toFixed(12, 0), + inputAmount: brlaAmount.toFixed(4, 0), inputCurrency: BrlaCurrency.BRLA, outputCurrency: BrlaCurrency.USDC }; diff --git a/apps/rebalancer/src/rebalance/brla-to-usdc-base/index.ts b/apps/rebalancer/src/rebalance/brla-to-usdc-base/index.ts index 054a8f28e..60c39cfa1 100644 --- a/apps/rebalancer/src/rebalance/brla-to-usdc-base/index.ts +++ b/apps/rebalancer/src/rebalance/brla-to-usdc-base/index.ts @@ -7,16 +7,12 @@ import { } from "../../services/stateManager.ts"; import { getBaseEvmClients } from "../../utils/config.ts"; import { NonceManager } from "../../utils/nonce.ts"; +import { checkInitialUsdcBalanceOnBase } from "../usdc-brla-usdc-base/steps.ts"; import { formatBrlaToUsdcBaseCompletionMessage } from "./notifications.ts"; -import { - checkInitialBrlaBalanceOnBase, - mainNablaSwapUsdcToBrlaOnBase, - nablaSwapBrlaToUsdcOnBase, - verifyFinalUsdcBalanceOnBase -} from "./steps.ts"; +import { mainNablaSwapUsdcToBrlaOnBase, nablaSwapBrlaToUsdcOnBase, verifyFinalUsdcBalanceOnBase } from "./steps.ts"; -export async function rebalanceBrlaToUsdcBase(brlaAmountRaw: string, forceRestart = false) { - console.log(`Starting BRLA->USDC rebalance on Base with amount: ${brlaAmountRaw} (raw BRLA)`); +export async function rebalanceBrlaToUsdcBase(usdcAmountRaw: string, forceRestart = false) { + console.log(`Starting USDC→BRLA→USDC rebalance on Base with amount: ${usdcAmountRaw} (raw USDC)`); const stateManager = new BrlaToUsdcBaseStateManager(); let state = await stateManager.getState(); @@ -26,7 +22,7 @@ export async function rebalanceBrlaToUsdcBase(brlaAmountRaw: string, forceRestar if (isResuming) { console.log(`Resuming rebalance from phase: ${state?.currentPhase}`); } else { - state = await stateManager.startNewRebalance(brlaAmountRaw); + state = await stateManager.startNewRebalance(usdcAmountRaw); } if (!state) { @@ -40,28 +36,28 @@ export async function rebalanceBrlaToUsdcBase(brlaAmountRaw: string, forceRestar const currentOrder = brlaToUsdcBasePhaseOrder[state.currentPhase]; console.log(`Current phase order: ${currentOrder}`); - if (currentOrder <= brlaToUsdcBasePhaseOrder[BrlaToUsdcBaseRebalancePhase.CheckInitialBrlaBalance]) { - if (!state.brlaAmountRaw) throw new Error("State corrupted: brlaAmountRaw missing for step 1"); + if (currentOrder <= brlaToUsdcBasePhaseOrder[BrlaToUsdcBaseRebalancePhase.CheckInitialUsdcBalance]) { + if (!state.usdcAmountRaw) throw new Error("State corrupted: usdcAmountRaw missing for step 1"); - const initialBalance = await checkInitialBrlaBalanceOnBase(state.brlaAmountRaw); - state.initialBrlaBalance = initialBalance.toString(); - state.currentPhase = BrlaToUsdcBaseRebalancePhase.NablaSwapBrlaToUsdc; + const initialBalance = await checkInitialUsdcBalanceOnBase(state.usdcAmountRaw); + state.initialUsdcBalance = initialBalance.toString(); + state.currentPhase = BrlaToUsdcBaseRebalancePhase.MainNablaSwapUsdcToBrla; await stateManager.saveState(state); } - if (currentOrder <= brlaToUsdcBasePhaseOrder[BrlaToUsdcBaseRebalancePhase.NablaSwapBrlaToUsdc]) { - if (!state.brlaAmountRaw) throw new Error("State corrupted: brlaAmountRaw missing for step 2"); + if (currentOrder <= brlaToUsdcBasePhaseOrder[BrlaToUsdcBaseRebalancePhase.MainNablaSwapUsdcToBrla]) { + if (!state.usdcAmountRaw) throw new Error("State corrupted: usdcAmountRaw missing for step 2"); - await nablaSwapBrlaToUsdcOnBase(state.brlaAmountRaw, baseNonce, state, stateManager); + await mainNablaSwapUsdcToBrlaOnBase(state.usdcAmountRaw, baseNonce, state, stateManager); - state.currentPhase = BrlaToUsdcBaseRebalancePhase.MainNablaSwapUsdcToBrla; + state.currentPhase = BrlaToUsdcBaseRebalancePhase.NablaSwapBrlaToUsdc; await stateManager.saveState(state); } - if (currentOrder <= brlaToUsdcBasePhaseOrder[BrlaToUsdcBaseRebalancePhase.MainNablaSwapUsdcToBrla]) { - if (!state.usdcReceivedRaw) throw new Error("State corrupted: usdcReceivedRaw missing for main nabla step"); + if (currentOrder <= brlaToUsdcBasePhaseOrder[BrlaToUsdcBaseRebalancePhase.NablaSwapBrlaToUsdc]) { + if (!state.mainNablaBrlaReceivedRaw) throw new Error("State corrupted: mainNablaBrlaReceivedRaw missing for step 3"); - await mainNablaSwapUsdcToBrlaOnBase(state.usdcReceivedRaw, baseNonce, state, stateManager); + await nablaSwapBrlaToUsdcOnBase(state.mainNablaBrlaReceivedRaw, baseNonce, state, stateManager); state.currentPhase = BrlaToUsdcBaseRebalancePhase.VerifyFinalBalance; await stateManager.saveState(state); @@ -75,36 +71,36 @@ export async function rebalanceBrlaToUsdcBase(brlaAmountRaw: string, forceRestar await stateManager.saveState(state); } - if (!state.brlaAmountRaw) throw new Error("State corrupted: brlaAmountRaw missing at completion"); + if (!state.usdcAmountRaw) throw new Error("State corrupted: usdcAmountRaw missing at completion"); if (!state.usdcReceivedRaw) throw new Error("State corrupted: usdcReceivedRaw missing at completion"); if (!state.mainNablaBrlaReceivedRaw) throw new Error("State corrupted: mainNablaBrlaReceivedRaw missing at completion"); - const brlaIn = multiplyByPowerOfTen(Big(state.brlaAmountRaw), -18); - const usdcIntermediate = multiplyByPowerOfTen(Big(state.usdcReceivedRaw), -6); - const brlaOut = multiplyByPowerOfTen(Big(state.mainNablaBrlaReceivedRaw), -18); - const cost = brlaIn.minus(brlaOut); - const costRelative = brlaIn.gt(0) ? cost.div(brlaIn).toFixed(4, 0) : "N/A"; + const usdcIn = multiplyByPowerOfTen(Big(state.usdcAmountRaw), -6); + const brlaIntermediate = multiplyByPowerOfTen(Big(state.mainNablaBrlaReceivedRaw), -18); + const usdcOut = multiplyByPowerOfTen(Big(state.usdcReceivedRaw), -6); + const cost = usdcIn.minus(usdcOut); + const costRelative = usdcIn.gt(0) ? cost.div(usdcIn).toFixed(4, 0) : "N/A"; console.log( - `Rebalance completed! BRLA in: ${brlaIn.toFixed(6)}, USDC intermediate: ${usdcIntermediate.toFixed(6)}, BRLA out: ${brlaOut.toFixed(6)}` + `Rebalance completed! USDC in: ${usdcIn.toFixed(6)}, BRLA intermediate: ${brlaIntermediate.toFixed(6)}, USDC out: ${usdcOut.toFixed(6)}` ); - console.log(`Cost: absolute: ${cost.toFixed(6)} BRLA | relative: ${costRelative}`); + console.log(`Cost: absolute: ${cost.toFixed(6)} USDC | relative: ${costRelative}`); await stateManager.addHistoryEntry({ cost: cost.toFixed(6), costRelative, endingTime: new Date().toISOString(), - initialAmount: state.brlaAmountRaw, + initialAmount: state.usdcAmountRaw, startingTime: state.startingTime }); const slackNotifier = new SlackNotifier(process.env.SLACK_WEB_HOOK_TOKEN); await slackNotifier.sendMessage({ text: formatBrlaToUsdcBaseCompletionMessage({ - brlaIn, - brlaOut, + brlaIntermediate, cost, - usdcIntermediate + usdcIn, + usdcOut }) }); } diff --git a/apps/rebalancer/src/rebalance/brla-to-usdc-base/notifications.ts b/apps/rebalancer/src/rebalance/brla-to-usdc-base/notifications.ts index f66d7b389..563afcdfd 100644 --- a/apps/rebalancer/src/rebalance/brla-to-usdc-base/notifications.ts +++ b/apps/rebalancer/src/rebalance/brla-to-usdc-base/notifications.ts @@ -1,27 +1,27 @@ import Big from "big.js"; interface BrlaToUsdcBaseCompletionMessageParams { - brlaIn: Big; - brlaOut: Big; + brlaIntermediate: Big; cost: Big; - usdcIntermediate: Big; + usdcIn: Big; + usdcOut: Big; } export function formatBrlaToUsdcBaseCompletionMessage(params: BrlaToUsdcBaseCompletionMessageParams): string { - const costPercent = params.brlaIn.gt(0) ? params.cost.div(params.brlaIn).mul(100).toFixed(2) : "N/A"; + const costPercent = params.usdcIn.gt(0) ? params.cost.div(params.usdcIn).mul(100).toFixed(2) : "N/A"; return [ - "\u2705 *Base rebalancer completed (BRLA Nabla \u2192 Main Nabla)*", - "BRLA \u2192 USDC (BRLA Nabla) \u2192 BRLA (Main Nabla)", + "\u2705 *Base rebalancer completed (Main Nabla \u2192 BRLA Nabla)*", + "USDC \u2192 BRLA (Main Nabla) \u2192 USDC (BRLA Nabla)", "", "*Execution*", - "- \uD83D\uDEE3\uFE0F Route: BRLA Nabla + Main Nabla", - `- \uD83D\uDCB5 Requested amount: \`${params.brlaIn.toFixed(6)} BRLA\``, - `- \uD83E\uDD9A USDC intermediate: \`${params.usdcIntermediate.toFixed(6)} USDC\``, - `- \uD83E\uDD9A BRLA out: \`${params.brlaOut.toFixed(6)} BRLA\``, + "- \uD83D\uDEE3\uFE0F Route: Main Nabla + BRLA Nabla", + `- \uD83D\uDCB5 USDC in: \`${params.usdcIn.toFixed(6)} USDC\``, + `- \uD83E\uDD9A BRLA intermediate: \`${params.brlaIntermediate.toFixed(6)} BRLA\``, + `- \uD83D\uDCB5 USDC out: \`${params.usdcOut.toFixed(6)} USDC\``, "", "*Cost*", - `- \uD83D\uDCC9 Net BRLA cost: \`${params.cost.toFixed(6)} BRLA\``, - `- \uD83D\uDCCA Cost/requested amount: \`${costPercent === "N/A" ? costPercent : `${costPercent}%`}\`` + `- \uD83D\uDCC9 Net USDC cost: \`${params.cost.toFixed(6)} USDC\``, + `- \uD83D\uDCCA Cost/input: \`${costPercent === "N/A" ? costPercent : `${costPercent}%`}\`` ].join("\n"); } diff --git a/apps/rebalancer/src/rebalance/brla-to-usdc-base/steps.ts b/apps/rebalancer/src/rebalance/brla-to-usdc-base/steps.ts index ff4f8d92c..2edc87ab5 100644 --- a/apps/rebalancer/src/rebalance/brla-to-usdc-base/steps.ts +++ b/apps/rebalancer/src/rebalance/brla-to-usdc-base/steps.ts @@ -61,21 +61,7 @@ export async function getBrlaBalanceOnBaseRaw(): Promise { return balance.toString(); } -export async function checkInitialBrlaBalanceOnBase(brlaAmountRaw: string): Promise { - const { walletClient } = getBaseEvmClients(); - const address = walletClient.account.address; - - const balanceRaw = await getBrlaBalanceOnBaseRaw(); - const balanceDecimal = multiplyByPowerOfTen(Big(balanceRaw), -18); - console.log(`Current BRLA balance on Base (${address}): ${balanceDecimal.toFixed(6)} BRLA`); - - const requiredAmount = multiplyByPowerOfTen(Big(brlaAmountRaw), -18); - if (balanceDecimal.lt(requiredAmount)) { - throw new Error(`Insufficient BRLA on Base. Have: ${balanceDecimal.toFixed(6)}, need: ${requiredAmount.toFixed(6)}`); - } - - return balanceDecimal; -} +import { checkInitialUsdcBalanceOnBase } from "../usdc-brla-usdc-base/steps.ts"; export async function nablaSwapBrlaToUsdcOnBase( brlaAmountRaw: string, diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts index 1f4a6d209..d552c022a 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts @@ -185,6 +185,7 @@ export async function rebalanceUsdcBrlaUsdcBase( if (state.winningRoute === "avenia" && state.aveniaTicketId) { console.log(`Checking status for Avenia swap ticket ${state.aveniaTicketId}...`); const paidTicket = await checkTicketStatusPaid(brlaApiService, state.aveniaTicketId); + // Avenia API returns outputAmount in decimal units. state.aveniaQuoteUsdc = paidTicket.quote.outputAmount; console.log(`Avenia swap completed. USDC output: ${state.aveniaQuoteUsdc}`); @@ -247,7 +248,9 @@ export async function rebalanceUsdcBrlaUsdcBase( throw new Error("State corrupted: polygonBrlaBalanceBeforeTransferRaw missing for squid step 2"); } - await waitBrlaOnPolygon(state.brlaAmountRaw, state.polygonBrlaBalanceBeforeTransferRaw); + const arrivedBrlaRaw = await waitBrlaOnPolygon(state.brlaAmountRaw, state.polygonBrlaBalanceBeforeTransferRaw); + // Continue with whatever actually arrived (after Avenia fees). + state.brlaAmountRaw = arrivedBrlaRaw; console.log("BRLA confirmed on Polygon."); state.currentPhase = UsdcBaseRebalancePhase.SquidRouterApproveAndSwap; @@ -265,6 +268,7 @@ export async function rebalanceUsdcBrlaUsdcBase( const result = await squidRouterApproveAndSwap(state.brlaAmountRaw, baseAddress, polygonNonce, state, stateManager); state.squidRouterSwapHash = result.swapHash; + state.squidRouterQuoteUsdc = result.toAmountRaw; console.log(`SquidRouter swap completed. Tx: ${result.swapHash}`); state.currentPhase = UsdcBaseRebalancePhase.WaitUsdcOnBaseFromSquid; await stateManager.saveState(state); diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts index 9d47bed46..5e09431ee 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts @@ -307,7 +307,8 @@ export async function waitForBrlaOnAvenia(brlaAmountDecimal: Big, startingBrlaBa const timeout = 10 * 60 * 1000; const startTime = Date.now(); const brlaApiService = BrlaApiService.getInstance(); - const minimumReceived = calculateMinimumDelta(brlaAmountDecimal); + // Use generous tolerance (95%) — continue with whatever actually arrives after fees. + const minimumReceived = calculateMinimumDelta(brlaAmountDecimal, "0.95"); console.log(`Waiting for ~${brlaAmountDecimal.toFixed(4)} BRLA delta to appear on Avenia main account balance...`); @@ -366,7 +367,7 @@ export async function fetchAveniaQuote(brlaAmountDecimal: Big): Promise const brlaApiService = BrlaApiService.getInstance(); const aveniaQuote = await brlaApiService.createOnchainSwapQuote( { - inputAmount: brlaAmountDecimal.toFixed(12, 0), + inputAmount: brlaAmountDecimal.toFixed(4, 0), inputCurrency: BrlaCurrency.BRLA, outputCurrency: BrlaCurrency.USDC, outputPaymentMethod: AveniaPaymentMethod.BASE @@ -374,8 +375,10 @@ export async function fetchAveniaQuote(brlaAmountDecimal: Big): Promise { useCache: true } ); - console.log(`Avenia quote: ${aveniaQuote.outputAmount} USDC`); - return aveniaQuote.outputAmount; + // Avenia API returns outputAmount in decimal units. Convert to raw USDC (6 decimals). + const outputUsdcRaw = multiplyByPowerOfTen(Big(aveniaQuote.outputAmount), 6).toFixed(0, 0); + console.log(`Avenia quote: ${outputUsdcRaw} USDC (raw, 6 decimals)`); + return outputUsdcRaw; } export async function compareRates(brlaAmountDecimal: Big): Promise<{ @@ -415,7 +418,7 @@ export async function compareRates(brlaAmountDecimal: Big): Promise<{ } const squidUsdcDecimal = multiplyByPowerOfTen(Big(squidRouterQuoteUsdc), -6); - const aveniaUsdcDecimal = Big(aveniaQuoteUsdc); + const aveniaUsdcDecimal = multiplyByPowerOfTen(Big(aveniaQuoteUsdc), -6); console.log(`SquidRouter: ${squidUsdcDecimal.toFixed(6)} USDC | Avenia: ${aveniaUsdcDecimal.toFixed(6)} USDC`); @@ -431,7 +434,7 @@ export async function aveniaTransferBrlaToPolygon(brlaAmountDecimal: Big): Promi const brlaApiService = BrlaApiService.getInstance(); const quote = await brlaApiService.createOnchainSwapQuote({ - inputAmount: brlaAmountDecimal.toFixed(12, 0), + inputAmount: brlaAmountDecimal.toFixed(4, 0), inputCurrency: BrlaCurrency.BRLA, outputCurrency: BrlaCurrency.BRLA, outputPaymentMethod: AveniaPaymentMethod.POLYGON @@ -451,16 +454,26 @@ export async function aveniaTransferBrlaToPolygon(brlaAmountDecimal: Big): Promi return ticket.id; } -export async function waitBrlaOnPolygon(brlaAmountRaw: string, startingBrlaBalanceRaw: string): Promise { +export async function waitBrlaOnPolygon(brlaAmountRaw: string, startingBrlaBalanceRaw: string): Promise { const { walletClient: polygonWalletClient } = getPolygonEvmClients(); const polygonAddress = polygonWalletClient.account.address; - const targetBalanceRaw = calculateTargetBalanceRaw(startingBrlaBalanceRaw, brlaAmountRaw, DEFAULT_ARRIVAL_TOLERANCE); + // Use a generous tolerance (95%) — we continue with whatever actually arrives. + const targetBalanceRaw = calculateTargetBalanceRaw(startingBrlaBalanceRaw, brlaAmountRaw, "0.95"); console.log(`Waiting for BRLA delta to arrive on Polygon (${polygonAddress})...`); - await checkEvmBalancePeriodically(BRLA_POLYGON, polygonAddress, targetBalanceRaw, 1_000, 10 * 60 * 1_000, Networks.Polygon); + const finalBalance = await checkEvmBalancePeriodically( + BRLA_POLYGON, + polygonAddress, + targetBalanceRaw, + 1_000, + 10 * 60 * 1_000, + Networks.Polygon + ); - console.log("BRLA arrived on Polygon."); + const arrivedRaw = finalBalance.minus(Big(startingBrlaBalanceRaw)).toFixed(0, 0); + console.log(`BRLA arrived on Polygon. Actual delta: ${arrivedRaw} raw`); + return arrivedRaw; } export async function squidRouterApproveAndSwap( @@ -469,9 +482,10 @@ export async function squidRouterApproveAndSwap( polygonNonce: NonceManager, state: UsdcBaseRebalanceState, stateManager: UsdcBaseStateManager -): Promise<{ swapHash: string; toAmountUsd: string }> { +): Promise<{ swapHash: string; toAmountUsd: string; toAmountRaw: string }> { let swapHash = state.squidRouterSwapHash; let toAmountUsd = "0"; + let toAmountRaw = "0"; if (!swapHash) { console.log("Executing SquidRouter swap: Polygon BRLA -> Base USDC..."); @@ -494,7 +508,8 @@ export async function squidRouterApproveAndSwap( const route = routeResult.data.route; toAmountUsd = route.estimate.toAmountUSD; - console.log(`SquidRouter route obtained. Expected output: ${route.estimate.toAmount} USDC (raw)`); + toAmountRaw = route.estimate.toAmount; + console.log(`SquidRouter route obtained. Expected output: ${toAmountRaw} USDC (raw)`); const { approveData, swapData } = await createTransactionDataFromRoute({ inputTokenErc20Address: BRLA_POLYGON, @@ -560,7 +575,7 @@ export async function squidRouterApproveAndSwap( throw new Error("Axelar execution timed out after 30 minutes"); } - return { swapHash, toAmountUsd }; + return { swapHash, toAmountRaw, toAmountUsd }; } export async function waitUsdcOnBase(expectedUsdcRaw: string, startingUsdcBalanceRaw: string): Promise { @@ -587,7 +602,7 @@ export async function aveniaCreateSwapToUsdcBaseTicket( const brlaApiService = BrlaApiService.getInstance(); const quote = await brlaApiService.createOnchainSwapQuote({ - inputAmount: brlaAmountDecimal.toFixed(12, 0), + inputAmount: brlaAmountDecimal.toFixed(4, 0), inputCurrency: BrlaCurrency.BRLA, outputCurrency: BrlaCurrency.USDC, outputPaymentMethod: AveniaPaymentMethod.BASE @@ -602,6 +617,7 @@ export async function aveniaCreateSwapToUsdcBaseTicket( }); console.log(`Avenia swap ticket created: ${ticket.id}`); + // Avenia API returns outputAmount in decimal units. return { outputAmount: quote.outputAmount, ticketId: ticket.id }; } @@ -749,7 +765,7 @@ export async function compareRoutesUpfront(usdcAmountRaw: string): Promise<{ candidates.push({ route: "squidrouter", usdcDecimal: multiplyByPowerOfTen(Big(squidRouterQuoteUsdc), -6) }); } if (aveniaQuoteUsdc) { - candidates.push({ route: "avenia", usdcDecimal: Big(aveniaQuoteUsdc) }); + candidates.push({ route: "avenia", usdcDecimal: multiplyByPowerOfTen(Big(aveniaQuoteUsdc), -6) }); } if (mainNablaQuoteUsdc) { candidates.push({ route: "nabla-main", usdcDecimal: multiplyByPowerOfTen(Big(mainNablaQuoteUsdc), -6) }); diff --git a/apps/rebalancer/src/services/stateManager.ts b/apps/rebalancer/src/services/stateManager.ts index aefcd1f43..724cbf8ef 100644 --- a/apps/rebalancer/src/services/stateManager.ts +++ b/apps/rebalancer/src/services/stateManager.ts @@ -347,24 +347,23 @@ export class UsdcBaseStateManager { export enum BrlaToUsdcBaseRebalancePhase { Idle = "idle", - CheckInitialBrlaBalance = "checkInitialBrlaBalance", - NablaSwapBrlaToUsdc = "nablaSwapBrlaToUsdc", + CheckInitialUsdcBalance = "checkInitialUsdcBalance", MainNablaSwapUsdcToBrla = "mainNablaSwapUsdcToBrla", + NablaSwapBrlaToUsdc = "nablaSwapBrlaToUsdc", VerifyFinalBalance = "verifyFinalBalance" } export const brlaToUsdcBasePhaseOrder: Record = { [BrlaToUsdcBaseRebalancePhase.Idle]: 0, - [BrlaToUsdcBaseRebalancePhase.CheckInitialBrlaBalance]: 1, - [BrlaToUsdcBaseRebalancePhase.NablaSwapBrlaToUsdc]: 2, - [BrlaToUsdcBaseRebalancePhase.MainNablaSwapUsdcToBrla]: 3, + [BrlaToUsdcBaseRebalancePhase.CheckInitialUsdcBalance]: 1, + [BrlaToUsdcBaseRebalancePhase.MainNablaSwapUsdcToBrla]: 2, + [BrlaToUsdcBaseRebalancePhase.NablaSwapBrlaToUsdc]: 3, [BrlaToUsdcBaseRebalancePhase.VerifyFinalBalance]: 4 }; export interface BrlaToUsdcBaseRebalanceState { currentPhase: BrlaToUsdcBaseRebalancePhase; - brlaAmountRaw: string | null; - initialBrlaBalance: string | null; + usdcAmountRaw: string | null; initialUsdcBalance: string | null; usdcBalanceBeforeNablaRaw: string | null; nablaApproveHash: string | null; @@ -423,15 +422,13 @@ export class BrlaToUsdcBaseStateManager { await this.inner.saveState(existing); } - async startNewRebalance(brlaAmountRaw: string): Promise { + async startNewRebalance(usdcAmountRaw: string): Promise { const existing = await this.getContainer(); const history = existing?.history ?? []; const state: BrlaToUsdcBaseRebalanceState = { - brlaAmountRaw, - currentPhase: BrlaToUsdcBaseRebalancePhase.CheckInitialBrlaBalance, + currentPhase: BrlaToUsdcBaseRebalancePhase.CheckInitialUsdcBalance, finalUsdcBalance: null, - initialBrlaBalance: null, initialUsdcBalance: null, mainNablaApproveHash: null, mainNablaBrlaBalanceBeforeRaw: null, @@ -441,6 +438,7 @@ export class BrlaToUsdcBaseStateManager { nablaSwapHash: null, startingTime: new Date().toISOString(), updatedTime: new Date().toISOString(), + usdcAmountRaw, usdcBalanceBeforeNablaRaw: null, usdcReceivedRaw: null }; diff --git a/apps/rebalancer/src/utils/config.ts b/apps/rebalancer/src/utils/config.ts index f943a9ca2..e16296ab1 100644 --- a/apps/rebalancer/src/utils/config.ts +++ b/apps/rebalancer/src/utils/config.ts @@ -23,13 +23,19 @@ export function getConfig() { pendulumAccountSecret: process.env.PENDULUM_ACCOUNT_SECRET, /// The amount in BRLA to swap to USDC during each execution (BRLA→USDC reverse flow on Base). + /// NOTE: The rebalancer now starts with USDC; this amount is now interpreted as a USD amount. rebalancingBrlToUsdAmount: process.env.REBALANCING_BRL_TO_USD_AMOUNT || "1", - /// The minimum balance in BRLA that the rebalancer account on Base must have to allow BRLA→USDC rebalancing. + /// The minimum balance in USDC that the rebalancer account on Base must have to allow the BRLA pool rebalancing. rebalancingBrlToUsdMinBalance: process.env.REBALANCING_BRL_TO_USD_MIN_BALANCE || undefined, rebalancingDailyBridgeLimitUsd: Number(process.env.REBALANCING_DAILY_BRIDGE_LIMIT_USD) || 10_000, /// The threshold above and below the optimal coverage ratio at which the rebalancing will be triggered. rebalancingThreshold: Number(process.env.REBALANCING_THRESHOLD) || 0.01, + /// Route-specific thresholds (fall back to rebalancingThreshold if unset). + rebalancingThresholdBrlaToUsdc: + Number(process.env.REBALANCING_THRESHOLD_BRLA_TO_USDC) || Number(process.env.REBALANCING_THRESHOLD) || 0.01, + rebalancingThresholdUsdcToBrla: + Number(process.env.REBALANCING_THRESHOLD_USDC_TO_BRLA) || Number(process.env.REBALANCING_THRESHOLD) || 0.01, /// The amount in USD to rebalance from the USD pool to the BRL pool on Pendulum during each execution. rebalancingUsdToBrlAmount: process.env.REBALANCING_USD_TO_BRL_AMOUNT || "1", /// The minimum balance in USD that the rebalancer account on Pendulum must have to allow rebalancing to occur. From d9e78ca669db1aad0fe93a6227c0addcc799ec85 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Fri, 5 Jun 2026 13:06:45 -0300 Subject: [PATCH 026/131] revert testing change --- apps/rebalancer/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/rebalancer/src/index.ts b/apps/rebalancer/src/index.ts index 9e08cb17f..7bf4fc3a7 100644 --- a/apps/rebalancer/src/index.ts +++ b/apps/rebalancer/src/index.ts @@ -163,7 +163,7 @@ async function checkForRebalancing() { if (coverage.brlaCoverageRatio < lowerBound) { console.log(`BRLA coverage ${coverage.brlaCoverageRatio} < ${lowerBound}. Running BRLA->USDC.`); - await runUsdcToBrla(bridgedToday, dailyLimitRaw); + await runBrlaToUsdc(bridgedToday, dailyLimitRaw); return; } From 931b638b2520e26f1f3265dc22aebab0ad0a7708 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Wed, 10 Jun 2026 18:24:22 +0100 Subject: [PATCH 027/131] feat(shared): make BrlaCreateSubaccountRequest.quoteId optional --- packages/shared/src/endpoints/brla.endpoints.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/endpoints/brla.endpoints.ts b/packages/shared/src/endpoints/brla.endpoints.ts index 90865118a..fa7b1d0cd 100644 --- a/packages/shared/src/endpoints/brla.endpoints.ts +++ b/packages/shared/src/endpoints/brla.endpoints.ts @@ -102,7 +102,8 @@ export interface BrlaCreateSubaccountRequest { accountType: AveniaAccountType; name: string; taxId: string; - quoteId: string; + // Optional: the KYB deep link creates a subaccount without a quote. The backend stores it as a nullable initialQuoteId. + quoteId?: string; sessionId?: string; } From 33f0ea8f00ec6c9da8bf2a1f1500e66b3e77e06c Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Wed, 10 Jun 2026 18:24:47 +0100 Subject: [PATCH 028/131] feat(frontend): quote-less KYB deep link with region selector --- .../widget-steps/KybTaxIdStep/index.tsx | 81 ++++++++++++++++ .../widget-steps/RegionSelectStep/index.tsx | 95 +++++++++++++++++++ apps/frontend/src/constants/kybRegions.ts | 29 ++++++ apps/frontend/src/hooks/useRampUrlParams.ts | 25 ++++- .../actors/brla/createSubaccount.actor.ts | 16 ++-- apps/frontend/src/machines/kyc.states.ts | 39 ++++++-- apps/frontend/src/machines/ramp.machine.ts | 87 +++++++++++++++++ apps/frontend/src/machines/types.ts | 15 ++- apps/frontend/src/pages/widget/index.tsx | 56 ++++++++--- apps/frontend/src/translations/en.json | 27 ++++++ apps/frontend/src/translations/pt.json | 27 ++++++ apps/frontend/src/types/searchParams.ts | 5 + 12 files changed, 470 insertions(+), 32 deletions(-) create mode 100644 apps/frontend/src/components/widget-steps/KybTaxIdStep/index.tsx create mode 100644 apps/frontend/src/components/widget-steps/RegionSelectStep/index.tsx create mode 100644 apps/frontend/src/constants/kybRegions.ts diff --git a/apps/frontend/src/components/widget-steps/KybTaxIdStep/index.tsx b/apps/frontend/src/components/widget-steps/KybTaxIdStep/index.tsx new file mode 100644 index 000000000..8e5569112 --- /dev/null +++ b/apps/frontend/src/components/widget-steps/KybTaxIdStep/index.tsx @@ -0,0 +1,81 @@ +import { isValidCnpj } from "@vortexfi/shared"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useRampActor } from "../../../contexts/rampState"; +import { cn } from "../../../helpers/cn"; +import { MenuButtons } from "../../MenuButtons"; +import { StepFooter } from "../../StepFooter"; + +export interface KybTaxIdStepProps { + className?: string; +} + +export const KybTaxIdStep = ({ className }: KybTaxIdStepProps) => { + const { t } = useTranslation(); + const rampActor = useRampActor(); + const [taxId, setTaxId] = useState(""); + const [localError, setLocalError] = useState(""); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + const trimmed = taxId.trim(); + if (!isValidCnpj(trimmed)) { + setLocalError(t("components.kybTaxIdStep.validation.invalidCnpj")); + return; + } + + setLocalError(""); + rampActor.send({ taxId: trimmed, type: "SUBMIT_KYB_TAX_ID" }); + }; + + return ( +
+
+ +
+ +
+
+

{t("components.kybTaxIdStep.title")}

+

{t("components.kybTaxIdStep.description")}

+
+ +
+
+
+ + setTaxId(e.target.value)} + placeholder={t("components.kybTaxIdStep.fields.cnpj.placeholder")} + type="text" + value={taxId} + /> + {localError && ( + + {localError} + + )} +
+
+
+
+ + + + +
+ ); +}; diff --git a/apps/frontend/src/components/widget-steps/RegionSelectStep/index.tsx b/apps/frontend/src/components/widget-steps/RegionSelectStep/index.tsx new file mode 100644 index 000000000..19c974635 --- /dev/null +++ b/apps/frontend/src/components/widget-steps/RegionSelectStep/index.tsx @@ -0,0 +1,95 @@ +import { getAnyFiatTokenDetails } from "@vortexfi/shared"; +import { useSelector } from "@xstate/react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { isFiatTokenEnabled } from "../../../config/tokenAvailability"; +import { KYB_REGIONS, KybRegion } from "../../../constants/kybRegions"; +import { useRampActor } from "../../../contexts/rampState"; +import { cn } from "../../../helpers/cn"; +import { FiatIcon } from "../../FiatIcon"; +import { MenuButtons } from "../../MenuButtons"; +import { StepFooter } from "../../StepFooter"; +import { DropdownSelector } from "../../ui/DropdownSelector"; + +export interface RegionSelectStepProps { + className?: string; +} + +const availableRegions = KYB_REGIONS.filter(region => isFiatTokenEnabled(region.fiatToken)); + +const RegionOption = ({ region }: { region: KybRegion }) => { + const { t } = useTranslation(); + + return ( +
+ + {t(region.labelKey)} +
+ ); +}; + +export const RegionSelectStep = ({ className }: RegionSelectStepProps) => { + const { t } = useTranslation(); + const rampActor = useRampActor(); + const presetFiatToken = useSelector(rampActor, state => state.context.kybFiatToken); + + const presetRegion = availableRegions.find(region => region.fiatToken === presetFiatToken); + const [selected, setSelected] = useState(presetRegion); + const [open, setOpen] = useState(false); + + const handleContinue = () => { + if (!selected) return; + rampActor.send({ fiatToken: selected.fiatToken, type: "SELECT_REGION" }); + }; + + return ( +
+
+ +
+ +
+
+

{t("components.regionSelectStep.title")}

+

{t("components.regionSelectStep.description")}

+
+ + + ) : ( + {t("components.regionSelectStep.placeholder")} + ) + } + > + {availableRegions.map(region => ( + + ))} + +
+ + + + +
+ ); +}; diff --git a/apps/frontend/src/constants/kybRegions.ts b/apps/frontend/src/constants/kybRegions.ts new file mode 100644 index 000000000..a92bdee01 --- /dev/null +++ b/apps/frontend/src/constants/kybRegions.ts @@ -0,0 +1,29 @@ +import { FiatToken } from "@vortexfi/shared"; + +export interface KybRegion { + /** Region code used by the `?region=` deep-link param, e.g. "BR". */ + code: string; + /** i18n key for the region's display name. */ + labelKey: string; + /** Fiat token that determines which KYC/B provider the user is routed to. */ + fiatToken: FiatToken; +} + +/** + * Regions offered in the KYB deep-link selector. Each maps to the fiat token + * that determines the KYC/B provider (Brazil → Avenia, Mexico/Colombia/USA → Alfredpay). + * Europe/Mykobo is intentionally excluded: it is individual KYC only and requires a connected + * wallet, so it cannot complete a quote-less KYB deep link. Add or remove entries here. + */ +export const KYB_REGIONS: KybRegion[] = [ + { code: "BR", fiatToken: FiatToken.BRL, labelKey: "components.regionSelectStep.regions.BR" }, + { code: "MX", fiatToken: FiatToken.MXN, labelKey: "components.regionSelectStep.regions.MX" }, + { code: "CO", fiatToken: FiatToken.COP, labelKey: "components.regionSelectStep.regions.CO" }, + { code: "US", fiatToken: FiatToken.USD, labelKey: "components.regionSelectStep.regions.US" } +]; + +export function findKybRegionByCode(code?: string): KybRegion | undefined { + if (!code) return undefined; + const normalized = code.toUpperCase(); + return KYB_REGIONS.find(region => region.code === normalized); +} diff --git a/apps/frontend/src/hooks/useRampUrlParams.ts b/apps/frontend/src/hooks/useRampUrlParams.ts index edfcee1ec..8eb0969ba 100644 --- a/apps/frontend/src/hooks/useRampUrlParams.ts +++ b/apps/frontend/src/hooks/useRampUrlParams.ts @@ -47,6 +47,9 @@ interface RampUrlParams { walletLocked?: string; callbackUrl?: string; externalSessionId?: string; + kybMode?: boolean; + region?: string; + kybRegionLocked?: boolean; } function findFiatToken(fiatToken?: string): FiatToken | undefined { @@ -183,6 +186,13 @@ export const useRampUrlParams = (): RampUrlParams => { const fiatParam = searchParams.fiat?.toUpperCase(); const cryptoLockedParam = searchParams.cryptoLocked?.toUpperCase(); const countryCodeParam = searchParams.countryCode?.toUpperCase(); + // `kyb` or `kybLocked` present (any value, including a bare flag) enables KYB mode; a string value is the region code. + // `kybLocked` additionally pins the region and skips the selector. + const kybRegionLocked = searchParams.kybLocked !== undefined; + const kybMode = searchParams.kyb !== undefined || kybRegionLocked; + const lockedRegion = typeof searchParams.kybLocked === "string" ? searchParams.kybLocked.toUpperCase() : undefined; + const kybRegion = typeof searchParams.kyb === "string" ? searchParams.kyb.toUpperCase() : undefined; + const regionParam = lockedRegion || kybRegion; const networkParam = searchParams.network?.toLowerCase(); const providedQuoteId = searchParams.quoteId?.toLowerCase(); @@ -215,11 +225,14 @@ export const useRampUrlParams = (): RampUrlParams => { externalSessionId: externalSessionIdParam || undefined, fiat, inputAmount: inputAmountParam || undefined, + kybMode, + kybRegionLocked, network, partnerId: partnerIdParam || undefined, paymentMethod: paymentMethodParam || undefined, providedQuoteId, rampDirection, + region: regionParam || undefined, walletLocked: walletLockedParam || undefined }; // evmTokensLoaded: triggers re-evaluation of cryptoLocked when dynamic tokens (e.g. WETH, WBTC) finish loading from SquidRouter @@ -242,7 +255,10 @@ export const useSetRampUrlParams = () => { paymentMethod, walletLocked, callbackUrl, - externalSessionId + externalSessionId, + kybMode, + region, + kybRegionLocked } = useRampUrlParams(); const onToggle = useRampDirectionToggle(); @@ -278,6 +294,13 @@ export const useSetRampUrlParams = () => { if (!isWidget) return; if (hasInitialized.current) return; + // KYB deep link: jump straight into the email/OTP → region → KYB flow, no quote needed. + if (kybMode) { + rampActor.send({ locked: kybRegionLocked, region, type: "START_KYB_LINK" }); + hasInitialized.current = true; + return; + } + // Modify the ramp state machine accordingly if (providedQuoteId) { const quote = rampActor.getSnapshot()?.context.quote; diff --git a/apps/frontend/src/machines/actors/brla/createSubaccount.actor.ts b/apps/frontend/src/machines/actors/brla/createSubaccount.actor.ts index b5d69a1fe..1e4203fae 100644 --- a/apps/frontend/src/machines/actors/brla/createSubaccount.actor.ts +++ b/apps/frontend/src/machines/actors/brla/createSubaccount.actor.ts @@ -28,18 +28,22 @@ export const createSubaccountActor = fromPromise( if (!kycFormData) { throw new Error("Invalid input state. This is a Bug."); } - if (!quoteId) { + // The KYB deep link has no quote; the backend treats quoteId as optional, so only require it for the normal flow. + if (!quoteId && !input.isKybLinkMode) { throw new Error("createSubaccountActor: Missing quoteId in input"); } try { ({ subAccountId } = await BrlaService.getUser(taxId)); - try { - maybeKycAttemptStatus = await fetchKycStatus(taxId, quoteId, input.externalSessionId); - } catch (e) { - console.log("Debug: could not fetch kyc status", e); - // It's fine if this fails, we just won't have the status. + // KYB status is tied to a quote; the quote-less KYB deep link has none, so skip this non-fatal lookup. + if (quoteId) { + try { + maybeKycAttemptStatus = await fetchKycStatus(taxId, quoteId, input.externalSessionId); + } catch (e) { + console.log("Debug: could not fetch kyc status", e); + // It's fine if this fails, we just won't have the status. + } } if (isCompany) { diff --git a/apps/frontend/src/machines/kyc.states.ts b/apps/frontend/src/machines/kyc.states.ts index 85199a9af..5ef541081 100644 --- a/apps/frontend/src/machines/kyc.states.ts +++ b/apps/frontend/src/machines/kyc.states.ts @@ -26,6 +26,11 @@ const KYC_CHILD_BY_FIAT: Record = { [FiatToken.COP]: "alfredpayKyc" }; +// In the normal flow the fiat token comes from the quote (executionInput); in the quote-less +// KYB deep-link flow it comes from the region the user picked (kybFiatToken). +const resolveKycFiatToken = (context: RampContext): FiatToken | undefined => + context.executionInput?.fiatToken ?? context.kybFiatToken; + export interface AlfredpayKycContext extends RampContext { verificationUrl?: string; submissionId?: string; @@ -81,7 +86,7 @@ export const kycStateNode = { actions: [ sendTo( ({ context }: { context: RampContext }) => { - const fiatToken = context.executionInput?.fiatToken; + const fiatToken = resolveKycFiatToken(context); return fiatToken ? KYC_CHILD_BY_FIAT[fiatToken] : "aveniaKyc"; }, { type: "SummaryConfirm" } @@ -94,10 +99,12 @@ export const kycStateNode = { invoke: { id: "alfredpayKyc", input: ({ context }: { context: RampContext }): AlfredpayKycContext => { - const fiatToken = context.executionInput?.fiatToken; + const fiatToken = resolveKycFiatToken(context); const country = fiatToken ? (ALFREDPAY_FIAT_TOKEN_TO_COUNTRY[fiatToken] ?? "US") : "US"; return { ...context, + // A KYB deep link is business verification by definition; preselect the business customer type. + business: context.isKybLinkMode || undefined, country }; }, @@ -130,7 +137,8 @@ export const kycStateNode = { return { ...context, kycFormData: context.kycFormData, - taxId: context.executionInput?.taxId ?? "" + // KYB deep link has no quote; fall back to the CNPJ collected on the deep-link tax-id step. + taxId: context.executionInput?.taxId ?? context.kybTaxId ?? "" }; }, onDone: [ @@ -161,13 +169,17 @@ export const kycStateNode = { Deciding: { always: [ { - guard: ({ context }: { context: RampContext }) => - !!context.executionInput?.fiatToken && KYC_CHILD_BY_FIAT[context.executionInput.fiatToken] === "alfredpayKyc", + guard: ({ context }: { context: RampContext }) => { + const fiatToken = resolveKycFiatToken(context); + return !!fiatToken && KYC_CHILD_BY_FIAT[fiatToken] === "alfredpayKyc"; + }, target: "Alfredpay" }, { - guard: ({ context }: { context: RampContext }) => - !!context.executionInput?.fiatToken && KYC_CHILD_BY_FIAT[context.executionInput.fiatToken] === "mykoboKyc", + guard: ({ context }: { context: RampContext }) => { + const fiatToken = resolveKycFiatToken(context); + return !!fiatToken && KYC_CHILD_BY_FIAT[fiatToken] === "mykoboKyc"; + }, target: "Mykobo" }, { @@ -214,9 +226,16 @@ export const kycStateNode = { } }, VerificationComplete: { - always: { - target: "#ramp.KycComplete" - } + always: [ + { + // KYB deep-link flow has no quote/summary to return to — go straight to the success screen. + guard: ({ context }: { context: RampContext }) => !!context.isKybLinkMode, + target: "#ramp.KybLinkComplete" + }, + { + target: "#ramp.KycComplete" + } + ] } } }; diff --git a/apps/frontend/src/machines/ramp.machine.ts b/apps/frontend/src/machines/ramp.machine.ts index f7540af07..f880e64c2 100644 --- a/apps/frontend/src/machines/ramp.machine.ts +++ b/apps/frontend/src/machines/ramp.machine.ts @@ -1,5 +1,6 @@ import { FiatToken, RampDirection } from "@vortexfi/shared"; import { assign, emit, fromCallback, fromPromise, setup } from "xstate"; +import { findKybRegionByCode } from "../constants/kybRegions"; import { ToastMessage } from "../helpers/notifications"; import { AuthService } from "../services/auth"; import { checkEmailActor, requestOTPActor, verifyOTPActor } from "./actors/auth.actor"; @@ -173,6 +174,15 @@ export const rampMachine = setup({ rampSigningPhaseMax: ({ context, event }) => (event.max !== undefined ? event.max : context.rampSigningPhaseMax) }) ] + }, + START_KYB_LINK: { + actions: assign({ + isKybLinkMode: true, + isKybRegionLocked: ({ event }) => event.locked, + kybFiatToken: ({ event }) => findKybRegionByCode(event.region)?.fiatToken, + postAuthTarget: () => "SelectRegion" as const + }), + target: "#ramp.CheckAuth" } }, states: { @@ -201,6 +211,17 @@ export const rampMachine = setup({ guard: ({ event, context }) => event.output.success === true && context.postAuthTarget === "QuoteReady", target: "QuoteReady" }, + { + actions: [ + assign({ + isAuthenticated: true, + userEmail: ({ event }) => event.output.tokens?.userEmail, + userId: ({ event }) => event.output.tokens?.userId + }) + ], + guard: ({ event, context }) => event.output.success === true && context.postAuthTarget === "SelectRegion", + target: "SelectRegion" + }, { actions: [ assign({ @@ -324,6 +345,20 @@ export const rampMachine = setup({ } } }, + // KYB deep-link: Brazil-only step to collect the company CNPJ before entering the Avenia KYB flow. + EnterKybTaxId: { + on: { + // Returns to the selector — but a locked region's SelectRegion.always immediately re-routes back + // through KybRouting, so back is intentionally a no-op when ?kybLocked is set. + GO_BACK: { + target: "SelectRegion" + }, + SUBMIT_KYB_TAX_ID: { + actions: assign({ kybTaxId: ({ event }) => event.taxId }), + target: "KYC" + } + } + }, EnterOTP: { on: { CHANGE_EMAIL: { @@ -407,6 +442,21 @@ export const rampMachine = setup({ InitialFetchFailed: {}, // biome-ignore lint/suspicious/noExplicitAny: child KYC state node is shared across machines and XState cannot infer its event union here. KYC: kycStateNode as any, + // KYB deep-link: terminal success screen shown after a quote-less KYB completes. RESET_RAMP (global) exits. + KybLinkComplete: {}, + // KYB deep-link: single place that routes a chosen region to its next step. Brazil/Avenia needs a CNPJ + // (normally supplied by the quote) collected first; every other region goes straight to the KYC node. + KybRouting: { + always: [ + { + guard: ({ context }) => context.kybFiatToken === FiatToken.BRL, + target: "EnterKybTaxId" + }, + { + target: "KYC" + } + ] + }, KycComplete: { invoke: { input: ({ context }) => ({ context }), @@ -671,6 +721,22 @@ export const rampMachine = setup({ src: "urlCleaner" } }, + SelectRegion: { + // `?kybLocked=` pins the region: if it resolved to a fiat token, skip the selector and route straight in. + always: { + guard: ({ context }) => !!context.isKybRegionLocked && !!context.kybFiatToken, + target: "KybRouting" + }, + on: { + GO_BACK: { + target: "EnterEmail" + }, + SELECT_REGION: { + actions: assign({ kybFiatToken: ({ event }) => event.fiatToken }), + target: "KybRouting" + } + } + }, StartRamp: { invoke: { input: ({ context }) => context, @@ -780,6 +846,27 @@ export const rampMachine = setup({ guard: ({ context }) => context.postAuthTarget === "RegisterRamp", target: "RegisterRamp" }, + { + actions: [ + assign({ + errorMessage: undefined, + isAuthenticated: true, + postAuthTarget: undefined, + userId: ({ event }) => event.output.userId + }), + ({ event, context }) => { + // Store tokens in localStorage for session persistence + AuthService.storeTokens({ + accessToken: event.output.accessToken, + refreshToken: event.output.refreshToken, + userEmail: context.userEmail, + userId: event.output.userId + }); + } + ], + guard: ({ context }) => context.postAuthTarget === "SelectRegion", + target: "SelectRegion" + }, { actions: [ assign({ diff --git a/apps/frontend/src/machines/types.ts b/apps/frontend/src/machines/types.ts index 5e6cc57ed..f2eeae2ac 100644 --- a/apps/frontend/src/machines/types.ts +++ b/apps/frontend/src/machines/types.ts @@ -1,5 +1,5 @@ import { WalletAccount } from "@talismn/connect-wallets"; -import { PaymentData, QuoteResponse, RampDirection } from "@vortexfi/shared"; +import { FiatToken, PaymentData, QuoteResponse, RampDirection } from "@vortexfi/shared"; import { ActorRef, ActorRefFrom, Snapshot, SnapshotFrom } from "xstate"; import { ToastMessage } from "../helpers/notifications"; import { KYCFormData } from "../hooks/brla/useKYCForm"; @@ -45,7 +45,13 @@ export interface RampContext { isAuthenticated: boolean; isAuthLoading?: boolean; alfredpayCustomer?: unknown; - postAuthTarget?: "QuoteReady" | "RegisterRamp"; + postAuthTarget?: "QuoteReady" | "RegisterRamp" | "SelectRegion"; + isKybLinkMode?: boolean; + // Drives KYC provider routing when there is no quote (set from the region selector). + kybFiatToken?: FiatToken; + isKybRegionLocked?: boolean; + // CNPJ collected on the deep-link tax-id step; normally comes from the quote. + kybTaxId?: string; } export type RampMachineEvents = @@ -81,7 +87,10 @@ export type RampMachineEvents = | { type: "AUTH_SUCCESS"; tokens: { accessToken: string; refreshToken: string; userId: string; userEmail?: string } } | { type: "AUTH_ERROR"; error: string } | { type: "LOGOUT" } - | { type: "GO_BACK" }; + | { type: "GO_BACK" } + | { type: "START_KYB_LINK"; region?: string; locked?: boolean } + | { type: "SELECT_REGION"; fiatToken: FiatToken } + | { type: "SUBMIT_KYB_TAX_ID"; taxId: string }; export type RampMachineActor = ActorRef, RampMachineEvents>; export type RampMachineSnapshot = SnapshotFrom; diff --git a/apps/frontend/src/pages/widget/index.tsx b/apps/frontend/src/pages/widget/index.tsx index 0cf6e0428..fec7eeab1 100644 --- a/apps/frontend/src/pages/widget/index.tsx +++ b/apps/frontend/src/pages/widget/index.tsx @@ -6,6 +6,7 @@ import { LoadingScreen } from "../../components/Alfredpay/LoadingScreen"; import { AveniaKYBFlow } from "../../components/Avenia/AveniaKYBFlow"; import { AveniaKYBForm } from "../../components/Avenia/AveniaKYBForm"; import { AveniaKYCForm } from "../../components/Avenia/AveniaKYCForm"; +import { DoneScreen } from "../../components/DoneScreen"; import { MykoboKycFlow } from "../../components/Mykobo/MykoboKycFlow"; import { HistoryMenu } from "../../components/menus/HistoryMenu"; import { SettingsMenu } from "../../components/menus/SettingsMenu"; @@ -14,7 +15,9 @@ import { AuthOTPStep } from "../../components/widget-steps/AuthOTPStep"; import { DetailsStep } from "../../components/widget-steps/DetailsStep"; import { ErrorStep } from "../../components/widget-steps/ErrorStep"; import { InitialQuoteFailedStep } from "../../components/widget-steps/InitialQuoteFailedStep"; +import { KybTaxIdStep } from "../../components/widget-steps/KybTaxIdStep"; import { RampFollowUpRedirectStep } from "../../components/widget-steps/RampFollowUpRedirectStep"; +import { RegionSelectStep } from "../../components/widget-steps/RegionSelectStep"; import { SummaryStep } from "../../components/widget-steps/SummaryStep"; import { FiatAccountMachineContext, useFiatAccountSelector } from "../../contexts/FiatAccountMachineContext"; import { @@ -62,16 +65,31 @@ const WidgetContent = () => { // Enable session persistence and auto-refresh useAuthTokens(rampActor); - const { rampState, isRedirectCallback, isError, isInitialQuoteFailed, isAuthEmail, isLoadingAuthEmail, isAuthOTP } = - useSelector(rampActor, state => ({ - isAuthEmail: state.matches("EnterEmail") || state.matches("CheckingEmail") || state.matches("RequestingOTP"), - isAuthOTP: state.matches("EnterOTP") || state.matches("VerifyingOTP"), - isError: state.matches("Error"), - isInitialQuoteFailed: state.matches("InitialFetchFailed"), - isLoadingAuthEmail: state.matches("CheckAuth"), - isRedirectCallback: state.matches("RedirectCallback"), - rampState: state.value - })); + const { + rampState, + isRedirectCallback, + isError, + isInitialQuoteFailed, + isAuthEmail, + isLoadingAuthEmail, + isAuthOTP, + isSelectRegion, + isEnterKybTaxId, + isKybComplete, + isKybLinkMode + } = useSelector(rampActor, state => ({ + isAuthEmail: state.matches("EnterEmail") || state.matches("CheckingEmail") || state.matches("RequestingOTP"), + isAuthOTP: state.matches("EnterOTP") || state.matches("VerifyingOTP"), + isEnterKybTaxId: state.matches("EnterKybTaxId"), + isError: state.matches("Error"), + isInitialQuoteFailed: state.matches("InitialFetchFailed"), + isKybComplete: state.matches("KybLinkComplete"), + isKybLinkMode: state.context.isKybLinkMode, + isLoadingAuthEmail: state.matches("CheckAuth"), + isRedirectCallback: state.matches("RedirectCallback"), + isSelectRegion: state.matches("SelectRegion"), + rampState: state.value + })); const rampSummaryVisible = rampState === "KycComplete" || rampState === "RegisterRamp" || rampState === "UpdateRamp" || rampState === "StartRamp"; @@ -84,6 +102,10 @@ const WidgetContent = () => { return ; } + if (isKybComplete) { + return rampActor.send({ type: "RESET_RAMP" })} />; + } + if (isRedirectCallback) { return ; } @@ -96,6 +118,14 @@ const WidgetContent = () => { return ; } + if (isSelectRegion) { + return ; + } + + if (isEnterKybTaxId) { + return ; + } + if (rampSummaryVisible) { if (showFiatAccountRegistration && fiatRegistrationCountry) { return ; @@ -105,14 +135,16 @@ const WidgetContent = () => { if (aveniaKycActor) { const isCnpj = aveniaState?.context.taxId ? isValidCnpj(aveniaState.context.taxId) : false; + // A KYB deep link has no quote-supplied taxId yet, so route to the company (KYB) flow regardless of CNPJ. + const treatAsKyb = isCnpj || !!isKybLinkMode; - const isInKybFlow = isCnpj && isInCompoundState(aveniaState?.stateValue, "KYBFlow"); + const isInKybFlow = treatAsKyb && isInCompoundState(aveniaState?.stateValue, "KYBFlow"); if (isInKybFlow) { return ; } - return isCnpj ? : ; + return treatAsKyb ? : ; } if (alfredpayKycActor) { diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index dc3e4fe66..1c2a9b0c1 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -508,6 +508,20 @@ "subtitle": "Please upload a photo or scan of the representative's ID document.", "title": "Representative ID ({{current}} of {{total}})" }, + "kybTaxIdStep": { + "continue": "Continue", + "description": "Enter your company's CNPJ to begin business verification.", + "fields": { + "cnpj": { + "label": "CNPJ", + "placeholder": "00.000.000/0000-00" + } + }, + "title": "Company Tax ID", + "validation": { + "invalidCnpj": "Please enter a valid CNPJ" + } + }, "kycDoneScreen": { "accountVerified": "Your account has been verified. You can now proceed.", "completed": "{{kycOrKyb}} Completed!", @@ -642,6 +656,19 @@ "thankYou": "Thank you!", "title": "Your opinion matters!" }, + "regionSelectStep": { + "continue": "Continue", + "description": "Select the region you want to verify your business for.", + "label": "Region", + "placeholder": "Select a region", + "regions": { + "BR": "Brazil", + "CO": "Colombia", + "MX": "Mexico", + "US": "United States" + }, + "title": "Select Your Region" + }, "SummaryPage": { "BRLOnrampDetails": { "copyCode": "or copy the PIX code below and paste it in your bank app", diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index ff06500bb..9962d4e9e 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -512,6 +512,20 @@ "subtitle": "Por favor, envie uma foto ou digitalização do documento de identidade do representante.", "title": "Documento do Representante ({{current}} de {{total}})" }, + "kybTaxIdStep": { + "continue": "Continuar", + "description": "Informe o CNPJ da sua empresa para iniciar a verificação empresarial.", + "fields": { + "cnpj": { + "label": "CNPJ", + "placeholder": "00.000.000/0000-00" + } + }, + "title": "CNPJ da Empresa", + "validation": { + "invalidCnpj": "Por favor, informe um CNPJ válido" + } + }, "kycDoneScreen": { "accountVerified": "Sua conta foi verificada. Você pode prosseguir agora.", "completed": "{{kycOrKyb}} Concluído!", @@ -646,6 +660,19 @@ "thankYou": "Obrigado!", "title": "Sua opinião é importante!" }, + "regionSelectStep": { + "continue": "Continuar", + "description": "Selecione a região para a qual deseja verificar sua empresa.", + "label": "Região", + "placeholder": "Selecione uma região", + "regions": { + "BR": "Brasil", + "CO": "Colômbia", + "MX": "México", + "US": "Estados Unidos" + }, + "title": "Selecione sua região" + }, "SummaryPage": { "BRLOnrampDetails": { "copyCode": "Ou copie o código PIX abaixo, selecione PIX no app do seu banco e cole o código fornecido.", diff --git a/apps/frontend/src/types/searchParams.ts b/apps/frontend/src/types/searchParams.ts index 9f8a2cae7..79cc6843e 100644 --- a/apps/frontend/src/types/searchParams.ts +++ b/apps/frontend/src/types/searchParams.ts @@ -19,6 +19,11 @@ export const rampSearchSchema = z.object({ externalSessionId: z.string().optional(), fiat: z.string().optional(), inputAmount: stringOrNumberParam, + // KYB deep link, no quote. Presence enables it; a region-code value (e.g. `?kyb=BR`) prefills the selector. + // Union with boolean so a bare `?kyb` flag validates too. + kyb: z.union([z.string(), z.boolean()]).optional(), + // Like `kyb`, but the region is locked in and the selector is skipped (e.g. `?kybLocked=BR`). + kybLocked: z.union([z.string(), z.boolean()]).optional(), network: z.string().optional(), partnerId: z.string().optional(), paymentMethod: z.string().optional(), From 95031378b2c413851c4e5054797190198277e167 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Thu, 11 Jun 2026 09:51:22 +0100 Subject: [PATCH 029/131] fix(frontend): KYB deep-link back button returns to region selector --- apps/frontend/src/machines/kyc.states.ts | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/apps/frontend/src/machines/kyc.states.ts b/apps/frontend/src/machines/kyc.states.ts index 5ef541081..a89ed0134 100644 --- a/apps/frontend/src/machines/kyc.states.ts +++ b/apps/frontend/src/machines/kyc.states.ts @@ -75,13 +75,27 @@ export interface MykoboKycContext extends RampContext { type MykoboKycOutput = { profileApproved?: boolean; error?: MykoboKycMachineError }; +const clearSigningPhase = assign({ + rampSigningPhase: undefined, + rampSigningPhaseCurrent: undefined, + rampSigningPhaseMax: undefined +}); + export const kycStateNode = { initial: "Deciding", on: { - GO_BACK: { - actions: [assign({ rampSigningPhase: undefined, rampSigningPhaseCurrent: undefined, rampSigningPhaseMax: undefined })], - target: "#ramp.QuoteReady" - }, + GO_BACK: [ + { + // KYB deep link has no quote to return to — go back to the region selector instead. + actions: [clearSigningPhase], + guard: ({ context }: { context: RampContext }) => !!context.isKybLinkMode, + target: "#ramp.SelectRegion" + }, + { + actions: [clearSigningPhase], + target: "#ramp.QuoteReady" + } + ], SummaryConfirm: { actions: [ sendTo( From fd50982d20f8aedf1868673659c3438382b85bc6 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Thu, 11 Jun 2026 13:53:54 +0100 Subject: [PATCH 030/131] core fix: kybLink consolidation + reset bug + machine cleanups --- .../widget-steps/RegionSelectStep/index.tsx | 4 +- .../actors/brla/createSubaccount.actor.ts | 2 +- apps/frontend/src/machines/kyc.states.ts | 16 +- apps/frontend/src/machines/ramp.context.ts | 1 + apps/frontend/src/machines/ramp.machine.ts | 148 ++++++------------ apps/frontend/src/machines/types.ts | 15 +- apps/frontend/src/pages/widget/index.tsx | 4 +- 7 files changed, 73 insertions(+), 117 deletions(-) diff --git a/apps/frontend/src/components/widget-steps/RegionSelectStep/index.tsx b/apps/frontend/src/components/widget-steps/RegionSelectStep/index.tsx index 19c974635..765742058 100644 --- a/apps/frontend/src/components/widget-steps/RegionSelectStep/index.tsx +++ b/apps/frontend/src/components/widget-steps/RegionSelectStep/index.tsx @@ -31,7 +31,7 @@ const RegionOption = ({ region }: { region: KybRegion }) => { export const RegionSelectStep = ({ className }: RegionSelectStepProps) => { const { t } = useTranslation(); const rampActor = useRampActor(); - const presetFiatToken = useSelector(rampActor, state => state.context.kybFiatToken); + const presetFiatToken = useSelector(rampActor, state => state.context.kybLink?.fiatToken); const presetRegion = availableRegions.find(region => region.fiatToken === presetFiatToken); const [selected, setSelected] = useState(presetRegion); @@ -70,7 +70,7 @@ export const RegionSelectStep = ({ className }: RegionSelectStepProps) => { {availableRegions.map(region => ( - + {open && ( Date: Thu, 11 Jun 2026 13:54:52 +0100 Subject: [PATCH 035/131] feat(frontend): default widget locale from pinned KYB region (?kybLocked=BR -> pt-BR) --- apps/frontend/src/routes/{-$locale}.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/frontend/src/routes/{-$locale}.tsx b/apps/frontend/src/routes/{-$locale}.tsx index de74df79f..e45004b28 100644 --- a/apps/frontend/src/routes/{-$locale}.tsx +++ b/apps/frontend/src/routes/{-$locale}.tsx @@ -1,12 +1,13 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; import i18n from "i18next"; +import { findKybRegionByCode } from "../constants/kybRegions"; import { Language } from "../translations/helpers"; // Define valid locales const VALID_LOCALES = [Language.English, Language.Portuguese_Brazil]; export const Route = createFileRoute("/{-$locale}")({ - beforeLoad: async ({ params }) => { + beforeLoad: async ({ params, location }) => { const { locale } = params; // Normalize locale to handle case-insensitivity (pt-br vs pt-BR) @@ -21,8 +22,13 @@ export const Route = createFileRoute("/{-$locale}")({ }); } + // A region-pinned KYB deep link (e.g. `?kybLocked=BR`) targets businesses in that region — fall back + // to the region's default locale. An explicit locale in the path still wins. + const { kybLocked } = location.search as { kybLocked?: unknown }; + const kybRegion = typeof kybLocked === "string" ? findKybRegionByCode(kybLocked) : undefined; + // Use matched locale or default to English - const currentLocale = validLocale || Language.English; + const currentLocale = validLocale || kybRegion?.defaultLocale || Language.English; // Update i18n language await i18n.changeLanguage(currentLocale); From d2c47860f955d160a54cf58905e7c4c9d3535bc0 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Mon, 15 Jun 2026 16:25:24 +0200 Subject: [PATCH 036/131] feat(frontend): collect Brazil KYB CNPJ on the company form Drop the separate CNPJ-only step and enter the CNPJ together with the company name on the Avenia KYB form (editable only in the quote-less deep link; read-only when supplied by a quote). Validate it as CNPJ-only via a new useKYBForm requireCnpj flag so a business deep link can't resolve to an individual account. Only honor ?kybLocked= when the region code is valid, otherwise fall back to the open selector, and fix the region-code JSDoc. --- .../src/components/Avenia/AveniaKYBForm.tsx | 13 ++- .../widget-steps/KybTaxIdStep/index.tsx | 81 ------------------- apps/frontend/src/constants/kybRegions.ts | 2 +- .../src/hooks/brla/useKYBForm/index.tsx | 13 ++- apps/frontend/src/machines/kyc.states.ts | 4 +- apps/frontend/src/machines/ramp.machine.ts | 43 +++------- apps/frontend/src/machines/types.ts | 5 +- apps/frontend/src/pages/widget/index.tsx | 7 -- apps/frontend/src/translations/en.json | 15 +--- apps/frontend/src/translations/pt.json | 15 +--- 10 files changed, 34 insertions(+), 164 deletions(-) delete mode 100644 apps/frontend/src/components/widget-steps/KybTaxIdStep/index.tsx diff --git a/apps/frontend/src/components/Avenia/AveniaKYBForm.tsx b/apps/frontend/src/components/Avenia/AveniaKYBForm.tsx index ed90ea3a4..80536773a 100644 --- a/apps/frontend/src/components/Avenia/AveniaKYBForm.tsx +++ b/apps/frontend/src/components/Avenia/AveniaKYBForm.tsx @@ -15,12 +15,17 @@ export const AveniaKYBForm = () => { initialData: { fullName: aveniaState?.context.kycFormData?.fullName, taxId: aveniaState?.context.taxId - } + }, + // No quote-supplied tax ID means the user types it on this form; KYB is business-only, so require a CNPJ. + requireCnpj: !aveniaState?.context.taxId }); if (!aveniaState) return null; if (!aveniaKycActor) return null; - if (!aveniaState.context.taxId) { + // Quoted flow pre-supplies the CNPJ from the quote; the KYB deep-link flow has no quote, so the CNPJ + // is entered here together with the company name. Render whenever either source applies. + const hasQuoteTaxId = !!aveniaState.context.taxId; + if (!hasQuoteTaxId && !aveniaState.context.kybLink) { return null; } @@ -37,8 +42,8 @@ export const AveniaKYBForm = () => { id: ExtendedAveniaFieldOptions.TAX_ID, index: 1, label: "CNPJ", - placeholder: "", - readOnly: true, + placeholder: "00.000.000/0000-00", + readOnly: hasQuoteTaxId, required: true, type: "text" } diff --git a/apps/frontend/src/components/widget-steps/KybTaxIdStep/index.tsx b/apps/frontend/src/components/widget-steps/KybTaxIdStep/index.tsx deleted file mode 100644 index 8e5569112..000000000 --- a/apps/frontend/src/components/widget-steps/KybTaxIdStep/index.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { isValidCnpj } from "@vortexfi/shared"; -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { useRampActor } from "../../../contexts/rampState"; -import { cn } from "../../../helpers/cn"; -import { MenuButtons } from "../../MenuButtons"; -import { StepFooter } from "../../StepFooter"; - -export interface KybTaxIdStepProps { - className?: string; -} - -export const KybTaxIdStep = ({ className }: KybTaxIdStepProps) => { - const { t } = useTranslation(); - const rampActor = useRampActor(); - const [taxId, setTaxId] = useState(""); - const [localError, setLocalError] = useState(""); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - - const trimmed = taxId.trim(); - if (!isValidCnpj(trimmed)) { - setLocalError(t("components.kybTaxIdStep.validation.invalidCnpj")); - return; - } - - setLocalError(""); - rampActor.send({ taxId: trimmed, type: "SUBMIT_KYB_TAX_ID" }); - }; - - return ( -
-
- -
- -
-
-

{t("components.kybTaxIdStep.title")}

-

{t("components.kybTaxIdStep.description")}

-
- -
-
-
- - setTaxId(e.target.value)} - placeholder={t("components.kybTaxIdStep.fields.cnpj.placeholder")} - type="text" - value={taxId} - /> - {localError && ( - - {localError} - - )} -
-
-
-
- - - - -
- ); -}; diff --git a/apps/frontend/src/constants/kybRegions.ts b/apps/frontend/src/constants/kybRegions.ts index d5fb38e2f..9eceba55e 100644 --- a/apps/frontend/src/constants/kybRegions.ts +++ b/apps/frontend/src/constants/kybRegions.ts @@ -2,7 +2,7 @@ import { FiatToken } from "@vortexfi/shared"; import { Language } from "../translations/helpers"; export interface KybRegion { - /** Region code used by the `?region=` deep-link param, e.g. "BR". */ + /** Region code used by the `?kyb=` / `?kybLocked=` deep-link param, e.g. "BR". */ code: string; /** i18n key for the region's display name. */ labelKey: string; diff --git a/apps/frontend/src/hooks/brla/useKYBForm/index.tsx b/apps/frontend/src/hooks/brla/useKYBForm/index.tsx index 2d9f029ef..8d2aba3de 100644 --- a/apps/frontend/src/hooks/brla/useKYBForm/index.tsx +++ b/apps/frontend/src/hooks/brla/useKYBForm/index.tsx @@ -6,12 +6,15 @@ import { z } from "zod"; import { ExtendedAveniaFieldOptions } from "../../../components/Avenia/AveniaField"; import { useTaxId } from "../../../stores/quote/useQuoteFormStore"; -const createKybFormSchema = (t: (key: string) => string) => +const createKybFormSchema = (t: (key: string) => string, requireCnpj: boolean) => z.object({ [ExtendedAveniaFieldOptions.TAX_ID]: z .string() .min(1, t("components.brlaExtendedForm.validation.taxId.required")) - .refine(value => isValidCpf(value) || isValidCnpj(value), t("components.brlaExtendedForm.validation.taxId.format")), + .refine( + value => (requireCnpj ? isValidCnpj(value) : isValidCpf(value) || isValidCnpj(value)), + t(`components.brlaExtendedForm.validation.taxId.${requireCnpj ? "cnpjFormat" : "format"}`) + ), [ExtendedAveniaFieldOptions.FULL_NAME]: z.string().min(3, t("components.brlaExtendedForm.validation.fullName.minLength")) }); @@ -19,9 +22,11 @@ export type KYBFormData = z.infer>; export interface UseKYBFormProps { initialData?: Partial; + // KYB is business-only; the quote-less deep link lets the user type the tax ID, so restrict it to CNPJ. + requireCnpj?: boolean; } -export const useKYBForm = ({ initialData }: UseKYBFormProps) => { +export const useKYBForm = ({ initialData, requireCnpj = false }: UseKYBFormProps) => { const { t } = useTranslation(); const taxIdFromStore = useTaxId(); @@ -31,7 +36,7 @@ export const useKYBForm = ({ initialData }: UseKYBFormProps) => { [ExtendedAveniaFieldOptions.FULL_NAME]: initialData?.fullName || "" }, mode: "onBlur", - resolver: standardSchemaResolver(createKybFormSchema(t)) + resolver: standardSchemaResolver(createKybFormSchema(t, requireCnpj)) }); return { kybForm }; diff --git a/apps/frontend/src/machines/kyc.states.ts b/apps/frontend/src/machines/kyc.states.ts index 90a34be33..a0dc8d97d 100644 --- a/apps/frontend/src/machines/kyc.states.ts +++ b/apps/frontend/src/machines/kyc.states.ts @@ -155,8 +155,8 @@ export const kycStateNode = { return { ...context, kycFormData: context.kycFormData, - // KYB deep link has no quote; fall back to the CNPJ collected on the deep-link tax-id step. - taxId: context.executionInput?.taxId ?? context.kybLink?.taxId ?? "" + // KYB deep link has no quote-supplied taxId; the CNPJ is collected on the Avenia company form instead. + taxId: context.executionInput?.taxId ?? "" }; }, onDone: [ diff --git a/apps/frontend/src/machines/ramp.machine.ts b/apps/frontend/src/machines/ramp.machine.ts index afabe7aae..fd669849c 100644 --- a/apps/frontend/src/machines/ramp.machine.ts +++ b/apps/frontend/src/machines/ramp.machine.ts @@ -177,10 +177,14 @@ export const rampMachine = setup({ }, START_KYB_LINK: { actions: assign({ - kybLink: ({ event }) => ({ - fiatToken: findKybRegionByCode(event.region)?.fiatToken, - regionLocked: event.locked - }), + kybLink: ({ event }) => { + const region = findKybRegionByCode(event.region); + return { + fiatToken: region?.fiatToken, + // Only honor the lock when the region code is valid; an unknown code degrades to the open selector. + regionLocked: !!event.locked && region !== undefined + }; + }, postAuthTarget: () => "SelectRegion" as const }), target: "#ramp.CheckAuth" @@ -324,20 +328,6 @@ export const rampMachine = setup({ } } }, - // KYB deep-link: Brazil-only step to collect the company CNPJ before entering the Avenia KYB flow. - EnterKybTaxId: { - on: { - // Back returns to the selector; with `?kybLocked=` the region is pinned, so back does nothing. - GO_BACK: { - guard: ({ context }) => !context.kybLink?.regionLocked, - target: "SelectRegion" - }, - SUBMIT_KYB_TAX_ID: { - actions: assign({ kybLink: ({ context, event }) => ({ ...context.kybLink, taxId: event.taxId }) }), - target: "KYC" - } - } - }, EnterOTP: { on: { CHANGE_EMAIL: { @@ -423,19 +413,6 @@ export const rampMachine = setup({ KYC: kycStateNode as any, // KYB deep-link: terminal success screen shown after a quote-less KYB completes. RESET_RAMP (global) exits. KybLinkComplete: {}, - // KYB deep-link: single place that routes a chosen region to its next step. Brazil/Avenia needs a CNPJ - // (normally supplied by the quote) collected first; every other region goes straight to the KYC node. - KybRouting: { - always: [ - { - guard: ({ context }) => context.kybLink?.fiatToken === FiatToken.BRL, - target: "EnterKybTaxId" - }, - { - target: "KYC" - } - ] - }, KycComplete: { invoke: { input: ({ context }) => ({ context }), @@ -721,12 +698,12 @@ export const rampMachine = setup({ // `?kybLocked=` pins the region: if it resolved to a fiat token, skip the selector and route straight in. always: { guard: ({ context }) => !!context.kybLink?.regionLocked && !!context.kybLink.fiatToken, - target: "KybRouting" + target: "KYC" }, on: { SELECT_REGION: { actions: assign({ kybLink: ({ context, event }) => ({ ...context.kybLink, fiatToken: event.fiatToken }) }), - target: "KybRouting" + target: "KYC" } } }, diff --git a/apps/frontend/src/machines/types.ts b/apps/frontend/src/machines/types.ts index 838d5042a..80b42498c 100644 --- a/apps/frontend/src/machines/types.ts +++ b/apps/frontend/src/machines/types.ts @@ -52,8 +52,6 @@ export interface RampContext { fiatToken?: FiatToken; // `?kybLocked=` pins the region; going back to the selector is disabled. regionLocked?: boolean; - // CNPJ collected on the deep-link tax-id step; normally comes from the quote. - taxId?: string; }; } @@ -92,8 +90,7 @@ export type RampMachineEvents = | { type: "LOGOUT" } | { type: "GO_BACK" } | { type: "START_KYB_LINK"; region?: string; locked?: boolean } - | { type: "SELECT_REGION"; fiatToken: FiatToken } - | { type: "SUBMIT_KYB_TAX_ID"; taxId: string }; + | { type: "SELECT_REGION"; fiatToken: FiatToken }; export type RampMachineActor = ActorRef, RampMachineEvents>; export type RampMachineSnapshot = SnapshotFrom; diff --git a/apps/frontend/src/pages/widget/index.tsx b/apps/frontend/src/pages/widget/index.tsx index 8ee1c86a4..d171bbfa9 100644 --- a/apps/frontend/src/pages/widget/index.tsx +++ b/apps/frontend/src/pages/widget/index.tsx @@ -15,7 +15,6 @@ import { AuthOTPStep } from "../../components/widget-steps/AuthOTPStep"; import { DetailsStep } from "../../components/widget-steps/DetailsStep"; import { ErrorStep } from "../../components/widget-steps/ErrorStep"; import { InitialQuoteFailedStep } from "../../components/widget-steps/InitialQuoteFailedStep"; -import { KybTaxIdStep } from "../../components/widget-steps/KybTaxIdStep"; import { RampFollowUpRedirectStep } from "../../components/widget-steps/RampFollowUpRedirectStep"; import { RegionSelectStep } from "../../components/widget-steps/RegionSelectStep"; import { SummaryStep } from "../../components/widget-steps/SummaryStep"; @@ -74,13 +73,11 @@ const WidgetContent = () => { isLoadingAuthEmail, isAuthOTP, isSelectRegion, - isEnterKybTaxId, isKybComplete, isKybLinkMode } = useSelector(rampActor, state => ({ isAuthEmail: state.matches("EnterEmail") || state.matches("CheckingEmail") || state.matches("RequestingOTP"), isAuthOTP: state.matches("EnterOTP") || state.matches("VerifyingOTP"), - isEnterKybTaxId: state.matches("EnterKybTaxId"), isError: state.matches("Error"), isInitialQuoteFailed: state.matches("InitialFetchFailed"), isKybComplete: state.matches("KybLinkComplete"), @@ -122,10 +119,6 @@ const WidgetContent = () => { return ; } - if (isEnterKybTaxId) { - return ; - } - if (rampSummaryVisible) { if (showFiatAccountRegistration && fiatRegistrationCountry) { return ; diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index 1c2a9b0c1..cd3a2d373 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -508,20 +508,6 @@ "subtitle": "Please upload a photo or scan of the representative's ID document.", "title": "Representative ID ({{current}} of {{total}})" }, - "kybTaxIdStep": { - "continue": "Continue", - "description": "Enter your company's CNPJ to begin business verification.", - "fields": { - "cnpj": { - "label": "CNPJ", - "placeholder": "00.000.000/0000-00" - } - }, - "title": "Company Tax ID", - "validation": { - "invalidCnpj": "Please enter a valid CNPJ" - } - }, "kycDoneScreen": { "accountVerified": "Your account has been verified. You can now proceed.", "completed": "{{kycOrKyb}} Completed!", @@ -780,6 +766,7 @@ "required": "PIX key is required when transferring BRL" }, "taxId": { + "cnpjFormat": "Invalid CNPJ format", "format": "Invalid CPF or CNPJ format", "required": "CPF or CNPJ is required when transferring BRL" }, diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index 9962d4e9e..da91d9e43 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -512,20 +512,6 @@ "subtitle": "Por favor, envie uma foto ou digitalização do documento de identidade do representante.", "title": "Documento do Representante ({{current}} de {{total}})" }, - "kybTaxIdStep": { - "continue": "Continuar", - "description": "Informe o CNPJ da sua empresa para iniciar a verificação empresarial.", - "fields": { - "cnpj": { - "label": "CNPJ", - "placeholder": "00.000.000/0000-00" - } - }, - "title": "CNPJ da Empresa", - "validation": { - "invalidCnpj": "Por favor, informe um CNPJ válido" - } - }, "kycDoneScreen": { "accountVerified": "Sua conta foi verificada. Você pode prosseguir agora.", "completed": "{{kycOrKyb}} Concluído!", @@ -784,6 +770,7 @@ "required": "Chave PIX é obrigatória para transferências em BRL" }, "taxId": { + "cnpjFormat": "CNPJ inválido", "format": "CPF o CNPJ inválido", "required": "CPF o CNPJ é obrigatório para transferências em BRL" }, From b427adddf7677a9a08d395bc3b78ae91f5158006 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Mon, 15 Jun 2026 16:25:49 +0200 Subject: [PATCH 037/131] fix(frontend): route MX/CO business KYB deep links to the company form In Alfredpay CheckingStatus, send business customers on API-based countries (MX/CO) to FillingKybForm instead of the individual KYC form, mirroring the existing CreatingCustomer branch. --- apps/frontend/src/machines/alfredpayKyc.machine.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/frontend/src/machines/alfredpayKyc.machine.ts b/apps/frontend/src/machines/alfredpayKyc.machine.ts index 3ff7c7686..2d459bcf1 100644 --- a/apps/frontend/src/machines/alfredpayKyc.machine.ts +++ b/apps/frontend/src/machines/alfredpayKyc.machine.ts @@ -374,6 +374,11 @@ export const alfredpayKycMachine = setup({ event.output.status === AlfredPayStatus.Failed || event.output.status === AlfredPayStatus.UpdateRequired, target: "FailureKyc" }, + { + // Business (KYB deep link) on API-based countries → company KYB form, not the individual KYC form. + guard: ({ context }) => (context.country === "MX" || context.country === "CO") && !!context.business, + target: "FillingKybForm" + }, { // MXN and CO use API-based form, not iFrame link guard: ({ context }) => context.country === "MX" || context.country === "CO", From 95f0f9fa607fced12b97463e6a55399f8302211b Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Mon, 15 Jun 2026 16:26:00 +0200 Subject: [PATCH 038/131] fix(frontend): hide back button on locked KYB entry screen With ?kybLocked= the region selector is skipped, so the first KYB screen has nothing to go back to and its GO_BACK is a guarded no-op. Hide the button there while keeping it on deeper KYB steps that own their back navigation. --- apps/frontend/src/hooks/useStepBackNavigation.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/frontend/src/hooks/useStepBackNavigation.ts b/apps/frontend/src/hooks/useStepBackNavigation.ts index 92843e761..7e298394e 100644 --- a/apps/frontend/src/hooks/useStepBackNavigation.ts +++ b/apps/frontend/src/hooks/useStepBackNavigation.ts @@ -23,6 +23,7 @@ export const useStepBackNavigation = () => { const rampState = useSelector(rampActor, state => state.value); const enteredViaForm = useSelector(rampActor, state => state.context.enteredViaForm); + const regionLocked = useSelector(rampActor, state => !!state.context.kybLink?.regionLocked); const searchParams = useSearch({ strict: false }); const isExternalProviderEntry = !!searchParams.externalSessionId; @@ -47,11 +48,23 @@ export const useStepBackNavigation = () => { } }, [rampActor, hasQuoteIdInUrl, rampState, enteredViaForm]); + // With `?kybLocked=` the region selector is skipped, so the first KYB screen has nothing to go back to + // (the parent KYC `GO_BACK` is a guarded no-op when locked). Deeper KYB steps that own their back + // navigation — the same ones `handleBack` forwards to the child machine — keep the button. + const isKybInternalBackStep = + (!!aveniaState && + (aveniaState.stateValue === "DocumentUpload" || + aveniaState.stateValue === "LivenessCheck" || + isInCompoundState(aveniaState.stateValue, "KYBFlow"))) || + (!!alfredpayKycState && alfredpayKycState.stateValue === "UploadingDocuments"); + const hideForLockedKyb = regionLocked && isInCompoundState(rampState, "KYC") && !isKybInternalBackStep; + const shouldHide = rampState === "RampFollowUp" || rampState === "RedirectCallback" || // The region selector is the root of the KYB deep-link flow — there is nothing to go back to. rampState === "SelectRegion" || + hideForLockedKyb || isExternalProviderEntry || (rampState === "QuoteReady" && !enteredViaForm); From 7ad765389336859ee23f9ddd32afa3c999c3c586 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Mon, 15 Jun 2026 16:26:08 +0200 Subject: [PATCH 039/131] docs(api): document quote-less KYB deep link Make createSubaccount quoteId optional in the OpenAPI spec, add a KYB Deep Link guide page (manifest entry included), and note quote-less subaccount creation in the BRLA security spec. --- docs/api/apidog/page-manifest.json | 6 ++ docs/api/openapi/vortex.openapi.json | 6 +- docs/api/pages/13-kyb-deep-link.md | 67 ++++++++++++++++++++++ docs/security-spec/05-integrations/brla.md | 4 +- 4 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 docs/api/pages/13-kyb-deep-link.md diff --git a/docs/api/apidog/page-manifest.json b/docs/api/apidog/page-manifest.json index fbd63cee7..c9bac3280 100644 --- a/docs/api/apidog/page-manifest.json +++ b/docs/api/apidog/page-manifest.json @@ -107,6 +107,12 @@ "slug": "ai-agent-integration", "source": "docs/api/pages/12-ai-agent-integration.md", "title": "AI Agent Integration" + }, + { + "order": 13, + "slug": "kyb-deep-link", + "source": "docs/api/pages/13-kyb-deep-link.md", + "title": "KYB Deep Link" } ], "publicDocsUrl": "https://api-docs.vortexfinance.co/" diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index 3dcecca48..6177d1aca 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -299,6 +299,10 @@ "phone": { "type": "string" }, + "quoteId": { + "description": "Optional. The quote that triggered onboarding. Omit it for the quote-less KYB deep link (`?kyb` / `?kybLocked` widget entry), where business verification starts before any quote exists. Stored only as onboarding provenance; it is not an authorization input.", + "type": ["string", "null"] + }, "startDate": { "description": "Date must be in format YYYY-MMM-DD.", "format": "date", @@ -1243,7 +1247,7 @@ "/v1/brla/createSubaccount": { "post": { "deprecated": false, - "description": "`companyName`, `startDate` and `cnpj` are only required when taxIdType is `CNPJ`\n\n**Auth:** uses `optionalAuth` \u2014 accepts a Supabase Bearer token if present but does not require one.", + "description": "`companyName`, `startDate` and `cnpj` are only required when taxIdType is `CNPJ`\n\n`quoteId` is optional: pass it in the normal ramp flow, or omit it for the quote-less KYB deep link where business verification starts before any quote exists.\n\n**Auth:** uses `optionalAuth` \u2014 accepts a Supabase Bearer token if present but does not require one.", "operationId": "createSubaccount", "parameters": [], "requestBody": { diff --git a/docs/api/pages/13-kyb-deep-link.md b/docs/api/pages/13-kyb-deep-link.md new file mode 100644 index 000000000..9bd3beb7c --- /dev/null +++ b/docs/api/pages/13-kyb-deep-link.md @@ -0,0 +1,67 @@ +# KYB Deep Link + +The KYB deep link takes a business user straight into KYB (Know Your Business) verification from a single widget URL — **no quote required**. Use it when you want a partner's users to complete business verification ahead of, or independently from, any ramp. + +It is a variant of the [hosted widget](https://api-docs.vortexfinance.co/widget-integration): instead of `POST /v1/session/create`, you link the user directly to the widget with a KYB query parameter. + +## Flow + +``` +?kyb / ?kybLocked → email + OTP sign-in → region selector → provider KYB → "KYB Completed" screen +``` + +- **Brazil** routes to Avenia KYB. The user enters the company name and CNPJ together on the company form, then completes Avenia's hosted company and representative verification. +- **Mexico / Colombia / USA** route to the Alfredpay business KYB form (the business customer type is preselected). +- Europe is intentionally excluded — it is individual KYC only and requires a connected wallet, so it cannot complete a quote-less KYB deep link. + +After verification the user lands on a **KYB Completed** screen. *Continue* returns them to the standard quote form with the session still authenticated and the deep-link parameters stripped from the URL. + +## URL Parameters + +Append one of these to the widget URL (e.g. `https://widget.vortexfinance.co/en/widget?kyb`): + +| URL | Behavior | +|---|---| +| `?kyb` | KYB mode; the region selector is shown. | +| `?kyb=BR` \| `MX` \| `CO` \| `US` | Selector shown with the region preselected. The user can still change it. | +| `?kybLocked=BR` \| `MX` \| `CO` \| `US` | Selector skipped, region pinned, back navigation into the selector disabled. | +| `?kybLocked=BR` (specifically) | Additionally defaults the widget locale to `pt-BR`. An explicit locale in the path still wins (e.g. `/en/widget?kybLocked=BR` stays English). | +| unknown region code (e.g. `?kybLocked=ZZ`) | Degrades gracefully to the open selector; the region is **not** treated as locked. | + +Region codes are case-insensitive. + +## Attribution + +`externalSessionId`, `partnerId`, and `apiKey` are forwarded in KYB mode exactly as in the quoted widget flow, so partner and session attribution work the same way: + +``` +https://widget.vortexfinance.co/en/widget?kybLocked=BR&externalSessionId=my-session-id&partnerId=my-partner&apiKey=pk_live_... +``` + +Pass your partner public key (`pk_live_*` / `pk_test_*`) as `apiKey` for attribution. `externalSessionId` is your own opaque identifier and is echoed back in [webhook payloads](https://api-docs.vortexfinance.co/webhooks). + +## Embedding + +The KYB deep link is a normal widget URL, so the same embed options apply: + +```html + +``` + +```js +window.open( + "https://widget.vortexfinance.co/en/widget?kybLocked=BR&externalSessionId=my-session-id", + "vortex-kyb", + "width=480,height=760" +); +``` + +## Underlying KYB Onboarding + +For Brazil, the deep link drives `POST /v1/brla/createSubaccount` **without** a `quoteId` — the subaccount is created from the company name and CNPJ collected on the form, and the optional quote association is simply omitted. See [BRL / KYC Notes](https://api-docs.vortexfinance.co/brl-kyc-notes) for how BRLA onboarding relates to the ramp flow. + +--- diff --git a/docs/security-spec/05-integrations/brla.md b/docs/security-spec/05-integrations/brla.md index a771bfa8e..a044f24b7 100644 --- a/docs/security-spec/05-integrations/brla.md +++ b/docs/security-spec/05-integrations/brla.md @@ -40,7 +40,9 @@ When the user on-ramps BRL and asks for **BRLA delivered on Base** (input BRL, o ### Subaccount model -Avenia requires a subaccount per user, identified by tax ID (CPF). The system creates/manages subaccounts during ramp registration and maps them via the `TaxId` model (`taxIdRecord.subAccountId`). +Avenia requires a subaccount per user, identified by tax ID (CPF for individuals, CNPJ for businesses). The system creates/manages subaccounts during ramp registration and maps them via the `TaxId` model (`taxIdRecord.subAccountId`). + +`POST /v1/brla/createSubaccount` accepts an **optional** `quoteId`. In the normal ramp flow it is the quote that triggered onboarding; in the **quote-less KYB deep link** (`?kyb` / `?kybLocked` widget entry, where business verification starts before any quote exists) it is omitted. The controller stores it as the nullable `TaxId.initialQuoteId` — a provenance field only. It is never used as an authorization input, so its absence does not weaken any access check: the ownership guard (below) and `optionalAuth` user context gate subaccount creation independently of whether a quote is present. ### The three-amount model (off-ramp) From 441830dca222033741444feae70debe55ac55a20 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 15 Jun 2026 16:44:54 +0200 Subject: [PATCH 040/131] Update rebalancing daily bridge limit to 20,000 --- apps/rebalancer/src/utils/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/rebalancer/src/utils/config.ts b/apps/rebalancer/src/utils/config.ts index e16296ab1..7cc07afbe 100644 --- a/apps/rebalancer/src/utils/config.ts +++ b/apps/rebalancer/src/utils/config.ts @@ -27,7 +27,7 @@ export function getConfig() { rebalancingBrlToUsdAmount: process.env.REBALANCING_BRL_TO_USD_AMOUNT || "1", /// The minimum balance in USDC that the rebalancer account on Base must have to allow the BRLA pool rebalancing. rebalancingBrlToUsdMinBalance: process.env.REBALANCING_BRL_TO_USD_MIN_BALANCE || undefined, - rebalancingDailyBridgeLimitUsd: Number(process.env.REBALANCING_DAILY_BRIDGE_LIMIT_USD) || 10_000, + rebalancingDailyBridgeLimitUsd: Number(process.env.REBALANCING_DAILY_BRIDGE_LIMIT_USD) || 20_000, /// The threshold above and below the optimal coverage ratio at which the rebalancing will be triggered. rebalancingThreshold: Number(process.env.REBALANCING_THRESHOLD) || 0.01, From c88e57295b478e0e514fb3cf0a2257e052c1b9c5 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Mon, 15 Jun 2026 17:39:58 +0200 Subject: [PATCH 041/131] improve basic Sentry config --- apps/frontend/src/main.tsx | 43 ++++++++++--------- apps/frontend/src/services/api/api-client.ts | 4 +- .../src/services/transactions/userSigning.ts | 4 +- apps/frontend/vite.config.ts | 4 +- 4 files changed, 31 insertions(+), 24 deletions(-) diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx index 46cc2998c..8a9045f5b 100644 --- a/apps/frontend/src/main.tsx +++ b/apps/frontend/src/main.tsx @@ -27,25 +27,6 @@ import ptTranslations from "./translations/pt.json"; const queryClient = new QueryClient(); -// Boilerplate code for Sentry -const sentryDsn = import.meta.env.VITE_SENTRY_DSN; -if (sentryDsn) { - Sentry.init({ - dsn: sentryDsn, - enabled: !window.location.hostname.includes("localhost"), // Disable sentry entirely when testing locally - environment: config.isProd ? "production" : "development", - integrations: [Sentry.browserTracingIntegration(), Sentry.replayIntegration()], - replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur. // Capture 100% of the transactions - // Session Replay - replaysSessionSampleRate: 1.0, - // Set 'tracePropagationTargets' to control for which URLs distributed tracing should be enabled - // We allow all to account for different Netlify URLs which are dependant on the branch name - tracePropagationTargets: ["*"], // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production. - // Tracing - tracesSampleRate: 1.0 - }); -} - // Initialize i18n with browser language as default // The actual language will be set by the route's beforeLoad const lng = getBrowserLanguage(); @@ -71,6 +52,24 @@ declare module "@tanstack/react-router" { } } +// Sentry must initialize before the app renders. The TanStack Router tracing +// integration needs the router instance, so init runs after the router is created. +const sentryDsn = import.meta.env.VITE_SENTRY_DSN; +if (sentryDsn) { + Sentry.init({ + dsn: sentryDsn, + enabled: !window.location.hostname.includes("localhost"), // Disable sentry entirely when testing locally + environment: config.isProd ? "production" : "development", + integrations: [Sentry.tanstackRouterBrowserTracingIntegration(router), Sentry.replayIntegration()], + // Capture 100% of sessions where an error occurs; sample plain sessions only in prod. + replaysOnErrorSampleRate: 1.0, + replaysSessionSampleRate: config.isProd ? 0.1 : 1.0, + // Allow all targets to account for different Netlify URLs which depend on the branch name. + tracePropagationTargets: ["*"], + tracesSampleRate: config.isProd ? 0.2 : 1.0 + }); +} + const root = document.getElementById("app"); if (!root) { @@ -80,7 +79,11 @@ if (!root) { // Initialize dynamic EVM tokens from SquidRouter API (falls back to static config on failure) initializeEvmTokens(); -createRoot(root).render( +createRoot(root, { + onCaughtError: Sentry.reactErrorHandler(), + onRecoverableError: Sentry.reactErrorHandler(), + onUncaughtError: Sentry.reactErrorHandler() +}).render( diff --git a/apps/frontend/src/services/api/api-client.ts b/apps/frontend/src/services/api/api-client.ts index 94c94de9b..66f9c66a1 100644 --- a/apps/frontend/src/services/api/api-client.ts +++ b/apps/frontend/src/services/api/api-client.ts @@ -51,7 +51,9 @@ async function apiFetch( if (!response.ok) { const errorData = (await response.json().catch(() => ({}))) as { error?: string; message?: string }; console.error("API Error:", errorData); - throw new ApiError(response.status, errorData, errorData.error ?? errorData.message ?? response.statusText); + const serverMessage = errorData.error ?? errorData.message ?? response.statusText; + // Prefix with method/status/path so Sentry groups by endpoint instead of one generic bucket. + throw new ApiError(response.status, errorData, `${method.toUpperCase()} ${path} (${response.status}): ${serverMessage}`); } if (response.status === 204) return undefined as T; diff --git a/apps/frontend/src/services/transactions/userSigning.ts b/apps/frontend/src/services/transactions/userSigning.ts index e30d40e29..64e2f5e91 100644 --- a/apps/frontend/src/services/transactions/userSigning.ts +++ b/apps/frontend/src/services/transactions/userSigning.ts @@ -163,7 +163,7 @@ export async function signAndSubmitSubstrateTransaction(unsignedTx: UnsignedTx, // Try to find a 'system.ExtrinsicFailed' event if (dispatchError) { - reject("Substrate transaction execution failed"); + reject(new Error(`Substrate transaction execution failed: ${dispatchError.toString()}`)); } resolve(hash); @@ -173,7 +173,7 @@ export async function signAndSubmitSubstrateTransaction(unsignedTx: UnsignedTx, .catch(error => { // Most likely, the user cancelled the signing process. console.error("Error signing and submitting transaction", error); - reject("Error signing and sending transaction:" + error); + reject(error instanceof Error ? error : new Error(`Error signing and submitting transaction: ${String(error)}`)); }); }); } diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts index fe6b73253..19f54d97f 100644 --- a/apps/frontend/vite.config.ts +++ b/apps/frontend/vite.config.ts @@ -7,7 +7,9 @@ import { defineConfig } from "vite"; export default defineConfig({ build: { - sourcemap: true, + // "hidden" uploads source maps to Sentry without leaving sourceMappingURL + // comments in the shipped bundles. + sourcemap: "hidden", target: "esnext" }, define: { From d8b3484965a0824e85ce66a9caf8ca84ed923d76 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 15 Jun 2026 19:57:28 +0200 Subject: [PATCH 042/131] Change default daily bridge limit back to 10k --- apps/rebalancer/src/utils/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/rebalancer/src/utils/config.ts b/apps/rebalancer/src/utils/config.ts index 7cc07afbe..e16296ab1 100644 --- a/apps/rebalancer/src/utils/config.ts +++ b/apps/rebalancer/src/utils/config.ts @@ -27,7 +27,7 @@ export function getConfig() { rebalancingBrlToUsdAmount: process.env.REBALANCING_BRL_TO_USD_AMOUNT || "1", /// The minimum balance in USDC that the rebalancer account on Base must have to allow the BRLA pool rebalancing. rebalancingBrlToUsdMinBalance: process.env.REBALANCING_BRL_TO_USD_MIN_BALANCE || undefined, - rebalancingDailyBridgeLimitUsd: Number(process.env.REBALANCING_DAILY_BRIDGE_LIMIT_USD) || 20_000, + rebalancingDailyBridgeLimitUsd: Number(process.env.REBALANCING_DAILY_BRIDGE_LIMIT_USD) || 10_000, /// The threshold above and below the optimal coverage ratio at which the rebalancing will be triggered. rebalancingThreshold: Number(process.env.REBALANCING_THRESHOLD) || 0.01, From d77b5557ae74ef9886d1c1d190a684f20f7a22e1 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 16 Jun 2026 10:23:12 +0200 Subject: [PATCH 043/131] Update security-spec for rebalancer --- apps/rebalancer/.env.example | 2 +- .../security-spec/07-operations/rebalancer.md | 108 +++++++++++------- docs/security-spec/AUDIT-RESULTS.md | 6 +- docs/security-spec/README.md | 2 +- 4 files changed, 73 insertions(+), 45 deletions(-) diff --git a/apps/rebalancer/.env.example b/apps/rebalancer/.env.example index 3a04baf5a..9a5450e45 100644 --- a/apps/rebalancer/.env.example +++ b/apps/rebalancer/.env.example @@ -10,7 +10,7 @@ BRLA_LOGIN_PASSWORD=your_password_here SLACK_WEB_HOOK_TOKEN=your_slack_webhook_token_here -REBALANCING_AMOUNT_USD_TO_BRL=100 +REBALANCING_USD_TO_BRL_AMOUNT=100 SUPABASE_URL=your_supabase_url_here SUPABASE_SERVICE_KEY=your_supabase_service_key_here diff --git a/docs/security-spec/07-operations/rebalancer.md b/docs/security-spec/07-operations/rebalancer.md index bd93e5e95..eca83d4a3 100644 --- a/docs/security-spec/07-operations/rebalancer.md +++ b/docs/security-spec/07-operations/rebalancer.md @@ -4,18 +4,21 @@ 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. -**Current implementation:** Two rebalancing paths: +**Current implementation:** Three rebalancing paths: 1. **BRLA ↔ axlUSDC (legacy, Pendulum)** — 8-step cross-chain process on Pendulum/Moonbeam/Polygon. Activated via `--legacy` flag. -2. **USDC → BRLA → USDC (Base)** — Default flow. Multi-step process on Base with dual-route optimization (SquidRouter vs Avenia). +2. **USDC → BRLA → USDC (Base)** — Default high-coverage flow. Multi-step process on Base with route optimization across SquidRouter, Avenia, and optional main Nabla. +3. **BRLA → USDC correction (Base)** — Default low-coverage flow. Base-only two-swap process that uses main Nabla for USDC→BRLA and the BRLA pool for BRLA→USDC. **Architecture:** - `index.ts` — Entry point: parses CLI args (`--legacy`, `--restart`, `--route=`, amount), checks coverage ratios, selects flow - `rebalance/brla-to-axlusdc/index.ts` — Legacy orchestrator: 8-step state machine on Pendulum - `rebalance/brla-to-axlusdc/steps.ts` — Legacy step implementations -- `rebalance/usdc-brla-usdc-base/index.ts` — Base orchestrator: multi-step state machine with dual-route branching -- `rebalance/usdc-brla-usdc-base/steps.ts` — Base step implementations (Nabla swap, Avenia transfers, SquidRouter, rate comparison) -- `services/stateManager.ts` — Generic `StateManager` base class + flow-specific managers (`BrlaToAxlUsdcStateManager`, `UsdcBaseStateManager`) +- `rebalance/usdc-brla-usdc-base/index.ts` — Base high-coverage orchestrator: multi-step state machine with route branching +- `rebalance/usdc-brla-usdc-base/steps.ts` — Base high-coverage step implementations (Nabla swaps, Avenia transfers, SquidRouter, rate comparison) +- `rebalance/brla-to-usdc-base/index.ts` — Base low-coverage orchestrator: main Nabla + BRLA-pool two-swap correction +- `rebalance/brla-to-usdc-base/steps.ts` — Base low-coverage step implementations +- `services/stateManager.ts` — Generic `StateManager` base class + flow-specific managers (`BrlaToAxlUsdcStateManager`, `UsdcBaseStateManager`, `BrlaToUsdcBaseStateManager`) - `services/indexer/index.ts` — Nabla coverage ratio queries (Pendulum via GraphQL, Base via on-chain reads) - `utils/config.ts` — Configuration and secret loading - `utils/nonce.ts` — `NonceManager` for sequential EVM transaction nonces @@ -23,12 +26,12 @@ The rebalancer is a standalone service (`apps/rebalancer/`) that monitors token **CLI interface:** ``` -bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia] +bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla-main] ``` - No flag → Base flow (default) - `--legacy` → Pendulum flow - `--restart` → Force fresh state, ignore in-progress rebalance -- `--route=` → Force specific route (skip rate comparison) +- `--route=squidrouter|avenia|nabla-main` → Force specific high-coverage return route (skip route comparison) --- @@ -44,15 +47,15 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia] 7. Verify arrival on Pendulum 8. Clean up state -**Key secrets:** `PENDULUM_ACCOUNT_SECRET` (sr25519), `EVM_ACCOUNT_SECRET` (mnemonic for Moonbeam + Polygon). These are **distinct from the API service keys** — the rebalancer operates its own accounts. +**Key secrets:** `PENDULUM_ACCOUNT_SECRET` (sr25519), `EVM_ACCOUNT_SECRET` (mnemonic for Moonbeam and Polygon). These are **distinct from the API service keys** — the rebalancer operates its own accounts. --- -### Flow 2: USDC → BRLA → USDC (Base, default) +### Flow 2: USDC → BRLA → USDC (Base, default high-coverage flow) -**Trigger condition:** Base Nabla BRLA pool coverage ratio ≥ `1 + rebalancingThreshold` (default 1.25). +**Trigger condition:** Base Nabla BRLA pool coverage ratio > `1 + REBALANCING_THRESHOLD_USDC_TO_BRLA` (default upper bound `1.01`). Falls back to `REBALANCING_THRESHOLD` when the route-specific threshold is unset. -**Daily bridge limit:** Total USDC bridged per calendar day (UTC), including the amount about to be rebalanced, must not exceed `REBALANCING_DAILY_BRIDGE_LIMIT_USD` (default 10,000). Checked against `UsdcBaseStateManager` history before starting. +**Daily bridge limit:** Total requested USDC amount recorded by Base-flow history per calendar day (UTC), including the amount about to be rebalanced, must not exceed `REBALANCING_DAILY_BRIDGE_LIMIT_USD` (default 10,000). Checked against both `UsdcBaseStateManager` and `BrlaToUsdcBaseStateManager` history before starting a fresh Base rebalance. **Rebalancing flow (linear phase):** 1. Check initial USDC balance on Base (sufficient for requested amount) @@ -61,23 +64,30 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia] 4. Wait for BRLA delta to appear on Avenia internal balance (polling, 10-min timeout) **Rate comparison phase:** -5. Compare rates between SquidRouter and Avenia for BRLA → USDC conversion +5. Compare rates between SquidRouter, Avenia, and optional main Nabla for BRLA → USDC conversion - If `--route=` specified, fetches quote for that route only - - If both quotes fail, aborts + - Main Nabla route is available only when both `MAIN_NABLA_ROUTER` and `MAIN_NABLA_QUOTER` are set + - If every enabled route quote fails, aborts - If one fails, uses the other -**Route A: Avenia (BRLA → USDC on Base, direct):** -6a. Create Avenia swap ticket (BRLA → USDC, output on Base) -7a. Poll ticket status until PAID (5-min timeout) -8a. Wait for USDC delta arrival on Base (balance polling, 30-min timeout) - -**Route B: SquidRouter (BRLA on Polygon → USDC on Base, cross-chain):** -6b. Request Avenia to transfer BRLA from internal balance to Polygon -7b. Poll ticket status until PAID (5-min timeout) -8b. Wait for BRLA delta arrival on Polygon (balance polling, 10-min timeout) -9b. SquidRouter approve + swap: BRLA on Polygon → USDC on Base -10b. Wait for Axelar cross-chain execution (30-min timeout) -11b. Wait for USDC delta arrival on Base (balance polling, 30-min timeout) +**Route A: main Nabla (BRLA → USDC on Base, direct):** +6a. Main Nabla approve + swap: BRLA → USDC on Base +7a. Verify final USDC balance on Base + +**Route B: Avenia (BRLA → USDC on Base, direct):** +6b. Transfer BRLA to Avenia business account on Base if not already transferred +7b. Create Avenia swap ticket (BRLA → USDC, output on Base) +8b. Poll ticket status until PAID (5-min timeout) +9b. Wait for USDC delta arrival on Base (balance polling, 30-min timeout) + +**Route C: SquidRouter (BRLA on Polygon → USDC on Base, cross-chain):** +6c. Transfer BRLA to Avenia business account on Base if not already transferred +7c. Request Avenia to transfer BRLA from internal balance to Polygon +8c. Poll ticket status until PAID (5-min timeout) +9c. Wait for BRLA delta arrival on Polygon (balance polling, 10-min timeout) +10c. SquidRouter approve + swap: BRLA on Polygon → USDC on Base +11c. Wait for Axelar cross-chain execution (30-min timeout) +12c. Wait for USDC delta arrival on Base (balance polling, 30-min timeout) **Verification:** 12. Verify final USDC balance on Base @@ -88,12 +98,29 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia] **Key secrets:** `EVM_ACCOUNT_SECRET` (single BIP-39 mnemonic, derives accounts for Base + Polygon). `PENDULUM_ACCOUNT_SECRET` not required for this flow. +--- + +### Flow 3: BRLA → USDC correction (Base, default low-coverage flow) + +**Trigger condition:** Base Nabla BRLA pool coverage ratio < `1 - REBALANCING_THRESHOLD_BRLA_TO_USDC` (default lower bound `0.99`). Falls back to `REBALANCING_THRESHOLD` when the route-specific threshold is unset. + +**Daily bridge limit:** Uses the same Base-flow daily limit described above and records history in `rebalancer_state_brla_to_usdc_base.json`. + +**Rebalancing flow:** +1. Check initial USDC balance on Base +2. Main Nabla swap: USDC → BRLA on Base +3. BRLA pool swap: BRLA → USDC on Base +4. Verify final USDC balance on Base +5. Record history entry and send Slack notification + +**Key secrets:** `EVM_ACCOUNT_SECRET` for Base transactions. `PENDULUM_ACCOUNT_SECRET` is not required. + ## Security Invariants ### Shared (both flows) -1. **Coverage ratio check MUST precede rebalancing** — The rebalancer only triggers when a flow-specific coverage threshold is crossed. Legacy flow uses Pendulum indexer data and triggers when BRLA is over-covered while USDC.axl is not; Base flow uses on-chain Nabla contract reads and triggers when the Base BRLA coverage ratio is at least `1 + rebalancingThreshold`. It must never rebalance preemptively or based on stale data. -2. **State persistence MUST survive process restarts** — Each flow has its own Supabase Storage JSON file (`rebalancer_state.json` for legacy, `rebalancer_state_usdc_base.json` for Base). On restart, the rebalancer reads the file and resumes from the last completed phase. +1. **Coverage ratio check MUST precede rebalancing** — The rebalancer only triggers when a flow-specific coverage threshold is crossed. Legacy flow uses Pendulum indexer data and triggers when BRLA is over-covered while USDC.axl is not; the default Base flow uses on-chain Nabla contract reads and triggers above `1 + REBALANCING_THRESHOLD_USDC_TO_BRLA` or below `1 - REBALANCING_THRESHOLD_BRLA_TO_USDC`. It must never rebalance preemptively or based on stale data. +2. **State persistence MUST survive process restarts** — Each flow has its own Supabase Storage JSON file (`rebalancer_state.json` for legacy, `rebalancer_state_usdc_base.json` for Base high-coverage, `rebalancer_state_brla_to_usdc_base.json` for Base low-coverage). On restart, the rebalancer reads the file and resumes from the last completed phase. 3. **Each phase MUST be idempotent or guarded against re-execution** — If the process crashes mid-phase and resumes, re-executing a completed phase must not cause double-swaps, double-transfers, or double-settlements. Transaction hashes and pre-action balance baselines are stored in state to detect already-completed phases and verify per-run deltas. 4. **Rebalancer private keys MUST be isolated from API service keys** — The rebalancer keys operate separate accounts. Compromise of rebalancer keys should not affect API ramp operations, and vice versa. 5. **BRLA business account address MUST be verified** — `brlaBusinessAccountAddress` has a hardcoded default (`0xDF5Fb34B90e5FDF612372dA0c774A516bF5F08b2`). If this address is wrong, funds are sent to the wrong recipient with no recovery. @@ -105,15 +132,15 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia] 8. **SquidRouter gas pricing MUST not overpay excessively** — `gasMultiplier * 5n` is applied to `maxFeePerGas` for SquidRouter transactions. This aggressive multiplier ensures inclusion but could result in significant gas overpayment. 9. **Axelar polling MUST have a timeout** — **F-034 (legacy):** The legacy flow's Axelar polling loop (`while (!isExecuted)`) has no timeout — it will poll indefinitely if Axelar never reports success. This is a known deficiency in the legacy flow; the Base flow fixes it with a 30-minute timeout. -### Base flow (USDC → BRLA → USDC) invariants +### Base flow invariants -10. **Daily bridge limit MUST be enforced** — Total USDC bridged per calendar day (UTC), including the amount about to be rebalanced, must not exceed `REBALANCING_DAILY_BRIDGE_LIMIT_USD`. Checked against `UsdcBaseStateManager` history entries before starting a new rebalance. Prevents runaway rebalancing from draining hot wallets. -11. **Rate comparison MUST handle provider failures gracefully** — If both SquidRouter and Avenia quotes fail, the rebalancer MUST abort (not proceed with zero information). If one fails, the other is used. If `--route=` is specified, only that route's quote is fetched. +10. **Daily bridge limit MUST be enforced** — 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`. Checked against both Base flow history entries before starting a new Base rebalance. Prevents runaway rebalancing from draining hot wallets. +11. **Route comparison MUST handle provider failures gracefully** — If every enabled return route quote fails, the high-coverage flow MUST abort (not proceed with zero information). If some routes fail, the best available route is used. If `--route=` is specified, only that route's quote is fetched. 12. **Avenia fallback to SquidRouter MUST be atomic in state** — If Avenia ticket creation fails, the flow sets `winningRoute = "squidrouter"` and `currentPhase = AveniaTransferToPolygon` in a single `saveState()` call. A crash between the failure and the save could leave the flow in an inconsistent state. 13. **NonceManager MUST be re-initialized on resume** — The `NonceManager` is created fresh at the start of each execution from `getTransactionCount()`. On resume, it must not reuse stale nonces from a previous execution. 14. **Axelar cross-chain execution MUST have a timeout** — SquidRouter's Axelar polling has a 30-minute timeout. If Axelar does not confirm execution within this window, the flow MUST throw (not poll indefinitely). This resolves F-034 for the Base flow. -15. **Balance arrival checks MUST be delta-based** — The Base flow persists pre-action balances before each arrival-producing operation and waits for `starting balance + expected delta` rather than checking absolute hot-wallet/provider balances. Avenia and Polygon BRLA arrival checks allow a 99.8% tolerance to account for rounding and minor deductions without sweeping unrelated leftover balances into the current run. -16. **`EVM_ACCOUNT_SECRET` derives the same address on all EVM chains** — A single BIP-39 mnemonic is used for Base and Polygon. This means compromise of this one secret drains the rebalancer on ALL EVM chains. The legacy flow's separate-key model had narrower blast radius per key. +15. **Balance arrival checks MUST be delta-based** — The Base high-coverage flow persists pre-action balances before each arrival-producing operation and waits for `starting balance + expected delta` rather than checking absolute hot-wallet/provider balances. Avenia and Polygon BRLA arrival checks allow a 99.8% tolerance to account for rounding and minor deductions without sweeping unrelated leftover balances into the current run. +16. **`EVM_ACCOUNT_SECRET` derives the same address on all EVM chains** — A single BIP-39 mnemonic is used for Base, Polygon, and Moonbeam. This means compromise of this one secret drains the rebalancer on ALL EVM chains. `PENDULUM_ACCOUNT_SECRET` is separate and legacy-only. 17. **Terminal Avenia ticket failures MUST abort immediately** — `checkTicketStatusPaid` treats `FAILED` as terminal and throws immediately instead of retrying until timeout. ## Threat Vectors & Mitigations @@ -123,7 +150,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia] | Threat | Mitigation | |---|---| | **⚠️ State file corruption from concurrent execution** — Two rebalancer instances read the same JSON file from Supabase Storage, both decide to rebalance, both execute phases simultaneously | **NO MITIGATION.** Supabase Storage has no file locking, no atomic compare-and-swap, no conditional writes. If the rebalancer is deployed as multiple instances or triggered concurrently, state corruption and double-execution are possible. | -| **Rebalancer key compromise** — Attacker obtains the rebalancer private key(s) | Full drain of the rebalancer's accounts on all affected chains. Legacy: three separate keys (Pendulum, Moonbeam, Polygon) — compromise of one drains one chain. Base: single `EVM_ACCOUNT_SECRET` mnemonic — compromise drains both Base and Polygon. The API service accounts are separate, so ramp operations are not directly affected (but liquidity would be depleted). | +| **Rebalancer key compromise** — Attacker obtains the rebalancer private key(s) | Full drain of the rebalancer's accounts on all affected chains. `EVM_ACCOUNT_SECRET` is one mnemonic for Base, Polygon, and Moonbeam; `PENDULUM_ACCOUNT_SECRET` is separate and only needed for legacy Pendulum operations. The API service accounts are separate, so ramp operations are not directly affected (but liquidity would be depleted). | | **Hardcoded business account address** — `brlaBusinessAccountAddress` default is wrong or points to an attacker-controlled address | Funds would be sent to the wrong address. The address should be verified against BRLA's official documentation and set via environment variable, not hardcoded. | | **State file deletion or corruption** — Supabase Storage file is deleted or corrupted manually | The rebalancer would lose track of in-progress operations. Phases that already executed (swaps, transfers) would not be resumed, and the rebalancer would start fresh. This could leave funds stranded mid-flow. | | **Stale coverage ratio** — The coverage ratio is checked once at startup, but by the time the multi-step rebalance completes, the ratio may have changed significantly | No re-check between phases. The rebalance amount is calculated upfront. If conditions change during the multi-step process, the rebalance may be unnecessary or insufficient. | @@ -141,11 +168,11 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia] | Threat | Mitigation | |---|---| -| **Rate comparison manipulation** — Both Avenia and SquidRouter quotes are fetched and compared; an attacker could manipulate one provider's rate to force the other route | The rebalancer trusts both providers' quotes without independent verification. However, since both routes end with USDC on Base, the worst case is choosing a slightly worse rate, not fund loss. The `slippage: 4` parameter on SquidRouter provides some buffer. | +| **Route comparison manipulation** — Avenia, SquidRouter, and optional main Nabla quotes are fetched and compared; an attacker could manipulate one provider's rate to force another route | The rebalancer trusts provider quotes without independent verification. However, since all high-coverage routes end with USDC on Base, the worst case is choosing a slightly worse rate, not direct fund loss. The `slippage: 4` parameter on SquidRouter provides some buffer. | | **Avenia ticket creation failure mid-flow** — Avenia API fails after the flow committed to the Avenia route | **Mitigated.** The flow catches ticket creation errors and falls back to SquidRouter by setting `winningRoute = "squidrouter"` and saving state. Avenia tickets that return `FAILED` after creation are terminal and abort immediately. | | **Daily bridge limit bypass** — History entries are stored in Supabase Storage; an attacker who can modify the storage could clear history to bypass the daily limit | **Weak mitigation.** The limit is enforced client-side by reading history from Supabase and adding the current requested amount before starting. An attacker with Supabase access could also drain funds directly, so the limit bypass is a secondary concern. | | **NonceManager stale nonce** — If the process crashes after sending a transaction but before saving the nonce, the resumed execution could reuse the same nonce | **Mitigated.** `NonceManager` is re-initialized from `getTransactionCount()` on each execution. The stored transaction hashes in state also prevent re-execution of already-completed phases. | -| **`EVM_ACCOUNT_SECRET` single-key blast radius** — One mnemonic controls all EVM chain accounts for the rebalancer | Compromise of this one secret drains rebalancer funds on Base AND Polygon. The legacy flow's three separate keys had narrower per-key blast radius. This is a deliberate simplification accepted for operational convenience. | +| **`EVM_ACCOUNT_SECRET` single-key blast radius** — One mnemonic controls all EVM chain accounts for the rebalancer | Compromise of this one secret drains rebalancer funds on Base, Polygon, and Moonbeam. The separate `PENDULUM_ACCOUNT_SECRET` limits Pendulum blast radius to the legacy flow. This is a deliberate simplification accepted for operational convenience. | | **SquidRouter cross-chain timeout** — Axelar cross-chain execution could take longer than 30 minutes during network congestion | The rebalancer throws on timeout, leaving the BRLA-to-USDC swap incomplete on Polygon. Funds would be stuck as BRLA on Polygon until manual intervention or the next rebalance attempt resumes from the `SquidRouterApproveAndSwap` phase. | | **Absolute balance false positives** — Hot wallets/provider accounts can contain leftovers from previous runs, so absolute balance checks could pass before the current run's funds arrive | **Mitigated for Base flow.** The flow stores pre-action baselines and waits for deltas on Avenia BRLA, Polygon BRLA, and Base USDC arrivals. | | **BRLA balance tolerance (99.8%)** — BRLA delta checks accept 99.8% of expected amount as sufficient | If Avenia deducts a fee > 0.2%, the flow will not proceed and will time out. The tolerance prevents rounding dust from blocking valid arrivals while rejecting meaningful shortfalls. | @@ -166,19 +193,19 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia] - [x] **FINDING**: 5% slippage tolerance hardcoded in Nabla swap — verify this is acceptable for expected rebalancing amounts. **PASS (confirmed limitation)** — 5% is generous but acceptable for the current rebalancing volumes; documented as known risk. - [x] **FINDING**: `gasMultiplier * 5n` applied to `maxFeePerGas` — verify this doesn't cause excessive gas overpayment in production. **PASS (confirmed limitation)** — aggressive multiplier ensures inclusion; overpayment risk accepted for reliability. -- [x] Verify `COVERAGE_RATIO_THRESHOLD` default (0.25) is appropriate for the expected token volumes. **PASS** — 25% threshold reasonable for current volumes. +- [x] Verify legacy coverage trigger is appropriate for the expected token volumes. **PASS** — legacy flow still checks BRLA over-coverage while USDC.axl is not over-covered before starting. - [x] Verify the rebalancer private keys are distinct from all API service keys. **PASS** — separate env vars and accounts confirmed. - [PARTIAL] Verify step idempotency: can each of the 8 steps be safely re-executed after a crash? Check for nonce guards, balance checks, or transaction hash verification. **PARTIAL F-033** — steps 2, 3, 5, 6, 7 are NOT idempotent; crash between step execution and `saveState()` causes double-spend risk. - [PARTIAL] Verify the BRLA→USDC swap (step 3) validates the received USDC amount against expectations. **PARTIAL** — BRLA API response is trusted; no independent amount verification. - [FAIL] Verify the SquidRouter swap (step 5) validates the received axlUSDC amount against expectations. **FAIL F-034** — no output amount validation AND Axelar status polling has no timeout; infinite loop risk if Axelar never reports success. -### Base flow (USDC → BRLA → USDC) +### Base flows - [x] **FINDING**: Axelar polling has 30-minute timeout — resolves F-034 for Base flow. **PASS** — `axelarTimeout = 30 * 60 * 1000` enforced in `squidRouterApproveAndSwap()`. -- [x] **FINDING**: Daily bridge limit check — `REBALANCING_DAILY_BRIDGE_LIMIT_USD` (default 10,000) enforced against history plus the current requested amount. **PASS** — checked before starting new rebalance; prevents runaway bridging beyond the cap. +- [x] **FINDING**: Daily bridge limit check — `REBALANCING_DAILY_BRIDGE_LIMIT_USD` (default 10,000) enforced against both Base-flow histories plus the current requested amount. **PASS** — checked before starting new Base rebalance; prevents runaway execution beyond the cap. - [x] **FINDING**: Avenia fallback to SquidRouter — if Avenia ticket creation fails, flow falls back to SquidRouter route. **PASS** — error caught, `winningRoute` updated, state saved atomically. -- [x] **FINDING**: `EVM_ACCOUNT_SECRET` single mnemonic for all EVM chains — broader blast radius than legacy three-key model. **PASS (accepted)** — deliberate simplification; documented in invariants. -- [x] Verify rate comparison handles partial failures — what happens if one provider's quote fails? **PASS** — if both fail, throws; if one fails, uses the other; if `--route=` specified, only fetches that quote. +- [x] **FINDING**: `EVM_ACCOUNT_SECRET` single mnemonic for all EVM chains — broad EVM blast radius across Base, Polygon, and Moonbeam. **PASS (accepted)** — deliberate simplification; documented in invariants. +- [x] Verify route comparison handles partial failures — what happens if one provider's quote fails? **PASS** — if every enabled route fails, throws; otherwise uses the best available route. If `--route=` is specified, only fetches that quote. - [x] Verify NonceManager re-initialization on resume — does it fetch fresh nonce from chain? **PASS** — `NonceManager.create()` calls `getTransactionCount()` on each execution. - [x] Verify BRLA balance arrival tolerance (99.8%) is appropriate. **PASS** — accounts for rounding and minor fee deductions; 0.2% tolerance is tight enough to reject significant shortfalls. - [x] Verify `checkTicketStatusPaid` has a timeout and treats FAILED as terminal. **PASS** — 5-minute timeout with 5-second poll interval; FAILED tickets throw immediately. @@ -189,3 +216,4 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia] - [x] Verify the `usdcBasePhaseOrder` overlap (`AveniaTransferToPolygon` and `AveniaSwapToUsdcBase` both at order 6; both wait phases at order 7) cannot cause incorrect phase transitions. **PASS** — routes are mutually exclusive, guarded by `if (state.winningRoute === "avenia")` / `if (state.winningRoute === "squidrouter")` checks. - [x] Verify Base flow arrival checks are delta-based. **PASS** — Avenia BRLA, Polygon BRLA, Avenia USDC-on-Base, and SquidRouter USDC-on-Base waits all use persisted pre-action baselines plus expected deltas. - [x] Verify Nabla swap resume cannot lose the received BRLA amount. **PASS** — pre-swap BRLA baseline and swap hash are persisted; resume computes output from the persisted baseline or reuses already recorded output. +- [x] Verify Base low-coverage flow state/history is isolated from the high-coverage flow. **PASS** — `BrlaToUsdcBaseStateManager` uses `rebalancer_state_brla_to_usdc_base.json` while sharing the daily limit calculation. diff --git a/docs/security-spec/AUDIT-RESULTS.md b/docs/security-spec/AUDIT-RESULTS.md index 4f9b1c4d6..6c0684f67 100644 --- a/docs/security-spec/AUDIT-RESULTS.md +++ b/docs/security-spec/AUDIT-RESULTS.md @@ -857,7 +857,7 @@ Hardcoded `0.95` multiplier. Reasonable for default small amounts ($1 USD). Aggressive but ensures inclusion on Polygon. Gas is typically cheap. #### 5. `[PASS]` Coverage ratio threshold -`1 + 0.25` threshold. Configurable via env var. Only rebalances when genuine surplus/deficit. +Default Base flow uses asymmetric bounds around 1.0: `1 - REBALANCING_THRESHOLD_BRLA_TO_USDC` for the low-coverage correction and `1 + REBALANCING_THRESHOLD_USDC_TO_BRLA` for the high-coverage flow. Both route-specific thresholds fall back to `REBALANCING_THRESHOLD` and default to `0.01`. #### 6. `[PASS]` Rebalancer keys distinct from API keys Different env var names. Actual isolation is operational. @@ -866,10 +866,10 @@ Different env var names. Actual isolation is operational. Steps 2, 3, 5, 6, 7 have crash windows between execution and `saveState()` causing double-spend on re-execution. No tx hash guards or nonce guards. → [F-033](FINDINGS.md) #### 8. `[PARTIAL]` BRLA→USDC swap amount validation -Verifies USDC arrives on-chain but doesn't compare arrived amount to quoted amount. +Legacy BRLA→USDC trusts the BRLA API response. Base high-coverage routes use provider quotes and delta-based arrival checks; Base low-coverage is a Base-only two-swap loop with final balance verification. #### 9. `[FAIL]` SquidRouter swap amount validation -Never validates received amount matches estimate. Axelar polling has no timeout (infinite loop risk). → [F-034](FINDINGS.md) +Legacy SquidRouter never validates received amount matches estimate and its Axelar polling has no timeout (infinite loop risk). The Base SquidRouter route has a 30-minute Axelar timeout and delta-based Base USDC arrival check. → [F-034](FINDINGS.md) #### 10. `[PASS]` Storage write errors handled Errors thrown and propagated. Process exits with code 1. diff --git a/docs/security-spec/README.md b/docs/security-spec/README.md index c14d8f96e..8687455b1 100644 --- a/docs/security-spec/README.md +++ b/docs/security-spec/README.md @@ -42,7 +42,7 @@ This directory contains the security specification for the Vortex cross-border p | XCM Transfers | `06-cross-chain/xcm-transfers.md` | Pendulum↔Moonbeam↔AssetHub↔Hydration | | Bridge Security | `06-cross-chain/bridge-security.md` | Spacewalk bridge trust model | | Fund Routing | `06-cross-chain/fund-routing.md` | Subsidization, fee distribution, amount integrity | -| Rebalancer | `07-operations/rebalancer.md` | Automated liquidity management — BRLA↔axlUSDC (legacy, Pendulum) and USDC→BRLA→USDC (Base, default) | +| Rebalancer | `07-operations/rebalancer.md` | Automated liquidity management — BRLA↔axlUSDC (legacy, Pendulum), USDC→BRLA→USDC (Base high-coverage), and BRLA→USDC correction (Base low-coverage) | | Secret Management | `07-operations/secret-management.md` | Env vars, rotation, blast radius | | API Surface | `07-operations/api-surface.md` | Rate limiting, CORS, input validation, error handling | | Client Observability | `07-operations/client-observability.md` | Request IDs, sanitized API client events, operational monitoring | From 51d4d0faf8b61b597d9027134e603f976287dcb4 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 16 Jun 2026 10:23:35 +0200 Subject: [PATCH 044/131] Fix parsing of rebalancing daily volume limit --- apps/rebalancer/src/utils/config.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/rebalancer/src/utils/config.ts b/apps/rebalancer/src/utils/config.ts index e16296ab1..18cb6f1a2 100644 --- a/apps/rebalancer/src/utils/config.ts +++ b/apps/rebalancer/src/utils/config.ts @@ -2,6 +2,20 @@ import { Keyring } from "@polkadot/api"; import { BRLA_BASE_URL, EvmClientManager, Networks } from "@vortexfi/shared"; import { mnemonicToAccount } from "viem/accounts"; +const DEFAULT_REBALANCING_DAILY_BRIDGE_LIMIT_USD = 10_000; + +export function parseRebalancingDailyBridgeLimitUsd(value = process.env.REBALANCING_DAILY_BRIDGE_LIMIT_USD) { + const trimmedValue = value?.trim(); + if (!trimmedValue) return DEFAULT_REBALANCING_DAILY_BRIDGE_LIMIT_USD; + + const parsedValue = Number(trimmedValue.replaceAll("_", "").replaceAll(",", "")); + if (!Number.isFinite(parsedValue) || parsedValue < 0) { + throw new Error("REBALANCING_DAILY_BRIDGE_LIMIT_USD must be a non-negative number."); + } + + return parsedValue; +} + export function getConfig() { if (!process.env.EVM_ACCOUNT_SECRET) throw new Error("Missing EVM_ACCOUNT_SECRET environment variable"); @@ -27,7 +41,7 @@ export function getConfig() { rebalancingBrlToUsdAmount: process.env.REBALANCING_BRL_TO_USD_AMOUNT || "1", /// The minimum balance in USDC that the rebalancer account on Base must have to allow the BRLA pool rebalancing. rebalancingBrlToUsdMinBalance: process.env.REBALANCING_BRL_TO_USD_MIN_BALANCE || undefined, - rebalancingDailyBridgeLimitUsd: Number(process.env.REBALANCING_DAILY_BRIDGE_LIMIT_USD) || 10_000, + rebalancingDailyBridgeLimitUsd: parseRebalancingDailyBridgeLimitUsd(), /// The threshold above and below the optimal coverage ratio at which the rebalancing will be triggered. rebalancingThreshold: Number(process.env.REBALANCING_THRESHOLD) || 0.01, From 19fd3e84a3cbc8976a1fe200fdaa66032bb9e626 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 16 Jun 2026 10:49:09 +0200 Subject: [PATCH 045/131] Add rebalancer cost policy guards --- .../usdc-brla-usdc-base/guards.test.ts | 78 ++++++++++- .../rebalance/usdc-brla-usdc-base/guards.ts | 123 ++++++++++++++++++ 2 files changed, 200 insertions(+), 1 deletion(-) diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.test.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.test.ts index 67a3b4804..7424552a5 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.test.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.test.ts @@ -1,6 +1,24 @@ import { describe, expect, test } from "bun:test"; import Big from "big.js"; -import { calculateMinimumDelta, calculateTargetBalanceRaw, wouldExceedDailyBridgeLimit } from "./guards.ts"; +import { + calculateMinimumDelta, + calculateProjectedCostBps, + calculateTargetBalanceRaw, + evaluateRebalancingCostPolicy, + getRebalancingUrgencyBand, + wouldExceedDailyBridgeLimit, + type RebalancingCostPolicyConfig +} from "./guards.ts"; + +const policyConfig: RebalancingCostPolicyConfig = { + hardMaxCostBps: 1_000, + maxCostBpsMild: 25, + maxCostBpsModerate: 75, + maxCostBpsSevere: 250, + mode: "auto", + moderateDeviationBps: 200, + severeDeviationBps: 500 +}; describe("USDC Base rebalance guards", () => { test("calculates arrival target from the starting balance plus expected delta", () => { @@ -15,4 +33,62 @@ describe("USDC Base rebalance guards", () => { expect(wouldExceedDailyBridgeLimit(Big("9500000000"), Big("600000000"), Big("10000000000"))).toBe(true); expect(wouldExceedDailyBridgeLimit(Big("9000000000"), Big("1000000000"), Big("10000000000"))).toBe(false); }); + + test("calculates projected rebalancing cost in basis points", () => { + expect(calculateProjectedCostBps(Big("100000000"), Big("99000000"))).toBe(100); + expect(calculateProjectedCostBps(Big("100000000"), Big("101000000"))).toBe(-100); + }); + + test("selects urgency bands from coverage deviation", () => { + expect(getRebalancingUrgencyBand(50, policyConfig)).toBe("mild"); + expect(getRebalancingUrgencyBand(200, policyConfig)).toBe("moderate"); + expect(getRebalancingUrgencyBand(500, policyConfig)).toBe("severe"); + }); + + test("skips mild rebalances when projected cost exceeds the mild limit", () => { + const decision = evaluateRebalancingCostPolicy(Big("100000000"), Big("99000000"), 50, policyConfig); + + expect(decision.shouldExecute).toBe(false); + expect(decision.band).toBe("mild"); + expect(decision.costBps).toBe(100); + }); + + test("allows severe rebalances at a higher configured cost", () => { + const decision = evaluateRebalancingCostPolicy(Big("100000000"), Big("99000000"), 600, policyConfig); + + expect(decision.shouldExecute).toBe(true); + expect(decision.band).toBe("severe"); + expect(decision.allowedCostBps).toBe(250); + }); + + test("dry-run evaluates but never executes", () => { + const decision = evaluateRebalancingCostPolicy(Big("100000000"), Big("99900000"), 50, { + ...policyConfig, + mode: "dry-run" + }); + + expect(decision.shouldExecute).toBe(false); + expect(decision.dryRun).toBe(true); + expect(decision.reason).toContain("Dry-run"); + }); + + test("off mode never executes", () => { + const decision = evaluateRebalancingCostPolicy(Big("100000000"), Big("100000000"), 600, { + ...policyConfig, + mode: "off" + }); + + expect(decision.shouldExecute).toBe(false); + expect(decision.reason).toBe("Rebalancing policy mode is off."); + }); + + test("hard max cost cap blocks even always mode", () => { + const decision = evaluateRebalancingCostPolicy(Big("100000000"), Big("80000000"), 600, { + ...policyConfig, + mode: "always" + }); + + expect(decision.shouldExecute).toBe(false); + expect(decision.reason).toContain("hard cap"); + }); }); diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.ts index 28a3f9737..bc0f010b2 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.ts @@ -2,6 +2,29 @@ import Big from "big.js"; export const DEFAULT_ARRIVAL_TOLERANCE = "0.998"; +export type RebalancingPolicyMode = "auto" | "always" | "dry-run" | "off"; +export type RebalancingUrgencyBand = "mild" | "moderate" | "severe"; + +export interface RebalancingCostPolicyConfig { + hardMaxCostBps: number; + maxCostBpsMild: number; + maxCostBpsModerate: number; + maxCostBpsSevere: number; + mode: RebalancingPolicyMode; + moderateDeviationBps: number; + severeDeviationBps: number; +} + +export interface RebalancingCostPolicyDecision { + allowedCostBps: number; + band: RebalancingUrgencyBand; + costBps: number; + dryRun: boolean; + projectedCostRaw: string; + reason: string; + shouldExecute: boolean; +} + export function calculateMinimumDelta(expectedDelta: Big, tolerance = DEFAULT_ARRIVAL_TOLERANCE): Big { return expectedDelta.mul(tolerance); } @@ -15,3 +38,103 @@ export function calculateTargetBalanceRaw(startingBalanceRaw: string, expectedDe export function wouldExceedDailyBridgeLimit(bridgedTodayRaw: Big, requestedAmountRaw: Big, dailyLimitRaw: Big): boolean { return bridgedTodayRaw.plus(requestedAmountRaw).gt(dailyLimitRaw); } + +export function calculateProjectedCostBps(inputAmountRaw: Big, projectedOutputRaw: Big): number { + if (inputAmountRaw.lte(0)) throw new Error("inputAmountRaw must be greater than zero."); + return Number(inputAmountRaw.minus(projectedOutputRaw).div(inputAmountRaw).mul(10_000).toFixed(2)); +} + +export function getRebalancingUrgencyBand( + deviationBps: number, + config: Pick +): RebalancingUrgencyBand { + if (deviationBps >= config.severeDeviationBps) return "severe"; + if (deviationBps >= config.moderateDeviationBps) return "moderate"; + return "mild"; +} + +export function evaluateRebalancingCostPolicy( + inputAmountRaw: Big, + projectedOutputRaw: Big, + deviationBps: number, + config: RebalancingCostPolicyConfig +): RebalancingCostPolicyDecision { + const band = getRebalancingUrgencyBand(deviationBps, config); + const projectedCostRaw = inputAmountRaw.minus(projectedOutputRaw); + const costBps = calculateProjectedCostBps(inputAmountRaw, projectedOutputRaw); + const allowedCostBps = { + mild: config.maxCostBpsMild, + moderate: config.maxCostBpsModerate, + severe: config.maxCostBpsSevere + }[band]; + + if (config.mode === "off") { + return { + allowedCostBps, + band, + costBps, + dryRun: false, + projectedCostRaw: projectedCostRaw.toFixed(0, 0), + reason: "Rebalancing policy mode is off.", + shouldExecute: false + }; + } + + if (costBps > config.hardMaxCostBps) { + return { + allowedCostBps, + band, + costBps, + dryRun: config.mode === "dry-run", + projectedCostRaw: projectedCostRaw.toFixed(0, 0), + reason: `Projected cost ${costBps} bps exceeds hard cap ${config.hardMaxCostBps} bps.`, + shouldExecute: false + }; + } + + if (config.mode === "dry-run") { + return { + allowedCostBps, + band, + costBps, + dryRun: true, + projectedCostRaw: projectedCostRaw.toFixed(0, 0), + reason: `Dry-run: would ${costBps <= allowedCostBps ? "execute" : "skip"} ${band} rebalance at ${costBps} bps cost.`, + shouldExecute: false + }; + } + + if (config.mode === "always") { + return { + allowedCostBps, + band, + costBps, + dryRun: false, + projectedCostRaw: projectedCostRaw.toFixed(0, 0), + reason: `Always mode permits ${band} rebalance at ${costBps} bps cost.`, + shouldExecute: true + }; + } + + if (costBps > allowedCostBps) { + return { + allowedCostBps, + band, + costBps, + dryRun: false, + projectedCostRaw: projectedCostRaw.toFixed(0, 0), + reason: `Projected cost ${costBps} bps exceeds ${band} limit ${allowedCostBps} bps.`, + shouldExecute: false + }; + } + + return { + allowedCostBps, + band, + costBps, + dryRun: false, + projectedCostRaw: projectedCostRaw.toFixed(0, 0), + reason: `Projected cost ${costBps} bps is within ${band} limit ${allowedCostBps} bps.`, + shouldExecute: true + }; +} From 7b74acbde3d666fa441f567557d2e94fc7e4a1e9 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 16 Jun 2026 10:49:53 +0200 Subject: [PATCH 046/131] Add rebalancer cost policy config --- apps/rebalancer/.env.example | 10 +++ apps/rebalancer/src/utils/config.test.ts | 110 +++++++++++++++++++++++ apps/rebalancer/src/utils/config.ts | 64 ++++++++++++- 3 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 apps/rebalancer/src/utils/config.test.ts diff --git a/apps/rebalancer/.env.example b/apps/rebalancer/.env.example index 9a5450e45..4bfdbd012 100644 --- a/apps/rebalancer/.env.example +++ b/apps/rebalancer/.env.example @@ -23,6 +23,16 @@ REBALANCING_DAILY_BRIDGE_LIMIT_USD=10000 # REBALANCING_THRESHOLD_BRLA_TO_USDC=0.01 # REBALANCING_THRESHOLD_USDC_TO_BRLA=0.01 +# Cost-aware rebalancing policy. Modes: auto, dry-run, off, always. +# Daily bridge limit is still enforced in every mode. The hard max cost cap is also enforced in always mode. +# REBALANCING_POLICY_MODE=auto +# REBALANCING_MODERATE_DEVIATION_BPS=200 +# REBALANCING_SEVERE_DEVIATION_BPS=500 +# REBALANCING_MAX_COST_BPS_MILD=25 +# REBALANCING_MAX_COST_BPS_MODERATE=75 +# REBALANCING_MAX_COST_BPS_SEVERE=250 +# REBALANCING_HARD_MAX_COST_BPS=1000 + # Main Nabla instance on Base (BRL→USDC route). Leave empty to disable this route. MAIN_NABLA_ROUTER=0x... MAIN_NABLA_QUOTER=0x... diff --git a/apps/rebalancer/src/utils/config.test.ts b/apps/rebalancer/src/utils/config.test.ts new file mode 100644 index 000000000..5aeb01cd4 --- /dev/null +++ b/apps/rebalancer/src/utils/config.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { getRebalancingCostPolicyConfig, parseRebalancingDailyBridgeLimitUsd, parseRebalancingPolicyMode } from "./config.ts"; + +const policyEnvVars = [ + "REBALANCING_POLICY_MODE", + "REBALANCING_MODERATE_DEVIATION_BPS", + "REBALANCING_SEVERE_DEVIATION_BPS", + "REBALANCING_MAX_COST_BPS_MILD", + "REBALANCING_MAX_COST_BPS_MODERATE", + "REBALANCING_MAX_COST_BPS_SEVERE", + "REBALANCING_HARD_MAX_COST_BPS" +]; + +const originalPolicyEnv = new Map(policyEnvVars.map(name => [name, process.env[name]])); + +function restorePolicyEnv() { + for (const name of policyEnvVars) { + const originalValue = originalPolicyEnv.get(name); + if (originalValue === undefined) { + delete process.env[name]; + } else { + process.env[name] = originalValue; + } + } +} + +beforeEach(() => { + for (const name of policyEnvVars) { + delete process.env[name]; + } +}); + +afterEach(restorePolicyEnv); + +describe("parseRebalancingDailyBridgeLimitUsd", () => { + test("uses the default when the env value is missing", () => { + expect(parseRebalancingDailyBridgeLimitUsd(undefined)).toBe(10_000); + }); + + test("preserves zero as an explicit limit", () => { + expect(parseRebalancingDailyBridgeLimitUsd("0")).toBe(0); + }); + + test("accepts common thousands separators", () => { + expect(parseRebalancingDailyBridgeLimitUsd("100_000")).toBe(100_000); + expect(parseRebalancingDailyBridgeLimitUsd("100,000")).toBe(100_000); + }); + + test("rejects invalid numeric values", () => { + expect(() => parseRebalancingDailyBridgeLimitUsd("not-a-number")).toThrow( + "REBALANCING_DAILY_BRIDGE_LIMIT_USD must be a non-negative number." + ); + }); +}); + +describe("parseRebalancingPolicyMode", () => { + test("defaults to auto", () => { + expect(parseRebalancingPolicyMode(undefined)).toBe("auto"); + }); + + test("accepts supported modes", () => { + expect(parseRebalancingPolicyMode("always")).toBe("always"); + expect(parseRebalancingPolicyMode("dry-run")).toBe("dry-run"); + expect(parseRebalancingPolicyMode("off")).toBe("off"); + }); + + test("rejects unsupported modes", () => { + expect(() => parseRebalancingPolicyMode("sometimes")).toThrow("REBALANCING_POLICY_MODE must be one of"); + }); +}); + +describe("getRebalancingCostPolicyConfig", () => { + test("uses conservative defaults", () => { + const config = getRebalancingCostPolicyConfig(); + + expect(config).toEqual({ + hardMaxCostBps: 1_000, + maxCostBpsMild: 25, + maxCostBpsModerate: 75, + maxCostBpsSevere: 250, + mode: "auto", + moderateDeviationBps: 200, + severeDeviationBps: 500 + }); + }); + + test("rejects non-monotonic deviation thresholds", () => { + process.env.REBALANCING_MODERATE_DEVIATION_BPS = "600"; + process.env.REBALANCING_SEVERE_DEVIATION_BPS = "500"; + + expect(() => getRebalancingCostPolicyConfig()).toThrow( + "REBALANCING_MODERATE_DEVIATION_BPS must be less than or equal to REBALANCING_SEVERE_DEVIATION_BPS." + ); + + delete process.env.REBALANCING_MODERATE_DEVIATION_BPS; + delete process.env.REBALANCING_SEVERE_DEVIATION_BPS; + }); + + test("rejects non-monotonic cost thresholds", () => { + process.env.REBALANCING_MAX_COST_BPS_MILD = "100"; + process.env.REBALANCING_MAX_COST_BPS_MODERATE = "75"; + + expect(() => getRebalancingCostPolicyConfig()).toThrow( + "Rebalancing max cost bps values must be ordered: mild <= moderate <= severe." + ); + + delete process.env.REBALANCING_MAX_COST_BPS_MILD; + delete process.env.REBALANCING_MAX_COST_BPS_MODERATE; + }); +}); diff --git a/apps/rebalancer/src/utils/config.ts b/apps/rebalancer/src/utils/config.ts index 18cb6f1a2..77d4bc5aa 100644 --- a/apps/rebalancer/src/utils/config.ts +++ b/apps/rebalancer/src/utils/config.ts @@ -1,21 +1,78 @@ import { Keyring } from "@polkadot/api"; import { BRLA_BASE_URL, EvmClientManager, Networks } from "@vortexfi/shared"; import { mnemonicToAccount } from "viem/accounts"; +import type { RebalancingCostPolicyConfig, RebalancingPolicyMode } from "../rebalance/usdc-brla-usdc-base/guards.ts"; const DEFAULT_REBALANCING_DAILY_BRIDGE_LIMIT_USD = 10_000; +const REBALANCING_POLICY_MODES: RebalancingPolicyMode[] = ["auto", "always", "dry-run", "off"]; -export function parseRebalancingDailyBridgeLimitUsd(value = process.env.REBALANCING_DAILY_BRIDGE_LIMIT_USD) { +function parseNonNegativeNumber(name: string, value: string | undefined, defaultValue: number): number { const trimmedValue = value?.trim(); - if (!trimmedValue) return DEFAULT_REBALANCING_DAILY_BRIDGE_LIMIT_USD; + if (!trimmedValue) return defaultValue; const parsedValue = Number(trimmedValue.replaceAll("_", "").replaceAll(",", "")); if (!Number.isFinite(parsedValue) || parsedValue < 0) { - throw new Error("REBALANCING_DAILY_BRIDGE_LIMIT_USD must be a non-negative number."); + throw new Error(`${name} must be a non-negative number.`); } return parsedValue; } +export function parseRebalancingDailyBridgeLimitUsd(value = process.env.REBALANCING_DAILY_BRIDGE_LIMIT_USD) { + return parseNonNegativeNumber("REBALANCING_DAILY_BRIDGE_LIMIT_USD", value, DEFAULT_REBALANCING_DAILY_BRIDGE_LIMIT_USD); +} + +export function parseRebalancingPolicyMode(value = process.env.REBALANCING_POLICY_MODE): RebalancingPolicyMode { + const mode = value?.trim() || "auto"; + if (!REBALANCING_POLICY_MODES.includes(mode as RebalancingPolicyMode)) { + throw new Error(`REBALANCING_POLICY_MODE must be one of: ${REBALANCING_POLICY_MODES.join(", ")}.`); + } + + return mode as RebalancingPolicyMode; +} + +export function getRebalancingCostPolicyConfig(): RebalancingCostPolicyConfig { + const config: RebalancingCostPolicyConfig = { + hardMaxCostBps: parseNonNegativeNumber("REBALANCING_HARD_MAX_COST_BPS", process.env.REBALANCING_HARD_MAX_COST_BPS, 1_000), + maxCostBpsMild: parseNonNegativeNumber("REBALANCING_MAX_COST_BPS_MILD", process.env.REBALANCING_MAX_COST_BPS_MILD, 25), + maxCostBpsModerate: parseNonNegativeNumber( + "REBALANCING_MAX_COST_BPS_MODERATE", + process.env.REBALANCING_MAX_COST_BPS_MODERATE, + 75 + ), + maxCostBpsSevere: parseNonNegativeNumber( + "REBALANCING_MAX_COST_BPS_SEVERE", + process.env.REBALANCING_MAX_COST_BPS_SEVERE, + 250 + ), + mode: parseRebalancingPolicyMode(), + moderateDeviationBps: parseNonNegativeNumber( + "REBALANCING_MODERATE_DEVIATION_BPS", + process.env.REBALANCING_MODERATE_DEVIATION_BPS, + 200 + ), + severeDeviationBps: parseNonNegativeNumber( + "REBALANCING_SEVERE_DEVIATION_BPS", + process.env.REBALANCING_SEVERE_DEVIATION_BPS, + 500 + ) + }; + + if (config.moderateDeviationBps > config.severeDeviationBps) { + throw new Error("REBALANCING_MODERATE_DEVIATION_BPS must be less than or equal to REBALANCING_SEVERE_DEVIATION_BPS."); + } + + if (config.maxCostBpsMild > config.maxCostBpsModerate || config.maxCostBpsModerate > config.maxCostBpsSevere) { + throw new Error("Rebalancing max cost bps values must be ordered: mild <= moderate <= severe."); + } + + if (config.maxCostBpsSevere > config.hardMaxCostBps) { + throw new Error("REBALANCING_MAX_COST_BPS_SEVERE must be less than or equal to REBALANCING_HARD_MAX_COST_BPS."); + } + + return config; +} + export function getConfig() { if (!process.env.EVM_ACCOUNT_SECRET) throw new Error("Missing EVM_ACCOUNT_SECRET environment variable"); @@ -41,6 +98,7 @@ export function getConfig() { rebalancingBrlToUsdAmount: process.env.REBALANCING_BRL_TO_USD_AMOUNT || "1", /// The minimum balance in USDC that the rebalancer account on Base must have to allow the BRLA pool rebalancing. rebalancingBrlToUsdMinBalance: process.env.REBALANCING_BRL_TO_USD_MIN_BALANCE || undefined, + rebalancingCostPolicy: getRebalancingCostPolicyConfig(), rebalancingDailyBridgeLimitUsd: parseRebalancingDailyBridgeLimitUsd(), /// The threshold above and below the optimal coverage ratio at which the rebalancing will be triggered. From 32e0ce233a9040981115277c55cdb7e7bc120ac7 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 16 Jun 2026 10:50:35 +0200 Subject: [PATCH 047/131] Apply cost policy to base rebalancer --- apps/rebalancer/src/index.ts | 138 ++++++++++++++++-- .../src/rebalance/brla-to-usdc-base/steps.ts | 59 ++++++-- 2 files changed, 173 insertions(+), 24 deletions(-) diff --git a/apps/rebalancer/src/index.ts b/apps/rebalancer/src/index.ts index 7bf4fc3a7..acef204df 100644 --- a/apps/rebalancer/src/index.ts +++ b/apps/rebalancer/src/index.ts @@ -4,9 +4,14 @@ import Big from "big.js"; import { rebalanceBrlaToUsdcAxl } from "./rebalance/brla-to-axlusdc"; import { checkInitialPendulumBalance } from "./rebalance/brla-to-axlusdc/steps.ts"; import { rebalanceBrlaToUsdcBase } from "./rebalance/brla-to-usdc-base"; +import { quoteBrlaToUsdcBaseRebalance } from "./rebalance/brla-to-usdc-base/steps.ts"; import { rebalanceUsdcBrlaUsdcBase } from "./rebalance/usdc-brla-usdc-base"; -import { wouldExceedDailyBridgeLimit } from "./rebalance/usdc-brla-usdc-base/guards.ts"; -import { checkInitialUsdcBalanceOnBase } from "./rebalance/usdc-brla-usdc-base/steps.ts"; +import { + evaluateRebalancingCostPolicy, + type RebalancingCostPolicyDecision, + wouldExceedDailyBridgeLimit +} from "./rebalance/usdc-brla-usdc-base/guards.ts"; +import { checkInitialUsdcBalanceOnBase, compareRoutesUpfront } from "./rebalance/usdc-brla-usdc-base/steps.ts"; import { getBaseNablaCoverageRatio, getSwapPoolsWithCoverageRatio } from "./services/indexer"; import { BrlaToAxlUsdcStateManager, @@ -14,7 +19,8 @@ import { BrlaToUsdcBaseStateManager, RebalancePhase, UsdcBaseRebalancePhase, - UsdcBaseStateManager + UsdcBaseStateManager, + type WinningRoute } from "./services/stateManager.ts"; import { getConfig, getPendulumAccount } from "./utils/config.ts"; @@ -101,7 +107,110 @@ function checkDailyLimit(bridgedToday: Big, requestedAmountRaw: Big, dailyLimitR return false; } -async function runUsdcToBrla(bridgedToday: Big, dailyLimitRaw: Big) { +function calculateCoverageDeviationBps(coverageRatio: number, triggerBound: number): number { + return Number( + Big(Math.abs(coverageRatio - triggerBound)) + .mul(10_000) + .toFixed(2) + ); +} + +function getQuoteForRoute( + route: Exclude, + quotes: { + squidRouterQuoteUsdc: string | null; + aveniaQuoteUsdc: string | null; + mainNablaQuoteUsdc: string | null; + } +): string | null { + if (route === "squidrouter") return quotes.squidRouterQuoteUsdc; + if (route === "avenia") return quotes.aveniaQuoteUsdc; + return quotes.mainNablaQuoteUsdc; +} + +function logCostPolicyDecision( + direction: string, + inputAmountRaw: string, + projectedOutputRaw: string, + decision: RebalancingCostPolicyDecision +) { + const inputUsdc = Big(inputAmountRaw).div(1e6).toFixed(6); + const projectedUsdc = Big(projectedOutputRaw).div(1e6).toFixed(6); + const projectedCostUsdc = Big(decision.projectedCostRaw).div(1e6).toFixed(6); + console.log( + [ + `Rebalancing cost policy (${direction}): ${decision.shouldExecute ? "execute" : "skip"}`, + `band=${decision.band}`, + `cost=${decision.costBps}bps`, + `allowed=${decision.allowedCostBps}bps`, + `input=${inputUsdc} USDC`, + `projectedOutput=${projectedUsdc} USDC`, + `projectedCost=${projectedCostUsdc} USDC`, + `reason=${decision.reason}` + ].join(" | ") + ); +} + +async function evaluateUsdcToBrlaPolicy( + amountUsdcRaw: string, + coverageDeviationBps: number +): Promise<{ shouldExecute: boolean; routeToRun?: Exclude }> { + const config = getConfig(); + if (config.rebalancingCostPolicy.mode === "off") { + const decision = evaluateRebalancingCostPolicy( + Big(amountUsdcRaw), + Big(amountUsdcRaw), + coverageDeviationBps, + config.rebalancingCostPolicy + ); + logCostPolicyDecision("USDC->BRLA->USDC", amountUsdcRaw, amountUsdcRaw, decision); + return { shouldExecute: false }; + } + + const comparison = await compareRoutesUpfront(amountUsdcRaw); + const routeToRun = forcedRoute || comparison.winningRoute; + if (!routeToRun) throw new Error("Route comparison did not select a route."); + + const projectedOutputRaw = getQuoteForRoute(routeToRun, comparison); + if (!projectedOutputRaw) throw new Error(`Forced route ${routeToRun} did not return a quote.`); + + const decision = evaluateRebalancingCostPolicy( + Big(amountUsdcRaw), + Big(projectedOutputRaw), + coverageDeviationBps, + config.rebalancingCostPolicy + ); + logCostPolicyDecision(`USDC->BRLA->USDC via ${routeToRun}`, amountUsdcRaw, projectedOutputRaw, decision); + + return { routeToRun, shouldExecute: decision.shouldExecute }; +} + +async function evaluateBrlaToUsdcPolicy(amountUsdcRaw: string, coverageDeviationBps: number): Promise { + const config = getConfig(); + if (config.rebalancingCostPolicy.mode === "off") { + const decision = evaluateRebalancingCostPolicy( + Big(amountUsdcRaw), + Big(amountUsdcRaw), + coverageDeviationBps, + config.rebalancingCostPolicy + ); + logCostPolicyDecision("BRLA->USDC", amountUsdcRaw, amountUsdcRaw, decision); + return false; + } + + const quote = await quoteBrlaToUsdcBaseRebalance(amountUsdcRaw); + const decision = evaluateRebalancingCostPolicy( + Big(amountUsdcRaw), + Big(quote.projectedUsdcRaw), + coverageDeviationBps, + config.rebalancingCostPolicy + ); + logCostPolicyDecision("BRLA->USDC", amountUsdcRaw, quote.projectedUsdcRaw, decision); + + return decision.shouldExecute; +} + +async function runUsdcToBrla(bridgedToday: Big, dailyLimitRaw: Big, coverageDeviationBps: number) { const config = getConfig(); const amountUsdc = manualAmount || config.rebalancingUsdToBrlAmount; const amountUsdcRaw = multiplyByPowerOfTen(new Big(amountUsdc), 6).toFixed(0, 0); @@ -112,13 +221,17 @@ async function runUsdcToBrla(bridgedToday: Big, dailyLimitRaw: Big) { if (!isResuming) { if (checkDailyLimit(bridgedToday, Big(amountUsdcRaw), dailyLimitRaw, config.rebalancingDailyBridgeLimitUsd)) return; + const policyDecision = await evaluateUsdcToBrlaPolicy(amountUsdcRaw, coverageDeviationBps); + if (!policyDecision.shouldExecute) return; await checkInitialUsdcBalanceOnBase(amountUsdcRaw); + await rebalanceUsdcBrlaUsdcBase(amountUsdcRaw, forceRestart, policyDecision.routeToRun); + return; } await rebalanceUsdcBrlaUsdcBase(amountUsdcRaw, forceRestart, forcedRoute); } -async function runBrlaToUsdc(bridgedToday: Big, dailyLimitRaw: Big) { +async function runBrlaToUsdc(bridgedToday: Big, dailyLimitRaw: Big, coverageDeviationBps: number) { const config = getConfig(); const amountUsdc = manualAmount || config.rebalancingBrlToUsdAmount; const amountUsdcRaw = multiplyByPowerOfTen(new Big(amountUsdc), 6).toFixed(0, 0); @@ -129,6 +242,7 @@ async function runBrlaToUsdc(bridgedToday: Big, dailyLimitRaw: Big) { if (!isResuming) { if (checkDailyLimit(bridgedToday, Big(amountUsdcRaw), dailyLimitRaw, config.rebalancingDailyBridgeLimitUsd)) return; + if (!(await evaluateBrlaToUsdcPolicy(amountUsdcRaw, coverageDeviationBps))) return; const rebalancerUsdcBalance = await checkInitialUsdcBalanceOnBase(amountUsdcRaw); if (config.rebalancingBrlToUsdMinBalance && rebalancerUsdcBalance.lt(config.rebalancingBrlToUsdMinBalance)) { @@ -162,13 +276,19 @@ async function checkForRebalancing() { ); if (coverage.brlaCoverageRatio < lowerBound) { - console.log(`BRLA coverage ${coverage.brlaCoverageRatio} < ${lowerBound}. Running BRLA->USDC.`); - await runBrlaToUsdc(bridgedToday, dailyLimitRaw); + const deviationBps = calculateCoverageDeviationBps(coverage.brlaCoverageRatio, lowerBound); + console.log( + `BRLA coverage ${coverage.brlaCoverageRatio} < ${lowerBound}. Evaluating BRLA->USDC (${deviationBps} bps deviation).` + ); + await runBrlaToUsdc(bridgedToday, dailyLimitRaw, deviationBps); return; } - console.log(`BRLA coverage ${coverage.brlaCoverageRatio} > ${upperBound}. Running USDC->BRLA.`); - await runUsdcToBrla(bridgedToday, dailyLimitRaw); + const deviationBps = calculateCoverageDeviationBps(coverage.brlaCoverageRatio, upperBound); + console.log( + `BRLA coverage ${coverage.brlaCoverageRatio} > ${upperBound}. Evaluating USDC->BRLA (${deviationBps} bps deviation).` + ); + await runUsdcToBrla(bridgedToday, dailyLimitRaw, deviationBps); } const rebalanceFn = useLegacy ? checkForRebalancingLegacy : checkForRebalancing; diff --git a/apps/rebalancer/src/rebalance/brla-to-usdc-base/steps.ts b/apps/rebalancer/src/rebalance/brla-to-usdc-base/steps.ts index 2edc87ab5..df703aebe 100644 --- a/apps/rebalancer/src/rebalance/brla-to-usdc-base/steps.ts +++ b/apps/rebalancer/src/rebalance/brla-to-usdc-base/steps.ts @@ -63,6 +63,47 @@ export async function getBrlaBalanceOnBaseRaw(): Promise { import { checkInitialUsdcBalanceOnBase } from "../usdc-brla-usdc-base/steps.ts"; +export async function quoteMainNablaUsdcToBrlaOnBase(usdcAmountRaw: string): Promise { + const { router, quoter } = getMainNablaConfig(); + const evmClientManager = EvmClientManager.getInstance(); + + const expectedOutputRaw = await evmClientManager.readContractWithRetry(Networks.Base, { + abi: MAIN_NABLA_QUOTE_ABI, + address: quoter, + args: [BigInt(usdcAmountRaw), [USDC_BASE, ERC20_BRLA_BASE], [router]], + functionName: "quoteSwapExactTokensForTokens" + }); + + const expectedOutputDecimal = multiplyByPowerOfTen(Big(expectedOutputRaw.toString()), -18); + console.log(`Main Nabla preflight quote: ${expectedOutputDecimal.toFixed(6)} BRLA`); + return expectedOutputRaw.toString(); +} + +export async function quoteBrlaNablaToUsdcOnBase(brlaAmountRaw: string): Promise { + const evmClientManager = EvmClientManager.getInstance(); + + const expectedOutputRaw = await evmClientManager.readContractWithRetry(Networks.Base, { + abi: NABLA_QUOTE_ABI, + address: BRLA_NABLA_QUOTER, + args: [BigInt(brlaAmountRaw), [ERC20_BRLA_BASE, USDC_BASE], [BRLA_NABLA_ROUTER]], + functionName: "quoteSwapExactTokensForTokens" + }); + + const expectedOutputDecimal = multiplyByPowerOfTen(Big(expectedOutputRaw.toString()), -6); + console.log(`BRLA Nabla preflight quote: ${expectedOutputDecimal.toFixed(6)} USDC`); + return expectedOutputRaw.toString(); +} + +export async function quoteBrlaToUsdcBaseRebalance(usdcAmountRaw: string): Promise<{ + estimatedBrlaRaw: string; + projectedUsdcRaw: string; +}> { + const estimatedBrlaRaw = await quoteMainNablaUsdcToBrlaOnBase(usdcAmountRaw); + const projectedUsdcRaw = await quoteBrlaNablaToUsdcOnBase(estimatedBrlaRaw); + + return { estimatedBrlaRaw, projectedUsdcRaw }; +} + export async function nablaSwapBrlaToUsdcOnBase( brlaAmountRaw: string, baseNonce: NonceManager, @@ -93,13 +134,7 @@ export async function nablaSwapBrlaToUsdcOnBase( let swapHash = state.nablaSwapHash; if (!swapHash) { - const evmClientManager = EvmClientManager.getInstance(); - const expectedOutputRaw = await evmClientManager.readContractWithRetry(Networks.Base, { - abi: NABLA_QUOTE_ABI, - address: BRLA_NABLA_QUOTER, - args: [BigInt(brlaAmountRaw), [ERC20_BRLA_BASE, USDC_BASE], [BRLA_NABLA_ROUTER]], - functionName: "quoteSwapExactTokensForTokens" - }); + const expectedOutputRaw = BigInt(await quoteBrlaNablaToUsdcOnBase(brlaAmountRaw)); const expectedOutputDecimal = multiplyByPowerOfTen(Big(expectedOutputRaw.toString()), -6); console.log(`Expected USDC output: ${expectedOutputDecimal.toFixed(6)}`); @@ -229,7 +264,7 @@ export async function mainNablaSwapUsdcToBrlaOnBase( state: BrlaToUsdcBaseRebalanceState, stateManager: BrlaToUsdcBaseStateManager ): Promise { - const { router, quoter } = getMainNablaConfig(); + const { router } = getMainNablaConfig(); const { walletClient, publicClient } = getBaseEvmClients(); const executorAddress = walletClient.account.address; @@ -254,13 +289,7 @@ export async function mainNablaSwapUsdcToBrlaOnBase( let swapHash = state.mainNablaSwapHash; if (!swapHash) { - const evmClientManager = EvmClientManager.getInstance(); - const expectedOutputRaw = await evmClientManager.readContractWithRetry(Networks.Base, { - abi: MAIN_NABLA_QUOTE_ABI, - address: quoter, - args: [BigInt(usdcAmountRaw), [USDC_BASE, ERC20_BRLA_BASE], [router]], - functionName: "quoteSwapExactTokensForTokens" - }); + const expectedOutputRaw = BigInt(await quoteMainNablaUsdcToBrlaOnBase(usdcAmountRaw)); const expectedOutputDecimal = multiplyByPowerOfTen(Big(expectedOutputRaw.toString()), -18); console.log(`Expected BRLA output from Main Nabla: ${expectedOutputDecimal.toFixed(6)}`); From 9c4c78748407510b1b29437de6326e51655ce9bd Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 16 Jun 2026 10:51:06 +0200 Subject: [PATCH 048/131] Document cost-aware rebalancer policy --- .../security-spec/07-operations/rebalancer.md | 60 ++++++++++++++----- docs/security-spec/README.md | 2 +- 2 files changed, 45 insertions(+), 17 deletions(-) diff --git a/docs/security-spec/07-operations/rebalancer.md b/docs/security-spec/07-operations/rebalancer.md index eca83d4a3..ea1ad6b18 100644 --- a/docs/security-spec/07-operations/rebalancer.md +++ b/docs/security-spec/07-operations/rebalancer.md @@ -4,6 +4,8 @@ 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. `REBALANCING_DAILY_BRIDGE_LIMIT_USD` and `REBALANCING_HARD_MAX_COST_BPS` remain hard caps in every mode. + **Current implementation:** Three rebalancing paths: 1. **BRLA ↔ axlUSDC (legacy, Pendulum)** — 8-step cross-chain process on Pendulum/Moonbeam/Polygon. Activated via `--legacy` flag. @@ -31,7 +33,13 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- - No flag → Base flow (default) - `--legacy` → Pendulum flow - `--restart` → Force fresh state, ignore in-progress rebalance -- `--route=squidrouter|avenia|nabla-main` → Force specific high-coverage return route (skip route comparison) +- `--route=squidrouter|avenia|nabla-main` → Constrain the high-coverage return route; the route is still quoted and cost-gated before execution + +**Cost policy controls:** +- `REBALANCING_POLICY_MODE=auto|dry-run|off|always` — `auto` applies urgency-band cost gating; `dry-run` quotes and logs the decision without state writes or fund movement; `off` skips fresh Base rebalances; `always` bypasses per-band cost gating but still respects the daily bridge limit and hard max-cost cap. +- `REBALANCING_MODERATE_DEVIATION_BPS` / `REBALANCING_SEVERE_DEVIATION_BPS` — classify coverage deviation beyond the trigger bound into mild, moderate, or severe bands. +- `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. --- @@ -53,10 +61,12 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- ### Flow 2: USDC → BRLA → USDC (Base, default high-coverage flow) -**Trigger condition:** Base Nabla BRLA pool coverage ratio > `1 + REBALANCING_THRESHOLD_USDC_TO_BRLA` (default upper bound `1.01`). Falls back to `REBALANCING_THRESHOLD` when the route-specific threshold is unset. +**Trigger condition:** Base Nabla BRLA pool coverage ratio > `1 + REBALANCING_THRESHOLD_USDC_TO_BRLA` (default upper bound `1.01`). Falls back to `REBALANCING_THRESHOLD` when the route-specific threshold is unset. This makes the flow eligible for evaluation; cost policy may still skip fresh execution. **Daily bridge limit:** Total requested USDC amount recorded by Base-flow history per calendar day (UTC), including the amount about to be rebalanced, must not exceed `REBALANCING_DAILY_BRIDGE_LIMIT_USD` (default 10,000). Checked against both `UsdcBaseStateManager` and `BrlaToUsdcBaseStateManager` history before starting a fresh Base rebalance. +**Urgency-band policy:** Before any state write or transaction, the flow quotes the expected round-trip USDC output. Projected cost is `(input USDC - projected output USDC) / input USDC` in basis points. `auto` mode executes only when the projected cost is within the configured limit for the current coverage-deviation band. `dry-run` logs the same decision but never starts a rebalance. `off` skips without quoting. `always` can execute above the band limit, but not above `REBALANCING_HARD_MAX_COST_BPS` or the daily bridge limit. + **Rebalancing flow (linear phase):** 1. Check initial USDC balance on Base (sufficient for requested amount) 2. Nabla approve + swap: USDC → BRLA on Base @@ -65,10 +75,11 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- **Rate comparison phase:** 5. Compare rates between SquidRouter, Avenia, and optional main Nabla for BRLA → USDC conversion - - If `--route=` specified, fetches quote for that route only + - If `--route=` is specified, execution is constrained to that route and the policy still requires a quote for that route before any swap - Main Nabla route is available only when both `MAIN_NABLA_ROUTER` and `MAIN_NABLA_QUOTER` are set - If every enabled route quote fails, aborts - If one fails, uses the other + - The selected quote feeds both route selection and the cost-policy gate before the first Nabla swap **Route A: main Nabla (BRLA → USDC on Base, direct):** 6a. Main Nabla approve + swap: BRLA → USDC on Base @@ -102,10 +113,12 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- ### Flow 3: BRLA → USDC correction (Base, default low-coverage flow) -**Trigger condition:** Base Nabla BRLA pool coverage ratio < `1 - REBALANCING_THRESHOLD_BRLA_TO_USDC` (default lower bound `0.99`). Falls back to `REBALANCING_THRESHOLD` when the route-specific threshold is unset. +**Trigger condition:** Base Nabla BRLA pool coverage ratio < `1 - REBALANCING_THRESHOLD_BRLA_TO_USDC` (default lower bound `0.99`). Falls back to `REBALANCING_THRESHOLD` when the route-specific threshold is unset. This makes the flow eligible for evaluation; cost policy may still skip fresh execution. **Daily bridge limit:** Uses the same Base-flow daily limit described above and records history in `rebalancer_state_brla_to_usdc_base.json`. +**Urgency-band policy:** Uses the same Base policy controls as the high-coverage flow. Before any state write or transaction, the rebalancer pre-quotes the main Nabla USDC→BRLA leg and the BRLA-pool BRLA→USDC leg, then applies the band-specific projected-cost threshold. + **Rebalancing flow:** 1. Check initial USDC balance on Base 2. Main Nabla swap: USDC → BRLA on Base @@ -119,29 +132,34 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- ### Shared (both flows) -1. **Coverage ratio check MUST precede rebalancing** — The rebalancer only triggers when a flow-specific coverage threshold is crossed. Legacy flow uses Pendulum indexer data and triggers when BRLA is over-covered while USDC.axl is not; the default Base flow uses on-chain Nabla contract reads and triggers above `1 + REBALANCING_THRESHOLD_USDC_TO_BRLA` or below `1 - REBALANCING_THRESHOLD_BRLA_TO_USDC`. It must never rebalance preemptively or based on stale data. +1. **Coverage ratio check MUST precede rebalancing** — The rebalancer only considers a fresh run when a flow-specific coverage threshold is crossed. Legacy flow uses Pendulum indexer data and triggers when BRLA is over-covered while USDC.axl is not; the default Base flow uses on-chain Nabla contract reads and becomes eligible above `1 + REBALANCING_THRESHOLD_USDC_TO_BRLA` or below `1 - REBALANCING_THRESHOLD_BRLA_TO_USDC`. For Base flows, threshold crossing is necessary but not sufficient: cost policy can still skip execution. 2. **State persistence MUST survive process restarts** — Each flow has its own Supabase Storage JSON file (`rebalancer_state.json` for legacy, `rebalancer_state_usdc_base.json` for Base high-coverage, `rebalancer_state_brla_to_usdc_base.json` for Base low-coverage). On restart, the rebalancer reads the file and resumes from the last completed phase. 3. **Each phase MUST be idempotent or guarded against re-execution** — If the process crashes mid-phase and resumes, re-executing a completed phase must not cause double-swaps, double-transfers, or double-settlements. Transaction hashes and pre-action balance baselines are stored in state to detect already-completed phases and verify per-run deltas. 4. **Rebalancer private keys MUST be isolated from API service keys** — The rebalancer keys operate separate accounts. Compromise of rebalancer keys should not affect API ramp operations, and vice versa. 5. **BRLA business account address MUST be verified** — `brlaBusinessAccountAddress` has a hardcoded default (`0xDF5Fb34B90e5FDF612372dA0c774A516bF5F08b2`). If this address is wrong, funds are sent to the wrong recipient with no recovery. 6. **Concurrent rebalancer executions MUST NOT corrupt state** — If two rebalancer instances run simultaneously, both would read the same state file and potentially execute the same phases in parallel. Supabase Storage has no file locking or atomic compare-and-swap. +7. **Policy modes MUST be fail-safe** — `off` performs no fresh Base rebalancing; `dry-run` performs read-only quote/evaluation/logging with no state writes, tickets, approvals, swaps, transfers, or history entries; `always` bypasses per-band cost gating only, not the daily bridge limit or hard max-cost cap. ### Legacy flow (BRLA ↔ axlUSDC) invariants -7. **Slippage MUST be bounded** — The Nabla swap step uses a 5% slippage tolerance (hardcoded). Excessive slippage could result in significant value loss per rebalance. -8. **SquidRouter gas pricing MUST not overpay excessively** — `gasMultiplier * 5n` is applied to `maxFeePerGas` for SquidRouter transactions. This aggressive multiplier ensures inclusion but could result in significant gas overpayment. -9. **Axelar polling MUST have a timeout** — **F-034 (legacy):** The legacy flow's Axelar polling loop (`while (!isExecuted)`) has no timeout — it will poll indefinitely if Axelar never reports success. This is a known deficiency in the legacy flow; the Base flow fixes it with a 30-minute timeout. +8. **Slippage MUST be bounded** — The Nabla swap step uses a 5% slippage tolerance (hardcoded). Excessive slippage could result in significant value loss per rebalance. +9. **SquidRouter gas pricing MUST not overpay excessively** — `gasMultiplier * 5n` is applied to `maxFeePerGas` for SquidRouter transactions. This aggressive multiplier ensures inclusion but could result in significant gas overpayment. +10. **Axelar polling MUST have a timeout** — **F-034 (legacy):** The legacy flow's Axelar polling loop (`while (!isExecuted)`) has no timeout — it will poll indefinitely if Axelar never reports success. This is a known deficiency in the legacy flow; the Base flow fixes it with a 30-minute timeout. ### Base flow invariants -10. **Daily bridge limit MUST be enforced** — 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`. Checked against both Base flow history entries before starting a new Base rebalance. Prevents runaway rebalancing from draining hot wallets. -11. **Route comparison MUST handle provider failures gracefully** — If every enabled return route quote fails, the high-coverage flow MUST abort (not proceed with zero information). If some routes fail, the best available route is used. If `--route=` is specified, only that route's quote is fetched. -12. **Avenia fallback to SquidRouter MUST be atomic in state** — If Avenia ticket creation fails, the flow sets `winningRoute = "squidrouter"` and `currentPhase = AveniaTransferToPolygon` in a single `saveState()` call. A crash between the failure and the save could leave the flow in an inconsistent state. -13. **NonceManager MUST be re-initialized on resume** — The `NonceManager` is created fresh at the start of each execution from `getTransactionCount()`. On resume, it must not reuse stale nonces from a previous execution. -14. **Axelar cross-chain execution MUST have a timeout** — SquidRouter's Axelar polling has a 30-minute timeout. If Axelar does not confirm execution within this window, the flow MUST throw (not poll indefinitely). This resolves F-034 for the Base flow. -15. **Balance arrival checks MUST be delta-based** — The Base high-coverage flow persists pre-action balances before each arrival-producing operation and waits for `starting balance + expected delta` rather than checking absolute hot-wallet/provider balances. Avenia and Polygon BRLA arrival checks allow a 99.8% tolerance to account for rounding and minor deductions without sweeping unrelated leftover balances into the current run. -16. **`EVM_ACCOUNT_SECRET` derives the same address on all EVM chains** — A single BIP-39 mnemonic is used for Base, Polygon, and Moonbeam. This means compromise of this one secret drains the rebalancer on ALL EVM chains. `PENDULUM_ACCOUNT_SECRET` is separate and legacy-only. -17. **Terminal Avenia ticket failures MUST abort immediately** — `checkTicketStatusPaid` treats `FAILED` as terminal and throws immediately instead of retrying until timeout. +11. **Daily bridge limit MUST be enforced** — 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`. Checked against both Base flow history entries before starting a new Base rebalance. This cap remains a hard upper bound in severe and `always` mode. +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. +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. **Severe imbalances MAY use higher tolerance but MUST NOT bypass hard caps** — Severe band can permit higher projected cost, but it cannot bypass the daily bridge limit, `REBALANCING_HARD_MAX_COST_BPS`, balance checks, slippage limits, or phase safety checks. +16. **Route comparison MUST handle provider failures gracefully** — If every enabled return route quote fails, the high-coverage flow MUST abort (not proceed with zero information). If some routes fail, the best available route is used. If `--route=` is specified, that route is still quoted and cost-gated before execution. +17. **Avenia fallback to SquidRouter MUST be atomic in state** — If Avenia ticket creation fails, the flow sets `winningRoute = "squidrouter"` and `currentPhase = AveniaTransferToPolygon` in a single `saveState()` call. A crash between the failure and the save could leave the flow in an inconsistent state. +18. **NonceManager MUST be re-initialized on resume** — The `NonceManager` is created fresh at the start of each execution from `getTransactionCount()`. On resume, it must not reuse stale nonces from a previous execution. +19. **Axelar cross-chain execution MUST have a timeout** — SquidRouter's Axelar polling has a 30-minute timeout. If Axelar does not confirm execution within this window, the flow MUST throw (not poll indefinitely). This resolves F-034 for the Base flow. +20. **Balance arrival checks MUST be delta-based** — The Base high-coverage flow persists pre-action balances before each arrival-producing operation and waits for `starting balance + expected delta` rather than checking absolute hot-wallet/provider balances. Avenia and Polygon BRLA arrival checks allow a 99.8% tolerance to account for rounding and minor deductions without sweeping unrelated leftover balances into the current run. +21. **`EVM_ACCOUNT_SECRET` derives the same address on all EVM chains** — A single BIP-39 mnemonic is used for Base, Polygon, and Moonbeam. This means compromise of this one secret drains the rebalancer on ALL EVM chains. `PENDULUM_ACCOUNT_SECRET` is separate and legacy-only. +22. **Terminal Avenia ticket failures MUST abort immediately** — `checkTicketStatusPaid` treats `FAILED` as terminal and throws immediately instead of retrying until timeout. ## Threat Vectors & Mitigations @@ -171,6 +189,10 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- | **Route comparison manipulation** — Avenia, SquidRouter, and optional main Nabla quotes are fetched and compared; an attacker could manipulate one provider's rate to force another route | The rebalancer trusts provider quotes without independent verification. However, since all high-coverage routes end with USDC on Base, the worst case is choosing a slightly worse rate, not direct fund loss. The `slippage: 4` parameter on SquidRouter provides some buffer. | | **Avenia ticket creation failure mid-flow** — Avenia API fails after the flow committed to the Avenia route | **Mitigated.** The flow catches ticket creation errors and falls back to SquidRouter by setting `winningRoute = "squidrouter"` and saving state. Avenia tickets that return `FAILED` after creation are terminal and abort immediately. | | **Daily bridge limit bypass** — History entries are stored in Supabase Storage; an attacker who can modify the storage could clear history to bypass the daily limit | **Weak mitigation.** The limit is enforced client-side by reading history from Supabase and adding the current requested amount before starting. An attacker with Supabase access could also drain funds directly, so the limit bypass is a secondary concern. | +| **Cost-threshold misconfiguration** — Cost thresholds set too low can cause chronic under-rebalancing; thresholds set too high can cause repeated expensive rebalancing | Defaults are conservative and env parsing fails fast for non-monotonic values. Operators should first use `REBALANCING_POLICY_MODE=dry-run` to observe decisions before enabling tighter or looser production thresholds. | +| **Always-mode misuse** — Operator leaves `REBALANCING_POLICY_MODE=always` enabled and accepts expensive routes repeatedly | `always` still respects the daily bridge limit and `REBALANCING_HARD_MAX_COST_BPS`. Decision logs include band, projected cost, allowed cost, and reason so misuse is observable. | +| **Dry-run/off mode drift** — Cron appears healthy but liquidity is not actually moving | `dry-run` and `off` log explicit skip reasons. External monitoring must distinguish successful dry-run/off exits from real completed rebalances. | +| **Quote-cost manipulation near thresholds** — Provider quotes near a configured boundary can nudge execution or skipping | Cost policy uses the best/forced quoted route before any side effect. Hard max-cost cap limits catastrophic execution, but provider quote trust remains a known risk. | | **NonceManager stale nonce** — If the process crashes after sending a transaction but before saving the nonce, the resumed execution could reuse the same nonce | **Mitigated.** `NonceManager` is re-initialized from `getTransactionCount()` on each execution. The stored transaction hashes in state also prevent re-execution of already-completed phases. | | **`EVM_ACCOUNT_SECRET` single-key blast radius** — One mnemonic controls all EVM chain accounts for the rebalancer | Compromise of this one secret drains rebalancer funds on Base, Polygon, and Moonbeam. The separate `PENDULUM_ACCOUNT_SECRET` limits Pendulum blast radius to the legacy flow. This is a deliberate simplification accepted for operational convenience. | | **SquidRouter cross-chain timeout** — Axelar cross-chain execution could take longer than 30 minutes during network congestion | The rebalancer throws on timeout, leaving the BRLA-to-USDC swap incomplete on Polygon. Funds would be stuck as BRLA on Polygon until manual intervention or the next rebalance attempt resumes from the `SquidRouterApproveAndSwap` phase. | @@ -217,3 +239,9 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- - [x] Verify Base flow arrival checks are delta-based. **PASS** — Avenia BRLA, Polygon BRLA, Avenia USDC-on-Base, and SquidRouter USDC-on-Base waits all use persisted pre-action baselines plus expected deltas. - [x] Verify Nabla swap resume cannot lose the received BRLA amount. **PASS** — pre-swap BRLA baseline and swap hash are persisted; resume computes output from the persisted baseline or reuses already recorded output. - [x] Verify Base low-coverage flow state/history is isolated from the high-coverage flow. **PASS** — `BrlaToUsdcBaseStateManager` uses `rebalancer_state_brla_to_usdc_base.json` while sharing the daily limit calculation. +- [x] Verify mild/moderate expensive rebalances are skipped in `auto` mode. **PASS** — projected cost is compared against the configured band threshold before any fresh Base state write or transaction. +- [x] Verify severe imbalances can execute at a higher configured cost while still respecting hard caps. **PASS** — severe uses `REBALANCING_MAX_COST_BPS_SEVERE`, bounded by `REBALANCING_HARD_MAX_COST_BPS`. +- [x] Verify `off` mode performs no fresh Base execution. **PASS** — policy returns a skip decision before quotes, state writes, balances, approvals, swaps, transfers, or tickets. +- [x] Verify `dry-run` mode performs no approvals/swaps/transfers/history mutations. **PASS** — policy quotes and logs the decision, then exits before state creation. +- [x] Verify `always` mode still enforces daily bridge limit and hard max-cost cap. **PASS** — daily limit is checked before policy evaluation; policy rejects cost above `REBALANCING_HARD_MAX_COST_BPS` in every mode. +- [x] Verify skipped decisions are observable. **PASS** — logs include direction, band, projected cost bps, allowed bps, input, projected output, projected cost, and reason. diff --git a/docs/security-spec/README.md b/docs/security-spec/README.md index 8687455b1..6fbf45afc 100644 --- a/docs/security-spec/README.md +++ b/docs/security-spec/README.md @@ -42,7 +42,7 @@ This directory contains the security specification for the Vortex cross-border p | XCM Transfers | `06-cross-chain/xcm-transfers.md` | Pendulum↔Moonbeam↔AssetHub↔Hydration | | Bridge Security | `06-cross-chain/bridge-security.md` | Spacewalk bridge trust model | | Fund Routing | `06-cross-chain/fund-routing.md` | Subsidization, fee distribution, amount integrity | -| Rebalancer | `07-operations/rebalancer.md` | Automated liquidity management — BRLA↔axlUSDC (legacy, Pendulum), USDC→BRLA→USDC (Base high-coverage), and BRLA→USDC correction (Base low-coverage) | +| Rebalancer | `07-operations/rebalancer.md` | Automated liquidity management — BRLA↔axlUSDC (legacy, Pendulum), cost-aware USDC→BRLA→USDC (Base high-coverage), and cost-aware BRLA→USDC correction (Base low-coverage) | | Secret Management | `07-operations/secret-management.md` | Env vars, rotation, blast radius | | API Surface | `07-operations/api-surface.md` | Rate limiting, CORS, input validation, error handling | | Client Observability | `07-operations/client-observability.md` | Request IDs, sanitized API client events, operational monitoring | From 3c6072da12f27e317423077e72973077b23592e1 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 16 Jun 2026 11:08:07 +0200 Subject: [PATCH 049/131] Add handling for when received squidrouter amount is lower than expected --- .../usdc-brla-usdc-base/guards.test.ts | 10 ++++++--- .../rebalance/usdc-brla-usdc-base/index.ts | 4 ++-- .../rebalance/usdc-brla-usdc-base/steps.ts | 22 ++++++++++++++----- .../security-spec/07-operations/rebalancer.md | 4 ++-- 4 files changed, 28 insertions(+), 12 deletions(-) diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.test.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.test.ts index 7424552a5..996e24d30 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.test.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import {describe, expect, test} from "bun:test"; import Big from "big.js"; import { calculateMinimumDelta, @@ -6,8 +6,8 @@ import { calculateTargetBalanceRaw, evaluateRebalancingCostPolicy, getRebalancingUrgencyBand, - wouldExceedDailyBridgeLimit, - type RebalancingCostPolicyConfig + type RebalancingCostPolicyConfig, + wouldExceedDailyBridgeLimit } from "./guards.ts"; const policyConfig: RebalancingCostPolicyConfig = { @@ -29,6 +29,10 @@ describe("USDC Base rebalance guards", () => { expect(calculateMinimumDelta(Big("100"), "0.998").toString()).toBe("99.8"); }); + test("allows small Base USDC arrival shortfalls with the default tolerance", () => { + expect(calculateTargetBalanceRaw("13148408", "999225918", "0.998")).toBe("1010375874"); + }); + test("daily bridge limit includes the amount about to be rebalanced", () => { expect(wouldExceedDailyBridgeLimit(Big("9500000000"), Big("600000000"), Big("10000000000"))).toBe(true); expect(wouldExceedDailyBridgeLimit(Big("9000000000"), Big("1000000000"), Big("10000000000"))).toBe(false); diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts index d552c022a..afbd6a745 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts @@ -206,7 +206,7 @@ export async function rebalanceUsdcBrlaUsdcBase( } const aveniaUsdcRaw = multiplyByPowerOfTen(Big(state.aveniaQuoteUsdc), 6).toFixed(0, 0); - await waitUsdcOnBase(aveniaUsdcRaw, state.baseUsdcBalanceBeforeAveniaSwapRaw); + state.aveniaQuoteUsdc = await waitUsdcOnBase(aveniaUsdcRaw, state.baseUsdcBalanceBeforeAveniaSwapRaw); console.log("USDC from Avenia confirmed on Base."); state.currentPhase = UsdcBaseRebalancePhase.VerifyFinalBalance; @@ -280,7 +280,7 @@ export async function rebalanceUsdcBrlaUsdcBase( throw new Error("State corrupted: baseUsdcBalanceBeforeSquidSwapRaw missing for squid step 4"); } - await waitUsdcOnBase(state.squidRouterQuoteUsdc, state.baseUsdcBalanceBeforeSquidSwapRaw); + state.squidRouterQuoteUsdc = await waitUsdcOnBase(state.squidRouterQuoteUsdc, state.baseUsdcBalanceBeforeSquidSwapRaw); console.log("USDC from SquidRouter confirmed on Base."); state.currentPhase = UsdcBaseRebalancePhase.VerifyFinalBalance; diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts index 5e09431ee..81b2caedd 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts @@ -485,7 +485,7 @@ export async function squidRouterApproveAndSwap( ): Promise<{ swapHash: string; toAmountUsd: string; toAmountRaw: string }> { let swapHash = state.squidRouterSwapHash; let toAmountUsd = "0"; - let toAmountRaw = "0"; + let toAmountRaw = state.squidRouterQuoteUsdc ?? "0"; if (!swapHash) { console.log("Executing SquidRouter swap: Polygon BRLA -> Base USDC..."); @@ -552,6 +552,9 @@ export async function squidRouterApproveAndSwap( console.log(`Swap tx: ${swapHash}`); await waitForTransactionConfirmation(swapHash, polygonPublicClient); } else { + if (!state.squidRouterQuoteUsdc) { + throw new Error("State corrupted: squidRouterQuoteUsdc missing while resuming SquidRouter swap."); + } console.log(`Resuming SquidRouter swap with existing swap tx: ${swapHash}`); } @@ -578,16 +581,25 @@ export async function squidRouterApproveAndSwap( return { swapHash, toAmountRaw, toAmountUsd }; } -export async function waitUsdcOnBase(expectedUsdcRaw: string, startingUsdcBalanceRaw: string): Promise { +export async function waitUsdcOnBase(expectedUsdcRaw: string, startingUsdcBalanceRaw: string): Promise { const { walletClient } = getBaseEvmClients(); const baseAddress = walletClient.account.address; - const targetBalanceRaw = calculateTargetBalanceRaw(startingUsdcBalanceRaw, expectedUsdcRaw); + const targetBalanceRaw = calculateTargetBalanceRaw(startingUsdcBalanceRaw, expectedUsdcRaw, DEFAULT_ARRIVAL_TOLERANCE); console.log(`Waiting for USDC delta to arrive on Base (${baseAddress})...`); - await checkEvmBalancePeriodically(USDC_BASE, baseAddress, targetBalanceRaw, 1_000, 30 * 60 * 1_000, Networks.Base); + const finalBalanceRaw = await checkEvmBalancePeriodically( + USDC_BASE, + baseAddress, + targetBalanceRaw, + 1_000, + 30 * 60 * 1_000, + Networks.Base + ); + const receivedDeltaRaw = finalBalanceRaw.minus(Big(startingUsdcBalanceRaw)).toFixed(0, 0); - console.log("USDC arrived on Base."); + console.log(`USDC arrived on Base. Actual delta: ${receivedDeltaRaw} raw`); + return receivedDeltaRaw; } export async function aveniaCreateSwapToUsdcBaseTicket( diff --git a/docs/security-spec/07-operations/rebalancer.md b/docs/security-spec/07-operations/rebalancer.md index ea1ad6b18..0dd03a117 100644 --- a/docs/security-spec/07-operations/rebalancer.md +++ b/docs/security-spec/07-operations/rebalancer.md @@ -157,7 +157,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- 17. **Avenia fallback to SquidRouter MUST be atomic in state** — If Avenia ticket creation fails, the flow sets `winningRoute = "squidrouter"` and `currentPhase = AveniaTransferToPolygon` in a single `saveState()` call. A crash between the failure and the save could leave the flow in an inconsistent state. 18. **NonceManager MUST be re-initialized on resume** — The `NonceManager` is created fresh at the start of each execution from `getTransactionCount()`. On resume, it must not reuse stale nonces from a previous execution. 19. **Axelar cross-chain execution MUST have a timeout** — SquidRouter's Axelar polling has a 30-minute timeout. If Axelar does not confirm execution within this window, the flow MUST throw (not poll indefinitely). This resolves F-034 for the Base flow. -20. **Balance arrival checks MUST be delta-based** — The Base high-coverage flow persists pre-action balances before each arrival-producing operation and waits for `starting balance + expected delta` rather than checking absolute hot-wallet/provider balances. Avenia and Polygon BRLA arrival checks allow a 99.8% tolerance to account for rounding and minor deductions without sweeping unrelated leftover balances into the current run. +20. **Balance arrival checks MUST be delta-based** — The Base high-coverage flow persists pre-action balances before each arrival-producing operation and waits for `starting balance + expected delta` rather than checking absolute hot-wallet/provider balances. BRLA and Base USDC arrival checks allow a 99.8% tolerance to account for rounding, route deductions, and minor quote shortfalls without sweeping unrelated leftover balances into the current run. The actual received Base USDC delta is persisted before advancing to final verification. 21. **`EVM_ACCOUNT_SECRET` derives the same address on all EVM chains** — A single BIP-39 mnemonic is used for Base, Polygon, and Moonbeam. This means compromise of this one secret drains the rebalancer on ALL EVM chains. `PENDULUM_ACCOUNT_SECRET` is separate and legacy-only. 22. **Terminal Avenia ticket failures MUST abort immediately** — `checkTicketStatusPaid` treats `FAILED` as terminal and throws immediately instead of retrying until timeout. @@ -236,7 +236,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- - [x] Verify `waitBrlaOnPolygon` has a timeout. **PASS** — 10-minute timeout via `checkEvmBalancePeriodically`. - [PARTIAL] Verify the Nabla swap validates output amount against expectations. **PARTIAL** — uses `AMM_MINIMUM_OUTPUT_HARD_MARGIN` (5%) for slippage protection via `quoteSwapExactTokensForTokens`, but post-swap balance is verified by comparing pre/post BRLA balance (not against the quote). A sandwich attack could extract up to 5%. - [x] Verify the `usdcBasePhaseOrder` overlap (`AveniaTransferToPolygon` and `AveniaSwapToUsdcBase` both at order 6; both wait phases at order 7) cannot cause incorrect phase transitions. **PASS** — routes are mutually exclusive, guarded by `if (state.winningRoute === "avenia")` / `if (state.winningRoute === "squidrouter")` checks. -- [x] Verify Base flow arrival checks are delta-based. **PASS** — Avenia BRLA, Polygon BRLA, Avenia USDC-on-Base, and SquidRouter USDC-on-Base waits all use persisted pre-action baselines plus expected deltas. +- [x] Verify Base flow arrival checks are delta-based. **PASS** — Avenia BRLA, Polygon BRLA, Avenia USDC-on-Base, and SquidRouter USDC-on-Base waits all use persisted pre-action baselines plus expected deltas. Base USDC waits use the default 99.8% tolerance and persist the actual received delta before final verification. - [x] Verify Nabla swap resume cannot lose the received BRLA amount. **PASS** — pre-swap BRLA baseline and swap hash are persisted; resume computes output from the persisted baseline or reuses already recorded output. - [x] Verify Base low-coverage flow state/history is isolated from the high-coverage flow. **PASS** — `BrlaToUsdcBaseStateManager` uses `rebalancer_state_brla_to_usdc_base.json` while sharing the daily limit calculation. - [x] Verify mild/moderate expensive rebalances are skipped in `auto` mode. **PASS** — projected cost is compared against the configured band threshold before any fresh Base state write or transaction. From 3057b423a0784d4ad22c7bf99ec71186553cf4af Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 16 Jun 2026 12:01:25 +0200 Subject: [PATCH 050/131] Adjust formatting of slack notification --- apps/rebalancer/src/index.ts | 30 +++++--- .../src/rebalance/brla-to-usdc-base/index.ts | 6 +- .../brla-to-usdc-base/notifications.test.ts | 47 ++++++++++++ .../brla-to-usdc-base/notifications.ts | 32 +++++---- .../rebalance/usdc-brla-usdc-base/index.ts | 8 ++- .../usdc-brla-usdc-base/notifications.test.ts | 52 ++++++++++++++ .../usdc-brla-usdc-base/notifications.ts | 72 ++++++++++++++++--- 7 files changed, 212 insertions(+), 35 deletions(-) create mode 100644 apps/rebalancer/src/rebalance/brla-to-usdc-base/notifications.test.ts create mode 100644 apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.test.ts diff --git a/apps/rebalancer/src/index.ts b/apps/rebalancer/src/index.ts index acef204df..961074b3a 100644 --- a/apps/rebalancer/src/index.ts +++ b/apps/rebalancer/src/index.ts @@ -154,7 +154,7 @@ function logCostPolicyDecision( async function evaluateUsdcToBrlaPolicy( amountUsdcRaw: string, coverageDeviationBps: number -): Promise<{ shouldExecute: boolean; routeToRun?: Exclude }> { +): Promise<{ decision: RebalancingCostPolicyDecision; shouldExecute: boolean; routeToRun?: Exclude }> { const config = getConfig(); if (config.rebalancingCostPolicy.mode === "off") { const decision = evaluateRebalancingCostPolicy( @@ -164,7 +164,7 @@ async function evaluateUsdcToBrlaPolicy( config.rebalancingCostPolicy ); logCostPolicyDecision("USDC->BRLA->USDC", amountUsdcRaw, amountUsdcRaw, decision); - return { shouldExecute: false }; + return { decision, shouldExecute: false }; } const comparison = await compareRoutesUpfront(amountUsdcRaw); @@ -182,10 +182,13 @@ async function evaluateUsdcToBrlaPolicy( ); logCostPolicyDecision(`USDC->BRLA->USDC via ${routeToRun}`, amountUsdcRaw, projectedOutputRaw, decision); - return { routeToRun, shouldExecute: decision.shouldExecute }; + return { decision, routeToRun, shouldExecute: decision.shouldExecute }; } -async function evaluateBrlaToUsdcPolicy(amountUsdcRaw: string, coverageDeviationBps: number): Promise { +async function evaluateBrlaToUsdcPolicy( + amountUsdcRaw: string, + coverageDeviationBps: number +): Promise<{ decision: RebalancingCostPolicyDecision; shouldExecute: boolean }> { const config = getConfig(); if (config.rebalancingCostPolicy.mode === "off") { const decision = evaluateRebalancingCostPolicy( @@ -195,7 +198,7 @@ async function evaluateBrlaToUsdcPolicy(amountUsdcRaw: string, coverageDeviation config.rebalancingCostPolicy ); logCostPolicyDecision("BRLA->USDC", amountUsdcRaw, amountUsdcRaw, decision); - return false; + return { decision, shouldExecute: false }; } const quote = await quoteBrlaToUsdcBaseRebalance(amountUsdcRaw); @@ -207,7 +210,7 @@ async function evaluateBrlaToUsdcPolicy(amountUsdcRaw: string, coverageDeviation ); logCostPolicyDecision("BRLA->USDC", amountUsdcRaw, quote.projectedUsdcRaw, decision); - return decision.shouldExecute; + return { decision, shouldExecute: decision.shouldExecute }; } async function runUsdcToBrla(bridgedToday: Big, dailyLimitRaw: Big, coverageDeviationBps: number) { @@ -224,7 +227,11 @@ async function runUsdcToBrla(bridgedToday: Big, dailyLimitRaw: Big, coverageDevi const policyDecision = await evaluateUsdcToBrlaPolicy(amountUsdcRaw, coverageDeviationBps); if (!policyDecision.shouldExecute) return; await checkInitialUsdcBalanceOnBase(amountUsdcRaw); - await rebalanceUsdcBrlaUsdcBase(amountUsdcRaw, forceRestart, policyDecision.routeToRun); + await rebalanceUsdcBrlaUsdcBase(amountUsdcRaw, forceRestart, policyDecision.routeToRun, { + config: config.rebalancingCostPolicy, + decision: policyDecision.decision, + deviationBps: coverageDeviationBps + }); return; } @@ -242,7 +249,8 @@ async function runBrlaToUsdc(bridgedToday: Big, dailyLimitRaw: Big, coverageDevi if (!isResuming) { if (checkDailyLimit(bridgedToday, Big(amountUsdcRaw), dailyLimitRaw, config.rebalancingDailyBridgeLimitUsd)) return; - if (!(await evaluateBrlaToUsdcPolicy(amountUsdcRaw, coverageDeviationBps))) return; + const policyDecision = await evaluateBrlaToUsdcPolicy(amountUsdcRaw, coverageDeviationBps); + if (!policyDecision.shouldExecute) return; const rebalancerUsdcBalance = await checkInitialUsdcBalanceOnBase(amountUsdcRaw); if (config.rebalancingBrlToUsdMinBalance && rebalancerUsdcBalance.lt(config.rebalancingBrlToUsdMinBalance)) { @@ -250,6 +258,12 @@ async function runBrlaToUsdc(bridgedToday: Big, dailyLimitRaw: Big, coverageDevi `Rebalancer USDC balance ${rebalancerUsdcBalance} is below the minimum required balance of ${config.rebalancingBrlToUsdMinBalance} to perform rebalancing.` ); } + await rebalanceBrlaToUsdcBase(amountUsdcRaw, forceRestart, { + config: config.rebalancingCostPolicy, + decision: policyDecision.decision, + deviationBps: coverageDeviationBps + }); + return; } await rebalanceBrlaToUsdcBase(amountUsdcRaw, forceRestart); diff --git a/apps/rebalancer/src/rebalance/brla-to-usdc-base/index.ts b/apps/rebalancer/src/rebalance/brla-to-usdc-base/index.ts index 60c39cfa1..65873cbd9 100644 --- a/apps/rebalancer/src/rebalance/brla-to-usdc-base/index.ts +++ b/apps/rebalancer/src/rebalance/brla-to-usdc-base/index.ts @@ -5,13 +5,14 @@ import { BrlaToUsdcBaseStateManager, brlaToUsdcBasePhaseOrder } from "../../services/stateManager.ts"; -import { getBaseEvmClients } from "../../utils/config.ts"; +import { getBaseEvmClients, getConfig } from "../../utils/config.ts"; import { NonceManager } from "../../utils/nonce.ts"; +import type { RebalancePolicySummary } from "../usdc-brla-usdc-base/notifications.ts"; import { checkInitialUsdcBalanceOnBase } from "../usdc-brla-usdc-base/steps.ts"; import { formatBrlaToUsdcBaseCompletionMessage } from "./notifications.ts"; import { mainNablaSwapUsdcToBrlaOnBase, nablaSwapBrlaToUsdcOnBase, verifyFinalUsdcBalanceOnBase } from "./steps.ts"; -export async function rebalanceBrlaToUsdcBase(usdcAmountRaw: string, forceRestart = false) { +export async function rebalanceBrlaToUsdcBase(usdcAmountRaw: string, forceRestart = false, policy?: RebalancePolicySummary) { console.log(`Starting USDC→BRLA→USDC rebalance on Base with amount: ${usdcAmountRaw} (raw USDC)`); const stateManager = new BrlaToUsdcBaseStateManager(); @@ -99,6 +100,7 @@ export async function rebalanceBrlaToUsdcBase(usdcAmountRaw: string, forceRestar text: formatBrlaToUsdcBaseCompletionMessage({ brlaIntermediate, cost, + policy: policy ?? { config: getConfig().rebalancingCostPolicy }, usdcIn, usdcOut }) diff --git a/apps/rebalancer/src/rebalance/brla-to-usdc-base/notifications.test.ts b/apps/rebalancer/src/rebalance/brla-to-usdc-base/notifications.test.ts new file mode 100644 index 000000000..5e4808ee2 --- /dev/null +++ b/apps/rebalancer/src/rebalance/brla-to-usdc-base/notifications.test.ts @@ -0,0 +1,47 @@ +import {describe, expect, test} from "bun:test"; +import Big from "big.js"; +import {formatBrlaToUsdcBaseCompletionMessage} from "./notifications.ts"; + +const policyConfig = { + hardMaxCostBps: 1_000, + maxCostBpsMild: 25, + maxCostBpsModerate: 75, + maxCostBpsSevere: 250, + mode: "auto" as const, + moderateDeviationBps: 200, + severeDeviationBps: 500 +}; + +describe("BRLA to USDC Base Slack notifications", () => { + test("formats summary and policy bounds in Slack-friendly code tables", () => { + const message = formatBrlaToUsdcBaseCompletionMessage({ + brlaIntermediate: Big("994.5"), + cost: Big("8.5"), + policy: { + config: policyConfig, + decision: { + allowedCostBps: 250, + band: "severe", + costBps: 85, + dryRun: false, + projectedCostRaw: "8500000", + reason: "Projected cost 85 bps is within severe limit 250 bps.", + shouldExecute: true + }, + deviationBps: 520 + }, + usdcIn: Big("1000"), + usdcOut: Big("991.5") + }); + + expect(message).toContain("*Rebalance summary*"); + expect(message).toContain("Route Main Nabla + BRLA Nabla"); + expect(message).toContain("Net USDC cost 8.500000 USDC"); + expect(message).toContain("Cost/input 85.00 bps"); + expect(message).not.toContain("0.85%"); + expect(message).toContain("*Policy bounds*"); + expect(message).toContain("Band severe"); + expect(message).toContain("Deviation 520 bps"); + expect(message).toContain("Allowed for band 250 bps"); + }); +}); diff --git a/apps/rebalancer/src/rebalance/brla-to-usdc-base/notifications.ts b/apps/rebalancer/src/rebalance/brla-to-usdc-base/notifications.ts index 563afcdfd..f3c913cf6 100644 --- a/apps/rebalancer/src/rebalance/brla-to-usdc-base/notifications.ts +++ b/apps/rebalancer/src/rebalance/brla-to-usdc-base/notifications.ts @@ -1,27 +1,35 @@ import Big from "big.js"; +import { + formatCodeTable, + formatCostBps, + formatPolicySummary, + type RebalancePolicySummary +} from "../usdc-brla-usdc-base/notifications.ts"; interface BrlaToUsdcBaseCompletionMessageParams { brlaIntermediate: Big; cost: Big; + policy?: RebalancePolicySummary; usdcIn: Big; usdcOut: Big; } export function formatBrlaToUsdcBaseCompletionMessage(params: BrlaToUsdcBaseCompletionMessageParams): string { - const costPercent = params.usdcIn.gt(0) ? params.cost.div(params.usdcIn).mul(100).toFixed(2) : "N/A"; - return [ - "\u2705 *Base rebalancer completed (Main Nabla \u2192 BRLA Nabla)*", - "USDC \u2192 BRLA (Main Nabla) \u2192 USDC (BRLA Nabla)", + "✅ *Base rebalancer completed (Main Nabla → BRLA Nabla)*", + "USDC → BRLA (Main Nabla) → USDC (BRLA Nabla)", "", - "*Execution*", - "- \uD83D\uDEE3\uFE0F Route: Main Nabla + BRLA Nabla", - `- \uD83D\uDCB5 USDC in: \`${params.usdcIn.toFixed(6)} USDC\``, - `- \uD83E\uDD9A BRLA intermediate: \`${params.brlaIntermediate.toFixed(6)} BRLA\``, - `- \uD83D\uDCB5 USDC out: \`${params.usdcOut.toFixed(6)} USDC\``, + "*Rebalance summary*", + formatCodeTable([ + ["Route", "Main Nabla + BRLA Nabla"], + ["USDC in", `${params.usdcIn.toFixed(6)} USDC`], + ["BRLA intermediate", `${params.brlaIntermediate.toFixed(6)} BRLA`], + ["USDC out", `${params.usdcOut.toFixed(6)} USDC`], + ["Net USDC cost", `${params.cost.toFixed(6)} USDC`], + ["Cost/input", formatCostBps(params.cost, params.usdcIn)] + ]), "", - "*Cost*", - `- \uD83D\uDCC9 Net USDC cost: \`${params.cost.toFixed(6)} USDC\``, - `- \uD83D\uDCCA Cost/input: \`${costPercent === "N/A" ? costPercent : `${costPercent}%`}\`` + "*Policy bounds*", + formatPolicySummary(params.policy) ].join("\n"); } diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts index afbd6a745..47660412a 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts @@ -2,9 +2,9 @@ import { BrlaApiService, multiplyByPowerOfTen, SlackNotifier } from "@vortexfi/s import Big from "big.js"; import { UsdcBaseRebalancePhase, UsdcBaseStateManager, usdcBasePhaseOrder } from "../../services/stateManager.ts"; import { checkTicketStatusPaid } from "../../utils/brla.ts"; -import { getBaseEvmClients, getPolygonEvmClients } from "../../utils/config.ts"; +import { getBaseEvmClients, getConfig, getPolygonEvmClients } from "../../utils/config.ts"; import { NonceManager } from "../../utils/nonce.ts"; -import { formatBaseRebalanceCompletionMessage } from "./notifications.ts"; +import { formatBaseRebalanceCompletionMessage, type RebalancePolicySummary } from "./notifications.ts"; import { aveniaCreateSwapToUsdcBaseTicket, aveniaTransferBrlaToPolygon, @@ -26,7 +26,8 @@ import { export async function rebalanceUsdcBrlaUsdcBase( usdcAmountRaw: string, forceRestart = false, - forcedRoute?: "squidrouter" | "avenia" | "nabla-main" + forcedRoute?: "squidrouter" | "avenia" | "nabla-main", + policy?: RebalancePolicySummary ) { console.log(`Starting USDC->BRLA->USDC rebalance on Base with amount: ${usdcAmountRaw} (raw USDC)`); @@ -329,6 +330,7 @@ export async function rebalanceUsdcBrlaUsdcBase( cost, finalUsdcBalance: finalUsdcDecimal, initialUsdcBalance: initialUsdcDecimal, + policy: policy ?? { config: getConfig().rebalancingCostPolicy }, requestedUsdc: usdcRebalanced, route: state.winningRoute }) diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.test.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.test.ts new file mode 100644 index 000000000..f8378bca2 --- /dev/null +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.test.ts @@ -0,0 +1,52 @@ +import {describe, expect, test} from "bun:test"; +import Big from "big.js"; +import {formatBaseRebalanceCompletionMessage} from "./notifications.ts"; + +const policyConfig = { + hardMaxCostBps: 1_000, + maxCostBpsMild: 25, + maxCostBpsModerate: 75, + maxCostBpsSevere: 250, + mode: "auto" as const, + moderateDeviationBps: 200, + severeDeviationBps: 500 +}; + +describe("Base rebalance Slack notifications", () => { + test("formats summary and policy bounds in Slack-friendly code tables", () => { + const message = formatBaseRebalanceCompletionMessage({ + brlaReceived: Big("994.5"), + cost: Big("12.34"), + finalUsdcBalance: Big("987.66"), + initialUsdcBalance: Big("1000"), + policy: { + config: policyConfig, + decision: { + allowedCostBps: 75, + band: "moderate", + costBps: 42, + dryRun: false, + projectedCostRaw: "4200000", + reason: "Projected cost 42 bps is within moderate limit 75 bps.", + shouldExecute: true + }, + deviationBps: 220 + }, + requestedUsdc: Big("1000"), + route: "squidrouter" + }); + + expect(message).toContain("*Rebalance summary*"); + expect(message).toContain("Route SquidRouter"); + expect(message).toContain("Net USDC cost 12.340000 USDC"); + expect(message).toContain("Cost/requested 123.40 bps"); + expect(message).not.toContain("Cost/requested amount"); + expect(message).not.toContain("1.23%"); + expect(message).toContain("*Policy bounds*"); + expect(message).toContain("```\nMode"); + expect(message).toContain("Band moderate"); + expect(message).toContain("Deviation 220 bps"); + expect(message).toContain("Allowed for band 75 bps"); + expect(message).toContain("Hard max cost 1000 bps"); + }); +}); diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts index 4af870432..001cd6548 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts @@ -1,34 +1,86 @@ import Big from "big.js"; import type { WinningRoute } from "../../services/stateManager.ts"; +import type { RebalancingCostPolicyConfig, RebalancingCostPolicyDecision } from "./guards.ts"; + +export interface RebalancePolicySummary { + config: RebalancingCostPolicyConfig; + decision?: RebalancingCostPolicyDecision; + deviationBps?: number; +} interface BaseRebalanceCompletionMessageParams { brlaReceived: Big; cost: Big; finalUsdcBalance: Big; initialUsdcBalance: Big; + policy?: RebalancePolicySummary; requestedUsdc: Big; route: WinningRoute; } export function formatBaseRebalanceCompletionMessage(params: BaseRebalanceCompletionMessageParams): string { - const costPercent = params.requestedUsdc.gt(0) ? params.cost.div(params.requestedUsdc).mul(100).toFixed(2) : "N/A"; - return [ "✅ *Base rebalancer completed*", "USDC -> BRLA -> USDC on Base", "", - "*Execution*", - `- 🛣️ Route selected: \`${formatRoute(params.route)}\``, - `- 💵 Requested amount: \`${params.requestedUsdc.toFixed(6)} USDC\``, - `- 🪙 BRLA after Nabla swap: \`${params.brlaReceived.toFixed(6)} BRLA\``, + "*Rebalance summary*", + formatCodeTable([ + ["Route", formatRoute(params.route)], + ["Requested", `${params.requestedUsdc.toFixed(6)} USDC`], + ["BRLA received", `${params.brlaReceived.toFixed(6)} BRLA`], + ["Start balance", `${params.initialUsdcBalance.toFixed(6)} USDC`], + ["Final balance", `${params.finalUsdcBalance.toFixed(6)} USDC`], + ["Net USDC cost", `${params.cost.toFixed(6)} USDC`], + ["Cost/requested", formatCostBps(params.cost, params.requestedUsdc)] + ]), "", - "*Balance impact*", - `- 🏦 Base USDC balance: \`${params.initialUsdcBalance.toFixed(6)} -> ${params.finalUsdcBalance.toFixed(6)} USDC\``, - `- 📉 Net USDC cost: \`${params.cost.toFixed(6)} USDC\``, - `- 📊 Cost/requested amount: \`${costPercent === "N/A" ? costPercent : `${costPercent}%`}\`` + "*Policy bounds*", + formatPolicySummary(params.policy) ].join("\n"); } +export function formatPolicySummary(policy: RebalancePolicySummary | undefined): string { + if (!policy) return "```Policy decision unavailable (resumed or manual execution).```"; + + const decisionRows: [string, string][] = policy.decision + ? [ + ["Decision", policy.decision.shouldExecute ? "execute" : "skip"], + ["Band", policy.decision.band], + ["Deviation", policy.deviationBps === undefined ? "N/A" : `${formatBps(policy.deviationBps)} bps`], + ["Projected cost", `${formatBps(policy.decision.costBps)} bps`], + ["Allowed for band", `${formatBps(policy.decision.allowedCostBps)} bps`] + ] + : [["Decision", "unavailable"]]; + + const rows: [string, string][] = [ + ["Mode", policy.config.mode], + ...decisionRows, + ["Moderate starts", `${formatBps(policy.config.moderateDeviationBps)} bps`], + ["Severe starts", `${formatBps(policy.config.severeDeviationBps)} bps`], + ["Mild max cost", `${formatBps(policy.config.maxCostBpsMild)} bps`], + ["Moderate max cost", `${formatBps(policy.config.maxCostBpsModerate)} bps`], + ["Severe max cost", `${formatBps(policy.config.maxCostBpsSevere)} bps`], + ["Hard max cost", `${formatBps(policy.config.hardMaxCostBps)} bps`] + ]; + + return formatCodeTable(rows); +} + +export function formatCodeTable(rows: [string, string][]): string { + const labelWidth = Math.max(...rows.map(([label]) => label.length)); + const formattedRows = rows.map(([label, value]) => `${label.padEnd(labelWidth)} ${value}`); + return ["```", ...formattedRows, "```"].join("\n"); +} + +export function formatCostBps(cost: Big, denominator: Big): string { + if (denominator.lte(0)) return "N/A"; + return `${cost.div(denominator).mul(10_000).toFixed(2)} bps`; +} + +function formatBps(value: number): string { + return Number.isInteger(value) ? String(value) : value.toFixed(2); +} + function formatRoute(route: WinningRoute): string { if (route === "avenia") return "Avenia"; if (route === "squidrouter") return "SquidRouter"; From 393c05a09c163a4833de23d3a81ca62bb96e3420 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 16 Jun 2026 12:33:51 +0200 Subject: [PATCH 051/131] Adjust notification again --- .../brla-to-usdc-base/notifications.test.ts | 13 ++-- .../brla-to-usdc-base/notifications.ts | 27 ++++--- .../usdc-brla-usdc-base/notifications.test.ts | 17 ++--- .../usdc-brla-usdc-base/notifications.ts | 75 ++++++++++--------- 4 files changed, 66 insertions(+), 66 deletions(-) diff --git a/apps/rebalancer/src/rebalance/brla-to-usdc-base/notifications.test.ts b/apps/rebalancer/src/rebalance/brla-to-usdc-base/notifications.test.ts index 5e4808ee2..3c9e19580 100644 --- a/apps/rebalancer/src/rebalance/brla-to-usdc-base/notifications.test.ts +++ b/apps/rebalancer/src/rebalance/brla-to-usdc-base/notifications.test.ts @@ -34,14 +34,11 @@ describe("BRLA to USDC Base Slack notifications", () => { usdcOut: Big("991.5") }); - expect(message).toContain("*Rebalance summary*"); - expect(message).toContain("Route Main Nabla + BRLA Nabla"); - expect(message).toContain("Net USDC cost 8.500000 USDC"); - expect(message).toContain("Cost/input 85.00 bps"); + expect(message).toContain("*Summary*"); + expect(message).toContain("Route USDC in BRLA mid USDC out Cost Cost bps"); + expect(message).toContain("Main+BRLA Nabla 1000.000000 994.500000 991.500000 8.500000 85.00"); expect(message).not.toContain("0.85%"); - expect(message).toContain("*Policy bounds*"); - expect(message).toContain("Band severe"); - expect(message).toContain("Deviation 520 bps"); - expect(message).toContain("Allowed for band 250 bps"); + expect(message).toContain("*Policy*"); + expect(message).toContain("auto execute severe 520 85 250 1000"); }); }); diff --git a/apps/rebalancer/src/rebalance/brla-to-usdc-base/notifications.ts b/apps/rebalancer/src/rebalance/brla-to-usdc-base/notifications.ts index f3c913cf6..3ce9e2958 100644 --- a/apps/rebalancer/src/rebalance/brla-to-usdc-base/notifications.ts +++ b/apps/rebalancer/src/rebalance/brla-to-usdc-base/notifications.ts @@ -1,6 +1,6 @@ import Big from "big.js"; import { - formatCodeTable, + formatCompactTable, formatCostBps, formatPolicySummary, type RebalancePolicySummary @@ -19,17 +19,20 @@ export function formatBrlaToUsdcBaseCompletionMessage(params: BrlaToUsdcBaseComp "✅ *Base rebalancer completed (Main Nabla → BRLA Nabla)*", "USDC → BRLA (Main Nabla) → USDC (BRLA Nabla)", "", - "*Rebalance summary*", - formatCodeTable([ - ["Route", "Main Nabla + BRLA Nabla"], - ["USDC in", `${params.usdcIn.toFixed(6)} USDC`], - ["BRLA intermediate", `${params.brlaIntermediate.toFixed(6)} BRLA`], - ["USDC out", `${params.usdcOut.toFixed(6)} USDC`], - ["Net USDC cost", `${params.cost.toFixed(6)} USDC`], - ["Cost/input", formatCostBps(params.cost, params.usdcIn)] - ]), - "", - "*Policy bounds*", + "*Summary*", + formatCompactTable( + ["Route", "USDC in", "BRLA mid", "USDC out", "Cost", "Cost bps"], + [ + [ + "Main+BRLA Nabla", + params.usdcIn.toFixed(6), + params.brlaIntermediate.toFixed(6), + params.usdcOut.toFixed(6), + params.cost.toFixed(6), + formatCostBps(params.cost, params.usdcIn) + ] + ] + ), formatPolicySummary(params.policy) ].join("\n"); } diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.test.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.test.ts index f8378bca2..e91a3f830 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.test.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.test.ts @@ -36,17 +36,14 @@ describe("Base rebalance Slack notifications", () => { route: "squidrouter" }); - expect(message).toContain("*Rebalance summary*"); - expect(message).toContain("Route SquidRouter"); - expect(message).toContain("Net USDC cost 12.340000 USDC"); - expect(message).toContain("Cost/requested 123.40 bps"); + expect(message).toContain("*Summary*"); + expect(message).toContain("Route Req USDC BRLA out Start Final Cost Cost bps"); + expect(message).toContain("SquidRouter 1000.000000 994.500000 1000.000000 987.660000 12.340000 123.40"); expect(message).not.toContain("Cost/requested amount"); expect(message).not.toContain("1.23%"); - expect(message).toContain("*Policy bounds*"); - expect(message).toContain("```\nMode"); - expect(message).toContain("Band moderate"); - expect(message).toContain("Deviation 220 bps"); - expect(message).toContain("Allowed for band 75 bps"); - expect(message).toContain("Hard max cost 1000 bps"); + expect(message).toContain("*Policy*"); + expect(message).toContain("Mode Decision Band Dev bps Cost bps Cap bps Hard bps"); + expect(message).toContain("auto execute moderate 220 42 75 1000"); + expect(message).toContain("Bands bps: mod>=200 severe>=500 | caps bps: mild<=25 mod<=75 severe<=250"); }); }); diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts index 001cd6548..79e9c9b63 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts @@ -23,18 +23,21 @@ export function formatBaseRebalanceCompletionMessage(params: BaseRebalanceComple "✅ *Base rebalancer completed*", "USDC -> BRLA -> USDC on Base", "", - "*Rebalance summary*", - formatCodeTable([ - ["Route", formatRoute(params.route)], - ["Requested", `${params.requestedUsdc.toFixed(6)} USDC`], - ["BRLA received", `${params.brlaReceived.toFixed(6)} BRLA`], - ["Start balance", `${params.initialUsdcBalance.toFixed(6)} USDC`], - ["Final balance", `${params.finalUsdcBalance.toFixed(6)} USDC`], - ["Net USDC cost", `${params.cost.toFixed(6)} USDC`], - ["Cost/requested", formatCostBps(params.cost, params.requestedUsdc)] - ]), - "", - "*Policy bounds*", + "*Summary*", + formatCompactTable( + ["Route", "Req USDC", "BRLA out", "Start", "Final", "Cost", "Cost bps"], + [ + [ + formatRoute(params.route), + params.requestedUsdc.toFixed(6), + params.brlaReceived.toFixed(6), + params.initialUsdcBalance.toFixed(6), + params.finalUsdcBalance.toFixed(6), + params.cost.toFixed(6), + formatCostBps(params.cost, params.requestedUsdc) + ] + ] + ), formatPolicySummary(params.policy) ].join("\n"); } @@ -42,39 +45,39 @@ export function formatBaseRebalanceCompletionMessage(params: BaseRebalanceComple export function formatPolicySummary(policy: RebalancePolicySummary | undefined): string { if (!policy) return "```Policy decision unavailable (resumed or manual execution).```"; - const decisionRows: [string, string][] = policy.decision + const decision = policy.decision; + const decisionRow = decision ? [ - ["Decision", policy.decision.shouldExecute ? "execute" : "skip"], - ["Band", policy.decision.band], - ["Deviation", policy.deviationBps === undefined ? "N/A" : `${formatBps(policy.deviationBps)} bps`], - ["Projected cost", `${formatBps(policy.decision.costBps)} bps`], - ["Allowed for band", `${formatBps(policy.decision.allowedCostBps)} bps`] + policy.config.mode, + decision.shouldExecute ? "execute" : "skip", + decision.band, + policy.deviationBps === undefined ? "N/A" : formatBps(policy.deviationBps), + formatBps(decision.costBps), + formatBps(decision.allowedCostBps), + formatBps(policy.config.hardMaxCostBps) ] - : [["Decision", "unavailable"]]; - - const rows: [string, string][] = [ - ["Mode", policy.config.mode], - ...decisionRows, - ["Moderate starts", `${formatBps(policy.config.moderateDeviationBps)} bps`], - ["Severe starts", `${formatBps(policy.config.severeDeviationBps)} bps`], - ["Mild max cost", `${formatBps(policy.config.maxCostBpsMild)} bps`], - ["Moderate max cost", `${formatBps(policy.config.maxCostBpsModerate)} bps`], - ["Severe max cost", `${formatBps(policy.config.maxCostBpsSevere)} bps`], - ["Hard max cost", `${formatBps(policy.config.hardMaxCostBps)} bps`] - ]; + : [policy.config.mode, "unavailable", "N/A", "N/A", "N/A", "N/A", formatBps(policy.config.hardMaxCostBps)]; - return formatCodeTable(rows); + return [ + "*Policy*", + formatCompactTable(["Mode", "Decision", "Band", "Dev bps", "Cost bps", "Cap bps", "Hard bps"], [decisionRow]), + `Bands bps: mod>=${formatBps(policy.config.moderateDeviationBps)} severe>=${formatBps(policy.config.severeDeviationBps)} | caps bps: mild<=${formatBps(policy.config.maxCostBpsMild)} mod<=${formatBps(policy.config.maxCostBpsModerate)} severe<=${formatBps(policy.config.maxCostBpsSevere)}` + ].join("\n"); } -export function formatCodeTable(rows: [string, string][]): string { - const labelWidth = Math.max(...rows.map(([label]) => label.length)); - const formattedRows = rows.map(([label, value]) => `${label.padEnd(labelWidth)} ${value}`); - return ["```", ...formattedRows, "```"].join("\n"); +export function formatCompactTable(headers: string[], rows: string[][]): string { + const widths = headers.map((header, index) => Math.max(header.length, ...rows.map(row => row[index]?.length ?? 0))); + const formatRow = (row: string[]) => + row + .map((cell, index) => cell.padEnd(widths[index] ?? cell.length)) + .join(" ") + .trimEnd(); + return ["```", formatRow(headers), ...rows.map(formatRow), "```"].join("\n"); } export function formatCostBps(cost: Big, denominator: Big): string { if (denominator.lte(0)) return "N/A"; - return `${cost.div(denominator).mul(10_000).toFixed(2)} bps`; + return cost.div(denominator).mul(10_000).toFixed(2); } function formatBps(value: number): string { From 3fd843c3e012695e0e0e63d9646442a0bf69f03f Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Tue, 16 Jun 2026 14:37:07 +0200 Subject: [PATCH 052/131] unify @scure/bip39 package version in the project --- apps/api/package.json | 2 +- bun.lock | 12 +++++------- package.json | 2 +- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index aece93e61..efe344e22 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -10,7 +10,7 @@ "@polkadot/keyring": "catalog:", "@polkadot/util": "catalog:", "@polkadot/util-crypto": "catalog:", - "@scure/bip39": "^1.5.4", + "@scure/bip39": "catalog:", "@supabase/supabase-js": "catalog:", "@types/multer": "^2.1.0", "@vortexfi/shared": "workspace:*", diff --git a/bun.lock b/bun.lock index 89826af61..6eaa48629 100644 --- a/bun.lock +++ b/bun.lock @@ -28,7 +28,7 @@ "@polkadot/keyring": "catalog:", "@polkadot/util": "catalog:", "@polkadot/util-crypto": "catalog:", - "@scure/bip39": "^1.5.4", + "@scure/bip39": "catalog:", "@supabase/supabase-js": "catalog:", "@types/multer": "^2.1.0", "@vortexfi/shared": "workspace:*", @@ -366,7 +366,7 @@ "@polkadot/types-known": "^16.4.6", "@polkadot/util": "^13.5.6", "@polkadot/util-crypto": "^13.5.6", - "@scure/bip39": "2.0.1", + "@scure/bip39": "^2.2.0", "@storybook/react": "^9.1.16", "@supabase/supabase-js": "^2.80.0", "@types/big.js": "^6.0.2", @@ -1498,7 +1498,7 @@ "@scure/bip32": ["@scure/bip32@1.7.0", "", { "dependencies": { "@noble/curves": "~1.9.0", "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw=="], - "@scure/bip39": ["@scure/bip39@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1", "@scure/base": "2.0.0" } }, "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg=="], + "@scure/bip39": ["@scure/bip39@2.2.0", "", { "dependencies": { "@noble/hashes": "2.2.0", "@scure/base": "2.2.0" } }, "sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A=="], "@sentry-internal/browser-utils": ["@sentry-internal/browser-utils@8.55.2", "", { "dependencies": { "@sentry/core": "8.55.2" } }, "sha512-GnKod+gL/Y+1FUM/RGV8q6le1CoyiGbT40MitEK7eVwWe+bfTRq1gN7ioupyHFMUg1RlQkDQ4/sENmio/uow5A=="], @@ -4764,9 +4764,9 @@ "@scure/bip32/@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], - "@scure/bip39/@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], + "@scure/bip39/@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], - "@scure/bip39/@scure/base": ["@scure/base@2.0.0", "", {}, "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w=="], + "@scure/bip39/@scure/base": ["@scure/base@2.2.0", "", {}, "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg=="], "@sentry/bundler-plugin-core/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], @@ -5302,8 +5302,6 @@ "vitest/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], - "vortex-backend/@scure/bip39": ["@scure/bip39@1.6.0", "", { "dependencies": { "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A=="], - "wagmi/use-sync-external-store": ["use-sync-external-store@1.4.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw=="], "wcwidth/defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="], diff --git a/package.json b/package.json index 721ef502d..31e58c5a7 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "@polkadot/types-known": "^16.4.6", "@polkadot/util": "^13.5.6", "@polkadot/util-crypto": "^13.5.6", - "@scure/bip39": "2.0.1", + "@scure/bip39": "^2.2.0", "@storybook/react": "^9.1.16", "@supabase/supabase-js": "^2.80.0", "@types/big.js": "^6.0.2", From 79a48e67b41b78a5fa2e1fb946485eb945aaf021 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 15 Jun 2026 16:59:26 -0300 Subject: [PATCH 053/131] fix bug in nabla minimum output calculation --- apps/api/src/api/services/quote/core/types.ts | 2 ++ .../quote/engines/merge-subsidy/offramp-evm.ts | 2 ++ .../transactions/onramp/common/transactions.ts | 12 ++++++++++-- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/api/src/api/services/quote/core/types.ts b/apps/api/src/api/services/quote/core/types.ts index fd41f71bb..f4138e233 100644 --- a/apps/api/src/api/services/quote/core/types.ts +++ b/apps/api/src/api/services/quote/core/types.ts @@ -139,6 +139,8 @@ export interface QuoteContext { inputDecimals: number; outputAmountRaw: string; outputAmountDecimal: Big; + ammOutputAmountRaw?: string; + ammOutputAmountDecimal?: Big; outputCurrency: EvmToken; outputDecimals: number; outputToken: string; // ERC20 address diff --git a/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts b/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts index 10c0317cd..5509a53ce 100644 --- a/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts +++ b/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts @@ -32,6 +32,8 @@ export class OffRampMergeSubsidyEvmEngine implements Stage { ctx.nablaSwapEvm = { ...ctx.nablaSwapEvm, + ammOutputAmountDecimal: ctx.nablaSwapEvm.ammOutputAmountDecimal ?? ctx.nablaSwapEvm.outputAmountDecimal, + ammOutputAmountRaw: ctx.nablaSwapEvm.ammOutputAmountRaw ?? ctx.nablaSwapEvm.outputAmountRaw, outputAmountDecimal: ctx.nablaSwapEvm.outputAmountDecimal.plus(ctx.subsidy.subsidyAmountInOutputTokenDecimal), outputAmountRaw: (BigInt(ctx.nablaSwapEvm.outputAmountRaw) + BigInt(ctx.subsidy.subsidyAmountInOutputTokenRaw)).toString() }; diff --git a/apps/api/src/api/services/transactions/onramp/common/transactions.ts b/apps/api/src/api/services/transactions/onramp/common/transactions.ts index b3cfec69e..a1136c75c 100644 --- a/apps/api/src/api/services/transactions/onramp/common/transactions.ts +++ b/apps/api/src/api/services/transactions/onramp/common/transactions.ts @@ -261,10 +261,18 @@ export async function addNablaSwapTransactionsOnBase( // The input amount for the swap was already calculated in the quote. const inputAmountForNablaSwapRaw = quote.metadata.nablaSwapEvm.inputAmountForSwapRaw; + // For offramps, outputAmountRaw may include a partner subsidy (merged in + // OffRampMergeSubsidyEvmEngine). Use the AMM-only amount when available so + // the on-chain minimum reflects what the AMM can actually deliver. + const minOutputBaseRaw = quote.metadata.nablaSwapEvm.ammOutputAmountRaw ?? quote.metadata.nablaSwapEvm.outputAmountRaw; const outputAmountRaw = Big(quote.metadata.nablaSwapEvm.outputAmountRaw); - const nablaSoftMinimumOutputRaw = outputAmountRaw.mul(1 - AMM_MINIMUM_OUTPUT_SOFT_MARGIN).toFixed(0, 0); - const nablaHardMinimumOutputRaw = outputAmountRaw.mul(1 - AMM_MINIMUM_OUTPUT_HARD_MARGIN).toFixed(0, 0); + const nablaSoftMinimumOutputRaw = Big(minOutputBaseRaw) + .mul(1 - AMM_MINIMUM_OUTPUT_SOFT_MARGIN) + .toFixed(0, 0); + const nablaHardMinimumOutputRaw = Big(minOutputBaseRaw) + .mul(1 - AMM_MINIMUM_OUTPUT_HARD_MARGIN) + .toFixed(0, 0); const { approve, swap } = await createNablaTransactionsForOnrampOnEVM( inputAmountForNablaSwapRaw, From 90c9bb8f3aafa9f98986f28155532e61b5b808cd Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Tue, 16 Jun 2026 12:03:20 -0300 Subject: [PATCH 054/131] Revert "fix bug in nabla minimum output calculation" This reverts commit 79a48e67b41b78a5fa2e1fb946485eb945aaf021. --- apps/api/src/api/services/quote/core/types.ts | 2 -- .../quote/engines/merge-subsidy/offramp-evm.ts | 2 -- .../transactions/onramp/common/transactions.ts | 12 ++---------- 3 files changed, 2 insertions(+), 14 deletions(-) diff --git a/apps/api/src/api/services/quote/core/types.ts b/apps/api/src/api/services/quote/core/types.ts index f4138e233..fd41f71bb 100644 --- a/apps/api/src/api/services/quote/core/types.ts +++ b/apps/api/src/api/services/quote/core/types.ts @@ -139,8 +139,6 @@ export interface QuoteContext { inputDecimals: number; outputAmountRaw: string; outputAmountDecimal: Big; - ammOutputAmountRaw?: string; - ammOutputAmountDecimal?: Big; outputCurrency: EvmToken; outputDecimals: number; outputToken: string; // ERC20 address diff --git a/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts b/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts index 5509a53ce..10c0317cd 100644 --- a/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts +++ b/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts @@ -32,8 +32,6 @@ export class OffRampMergeSubsidyEvmEngine implements Stage { ctx.nablaSwapEvm = { ...ctx.nablaSwapEvm, - ammOutputAmountDecimal: ctx.nablaSwapEvm.ammOutputAmountDecimal ?? ctx.nablaSwapEvm.outputAmountDecimal, - ammOutputAmountRaw: ctx.nablaSwapEvm.ammOutputAmountRaw ?? ctx.nablaSwapEvm.outputAmountRaw, outputAmountDecimal: ctx.nablaSwapEvm.outputAmountDecimal.plus(ctx.subsidy.subsidyAmountInOutputTokenDecimal), outputAmountRaw: (BigInt(ctx.nablaSwapEvm.outputAmountRaw) + BigInt(ctx.subsidy.subsidyAmountInOutputTokenRaw)).toString() }; diff --git a/apps/api/src/api/services/transactions/onramp/common/transactions.ts b/apps/api/src/api/services/transactions/onramp/common/transactions.ts index a1136c75c..b3cfec69e 100644 --- a/apps/api/src/api/services/transactions/onramp/common/transactions.ts +++ b/apps/api/src/api/services/transactions/onramp/common/transactions.ts @@ -261,18 +261,10 @@ export async function addNablaSwapTransactionsOnBase( // The input amount for the swap was already calculated in the quote. const inputAmountForNablaSwapRaw = quote.metadata.nablaSwapEvm.inputAmountForSwapRaw; - // For offramps, outputAmountRaw may include a partner subsidy (merged in - // OffRampMergeSubsidyEvmEngine). Use the AMM-only amount when available so - // the on-chain minimum reflects what the AMM can actually deliver. - const minOutputBaseRaw = quote.metadata.nablaSwapEvm.ammOutputAmountRaw ?? quote.metadata.nablaSwapEvm.outputAmountRaw; const outputAmountRaw = Big(quote.metadata.nablaSwapEvm.outputAmountRaw); - const nablaSoftMinimumOutputRaw = Big(minOutputBaseRaw) - .mul(1 - AMM_MINIMUM_OUTPUT_SOFT_MARGIN) - .toFixed(0, 0); - const nablaHardMinimumOutputRaw = Big(minOutputBaseRaw) - .mul(1 - AMM_MINIMUM_OUTPUT_HARD_MARGIN) - .toFixed(0, 0); + const nablaSoftMinimumOutputRaw = outputAmountRaw.mul(1 - AMM_MINIMUM_OUTPUT_SOFT_MARGIN).toFixed(0, 0); + const nablaHardMinimumOutputRaw = outputAmountRaw.mul(1 - AMM_MINIMUM_OUTPUT_HARD_MARGIN).toFixed(0, 0); const { approve, swap } = await createNablaTransactionsForOnrampOnEVM( inputAmountForNablaSwapRaw, From ccd7bc77bc463e191c724b25a00ab6b1b1a17f02 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 15 Jun 2026 16:59:26 -0300 Subject: [PATCH 055/131] fix bug in nabla minimum output calculation --- apps/api/src/api/services/quote/core/types.ts | 2 ++ .../quote/engines/merge-subsidy/offramp-evm.ts | 2 ++ .../transactions/onramp/common/transactions.ts | 12 ++++++++++-- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/api/src/api/services/quote/core/types.ts b/apps/api/src/api/services/quote/core/types.ts index fd41f71bb..f4138e233 100644 --- a/apps/api/src/api/services/quote/core/types.ts +++ b/apps/api/src/api/services/quote/core/types.ts @@ -139,6 +139,8 @@ export interface QuoteContext { inputDecimals: number; outputAmountRaw: string; outputAmountDecimal: Big; + ammOutputAmountRaw?: string; + ammOutputAmountDecimal?: Big; outputCurrency: EvmToken; outputDecimals: number; outputToken: string; // ERC20 address diff --git a/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts b/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts index 10c0317cd..5509a53ce 100644 --- a/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts +++ b/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts @@ -32,6 +32,8 @@ export class OffRampMergeSubsidyEvmEngine implements Stage { ctx.nablaSwapEvm = { ...ctx.nablaSwapEvm, + ammOutputAmountDecimal: ctx.nablaSwapEvm.ammOutputAmountDecimal ?? ctx.nablaSwapEvm.outputAmountDecimal, + ammOutputAmountRaw: ctx.nablaSwapEvm.ammOutputAmountRaw ?? ctx.nablaSwapEvm.outputAmountRaw, outputAmountDecimal: ctx.nablaSwapEvm.outputAmountDecimal.plus(ctx.subsidy.subsidyAmountInOutputTokenDecimal), outputAmountRaw: (BigInt(ctx.nablaSwapEvm.outputAmountRaw) + BigInt(ctx.subsidy.subsidyAmountInOutputTokenRaw)).toString() }; diff --git a/apps/api/src/api/services/transactions/onramp/common/transactions.ts b/apps/api/src/api/services/transactions/onramp/common/transactions.ts index b3cfec69e..a1136c75c 100644 --- a/apps/api/src/api/services/transactions/onramp/common/transactions.ts +++ b/apps/api/src/api/services/transactions/onramp/common/transactions.ts @@ -261,10 +261,18 @@ export async function addNablaSwapTransactionsOnBase( // The input amount for the swap was already calculated in the quote. const inputAmountForNablaSwapRaw = quote.metadata.nablaSwapEvm.inputAmountForSwapRaw; + // For offramps, outputAmountRaw may include a partner subsidy (merged in + // OffRampMergeSubsidyEvmEngine). Use the AMM-only amount when available so + // the on-chain minimum reflects what the AMM can actually deliver. + const minOutputBaseRaw = quote.metadata.nablaSwapEvm.ammOutputAmountRaw ?? quote.metadata.nablaSwapEvm.outputAmountRaw; const outputAmountRaw = Big(quote.metadata.nablaSwapEvm.outputAmountRaw); - const nablaSoftMinimumOutputRaw = outputAmountRaw.mul(1 - AMM_MINIMUM_OUTPUT_SOFT_MARGIN).toFixed(0, 0); - const nablaHardMinimumOutputRaw = outputAmountRaw.mul(1 - AMM_MINIMUM_OUTPUT_HARD_MARGIN).toFixed(0, 0); + const nablaSoftMinimumOutputRaw = Big(minOutputBaseRaw) + .mul(1 - AMM_MINIMUM_OUTPUT_SOFT_MARGIN) + .toFixed(0, 0); + const nablaHardMinimumOutputRaw = Big(minOutputBaseRaw) + .mul(1 - AMM_MINIMUM_OUTPUT_HARD_MARGIN) + .toFixed(0, 0); const { approve, swap } = await createNablaTransactionsForOnrampOnEVM( inputAmountForNablaSwapRaw, From ff9dbc608208a6492886c89f92a408e2762b5335 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 16 Jun 2026 18:45:26 +0200 Subject: [PATCH 056/131] Address PR review comments --- apps/frontend/src/hooks/brla/useKYBForm/index.tsx | 2 +- docs/api/pages/13-kyb-deep-link.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/frontend/src/hooks/brla/useKYBForm/index.tsx b/apps/frontend/src/hooks/brla/useKYBForm/index.tsx index 8d2aba3de..e25bdba78 100644 --- a/apps/frontend/src/hooks/brla/useKYBForm/index.tsx +++ b/apps/frontend/src/hooks/brla/useKYBForm/index.tsx @@ -32,7 +32,7 @@ export const useKYBForm = ({ initialData, requireCnpj = false }: UseKYBFormProps const kybForm = useForm({ defaultValues: { - [ExtendedAveniaFieldOptions.TAX_ID]: initialData?.taxId || taxIdFromStore || "", + [ExtendedAveniaFieldOptions.TAX_ID]: initialData?.taxId ?? taxIdFromStore ?? "", [ExtendedAveniaFieldOptions.FULL_NAME]: initialData?.fullName || "" }, mode: "onBlur", diff --git a/docs/api/pages/13-kyb-deep-link.md b/docs/api/pages/13-kyb-deep-link.md index 9bd3beb7c..73abfa4df 100644 --- a/docs/api/pages/13-kyb-deep-link.md +++ b/docs/api/pages/13-kyb-deep-link.md @@ -26,9 +26,9 @@ Append one of these to the widget URL (e.g. `https://widget.vortexfinance.co/en/ | `?kyb=BR` \| `MX` \| `CO` \| `US` | Selector shown with the region preselected. The user can still change it. | | `?kybLocked=BR` \| `MX` \| `CO` \| `US` | Selector skipped, region pinned, back navigation into the selector disabled. | | `?kybLocked=BR` (specifically) | Additionally defaults the widget locale to `pt-BR`. An explicit locale in the path still wins (e.g. `/en/widget?kybLocked=BR` stays English). | -| unknown region code (e.g. `?kybLocked=ZZ`) | Degrades gracefully to the open selector; the region is **not** treated as locked. | +| unknown or empty region code (e.g. `?kybLocked=ZZ`, `?kybLocked`) | Degrades gracefully to the open selector; the region is **not** treated as locked. | -Region codes are case-insensitive. +Query keys are case-sensitive: use `kyb` and `kybLocked` exactly. Region codes are case-insensitive. ## Attribution From b9077ea9f7e4467ea1018ef39d2bc825a5e06184 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 16 Jun 2026 18:53:25 +0200 Subject: [PATCH 057/131] Add missing translation strings --- apps/frontend/src/translations/en.json | 1 + apps/frontend/src/translations/pt.json | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index c80e1b735..6f81f755b 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -245,6 +245,7 @@ "required": "Street is required" }, "taxId": { + "cnpjFormat": "Invalid CNPJ format", "format": "Invalid Tax ID format", "required": "Tax ID is required" } diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index 56a1464fb..26f233b76 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -248,6 +248,7 @@ "required": "Rua é obrigatória" }, "taxId": { + "cnpjFormat": "CNPJ inválido", "format": "Tax Id inválido", "required": "Tax Id é obrigatório" } From 728ec848c52c0c1e5a45203c0942ea4440d43329 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 16 Jun 2026 19:27:45 +0200 Subject: [PATCH 058/131] Allow rebalancer to bypass daily volume limit for profitable quotes --- apps/rebalancer/src/index.ts | 70 ++++++++++++++----- .../usdc-brla-usdc-base/guards.test.ts | 23 ++++++ .../rebalance/usdc-brla-usdc-base/guards.ts | 29 ++++++++ .../security-spec/07-operations/rebalancer.md | 24 +++---- docs/security-spec/README.md | 2 +- 5 files changed, 117 insertions(+), 31 deletions(-) diff --git a/apps/rebalancer/src/index.ts b/apps/rebalancer/src/index.ts index 961074b3a..fc6967532 100644 --- a/apps/rebalancer/src/index.ts +++ b/apps/rebalancer/src/index.ts @@ -7,9 +7,11 @@ import { rebalanceBrlaToUsdcBase } from "./rebalance/brla-to-usdc-base"; import { quoteBrlaToUsdcBaseRebalance } from "./rebalance/brla-to-usdc-base/steps.ts"; import { rebalanceUsdcBrlaUsdcBase } from "./rebalance/usdc-brla-usdc-base"; import { + type DailyBridgeLimitDecision, + evaluateDailyBridgeLimit, evaluateRebalancingCostPolicy, - type RebalancingCostPolicyDecision, - wouldExceedDailyBridgeLimit + isProjectedProfit, + type RebalancingCostPolicyDecision } from "./rebalance/usdc-brla-usdc-base/guards.ts"; import { checkInitialUsdcBalanceOnBase, compareRoutesUpfront } from "./rebalance/usdc-brla-usdc-base/steps.ts"; import { getBaseNablaCoverageRatio, getSwapPoolsWithCoverageRatio } from "./services/indexer"; @@ -96,15 +98,16 @@ async function getTodayBridgedUsdRaw(): Promise { .reduce((sum, e) => sum.plus(Big(e.initialAmount)), Big(0)); } -function checkDailyLimit(bridgedToday: Big, requestedAmountRaw: Big, dailyLimitRaw: Big, dailyLimitUsd: number): boolean { - if (wouldExceedDailyBridgeLimit(bridgedToday, requestedAmountRaw, dailyLimitRaw)) { - const projectedTotal = bridgedToday.plus(requestedAmountRaw); - console.log( - `Daily bridge limit reached: projected $${projectedTotal.div(1e6).toFixed(2)}, limit $${dailyLimitUsd}. Skipping.` - ); - return true; +function logDailyLimitDecision(decision: DailyBridgeLimitDecision, dailyLimitUsd: number) { + if (decision.reason === "under_limit") return; + + const projectedTotalUsd = Big(decision.projectedTotalRaw).div(1e6).toFixed(2); + if (decision.reason === "profitable_quote") { + console.log(`Daily bridge limit bypassed (profitable quote): projected $${projectedTotalUsd}, limit $${dailyLimitUsd}.`); + return; } - return false; + + console.log(`Daily bridge limit reached: projected $${projectedTotalUsd}, limit $${dailyLimitUsd}. Skipping.`); } function calculateCoverageDeviationBps(coverageRatio: number, triggerBound: number): number { @@ -154,7 +157,12 @@ function logCostPolicyDecision( async function evaluateUsdcToBrlaPolicy( amountUsdcRaw: string, coverageDeviationBps: number -): Promise<{ decision: RebalancingCostPolicyDecision; shouldExecute: boolean; routeToRun?: Exclude }> { +): Promise<{ + decision: RebalancingCostPolicyDecision; + profitable: boolean; + shouldExecute: boolean; + routeToRun?: Exclude; +}> { const config = getConfig(); if (config.rebalancingCostPolicy.mode === "off") { const decision = evaluateRebalancingCostPolicy( @@ -164,7 +172,7 @@ async function evaluateUsdcToBrlaPolicy( config.rebalancingCostPolicy ); logCostPolicyDecision("USDC->BRLA->USDC", amountUsdcRaw, amountUsdcRaw, decision); - return { decision, shouldExecute: false }; + return { decision, profitable: false, shouldExecute: false }; } const comparison = await compareRoutesUpfront(amountUsdcRaw); @@ -182,13 +190,18 @@ async function evaluateUsdcToBrlaPolicy( ); logCostPolicyDecision(`USDC->BRLA->USDC via ${routeToRun}`, amountUsdcRaw, projectedOutputRaw, decision); - return { decision, routeToRun, shouldExecute: decision.shouldExecute }; + return { + decision, + profitable: isProjectedProfit(Big(amountUsdcRaw), Big(projectedOutputRaw)), + routeToRun, + shouldExecute: decision.shouldExecute + }; } async function evaluateBrlaToUsdcPolicy( amountUsdcRaw: string, coverageDeviationBps: number -): Promise<{ decision: RebalancingCostPolicyDecision; shouldExecute: boolean }> { +): Promise<{ decision: RebalancingCostPolicyDecision; profitable: boolean; shouldExecute: boolean }> { const config = getConfig(); if (config.rebalancingCostPolicy.mode === "off") { const decision = evaluateRebalancingCostPolicy( @@ -198,7 +211,7 @@ async function evaluateBrlaToUsdcPolicy( config.rebalancingCostPolicy ); logCostPolicyDecision("BRLA->USDC", amountUsdcRaw, amountUsdcRaw, decision); - return { decision, shouldExecute: false }; + return { decision, profitable: false, shouldExecute: false }; } const quote = await quoteBrlaToUsdcBaseRebalance(amountUsdcRaw); @@ -210,7 +223,11 @@ async function evaluateBrlaToUsdcPolicy( ); logCostPolicyDecision("BRLA->USDC", amountUsdcRaw, quote.projectedUsdcRaw, decision); - return { decision, shouldExecute: decision.shouldExecute }; + return { + decision, + profitable: isProjectedProfit(Big(amountUsdcRaw), Big(quote.projectedUsdcRaw)), + shouldExecute: decision.shouldExecute + }; } async function runUsdcToBrla(bridgedToday: Big, dailyLimitRaw: Big, coverageDeviationBps: number) { @@ -223,9 +240,18 @@ async function runUsdcToBrla(bridgedToday: Big, dailyLimitRaw: Big, coverageDevi const isResuming = !forceRestart && state && state.currentPhase !== UsdcBaseRebalancePhase.Idle; if (!isResuming) { - if (checkDailyLimit(bridgedToday, Big(amountUsdcRaw), dailyLimitRaw, config.rebalancingDailyBridgeLimitUsd)) return; const policyDecision = await evaluateUsdcToBrlaPolicy(amountUsdcRaw, coverageDeviationBps); if (!policyDecision.shouldExecute) return; + + const dailyLimitDecision = evaluateDailyBridgeLimit( + bridgedToday, + Big(amountUsdcRaw), + dailyLimitRaw, + policyDecision.profitable + ); + logDailyLimitDecision(dailyLimitDecision, config.rebalancingDailyBridgeLimitUsd); + if (dailyLimitDecision.shouldSkip) return; + await checkInitialUsdcBalanceOnBase(amountUsdcRaw); await rebalanceUsdcBrlaUsdcBase(amountUsdcRaw, forceRestart, policyDecision.routeToRun, { config: config.rebalancingCostPolicy, @@ -248,10 +274,18 @@ async function runBrlaToUsdc(bridgedToday: Big, dailyLimitRaw: Big, coverageDevi const isResuming = !forceRestart && state && state.currentPhase !== BrlaToUsdcBaseRebalancePhase.Idle; if (!isResuming) { - if (checkDailyLimit(bridgedToday, Big(amountUsdcRaw), dailyLimitRaw, config.rebalancingDailyBridgeLimitUsd)) return; const policyDecision = await evaluateBrlaToUsdcPolicy(amountUsdcRaw, coverageDeviationBps); if (!policyDecision.shouldExecute) return; + const dailyLimitDecision = evaluateDailyBridgeLimit( + bridgedToday, + Big(amountUsdcRaw), + dailyLimitRaw, + policyDecision.profitable + ); + logDailyLimitDecision(dailyLimitDecision, config.rebalancingDailyBridgeLimitUsd); + if (dailyLimitDecision.shouldSkip) return; + const rebalancerUsdcBalance = await checkInitialUsdcBalanceOnBase(amountUsdcRaw); if (config.rebalancingBrlToUsdMinBalance && rebalancerUsdcBalance.lt(config.rebalancingBrlToUsdMinBalance)) { throw new Error( diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.test.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.test.ts index 996e24d30..d273f9b9e 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.test.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.test.ts @@ -4,8 +4,10 @@ import { calculateMinimumDelta, calculateProjectedCostBps, calculateTargetBalanceRaw, + evaluateDailyBridgeLimit, evaluateRebalancingCostPolicy, getRebalancingUrgencyBand, + isProjectedProfit, type RebalancingCostPolicyConfig, wouldExceedDailyBridgeLimit } from "./guards.ts"; @@ -38,6 +40,27 @@ describe("USDC Base rebalance guards", () => { expect(wouldExceedDailyBridgeLimit(Big("9000000000"), Big("1000000000"), Big("10000000000"))).toBe(false); }); + test("detects profitable projected rebalances", () => { + expect(isProjectedProfit(Big("100000000"), Big("101000000"))).toBe(true); + expect(isProjectedProfit(Big("100000000"), Big("100000000"))).toBe(false); + expect(isProjectedProfit(Big("100000000"), Big("99000000"))).toBe(false); + }); + + test("evaluates daily bridge limit decisions", () => { + expect(evaluateDailyBridgeLimit(Big("9000000000"), Big("600000000"), Big("10000000000"), false)).toMatchObject({ + reason: "under_limit", + shouldSkip: false + }); + expect(evaluateDailyBridgeLimit(Big("9500000000"), Big("600000000"), Big("10000000000"), false)).toMatchObject({ + reason: "daily_limit_reached", + shouldSkip: true + }); + expect(evaluateDailyBridgeLimit(Big("9500000000"), Big("600000000"), Big("10000000000"), true)).toMatchObject({ + reason: "profitable_quote", + shouldSkip: false + }); + }); + test("calculates projected rebalancing cost in basis points", () => { expect(calculateProjectedCostBps(Big("100000000"), Big("99000000"))).toBe(100); expect(calculateProjectedCostBps(Big("100000000"), Big("101000000"))).toBe(-100); diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.ts index bc0f010b2..cb5d3f1a3 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.ts @@ -25,6 +25,12 @@ export interface RebalancingCostPolicyDecision { shouldExecute: boolean; } +export interface DailyBridgeLimitDecision { + projectedTotalRaw: string; + reason: "under_limit" | "profitable_quote" | "daily_limit_reached"; + shouldSkip: boolean; +} + export function calculateMinimumDelta(expectedDelta: Big, tolerance = DEFAULT_ARRIVAL_TOLERANCE): Big { return expectedDelta.mul(tolerance); } @@ -39,6 +45,29 @@ export function wouldExceedDailyBridgeLimit(bridgedTodayRaw: Big, requestedAmoun return bridgedTodayRaw.plus(requestedAmountRaw).gt(dailyLimitRaw); } +export function isProjectedProfit(inputAmountRaw: Big, projectedOutputRaw: Big): boolean { + return projectedOutputRaw.gt(inputAmountRaw); +} + +export function evaluateDailyBridgeLimit( + bridgedTodayRaw: Big, + requestedAmountRaw: Big, + dailyLimitRaw: Big, + profitable: boolean +): DailyBridgeLimitDecision { + const projectedTotalRaw = bridgedTodayRaw.plus(requestedAmountRaw).toFixed(0, 0); + + if (!wouldExceedDailyBridgeLimit(bridgedTodayRaw, requestedAmountRaw, dailyLimitRaw)) { + return { projectedTotalRaw, reason: "under_limit", shouldSkip: false }; + } + + if (profitable) { + return { projectedTotalRaw, reason: "profitable_quote", shouldSkip: false }; + } + + return { projectedTotalRaw, reason: "daily_limit_reached", shouldSkip: true }; +} + export function calculateProjectedCostBps(inputAmountRaw: Big, projectedOutputRaw: Big): number { if (inputAmountRaw.lte(0)) throw new Error("inputAmountRaw must be greater than zero."); return Number(inputAmountRaw.minus(projectedOutputRaw).div(inputAmountRaw).mul(10_000).toFixed(2)); diff --git a/docs/security-spec/07-operations/rebalancer.md b/docs/security-spec/07-operations/rebalancer.md index 0dd03a117..5e0883a49 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. `REBALANCING_DAILY_BRIDGE_LIMIT_USD` and `REBALANCING_HARD_MAX_COST_BPS` remain hard caps in every mode. +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. `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. **Current implementation:** Three rebalancing paths: @@ -36,7 +36,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- - `--route=squidrouter|avenia|nabla-main` → Constrain the high-coverage return route; the route is still quoted and cost-gated before execution **Cost policy controls:** -- `REBALANCING_POLICY_MODE=auto|dry-run|off|always` — `auto` applies urgency-band cost gating; `dry-run` quotes and logs the decision without state writes or fund movement; `off` skips fresh Base rebalances; `always` bypasses per-band cost gating but still respects the daily bridge limit and hard max-cost cap. +- `REBALANCING_POLICY_MODE=auto|dry-run|off|always` — `auto` applies urgency-band cost gating; `dry-run` quotes and logs the decision without state writes or fund movement; `off` skips fresh Base rebalances; `always` bypasses per-band cost gating but still respects `REBALANCING_HARD_MAX_COST_BPS`. The daily bridge limit still blocks non-profitable quotes in every executing mode, but projected-profitable quotes may bypass it. - `REBALANCING_MODERATE_DEVIATION_BPS` / `REBALANCING_SEVERE_DEVIATION_BPS` — classify coverage deviation beyond the trigger bound into mild, moderate, or severe bands. - `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. @@ -63,9 +63,9 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- **Trigger condition:** Base Nabla BRLA pool coverage ratio > `1 + REBALANCING_THRESHOLD_USDC_TO_BRLA` (default upper bound `1.01`). Falls back to `REBALANCING_THRESHOLD` when the route-specific threshold is unset. This makes the flow eligible for evaluation; cost policy may still skip fresh execution. -**Daily bridge limit:** Total requested USDC amount recorded by Base-flow history per calendar day (UTC), including the amount about to be rebalanced, must not exceed `REBALANCING_DAILY_BRIDGE_LIMIT_USD` (default 10,000). Checked against both `UsdcBaseStateManager` and `BrlaToUsdcBaseStateManager` history before starting a fresh Base rebalance. +**Daily bridge limit:** Total requested USDC amount recorded by Base-flow history per calendar day (UTC), including the amount about to be rebalanced, must not exceed `REBALANCING_DAILY_BRIDGE_LIMIT_USD` (default 10,000) unless the selected quote projects a profit. Profit is inferred from projected output USDC greater than input USDC, which also yields negative projected cost bps. The limit decision is checked against both `UsdcBaseStateManager` and `BrlaToUsdcBaseStateManager` history after quote/cost-policy evaluation and before any fresh Base state write or transaction. Completed profitable runs still write normal history entries, so they remain visible in daily accounting. -**Urgency-band policy:** Before any state write or transaction, the flow quotes the expected round-trip USDC output. Projected cost is `(input USDC - projected output USDC) / input USDC` in basis points. `auto` mode executes only when the projected cost is within the configured limit for the current coverage-deviation band. `dry-run` logs the same decision but never starts a rebalance. `off` skips without quoting. `always` can execute above the band limit, but not above `REBALANCING_HARD_MAX_COST_BPS` or the daily bridge limit. +**Urgency-band policy:** Before any state write or transaction, the flow quotes the expected round-trip USDC output. Projected cost is `(input USDC - projected output USDC) / input USDC` in basis points. `auto` mode executes only when the projected cost is within the configured limit for the current coverage-deviation band. `dry-run` logs the same decision but never starts a rebalance. `off` skips without quoting. `always` can execute above the band limit, but not above `REBALANCING_HARD_MAX_COST_BPS`; the daily bridge limit still applies unless the quote projects profit. **Rebalancing flow (linear phase):** 1. Check initial USDC balance on Base (sufficient for requested amount) @@ -115,7 +115,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- **Trigger condition:** Base Nabla BRLA pool coverage ratio < `1 - REBALANCING_THRESHOLD_BRLA_TO_USDC` (default lower bound `0.99`). Falls back to `REBALANCING_THRESHOLD` when the route-specific threshold is unset. This makes the flow eligible for evaluation; cost policy may still skip fresh execution. -**Daily bridge limit:** Uses the same Base-flow daily limit described above and records history in `rebalancer_state_brla_to_usdc_base.json`. +**Daily bridge limit:** Uses the same Base-flow daily limit and projected-profit bypass described above. Completed runs record history in `rebalancer_state_brla_to_usdc_base.json`. **Urgency-band policy:** Uses the same Base policy controls as the high-coverage flow. Before any state write or transaction, the rebalancer pre-quotes the main Nabla USDC→BRLA leg and the BRLA-pool BRLA→USDC leg, then applies the band-specific projected-cost threshold. @@ -138,7 +138,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- 4. **Rebalancer private keys MUST be isolated from API service keys** — The rebalancer keys operate separate accounts. Compromise of rebalancer keys should not affect API ramp operations, and vice versa. 5. **BRLA business account address MUST be verified** — `brlaBusinessAccountAddress` has a hardcoded default (`0xDF5Fb34B90e5FDF612372dA0c774A516bF5F08b2`). If this address is wrong, funds are sent to the wrong recipient with no recovery. 6. **Concurrent rebalancer executions MUST NOT corrupt state** — If two rebalancer instances run simultaneously, both would read the same state file and potentially execute the same phases in parallel. Supabase Storage has no file locking or atomic compare-and-swap. -7. **Policy modes MUST be fail-safe** — `off` performs no fresh Base rebalancing; `dry-run` performs read-only quote/evaluation/logging with no state writes, tickets, approvals, swaps, transfers, or history entries; `always` bypasses per-band cost gating only, not the daily bridge limit or hard max-cost cap. +7. **Policy modes MUST be fail-safe** — `off` performs no fresh Base rebalancing; `dry-run` performs read-only quote/evaluation/logging with no state writes, tickets, approvals, swaps, transfers, or history entries; `always` bypasses per-band cost gating only, not `REBALANCING_HARD_MAX_COST_BPS`. The daily bridge limit still blocks non-profitable quotes in every executing mode, while projected-profitable quotes may bypass it. ### Legacy flow (BRLA ↔ axlUSDC) invariants @@ -148,11 +148,11 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- ### Base flow invariants -11. **Daily bridge limit MUST be enforced** — 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`. Checked against both Base flow history entries before starting a new Base rebalance. This cap remains a hard upper bound in severe and `always` mode. +11. **Daily bridge limit MUST be enforced except for projected-profit quotes** — 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. Projected-profitable quotes may bypass the cap, but completed profitable runs must still be recorded in history. 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. 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. **Severe imbalances MAY use higher tolerance but MUST NOT bypass hard caps** — Severe band can permit higher projected cost, but it cannot bypass the daily bridge limit, `REBALANCING_HARD_MAX_COST_BPS`, balance checks, slippage limits, or phase safety checks. +15. **Severe imbalances MAY use higher tolerance but MUST NOT bypass hard cost caps** — Severe band can permit higher projected cost, but it cannot bypass `REBALANCING_HARD_MAX_COST_BPS`, balance checks, slippage limits, or phase safety checks. It also cannot bypass the daily bridge limit unless the selected quote projects profit. 16. **Route comparison MUST handle provider failures gracefully** — If every enabled return route quote fails, the high-coverage flow MUST abort (not proceed with zero information). If some routes fail, the best available route is used. If `--route=` is specified, that route is still quoted and cost-gated before execution. 17. **Avenia fallback to SquidRouter MUST be atomic in state** — If Avenia ticket creation fails, the flow sets `winningRoute = "squidrouter"` and `currentPhase = AveniaTransferToPolygon` in a single `saveState()` call. A crash between the failure and the save could leave the flow in an inconsistent state. 18. **NonceManager MUST be re-initialized on resume** — The `NonceManager` is created fresh at the start of each execution from `getTransactionCount()`. On resume, it must not reuse stale nonces from a previous execution. @@ -188,9 +188,9 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- |---|---| | **Route comparison manipulation** — Avenia, SquidRouter, and optional main Nabla quotes are fetched and compared; an attacker could manipulate one provider's rate to force another route | The rebalancer trusts provider quotes without independent verification. However, since all high-coverage routes end with USDC on Base, the worst case is choosing a slightly worse rate, not direct fund loss. The `slippage: 4` parameter on SquidRouter provides some buffer. | | **Avenia ticket creation failure mid-flow** — Avenia API fails after the flow committed to the Avenia route | **Mitigated.** The flow catches ticket creation errors and falls back to SquidRouter by setting `winningRoute = "squidrouter"` and saving state. Avenia tickets that return `FAILED` after creation are terminal and abort immediately. | -| **Daily bridge limit bypass** — History entries are stored in Supabase Storage; an attacker who can modify the storage could clear history to bypass the daily limit | **Weak mitigation.** The limit is enforced client-side by reading history from Supabase and adding the current requested amount before starting. An attacker with Supabase access could also drain funds directly, so the limit bypass is a secondary concern. | +| **Daily bridge limit bypass** — History entries are stored in Supabase Storage; an attacker who can modify the storage could clear history to bypass the daily limit. Separately, projected-profitable quotes intentionally bypass the daily cap. | **Weak mitigation.** The limit is enforced client-side by reading history from Supabase and adding the current requested amount before starting. The intentional profit bypass requires a quote with projected output greater than input and still writes history on completion. An attacker with Supabase access could also drain funds directly, so malicious history tampering is a secondary concern. | | **Cost-threshold misconfiguration** — Cost thresholds set too low can cause chronic under-rebalancing; thresholds set too high can cause repeated expensive rebalancing | Defaults are conservative and env parsing fails fast for non-monotonic values. Operators should first use `REBALANCING_POLICY_MODE=dry-run` to observe decisions before enabling tighter or looser production thresholds. | -| **Always-mode misuse** — Operator leaves `REBALANCING_POLICY_MODE=always` enabled and accepts expensive routes repeatedly | `always` still respects the daily bridge limit and `REBALANCING_HARD_MAX_COST_BPS`. Decision logs include band, projected cost, allowed cost, and reason so misuse is observable. | +| **Always-mode misuse** — Operator leaves `REBALANCING_POLICY_MODE=always` enabled and accepts expensive routes repeatedly | `always` still respects `REBALANCING_HARD_MAX_COST_BPS` and the daily bridge limit for non-profitable quotes. Decision logs include band, projected cost, allowed cost, daily-limit decision, and reason so misuse is observable. | | **Dry-run/off mode drift** — Cron appears healthy but liquidity is not actually moving | `dry-run` and `off` log explicit skip reasons. External monitoring must distinguish successful dry-run/off exits from real completed rebalances. | | **Quote-cost manipulation near thresholds** — Provider quotes near a configured boundary can nudge execution or skipping | Cost policy uses the best/forced quoted route before any side effect. Hard max-cost cap limits catastrophic execution, but provider quote trust remains a known risk. | | **NonceManager stale nonce** — If the process crashes after sending a transaction but before saving the nonce, the resumed execution could reuse the same nonce | **Mitigated.** `NonceManager` is re-initialized from `getTransactionCount()` on each execution. The stored transaction hashes in state also prevent re-execution of already-completed phases. | @@ -224,7 +224,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- ### Base flows - [x] **FINDING**: Axelar polling has 30-minute timeout — resolves F-034 for Base flow. **PASS** — `axelarTimeout = 30 * 60 * 1000` enforced in `squidRouterApproveAndSwap()`. -- [x] **FINDING**: Daily bridge limit check — `REBALANCING_DAILY_BRIDGE_LIMIT_USD` (default 10,000) enforced against both Base-flow histories plus the current requested amount. **PASS** — checked before starting new Base rebalance; prevents runaway execution beyond the cap. +- [x] **FINDING**: Daily bridge limit check — `REBALANCING_DAILY_BRIDGE_LIMIT_USD` (default 10,000) enforced against both Base-flow histories plus the current requested amount. **PASS** — checked after quote/cost-policy evaluation and before fresh Base side effects. Non-profitable quotes are blocked over the cap; projected-profitable quotes may bypass and are still recorded in history after completion. - [x] **FINDING**: Avenia fallback to SquidRouter — if Avenia ticket creation fails, flow falls back to SquidRouter route. **PASS** — error caught, `winningRoute` updated, state saved atomically. - [x] **FINDING**: `EVM_ACCOUNT_SECRET` single mnemonic for all EVM chains — broad EVM blast radius across Base, Polygon, and Moonbeam. **PASS (accepted)** — deliberate simplification; documented in invariants. - [x] Verify route comparison handles partial failures — what happens if one provider's quote fails? **PASS** — if every enabled route fails, throws; otherwise uses the best available route. If `--route=` is specified, only fetches that quote. @@ -243,5 +243,5 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- - [x] Verify severe imbalances can execute at a higher configured cost while still respecting hard caps. **PASS** — severe uses `REBALANCING_MAX_COST_BPS_SEVERE`, bounded by `REBALANCING_HARD_MAX_COST_BPS`. - [x] Verify `off` mode performs no fresh Base execution. **PASS** — policy returns a skip decision before quotes, state writes, balances, approvals, swaps, transfers, or tickets. - [x] Verify `dry-run` mode performs no approvals/swaps/transfers/history mutations. **PASS** — policy quotes and logs the decision, then exits before state creation. -- [x] Verify `always` mode still enforces daily bridge limit and hard max-cost cap. **PASS** — daily limit is checked before policy evaluation; policy rejects cost above `REBALANCING_HARD_MAX_COST_BPS` in every mode. +- [x] Verify `always` mode still enforces non-profitable daily bridge limit and hard max-cost cap. **PASS** — daily limit is checked after quote/cost-policy evaluation so profitable quotes can bypass; policy rejects cost above `REBALANCING_HARD_MAX_COST_BPS` in every mode. - [x] Verify skipped decisions are observable. **PASS** — logs include direction, band, projected cost bps, allowed bps, input, projected output, projected cost, and reason. diff --git a/docs/security-spec/README.md b/docs/security-spec/README.md index 6fbf45afc..b45253e4b 100644 --- a/docs/security-spec/README.md +++ b/docs/security-spec/README.md @@ -42,7 +42,7 @@ This directory contains the security specification for the Vortex cross-border p | XCM Transfers | `06-cross-chain/xcm-transfers.md` | Pendulum↔Moonbeam↔AssetHub↔Hydration | | Bridge Security | `06-cross-chain/bridge-security.md` | Spacewalk bridge trust model | | Fund Routing | `06-cross-chain/fund-routing.md` | Subsidization, fee distribution, amount integrity | -| Rebalancer | `07-operations/rebalancer.md` | Automated liquidity management — BRLA↔axlUSDC (legacy, Pendulum), cost-aware USDC→BRLA→USDC (Base high-coverage), and cost-aware BRLA→USDC correction (Base low-coverage) | +| Rebalancer | `07-operations/rebalancer.md` | Automated liquidity management — BRLA↔axlUSDC (legacy, Pendulum), cost/profit-aware USDC→BRLA→USDC (Base high-coverage), and cost/profit-aware BRLA→USDC correction (Base low-coverage) | | Secret Management | `07-operations/secret-management.md` | Env vars, rotation, blast radius | | API Surface | `07-operations/api-surface.md` | Rate limiting, CORS, input validation, error handling | | Client Observability | `07-operations/client-observability.md` | Request IDs, sanitized API client events, operational monitoring | From 05919d1fdd09d9b7b7bbb25734d32bfc0f8036e1 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 17 Jun 2026 09:57:40 +0200 Subject: [PATCH 059/131] Potential fix for pull request finding 'Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- apps/rebalancer/src/rebalance/brla-to-usdc-base/steps.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/rebalancer/src/rebalance/brla-to-usdc-base/steps.ts b/apps/rebalancer/src/rebalance/brla-to-usdc-base/steps.ts index df703aebe..01a21c597 100644 --- a/apps/rebalancer/src/rebalance/brla-to-usdc-base/steps.ts +++ b/apps/rebalancer/src/rebalance/brla-to-usdc-base/steps.ts @@ -61,8 +61,6 @@ export async function getBrlaBalanceOnBaseRaw(): Promise { return balance.toString(); } -import { checkInitialUsdcBalanceOnBase } from "../usdc-brla-usdc-base/steps.ts"; - export async function quoteMainNablaUsdcToBrlaOnBase(usdcAmountRaw: string): Promise { const { router, quoter } = getMainNablaConfig(); const evmClientManager = EvmClientManager.getInstance(); From 96e69a39b1c38623f587c8e126965cc246d54a1c Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 17 Jun 2026 11:04:18 +0200 Subject: [PATCH 060/131] Add biome-ignore and tests --- .../engines/merge-subsidy/offramp-evm.test.ts | 79 ++++++++++++ .../onramp/common/transactions.test.ts | 119 ++++++++++++++++++ .../onramp/common/transactions.ts | 1 + 3 files changed, 199 insertions(+) create mode 100644 apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.test.ts create mode 100644 apps/api/src/api/services/transactions/onramp/common/transactions.test.ts diff --git a/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.test.ts b/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.test.ts new file mode 100644 index 000000000..2822b080e --- /dev/null +++ b/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.test.ts @@ -0,0 +1,79 @@ +import {describe, expect, it, mock} from "bun:test"; +import {RampDirection} from "@vortexfi/shared"; +import Big from "big.js"; +import {QuoteContext} from "../../core/types"; +import {OffRampMergeSubsidyEvmEngine} from "./offramp-evm"; + +function createContext(nablaSwapEvm: QuoteContext["nablaSwapEvm"]): QuoteContext { + return { + addNote: mock(() => undefined), + nablaSwapEvm, + request: { + rampType: RampDirection.SELL + }, + subsidy: { + actualOutputAmountDecimal: new Big("100"), + actualOutputAmountRaw: "100000000", + adjustedDifference: new Big("0"), + adjustedTargetDiscount: 0, + expectedOutputAmountDecimal: new Big("110"), + expectedOutputAmountRaw: "110000000", + idealSubsidyAmountInOutputTokenDecimal: new Big("10"), + idealSubsidyAmountInOutputTokenRaw: "10000000", + partnerId: "partner-1", + subsidyAmountInOutputTokenDecimal: new Big("10"), + subsidyAmountInOutputTokenRaw: "10000000", + subsidyRate: new Big("0.1"), + targetOutputAmountDecimal: new Big("110"), + targetOutputAmountRaw: "110000000" + } + } as unknown as QuoteContext; +} + +describe("OffRampMergeSubsidyEvmEngine", () => { + it("preserves AMM-only output before writing the merged subsidized output", async () => { + const ctx = createContext({ + inputAmountForSwapDecimal: "100", + inputAmountForSwapRaw: "100000000", + inputCurrency: "USDC", + inputDecimals: 6, + inputToken: "0xinput", + outputAmountDecimal: new Big("100"), + outputAmountRaw: "100000000", + outputCurrency: "BRLA", + outputDecimals: 6, + outputToken: "0xoutput" + } as QuoteContext["nablaSwapEvm"]); + + await new OffRampMergeSubsidyEvmEngine().execute(ctx); + + expect(ctx.nablaSwapEvm?.ammOutputAmountDecimal?.toFixed()).toBe("100"); + expect(ctx.nablaSwapEvm?.ammOutputAmountRaw).toBe("100000000"); + expect(ctx.nablaSwapEvm?.outputAmountDecimal.toFixed()).toBe("110"); + expect(ctx.nablaSwapEvm?.outputAmountRaw).toBe("110000000"); + }); + + it("keeps an existing AMM-only snapshot when subsidy is merged again", async () => { + const ctx = createContext({ + ammOutputAmountDecimal: new Big("100"), + ammOutputAmountRaw: "100000000", + inputAmountForSwapDecimal: "100", + inputAmountForSwapRaw: "100000000", + inputCurrency: "USDC", + inputDecimals: 6, + inputToken: "0xinput", + outputAmountDecimal: new Big("110"), + outputAmountRaw: "110000000", + outputCurrency: "BRLA", + outputDecimals: 6, + outputToken: "0xoutput" + } as QuoteContext["nablaSwapEvm"]); + + await new OffRampMergeSubsidyEvmEngine().execute(ctx); + + expect(ctx.nablaSwapEvm?.ammOutputAmountDecimal?.toFixed()).toBe("100"); + expect(ctx.nablaSwapEvm?.ammOutputAmountRaw).toBe("100000000"); + expect(ctx.nablaSwapEvm?.outputAmountDecimal.toFixed()).toBe("120"); + expect(ctx.nablaSwapEvm?.outputAmountRaw).toBe("120000000"); + }); +}); diff --git a/apps/api/src/api/services/transactions/onramp/common/transactions.test.ts b/apps/api/src/api/services/transactions/onramp/common/transactions.test.ts new file mode 100644 index 000000000..16d896c4c --- /dev/null +++ b/apps/api/src/api/services/transactions/onramp/common/transactions.test.ts @@ -0,0 +1,119 @@ +import {beforeEach, describe, expect, it, mock} from "bun:test"; +import type {AccountMeta, UnsignedTx} from "@vortexfi/shared"; +import type {QuoteTicketAttributes} from "../../../../../models/quoteTicket.model"; + +const Networks = { + Base: "base", + Moonbeam: "moonbeam" +} as const; + +const createNablaTransactionsForOnrampOnEVM = mock(async () => ({ + approve: { + data: "0xapprove", + gas: "100000", + to: "0xinput", + value: "0" + }, + swap: { + data: "0xswap", + gas: "200000", + to: "0xrouter", + value: "0" + } +})); + +mock.module("@vortexfi/shared", () => ({ + AMM_MINIMUM_OUTPUT_HARD_MARGIN: 0.02, + AMM_MINIMUM_OUTPUT_SOFT_MARGIN: 0.01, + createMoonbeamToPendulumXCM: mock(async () => "0xmoonbeam"), + createNablaTransactionsForOnramp: mock(async () => ({ approve: "0xapprove", swap: "0xswap" })), + createNablaTransactionsForOnrampOnEVM, + encodeSubmittableExtrinsic: (tx: unknown) => tx, + EvmClientManager: { + getInstance: () => ({ + getClient: () => ({ + estimateFeesPerGas: mock(async () => ({ maxFeePerGas: 1n, maxPriorityFeePerGas: 1n })) + }) + }) + }, + getNablaBasePool: () => ({ router: "0xrouter" }), + getNetworkId: () => 1, + Networks +})); + +mock.module("../../../../../config/vars", () => ({ + config: { + swap: { + deadlineMinutes: 20 + } + } +})); + +mock.module("../../moonbeam/cleanup", () => ({ + prepareMoonbeamCleanupTransaction: mock(async () => "0xcleanup") +})); + +mock.module("../../pendulum/cleanup", () => ({ + preparePendulumCleanupTransaction: mock(async () => "0xcleanup") +})); + +const {addNablaSwapTransactionsOnBase} = await import("./transactions"); + +function createQuote(ammOutputAmountRaw?: string): QuoteTicketAttributes { + return { + metadata: { + nablaSwapEvm: { + ammOutputAmountRaw, + inputAmountForSwapRaw: "100000000", + outputAmountRaw: "110000000" + } + } + } as unknown as QuoteTicketAttributes; +} + +describe("addNablaSwapTransactionsOnBase", () => { + const account = { + address: "0x1111111111111111111111111111111111111111", + type: "EVM" + } as unknown as AccountMeta; + + beforeEach(() => { + createNablaTransactionsForOnrampOnEVM.mockClear(); + }); + + it("uses AMM-only output for Nabla minimums when subsidy was merged into the quote", async () => { + const unsignedTxs: UnsignedTx[] = []; + + const result = await addNablaSwapTransactionsOnBase( + { + account, + inputTokenAddress: "0x2222222222222222222222222222222222222222", + outputTokenAddress: "0x3333333333333333333333333333333333333333", + quote: createQuote("100000000") + }, + unsignedTxs, + 7 + ); + + expect(createNablaTransactionsForOnrampOnEVM.mock.calls[0][4]).toBe("98000000"); + expect(result.stateMeta.nablaSoftMinimumOutputRaw).toBe("99000000"); + expect(unsignedTxs.map(tx => tx.phase)).toEqual(["nablaApprove", "nablaSwap"]); + expect(result.nextNonce).toBe(9); + }); + + it("falls back to outputAmountRaw for quotes without an AMM-only output snapshot", async () => { + const result = await addNablaSwapTransactionsOnBase( + { + account, + inputTokenAddress: "0x2222222222222222222222222222222222222222", + outputTokenAddress: "0x3333333333333333333333333333333333333333", + quote: createQuote() + }, + [], + 0 + ); + + expect(createNablaTransactionsForOnrampOnEVM.mock.calls[0][4]).toBe("107800000"); + expect(result.stateMeta.nablaSoftMinimumOutputRaw).toBe("108900000"); + }); +}); diff --git a/apps/api/src/api/services/transactions/onramp/common/transactions.ts b/apps/api/src/api/services/transactions/onramp/common/transactions.ts index a1136c75c..0cf243b35 100644 --- a/apps/api/src/api/services/transactions/onramp/common/transactions.ts +++ b/apps/api/src/api/services/transactions/onramp/common/transactions.ts @@ -265,6 +265,7 @@ export async function addNablaSwapTransactionsOnBase( // OffRampMergeSubsidyEvmEngine). Use the AMM-only amount when available so // the on-chain minimum reflects what the AMM can actually deliver. const minOutputBaseRaw = quote.metadata.nablaSwapEvm.ammOutputAmountRaw ?? quote.metadata.nablaSwapEvm.outputAmountRaw; + // biome-ignore lint/correctness/noUnusedVariables: retained to keep the downstream interface stable while min-output uses the AMM-only amount const outputAmountRaw = Big(quote.metadata.nablaSwapEvm.outputAmountRaw); const nablaSoftMinimumOutputRaw = Big(minOutputBaseRaw) From e0dddbf5ee32899d69443c16d1695c67bc1537a5 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 17 Jun 2026 11:04:26 +0200 Subject: [PATCH 061/131] Update security spec --- docs/security-spec/03-ramp-engine/discount-mechanism.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/security-spec/03-ramp-engine/discount-mechanism.md b/docs/security-spec/03-ramp-engine/discount-mechanism.md index 95125c150..cdb6793dc 100644 --- a/docs/security-spec/03-ramp-engine/discount-mechanism.md +++ b/docs/security-spec/03-ramp-engine/discount-mechanism.md @@ -36,8 +36,9 @@ For onramps to EVM destinations other than AssetHub, the engine also probes Squi 7. **For offramps, the anchor fee MUST be added back to `expectedOutput`** before computing the shortfall (`adjustedExpectedOutputDecimal = oracleExpected + anchorFeeInBrl`). Otherwise the user would receive `expectedOutput − anchorFee`, which is short of the advertised rate by the anchor's cut. 8. **Subsidy amounts written to `ctx.subsidy` MUST be deterministic for a given input.** With `targetDiscount=0` the actual subsidy is forced to zero (`actualSubsidyAmountDecimal = Big(0)`), even when `idealSubsidy > 0`. This is the contract the merge-subsidy and subsidy phase handlers rely on. 9. **Discount subsidy MUST remain distinct from runtime swap discrepancy subsidy.** `ctx.subsidy.subsidyAmountInOutputTokenRaw` is the quote-time discount component, bounded by partner `maxSubsidy`. On EVM `subsidizePostSwap`, any actual-vs-quoted swap-output discrepancy is calculated against the live post-swap balance and capped by env-configured `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION`; the discount component is capped separately by env-configured `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION`. Both runtime fractions default to `0.05`. -10. **The dynamic difference MUST NOT be incremented within `discountStateTimeoutMinutes` of the last quote** — `getAdjustedDifference` only adds `deltaD` when `isWithinStateTimeout` is **false**. Otherwise repeated quotes from the same partner would inflate the difference faster than intended. -11. **Squid Router probe failures MUST fall back to a 1:1 assumption, never block the quote.** Both `getSquidRouterUSDCConversionRate` and `getSquidRouterAxlUSDCConversionRate` return `null` on error and the engine proceeds with `adjustedExpectedOutputDecimal = oracleExpectedOutputDecimal`. A network failure on the probe MUST NOT cause the entire quote stage to throw. +10. **EVM off-ramp Nabla minimums MUST use AMM-only output, not subsidy-merged output.** On Base EVM offramps, `MergeSubsidy` may merge the quote-time discount subsidy into `nablaSwapEvm.outputAmount*` so downstream payout/finalization targets reflect the subsidized amount. The on-chain Nabla swap minimums MUST instead be derived from the preserved AMM-only amount (`ammOutputAmountRaw`, falling back to `outputAmountRaw` only for legacy quotes without the snapshot), otherwise the minimum can exceed what the AMM can deliver and cause deterministic swap reverts. +11. **The dynamic difference MUST NOT be incremented within `discountStateTimeoutMinutes` of the last quote** — `getAdjustedDifference` only adds `deltaD` when `isWithinStateTimeout` is **false**. Otherwise repeated quotes from the same partner would inflate the difference faster than intended. +12. **Squid Router probe failures MUST fall back to a 1:1 assumption, never block the quote.** Both `getSquidRouterUSDCConversionRate` and `getSquidRouterAxlUSDCConversionRate` return `null` on error and the engine proceeds with `adjustedExpectedOutputDecimal = oracleExpectedOutputDecimal`. A network failure on the probe MUST NOT cause the entire quote stage to throw. ## Threat Vectors & Mitigations @@ -63,6 +64,7 @@ For onramps to EVM destinations other than AssetHub, the engine also probes Squi - [x] Discount parameters (`targetDiscount`, `maxSubsidy`, `minDynamicDifference`, `maxDynamicDifference`) are read exclusively from the `Partner` Sequelize model and never accepted from request fields. **PASS** — `resolveDiscountPartner` uses `Partner.findOne`; no request field is read. - [x] Subsidy cap `maxSubsidy × expectedOutput` is enforced in `calculateSubsidyAmount` (`helpers.ts:152-167`). **PASS**. - [x] EVM post-swap runtime cap logic treats discount subsidy separately from swap discrepancy subsidy. **PASS** — the discount component comes from `ctx.subsidy.subsidyAmountInOutputTokenRaw`; the live discrepancy component is computed from the post-swap balance and quoted actual output. Each component must pass its own env-configured runtime cap before transfer. +- [x] Base EVM off-ramp Nabla swap minimums use the AMM-only output when quote-time subsidy was merged. **PASS** — `OffRampMergeSubsidyEvmEngine` snapshots `nablaSwapEvm.ammOutputAmountRaw` before merging subsidy, and `addNablaSwapTransactionsOnBase` derives soft/hard minimums from `ammOutputAmountRaw ?? outputAmountRaw`. - [x] `targetDiscount=0` forces `actualSubsidyAmountDecimal = Big(0)` in both engines. **PASS** — `offramp.ts:76-79`, `onramp.ts:209-212`. - [x] Offramp `expectedOutput` adds back the anchor fee (`adjustedExpectedOutputDecimal = oracleExpectedOutput + anchorFeeInBrl`). **PASS** — `offramp.ts:50-51`. - [x] Onramp `actualOutput` subtracts post-swap fees (`network + vortex + partnerMarkup`). **PASS** — `onramp.ts:198-199`. From 701e825354bb0007b4be4d00fb16a9ec896b8912 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 17 Jun 2026 11:16:55 +0200 Subject: [PATCH 062/131] Refactor so that `ammOutputAmountDecimal/Raw` are always assigned in nabla-swap engine --- .../engines/merge-subsidy/offramp-evm.test.ts | 4 +- .../engines/merge-subsidy/offramp-evm.ts | 2 - .../quote/engines/nabla-swap/base-evm.test.ts | 89 +++++++++++++++++++ .../quote/engines/nabla-swap/base-evm.ts | 2 + .../quote/engines/nabla-swap/onramp-evm.ts | 2 + .../engines/nabla-swap/onramp-mykobo-evm.ts | 2 + .../03-ramp-engine/discount-mechanism.md | 4 +- 7 files changed, 100 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/api/services/quote/engines/nabla-swap/base-evm.test.ts diff --git a/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.test.ts b/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.test.ts index 2822b080e..2d45e593f 100644 --- a/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.test.ts +++ b/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.test.ts @@ -33,6 +33,8 @@ function createContext(nablaSwapEvm: QuoteContext["nablaSwapEvm"]): QuoteContext describe("OffRampMergeSubsidyEvmEngine", () => { it("preserves AMM-only output before writing the merged subsidized output", async () => { const ctx = createContext({ + ammOutputAmountDecimal: new Big("100"), + ammOutputAmountRaw: "100000000", inputAmountForSwapDecimal: "100", inputAmountForSwapRaw: "100000000", inputCurrency: "USDC", @@ -53,7 +55,7 @@ describe("OffRampMergeSubsidyEvmEngine", () => { expect(ctx.nablaSwapEvm?.outputAmountRaw).toBe("110000000"); }); - it("keeps an existing AMM-only snapshot when subsidy is merged again", async () => { + it("does not change the AMM-only output when subsidy is merged again", async () => { const ctx = createContext({ ammOutputAmountDecimal: new Big("100"), ammOutputAmountRaw: "100000000", diff --git a/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts b/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts index 5509a53ce..10c0317cd 100644 --- a/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts +++ b/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts @@ -32,8 +32,6 @@ export class OffRampMergeSubsidyEvmEngine implements Stage { ctx.nablaSwapEvm = { ...ctx.nablaSwapEvm, - ammOutputAmountDecimal: ctx.nablaSwapEvm.ammOutputAmountDecimal ?? ctx.nablaSwapEvm.outputAmountDecimal, - ammOutputAmountRaw: ctx.nablaSwapEvm.ammOutputAmountRaw ?? ctx.nablaSwapEvm.outputAmountRaw, outputAmountDecimal: ctx.nablaSwapEvm.outputAmountDecimal.plus(ctx.subsidy.subsidyAmountInOutputTokenDecimal), outputAmountRaw: (BigInt(ctx.nablaSwapEvm.outputAmountRaw) + BigInt(ctx.subsidy.subsidyAmountInOutputTokenRaw)).toString() }; diff --git a/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.test.ts b/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.test.ts new file mode 100644 index 000000000..2414c3c9e --- /dev/null +++ b/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.test.ts @@ -0,0 +1,89 @@ +import {describe, expect, it, mock} from "bun:test"; +import Big from "big.js"; +import type {QuoteContext} from "../../core/types"; + +const EvmToken = { + BRLA: "BRLA", + USDC: "USDC" +} as const; + +const Networks = { + Base: "base" +} as const; + +const RampDirection = { + BUY: "BUY", + SELL: "SELL" +} as const; + +mock.module("@vortexfi/shared", () => ({ + EvmToken, + getOnChainTokenDetails: (_network: string, token: string) => ({ + assetSymbol: token, + decimals: 6, + erc20AddressSourceChain: token === EvmToken.USDC ? "0xusdc" : "0xbrla", + isNative: false, + network: Networks.Base, + type: "evm" + }), + Networks, + RampDirection +})); + +mock.module("../../core/nabla", () => ({ + calculateNablaSwapOutputEvm: mock(async () => ({ + effectiveExchangeRate: "0.99", + nablaOutputAmountDecimal: new Big("99"), + nablaOutputAmountRaw: "99000000" + })) +})); + +mock.module("../../../priceFeed.service", () => ({ + priceFeedService: { + getOnchainOraclePrice: mock(async () => ({ price: new Big("1") })) + } +})); + +mock.module("../../../../../config/logger", () => ({ + default: { + warn: mock(() => undefined) + } +})); + +const {BaseNablaSwapEngineEvm} = await import("./base-evm"); + +class TestNablaSwapEngineEvm extends BaseNablaSwapEngineEvm { + readonly config = { + direction: RampDirection.SELL, + skipNote: "skip" + } as const; + + protected validate(): void {} + + protected compute() { + return { + inputAmountPreFees: new Big("100"), + inputToken: EvmToken.USDC, + outputToken: EvmToken.BRLA + }; + } +} + +describe("BaseNablaSwapEngineEvm", () => { + it("stores AMM-only output fields when assigning Nabla swap context", async () => { + const ctx = { + addNote: mock(() => undefined), + request: { + outputCurrency: "BRL", + rampType: RampDirection.SELL + } + } as unknown as QuoteContext; + + await new TestNablaSwapEngineEvm().execute(ctx); + + expect(ctx.nablaSwapEvm?.ammOutputAmountDecimal?.toFixed()).toBe("99"); + expect(ctx.nablaSwapEvm?.ammOutputAmountRaw).toBe("99000000"); + expect(ctx.nablaSwapEvm?.outputAmountDecimal.toFixed()).toBe("99"); + expect(ctx.nablaSwapEvm?.outputAmountRaw).toBe("99000000"); + }); +}); diff --git a/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.ts b/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.ts index 550f62588..f566695a8 100644 --- a/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.ts +++ b/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.ts @@ -110,6 +110,8 @@ export abstract class BaseNablaSwapEngineEvm implements Stage { ): void { ctx.nablaSwapEvm = { ...ctx.nablaSwapEvm, + ammOutputAmountDecimal: result.nablaOutputAmountDecimal, + ammOutputAmountRaw: result.nablaOutputAmountRaw, effectiveExchangeRate: result.effectiveExchangeRate, inputAmountForSwapDecimal, inputAmountForSwapRaw, diff --git a/apps/api/src/api/services/quote/engines/nabla-swap/onramp-evm.ts b/apps/api/src/api/services/quote/engines/nabla-swap/onramp-evm.ts index 4fdec5a91..3b8436d29 100644 --- a/apps/api/src/api/services/quote/engines/nabla-swap/onramp-evm.ts +++ b/apps/api/src/api/services/quote/engines/nabla-swap/onramp-evm.ts @@ -38,6 +38,8 @@ export class OnRampSwapEngineEvm extends BaseNablaSwapEngineEvm { const inputAmountForSwapRaw = inputAmountPreFees.times(new Big(10).pow(brlaTokenDetails.decimals)).toFixed(0, 0); ctx.nablaSwapEvm = { + ammOutputAmountDecimal: inputAmountPreFees, + ammOutputAmountRaw: inputAmountForSwapRaw, effectiveExchangeRate: "1", inputAmountForSwapDecimal: inputAmountPreFees.toString(), inputAmountForSwapRaw, diff --git a/apps/api/src/api/services/quote/engines/nabla-swap/onramp-mykobo-evm.ts b/apps/api/src/api/services/quote/engines/nabla-swap/onramp-mykobo-evm.ts index 7f6ee0b35..9218533b5 100644 --- a/apps/api/src/api/services/quote/engines/nabla-swap/onramp-mykobo-evm.ts +++ b/apps/api/src/api/services/quote/engines/nabla-swap/onramp-mykobo-evm.ts @@ -39,6 +39,8 @@ export class OnRampSwapEngineMykoboEvm extends BaseNablaSwapEngineEvm { const inputAmountForSwapRaw = inputAmountPreFees.times(new Big(10).pow(eurcTokenDetails.decimals)).toFixed(0, 0); ctx.nablaSwapEvm = { + ammOutputAmountDecimal: inputAmountPreFees, + ammOutputAmountRaw: inputAmountForSwapRaw, effectiveExchangeRate: "1", inputAmountForSwapDecimal: inputAmountPreFees.toString(), inputAmountForSwapRaw, diff --git a/docs/security-spec/03-ramp-engine/discount-mechanism.md b/docs/security-spec/03-ramp-engine/discount-mechanism.md index cdb6793dc..36a507982 100644 --- a/docs/security-spec/03-ramp-engine/discount-mechanism.md +++ b/docs/security-spec/03-ramp-engine/discount-mechanism.md @@ -36,7 +36,7 @@ For onramps to EVM destinations other than AssetHub, the engine also probes Squi 7. **For offramps, the anchor fee MUST be added back to `expectedOutput`** before computing the shortfall (`adjustedExpectedOutputDecimal = oracleExpected + anchorFeeInBrl`). Otherwise the user would receive `expectedOutput − anchorFee`, which is short of the advertised rate by the anchor's cut. 8. **Subsidy amounts written to `ctx.subsidy` MUST be deterministic for a given input.** With `targetDiscount=0` the actual subsidy is forced to zero (`actualSubsidyAmountDecimal = Big(0)`), even when `idealSubsidy > 0`. This is the contract the merge-subsidy and subsidy phase handlers rely on. 9. **Discount subsidy MUST remain distinct from runtime swap discrepancy subsidy.** `ctx.subsidy.subsidyAmountInOutputTokenRaw` is the quote-time discount component, bounded by partner `maxSubsidy`. On EVM `subsidizePostSwap`, any actual-vs-quoted swap-output discrepancy is calculated against the live post-swap balance and capped by env-configured `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION`; the discount component is capped separately by env-configured `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION`. Both runtime fractions default to `0.05`. -10. **EVM off-ramp Nabla minimums MUST use AMM-only output, not subsidy-merged output.** On Base EVM offramps, `MergeSubsidy` may merge the quote-time discount subsidy into `nablaSwapEvm.outputAmount*` so downstream payout/finalization targets reflect the subsidized amount. The on-chain Nabla swap minimums MUST instead be derived from the preserved AMM-only amount (`ammOutputAmountRaw`, falling back to `outputAmountRaw` only for legacy quotes without the snapshot), otherwise the minimum can exceed what the AMM can deliver and cause deterministic swap reverts. +10. **EVM off-ramp Nabla minimums MUST use AMM-only output, not subsidy-merged output.** EVM Nabla quote producers write the AMM-only result to `nablaSwapEvm.ammOutputAmount*`. On Base EVM offramps, `MergeSubsidy` may then merge the quote-time discount subsidy into `nablaSwapEvm.outputAmount*` so downstream payout/finalization targets reflect the subsidized amount. The on-chain Nabla swap minimums MUST be derived from the preserved AMM-only amount (`ammOutputAmountRaw`, falling back to `outputAmountRaw` only for legacy quotes without the snapshot), otherwise the minimum can exceed what the AMM can deliver and cause deterministic swap reverts. 11. **The dynamic difference MUST NOT be incremented within `discountStateTimeoutMinutes` of the last quote** — `getAdjustedDifference` only adds `deltaD` when `isWithinStateTimeout` is **false**. Otherwise repeated quotes from the same partner would inflate the difference faster than intended. 12. **Squid Router probe failures MUST fall back to a 1:1 assumption, never block the quote.** Both `getSquidRouterUSDCConversionRate` and `getSquidRouterAxlUSDCConversionRate` return `null` on error and the engine proceeds with `adjustedExpectedOutputDecimal = oracleExpectedOutputDecimal`. A network failure on the probe MUST NOT cause the entire quote stage to throw. @@ -64,7 +64,7 @@ For onramps to EVM destinations other than AssetHub, the engine also probes Squi - [x] Discount parameters (`targetDiscount`, `maxSubsidy`, `minDynamicDifference`, `maxDynamicDifference`) are read exclusively from the `Partner` Sequelize model and never accepted from request fields. **PASS** — `resolveDiscountPartner` uses `Partner.findOne`; no request field is read. - [x] Subsidy cap `maxSubsidy × expectedOutput` is enforced in `calculateSubsidyAmount` (`helpers.ts:152-167`). **PASS**. - [x] EVM post-swap runtime cap logic treats discount subsidy separately from swap discrepancy subsidy. **PASS** — the discount component comes from `ctx.subsidy.subsidyAmountInOutputTokenRaw`; the live discrepancy component is computed from the post-swap balance and quoted actual output. Each component must pass its own env-configured runtime cap before transfer. -- [x] Base EVM off-ramp Nabla swap minimums use the AMM-only output when quote-time subsidy was merged. **PASS** — `OffRampMergeSubsidyEvmEngine` snapshots `nablaSwapEvm.ammOutputAmountRaw` before merging subsidy, and `addNablaSwapTransactionsOnBase` derives soft/hard minimums from `ammOutputAmountRaw ?? outputAmountRaw`. +- [x] Base EVM off-ramp Nabla swap minimums use the AMM-only output when quote-time subsidy was merged. **PASS** — EVM Nabla quote producers write `nablaSwapEvm.ammOutputAmountRaw`, `OffRampMergeSubsidyEvmEngine` leaves that AMM-only value untouched while merging subsidy into `outputAmountRaw`, and `addNablaSwapTransactionsOnBase` derives soft/hard minimums from `ammOutputAmountRaw ?? outputAmountRaw`. - [x] `targetDiscount=0` forces `actualSubsidyAmountDecimal = Big(0)` in both engines. **PASS** — `offramp.ts:76-79`, `onramp.ts:209-212`. - [x] Offramp `expectedOutput` adds back the anchor fee (`adjustedExpectedOutputDecimal = oracleExpectedOutput + anchorFeeInBrl`). **PASS** — `offramp.ts:50-51`. - [x] Onramp `actualOutput` subtracts post-swap fees (`network + vortex + partnerMarkup`). **PASS** — `onramp.ts:198-199`. From 040ad0c7e952d53cc0f10b1466bd1e37c3fb00f4 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 17 Jun 2026 11:32:30 +0200 Subject: [PATCH 063/131] Fix type issues --- .../quote/engines/nabla-swap/base-evm.test.ts | 17 +++---- .../onramp/common/transactions.test.ts | 47 +++++++++++++------ 2 files changed, 41 insertions(+), 23 deletions(-) diff --git a/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.test.ts b/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.test.ts index 2414c3c9e..58f816d48 100644 --- a/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.test.ts +++ b/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.test.ts @@ -2,32 +2,32 @@ import {describe, expect, it, mock} from "bun:test"; import Big from "big.js"; import type {QuoteContext} from "../../core/types"; -const EvmToken = { +const mockedEvmToken = { BRLA: "BRLA", USDC: "USDC" } as const; -const Networks = { +const mockedNetworks = { Base: "base" } as const; -const RampDirection = { +const mockedRampDirection = { BUY: "BUY", SELL: "SELL" } as const; mock.module("@vortexfi/shared", () => ({ - EvmToken, + EvmToken: mockedEvmToken, getOnChainTokenDetails: (_network: string, token: string) => ({ assetSymbol: token, decimals: 6, - erc20AddressSourceChain: token === EvmToken.USDC ? "0xusdc" : "0xbrla", + erc20AddressSourceChain: token === mockedEvmToken.USDC ? "0xusdc" : "0xbrla", isNative: false, - network: Networks.Base, + network: mockedNetworks.Base, type: "evm" }), - Networks, - RampDirection + Networks: mockedNetworks, + RampDirection: mockedRampDirection })); mock.module("../../core/nabla", () => ({ @@ -50,6 +50,7 @@ mock.module("../../../../../config/logger", () => ({ } })); +const {EvmToken, RampDirection} = await import("@vortexfi/shared"); const {BaseNablaSwapEngineEvm} = await import("./base-evm"); class TestNablaSwapEngineEvm extends BaseNablaSwapEngineEvm { diff --git a/apps/api/src/api/services/transactions/onramp/common/transactions.test.ts b/apps/api/src/api/services/transactions/onramp/common/transactions.test.ts index 16d896c4c..762e8e729 100644 --- a/apps/api/src/api/services/transactions/onramp/common/transactions.test.ts +++ b/apps/api/src/api/services/transactions/onramp/common/transactions.test.ts @@ -7,20 +7,36 @@ const Networks = { Moonbeam: "moonbeam" } as const; -const createNablaTransactionsForOnrampOnEVM = mock(async () => ({ - approve: { - data: "0xapprove", - gas: "100000", - to: "0xinput", - value: "0" - }, - swap: { - data: "0xswap", - gas: "200000", - to: "0xrouter", - value: "0" +const nablaHardMinimumOutputRawCalls: string[] = []; + +const createNablaTransactionsForOnrampOnEVM = mock( + async ( + _inputAmountForNablaSwapRaw: string, + _account: AccountMeta, + _inputTokenAddress: `0x${string}`, + _outputTokenAddress: `0x${string}`, + nablaHardMinimumOutputRaw: string, + _deadlineMinutes: number, + _router: string + ) => { + nablaHardMinimumOutputRawCalls.push(nablaHardMinimumOutputRaw); + + return { + approve: { + data: "0xapprove", + gas: "100000", + to: "0xinput", + value: "0" + }, + swap: { + data: "0xswap", + gas: "200000", + to: "0xrouter", + value: "0" + } + }; } -})); +); mock.module("@vortexfi/shared", () => ({ AMM_MINIMUM_OUTPUT_HARD_MARGIN: 0.02, @@ -79,6 +95,7 @@ describe("addNablaSwapTransactionsOnBase", () => { beforeEach(() => { createNablaTransactionsForOnrampOnEVM.mockClear(); + nablaHardMinimumOutputRawCalls.length = 0; }); it("uses AMM-only output for Nabla minimums when subsidy was merged into the quote", async () => { @@ -95,7 +112,7 @@ describe("addNablaSwapTransactionsOnBase", () => { 7 ); - expect(createNablaTransactionsForOnrampOnEVM.mock.calls[0][4]).toBe("98000000"); + expect(nablaHardMinimumOutputRawCalls).toEqual(["98000000"]); expect(result.stateMeta.nablaSoftMinimumOutputRaw).toBe("99000000"); expect(unsignedTxs.map(tx => tx.phase)).toEqual(["nablaApprove", "nablaSwap"]); expect(result.nextNonce).toBe(9); @@ -113,7 +130,7 @@ describe("addNablaSwapTransactionsOnBase", () => { 0 ); - expect(createNablaTransactionsForOnrampOnEVM.mock.calls[0][4]).toBe("107800000"); + expect(nablaHardMinimumOutputRawCalls).toEqual(["107800000"]); expect(result.stateMeta.nablaSoftMinimumOutputRaw).toBe("108900000"); }); }); From 44e171352a5b9648befbc8fbb4e1fb9a5bb4e1dc Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 17 Jun 2026 12:00:25 +0200 Subject: [PATCH 064/131] Exclude generated TypeChain files from Biome --- biome.json | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/biome.json b/biome.json index edb8a852f..fac11da63 100644 --- a/biome.json +++ b/biome.json @@ -19,6 +19,7 @@ "!**/coverage/**", "!**/gql/**", "!**/lottie/**", + "!contracts/relayer/typechain-types/**", "!**/*.test.ts", "!**/*.test.tsx", "!**/routeTree.gen.ts" @@ -113,16 +114,5 @@ "useNamespaceKeyword": "error" } } - }, - "overrides": [ - { - "linter": { - "rules": { - "suspicious": { - "noExplicitAny": "off" - } - } - } - } - ] + } } From 15bd88dd3c008fe0deeb995e4234f3240f365f64 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 17 Jun 2026 12:00:55 +0200 Subject: [PATCH 065/131] Use module augmentation for Express request fields --- apps/api/src/api/middlewares/auth.ts | 8 +++----- apps/api/src/api/middlewares/supabaseAuth.ts | 10 ++++------ 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/apps/api/src/api/middlewares/auth.ts b/apps/api/src/api/middlewares/auth.ts index 0d9e5801a..466027d11 100644 --- a/apps/api/src/api/middlewares/auth.ts +++ b/apps/api/src/api/middlewares/auth.ts @@ -3,11 +3,9 @@ import httpStatus from "http-status"; import logger from "../../config/logger"; import { validateSignatureAndGetMemo } from "../services/siwe.service"; -declare global { - namespace Express { - interface Request { - derivedMemo: string | null; - } +declare module "express" { + interface Request { + derivedMemo?: string | null; } } diff --git a/apps/api/src/api/middlewares/supabaseAuth.ts b/apps/api/src/api/middlewares/supabaseAuth.ts index 2c0d64e92..2e5bf38ee 100644 --- a/apps/api/src/api/middlewares/supabaseAuth.ts +++ b/apps/api/src/api/middlewares/supabaseAuth.ts @@ -2,12 +2,10 @@ import { NextFunction, Request, Response } from "express"; import logger from "../../config/logger"; import { SupabaseAuthService } from "../services/auth"; -declare global { - namespace Express { - interface Request { - userId?: string; - userEmail?: string; - } +declare module "express" { + interface Request { + userId?: string; + userEmail?: string; } } From f2604dce6ce180b0437f24a40418f0f336e87f30 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 17 Jun 2026 12:01:28 +0200 Subject: [PATCH 066/131] Tighten API controller lint types --- .../api/controllers/alfredpay.controller.ts | 58 +++++++++++-------- .../src/api/controllers/metrics.controller.ts | 38 ++++++------ 2 files changed, 55 insertions(+), 41 deletions(-) diff --git a/apps/api/src/api/controllers/alfredpay.controller.ts b/apps/api/src/api/controllers/alfredpay.controller.ts index 7f72d0555..550188df5 100644 --- a/apps/api/src/api/controllers/alfredpay.controller.ts +++ b/apps/api/src/api/controllers/alfredpay.controller.ts @@ -26,6 +26,18 @@ import logger from "../../config/logger"; import AlfredPayCustomer from "../../models/alfredPayCustomer.model"; export class AlfredpayController { + private static getRequiredUserId(req: Request): string { + if (!req.userId) { + throw new Error("Authenticated user id not available"); + } + + return req.userId; + } + + private static getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } + private static mapKycStatus(status: AlfredpayKycStatus): AlfredPayStatus | null { switch (status) { case AlfredpayKycStatus.IN_REVIEW: @@ -61,7 +73,7 @@ export class AlfredpayController { static async alfredpayStatus(req: Request, res: Response) { try { const { country } = req.query as unknown as AlfredpayStatusRequest; - const userId = req.userId!; + const userId = AlfredpayController.getRequiredUserId(req); const alfredPayCustomer = await AlfredPayCustomer.findOne({ order: [["updatedAt", "DESC"]], @@ -107,7 +119,7 @@ export class AlfredpayController { // If the upstream API returns 404 (KYC submission not found), the local status is stale. // Reset to Consulted so the frontend re-triggers the KYC flow. - const errorMessage = ((error as any)?.message || (error as any)?.toString() || "").toLowerCase(); + const errorMessage = AlfredpayController.getErrorMessage(error).toLowerCase(); if (errorMessage.includes("404") || errorMessage.includes("not found")) { if (alfredPayCustomer.status === AlfredPayStatus.Success) { logger.info("Resetting stale AlfredPay status to Consulted due to upstream 404"); @@ -132,7 +144,7 @@ export class AlfredpayController { static async createIndividualCustomer(req: Request, res: Response) { try { const { country } = req.body as AlfredpayCreateCustomerRequest; - const userId = req.userId!; + const userId = AlfredpayController.getRequiredUserId(req); const userEmail = req.userEmail; if (!userEmail) { @@ -187,7 +199,7 @@ export class AlfredpayController { static async getKycRedirectLink(req: Request, res: Response) { try { const { country } = req.query as unknown as AlfredpayGetKycRedirectLinkRequest; - const userId = req.userId!; + const userId = AlfredpayController.getRequiredUserId(req); const alfredPayCustomer = await AlfredPayCustomer.findOne({ where: { country: country as AlfredPayCountry, userId } @@ -211,7 +223,7 @@ export class AlfredpayController { return res.status(400).json({ error: `KYC is in status ${statusRes.status}` }); } } - } catch (e) { + } catch { logger.info("No previous KYC submission found or error fetching it, proceeding."); } @@ -228,7 +240,7 @@ export class AlfredpayController { static async kycRedirectOpened(req: Request, res: Response) { try { const { country, type } = req.body as unknown as { country: string; type?: AlfredpayCustomerType }; - const userId = req.userId!; + const userId = AlfredpayController.getRequiredUserId(req); const selectedType = type || AlfredpayCustomerType.INDIVIDUAL; const alfredPayCustomer = await AlfredPayCustomer.findOne({ @@ -252,7 +264,7 @@ export class AlfredpayController { static async kycRedirectFinished(req: Request, res: Response) { try { const { country, type } = req.body as unknown as { country: string; type?: AlfredpayCustomerType }; - const userId = req.userId!; + const userId = AlfredpayController.getRequiredUserId(req); const selectedType = type || AlfredpayCustomerType.INDIVIDUAL; const alfredPayCustomer = await AlfredPayCustomer.findOne({ @@ -276,7 +288,7 @@ export class AlfredpayController { static async getKycStatus(req: Request, res: Response) { try { const { country, type } = req.query as unknown as { country: string; type?: AlfredpayCustomerType }; - const userId = req.userId!; + const userId = AlfredpayController.getRequiredUserId(req); const selectedType = type || AlfredpayCustomerType.INDIVIDUAL; const alfredPayCustomer = await AlfredPayCustomer.findOne({ @@ -336,7 +348,7 @@ export class AlfredpayController { static async retryKyc(req: Request, res: Response) { try { const { country, type } = req.body as { country: string; type?: AlfredpayCustomerType }; - const userId = req.userId!; + const userId = AlfredpayController.getRequiredUserId(req); const selectedType = type || AlfredpayCustomerType.INDIVIDUAL; const alfredPayCustomer = await AlfredPayCustomer.findOne({ @@ -392,7 +404,7 @@ export class AlfredpayController { static async createBusinessCustomer(req: Request, res: Response) { try { const { country } = req.body as { country: string }; - const userId = req.userId!; + const userId = AlfredpayController.getRequiredUserId(req); const userEmail = req.userEmail; if (!userEmail) { @@ -448,7 +460,7 @@ export class AlfredpayController { static async getKybRedirectLink(req: Request, res: Response) { try { const { country } = req.query as unknown as AlfredpayGetKycRedirectLinkRequest; - const userId = req.userId!; + const userId = AlfredpayController.getRequiredUserId(req); const alfredPayCustomer = await AlfredPayCustomer.findOne({ where: { country: country as AlfredPayCountry, type: AlfredpayCustomerType.BUSINESS, userId } @@ -472,7 +484,7 @@ export class AlfredpayController { return res.status(400).json({ error: `KYB is in status ${statusRes.status}` }); } } - } catch (e) { + } catch { logger.info("No previous KYB submission found or error fetching it, proceeding."); } @@ -488,7 +500,7 @@ export class AlfredpayController { static async submitKycInformation(req: Request, res: Response) { try { const { country, ...kycData } = req.body as SubmitKycInformationRequest; - const userId = req.userId!; + const userId = AlfredpayController.getRequiredUserId(req); const alfredPayCustomer = await AlfredPayCustomer.findOne({ where: { country: country as AlfredPayCountry, type: AlfredpayCustomerType.INDIVIDUAL, userId } @@ -526,7 +538,7 @@ export class AlfredpayController { static async submitKycFile(req: Request, res: Response) { try { const { country, submissionId, fileType } = req.body as { country: string; submissionId: string; fileType: string }; - const userId = req.userId!; + const userId = AlfredpayController.getRequiredUserId(req); if (!req.file) { return res.status(400).json({ error: "No file uploaded" }); @@ -560,7 +572,7 @@ export class AlfredpayController { static async sendKycSubmission(req: Request, res: Response) { try { const { country, submissionId } = req.body as { country: string; submissionId: string }; - const userId = req.userId!; + const userId = AlfredpayController.getRequiredUserId(req); const alfredPayCustomer = await AlfredPayCustomer.findOne({ where: { country: country as AlfredPayCountry, type: AlfredpayCustomerType.INDIVIDUAL, userId } @@ -584,7 +596,7 @@ export class AlfredpayController { static async submitKybInformation(req: Request, res: Response) { try { const { country, ...kybData } = req.body as SubmitKybInformationRequest & { country: string }; - const userId = req.userId!; + const userId = AlfredpayController.getRequiredUserId(req); const alfredPayCustomer = await AlfredPayCustomer.findOne({ where: { country: country as AlfredPayCountry, type: AlfredpayCustomerType.BUSINESS, userId } @@ -622,7 +634,7 @@ export class AlfredpayController { static async findKybCustomerAndBusiness(req: Request, res: Response) { try { const { country } = req.query as { country: string }; - const userId = req.userId!; + const userId = AlfredpayController.getRequiredUserId(req); const alfredPayCustomer = await AlfredPayCustomer.findOne({ where: { country: country as AlfredPayCountry, type: AlfredpayCustomerType.BUSINESS, userId } @@ -650,7 +662,7 @@ export class AlfredpayController { static async submitKybFile(req: Request, res: Response) { try { const { country, submissionId, fileType } = req.body as { country: string; submissionId: string; fileType: string }; - const userId = req.userId!; + const userId = AlfredpayController.getRequiredUserId(req); if (!req.file) { return res.status(400).json({ error: "No file uploaded" }); @@ -689,7 +701,7 @@ export class AlfredpayController { relatedPersonId: string; fileType: string; }; - const userId = req.userId!; + const userId = AlfredpayController.getRequiredUserId(req); if (!req.file) { return res.status(400).json({ error: "No file uploaded" }); @@ -729,7 +741,7 @@ export class AlfredpayController { static async sendKybSubmission(req: Request, res: Response) { try { const { country, submissionId } = req.body as { country: string; submissionId: string }; - const userId = req.userId!; + const userId = AlfredpayController.getRequiredUserId(req); const alfredPayCustomer = await AlfredPayCustomer.findOne({ where: { country: country as AlfredPayCountry, type: AlfredpayCustomerType.BUSINESS, userId } @@ -774,7 +786,7 @@ export class AlfredpayController { documentNumber, isExternal = false } = req.body as AlfredpayAddFiatAccountRequest; - const userId = req.userId!; + const userId = AlfredpayController.getRequiredUserId(req); const alfredPayCustomer = await AlfredPayCustomer.findOne({ order: [["updatedAt", "DESC"]], @@ -860,7 +872,7 @@ export class AlfredpayController { static async listFiatAccounts(req: Request, res: Response) { try { const { country } = req.query as { country: string }; - const userId = req.userId!; + const userId = AlfredpayController.getRequiredUserId(req); const alfredPayCustomer = await AlfredPayCustomer.findOne({ order: [["updatedAt", "DESC"]], @@ -885,7 +897,7 @@ export class AlfredpayController { try { const { fiatAccountId } = req.params as { fiatAccountId: string }; const { country } = req.query as { country: string }; - const userId = req.userId!; + const userId = AlfredpayController.getRequiredUserId(req); const alfredPayCustomer = await AlfredPayCustomer.findOne({ order: [["updatedAt", "DESC"]], diff --git a/apps/api/src/api/controllers/metrics.controller.ts b/apps/api/src/api/controllers/metrics.controller.ts index 29799dd0a..5cfc1af2c 100644 --- a/apps/api/src/api/controllers/metrics.controller.ts +++ b/apps/api/src/api/controllers/metrics.controller.ts @@ -13,10 +13,6 @@ export interface VolumeRow { total_usd: number; } -export interface MonthlyVolume extends VolumeRow { - month: string; -} - export interface ChainVolume { chain: string; buy_usd: number; @@ -80,9 +76,10 @@ function getAnonSupabaseClient() { return supabaseAnonClient; } -function isAuthOrPermissionError(error: any): boolean { - const rawCode = error?.code ?? ""; - const rawMessage = error?.message ?? ""; +function isAuthOrPermissionError(error: unknown): boolean { + const errorRecord = error && typeof error === "object" ? (error as Record) : {}; + const rawCode = errorRecord.code ?? ""; + const rawMessage = errorRecord.message ?? ""; const errorCode = String(rawCode).toLowerCase(); const errorMessage = String(rawMessage).toLowerCase(); @@ -100,7 +97,7 @@ function isAuthOrPermissionError(error: any): boolean { return hasAuthOrPermissionText || has401 || has403; } -async function rpcWithFallback>(fn: string, params: P): Promise { +async function rpcWithFallback>(fn: string, params: P): Promise { const hasServiceKey = !!config.supabase.serviceRoleKey; const hasAnonKey = !!config.supabase.anonKey; @@ -164,10 +161,15 @@ async function rpcWithFallback ({ - [keyName]: key, - chains: [] -}); +function zeroVolume(key: string, keyName: "day"): DailyVolume; +function zeroVolume(key: string, keyName: "month"): MonthlyVolume; +function zeroVolume(key: string, keyName: "day" | "month"): DailyVolume | MonthlyVolume { + return keyName === "day" ? { chains: [], day: key } : { chains: [], month: key }; +} + +function getErrorStack(error: unknown): string | undefined { + return error instanceof Error ? error.stack : undefined; +} async function getMonthlyVolumes(): Promise { const cacheKey = "monthly"; @@ -198,9 +200,9 @@ async function getMonthlyVolumes(): Promise { cache.set(cacheKey, volumes, CACHE_TTL_SECONDS); return volumes; - } catch (error: any) { + } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - logger.error("Could not calculate monthly volumes", { error: errorMessage, stack: error?.stack }); + logger.error("Could not calculate monthly volumes", { error: errorMessage, stack: getErrorStack(error) }); throw new Error("Could not calculate monthly volumes: " + errorMessage); } } @@ -229,9 +231,9 @@ async function getDailyVolumes(startDate: string, endDate: string): Promise(); + const chainTotals = new Map(); chunk.forEach(day => { - day.chains.forEach((c: any) => { + day.chains.forEach(c => { const existing = chainTotals.get(c.chain) || { buy_usd: 0, chain: c.chain, sell_usd: 0, total_usd: 0 }; existing.buy_usd += c.buy_usd; existing.sell_usd += c.sell_usd; From 392af4a898baeb6f3ea1a8eb0d9db553097b7c31 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 17 Jun 2026 12:02:04 +0200 Subject: [PATCH 067/131] Tighten API auth and model types --- apps/api/src/api/services/auth/supabase.service.ts | 3 ++- apps/api/src/models/apiKey.model.ts | 3 ++- apps/api/src/models/kycLevel2.model.ts | 8 ++++---- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/api/src/api/services/auth/supabase.service.ts b/apps/api/src/api/services/auth/supabase.service.ts index d0394316d..6da20804e 100644 --- a/apps/api/src/api/services/auth/supabase.service.ts +++ b/apps/api/src/api/services/auth/supabase.service.ts @@ -1,3 +1,4 @@ +import type { User } from "@supabase/supabase-js"; import logger from "../../../config/logger"; import { supabase, supabaseAdmin } from "../../../config/supabase"; @@ -182,7 +183,7 @@ export class SupabaseAuthService { /** * Get user profile from Supabase */ - static async getUserProfile(userId: string): Promise { + static async getUserProfile(userId: string): Promise { const { data, error } = await supabaseAdmin.auth.admin.getUserById(userId); if (error) { diff --git a/apps/api/src/models/apiKey.model.ts b/apps/api/src/models/apiKey.model.ts index 559db4076..d665d5fbf 100644 --- a/apps/api/src/models/apiKey.model.ts +++ b/apps/api/src/models/apiKey.model.ts @@ -1,5 +1,6 @@ import { DataTypes, Model, Optional } from "sequelize"; import sequelize from "../config/database"; +import Partner from "./partner.model"; // Define the attributes of the ApiKey model export interface ApiKeyAttributes { @@ -50,7 +51,7 @@ class ApiKey extends Model implement declare updatedAt: Date; // Association helper - partners with this name - declare partners?: any[]; + declare partners?: Partner[]; } // Initialize the model diff --git a/apps/api/src/models/kycLevel2.model.ts b/apps/api/src/models/kycLevel2.model.ts index 1dde0ff01..8c71e3f56 100644 --- a/apps/api/src/models/kycLevel2.model.ts +++ b/apps/api/src/models/kycLevel2.model.ts @@ -6,9 +6,9 @@ export interface KycLevel2Attributes { userId: string | null; subaccountId: string; documentType: "RG" | "CNH"; - uploadData: any; + uploadData: unknown; status: "Requested" | "DataCollected" | "BrlaValidating" | "Rejected" | "Accepted" | "Cancelled"; - errorLogs: any[]; + errorLogs: unknown[]; createdAt: Date; updatedAt: Date; } @@ -20,9 +20,9 @@ class KycLevel2 extends Model declare userId: string | null; declare subaccountId: string; declare documentType: "RG" | "CNH"; - declare uploadData: any; + declare uploadData: unknown; declare status: "Requested" | "DataCollected" | "BrlaValidating" | "Rejected" | "Accepted" | "Cancelled"; - declare errorLogs: any[]; + declare errorLogs: unknown[]; declare createdAt: Date; declare updatedAt: Date; } From 45ba134dff82cd2a58fd0a8466358bacaa3f6a4a Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 17 Jun 2026 12:02:28 +0200 Subject: [PATCH 068/131] Type phase handler error paths --- .../alfredpay-offramp-transfer-handler.ts | 23 +++++++++++++++---- .../handlers/alfredpay-onramp-mint-handler.ts | 17 ++++++++++---- .../handlers/brla-payout-base-handler.ts | 10 +++++--- .../moonbeam-to-pendulum-xcm-handler.ts | 2 +- 4 files changed, 39 insertions(+), 13 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/alfredpay-offramp-transfer-handler.ts b/apps/api/src/api/services/phases/handlers/alfredpay-offramp-transfer-handler.ts index d8830da97..dfbfd475f 100644 --- a/apps/api/src/api/services/phases/handlers/alfredpay-offramp-transfer-handler.ts +++ b/apps/api/src/api/services/phases/handlers/alfredpay-offramp-transfer-handler.ts @@ -18,6 +18,19 @@ import { StateMetadata } from "../meta-state-types"; const ALFREDPAY_POLL_INTERVAL_MS = 30000; const ALFREDPAY_OFFRAMP_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes +type AlfredpayFailedStatusError = { + failureReason?: string; + kind: "failed"; +}; + +function isAlfredpayFailedStatusError(error: unknown): error is AlfredpayFailedStatusError { + return !!error && typeof error === "object" && "kind" in error && error.kind === "failed"; +} + +function getErrorName(error: unknown): string | undefined { + return error && typeof error === "object" && "name" in error ? String(error.name) : undefined; +} + export class AlfredpayOfframpTransferHandler extends BasePhaseHandler { public getPhaseName(): RampPhase { return "alfredpayOfframpTransfer"; @@ -85,8 +98,8 @@ export class AlfredpayOfframpTransferHandler extends BasePhaseHandler { `AlfredpayOfframpTransferHandler: Final transfer transaction ${alfredpayOfframpTransferTxHash} failed on chain.` ); } - } catch (error: any) { - if (error?.name !== "TransactionReceiptNotFoundError") { + } catch (error) { + if (getErrorName(error) !== "TransactionReceiptNotFoundError") { throw error; } } @@ -94,8 +107,8 @@ export class AlfredpayOfframpTransferHandler extends BasePhaseHandler { try { await this.pollAlfredpayOfframpStatus(alfredpayTx.transactionId, ALFREDPAY_POLL_INTERVAL_MS); - } catch (error: any) { - if (error?.kind === "failed") { + } catch (error) { + if (isAlfredpayFailedStatusError(error)) { logger.error(`AlfredpayOfframpTransferHandler: Alfredpay offramp FAILED. Reason: ${error.failureReason ?? "unknown"}`); return this.transitionToNextPhase(state, "failed"); } @@ -198,7 +211,7 @@ export class AlfredpayOfframpTransferHandler extends BasePhaseHandler { } logger.debug(`AlfredpayOfframpTransferHandler: Alfredpay offramp ${transactionId} status: ${status}`); - } catch (error: any) { + } catch (error) { logger.warn(`AlfredpayOfframpTransferHandler: Error polling Alfredpay status for ${transactionId}: ${error}`); } diff --git a/apps/api/src/api/services/phases/handlers/alfredpay-onramp-mint-handler.ts b/apps/api/src/api/services/phases/handlers/alfredpay-onramp-mint-handler.ts index de4cc7bd0..2b93aa10f 100644 --- a/apps/api/src/api/services/phases/handlers/alfredpay-onramp-mint-handler.ts +++ b/apps/api/src/api/services/phases/handlers/alfredpay-onramp-mint-handler.ts @@ -19,6 +19,15 @@ const ALFREDPAY_ONRAMP_MINT_TIMEOUT_MS = 5 * 60 * 1000; const BALANCE_POLL_INTERVAL_MS = 5000; const ALFREDPAY_POLL_INTERVAL_MS = 5000; +type AlfredpayFailedStatusError = { + failureReason?: string; + kind: "failed"; +}; + +function isAlfredpayFailedStatusError(error: unknown): error is AlfredpayFailedStatusError { + return !!error && typeof error === "object" && "kind" in error && error.kind === "failed"; +} + export class AlfredpayOnrampMintHandler extends BasePhaseHandler { public getPhaseName(): RampPhase { return "alfredpayOnrampMint"; @@ -74,8 +83,8 @@ export class AlfredpayOnrampMintHandler extends BasePhaseHandler { // (it does NOT resolve on ON_CHAIN_COMPLETED, because we trust the balance check) try { await Promise.race([balanceCheckPromise, alfredpayPollingPromise]); - } catch (error: any) { - if (error?.kind === "failed") { + } catch (error) { + if (isAlfredpayFailedStatusError(error)) { logger.error(`AlfredpayOnrampMintHandler: Alfredpay onramp FAILED. Reason: ${error.failureReason ?? "unknown"}`); return this.transitionToNextPhase(state, "failed"); } @@ -147,8 +156,8 @@ export class AlfredpayOnrampMintHandler extends BasePhaseHandler { } return; } - } catch (error: any) { - if (error?.kind === "failed") { + } catch (error) { + if (isAlfredpayFailedStatusError(error)) { reject(error); return; } diff --git a/apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts b/apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts index 7b13b2e8c..c5ad18fea 100644 --- a/apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts +++ b/apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts @@ -16,6 +16,10 @@ import { PhaseError } from "../../../errors/phase-error"; import { BasePhaseHandler } from "../base-phase-handler"; import { StateMetadata } from "../meta-state-types"; +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler { public getPhaseName(): RampPhase { return "brlaPayoutOnBase"; @@ -66,7 +70,7 @@ export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler { const pollInterval = 5000; // 5 seconds const timeout = 5 * 60 * 1000; // 5 minutes const startTime = Date.now(); - let lastError: any; + let lastError: unknown; while (Date.now() - startTime < timeout) { try { @@ -225,7 +229,7 @@ export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler { const pollInterval = 5000; // 5 seconds const timeout = 5 * 60 * 1000; // 5 minutes const startTime = Date.now(); - let lastError: any; + let lastError: unknown; while (Date.now() - startTime < timeout) { try { @@ -252,7 +256,7 @@ export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler { if (lastError) { logger.error("BrlaPayoutOnBasePhaseHandler: Polling for ticket status timed out with an error: ", lastError); throw this.createUnrecoverableError( - `BrlaPayoutOnBasePhaseHandler: Polling for ticket status timed out with an error: ${lastError.message}` + `BrlaPayoutOnBasePhaseHandler: Polling for ticket status timed out with an error: ${getErrorMessage(lastError)}` ); } diff --git a/apps/api/src/api/services/phases/handlers/moonbeam-to-pendulum-xcm-handler.ts b/apps/api/src/api/services/phases/handlers/moonbeam-to-pendulum-xcm-handler.ts index ce78d71ac..1d044401f 100644 --- a/apps/api/src/api/services/phases/handlers/moonbeam-to-pendulum-xcm-handler.ts +++ b/apps/api/src/api/services/phases/handlers/moonbeam-to-pendulum-xcm-handler.ts @@ -32,7 +32,7 @@ export class MoonbeamToPendulumXcmPhaseHandler extends BasePhaseHandler { moonbeamNode = hasPreviousError ? await apiManager.getApiWithShuffling("moonbeam", state.id) : await apiManager.getApi("moonbeam"); - } catch (e) { + } catch { throw new RecoverablePhaseError( "MoonbeamToPendulumXcmPhaseHandler: All RPC options exhausted.", MINIMUM_WAIT_SECONDS_FOR_EXHAUSTION From 281d0175d22df8758dee33cd560ad38484872da7 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 17 Jun 2026 12:02:55 +0200 Subject: [PATCH 069/131] Remove quote engine non-null assertions --- .../quote/engines/discount/onramp-alfredpay.ts | 11 ++++++++--- .../services/quote/engines/discount/onramp.ts | 17 ++++++++++++----- .../quote/engines/fee/offramp-avenia.ts | 8 +++++--- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts b/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts index 54962943c..d6c71bacc 100644 --- a/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts +++ b/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts @@ -32,12 +32,17 @@ export class OnRampAlfredpayDiscountEngine extends BaseDiscountEngine { const targetDiscount = partner?.targetDiscount ?? 0; const maxSubsidy = partner?.maxSubsidy ?? 0; - const alfredpayMint = ctx.alfredpayMint!; + const alfredpayMint = ctx.alfredpayMint; + if (!alfredpayMint) { + throw new Error("OnRampAlfredpayDiscountEngine requires alfredpayMint to be defined"); + } const effectiveRate = alfredpayMint.outputAmountDecimal.div(alfredpayMint.inputAmountDecimal); - // biome-ignore lint/style/noNonNullAssertion: validated in validate() - const usdFees = ctx.fees!.usd!; + const usdFees = ctx.fees?.usd; + if (!usdFees) { + throw new Error("OnRampAlfredpayDiscountEngine requires fees.usd to be defined"); + } const feesToDeduct = new Big(usdFees.vortex).plus(usdFees.partnerMarkup); const finalOutput = ctx.evmToEvm?.outputAmountDecimal ?? alfredpayMint.outputAmountDecimal.minus(feesToDeduct); diff --git a/apps/api/src/api/services/quote/engines/discount/onramp.ts b/apps/api/src/api/services/quote/engines/discount/onramp.ts index 8b0d5b8a3..cdd2ef00d 100644 --- a/apps/api/src/api/services/quote/engines/discount/onramp.ts +++ b/apps/api/src/api/services/quote/engines/discount/onramp.ts @@ -167,11 +167,18 @@ export class OnRampDiscountEngine extends BaseDiscountEngine { // Determine which nabla swap we're using (Base EVM or Pendulum) const isBaseFlow = !!ctx.nablaSwapEvm; - const nablaSwap = ctx.nablaSwapEvm || ctx.nablaSwap!; - // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate - const oraclePrice = nablaSwap.oraclePrice!; - // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate - const usdFees = ctx.fees!.usd!; + const nablaSwap = ctx.nablaSwapEvm ?? ctx.nablaSwap; + if (!nablaSwap) { + throw new Error("OnRampDiscountEngine requires nablaSwap or nablaSwapEvm in context"); + } + const oraclePrice = nablaSwap.oraclePrice; + if (!oraclePrice) { + throw new Error("OnRampDiscountEngine requires oraclePrice in swap metadata"); + } + const usdFees = ctx.fees?.usd; + if (!usdFees) { + throw new Error("OnRampDiscountEngine requires fees.usd in context"); + } const { inputAmount, rampType } = ctx.request; diff --git a/apps/api/src/api/services/quote/engines/fee/offramp-avenia.ts b/apps/api/src/api/services/quote/engines/fee/offramp-avenia.ts index 153bd98d0..2f17fa459 100644 --- a/apps/api/src/api/services/quote/engines/fee/offramp-avenia.ts +++ b/apps/api/src/api/services/quote/engines/fee/offramp-avenia.ts @@ -16,9 +16,11 @@ export class OffRampFeeAveniaEngine extends BaseFeeEngine { } protected async compute(ctx: QuoteContext, anchorFee: string, feeCurrency: RampCurrency): Promise { - // biome-ignore lint/style/noNonNullAssertion: Context is validated in `validate` - const outputAmountOfframp = - ctx.nablaSwap?.outputAmountDecimal.toFixed(2, 0) ?? ctx.nablaSwapEvm!.outputAmountDecimal.toFixed(2, 0); + const swap = ctx.nablaSwap ?? ctx.nablaSwapEvm; + if (!swap) { + throw new Error("OffRampFeeAveniaEngine requires nablaSwap or nablaSwapEvm in context"); + } + const outputAmountOfframp = swap.outputAmountDecimal.toFixed(2, 0); const brlaApiService = BrlaApiService.getInstance(); const aveniaQuote = await brlaApiService.createPayOutQuote( From 795af9e1984bb03a3010d049c13a11bcd938ebd4 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 17 Jun 2026 12:03:26 +0200 Subject: [PATCH 070/131] Guard Alfredpay EVM transaction metadata --- .../offramp/routes/evm-to-alfredpay.ts | 7 +++++-- .../onramp/routes/alfredpay-to-evm.ts | 18 +++++++++++++----- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts b/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts index eec483684..42be242b2 100644 --- a/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts +++ b/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts @@ -249,7 +249,10 @@ export async function prepareEvmToAlfredpayOfframpTransactions({ fromNetwork === Networks.Polygon && inputTokenAddress.toLowerCase() === ALFREDPAY_ERC20_TOKEN.toLowerCase(); const publicClient = evmClientManager.getClient(fromNetwork); - const chainId = getNetworkId(fromNetwork)!; + const chainId = getNetworkId(fromNetwork); + if (chainId === undefined) { + throw new Error(`Unsupported EVM network for Alfredpay offramp: ${fromNetwork}`); + } // Probe EIP-2612 support: tokens that don't implement nonces() (e.g. USDT on Base) revert here. // Only treat contract-call failures as "no permit"; rethrow network/transport errors. @@ -367,7 +370,7 @@ export async function prepareEvmToAlfredpayOfframpTransactions({ const payloadTypedData: SignedTypedData = { domain: { - chainId: getNetworkId(fromNetwork)!, + chainId, name: "TokenRelayer", verifyingContract: relayerAddress, version: "1" diff --git a/apps/api/src/api/services/transactions/onramp/routes/alfredpay-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/alfredpay-to-evm.ts index 7c45ef6d6..5ea5e071b 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/alfredpay-to-evm.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/alfredpay-to-evm.ts @@ -29,6 +29,15 @@ import { preparePolygonCleanupApproval } from "../../polygon/cleanup"; import { addDestinationChainApprovalTransaction, addOnrampDestinationChainTransactions } from "../common/transactions"; import { AlfredpayOnrampTransactionParams, OnrampTransactionsWithMeta } from "../common/types"; +function getEthereumUsdcAddressForFallback(): `0x${string}` { + const ethereumUsdc = evmTokenConfig.ethereum.USDC; + if (!ethereumUsdc) { + throw new Error("Ethereum USDC token config is required for Alfredpay EVM onramp fallback swap"); + } + + return ethereumUsdc.erc20AddressSourceChain; +} + /** * Prepares all transactions for Alfredpay (USD) onramp to EVM chain. * This route handles: USD → Polygon (USDC/USDT) → EVM (final transfer) @@ -248,14 +257,13 @@ export async function prepareAlfredpayToEvmOnrampTransactions({ // Fallback swap depends on the EVM chain. For Ethereum, the bridged token is USDC. For the rest, it is axlUSDC. const destinationAxlUsdcDetails = getOnChainTokenDetailsOrDefault(toNetwork as Networks, EvmToken.AXLUSDC) as EvmTokenDetails; const bridgedTokenForFallback = - toNetwork === Networks.Ethereum - ? evmTokenConfig.ethereum.USDC!.erc20AddressSourceChain - : destinationAxlUsdcDetails.erc20AddressSourceChain; + toNetwork === Networks.Ethereum ? getEthereumUsdcAddressForFallback() : destinationAxlUsdcDetails.erc20AddressSourceChain; + const bridgedTokenAddress = bridgedTokenForFallback as `0x${string}`; const { approveData: destApproveData, swapData: destSwapData } = await createOnrampSquidrouterTransactionsOnDestinationChain({ destinationAddress: evmEphemeralEntry.address, fromAddress: evmEphemeralEntry.address, - fromToken: bridgedTokenForFallback, + fromToken: bridgedTokenAddress, network: toNetwork as EvmNetworks, rawAmount: multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals).toFixed(0, 0), toToken: (outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain @@ -287,7 +295,7 @@ export async function prepareAlfredpayToEvmOnrampTransactions({ amountRaw: maxUint256.toString(), destinationNetwork: toNetwork as EvmNetworks, spenderAddress: fundingAccount.address, - tokenAddress: bridgedTokenForFallback + tokenAddress: bridgedTokenAddress }); // We set this to the destinationTransfer nonce on purpose because we don't want to risk that the required nonce is never reached From 555d87a586cc409f682b490237bff530c9484029 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 17 Jun 2026 12:03:51 +0200 Subject: [PATCH 071/131] Type migration recovery helpers --- .../migrations/022-add-user-id-to-entities.ts | 15 ++++- .../023-create-alfredpay-customers-table.ts | 14 +++- .../029-create-mykobo-customers-table.ts | 12 +++- apps/api/src/database/migrator.ts | 66 +++++++++++-------- 4 files changed, 73 insertions(+), 34 deletions(-) diff --git a/apps/api/src/database/migrations/022-add-user-id-to-entities.ts b/apps/api/src/database/migrations/022-add-user-id-to-entities.ts index 576856ee7..3d4a004c2 100644 --- a/apps/api/src/database/migrations/022-add-user-id-to-entities.ts +++ b/apps/api/src/database/migrations/022-add-user-id-to-entities.ts @@ -1,5 +1,14 @@ import { DataTypes, QueryInterface } from "sequelize"; +function getDatabaseErrorCode(error: unknown): string | undefined { + if (!error || typeof error !== "object" || !("original" in error)) { + return undefined; + } + + const { original } = error as { original?: unknown }; + return original && typeof original === "object" && "code" in original ? String(original.code) : undefined; +} + export async function up(queryInterface: QueryInterface): Promise { // Add user_id to kyc_level_2 await queryInterface.addColumn("kyc_level_2", "user_id", { @@ -90,7 +99,7 @@ export async function down(queryInterface: QueryInterface): Promise { const safeRemoveIndex = async (tableName: string, indexName: string) => { try { await queryInterface.removeIndex(tableName, indexName); - } catch (error: any) { + } catch (error) { console.warn(`Failed to remove index ${indexName} from ${tableName}:`, error); } }; @@ -98,9 +107,9 @@ export async function down(queryInterface: QueryInterface): Promise { const safeRemoveColumn = async (tableName: string, columnName: string) => { try { await queryInterface.removeColumn(tableName, columnName); - } catch (error: any) { + } catch (error) { // Ignore undefined_column error (code 42703) - if (error?.original?.code === "42703") { + if (getDatabaseErrorCode(error) === "42703") { console.warn(`Column ${columnName} does not exist in ${tableName}, skipping removal.`); } else { throw error; diff --git a/apps/api/src/database/migrations/023-create-alfredpay-customers-table.ts b/apps/api/src/database/migrations/023-create-alfredpay-customers-table.ts index 22e734a10..6bf789b62 100644 --- a/apps/api/src/database/migrations/023-create-alfredpay-customers-table.ts +++ b/apps/api/src/database/migrations/023-create-alfredpay-customers-table.ts @@ -1,5 +1,13 @@ import { DataTypes, QueryInterface } from "sequelize"; +async function dropEnumType(queryInterface: QueryInterface, enumName: string): Promise { + try { + await queryInterface.sequelize.query(`DROP TYPE IF EXISTS "${enumName}";`); + } catch (error) { + console.warn(`Failed to drop enum ${enumName}:`, error); + } +} + export async function up(queryInterface: QueryInterface): Promise { await queryInterface.createTable("alfredpay_customers", { alfred_pay_id: { @@ -84,7 +92,7 @@ export async function down(queryInterface: QueryInterface): Promise { await queryInterface.dropTable("alfredpay_customers"); // Drop enums - await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_alfredpay_customers_country";').catch(() => {}); - await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_alfredpay_customers_status";').catch(() => {}); - await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_alfredpay_customers_type";').catch(() => {}); + await dropEnumType(queryInterface, "enum_alfredpay_customers_country"); + await dropEnumType(queryInterface, "enum_alfredpay_customers_status"); + await dropEnumType(queryInterface, "enum_alfredpay_customers_type"); } diff --git a/apps/api/src/database/migrations/029-create-mykobo-customers-table.ts b/apps/api/src/database/migrations/029-create-mykobo-customers-table.ts index 415aa966d..870109cd9 100644 --- a/apps/api/src/database/migrations/029-create-mykobo-customers-table.ts +++ b/apps/api/src/database/migrations/029-create-mykobo-customers-table.ts @@ -1,5 +1,13 @@ import { DataTypes, QueryInterface } from "sequelize"; +async function dropEnumType(queryInterface: QueryInterface, enumName: string): Promise { + try { + await queryInterface.sequelize.query(`DROP TYPE IF EXISTS "${enumName}";`); + } catch (error) { + console.warn(`Failed to drop enum ${enumName}:`, error); + } +} + export async function up(queryInterface: QueryInterface): Promise { await queryInterface.createTable("mykobo_customers", { created_at: { @@ -77,6 +85,6 @@ export async function down(queryInterface: QueryInterface): Promise { await queryInterface.dropTable("mykobo_customers"); - await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_mykobo_customers_status";').catch(() => {}); - await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_mykobo_customers_type";').catch(() => {}); + await dropEnumType(queryInterface, "enum_mykobo_customers_status"); + await dropEnumType(queryInterface, "enum_mykobo_customers_type"); } diff --git a/apps/api/src/database/migrator.ts b/apps/api/src/database/migrator.ts index ec719b136..74c262423 100644 --- a/apps/api/src/database/migrator.ts +++ b/apps/api/src/database/migrator.ts @@ -4,18 +4,34 @@ import { MigrationParams, SequelizeStorage, Umzug } from "umzug"; import sequelize from "../config/database"; import logger from "../config/logger"; +function getDatabaseErrorCode(error: unknown): string | undefined { + if (!error || typeof error !== "object" || !("original" in error)) { + return undefined; + } + + const { original } = error as { original?: unknown }; + return original && typeof original === "object" && "code" in original ? String(original.code) : undefined; +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + // Create Umzug instance for migrations const umzug = new Umzug({ context: new Proxy(sequelize.getQueryInterface(), { get(target, prop, receiver) { if (prop === "addIndex") { - return async (...args: any[]) => { + return async (...args: Parameters) => { try { - // @ts-ignore: dynamic args spreading return await target.addIndex(...args); - } catch (error: any) { - if (error?.original?.code === "42P07") { - const indexName = args[2]?.name || "unknown"; + } catch (error) { + if (getDatabaseErrorCode(error) === "42P07") { + const options = args[2] as unknown; + const indexName = + typeof options === "object" && options !== null && "name" in options + ? String((options as Record<"name", unknown>).name) + : "unknown"; const tableName = args[0]; logger.warn(`Index ${indexName} already exists on ${tableName}, skipping creation.`); return; @@ -25,26 +41,24 @@ const umzug = new Umzug({ }; } if (prop === "bulkInsert") { - return async (...args: any[]) => { + return async (...args: Parameters) => { try { - // @ts-ignore: dynamic args spreading return await target.bulkInsert(...args); - } catch (error: any) { + } catch (error) { // Swallow ALL bulkInsert errors to force migration forward in inconsistent environments // This is critical to unblock 022 when 001/004 etc are re-running on existing data const tableName = args[0]; - logger.warn(`Swallowing bulkInsert error on ${tableName}: ${error.message || error}`); + logger.warn(`Swallowing bulkInsert error on ${tableName}: ${getErrorMessage(error)}`); return 0; } }; } if (prop === "addColumn") { - return async (...args: any[]) => { + return async (...args: Parameters) => { try { - // @ts-ignore: dynamic args spreading return await target.addColumn(...args); - } catch (error: any) { - if (error?.original?.code === "42701") { + } catch (error) { + if (getDatabaseErrorCode(error) === "42701") { const columnName = args[1]; const tableName = args[0]; logger.warn(`Column ${columnName} already exists on ${tableName}, skipping creation.`); @@ -55,14 +69,14 @@ const umzug = new Umzug({ }; } if (prop === "renameColumn") { - return async (...args: any[]) => { + return async (...args: Parameters) => { try { - // @ts-ignore: dynamic args spreading return await target.renameColumn(...args); - } catch (error: any) { + } catch (error) { // 42701: duplicate_column (target column already exists) // 42703: undefined_column (source column does not exist) - if (error?.original?.code === "42701" || error?.original?.code === "42703") { + const code = getDatabaseErrorCode(error); + if (code === "42701" || code === "42703") { const tableName = args[0]; const oldName = args[1]; const newName = args[2]; @@ -74,14 +88,14 @@ const umzug = new Umzug({ }; } if (prop === "changeColumn") { - return async (...args: any[]) => { + return async (...args: Parameters) => { try { - // @ts-ignore: dynamic args spreading return await target.changeColumn(...args); - } catch (error: any) { + } catch (error) { // 42710: duplicate_object (constraint already exists) // 42P07: duplicate_table (relation/constraint already exists) - if (error?.original?.code === "42710" || error?.original?.code === "42P07") { + const code = getDatabaseErrorCode(error); + if (code === "42710" || code === "42P07") { const tableName = args[0]; const columnName = args[1]; logger.warn(`Change column ${columnName} on ${tableName} failed (likely constraint exists), skipping.`); @@ -96,19 +110,19 @@ const umzug = new Umzug({ return new Proxy(originalSequelize, { get(seqTarget, seqProp, seqReceiver) { if (seqProp === "query") { - return async (...args: any[]) => { + return async (...args: Parameters) => { try { - // @ts-ignore: dynamic args spreading return await seqTarget.query(...args); - } catch (error: any) { + } catch (error) { // 42710: duplicate_object (trigger/function already exists) // 42P07: duplicate_table (relation already exists) - if (error?.original?.code === "42710" || error?.original?.code === "42P07") { + const code = getDatabaseErrorCode(error); + if (code === "42710" || code === "42P07") { const sql = args[0] as string; // Try to extract object name from SQL for logging const match = sql.match(/CREATE (?:OR REPLACE )?(?:TRIGGER|FUNCTION|TABLE) ["']?(\w+)["']?/i); const objectName = match ? match[1] : "unknown object"; - logger.warn(`Query failed with "${error.message}" for ${objectName}, skipping.`); + logger.warn(`Query failed with "${getErrorMessage(error)}" for ${objectName}, skipping.`); return [[], 0]; } throw error; From 90ec612579817846dd3d828a30c4c3b837992089 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 17 Jun 2026 12:04:16 +0200 Subject: [PATCH 072/131] Log spreadsheet header lookup failures --- apps/api/src/api/services/spreadsheet.service.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/api/src/api/services/spreadsheet.service.ts b/apps/api/src/api/services/spreadsheet.service.ts index 4ce087ad0..3ff683296 100644 --- a/apps/api/src/api/services/spreadsheet.service.ts +++ b/apps/api/src/api/services/spreadsheet.service.ts @@ -1,5 +1,6 @@ import { JWT } from "google-auth-library"; import { GoogleSpreadsheet, GoogleSpreadsheetWorksheet } from "google-spreadsheet"; +import logger from "../../config/logger"; const SCOPES = ["https://www.googleapis.com/auth/spreadsheets"]; @@ -48,7 +49,9 @@ export const getOrCreateSheet = async (doc: GoogleSpreadsheet, headerValues: str if (doHeadersMatch(sheet.headerValues, headerValues)) { return sheet; } - } catch {} + } catch (error) { + logger.debug("Spreadsheet header check failed while searching for matching sheet", error); + } } // Create new sheet if no match found From f8d553835762b9d91fd53290695ab1ef55d6edb8 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 17 Jun 2026 12:04:51 +0200 Subject: [PATCH 073/131] Improve frontend component lint compliance --- apps/frontend/src/components/ComparisonSlider/index.tsx | 5 +++++ apps/frontend/src/components/Globe/index.tsx | 5 +++-- apps/frontend/src/components/Navbar/SolutionsDropdown.tsx | 2 +- .../components/menus/HistoryMenu/TransactionItem/index.tsx | 1 + .../components/widget-steps/SummaryStep/USOnrampDetails.tsx | 2 +- 5 files changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/frontend/src/components/ComparisonSlider/index.tsx b/apps/frontend/src/components/ComparisonSlider/index.tsx index 4e344240f..ff367f5cd 100644 --- a/apps/frontend/src/components/ComparisonSlider/index.tsx +++ b/apps/frontend/src/components/ComparisonSlider/index.tsx @@ -62,10 +62,15 @@ export const ComparisonSlider: React.FC = ({ return (
{beforeAlt} { useEffect(() => { if (!canvasRef.current || reducedMotion) return; + const draggingRef = isDraggingRef; let rafId: number; const globe = createGlobe(canvasRef.current, createGlobeConfig(size)); const tick = () => { - if (!isDraggingRef.current) { + if (!draggingRef.current) { phiRef.current += NORMAL_SPEED; thetaRef.current += VERTICAL_SPEED; } @@ -180,7 +181,7 @@ export const Globe = ({ className }: GlobeProps) => { globe.destroy(); cancelAnimationFrame(rafId); }; - }, [reducedMotion, size]); + }, [isDraggingRef, reducedMotion, size]); return (
+
{t("components.navbar.solutions")}
{isOpen && (
diff --git a/apps/frontend/src/components/menus/HistoryMenu/TransactionItem/index.tsx b/apps/frontend/src/components/menus/HistoryMenu/TransactionItem/index.tsx index dac497265..8af6af0ad 100644 --- a/apps/frontend/src/components/menus/HistoryMenu/TransactionItem/index.tsx +++ b/apps/frontend/src/components/menus/HistoryMenu/TransactionItem/index.tsx @@ -62,6 +62,7 @@ export const TransactionItem: FC = ({ transaction }) => { className="group flex items-center justify-between border-gray-200 border-b p-4 hover:bg-gray-50" onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} + role="listitem" >
diff --git a/apps/frontend/src/components/widget-steps/SummaryStep/USOnrampDetails.tsx b/apps/frontend/src/components/widget-steps/SummaryStep/USOnrampDetails.tsx index 162f5d36f..f1cdaa0e7 100644 --- a/apps/frontend/src/components/widget-steps/SummaryStep/USOnrampDetails.tsx +++ b/apps/frontend/src/components/widget-steps/SummaryStep/USOnrampDetails.tsx @@ -25,7 +25,7 @@ const displayValue = (value: unknown, fallbackValue: string): string => { export const USOnrampDetails: FC = () => { const { t } = useTranslation(); const rampActor = useRampActor(); - const { isQuoteExpired, rampState } = useSelector(rampActor, state => state.context); + const { rampState } = useSelector(rampActor, state => state.context); const achPaymentData = rampState?.ramp?.achPaymentData; if (!rampState?.ramp || !achPaymentData) return null; From c3452f59d13aa04a5773efa67107c2dac3891f9a Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 17 Jun 2026 12:05:15 +0200 Subject: [PATCH 074/131] Tighten frontend machine and service types --- apps/frontend/src/machines/alfredpayKyc.machine.ts | 7 ++++--- apps/frontend/src/services/api/brla.service.ts | 4 ++-- apps/frontend/src/services/api/polkadot.service.ts | 11 +++++++---- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/apps/frontend/src/machines/alfredpayKyc.machine.ts b/apps/frontend/src/machines/alfredpayKyc.machine.ts index c2b7ce0e4..58d2f9679 100644 --- a/apps/frontend/src/machines/alfredpayKyc.machine.ts +++ b/apps/frontend/src/machines/alfredpayKyc.machine.ts @@ -402,9 +402,10 @@ export const alfredpayKycMachine = setup({ { // No customer found → show CustomerDefinition for all countries (individual or business choice) guard: ({ event }) => { - const error = event.error as any; - const message = (error?.message || error?.toString() || "").toLowerCase(); - return error?.status === 404 || message.includes("404") || message.includes("not found"); + const error = event.error; + const errorRecord = error && typeof error === "object" ? (error as Record) : {}; + const message = String(errorRecord.message ?? error).toLowerCase(); + return errorRecord.status === 404 || message.includes("404") || message.includes("not found"); }, target: "CustomerDefinition" }, diff --git a/apps/frontend/src/services/api/brla.service.ts b/apps/frontend/src/services/api/brla.service.ts index 5d4f7df10..a62fc37b5 100644 --- a/apps/frontend/src/services/api/brla.service.ts +++ b/apps/frontend/src/services/api/brla.service.ts @@ -45,8 +45,8 @@ export class BrlaService { * @param quoteId * @returns An empty response **/ - static async recordInitialKycAttempt(taxId: string, quoteId: string, sessionId?: string): Promise<{}> { - return apiRequest<{}>("post", `${this.BASE_PATH}/kyc/record-attempt`, { quoteId, sessionId, taxId }); + static async recordInitialKycAttempt(taxId: string, quoteId: string, sessionId?: string): Promise> { + return apiRequest>("post", `${this.BASE_PATH}/kyc/record-attempt`, { quoteId, sessionId, taxId }); } /** * Get the KYC status of a subaccount diff --git a/apps/frontend/src/services/api/polkadot.service.ts b/apps/frontend/src/services/api/polkadot.service.ts index 15a5383d5..69d0a4715 100644 --- a/apps/frontend/src/services/api/polkadot.service.ts +++ b/apps/frontend/src/services/api/polkadot.service.ts @@ -76,11 +76,14 @@ class PolkadotApiService { if (!config.isSandbox && nodeName === NodeName.Paseo) { throw new Error("Paseo only available in sandbox mode"); } - if (!this.apiComponents.has(nodeName)) { - const promise = createApiComponents(nodeUrls[nodeName]); - this.apiComponents.set(nodeName, promise); + const existing = this.apiComponents.get(nodeName); + if (existing) { + return existing; } - return this.apiComponents.get(nodeName)!; + + const promise = createApiComponents(nodeUrls[nodeName]); + this.apiComponents.set(nodeName, promise); + return promise; } } From a33406edfc3e5b69ea045d6366aea080b836d502 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 17 Jun 2026 12:05:38 +0200 Subject: [PATCH 075/131] Type Storybook mocks --- .../src/stories/AveniaFormStep.stories.tsx | 8 +++++++- .../src/stories/AveniaLivenessStep.stories.tsx | 16 +++++++++------- .../stories/RampFollowUpRedirectStep.stories.tsx | 4 +++- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/apps/frontend/src/stories/AveniaFormStep.stories.tsx b/apps/frontend/src/stories/AveniaFormStep.stories.tsx index fb3814fce..5928cdf0e 100644 --- a/apps/frontend/src/stories/AveniaFormStep.stories.tsx +++ b/apps/frontend/src/stories/AveniaFormStep.stories.tsx @@ -3,7 +3,13 @@ import { FormProvider, useForm } from "react-hook-form"; import { AveniaFormStep } from "../components/widget-steps/AveniaFormStep"; // Wrapper to provide form context -const FormWrapper = ({ children, defaultValues }: { children: React.ReactNode; defaultValues?: any }) => { +type AveniaFormValues = { + pixId: string; + taxId: string; + walletAddress: string; +}; + +const FormWrapper = ({ children, defaultValues }: { children: React.ReactNode; defaultValues?: AveniaFormValues }) => { const methods = useForm({ defaultValues: defaultValues || { pixId: "", diff --git a/apps/frontend/src/stories/AveniaLivenessStep.stories.tsx b/apps/frontend/src/stories/AveniaLivenessStep.stories.tsx index 28a2989a4..10c9e33bb 100644 --- a/apps/frontend/src/stories/AveniaLivenessStep.stories.tsx +++ b/apps/frontend/src/stories/AveniaLivenessStep.stories.tsx @@ -1,9 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react"; import { AveniaLivenessStep } from "../components/widget-steps/AveniaLivenessStep"; +type AveniaLivenessStepProps = React.ComponentProps; + // Create mock Avenia KYC actor const createMockAveniaKycActor = (livenessCheckOpened = false) => ({ - send: (event: any) => { + send: (event: unknown) => { console.log("Avenia KYC event:", event); } }); @@ -48,8 +50,8 @@ type Story = StoryObj; export const BeforeLivenessCheck: Story = { args: { - aveniaKycActor: createMockAveniaKycActor(false) as any, - aveniaState: createMockAveniaState(false) as any + aveniaKycActor: createMockAveniaKycActor(false) as unknown as AveniaLivenessStepProps["aveniaKycActor"], + aveniaState: createMockAveniaState(false) as unknown as AveniaLivenessStepProps["aveniaState"] }, parameters: { docs: { @@ -62,8 +64,8 @@ export const BeforeLivenessCheck: Story = { export const AfterLivenessCheckOpened: Story = { args: { - aveniaKycActor: createMockAveniaKycActor(true) as any, - aveniaState: createMockAveniaState(true) as any + aveniaKycActor: createMockAveniaKycActor(true) as unknown as AveniaLivenessStepProps["aveniaKycActor"], + aveniaState: createMockAveniaState(true) as unknown as AveniaLivenessStepProps["aveniaState"] }, parameters: { docs: { @@ -77,8 +79,8 @@ export const AfterLivenessCheckOpened: Story = { export const WithoutLivenessUrl: Story = { args: { - aveniaKycActor: createMockAveniaKycActor(false) as any, - aveniaState: createMockAveniaState(false, "") as any + aveniaKycActor: createMockAveniaKycActor(false) as unknown as AveniaLivenessStepProps["aveniaKycActor"], + aveniaState: createMockAveniaState(false, "") as unknown as AveniaLivenessStepProps["aveniaState"] }, parameters: { docs: { diff --git a/apps/frontend/src/stories/RampFollowUpRedirectStep.stories.tsx b/apps/frontend/src/stories/RampFollowUpRedirectStep.stories.tsx index beb7999c5..e4b7688a6 100644 --- a/apps/frontend/src/stories/RampFollowUpRedirectStep.stories.tsx +++ b/apps/frontend/src/stories/RampFollowUpRedirectStep.stories.tsx @@ -2,6 +2,8 @@ import type { Meta, StoryObj } from "@storybook/react"; import { RampFollowUpRedirectStep } from "../components/widget-steps/RampFollowUpRedirectStep"; import { RampStateContext } from "../contexts/rampState"; +type RampStateProviderOptions = NonNullable["options"]>; + // Helper to create a complete snapshot const createSnapshot = (callbackUrl = "https://example.com/callback") => ({ children: {}, @@ -46,7 +48,7 @@ const meta: Meta = { const snapshot = createSnapshot(callbackUrl); return ( - +
From 6893cb44d2e98edf1518f15c3b072eeb64adcadf Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 17 Jun 2026 12:06:04 +0200 Subject: [PATCH 076/131] Tighten BRLA rebalancer lint types --- apps/rebalancer/src/rebalance/brla-to-axlusdc/index.ts | 9 ++++++--- apps/rebalancer/src/rebalance/brla-to-axlusdc/steps.ts | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/rebalancer/src/rebalance/brla-to-axlusdc/index.ts b/apps/rebalancer/src/rebalance/brla-to-axlusdc/index.ts index cb63e5774..057774f12 100644 --- a/apps/rebalancer/src/rebalance/brla-to-axlusdc/index.ts +++ b/apps/rebalancer/src/rebalance/brla-to-axlusdc/index.ts @@ -26,7 +26,11 @@ export async function rebalanceBrlaToUsdcAxl(amountAxlUsdc: string, forceRestart const isResuming = !forceRestart && state && state.currentPhase !== RebalancePhase.Idle; if (isResuming) { - console.log(`Resuming rebalance from phase: ${state!.currentPhase}`); + const currentState = state; + if (!currentState) { + throw new Error("State is undefined while resuming rebalance."); + } + console.log(`Resuming rebalance from phase: ${currentState.currentPhase}`); } else { // Forcing reset state, to ensure a clean one. state = await stateManager.startNewRebalance(amountAxlUsdc); @@ -106,8 +110,7 @@ export async function rebalanceBrlaToUsdcAxl(amountAxlUsdc: string, forceRestart const result = await transferUsdcToMoonbeamWithSquidrouter(usdcAmountRaw, pendulumAccount.address); const squidRouterReceiverId = result.squidRouterReceiverId; - const amountUsd = result.amountUsd; - console.log(`Swapped BRLA to USDC.axl on Polygon, receiver ID: ${squidRouterReceiverId}`); + console.log(`Swapped ${result.amountUsd} USDC.axl on Polygon, receiver ID: ${squidRouterReceiverId}`); state = { ...state, currentPhase: RebalancePhase.TriggerXcmFromMoonbeam, squidRouterReceiverId, usdcAmountRaw }; await stateManager.saveState(state); } diff --git a/apps/rebalancer/src/rebalance/brla-to-axlusdc/steps.ts b/apps/rebalancer/src/rebalance/brla-to-axlusdc/steps.ts index 4aa84cab0..3c6645b7b 100644 --- a/apps/rebalancer/src/rebalance/brla-to-axlusdc/steps.ts +++ b/apps/rebalancer/src/rebalance/brla-to-axlusdc/steps.ts @@ -338,7 +338,7 @@ export const pollForSufficientBalance = async (brlaAmountBig: Big) => { const pollInterval = 5000; // 5 seconds const timeout = 5 * 60 * 1000; // 5 minutes const startTime = Date.now(); - let lastError: any; + let lastError: unknown; const brlaAmountUnits = brlaAmountBig.toFixed(0, 0); const brlaApiService = BrlaApiService.getInstance(); From 1e65a629cf289fbe530b9a94bb28d5717a1e96c5 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 17 Jun 2026 12:06:33 +0200 Subject: [PATCH 077/131] Clean relayer script error handling --- .../relayer/test/relayer-execution-squid.ts | 17 ++--------------- contracts/relayer/test/relayer-execution.ts | 4 ++-- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/contracts/relayer/test/relayer-execution-squid.ts b/contracts/relayer/test/relayer-execution-squid.ts index 4afc4ab74..025c5a0fc 100644 --- a/contracts/relayer/test/relayer-execution-squid.ts +++ b/contracts/relayer/test/relayer-execution-squid.ts @@ -36,19 +36,6 @@ const erc20Abi = [ { inputs: [], name: "name", outputs: [{ name: "", type: "string" }], stateMutability: "view", type: "function" } ]; -const transferAbi = [ - { - inputs: [ - { name: "to", type: "address" }, - { name: "amount", type: "uint256" } - ], - name: "transfer", - outputs: [{ name: "", type: "bool" }], - stateMutability: "nonpayable", - type: "function" - } -]; - const tokenRelayerAbi = [ { inputs: [ @@ -290,8 +277,8 @@ async function main() { const receipt = await publicClient.waitForTransactionReceipt({ hash }); console.log("Transaction hash:", hash); console.log("Gas used:", receipt.gasUsed.toString()); - } catch (error: any) { - console.error("Execution failed:", error.message); + } catch (error) { + console.error("Execution failed:", error instanceof Error ? error.message : String(error)); return; } } diff --git a/contracts/relayer/test/relayer-execution.ts b/contracts/relayer/test/relayer-execution.ts index 375313b33..7299e6d1c 100644 --- a/contracts/relayer/test/relayer-execution.ts +++ b/contracts/relayer/test/relayer-execution.ts @@ -318,8 +318,8 @@ async function main() { const receipt = await publicClient.waitForTransactionReceipt({ hash }); console.log("Transaction hash:", hash); console.log("Gas used:", receipt.gasUsed.toString()); - } catch (error: any) { - console.error("Execution failed:", error.message); + } catch (error) { + console.error("Execution failed:", error instanceof Error ? error.message : String(error)); return; } From ab094859e43db2cbb4cbebbec7bea1f4179520fd Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 17 Jun 2026 12:06:51 +0200 Subject: [PATCH 078/131] Tighten SDK lint types --- packages/sdk/src/errors.ts | 13 +++++++++---- packages/sdk/src/handlers/BrlHandler.ts | 6 ++++-- packages/sdk/src/storage.ts | 8 ++++---- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts index cb7ede03d..40f4f03ce 100644 --- a/packages/sdk/src/errors.ts +++ b/packages/sdk/src/errors.ts @@ -343,9 +343,9 @@ function extractErrorMessage(value: unknown): string | undefined { /** * Error parsing utilities */ -export function parseAPIError(response: any): VortexSdkError { +export function parseAPIError(response: unknown): VortexSdkError { if (response && typeof response === "object") { - const { message, error, status, errors } = response; + const { message, error, status, errors } = response as Record; const normalizedStatus = typeof status === "number" ? status : 500; const errorMessage = extractErrorMessage(message) ?? extractErrorMessage(error); @@ -422,7 +422,12 @@ export function parseAPIError(response: any): VortexSdkError { } } - return new VortexSdkError(errorMessage ?? "Unknown API error", normalizedStatus, true, errors); + return new VortexSdkError( + errorMessage ?? "Unknown API error", + normalizedStatus, + true, + Array.isArray(errors) ? errors : undefined + ); } return new VortexSdkError("Unknown error occurred", 500); @@ -430,7 +435,7 @@ export function parseAPIError(response: any): VortexSdkError { export async function handleAPIResponse(response: Response, endpoint: string): Promise { if (!response.ok) { - let errorData: any; + let errorData: unknown; try { errorData = await response.json(); } catch { diff --git a/packages/sdk/src/handlers/BrlHandler.ts b/packages/sdk/src/handlers/BrlHandler.ts index 981a1d8b9..9e443c583 100644 --- a/packages/sdk/src/handlers/BrlHandler.ts +++ b/packages/sdk/src/handlers/BrlHandler.ts @@ -18,6 +18,8 @@ import type { VortexSdkContext } from "../types"; +type SignedTransactionPayload = UnsignedTx; + export class BrlHandler implements RampHandler { private apiService: ApiService; private context: VortexSdkContext; @@ -31,7 +33,7 @@ export class BrlHandler implements RampHandler { substrateEphemeral?: EphemeralAccount; evmEphemeral?: EphemeralAccount; } - ) => Promise; + ) => Promise; constructor( apiService: ApiService, @@ -46,7 +48,7 @@ export class BrlHandler implements RampHandler { substrateEphemeral?: EphemeralAccount; evmEphemeral?: EphemeralAccount; } - ) => Promise + ) => Promise ) { this.apiService = apiService; this.context = context; diff --git a/packages/sdk/src/storage.ts b/packages/sdk/src/storage.ts index 69caa74b7..7e609c70f 100644 --- a/packages/sdk/src/storage.ts +++ b/packages/sdk/src/storage.ts @@ -1,6 +1,6 @@ const isNode = typeof window === "undefined"; -async function storeEphemeralKeys(fileName: string, data: any): Promise { +async function storeEphemeralKeys(fileName: string, data: unknown): Promise { const content = JSON.stringify(data, null, 2); if (isNode) { const { writeFile } = await import("fs/promises"); @@ -10,14 +10,14 @@ async function storeEphemeralKeys(fileName: string, data: any): Promise { } } -async function retrieveEphemeralKeys(key: string): Promise { +async function retrieveEphemeralKeys(key: string): Promise { if (isNode) { try { const { readFile } = await import("fs/promises"); const data = await readFile(`${key}.json`, "utf-8"); return JSON.parse(data); - } catch (error: any) { - if (error.code === "ENOENT") { + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { return null; } throw error; From 7f18db92a5a7111cd128eadca4ce34ea8a8bbece Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 17 Jun 2026 12:07:19 +0200 Subject: [PATCH 079/131] Type shared package lint boundaries --- packages/shared/src/index.ts | 4 +--- packages/shared/src/services/nabla/contractRead.ts | 2 +- .../shared/src/services/xcm/moonbeamToAssethub.ts | 5 ++++- packages/shared/src/substrateEvents/xcmParsers.ts | 14 +++++++++++--- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 400810fc9..485696d89 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,6 +1,3 @@ -// biome-ignore assist/source/organizeImports: Tokens has to be imported first to ensure the types are available globally -export * from "./tokens"; - export * from "./constants"; export * from "./endpoints"; export * from "./endpoints/ramp.endpoints"; @@ -9,4 +6,5 @@ export * from "./helpers/environment"; export * from "./logger"; export * from "./services"; export * from "./substrateEvents"; +export * from "./tokens"; export * from "./types"; diff --git a/packages/shared/src/services/nabla/contractRead.ts b/packages/shared/src/services/nabla/contractRead.ts index a10a069c1..1d21f115a 100644 --- a/packages/shared/src/services/nabla/contractRead.ts +++ b/packages/shared/src/services/nabla/contractRead.ts @@ -8,7 +8,7 @@ const ALICE = "6mfqoTMHrMeVMyKwjqomUjVomPMJ4AjdCm1VReFtk7Be8wqr"; type MessageCallErrorResult = ReadMessageResult & { type: "error" | "panic" | "reverted" }; export async function contractRead(params: { - abi: any; + abi: ConstructorParameters[0]; address: string; method: string; args: unknown[]; diff --git a/packages/shared/src/services/xcm/moonbeamToAssethub.ts b/packages/shared/src/services/xcm/moonbeamToAssethub.ts index a682b29fe..be0e6674a 100644 --- a/packages/shared/src/services/xcm/moonbeamToAssethub.ts +++ b/packages/shared/src/services/xcm/moonbeamToAssethub.ts @@ -203,5 +203,8 @@ export async function createMoonbeamToAssethubTransferWithSwapOnHydration( const maxWeight = { proofSize: "2222220", refTime: "220000000000" }; - return moonbeamNode.api.tx.polkadotXcm.execute(xcmMessage as any, maxWeight); + return moonbeamNode.api.tx.polkadotXcm.execute( + xcmMessage as Parameters[0], + maxWeight + ); } diff --git a/packages/shared/src/substrateEvents/xcmParsers.ts b/packages/shared/src/substrateEvents/xcmParsers.ts index 4b8a6c469..d418a8559 100644 --- a/packages/shared/src/substrateEvents/xcmParsers.ts +++ b/packages/shared/src/substrateEvents/xcmParsers.ts @@ -4,8 +4,16 @@ import { encodeAddress } from "@polkadot/util-crypto"; export type XcmSentEvent = ReturnType; export type XTokensEvent = ReturnType; +type XcmSentJson = { + interior: { x1: [{ accountId32: { id: { toString: () => string } } }] }; +}; + +type MoonbeamXcmSentJson = { + interior: { x1: [{ accountKey20: { key: string } }] }; +}; + export function parseEventXcmSent({ event }: { event: Event }) { - const rawEventData = event.data.toJSON() as any[]; + const rawEventData = event.data.toJSON() as unknown as [XcmSentJson]; const mappedData = { originAddress: encodeAddress(rawEventData[0].interior.x1[0].accountId32.id.toString()) }; @@ -13,7 +21,7 @@ export function parseEventXcmSent({ event }: { event: Event }) { } export function parseEventMoonbeamXcmSent({ event }: { event: Event }) { - const rawEventData = event.data.toJSON() as any[]; + const rawEventData = event.data.toJSON() as unknown as [MoonbeamXcmSentJson]; const mappedData = { originAddress: rawEventData[0].interior.x1[0].accountKey20.key @@ -22,7 +30,7 @@ export function parseEventMoonbeamXcmSent({ event }: { event: Event }) { } export function parseEventXTokens({ event }: { event: Event }) { - const rawEventData = event.data.toJSON() as any[]; + const rawEventData = event.data.toJSON() as unknown as [{ toString: () => string }]; const mappedData = { sender: rawEventData[0].toString() }; From 1a97693246b107be7083812600e1e6cfa6105a05 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 17 Jun 2026 16:25:59 +0200 Subject: [PATCH 080/131] Restore Express request augmentation pattern --- apps/api/src/api/middlewares/auth.ts | 9 ++++++--- apps/api/src/api/middlewares/supabaseAuth.ts | 11 +++++++---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/api/src/api/middlewares/auth.ts b/apps/api/src/api/middlewares/auth.ts index 466027d11..3eef63062 100644 --- a/apps/api/src/api/middlewares/auth.ts +++ b/apps/api/src/api/middlewares/auth.ts @@ -3,9 +3,12 @@ import httpStatus from "http-status"; import logger from "../../config/logger"; import { validateSignatureAndGetMemo } from "../services/siwe.service"; -declare module "express" { - interface Request { - derivedMemo?: string | null; +declare global { + // biome-ignore lint/style/noNamespace: Express request augmentation follows the existing backend pattern. + namespace Express { + interface Request { + derivedMemo?: string | null; + } } } diff --git a/apps/api/src/api/middlewares/supabaseAuth.ts b/apps/api/src/api/middlewares/supabaseAuth.ts index 2e5bf38ee..c98c48175 100644 --- a/apps/api/src/api/middlewares/supabaseAuth.ts +++ b/apps/api/src/api/middlewares/supabaseAuth.ts @@ -2,10 +2,13 @@ import { NextFunction, Request, Response } from "express"; import logger from "../../config/logger"; import { SupabaseAuthService } from "../services/auth"; -declare module "express" { - interface Request { - userId?: string; - userEmail?: string; +declare global { + // biome-ignore lint/style/noNamespace: Express request augmentation follows the existing backend pattern. + namespace Express { + interface Request { + userId?: string; + userEmail?: string; + } } } From ad88c257bde01062c77cdeb18e596a0273a861f4 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 17 Jun 2026 16:26:30 +0200 Subject: [PATCH 081/131] Add keyboard support to comparison slider --- .../src/components/ComparisonSlider/index.tsx | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/apps/frontend/src/components/ComparisonSlider/index.tsx b/apps/frontend/src/components/ComparisonSlider/index.tsx index ff367f5cd..dfd5935ec 100644 --- a/apps/frontend/src/components/ComparisonSlider/index.tsx +++ b/apps/frontend/src/components/ComparisonSlider/index.tsx @@ -59,6 +59,32 @@ export const ComparisonSlider: React.FC = ({ const handleMouseDown = () => setIsDragging(true); const handleTouchStart = () => setIsDragging(true); + const updateSliderPosition = (delta: number) => { + setSliderPosition(position => Math.min(Math.max(position + delta, 0), 100)); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + switch (event.key) { + case "ArrowLeft": + case "ArrowDown": + event.preventDefault(); + updateSliderPosition(-5); + break; + case "ArrowRight": + case "ArrowUp": + event.preventDefault(); + updateSliderPosition(5); + break; + case "Home": + event.preventDefault(); + setSliderPosition(0); + break; + case "End": + event.preventDefault(); + setSliderPosition(100); + break; + } + }; return (
= ({ aria-valuemin={0} aria-valuenow={sliderPosition} className={`group relative h-full touch-none select-none overflow-hidden ${className}`} + onKeyDown={handleKeyDown} onMouseDown={handleMouseDown} onTouchStart={handleTouchStart} ref={containerRef} role="slider" + tabIndex={0} > {beforeAlt} Date: Wed, 17 Jun 2026 16:46:46 +0200 Subject: [PATCH 082/131] Scope Biome verification inputs --- .../src/database/migrations/021-create-users-table.ts | 2 +- apps/api/src/models/user.model.ts | 2 +- biome.json | 11 +++++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/api/src/database/migrations/021-create-users-table.ts b/apps/api/src/database/migrations/021-create-users-table.ts index be351032b..b3bfe1748 100644 --- a/apps/api/src/database/migrations/021-create-users-table.ts +++ b/apps/api/src/database/migrations/021-create-users-table.ts @@ -1,4 +1,4 @@ -import {DataTypes, QueryInterface} from "sequelize"; +import { DataTypes, QueryInterface } from "sequelize"; export async function up(queryInterface: QueryInterface): Promise { // Create users table diff --git a/apps/api/src/models/user.model.ts b/apps/api/src/models/user.model.ts index 92478cc2f..ef971917c 100644 --- a/apps/api/src/models/user.model.ts +++ b/apps/api/src/models/user.model.ts @@ -1,4 +1,4 @@ -import {DataTypes, Model, Optional} from "sequelize"; +import { DataTypes, Model, Optional } from "sequelize"; import sequelize from "../config/database"; export interface UserAttributes { diff --git a/biome.json b/biome.json index fac11da63..39d9d6c77 100644 --- a/biome.json +++ b/biome.json @@ -19,7 +19,13 @@ "!**/coverage/**", "!**/gql/**", "!**/lottie/**", + "!**/storybook-static/**", + "!contracts/relayer/artifacts/**", + "!contracts/relayer/cache/**", + "!contracts/relayer/ignition/deployments/**", "!contracts/relayer/typechain-types/**", + "!contracts/relayer/coverage.json", + "!docs/api/openapi/vortex.openapi.d.ts", "!**/*.test.ts", "!**/*.test.tsx", "!**/routeTree.gen.ts" @@ -114,5 +120,10 @@ "useNamespaceKeyword": "error" } } + }, + "vcs": { + "clientKind": "git", + "enabled": true, + "useIgnoreFile": true } } From 9715291cb0433c78527f5764e0588246a5c4c7d8 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 17 Jun 2026 16:47:06 +0200 Subject: [PATCH 083/131] Run Biome checks in CI --- .github/workflows/ci.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee511c08e..77d24ba5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,9 +34,8 @@ jobs: - name: Build run: bun run build + - name: 🧪 Biome check + run: bun run verify + - name: ✏️ Typecheck run: bun run typecheck - -# We currently do not run the linter as we have too many issues. -# - name: 🧪 Lint -# run: bun run lint From d68e728212f4dc897a412a45c86f3a2d0bcce98c Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 17 Jun 2026 17:18:21 +0200 Subject: [PATCH 084/131] Remove invalid frontend ARIA roles --- apps/frontend/src/components/Navbar/SolutionsDropdown.tsx | 3 ++- .../src/components/menus/HistoryMenu/TransactionItem/index.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/frontend/src/components/Navbar/SolutionsDropdown.tsx b/apps/frontend/src/components/Navbar/SolutionsDropdown.tsx index 12a882449..ef376aedd 100644 --- a/apps/frontend/src/components/Navbar/SolutionsDropdown.tsx +++ b/apps/frontend/src/components/Navbar/SolutionsDropdown.tsx @@ -15,7 +15,8 @@ export const SolutionsDropdown = ({ isOpen, onMouseEnter, onMouseLeave, submenuI const { t } = useTranslation(); return ( -
+ // biome-ignore lint/a11y/noStaticElementInteractions: Hover controls a visual dropdown; this is not a full ARIA menu. +
{t("components.navbar.solutions")}
{isOpen && (
diff --git a/apps/frontend/src/components/menus/HistoryMenu/TransactionItem/index.tsx b/apps/frontend/src/components/menus/HistoryMenu/TransactionItem/index.tsx index 8af6af0ad..3bae862de 100644 --- a/apps/frontend/src/components/menus/HistoryMenu/TransactionItem/index.tsx +++ b/apps/frontend/src/components/menus/HistoryMenu/TransactionItem/index.tsx @@ -58,11 +58,11 @@ export const TransactionItem: FC = ({ transaction }) => { const toIcon = useTokenIcon(transaction.toCurrency, toNetwork); return ( + // biome-ignore lint/a11y/noStaticElementInteractions: Hover only reveals visual status details; there is no list container here.
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} - role="listitem" >
From 429921fe59649133b2680babda616fff72d0cacf Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 17 Jun 2026 17:18:45 +0200 Subject: [PATCH 085/131] Clarify review feedback types --- apps/api/src/models/apiKey.model.ts | 2 +- packages/sdk/src/handlers/BrlHandler.ts | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/api/src/models/apiKey.model.ts b/apps/api/src/models/apiKey.model.ts index d665d5fbf..f4404ad0e 100644 --- a/apps/api/src/models/apiKey.model.ts +++ b/apps/api/src/models/apiKey.model.ts @@ -1,6 +1,6 @@ import { DataTypes, Model, Optional } from "sequelize"; import sequelize from "../config/database"; -import Partner from "./partner.model"; +import type Partner from "./partner.model"; // Define the attributes of the ApiKey model export interface ApiKeyAttributes { diff --git a/packages/sdk/src/handlers/BrlHandler.ts b/packages/sdk/src/handlers/BrlHandler.ts index 9e443c583..117a9f990 100644 --- a/packages/sdk/src/handlers/BrlHandler.ts +++ b/packages/sdk/src/handlers/BrlHandler.ts @@ -2,6 +2,7 @@ import { AccountMeta, EphemeralAccount, EphemeralAccountType, + PresignedTx, RampDirection, RampProcess, RegisterRampRequest, @@ -18,8 +19,6 @@ import type { VortexSdkContext } from "../types"; -type SignedTransactionPayload = UnsignedTx; - export class BrlHandler implements RampHandler { private apiService: ApiService; private context: VortexSdkContext; @@ -33,7 +32,7 @@ export class BrlHandler implements RampHandler { substrateEphemeral?: EphemeralAccount; evmEphemeral?: EphemeralAccount; } - ) => Promise; + ) => Promise; constructor( apiService: ApiService, @@ -48,7 +47,7 @@ export class BrlHandler implements RampHandler { substrateEphemeral?: EphemeralAccount; evmEphemeral?: EphemeralAccount; } - ) => Promise + ) => Promise ) { this.apiService = apiService; this.context = context; From 181f5149a3de797f88e7420b0c8a7b31d3ba5ef4 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 17 Jun 2026 17:41:22 +0200 Subject: [PATCH 086/131] Import React story types explicitly --- apps/frontend/src/stories/AveniaFormStep.stories.tsx | 3 ++- apps/frontend/src/stories/AveniaLivenessStep.stories.tsx | 3 ++- apps/frontend/src/stories/RampFollowUpRedirectStep.stories.tsx | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/frontend/src/stories/AveniaFormStep.stories.tsx b/apps/frontend/src/stories/AveniaFormStep.stories.tsx index 5928cdf0e..1763b58d7 100644 --- a/apps/frontend/src/stories/AveniaFormStep.stories.tsx +++ b/apps/frontend/src/stories/AveniaFormStep.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react"; +import type { ReactNode } from "react"; import { FormProvider, useForm } from "react-hook-form"; import { AveniaFormStep } from "../components/widget-steps/AveniaFormStep"; @@ -9,7 +10,7 @@ type AveniaFormValues = { walletAddress: string; }; -const FormWrapper = ({ children, defaultValues }: { children: React.ReactNode; defaultValues?: AveniaFormValues }) => { +const FormWrapper = ({ children, defaultValues }: { children: ReactNode; defaultValues?: AveniaFormValues }) => { const methods = useForm({ defaultValues: defaultValues || { pixId: "", diff --git a/apps/frontend/src/stories/AveniaLivenessStep.stories.tsx b/apps/frontend/src/stories/AveniaLivenessStep.stories.tsx index 10c9e33bb..ae1ad48c2 100644 --- a/apps/frontend/src/stories/AveniaLivenessStep.stories.tsx +++ b/apps/frontend/src/stories/AveniaLivenessStep.stories.tsx @@ -1,7 +1,8 @@ import type { Meta, StoryObj } from "@storybook/react"; +import type { ComponentProps } from "react"; import { AveniaLivenessStep } from "../components/widget-steps/AveniaLivenessStep"; -type AveniaLivenessStepProps = React.ComponentProps; +type AveniaLivenessStepProps = ComponentProps; // Create mock Avenia KYC actor const createMockAveniaKycActor = (livenessCheckOpened = false) => ({ diff --git a/apps/frontend/src/stories/RampFollowUpRedirectStep.stories.tsx b/apps/frontend/src/stories/RampFollowUpRedirectStep.stories.tsx index e4b7688a6..b698aa76e 100644 --- a/apps/frontend/src/stories/RampFollowUpRedirectStep.stories.tsx +++ b/apps/frontend/src/stories/RampFollowUpRedirectStep.stories.tsx @@ -1,8 +1,9 @@ import type { Meta, StoryObj } from "@storybook/react"; +import type { ComponentProps } from "react"; import { RampFollowUpRedirectStep } from "../components/widget-steps/RampFollowUpRedirectStep"; import { RampStateContext } from "../contexts/rampState"; -type RampStateProviderOptions = NonNullable["options"]>; +type RampStateProviderOptions = NonNullable["options"]>; // Helper to create a complete snapshot const createSnapshot = (callbackUrl = "https://example.com/callback") => ({ From 5e1034df7c1a6a74752080101a3ebe8c359e7d0c Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 17 Jun 2026 17:42:05 +0200 Subject: [PATCH 087/131] Include spreadsheet header error details --- apps/api/src/api/services/spreadsheet.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/api/src/api/services/spreadsheet.service.ts b/apps/api/src/api/services/spreadsheet.service.ts index 3ff683296..86798f8a3 100644 --- a/apps/api/src/api/services/spreadsheet.service.ts +++ b/apps/api/src/api/services/spreadsheet.service.ts @@ -50,7 +50,8 @@ export const getOrCreateSheet = async (doc: GoogleSpreadsheet, headerValues: str return sheet; } } catch (error) { - logger.debug("Spreadsheet header check failed while searching for matching sheet", error); + const message = error instanceof Error ? error.message : String(error); + logger.debug(`Spreadsheet header check failed while searching for matching sheet: ${message}`); } } From c865729c86c5f255a196e485745fd551e706ba55 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 17 Jun 2026 17:42:36 +0200 Subject: [PATCH 088/131] Add keyboard paths for hover UI --- .../components/Navbar/SolutionsDropdown.tsx | 47 +++++++++++++++++-- .../src/components/StatusBadge/index.tsx | 19 ++++++-- .../HistoryMenu/TransactionItem/index.tsx | 13 ++--- 3 files changed, 60 insertions(+), 19 deletions(-) diff --git a/apps/frontend/src/components/Navbar/SolutionsDropdown.tsx b/apps/frontend/src/components/Navbar/SolutionsDropdown.tsx index ef376aedd..1af9d1749 100644 --- a/apps/frontend/src/components/Navbar/SolutionsDropdown.tsx +++ b/apps/frontend/src/components/Navbar/SolutionsDropdown.tsx @@ -1,3 +1,4 @@ +import type { KeyboardEvent } from "react"; import { useTranslation } from "react-i18next"; import { SubmenuItem } from "./types"; @@ -14,15 +15,53 @@ const submenuButtonStyles = "block w-full cursor-pointer px-4 py-2 text-left tex export const SolutionsDropdown = ({ isOpen, onMouseEnter, onMouseLeave, submenuItems }: SolutionsDropdownProps) => { const { t } = useTranslation(); + const handleTriggerClick = () => { + if (isOpen) { + onMouseLeave(); + } else { + onMouseEnter(); + } + }; + + const handleTriggerKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + if (isOpen) { + onMouseLeave(); + } else { + onMouseEnter(); + } + } + if (event.key === "Escape") { + onMouseLeave(); + } + }; + return ( - // biome-ignore lint/a11y/noStaticElementInteractions: Hover controls a visual dropdown; this is not a full ARIA menu. -
-
{t("components.navbar.solutions")}
+
+ {isOpen && (
{submenuItems.map(item => ( - ))} diff --git a/apps/frontend/src/components/StatusBadge/index.tsx b/apps/frontend/src/components/StatusBadge/index.tsx index 6f4c20fd5..151ae9087 100644 --- a/apps/frontend/src/components/StatusBadge/index.tsx +++ b/apps/frontend/src/components/StatusBadge/index.tsx @@ -1,17 +1,17 @@ import { ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid"; import { TransactionStatus } from "@vortexfi/shared"; import { AnimatePresence, motion } from "motion/react"; -import { FC } from "react"; +import { FC, useState } from "react"; import { useTranslation } from "react-i18next"; import { cn } from "../../helpers/cn"; interface StatusBadgeProps { status: TransactionStatus; explorerLink?: string; - isHovered?: boolean; } -export const StatusBadge: FC = ({ status, explorerLink, isHovered = false }) => { +export const StatusBadge: FC = ({ status, explorerLink }) => { + const [showExplorerLink, setShowExplorerLink] = useState(false); const { t } = useTranslation(); const normalizedStatus = status.toLowerCase(); @@ -22,7 +22,7 @@ export const StatusBadge: FC = ({ status, explorerLink, isHove } as const; const colorClass = colors[normalizedStatus as keyof typeof colors] || colors.pending; - const showLink = isHovered && !!explorerLink; + const showLink = showExplorerLink && !!explorerLink; const badgeContent = ( = ({ status, explorerLink, isHove if (explorerLink) { return ( - e.stopPropagation()} rel="noopener noreferrer" target="_blank"> + setShowExplorerLink(false)} + onClick={e => e.stopPropagation()} + onFocus={() => setShowExplorerLink(true)} + onMouseEnter={() => setShowExplorerLink(true)} + onMouseLeave={() => setShowExplorerLink(false)} + rel="noopener noreferrer" + target="_blank" + > {badgeContent} ); diff --git a/apps/frontend/src/components/menus/HistoryMenu/TransactionItem/index.tsx b/apps/frontend/src/components/menus/HistoryMenu/TransactionItem/index.tsx index 3bae862de..ec22518c7 100644 --- a/apps/frontend/src/components/menus/HistoryMenu/TransactionItem/index.tsx +++ b/apps/frontend/src/components/menus/HistoryMenu/TransactionItem/index.tsx @@ -7,7 +7,7 @@ import { roundDownToSignificantDecimals } from "@vortexfi/shared"; import Big from "big.js"; -import { FC, useState } from "react"; +import { FC } from "react"; import { useTokenIcon } from "../../../../hooks/useTokenIcon"; import { StatusBadge } from "../../../StatusBadge"; import { TokenIconWithNetwork } from "../../../TokenIconWithNetwork"; @@ -48,8 +48,6 @@ const getNetworkName = (network: TransactionDestination) => { }; export const TransactionItem: FC = ({ transaction }) => { - const [isHovered, setIsHovered] = useState(false); - // Determine network for each currency (only on-chain tokens have networks) const fromNetwork = isNetwork(transaction.from) ? transaction.from : undefined; const toNetwork = isNetwork(transaction.to) ? transaction.to : undefined; @@ -58,12 +56,7 @@ export const TransactionItem: FC = ({ transaction }) => { const toIcon = useTokenIcon(transaction.toCurrency, toNetwork); return ( - // biome-ignore lint/a11y/noStaticElementInteractions: Hover only reveals visual status details; there is no list container here. -
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > +
@@ -99,7 +92,7 @@ export const TransactionItem: FC = ({ transaction }) => {
- +
{formatDate(transaction.date)} From 89e74c99236daf53acfd3b96658e5ee6e4cacbca Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 17 Jun 2026 18:27:28 +0200 Subject: [PATCH 089/131] Address PR #1227 review feedback --- .../api/controllers/alfredpay.controller.ts | 4 +++- apps/api/src/database/migrator.ts | 8 +++++++- .../components/Navbar/SolutionsDropdown.tsx | 19 ++++++++++--------- docs/security-spec/README.md | 1 + 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/apps/api/src/api/controllers/alfredpay.controller.ts b/apps/api/src/api/controllers/alfredpay.controller.ts index 550188df5..9ec108ff5 100644 --- a/apps/api/src/api/controllers/alfredpay.controller.ts +++ b/apps/api/src/api/controllers/alfredpay.controller.ts @@ -315,7 +315,9 @@ export class AlfredpayController { ? await alfredpayService.getKybStatus(alfredPayCustomer.alfredPayId, lastSubmission.submissionId) : await alfredpayService.getKycStatus(alfredPayCustomer.alfredPayId, lastSubmission.submissionId); - const newStatus = AlfredpayController.mapKycStatus(statusResponse.status); + const newStatus = isBusiness + ? AlfredpayController.mapKybStatus(statusResponse.status) + : AlfredpayController.mapKycStatus(statusResponse.status); const updateData: Partial = {}; if (newStatus && newStatus !== alfredPayCustomer.status) { diff --git a/apps/api/src/database/migrator.ts b/apps/api/src/database/migrator.ts index 74c262423..d57c6a971 100644 --- a/apps/api/src/database/migrator.ts +++ b/apps/api/src/database/migrator.ts @@ -17,6 +17,12 @@ function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +function getAddIndexOptions(args: readonly unknown[]): Record | undefined { + const options = args.length >= 3 ? args[2] : args[1]; + + return typeof options === "object" && options !== null ? (options as Record) : undefined; +} + // Create Umzug instance for migrations const umzug = new Umzug({ context: new Proxy(sequelize.getQueryInterface(), { @@ -27,7 +33,7 @@ const umzug = new Umzug({ return await target.addIndex(...args); } catch (error) { if (getDatabaseErrorCode(error) === "42P07") { - const options = args[2] as unknown; + const options = getAddIndexOptions(args); const indexName = typeof options === "object" && options !== null && "name" in options ? String((options as Record<"name", unknown>).name) diff --git a/apps/frontend/src/components/Navbar/SolutionsDropdown.tsx b/apps/frontend/src/components/Navbar/SolutionsDropdown.tsx index 1af9d1749..5e265c4e3 100644 --- a/apps/frontend/src/components/Navbar/SolutionsDropdown.tsx +++ b/apps/frontend/src/components/Navbar/SolutionsDropdown.tsx @@ -1,4 +1,4 @@ -import type { KeyboardEvent } from "react"; +import type { FocusEvent, KeyboardEvent } from "react"; import { useTranslation } from "react-i18next"; import { SubmenuItem } from "./types"; @@ -37,8 +37,15 @@ export const SolutionsDropdown = ({ isOpen, onMouseEnter, onMouseLeave, submenuI } }; + const handleBlur = (event: FocusEvent) => { + const nextFocusedElement = event.relatedTarget; + if (!nextFocusedElement || !event.currentTarget.contains(nextFocusedElement)) { + onMouseLeave(); + } + }; + return ( -
+
))} diff --git a/docs/security-spec/README.md b/docs/security-spec/README.md index 4a8ff3d14..3ed16ea92 100644 --- a/docs/security-spec/README.md +++ b/docs/security-spec/README.md @@ -29,6 +29,7 @@ This directory contains the security specification for the Vortex cross-border p | Fee Integrity | `03-ramp-engine/fee-integrity.md` | Fee calculation, dual-system discrepancy | | Discount Mechanism | `03-ramp-engine/discount-mechanism.md` | Partner discounts, subsidies, dynamic adjustment | | Profile Partner Pricing | `03-ramp-engine/profile-partner-pricing.md` | Supabase profile assignments to ramp-specific partner pricing IDs | +| FastForex | `05-integrations/fastforex.md` | USD-fiat conversion provider hardening and fallback | | Transaction Validation | `03-ramp-engine/transaction-validation.md` | Presigned tx verification, content validation, signing model | | Ephemeral Account Lifecycle | `03-ramp-engine/ephemeral-accounts.md` | Funding, cleanup, stuck fund prevention | | Ramp Phase Flows | `03-ramp-engine/ramp-phase-flows.md` | Per-corridor token flow, phase handler map, subsidy bounds | From ea75fec7263b99ce2671358f8823a37340060220 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 17 Jun 2026 18:46:27 +0200 Subject: [PATCH 090/131] Simplify addIndex option name check --- apps/api/src/database/migrator.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/api/src/database/migrator.ts b/apps/api/src/database/migrator.ts index d57c6a971..7c6afcd14 100644 --- a/apps/api/src/database/migrator.ts +++ b/apps/api/src/database/migrator.ts @@ -34,10 +34,7 @@ const umzug = new Umzug({ } catch (error) { if (getDatabaseErrorCode(error) === "42P07") { const options = getAddIndexOptions(args); - const indexName = - typeof options === "object" && options !== null && "name" in options - ? String((options as Record<"name", unknown>).name) - : "unknown"; + const indexName = options && "name" in options ? String(options.name) : "unknown"; const tableName = args[0]; logger.warn(`Index ${indexName} already exists on ${tableName}, skipping creation.`); return; From ac545a0f77eb7be3e7bd6274bde5cf6d4a0a0871 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 17 Jun 2026 18:47:38 +0200 Subject: [PATCH 091/131] Add fast forex spec document --- .../05-integrations/fastforex.md | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 docs/security-spec/05-integrations/fastforex.md diff --git a/docs/security-spec/05-integrations/fastforex.md b/docs/security-spec/05-integrations/fastforex.md new file mode 100644 index 000000000..a075bb50b --- /dev/null +++ b/docs/security-spec/05-integrations/fastforex.md @@ -0,0 +1,52 @@ +# FastForex Integration + +## What This Does + +FastForex is the primary fiat exchange-rate provider used by `PriceFeedService` for USD-to-fiat conversion in quote, fee, and subsidy math. Vortex calls FastForex for a single forex pair at a time and validates the returned rate before it can affect a quote or conversion. + +**Provider type:** Price provider +**Fiat currencies:** ARS, BRL, COP, EUR, MXN, USD +**Chains involved:** None directly — the rates feed quote/conversion math before on-chain transactions are built or subsidy caps are evaluated. +**Phase handlers:** No phase handler calls FastForex directly; handlers call `PriceFeedService.convertCurrency()` when they need USD-denominated caps or conversions. +**API auth method:** `X-API-Key` header from `FASTFOREX_API_KEY`. + +The API request shape is `GET {FASTFOREX_API_URL}/fetch-one?from=USD&to=`. The response is accepted only when `result[]` exists and is a positive finite rate. FastForex rates are sanity-checked against CoinGecko's USDC-to-fiat price when that reference is available. If FastForex is unavailable, missing, invalid, or outside the configured per-currency sanity band, Vortex falls back to CoinGecko. If FastForex returns a valid rate but CoinGecko is unavailable or invalid, Vortex logs the missing sanity check and accepts FastForex rather than making the fallback provider a hard dependency. If no valid provider remains, the conversion fails closed. + +## Security Invariants + +1. **FastForex credentials MUST be stored as environment variables** — `FASTFOREX_API_KEY` is loaded at startup and sent only in the `X-API-Key` header. It must never appear in source code, query strings, logs, database rows, or API responses. +2. **FastForex endpoint configuration MUST be treated as integrity-sensitive** — `FASTFOREX_API_URL` is not a secret, but a malicious or mistaken value can redirect quote-time forex lookups. Production deployments should pin it to the expected HTTPS FastForex API origin. +3. **FastForex MUST only be used for fiat forex rates** — Callers must request USD-to-fiat rates for supported fiat currencies. Non-fiat targets must be rejected before any provider call. +4. **FastForex responses MUST be validated before use** — The API response must be `2xx`, contain `result[targetCurrency]`, and the rate must be finite and greater than zero. +5. **FastForex rates SHOULD be sanity-checked against an independent reference when available** — A returned rate is compared with CoinGecko's USDC-to-fiat reference when CoinGecko returns a valid positive rate, and rejected when the spread exceeds the configured per-currency limit. CoinGecko outages or invalid reference rates must not make CoinGecko a hard dependency for otherwise valid FastForex rates. +6. **USDC-as-USD fallback risk MUST be understood operationally** — CoinGecko fallback and sanity checks use `usd-coin` as the USD proxy. During a USDC depeg, the fallback/reference may no longer represent real USD fiat FX, so operators should monitor quote availability and rate divergence. +7. **Provider failures MUST fail over safely** — FastForex HTTP errors, invalid responses, missing API key, or sanity-band failures may fall back to CoinGecko. If FastForex is valid but CoinGecko cannot provide the sanity reference, FastForex remains usable with a warning. If no valid provider remains, the quote/conversion path must fail closed rather than returning the original amount or proceeding with an invalid rate. +8. **FastForex rates MUST be cached only briefly** — Accepted fiat rates may be cached for `FIAT_CACHE_TTL_MS`, but stale cache entries must not be used past their expiry. +9. **FastForex MUST NOT be treated as an executable settlement authority** — It only informs pricing math. Actual swap outputs still come from Nabla or Squid, and stored quote amounts remain immutable once created. + +## Threat Vectors & Mitigations + +| Threat | Attack Scenario | Mitigation | +|---|---|---| +| **FastForex API key leak** | Attacker obtains `FASTFOREX_API_KEY` from environment or logs and uses the quota or account. | Key is loaded from env and sent in a header; no source-code secret. Rotate through FastForex if exposed. | +| **Endpoint tampering** | `FASTFOREX_API_URL` is pointed at an attacker-controlled endpoint returning favorable rates. | Treat URL config as integrity-sensitive production configuration; when CoinGecko is available, response must pass sanity-band validation before use. | +| **Manipulated forex rate** | Provider returns an incorrect USD-to-fiat rate that would distort quote output, fee conversion, or subsidy cap checks. | Rate must be positive and, when CoinGecko is available, within the configured spread limit. Out-of-band values fall back to CoinGecko. | +| **USDC depeg distorts fallback/reference** | CoinGecko's `usd-coin` fiat price diverges from true USD fiat FX, causing healthy FastForex rates to fail the sanity band or fallback rates to misprice quotes. | Treat the fallback as an emergency USD proxy, monitor spread warnings and missing-sanity-check warnings, and prefer valid FastForex when CoinGecko is unavailable. | +| **FastForex outage** | FastForex is down or returns non-2xx responses during quote creation. | Vortex logs a warning and falls back to CoinGecko. If both providers fail, quote/conversion fails closed. | +| **Stale fiat rates** | A cached forex rate persists long enough to misprice volatile fiat corridors. | Fiat cache uses `FIAT_CACHE_TTL_MS`; expired entries trigger a fresh provider lookup. | +| **Secret leakage through query params** | API key is accidentally placed in the FastForex URL where proxies can log it. | Implementation sends `FASTFOREX_API_KEY` in the `X-API-Key` header, not the query string. | + +## Audit Checklist + +- [x] FastForex API credential is loaded from environment variables. **PASS** — `config.priceProviders.fastforex.apiKey` reads `FASTFOREX_API_KEY`. +- [x] FastForex API key is sent in the `X-API-Key` header, not in query parameters. **PASS** — `getFastforexRate()` sets the header only when a key is configured. +- [x] FastForex URL is configurable but defaults to HTTPS. **PASS** — `FASTFOREX_API_URL` defaults to `https://api.fastforex.io`. +- [x] Non-fiat targets are rejected before fetching. **PASS** — `getUsdToFiatExchangeRate()` checks `isFiatToken(targetCurrency)`. +- [x] USD target returns `1` without calling external providers. **PASS** — `getUsdToFiatExchangeRate("USD")` short-circuits. +- [x] FastForex response status and rate are validated. **PASS** — non-OK responses throw; missing, zero, or negative rates throw. +- [x] FastForex rates are sanity-checked against CoinGecko when the reference is available. **PASS** — `assertFastforexRateWithinSanityBand()` compares the spread with per-currency limits when CoinGecko returns a valid reference; otherwise it warns and accepts the valid FastForex rate. +- [x] FastForex failures fall back to CoinGecko. **PASS** — failures are caught and logged before requesting the CoinGecko fallback. +- [x] CoinGecko fallback/reference uses USDC as the USD proxy. **PASS / OPERATIONAL RISK** — accepted by current code, but operators should monitor depeg conditions because this is not a pure fiat FX reference. +- [x] Both-provider failure fails closed. **PASS** — `convertCurrency()` rethrows provider failures instead of returning the original amount. +- [x] Accepted fiat rates use the configured short cache TTL. **PASS** — `fiatExchangeRateCache` entries expire after `FIAT_CACHE_TTL_MS`. +- [x] No FastForex secret is logged. **PASS** — logs include provider URL and error context, not `FASTFOREX_API_KEY`. From 0908261f6428d6e444f27180a9f21a9907b5db19 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 17 Jun 2026 18:57:16 +0200 Subject: [PATCH 092/131] Handle missing users and poll errors --- apps/api/src/api/services/auth/supabase.service.ts | 4 ++++ .../phases/handlers/alfredpay-offramp-transfer-handler.ts | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/api/src/api/services/auth/supabase.service.ts b/apps/api/src/api/services/auth/supabase.service.ts index 6da20804e..66e0d5710 100644 --- a/apps/api/src/api/services/auth/supabase.service.ts +++ b/apps/api/src/api/services/auth/supabase.service.ts @@ -190,6 +190,10 @@ export class SupabaseAuthService { throw error; } + if (!data.user) { + throw new Error(`Supabase user ${userId} not found`); + } + return data.user; } } diff --git a/apps/api/src/api/services/phases/handlers/alfredpay-offramp-transfer-handler.ts b/apps/api/src/api/services/phases/handlers/alfredpay-offramp-transfer-handler.ts index dfbfd475f..d4ef4dcc1 100644 --- a/apps/api/src/api/services/phases/handlers/alfredpay-offramp-transfer-handler.ts +++ b/apps/api/src/api/services/phases/handlers/alfredpay-offramp-transfer-handler.ts @@ -212,7 +212,9 @@ export class AlfredpayOfframpTransferHandler extends BasePhaseHandler { logger.debug(`AlfredpayOfframpTransferHandler: Alfredpay offramp ${transactionId} status: ${status}`); } catch (error) { - logger.warn(`AlfredpayOfframpTransferHandler: Error polling Alfredpay status for ${transactionId}: ${error}`); + logger.warn( + `AlfredpayOfframpTransferHandler: Error polling Alfredpay status for ${transactionId}: ${error instanceof Error ? error.message : String(error)}` + ); } setTimeout(poll, intervalMs); From cf9b8bb5cb70d8d71e4e556be023a5f13eef573d Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 17 Jun 2026 20:15:36 +0200 Subject: [PATCH 093/131] Implement opportunistic rebalancing logic and fallback route evaluation --- apps/rebalancer/.env.example | 3 +- apps/rebalancer/src/index.ts | 79 +++++++++++++------ .../usdc-brla-usdc-base/guards.test.ts | 42 ++++++++++ .../rebalance/usdc-brla-usdc-base/guards.ts | 47 +++++++++++ .../rebalance/usdc-brla-usdc-base/index.ts | 23 ++++++ .../usdc-brla-usdc-base/notifications.ts | 4 +- .../security-spec/07-operations/rebalancer.md | 25 +++--- docs/security-spec/README.md | 2 +- 8 files changed, 189 insertions(+), 36 deletions(-) diff --git a/apps/rebalancer/.env.example b/apps/rebalancer/.env.example index 4bfdbd012..9f5fa5e34 100644 --- a/apps/rebalancer/.env.example +++ b/apps/rebalancer/.env.example @@ -24,7 +24,8 @@ REBALANCING_DAILY_BRIDGE_LIMIT_USD=10000 # REBALANCING_THRESHOLD_USDC_TO_BRLA=0.01 # Cost-aware rebalancing policy. Modes: auto, dry-run, off, always. -# Daily bridge limit is still enforced in every mode. The hard max cost cap is also enforced in always mode. +# Daily bridge limit blocks non-profitable runs in every executing mode; projected-profitable quotes may bypass it. +# The hard max cost cap is also enforced in always mode. # REBALANCING_POLICY_MODE=auto # REBALANCING_MODERATE_DEVIATION_BPS=200 # REBALANCING_SEVERE_DEVIATION_BPS=500 diff --git a/apps/rebalancer/src/index.ts b/apps/rebalancer/src/index.ts index fc6967532..c548d0242 100644 --- a/apps/rebalancer/src/index.ts +++ b/apps/rebalancer/src/index.ts @@ -11,7 +11,9 @@ import { evaluateDailyBridgeLimit, evaluateRebalancingCostPolicy, isProjectedProfit, - type RebalancingCostPolicyDecision + OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS, + type RebalancingCostPolicyDecision, + shouldTriggerOpportunisticUsdcToBrla } from "./rebalance/usdc-brla-usdc-base/guards.ts"; import { checkInitialUsdcBalanceOnBase, compareRoutesUpfront } from "./rebalance/usdc-brla-usdc-base/steps.ts"; import { getBaseNablaCoverageRatio, getSwapPoolsWithCoverageRatio } from "./services/indexer"; @@ -198,6 +200,53 @@ async function evaluateUsdcToBrlaPolicy( }; } +async function executeUsdcToBrlaRebalance( + amountUsdcRaw: string, + bridgedToday: Big, + dailyLimitRaw: Big, + coverageDeviationBps: number, + policyDecision: Awaited>, + options: { opportunistic?: boolean } = {} +) { + const config = getConfig(); + const dailyLimitDecision = evaluateDailyBridgeLimit( + bridgedToday, + Big(amountUsdcRaw), + dailyLimitRaw, + policyDecision.profitable + ); + logDailyLimitDecision(dailyLimitDecision, config.rebalancingDailyBridgeLimitUsd); + if (dailyLimitDecision.shouldSkip) return; + + await checkInitialUsdcBalanceOnBase(amountUsdcRaw); + await rebalanceUsdcBrlaUsdcBase(amountUsdcRaw, forceRestart, policyDecision.routeToRun, { + config: config.rebalancingCostPolicy, + dailyLimitDecision, + decision: policyDecision.decision, + deviationBps: coverageDeviationBps, + opportunistic: options.opportunistic + }); +} + +async function tryOpportunisticUsdcToBrla(bridgedToday: Big, dailyLimitRaw: Big): Promise { + const config = getConfig(); + const amountUsdc = manualAmount || config.rebalancingUsdToBrlAmount; + const amountUsdcRaw = multiplyByPowerOfTen(new Big(amountUsdc), 6).toFixed(0, 0); + const policyDecision = await evaluateUsdcToBrlaPolicy(amountUsdcRaw, 0); + + if (!policyDecision.shouldExecute) return false; + if (!shouldTriggerOpportunisticUsdcToBrla(policyDecision.decision.costBps)) { + console.log( + `No opportunistic USDC->BRLA rebalance: projected cost ${policyDecision.decision.costBps} bps >= ${OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS} bps.` + ); + return false; + } + + console.log(`Opportunistic USDC->BRLA rebalance triggered at ${policyDecision.decision.costBps} bps projected cost.`); + await executeUsdcToBrlaRebalance(amountUsdcRaw, bridgedToday, dailyLimitRaw, 0, policyDecision, { opportunistic: true }); + return true; +} + async function evaluateBrlaToUsdcPolicy( amountUsdcRaw: string, coverageDeviationBps: number @@ -242,22 +291,7 @@ async function runUsdcToBrla(bridgedToday: Big, dailyLimitRaw: Big, coverageDevi if (!isResuming) { const policyDecision = await evaluateUsdcToBrlaPolicy(amountUsdcRaw, coverageDeviationBps); if (!policyDecision.shouldExecute) return; - - const dailyLimitDecision = evaluateDailyBridgeLimit( - bridgedToday, - Big(amountUsdcRaw), - dailyLimitRaw, - policyDecision.profitable - ); - logDailyLimitDecision(dailyLimitDecision, config.rebalancingDailyBridgeLimitUsd); - if (dailyLimitDecision.shouldSkip) return; - - await checkInitialUsdcBalanceOnBase(amountUsdcRaw); - await rebalanceUsdcBrlaUsdcBase(amountUsdcRaw, forceRestart, policyDecision.routeToRun, { - config: config.rebalancingCostPolicy, - decision: policyDecision.decision, - deviationBps: coverageDeviationBps - }); + await executeUsdcToBrlaRebalance(amountUsdcRaw, bridgedToday, dailyLimitRaw, coverageDeviationBps, policyDecision); return; } @@ -312,17 +346,18 @@ async function checkForRebalancing() { const lowerBound = 1 - config.rebalancingThresholdBrlaToUsdc; const upperBound = 1 + config.rebalancingThresholdUsdcToBrla; - if (coverage.brlaCoverageRatio >= lowerBound && coverage.brlaCoverageRatio <= upperBound) { - console.log(`BRLA coverage ${coverage.brlaCoverageRatio} in range [${lowerBound}, ${upperBound}]. No rebalancing needed.`); - return; - } - const bridgedToday = await getTodayBridgedUsdRaw(); const dailyLimitRaw = multiplyByPowerOfTen(Big(config.rebalancingDailyBridgeLimitUsd), 6); console.log( `Bridged $${bridgedToday.div(1e6).toFixed(2)} today. Daily bridge limit is $${config.rebalancingDailyBridgeLimitUsd}.` ); + if (coverage.brlaCoverageRatio >= lowerBound && coverage.brlaCoverageRatio <= upperBound) { + if (await tryOpportunisticUsdcToBrla(bridgedToday, dailyLimitRaw)) 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( diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.test.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.test.ts index d273f9b9e..b45a01290 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.test.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.test.ts @@ -5,10 +5,13 @@ import { calculateProjectedCostBps, calculateTargetBalanceRaw, evaluateDailyBridgeLimit, + evaluateFallbackRoutePolicy, evaluateRebalancingCostPolicy, getRebalancingUrgencyBand, isProjectedProfit, + OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS, type RebalancingCostPolicyConfig, + shouldTriggerOpportunisticUsdcToBrla, wouldExceedDailyBridgeLimit } from "./guards.ts"; @@ -66,6 +69,45 @@ describe("USDC Base rebalance guards", () => { expect(calculateProjectedCostBps(Big("100000000"), Big("101000000"))).toBe(-100); }); + test("triggers opportunistic USDC to BRLA rebalances only below the route cost cap", () => { + expect(shouldTriggerOpportunisticUsdcToBrla(-1)).toBe(true); + expect(shouldTriggerOpportunisticUsdcToBrla(OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS - 0.01)).toBe(true); + expect(shouldTriggerOpportunisticUsdcToBrla(OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS)).toBe(false); + }); + + test("allows fallback routes only when they satisfy opportunistic policy checks", () => { + expect( + evaluateFallbackRoutePolicy(Big("100000000"), Big("99910000"), 0, policyConfig, { + requireOpportunisticCost: true, + requireProfit: false + }).shouldExecute + ).toBe(true); + + expect( + evaluateFallbackRoutePolicy(Big("100000000"), Big("99900000"), 0, policyConfig, { + requireOpportunisticCost: true, + requireProfit: false + }).shouldExecute + ).toBe(false); + }); + + test("requires fallback route profit when the original route bypassed the daily limit as profitable", () => { + expect( + evaluateFallbackRoutePolicy(Big("100000000"), Big("100100000"), 0, policyConfig, { + requireOpportunisticCost: true, + requireProfit: true + }).shouldExecute + ).toBe(true); + + const decision = evaluateFallbackRoutePolicy(Big("100000000"), Big("99910000"), 0, policyConfig, { + requireOpportunisticCost: true, + requireProfit: true + }); + + expect(decision.shouldExecute).toBe(false); + expect(decision.reason).toContain("not profitable"); + }); + test("selects urgency bands from coverage deviation", () => { expect(getRebalancingUrgencyBand(50, policyConfig)).toBe("mild"); expect(getRebalancingUrgencyBand(200, policyConfig)).toBe("moderate"); diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.ts index cb5d3f1a3..8c63f54c7 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.ts @@ -1,6 +1,7 @@ import Big from "big.js"; export const DEFAULT_ARRIVAL_TOLERANCE = "0.998"; +export const OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS = 10; export type RebalancingPolicyMode = "auto" | "always" | "dry-run" | "off"; export type RebalancingUrgencyBand = "mild" | "moderate" | "severe"; @@ -31,6 +32,13 @@ export interface DailyBridgeLimitDecision { shouldSkip: boolean; } +export interface FallbackRoutePolicyDecision { + decision: RebalancingCostPolicyDecision; + profitable: boolean; + reason: string; + shouldExecute: boolean; +} + export function calculateMinimumDelta(expectedDelta: Big, tolerance = DEFAULT_ARRIVAL_TOLERANCE): Big { return expectedDelta.mul(tolerance); } @@ -73,6 +81,45 @@ export function calculateProjectedCostBps(inputAmountRaw: Big, projectedOutputRa return Number(inputAmountRaw.minus(projectedOutputRaw).div(inputAmountRaw).mul(10_000).toFixed(2)); } +export function shouldTriggerOpportunisticUsdcToBrla(costBps: number): boolean { + return costBps < OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS; +} + +export function evaluateFallbackRoutePolicy( + inputAmountRaw: Big, + fallbackOutputRaw: Big, + deviationBps: number, + config: RebalancingCostPolicyConfig, + options: { requireOpportunisticCost: boolean; requireProfit: boolean } +): FallbackRoutePolicyDecision { + const decision = evaluateRebalancingCostPolicy(inputAmountRaw, fallbackOutputRaw, deviationBps, config); + const profitable = isProjectedProfit(inputAmountRaw, fallbackOutputRaw); + + if (!decision.shouldExecute) { + return { decision, profitable, reason: decision.reason, shouldExecute: false }; + } + + if (options.requireOpportunisticCost && !shouldTriggerOpportunisticUsdcToBrla(decision.costBps)) { + return { + decision, + profitable, + reason: `Fallback route cost ${decision.costBps} bps is not below opportunistic cap ${OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS} bps.`, + shouldExecute: false + }; + } + + if (options.requireProfit && !profitable) { + return { + decision, + profitable, + reason: "Fallback route is not profitable, but the original route bypassed the daily bridge limit as profitable.", + shouldExecute: false + }; + } + + return { decision, profitable, reason: "Fallback route satisfies the required policy checks.", shouldExecute: true }; +} + export function getRebalancingUrgencyBand( deviationBps: number, config: Pick diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts index 47660412a..aaa40fdaf 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts @@ -4,6 +4,7 @@ import { UsdcBaseRebalancePhase, UsdcBaseStateManager, usdcBasePhaseOrder } from import { checkTicketStatusPaid } from "../../utils/brla.ts"; import { getBaseEvmClients, getConfig, getPolygonEvmClients } from "../../utils/config.ts"; import { NonceManager } from "../../utils/nonce.ts"; +import { evaluateFallbackRoutePolicy } from "./guards.ts"; import { formatBaseRebalanceCompletionMessage, type RebalancePolicySummary } from "./notifications.ts"; import { aveniaCreateSwapToUsdcBaseTicket, @@ -177,6 +178,28 @@ export async function rebalanceUsdcBrlaUsdcBase( await stateManager.saveState(state); } catch (error) { console.error("Avenia swap ticket creation failed. Falling back to SquidRouter route.", error); + if (policy?.opportunistic) { + if (!state.usdcAmountRaw) + throw new Error("State corrupted: usdcAmountRaw missing for opportunistic fallback check"); + if (!state.squidRouterQuoteUsdc) { + throw new Error("Opportunistic Avenia fallback blocked: SquidRouter was not quoted before execution."); + } + + const fallbackPolicy = evaluateFallbackRoutePolicy( + Big(state.usdcAmountRaw), + Big(state.squidRouterQuoteUsdc), + policy.deviationBps ?? 0, + policy.config, + { + requireOpportunisticCost: true, + requireProfit: policy.dailyLimitDecision?.reason === "profitable_quote" + } + ); + if (!fallbackPolicy.shouldExecute) { + throw new Error(`Opportunistic Avenia fallback blocked: ${fallbackPolicy.reason}`); + } + } + state.winningRoute = "squidrouter"; state.currentPhase = UsdcBaseRebalancePhase.AveniaTransferToPolygon; await stateManager.saveState(state); diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts index 79e9c9b63..0a8b8dd43 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts @@ -1,11 +1,13 @@ import Big from "big.js"; import type { WinningRoute } from "../../services/stateManager.ts"; -import type { RebalancingCostPolicyConfig, RebalancingCostPolicyDecision } from "./guards.ts"; +import type { DailyBridgeLimitDecision, RebalancingCostPolicyConfig, RebalancingCostPolicyDecision } from "./guards.ts"; export interface RebalancePolicySummary { config: RebalancingCostPolicyConfig; + dailyLimitDecision?: DailyBridgeLimitDecision; decision?: RebalancingCostPolicyDecision; deviationBps?: number; + opportunistic?: boolean; } interface BaseRebalanceCompletionMessageParams { diff --git a/docs/security-spec/07-operations/rebalancer.md b/docs/security-spec/07-operations/rebalancer.md index 5e0883a49..eb24634f1 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. `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. +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 the fixed 10 bps opportunistic cap. `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. **Current implementation:** Three rebalancing paths: @@ -61,7 +61,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- ### Flow 2: USDC → BRLA → USDC (Base, default high-coverage flow) -**Trigger condition:** Base Nabla BRLA pool coverage ratio > `1 + REBALANCING_THRESHOLD_USDC_TO_BRLA` (default upper bound `1.01`). Falls back to `REBALANCING_THRESHOLD` when the route-specific threshold is unset. This makes the flow eligible for evaluation; cost policy may still skip fresh execution. +**Trigger condition:** Base Nabla BRLA pool coverage ratio > `1 + REBALANCING_THRESHOLD_USDC_TO_BRLA` (default upper bound `1.01`). Falls back to `REBALANCING_THRESHOLD` when the route-specific threshold is unset. This makes the flow eligible for evaluation; cost policy may still skip fresh execution. If coverage is inside the configured bounds, the same flow can run opportunistically with zero coverage deviation only when the selected quote's projected route cost is below 10 bps. **Daily bridge limit:** Total requested USDC amount recorded by Base-flow history per calendar day (UTC), including the amount about to be rebalanced, must not exceed `REBALANCING_DAILY_BRIDGE_LIMIT_USD` (default 10,000) unless the selected quote projects a profit. Profit is inferred from projected output USDC greater than input USDC, which also yields negative projected cost bps. The limit decision is checked against both `UsdcBaseStateManager` and `BrlaToUsdcBaseStateManager` history after quote/cost-policy evaluation and before any fresh Base state write or transaction. Completed profitable runs still write normal history entries, so they remain visible in daily accounting. @@ -132,7 +132,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- ### Shared (both flows) -1. **Coverage ratio check MUST precede rebalancing** — The rebalancer only considers a fresh run when a flow-specific coverage threshold is crossed. Legacy flow uses Pendulum indexer data and triggers when BRLA is over-covered while USDC.axl is not; the default Base flow uses on-chain Nabla contract reads and becomes eligible above `1 + REBALANCING_THRESHOLD_USDC_TO_BRLA` or below `1 - REBALANCING_THRESHOLD_BRLA_TO_USDC`. For Base flows, threshold crossing is necessary but not sufficient: cost policy can still skip execution. +1. **Coverage ratio check MUST precede rebalancing** — Legacy flow uses Pendulum indexer data and triggers when BRLA is over-covered while USDC.axl is not; the default Base flow uses on-chain Nabla contract reads and becomes eligible above `1 + REBALANCING_THRESHOLD_USDC_TO_BRLA` or below `1 - REBALANCING_THRESHOLD_BRLA_TO_USDC`. When Base coverage is inside the configured bounds, only the USDC → BRLA → USDC flow may run, and only under the opportunistic `<10 bps` projected-cost guard. For Base flows, threshold crossing or opportunistic cost qualification is necessary but not sufficient: cost policy can still skip execution. 2. **State persistence MUST survive process restarts** — Each flow has its own Supabase Storage JSON file (`rebalancer_state.json` for legacy, `rebalancer_state_usdc_base.json` for Base high-coverage, `rebalancer_state_brla_to_usdc_base.json` for Base low-coverage). On restart, the rebalancer reads the file and resumes from the last completed phase. 3. **Each phase MUST be idempotent or guarded against re-execution** — If the process crashes mid-phase and resumes, re-executing a completed phase must not cause double-swaps, double-transfers, or double-settlements. Transaction hashes and pre-action balance baselines are stored in state to detect already-completed phases and verify per-run deltas. 4. **Rebalancer private keys MUST be isolated from API service keys** — The rebalancer keys operate separate accounts. Compromise of rebalancer keys should not affect API ramp operations, and vice versa. @@ -152,14 +152,15 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- 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. 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. **Severe imbalances MAY use higher tolerance but MUST NOT bypass hard cost caps** — Severe band can permit higher projected cost, but it cannot bypass `REBALANCING_HARD_MAX_COST_BPS`, balance checks, slippage limits, or phase safety checks. It also cannot bypass the daily bridge limit unless the selected quote projects profit. -16. **Route comparison MUST handle provider failures gracefully** — If every enabled return route quote fails, the high-coverage flow MUST abort (not proceed with zero information). If some routes fail, the best available route is used. If `--route=` is specified, that route is still quoted and cost-gated before execution. -17. **Avenia fallback to SquidRouter MUST be atomic in state** — If Avenia ticket creation fails, the flow sets `winningRoute = "squidrouter"` and `currentPhase = AveniaTransferToPolygon` in a single `saveState()` call. A crash between the failure and the save could leave the flow in an inconsistent state. -18. **NonceManager MUST be re-initialized on resume** — The `NonceManager` is created fresh at the start of each execution from `getTransactionCount()`. On resume, it must not reuse stale nonces from a previous execution. -19. **Axelar cross-chain execution MUST have a timeout** — SquidRouter's Axelar polling has a 30-minute timeout. If Axelar does not confirm execution within this window, the flow MUST throw (not poll indefinitely). This resolves F-034 for the Base flow. -20. **Balance arrival checks MUST be delta-based** — The Base high-coverage flow persists pre-action balances before each arrival-producing operation and waits for `starting balance + expected delta` rather than checking absolute hot-wallet/provider balances. BRLA and Base USDC arrival checks allow a 99.8% tolerance to account for rounding, route deductions, and minor quote shortfalls without sweeping unrelated leftover balances into the current run. The actual received Base USDC delta is persisted before advancing to final verification. -21. **`EVM_ACCOUNT_SECRET` derives the same address on all EVM chains** — A single BIP-39 mnemonic is used for Base, Polygon, and Moonbeam. This means compromise of this one secret drains the rebalancer on ALL EVM chains. `PENDULUM_ACCOUNT_SECRET` is separate and legacy-only. -22. **Terminal Avenia ticket failures MUST abort immediately** — `checkTicketStatusPaid` treats `FAILED` as terminal and throws immediately instead of retrying until timeout. +15. **Opportunistic in-range rebalances MUST stay below 10 bps projected cost** — 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 10 bps. If an opportunistic Avenia route later falls back to SquidRouter, the preflight SquidRouter quote must independently satisfy the normal cost policy, the `<10 bps` opportunistic cap, and the profitable-quote requirement when the original route bypassed the daily bridge limit as profitable. +16. **Severe imbalances MAY use higher tolerance but MUST NOT bypass hard cost caps** — Severe band can permit higher projected cost, but it cannot bypass `REBALANCING_HARD_MAX_COST_BPS`, balance checks, slippage limits, or phase safety checks. It also cannot bypass the daily bridge limit unless the selected quote projects profit. +17. **Route comparison MUST handle provider failures gracefully** — If every enabled return route quote fails, the high-coverage flow MUST abort (not proceed with zero information). If some routes fail, the best available route is used. If `--route=` is specified, that route is still quoted and cost-gated before execution. +18. **Avenia fallback to SquidRouter MUST be atomic in state** — If Avenia ticket creation fails, the flow sets `winningRoute = "squidrouter"` and `currentPhase = AveniaTransferToPolygon` in a single `saveState()` call. A crash between the failure and the save could leave the flow in an inconsistent state. +19. **NonceManager MUST be re-initialized on resume** — The `NonceManager` is created fresh at the start of each execution from `getTransactionCount()`. On resume, it must not reuse stale nonces from a previous execution. +20. **Axelar cross-chain execution MUST have a timeout** — SquidRouter's Axelar polling has a 30-minute timeout. If Axelar does not confirm execution within this window, the flow MUST throw (not poll indefinitely). This resolves F-034 for the Base flow. +21. **Balance arrival checks MUST be delta-based** — The Base high-coverage flow persists pre-action balances before each arrival-producing operation and waits for `starting balance + expected delta` rather than checking absolute hot-wallet/provider balances. BRLA and Base USDC arrival checks allow a 99.8% tolerance to account for rounding, route deductions, and minor quote shortfalls without sweeping unrelated leftover balances into the current run. The actual received Base USDC delta is persisted before advancing to final verification. +22. **`EVM_ACCOUNT_SECRET` derives the same address on all EVM chains** — A single BIP-39 mnemonic is used for Base, Polygon, and Moonbeam. This means compromise of this one secret drains the rebalancer on ALL EVM chains. `PENDULUM_ACCOUNT_SECRET` is separate and legacy-only. +23. **Terminal Avenia ticket failures MUST abort immediately** — `checkTicketStatusPaid` treats `FAILED` as terminal and throws immediately instead of retrying until timeout. ## Threat Vectors & Mitigations @@ -192,6 +193,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- | **Cost-threshold misconfiguration** — Cost thresholds set too low can cause chronic under-rebalancing; thresholds set too high can cause repeated expensive rebalancing | Defaults are conservative and env parsing fails fast for non-monotonic values. Operators should first use `REBALANCING_POLICY_MODE=dry-run` to observe decisions before enabling tighter or looser production thresholds. | | **Always-mode misuse** — Operator leaves `REBALANCING_POLICY_MODE=always` enabled and accepts expensive routes repeatedly | `always` still respects `REBALANCING_HARD_MAX_COST_BPS` and the daily bridge limit for non-profitable quotes. Decision logs include band, projected cost, allowed cost, daily-limit decision, and reason so misuse is observable. | | **Dry-run/off mode drift** — Cron appears healthy but liquidity is not actually moving | `dry-run` and `off` log explicit skip reasons. External monitoring must distinguish successful dry-run/off exits from real completed rebalances. | +| **Opportunistic rebalancing churn** — In-range coverage could repeatedly execute when quotes are merely acceptable but not needed for liquidity correction | The opportunistic path is restricted to USDC → BRLA → USDC, requires projected cost below 10 bps, still runs the normal cost policy and daily-limit/profit decision, and records completed runs in history. Avenia-to-SquidRouter fallback during an opportunistic run is allowed only when the preflight SquidRouter quote also satisfies the opportunistic cost and daily-limit/profit approval context. | | **Quote-cost manipulation near thresholds** — Provider quotes near a configured boundary can nudge execution or skipping | Cost policy uses the best/forced quoted route before any side effect. Hard max-cost cap limits catastrophic execution, but provider quote trust remains a known risk. | | **NonceManager stale nonce** — If the process crashes after sending a transaction but before saving the nonce, the resumed execution could reuse the same nonce | **Mitigated.** `NonceManager` is re-initialized from `getTransactionCount()` on each execution. The stored transaction hashes in state also prevent re-execution of already-completed phases. | | **`EVM_ACCOUNT_SECRET` single-key blast radius** — One mnemonic controls all EVM chain accounts for the rebalancer | Compromise of this one secret drains rebalancer funds on Base, Polygon, and Moonbeam. The separate `PENDULUM_ACCOUNT_SECRET` limits Pendulum blast radius to the legacy flow. This is a deliberate simplification accepted for operational convenience. | @@ -225,6 +227,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- - [x] **FINDING**: Axelar polling has 30-minute timeout — resolves F-034 for Base flow. **PASS** — `axelarTimeout = 30 * 60 * 1000` enforced in `squidRouterApproveAndSwap()`. - [x] **FINDING**: Daily bridge limit check — `REBALANCING_DAILY_BRIDGE_LIMIT_USD` (default 10,000) enforced against both Base-flow histories plus the current requested amount. **PASS** — checked after quote/cost-policy evaluation and before fresh Base side effects. Non-profitable quotes are blocked over the cap; projected-profitable quotes may bypass and are still recorded in history after completion. +- [x] **FINDING**: Opportunistic in-range trigger — Base coverage inside configured bounds can still run USDC→BRLA→USDC only when projected route cost is below 10 bps. **PASS** — uses the same quote/cost-policy path with zero coverage deviation, then applies a fixed `<10 bps` guard before balance checks and state-machine execution. Opportunistic Avenia fallback to SquidRouter is blocked unless the preflight SquidRouter quote independently passes the same policy and profitable-bypass requirements. - [x] **FINDING**: Avenia fallback to SquidRouter — if Avenia ticket creation fails, flow falls back to SquidRouter route. **PASS** — error caught, `winningRoute` updated, state saved atomically. - [x] **FINDING**: `EVM_ACCOUNT_SECRET` single mnemonic for all EVM chains — broad EVM blast radius across Base, Polygon, and Moonbeam. **PASS (accepted)** — deliberate simplification; documented in invariants. - [x] Verify route comparison handles partial failures — what happens if one provider's quote fails? **PASS** — if every enabled route fails, throws; otherwise uses the best available route. If `--route=` is specified, only fetches that quote. diff --git a/docs/security-spec/README.md b/docs/security-spec/README.md index 3ed16ea92..6fdc3d07d 100644 --- a/docs/security-spec/README.md +++ b/docs/security-spec/README.md @@ -44,7 +44,7 @@ This directory contains the security specification for the Vortex cross-border p | XCM Transfers | `06-cross-chain/xcm-transfers.md` | Pendulum↔Moonbeam↔AssetHub↔Hydration | | Bridge Security | `06-cross-chain/bridge-security.md` | Spacewalk bridge trust model | | Fund Routing | `06-cross-chain/fund-routing.md` | Subsidization, fee distribution, amount integrity | -| Rebalancer | `07-operations/rebalancer.md` | Automated liquidity management — BRLA↔axlUSDC (legacy, Pendulum), cost/profit-aware USDC→BRLA→USDC (Base high-coverage), and cost/profit-aware BRLA→USDC correction (Base low-coverage) | +| Rebalancer | `07-operations/rebalancer.md` | Automated liquidity management — BRLA↔axlUSDC (legacy, Pendulum), cost/profit/opportunistic USDC→BRLA→USDC (Base), and cost/profit-aware BRLA→USDC correction (Base low-coverage) | | Secret Management | `07-operations/secret-management.md` | Env vars, rotation, blast radius | | API Surface | `07-operations/api-surface.md` | Rate limiting, CORS, input validation, error handling | | Client Observability | `07-operations/client-observability.md` | Request IDs, sanitized API client events, operational monitoring | From 3e909e5bd2c2bd4c6201117f2e9bf184e143d88b Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 18 Jun 2026 10:08:50 +0200 Subject: [PATCH 094/131] Address PR comments and refactor --- apps/rebalancer/.env.example | 2 ++ apps/rebalancer/README.md | 3 +++ apps/rebalancer/src/index.ts | 19 +++++++++++--- .../brla-to-usdc-base/notifications.test.ts | 1 + .../usdc-brla-usdc-base/guards.test.ts | 14 +++++++--- .../rebalance/usdc-brla-usdc-base/guards.ts | 15 ++++++----- .../rebalance/usdc-brla-usdc-base/index.ts | 4 +++ .../usdc-brla-usdc-base/notifications.test.ts | 1 + .../usdc-brla-usdc-base/notifications.ts | 5 ++++ apps/rebalancer/src/utils/config.test.ts | 26 ++++++++++++++++--- apps/rebalancer/src/utils/config.ts | 5 ++++ .../security-spec/07-operations/rebalancer.md | 13 +++++----- 12 files changed, 85 insertions(+), 23 deletions(-) diff --git a/apps/rebalancer/.env.example b/apps/rebalancer/.env.example index 9f5fa5e34..8e49c1b9a 100644 --- a/apps/rebalancer/.env.example +++ b/apps/rebalancer/.env.example @@ -33,6 +33,8 @@ REBALANCING_DAILY_BRIDGE_LIMIT_USD=10000 # REBALANCING_MAX_COST_BPS_MODERATE=75 # REBALANCING_MAX_COST_BPS_SEVERE=250 # REBALANCING_HARD_MAX_COST_BPS=1000 +# In-range USDC→BRLA→USDC runs execute only below this projected route cost (default 10 bps). +# REBALANCING_OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS=10 # Main Nabla instance on Base (BRL→USDC route). Leave empty to disable this route. MAIN_NABLA_ROUTER=0x... diff --git a/apps/rebalancer/README.md b/apps/rebalancer/README.md index 65f85f80e..e2fe3b7e6 100644 --- a/apps/rebalancer/README.md +++ b/apps/rebalancer/README.md @@ -19,6 +19,9 @@ EVM_ACCOUNT_SECRET="your BIP-39 mnemonic (12/24 words)" PENDULUM_ACCOUNT_SECRET=xxx ``` +For Base rebalancing, the in-range opportunistic USDC→BRLA→USDC trigger is controlled by +`REBALANCING_OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS` and defaults to `10` bps when unset. + ## Installation To install dependencies: diff --git a/apps/rebalancer/src/index.ts b/apps/rebalancer/src/index.ts index c548d0242..f4f3ed30f 100644 --- a/apps/rebalancer/src/index.ts +++ b/apps/rebalancer/src/index.ts @@ -11,7 +11,6 @@ import { evaluateDailyBridgeLimit, evaluateRebalancingCostPolicy, isProjectedProfit, - OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS, type RebalancingCostPolicyDecision, shouldTriggerOpportunisticUsdcToBrla } from "./rebalance/usdc-brla-usdc-base/guards.ts"; @@ -162,6 +161,11 @@ async function evaluateUsdcToBrlaPolicy( ): Promise<{ decision: RebalancingCostPolicyDecision; profitable: boolean; + routeQuotes?: { + aveniaQuoteUsdc: string | null; + mainNablaQuoteUsdc: string | null; + squidRouterQuoteUsdc: string | null; + }; shouldExecute: boolean; routeToRun?: Exclude; }> { @@ -195,6 +199,11 @@ async function evaluateUsdcToBrlaPolicy( return { decision, profitable: isProjectedProfit(Big(amountUsdcRaw), Big(projectedOutputRaw)), + routeQuotes: { + aveniaQuoteUsdc: comparison.aveniaQuoteUsdc, + mainNablaQuoteUsdc: comparison.mainNablaQuoteUsdc, + squidRouterQuoteUsdc: comparison.squidRouterQuoteUsdc + }, routeToRun, shouldExecute: decision.shouldExecute }; @@ -224,7 +233,8 @@ async function executeUsdcToBrlaRebalance( dailyLimitDecision, decision: policyDecision.decision, deviationBps: coverageDeviationBps, - opportunistic: options.opportunistic + opportunistic: options.opportunistic, + preflightQuotes: policyDecision.routeQuotes }); } @@ -233,11 +243,12 @@ async function tryOpportunisticUsdcToBrla(bridgedToday: Big, dailyLimitRaw: Big) const amountUsdc = manualAmount || config.rebalancingUsdToBrlAmount; const amountUsdcRaw = multiplyByPowerOfTen(new Big(amountUsdc), 6).toFixed(0, 0); const policyDecision = await evaluateUsdcToBrlaPolicy(amountUsdcRaw, 0); + const opportunisticMaxCostBps = config.rebalancingCostPolicy.opportunisticUsdcToBrlaMaxCostBps; if (!policyDecision.shouldExecute) return false; - if (!shouldTriggerOpportunisticUsdcToBrla(policyDecision.decision.costBps)) { + if (!shouldTriggerOpportunisticUsdcToBrla(policyDecision.decision.costBps, opportunisticMaxCostBps)) { console.log( - `No opportunistic USDC->BRLA rebalance: projected cost ${policyDecision.decision.costBps} bps >= ${OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS} bps.` + `No opportunistic USDC->BRLA rebalance: projected cost ${policyDecision.decision.costBps} bps >= ${opportunisticMaxCostBps} bps.` ); return false; } diff --git a/apps/rebalancer/src/rebalance/brla-to-usdc-base/notifications.test.ts b/apps/rebalancer/src/rebalance/brla-to-usdc-base/notifications.test.ts index 3c9e19580..823122e82 100644 --- a/apps/rebalancer/src/rebalance/brla-to-usdc-base/notifications.test.ts +++ b/apps/rebalancer/src/rebalance/brla-to-usdc-base/notifications.test.ts @@ -9,6 +9,7 @@ const policyConfig = { maxCostBpsSevere: 250, mode: "auto" as const, moderateDeviationBps: 200, + opportunisticUsdcToBrlaMaxCostBps: 10, severeDeviationBps: 500 }; diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.test.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.test.ts index b45a01290..4e096193e 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.test.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.test.ts @@ -9,7 +9,6 @@ import { evaluateRebalancingCostPolicy, getRebalancingUrgencyBand, isProjectedProfit, - OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS, type RebalancingCostPolicyConfig, shouldTriggerOpportunisticUsdcToBrla, wouldExceedDailyBridgeLimit @@ -22,6 +21,7 @@ const policyConfig: RebalancingCostPolicyConfig = { maxCostBpsSevere: 250, mode: "auto", moderateDeviationBps: 200, + opportunisticUsdcToBrlaMaxCostBps: 10, severeDeviationBps: 500 }; @@ -70,14 +70,17 @@ describe("USDC Base rebalance guards", () => { }); test("triggers opportunistic USDC to BRLA rebalances only below the route cost cap", () => { - expect(shouldTriggerOpportunisticUsdcToBrla(-1)).toBe(true); - expect(shouldTriggerOpportunisticUsdcToBrla(OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS - 0.01)).toBe(true); - expect(shouldTriggerOpportunisticUsdcToBrla(OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS)).toBe(false); + const maxCostBps = 7.5; + + expect(shouldTriggerOpportunisticUsdcToBrla(-1, maxCostBps)).toBe(true); + expect(shouldTriggerOpportunisticUsdcToBrla(maxCostBps - 0.01, maxCostBps)).toBe(true); + expect(shouldTriggerOpportunisticUsdcToBrla(maxCostBps, maxCostBps)).toBe(false); }); test("allows fallback routes only when they satisfy opportunistic policy checks", () => { expect( evaluateFallbackRoutePolicy(Big("100000000"), Big("99910000"), 0, policyConfig, { + opportunisticMaxCostBps: policyConfig.opportunisticUsdcToBrlaMaxCostBps, requireOpportunisticCost: true, requireProfit: false }).shouldExecute @@ -85,6 +88,7 @@ describe("USDC Base rebalance guards", () => { expect( evaluateFallbackRoutePolicy(Big("100000000"), Big("99900000"), 0, policyConfig, { + opportunisticMaxCostBps: policyConfig.opportunisticUsdcToBrlaMaxCostBps, requireOpportunisticCost: true, requireProfit: false }).shouldExecute @@ -94,12 +98,14 @@ describe("USDC Base rebalance guards", () => { test("requires fallback route profit when the original route bypassed the daily limit as profitable", () => { expect( evaluateFallbackRoutePolicy(Big("100000000"), Big("100100000"), 0, policyConfig, { + opportunisticMaxCostBps: policyConfig.opportunisticUsdcToBrlaMaxCostBps, requireOpportunisticCost: true, requireProfit: true }).shouldExecute ).toBe(true); const decision = evaluateFallbackRoutePolicy(Big("100000000"), Big("99910000"), 0, policyConfig, { + opportunisticMaxCostBps: policyConfig.opportunisticUsdcToBrlaMaxCostBps, requireOpportunisticCost: true, requireProfit: true }); diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.ts index 8c63f54c7..fb033c6a5 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.ts @@ -1,7 +1,6 @@ import Big from "big.js"; export const DEFAULT_ARRIVAL_TOLERANCE = "0.998"; -export const OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS = 10; export type RebalancingPolicyMode = "auto" | "always" | "dry-run" | "off"; export type RebalancingUrgencyBand = "mild" | "moderate" | "severe"; @@ -13,6 +12,7 @@ export interface RebalancingCostPolicyConfig { maxCostBpsSevere: number; mode: RebalancingPolicyMode; moderateDeviationBps: number; + opportunisticUsdcToBrlaMaxCostBps: number; severeDeviationBps: number; } @@ -81,8 +81,8 @@ export function calculateProjectedCostBps(inputAmountRaw: Big, projectedOutputRa return Number(inputAmountRaw.minus(projectedOutputRaw).div(inputAmountRaw).mul(10_000).toFixed(2)); } -export function shouldTriggerOpportunisticUsdcToBrla(costBps: number): boolean { - return costBps < OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS; +export function shouldTriggerOpportunisticUsdcToBrla(costBps: number, maxCostBps: number): boolean { + return costBps < maxCostBps; } export function evaluateFallbackRoutePolicy( @@ -90,7 +90,7 @@ export function evaluateFallbackRoutePolicy( fallbackOutputRaw: Big, deviationBps: number, config: RebalancingCostPolicyConfig, - options: { requireOpportunisticCost: boolean; requireProfit: boolean } + options: { opportunisticMaxCostBps: number; requireOpportunisticCost: boolean; requireProfit: boolean } ): FallbackRoutePolicyDecision { const decision = evaluateRebalancingCostPolicy(inputAmountRaw, fallbackOutputRaw, deviationBps, config); const profitable = isProjectedProfit(inputAmountRaw, fallbackOutputRaw); @@ -99,11 +99,14 @@ export function evaluateFallbackRoutePolicy( return { decision, profitable, reason: decision.reason, shouldExecute: false }; } - if (options.requireOpportunisticCost && !shouldTriggerOpportunisticUsdcToBrla(decision.costBps)) { + if ( + options.requireOpportunisticCost && + !shouldTriggerOpportunisticUsdcToBrla(decision.costBps, options.opportunisticMaxCostBps) + ) { return { decision, profitable, - reason: `Fallback route cost ${decision.costBps} bps is not below opportunistic cap ${OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS} bps.`, + reason: `Fallback route cost ${decision.costBps} bps is not below opportunistic cap ${options.opportunisticMaxCostBps} bps.`, shouldExecute: false }; } diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts index aaa40fdaf..7504fcc5e 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts @@ -71,6 +71,9 @@ export async function rebalanceUsdcBrlaUsdcBase( if (forcedRoute) { console.log(`Forced route: ${forcedRoute}. Skipping full comparison.`); state.winningRoute = forcedRoute; + state.squidRouterQuoteUsdc = policy?.preflightQuotes?.squidRouterQuoteUsdc ?? null; + state.aveniaQuoteUsdc = policy?.preflightQuotes?.aveniaQuoteUsdc ?? null; + state.mainNablaQuoteUsdc = policy?.preflightQuotes?.mainNablaQuoteUsdc ?? null; } else { const comparison = await compareRoutesUpfront(state.usdcAmountRaw); state.winningRoute = comparison.winningRoute; @@ -191,6 +194,7 @@ export async function rebalanceUsdcBrlaUsdcBase( policy.deviationBps ?? 0, policy.config, { + opportunisticMaxCostBps: policy.config.opportunisticUsdcToBrlaMaxCostBps, requireOpportunisticCost: true, requireProfit: policy.dailyLimitDecision?.reason === "profitable_quote" } diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.test.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.test.ts index e91a3f830..4bb6b8f36 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.test.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.test.ts @@ -9,6 +9,7 @@ const policyConfig = { maxCostBpsSevere: 250, mode: "auto" as const, moderateDeviationBps: 200, + opportunisticUsdcToBrlaMaxCostBps: 10, severeDeviationBps: 500 }; diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts index 0a8b8dd43..04f241423 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts @@ -8,6 +8,11 @@ export interface RebalancePolicySummary { decision?: RebalancingCostPolicyDecision; deviationBps?: number; opportunistic?: boolean; + preflightQuotes?: { + aveniaQuoteUsdc: string | null; + mainNablaQuoteUsdc: string | null; + squidRouterQuoteUsdc: string | null; + }; } interface BaseRebalanceCompletionMessageParams { diff --git a/apps/rebalancer/src/utils/config.test.ts b/apps/rebalancer/src/utils/config.test.ts index 5aeb01cd4..92f68739f 100644 --- a/apps/rebalancer/src/utils/config.test.ts +++ b/apps/rebalancer/src/utils/config.test.ts @@ -1,5 +1,9 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { getRebalancingCostPolicyConfig, parseRebalancingDailyBridgeLimitUsd, parseRebalancingPolicyMode } from "./config.ts"; +import {afterEach, beforeEach, describe, expect, test} from "bun:test"; +import { + getRebalancingCostPolicyConfig, + parseRebalancingDailyBridgeLimitUsd, + parseRebalancingPolicyMode +} from "./config.ts"; const policyEnvVars = [ "REBALANCING_POLICY_MODE", @@ -8,7 +12,8 @@ const policyEnvVars = [ "REBALANCING_MAX_COST_BPS_MILD", "REBALANCING_MAX_COST_BPS_MODERATE", "REBALANCING_MAX_COST_BPS_SEVERE", - "REBALANCING_HARD_MAX_COST_BPS" + "REBALANCING_HARD_MAX_COST_BPS", + "REBALANCING_OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS" ]; const originalPolicyEnv = new Map(policyEnvVars.map(name => [name, process.env[name]])); @@ -80,10 +85,25 @@ describe("getRebalancingCostPolicyConfig", () => { maxCostBpsSevere: 250, mode: "auto", moderateDeviationBps: 200, + opportunisticUsdcToBrlaMaxCostBps: 10, severeDeviationBps: 500 }); }); + test("allows configuring the opportunistic USDC to BRLA max cost", () => { + process.env.REBALANCING_OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS = "7.5"; + + expect(getRebalancingCostPolicyConfig().opportunisticUsdcToBrlaMaxCostBps).toBe(7.5); + }); + + test("rejects invalid opportunistic USDC to BRLA max cost", () => { + process.env.REBALANCING_OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS = "not-a-number"; + + expect(() => getRebalancingCostPolicyConfig()).toThrow( + "REBALANCING_OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS must be a non-negative number." + ); + }); + test("rejects non-monotonic deviation thresholds", () => { process.env.REBALANCING_MODERATE_DEVIATION_BPS = "600"; process.env.REBALANCING_SEVERE_DEVIATION_BPS = "500"; diff --git a/apps/rebalancer/src/utils/config.ts b/apps/rebalancer/src/utils/config.ts index 77d4bc5aa..803efe915 100644 --- a/apps/rebalancer/src/utils/config.ts +++ b/apps/rebalancer/src/utils/config.ts @@ -51,6 +51,11 @@ export function getRebalancingCostPolicyConfig(): RebalancingCostPolicyConfig { process.env.REBALANCING_MODERATE_DEVIATION_BPS, 200 ), + opportunisticUsdcToBrlaMaxCostBps: parseNonNegativeNumber( + "REBALANCING_OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS", + process.env.REBALANCING_OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS, + 10 + ), severeDeviationBps: parseNonNegativeNumber( "REBALANCING_SEVERE_DEVIATION_BPS", process.env.REBALANCING_SEVERE_DEVIATION_BPS, diff --git a/docs/security-spec/07-operations/rebalancer.md b/docs/security-spec/07-operations/rebalancer.md index eb24634f1..f69ec7213 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 the fixed 10 bps opportunistic cap. `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. +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. **Current implementation:** Three rebalancing paths: @@ -40,6 +40,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- - `REBALANCING_MODERATE_DEVIATION_BPS` / `REBALANCING_SEVERE_DEVIATION_BPS` — classify coverage deviation beyond the trigger bound into mild, moderate, or severe bands. - `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). --- @@ -61,7 +62,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- ### Flow 2: USDC → BRLA → USDC (Base, default high-coverage flow) -**Trigger condition:** Base Nabla BRLA pool coverage ratio > `1 + REBALANCING_THRESHOLD_USDC_TO_BRLA` (default upper bound `1.01`). Falls back to `REBALANCING_THRESHOLD` when the route-specific threshold is unset. This makes the flow eligible for evaluation; cost policy may still skip fresh execution. If coverage is inside the configured bounds, the same flow can run opportunistically with zero coverage deviation only when the selected quote's projected route cost is below 10 bps. +**Trigger condition:** Base Nabla BRLA pool coverage ratio > `1 + REBALANCING_THRESHOLD_USDC_TO_BRLA` (default upper bound `1.01`). Falls back to `REBALANCING_THRESHOLD` when the route-specific threshold is unset. This makes the flow eligible for evaluation; cost policy may still skip fresh execution. If coverage is inside the configured bounds, the same flow can run opportunistically with zero coverage deviation only when the selected quote's projected route cost is below `REBALANCING_OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS` (default 10 bps). **Daily bridge limit:** Total requested USDC amount recorded by Base-flow history per calendar day (UTC), including the amount about to be rebalanced, must not exceed `REBALANCING_DAILY_BRIDGE_LIMIT_USD` (default 10,000) unless the selected quote projects a profit. Profit is inferred from projected output USDC greater than input USDC, which also yields negative projected cost bps. The limit decision is checked against both `UsdcBaseStateManager` and `BrlaToUsdcBaseStateManager` history after quote/cost-policy evaluation and before any fresh Base state write or transaction. Completed profitable runs still write normal history entries, so they remain visible in daily accounting. @@ -132,7 +133,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- ### Shared (both flows) -1. **Coverage ratio check MUST precede rebalancing** — Legacy flow uses Pendulum indexer data and triggers when BRLA is over-covered while USDC.axl is not; the default Base flow uses on-chain Nabla contract reads and becomes eligible above `1 + REBALANCING_THRESHOLD_USDC_TO_BRLA` or below `1 - REBALANCING_THRESHOLD_BRLA_TO_USDC`. When Base coverage is inside the configured bounds, only the USDC → BRLA → USDC flow may run, and only under the opportunistic `<10 bps` projected-cost guard. For Base flows, threshold crossing or opportunistic cost qualification is necessary but not sufficient: cost policy can still skip execution. +1. **Coverage ratio check MUST precede rebalancing** — Legacy flow uses Pendulum indexer data and triggers when BRLA is over-covered while USDC.axl is not; the default Base flow uses on-chain Nabla contract reads and becomes eligible above `1 + REBALANCING_THRESHOLD_USDC_TO_BRLA` or below `1 - REBALANCING_THRESHOLD_BRLA_TO_USDC`. When Base coverage is inside the configured bounds, only the USDC → BRLA → USDC flow may run, and only under the configured opportunistic projected-cost guard. For Base flows, threshold crossing or opportunistic cost qualification is necessary but not sufficient: cost policy can still skip execution. 2. **State persistence MUST survive process restarts** — Each flow has its own Supabase Storage JSON file (`rebalancer_state.json` for legacy, `rebalancer_state_usdc_base.json` for Base high-coverage, `rebalancer_state_brla_to_usdc_base.json` for Base low-coverage). On restart, the rebalancer reads the file and resumes from the last completed phase. 3. **Each phase MUST be idempotent or guarded against re-execution** — If the process crashes mid-phase and resumes, re-executing a completed phase must not cause double-swaps, double-transfers, or double-settlements. Transaction hashes and pre-action balance baselines are stored in state to detect already-completed phases and verify per-run deltas. 4. **Rebalancer private keys MUST be isolated from API service keys** — The rebalancer keys operate separate accounts. Compromise of rebalancer keys should not affect API ramp operations, and vice versa. @@ -152,7 +153,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- 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. 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 10 bps projected cost** — 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 10 bps. If an opportunistic Avenia route later falls back to SquidRouter, the preflight SquidRouter quote must independently satisfy the normal cost policy, the `<10 bps` opportunistic cap, and the profitable-quote requirement when the original route bypassed the daily bridge limit as profitable. +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 route bypassed the daily bridge limit as profitable. 16. **Severe imbalances MAY use higher tolerance but MUST NOT bypass hard cost caps** — Severe band can permit higher projected cost, but it cannot bypass `REBALANCING_HARD_MAX_COST_BPS`, balance checks, slippage limits, or phase safety checks. It also cannot bypass the daily bridge limit unless the selected quote projects profit. 17. **Route comparison MUST handle provider failures gracefully** — If every enabled return route quote fails, the high-coverage flow MUST abort (not proceed with zero information). If some routes fail, the best available route is used. If `--route=` is specified, that route is still quoted and cost-gated before execution. 18. **Avenia fallback to SquidRouter MUST be atomic in state** — If Avenia ticket creation fails, the flow sets `winningRoute = "squidrouter"` and `currentPhase = AveniaTransferToPolygon` in a single `saveState()` call. A crash between the failure and the save could leave the flow in an inconsistent state. @@ -193,7 +194,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- | **Cost-threshold misconfiguration** — Cost thresholds set too low can cause chronic under-rebalancing; thresholds set too high can cause repeated expensive rebalancing | Defaults are conservative and env parsing fails fast for non-monotonic values. Operators should first use `REBALANCING_POLICY_MODE=dry-run` to observe decisions before enabling tighter or looser production thresholds. | | **Always-mode misuse** — Operator leaves `REBALANCING_POLICY_MODE=always` enabled and accepts expensive routes repeatedly | `always` still respects `REBALANCING_HARD_MAX_COST_BPS` and the daily bridge limit for non-profitable quotes. Decision logs include band, projected cost, allowed cost, daily-limit decision, and reason so misuse is observable. | | **Dry-run/off mode drift** — Cron appears healthy but liquidity is not actually moving | `dry-run` and `off` log explicit skip reasons. External monitoring must distinguish successful dry-run/off exits from real completed rebalances. | -| **Opportunistic rebalancing churn** — In-range coverage could repeatedly execute when quotes are merely acceptable but not needed for liquidity correction | The opportunistic path is restricted to USDC → BRLA → USDC, requires projected cost below 10 bps, still runs the normal cost policy and daily-limit/profit decision, and records completed runs in history. Avenia-to-SquidRouter fallback during an opportunistic run is allowed only when the preflight SquidRouter quote also satisfies the opportunistic cost and daily-limit/profit approval context. | +| **Opportunistic rebalancing churn** — In-range coverage could repeatedly execute when quotes are merely acceptable but not needed for liquidity correction | The opportunistic path is restricted to USDC → BRLA → USDC, requires projected cost below `REBALANCING_OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS` (default 10 bps), still runs the normal cost policy and daily-limit/profit decision, and records completed runs in history. Avenia-to-SquidRouter fallback during an opportunistic run is allowed only when the preflight SquidRouter quote also satisfies the opportunistic cost and daily-limit/profit approval context. | | **Quote-cost manipulation near thresholds** — Provider quotes near a configured boundary can nudge execution or skipping | Cost policy uses the best/forced quoted route before any side effect. Hard max-cost cap limits catastrophic execution, but provider quote trust remains a known risk. | | **NonceManager stale nonce** — If the process crashes after sending a transaction but before saving the nonce, the resumed execution could reuse the same nonce | **Mitigated.** `NonceManager` is re-initialized from `getTransactionCount()` on each execution. The stored transaction hashes in state also prevent re-execution of already-completed phases. | | **`EVM_ACCOUNT_SECRET` single-key blast radius** — One mnemonic controls all EVM chain accounts for the rebalancer | Compromise of this one secret drains rebalancer funds on Base, Polygon, and Moonbeam. The separate `PENDULUM_ACCOUNT_SECRET` limits Pendulum blast radius to the legacy flow. This is a deliberate simplification accepted for operational convenience. | @@ -227,7 +228,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- - [x] **FINDING**: Axelar polling has 30-minute timeout — resolves F-034 for Base flow. **PASS** — `axelarTimeout = 30 * 60 * 1000` enforced in `squidRouterApproveAndSwap()`. - [x] **FINDING**: Daily bridge limit check — `REBALANCING_DAILY_BRIDGE_LIMIT_USD` (default 10,000) enforced against both Base-flow histories plus the current requested amount. **PASS** — checked after quote/cost-policy evaluation and before fresh Base side effects. Non-profitable quotes are blocked over the cap; projected-profitable quotes may bypass and are still recorded in history after completion. -- [x] **FINDING**: Opportunistic in-range trigger — Base coverage inside configured bounds can still run USDC→BRLA→USDC only when projected route cost is below 10 bps. **PASS** — uses the same quote/cost-policy path with zero coverage deviation, then applies a fixed `<10 bps` guard before balance checks and state-machine execution. Opportunistic Avenia fallback to SquidRouter is blocked unless the preflight SquidRouter quote independently passes the same policy and profitable-bypass requirements. +- [x] **FINDING**: Opportunistic in-range trigger — Base coverage inside configured bounds can still run USDC→BRLA→USDC only when projected route cost is below `REBALANCING_OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS` (default 10 bps). **PASS** — uses the same quote/cost-policy path with zero coverage deviation, then applies the configured opportunistic cap before balance checks and state-machine execution. Opportunistic Avenia fallback to SquidRouter is blocked unless the preflight SquidRouter quote independently passes the same policy and profitable-bypass requirements. - [x] **FINDING**: Avenia fallback to SquidRouter — if Avenia ticket creation fails, flow falls back to SquidRouter route. **PASS** — error caught, `winningRoute` updated, state saved atomically. - [x] **FINDING**: `EVM_ACCOUNT_SECRET` single mnemonic for all EVM chains — broad EVM blast radius across Base, Polygon, and Moonbeam. **PASS (accepted)** — deliberate simplification; documented in invariants. - [x] Verify route comparison handles partial failures — what happens if one provider's quote fails? **PASS** — if every enabled route fails, throws; otherwise uses the best available route. If `--route=` is specified, only fetches that quote. From bc3f7b910353390890717394f984a52e6133d5ea Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 18 Jun 2026 10:35:02 +0200 Subject: [PATCH 095/131] Add daily bridge limit context retrieval for opportunistic rebalancing --- apps/rebalancer/src/index.ts | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/apps/rebalancer/src/index.ts b/apps/rebalancer/src/index.ts index f4f3ed30f..af48e92e8 100644 --- a/apps/rebalancer/src/index.ts +++ b/apps/rebalancer/src/index.ts @@ -99,6 +99,17 @@ async function getTodayBridgedUsdRaw(): Promise { .reduce((sum, e) => sum.plus(Big(e.initialAmount)), Big(0)); } +async function getDailyBridgeLimitContext(): Promise<{ bridgedToday: Big; dailyLimitRaw: Big }> { + const config = getConfig(); + const bridgedToday = await getTodayBridgedUsdRaw(); + const dailyLimitRaw = multiplyByPowerOfTen(Big(config.rebalancingDailyBridgeLimitUsd), 6); + console.log( + `Bridged $${bridgedToday.div(1e6).toFixed(2)} today. Daily bridge limit is $${config.rebalancingDailyBridgeLimitUsd}.` + ); + + return { bridgedToday, dailyLimitRaw }; +} + function logDailyLimitDecision(decision: DailyBridgeLimitDecision, dailyLimitUsd: number) { if (decision.reason === "under_limit") return; @@ -238,7 +249,7 @@ async function executeUsdcToBrlaRebalance( }); } -async function tryOpportunisticUsdcToBrla(bridgedToday: Big, dailyLimitRaw: Big): Promise { +async function tryOpportunisticUsdcToBrla(): Promise { const config = getConfig(); const amountUsdc = manualAmount || config.rebalancingUsdToBrlAmount; const amountUsdcRaw = multiplyByPowerOfTen(new Big(amountUsdc), 6).toFixed(0, 0); @@ -254,6 +265,7 @@ async function tryOpportunisticUsdcToBrla(bridgedToday: Big, dailyLimitRaw: Big) } console.log(`Opportunistic USDC->BRLA rebalance triggered at ${policyDecision.decision.costBps} bps projected cost.`); + const { bridgedToday, dailyLimitRaw } = await getDailyBridgeLimitContext(); await executeUsdcToBrlaRebalance(amountUsdcRaw, bridgedToday, dailyLimitRaw, 0, policyDecision, { opportunistic: true }); return true; } @@ -357,18 +369,14 @@ async function checkForRebalancing() { const lowerBound = 1 - config.rebalancingThresholdBrlaToUsdc; const upperBound = 1 + config.rebalancingThresholdUsdcToBrla; - const bridgedToday = await getTodayBridgedUsdRaw(); - const dailyLimitRaw = multiplyByPowerOfTen(Big(config.rebalancingDailyBridgeLimitUsd), 6); - console.log( - `Bridged $${bridgedToday.div(1e6).toFixed(2)} today. Daily bridge limit is $${config.rebalancingDailyBridgeLimitUsd}.` - ); - if (coverage.brlaCoverageRatio >= lowerBound && coverage.brlaCoverageRatio <= upperBound) { - if (await tryOpportunisticUsdcToBrla(bridgedToday, dailyLimitRaw)) return; + if (await tryOpportunisticUsdcToBrla()) return; console.log(`BRLA coverage ${coverage.brlaCoverageRatio} in range [${lowerBound}, ${upperBound}]. No rebalancing needed.`); return; } + const { bridgedToday, dailyLimitRaw } = await getDailyBridgeLimitContext(); + if (coverage.brlaCoverageRatio < lowerBound) { const deviationBps = calculateCoverageDeviationBps(coverage.brlaCoverageRatio, lowerBound); console.log( From 55c617857a00f773ec91c73df335ba9a98dff321 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 18 Jun 2026 15:48:59 +0200 Subject: [PATCH 096/131] Enhance opportunistic rebalancing with fallback context and state management --- apps/rebalancer/src/index.ts | 8 +-- .../rebalance/usdc-brla-usdc-base/index.ts | 57 +++++++++++++++++-- apps/rebalancer/src/services/stateManager.ts | 12 ++++ 3 files changed, 67 insertions(+), 10 deletions(-) diff --git a/apps/rebalancer/src/index.ts b/apps/rebalancer/src/index.ts index af48e92e8..109b23812 100644 --- a/apps/rebalancer/src/index.ts +++ b/apps/rebalancer/src/index.ts @@ -227,7 +227,7 @@ async function executeUsdcToBrlaRebalance( coverageDeviationBps: number, policyDecision: Awaited>, options: { opportunistic?: boolean } = {} -) { +): Promise { const config = getConfig(); const dailyLimitDecision = evaluateDailyBridgeLimit( bridgedToday, @@ -236,7 +236,7 @@ async function executeUsdcToBrlaRebalance( policyDecision.profitable ); logDailyLimitDecision(dailyLimitDecision, config.rebalancingDailyBridgeLimitUsd); - if (dailyLimitDecision.shouldSkip) return; + if (dailyLimitDecision.shouldSkip) return false; await checkInitialUsdcBalanceOnBase(amountUsdcRaw); await rebalanceUsdcBrlaUsdcBase(amountUsdcRaw, forceRestart, policyDecision.routeToRun, { @@ -247,6 +247,7 @@ async function executeUsdcToBrlaRebalance( opportunistic: options.opportunistic, preflightQuotes: policyDecision.routeQuotes }); + return true; } async function tryOpportunisticUsdcToBrla(): Promise { @@ -266,8 +267,7 @@ async function tryOpportunisticUsdcToBrla(): Promise { console.log(`Opportunistic USDC->BRLA rebalance triggered at ${policyDecision.decision.costBps} bps projected cost.`); const { bridgedToday, dailyLimitRaw } = await getDailyBridgeLimitContext(); - await executeUsdcToBrlaRebalance(amountUsdcRaw, bridgedToday, dailyLimitRaw, 0, policyDecision, { opportunistic: true }); - return true; + return executeUsdcToBrlaRebalance(amountUsdcRaw, bridgedToday, dailyLimitRaw, 0, policyDecision, { opportunistic: true }); } async function evaluateBrlaToUsdcPolicy( diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts index 7504fcc5e..c0e8d555a 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts @@ -1,6 +1,11 @@ import { BrlaApiService, multiplyByPowerOfTen, SlackNotifier } from "@vortexfi/shared"; import Big from "big.js"; -import { UsdcBaseRebalancePhase, UsdcBaseStateManager, usdcBasePhaseOrder } from "../../services/stateManager.ts"; +import { + UsdcBaseRebalancePhase, + type UsdcBaseRebalanceState, + UsdcBaseStateManager, + usdcBasePhaseOrder +} from "../../services/stateManager.ts"; import { checkTicketStatusPaid } from "../../utils/brla.ts"; import { getBaseEvmClients, getConfig, getPolygonEvmClients } from "../../utils/config.ts"; import { NonceManager } from "../../utils/nonce.ts"; @@ -24,6 +29,37 @@ import { waitUsdcOnBase } from "./steps.ts"; +interface OpportunisticFallbackContext { + config: RebalancePolicySummary["config"]; + deviationBps: number; + opportunisticMaxCostBps: number; + requireProfit: boolean; +} + +function getOpportunisticFallbackContext( + state: UsdcBaseRebalanceState, + policy: RebalancePolicySummary | undefined +): OpportunisticFallbackContext | null { + if (policy?.opportunistic) { + return { + config: policy.config, + deviationBps: policy.deviationBps ?? 0, + opportunisticMaxCostBps: policy.config.opportunisticUsdcToBrlaMaxCostBps, + requireProfit: policy.dailyLimitDecision?.reason === "profitable_quote" + }; + } + + if (!state.opportunisticUsdcToBrla) return null; + + const config = getConfig().rebalancingCostPolicy; + return { + config, + deviationBps: state.opportunisticDeviationBps ?? 0, + opportunisticMaxCostBps: state.opportunisticMaxCostBps ?? config.opportunisticUsdcToBrlaMaxCostBps, + requireProfit: state.opportunisticRequiresProfit + }; +} + export async function rebalanceUsdcBrlaUsdcBase( usdcAmountRaw: string, forceRestart = false, @@ -47,6 +83,14 @@ export async function rebalanceUsdcBrlaUsdcBase( throw new Error("State is undefined after initialization."); } + if (policy?.opportunistic) { + state.opportunisticUsdcToBrla = true; + state.opportunisticMaxCostBps = policy.config.opportunisticUsdcToBrlaMaxCostBps; + state.opportunisticRequiresProfit = policy.dailyLimitDecision?.reason === "profitable_quote"; + state.opportunisticDeviationBps = policy.deviationBps ?? 0; + await stateManager.saveState(state); + } + const { publicClient: basePublicClient, walletClient: baseWalletClient } = getBaseEvmClients(); const baseAddress = baseWalletClient.account.address as `0x${string}`; const baseNonce = await NonceManager.create(basePublicClient, baseAddress); @@ -181,7 +225,8 @@ export async function rebalanceUsdcBrlaUsdcBase( await stateManager.saveState(state); } catch (error) { console.error("Avenia swap ticket creation failed. Falling back to SquidRouter route.", error); - if (policy?.opportunistic) { + const opportunisticFallbackContext = getOpportunisticFallbackContext(state, policy); + if (opportunisticFallbackContext) { if (!state.usdcAmountRaw) throw new Error("State corrupted: usdcAmountRaw missing for opportunistic fallback check"); if (!state.squidRouterQuoteUsdc) { @@ -191,12 +236,12 @@ export async function rebalanceUsdcBrlaUsdcBase( const fallbackPolicy = evaluateFallbackRoutePolicy( Big(state.usdcAmountRaw), Big(state.squidRouterQuoteUsdc), - policy.deviationBps ?? 0, - policy.config, + opportunisticFallbackContext.deviationBps, + opportunisticFallbackContext.config, { - opportunisticMaxCostBps: policy.config.opportunisticUsdcToBrlaMaxCostBps, + opportunisticMaxCostBps: opportunisticFallbackContext.opportunisticMaxCostBps, requireOpportunisticCost: true, - requireProfit: policy.dailyLimitDecision?.reason === "profitable_quote" + requireProfit: opportunisticFallbackContext.requireProfit } ); if (!fallbackPolicy.shouldExecute) { diff --git a/apps/rebalancer/src/services/stateManager.ts b/apps/rebalancer/src/services/stateManager.ts index 724cbf8ef..eebe2d6fc 100644 --- a/apps/rebalancer/src/services/stateManager.ts +++ b/apps/rebalancer/src/services/stateManager.ts @@ -206,6 +206,10 @@ export interface UsdcBaseRebalanceState { mainNablaApproveHash: string | null; mainNablaSwapHash: string | null; mainNablaUsdcBalanceBeforeRaw: string | null; + opportunisticDeviationBps: number | null; + opportunisticMaxCostBps: number | null; + opportunisticRequiresProfit: boolean; + opportunisticUsdcToBrla: boolean; polygonBrlaBalanceBeforeTransferRaw: string | null; squidRouterSwapHash: string | null; baseUsdcBalanceBeforeAveniaSwapRaw: string | null; @@ -249,6 +253,10 @@ function createFreshState(): UsdcBaseRebalanceState { mainNablaUsdcBalanceBeforeRaw: null, nablaApproveHash: null, nablaSwapHash: null, + opportunisticDeviationBps: null, + opportunisticMaxCostBps: null, + opportunisticRequiresProfit: false, + opportunisticUsdcToBrla: false, polygonBrlaBalanceBeforeTransferRaw: null, squidRouterQuoteUsdc: null, squidRouterSwapHash: null, @@ -330,6 +338,10 @@ export class UsdcBaseStateManager { mainNablaUsdcBalanceBeforeRaw: null, nablaApproveHash: null, nablaSwapHash: null, + opportunisticDeviationBps: null, + opportunisticMaxCostBps: null, + opportunisticRequiresProfit: false, + opportunisticUsdcToBrla: false, polygonBrlaBalanceBeforeTransferRaw: null, squidRouterQuoteUsdc: null, squidRouterSwapHash: null, From 8b4de355e2a5108fdbd6dde53b46b586b9d6a48c Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 18 Jun 2026 16:31:52 +0200 Subject: [PATCH 097/131] Refactor daily bridge limit evaluation and update related logic for paid rebalances --- apps/rebalancer/.env.example | 2 +- apps/rebalancer/README.md | 2 + apps/rebalancer/src/index.ts | 69 +++++++++---------- .../usdc-brla-usdc-base/guards.test.ts | 12 ++-- .../rebalance/usdc-brla-usdc-base/guards.ts | 11 +-- .../rebalance/usdc-brla-usdc-base/index.ts | 17 ++--- .../usdc-brla-usdc-base/notifications.ts | 1 + apps/rebalancer/src/services/stateManager.ts | 63 +++++++---------- .../security-spec/07-operations/rebalancer.md | 8 +-- 9 files changed, 79 insertions(+), 106 deletions(-) diff --git a/apps/rebalancer/.env.example b/apps/rebalancer/.env.example index 8e49c1b9a..617565554 100644 --- a/apps/rebalancer/.env.example +++ b/apps/rebalancer/.env.example @@ -15,7 +15,7 @@ REBALANCING_USD_TO_BRL_AMOUNT=100 SUPABASE_URL=your_supabase_url_here SUPABASE_SERVICE_KEY=your_supabase_service_key_here -# Maximum total USD bridged per day before the rebalancer stops (default 10,000) +# Maximum total USD bridged per day for paid Base runs (default 10,000). Projected-profitable runs bypass this cap. REBALANCING_DAILY_BRIDGE_LIMIT_USD=10000 # Coverage ratio thresholds for triggering each route (default 0.01 each, falls back to REBALANCING_THRESHOLD if unset) diff --git a/apps/rebalancer/README.md b/apps/rebalancer/README.md index e2fe3b7e6..e74981273 100644 --- a/apps/rebalancer/README.md +++ b/apps/rebalancer/README.md @@ -21,6 +21,8 @@ PENDULUM_ACCOUNT_SECRET=xxx For Base rebalancing, the in-range opportunistic USDC→BRLA→USDC trigger is controlled by `REBALANCING_OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS` and defaults to `10` bps when unset. +`REBALANCING_DAILY_BRIDGE_LIMIT_USD` caps paid Base rebalances only: projected-profitable current runs bypass the cap, +but all completed Base runs are recorded in history and count toward later paid-run limit checks. ## Installation diff --git a/apps/rebalancer/src/index.ts b/apps/rebalancer/src/index.ts index 109b23812..c8f2b834b 100644 --- a/apps/rebalancer/src/index.ts +++ b/apps/rebalancer/src/index.ts @@ -6,9 +6,9 @@ import { checkInitialPendulumBalance } from "./rebalance/brla-to-axlusdc/steps.t import { rebalanceBrlaToUsdcBase } from "./rebalance/brla-to-usdc-base"; import { quoteBrlaToUsdcBaseRebalance } from "./rebalance/brla-to-usdc-base/steps.ts"; import { rebalanceUsdcBrlaUsdcBase } from "./rebalance/usdc-brla-usdc-base"; +import { evaluatePaidRunDailyLimit, sumTodayBridgedUsdRaw } from "./rebalance/usdc-brla-usdc-base/dailyLimit.ts"; import { type DailyBridgeLimitDecision, - evaluateDailyBridgeLimit, evaluateRebalancingCostPolicy, isProjectedProfit, type RebalancingCostPolicyDecision, @@ -94,9 +94,7 @@ async function getTodayBridgedUsdRaw(): Promise { const todayStart = new Date(); todayStart.setUTCHours(0, 0, 0, 0); - return [...usdcHistory, ...brlaHistory] - .filter(e => new Date(e.startingTime) >= todayStart) - .reduce((sum, e) => sum.plus(Big(e.initialAmount)), Big(0)); + return sumTodayBridgedUsdRaw(usdcHistory, brlaHistory, todayStart); } async function getDailyBridgeLimitContext(): Promise<{ bridgedToday: Big; dailyLimitRaw: Big }> { @@ -114,12 +112,25 @@ function logDailyLimitDecision(decision: DailyBridgeLimitDecision, dailyLimitUsd if (decision.reason === "under_limit") return; const projectedTotalUsd = Big(decision.projectedTotalRaw).div(1e6).toFixed(2); - if (decision.reason === "profitable_quote") { - console.log(`Daily bridge limit bypassed (profitable quote): projected $${projectedTotalUsd}, limit $${dailyLimitUsd}.`); - return; + console.log(`Daily bridge limit reached: projected $${projectedTotalUsd}, limit $${dailyLimitUsd}. Skipping.`); +} + +async function evaluateCurrentRunDailyLimit( + amountUsdcRaw: string, + profitable: boolean +): Promise { + const config = getConfig(); + if (profitable) { + console.log( + `Daily bridge limit bypassed: projected profitable quote for ${Big(amountUsdcRaw).div(1e6).toFixed(6)} USDC. No limit applies.` + ); + return undefined; } - console.log(`Daily bridge limit reached: projected $${projectedTotalUsd}, limit $${dailyLimitUsd}. Skipping.`); + const dailyLimitDecision = await evaluatePaidRunDailyLimit(amountUsdcRaw, profitable, getDailyBridgeLimitContext); + if (!dailyLimitDecision) return undefined; + logDailyLimitDecision(dailyLimitDecision, config.rebalancingDailyBridgeLimitUsd); + return dailyLimitDecision; } function calculateCoverageDeviationBps(coverageRatio: number, triggerBound: number): number { @@ -222,21 +233,13 @@ async function evaluateUsdcToBrlaPolicy( async function executeUsdcToBrlaRebalance( amountUsdcRaw: string, - bridgedToday: Big, - dailyLimitRaw: Big, coverageDeviationBps: number, policyDecision: Awaited>, options: { opportunistic?: boolean } = {} ): Promise { const config = getConfig(); - const dailyLimitDecision = evaluateDailyBridgeLimit( - bridgedToday, - Big(amountUsdcRaw), - dailyLimitRaw, - policyDecision.profitable - ); - logDailyLimitDecision(dailyLimitDecision, config.rebalancingDailyBridgeLimitUsd); - if (dailyLimitDecision.shouldSkip) return false; + const dailyLimitDecision = await evaluateCurrentRunDailyLimit(amountUsdcRaw, policyDecision.profitable); + if (dailyLimitDecision?.shouldSkip) return false; await checkInitialUsdcBalanceOnBase(amountUsdcRaw); await rebalanceUsdcBrlaUsdcBase(amountUsdcRaw, forceRestart, policyDecision.routeToRun, { @@ -244,6 +247,7 @@ async function executeUsdcToBrlaRebalance( dailyLimitDecision, decision: policyDecision.decision, deviationBps: coverageDeviationBps, + fallbackRequiresProfit: policyDecision.profitable, opportunistic: options.opportunistic, preflightQuotes: policyDecision.routeQuotes }); @@ -266,8 +270,7 @@ async function tryOpportunisticUsdcToBrla(): Promise { } console.log(`Opportunistic USDC->BRLA rebalance triggered at ${policyDecision.decision.costBps} bps projected cost.`); - const { bridgedToday, dailyLimitRaw } = await getDailyBridgeLimitContext(); - return executeUsdcToBrlaRebalance(amountUsdcRaw, bridgedToday, dailyLimitRaw, 0, policyDecision, { opportunistic: true }); + return executeUsdcToBrlaRebalance(amountUsdcRaw, 0, policyDecision, { opportunistic: true }); } async function evaluateBrlaToUsdcPolicy( @@ -302,7 +305,7 @@ async function evaluateBrlaToUsdcPolicy( }; } -async function runUsdcToBrla(bridgedToday: Big, dailyLimitRaw: Big, coverageDeviationBps: number) { +async function runUsdcToBrla(coverageDeviationBps: number) { const config = getConfig(); const amountUsdc = manualAmount || config.rebalancingUsdToBrlAmount; const amountUsdcRaw = multiplyByPowerOfTen(new Big(amountUsdc), 6).toFixed(0, 0); @@ -314,14 +317,14 @@ async function runUsdcToBrla(bridgedToday: Big, dailyLimitRaw: Big, coverageDevi if (!isResuming) { const policyDecision = await evaluateUsdcToBrlaPolicy(amountUsdcRaw, coverageDeviationBps); if (!policyDecision.shouldExecute) return; - await executeUsdcToBrlaRebalance(amountUsdcRaw, bridgedToday, dailyLimitRaw, coverageDeviationBps, policyDecision); + await executeUsdcToBrlaRebalance(amountUsdcRaw, coverageDeviationBps, policyDecision); return; } await rebalanceUsdcBrlaUsdcBase(amountUsdcRaw, forceRestart, forcedRoute); } -async function runBrlaToUsdc(bridgedToday: Big, dailyLimitRaw: Big, coverageDeviationBps: number) { +async function runBrlaToUsdc(coverageDeviationBps: number) { const config = getConfig(); const amountUsdc = manualAmount || config.rebalancingBrlToUsdAmount; const amountUsdcRaw = multiplyByPowerOfTen(new Big(amountUsdc), 6).toFixed(0, 0); @@ -334,14 +337,8 @@ async function runBrlaToUsdc(bridgedToday: Big, dailyLimitRaw: Big, coverageDevi const policyDecision = await evaluateBrlaToUsdcPolicy(amountUsdcRaw, coverageDeviationBps); if (!policyDecision.shouldExecute) return; - const dailyLimitDecision = evaluateDailyBridgeLimit( - bridgedToday, - Big(amountUsdcRaw), - dailyLimitRaw, - policyDecision.profitable - ); - logDailyLimitDecision(dailyLimitDecision, config.rebalancingDailyBridgeLimitUsd); - if (dailyLimitDecision.shouldSkip) return; + const dailyLimitDecision = await evaluateCurrentRunDailyLimit(amountUsdcRaw, policyDecision.profitable); + if (dailyLimitDecision?.shouldSkip) return; const rebalancerUsdcBalance = await checkInitialUsdcBalanceOnBase(amountUsdcRaw); if (config.rebalancingBrlToUsdMinBalance && rebalancerUsdcBalance.lt(config.rebalancingBrlToUsdMinBalance)) { @@ -351,8 +348,10 @@ async function runBrlaToUsdc(bridgedToday: Big, dailyLimitRaw: Big, coverageDevi } await rebalanceBrlaToUsdcBase(amountUsdcRaw, forceRestart, { config: config.rebalancingCostPolicy, + dailyLimitDecision, decision: policyDecision.decision, - deviationBps: coverageDeviationBps + deviationBps: coverageDeviationBps, + fallbackRequiresProfit: policyDecision.profitable }); return; } @@ -375,14 +374,12 @@ async function checkForRebalancing() { return; } - const { bridgedToday, dailyLimitRaw } = await getDailyBridgeLimitContext(); - 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(bridgedToday, dailyLimitRaw, deviationBps); + await runBrlaToUsdc(deviationBps); return; } @@ -390,7 +387,7 @@ async function checkForRebalancing() { console.log( `BRLA coverage ${coverage.brlaCoverageRatio} > ${upperBound}. Evaluating USDC->BRLA (${deviationBps} bps deviation).` ); - await runUsdcToBrla(bridgedToday, dailyLimitRaw, deviationBps); + await runUsdcToBrla(deviationBps); } const rebalanceFn = useLegacy ? checkForRebalancingLegacy : checkForRebalancing; diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.test.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.test.ts index 4e096193e..47d2145eb 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.test.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.test.ts @@ -49,19 +49,15 @@ describe("USDC Base rebalance guards", () => { expect(isProjectedProfit(Big("100000000"), Big("99000000"))).toBe(false); }); - test("evaluates daily bridge limit decisions", () => { - expect(evaluateDailyBridgeLimit(Big("9000000000"), Big("600000000"), Big("10000000000"), false)).toMatchObject({ + test("evaluates daily bridge limit decisions for paid rebalances", () => { + expect(evaluateDailyBridgeLimit(Big("9000000000"), Big("600000000"), Big("10000000000"))).toMatchObject({ reason: "under_limit", shouldSkip: false }); - expect(evaluateDailyBridgeLimit(Big("9500000000"), Big("600000000"), Big("10000000000"), false)).toMatchObject({ + expect(evaluateDailyBridgeLimit(Big("9500000000"), Big("600000000"), Big("10000000000"))).toMatchObject({ reason: "daily_limit_reached", shouldSkip: true }); - expect(evaluateDailyBridgeLimit(Big("9500000000"), Big("600000000"), Big("10000000000"), true)).toMatchObject({ - reason: "profitable_quote", - shouldSkip: false - }); }); test("calculates projected rebalancing cost in basis points", () => { @@ -95,7 +91,7 @@ describe("USDC Base rebalance guards", () => { ).toBe(false); }); - test("requires fallback route profit when the original route bypassed the daily limit as profitable", () => { + test("requires fallback route profit when the original route was projected profitable", () => { expect( evaluateFallbackRoutePolicy(Big("100000000"), Big("100100000"), 0, policyConfig, { opportunisticMaxCostBps: policyConfig.opportunisticUsdcToBrlaMaxCostBps, diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.ts index fb033c6a5..0588a6b84 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.ts @@ -28,7 +28,7 @@ export interface RebalancingCostPolicyDecision { export interface DailyBridgeLimitDecision { projectedTotalRaw: string; - reason: "under_limit" | "profitable_quote" | "daily_limit_reached"; + reason: "under_limit" | "daily_limit_reached"; shouldSkip: boolean; } @@ -60,8 +60,7 @@ export function isProjectedProfit(inputAmountRaw: Big, projectedOutputRaw: Big): export function evaluateDailyBridgeLimit( bridgedTodayRaw: Big, requestedAmountRaw: Big, - dailyLimitRaw: Big, - profitable: boolean + dailyLimitRaw: Big ): DailyBridgeLimitDecision { const projectedTotalRaw = bridgedTodayRaw.plus(requestedAmountRaw).toFixed(0, 0); @@ -69,10 +68,6 @@ export function evaluateDailyBridgeLimit( return { projectedTotalRaw, reason: "under_limit", shouldSkip: false }; } - if (profitable) { - return { projectedTotalRaw, reason: "profitable_quote", shouldSkip: false }; - } - return { projectedTotalRaw, reason: "daily_limit_reached", shouldSkip: true }; } @@ -115,7 +110,7 @@ export function evaluateFallbackRoutePolicy( return { decision, profitable, - reason: "Fallback route is not profitable, but the original route bypassed the daily bridge limit as profitable.", + reason: "Fallback route is not profitable, but the original route was projected profitable.", shouldExecute: false }; } diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts index c0e8d555a..02ec15f03 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts @@ -45,7 +45,7 @@ function getOpportunisticFallbackContext( config: policy.config, deviationBps: policy.deviationBps ?? 0, opportunisticMaxCostBps: policy.config.opportunisticUsdcToBrlaMaxCostBps, - requireProfit: policy.dailyLimitDecision?.reason === "profitable_quote" + requireProfit: policy.fallbackRequiresProfit ?? false }; } @@ -76,21 +76,18 @@ export async function rebalanceUsdcBrlaUsdcBase( if (isResuming) { console.log(`Resuming rebalance from phase: ${state?.currentPhase}`); } else { - state = await stateManager.startNewRebalance(usdcAmountRaw); + state = await stateManager.startNewRebalance(usdcAmountRaw, { + opportunisticDeviationBps: policy?.opportunistic ? (policy.deviationBps ?? 0) : undefined, + opportunisticMaxCostBps: policy?.opportunistic ? policy.config.opportunisticUsdcToBrlaMaxCostBps : undefined, + opportunisticRequiresProfit: policy?.opportunistic ? (policy.fallbackRequiresProfit ?? false) : undefined, + opportunisticUsdcToBrla: policy?.opportunistic ?? false + }); } if (!state) { throw new Error("State is undefined after initialization."); } - if (policy?.opportunistic) { - state.opportunisticUsdcToBrla = true; - state.opportunisticMaxCostBps = policy.config.opportunisticUsdcToBrlaMaxCostBps; - state.opportunisticRequiresProfit = policy.dailyLimitDecision?.reason === "profitable_quote"; - state.opportunisticDeviationBps = policy.deviationBps ?? 0; - await stateManager.saveState(state); - } - const { publicClient: basePublicClient, walletClient: baseWalletClient } = getBaseEvmClients(); const baseAddress = baseWalletClient.account.address as `0x${string}`; const baseNonce = await NonceManager.create(basePublicClient, baseAddress); diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts index 04f241423..eb3fbf5b5 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts @@ -7,6 +7,7 @@ export interface RebalancePolicySummary { dailyLimitDecision?: DailyBridgeLimitDecision; decision?: RebalancingCostPolicyDecision; deviationBps?: number; + fallbackRequiresProfit?: boolean; opportunistic?: boolean; preflightQuotes?: { aveniaQuoteUsdc: string | null; diff --git a/apps/rebalancer/src/services/stateManager.ts b/apps/rebalancer/src/services/stateManager.ts index eebe2d6fc..510d1b972 100644 --- a/apps/rebalancer/src/services/stateManager.ts +++ b/apps/rebalancer/src/services/stateManager.ts @@ -233,7 +233,18 @@ export interface UsdcBaseRebalanceContainer { history: RebalanceHistoryEntry[]; } -function createFreshState(): UsdcBaseRebalanceState { +export interface UsdcBaseRebalanceStartOptions { + opportunisticDeviationBps?: number; + opportunisticMaxCostBps?: number; + opportunisticRequiresProfit?: boolean; + opportunisticUsdcToBrla?: boolean; +} + +export function createUsdcBaseRebalanceState( + usdcAmountRaw: string | null, + currentPhase: UsdcBaseRebalancePhase, + options: UsdcBaseRebalanceStartOptions = {} +): UsdcBaseRebalanceState { return { aveniaBrlaBalanceBeforeTransfer: null, aveniaQuoteUsdc: null, @@ -244,7 +255,7 @@ function createFreshState(): UsdcBaseRebalanceState { brlaAmountRaw: null, brlaBalanceBeforeNablaRaw: null, brlaTransferHash: null, - currentPhase: UsdcBaseRebalancePhase.Idle, + currentPhase, finalUsdcBalance: null, initialUsdcBalance: null, mainNablaApproveHash: null, @@ -253,20 +264,24 @@ function createFreshState(): UsdcBaseRebalanceState { mainNablaUsdcBalanceBeforeRaw: null, nablaApproveHash: null, nablaSwapHash: null, - opportunisticDeviationBps: null, - opportunisticMaxCostBps: null, - opportunisticRequiresProfit: false, - opportunisticUsdcToBrla: false, + opportunisticDeviationBps: options.opportunisticDeviationBps ?? null, + opportunisticMaxCostBps: options.opportunisticMaxCostBps ?? null, + opportunisticRequiresProfit: options.opportunisticRequiresProfit ?? false, + opportunisticUsdcToBrla: options.opportunisticUsdcToBrla ?? false, polygonBrlaBalanceBeforeTransferRaw: null, squidRouterQuoteUsdc: null, squidRouterSwapHash: null, startingTime: new Date().toISOString(), updatedTime: new Date().toISOString(), - usdcAmountRaw: null, + usdcAmountRaw, winningRoute: null }; } +function createFreshState(): UsdcBaseRebalanceState { + return createUsdcBaseRebalanceState(null, UsdcBaseRebalancePhase.Idle); +} + export class UsdcBaseStateManager { private inner: StateManager; @@ -315,41 +330,11 @@ export class UsdcBaseStateManager { await this.inner.saveState(existing); } - async startNewRebalance(usdcAmountRaw: string): Promise { + async startNewRebalance(usdcAmountRaw: string, options: UsdcBaseRebalanceStartOptions = {}): Promise { const existing = await this.getContainer(); const history = existing?.history ?? []; - const state: UsdcBaseRebalanceState = { - aveniaBrlaBalanceBeforeTransfer: null, - aveniaQuoteUsdc: null, - aveniaTicketId: null, - baseUsdcBalanceBeforeAveniaSwapRaw: null, - baseUsdcBalanceBeforeSquidSwapRaw: null, - brlaAmountDecimal: null, - brlaAmountRaw: null, - brlaBalanceBeforeNablaRaw: null, - brlaTransferHash: null, - currentPhase: UsdcBaseRebalancePhase.CheckInitialUsdcBalance, - finalUsdcBalance: null, - initialUsdcBalance: null, - mainNablaApproveHash: null, - mainNablaQuoteUsdc: null, - mainNablaSwapHash: null, - mainNablaUsdcBalanceBeforeRaw: null, - nablaApproveHash: null, - nablaSwapHash: null, - opportunisticDeviationBps: null, - opportunisticMaxCostBps: null, - opportunisticRequiresProfit: false, - opportunisticUsdcToBrla: false, - polygonBrlaBalanceBeforeTransferRaw: null, - squidRouterQuoteUsdc: null, - squidRouterSwapHash: null, - startingTime: new Date().toISOString(), - updatedTime: new Date().toISOString(), - usdcAmountRaw, - winningRoute: null - }; + const state = createUsdcBaseRebalanceState(usdcAmountRaw, UsdcBaseRebalancePhase.CheckInitialUsdcBalance, options); await this.inner.saveState({ history, state }); return state; } diff --git a/docs/security-spec/07-operations/rebalancer.md b/docs/security-spec/07-operations/rebalancer.md index f69ec7213..cf940968e 100644 --- a/docs/security-spec/07-operations/rebalancer.md +++ b/docs/security-spec/07-operations/rebalancer.md @@ -64,7 +64,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- **Trigger condition:** Base Nabla BRLA pool coverage ratio > `1 + REBALANCING_THRESHOLD_USDC_TO_BRLA` (default upper bound `1.01`). Falls back to `REBALANCING_THRESHOLD` when the route-specific threshold is unset. This makes the flow eligible for evaluation; cost policy may still skip fresh execution. If coverage is inside the configured bounds, the same flow can run opportunistically with zero coverage deviation only when the selected quote's projected route cost is below `REBALANCING_OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS` (default 10 bps). -**Daily bridge limit:** Total requested USDC amount recorded by Base-flow history per calendar day (UTC), including the amount about to be rebalanced, must not exceed `REBALANCING_DAILY_BRIDGE_LIMIT_USD` (default 10,000) unless the selected quote projects a profit. Profit is inferred from projected output USDC greater than input USDC, which also yields negative projected cost bps. The limit decision is checked against both `UsdcBaseStateManager` and `BrlaToUsdcBaseStateManager` history after quote/cost-policy evaluation and before any fresh Base state write or transaction. Completed profitable runs still write normal history entries, so they remain visible in daily accounting. +**Daily bridge limit:** Total requested USDC amount recorded by Base-flow history per calendar day (UTC), including the amount about to be rebalanced, must not exceed `REBALANCING_DAILY_BRIDGE_LIMIT_USD` (default 10,000) for paid current runs. Profit is inferred from projected output USDC greater than input USDC, which also yields negative projected cost bps. Projected-profitable current runs bypass the cap entirely. For paid runs, the limit decision is checked against both `UsdcBaseStateManager` and `BrlaToUsdcBaseStateManager` history after quote/cost-policy evaluation and before any fresh Base state write or transaction. Completed profitable runs still write normal history entries, so they remain visible in later paid-run daily accounting. **Urgency-band policy:** Before any state write or transaction, the flow quotes the expected round-trip USDC output. Projected cost is `(input USDC - projected output USDC) / input USDC` in basis points. `auto` mode executes only when the projected cost is within the configured limit for the current coverage-deviation band. `dry-run` logs the same decision but never starts a rebalance. `off` skips without quoting. `always` can execute above the band limit, but not above `REBALANCING_HARD_MAX_COST_BPS`; the daily bridge limit still applies unless the quote projects profit. @@ -149,11 +149,11 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- ### Base flow invariants -11. **Daily bridge limit MUST be enforced except for projected-profit quotes** — 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. Projected-profitable quotes may bypass the cap, but completed profitable runs must still be recorded in history. +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. 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 route bypassed the daily bridge limit as profitable. +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. 16. **Severe imbalances MAY use higher tolerance but MUST NOT bypass hard cost caps** — Severe band can permit higher projected cost, but it cannot bypass `REBALANCING_HARD_MAX_COST_BPS`, balance checks, slippage limits, or phase safety checks. It also cannot bypass the daily bridge limit unless the selected quote projects profit. 17. **Route comparison MUST handle provider failures gracefully** — If every enabled return route quote fails, the high-coverage flow MUST abort (not proceed with zero information). If some routes fail, the best available route is used. If `--route=` is specified, that route is still quoted and cost-gated before execution. 18. **Avenia fallback to SquidRouter MUST be atomic in state** — If Avenia ticket creation fails, the flow sets `winningRoute = "squidrouter"` and `currentPhase = AveniaTransferToPolygon` in a single `saveState()` call. A crash between the failure and the save could leave the flow in an inconsistent state. @@ -227,7 +227,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- ### Base flows - [x] **FINDING**: Axelar polling has 30-minute timeout — resolves F-034 for Base flow. **PASS** — `axelarTimeout = 30 * 60 * 1000` enforced in `squidRouterApproveAndSwap()`. -- [x] **FINDING**: Daily bridge limit check — `REBALANCING_DAILY_BRIDGE_LIMIT_USD` (default 10,000) enforced against both Base-flow histories plus the current requested amount. **PASS** — checked after quote/cost-policy evaluation and before fresh Base side effects. Non-profitable quotes are blocked over the cap; projected-profitable quotes may bypass and are still recorded in history after completion. +- [x] **FINDING**: Daily bridge limit check — `REBALANCING_DAILY_BRIDGE_LIMIT_USD` (default 10,000) enforced against both Base-flow histories plus the current requested amount for paid runs. **PASS** — checked after quote/cost-policy evaluation and before fresh Base side effects for non-profitable quotes. Projected-profitable current runs bypass the cap and are still recorded in history after completion. - [x] **FINDING**: Opportunistic in-range trigger — Base coverage inside configured bounds can still run USDC→BRLA→USDC only when projected route cost is below `REBALANCING_OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS` (default 10 bps). **PASS** — uses the same quote/cost-policy path with zero coverage deviation, then applies the configured opportunistic cap before balance checks and state-machine execution. Opportunistic Avenia fallback to SquidRouter is blocked unless the preflight SquidRouter quote independently passes the same policy and profitable-bypass requirements. - [x] **FINDING**: Avenia fallback to SquidRouter — if Avenia ticket creation fails, flow falls back to SquidRouter route. **PASS** — error caught, `winningRoute` updated, state saved atomically. - [x] **FINDING**: `EVM_ACCOUNT_SECRET` single mnemonic for all EVM chains — broad EVM blast radius across Base, Polygon, and Moonbeam. **PASS (accepted)** — deliberate simplification; documented in invariants. From c058d8d2ac813444f9ad7557c7bfa58d10348f11 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 18 Jun 2026 19:03:23 +0200 Subject: [PATCH 098/131] Add missing module --- .../usdc-brla-usdc-base/dailyLimit.test.ts | 50 +++++++++++++++++++ .../usdc-brla-usdc-base/dailyLimit.ts | 27 ++++++++++ .../src/services/stateManager.test.ts | 19 +++++++ 3 files changed, 96 insertions(+) create mode 100644 apps/rebalancer/src/rebalance/usdc-brla-usdc-base/dailyLimit.test.ts create mode 100644 apps/rebalancer/src/rebalance/usdc-brla-usdc-base/dailyLimit.ts create mode 100644 apps/rebalancer/src/services/stateManager.test.ts diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/dailyLimit.test.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/dailyLimit.test.ts new file mode 100644 index 000000000..6b5d8dedb --- /dev/null +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/dailyLimit.test.ts @@ -0,0 +1,50 @@ +import {describe, expect, test} from "bun:test"; +import Big from "big.js"; +import {evaluatePaidRunDailyLimit, sumTodayBridgedUsdRaw} from "./dailyLimit.ts"; + +describe("Base rebalancer daily limit orchestration", () => { + test("profitable current runs bypass daily history lookup", async () => { + let contextCalls = 0; + + const decision = await evaluatePaidRunDailyLimit("600000000", true, async () => { + contextCalls += 1; + return { bridgedToday: Big("9500000000"), dailyLimitRaw: Big("10000000000") }; + }); + + expect(decision).toBeUndefined(); + expect(contextCalls).toBe(0); + }); + + test("paid current runs enforce the daily limit after loading history context", async () => { + let contextCalls = 0; + + const decision = await evaluatePaidRunDailyLimit("600000000", false, async () => { + contextCalls += 1; + return { bridgedToday: Big("9500000000"), dailyLimitRaw: Big("10000000000") }; + }); + + expect(contextCalls).toBe(1); + expect(decision).toMatchObject({ + projectedTotalRaw: "10100000000", + reason: "daily_limit_reached", + shouldSkip: true + }); + }); + + test("combined Base history counts all completed runs for later paid-run checks", () => { + const now = new Date("2026-06-18T12:00:00.000Z"); + const yesterday = "2026-06-17T23:59:59.999Z"; + const today = "2026-06-18T00:00:00.000Z"; + + const bridgedToday = sumTodayBridgedUsdRaw( + [ + { cost: "-1", costRelative: "-0.001", endingTime: today, initialAmount: "200000000", startingTime: today }, + { cost: "1", costRelative: "0.001", endingTime: yesterday, initialAmount: "999000000", startingTime: yesterday } + ], + [{ cost: "2", costRelative: "0.002", endingTime: today, initialAmount: "300000000", startingTime: today }], + now + ); + + expect(bridgedToday.toString()).toBe("500000000"); + }); +}); diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/dailyLimit.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/dailyLimit.ts new file mode 100644 index 000000000..b2efbc76a --- /dev/null +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/dailyLimit.ts @@ -0,0 +1,27 @@ +import Big from "big.js"; +import type { RebalanceHistoryEntry } from "../../services/stateManager.ts"; +import { type DailyBridgeLimitDecision, evaluateDailyBridgeLimit } from "./guards.ts"; + +export function sumTodayBridgedUsdRaw( + usdcHistory: RebalanceHistoryEntry[], + brlaHistory: RebalanceHistoryEntry[], + now = new Date() +): Big { + const todayStart = new Date(now); + todayStart.setUTCHours(0, 0, 0, 0); + + return [...usdcHistory, ...brlaHistory] + .filter(e => new Date(e.startingTime) >= todayStart) + .reduce((sum, e) => sum.plus(Big(e.initialAmount)), Big(0)); +} + +export async function evaluatePaidRunDailyLimit( + amountUsdcRaw: string, + profitable: boolean, + getDailyBridgeLimitContext: () => Promise<{ bridgedToday: Big; dailyLimitRaw: Big }> +): Promise { + if (profitable) return undefined; + + const { bridgedToday, dailyLimitRaw } = await getDailyBridgeLimitContext(); + return evaluateDailyBridgeLimit(bridgedToday, Big(amountUsdcRaw), dailyLimitRaw); +} diff --git a/apps/rebalancer/src/services/stateManager.test.ts b/apps/rebalancer/src/services/stateManager.test.ts new file mode 100644 index 000000000..5f1152fb3 --- /dev/null +++ b/apps/rebalancer/src/services/stateManager.test.ts @@ -0,0 +1,19 @@ +import {describe, expect, test} from "bun:test"; +import {createUsdcBaseRebalanceState, UsdcBaseRebalancePhase} from "./stateManager.ts"; + +describe("USDC Base rebalance state", () => { + test("stores opportunistic fallback policy in the initial state payload", () => { + const state = createUsdcBaseRebalanceState("100000000", UsdcBaseRebalancePhase.CheckInitialUsdcBalance, { + opportunisticDeviationBps: 0, + opportunisticMaxCostBps: 10, + opportunisticRequiresProfit: true, + opportunisticUsdcToBrla: true + }); + + expect(state.usdcAmountRaw).toBe("100000000"); + expect(state.opportunisticUsdcToBrla).toBe(true); + expect(state.opportunisticMaxCostBps).toBe(10); + expect(state.opportunisticRequiresProfit).toBe(true); + expect(state.opportunisticDeviationBps).toBe(0); + }); +}); From 5c195d7e4a92bb0e23017cdb5cd13f2f7ab91a1d Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 18 Jun 2026 19:24:30 +0200 Subject: [PATCH 099/131] Address PR` feedback --- apps/rebalancer/src/index.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/rebalancer/src/index.ts b/apps/rebalancer/src/index.ts index c8f2b834b..034bdf46c 100644 --- a/apps/rebalancer/src/index.ts +++ b/apps/rebalancer/src/index.ts @@ -91,10 +91,7 @@ async function getTodayBridgedUsdRaw(): Promise { const [usdcHistory, brlaHistory] = await Promise.all([usdcStateManager.getHistory(), brlaStateManager.getHistory()]); - const todayStart = new Date(); - todayStart.setUTCHours(0, 0, 0, 0); - - return sumTodayBridgedUsdRaw(usdcHistory, brlaHistory, todayStart); + return sumTodayBridgedUsdRaw(usdcHistory, brlaHistory); } async function getDailyBridgeLimitContext(): Promise<{ bridgedToday: Big; dailyLimitRaw: Big }> { From af0ddddb4d6caebed852eb4737d8ee38a3e5e292 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 19 Jun 2026 18:19:59 +0200 Subject: [PATCH 100/131] Add discount fields to shared responses --- packages/shared/src/endpoints/quote.endpoints.ts | 5 +++++ packages/shared/src/endpoints/ramp.endpoints.ts | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/packages/shared/src/endpoints/quote.endpoints.ts b/packages/shared/src/endpoints/quote.endpoints.ts index a5367bdae..616182c81 100644 --- a/packages/shared/src/endpoints/quote.endpoints.ts +++ b/packages/shared/src/endpoints/quote.endpoints.ts @@ -71,6 +71,11 @@ export interface QuoteResponse { totalFeeUsd: string; processingFeeUsd: string; + // User benefit from quote-time discount, displayed in feeCurrency when present + discountFiat?: string; + discountUsd?: string; + discountCurrency?: RampCurrency; + paymentMethod: PaymentMethod; expiresAt: Date; createdAt: Date; diff --git a/packages/shared/src/endpoints/ramp.endpoints.ts b/packages/shared/src/endpoints/ramp.endpoints.ts index ab4a9ecbb..76589f85f 100644 --- a/packages/shared/src/endpoints/ramp.endpoints.ts +++ b/packages/shared/src/endpoints/ramp.endpoints.ts @@ -261,6 +261,10 @@ export interface GetRampStatusResponse extends RampProcess { vortexFeeUsd: string; totalFeeUsd: string; processingFeeUsd: string; + // User benefit from quote-time discount, displayed in feeCurrency when present + discountFiat?: string; + discountUsd?: string; + discountCurrency?: string; } export interface GetRampErrorLogsRequest { From 10c8a59d64e4f0968ed02c582ff7b053d2dac9d6 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 19 Jun 2026 18:20:21 +0200 Subject: [PATCH 101/131] Serialize quote discount display data --- apps/api/src/api/services/quote/core/types.ts | 6 ++ .../quote/engines/finalize/index.test.ts | 92 +++++++++++++++++++ .../services/quote/engines/finalize/index.ts | 53 ++++++++++- 3 files changed, 150 insertions(+), 1 deletion(-) diff --git a/apps/api/src/api/services/quote/core/types.ts b/apps/api/src/api/services/quote/core/types.ts index f4138e233..b57bca277 100644 --- a/apps/api/src/api/services/quote/core/types.ts +++ b/apps/api/src/api/services/quote/core/types.ts @@ -261,6 +261,12 @@ export interface QuoteContext { targetOutputAmountRaw: string; }; + subsidyDisplay?: { + fiat: string; + usd: string; + currency: RampCurrency; + }; + // Accumulated logs/notes for debugging (optional) notes?: string[]; // Allow engines to supply a ready response (used by special-case engine and finalize stage) diff --git a/apps/api/src/api/services/quote/engines/finalize/index.test.ts b/apps/api/src/api/services/quote/engines/finalize/index.test.ts index 3ad8001ea..30d082725 100644 --- a/apps/api/src/api/services/quote/engines/finalize/index.test.ts +++ b/apps/api/src/api/services/quote/engines/finalize/index.test.ts @@ -2,6 +2,7 @@ import {afterEach, describe, expect, it, mock} from "bun:test"; import {EPaymentMethod, EvmToken, FiatToken, Networks, RampDirection} from "@vortexfi/shared"; import Big from "big.js"; import QuoteTicket from "../../../../../models/quoteTicket.model"; +import {priceFeedService} from "../../../priceFeed.service"; import {QuoteContext} from "../../core/types"; import {BaseFinalizeEngine, FinalizeComputation} from "."; @@ -22,9 +23,11 @@ class TestFinalizeEngine extends BaseFinalizeEngine { describe("BaseFinalizeEngine", () => { const originalQuoteTicketCreate = QuoteTicket.create; + const originalConvertCurrency = priceFeedService.convertCurrency; afterEach(() => { QuoteTicket.create = originalQuoteTicketCreate; + priceFeedService.convertCurrency = originalConvertCurrency; }); it("persists profile-priced quotes as user-owned with a separate pricing partner", async () => { @@ -81,4 +84,93 @@ describe("BaseFinalizeEngine", () => { userId: "user-1" }); }); + + it("serializes applied subsidy as a separate public discount benefit", async () => { + const createdAt = new Date("2026-06-03T12:00:00.000Z"); + const expiresAt = new Date("2026-06-03T12:10:00.000Z"); + const quoteCreateMock = mock(async data => ({ + ...data, + createdAt, + expiresAt, + id: "quote-1" + })); + QuoteTicket.create = quoteCreateMock as unknown as typeof QuoteTicket.create; + priceFeedService.convertCurrency = mock(async (amount, _from, to) => { + if (to === FiatToken.BRL) { + return new Big(amount).mul(5).toString(); + } + return amount; + }) as typeof priceFeedService.convertCurrency; + + const ctx = { + addNote: mock(() => undefined), + fees: { + displayFiat: { + anchor: "1", + currency: FiatToken.BRL, + network: "0", + partnerMarkup: "2", + total: "13", + vortex: "10" + }, + usd: { + anchor: "0.2", + network: "0", + partnerMarkup: "0.4", + total: "2.6", + vortex: "2" + } + }, + nablaSwapEvm: { + inputAmountForSwapDecimal: "100", + inputAmountForSwapRaw: "100000000", + inputCurrency: EvmToken.BRLA, + inputDecimals: 6, + inputToken: "0xbrla", + outputAmountDecimal: new Big("98"), + outputAmountRaw: "98000000", + outputCurrency: EvmToken.USDC, + outputDecimals: 6, + outputToken: "0xusdc" + }, + request: { + from: EPaymentMethod.PIX, + inputAmount: "100", + inputCurrency: FiatToken.BRL, + network: Networks.Base, + outputCurrency: EvmToken.USDC, + rampType: RampDirection.BUY, + to: Networks.Base + }, + subsidy: { + actualOutputAmountDecimal: new Big("98"), + actualOutputAmountRaw: "98000000", + applied: true, + expectedOutputAmountDecimal: new Big("100"), + expectedOutputAmountRaw: "100000000", + idealSubsidyAmountInOutputTokenDecimal: new Big("2"), + idealSubsidyAmountInOutputTokenRaw: "2000000", + partnerId: "partner-1", + subsidyAmountInOutputTokenDecimal: new Big("2"), + subsidyAmountInOutputTokenRaw: "2000000", + subsidyRate: new Big("0.02"), + targetOutputAmountDecimal: new Big("100"), + targetOutputAmountRaw: "100000000" + }, + targetFeeFiatCurrency: FiatToken.BRL + } as unknown as QuoteContext; + + await new TestFinalizeEngine().execute(ctx); + + expect(quoteCreateMock.mock.calls[0][0].metadata.subsidyDisplay).toEqual({ + currency: FiatToken.BRL, + fiat: "10.00", + usd: "2.000000" + }); + expect(ctx.builtResponse).toMatchObject({ + discountCurrency: FiatToken.BRL, + discountFiat: "10.00", + discountUsd: "2.000000" + }); + }); }); diff --git a/apps/api/src/api/services/quote/engines/finalize/index.ts b/apps/api/src/api/services/quote/engines/finalize/index.ts index a4134adde..3a6997415 100644 --- a/apps/api/src/api/services/quote/engines/finalize/index.ts +++ b/apps/api/src/api/services/quote/engines/finalize/index.ts @@ -1,9 +1,10 @@ -import { getPaymentMethodFromDestinations, QuoteResponse, RampDirection } from "@vortexfi/shared"; +import { EvmToken, getPaymentMethodFromDestinations, QuoteResponse, RampCurrency, RampDirection } from "@vortexfi/shared"; import Big from "big.js"; import httpStatus from "http-status"; import { config } from "../../../../../config/vars"; import QuoteTicket from "../../../../../models/quoteTicket.model"; import { APIError } from "../../../../errors/api-error"; +import { priceFeedService } from "../../../priceFeed.service"; import { trimTrailingZeros } from "../../core/helpers"; import { QuoteContext, Stage, StageKey } from "../../core/types"; @@ -28,6 +29,41 @@ function getExpirationDate(ctx: QuoteContext): Date { return new Date(Date.now() + 10 * 60 * 1000); } +function getSubsidySourceCurrency(ctx: QuoteContext): RampCurrency | null { + return ( + ctx.nablaSwapEvm?.outputCurrency ?? + ctx.nablaSwap?.outputCurrency ?? + ctx.alfredpayMint?.currency ?? + ctx.alfredpayOfframp?.currency ?? + null + ); +} + +async function assignSubsidyDisplay(ctx: QuoteContext): Promise { + const subsidy = ctx.subsidy; + if (!subsidy?.applied || subsidy.subsidyAmountInOutputTokenDecimal.lte(0)) { + ctx.subsidyDisplay = undefined; + return; + } + + const sourceCurrency = getSubsidySourceCurrency(ctx); + if (!sourceCurrency) { + throw new APIError({ message: "Missing subsidy currency", status: httpStatus.INTERNAL_SERVER_ERROR }); + } + + const subsidyAmount = subsidy.subsidyAmountInOutputTokenDecimal.toString(); + const [discountFiat, discountUsd] = await Promise.all([ + priceFeedService.convertCurrency(subsidyAmount, sourceCurrency, ctx.targetFeeFiatCurrency), + priceFeedService.convertCurrency(subsidyAmount, sourceCurrency, EvmToken.USDC as RampCurrency) + ]); + + ctx.subsidyDisplay = { + currency: ctx.targetFeeFiatCurrency, + fiat: new Big(discountFiat).toFixed(2), + usd: new Big(discountUsd).toFixed(6) + }; +} + export function buildQuoteResponse(quoteTicket: QuoteTicket): QuoteResponse { const usdFees = quoteTicket.metadata.fees?.usd; const fiatFees = quoteTicket.metadata.fees?.displayFiat; @@ -62,6 +98,13 @@ export function buildQuoteResponse(quoteTicket: QuoteTicket): QuoteResponse { processingFeeFiat, processingFeeUsd, rampType: quoteTicket.rampType, + ...(quoteTicket.metadata.subsidyDisplay + ? { + discountCurrency: quoteTicket.metadata.subsidyDisplay.currency, + discountFiat: quoteTicket.metadata.subsidyDisplay.fiat, + discountUsd: quoteTicket.metadata.subsidyDisplay.usd + } + : {}), to: quoteTicket.to, totalFeeFiat: fiatFees.total, totalFeeUsd: usdFees.total, @@ -92,6 +135,7 @@ export abstract class BaseFinalizeEngine implements Stage { await this.validate(ctx, computation); const outputAmountStr = computation.amount.toFixed(computation.decimals, 0); + await assignSubsidyDisplay(ctx); const paymentMethod = getPaymentMethodFromDestinations(request.from, request.to); @@ -132,6 +176,13 @@ export abstract class BaseFinalizeEngine implements Stage { processingFeeFiat, processingFeeUsd, rampType: request.rampType, + ...(ctx.subsidyDisplay + ? { + discountCurrency: ctx.subsidyDisplay.currency, + discountFiat: ctx.subsidyDisplay.fiat, + discountUsd: ctx.subsidyDisplay.usd + } + : {}), to: request.to, totalFeeFiat: fiatFees.total, totalFeeUsd: usdFees.total, From daacf6a12d68b9699fb8e5572e579865d4f637d2 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 19 Jun 2026 18:20:54 +0200 Subject: [PATCH 102/131] Expose discount fields on API surfaces --- apps/api/src/api/routes/v1/quote.route.ts | 3 +++ apps/api/src/api/services/ramp/ramp.service.ts | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/apps/api/src/api/routes/v1/quote.route.ts b/apps/api/src/api/routes/v1/quote.route.ts index c8225e1dd..9dc6d724a 100644 --- a/apps/api/src/api/routes/v1/quote.route.ts +++ b/apps/api/src/api/routes/v1/quote.route.ts @@ -96,6 +96,9 @@ router * @apiSuccess (Created 201) {String} partnerFeeUsd Partner fee in USD * @apiSuccess (Created 201) {String} totalFeeUsd Total fee in USD * @apiSuccess (Created 201) {String} processingFeeUsd Processing fee (anchor + vortex) in USD + * @apiSuccess (Created 201) {String} [discountFiat] Quote-time discount benefit in feeCurrency + * @apiSuccess (Created 201) {String} [discountUsd] Quote-time discount benefit in USD + * @apiSuccess (Created 201) {String} [discountCurrency] Currency used for discount display * @apiSuccess (Created 201) {String} paymentMethod Payment method used for the quote * @apiSuccess (Created 201) {Date} expiresAt Expiration date * diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index 6bfe6c654..fbdbfcb74 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -600,6 +600,13 @@ export class RampService extends BaseRampService { quoteId: rampState.quoteId, sessionId: rampState.state.sessionId, status: this.mapPhaseToStatus(rampState.currentPhase), + ...(quote.metadata.subsidyDisplay + ? { + discountCurrency: quote.metadata.subsidyDisplay.currency, + discountFiat: quote.metadata.subsidyDisplay.fiat, + discountUsd: quote.metadata.subsidyDisplay.usd + } + : {}), to: rampState.to, totalFeeFiat: fiatFees.total, totalFeeUsd: usdFees.total, From 17e8d4e59cf2f99a92352e6317a473f87518a2a9 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 19 Jun 2026 18:21:15 +0200 Subject: [PATCH 103/131] Show discount in quote fee details --- .../src/components/RampFeeCollapse/index.tsx | 19 +++++- .../src/stories/RampFeeCollapse.stories.tsx | 63 +++++++++++++++++++ apps/frontend/src/translations/en.json | 4 ++ apps/frontend/src/translations/pt.json | 4 ++ 4 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 apps/frontend/src/stories/RampFeeCollapse.stories.tsx diff --git a/apps/frontend/src/components/RampFeeCollapse/index.tsx b/apps/frontend/src/components/RampFeeCollapse/index.tsx index e3a9478a4..2bc3779e4 100644 --- a/apps/frontend/src/components/RampFeeCollapse/index.tsx +++ b/apps/frontend/src/components/RampFeeCollapse/index.tsx @@ -44,6 +44,10 @@ function calculateNetExchangeRate(inputAmountString: Big.BigSource, outputAmount return inputAmount.gt(0) ? outputAmount.div(inputAmount).toNumber() : 0; } +function formatFeeAmount(amount: Big): string { + return amount.toFixed(2); +} + export function RampFeeCollapse() { const { t } = useTranslation(); @@ -57,6 +61,8 @@ export function RampFeeCollapse() { ? availableQuote : { anchorFeeFiat: "0", + discountCurrency: fiatToken, + discountFiat: "0", feeCurrency: fiatToken, inputAmount: 0, inputCurrency: rampDirection === RampDirection.BUY ? fiatToken : onChainToken, @@ -79,6 +85,7 @@ export function RampFeeCollapse() { quote.totalFeeFiat || "0" ); const netExchangeRate = calculateNetExchangeRate(quote.inputAmount, quote.outputAmount); + const effectiveTotalFee = Big(quote.totalFeeFiat || "0").minus(quote.discountFiat || "0"); // Generate fee items for display const feeItems: FeeItem[] = []; @@ -92,6 +99,14 @@ export function RampFeeCollapse() { }); } + if (Big(quote.discountFiat || "0").gt(0)) { + feeItems.push({ + label: t("components.feeCollapse.discount.label"), + tooltip: t("components.feeCollapse.discount.tooltip"), + value: `+ ${Big(quote.discountFiat || "0").toFixed(2)} ${(quote.discountCurrency || quote.feeCurrency || fiatToken).toUpperCase()}` + }); + } + if (Big(quote.networkFeeFiat || "0").gt(0)) { feeItems.push({ label: t("components.feeCollapse.networkFee.label"), @@ -116,7 +131,7 @@ export function RampFeeCollapse() {
{feeItems.map((item, index) => (
-
+
{item.label}{" "} {item.tooltip && (
@@ -134,7 +149,7 @@ export function RampFeeCollapse() { {t("components.feeCollapse.totalFee")}
- {quote.totalFeeFiat} {(quote.feeCurrency || fiatToken).toUpperCase()} + {formatFeeAmount(effectiveTotalFee)} {(quote.feeCurrency || fiatToken).toUpperCase()}
diff --git a/apps/frontend/src/stories/RampFeeCollapse.stories.tsx b/apps/frontend/src/stories/RampFeeCollapse.stories.tsx new file mode 100644 index 000000000..0481c7c39 --- /dev/null +++ b/apps/frontend/src/stories/RampFeeCollapse.stories.tsx @@ -0,0 +1,63 @@ +import type { Decorator, Meta, StoryObj } from "@storybook/react"; +import { EPaymentMethod, EvmToken, FiatToken, Networks, QuoteResponse, RampDirection } from "@vortexfi/shared"; +import { RampFeeCollapse } from "../components/RampFeeCollapse"; +import { useQuoteFormStore } from "../stores/quote/useQuoteFormStore"; +import { useQuoteStore } from "../stores/quote/useQuoteStore"; +import { useRampDirectionStore } from "../stores/rampDirectionStore"; + +const subsidizedQuote: QuoteResponse = { + anchorFeeFiat: "2.50", + anchorFeeUsd: "0.50", + createdAt: new Date(), + discountCurrency: FiatToken.BRL, + discountFiat: "5.00", + discountUsd: "1.000000", + expiresAt: new Date("2026-12-31T23:59:59Z"), + feeCurrency: FiatToken.BRL, + from: EPaymentMethod.PIX, + id: "quote_subsidized_brl_to_usdc", + inputAmount: "500.00", + inputCurrency: FiatToken.BRL, + network: Networks.Base, + networkFeeFiat: "0.00", + networkFeeUsd: "0.00", + outputAmount: "96.45", + outputCurrency: EvmToken.USDC, + partnerFeeFiat: "1.00", + partnerFeeUsd: "0.20", + paymentMethod: EPaymentMethod.PIX, + processingFeeFiat: "2.50", + processingFeeUsd: "0.50", + rampType: RampDirection.BUY, + to: Networks.Base, + totalFeeFiat: "3.50", + totalFeeUsd: "0.70", + vortexFeeFiat: "0.00", + vortexFeeUsd: "0.00" +}; + +const withQuoteState = + (quote: QuoteResponse): Decorator => + (Story, context) => { + useRampDirectionStore.getState().onToggle(quote.rampType); + useQuoteFormStore.getState().actions.setFiatToken(quote.inputCurrency as FiatToken); + useQuoteFormStore.getState().actions.setOnChainToken(quote.outputCurrency as EvmToken); + useQuoteStore.getState().actions.forceSetQuote(quote); + return Story(context); + }; + +const meta: Meta = { + component: RampFeeCollapse, + parameters: { + layout: "centered" + }, + tags: ["autodocs"], + title: "Components/RampFeeCollapse" +}; + +export default meta; +type Story = StoryObj; + +export const SubsidizedQuote: Story = { + decorators: [withQuoteState(subsidizedQuote)] +}; diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index 6f81f755b..01f5034a2 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -350,6 +350,10 @@ }, "feeCollapse": { "details": "Details", + "discount": { + "label": "Discount", + "tooltip": "Vortex is improving this quote with a discount. This is separate from fees." + }, "finalAmount": "Final Amount", "netRate": { "label": "Effective Rate", diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index 26f233b76..02569fc2d 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -353,6 +353,10 @@ }, "feeCollapse": { "details": "Detalhes", + "discount": { + "label": "Desconto", + "tooltip": "A Vortex está melhorando esta cotação com um desconto. Isso é separado das taxas." + }, "finalAmount": "Valor final", "netRate": { "label": "Câmbio Efetiva", From 0842dfd7609ec441fa3e9154f1f405090a0cc720 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 19 Jun 2026 18:21:39 +0200 Subject: [PATCH 104/131] Show discount in confirmation fee details --- .../widget-steps/SummaryStep/FeeDetails.tsx | 21 ++++++++++++++++++- .../SummaryStep/TransactionTokensDisplay.tsx | 8 +++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/components/widget-steps/SummaryStep/FeeDetails.tsx b/apps/frontend/src/components/widget-steps/SummaryStep/FeeDetails.tsx index 7085ac8a3..d2f18d42a 100644 --- a/apps/frontend/src/components/widget-steps/SummaryStep/FeeDetails.tsx +++ b/apps/frontend/src/components/widget-steps/SummaryStep/FeeDetails.tsx @@ -1,4 +1,5 @@ import { FiatTokenDetails, isFiatTokenDetails, OnChainTokenDetails, QuoteFeeStructure, RampDirection } from "@vortexfi/shared"; +import Big from "big.js"; import { FC } from "react"; import { useTranslation } from "react-i18next"; import { InterbankExchangeRate } from "../../InterbankExchangeRate"; @@ -6,6 +7,10 @@ import { InterbankExchangeRate } from "../../InterbankExchangeRate"; interface FeeDetailsProps { feesCost: QuoteFeeStructure; exchangeRate: string; + discount?: { + amount: string; + currency: string; + }; fromToken: OnChainTokenDetails | FiatTokenDetails; toToken: OnChainTokenDetails | FiatTokenDetails; partnerUrl: string; @@ -19,6 +24,7 @@ export const FeeDetails: FC = ({ fromToken, toToken, exchangeRate, + discount, partnerUrl, direction, destinationAddress, @@ -34,6 +40,9 @@ export const FeeDetails: FC = ({ } const inputCurrency = isOfframp ? fromToken.assetSymbol : fiatToken.fiat.symbol; const outputCurrency = isOfframp ? fiatToken.fiat.symbol : toToken.assetSymbol; + const effectiveTotalFee = Big(feesCost.total || "0") + .minus(discount?.amount || "0") + .toFixed(2); return (
@@ -41,10 +50,20 @@ export const FeeDetails: FC = ({

{isOfframp ? t("components.SummaryPage.offrampFee") : t("components.SummaryPage.onrampFee")}

- {feesCost.total} {feesCost.currency.toUpperCase()} + {effectiveTotalFee} {feesCost.currency.toUpperCase()}

+ {discount && ( +
+

{t("components.feeCollapse.discount.label")}

+

+ + + {discount.amount} {discount.currency.toUpperCase()} + +

+
+ )}

{t("components.SummaryPage.quote")}

diff --git a/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx b/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx index 82dd1404f..2b8b71816 100644 --- a/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx +++ b/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx @@ -134,6 +134,14 @@ export const TransactionTokensDisplay: FC = ({ ex Date: Fri, 19 Jun 2026 18:22:03 +0200 Subject: [PATCH 105/131] Document discount display invariants --- docs/security-spec/03-ramp-engine/discount-mechanism.md | 3 ++- docs/security-spec/03-ramp-engine/fee-integrity.md | 1 + docs/security-spec/03-ramp-engine/quote-lifecycle.md | 5 +++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/security-spec/03-ramp-engine/discount-mechanism.md b/docs/security-spec/03-ramp-engine/discount-mechanism.md index 36a507982..f345307b9 100644 --- a/docs/security-spec/03-ramp-engine/discount-mechanism.md +++ b/docs/security-spec/03-ramp-engine/discount-mechanism.md @@ -14,7 +14,7 @@ For each quote, the discount engine: 4. Calculates `expectedOutput = inputAmount × oraclePrice × (1 + targetDiscount + adjustedDifference)`. For offramps the oracle price is inverted first. 5. Calculates `actualOutput` as what the user would receive without subsidy (Nabla output minus post-swap fees on onramp, anchor fee added back on offramp). 6. Calculates `idealSubsidy = max(0, expectedOutput − actualOutput)` and `actualSubsidy = min(idealSubsidy, maxSubsidy × expectedOutput)` (only when `targetDiscount > 0`). -7. Writes a `ctx.subsidy` record consumed by downstream merge-subsidy and finalize stages and ultimately by the subsidy phase handlers. On EVM post-swap routes this record represents the discount-derived subsidy component only; the runtime handler may additionally cover actual-vs-quoted swap-output discrepancy, which is capped separately. +7. Writes a `ctx.subsidy` record consumed by downstream merge-subsidy and finalize stages and ultimately by the subsidy phase handlers. On EVM post-swap routes this record represents the discount-derived subsidy component only; the runtime handler may additionally cover actual-vs-quoted swap-output discrepancy, which is capped separately. The finalize stage snapshots the quote-time discount component into public display fields (`discountFiat`, `discountUsd`, `discountCurrency`) when the subsidy is applied, allowing the UI to show the user-facing discount separately from fees. The engine is wired by strategy configuration. Of the 10 route strategies in `apps/api/src/api/services/quote/routes/strategies/`, 9 register a discount engine and 1 does **not**: `onramp-monerium-to-evm`. On that single route, no subsidy is computed regardless of partner configuration. @@ -39,6 +39,7 @@ For onramps to EVM destinations other than AssetHub, the engine also probes Squi 10. **EVM off-ramp Nabla minimums MUST use AMM-only output, not subsidy-merged output.** EVM Nabla quote producers write the AMM-only result to `nablaSwapEvm.ammOutputAmount*`. On Base EVM offramps, `MergeSubsidy` may then merge the quote-time discount subsidy into `nablaSwapEvm.outputAmount*` so downstream payout/finalization targets reflect the subsidized amount. The on-chain Nabla swap minimums MUST be derived from the preserved AMM-only amount (`ammOutputAmountRaw`, falling back to `outputAmountRaw` only for legacy quotes without the snapshot), otherwise the minimum can exceed what the AMM can deliver and cause deterministic swap reverts. 11. **The dynamic difference MUST NOT be incremented within `discountStateTimeoutMinutes` of the last quote** — `getAdjustedDifference` only adds `deltaD` when `isWithinStateTimeout` is **false**. Otherwise repeated quotes from the same partner would inflate the difference faster than intended. 12. **Squid Router probe failures MUST fall back to a 1:1 assumption, never block the quote.** Both `getSquidRouterUSDCConversionRate` and `getSquidRouterAxlUSDCConversionRate` return `null` on error and the engine proceeds with `adjustedExpectedOutputDecimal = oracleExpectedOutputDecimal`. A network failure on the probe MUST NOT cause the entire quote stage to throw. +13. **Public discount display MUST only expose applied quote-time discount subsidy.** `discountFiat` / `discountUsd` MUST be present only when `ctx.subsidy.applied` is true and the subsidy amount is positive. Runtime swap-discrepancy top-ups MUST NOT be folded into this display field because they are execution-time protection, not a promotional rate improvement. ## Threat Vectors & Mitigations diff --git a/docs/security-spec/03-ramp-engine/fee-integrity.md b/docs/security-spec/03-ramp-engine/fee-integrity.md index 55acd47cf..509e7b047 100644 --- a/docs/security-spec/03-ramp-engine/fee-integrity.md +++ b/docs/security-spec/03-ramp-engine/fee-integrity.md @@ -48,6 +48,7 @@ The `distribute-fees-handler.ts` chooses the correct path at runtime based on th 9. **Partner markup distribution MUST use pricing attribution** — When `pricing_partner_id` is present, partner markup payout MUST use that partner row instead of relying only on the quote owner `partner_id`; `partner_id` is only the backward-compatible fallback. 10. **Rounding MUST be consistent and favor the platform** — On-ramp fees are rounded to 6 decimal places (round half up). Off-ramp fees are rounded to 2 decimal places (round half down). Rounding mode should never create a scenario where the user receives more than entitled. 11. **Fee configuration changes MUST NOT affect in-flight ramps** — Once a quote is created with specific fees, those fees are locked. Changing fee configuration should only apply to new quotes. +12. **Displayed discount MUST NOT hide charged fee components** — If a quote includes a subsidized rate improvement, clients may display the user benefit as a separate discount line and may show an effective total fee equal to charged fees minus discount. The underlying charged fee fields (`processingFeeFiat`, `networkFeeFiat`, `partnerFeeFiat`, and API `totalFeeFiat`) MUST remain unchanged; only the UI's effective total may become lower or negative. The discount is a platform-funded benefit, not negative revenue. ## Threat Vectors & Mitigations diff --git a/docs/security-spec/03-ramp-engine/quote-lifecycle.md b/docs/security-spec/03-ramp-engine/quote-lifecycle.md index e89e9ac12..323a1d9a1 100644 --- a/docs/security-spec/03-ramp-engine/quote-lifecycle.md +++ b/docs/security-spec/03-ramp-engine/quote-lifecycle.md @@ -4,7 +4,7 @@ Quotes are the entry point for every ramp. A quote calculates the expected output amount for a given input, factoring in exchange rates, fees, and dynamic pricing adjustments. The lifecycle: -1. **Creation** — Client requests a quote via `POST /v1/quotes` with input currency, output currency, amount, and ramp direction (`BUY` for on-ramp or `SELL` for off-ramp). If an active maintenance window exists, the backend rejects quote creation with `503 Service Unavailable`, `Retry-After`, and downtime start/end metadata before fetching rates or writing a quote. Otherwise, the API calculates fees, fetches live exchange rates (Nabla DEX, price providers), applies the dynamic pricing adjustment, and returns a `QuoteResponse` including the expected output amount, fee breakdown, and a quote ID. +1. **Creation** — Client requests a quote via `POST /v1/quotes` with input currency, output currency, amount, and ramp direction (`BUY` for on-ramp or `SELL` for off-ramp). If an active maintenance window exists, the backend rejects quote creation with `503 Service Unavailable`, `Retry-After`, and downtime start/end metadata before fetching rates or writing a quote. Otherwise, the API calculates fees, fetches live exchange rates (Nabla DEX, price providers), applies the dynamic pricing adjustment, and returns a `QuoteResponse` including the expected output amount, fee breakdown, optional quote-time subsidy display fields, and a quote ID. - If live route/pool liquidity cannot serve the quote at the requested amount, the API returns a user-facing `500` quote error (`This route is temporarily unavailable due to low liquidity. Please try a smaller amount or check back soon.`). Clients should treat it as a user-correctable liquidity failure and ask for a smaller amount or to check back soon. This applies to Nabla pool coverage failures, Squid route low-liquidity responses, and `/v1/quotes/best` when every candidate route fails for liquidity. Unexpected provider or calculation failures still follow the global production error policy and are masked as internal errors. 2. **Expiry** — Quotes expire **10 minutes** after creation (hardcoded in `QuoteTicket.create()` and the model default: `new Date(Date.now() + 10 * 60 * 1000)`). After expiry, the quote cannot be used to start a ramp. Note: this is a separate timeout from `discountStateTimeoutMinutes` (see Dynamic Pricing below). 3. **Binding** — When a ramp is registered (`POST /v1/ramp/register`), it binds to a specific quote ID. The quote's amounts become the committed values for the ramp. @@ -41,7 +41,7 @@ The system maintains an **in-memory** `Map 0`. +**Subsidy calculation:** After computing the expected output (oracle-based) and actual output (DEX-based), the shortfall is the "ideal subsidy." This is capped by `partner.maxSubsidy` (as a fraction of expected output). The subsidy is only applied if `targetDiscount > 0`. When applied, the quote snapshots `discountFiat` / `discountUsd` display amounts from the quote-time subsidy component so clients can show the user-facing discount separately from fees without recomputing it later. ### AlfredPay Provider Quote TTL @@ -70,6 +70,7 @@ The refresh policy is intentionally strict (byte-identical `toAmount` and `fee` 11. **Quote output precision MUST match the final settlement token** — For EVM onramps whose final output comes from Squid, the stored `quote.outputAmount` must retain the destination token's precision, not a fixed source-token precision. This includes BRL/EURC Base→EVM routes and routed Alfredpay USD/MXN/COP Polygon→EVM routes. Direct same-chain same-token passthrough keeps the minted/source token's precision. 12. **Quote creation MUST honor active maintenance windows server-side** — `POST /v1/quotes` and `POST /v1/quotes/best` must reject during active maintenance before quote calculation/persistence, including enough downtime metadata for direct API clients to retry after the window. 13. **Quote ownership MUST stay separate from pricing attribution** — Profile-assigned quotes MUST remain user-owned (`user_id = req.userId`, `partner_id = NULL`) while storing the applied partner pricing row in `pricing_partner_id`. +14. **Displayed discount MUST be a snapshot, not a live recomputation** — Public `QuoteResponse` discount fields MUST come from quote metadata captured at creation time. `GET /v1/quotes/:id` and ramp status responses MUST NOT recompute discount display amounts from live FX rates because quote economics are immutable after creation. ## Threat Vectors & Mitigations From d32ca666689b4fbfdf4f43090ca96488d2bb5976 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 19 Jun 2026 18:24:39 +0200 Subject: [PATCH 106/131] Fix discount tooltip overflow --- apps/frontend/src/components/RampFeeCollapse/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/frontend/src/components/RampFeeCollapse/index.tsx b/apps/frontend/src/components/RampFeeCollapse/index.tsx index 2bc3779e4..80abdc75c 100644 --- a/apps/frontend/src/components/RampFeeCollapse/index.tsx +++ b/apps/frontend/src/components/RampFeeCollapse/index.tsx @@ -116,7 +116,7 @@ export function RampFeeCollapse() { } return ( -

+
@@ -128,7 +128,7 @@ export function RampFeeCollapse() {

{t("components.feeCollapse.details")}

-
+
{feeItems.map((item, index) => (
From 174c7650fa123ec7a999dd8970612836bdc3b35c Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 19 Jun 2026 18:32:16 +0200 Subject: [PATCH 107/131] Polish discount fee display --- apps/frontend/src/pages/quote/index.tsx | 2 +- apps/frontend/src/pages/widget/index.tsx | 2 +- apps/frontend/src/translations/en.json | 4 ++-- apps/frontend/src/translations/pt.json | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/frontend/src/pages/quote/index.tsx b/apps/frontend/src/pages/quote/index.tsx index e2f6ecf2e..52f168740 100644 --- a/apps/frontend/src/pages/quote/index.tsx +++ b/apps/frontend/src/pages/quote/index.tsx @@ -14,7 +14,7 @@ export const Quote = () => { return (
-
+
{activeSwapDirection === RampDirection.BUY ? : } diff --git a/apps/frontend/src/pages/widget/index.tsx b/apps/frontend/src/pages/widget/index.tsx index d171bbfa9..9a244adaa 100644 --- a/apps/frontend/src/pages/widget/index.tsx +++ b/apps/frontend/src/pages/widget/index.tsx @@ -40,7 +40,7 @@ export const Widget = ({ className }: WidgetProps) => (
diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index 01f5034a2..6738f97e0 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -352,7 +352,7 @@ "details": "Details", "discount": { "label": "Discount", - "tooltip": "Vortex is improving this quote with a discount. This is separate from fees." + "tooltip": "Vortex is giving a discount on this quote. This is separate from fees." }, "finalAmount": "Final Amount", "netRate": { @@ -367,7 +367,7 @@ "label": "Processing fee", "tooltip": "This fee covers partner and Vortex platform costs." }, - "totalFee": "Total Fee", + "totalFee": "Effective Fee", "yourQuote": "Your quote" }, "fiatAccountForms": { diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index 02569fc2d..d5a4ac921 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -355,7 +355,7 @@ "details": "Detalhes", "discount": { "label": "Desconto", - "tooltip": "A Vortex está melhorando esta cotação com um desconto. Isso é separado das taxas." + "tooltip": "A Vortex está dando um desconto nesta cotação. Isso é separado das taxas." }, "finalAmount": "Valor final", "netRate": { @@ -370,7 +370,7 @@ "label": "Taxa de processamento", "tooltip": "Essa taxa cobre os custos do parceiro e da plataforma Vortex." }, - "totalFee": "Taxa total", + "totalFee": "Taxa efetiva", "yourQuote": "Sua cotação" }, "fiatAccountForms": { From a0a09217e484a2d31b4a0722ab5f60b3576dd740 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sun, 21 Jun 2026 19:38:53 +0200 Subject: [PATCH 108/131] Implement BRLA output recovery logic for Nabla swaps and enhance error handling --- .../rebalance/usdc-brla-usdc-base/steps.ts | 66 +++++++++++++++++-- 1 file changed, 60 insertions(+), 6 deletions(-) diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts index 81b2caedd..0d3900b58 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts @@ -30,6 +30,35 @@ export const BRLA_POLYGON: `0x${string}` = brlaMoonbeamTokenDetails.polygonErc20 const NABLA_SWAP_DEADLINE_MINUTES = 60 * 24 * 7; const AMM_MINIMUM_OUTPUT_HARD_MARGIN = 0.05; +function buildRecoveredBrlaOutput(brlaReceivedRaw: bigint) { + const brlaAmountRaw = brlaReceivedRaw.toString(); + const brlaAmountDecimal = multiplyByPowerOfTen(Big(brlaAmountRaw), -18); + return { brlaAmountDecimal, brlaAmountRaw }; +} + +async function recoverNablaBrlaOutputFromBalance( + brlaBalanceBefore: bigint, + state: UsdcBaseRebalanceState, + stateManager: UsdcBaseStateManager +): Promise<{ brlaAmountRaw: string; brlaAmountDecimal: Big } | null> { + const brlaBalanceAfter = BigInt(await getBrlaBalanceOnBaseRaw()); + const brlaReceivedRaw = brlaBalanceAfter - brlaBalanceBefore; + + if (brlaReceivedRaw <= 0n) return null; + + const recovered = buildRecoveredBrlaOutput(brlaReceivedRaw); + state.brlaAmountRaw = recovered.brlaAmountRaw; + state.brlaAmountDecimal = recovered.brlaAmountDecimal.toString(); + await stateManager.saveState(state); + + console.log( + `Recovered Nabla BRLA output from Base balance delta: ${recovered.brlaAmountDecimal.toFixed(4)} BRLA ` + + `(pre: ${brlaBalanceBefore}, post: ${brlaBalanceAfter})` + ); + + return recovered; +} + export async function getUsdcBalanceOnBaseRaw(): Promise { const { publicClient, walletClient } = getBaseEvmClients(); const balance = await publicClient.readContract({ @@ -99,8 +128,8 @@ export async function nablaApproveAndSwapOnBase( ): Promise<{ brlaAmountRaw: string; brlaAmountDecimal: Big; - approveHash: string; - swapHash: string; + approveHash: string | null; + swapHash: string | null; }> { console.log(`Starting Nabla swap of ${usdcAmountRaw} USDC (raw) to BRLA on Base...`); @@ -130,6 +159,17 @@ export async function nablaApproveAndSwapOnBase( const brlaBalanceBefore = BigInt(state.brlaBalanceBeforeNablaRaw); + const recoveredBeforeSend = await recoverNablaBrlaOutputFromBalance(brlaBalanceBefore, state, stateManager); + if (recoveredBeforeSend) { + console.log("Existing BRLA balance delta detected before sending a new Nabla swap. Continuing recovered rebalance."); + return { + approveHash: state.nablaApproveHash, + brlaAmountDecimal: recoveredBeforeSend.brlaAmountDecimal, + brlaAmountRaw: recoveredBeforeSend.brlaAmountRaw, + swapHash: state.nablaSwapHash + }; + } + let approveHash = state.nablaApproveHash; let swapHash = state.nablaSwapHash; @@ -222,8 +262,23 @@ export async function nablaApproveAndSwapOnBase( throw new Error("State corrupted: Nabla transaction hash missing after swap step."); } - await waitForTransactionConfirmation(swapHash, publicClient); - console.log("Nabla swap confirmed."); + try { + await waitForTransactionConfirmation(swapHash, publicClient); + console.log("Nabla swap confirmed."); + } catch (error) { + const recovered = await recoverNablaBrlaOutputFromBalance(brlaBalanceBefore, state, stateManager); + if (recovered) { + console.warn(`Nabla swap confirmation failed, but BRLA balance delta proves completion. Continuing. ${error}`); + return { + approveHash, + brlaAmountDecimal: recovered.brlaAmountDecimal, + brlaAmountRaw: recovered.brlaAmountRaw, + swapHash + }; + } + + throw error; + } // Delay to let the RPC sync the post-swap state before reading the balance await new Promise(resolve => setTimeout(resolve, 5_000)); @@ -244,8 +299,7 @@ export async function nablaApproveAndSwapOnBase( if (brlaReceivedRaw === 0n) { throw new Error(`No BRLA balance delta detected after Nabla swap (pre: ${brlaBalanceBefore}, post: ${brlaBalanceAfter}).`); } - const brlaAmountRaw = brlaReceivedRaw.toString(); - const brlaAmountDecimal = multiplyByPowerOfTen(Big(brlaAmountRaw), -18); + const { brlaAmountRaw, brlaAmountDecimal } = buildRecoveredBrlaOutput(brlaReceivedRaw); console.log(`Received ${brlaAmountDecimal.toFixed(4)} BRLA on Base (pre: ${brlaBalanceBefore}, post: ${brlaBalanceAfter})`); return { From 10df72e1dd50f55497a518551fa0a127925d665d Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 22 Jun 2026 09:44:39 +0200 Subject: [PATCH 109/131] Omit invalid discount display conversions --- .../api/src/api/services/priceFeed.service.ts | 110 ++++++++----- .../quote/engines/finalize/index.test.ts | 145 +++++++++++++++++- .../services/quote/engines/finalize/index.ts | 12 +- .../03-ramp-engine/discount-mechanism.md | 2 +- 4 files changed, 222 insertions(+), 47 deletions(-) diff --git a/apps/api/src/api/services/priceFeed.service.ts b/apps/api/src/api/services/priceFeed.service.ts index fcbec5388..4a9ed7cdb 100644 --- a/apps/api/src/api/services/priceFeed.service.ts +++ b/apps/api/src/api/services/priceFeed.service.ts @@ -269,6 +269,46 @@ export class PriceFeedService { fromCurrency: RampCurrency, toCurrency: RampCurrency, decimals: number | null = null // Allow overriding, but default to null + ): Promise { + try { + return await this.convertCurrencyStrict(amount, fromCurrency, toCurrency, decimals); + } catch (error) { + if (error instanceof Error) { + logger.error(`Error converting ${amount} from ${fromCurrency} to ${toCurrency}: ${error.message}`); + } else { + logger.error(`Unknown error converting ${amount} from ${fromCurrency} to ${toCurrency}`); + } + + // Return the original amount as fallback + logger.warn(`Returning original amount ${amount} as fallback due to conversion error`); + return amount; + } + } + + public async convertCurrencyOrNull( + amount: string, + fromCurrency: RampCurrency, + toCurrency: RampCurrency, + decimals: number | null = null + ): Promise { + try { + return await this.convertCurrencyStrict(amount, fromCurrency, toCurrency, decimals); + } catch (error) { + if (error instanceof Error) { + logger.error(`Error converting ${amount} from ${fromCurrency} to ${toCurrency}: ${error.message}`); + } else { + logger.error(`Unknown error converting ${amount} from ${fromCurrency} to ${toCurrency}`); + } + + return null; + } + } + + private async convertCurrencyStrict( + amount: string, + fromCurrency: RampCurrency, + toCurrency: RampCurrency, + decimals: number | null = null ): Promise { fromCurrency = fromCurrency.toUpperCase() as RampCurrency; toCurrency = toCurrency.toUpperCase() as RampCurrency; @@ -277,55 +317,43 @@ export class PriceFeedService { const targetDecimals = decimals !== null ? decimals : isFiatToken(toCurrency) ? 2 : 8; logger.debug(`Target decimals for ${toCurrency} set to ${targetDecimals}`); - try { - // If currencies are the same, return the original amount - if (fromCurrency === toCurrency) { - logger.debug(`Currencies are the same (${fromCurrency}), returning original amount: ${amount}`); - return new Big(amount).toFixed(targetDecimals); - } - - // Check if both currencies are USD-like stablecoins - const isUsdLikeCurrency = (currency: RampCurrency): boolean => - currency.toUpperCase() === "USD" || - Object.values(UsdLikeEvmToken).includes(normalizeTokenSymbol(currency) as unknown as UsdLikeEvmToken); + // If currencies are the same, return the original amount + if (fromCurrency === toCurrency) { + logger.debug(`Currencies are the same (${fromCurrency}), returning original amount: ${amount}`); + return new Big(amount).toFixed(targetDecimals); + } - if (isUsdLikeCurrency(fromCurrency) && isUsdLikeCurrency(toCurrency)) { - return this.convertUsdLikeToUsdLike(amount, fromCurrency, toCurrency); - } + // Check if both currencies are USD-like stablecoins + const isUsdLikeCurrency = (currency: RampCurrency): boolean => + currency.toUpperCase() === "USD" || + Object.values(UsdLikeEvmToken).includes(normalizeTokenSymbol(currency) as unknown as UsdLikeEvmToken); - if (isUsdLikeCurrency(fromCurrency) && isFiatToken(toCurrency)) { - return this.convertUsdToFiat(amount, toCurrency, targetDecimals); - } + if (isUsdLikeCurrency(fromCurrency) && isUsdLikeCurrency(toCurrency)) { + return this.convertUsdLikeToUsdLike(amount, fromCurrency, toCurrency); + } - if (isFiatToken(fromCurrency) && isUsdLikeCurrency(toCurrency)) { - return this.convertFiatToUsd(amount, fromCurrency, targetDecimals); - } + if (isUsdLikeCurrency(fromCurrency) && isFiatToken(toCurrency)) { + return this.convertUsdToFiat(amount, toCurrency, targetDecimals); + } - if (isUsdLikeCurrency(fromCurrency) && !isFiatToken(toCurrency) && !isUsdLikeCurrency(toCurrency)) { - return this.convertUsdToCrypto(amount, toCurrency, targetDecimals); - } + if (isFiatToken(fromCurrency) && isUsdLikeCurrency(toCurrency)) { + return this.convertFiatToUsd(amount, fromCurrency, targetDecimals); + } - if (!isFiatToken(fromCurrency) && !isUsdLikeCurrency(fromCurrency) && isUsdLikeCurrency(toCurrency)) { - return this.convertCryptoToUsd(amount, fromCurrency, targetDecimals); - } + if (isUsdLikeCurrency(fromCurrency) && !isFiatToken(toCurrency) && !isUsdLikeCurrency(toCurrency)) { + return this.convertUsdToCrypto(amount, toCurrency, targetDecimals); + } - // For other currency pairs, convert via USD as an intermediate step - logger.debug(`Converting ${fromCurrency} to ${toCurrency} via USD as intermediate`); - // Pass null for decimals to let the recursive call determine the correct precision - const amountInUSD = await this.convertCurrency(amount, fromCurrency, EvmToken.USDC, null); + if (!isFiatToken(fromCurrency) && !isUsdLikeCurrency(fromCurrency) && isUsdLikeCurrency(toCurrency)) { + return this.convertCryptoToUsd(amount, fromCurrency, targetDecimals); + } - return this.convertCurrency(amountInUSD, EvmToken.USDC, toCurrency, null); - } catch (error) { - if (error instanceof Error) { - logger.error(`Error converting ${amount} from ${fromCurrency} to ${toCurrency}: ${error.message}`); - } else { - logger.error(`Unknown error converting ${amount} from ${fromCurrency} to ${toCurrency}`); - } + // For other currency pairs, convert via USD as an intermediate step + logger.debug(`Converting ${fromCurrency} to ${toCurrency} via USD as intermediate`); + // Pass null for decimals to let the recursive call determine the correct precision + const amountInUSD = await this.convertCurrencyStrict(amount, fromCurrency, EvmToken.USDC, null); - // Return the original amount as fallback - logger.warn(`Returning original amount ${amount} as fallback due to conversion error`); - return amount; - } + return this.convertCurrencyStrict(amountInUSD, EvmToken.USDC, toCurrency, null); } // Checks if the onchain oracle prices are up to date. Sends a warning to Slack if not. diff --git a/apps/api/src/api/services/quote/engines/finalize/index.test.ts b/apps/api/src/api/services/quote/engines/finalize/index.test.ts index 30d082725..b6fcca3c2 100644 --- a/apps/api/src/api/services/quote/engines/finalize/index.test.ts +++ b/apps/api/src/api/services/quote/engines/finalize/index.test.ts @@ -24,10 +24,12 @@ class TestFinalizeEngine extends BaseFinalizeEngine { describe("BaseFinalizeEngine", () => { const originalQuoteTicketCreate = QuoteTicket.create; const originalConvertCurrency = priceFeedService.convertCurrency; + const originalConvertCurrencyOrNull = priceFeedService.convertCurrencyOrNull; afterEach(() => { QuoteTicket.create = originalQuoteTicketCreate; priceFeedService.convertCurrency = originalConvertCurrency; + priceFeedService.convertCurrencyOrNull = originalConvertCurrencyOrNull; }); it("persists profile-priced quotes as user-owned with a separate pricing partner", async () => { @@ -95,12 +97,12 @@ describe("BaseFinalizeEngine", () => { id: "quote-1" })); QuoteTicket.create = quoteCreateMock as unknown as typeof QuoteTicket.create; - priceFeedService.convertCurrency = mock(async (amount, _from, to) => { + priceFeedService.convertCurrencyOrNull = mock(async (amount, _from, to) => { if (to === FiatToken.BRL) { return new Big(amount).mul(5).toString(); } return amount; - }) as typeof priceFeedService.convertCurrency; + }) as typeof priceFeedService.convertCurrencyOrNull; const ctx = { addNote: mock(() => undefined), @@ -173,4 +175,143 @@ describe("BaseFinalizeEngine", () => { discountUsd: "2.000000" }); }); + + it("omits subsidy display when the subsidy currency cannot be inferred", async () => { + const createdAt = new Date("2026-06-03T12:00:00.000Z"); + const expiresAt = new Date("2026-06-03T12:10:00.000Z"); + const quoteCreateMock = mock(async data => ({ + ...data, + createdAt, + expiresAt, + id: "quote-1" + })); + QuoteTicket.create = quoteCreateMock as unknown as typeof QuoteTicket.create; + + const ctx = { + addNote: mock(() => undefined), + fees: { + displayFiat: { + anchor: "1", + currency: FiatToken.BRL, + network: "0", + partnerMarkup: "2", + total: "13", + vortex: "10" + }, + usd: { + anchor: "0.2", + network: "0", + partnerMarkup: "0.4", + total: "2.6", + vortex: "2" + } + }, + request: { + from: EPaymentMethod.PIX, + inputAmount: "100", + inputCurrency: FiatToken.BRL, + network: Networks.Base, + outputCurrency: EvmToken.USDC, + rampType: RampDirection.BUY, + to: Networks.Base + }, + subsidy: { + actualOutputAmountDecimal: new Big("98"), + actualOutputAmountRaw: "98000000", + applied: true, + expectedOutputAmountDecimal: new Big("100"), + expectedOutputAmountRaw: "100000000", + idealSubsidyAmountInOutputTokenDecimal: new Big("2"), + idealSubsidyAmountInOutputTokenRaw: "2000000", + partnerId: "partner-1", + subsidyAmountInOutputTokenDecimal: new Big("2"), + subsidyAmountInOutputTokenRaw: "2000000", + subsidyRate: new Big("0.02"), + targetOutputAmountDecimal: new Big("100"), + targetOutputAmountRaw: "100000000" + }, + targetFeeFiatCurrency: FiatToken.BRL + } as unknown as QuoteContext; + + await new TestFinalizeEngine().execute(ctx); + + expect(quoteCreateMock.mock.calls[0][0].metadata.subsidyDisplay).toBeUndefined(); + expect(ctx.builtResponse).not.toHaveProperty("discountFiat"); + }); + + it("omits subsidy display when display currency conversion fails", async () => { + const createdAt = new Date("2026-06-03T12:00:00.000Z"); + const expiresAt = new Date("2026-06-03T12:10:00.000Z"); + const quoteCreateMock = mock(async data => ({ + ...data, + createdAt, + expiresAt, + id: "quote-1" + })); + QuoteTicket.create = quoteCreateMock as unknown as typeof QuoteTicket.create; + priceFeedService.convertCurrencyOrNull = mock(async () => null) as typeof priceFeedService.convertCurrencyOrNull; + + const ctx = { + addNote: mock(() => undefined), + fees: { + displayFiat: { + anchor: "1", + currency: FiatToken.BRL, + network: "0", + partnerMarkup: "2", + total: "13", + vortex: "10" + }, + usd: { + anchor: "0.2", + network: "0", + partnerMarkup: "0.4", + total: "2.6", + vortex: "2" + } + }, + nablaSwapEvm: { + inputAmountForSwapDecimal: "100", + inputAmountForSwapRaw: "100000000", + inputCurrency: EvmToken.BRLA, + inputDecimals: 6, + inputToken: "0xbrla", + outputAmountDecimal: new Big("98"), + outputAmountRaw: "98000000", + outputCurrency: EvmToken.USDC, + outputDecimals: 6, + outputToken: "0xusdc" + }, + request: { + from: EPaymentMethod.PIX, + inputAmount: "100", + inputCurrency: FiatToken.BRL, + network: Networks.Base, + outputCurrency: EvmToken.USDC, + rampType: RampDirection.BUY, + to: Networks.Base + }, + subsidy: { + actualOutputAmountDecimal: new Big("98"), + actualOutputAmountRaw: "98000000", + applied: true, + expectedOutputAmountDecimal: new Big("100"), + expectedOutputAmountRaw: "100000000", + idealSubsidyAmountInOutputTokenDecimal: new Big("2"), + idealSubsidyAmountInOutputTokenRaw: "2000000", + partnerId: "partner-1", + subsidyAmountInOutputTokenDecimal: new Big("2"), + subsidyAmountInOutputTokenRaw: "2000000", + subsidyRate: new Big("0.02"), + targetOutputAmountDecimal: new Big("100"), + targetOutputAmountRaw: "100000000" + }, + targetFeeFiatCurrency: FiatToken.BRL + } as unknown as QuoteContext; + + await new TestFinalizeEngine().execute(ctx); + + expect(quoteCreateMock.mock.calls[0][0].metadata.subsidyDisplay).toBeUndefined(); + expect(ctx.builtResponse).not.toHaveProperty("discountFiat"); + }); }); diff --git a/apps/api/src/api/services/quote/engines/finalize/index.ts b/apps/api/src/api/services/quote/engines/finalize/index.ts index 3a6997415..d3fa516f7 100644 --- a/apps/api/src/api/services/quote/engines/finalize/index.ts +++ b/apps/api/src/api/services/quote/engines/finalize/index.ts @@ -48,15 +48,21 @@ async function assignSubsidyDisplay(ctx: QuoteContext): Promise { const sourceCurrency = getSubsidySourceCurrency(ctx); if (!sourceCurrency) { - throw new APIError({ message: "Missing subsidy currency", status: httpStatus.INTERNAL_SERVER_ERROR }); + ctx.subsidyDisplay = undefined; + return; } const subsidyAmount = subsidy.subsidyAmountInOutputTokenDecimal.toString(); const [discountFiat, discountUsd] = await Promise.all([ - priceFeedService.convertCurrency(subsidyAmount, sourceCurrency, ctx.targetFeeFiatCurrency), - priceFeedService.convertCurrency(subsidyAmount, sourceCurrency, EvmToken.USDC as RampCurrency) + priceFeedService.convertCurrencyOrNull(subsidyAmount, sourceCurrency, ctx.targetFeeFiatCurrency), + priceFeedService.convertCurrencyOrNull(subsidyAmount, sourceCurrency, EvmToken.USDC as RampCurrency) ]); + if (!discountFiat || !discountUsd) { + ctx.subsidyDisplay = undefined; + return; + } + ctx.subsidyDisplay = { currency: ctx.targetFeeFiatCurrency, fiat: new Big(discountFiat).toFixed(2), diff --git a/docs/security-spec/03-ramp-engine/discount-mechanism.md b/docs/security-spec/03-ramp-engine/discount-mechanism.md index f345307b9..9ba604935 100644 --- a/docs/security-spec/03-ramp-engine/discount-mechanism.md +++ b/docs/security-spec/03-ramp-engine/discount-mechanism.md @@ -39,7 +39,7 @@ For onramps to EVM destinations other than AssetHub, the engine also probes Squi 10. **EVM off-ramp Nabla minimums MUST use AMM-only output, not subsidy-merged output.** EVM Nabla quote producers write the AMM-only result to `nablaSwapEvm.ammOutputAmount*`. On Base EVM offramps, `MergeSubsidy` may then merge the quote-time discount subsidy into `nablaSwapEvm.outputAmount*` so downstream payout/finalization targets reflect the subsidized amount. The on-chain Nabla swap minimums MUST be derived from the preserved AMM-only amount (`ammOutputAmountRaw`, falling back to `outputAmountRaw` only for legacy quotes without the snapshot), otherwise the minimum can exceed what the AMM can deliver and cause deterministic swap reverts. 11. **The dynamic difference MUST NOT be incremented within `discountStateTimeoutMinutes` of the last quote** — `getAdjustedDifference` only adds `deltaD` when `isWithinStateTimeout` is **false**. Otherwise repeated quotes from the same partner would inflate the difference faster than intended. 12. **Squid Router probe failures MUST fall back to a 1:1 assumption, never block the quote.** Both `getSquidRouterUSDCConversionRate` and `getSquidRouterAxlUSDCConversionRate` return `null` on error and the engine proceeds with `adjustedExpectedOutputDecimal = oracleExpectedOutputDecimal`. A network failure on the probe MUST NOT cause the entire quote stage to throw. -13. **Public discount display MUST only expose applied quote-time discount subsidy.** `discountFiat` / `discountUsd` MUST be present only when `ctx.subsidy.applied` is true and the subsidy amount is positive. Runtime swap-discrepancy top-ups MUST NOT be folded into this display field because they are execution-time protection, not a promotional rate improvement. +13. **Public discount display MUST only expose applied quote-time discount subsidy.** `discountFiat` / `discountUsd` MUST be present only when `ctx.subsidy.applied` is true, the subsidy amount is positive, the subsidy currency can be inferred, and display conversion succeeds without fallback. Runtime swap-discrepancy top-ups MUST NOT be folded into this display field because they are execution-time protection, not a promotional rate improvement. ## Threat Vectors & Mitigations From e3d0d6db8ab476166a0d5d8b128e4d9522b860e3 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 22 Jun 2026 09:45:05 +0200 Subject: [PATCH 110/131] Type ramp discount currency --- packages/shared/src/endpoints/ramp.endpoints.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/endpoints/ramp.endpoints.ts b/packages/shared/src/endpoints/ramp.endpoints.ts index 76589f85f..45db37891 100644 --- a/packages/shared/src/endpoints/ramp.endpoints.ts +++ b/packages/shared/src/endpoints/ramp.endpoints.ts @@ -264,7 +264,7 @@ export interface GetRampStatusResponse extends RampProcess { // User benefit from quote-time discount, displayed in feeCurrency when present discountFiat?: string; discountUsd?: string; - discountCurrency?: string; + discountCurrency?: RampCurrency; } export interface GetRampErrorLogsRequest { From 2789bf9bc8651ef948de84bd651dfcb568eb5009 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 22 Jun 2026 09:45:27 +0200 Subject: [PATCH 111/131] Show discount as fee deduction --- apps/frontend/src/components/RampFeeCollapse/index.tsx | 2 +- .../src/components/widget-steps/SummaryStep/FeeDetails.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/frontend/src/components/RampFeeCollapse/index.tsx b/apps/frontend/src/components/RampFeeCollapse/index.tsx index 80abdc75c..9d71c1ef1 100644 --- a/apps/frontend/src/components/RampFeeCollapse/index.tsx +++ b/apps/frontend/src/components/RampFeeCollapse/index.tsx @@ -103,7 +103,7 @@ export function RampFeeCollapse() { feeItems.push({ label: t("components.feeCollapse.discount.label"), tooltip: t("components.feeCollapse.discount.tooltip"), - value: `+ ${Big(quote.discountFiat || "0").toFixed(2)} ${(quote.discountCurrency || quote.feeCurrency || fiatToken).toUpperCase()}` + value: `- ${Big(quote.discountFiat || "0").toFixed(2)} ${(quote.discountCurrency || quote.feeCurrency || fiatToken).toUpperCase()}` }); } diff --git a/apps/frontend/src/components/widget-steps/SummaryStep/FeeDetails.tsx b/apps/frontend/src/components/widget-steps/SummaryStep/FeeDetails.tsx index d2f18d42a..21b97fb00 100644 --- a/apps/frontend/src/components/widget-steps/SummaryStep/FeeDetails.tsx +++ b/apps/frontend/src/components/widget-steps/SummaryStep/FeeDetails.tsx @@ -59,7 +59,7 @@ export const FeeDetails: FC = ({

{t("components.feeCollapse.discount.label")}

- + {discount.amount} {discount.currency.toUpperCase()} + - {discount.amount} {discount.currency.toUpperCase()}

From 6964672434517ca35079395c405ed71181c11938 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 22 Jun 2026 14:09:49 +0200 Subject: [PATCH 112/131] Align effective fee displays --- apps/frontend/src/components/RampFeeCollapse/index.tsx | 4 ++-- .../src/components/widget-steps/SummaryStep/FeeDetails.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/frontend/src/components/RampFeeCollapse/index.tsx b/apps/frontend/src/components/RampFeeCollapse/index.tsx index 9d71c1ef1..432bf82d4 100644 --- a/apps/frontend/src/components/RampFeeCollapse/index.tsx +++ b/apps/frontend/src/components/RampFeeCollapse/index.tsx @@ -78,14 +78,14 @@ export function RampFeeCollapse() { const inputCurrency = quote.inputCurrency.toUpperCase(); const outputCurrency = quote.outputCurrency.toUpperCase(); + const effectiveTotalFee = Big(quote.totalFeeFiat || "0").minus(quote.discountFiat || "0"); const interbankExchangeRate = calculateInterbankExchangeRate( quote.rampType, quote.inputAmount, quote.outputAmount, - quote.totalFeeFiat || "0" + effectiveTotalFee ); const netExchangeRate = calculateNetExchangeRate(quote.inputAmount, quote.outputAmount); - const effectiveTotalFee = Big(quote.totalFeeFiat || "0").minus(quote.discountFiat || "0"); // Generate fee items for display const feeItems: FeeItem[] = []; diff --git a/apps/frontend/src/components/widget-steps/SummaryStep/FeeDetails.tsx b/apps/frontend/src/components/widget-steps/SummaryStep/FeeDetails.tsx index 21b97fb00..0184f44b3 100644 --- a/apps/frontend/src/components/widget-steps/SummaryStep/FeeDetails.tsx +++ b/apps/frontend/src/components/widget-steps/SummaryStep/FeeDetails.tsx @@ -47,7 +47,7 @@ export const FeeDetails: FC = ({ return (
-

{isOfframp ? t("components.SummaryPage.offrampFee") : t("components.SummaryPage.onrampFee")}

+

{t("components.feeCollapse.totalFee")}

{effectiveTotalFee} {feesCost.currency.toUpperCase()} From b5e11658774f631bf3ea48a886ee2add424f2ed3 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 22 Jun 2026 14:42:34 +0200 Subject: [PATCH 113/131] Fix discount story and rate types --- apps/frontend/src/components/RampFeeCollapse/index.tsx | 2 +- apps/frontend/src/stories/RampFeeCollapse.stories.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/frontend/src/components/RampFeeCollapse/index.tsx b/apps/frontend/src/components/RampFeeCollapse/index.tsx index 432bf82d4..55b5a4f02 100644 --- a/apps/frontend/src/components/RampFeeCollapse/index.tsx +++ b/apps/frontend/src/components/RampFeeCollapse/index.tsx @@ -83,7 +83,7 @@ export function RampFeeCollapse() { quote.rampType, quote.inputAmount, quote.outputAmount, - effectiveTotalFee + effectiveTotalFee.toString() ); const netExchangeRate = calculateNetExchangeRate(quote.inputAmount, quote.outputAmount); diff --git a/apps/frontend/src/stories/RampFeeCollapse.stories.tsx b/apps/frontend/src/stories/RampFeeCollapse.stories.tsx index 0481c7c39..e6dceca05 100644 --- a/apps/frontend/src/stories/RampFeeCollapse.stories.tsx +++ b/apps/frontend/src/stories/RampFeeCollapse.stories.tsx @@ -12,7 +12,7 @@ const subsidizedQuote: QuoteResponse = { discountCurrency: FiatToken.BRL, discountFiat: "5.00", discountUsd: "1.000000", - expiresAt: new Date("2026-12-31T23:59:59Z"), + expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), feeCurrency: FiatToken.BRL, from: EPaymentMethod.PIX, id: "quote_subsidized_brl_to_usdc", From 6348e518a08e2790c0d70c37f550df427bb43bef Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 22 Jun 2026 14:45:23 +0200 Subject: [PATCH 114/131] Refine discount tooltip copy --- apps/frontend/src/translations/en.json | 2 +- apps/frontend/src/translations/pt.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index 6738f97e0..d907e4066 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -352,7 +352,7 @@ "details": "Details", "discount": { "label": "Discount", - "tooltip": "Vortex is giving a discount on this quote. This is separate from fees." + "tooltip": "Vortex is giving a discount on platform costs." }, "finalAmount": "Final Amount", "netRate": { diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index d5a4ac921..3b9e954d3 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -355,7 +355,7 @@ "details": "Detalhes", "discount": { "label": "Desconto", - "tooltip": "A Vortex está dando um desconto nesta cotação. Isso é separado das taxas." + "tooltip": "A Vortex está dando um desconto nos custos da plataforma." }, "finalAmount": "Valor final", "netRate": { From 398c41788198ef4637fc8df5d60b46202602295c Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 22 Jun 2026 16:15:24 +0200 Subject: [PATCH 115/131] Implement BRLA transfer recovery logic and update balance arrival checks --- .../rebalance/usdc-brla-usdc-base/steps.ts | 60 ++++++++++++++++++- .../security-spec/07-operations/rebalancer.md | 8 +-- 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts index 0d3900b58..aa081c2d2 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts @@ -98,6 +98,36 @@ export async function getAveniaBrlaBalanceDecimal(): Promise { return String(balanceResponse?.balances?.BRLA ?? "0"); } +async function recoverBrlaTransferToAveniaFromBalance( + expectedBrlaAmountDecimal: Big, + state: UsdcBaseRebalanceState, + stateManager: UsdcBaseStateManager +): Promise { + if (!state.aveniaBrlaBalanceBeforeTransfer) return false; + + const currentAveniaBrlaBalance = Big(await getAveniaBrlaBalanceDecimal()); + const receivedDelta = currentAveniaBrlaBalance.minus(Big(state.aveniaBrlaBalanceBeforeTransfer)); + const minimumReceived = calculateMinimumDelta(expectedBrlaAmountDecimal, "0.95"); + + if (receivedDelta.lt(minimumReceived)) { + console.log( + `Avenia BRLA transfer not recoverable yet. Needed: ${minimumReceived.toFixed(12)}, ` + + `received: ${receivedDelta.toFixed(12)}.` + ); + return false; + } + + state.brlaAmountDecimal = receivedDelta.toString(); + state.brlaAmountRaw = multiplyByPowerOfTen(receivedDelta, 18).toFixed(0, 0); + await stateManager.saveState(state); + + console.log( + `Recovered BRLA transfer to Avenia from balance delta: ${receivedDelta.toString()} BRLA ` + + `(baseline: ${state.aveniaBrlaBalanceBeforeTransfer}, current: ${currentAveniaBrlaBalance.toString()})` + ); + return true; +} + export async function checkInitialUsdcBalanceOnBase(usdcAmountRaw: string): Promise { const { publicClient, walletClient } = getBaseEvmClients(); const address = walletClient.account.address; @@ -315,13 +345,28 @@ export async function transferBrlaToAveniaOnBase( baseNonce: NonceManager, state: UsdcBaseRebalanceState, stateManager: UsdcBaseStateManager -): Promise { +): Promise { const { brlaBusinessAccountAddress } = getConfig(); const { walletClient, publicClient } = getBaseEvmClients(); + const brlaAmountDecimal = multiplyByPowerOfTen(Big(brlaAmountRaw), -18); + + if (await recoverBrlaTransferToAveniaFromBalance(brlaAmountDecimal, state, stateManager)) { + console.log("Existing Avenia BRLA balance delta detected before sending a new transfer. Continuing recovered rebalance."); + return state.brlaTransferHash; + } if (state.brlaTransferHash) { console.log(`Resuming BRLA transfer with existing tx: ${state.brlaTransferHash}. Verifying on-chain...`); - await waitForTransactionConfirmation(state.brlaTransferHash, publicClient); + try { + await waitForTransactionConfirmation(state.brlaTransferHash, publicClient); + } catch (error) { + if (await recoverBrlaTransferToAveniaFromBalance(brlaAmountDecimal, state, stateManager)) { + console.warn(`BRLA transfer confirmation failed, but Avenia balance delta proves completion. Continuing. ${error}`); + return state.brlaTransferHash; + } + + throw error; + } return state.brlaTransferHash; } @@ -350,7 +395,16 @@ export async function transferBrlaToAveniaOnBase( state.brlaTransferHash = txHash; await stateManager.saveState(state); console.log(`BRLA transfer tx sent: ${txHash}`); - await waitForTransactionConfirmation(txHash, publicClient); + try { + await waitForTransactionConfirmation(txHash, publicClient); + } catch (error) { + if (await recoverBrlaTransferToAveniaFromBalance(brlaAmountDecimal, state, stateManager)) { + console.warn(`BRLA transfer confirmation failed, but Avenia balance delta proves completion. Continuing. ${error}`); + return txHash; + } + + throw error; + } console.log("BRLA transfer to Avenia confirmed on Base."); return txHash; diff --git a/docs/security-spec/07-operations/rebalancer.md b/docs/security-spec/07-operations/rebalancer.md index cf940968e..691ab20e5 100644 --- a/docs/security-spec/07-operations/rebalancer.md +++ b/docs/security-spec/07-operations/rebalancer.md @@ -159,7 +159,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- 18. **Avenia fallback to SquidRouter MUST be atomic in state** — If Avenia ticket creation fails, the flow sets `winningRoute = "squidrouter"` and `currentPhase = AveniaTransferToPolygon` in a single `saveState()` call. A crash between the failure and the save could leave the flow in an inconsistent state. 19. **NonceManager MUST be re-initialized on resume** — The `NonceManager` is created fresh at the start of each execution from `getTransactionCount()`. On resume, it must not reuse stale nonces from a previous execution. 20. **Axelar cross-chain execution MUST have a timeout** — SquidRouter's Axelar polling has a 30-minute timeout. If Axelar does not confirm execution within this window, the flow MUST throw (not poll indefinitely). This resolves F-034 for the Base flow. -21. **Balance arrival checks MUST be delta-based** — The Base high-coverage flow persists pre-action balances before each arrival-producing operation and waits for `starting balance + expected delta` rather than checking absolute hot-wallet/provider balances. BRLA and Base USDC arrival checks allow a 99.8% tolerance to account for rounding, route deductions, and minor quote shortfalls without sweeping unrelated leftover balances into the current run. The actual received Base USDC delta is persisted before advancing to final verification. +21. **Balance arrival checks MUST be delta-based** — The Base high-coverage flow persists pre-action balances before each arrival-producing operation and waits for `starting balance + expected delta` rather than checking absolute hot-wallet/provider balances. Avenia BRLA arrival checks allow a 95% tolerance for provider-side deductions, while Base/Polygon on-chain arrival checks use the default 99.8% tolerance for rounding, route deductions, and minor quote shortfalls without sweeping unrelated leftover balances into the current run. The actual received Base USDC delta is persisted before advancing to final verification. 22. **`EVM_ACCOUNT_SECRET` derives the same address on all EVM chains** — A single BIP-39 mnemonic is used for Base, Polygon, and Moonbeam. This means compromise of this one secret drains the rebalancer on ALL EVM chains. `PENDULUM_ACCOUNT_SECRET` is separate and legacy-only. 23. **Terminal Avenia ticket failures MUST abort immediately** — `checkTicketStatusPaid` treats `FAILED` as terminal and throws immediately instead of retrying until timeout. @@ -200,7 +200,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- | **`EVM_ACCOUNT_SECRET` single-key blast radius** — One mnemonic controls all EVM chain accounts for the rebalancer | Compromise of this one secret drains rebalancer funds on Base, Polygon, and Moonbeam. The separate `PENDULUM_ACCOUNT_SECRET` limits Pendulum blast radius to the legacy flow. This is a deliberate simplification accepted for operational convenience. | | **SquidRouter cross-chain timeout** — Axelar cross-chain execution could take longer than 30 minutes during network congestion | The rebalancer throws on timeout, leaving the BRLA-to-USDC swap incomplete on Polygon. Funds would be stuck as BRLA on Polygon until manual intervention or the next rebalance attempt resumes from the `SquidRouterApproveAndSwap` phase. | | **Absolute balance false positives** — Hot wallets/provider accounts can contain leftovers from previous runs, so absolute balance checks could pass before the current run's funds arrive | **Mitigated for Base flow.** The flow stores pre-action baselines and waits for deltas on Avenia BRLA, Polygon BRLA, and Base USDC arrivals. | -| **BRLA balance tolerance (99.8%)** — BRLA delta checks accept 99.8% of expected amount as sufficient | If Avenia deducts a fee > 0.2%, the flow will not proceed and will time out. The tolerance prevents rounding dust from blocking valid arrivals while rejecting meaningful shortfalls. | +| **BRLA balance tolerance** — Avenia BRLA delta checks accept 95% of expected amount as sufficient, while on-chain Base/Polygon arrival checks use 99.8% | If Avenia deducts a fee > 5%, the flow will not proceed and will time out. The tolerance prevents provider-side deductions and rounding dust from blocking valid arrivals while rejecting meaningful shortfalls. | ## Audit Checklist @@ -233,14 +233,14 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- - [x] **FINDING**: `EVM_ACCOUNT_SECRET` single mnemonic for all EVM chains — broad EVM blast radius across Base, Polygon, and Moonbeam. **PASS (accepted)** — deliberate simplification; documented in invariants. - [x] Verify route comparison handles partial failures — what happens if one provider's quote fails? **PASS** — if every enabled route fails, throws; otherwise uses the best available route. If `--route=` is specified, only fetches that quote. - [x] Verify NonceManager re-initialization on resume — does it fetch fresh nonce from chain? **PASS** — `NonceManager.create()` calls `getTransactionCount()` on each execution. -- [x] Verify BRLA balance arrival tolerance (99.8%) is appropriate. **PASS** — accounts for rounding and minor fee deductions; 0.2% tolerance is tight enough to reject significant shortfalls. +- [x] Verify BRLA balance arrival tolerance is appropriate. **PASS** — Avenia BRLA uses a 95% threshold for provider-side deductions; on-chain Base/Polygon arrivals use 99.8% to account for rounding and minor route deductions while rejecting significant shortfalls. - [x] Verify `checkTicketStatusPaid` has a timeout and treats FAILED as terminal. **PASS** — 5-minute timeout with 5-second poll interval; FAILED tickets throw immediately. - [x] Verify `waitForBrlaOnAvenia` has a timeout. **PASS** — 10-minute timeout with 5-second poll interval. - [x] Verify `waitUsdcOnBase` has a timeout. **PASS** — 30-minute timeout via `checkEvmBalancePeriodically`. - [x] Verify `waitBrlaOnPolygon` has a timeout. **PASS** — 10-minute timeout via `checkEvmBalancePeriodically`. - [PARTIAL] Verify the Nabla swap validates output amount against expectations. **PARTIAL** — uses `AMM_MINIMUM_OUTPUT_HARD_MARGIN` (5%) for slippage protection via `quoteSwapExactTokensForTokens`, but post-swap balance is verified by comparing pre/post BRLA balance (not against the quote). A sandwich attack could extract up to 5%. - [x] Verify the `usdcBasePhaseOrder` overlap (`AveniaTransferToPolygon` and `AveniaSwapToUsdcBase` both at order 6; both wait phases at order 7) cannot cause incorrect phase transitions. **PASS** — routes are mutually exclusive, guarded by `if (state.winningRoute === "avenia")` / `if (state.winningRoute === "squidrouter")` checks. -- [x] Verify Base flow arrival checks are delta-based. **PASS** — Avenia BRLA, Polygon BRLA, Avenia USDC-on-Base, and SquidRouter USDC-on-Base waits all use persisted pre-action baselines plus expected deltas. Base USDC waits use the default 99.8% tolerance and persist the actual received delta before final verification. +- [x] Verify Base flow arrival checks are delta-based. **PASS** — Avenia BRLA, Polygon BRLA, Avenia USDC-on-Base, and SquidRouter USDC-on-Base waits all use persisted pre-action baselines plus expected deltas. Avenia BRLA transfer recovery also uses the persisted Avenia baseline before resending. Base USDC waits use the default 99.8% tolerance and persist the actual received delta before final verification. - [x] Verify Nabla swap resume cannot lose the received BRLA amount. **PASS** — pre-swap BRLA baseline and swap hash are persisted; resume computes output from the persisted baseline or reuses already recorded output. - [x] Verify Base low-coverage flow state/history is isolated from the high-coverage flow. **PASS** — `BrlaToUsdcBaseStateManager` uses `rebalancer_state_brla_to_usdc_base.json` while sharing the daily limit calculation. - [x] Verify mild/moderate expensive rebalances are skipped in `auto` mode. **PASS** — projected cost is compared against the configured band threshold before any fresh Base state write or transaction. From 3bd5b2eeada7563c52bd287d09dbb1d756842ebf Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 23 Jun 2026 10:09:26 +0200 Subject: [PATCH 116/131] Model Avenia partial-failed ticket status --- apps/rebalancer/src/utils/brla.test.ts | 17 +++++++++++++++- apps/rebalancer/src/utils/brla.ts | 23 ++++++++++++++++++++-- packages/shared/src/services/brla/types.ts | 3 ++- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/apps/rebalancer/src/utils/brla.test.ts b/apps/rebalancer/src/utils/brla.test.ts index be686d015..5cad7f969 100644 --- a/apps/rebalancer/src/utils/brla.test.ts +++ b/apps/rebalancer/src/utils/brla.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { AveniaTicketStatus } from "@vortexfi/shared"; -import { checkTicketStatusPaid } from "./brla.ts"; +import { checkTicketStatusPaid, RetryableAveniaTicketStatusError } from "./brla.ts"; describe("checkTicketStatusPaid", () => { test("throws immediately for failed tickets", async () => { @@ -17,4 +17,19 @@ describe("checkTicketStatusPaid", () => { ]) ).rejects.toThrow("FAILED"); }); + + test("throws a retryable error for partial-failed tickets", async () => { + const service = { + getAveniaSwapTicket: async () => ({ + status: "partial-failed" + }) + }; + + await expect( + Promise.race([ + checkTicketStatusPaid(service as never, "ticket-1"), + new Promise((_, reject) => setTimeout(() => reject(new Error("timed out waiting for terminal failure")), 25)) + ]) + ).rejects.toBeInstanceOf(RetryableAveniaTicketStatusError); + }); }); diff --git a/apps/rebalancer/src/utils/brla.ts b/apps/rebalancer/src/utils/brla.ts index 2d4b476a7..e3cd9ac34 100644 --- a/apps/rebalancer/src/utils/brla.ts +++ b/apps/rebalancer/src/utils/brla.ts @@ -2,6 +2,20 @@ import { AveniaSwapTicket, AveniaTicketStatus, BrlaApiService } from "@vortexfi/ type AveniaTicketReader = Pick; +export class RetryableAveniaTicketStatusError extends Error { + constructor( + readonly ticketId: string, + readonly status: AveniaTicketStatus + ) { + super(`Ticket ${ticketId} status is ${status}`); + this.name = "RetryableAveniaTicketStatusError"; + } +} + +function normalizeAveniaTicketStatus(status: string): AveniaTicketStatus | string { + return status.trim().toUpperCase().replaceAll("_", "-"); +} + export async function checkTicketStatusPaid(brlaApiService: AveniaTicketReader, ticketId: string): Promise { const pollInterval = 5000; const timeout = 5 * 60 * 1000; @@ -19,10 +33,15 @@ export async function checkTicketStatusPaid(brlaApiService: AveniaTicketReader, continue; } - if (ticket?.status === AveniaTicketStatus.PAID) { + const normalizedStatus = ticket?.status ? normalizeAveniaTicketStatus(ticket.status) : undefined; + + if (normalizedStatus === AveniaTicketStatus.PAID) { return ticket; } - if (ticket?.status === AveniaTicketStatus.FAILED) { + if (normalizedStatus === AveniaTicketStatus.PARTIAL_FAILED) { + throw new RetryableAveniaTicketStatusError(ticketId, AveniaTicketStatus.PARTIAL_FAILED); + } + if (normalizedStatus === AveniaTicketStatus.FAILED) { throw new Error(`Ticket ${ticketId} status is FAILED`); } diff --git a/packages/shared/src/services/brla/types.ts b/packages/shared/src/services/brla/types.ts index 51e7bc32a..0a71b864a 100644 --- a/packages/shared/src/services/brla/types.ts +++ b/packages/shared/src/services/brla/types.ts @@ -105,7 +105,8 @@ export enum AveniaTicketStatus { ON_HOLD = "ON-HOLD", PENDING = "PENDING", PAID = "PAID", - FAILED = "FAILED" + FAILED = "FAILED", + PARTIAL_FAILED = "PARTIAL-FAILED" } // /account/tickets endpoint related types From 746a3b08626271b968c4b9d1150780ffb94075fc Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 23 Jun 2026 10:11:04 +0200 Subject: [PATCH 117/131] Retry partial Avenia Polygon transfers --- .../rebalance/usdc-brla-usdc-base/index.ts | 45 ++++++++++++++++++- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts index 02ec15f03..1abc55364 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts @@ -6,7 +6,7 @@ import { UsdcBaseStateManager, usdcBasePhaseOrder } from "../../services/stateManager.ts"; -import { checkTicketStatusPaid } from "../../utils/brla.ts"; +import { checkTicketStatusPaid, RetryableAveniaTicketStatusError } from "../../utils/brla.ts"; import { getBaseEvmClients, getConfig, getPolygonEvmClients } from "../../utils/config.ts"; import { NonceManager } from "../../utils/nonce.ts"; import { evaluateFallbackRoutePolicy } from "./guards.ts"; @@ -60,6 +60,17 @@ function getOpportunisticFallbackContext( }; } +async function calculateRemainingBrlaForPolygonTransfer(brlaAmountRaw: string, polygonBaselineRaw: string): Promise { + const currentPolygonBrlaRaw = Big(await getBrlaBalanceOnPolygonRaw()); + const arrivedDeltaRaw = currentPolygonBrlaRaw.minus(Big(polygonBaselineRaw)); + const alreadyArrivedRaw = arrivedDeltaRaw.gt(0) ? arrivedDeltaRaw : Big(0); + const remainingRaw = Big(brlaAmountRaw).minus(alreadyArrivedRaw); + + if (remainingRaw.lte(0)) return Big(0); + + return multiplyByPowerOfTen(remainingRaw, -18); +} + export async function rebalanceUsdcBrlaUsdcBase( usdcAmountRaw: string, forceRestart = false, @@ -305,7 +316,37 @@ export async function rebalanceUsdcBrlaUsdcBase( } const brlaApiService = BrlaApiService.getInstance(); - await checkTicketStatusPaid(brlaApiService, state.aveniaTicketId); + try { + await checkTicketStatusPaid(brlaApiService, state.aveniaTicketId); + } catch (error) { + if (!(error instanceof RetryableAveniaTicketStatusError)) { + throw error; + } + + console.warn( + `Avenia transfer ticket ${error.ticketId} reached retryable status ${error.status}. Creating a replacement ticket.` + ); + if (!state.brlaAmountRaw) + throw new Error("State corrupted: brlaAmountRaw missing while retrying Avenia Polygon ticket"); + if (!state.polygonBrlaBalanceBeforeTransferRaw) { + throw new Error("State corrupted: polygonBrlaBalanceBeforeTransferRaw missing while retrying Avenia Polygon ticket"); + } + + const remainingBrlaDecimal = await calculateRemainingBrlaForPolygonTransfer( + state.brlaAmountRaw, + state.polygonBrlaBalanceBeforeTransferRaw + ); + if (remainingBrlaDecimal.lte(0)) { + console.log( + "Avenia ticket failed after the full BRLA amount arrived on Polygon. Continuing to arrival confirmation." + ); + } else { + console.warn(`Retrying Avenia transfer to Polygon for remaining ${remainingBrlaDecimal.toFixed(4)} BRLA.`); + state.aveniaTicketId = await aveniaTransferBrlaToPolygon(remainingBrlaDecimal); + await stateManager.saveState(state); + await checkTicketStatusPaid(brlaApiService, state.aveniaTicketId); + } + } console.log("BRLA transferred to Polygon via Avenia."); state.currentPhase = UsdcBaseRebalancePhase.WaitBrlaOnPolygon; From 25f560026bb3080e3b48e0a85c220a91f53d7637 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 23 Jun 2026 10:12:08 +0200 Subject: [PATCH 118/131] Document Avenia ticket recovery behavior --- docs/security-spec/05-integrations/brla.md | 6 +++--- docs/security-spec/07-operations/rebalancer.md | 13 +++++++------ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/security-spec/05-integrations/brla.md b/docs/security-spec/05-integrations/brla.md index 991c2de9d..1ff4fb264 100644 --- a/docs/security-spec/05-integrations/brla.md +++ b/docs/security-spec/05-integrations/brla.md @@ -67,7 +67,7 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou 5. **User tax ID (CPF) MUST be validated** — CPF format validation at ramp registration, not at payout time. 6. **Avenia subaccount creation MUST be idempotent** — If a subaccount already exists for a tax ID, the system must not create a duplicate. 7. **PIX payment confirmation MUST be verified before advancing on-ramp** — `brlaOnrampMint` polls the Base ephemeral balance; advancement only on confirmed BRLA arrival. -8. **Avenia API responses MUST be validated** — Status codes, ticket IDs, and amount confirmations must be checked. `AveniaTicketStatus.FAILED` must throw an unrecoverable error; any other unexpected value must not advance the phase. +8. **Avenia API responses MUST be validated** — Status codes, ticket IDs, and amount confirmations must be checked. `AveniaTicketStatus.FAILED` must throw an unrecoverable error; `AveniaTicketStatus.PARTIAL_FAILED` must be handled as a ticket-specific partial failure and must not be polled indefinitely or treated as a generic success. 9. **Avenia interactions MUST be retryable** — Transient Avenia API failures throw `RecoverablePhaseError`; the phase processor retries. 10. **Recovery on resumed `brlaPayoutOnBase` MUST detect existing tickets** — If `payOutTicketId` is already in state, the handler skips re-issuing the PIX ticket and only polls status (prevents double-payout). 11. **Recovery on resumed on-chain transfer MUST detect existing tx hashes** — If `brlaPayoutTxHash` is in state, the handler waits for that receipt rather than re-broadcasting (prevents double on-chain BRLA transfer). @@ -86,7 +86,7 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou | **Double on-chain transfer** | Crash between sending the BRLA transfer and storing the hash | Handler stores `brlaPayoutTxHash` only after the receipt. On retry, if no hash is stored, the same presigned tx is re-broadcast — EVM nonce uniqueness prevents double-spend. | | **Avenia API compromise** | Attacker intercepts or manipulates Avenia API calls | HTTPS enforced; balance verified on-chain against deposit; PIX amount derived from immutable quote. | | **Amount manipulation between quote and payout** | Attacker modifies the payout amount between quote and execution | `quote.outputAmount` read from DB at execution time; quote is immutable post-creation. | -| **Avenia service outage** | Avenia API is unreachable mid-ramp | `RecoverablePhaseError` → phase processor retries; off-ramp fails to payout but BRLA is held on the Avenia subaccount, not lost. | +| **Avenia service outage or partial ticket failure** | Avenia API is unreachable mid-ramp, or a ticket reaches `PARTIAL-FAILED` after one leg completed and a later leg failed | `RecoverablePhaseError` → phase processor retries transient outages. `PARTIAL-FAILED` must be treated as ticket-specific failure with prior completed legs preserved; callers may retry only after reconciling source/destination balances. | | **Subaccount data leak** | Avenia subaccount details exposed via API | Only `subAccountId`, EVM wallet address, and balances are stored locally; no PII beyond CPF (which is itself a regulatory requirement). | | **Underdelivery from Nabla** | Nabla swap returns less BRLA than quoted, balance poll times out, ramp stuck | Balance-poll timeout (5min) fails the phase as recoverable; `subsidizePostSwap` (EVM branch) tops up eligible shortfalls subject to the env-configured split quote-relative EVM subsidy caps documented in `fund-routing.md`. The actual-vs-quoted swap discrepancy uses `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION`; the discount component uses `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION`. Both default to `0.05`. | | **Disabled AssetHub corridor accidentally re-enabled** | Legacy BRL↔AssetHub route files are selected and a user registers a route that the Base BRL rail no longer supports | Quote eligibility must return no quote for BRL→AssetHub and AssetHub→BRL. Treat any successful quote for those corridors as a regression until the corridor is intentionally re-enabled. | @@ -104,7 +104,7 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou - [x] Avenia subaccount creation is idempotent. **PASS** — checks existing subaccount before creating. - [x] Recovery: `payOutTicketId` short-circuits ticket re-creation. **PASS** — verified in `brla-payout-base-handler.ts`. - [x] Recovery: `brlaPayoutTxHash` short-circuits on-chain transfer re-broadcast. **PASS** — verified in `brla-payout-base-handler.ts`. -- [PARTIAL] Avenia API responses are validated (status, amount, ticket ID). **PARTIAL** — ticket status checked for `PAID`/`FAILED`; other statuses fall through to retry; no explicit amount cross-check on `getAccountBalance` response shape. +- [PARTIAL] Avenia API responses are validated (status, amount, ticket ID). **PARTIAL** — ticket status checked for `PAID`/`FAILED`; `PARTIAL-FAILED` is modeled and the rebalancer handles it for Polygon transfer tickets, but API payout handlers still treat only `FAILED` as terminal; no explicit amount cross-check on `getAccountBalance` response shape. - [x] `RecoverablePhaseError` used for transient Avenia API failures. **PASS** — `createRecoverableError` wraps `sendBrlaPayoutTransaction` failures and ticket-status timeouts. - [x] HTTPS enforced for all Avenia API calls. **PASS** — base URL uses `https://`. - [PARTIAL] No Avenia API credentials or user tax IDs appear in logs. **PARTIAL** — `payOutTicketId` is debug-logged with the literal CPF subaccount; review log redaction. diff --git a/docs/security-spec/07-operations/rebalancer.md b/docs/security-spec/07-operations/rebalancer.md index 691ab20e5..707b2b5dc 100644 --- a/docs/security-spec/07-operations/rebalancer.md +++ b/docs/security-spec/07-operations/rebalancer.md @@ -76,11 +76,11 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- **Rate comparison phase:** 5. Compare rates between SquidRouter, Avenia, and optional main Nabla for BRLA → USDC conversion - - If `--route=` is specified, execution is constrained to that route and the policy still requires a quote for that route before any swap + - If `--route=` is specified, execution is constrained to that route and the policy still requires a quote for that route before executing the selected return leg - Main Nabla route is available only when both `MAIN_NABLA_ROUTER` and `MAIN_NABLA_QUOTER` are set - If every enabled route quote fails, aborts - If one fails, uses the other - - The selected quote feeds both route selection and the cost-policy gate before the first Nabla swap + - The selected quote feeds both route selection and the cost-policy gate before any return-leg action **Route A: main Nabla (BRLA → USDC on Base, direct):** 6a. Main Nabla approve + swap: BRLA → USDC on Base @@ -97,6 +97,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- 7c. Request Avenia to transfer BRLA from internal balance to Polygon 8c. Poll ticket status until PAID (5-min timeout) 9c. Wait for BRLA delta arrival on Polygon (balance polling, 10-min timeout) + - If the BRLA-to-Polygon Avenia ticket reaches `PARTIAL-FAILED`, reconcile any Polygon BRLA delta against the persisted baseline and create a replacement ticket only for the remaining amount. 10c. SquidRouter approve + swap: BRLA on Polygon → USDC on Base 11c. Wait for Axelar cross-chain execution (30-min timeout) 12c. Wait for USDC delta arrival on Base (balance polling, 30-min timeout) @@ -106,7 +107,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- 13. Record history entry (amount, cost, cost-relative, timestamps) 14. Send Slack notification with route, amount, and cost metrics -**Fallback:** If Avenia ticket creation fails during Route A, the flow falls back to Route B (SquidRouter). +**Fallback:** If Avenia ticket creation fails during Route B, the flow falls back to Route C (SquidRouter). **Key secrets:** `EVM_ACCOUNT_SECRET` (single BIP-39 mnemonic, derives accounts for Base + Polygon). `PENDULUM_ACCOUNT_SECRET` not required for this flow. @@ -161,7 +162,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- 20. **Axelar cross-chain execution MUST have a timeout** — SquidRouter's Axelar polling has a 30-minute timeout. If Axelar does not confirm execution within this window, the flow MUST throw (not poll indefinitely). This resolves F-034 for the Base flow. 21. **Balance arrival checks MUST be delta-based** — The Base high-coverage flow persists pre-action balances before each arrival-producing operation and waits for `starting balance + expected delta` rather than checking absolute hot-wallet/provider balances. Avenia BRLA arrival checks allow a 95% tolerance for provider-side deductions, while Base/Polygon on-chain arrival checks use the default 99.8% tolerance for rounding, route deductions, and minor quote shortfalls without sweeping unrelated leftover balances into the current run. The actual received Base USDC delta is persisted before advancing to final verification. 22. **`EVM_ACCOUNT_SECRET` derives the same address on all EVM chains** — A single BIP-39 mnemonic is used for Base, Polygon, and Moonbeam. This means compromise of this one secret drains the rebalancer on ALL EVM chains. `PENDULUM_ACCOUNT_SECRET` is separate and legacy-only. -23. **Terminal Avenia ticket failures MUST abort immediately** — `checkTicketStatusPaid` treats `FAILED` as terminal and throws immediately instead of retrying until timeout. +23. **Terminal Avenia ticket failures MUST NOT poll indefinitely** — `checkTicketStatusPaid` treats `FAILED` as terminal and throws immediately instead of retrying until timeout. `PARTIAL-FAILED` is surfaced as a retryable ticket-specific status so the SquidRouter BRLA-to-Polygon branch can reconcile partial arrival and create a replacement ticket only for the remaining amount. ## Threat Vectors & Mitigations @@ -189,7 +190,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- | Threat | Mitigation | |---|---| | **Route comparison manipulation** — Avenia, SquidRouter, and optional main Nabla quotes are fetched and compared; an attacker could manipulate one provider's rate to force another route | The rebalancer trusts provider quotes without independent verification. However, since all high-coverage routes end with USDC on Base, the worst case is choosing a slightly worse rate, not direct fund loss. The `slippage: 4` parameter on SquidRouter provides some buffer. | -| **Avenia ticket creation failure mid-flow** — Avenia API fails after the flow committed to the Avenia route | **Mitigated.** The flow catches ticket creation errors and falls back to SquidRouter by setting `winningRoute = "squidrouter"` and saving state. Avenia tickets that return `FAILED` after creation are terminal and abort immediately. | +| **Avenia ticket creation or partial ticket failure mid-flow** — Avenia API fails after the flow committed to the Avenia route, or the SquidRouter branch's BRLA-to-Polygon transfer ticket reaches `PARTIAL-FAILED` | **Mitigated.** Direct Avenia ticket creation errors fall back to SquidRouter by setting `winningRoute = "squidrouter"` and saving state. Avenia tickets that return `FAILED` after creation are terminal and abort immediately. For SquidRouter BRLA-to-Polygon tickets, `PARTIAL-FAILED` triggers replacement-ticket creation for only the remaining BRLA after subtracting any Polygon balance delta from the persisted baseline. | | **Daily bridge limit bypass** — History entries are stored in Supabase Storage; an attacker who can modify the storage could clear history to bypass the daily limit. Separately, projected-profitable quotes intentionally bypass the daily cap. | **Weak mitigation.** The limit is enforced client-side by reading history from Supabase and adding the current requested amount before starting. The intentional profit bypass requires a quote with projected output greater than input and still writes history on completion. An attacker with Supabase access could also drain funds directly, so malicious history tampering is a secondary concern. | | **Cost-threshold misconfiguration** — Cost thresholds set too low can cause chronic under-rebalancing; thresholds set too high can cause repeated expensive rebalancing | Defaults are conservative and env parsing fails fast for non-monotonic values. Operators should first use `REBALANCING_POLICY_MODE=dry-run` to observe decisions before enabling tighter or looser production thresholds. | | **Always-mode misuse** — Operator leaves `REBALANCING_POLICY_MODE=always` enabled and accepts expensive routes repeatedly | `always` still respects `REBALANCING_HARD_MAX_COST_BPS` and the daily bridge limit for non-profitable quotes. Decision logs include band, projected cost, allowed cost, daily-limit decision, and reason so misuse is observable. | @@ -234,7 +235,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- - [x] Verify route comparison handles partial failures — what happens if one provider's quote fails? **PASS** — if every enabled route fails, throws; otherwise uses the best available route. If `--route=` is specified, only fetches that quote. - [x] Verify NonceManager re-initialization on resume — does it fetch fresh nonce from chain? **PASS** — `NonceManager.create()` calls `getTransactionCount()` on each execution. - [x] Verify BRLA balance arrival tolerance is appropriate. **PASS** — Avenia BRLA uses a 95% threshold for provider-side deductions; on-chain Base/Polygon arrivals use 99.8% to account for rounding and minor route deductions while rejecting significant shortfalls. -- [x] Verify `checkTicketStatusPaid` has a timeout and treats FAILED as terminal. **PASS** — 5-minute timeout with 5-second poll interval; FAILED tickets throw immediately. +- [x] Verify `checkTicketStatusPaid` has a timeout and treats terminal/retryable ticket statuses explicitly. **PASS** — 5-minute timeout with 5-second poll interval; `FAILED` tickets throw immediately; `PARTIAL-FAILED` tickets surface a retryable status for route-specific handling instead of timing out. - [x] Verify `waitForBrlaOnAvenia` has a timeout. **PASS** — 10-minute timeout with 5-second poll interval. - [x] Verify `waitUsdcOnBase` has a timeout. **PASS** — 30-minute timeout via `checkEvmBalancePeriodically`. - [x] Verify `waitBrlaOnPolygon` has a timeout. **PASS** — 10-minute timeout via `checkEvmBalancePeriodically`. From 7845a0dcef4a850a11c24e24dfe75e1609633d15 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 23 Jun 2026 10:42:50 +0200 Subject: [PATCH 119/131] Clarify Base rebalancer preflight quoting --- docs/security-spec/07-operations/rebalancer.md | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/docs/security-spec/07-operations/rebalancer.md b/docs/security-spec/07-operations/rebalancer.md index 707b2b5dc..57dfad178 100644 --- a/docs/security-spec/07-operations/rebalancer.md +++ b/docs/security-spec/07-operations/rebalancer.md @@ -68,19 +68,17 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- **Urgency-band policy:** Before any state write or transaction, the flow quotes the expected round-trip USDC output. Projected cost is `(input USDC - projected output USDC) / input USDC` in basis points. `auto` mode executes only when the projected cost is within the configured limit for the current coverage-deviation band. `dry-run` logs the same decision but never starts a rebalance. `off` skips without quoting. `always` can execute above the band limit, but not above `REBALANCING_HARD_MAX_COST_BPS`; the daily bridge limit still applies unless the quote projects profit. -**Rebalancing flow (linear phase):** +**Rebalancing flow:** 1. Check initial USDC balance on Base (sufficient for requested amount) -2. Nabla approve + swap: USDC → BRLA on Base -3. Transfer BRLA to Avenia business account on Base (ERC-20 transfer) -4. Wait for BRLA delta to appear on Avenia internal balance (polling, 10-min timeout) - -**Rate comparison phase:** -5. Compare rates between SquidRouter, Avenia, and optional main Nabla for BRLA → USDC conversion - - If `--route=` is specified, execution is constrained to that route and the policy still requires a quote for that route before executing the selected return leg +2. Before any on-chain action, quote the first Nabla USDC → BRLA swap to estimate BRLA output, then compare rates between SquidRouter, Avenia, and optional main Nabla for BRLA → USDC conversion + - If `--route=` is specified, execution is constrained to that route and the policy still requires a quote for that route before executing the common first swap - Main Nabla route is available only when both `MAIN_NABLA_ROUTER` and `MAIN_NABLA_QUOTER` are set - If every enabled route quote fails, aborts - If one fails, uses the other - - The selected quote feeds both route selection and the cost-policy gate before any return-leg action + - The selected quote feeds both route selection and the cost-policy gate before any on-chain action +3. Nabla approve + swap: USDC → BRLA on Base +4. Transfer BRLA to Avenia business account on Base (ERC-20 transfer) +5. Wait for BRLA delta to appear on Avenia internal balance (polling, 10-min timeout) **Route A: main Nabla (BRLA → USDC on Base, direct):** 6a. Main Nabla approve + swap: BRLA → USDC on Base From e3615816fd8154259896727723d274da7e517970 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 23 Jun 2026 11:21:43 +0200 Subject: [PATCH 120/131] Improve the rebalancer slack notification --- apps/rebalancer/src/index.ts | 45 ++++++++++++++----- .../rebalance/usdc-brla-usdc-base/index.ts | 6 +++ .../usdc-brla-usdc-base/notifications.test.ts | 9 ++++ .../usdc-brla-usdc-base/notifications.ts | 37 ++++++++++++--- 4 files changed, 81 insertions(+), 16 deletions(-) diff --git a/apps/rebalancer/src/index.ts b/apps/rebalancer/src/index.ts index 034bdf46c..8f9732ef0 100644 --- a/apps/rebalancer/src/index.ts +++ b/apps/rebalancer/src/index.ts @@ -105,6 +105,16 @@ async function getDailyBridgeLimitContext(): Promise<{ bridgedToday: Big; dailyL return { bridgedToday, dailyLimitRaw }; } +interface CurrentRunDailyLimitEvaluation { + dailyVolume: { + bypassedForProfit: boolean; + limitRaw: string; + projectedTotalRaw: string; + usedRaw: string; + }; + decision?: DailyBridgeLimitDecision; +} + function logDailyLimitDecision(decision: DailyBridgeLimitDecision, dailyLimitUsd: number) { if (decision.reason === "under_limit") return; @@ -115,19 +125,30 @@ function logDailyLimitDecision(decision: DailyBridgeLimitDecision, dailyLimitUsd async function evaluateCurrentRunDailyLimit( amountUsdcRaw: string, profitable: boolean -): Promise { +): Promise { const config = getConfig(); + const { bridgedToday, dailyLimitRaw } = await getDailyBridgeLimitContext(); + const dailyVolume = { + bypassedForProfit: profitable, + limitRaw: dailyLimitRaw.toFixed(0, 0), + projectedTotalRaw: bridgedToday.plus(Big(amountUsdcRaw)).toFixed(0, 0), + usedRaw: bridgedToday.toFixed(0, 0) + }; + if (profitable) { console.log( `Daily bridge limit bypassed: projected profitable quote for ${Big(amountUsdcRaw).div(1e6).toFixed(6)} USDC. No limit applies.` ); - return undefined; + return { dailyVolume }; } - const dailyLimitDecision = await evaluatePaidRunDailyLimit(amountUsdcRaw, profitable, getDailyBridgeLimitContext); - if (!dailyLimitDecision) return undefined; + const dailyLimitDecision = await evaluatePaidRunDailyLimit(amountUsdcRaw, profitable, async () => ({ + bridgedToday, + dailyLimitRaw + })); + if (!dailyLimitDecision) return { dailyVolume }; logDailyLimitDecision(dailyLimitDecision, config.rebalancingDailyBridgeLimitUsd); - return dailyLimitDecision; + return { dailyVolume, decision: dailyLimitDecision }; } function calculateCoverageDeviationBps(coverageRatio: number, triggerBound: number): number { @@ -235,13 +256,14 @@ async function executeUsdcToBrlaRebalance( options: { opportunistic?: boolean } = {} ): Promise { const config = getConfig(); - const dailyLimitDecision = await evaluateCurrentRunDailyLimit(amountUsdcRaw, policyDecision.profitable); - if (dailyLimitDecision?.shouldSkip) return false; + const dailyLimitEvaluation = await evaluateCurrentRunDailyLimit(amountUsdcRaw, policyDecision.profitable); + if (dailyLimitEvaluation.decision?.shouldSkip) return false; await checkInitialUsdcBalanceOnBase(amountUsdcRaw); await rebalanceUsdcBrlaUsdcBase(amountUsdcRaw, forceRestart, policyDecision.routeToRun, { config: config.rebalancingCostPolicy, - dailyLimitDecision, + dailyLimitDecision: dailyLimitEvaluation.decision, + dailyVolume: dailyLimitEvaluation.dailyVolume, decision: policyDecision.decision, deviationBps: coverageDeviationBps, fallbackRequiresProfit: policyDecision.profitable, @@ -334,8 +356,8 @@ async function runBrlaToUsdc(coverageDeviationBps: number) { const policyDecision = await evaluateBrlaToUsdcPolicy(amountUsdcRaw, coverageDeviationBps); if (!policyDecision.shouldExecute) return; - const dailyLimitDecision = await evaluateCurrentRunDailyLimit(amountUsdcRaw, policyDecision.profitable); - if (dailyLimitDecision?.shouldSkip) 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)) { @@ -345,7 +367,8 @@ async function runBrlaToUsdc(coverageDeviationBps: number) { } await rebalanceBrlaToUsdcBase(amountUsdcRaw, forceRestart, { config: config.rebalancingCostPolicy, - dailyLimitDecision, + dailyLimitDecision: dailyLimitEvaluation.decision, + dailyVolume: dailyLimitEvaluation.dailyVolume, decision: policyDecision.decision, deviationBps: coverageDeviationBps, fallbackRequiresProfit: policyDecision.profitable diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts index 1abc55364..fd4486818 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts @@ -425,6 +425,11 @@ export async function rebalanceUsdcBrlaUsdcBase( console.log(`Route taken: ${state.winningRoute}`); console.log(`Cost: absolute: ${cost.toFixed(6)} USDC | relative: ${costRelative}`); + const edgeCaseFlags = [ + policy?.opportunistic || state.opportunisticUsdcToBrla ? "OPP" : null, + state.winningRoute === "squidrouter" && state.baseUsdcBalanceBeforeAveniaSwapRaw ? "FB" : null + ].filter(flag => flag !== null); + await stateManager.addHistoryEntry({ cost: cost.toFixed(6), costRelative, @@ -438,6 +443,7 @@ export async function rebalanceUsdcBrlaUsdcBase( text: formatBaseRebalanceCompletionMessage({ brlaReceived: Big(state.brlaAmountDecimal), cost, + edgeCaseFlags, finalUsdcBalance: finalUsdcDecimal, initialUsdcBalance: initialUsdcDecimal, policy: policy ?? { config: getConfig().rebalancingCostPolicy }, diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.test.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.test.ts index 4bb6b8f36..05dad33b6 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.test.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.test.ts @@ -20,8 +20,15 @@ describe("Base rebalance Slack notifications", () => { cost: Big("12.34"), finalUsdcBalance: Big("987.66"), initialUsdcBalance: Big("1000"), + edgeCaseFlags: ["OPP", "FB"], policy: { config: policyConfig, + dailyVolume: { + bypassedForProfit: false, + limitRaw: "10000000000", + projectedTotalRaw: "3500000000", + usedRaw: "2500000000" + }, decision: { allowedCostBps: 75, band: "moderate", @@ -45,6 +52,8 @@ describe("Base rebalance Slack notifications", () => { expect(message).toContain("*Policy*"); expect(message).toContain("Mode Decision Band Dev bps Cost bps Cap bps Hard bps"); expect(message).toContain("auto execute moderate 220 42 75 1000"); + expect(message).toContain("Daily used/limit Daily proj Flags"); + expect(message).toContain("2500.00/10000.00 3500.00 OPP+FB"); expect(message).toContain("Bands bps: mod>=200 severe>=500 | caps bps: mild<=25 mod<=75 severe<=250"); }); }); diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts index eb3fbf5b5..550480a63 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts @@ -5,6 +5,12 @@ import type { DailyBridgeLimitDecision, RebalancingCostPolicyConfig, Rebalancing export interface RebalancePolicySummary { config: RebalancingCostPolicyConfig; dailyLimitDecision?: DailyBridgeLimitDecision; + dailyVolume?: { + bypassedForProfit: boolean; + limitRaw: string; + projectedTotalRaw: string; + usedRaw: string; + }; decision?: RebalancingCostPolicyDecision; deviationBps?: number; fallbackRequiresProfit?: boolean; @@ -21,6 +27,7 @@ interface BaseRebalanceCompletionMessageParams { cost: Big; finalUsdcBalance: Big; initialUsdcBalance: Big; + edgeCaseFlags?: string[]; policy?: RebalancePolicySummary; requestedUsdc: Big; route: WinningRoute; @@ -46,14 +53,15 @@ export function formatBaseRebalanceCompletionMessage(params: BaseRebalanceComple ] ] ), - formatPolicySummary(params.policy) + formatPolicySummary(params.policy, params.edgeCaseFlags) ].join("\n"); } -export function formatPolicySummary(policy: RebalancePolicySummary | undefined): string { +export function formatPolicySummary(policy: RebalancePolicySummary | undefined, edgeCaseFlags: string[] = []): string { if (!policy) return "```Policy decision unavailable (resumed or manual execution).```"; const decision = policy.decision; + const contextRow = formatPolicyContext(policy, edgeCaseFlags); const decisionRow = decision ? [ policy.config.mode, @@ -62,17 +70,32 @@ export function formatPolicySummary(policy: RebalancePolicySummary | undefined): policy.deviationBps === undefined ? "N/A" : formatBps(policy.deviationBps), formatBps(decision.costBps), formatBps(decision.allowedCostBps), - formatBps(policy.config.hardMaxCostBps) + formatBps(policy.config.hardMaxCostBps), + ...contextRow ] - : [policy.config.mode, "unavailable", "N/A", "N/A", "N/A", "N/A", formatBps(policy.config.hardMaxCostBps)]; + : [policy.config.mode, "unavailable", "N/A", "N/A", "N/A", "N/A", formatBps(policy.config.hardMaxCostBps), ...contextRow]; return [ "*Policy*", - formatCompactTable(["Mode", "Decision", "Band", "Dev bps", "Cost bps", "Cap bps", "Hard bps"], [decisionRow]), + formatCompactTable( + ["Mode", "Decision", "Band", "Dev bps", "Cost bps", "Cap bps", "Hard bps", "Daily used/limit", "Daily proj", "Flags"], + [decisionRow] + ), `Bands bps: mod>=${formatBps(policy.config.moderateDeviationBps)} severe>=${formatBps(policy.config.severeDeviationBps)} | caps bps: mild<=${formatBps(policy.config.maxCostBpsMild)} mod<=${formatBps(policy.config.maxCostBpsModerate)} severe<=${formatBps(policy.config.maxCostBpsSevere)}` ].join("\n"); } +function formatPolicyContext(policy: RebalancePolicySummary, edgeCaseFlags: string[]): string[] { + const dailyVolume = policy.dailyVolume; + const flags = [...edgeCaseFlags, dailyVolume?.bypassedForProfit ? "PB" : null].filter(flag => flag !== null); + + return [ + dailyVolume ? `${formatRawUsdc(dailyVolume.usedRaw)}/${formatRawUsdc(dailyVolume.limitRaw)}` : "N/A", + dailyVolume ? formatRawUsdc(dailyVolume.projectedTotalRaw) : "N/A", + flags.length > 0 ? flags.join("+") : "none" + ]; +} + export function formatCompactTable(headers: string[], rows: string[][]): string { const widths = headers.map((header, index) => Math.max(header.length, ...rows.map(row => row[index]?.length ?? 0))); const formatRow = (row: string[]) => @@ -92,6 +115,10 @@ function formatBps(value: number): string { return Number.isInteger(value) ? String(value) : value.toFixed(2); } +function formatRawUsdc(valueRaw: string): string { + return Big(valueRaw).div(1e6).toFixed(2); +} + function formatRoute(route: WinningRoute): string { if (route === "avenia") return "Avenia"; if (route === "squidrouter") return "SquidRouter"; From 959280e01de812f3345b1be7c138b13a0b3b0790 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 23 Jun 2026 12:26:11 +0200 Subject: [PATCH 121/131] Add resetFailedSquidRouterSwapOnResume function to handle failed swaps --- .../usdc-brla-usdc-base/steps.test.ts | 49 +++++++++++++++++++ .../rebalance/usdc-brla-usdc-base/steps.ts | 28 ++++++++++- .../security-spec/07-operations/rebalancer.md | 9 ++-- 3 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.test.ts diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.test.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.test.ts new file mode 100644 index 000000000..ac7fd6097 --- /dev/null +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.test.ts @@ -0,0 +1,49 @@ +import {describe, expect, test} from "bun:test"; +import {createUsdcBaseRebalanceState, UsdcBaseRebalancePhase} from "../../services/stateManager.ts"; +import {resetFailedSquidRouterSwapOnResume} from "./steps.ts"; + +describe("USDC Base SquidRouter steps", () => { + test("clears a persisted SquidRouter swap when the Polygon receipt failed", async () => { + const state = createUsdcBaseRebalanceState("1000000000", UsdcBaseRebalancePhase.SquidRouterApproveAndSwap); + state.squidRouterSwapHash = "0xfailed"; + state.squidRouterQuoteUsdc = "999060253"; + + const savedStates: Array<{ squidRouterQuoteUsdc: string | null; squidRouterSwapHash: string | null }> = []; + const stateManager = { + saveState: async () => { + savedStates.push({ squidRouterQuoteUsdc: state.squidRouterQuoteUsdc, squidRouterSwapHash: state.squidRouterSwapHash }); + } + }; + const publicClient = { + getTransactionReceipt: async () => ({ status: "reverted" }) + }; + + await expect( + resetFailedSquidRouterSwapOnResume("0xfailed", state, stateManager, publicClient as never) + ).resolves.toBe(true); + expect(state.squidRouterSwapHash).toBeNull(); + expect(state.squidRouterQuoteUsdc).toBeNull(); + expect(savedStates).toEqual([{ squidRouterQuoteUsdc: null, squidRouterSwapHash: null }]); + }); + + test("keeps a persisted SquidRouter swap when the Polygon receipt succeeded", async () => { + const state = createUsdcBaseRebalanceState("1000000000", UsdcBaseRebalancePhase.SquidRouterApproveAndSwap); + state.squidRouterSwapHash = "0xsuccess"; + state.squidRouterQuoteUsdc = "999060253"; + + const stateManager = { + saveState: async () => { + throw new Error("successful receipts should not rewrite state"); + } + }; + const publicClient = { + getTransactionReceipt: async () => ({ status: "success" }) + }; + + await expect( + resetFailedSquidRouterSwapOnResume("0xsuccess", state, stateManager, publicClient as never) + ).resolves.toBe(false); + expect(state.squidRouterSwapHash).toBe("0xsuccess"); + expect(state.squidRouterQuoteUsdc).toBe("999060253"); + }); +}); diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts index aa081c2d2..efd451bf9 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts @@ -16,7 +16,7 @@ import { Networks } from "@vortexfi/shared"; import Big from "big.js"; -import { encodeFunctionData, erc20Abi } from "viem"; +import { encodeFunctionData, erc20Abi, type PublicClient } from "viem"; import { base, polygon } from "viem/chains"; import { brlaMoonbeamTokenDetails } from "../../constants.ts"; import { UsdcBaseRebalanceState, UsdcBaseStateManager, type WinningRoute } from "../../services/stateManager.ts"; @@ -584,6 +584,23 @@ export async function waitBrlaOnPolygon(brlaAmountRaw: string, startingBrlaBalan return arrivedRaw; } +export async function resetFailedSquidRouterSwapOnResume( + swapHash: string, + state: UsdcBaseRebalanceState, + stateManager: Pick, + polygonPublicClient: PublicClient +): Promise { + const receipt = await polygonPublicClient.getTransactionReceipt({ hash: swapHash as `0x${string}` }); + + if (receipt.status === "success") return false; + + console.warn(`Persisted SquidRouter swap tx ${swapHash} failed on Polygon. Retrying with a fresh route.`); + state.squidRouterSwapHash = null; + state.squidRouterQuoteUsdc = null; + await stateManager.saveState(state); + return true; +} + export async function squidRouterApproveAndSwap( brlaAmountRaw: string, baseReceiverAddress: `0x${string}`, @@ -595,6 +612,15 @@ export async function squidRouterApproveAndSwap( let toAmountUsd = "0"; let toAmountRaw = state.squidRouterQuoteUsdc ?? "0"; + if (swapHash) { + const { publicClient: polygonPublicClient } = getPolygonEvmClients(); + + if (await resetFailedSquidRouterSwapOnResume(swapHash, state, stateManager, polygonPublicClient)) { + swapHash = null; + toAmountRaw = "0"; + } + } + if (!swapHash) { console.log("Executing SquidRouter swap: Polygon BRLA -> Base USDC..."); diff --git a/docs/security-spec/07-operations/rebalancer.md b/docs/security-spec/07-operations/rebalancer.md index 57dfad178..95de7f179 100644 --- a/docs/security-spec/07-operations/rebalancer.md +++ b/docs/security-spec/07-operations/rebalancer.md @@ -158,9 +158,10 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- 18. **Avenia fallback to SquidRouter MUST be atomic in state** — If Avenia ticket creation fails, the flow sets `winningRoute = "squidrouter"` and `currentPhase = AveniaTransferToPolygon` in a single `saveState()` call. A crash between the failure and the save could leave the flow in an inconsistent state. 19. **NonceManager MUST be re-initialized on resume** — The `NonceManager` is created fresh at the start of each execution from `getTransactionCount()`. On resume, it must not reuse stale nonces from a previous execution. 20. **Axelar cross-chain execution MUST have a timeout** — SquidRouter's Axelar polling has a 30-minute timeout. If Axelar does not confirm execution within this window, the flow MUST throw (not poll indefinitely). This resolves F-034 for the Base flow. -21. **Balance arrival checks MUST be delta-based** — The Base high-coverage flow persists pre-action balances before each arrival-producing operation and waits for `starting balance + expected delta` rather than checking absolute hot-wallet/provider balances. Avenia BRLA arrival checks allow a 95% tolerance for provider-side deductions, while Base/Polygon on-chain arrival checks use the default 99.8% tolerance for rounding, route deductions, and minor quote shortfalls without sweeping unrelated leftover balances into the current run. The actual received Base USDC delta is persisted before advancing to final verification. -22. **`EVM_ACCOUNT_SECRET` derives the same address on all EVM chains** — A single BIP-39 mnemonic is used for Base, Polygon, and Moonbeam. This means compromise of this one secret drains the rebalancer on ALL EVM chains. `PENDULUM_ACCOUNT_SECRET` is separate and legacy-only. -23. **Terminal Avenia ticket failures MUST NOT poll indefinitely** — `checkTicketStatusPaid` treats `FAILED` as terminal and throws immediately instead of retrying until timeout. `PARTIAL-FAILED` is surfaced as a retryable ticket-specific status so the SquidRouter BRLA-to-Polygon branch can reconcile partial arrival and create a replacement ticket only for the remaining amount. +21. **SquidRouter source transactions MUST be receipt-gated before Axelar polling** — On resume, a persisted Polygon SquidRouter swap hash must be checked on Polygon before Axelar polling starts. If the source receipt failed, the stale hash/quote must be cleared and the flow must request a fresh SquidRouter route instead of waiting for an Axelar execution that can never occur. +22. **Balance arrival checks MUST be delta-based** — The Base high-coverage flow persists pre-action balances before each arrival-producing operation and waits for `starting balance + expected delta` rather than checking absolute hot-wallet/provider balances. Avenia BRLA arrival checks allow a 95% tolerance for provider-side deductions, while Base/Polygon on-chain arrival checks use the default 99.8% tolerance for rounding, route deductions, and minor quote shortfalls without sweeping unrelated leftover balances into the current run. The actual received Base USDC delta is persisted before advancing to final verification. +23. **`EVM_ACCOUNT_SECRET` derives the same address on all EVM chains** — A single BIP-39 mnemonic is used for Base, Polygon, and Moonbeam. This means compromise of this one secret drains the rebalancer on ALL EVM chains. `PENDULUM_ACCOUNT_SECRET` is separate and legacy-only. +24. **Terminal Avenia ticket failures MUST NOT poll indefinitely** — `checkTicketStatusPaid` treats `FAILED` as terminal and throws immediately instead of retrying until timeout. `PARTIAL-FAILED` is surfaced as a retryable ticket-specific status so the SquidRouter BRLA-to-Polygon branch can reconcile partial arrival and create a replacement ticket only for the remaining amount. ## Threat Vectors & Mitigations @@ -197,7 +198,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- | **Quote-cost manipulation near thresholds** — Provider quotes near a configured boundary can nudge execution or skipping | Cost policy uses the best/forced quoted route before any side effect. Hard max-cost cap limits catastrophic execution, but provider quote trust remains a known risk. | | **NonceManager stale nonce** — If the process crashes after sending a transaction but before saving the nonce, the resumed execution could reuse the same nonce | **Mitigated.** `NonceManager` is re-initialized from `getTransactionCount()` on each execution. The stored transaction hashes in state also prevent re-execution of already-completed phases. | | **`EVM_ACCOUNT_SECRET` single-key blast radius** — One mnemonic controls all EVM chain accounts for the rebalancer | Compromise of this one secret drains rebalancer funds on Base, Polygon, and Moonbeam. The separate `PENDULUM_ACCOUNT_SECRET` limits Pendulum blast radius to the legacy flow. This is a deliberate simplification accepted for operational convenience. | -| **SquidRouter cross-chain timeout** — Axelar cross-chain execution could take longer than 30 minutes during network congestion | The rebalancer throws on timeout, leaving the BRLA-to-USDC swap incomplete on Polygon. Funds would be stuck as BRLA on Polygon until manual intervention or the next rebalance attempt resumes from the `SquidRouterApproveAndSwap` phase. | +| **SquidRouter source transaction failure or cross-chain timeout** — The Polygon source swap can fail before Axelar sees it, or Axelar cross-chain execution could take longer than 30 minutes during network congestion | **Partially mitigated.** On resume, the Base flow checks the persisted Polygon SquidRouter swap receipt before Axelar polling; failed source receipts clear the stale hash/quote and retry with a fresh route. If the source succeeds but Axelar does not confirm within 30 minutes, the rebalancer throws and the next attempt resumes from `SquidRouterApproveAndSwap`. | | **Absolute balance false positives** — Hot wallets/provider accounts can contain leftovers from previous runs, so absolute balance checks could pass before the current run's funds arrive | **Mitigated for Base flow.** The flow stores pre-action baselines and waits for deltas on Avenia BRLA, Polygon BRLA, and Base USDC arrivals. | | **BRLA balance tolerance** — Avenia BRLA delta checks accept 95% of expected amount as sufficient, while on-chain Base/Polygon arrival checks use 99.8% | If Avenia deducts a fee > 5%, the flow will not proceed and will time out. The tolerance prevents provider-side deductions and rounding dust from blocking valid arrivals while rejecting meaningful shortfalls. | From 08866d96300c47317962562dc979858045e348ea Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 23 Jun 2026 16:09:40 +0200 Subject: [PATCH 122/131] Improve recovery of squidrouter transfers --- .../rebalance/usdc-brla-usdc-base/index.ts | 6 +- .../usdc-brla-usdc-base/steps.test.ts | 63 ++++++++++++++++++- .../rebalance/usdc-brla-usdc-base/steps.ts | 61 +++++++++++++++++- .../security-spec/07-operations/rebalancer.md | 9 +-- 4 files changed, 128 insertions(+), 11 deletions(-) diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts index fd4486818..945fb68d5 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts @@ -378,9 +378,11 @@ export async function rebalanceUsdcBrlaUsdcBase( const result = await squidRouterApproveAndSwap(state.brlaAmountRaw, baseAddress, polygonNonce, state, stateManager); - state.squidRouterSwapHash = result.swapHash; + if (result.swapHash) { + state.squidRouterSwapHash = result.swapHash; + } state.squidRouterQuoteUsdc = result.toAmountRaw; - console.log(`SquidRouter swap completed. Tx: ${result.swapHash}`); + console.log(`SquidRouter swap completed. Tx: ${result.swapHash ?? "recovered from Base balance"}`); state.currentPhase = UsdcBaseRebalancePhase.WaitUsdcOnBaseFromSquid; await stateManager.saveState(state); } diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.test.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.test.ts index ac7fd6097..3c8ac696e 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.test.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.test.ts @@ -1,6 +1,10 @@ import {describe, expect, test} from "bun:test"; import {createUsdcBaseRebalanceState, UsdcBaseRebalancePhase} from "../../services/stateManager.ts"; -import {resetFailedSquidRouterSwapOnResume} from "./steps.ts"; +import { + ensurePolygonBrlaAvailableForSquidSwap, + recoverSquidUsdcOutputFromBaseBalance, + resetFailedSquidRouterSwapOnResume +} from "./steps.ts"; describe("USDC Base SquidRouter steps", () => { test("clears a persisted SquidRouter swap when the Polygon receipt failed", async () => { @@ -15,7 +19,7 @@ describe("USDC Base SquidRouter steps", () => { } }; const publicClient = { - getTransactionReceipt: async () => ({ status: "reverted" }) + waitForTransactionReceipt: async () => ({ status: "reverted" }) }; await expect( @@ -37,7 +41,7 @@ describe("USDC Base SquidRouter steps", () => { } }; const publicClient = { - getTransactionReceipt: async () => ({ status: "success" }) + waitForTransactionReceipt: async () => ({ status: "success" }) }; await expect( @@ -46,4 +50,57 @@ describe("USDC Base SquidRouter steps", () => { expect(state.squidRouterSwapHash).toBe("0xsuccess"); expect(state.squidRouterQuoteUsdc).toBe("999060253"); }); + + test("recovers SquidRouter output from Base USDC balance delta", async () => { + const state = createUsdcBaseRebalanceState("1000000000", UsdcBaseRebalancePhase.SquidRouterApproveAndSwap); + state.squidRouterQuoteUsdc = "999000000"; + + const savedStates: Array<{ squidRouterQuoteUsdc: string | null }> = []; + const stateManager = { + saveState: async () => { + savedStates.push({ squidRouterQuoteUsdc: state.squidRouterQuoteUsdc }); + } + }; + + await expect( + recoverSquidUsdcOutputFromBaseBalance( + "999000000", + "1000000", + state, + stateManager, + async () => "999500000" + ) + ).resolves.toBe("998500000"); + expect(state.squidRouterQuoteUsdc).toBe("998500000"); + expect(savedStates).toEqual([{ squidRouterQuoteUsdc: "998500000" }]); + }); + + test("does not recover SquidRouter output when Base USDC delta is below tolerance", async () => { + const state = createUsdcBaseRebalanceState("1000000000", UsdcBaseRebalancePhase.SquidRouterApproveAndSwap); + state.squidRouterQuoteUsdc = "999000000"; + + const stateManager = { + saveState: async () => { + throw new Error("insufficient recovery delta should not rewrite state"); + } + }; + + await expect( + recoverSquidUsdcOutputFromBaseBalance( + "999000000", + "1000000", + state, + stateManager, + async () => "997000000" + ) + ).resolves.toBeNull(); + expect(state.squidRouterQuoteUsdc).toBe("999000000"); + }); + + test("blocks SquidRouter swaps when Polygon BRLA is below the required input", () => { + expect(() => ensurePolygonBrlaAvailableForSquidSwap("499999999999999999999", "500000000000000000000")).toThrow( + "Insufficient Polygon BRLA" + ); + expect(() => ensurePolygonBrlaAvailableForSquidSwap("500000000000000000000", "500000000000000000000")).not.toThrow(); + }); }); diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts index efd451bf9..1547e0cbd 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts @@ -590,7 +590,10 @@ export async function resetFailedSquidRouterSwapOnResume( stateManager: Pick, polygonPublicClient: PublicClient ): Promise { - const receipt = await polygonPublicClient.getTransactionReceipt({ hash: swapHash as `0x${string}` }); + const receipt = await polygonPublicClient.waitForTransactionReceipt({ + confirmations: 1, + hash: swapHash as `0x${string}` + }); if (receipt.status === "success") return false; @@ -601,13 +604,47 @@ export async function resetFailedSquidRouterSwapOnResume( return true; } +export async function recoverSquidUsdcOutputFromBaseBalance( + expectedUsdcRaw: string | null, + startingUsdcBalanceRaw: string | null, + state: UsdcBaseRebalanceState, + stateManager: Pick, + getCurrentBaseUsdcRaw = getUsdcBalanceOnBaseRaw +): Promise { + if (!expectedUsdcRaw || !startingUsdcBalanceRaw) return null; + + const currentBaseUsdcRaw = Big(await getCurrentBaseUsdcRaw()); + const receivedDeltaRaw = currentBaseUsdcRaw.minus(Big(startingUsdcBalanceRaw)); + const minimumReceivedRaw = calculateMinimumDelta(Big(expectedUsdcRaw), DEFAULT_ARRIVAL_TOLERANCE); + + if (receivedDeltaRaw.lt(minimumReceivedRaw)) return null; + + const recoveredUsdcRaw = receivedDeltaRaw.toFixed(0, 0); + state.squidRouterQuoteUsdc = recoveredUsdcRaw; + await stateManager.saveState(state); + console.log( + `Recovered SquidRouter Base USDC output from balance delta: ${multiplyByPowerOfTen(Big(recoveredUsdcRaw), -6).toFixed(6)} USDC ` + + `(pre: ${startingUsdcBalanceRaw}, post: ${currentBaseUsdcRaw.toFixed(0, 0)})` + ); + + return recoveredUsdcRaw; +} + +export function ensurePolygonBrlaAvailableForSquidSwap(availableBrlaRaw: string, requiredBrlaRaw: string): void { + if (Big(availableBrlaRaw).gte(Big(requiredBrlaRaw))) return; + + throw new Error( + `Insufficient Polygon BRLA for SquidRouter swap: required ${requiredBrlaRaw} raw, available ${availableBrlaRaw} raw.` + ); +} + export async function squidRouterApproveAndSwap( brlaAmountRaw: string, baseReceiverAddress: `0x${string}`, polygonNonce: NonceManager, state: UsdcBaseRebalanceState, stateManager: UsdcBaseStateManager -): Promise<{ swapHash: string; toAmountUsd: string; toAmountRaw: string }> { +): Promise<{ swapHash: string | null; toAmountUsd: string; toAmountRaw: string }> { let swapHash = state.squidRouterSwapHash; let toAmountUsd = "0"; let toAmountRaw = state.squidRouterQuoteUsdc ?? "0"; @@ -615,6 +652,14 @@ export async function squidRouterApproveAndSwap( if (swapHash) { const { publicClient: polygonPublicClient } = getPolygonEvmClients(); + const recoveredUsdcRaw = await recoverSquidUsdcOutputFromBaseBalance( + state.squidRouterQuoteUsdc, + state.baseUsdcBalanceBeforeSquidSwapRaw, + state, + stateManager + ); + if (recoveredUsdcRaw) return { swapHash, toAmountRaw: recoveredUsdcRaw, toAmountUsd }; + if (await resetFailedSquidRouterSwapOnResume(swapHash, state, stateManager, polygonPublicClient)) { swapHash = null; toAmountRaw = "0"; @@ -627,6 +672,16 @@ export async function squidRouterApproveAndSwap( const { walletClient: polygonWalletClient, publicClient: polygonPublicClient } = getPolygonEvmClients(); const polygonAddress = polygonWalletClient.account.address; + const recoveredUsdcRaw = await recoverSquidUsdcOutputFromBaseBalance( + state.squidRouterQuoteUsdc, + state.baseUsdcBalanceBeforeSquidSwapRaw, + state, + stateManager + ); + if (recoveredUsdcRaw) return { swapHash, toAmountRaw: recoveredUsdcRaw, toAmountUsd }; + + ensurePolygonBrlaAvailableForSquidSwap(await getBrlaBalanceOnPolygonRaw(), brlaAmountRaw); + const routeResult = await getRoute({ bypassGuardrails: true, enableExpress: true, @@ -643,6 +698,8 @@ export async function squidRouterApproveAndSwap( const route = routeResult.data.route; toAmountUsd = route.estimate.toAmountUSD; toAmountRaw = route.estimate.toAmount; + state.squidRouterQuoteUsdc = toAmountRaw; + await stateManager.saveState(state); console.log(`SquidRouter route obtained. Expected output: ${toAmountRaw} USDC (raw)`); const { approveData, swapData } = await createTransactionDataFromRoute({ diff --git a/docs/security-spec/07-operations/rebalancer.md b/docs/security-spec/07-operations/rebalancer.md index 95de7f179..8eaf0b948 100644 --- a/docs/security-spec/07-operations/rebalancer.md +++ b/docs/security-spec/07-operations/rebalancer.md @@ -158,10 +158,11 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- 18. **Avenia fallback to SquidRouter MUST be atomic in state** — If Avenia ticket creation fails, the flow sets `winningRoute = "squidrouter"` and `currentPhase = AveniaTransferToPolygon` in a single `saveState()` call. A crash between the failure and the save could leave the flow in an inconsistent state. 19. **NonceManager MUST be re-initialized on resume** — The `NonceManager` is created fresh at the start of each execution from `getTransactionCount()`. On resume, it must not reuse stale nonces from a previous execution. 20. **Axelar cross-chain execution MUST have a timeout** — SquidRouter's Axelar polling has a 30-minute timeout. If Axelar does not confirm execution within this window, the flow MUST throw (not poll indefinitely). This resolves F-034 for the Base flow. -21. **SquidRouter source transactions MUST be receipt-gated before Axelar polling** — On resume, a persisted Polygon SquidRouter swap hash must be checked on Polygon before Axelar polling starts. If the source receipt failed, the stale hash/quote must be cleared and the flow must request a fresh SquidRouter route instead of waiting for an Axelar execution that can never occur. +21. **SquidRouter source transactions MUST be receipt-gated before Axelar polling** — On resume, a persisted Polygon SquidRouter swap hash must be checked on Polygon before Axelar polling starts. Before retrying a failed or missing SquidRouter swap, the flow must first check whether the expected Base USDC delta already arrived from the previous attempt. If not recovered and the source receipt failed, the stale hash/quote must be cleared and the flow must request a fresh SquidRouter route instead of waiting for an Axelar execution that can never occur. 22. **Balance arrival checks MUST be delta-based** — The Base high-coverage flow persists pre-action balances before each arrival-producing operation and waits for `starting balance + expected delta` rather than checking absolute hot-wallet/provider balances. Avenia BRLA arrival checks allow a 95% tolerance for provider-side deductions, while Base/Polygon on-chain arrival checks use the default 99.8% tolerance for rounding, route deductions, and minor quote shortfalls without sweeping unrelated leftover balances into the current run. The actual received Base USDC delta is persisted before advancing to final verification. -23. **`EVM_ACCOUNT_SECRET` derives the same address on all EVM chains** — A single BIP-39 mnemonic is used for Base, Polygon, and Moonbeam. This means compromise of this one secret drains the rebalancer on ALL EVM chains. `PENDULUM_ACCOUNT_SECRET` is separate and legacy-only. -24. **Terminal Avenia ticket failures MUST NOT poll indefinitely** — `checkTicketStatusPaid` treats `FAILED` as terminal and throws immediately instead of retrying until timeout. `PARTIAL-FAILED` is surfaced as a retryable ticket-specific status so the SquidRouter BRLA-to-Polygon branch can reconcile partial arrival and create a replacement ticket only for the remaining amount. +23. **SquidRouter swaps MUST require available Polygon BRLA before submission** — Before requesting and submitting a fresh SquidRouter Polygon BRLA → Base USDC swap, the flow must verify the Polygon account still holds at least the BRLA amount selected for the swap. If the balance is insufficient and Base USDC recovery does not prove completion, the flow MUST throw instead of submitting an inevitably failing transaction. +24. **`EVM_ACCOUNT_SECRET` derives the same address on all EVM chains** — A single BIP-39 mnemonic is used for Base, Polygon, and Moonbeam. This means compromise of this one secret drains the rebalancer on ALL EVM chains. `PENDULUM_ACCOUNT_SECRET` is separate and legacy-only. +25. **Terminal Avenia ticket failures MUST NOT poll indefinitely** — `checkTicketStatusPaid` treats `FAILED` as terminal and throws immediately instead of retrying until timeout. `PARTIAL-FAILED` is surfaced as a retryable ticket-specific status so the SquidRouter BRLA-to-Polygon branch can reconcile partial arrival and create a replacement ticket only for the remaining amount. ## Threat Vectors & Mitigations @@ -198,7 +199,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- | **Quote-cost manipulation near thresholds** — Provider quotes near a configured boundary can nudge execution or skipping | Cost policy uses the best/forced quoted route before any side effect. Hard max-cost cap limits catastrophic execution, but provider quote trust remains a known risk. | | **NonceManager stale nonce** — If the process crashes after sending a transaction but before saving the nonce, the resumed execution could reuse the same nonce | **Mitigated.** `NonceManager` is re-initialized from `getTransactionCount()` on each execution. The stored transaction hashes in state also prevent re-execution of already-completed phases. | | **`EVM_ACCOUNT_SECRET` single-key blast radius** — One mnemonic controls all EVM chain accounts for the rebalancer | Compromise of this one secret drains rebalancer funds on Base, Polygon, and Moonbeam. The separate `PENDULUM_ACCOUNT_SECRET` limits Pendulum blast radius to the legacy flow. This is a deliberate simplification accepted for operational convenience. | -| **SquidRouter source transaction failure or cross-chain timeout** — The Polygon source swap can fail before Axelar sees it, or Axelar cross-chain execution could take longer than 30 minutes during network congestion | **Partially mitigated.** On resume, the Base flow checks the persisted Polygon SquidRouter swap receipt before Axelar polling; failed source receipts clear the stale hash/quote and retry with a fresh route. If the source succeeds but Axelar does not confirm within 30 minutes, the rebalancer throws and the next attempt resumes from `SquidRouterApproveAndSwap`. | +| **SquidRouter source transaction failure, duplicate retry, or cross-chain timeout** — The Polygon source swap can fail before Axelar sees it, a previous attempt may already have delivered USDC on Base, or Axelar cross-chain execution could take longer than 30 minutes during network congestion | **Partially mitigated.** On resume, the Base flow checks for a recovered Base USDC delta before retrying, checks the persisted Polygon SquidRouter swap receipt before Axelar polling, and refuses fresh SquidRouter submissions when Polygon BRLA is below the intended input. Failed source receipts clear the stale hash/quote and retry with a fresh route only when Base recovery is not already proven. If the source succeeds but Axelar does not confirm within 30 minutes, the rebalancer throws and the next attempt resumes from `SquidRouterApproveAndSwap`. | | **Absolute balance false positives** — Hot wallets/provider accounts can contain leftovers from previous runs, so absolute balance checks could pass before the current run's funds arrive | **Mitigated for Base flow.** The flow stores pre-action baselines and waits for deltas on Avenia BRLA, Polygon BRLA, and Base USDC arrivals. | | **BRLA balance tolerance** — Avenia BRLA delta checks accept 95% of expected amount as sufficient, while on-chain Base/Polygon arrival checks use 99.8% | If Avenia deducts a fee > 5%, the flow will not proceed and will time out. The tolerance prevents provider-side deductions and rounding dust from blocking valid arrivals while rejecting meaningful shortfalls. | From f3de22529ae93227414f2bbd0115eeed0f5bd205 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 23 Jun 2026 16:24:43 +0200 Subject: [PATCH 123/131] Amend rebalancer improve --- .../usdc-brla-usdc-base/steps.test.ts | 19 +++++++++++++++++++ .../rebalance/usdc-brla-usdc-base/steps.ts | 5 +++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.test.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.test.ts index 3c8ac696e..e4a5e43ba 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.test.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.test.ts @@ -75,6 +75,25 @@ describe("USDC Base SquidRouter steps", () => { expect(savedStates).toEqual([{ squidRouterQuoteUsdc: "998500000" }]); }); + test("recovers SquidRouter output with a missing Squid quote using the persisted Avenia quote", async () => { + const state = createUsdcBaseRebalanceState("1000000000", UsdcBaseRebalancePhase.SquidRouterApproveAndSwap); + state.aveniaQuoteUsdc = "997124681"; + state.squidRouterQuoteUsdc = null; + + const savedStates: Array<{ squidRouterQuoteUsdc: string | null }> = []; + const stateManager = { + saveState: async () => { + savedStates.push({ squidRouterQuoteUsdc: state.squidRouterQuoteUsdc }); + } + }; + + await expect( + recoverSquidUsdcOutputFromBaseBalance(null, "22337450", state, stateManager, async () => "1021377450") + ).resolves.toBe("999040000"); + expect(state.squidRouterQuoteUsdc as string | null).toBe("999040000"); + expect(savedStates).toEqual([{ squidRouterQuoteUsdc: "999040000" }]); + }); + test("does not recover SquidRouter output when Base USDC delta is below tolerance", async () => { const state = createUsdcBaseRebalanceState("1000000000", UsdcBaseRebalancePhase.SquidRouterApproveAndSwap); state.squidRouterQuoteUsdc = "999000000"; diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts index 1547e0cbd..edec2dd1b 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts @@ -611,11 +611,12 @@ export async function recoverSquidUsdcOutputFromBaseBalance( stateManager: Pick, getCurrentBaseUsdcRaw = getUsdcBalanceOnBaseRaw ): Promise { - if (!expectedUsdcRaw || !startingUsdcBalanceRaw) return null; + const recoveryExpectedUsdcRaw = expectedUsdcRaw ?? state.squidRouterQuoteUsdc ?? state.aveniaQuoteUsdc ?? state.usdcAmountRaw; + if (!recoveryExpectedUsdcRaw || !startingUsdcBalanceRaw) return null; const currentBaseUsdcRaw = Big(await getCurrentBaseUsdcRaw()); const receivedDeltaRaw = currentBaseUsdcRaw.minus(Big(startingUsdcBalanceRaw)); - const minimumReceivedRaw = calculateMinimumDelta(Big(expectedUsdcRaw), DEFAULT_ARRIVAL_TOLERANCE); + const minimumReceivedRaw = calculateMinimumDelta(Big(recoveryExpectedUsdcRaw), DEFAULT_ARRIVAL_TOLERANCE); if (receivedDeltaRaw.lt(minimumReceivedRaw)) return null; From 4b8657481a72abd869e65adc8f98a83d3ec57ffa Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 23 Jun 2026 17:07:10 +0200 Subject: [PATCH 124/131] Improve wording of rebalancer log message --- apps/rebalancer/src/index.ts | 7 +++++-- apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts | 3 ++- .../src/rebalance/usdc-brla-usdc-base/notifications.ts | 1 + 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/rebalancer/src/index.ts b/apps/rebalancer/src/index.ts index 8f9732ef0..b3ab979b7 100644 --- a/apps/rebalancer/src/index.ts +++ b/apps/rebalancer/src/index.ts @@ -206,6 +206,7 @@ async function evaluateUsdcToBrlaPolicy( mainNablaQuoteUsdc: string | null; squidRouterQuoteUsdc: string | null; }; + routeSelection?: "forced" | "best-quote"; shouldExecute: boolean; routeToRun?: Exclude; }> { @@ -226,7 +227,7 @@ async function evaluateUsdcToBrlaPolicy( if (!routeToRun) throw new Error("Route comparison did not select a route."); const projectedOutputRaw = getQuoteForRoute(routeToRun, comparison); - if (!projectedOutputRaw) throw new Error(`Forced route ${routeToRun} did not return a quote.`); + if (!projectedOutputRaw) throw new Error(`Selected route ${routeToRun} did not return a quote.`); const decision = evaluateRebalancingCostPolicy( Big(amountUsdcRaw), @@ -244,6 +245,7 @@ async function evaluateUsdcToBrlaPolicy( mainNablaQuoteUsdc: comparison.mainNablaQuoteUsdc, squidRouterQuoteUsdc: comparison.squidRouterQuoteUsdc }, + routeSelection: forcedRoute ? "forced" : "best-quote", routeToRun, shouldExecute: decision.shouldExecute }; @@ -268,7 +270,8 @@ async function executeUsdcToBrlaRebalance( deviationBps: coverageDeviationBps, fallbackRequiresProfit: policyDecision.profitable, opportunistic: options.opportunistic, - preflightQuotes: policyDecision.routeQuotes + preflightQuotes: policyDecision.routeQuotes, + routeSelection: policyDecision.routeSelection }); return true; } diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts index 945fb68d5..d767c20d4 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts @@ -121,7 +121,8 @@ export async function rebalanceUsdcBrlaUsdcBase( if (!state.usdcAmountRaw) throw new Error("State corrupted: usdcAmountRaw missing for step 2"); if (forcedRoute) { - console.log(`Forced route: ${forcedRoute}. Skipping full comparison.`); + const routeSelection = policy?.routeSelection === "forced" ? "Forced route" : "Preselected best-quote route"; + console.log(`${routeSelection}: ${forcedRoute}. Reusing preflight quotes and skipping duplicate comparison.`); state.winningRoute = forcedRoute; state.squidRouterQuoteUsdc = policy?.preflightQuotes?.squidRouterQuoteUsdc ?? null; state.aveniaQuoteUsdc = policy?.preflightQuotes?.aveniaQuoteUsdc ?? null; diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts index 550480a63..cd0521825 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts @@ -20,6 +20,7 @@ export interface RebalancePolicySummary { mainNablaQuoteUsdc: string | null; squidRouterQuoteUsdc: string | null; }; + routeSelection?: "forced" | "best-quote"; } interface BaseRebalanceCompletionMessageParams { From 7d5c8b2d22244741ec77bd0db8d6e6e32701c724 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 23 Jun 2026 17:47:16 +0200 Subject: [PATCH 125/131] Fix log ambiguity --- .../src/services/evm/clientManager.test.ts | 18 ++++++++++++++++- .../shared/src/services/evm/clientManager.ts | 20 +++++++++++++------ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/packages/shared/src/services/evm/clientManager.test.ts b/packages/shared/src/services/evm/clientManager.test.ts index 69c027090..c5383e7a2 100644 --- a/packages/shared/src/services/evm/clientManager.test.ts +++ b/packages/shared/src/services/evm/clientManager.test.ts @@ -1,5 +1,6 @@ -import {describe, expect, it} from "bun:test"; +import {describe, expect, it, mock} from "bun:test"; import {Networks} from "../../helpers"; +import logger from "../../logger"; import {EvmClientManager, redactRpcUrlForLogs, sanitizeRpcErrorMessage} from "./clientManager"; describe("redactRpcUrlForLogs", () => { @@ -35,8 +36,19 @@ describe("EvmClientManager read contract retries", () => { const manager = EvmClientManager.getInstance(); const managerWithMockedClient = manager as EvmClientManager & { getClient: EvmClientManager["getClient"] }; const originalGetClient = managerWithMockedClient.getClient; + const originalLogger = logger.current; + const warningMessages: string[] = []; let attempts = 0; + logger.current = { + debug: mock(() => {}), + error: mock(() => {}), + info: mock(() => {}), + warn: mock((...args: unknown[]) => { + warningMessages.push(String(args[0])); + }) + }; + managerWithMockedClient.getClient = (() => ({ readContract: async () => { @@ -59,8 +71,12 @@ describe("EvmClientManager read contract retries", () => { ) ).rejects.toThrow("EXCEEDS_MAX_COVERAGE_RATIO"); expect(attempts).toBe(1); + expect(warningMessages).toHaveLength(1); + expect(warningMessages[0]).toContain("read contract failed without retry on base"); + expect(warningMessages[0]).not.toContain("attempt 1/4 failed"); } finally { managerWithMockedClient.getClient = originalGetClient; + logger.current = originalLogger; } }); }); diff --git a/packages/shared/src/services/evm/clientManager.ts b/packages/shared/src/services/evm/clientManager.ts index 9018bbcc1..46fed69b9 100644 --- a/packages/shared/src/services/evm/clientManager.ts +++ b/packages/shared/src/services/evm/clientManager.ts @@ -188,13 +188,21 @@ export class EvmClientManager { return result; } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); + const retryable = shouldRetry(lastError); + const sanitizedMessage = sanitizeRpcErrorMessage(lastError.message); + + if (retryable) { + logger.current.warn( + `${operationName} attempt ${attempt + 1}/${maxRetries + 1} failed on ${networkName} with RPC ${redactRpcUrlForLogs(rpcUrl)}: ${sanitizedMessage}` + ); + } else { + logger.current.warn( + `${operationName} failed without retry on ${networkName} with RPC ${redactRpcUrlForLogs(rpcUrl)}: ${sanitizedMessage}` + ); + } - logger.current.warn( - `${operationName} attempt ${attempt + 1}/${maxRetries + 1} failed on ${networkName} with RPC ${redactRpcUrlForLogs(rpcUrl)}: ${sanitizeRpcErrorMessage(lastError.message)}` - ); - - if (!shouldRetry(lastError)) { - throw new Error(`${operationName} failed on ${networkName}: ${sanitizeRpcErrorMessage(lastError.message)}`, { + if (!retryable) { + throw new Error(`${operationName} failed on ${networkName}: ${sanitizedMessage}`, { cause: lastError }); } From 3d8c6f1258973f7ee9d6ae485afcc96133cf5b6f Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 24 Jun 2026 10:34:15 +0200 Subject: [PATCH 126/131] Remove PR_SUMMARY.md --- PR_SUMMARY.md | 49 ------------------------------------------------- 1 file changed, 49 deletions(-) delete mode 100644 PR_SUMMARY.md diff --git a/PR_SUMMARY.md b/PR_SUMMARY.md deleted file mode 100644 index 6331336fe..000000000 --- a/PR_SUMMARY.md +++ /dev/null @@ -1,49 +0,0 @@ -# Quote-less KYB deep link with region selector - -Adds a partner-facing deep link that takes business users straight into KYB verification - no quote required. Partners can send their users to the widget with a single URL and have them complete business verification for any supported region. - -## Flow - -``` -?kyb / ?kybLocked → email/OTP auth → region selector → provider KYB → success screen -``` - -- **Brazil** → Avenia KYB. The company name and CNPJ are collected **together on a single company form** (no separate CNPJ card). The CNPJ field is editable in the deep-link flow and validated as **CNPJ-only** (KYB is business-only); in the normal quoted flow it stays read-only and pre-filled from the quote. -- **Mexico / Colombia / USA** → Alfredpay business KYB (the business customer type is preselected). MX/CO business deep links are routed to the company KYB form, not the individual KYC form. -- **Europe / Mykobo** is intentionally excluded (individual KYC only, requires a connected wallet). -- After success, the user lands on a **"KYB Completed"** screen; *Continue* resets to the standard quote form with the session still authenticated and the deep-link params stripped from the URL. - -## URL parameters - -| URL | Behavior | -|---|---| -| `?kyb` | KYB mode, region selector shown | -| `?kyb=BR` \| `MX` \| `CO` \| `US` | Selector shown with the region preselected (user can change it) | -| `?kybLocked=BR` \| `MX` \| `CO` \| `US` | Selector skipped, region pinned, back navigation disabled | -| `?kybLocked=BR` (specifically) | Additionally defaults the widget locale to `pt-BR` (an explicit locale in the path still wins, e.g. `/en/widget?kybLocked=BR`) | -| unknown / empty region code (e.g. `?kybLocked=ZZ`, bare `?kybLocked`) | Degrades gracefully to the **open selector** - the region is **not** treated as locked | - -> Query keys are case-sensitive per the W3C/RFC URL spec: the parameter is `kybLocked` (lowercase `k`). A re-cased `KybLocked` is a different key and is ignored. - -`externalSessionId`, `partnerId`, and `apiKey` are forwarded in KYB mode, so partner/session attribution works the same as in the quoted flow. - -## Implementation notes - -- **`kybLink` context object** - all KYB deep-link state (`fiatToken`, `regionLocked`) lives in a single optional context object whose *presence* enables the mode. It is registered in `initialRampContext`, so `RESET_RAMP` fully exits KYB mode. -- **New machine states** - `SelectRegion` (region picker, auto-skipped when locked and resolved to a fiat token), `KybLinkComplete` (terminal success screen), and `PostAuthRouting` (a transient state that routes a successful login - token check or OTP - to the destination recorded in `postAuthTarget`, replacing the duplicated transition branches in `CheckAuth`/`VerifyingOTP`). A chosen region routes straight to the shared `KYC` node; Brazil collects its CNPJ on the Avenia company form rather than on a dedicated step. -- **Graceful region-lock degradation** - `?kybLocked=` only pins the region when the code resolves to a known region. An unknown or empty code yields `regionLocked: false`, so the selector is shown with working back navigation instead of a dead-end. -- **Locked-region back behavior** - with `?kybLocked=`, the parent KYC `GO_BACK` is an explicit guarded no-op (it would otherwise restart the child KYC machine). The back button is **hidden** on the locked KYB entry screen and on the region selector (the selector is the root of the flow). Deeper KYB steps that own their own back navigation keep the button. -- **CNPJ-only validation** - `useKYBForm` gains a `requireCnpj` flag; when the tax ID is user-typed (the quote-less deep link), it must be a CNPJ, so a "business" entry can't silently resolve to an individual account downstream. -- **Region table as single source of truth** - `KYB_REGIONS` maps each region code to its fiat token (provider routing), label key, and optional `defaultLocale`. Adding Spanish for MX/CO later is a data-only change. -- **Shared package & backend** - `BrlaCreateSubaccountRequest.quoteId` is now optional; the backend stores it as a nullable `initialQuoteId` (provenance only, never an authorization input). The subaccount actor only requires a quote in the normal flow and skips the quote-bound KYC-status lookup in deep-link mode. -- **Misc** - the dropdown trigger press scale now actually animates (`transition-property` previously covered only colors), and the panel no longer animates on initial mount. - -## Docs - -- **OpenAPI** (`docs/api/openapi/vortex.openapi.json`) - `createSubaccount` `quoteId` is now documented as optional, with the quote-less KYB deep-link case described. -- **API guide** - new partner-facing page `docs/api/pages/13-kyb-deep-link.md` (registered in the Apidog page manifest) covering the flow, URL parameters, attribution, and embedding. -- **Security spec** (`docs/security-spec/05-integrations/brla.md`) - documents quote-less subaccount creation and that the nullable `initialQuoteId` does not weaken any access check. - -## Testing - -- `bun typecheck` clean, Biome clean on changed files, frontend test suite passing (23/23). From 3a91d6e3227491a5cdb9a1709abb32333ad2082b Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 24 Jun 2026 10:55:28 +0200 Subject: [PATCH 127/131] Fix issue with token selection dialog animation --- .../menus/TokenSelectionMenu/index.tsx | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/apps/frontend/src/components/menus/TokenSelectionMenu/index.tsx b/apps/frontend/src/components/menus/TokenSelectionMenu/index.tsx index e6ca6c304..3e31b59e9 100644 --- a/apps/frontend/src/components/menus/TokenSelectionMenu/index.tsx +++ b/apps/frontend/src/components/menus/TokenSelectionMenu/index.tsx @@ -12,19 +12,21 @@ export function TokenSelectionMenu() { useEscapeKey(isOpen, closeTokenSelectModal); return ( - - {isOpen && ( - - {content} - - )} - +

+ + {isOpen && ( + + {content} + + )} + +
); } From 6678713e5a2b96e47d05f1d8db71be215b683d7f Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 24 Jun 2026 10:56:04 +0200 Subject: [PATCH 128/131] Make rebalancer recover from avenia to polygon transfer issues --- .../rebalance/usdc-brla-usdc-base/index.ts | 95 +++++++++++++------ .../usdc-brla-usdc-base/steps.test.ts | 45 +++++++++ .../rebalance/usdc-brla-usdc-base/steps.ts | 25 +++++ .../security-spec/07-operations/rebalancer.md | 6 +- 4 files changed, 138 insertions(+), 33 deletions(-) diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts index d767c20d4..5ce2630ef 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts @@ -21,6 +21,7 @@ import { getUsdcBalanceOnBaseRaw, mainNablaApproveAndSwap, nablaApproveAndSwapOnBase, + recoverAveniaPolygonTransferFromBalance, squidRouterApproveAndSwap, transferBrlaToAveniaOnBase, verifyFinalUsdcBalanceOnBase, @@ -71,6 +72,22 @@ async function calculateRemainingBrlaForPolygonTransfer(brlaAmountRaw: string, p return multiplyByPowerOfTen(remainingRaw, -18); } +async function recoverCompletedAveniaPolygonTransfer( + state: UsdcBaseRebalanceState, + stateManager: Pick +): Promise { + if (!state.brlaAmountRaw || !state.polygonBrlaBalanceBeforeTransferRaw) return false; + + const recoveredBrlaRaw = await recoverAveniaPolygonTransferFromBalance( + state.brlaAmountRaw, + state.polygonBrlaBalanceBeforeTransferRaw, + state, + stateManager + ); + + return recoveredBrlaRaw !== null; +} + export async function rebalanceUsdcBrlaUsdcBase( usdcAmountRaw: string, forceRestart = false, @@ -311,41 +328,59 @@ export async function rebalanceUsdcBrlaUsdcBase( await stateManager.saveState(state); } - const ticketId = await aveniaTransferBrlaToPolygon(Big(state.brlaAmountDecimal)); - state.aveniaTicketId = ticketId; - await stateManager.saveState(state); - } - - const brlaApiService = BrlaApiService.getInstance(); - try { - await checkTicketStatusPaid(brlaApiService, state.aveniaTicketId); - } catch (error) { - if (!(error instanceof RetryableAveniaTicketStatusError)) { - throw error; - } - - console.warn( - `Avenia transfer ticket ${error.ticketId} reached retryable status ${error.status}. Creating a replacement ticket.` - ); - if (!state.brlaAmountRaw) - throw new Error("State corrupted: brlaAmountRaw missing while retrying Avenia Polygon ticket"); - if (!state.polygonBrlaBalanceBeforeTransferRaw) { - throw new Error("State corrupted: polygonBrlaBalanceBeforeTransferRaw missing while retrying Avenia Polygon ticket"); - } - - const remainingBrlaDecimal = await calculateRemainingBrlaForPolygonTransfer( - state.brlaAmountRaw, - state.polygonBrlaBalanceBeforeTransferRaw - ); - if (remainingBrlaDecimal.lte(0)) { + if (await recoverCompletedAveniaPolygonTransfer(state, stateManager)) { console.log( - "Avenia ticket failed after the full BRLA amount arrived on Polygon. Continuing to arrival confirmation." + "Existing Polygon BRLA balance delta detected before creating a new Avenia ticket. Continuing recovered rebalance." ); + state.currentPhase = UsdcBaseRebalancePhase.WaitBrlaOnPolygon; + await stateManager.saveState(state); } else { - console.warn(`Retrying Avenia transfer to Polygon for remaining ${remainingBrlaDecimal.toFixed(4)} BRLA.`); - state.aveniaTicketId = await aveniaTransferBrlaToPolygon(remainingBrlaDecimal); + const ticketId = await aveniaTransferBrlaToPolygon(Big(state.brlaAmountDecimal)); + state.aveniaTicketId = ticketId; await stateManager.saveState(state); + } + } + + const brlaApiService = BrlaApiService.getInstance(); + if (state.currentPhase === UsdcBaseRebalancePhase.AveniaTransferToPolygon) { + if (!state.aveniaTicketId) throw new Error("State corrupted: aveniaTicketId missing for Avenia Polygon transfer"); + + try { await checkTicketStatusPaid(brlaApiService, state.aveniaTicketId); + } catch (error) { + if (await recoverCompletedAveniaPolygonTransfer(state, stateManager)) { + console.warn( + `Avenia transfer ticket ${state.aveniaTicketId} did not confirm, but Polygon BRLA balance delta proves completion. Continuing.` + ); + } else if (error instanceof RetryableAveniaTicketStatusError) { + console.warn( + `Avenia transfer ticket ${error.ticketId} reached retryable status ${error.status}. Creating a replacement ticket.` + ); + if (!state.brlaAmountRaw) + throw new Error("State corrupted: brlaAmountRaw missing while retrying Avenia Polygon ticket"); + if (!state.polygonBrlaBalanceBeforeTransferRaw) { + throw new Error( + "State corrupted: polygonBrlaBalanceBeforeTransferRaw missing while retrying Avenia Polygon ticket" + ); + } + + const remainingBrlaDecimal = await calculateRemainingBrlaForPolygonTransfer( + state.brlaAmountRaw, + state.polygonBrlaBalanceBeforeTransferRaw + ); + if (remainingBrlaDecimal.lte(0)) { + console.log( + "Avenia ticket failed after the full BRLA amount arrived on Polygon. Continuing to arrival confirmation." + ); + } else { + console.warn(`Retrying Avenia transfer to Polygon for remaining ${remainingBrlaDecimal.toFixed(4)} BRLA.`); + state.aveniaTicketId = await aveniaTransferBrlaToPolygon(remainingBrlaDecimal); + await stateManager.saveState(state); + await checkTicketStatusPaid(brlaApiService, state.aveniaTicketId); + } + } else { + throw error; + } } } diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.test.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.test.ts index e4a5e43ba..ad8ce851d 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.test.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.test.ts @@ -2,6 +2,7 @@ import {describe, expect, test} from "bun:test"; import {createUsdcBaseRebalanceState, UsdcBaseRebalancePhase} from "../../services/stateManager.ts"; import { ensurePolygonBrlaAvailableForSquidSwap, + recoverAveniaPolygonTransferFromBalance, recoverSquidUsdcOutputFromBaseBalance, resetFailedSquidRouterSwapOnResume } from "./steps.ts"; @@ -122,4 +123,48 @@ describe("USDC Base SquidRouter steps", () => { ); expect(() => ensurePolygonBrlaAvailableForSquidSwap("500000000000000000000", "500000000000000000000")).not.toThrow(); }); + + test("recovers Avenia Polygon transfer from BRLA balance delta", async () => { + const state = createUsdcBaseRebalanceState("1000000000", UsdcBaseRebalancePhase.AveniaTransferToPolygon); + const savedStates: Array<{ brlaAmountDecimal: string | null; brlaAmountRaw: string | null }> = []; + const stateManager = { + saveState: async () => { + savedStates.push({ brlaAmountDecimal: state.brlaAmountDecimal, brlaAmountRaw: state.brlaAmountRaw }); + } + }; + + await expect( + recoverAveniaPolygonTransferFromBalance( + "5229427423000000000000", + "4045105000000000000", + state, + stateManager, + async () => "5233472528000000000000" + ) + ).resolves.toBe("5229427423000000000000"); + expect(state.brlaAmountRaw).toBe("5229427423000000000000"); + expect(state.brlaAmountDecimal).toBe("5229.427423"); + expect(savedStates).toEqual([{ brlaAmountDecimal: "5229.427423", brlaAmountRaw: "5229427423000000000000" }]); + }); + + test("does not recover Avenia Polygon transfer when BRLA delta is below tolerance", async () => { + const state = createUsdcBaseRebalanceState("1000000000", UsdcBaseRebalancePhase.AveniaTransferToPolygon); + const stateManager = { + saveState: async () => { + throw new Error("insufficient recovery delta should not rewrite state"); + } + }; + + await expect( + recoverAveniaPolygonTransferFromBalance( + "5229427423000000000000", + "4045105000000000000", + state, + stateManager, + async () => "4900000000000000000000" + ) + ).resolves.toBeNull(); + expect(state.brlaAmountRaw).toBeNull(); + expect(state.brlaAmountDecimal).toBeNull(); + }); }); diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts index edec2dd1b..fe318a8bf 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts @@ -584,6 +584,31 @@ export async function waitBrlaOnPolygon(brlaAmountRaw: string, startingBrlaBalan return arrivedRaw; } +export async function recoverAveniaPolygonTransferFromBalance( + expectedBrlaRaw: string, + startingBrlaBalanceRaw: string, + state: UsdcBaseRebalanceState, + stateManager: Pick, + getCurrentPolygonBrlaRaw = getBrlaBalanceOnPolygonRaw +): Promise { + const currentPolygonBrlaRaw = Big(await getCurrentPolygonBrlaRaw()); + const receivedDeltaRaw = currentPolygonBrlaRaw.minus(Big(startingBrlaBalanceRaw)); + const minimumReceivedRaw = calculateMinimumDelta(Big(expectedBrlaRaw), "0.95"); + + if (receivedDeltaRaw.lt(minimumReceivedRaw)) return null; + + const recoveredBrlaRaw = receivedDeltaRaw.toFixed(0, 0); + state.brlaAmountRaw = recoveredBrlaRaw; + state.brlaAmountDecimal = multiplyByPowerOfTen(Big(recoveredBrlaRaw), -18).toString(); + await stateManager.saveState(state); + console.log( + `Recovered Avenia Polygon BRLA transfer from balance delta: ${state.brlaAmountDecimal} BRLA ` + + `(pre: ${startingBrlaBalanceRaw}, post: ${currentPolygonBrlaRaw.toFixed(0, 0)})` + ); + + return recoveredBrlaRaw; +} + export async function resetFailedSquidRouterSwapOnResume( swapHash: string, state: UsdcBaseRebalanceState, diff --git a/docs/security-spec/07-operations/rebalancer.md b/docs/security-spec/07-operations/rebalancer.md index 8eaf0b948..60eb00431 100644 --- a/docs/security-spec/07-operations/rebalancer.md +++ b/docs/security-spec/07-operations/rebalancer.md @@ -95,7 +95,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- 7c. Request Avenia to transfer BRLA from internal balance to Polygon 8c. Poll ticket status until PAID (5-min timeout) 9c. Wait for BRLA delta arrival on Polygon (balance polling, 10-min timeout) - - If the BRLA-to-Polygon Avenia ticket reaches `PARTIAL-FAILED`, reconcile any Polygon BRLA delta against the persisted baseline and create a replacement ticket only for the remaining amount. + - Before creating/retrying a BRLA-to-Polygon Avenia ticket, and after ticket status failures, reconcile any Polygon BRLA delta against the persisted baseline. If the expected BRLA already arrived, continue to arrival confirmation; for `PARTIAL-FAILED`, create a replacement ticket only for the remaining amount. 10c. SquidRouter approve + swap: BRLA on Polygon → USDC on Base 11c. Wait for Axelar cross-chain execution (30-min timeout) 12c. Wait for USDC delta arrival on Base (balance polling, 30-min timeout) @@ -190,7 +190,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- | Threat | Mitigation | |---|---| | **Route comparison manipulation** — Avenia, SquidRouter, and optional main Nabla quotes are fetched and compared; an attacker could manipulate one provider's rate to force another route | The rebalancer trusts provider quotes without independent verification. However, since all high-coverage routes end with USDC on Base, the worst case is choosing a slightly worse rate, not direct fund loss. The `slippage: 4` parameter on SquidRouter provides some buffer. | -| **Avenia ticket creation or partial ticket failure mid-flow** — Avenia API fails after the flow committed to the Avenia route, or the SquidRouter branch's BRLA-to-Polygon transfer ticket reaches `PARTIAL-FAILED` | **Mitigated.** Direct Avenia ticket creation errors fall back to SquidRouter by setting `winningRoute = "squidrouter"` and saving state. Avenia tickets that return `FAILED` after creation are terminal and abort immediately. For SquidRouter BRLA-to-Polygon tickets, `PARTIAL-FAILED` triggers replacement-ticket creation for only the remaining BRLA after subtracting any Polygon balance delta from the persisted baseline. | +| **Avenia ticket creation or transfer-ticket failure mid-flow** — Avenia API fails after the flow committed to the Avenia route, or the SquidRouter branch's BRLA-to-Polygon transfer ticket reaches `FAILED`/`PARTIAL-FAILED` after funds may already have arrived | **Mitigated.** Direct Avenia ticket creation errors fall back to SquidRouter by setting `winningRoute = "squidrouter"` and saving state. For SquidRouter BRLA-to-Polygon tickets, the flow checks the Polygon BRLA delta before creating a new ticket and again after ticket-status failures. If the expected BRLA already arrived, it continues to arrival confirmation; `PARTIAL-FAILED` creates a replacement ticket only for the remaining BRLA. Other Avenia ticket failures remain terminal when Polygon balance recovery does not prove completion. | | **Daily bridge limit bypass** — History entries are stored in Supabase Storage; an attacker who can modify the storage could clear history to bypass the daily limit. Separately, projected-profitable quotes intentionally bypass the daily cap. | **Weak mitigation.** The limit is enforced client-side by reading history from Supabase and adding the current requested amount before starting. The intentional profit bypass requires a quote with projected output greater than input and still writes history on completion. An attacker with Supabase access could also drain funds directly, so malicious history tampering is a secondary concern. | | **Cost-threshold misconfiguration** — Cost thresholds set too low can cause chronic under-rebalancing; thresholds set too high can cause repeated expensive rebalancing | Defaults are conservative and env parsing fails fast for non-monotonic values. Operators should first use `REBALANCING_POLICY_MODE=dry-run` to observe decisions before enabling tighter or looser production thresholds. | | **Always-mode misuse** — Operator leaves `REBALANCING_POLICY_MODE=always` enabled and accepts expensive routes repeatedly | `always` still respects `REBALANCING_HARD_MAX_COST_BPS` and the daily bridge limit for non-profitable quotes. Decision logs include band, projected cost, allowed cost, daily-limit decision, and reason so misuse is observable. | @@ -200,7 +200,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- | **NonceManager stale nonce** — If the process crashes after sending a transaction but before saving the nonce, the resumed execution could reuse the same nonce | **Mitigated.** `NonceManager` is re-initialized from `getTransactionCount()` on each execution. The stored transaction hashes in state also prevent re-execution of already-completed phases. | | **`EVM_ACCOUNT_SECRET` single-key blast radius** — One mnemonic controls all EVM chain accounts for the rebalancer | Compromise of this one secret drains rebalancer funds on Base, Polygon, and Moonbeam. The separate `PENDULUM_ACCOUNT_SECRET` limits Pendulum blast radius to the legacy flow. This is a deliberate simplification accepted for operational convenience. | | **SquidRouter source transaction failure, duplicate retry, or cross-chain timeout** — The Polygon source swap can fail before Axelar sees it, a previous attempt may already have delivered USDC on Base, or Axelar cross-chain execution could take longer than 30 minutes during network congestion | **Partially mitigated.** On resume, the Base flow checks for a recovered Base USDC delta before retrying, checks the persisted Polygon SquidRouter swap receipt before Axelar polling, and refuses fresh SquidRouter submissions when Polygon BRLA is below the intended input. Failed source receipts clear the stale hash/quote and retry with a fresh route only when Base recovery is not already proven. If the source succeeds but Axelar does not confirm within 30 minutes, the rebalancer throws and the next attempt resumes from `SquidRouterApproveAndSwap`. | -| **Absolute balance false positives** — Hot wallets/provider accounts can contain leftovers from previous runs, so absolute balance checks could pass before the current run's funds arrive | **Mitigated for Base flow.** The flow stores pre-action baselines and waits for deltas on Avenia BRLA, Polygon BRLA, and Base USDC arrivals. | +| **Absolute balance false positives** — Hot wallets/provider accounts can contain leftovers from previous runs, so absolute balance checks could pass before the current run's funds arrive | **Mitigated for Base flow.** The flow stores pre-action baselines and waits for deltas on Avenia BRLA, Polygon BRLA, and Base USDC arrivals. Avenia BRLA-to-Polygon recovery also uses the persisted Polygon baseline before treating a failed ticket as recoverable. | | **BRLA balance tolerance** — Avenia BRLA delta checks accept 95% of expected amount as sufficient, while on-chain Base/Polygon arrival checks use 99.8% | If Avenia deducts a fee > 5%, the flow will not proceed and will time out. The tolerance prevents provider-side deductions and rounding dust from blocking valid arrivals while rejecting meaningful shortfalls. | ## Audit Checklist From 8d5645c1781406e2be5ce72b12ef48a7daae32a2 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 24 Jun 2026 10:56:13 +0200 Subject: [PATCH 129/131] Address PR review feedback --- .../api/services/priceFeed.service.test.ts | 84 +++++++++++++------ .../api/src/api/services/priceFeed.service.ts | 9 ++ apps/rebalancer/src/services/indexer/index.ts | 6 +- 3 files changed, 70 insertions(+), 29 deletions(-) diff --git a/apps/api/src/api/services/priceFeed.service.test.ts b/apps/api/src/api/services/priceFeed.service.test.ts index bddd4f739..b4b31b397 100644 --- a/apps/api/src/api/services/priceFeed.service.test.ts +++ b/apps/api/src/api/services/priceFeed.service.test.ts @@ -1,11 +1,20 @@ // eslint-disable-next-line import/no-unresolved import {afterEach, beforeEach, describe, expect, it, mock} from "bun:test"; // Import the mocked function to check calls -import {getTokenOutAmount as getTokenOutAmountMock} from "@vortexfi/shared"; +import {getTokenOutAmount as getTokenOutAmountMock, type RampCurrency} from "@vortexfi/shared"; import {PriceFeedService, priceFeedService} from "./priceFeed.service"; // Mock all external dependencies -mock.module("shared", () => ({ +mock.module("@vortexfi/shared", () => ({ + ApiManager: { + getInstance: mock(() => ({ + getApi: mock(async () => ({ + api: {}, + decimals: 12, + ss58Format: 42 + })) + })) + }, EvmToken: { USDC: "USDC", USDCE: "USDC.e", @@ -37,7 +46,29 @@ mock.module("shared", () => ({ pendulumErc20WrapperAddress: "0x123" }; }), + getTokenOutAmount: mock(async () => ({ + effectiveExchangeRate: "1.25", + preciseQuotedAmountOut: { + preciseBigDecimal: { + toString: () => "1.25" + } + }, + roundedDownQuotedAmountOut: { + toString: () => "1.25" + }, + swapFee: { + toString: () => "0.01" + } + })), + getTokenUsdPrice: mock(() => undefined), isFiatToken: mock((currency: string) => ["BRL", "EUR", "ARS"].includes(currency)), + normalizeTokenSymbol: mock((currency: string) => currency), + PENDULUM_USDC_AXL: { + pendulumAssetSymbol: "USDC", + pendulumCurrencyId: { Token: "USDC" }, + pendulumDecimals: 6, + pendulumErc20WrapperAddress: "0xUSDC" + }, RampCurrency: { ARS: "ARS", AVAX: "AVAX", @@ -51,6 +82,11 @@ mock.module("shared", () => ({ USDC: "USDC", USDCE: "USDC.e", USDT: "USDT" + }, + UsdLikeEvmToken: { + USDC: "USDC", + USDCE: "USDC.e", + USDT: "USDT" } })); @@ -243,6 +279,7 @@ describe("PriceFeedService", () => { // @ts-expect-error - accessing private property for testing PriceFeedService.instance = undefined; const serviceInstance = PriceFeedService.getInstance(); // Get the new instance + Object.assign(serviceInstance, { cryptoCacheTtlMs: 100 }); // Mock Date.now to return a fixed timestamp const startTime = 1000000; @@ -390,7 +427,7 @@ describe("PriceFeedService", () => { beforeEach(() => { // Add validation to the mock implementation (getTokenOutAmountMock as any).mockImplementation(async (params: any) => { - if (!params.fromAmountString || !params.inputTokenDetails || !params.outputTokenDetails) { + if (!params.fromAmountString || !params.inputTokenPendulumDetails || !params.outputTokenPendulumDetails) { throw new Error("Missing required parameters"); } return { @@ -432,6 +469,7 @@ describe("PriceFeedService", () => { // @ts-expect-error - accessing private property for testing PriceFeedService.instance = undefined; const serviceInstance = PriceFeedService.getInstance(); // Get new instance + Object.assign(serviceInstance, { fiatCacheTtlMs: 100 }); // Mock Date.now to return a fixed timestamp const startTime = 1000000; @@ -481,7 +519,7 @@ describe("PriceFeedService", () => { describe("convertCurrency", () => { it("should return the original amount when currencies are the same", async () => { const result = await priceFeedService.convertCurrency("100", "USDC" as any, "USDC" as any); - expect(result).toBe("100"); + expect(result).toBe("100.00000000"); }); it("should perform 1:1 conversion between USD-like stablecoins", async () => { @@ -499,7 +537,7 @@ describe("PriceFeedService", () => { (getTokenOutAmountMock as any).mockClear(); const result = await freshInstance.convertCurrency("100", "USDC" as any, "BRL" as any); - expect(result).toBe("125.000000"); // 100 * 1.25 = 125 + expect(result).toBe("125.00"); // 100 * 1.25 = 125 expect(getTokenOutAmountMock).toHaveBeenCalledTimes(1); }); @@ -513,13 +551,13 @@ describe("PriceFeedService", () => { (getTokenOutAmountMock as any).mockClear(); const result = await freshInstance.convertCurrency("125", "BRL" as any, "USDC" as any); - expect(result).toBe("100.000000"); // 125 / 1.25 = 100 + expect(result).toBe("100.00000000"); // 125 / 1.25 = 100 expect(getTokenOutAmountMock).toHaveBeenCalledTimes(1); }); it("should convert USD to crypto using getCryptoPrice", async () => { const result = await priceFeedService.convertCurrency("300", "USDC" as any, "ETH" as any); - expect(result).toBe("0.100000"); // 300 / 3000 = 0.1 + expect(result).toBe("0.10000000"); // 300 / 3000 = 0.1 expect(fetchMock).toHaveBeenCalledTimes(1); }); @@ -533,23 +571,19 @@ describe("PriceFeedService", () => { fetchMock.mockClear(); const result = await freshInstance.convertCurrency("0.1", "ETH" as any, "USDC" as any); - expect(result).toBe("300.000000"); // 0.1 * 3000 = 300 + expect(result).toBe("300.00000000"); // 0.1 * 3000 = 300 expect(fetchMock).toHaveBeenCalledTimes(1); }); - it("should handle conversion errors by returning the original amount", async () => { - // Force an error by making getCoinGeckoTokenId return null - // @ts-expect-error - accessing private method for testing - const originalGetCoinGeckoTokenId = priceFeedService.getCoinGeckoTokenId; - // @ts-expect-error - overriding private method for testing - priceFeedService.getCoinGeckoTokenId = () => null; - - const result = await priceFeedService.convertCurrency("100", "USDC" as any, "UNKNOWN" as any); - expect(result).toBe("100"); // Should return original amount on error + it("should fail closed on conversion errors", async () => { + await expect(priceFeedService.convertCurrency("100", "USDC" as RampCurrency, "UNKNOWN" as RampCurrency)).rejects.toThrow( + "No CoinGecko token ID mapping for UNKNOWN" + ); + }); - // Restore the original method - // @ts-expect-error - restoring private method - priceFeedService.getCoinGeckoTokenId = originalGetCoinGeckoTokenId; + it("should return the original amount only through the explicit fallback helper", async () => { + const result = await priceFeedService.convertCurrencyOrOriginal("100", "USDC" as RampCurrency, "UNKNOWN" as RampCurrency); + expect(result).toBe("100"); }); it("should use specified decimal precision", async () => { @@ -582,8 +616,8 @@ describe("PriceFeedService", () => { expect(instance.fiatCacheTtlMs).toBe(300000); }); - it("should use environment variables when provided", () => { - // Set specific values for this test + it("should keep loaded configuration values when environment variables change after import", () => { + // Set specific values after the config module has already been imported process.env.COINGECKO_API_URL = "https://custom-api.example.com"; process.env.CRYPTO_CACHE_TTL_MS = "60000"; process.env.FIAT_CACHE_TTL_MS = "120000"; @@ -596,11 +630,11 @@ describe("PriceFeedService", () => { const instance = PriceFeedService.getInstance(); // @ts-expect-error - accessing private properties for testing - expect(instance.coingeckoApiBaseUrl).toBe("https://custom-api.example.com"); + expect(instance.coingeckoApiBaseUrl).toBe("https://pro-api.coingecko.com/api/v3"); // @ts-expect-error - accessing private properties for testing - expect(instance.cryptoCacheTtlMs).toBe(60000); + expect(instance.cryptoCacheTtlMs).toBe(300000); // @ts-expect-error - accessing private properties for testing - expect(instance.fiatCacheTtlMs).toBe(120000); + expect(instance.fiatCacheTtlMs).toBe(300000); }); }); }); diff --git a/apps/api/src/api/services/priceFeed.service.ts b/apps/api/src/api/services/priceFeed.service.ts index 4a9ed7cdb..5248759ad 100644 --- a/apps/api/src/api/services/priceFeed.service.ts +++ b/apps/api/src/api/services/priceFeed.service.ts @@ -269,6 +269,15 @@ export class PriceFeedService { fromCurrency: RampCurrency, toCurrency: RampCurrency, decimals: number | null = null // Allow overriding, but default to null + ): Promise { + return this.convertCurrencyStrict(amount, fromCurrency, toCurrency, decimals); + } + + public async convertCurrencyOrOriginal( + amount: string, + fromCurrency: RampCurrency, + toCurrency: RampCurrency, + decimals: number | null = null ): Promise { try { return await this.convertCurrencyStrict(amount, fromCurrency, toCurrency, decimals); diff --git a/apps/rebalancer/src/services/indexer/index.ts b/apps/rebalancer/src/services/indexer/index.ts index 3ef41f780..f42077da6 100644 --- a/apps/rebalancer/src/services/indexer/index.ts +++ b/apps/rebalancer/src/services/indexer/index.ts @@ -30,9 +30,7 @@ const ROUTER_ABI = [ } ] as const; -export async function getBaseNablaCoverageRatio(): Promise< - { brlaCoverageRatio: number; usdcCoverageRatio: number } | undefined -> { +export async function getBaseNablaCoverageRatio(): Promise<{ brlaCoverageRatio: number } | undefined> { try { const evmClientManager = EvmClientManager.getInstance(); const baseClient = evmClientManager.getClient(Networks.Base); @@ -66,7 +64,7 @@ export async function getBaseNablaCoverageRatio(): Promise< console.log(`Base Nabla BRLA pool coverage ratio: ${brlaCoverageRatio}`); - return { brlaCoverageRatio, usdcCoverageRatio: 0 }; + return { brlaCoverageRatio }; } catch (error) { console.error("Failed to fetch Base Nabla coverage ratio:", error); return undefined; From b9b59feb8344c93c572a00c39630df7d7f4ad925 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 24 Jun 2026 11:18:23 +0200 Subject: [PATCH 130/131] Fix animation/transition also for ramp history and settings menu --- .../src/components/menus/Menu/index.tsx | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/apps/frontend/src/components/menus/Menu/index.tsx b/apps/frontend/src/components/menus/Menu/index.tsx index 05a3cdf5b..f190e524e 100644 --- a/apps/frontend/src/components/menus/Menu/index.tsx +++ b/apps/frontend/src/components/menus/Menu/index.tsx @@ -37,20 +37,22 @@ export function Menu({ isOpen, onClose, title, children, animationDirection }: M }; return ( - - {isOpen && ( - - -
-
{children}
-
- )} -
+
+ + {isOpen && ( + + +
+
{children}
+
+ )} +
+
); } From 40a76090099daeb85c0df8686f812284e3d41177 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 24 Jun 2026 11:18:36 +0200 Subject: [PATCH 131/131] Change order of discount and effective fee on checkout page --- .../widget-steps/SummaryStep/FeeDetails.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/frontend/src/components/widget-steps/SummaryStep/FeeDetails.tsx b/apps/frontend/src/components/widget-steps/SummaryStep/FeeDetails.tsx index 0184f44b3..2a4523dce 100644 --- a/apps/frontend/src/components/widget-steps/SummaryStep/FeeDetails.tsx +++ b/apps/frontend/src/components/widget-steps/SummaryStep/FeeDetails.tsx @@ -46,14 +46,6 @@ export const FeeDetails: FC = ({ return (
-
-

{t("components.feeCollapse.totalFee")}

-

- - {effectiveTotalFee} {feesCost.currency.toUpperCase()} - -

-
{discount && (

{t("components.feeCollapse.discount.label")}

@@ -64,6 +56,14 @@ export const FeeDetails: FC = ({

)} +
+

{t("components.feeCollapse.totalFee")}

+

+ + {effectiveTotalFee} {feesCost.currency.toUpperCase()} + +

+

{t("components.SummaryPage.quote")}