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 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/apps/api/src/api/controllers/alfredpay.controller.ts b/apps/api/src/api/controllers/alfredpay.controller.ts index 7f72d0555..9ec108ff5 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({ @@ -303,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) { @@ -336,7 +350,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 +406,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 +462,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 +486,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 +502,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 +540,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 +574,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 +598,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 +636,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 +664,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 +703,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 +743,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 +788,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 +874,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 +899,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; diff --git a/apps/api/src/api/middlewares/auth.ts b/apps/api/src/api/middlewares/auth.ts index 0d9e5801a..3eef63062 100644 --- a/apps/api/src/api/middlewares/auth.ts +++ b/apps/api/src/api/middlewares/auth.ts @@ -4,9 +4,10 @@ import logger from "../../config/logger"; import { validateSignatureAndGetMemo } from "../services/siwe.service"; declare global { + // biome-ignore lint/style/noNamespace: Express request augmentation follows the existing backend pattern. namespace Express { interface Request { - derivedMemo: string | null; + derivedMemo?: string | null; } } } diff --git a/apps/api/src/api/middlewares/supabaseAuth.ts b/apps/api/src/api/middlewares/supabaseAuth.ts index 2c0d64e92..c98c48175 100644 --- a/apps/api/src/api/middlewares/supabaseAuth.ts +++ b/apps/api/src/api/middlewares/supabaseAuth.ts @@ -3,6 +3,7 @@ import logger from "../../config/logger"; import { SupabaseAuthService } from "../services/auth"; declare global { + // biome-ignore lint/style/noNamespace: Express request augmentation follows the existing backend pattern. namespace Express { interface Request { userId?: string; 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/auth/supabase.service.ts b/apps/api/src/api/services/auth/supabase.service.ts index d0394316d..66e0d5710 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,13 +183,17 @@ 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) { 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 d8830da97..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 @@ -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,8 +211,10 @@ export class AlfredpayOfframpTransferHandler extends BasePhaseHandler { } logger.debug(`AlfredpayOfframpTransferHandler: Alfredpay offramp ${transactionId} status: ${status}`); - } catch (error: any) { - logger.warn(`AlfredpayOfframpTransferHandler: Error polling Alfredpay status for ${transactionId}: ${error}`); + } catch (error) { + logger.warn( + `AlfredpayOfframpTransferHandler: Error polling Alfredpay status for ${transactionId}: ${error instanceof Error ? error.message : String(error)}` + ); } setTimeout(poll, intervalMs); 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 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 fcbec5388..5248759ad 100644 --- a/apps/api/src/api/services/priceFeed.service.ts +++ b/apps/api/src/api/services/priceFeed.service.ts @@ -269,6 +269,55 @@ 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); + } 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 +326,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/core/types.ts b/apps/api/src/api/services/quote/core/types.ts index fd41f71bb..b57bca277 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 @@ -259,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/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( 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..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 @@ -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,13 @@ 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 () => { @@ -81,4 +86,232 @@ 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.convertCurrencyOrNull = mock(async (amount, _from, to) => { + if (to === FiatToken.BRL) { + return new Big(amount).mul(5).toString(); + } + return amount; + }) 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).toEqual({ + currency: FiatToken.BRL, + fiat: "10.00", + usd: "2.000000" + }); + expect(ctx.builtResponse).toMatchObject({ + discountCurrency: FiatToken.BRL, + discountFiat: "10.00", + 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 a4134adde..d3fa516f7 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,47 @@ 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) { + ctx.subsidyDisplay = undefined; + return; + } + + const subsidyAmount = subsidy.subsidyAmountInOutputTokenDecimal.toString(); + const [discountFiat, discountUsd] = await Promise.all([ + 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), + 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 +104,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 +141,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 +182,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, 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..2d45e593f --- /dev/null +++ b/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.test.ts @@ -0,0 +1,81 @@ +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({ + ammOutputAmountDecimal: new Big("100"), + ammOutputAmountRaw: "100000000", + 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("does not change the AMM-only output 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/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..58f816d48 --- /dev/null +++ b/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.test.ts @@ -0,0 +1,90 @@ +import {describe, expect, it, mock} from "bun:test"; +import Big from "big.js"; +import type {QuoteContext} from "../../core/types"; + +const mockedEvmToken = { + BRLA: "BRLA", + USDC: "USDC" +} as const; + +const mockedNetworks = { + Base: "base" +} as const; + +const mockedRampDirection = { + BUY: "BUY", + SELL: "SELL" +} as const; + +mock.module("@vortexfi/shared", () => ({ + EvmToken: mockedEvmToken, + getOnChainTokenDetails: (_network: string, token: string) => ({ + assetSymbol: token, + decimals: 6, + erc20AddressSourceChain: token === mockedEvmToken.USDC ? "0xusdc" : "0xbrla", + isNative: false, + network: mockedNetworks.Base, + type: "evm" + }), + Networks: mockedNetworks, + RampDirection: mockedRampDirection +})); + +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 {EvmToken, RampDirection} = await import("@vortexfi/shared"); +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/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, diff --git a/apps/api/src/api/services/spreadsheet.service.ts b/apps/api/src/api/services/spreadsheet.service.ts index 4ce087ad0..86798f8a3 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,10 @@ export const getOrCreateSheet = async (doc: GoogleSpreadsheet, headerValues: str if (doHeadersMatch(sheet.headerValues, headerValues)) { return sheet; } - } catch {} + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.debug(`Spreadsheet header check failed while searching for matching sheet: ${message}`); + } } // Create new sheet if no match found 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/common/transactions.test.ts b/apps/api/src/api/services/transactions/onramp/common/transactions.test.ts new file mode 100644 index 000000000..762e8e729 --- /dev/null +++ b/apps/api/src/api/services/transactions/onramp/common/transactions.test.ts @@ -0,0 +1,136 @@ +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 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, + 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(); + nablaHardMinimumOutputRawCalls.length = 0; + }); + + 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(nablaHardMinimumOutputRawCalls).toEqual(["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(nablaHardMinimumOutputRawCalls).toEqual(["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 b3cfec69e..0cf243b35 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,19 @@ 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; + // 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 = 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, 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 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/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..7c6afcd14 100644 --- a/apps/api/src/database/migrator.ts +++ b/apps/api/src/database/migrator.ts @@ -4,18 +4,37 @@ 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); +} + +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(), { 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 = getAddIndexOptions(args); + 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; @@ -25,26 +44,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 +72,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 +91,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 +113,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; diff --git a/apps/api/src/models/apiKey.model.ts b/apps/api/src/models/apiKey.model.ts index 559db4076..f4404ad0e 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 type 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; } 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/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/ComparisonSlider/index.tsx b/apps/frontend/src/components/ComparisonSlider/index.tsx index 4e344240f..dfd5935ec 100644 --- a/apps/frontend/src/components/ComparisonSlider/index.tsx +++ b/apps/frontend/src/components/ComparisonSlider/index.tsx @@ -59,13 +59,46 @@ 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 (
{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 (
) => { + const nextFocusedElement = event.relatedTarget; + if (!nextFocusedElement || !event.currentTarget.contains(nextFocusedElement)) { + onMouseLeave(); + } + }; + return ( -
-
{t("components.navbar.solutions")}
+
+ {isOpen && (
{submenuItems.map(item => ( - ))} diff --git a/apps/frontend/src/components/RampFeeCollapse/index.tsx b/apps/frontend/src/components/RampFeeCollapse/index.tsx index e3a9478a4..55b5a4f02 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, @@ -72,11 +78,12 @@ 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.toString() ); const netExchangeRate = calculateNetExchangeRate(quote.inputAmount, quote.outputAmount); @@ -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"), @@ -101,7 +116,7 @@ export function RampFeeCollapse() { } return ( -
+
@@ -113,10 +128,10 @@ export function RampFeeCollapse() {

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

-
+
{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/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 dac497265..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,11 +56,7 @@ export const TransactionItem: FC = ({ transaction }) => { const toIcon = useTokenIcon(transaction.toCurrency, toNetwork); return ( -
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > +
@@ -98,7 +92,7 @@ export const TransactionItem: FC = ({ transaction }) => {
- +
{formatDate(transaction.date)} 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}
+
+ )} +
+
); } 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} + + )} + +
); } diff --git a/apps/frontend/src/components/ui/DropdownSelector.tsx b/apps/frontend/src/components/ui/DropdownSelector.tsx index 27342576d..9a2bb9a4c 100644 --- a/apps/frontend/src/components/ui/DropdownSelector.tsx +++ b/apps/frontend/src/components/ui/DropdownSelector.tsx @@ -66,7 +66,7 @@ export function DropdownSelector({ aria-haspopup="listbox" aria-label={triggerAriaLabel} className={cn( - "flex min-h-[44px] w-full cursor-pointer touch-manipulation items-center gap-3 rounded-xl border border-base-300 bg-base-200 px-3 py-2.5 text-left transition-colors active:scale-[0.98]", + "flex min-h-[44px] w-full cursor-pointer touch-manipulation items-center gap-3 rounded-xl border border-base-300 bg-base-200 px-3 py-2.5 text-left transition-[border-color,background-color,transform] active:scale-[0.96]", "[@media(hover:hover)]:hover:border-gray-300 [@media(hover:hover)]:hover:bg-neutral" )} onClick={() => onOpenChange(!open)} @@ -85,7 +85,7 @@ export function DropdownSelector({ - + {open && ( 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.kybLink?.fiatToken); + + 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/components/widget-steps/SummaryStep/FeeDetails.tsx b/apps/frontend/src/components/widget-steps/SummaryStep/FeeDetails.tsx index 7085ac8a3..2a4523dce 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,14 +40,27 @@ 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 (
+ {discount && ( +
+

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

+

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

+
+ )}
-

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

+

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

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

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 { 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; diff --git a/apps/frontend/src/constants/kybRegions.ts b/apps/frontend/src/constants/kybRegions.ts new file mode 100644 index 000000000..9eceba55e --- /dev/null +++ b/apps/frontend/src/constants/kybRegions.ts @@ -0,0 +1,37 @@ +import { FiatToken } from "@vortexfi/shared"; +import { Language } from "../translations/helpers"; + +export interface KybRegion { + /** 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; + /** Fiat token that determines which KYC/B provider the user is routed to. */ + fiatToken: FiatToken; + /** Locale applied when a deep link pins this region (`?kybLocked=`) and the URL has no explicit locale. */ + defaultLocale?: Language; +} + +/** + * 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", + defaultLocale: Language.Portuguese_Brazil, + 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/brla/useKYBForm/index.tsx b/apps/frontend/src/hooks/brla/useKYBForm/index.tsx index 2d9f029ef..e25bdba78 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,19 +22,21 @@ 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(); const kybForm = useForm({ defaultValues: { - [ExtendedAveniaFieldOptions.TAX_ID]: initialData?.taxId || taxIdFromStore || "", + [ExtendedAveniaFieldOptions.TAX_ID]: initialData?.taxId ?? taxIdFromStore ?? "", [ExtendedAveniaFieldOptions.FULL_NAME]: initialData?.fullName || "" }, mode: "onBlur", - resolver: standardSchemaResolver(createKybFormSchema(t)) + resolver: standardSchemaResolver(createKybFormSchema(t, requireCnpj)) }); return { kybForm }; diff --git a/apps/frontend/src/hooks/useRampUrlParams.ts b/apps/frontend/src/hooks/useRampUrlParams.ts index edfcee1ec..6519efdff 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,19 @@ 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. + // Session/partner attribution still applies β€” the subaccount creation forwards externalSessionId. + if (kybMode) { + if (externalSessionId) { + rampActor.send({ externalSessionId, type: "SET_EXTERNAL_ID" }); + } + setPartnerIdFn(partnerId || null); + setApiKeyFn(apiKey || null); + 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/hooks/useStepBackNavigation.ts b/apps/frontend/src/hooks/useStepBackNavigation.ts index f31f069b5..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,9 +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); diff --git a/apps/frontend/src/machines/actors/brla/createSubaccount.actor.ts b/apps/frontend/src/machines/actors/brla/createSubaccount.actor.ts index b5d69a1fe..2fef758e3 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.kybLink) { 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/alfredpayKyc.machine.ts b/apps/frontend/src/machines/alfredpayKyc.machine.ts index c9c8cf21a..58d2f9679 100644 --- a/apps/frontend/src/machines/alfredpayKyc.machine.ts +++ b/apps/frontend/src/machines/alfredpayKyc.machine.ts @@ -383,6 +383,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, CO, and AR use API-based form, not iFrame link guard: ({ context }) => context.country === "MX" || context.country === "CO" || context.country === "AR", @@ -397,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/machines/kyc.states.ts b/apps/frontend/src/machines/kyc.states.ts index 9c6b52ae0..17d161117 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 (kybLink.fiatToken). +const resolveKycFiatToken = (context: RampContext): FiatToken | undefined => + context.executionInput?.fiatToken ?? context.kybLink?.fiatToken; + export interface AlfredpayKycContext extends RampContext { verificationUrl?: string; submissionId?: string; @@ -70,18 +75,36 @@ 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: [ + { + // `?kybLocked=` pins the region β€” leaving and re-entering KYC would restart the child flow, so back does nothing. + guard: ({ context }: { context: RampContext }) => !!context.kybLink?.regionLocked + }, + { + // KYB deep link has no quote to return to β€” go back to the region selector instead. + actions: [clearSigningPhase], + guard: ({ context }: { context: RampContext }) => !!context.kybLink, + target: "#ramp.SelectRegion" + }, + { + actions: [clearSigningPhase], + target: "#ramp.QuoteReady" + } + ], SummaryConfirm: { 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 +117,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.kybLink ? true : undefined, country }; }, @@ -130,6 +155,7 @@ export const kycStateNode = { return { ...context, kycFormData: context.kycFormData, + // KYB deep link has no quote-supplied taxId; the CNPJ is collected on the Avenia company form instead. taxId: context.executionInput?.taxId ?? "" }; }, @@ -161,13 +187,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 +244,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.kybLink, + target: "#ramp.KybLinkComplete" + }, + { + target: "#ramp.KycComplete" + } + ] } } }; diff --git a/apps/frontend/src/machines/ramp.context.ts b/apps/frontend/src/machines/ramp.context.ts index e0f7e2424..be3a20542 100644 --- a/apps/frontend/src/machines/ramp.context.ts +++ b/apps/frontend/src/machines/ramp.context.ts @@ -15,6 +15,7 @@ export const initialRampContext: RampContext = { isAuthenticated: false, isQuoteExpired: false, isSep24Redo: false, + kybLink: undefined, partnerId: undefined, paymentData: undefined, postAuthTarget: undefined, diff --git a/apps/frontend/src/machines/ramp.machine.ts b/apps/frontend/src/machines/ramp.machine.ts index f7540af07..fd669849c 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,20 @@ export const rampMachine = setup({ rampSigningPhaseMax: ({ context, event }) => (event.max !== undefined ? event.max : context.rampSigningPhaseMax) }) ] + }, + START_KYB_LINK: { + actions: assign({ + 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" } }, states: { @@ -187,19 +202,8 @@ export const rampMachine = setup({ userId: ({ event }) => event.output.tokens?.userId }) ], - guard: ({ event, context }) => event.output.success === true && context.postAuthTarget === "RegisterRamp", - target: "RegisterRamp" - }, - { - 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 === "QuoteReady", - target: "QuoteReady" + guard: ({ event, context }) => event.output.success === true && context.postAuthTarget !== undefined, + target: "PostAuthRouting" }, { actions: [ @@ -407,6 +411,8 @@ 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: {}, KycComplete: { invoke: { input: ({ context }) => ({ context }), @@ -509,6 +515,23 @@ export const rampMachine = setup({ src: "loadQuote" } }, + // Single place that routes a successful login (CheckAuth or OTP) to the state recorded in postAuthTarget. + PostAuthRouting: { + always: [ + { + guard: ({ context }) => context.postAuthTarget === "RegisterRamp", + target: "RegisterRamp" + }, + { + guard: ({ context }) => context.postAuthTarget === "SelectRegion", + target: "SelectRegion" + }, + { + target: "QuoteReady" + } + ], + exit: assign({ postAuthTarget: undefined }) + }, QuoteReady: { always: [ { @@ -671,6 +694,19 @@ 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.kybLink?.regionLocked && !!context.kybLink.fiatToken, + target: "KYC" + }, + on: { + SELECT_REGION: { + actions: assign({ kybLink: ({ context, event }) => ({ ...context.kybLink, fiatToken: event.fiatToken }) }), + target: "KYC" + } + } + }, StartRamp: { invoke: { input: ({ context }) => context, @@ -758,49 +794,25 @@ export const rampMachine = setup({ email: context.userEmail }; }, - onDone: [ - { - 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 === "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 - }); - } - ], - target: "QuoteReady" - } - ], + onDone: { + actions: [ + assign({ + errorMessage: undefined, + isAuthenticated: true, + 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 + }); + } + ], + target: "PostAuthRouting" + }, onError: { actions: assign({ errorMessage: "Invalid OTP code. Please try again." diff --git a/apps/frontend/src/machines/types.ts b/apps/frontend/src/machines/types.ts index 5e6cc57ed..80b42498c 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,14 @@ export interface RampContext { isAuthenticated: boolean; isAuthLoading?: boolean; alfredpayCustomer?: unknown; - postAuthTarget?: "QuoteReady" | "RegisterRamp"; + postAuthTarget?: "QuoteReady" | "RegisterRamp" | "SelectRegion"; + // Present only in the quote-less KYB deep-link flow β€” its presence enables the mode. + kybLink?: { + // Drives KYC provider routing when there is no quote (set from the region selector). + fiatToken?: FiatToken; + // `?kybLocked=` pins the region; going back to the selector is disabled. + regionLocked?: boolean; + }; } export type RampMachineEvents = @@ -81,7 +88,9 @@ 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 }; export type RampMachineActor = ActorRef, RampMachineEvents>; export type RampMachineSnapshot = SnapshotFrom; 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/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 0cf6e0428..9a244adaa 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"; @@ -15,6 +16,7 @@ import { DetailsStep } from "../../components/widget-steps/DetailsStep"; import { ErrorStep } from "../../components/widget-steps/ErrorStep"; import { InitialQuoteFailedStep } from "../../components/widget-steps/InitialQuoteFailedStep"; 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 { @@ -38,7 +40,7 @@ export const Widget = ({ className }: WidgetProps) => (
@@ -62,16 +64,29 @@ 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, + isKybComplete, + isKybLinkMode + } = 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"), + isKybComplete: state.matches("KybLinkComplete"), + isKybLinkMode: !!state.context.kybLink, + 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 +99,10 @@ const WidgetContent = () => { return ; } + if (isKybComplete) { + return rampActor.send({ type: "RESET_RAMP" })} />; + } + if (isRedirectCallback) { return ; } @@ -96,6 +115,10 @@ const WidgetContent = () => { return ; } + if (isSelectRegion) { + return ; + } + if (rampSummaryVisible) { if (showFiatAccountRegistration && fiatRegistrationCountry) { return ; @@ -105,14 +128,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/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); 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/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; } } 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/src/stories/AveniaFormStep.stories.tsx b/apps/frontend/src/stories/AveniaFormStep.stories.tsx index fb3814fce..1763b58d7 100644 --- a/apps/frontend/src/stories/AveniaFormStep.stories.tsx +++ b/apps/frontend/src/stories/AveniaFormStep.stories.tsx @@ -1,9 +1,16 @@ 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"; // 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: 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..ae1ad48c2 100644 --- a/apps/frontend/src/stories/AveniaLivenessStep.stories.tsx +++ b/apps/frontend/src/stories/AveniaLivenessStep.stories.tsx @@ -1,9 +1,12 @@ import type { Meta, StoryObj } from "@storybook/react"; +import type { ComponentProps } from "react"; import { AveniaLivenessStep } from "../components/widget-steps/AveniaLivenessStep"; +type AveniaLivenessStepProps = ComponentProps; + // Create mock Avenia KYC actor const createMockAveniaKycActor = (livenessCheckOpened = false) => ({ - send: (event: any) => { + send: (event: unknown) => { console.log("Avenia KYC event:", event); } }); @@ -48,8 +51,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 +65,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 +80,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/RampFeeCollapse.stories.tsx b/apps/frontend/src/stories/RampFeeCollapse.stories.tsx new file mode 100644 index 000000000..e6dceca05 --- /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(Date.now() + 365 * 24 * 60 * 60 * 1000), + 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/stories/RampFollowUpRedirectStep.stories.tsx b/apps/frontend/src/stories/RampFollowUpRedirectStep.stories.tsx index beb7999c5..b698aa76e 100644 --- a/apps/frontend/src/stories/RampFollowUpRedirectStep.stories.tsx +++ b/apps/frontend/src/stories/RampFollowUpRedirectStep.stories.tsx @@ -1,7 +1,10 @@ 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"]>; + // Helper to create a complete snapshot const createSnapshot = (callbackUrl = "https://example.com/callback") => ({ children: {}, @@ -46,7 +49,7 @@ const meta: Meta = { const snapshot = createSnapshot(callbackUrl); return ( - +
diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index 435474762..d907e4066 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" } @@ -349,6 +350,10 @@ }, "feeCollapse": { "details": "Details", + "discount": { + "label": "Discount", + "tooltip": "Vortex is giving a discount on platform costs." + }, "finalAmount": "Final Amount", "netRate": { "label": "Effective Rate", @@ -362,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": { @@ -673,6 +678,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": { "ARSOnrampDetails": { "aliasLabel": "Alias", @@ -793,6 +811,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 894b09ab4..3b9e954d3 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" } @@ -352,6 +353,10 @@ }, "feeCollapse": { "details": "Detalhes", + "discount": { + "label": "Desconto", + "tooltip": "A Vortex estΓ‘ dando um desconto nos custos da plataforma." + }, "finalAmount": "Valor final", "netRate": { "label": "CΓ’mbio Efetiva", @@ -365,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": { @@ -677,6 +682,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": { "ARSOnrampDetails": { "aliasLabel": "Alias", @@ -797,6 +815,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" }, 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(), 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: { diff --git a/apps/rebalancer/.env.example b/apps/rebalancer/.env.example index dd128076d..617565554 100644 --- a/apps/rebalancer/.env.example +++ b/apps/rebalancer/.env.example @@ -1,14 +1,41 @@ ALCHEMY_API_KEY=your_api_key_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" -MOONBEAM_ACCOUNT_SECRET="your_secret_here" -POLYGON_ACCOUNT_SECRET="your_secret_here" BRLA_LOGIN_USERNAME=test@test.com 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 + +# 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) +# REBALANCING_THRESHOLD=0.01 +# 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 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 +# REBALANCING_MAX_COST_BPS_MILD=25 +# 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... +MAIN_NABLA_QUOTER=0x... diff --git a/apps/rebalancer/README.md b/apps/rebalancer/README.md index eb188f411..e74981273 100644 --- a/apps/rebalancer/README.md +++ b/apps/rebalancer/README.md @@ -13,11 +13,17 @@ Then, open the `.env` file and add your Alchemy API key. ``` ALCHEMY_API_KEY=your_alchemy_api_key +EVM_ACCOUNT_SECRET="your BIP-39 mnemonic (12/24 words)" + +# Only required for legacy flow (--legacy flag) PENDULUM_ACCOUNT_SECRET=xxx -MOONBEAM_ACCOUNT_SECRET=xxx -POLYGON_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 To install dependencies: diff --git a/apps/rebalancer/src/index.ts b/apps/rebalancer/src/index.ts index fff8a238f..b3ab979b7 100644 --- a/apps/rebalancer/src/index.ts +++ b/apps/rebalancer/src/index.ts @@ -1,24 +1,52 @@ 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 { 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, + evaluateRebalancingCostPolicy, + isProjectedProfit, + 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"; +import { + BrlaToAxlUsdcStateManager, + BrlaToUsdcBaseRebalancePhase, + BrlaToUsdcBaseStateManager, + RebalancePhase, + UsdcBaseRebalancePhase, + UsdcBaseStateManager, + type WinningRoute +} 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" | "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); +} -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 +66,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 +85,338 @@ async function checkForRebalancing() { await rebalanceBrlaToUsdcAxl(amountAxlUsdc, forceRestart); } -checkForRebalancing() +async function getTodayBridgedUsdRaw(): Promise { + const usdcStateManager = new UsdcBaseStateManager(); + const brlaStateManager = new BrlaToUsdcBaseStateManager(); + + const [usdcHistory, brlaHistory] = await Promise.all([usdcStateManager.getHistory(), brlaStateManager.getHistory()]); + + return sumTodayBridgedUsdRaw(usdcHistory, brlaHistory); +} + +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 }; +} + +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; + + const projectedTotalUsd = Big(decision.projectedTotalRaw).div(1e6).toFixed(2); + console.log(`Daily bridge limit reached: projected $${projectedTotalUsd}, limit $${dailyLimitUsd}. Skipping.`); +} + +async function evaluateCurrentRunDailyLimit( + amountUsdcRaw: string, + profitable: boolean +): 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 { dailyVolume }; + } + + const dailyLimitDecision = await evaluatePaidRunDailyLimit(amountUsdcRaw, profitable, async () => ({ + bridgedToday, + dailyLimitRaw + })); + if (!dailyLimitDecision) return { dailyVolume }; + logDailyLimitDecision(dailyLimitDecision, config.rebalancingDailyBridgeLimitUsd); + return { dailyVolume, decision: dailyLimitDecision }; +} + +function calculateCoverageDeviationBps(coverageRatio: number, triggerBound: number): number { + return Number( + Big(Math.abs(coverageRatio - triggerBound)) + .mul(10_000) + .toFixed(2) + ); +} + +function getQuoteForRoute( + route: Exclude, + quotes: { + 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<{ + decision: RebalancingCostPolicyDecision; + profitable: boolean; + routeQuotes?: { + aveniaQuoteUsdc: string | null; + mainNablaQuoteUsdc: string | null; + squidRouterQuoteUsdc: string | null; + }; + routeSelection?: "forced" | "best-quote"; + 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 { decision, profitable: false, 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(`Selected 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 { + decision, + profitable: isProjectedProfit(Big(amountUsdcRaw), Big(projectedOutputRaw)), + routeQuotes: { + aveniaQuoteUsdc: comparison.aveniaQuoteUsdc, + mainNablaQuoteUsdc: comparison.mainNablaQuoteUsdc, + squidRouterQuoteUsdc: comparison.squidRouterQuoteUsdc + }, + routeSelection: forcedRoute ? "forced" : "best-quote", + routeToRun, + shouldExecute: decision.shouldExecute + }; +} + +async function executeUsdcToBrlaRebalance( + amountUsdcRaw: string, + coverageDeviationBps: number, + policyDecision: Awaited>, + options: { opportunistic?: boolean } = {} +): Promise { + const config = getConfig(); + 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: dailyLimitEvaluation.decision, + dailyVolume: dailyLimitEvaluation.dailyVolume, + decision: policyDecision.decision, + deviationBps: coverageDeviationBps, + fallbackRequiresProfit: policyDecision.profitable, + opportunistic: options.opportunistic, + preflightQuotes: policyDecision.routeQuotes, + routeSelection: policyDecision.routeSelection + }); + return true; +} + +async function tryOpportunisticUsdcToBrla(): 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); + const opportunisticMaxCostBps = config.rebalancingCostPolicy.opportunisticUsdcToBrlaMaxCostBps; + + if (!policyDecision.shouldExecute) return false; + if (!shouldTriggerOpportunisticUsdcToBrla(policyDecision.decision.costBps, opportunisticMaxCostBps)) { + console.log( + `No opportunistic USDC->BRLA rebalance: projected cost ${policyDecision.decision.costBps} bps >= ${opportunisticMaxCostBps} bps.` + ); + return false; + } + + console.log(`Opportunistic USDC->BRLA rebalance triggered at ${policyDecision.decision.costBps} bps projected cost.`); + return executeUsdcToBrlaRebalance(amountUsdcRaw, 0, policyDecision, { opportunistic: true }); +} + +async function evaluateBrlaToUsdcPolicy( + amountUsdcRaw: string, + coverageDeviationBps: number +): Promise<{ decision: RebalancingCostPolicyDecision; profitable: boolean; shouldExecute: boolean }> { + 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 { decision, profitable: false, shouldExecute: 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, + profitable: isProjectedProfit(Big(amountUsdcRaw), Big(quote.projectedUsdcRaw)), + shouldExecute: decision.shouldExecute + }; +} + +async function runUsdcToBrla(coverageDeviationBps: number) { + 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 policyDecision = await evaluateUsdcToBrlaPolicy(amountUsdcRaw, coverageDeviationBps); + if (!policyDecision.shouldExecute) return; + await executeUsdcToBrlaRebalance(amountUsdcRaw, coverageDeviationBps, policyDecision); + return; + } + + await rebalanceUsdcBrlaUsdcBase(amountUsdcRaw, forceRestart, forcedRoute); +} + +async function runBrlaToUsdc(coverageDeviationBps: number) { + const config = getConfig(); + const amountUsdc = manualAmount || config.rebalancingBrlToUsdAmount; + const amountUsdcRaw = multiplyByPowerOfTen(new Big(amountUsdc), 6).toFixed(0, 0); + + const stateManager = new BrlaToUsdcBaseStateManager(); + const state = await stateManager.getState(); + const isResuming = !forceRestart && state && state.currentPhase !== BrlaToUsdcBaseRebalancePhase.Idle; + + if (!isResuming) { + const policyDecision = await evaluateBrlaToUsdcPolicy(amountUsdcRaw, coverageDeviationBps); + if (!policyDecision.shouldExecute) return; + + const dailyLimitEvaluation = await evaluateCurrentRunDailyLimit(amountUsdcRaw, policyDecision.profitable); + if (dailyLimitEvaluation.decision?.shouldSkip) return; + + const rebalancerUsdcBalance = await checkInitialUsdcBalanceOnBase(amountUsdcRaw); + if (config.rebalancingBrlToUsdMinBalance && rebalancerUsdcBalance.lt(config.rebalancingBrlToUsdMinBalance)) { + throw new Error( + `Rebalancer USDC balance ${rebalancerUsdcBalance} is below the minimum required balance of ${config.rebalancingBrlToUsdMinBalance} to perform rebalancing.` + ); + } + await rebalanceBrlaToUsdcBase(amountUsdcRaw, forceRestart, { + config: config.rebalancingCostPolicy, + dailyLimitDecision: dailyLimitEvaluation.decision, + dailyVolume: dailyLimitEvaluation.dailyVolume, + decision: policyDecision.decision, + deviationBps: coverageDeviationBps, + fallbackRequiresProfit: policyDecision.profitable + }); + return; + } + + await rebalanceBrlaToUsdcBase(amountUsdcRaw, forceRestart); +} + +async function checkForRebalancing() { + const config = getConfig(); + const coverage = await getBaseNablaCoverageRatio(); + + if (!coverage) throw new Error("Failed to fetch Base Nabla coverage ratio."); + + const lowerBound = 1 - config.rebalancingThresholdBrlaToUsdc; + const upperBound = 1 + config.rebalancingThresholdUsdcToBrla; + + if (coverage.brlaCoverageRatio >= lowerBound && coverage.brlaCoverageRatio <= upperBound) { + if (await tryOpportunisticUsdcToBrla()) return; + console.log(`BRLA coverage ${coverage.brlaCoverageRatio} in range [${lowerBound}, ${upperBound}]. No rebalancing needed.`); + return; + } + + if (coverage.brlaCoverageRatio < lowerBound) { + const deviationBps = calculateCoverageDeviationBps(coverage.brlaCoverageRatio, lowerBound); + console.log( + `BRLA coverage ${coverage.brlaCoverageRatio} < ${lowerBound}. Evaluating BRLA->USDC (${deviationBps} bps deviation).` + ); + await runBrlaToUsdc(deviationBps); + return; + } + + const deviationBps = calculateCoverageDeviationBps(coverage.brlaCoverageRatio, upperBound); + console.log( + `BRLA coverage ${coverage.brlaCoverageRatio} > ${upperBound}. Evaluating USDC->BRLA (${deviationBps} bps deviation).` + ); + await runUsdcToBrla(deviationBps); +} + +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..057774f12 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,13 +20,17 @@ 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); 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); @@ -45,7 +49,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 +59,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 +70,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 +81,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 +92,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,21 +103,20 @@ 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); 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); } // 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 +128,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); @@ -155,7 +158,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/brla-to-axlusdc/steps.ts b/apps/rebalancer/src/rebalance/brla-to-axlusdc/steps.ts index 64672bf1a..3c6645b7b 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"); @@ -280,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 }; @@ -371,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(); 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..65873cbd9 --- /dev/null +++ b/apps/rebalancer/src/rebalance/brla-to-usdc-base/index.ts @@ -0,0 +1,108 @@ +import { multiplyByPowerOfTen, SlackNotifier } from "@vortexfi/shared"; +import Big from "big.js"; +import { + BrlaToUsdcBaseRebalancePhase, + BrlaToUsdcBaseStateManager, + brlaToUsdcBasePhaseOrder +} from "../../services/stateManager.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, policy?: RebalancePolicySummary) { + console.log(`Starting USDCβ†’BRLAβ†’USDC rebalance on Base with amount: ${usdcAmountRaw} (raw USDC)`); + + 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(usdcAmountRaw); + } + + 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 as `0x${string}`); + + const currentOrder = brlaToUsdcBasePhaseOrder[state.currentPhase]; + console.log(`Current phase order: ${currentOrder}`); + + if (currentOrder <= brlaToUsdcBasePhaseOrder[BrlaToUsdcBaseRebalancePhase.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 = BrlaToUsdcBaseRebalancePhase.MainNablaSwapUsdcToBrla; + await stateManager.saveState(state); + } + + if (currentOrder <= brlaToUsdcBasePhaseOrder[BrlaToUsdcBaseRebalancePhase.MainNablaSwapUsdcToBrla]) { + if (!state.usdcAmountRaw) throw new Error("State corrupted: usdcAmountRaw missing for step 2"); + + await mainNablaSwapUsdcToBrlaOnBase(state.usdcAmountRaw, baseNonce, state, stateManager); + + state.currentPhase = BrlaToUsdcBaseRebalancePhase.NablaSwapBrlaToUsdc; + await stateManager.saveState(state); + } + + if (currentOrder <= brlaToUsdcBasePhaseOrder[BrlaToUsdcBaseRebalancePhase.NablaSwapBrlaToUsdc]) { + if (!state.mainNablaBrlaReceivedRaw) throw new Error("State corrupted: mainNablaBrlaReceivedRaw missing for step 3"); + + await nablaSwapBrlaToUsdcOnBase(state.mainNablaBrlaReceivedRaw, 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.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 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! USDC in: ${usdcIn.toFixed(6)}, BRLA intermediate: ${brlaIntermediate.toFixed(6)}, USDC out: ${usdcOut.toFixed(6)}` + ); + 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: 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..823122e82 --- /dev/null +++ b/apps/rebalancer/src/rebalance/brla-to-usdc-base/notifications.test.ts @@ -0,0 +1,45 @@ +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, + opportunisticUsdcToBrlaMaxCostBps: 10, + 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("*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*"); + 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 new file mode 100644 index 000000000..3ce9e2958 --- /dev/null +++ b/apps/rebalancer/src/rebalance/brla-to-usdc-base/notifications.ts @@ -0,0 +1,38 @@ +import Big from "big.js"; +import { + formatCompactTable, + 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 { + return [ + "βœ… *Base rebalancer completed (Main Nabla β†’ BRLA Nabla)*", + "USDC β†’ BRLA (Main Nabla) β†’ USDC (BRLA Nabla)", + "", + "*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/brla-to-usdc-base/steps.ts b/apps/rebalancer/src/rebalance/brla-to-usdc-base/steps.ts new file mode 100644 index 000000000..01a21c597 --- /dev/null +++ b/apps/rebalancer/src/rebalance/brla-to-usdc-base/steps.ts @@ -0,0 +1,377 @@ +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 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, + 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 expectedOutputRaw = BigInt(await quoteBrlaNablaToUsdcOnBase(brlaAmountRaw)); + + 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 } = 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 expectedOutputRaw = BigInt(await quoteMainNablaUsdcToBrlaOnBase(usdcAmountRaw)); + + 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/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/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..47d2145eb --- /dev/null +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.test.ts @@ -0,0 +1,165 @@ +import {describe, expect, test} from "bun:test"; +import Big from "big.js"; +import { + calculateMinimumDelta, + calculateProjectedCostBps, + calculateTargetBalanceRaw, + evaluateDailyBridgeLimit, + evaluateFallbackRoutePolicy, + evaluateRebalancingCostPolicy, + getRebalancingUrgencyBand, + isProjectedProfit, + type RebalancingCostPolicyConfig, + shouldTriggerOpportunisticUsdcToBrla, + wouldExceedDailyBridgeLimit +} from "./guards.ts"; + +const policyConfig: RebalancingCostPolicyConfig = { + hardMaxCostBps: 1_000, + maxCostBpsMild: 25, + maxCostBpsModerate: 75, + maxCostBpsSevere: 250, + mode: "auto", + moderateDeviationBps: 200, + opportunisticUsdcToBrlaMaxCostBps: 10, + severeDeviationBps: 500 +}; + +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("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); + }); + + 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 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"))).toMatchObject({ + reason: "daily_limit_reached", + shouldSkip: true + }); + }); + + 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("triggers opportunistic USDC to BRLA rebalances only below the route cost cap", () => { + 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 + ).toBe(true); + + expect( + evaluateFallbackRoutePolicy(Big("100000000"), Big("99900000"), 0, policyConfig, { + opportunisticMaxCostBps: policyConfig.opportunisticUsdcToBrlaMaxCostBps, + requireOpportunisticCost: true, + requireProfit: false + }).shouldExecute + ).toBe(false); + }); + + test("requires fallback route profit when the original route was projected 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 + }); + + 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"); + 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 new file mode 100644 index 000000000..0588a6b84 --- /dev/null +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/guards.ts @@ -0,0 +1,214 @@ +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; + opportunisticUsdcToBrlaMaxCostBps: number; + severeDeviationBps: number; +} + +export interface RebalancingCostPolicyDecision { + allowedCostBps: number; + band: RebalancingUrgencyBand; + costBps: number; + dryRun: boolean; + projectedCostRaw: string; + reason: string; + shouldExecute: boolean; +} + +export interface DailyBridgeLimitDecision { + projectedTotalRaw: string; + reason: "under_limit" | "daily_limit_reached"; + 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); +} + +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); +} + +export function isProjectedProfit(inputAmountRaw: Big, projectedOutputRaw: Big): boolean { + return projectedOutputRaw.gt(inputAmountRaw); +} + +export function evaluateDailyBridgeLimit( + bridgedTodayRaw: Big, + requestedAmountRaw: Big, + dailyLimitRaw: Big +): DailyBridgeLimitDecision { + const projectedTotalRaw = bridgedTodayRaw.plus(requestedAmountRaw).toFixed(0, 0); + + if (!wouldExceedDailyBridgeLimit(bridgedTodayRaw, requestedAmountRaw, dailyLimitRaw)) { + return { projectedTotalRaw, reason: "under_limit", 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)); +} + +export function shouldTriggerOpportunisticUsdcToBrla(costBps: number, maxCostBps: number): boolean { + return costBps < maxCostBps; +} + +export function evaluateFallbackRoutePolicy( + inputAmountRaw: Big, + fallbackOutputRaw: Big, + deviationBps: number, + config: RebalancingCostPolicyConfig, + options: { opportunisticMaxCostBps: number; 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, options.opportunisticMaxCostBps) + ) { + return { + decision, + profitable, + reason: `Fallback route cost ${decision.costBps} bps is not below opportunistic cap ${options.opportunisticMaxCostBps} bps.`, + shouldExecute: false + }; + } + + if (options.requireProfit && !profitable) { + return { + decision, + profitable, + reason: "Fallback route is not profitable, but the original route was projected profitable.", + shouldExecute: false + }; + } + + return { decision, profitable, reason: "Fallback route satisfies the required policy checks.", shouldExecute: true }; +} + +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 + }; +} 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..5ce2630ef --- /dev/null +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/index.ts @@ -0,0 +1,492 @@ +import { BrlaApiService, multiplyByPowerOfTen, SlackNotifier } from "@vortexfi/shared"; +import Big from "big.js"; +import { + UsdcBaseRebalancePhase, + type UsdcBaseRebalanceState, + UsdcBaseStateManager, + usdcBasePhaseOrder +} from "../../services/stateManager.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"; +import { formatBaseRebalanceCompletionMessage, type RebalancePolicySummary } from "./notifications.ts"; +import { + aveniaCreateSwapToUsdcBaseTicket, + aveniaTransferBrlaToPolygon, + checkInitialUsdcBalanceOnBase, + compareRoutesUpfront, + getAveniaBrlaBalanceDecimal, + getBrlaBalanceOnPolygonRaw, + getUsdcBalanceOnBaseRaw, + mainNablaApproveAndSwap, + nablaApproveAndSwapOnBase, + recoverAveniaPolygonTransferFromBalance, + squidRouterApproveAndSwap, + transferBrlaToAveniaOnBase, + verifyFinalUsdcBalanceOnBase, + waitBrlaOnPolygon, + waitForBrlaOnAvenia, + 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.fallbackRequiresProfit ?? false + }; + } + + if (!state.opportunisticUsdcToBrla) return null; + + const config = getConfig().rebalancingCostPolicy; + return { + config, + deviationBps: state.opportunisticDeviationBps ?? 0, + opportunisticMaxCostBps: state.opportunisticMaxCostBps ?? config.opportunisticUsdcToBrlaMaxCostBps, + requireProfit: state.opportunisticRequiresProfit + }; +} + +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); +} + +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, + forcedRoute?: "squidrouter" | "avenia" | "nabla-main", + policy?: RebalancePolicySummary +) { + 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, { + 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."); + } + + 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}`); + + // ── 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) { + 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; + state.mainNablaQuoteUsdc = policy?.preflightQuotes?.mainNablaQuoteUsdc ?? null; + } 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 3"); + + const result = await nablaApproveAndSwapOnBase(state.usdcAmountRaw, baseNonce, state, stateManager); + + state.brlaAmountRaw = result.brlaAmountRaw; + state.brlaAmountDecimal = result.brlaAmountDecimal.toString(); + + console.log(`Nabla swap completed. BRLA received: ${result.brlaAmountDecimal.toFixed(4)}`); + + if (state.winningRoute === "nabla-main") { + state.currentPhase = UsdcBaseRebalancePhase.MainNablaApproveAndSwap; + } else { + state.currentPhase = UsdcBaseRebalancePhase.TransferBrlaToAvenia; + } + await stateManager.saveState(state); + } + + // ── 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); + + console.log("Main Nabla swap completed. Proceeding to verify final balance."); + state.currentPhase = UsdcBaseRebalancePhase.VerifyFinalBalance; + await stateManager.saveState(state); + } + } + + // ── 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"); + + 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}`); + 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 5"); + if (!state.aveniaBrlaBalanceBeforeTransfer) { + throw new Error("State corrupted: aveniaBrlaBalanceBeforeTransfer missing for step 5"); + } + + const actualBrlaBalance = await waitForBrlaOnAvenia( + Big(state.brlaAmountDecimal), + Big(state.aveniaBrlaBalanceBeforeTransfer) + ); + + state.brlaAmountDecimal = actualBrlaBalance; + state.brlaAmountRaw = multiplyByPowerOfTen(Big(actualBrlaBalance), 18).toFixed(0, 0); + + console.log("BRLA confirmed on Avenia internal balance."); + + if (state.winningRoute === "squidrouter") { + state.currentPhase = UsdcBaseRebalancePhase.AveniaTransferToPolygon; + } else { + state.currentPhase = UsdcBaseRebalancePhase.AveniaSwapToUsdcBase; + } + 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"); + + const brlaApiService = BrlaApiService.getInstance(); + + 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; + await stateManager.saveState(state); + } catch (error) { + console.error("Avenia swap ticket creation failed. Falling back to SquidRouter route.", error); + const opportunisticFallbackContext = getOpportunisticFallbackContext(state, policy); + if (opportunisticFallbackContext) { + 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), + opportunisticFallbackContext.deviationBps, + opportunisticFallbackContext.config, + { + opportunisticMaxCostBps: opportunisticFallbackContext.opportunisticMaxCostBps, + requireOpportunisticCost: true, + requireProfit: opportunisticFallbackContext.requireProfit + } + ); + if (!fallbackPolicy.shouldExecute) { + throw new Error(`Opportunistic Avenia fallback blocked: ${fallbackPolicy.reason}`); + } + } + + 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); + // Avenia API returns outputAmount in decimal units. + 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) { + 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"); + if (!state.baseUsdcBalanceBeforeAveniaSwapRaw) { + throw new Error("State corrupted: baseUsdcBalanceBeforeAveniaSwapRaw missing for avenia step 2"); + } + + const aveniaUsdcRaw = multiplyByPowerOfTen(Big(state.aveniaQuoteUsdc), 6).toFixed(0, 0); + state.aveniaQuoteUsdc = await waitUsdcOnBase(aveniaUsdcRaw, state.baseUsdcBalanceBeforeAveniaSwapRaw); + + console.log("USDC from Avenia confirmed on Base."); + state.currentPhase = UsdcBaseRebalancePhase.VerifyFinalBalance; + await stateManager.saveState(state); + } + } + } + + // ── 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}`); + + if (currentOrder <= usdcBasePhaseOrder[UsdcBaseRebalancePhase.AveniaTransferToPolygon]) { + 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); + } + + if (await recoverCompletedAveniaPolygonTransfer(state, stateManager)) { + console.log( + "Existing Polygon BRLA balance delta detected before creating a new Avenia ticket. Continuing recovered rebalance." + ); + state.currentPhase = UsdcBaseRebalancePhase.WaitBrlaOnPolygon; + await stateManager.saveState(state); + } else { + 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; + } + } + } + + 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"); + if (!state.polygonBrlaBalanceBeforeTransferRaw) { + throw new Error("State corrupted: polygonBrlaBalanceBeforeTransferRaw missing for squid step 2"); + } + + 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; + await stateManager.saveState(state); + } + + 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); + + if (result.swapHash) { + state.squidRouterSwapHash = result.swapHash; + } + state.squidRouterQuoteUsdc = result.toAmountRaw; + console.log(`SquidRouter swap completed. Tx: ${result.swapHash ?? "recovered from Base balance"}`); + 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"); + if (!state.baseUsdcBalanceBeforeSquidSwapRaw) { + throw new Error("State corrupted: baseUsdcBalanceBeforeSquidSwapRaw missing for squid step 4"); + } + + state.squidRouterQuoteUsdc = await waitUsdcOnBase(state.squidRouterQuoteUsdc, state.baseUsdcBalanceBeforeSquidSwapRaw); + + console.log("USDC from SquidRouter confirmed on Base."); + state.currentPhase = UsdcBaseRebalancePhase.VerifyFinalBalance; + await stateManager.saveState(state); + } + } + + // ── Final: verify balance and report ──────────────────────────────────────── + 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 usdcRebalanced = Big(state.usdcAmountRaw).div(10 ** 6); + const cost = initialUsdcDecimal.minus(finalUsdcDecimal); + 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` + ); + 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, + endingTime: new Date().toISOString(), + initialAmount: state.usdcAmountRaw, + startingTime: state.startingTime + }); + + const slackNotifier = new SlackNotifier(process.env.SLACK_WEB_HOOK_TOKEN); + await slackNotifier.sendMessage({ + text: formatBaseRebalanceCompletionMessage({ + brlaReceived: Big(state.brlaAmountDecimal), + cost, + edgeCaseFlags, + 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..05dad33b6 --- /dev/null +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.test.ts @@ -0,0 +1,59 @@ +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, + opportunisticUsdcToBrlaMaxCostBps: 10, + 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"), + edgeCaseFlags: ["OPP", "FB"], + policy: { + config: policyConfig, + dailyVolume: { + bypassedForProfit: false, + limitRaw: "10000000000", + projectedTotalRaw: "3500000000", + usedRaw: "2500000000" + }, + 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("*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*"); + 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 new file mode 100644 index 000000000..cd0521825 --- /dev/null +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/notifications.ts @@ -0,0 +1,128 @@ +import Big from "big.js"; +import type { WinningRoute } from "../../services/stateManager.ts"; +import type { DailyBridgeLimitDecision, RebalancingCostPolicyConfig, RebalancingCostPolicyDecision } from "./guards.ts"; + +export interface RebalancePolicySummary { + config: RebalancingCostPolicyConfig; + dailyLimitDecision?: DailyBridgeLimitDecision; + dailyVolume?: { + bypassedForProfit: boolean; + limitRaw: string; + projectedTotalRaw: string; + usedRaw: string; + }; + decision?: RebalancingCostPolicyDecision; + deviationBps?: number; + fallbackRequiresProfit?: boolean; + opportunistic?: boolean; + preflightQuotes?: { + aveniaQuoteUsdc: string | null; + mainNablaQuoteUsdc: string | null; + squidRouterQuoteUsdc: string | null; + }; + routeSelection?: "forced" | "best-quote"; +} + +interface BaseRebalanceCompletionMessageParams { + brlaReceived: Big; + cost: Big; + finalUsdcBalance: Big; + initialUsdcBalance: Big; + edgeCaseFlags?: string[]; + policy?: RebalancePolicySummary; + requestedUsdc: Big; + route: WinningRoute; +} + +export function formatBaseRebalanceCompletionMessage(params: BaseRebalanceCompletionMessageParams): string { + return [ + "βœ… *Base rebalancer completed*", + "USDC -> BRLA -> USDC on Base", + "", + "*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, params.edgeCaseFlags) + ].join("\n"); +} + +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, + decision.shouldExecute ? "execute" : "skip", + decision.band, + policy.deviationBps === undefined ? "N/A" : formatBps(policy.deviationBps), + formatBps(decision.costBps), + formatBps(decision.allowedCostBps), + formatBps(policy.config.hardMaxCostBps), + ...contextRow + ] + : [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", "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[]) => + 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); +} + +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"; + if (route === "nabla-main") return "Main Nabla"; + return "Unknown"; +} 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..ad8ce851d --- /dev/null +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.test.ts @@ -0,0 +1,170 @@ +import {describe, expect, test} from "bun:test"; +import {createUsdcBaseRebalanceState, UsdcBaseRebalancePhase} from "../../services/stateManager.ts"; +import { + ensurePolygonBrlaAvailableForSquidSwap, + recoverAveniaPolygonTransferFromBalance, + recoverSquidUsdcOutputFromBaseBalance, + 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 = { + waitForTransactionReceipt: 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 = { + waitForTransactionReceipt: 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"); + }); + + 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("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"; + + 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(); + }); + + 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 new file mode 100644 index 000000000..fe318a8bf --- /dev/null +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts @@ -0,0 +1,1143 @@ +import { + AveniaPaymentMethod, + 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, type PublicClient } from "viem"; +import { base, polygon } from "viem/chains"; +import { brlaMoonbeamTokenDetails } from "../../constants.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 as `0x${string}`; +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({ + 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"); +} + +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; + + 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, + state: UsdcBaseRebalanceState, + stateManager: UsdcBaseStateManager +): Promise<{ + brlaAmountRaw: string; + brlaAmountDecimal: Big; + approveHash: string | null; + swapHash: string | null; +}> { + 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); + + 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); + + 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; + + if (!swapHash) { + 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 + ); + + 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."); + + 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) + }); + state.nablaSwapHash = swapHash; + await stateManager.saveState(state); + console.log(`Swap tx sent: ${swapHash}`); + } else { + 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."); + } + + 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)); + + const brlaBalanceAfter = await publicClient.readContract({ + abi: erc20Abi, + address: ERC20_BRLA_BASE, + args: [executorAddress], + functionName: "balanceOf" + }); + + const brlaReceivedRaw = brlaBalanceAfter - brlaBalanceBefore; + if (brlaReceivedRaw < 0n) { + throw new Error( + `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, brlaAmountDecimal } = buildRecoveredBrlaOutput(brlaReceivedRaw); + 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, + state: UsdcBaseRebalanceState, + stateManager: UsdcBaseStateManager +): 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...`); + 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; + } + + 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 + }); + + state.brlaTransferHash = txHash; + await stateManager.saveState(state); + console.log(`BRLA transfer tx sent: ${txHash}`); + 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; +} + +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(); + // 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...`); + + 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); + 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 delta. Needed: ${minimumReceived.toFixed(12)}, received: ${receivedDelta.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 fetchSquidRouterQuote(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(4, 0), + inputCurrency: BrlaCurrency.BRLA, + outputCurrency: BrlaCurrency.USDC, + outputPaymentMethod: AveniaPaymentMethod.BASE + }, + { useCache: true } + ); + + // 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<{ + winningRoute: "squidrouter" | "avenia"; + squidRouterQuoteUsdc: string | null; + aveniaQuoteUsdc: string | null; +}> { + console.log("Comparing SquidRouter vs Avenia rates for BRLA -> USDC..."); + + let squidRouterQuoteUsdc: string | null = null; + let aveniaQuoteUsdc: string | null = null; + + try { + squidRouterQuoteUsdc = await fetchSquidRouterQuote(brlaAmountDecimal); + } catch (error) { + console.warn("SquidRouter quote failed:", error); + } + + try { + aveniaQuoteUsdc = await fetchAveniaQuote(brlaAmountDecimal); + } 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 = multiplyByPowerOfTen(Big(aveniaQuoteUsdc), -6); + + 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(4, 0), + inputCurrency: BrlaCurrency.BRLA, + outputCurrency: BrlaCurrency.BRLA, + outputPaymentMethod: AveniaPaymentMethod.POLYGON + }); + + 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, startingBrlaBalanceRaw: string): Promise { + const { walletClient: polygonWalletClient } = getPolygonEvmClients(); + const polygonAddress = polygonWalletClient.account.address; + // 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})...`); + + const finalBalance = await checkEvmBalancePeriodically( + BRLA_POLYGON, + polygonAddress, + targetBalanceRaw, + 1_000, + 10 * 60 * 1_000, + Networks.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 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, + stateManager: Pick, + polygonPublicClient: PublicClient +): Promise { + const receipt = await polygonPublicClient.waitForTransactionReceipt({ + confirmations: 1, + 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 recoverSquidUsdcOutputFromBaseBalance( + expectedUsdcRaw: string | null, + startingUsdcBalanceRaw: string | null, + state: UsdcBaseRebalanceState, + stateManager: Pick, + getCurrentBaseUsdcRaw = getUsdcBalanceOnBaseRaw +): Promise { + 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(recoveryExpectedUsdcRaw), 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 | null; toAmountUsd: string; toAmountRaw: string }> { + let swapHash = state.squidRouterSwapHash; + let toAmountUsd = "0"; + let toAmountRaw = state.squidRouterQuoteUsdc ?? "0"; + + 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"; + } + } + + if (!swapHash) { + console.log("Executing SquidRouter swap: Polygon BRLA -> Base USDC..."); + + 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, + 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; + 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({ + 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) + }); + state.squidRouterSwapHash = swapHash; + await stateManager.saveState(state); + 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}`); + } + + console.log("Waiting for Axelar to execute the cross-chain swap..."); + let isExecuted = false; + const axelarTimeout = 30 * 60 * 1000; + const axelarStartTime = Date.now(); + + while (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, toAmountRaw, toAmountUsd }; +} + +export async function waitUsdcOnBase(expectedUsdcRaw: string, startingUsdcBalanceRaw: string): Promise { + const { walletClient } = getBaseEvmClients(); + const baseAddress = walletClient.account.address; + const targetBalanceRaw = calculateTargetBalanceRaw(startingUsdcBalanceRaw, expectedUsdcRaw, DEFAULT_ARRIVAL_TOLERANCE); + + console.log(`Waiting for USDC delta to arrive on Base (${baseAddress})...`); + + 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. Actual delta: ${receivedDeltaRaw} raw`); + return receivedDeltaRaw; +} + +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(4, 0), + inputCurrency: BrlaCurrency.BRLA, + outputCurrency: BrlaCurrency.USDC, + outputPaymentMethod: AveniaPaymentMethod.BASE + }); + + const ticket = await brlaApiService.createOnchainSwapTicket({ + quoteToken: quote.quoteToken, + ticketBlockchainOutput: { + walletAddress: baseReceiverAddress, + walletChain: AveniaPaymentMethod.BASE + } + }); + console.log(`Avenia swap ticket created: ${ticket.id}`); + + // Avenia API returns outputAmount in decimal units. + 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; +} + +// ── 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: multiplyByPowerOfTen(Big(aveniaQuoteUsdc), -6) }); + } + 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 1c9e05888..f42077da6 100644 --- a/apps/rebalancer/src/services/indexer/index.ts +++ b/apps/rebalancer/src/services/indexer/index.ts @@ -1,6 +1,76 @@ +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"; +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 } | 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}`; + + 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, + address: brlaPoolAddress, + functionName: "reserve" + }) as Promise, + baseClient.readContract({ + abi: SWAP_POOL_ABI, + address: brlaPoolAddress, + functionName: "totalLiabilities" + }) as Promise + ]); + + const brlaCoverageRatio = + brlaLiabilities > 0n ? new Big(brlaReserve.toString()).div(new Big(brlaLiabilities.toString())).toNumber() : 0; + + console.log(`Base Nabla BRLA pool coverage ratio: ${brlaCoverageRatio}`); + + return { brlaCoverageRatio }; + } 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.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); + }); +}); diff --git a/apps/rebalancer/src/services/stateManager.ts b/apps/rebalancer/src/services/stateManager.ts index f4ce27db5..510d1b972 100644 --- a/apps/rebalancer/src/services/stateManager.ts +++ b/apps/rebalancer/src/services/stateManager.ts @@ -2,6 +2,60 @@ 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 { + const { data, error } = await this.supabase.storage.from("rebalancer_state").download(this.filename); + + if (error) { + 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; + } + + const stateText = await data.text(); + try { + return JSON.parse(stateText) as T; + } catch { + console.warn("Rebalancer state is not valid JSON, treating as missing."); + return undefined; + } + } + + async saveState(state: T): Promise { + 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, { + 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 +104,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 +128,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 +147,299 @@ export class StateManager { return state; } } + +// --- USDC->BRLA->USDC (Base) rebalance flow --- + +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", + 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.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" | "nabla-main" | null; + +export interface UsdcBaseRebalanceState { + currentPhase: UsdcBaseRebalancePhase; + initialUsdcBalance: string | null; + 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; + mainNablaQuoteUsdc: string | null; + 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; + baseUsdcBalanceBeforeSquidSwapRaw: string | null; + aveniaTicketId: string | null; + finalUsdcBalance: string | null; + startingTime: string; + updatedTime: string; +} + +export interface RebalanceHistoryEntry { + initialAmount: string; + startingTime: string; + endingTime: string; + cost: string; + costRelative: string; +} + +export interface UsdcBaseRebalanceContainer { + state: UsdcBaseRebalanceState; + history: RebalanceHistoryEntry[]; +} + +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, + aveniaTicketId: null, + baseUsdcBalanceBeforeAveniaSwapRaw: null, + baseUsdcBalanceBeforeSquidSwapRaw: null, + brlaAmountDecimal: null, + brlaAmountRaw: null, + brlaBalanceBeforeNablaRaw: null, + brlaTransferHash: null, + currentPhase, + finalUsdcBalance: null, + initialUsdcBalance: null, + mainNablaApproveHash: null, + mainNablaQuoteUsdc: null, + mainNablaSwapHash: null, + mainNablaUsdcBalanceBeforeRaw: null, + nablaApproveHash: null, + nablaSwapHash: null, + 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, + winningRoute: null + }; +} + +function createFreshState(): UsdcBaseRebalanceState { + return createUsdcBaseRebalanceState(null, UsdcBaseRebalancePhase.Idle); +} + +export class UsdcBaseStateManager { + private inner: StateManager; + + constructor() { + 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 { + const container = await this.getContainer(); + return container?.state; + } + + async getHistory(): Promise { + const container = await this.getContainer(); + return container?.history ?? []; + } + + async saveState(state: UsdcBaseRebalanceState): 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. Writing entry to fresh history."); + await this.inner.saveState({ history: [entry], state: createFreshState() }); + return; + } + existing.history.push(entry); + existing.state.updatedTime = new Date().toISOString(); + await this.inner.saveState(existing); + } + + async startNewRebalance(usdcAmountRaw: string, options: UsdcBaseRebalanceStartOptions = {}): Promise { + const existing = await this.getContainer(); + const history = existing?.history ?? []; + + const state = createUsdcBaseRebalanceState(usdcAmountRaw, UsdcBaseRebalancePhase.CheckInitialUsdcBalance, options); + await this.inner.saveState({ history, state }); + return state; + } +} + +// --- BRLA->USDC (Base) rebalance flow --- + +export enum BrlaToUsdcBaseRebalancePhase { + Idle = "idle", + CheckInitialUsdcBalance = "checkInitialUsdcBalance", + MainNablaSwapUsdcToBrla = "mainNablaSwapUsdcToBrla", + NablaSwapBrlaToUsdc = "nablaSwapBrlaToUsdc", + VerifyFinalBalance = "verifyFinalBalance" +} + +export const brlaToUsdcBasePhaseOrder: Record = { + [BrlaToUsdcBaseRebalancePhase.Idle]: 0, + [BrlaToUsdcBaseRebalancePhase.CheckInitialUsdcBalance]: 1, + [BrlaToUsdcBaseRebalancePhase.MainNablaSwapUsdcToBrla]: 2, + [BrlaToUsdcBaseRebalancePhase.NablaSwapBrlaToUsdc]: 3, + [BrlaToUsdcBaseRebalancePhase.VerifyFinalBalance]: 4 +}; + +export interface BrlaToUsdcBaseRebalanceState { + currentPhase: BrlaToUsdcBaseRebalancePhase; + usdcAmountRaw: 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(usdcAmountRaw: string): Promise { + const existing = await this.getContainer(); + const history = existing?.history ?? []; + + const state: BrlaToUsdcBaseRebalanceState = { + currentPhase: BrlaToUsdcBaseRebalancePhase.CheckInitialUsdcBalance, + finalUsdcBalance: null, + initialUsdcBalance: null, + mainNablaApproveHash: null, + mainNablaBrlaBalanceBeforeRaw: null, + mainNablaBrlaReceivedRaw: null, + mainNablaSwapHash: null, + nablaApproveHash: null, + nablaSwapHash: null, + startingTime: new Date().toISOString(), + updatedTime: new Date().toISOString(), + usdcAmountRaw, + usdcBalanceBeforeNablaRaw: null, + usdcReceivedRaw: null + }; + await this.inner.saveState({ history, state }); + return state; + } +} diff --git a/apps/rebalancer/src/utils/brla.test.ts b/apps/rebalancer/src/utils/brla.test.ts new file mode 100644 index 000000000..5cad7f969 --- /dev/null +++ b/apps/rebalancer/src/utils/brla.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; +import { AveniaTicketStatus } from "@vortexfi/shared"; +import { checkTicketStatusPaid, RetryableAveniaTicketStatusError } 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"); + }); + + 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 new file mode 100644 index 000000000..e3cd9ac34 --- /dev/null +++ b/apps/rebalancer/src/utils/brla.ts @@ -0,0 +1,55 @@ +import { AveniaSwapTicket, AveniaTicketStatus, BrlaApiService } from "@vortexfi/shared"; + +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; + const startTime = Date.now(); + let lastError: Error | undefined; + + while (Date.now() - startTime < timeout) { + let ticket: AveniaSwapTicket; + try { + 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; + } + + const normalizedStatus = ticket?.status ? normalizeAveniaTicketStatus(ticket.status) : undefined; + + if (normalizedStatus === AveniaTicketStatus.PAID) { + return ticket; + } + if (normalizedStatus === AveniaTicketStatus.PARTIAL_FAILED) { + throw new RetryableAveniaTicketStatusError(ticketId, AveniaTicketStatus.PARTIAL_FAILED); + } + if (normalizedStatus === AveniaTicketStatus.FAILED) { + throw new Error(`Ticket ${ticketId} status is FAILED`); + } + + 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."); +} diff --git a/apps/rebalancer/src/utils/config.test.ts b/apps/rebalancer/src/utils/config.test.ts new file mode 100644 index 000000000..92f68739f --- /dev/null +++ b/apps/rebalancer/src/utils/config.test.ts @@ -0,0 +1,130 @@ +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", + "REBALANCING_OPPORTUNISTIC_USDC_TO_BRLA_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, + 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"; + + 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 27b18c469..803efe915 100644 --- a/apps/rebalancer/src/utils/config.ts +++ b/apps/rebalancer/src/utils/config.ts @@ -1,11 +1,85 @@ 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"]; + +function parseNonNegativeNumber(name: string, value: string | undefined, defaultValue: number): number { + const trimmedValue = value?.trim(); + if (!trimmedValue) return defaultValue; + + const parsedValue = Number(trimmedValue.replaceAll("_", "").replaceAll(",", "")); + if (!Number.isFinite(parsedValue) || parsedValue < 0) { + 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 + ), + 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, + 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.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,16 +87,32 @@ 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, + // 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, - polygonAccountSecret: process.env.POLYGON_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 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. - rebalancingThreshold: Number(process.env.REBALANCING_THRESHOLD) || 0.25, + 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. @@ -34,6 +124,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 +133,32 @@ export function getPendulumAccount() { export function getMoonbeamEvmClients() { const config = getConfig(); - const moonbeamExecutorAccount = mnemonicToAccount(config.moonbeamAccountSecret as `0x${string}`); + const evmExecutorAccount = mnemonicToAccount(config.evmAccountSecret); 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); 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); + 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/biome.json b/biome.json index edb8a852f..39d9d6c77 100644 --- a/biome.json +++ b/biome.json @@ -19,6 +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,15 +121,9 @@ } } }, - "overrides": [ - { - "linter": { - "rules": { - "suspicious": { - "noExplicitAny": "off" - } - } - } - } - ] + "vcs": { + "clientKind": "git", + "enabled": true, + "useIgnoreFile": true + } } 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/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; } 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 32c1c2b8f..3f86cd18c 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", @@ -1263,7 +1267,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..73abfa4df --- /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 or empty region code (e.g. `?kybLocked=ZZ`, `?kybLocked`) | Degrades gracefully to the open selector; the region is **not** treated as locked. | + +Query keys are case-sensitive: use `kyb` and `kybLocked` exactly. 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/03-ramp-engine/discount-mechanism.md b/docs/security-spec/03-ramp-engine/discount-mechanism.md index 95125c150..9ba604935 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. @@ -36,8 +36,10 @@ 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.** 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, 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 @@ -63,6 +65,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** β€” 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`. 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 diff --git a/docs/security-spec/05-integrations/brla.md b/docs/security-spec/05-integrations/brla.md index 7b0d3622e..1ff4fb264 100644 --- a/docs/security-spec/05-integrations/brla.md +++ b/docs/security-spec/05-integrations/brla.md @@ -42,7 +42,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) @@ -65,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). @@ -84,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. | @@ -102,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/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`. diff --git a/docs/security-spec/07-operations/rebalancer.md b/docs/security-spec/07-operations/rebalancer.md index ec6e5a955..60eb00431 100644 --- a/docs/security-spec/07-operations/rebalancer.md +++ b/docs/security-spec/07-operations/rebalancer.md @@ -2,18 +2,51 @@ ## 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 indicate a pool imbalance. 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. +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: + +1. **BRLA ↔ axlUSDC (legacy, Pendulum)** β€” 8-step cross-chain process on Pendulum/Moonbeam/Polygon. Activated via `--legacy` flag. +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: 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 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 +- `utils/transactions.ts` β€” Transaction confirmation helpers + +**CLI interface:** +``` +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=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 `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. +- `REBALANCING_OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS` β€” maximum projected route cost for in-range opportunistic USDC β†’ BRLA β†’ USDC execution (default 10 bps). -**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 +56,197 @@ 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 and Polygon). These are **distinct from the API service keys** β€” the rebalancer operates its own accounts. + +--- + +### 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 `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) 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. + +**Rebalancing flow:** +1. Check initial USDC balance on Base (sufficient for requested amount) +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 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 +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) + - 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) + +**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 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. + +--- + +### 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. This makes the flow eligible for evaluation; cost policy may still skip fresh execution. + +**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. + +**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 -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** β€” 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. 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. +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 + +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 + +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 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. +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. 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. **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 +### 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. `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. | + +### 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 | +|---|---| +| **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 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. | +| **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 `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. | +| **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. 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 +### 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 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. -- [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 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 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. +- [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 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`. +- [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. 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. +- [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 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/07-operations/secret-management.md b/docs/security-spec/07-operations/secret-management.md index f8047b40b..ded1e1e03 100644 --- a/docs/security-spec/07-operations/secret-management.md +++ b/docs/security-spec/07-operations/secret-management.md @@ -35,9 +35,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 @@ -51,7 +50,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. 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 e1d2be517..6fdc3d07d 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 | @@ -43,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 | +| 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 | @@ -72,9 +73,12 @@ 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 | | **Request ID** | Non-secret correlation identifier generated or propagated by the API for log/event debugging | | **Client event** | Sanitized operational record of a partner-facing API request outcome | 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", 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..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, @@ -31,7 +32,7 @@ export class BrlHandler implements RampHandler { substrateEphemeral?: EphemeralAccount; evmEphemeral?: EphemeralAccount; } - ) => Promise; + ) => Promise; constructor( apiService: ApiService, @@ -46,7 +47,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; 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; } 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..45db37891 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?: RampCurrency; } export interface GetRampErrorLogsRequest { 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/brla/brlaApiService.ts b/packages/shared/src/services/brla/brlaApiService.ts index 6628d3a45..ef8837170 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 166fcad7c..0a71b864a 100644 --- a/packages/shared/src/services/brla/types.ts +++ b/packages/shared/src/services/brla/types.ts @@ -98,13 +98,15 @@ export interface OnchainSwapQuoteParams { inputCurrency: BrlaCurrency; inputAmount: string; outputCurrency: BrlaCurrency; + outputPaymentMethod?: AveniaPaymentMethod; } export enum AveniaTicketStatus { ON_HOLD = "ON-HOLD", PENDING = "PENDING", PAID = "PAID", - FAILED = "FAILED" + FAILED = "FAILED", + PARTIAL_FAILED = "PARTIAL-FAILED" } // /account/tickets endpoint related types 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 }); } 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() };