diff --git a/apps/api/.env.example b/apps/api/.env.example index c01fc3697..8c997b532 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -64,3 +64,8 @@ DELTA_D_BASIS_POINTS=0.3 # RSA Keys for Webhook Signing # Only the private key is needed - public key is derived from it WEBHOOK_PRIVATE_KEY=your-webhook-private-key + +# AlfredPay (Penny) API +ALFREDPAY_BASE_URL=https://penny-api-restricted-dev.alfredpay.io +ALFREDPAY_API_KEY=your-alfredpay-api-key +ALFREDPAY_API_SECRET=your-alfredpay-api-secret \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index 82844fc53..43e2381dd 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -2,7 +2,7 @@ "author": "Pendulum Chain", "dependencies": { "@galacticcouncil/api-augment": "^0.8.1", - "@galacticcouncil/sdk": "^9.16.0", + "@galacticcouncil/sdk": "^10.6.2", "@paraspell/sdk-pjs": "^11.8.4", "@pendulum-chain/api-solang": "catalog:", "@polkadot/api": "catalog:", diff --git a/apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts b/apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts index 01ebc433f..9da563920 100644 --- a/apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts +++ b/apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts @@ -10,9 +10,9 @@ import { generateApiKey, getKeyPrefix, hashApiKey } from "../../middlewares/apiK * Create a new API key pair (public + secret) for a partner * POST /v1/admin/partners/:partnerName/api-keys */ -export async function createApiKey(req: Request, res: Response): Promise { +export async function createApiKey(req: Request<{ partnerName: string }>, res: Response): Promise { try { - const { partnerName } = req.params as { partnerName: string }; + const partnerName = req.params.partnerName; const { name, expiresAt } = req.body; // Verify at least one partner with this name exists and is active @@ -110,9 +110,9 @@ export async function createApiKey(req: Request, res: Response): Promise { * List all API keys for a partner (by name) * GET /v1/admin/partners/:partnerName/api-keys */ -export async function listApiKeys(req: Request, res: Response): Promise { +export async function listApiKeys(req: Request<{ partnerName: string }>, res: Response): Promise { try { - const { partnerName } = req.params as { partnerName: string }; + const partnerName = req.params.partnerName; // Verify partner exists const partners = await Partner.findAll({ @@ -180,7 +180,7 @@ export async function listApiKeys(req: Request, res: Response): Promise { * Revoke (soft delete) an API key * DELETE /v1/admin/partners/:partnerName/api-keys/:keyId */ -export async function revokeApiKey(req: Request, res: Response): Promise { +export async function revokeApiKey(req: Request<{ partnerName: string; keyId: string }>, res: Response): Promise { try { const { partnerName, keyId } = req.params; diff --git a/apps/api/src/api/controllers/alfredpay.controller.ts b/apps/api/src/api/controllers/alfredpay.controller.ts index 882080c96..d6270e087 100644 --- a/apps/api/src/api/controllers/alfredpay.controller.ts +++ b/apps/api/src/api/controllers/alfredpay.controller.ts @@ -1,9 +1,13 @@ import { AlfredPayCountry, AlfredPayStatus, + AlfredpayAddFiatAccountRequest, AlfredpayApiService, AlfredpayCreateCustomerRequest, AlfredpayCreateCustomerResponse, + AlfredpayDeleteFiatAccountRequest, + AlfredpayFiatAccountRequirementsRequest, + AlfredpayFiatAccountType, AlfredpayGetKycRedirectLinkRequest, AlfredpayGetKycRedirectLinkResponse, AlfredpayGetKycStatusRequest, @@ -11,6 +15,7 @@ import { AlfredpayKycRedirectFinishedRequest, AlfredpayKycRedirectOpenedRequest, AlfredpayKycStatus, + AlfredpayListFiatAccountsRequest, AlfredpayStatusRequest, AlfredpayStatusResponse } from "@vortexfi/shared"; @@ -23,6 +28,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 alfredPayCustomer = await AlfredPayCustomer.findOne({ @@ -127,6 +133,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 alfredPayCustomer = await AlfredPayCustomer.findOne({ @@ -213,6 +220,7 @@ export class AlfredpayController { static async getKycStatus(req: Request, res: Response) { try { const { country } = req.query as unknown as AlfredpayGetKycStatusRequest; + const userId = req.userId!; const alfredPayCustomer = await AlfredPayCustomer.findOne({ @@ -267,6 +275,133 @@ export class AlfredpayController { } } + static async listFiatAccounts(req: Request, res: Response) { + try { + const { country } = req.query as unknown as AlfredpayListFiatAccountsRequest; + const userId = req.userId!; + + const alfredPayCustomer = await AlfredPayCustomer.findOne({ + order: [["updatedAt", "DESC"]], + where: { country: country as AlfredPayCountry, userId } + }); + + if (!alfredPayCustomer) { + return res.status(404).json({ error: "Alfredpay customer not found" }); + } + + if (alfredPayCustomer.status !== AlfredPayStatus.Success) { + return res.status(403).json({ error: "KYC verification must be completed before managing payment methods" }); + } + + const alfredpayService = AlfredpayApiService.getInstance(); + const accounts = await alfredpayService.listFiatAccounts(alfredPayCustomer.alfredPayId); + + res.json(accounts); + } catch (error) { + logger.error("Error listing fiat accounts:", error); + res.status(500).json({ error: "Internal server error" }); + } + } + + static async addFiatAccount(req: Request, res: Response) { + try { + const { + country, + type, + accountNumber, + accountType, + accountName, + accountBankCode, + accountAlias, + networkIdentifier, + routingNumber, + bankStreet, + bankCity, + bankState, + bankCountry, + bankPostalCode + } = req.body as AlfredpayAddFiatAccountRequest; + const userId = req.userId!; + + const alfredPayCustomer = await AlfredPayCustomer.findOne({ + order: [["updatedAt", "DESC"]], + where: { country: country as AlfredPayCountry, userId } + }); + + if (!alfredPayCustomer) { + return res.status(404).json({ error: "Alfredpay customer not found" }); + } + + if (alfredPayCustomer.status !== AlfredPayStatus.Success) { + return res.status(403).json({ error: "KYC verification must be completed before adding payment methods" }); + } + + const alfredpayService = AlfredpayApiService.getInstance(); + + // First-party only: do NOT pass any third-party beneficiary fields + const result = await alfredpayService.createFiatAccount(alfredPayCustomer.alfredPayId, type as AlfredpayFiatAccountType, { + accountAlias: accountAlias ?? "", + accountBankCode, + accountName, + accountNumber, + accountType, + bankCity, + bankCountry, + bankPostalCode, + bankState, + bankStreet, + networkIdentifier: networkIdentifier ?? "", + routingNumber + }); + + res.json(result); + } catch (error) { + logger.error("Error adding fiat account:", error); + res.status(500).json({ error: "Internal server error" }); + } + } + + static async deleteFiatAccount(req: Request, res: Response) { + try { + const fiatAccountId = req.params.fiatAccountId as string; + const { country } = req.query as unknown as AlfredpayDeleteFiatAccountRequest; + const userId = req.userId!; + + const alfredPayCustomer = await AlfredPayCustomer.findOne({ + order: [["updatedAt", "DESC"]], + where: { country: country as AlfredPayCountry, userId } + }); + + if (!alfredPayCustomer) { + return res.status(404).json({ error: "Alfredpay customer not found" }); + } + + const alfredpayService = AlfredpayApiService.getInstance(); + await alfredpayService.deleteFiatAccount(alfredPayCustomer.alfredPayId, fiatAccountId); + + res.status(204).send(); + } catch (error) { + logger.error("Error deleting fiat account:", error); + res.status(500).json({ error: "Internal server error" }); + } + } + + static async getFiatAccountRequirements(req: Request, res: Response) { + try { + const { country, paymentMethod } = req.query as unknown as AlfredpayFiatAccountRequirementsRequest; + const alfredpayService = AlfredpayApiService.getInstance(); + + // TODO: verify exact query parameter names against AlfredPay sandbox before going live + const requirements = await alfredpayService.getFiatAccountRequirements(country, paymentMethod); + + res.json(requirements); + } catch (error) { + logger.error("Error fetching fiat account requirements:", error); + // Return empty array so the frontend can fall back to static forms + res.json([]); + } + } + private static mapKycStatus(status: AlfredpayKycStatus): AlfredPayStatus | null { switch (status) { case AlfredpayKycStatus.IN_REVIEW: diff --git a/apps/api/src/api/controllers/contact.controller.ts b/apps/api/src/api/controllers/contact.controller.ts new file mode 100644 index 000000000..083b369dc --- /dev/null +++ b/apps/api/src/api/controllers/contact.controller.ts @@ -0,0 +1,32 @@ +import type { SubmitContactErrorResponse, SubmitContactResponse } from "@vortexfi/shared"; +import type { Request, Response } from "express"; +import { config } from "../../config"; +import { storeDataInGoogleSpreadsheet } from "./googleSpreadSheet.controller"; + +enum ContactSheetHeaders { + Timestamp = "timestamp", + FullName = "fullName", + Email = "email", + ProjectName = "projectName", + Inquiry = "inquiry" +} + +const CONTACT_SHEET_HEADER_VALUES = [ + ContactSheetHeaders.Timestamp, + ContactSheetHeaders.FullName, + ContactSheetHeaders.Email, + ContactSheetHeaders.ProjectName, + ContactSheetHeaders.Inquiry +]; + +export { CONTACT_SHEET_HEADER_VALUES }; + +export const submitContact = async ( + req: Request, + res: Response +): Promise => { + if (!config.spreadsheet.contactSheetId) { + throw new Error("Contact sheet ID is not configured"); + } + await storeDataInGoogleSpreadsheet(req, res, config.spreadsheet.contactSheetId, CONTACT_SHEET_HEADER_VALUES); +}; diff --git a/apps/api/src/api/controllers/maintenance.controller.ts b/apps/api/src/api/controllers/maintenance.controller.ts index cca2b3fec..a1ca17a27 100644 --- a/apps/api/src/api/controllers/maintenance.controller.ts +++ b/apps/api/src/api/controllers/maintenance.controller.ts @@ -61,9 +61,9 @@ export const getAllMaintenanceSchedules: RequestHandler = async (_, res) => { * @returns {Object} 404 - Schedule not found * @returns {Object} 500 - Internal server error */ -export const updateScheduleActiveStatus: RequestHandler = async (req, res) => { +export const updateScheduleActiveStatus: RequestHandler<{ id: string }> = async (req, res) => { try { - const { id } = req.params as { id: string }; + const id = req.params.id; const { isActive } = req.body; if (typeof isActive !== "boolean") { diff --git a/apps/api/src/api/controllers/metrics.controller.ts b/apps/api/src/api/controllers/metrics.controller.ts index e7c972fd0..76a7ce591 100644 --- a/apps/api/src/api/controllers/metrics.controller.ts +++ b/apps/api/src/api/controllers/metrics.controller.ts @@ -62,7 +62,7 @@ const zeroVolume = (key: string, keyName: "day" | "month"): any => ({ }); async function getMonthlyVolumes(): Promise { - const cacheKey = `monthly`; + const cacheKey = "monthly"; const cached = cache.get(cacheKey); if (cached) return cached; diff --git a/apps/api/src/api/middlewares/validators.ts b/apps/api/src/api/middlewares/validators.ts index 193af3f08..cecf9178e 100644 --- a/apps/api/src/api/middlewares/validators.ts +++ b/apps/api/src/api/middlewares/validators.ts @@ -20,6 +20,7 @@ import { } from "@vortexfi/shared"; import { RequestHandler } from "express"; import httpStatus from "http-status"; +import { CONTACT_SHEET_HEADER_VALUES } from "../controllers/contact.controller"; import { EMAIL_SHEET_HEADER_VALUES } from "../controllers/email.controller"; import { RATING_SHEET_HEADER_VALUES } from "../controllers/rating.controller"; import { FLOW_HEADERS } from "../controllers/storage.controller"; @@ -261,6 +262,7 @@ const validateRequestBodyValues = }; export const validateStorageInput = validateRequestBodyValuesForTransactionStore(); +export const validateContactInput = validateRequestBodyValues(CONTACT_SHEET_HEADER_VALUES); export const validateEmailInput = validateRequestBodyValues(EMAIL_SHEET_HEADER_VALUES); export const validateRatingInput = validateRequestBodyValues(RATING_SHEET_HEADER_VALUES); export const validateExecuteXCM = validateRequestBodyValues(["id", "payload"]); @@ -377,6 +379,11 @@ export const validateSubaccountCreation: RequestHandler = (req, res, next) => { }; export const validateCreateQuoteInput: RequestHandler = (req, res, next) => { + if (req.body) { + req.body.inputCurrency = normalizeAxlUsdcCurrency(req.body.inputCurrency) as CreateQuoteRequest["inputCurrency"]; + req.body.outputCurrency = normalizeAxlUsdcCurrency(req.body.outputCurrency) as CreateQuoteRequest["outputCurrency"]; + } + const { rampType, from, to, inputAmount, inputCurrency, outputCurrency } = req.body; if (!rampType || !from || !to || !inputAmount || !inputCurrency || !outputCurrency) { @@ -397,6 +404,11 @@ export const validateCreateBestQuoteInput: RequestHandler { + if (req.body) { + req.body.inputCurrency = normalizeAxlUsdcCurrency(req.body.inputCurrency) as CreateQuoteRequest["inputCurrency"]; + req.body.outputCurrency = normalizeAxlUsdcCurrency(req.body.outputCurrency) as CreateQuoteRequest["outputCurrency"]; + } + const { rampType, from, to, inputAmount, inputCurrency, outputCurrency } = req.body; if (!rampType || !inputAmount || !inputCurrency || !outputCurrency) { @@ -421,6 +433,12 @@ export const validateCreateBestQuoteInput: RequestHandler { + if (typeof value !== "string") return value; + + return value.toLowerCase() === "axlusdc" ? "USDC.axl" : value; +}; + export const validateGetWidgetUrlInput: RequestHandler = ( req, res, diff --git a/apps/api/src/api/routes/v1/alfredpay.route.ts b/apps/api/src/api/routes/v1/alfredpay.route.ts index 6febeb96f..49cb24794 100644 --- a/apps/api/src/api/routes/v1/alfredpay.route.ts +++ b/apps/api/src/api/routes/v1/alfredpay.route.ts @@ -11,5 +11,9 @@ router.get("/getKycRedirectLink", requireAuth, validateResultCountry, AlfredpayC router.post("/kycRedirectOpened", requireAuth, validateResultCountry, AlfredpayController.kycRedirectOpened); router.post("/kycRedirectFinished", requireAuth, validateResultCountry, AlfredpayController.kycRedirectFinished); router.get("/getKycStatus", requireAuth, validateResultCountry, AlfredpayController.getKycStatus); +router.get("/fiatAccounts", requireAuth, validateResultCountry, AlfredpayController.listFiatAccounts); +router.post("/fiatAccounts", requireAuth, validateResultCountry, AlfredpayController.addFiatAccount); +router.delete("/fiatAccounts/:fiatAccountId", requireAuth, validateResultCountry, AlfredpayController.deleteFiatAccount); +router.get("/fiatAccountRequirements", requireAuth, AlfredpayController.getFiatAccountRequirements); export default router; diff --git a/apps/api/src/api/routes/v1/contact.route.ts b/apps/api/src/api/routes/v1/contact.route.ts new file mode 100644 index 000000000..e5883b19e --- /dev/null +++ b/apps/api/src/api/routes/v1/contact.route.ts @@ -0,0 +1,9 @@ +import { Router } from "express"; +import * as contactController from "../../controllers/contact.controller"; +import { validateContactInput } from "../../middlewares/validators"; + +const router: Router = Router({ mergeParams: true }); + +router.route("/submit").post(validateContactInput, contactController.submitContact); + +export default router; diff --git a/apps/api/src/api/routes/v1/index.ts b/apps/api/src/api/routes/v1/index.ts index 69ea4c7dc..8774114d1 100644 --- a/apps/api/src/api/routes/v1/index.ts +++ b/apps/api/src/api/routes/v1/index.ts @@ -6,6 +6,7 @@ import partnerApiKeysRoutes from "./admin/partner-api-keys.route"; import alfredpayRoutes from "./alfredpay.route"; import authRoutes from "./auth.route"; import brlaRoutes from "./brla.route"; +import contactRoutes from "./contact.route"; import countriesRoutes from "./countries.route"; import cryptocurrenciesRoutes from "./cryptocurrencies.route"; import emailRoutes from "./email.route"; @@ -87,6 +88,11 @@ router.use("/pendulum", pendulumRoutes); */ router.use("/storage", storageRoutes); +/** + * POST v1/contact + */ +router.use("/contact", contactRoutes); + /** * POST v1/email */ diff --git a/apps/api/src/api/services/hydration/swap.ts b/apps/api/src/api/services/hydration/swap.ts index b39e239d8..fd6cc6854 100644 --- a/apps/api/src/api/services/hydration/swap.ts +++ b/apps/api/src/api/services/hydration/swap.ts @@ -18,7 +18,9 @@ export class HydrationRouter { const apiManager = ApiManager.getInstance(); this.cachedXcmFees = {}; this.sdk = apiManager.getApi("hydration").then(async ({ api }) => { - return createSdkContext(api, { router: { includeOnly: [PoolType.Omni, PoolType.Stable] } }); + return createSdkContext(api, { + router: { includeOnly: [PoolType.Omni, PoolType.Stable, PoolType.Aave] } + }); }); // Refresh transaction fees every hour diff --git a/apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts b/apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts new file mode 100644 index 000000000..bcdcb8726 --- /dev/null +++ b/apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts @@ -0,0 +1,101 @@ +import { + checkEvmBalancePeriodically, + EvmClientManager, + EvmNetworks, + EvmTokenDetails, + FiatToken, + getAnyFiatTokenDetailsMoonbeam, + getOnChainTokenDetails, + isEvmToken, + multiplyByPowerOfTen, + Networks, + RampDirection, + RampPhase +} from "@vortexfi/shared"; +import QuoteTicket from "../../../../models/quoteTicket.model"; +import RampState from "../../../../models/rampState.model"; +import { BasePhaseHandler } from "../base-phase-handler"; + +const BALANCE_POLLING_TIME_MS = 5000; +const EVM_BALANCE_CHECK_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes +/** + * Handler for transferring funds to the destination address on EVM networks (onramp only) + */ +export class DestinationTransferHandler extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "destinationTransfer"; + } + + protected async executePhase(state: RampState): Promise { + const evmClientManager = EvmClientManager.getInstance(); + // Only handle onramp operations + if (state.type !== RampDirection.BUY) { + throw new Error("DestinationTransferHandler: Only supports onramp operations"); + } + + const quote = await QuoteTicket.findByPk(state.quoteId); + if (!quote) { + throw new Error("Quote not found for the given state"); + } + + if (!isEvmToken(quote.outputCurrency)) { + throw new Error("DestinationTransferHandler: Output currency is not an EVM token"); + } + const { txData: destinationTransfer } = this.getPresignedTransaction(state, "destinationTransfer"); + const outTokenDetails = getOnChainTokenDetails(quote.network, quote.outputCurrency) as EvmTokenDetails; + const expectedAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outTokenDetails.decimals).toString(); + const destinationNetwork = quote.network as EvmNetworks; // We can assert this type due to checks before + const { destinationTransferTxHash } = state.state; + if (destinationTransferTxHash) { + try { + const client = evmClientManager.getClient(destinationNetwork); + const receipt = await client.getTransactionReceipt({ hash: destinationTransferTxHash as `0x${string}` }); + + if (receipt.status === "success") { + return this.transitionToNextPhase(state, "complete"); + } else { + throw new Error(`Transaction ${destinationTransferTxHash} failed on chain.`); + } + } catch (error) { + if (error instanceof Error && error.name !== "TransactionReceiptNotFoundError") { + throw error; + } + // If receipt not found, proceed to normal flow + } + } + + // main phase execution loop: + try { + await checkEvmBalancePeriodically( + outTokenDetails.erc20AddressSourceChain, + state.state.evmEphemeralAddress, + expectedAmountRaw, + BALANCE_POLLING_TIME_MS, + EVM_BALANCE_CHECK_TIMEOUT_MS, + destinationNetwork + ); + + // send the transaction, log hash in the state for recovery. + const txHash = await evmClientManager.sendRawTransactionWithRetry( + quote.network as EvmNetworks, + destinationTransfer as `0x${string}` + ); + // store in state + await state.update({ + state: { + ...state.state, + destinationTransferTxHash: txHash + } + }); + // (optional) wait for balance to be updated on user - destination + + return this.transitionToNextPhase(state, "complete"); + } catch (error) { + throw this.createRecoverableError( + `DestinationTransferHandler: Error during phase execution - ${(error as Error).message}` + ); + } + } +} + +export default new DestinationTransferHandler(); diff --git a/apps/api/src/api/services/phases/handlers/distribute-fees-handler.ts b/apps/api/src/api/services/phases/handlers/distribute-fees-handler.ts index ad7fbcd42..706e36714 100644 --- a/apps/api/src/api/services/phases/handlers/distribute-fees-handler.ts +++ b/apps/api/src/api/services/phases/handlers/distribute-fees-handler.ts @@ -64,7 +64,7 @@ export class DistributeFeesHandler extends BasePhaseHandler { logger.info(`Found existing distribute fee hash for ramp ${state.id}: ${existingHash}`); const status = await this.checkExtrinsicStatus(existingHash).catch((_: unknown) => { - throw this.createRecoverableError(`Failed to check extrinsic status`); + throw this.createRecoverableError("Failed to check extrinsic status"); }); if (status === ExtrinsicStatus.Success) { @@ -135,7 +135,7 @@ export class DistributeFeesHandler extends BasePhaseHandler { if (status === ExtrinsicStatus.Success) { return; } else if (status === ExtrinsicStatus.Fail) { - //throw this.createUnrecoverableError(`Extrinsic failed for hash ${extrinsicHash}`); + await new Promise(resolve => setTimeout(resolve, pollIntervalMs)); continue; } else if (status === ExtrinsicStatus.Undefined) { await new Promise(resolve => setTimeout(resolve, pollIntervalMs)); @@ -212,10 +212,14 @@ export class DistributeFeesHandler extends BasePhaseHandler { reject(this.handleDispatchError(api, dispatchError, systemExtrinsicFailedEvent, "distributeFees")); } - if (status.isBroadcast || status.isInBlock) { + if (status.isBroadcast) { logger.info(`Transaction broadcasted: ${status.asBroadcast.toString()}`); resolve(txHash.toHex()); } + if (status.isInBlock) { + logger.info(`Transaction in block: ${status.asInBlock.toString()}`); + resolve(txHash.toHex()); + } }) .catch((error: unknown) => { logger.error("Error submitting transaction to distribute fees:", error); diff --git a/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts b/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts new file mode 100644 index 000000000..83f45bc7e --- /dev/null +++ b/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts @@ -0,0 +1,270 @@ +import { + checkEvmBalancePeriodically, + EvmClientManager, + EvmNetworks, + EvmTokenDetails, + getNetworkId, + getOnChainTokenDetails, + getRoute, + isEvmToken, + multiplyByPowerOfTen, + Networks, + RampCurrency, + RampDirection, + RampPhase +} from "@vortexfi/shared"; +import Big from "big.js"; +import { encodeFunctionData, erc20Abi, TransactionReceipt } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import logger from "../../../../config/logger"; +import { MAX_FINAL_SETTLEMENT_SUBSIDY_USD, MOONBEAM_FUNDING_PRIVATE_KEY } from "../../../../constants/constants"; +import QuoteTicket from "../../../../models/quoteTicket.model"; +import RampState from "../../../../models/rampState.model"; +import { priceFeedService } from "../../priceFeed.service"; +import { BasePhaseHandler } from "../base-phase-handler"; + +const BALANCE_POLLING_TIME_MS = 5000; +const EVM_BALANCE_CHECK_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes +const NATIVE_TOKEN_ADDRESS = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"; + +const NATIVE_TOKENS: Record = { + [Networks.Ethereum]: { decimals: 18, symbol: "ETH" }, + [Networks.Polygon]: { decimals: 18, symbol: "MATIC" }, + [Networks.PolygonAmoy]: { decimals: 18, symbol: "MATIC" }, + [Networks.BSC]: { decimals: 18, symbol: "BNB" }, + [Networks.Arbitrum]: { decimals: 18, symbol: "ETH" }, + [Networks.Base]: { decimals: 18, symbol: "ETH" }, + [Networks.Avalanche]: { decimals: 18, symbol: "AVAX" }, + [Networks.Moonbeam]: { decimals: 18, symbol: "GLMR" } +}; + +/** + * Handler for transferring funds to the destination address on EVM networks (onramp only) + */ +export class FinalSettlementSubsidyHandler extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "finalSettlementSubsidy"; + } + + protected async executePhase(state: RampState): Promise { + const evmClientManager = EvmClientManager.getInstance(); + const fundingAccount = privateKeyToAccount(MOONBEAM_FUNDING_PRIVATE_KEY as `0x${string}`); + + // Only handle onramp operations + if (state.type !== RampDirection.BUY) { + throw new Error("FinalSettlementSubsidyHandler: Only supports onramp operations"); + } + + const quote = await QuoteTicket.findByPk(state.quoteId); + if (!quote) { + throw new Error("Quote not found for the given state"); + } + + if (!isEvmToken(quote.outputCurrency)) { + throw new Error("FinalSettlementSubsidyHandler: Output currency is not an EVM token"); + } + + const outTokenDetails = getOnChainTokenDetails(quote.network, quote.outputCurrency) as EvmTokenDetails; + const expectedAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outTokenDetails.decimals); + const destinationNetwork = quote.network as EvmNetworks; + const publicClient = evmClientManager.getClient(destinationNetwork); + const ephemeralAddress = state.state.evmEphemeralAddress as `0x${string}`; + + // 1. Idempotency Check + if (state.state.finalSettlementSubsidyTxHash) { + const receipt = await publicClient + .getTransactionReceipt({ + hash: state.state.finalSettlementSubsidyTxHash as `0x${string}` + }) + .catch(() => null); + + if (receipt && receipt.status === "success") { + logger.info( + `FinalSettlementSubsidyHandler: Transaction ${state.state.finalSettlementSubsidyTxHash} already successful. Skipping.` + ); + return this.transitionToNextPhase(state, "destinationTransfer"); + } + } + + const actualBalance = await checkEvmBalancePeriodically( + outTokenDetails.erc20AddressSourceChain, + ephemeralAddress, + "1", // If we passed expectedAmountRaw, we might timeout if the bridge slipped and delivered slightly less. + BALANCE_POLLING_TIME_MS, + EVM_BALANCE_CHECK_TIMEOUT_MS, + destinationNetwork + ); + + const actualBalanceFundingAccount = await publicClient.readContract({ + abi: erc20Abi, + address: outTokenDetails.erc20AddressSourceChain as `0x${string}`, + args: [fundingAccount.address], + functionName: "balanceOf" + }); + + const subsidyAmountRaw = expectedAmountRaw.minus(actualBalance); + + if (subsidyAmountRaw.lte(0)) { + logger.info( + `FinalSettlementSubsidyHandler: Actual balance (${actualBalance.toString()}) meets expected amount. No subsidy needed.` + ); + return this.transitionToNextPhase(state, "destinationTransfer"); + } + + logger.info(`FinalSettlementSubsidyHandler: Subsidizing ${subsidyAmountRaw.toString()} units to ${ephemeralAddress}`); + + // Check if funding account has enough balance + if (new Big(actualBalanceFundingAccount.toString()).lt(subsidyAmountRaw)) { + logger.info( + `FinalSettlementSubsidyHandler: Funding account has insufficient balance. Swapping native token to ${outTokenDetails.assetSymbol}` + ); + + const nativeToken = NATIVE_TOKENS[destinationNetwork]; + const oneUsdInNative = await priceFeedService.convertCurrency( + "1", + "USD" as RampCurrency, + nativeToken.symbol as RampCurrency + ); + const oneUsdInNativeRaw = multiplyByPowerOfTen(oneUsdInNative, nativeToken.decimals).toFixed(0); + console.log("values; oneUsdInNativeRaw:", oneUsdInNativeRaw); + + const chainId = getNetworkId(destinationNetwork).toString(); + const testRouteResult = await getRoute({ + bypassGuardrails: true, + enableExpress: true, + fromAddress: fundingAccount.address, + fromAmount: oneUsdInNativeRaw, + fromChain: chainId, + fromToken: NATIVE_TOKEN_ADDRESS, + slippageConfig: { + autoMode: 1 + }, + toAddress: fundingAccount.address, + toChain: chainId, + toToken: outTokenDetails.erc20AddressSourceChain + }); + + const { route: testRoute } = testRouteResult.data; + const rate = new Big(testRoute.estimate.toAmount).div(new Big(oneUsdInNativeRaw)); + const requiredNativeRaw = subsidyAmountRaw.div(rate).mul(1.1).toFixed(0); + + logger.info( + `FinalSettlementSubsidyHandler: Swapping ${requiredNativeRaw} native units (approx. rate ${rate}) to get required subsidy.` + ); + + // Check the amount of native is not higher than cap, cap specidied in units of usd. + const requiredNative = new Big(requiredNativeRaw).div(new Big(10).pow(nativeToken.decimals)); + const requiredNativeInUsd = await priceFeedService.convertCurrency( + requiredNative.toString(), + nativeToken.symbol as RampCurrency, + "USD" as RampCurrency + ); + + if (new Big(requiredNativeInUsd).gt(MAX_FINAL_SETTLEMENT_SUBSIDY_USD)) { + this.createUnrecoverableError( + `FinalSettlementSubsidyHandler: Required subsidy swap amount $${requiredNativeInUsd} exceeds maximum allowed $${MAX_FINAL_SETTLEMENT_SUBSIDY_USD}` + ); + } + + const swapRouteResult = await getRoute({ + bypassGuardrails: true, + enableExpress: true, + fromAddress: fundingAccount.address, + fromAmount: requiredNativeRaw, + fromChain: chainId, + fromToken: NATIVE_TOKEN_ADDRESS, + slippageConfig: { + autoMode: 1 + }, + toAddress: fundingAccount.address, + toChain: chainId, + toToken: outTokenDetails.erc20AddressSourceChain + }); + + const { route: swapRoute } = swapRouteResult.data; + + const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); + const txHashIdx = await evmClientManager.sendTransactionWithBlindRetry(destinationNetwork, fundingAccount, { + data: swapRoute.transactionRequest.data as `0x${string}`, + gas: BigInt(swapRoute.transactionRequest.gasLimit), + maxFeePerGas, + maxPriorityFeePerGas, + to: swapRoute.transactionRequest.target as `0x${string}`, + value: BigInt(swapRoute.transactionRequest.value) + }); + + logger.info(`FinalSettlementSubsidyHandler: Swap transaction sent: ${txHashIdx}. Waiting for receipt...`); + const receipt = await publicClient.waitForTransactionReceipt({ hash: txHashIdx }); + + if (receipt.status !== "success") { + throw new Error(`Swap transaction ${txHashIdx} failed`); + } + + logger.info("FinalSettlementSubsidyHandler: Swap successful. Waiting for balance update..."); + + // Wait for balance checks to pass + await checkEvmBalancePeriodically( + outTokenDetails.erc20AddressSourceChain, + fundingAccount.address, + subsidyAmountRaw.toString(), + BALANCE_POLLING_TIME_MS, + EVM_BALANCE_CHECK_TIMEOUT_MS, + destinationNetwork + ); + } + + // Execution Loop + let txHash: `0x${string}` | undefined = state.state.finalSettlementSubsidyTxHash as `0x${string}` | undefined; + + try { + const data = encodeFunctionData({ + abi: erc20Abi, + args: [ephemeralAddress, BigInt(subsidyAmountRaw.toFixed(0))], + functionName: "transfer" + }); + + const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); + + let receipt: TransactionReceipt | undefined = undefined; + let attempt = 0; + + while (attempt < 5 && (!receipt || receipt.status !== "success")) { + // Blind retry for transaction submission + txHash = await evmClientManager.sendTransactionWithBlindRetry(destinationNetwork, fundingAccount, { + data, + maxFeePerGas, + maxPriorityFeePerGas, + to: outTokenDetails.erc20AddressSourceChain as `0x${string}`, + value: 0n + }); + + receipt = await publicClient.waitForTransactionReceipt({ hash: txHash }); + + if (!receipt || receipt.status !== "success") { + logger.error(`FinalSettlementSubsidyHandler: Transaction ${txHash} failed or was not found. Retrying...`); + attempt++; + await new Promise(resolve => setTimeout(resolve, 20000)); + } + } + + if (!receipt || receipt.status !== "success") { + throw new Error(`Failed to confirm subsidy transaction after ${attempt} attempts`); + } + + await state.update({ + state: { + ...state.state, + finalSettlementSubsidyTxHash: txHash + } + }); + + return this.transitionToNextPhase(state, "destinationTransfer"); + } catch (error) { + throw this.createRecoverableError( + `FinalSettlementSubsidyHandler: Error during phase execution - ${(error as Error).message}` + ); + } + } +} + +export default new FinalSettlementSubsidyHandler(); diff --git a/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts b/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts index 6944b5210..923388076 100644 --- a/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts +++ b/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts @@ -1,8 +1,10 @@ import { ApiManager, EvmClientManager, + EvmNetworks, FiatToken, getNetworkFromDestination, + isNetworkEVM, Networks, RampDirection, RampPhase @@ -12,6 +14,7 @@ import { privateKeyToAccount } from "viem/accounts"; import { polygon } from "viem/chains"; import logger from "../../../../config/logger"; import { MOONBEAM_FUNDING_PRIVATE_KEY, POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS } from "../../../../constants/constants"; + import QuoteTicket from "../../../../models/quoteTicket.model"; import RampState from "../../../../models/rampState.model"; import { UnrecoverablePhaseError } from "../../../errors/phase-error"; @@ -23,6 +26,7 @@ import { validateStellarPaymentSequenceNumber } from "../helpers/stellar-sequenc import { StateMetadata } from "../meta-state-types"; import { horizonServer, + isDestinationEvmEphemeralFunded, isMoonbeamEphemeralFunded, isPendulumEphemeralFunded, isPolygonEphemeralFunded, @@ -44,6 +48,17 @@ function isOnramp(state: RampState): boolean { return state.type === RampDirection.BUY; } +const DESTINATION_EVM_FUNDING_AMOUNTS: Record = { + [Networks.Ethereum]: "0.00016", // ~0.5 USD @ 3000 + [Networks.Arbitrum]: "0.000045", // ~0.1 USD @ 2300 + [Networks.Base]: "0.000034", // ~0.1 USD @ 3000 + [Networks.Polygon]: "0.6", // ~0.06 USD @ 0.13 + [Networks.BSC]: "0.000115", // ~0.1 USD @ 889 + [Networks.Avalanche]: "0.0034", // ~0.1 USD @ 30 + [Networks.Moonbeam]: "0.34", // ~0.1 USD @ 0.30 + [Networks.PolygonAmoy]: "0.2" // ~0.1 USD @ 0.50 +}; + export class FundEphemeralPhaseHandler extends BasePhaseHandler { public getPhaseName(): RampPhase { return "fundEphemeral"; @@ -73,6 +88,17 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { return false; } + protected getRequiresDestinationEvmFunding(state: RampState): boolean { + // Required for onramps where the destination is an EVM network (not AssetHub) + if (isOnramp(state) && state.to !== Networks.AssetHub) { + const destinationNetwork = getNetworkFromDestination(state.to); + if (destinationNetwork && isNetworkEVM(destinationNetwork)) { + return true; + } + } + return false; + } + protected async executePhase(state: RampState): Promise { const quote = await QuoteTicket.findByPk(state.quoteId); if (!quote) { @@ -87,6 +113,7 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { const requiresPendulumEphemeralAddress = this.getRequiresPendulumEphemeralAddress(state, quote.inputCurrency); const requiresPolygonEphemeralAddress = this.getRequiresPolygonEphemeralAddress(state, quote.inputCurrency); const requiresMoonbeamEphemeralAddress = this.getRequiresMoonbeamEphemeralAddress(state, quote.inputCurrency); + const requiresDestinationEvmFunding = this.getRequiresDestinationEvmFunding(state); // Ephemeral checks. if (!substrateEphemeralAddress && requiresPendulumEphemeralAddress) { @@ -110,6 +137,12 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { const isPolygonFunded = requiresPolygonEphemeralAddress ? await isPolygonEphemeralFunded(evmEphemeralAddress) : true; + const destinationNetwork = getNetworkFromDestination(state.to); + const isDestinationEvmFunded = + requiresDestinationEvmFunding && destinationNetwork && isNetworkEVM(destinationNetwork) // for type safety + ? await isDestinationEvmEphemeralFunded(evmEphemeralAddress, destinationNetwork) + : true; + if (state.state.stellarTarget) { const isFunded = await isStellarEphemeralFunded( state.state.stellarEphemeralAccountId, @@ -129,7 +162,7 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { } else { await fundEphemeralAccount("pendulum", substrateEphemeralAddress, false); } - } else { + } else if (requiresPendulumEphemeralAddress) { logger.info("Pendulum ephemeral address already funded."); } @@ -148,9 +181,16 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { if (isOnramp(state) && !isPolygonFunded) { logger.info(`Funding polygon ephemeral account ${evmEphemeralAddress}`); await this.fundPolygonEphemeralAccount(state); - } else { + } else if (requiresPolygonEphemeralAddress) { logger.info("Polygon ephemeral address already funded."); } + + if (isOnramp(state) && !isDestinationEvmFunded && destinationNetwork && isNetworkEVM(destinationNetwork)) { + logger.info(`Funding destination EVM ephemeral account ${evmEphemeralAddress} on ${destinationNetwork}`); + await this.fundDestinationEvmEphemeralAccount(state, destinationNetwork); + } else if (requiresDestinationEvmFunding) { + logger.info(`Destination EVM ephemeral address already funded on ${destinationNetwork}.`); + } } catch (e) { console.error("Error in FundEphemeralPhaseHandler:", e); @@ -277,6 +317,41 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { throw new Error("FundEphemeralPhaseHandler: Error during funding Polygon ephemeral: " + error); } } + + protected async fundDestinationEvmEphemeralAccount(state: RampState, destinationNetwork: EvmNetworks): Promise { + try { + const evmClientManager = EvmClientManager.getInstance(); + const destinationClient = evmClientManager.getClient(destinationNetwork); + const chain = destinationClient.chain; + + if (!chain) { + throw new Error(`FundEphemeralPhaseHandler: Could not get chain info for ${destinationNetwork}`); + } + + const ephemeralAddress = state.state.evmEphemeralAddress; + const fundingAmountUnits = DESTINATION_EVM_FUNDING_AMOUNTS[destinationNetwork]; + const fundingAmountRaw = multiplyByPowerOfTen(fundingAmountUnits, chain.nativeCurrency.decimals).toFixed(); + + const fundingAccount = privateKeyToAccount(MOONBEAM_FUNDING_PRIVATE_KEY as `0x${string}`); + const walletClient = evmClientManager.getWalletClient(destinationNetwork, fundingAccount); + + const txHash = await walletClient.sendTransaction({ + to: ephemeralAddress as `0x${string}`, + value: BigInt(fundingAmountRaw) + }); + + const receipt = await destinationClient.waitForTransactionReceipt({ + hash: txHash as `0x${string}` + }); + + if (!receipt || receipt.status !== "success") { + throw new Error(`FundEphemeralPhaseHandler: Transaction ${txHash} failed or was not found on ${destinationNetwork}`); + } + } catch (error) { + console.error(`FundEphemeralPhaseHandler: Error during funding ${destinationNetwork} ephemeral:`, error); + throw new Error(`FundEphemeralPhaseHandler: Error during funding ${destinationNetwork} ephemeral: ` + error); + } + } } export default new FundEphemeralPhaseHandler(); diff --git a/apps/api/src/api/services/phases/handlers/helpers.ts b/apps/api/src/api/services/phases/handlers/helpers.ts index b0cfe753b..e3578e43f 100644 --- a/apps/api/src/api/services/phases/handlers/helpers.ts +++ b/apps/api/src/api/services/phases/handlers/helpers.ts @@ -1,4 +1,11 @@ -import { API, EvmClientManager, HORIZON_URL, StellarTokenDetails, Networks as VortexNetworks } from "@vortexfi/shared"; +import { + API, + EvmClientManager, + EvmNetworks, + HORIZON_URL, + StellarTokenDetails, + Networks as VortexNetworks +} from "@vortexfi/shared"; import Big from "big.js"; import { Horizon, Networks } from "stellar-sdk"; import { polygon } from "viem/chains"; @@ -65,3 +72,25 @@ export async function isPolygonEphemeralFunded(polygonEphemeralAddress: string): return Big(balance.toString()).gte(fundingAmountRaw); } + +export async function isDestinationEvmEphemeralFunded( + evmEphemeralAddress: string, + destinationNetwork: EvmNetworks +): Promise { + const evmClientManager = EvmClientManager.getInstance(); + const destinationClient = evmClientManager.getClient(destinationNetwork); + const chain = destinationClient.chain; + + if (!chain) { + return false; + } + + const balance = await destinationClient.getBalance({ + address: evmEphemeralAddress as `0x${string}` + }); + const fundingAmountRaw = new Big( + multiplyByPowerOfTen(POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS, chain.nativeCurrency.decimals).toFixed() + ); + + return Big(balance.toString()).gte(fundingAmountRaw); +} diff --git a/apps/api/src/api/services/phases/handlers/moonbeam-to-pendulum-handler.ts b/apps/api/src/api/services/phases/handlers/moonbeam-to-pendulum-handler.ts index 95cc09c78..0b1f97b6d 100644 --- a/apps/api/src/api/services/phases/handlers/moonbeam-to-pendulum-handler.ts +++ b/apps/api/src/api/services/phases/handlers/moonbeam-to-pendulum-handler.ts @@ -16,6 +16,7 @@ import logger from "../../../../config/logger"; import { MOONBEAM_EXECUTOR_PRIVATE_KEY, MOONBEAM_RECEIVER_CONTRACT_ADDRESS } from "../../../../constants/constants"; import QuoteTicket from "../../../../models/quoteTicket.model"; import RampState from "../../../../models/rampState.model"; +import { RecoverablePhaseError } from "../../../errors/phase-error"; import { BasePhaseHandler } from "../base-phase-handler"; import { StateMetadata } from "../meta-state-types"; @@ -30,13 +31,15 @@ export class MoonbeamToPendulumPhaseHandler extends BasePhaseHandler { throw new Error("Quote not found for the given state"); } + const evmClientManager = EvmClientManager.getInstance(); + const apiManager = ApiManager.getInstance(); const pendulumNode = await apiManager.getApi("pendulum"); const { substrateEphemeralAddress, moonbeamXcmTransactionHash, squidRouterReceiverId, squidRouterReceiverHash } = state.state as StateMetadata; - if (!substrateEphemeralAddress || !squidRouterReceiverId || !squidRouterReceiverId || !squidRouterReceiverHash) { + if (!substrateEphemeralAddress || !squidRouterReceiverId || !squidRouterReceiverHash) { throw new Error("MoonbeamToPendulumPhaseHandler: State metadata corrupted. This is a bug."); } @@ -59,11 +62,10 @@ export class MoonbeamToPendulumPhaseHandler extends BasePhaseHandler { }; const moonbeamExecutorAccount = privateKeyToAccount(MOONBEAM_EXECUTOR_PRIVATE_KEY as `0x${string}`); - const evmClientManager = EvmClientManager.getInstance(); const publicClient = evmClientManager.getClient(Networks.Moonbeam); const isHashRegisteredInSplitReceiver = async () => { - const result = await evmClientManager.readContractWithRetry(Networks.Moonbeam, { + const result = await publicClient.readContract({ abi: splitReceiverABI, address: MOONBEAM_RECEIVER_CONTRACT_ADDRESS, args: [squidRouterReceiverHash], @@ -80,7 +82,10 @@ export class MoonbeamToPendulumPhaseHandler extends BasePhaseHandler { } } catch (e) { logger.error(e); - throw new Error("MoonbeamToPendulumPhaseHandler: Failed to wait for hash registration in split receiver."); + throw new RecoverablePhaseError( + "MoonbeamToPendulumPhaseHandler: Failed to wait for hash registration in split receiver.", + 30 + ); } let obtainedHash: `0x${string}` | undefined = moonbeamXcmTransactionHash; @@ -129,14 +134,14 @@ export class MoonbeamToPendulumPhaseHandler extends BasePhaseHandler { } } catch (e) { console.error("Error while executing moonbeam split contract transaction:", e); - throw new Error("MoonbeamToPendulumPhaseHandler: Failed to send XCM transaction"); + throw new RecoverablePhaseError("MoonbeamToPendulumPhaseHandler: Failed to send XCM transaction", 30); } try { await waitUntilTrue(didInputTokenArriveOnPendulum, 5000); } catch (e) { console.error("Error while waiting for transaction receipt:", e); - throw new Error("MoonbeamToPendulumPhaseHandler: Failed to wait for tokens to arrive on Pendulum."); + throw new RecoverablePhaseError("MoonbeamToPendulumPhaseHandler: Failed to wait for tokens to arrive on Pendulum.", 30); } return this.transitionToNextPhase(state, this.nextPhaseSelector(state)); diff --git a/apps/api/src/api/services/phases/handlers/pendulum-to-moonbeam-xcm-handler.ts b/apps/api/src/api/services/phases/handlers/pendulum-to-moonbeam-xcm-handler.ts index 9d1832268..1d48c4b0f 100644 --- a/apps/api/src/api/services/phases/handlers/pendulum-to-moonbeam-xcm-handler.ts +++ b/apps/api/src/api/services/phases/handlers/pendulum-to-moonbeam-xcm-handler.ts @@ -85,16 +85,57 @@ export class PendulumToMoonbeamXCMPhaseHandler extends BasePhaseHandler { return balance.gte(expectedOutputAmountRaw); }; + const waitForMoonbeamArrival = async (timeoutMs = 120000): Promise => { + const startTime = Date.now(); + const pollIntervalMs = 5000; + + while (Date.now() - startTime < timeoutMs) { + if (await didTokensArriveOnMoonbeam()) { + return true; + } + await new Promise(resolve => setTimeout(resolve, pollIntervalMs)); + } + return false; + }; + try { - // We have to check if the input token already arrived on Moonbeam and if it left Pendulum. - // If we'd only check if it arrived on Moonbeam, we might miss transferring them if the target account already has some tokens. - if ((await didTokensLeavePendulum()) && (await didTokensArriveOnMoonbeam())) { + // Check if we already have a stored XCM hash (XCM was submitted in a previous attempt) + if (state.state.pendulumToMoonbeamXcmHash) { + logger.info( + `PendulumToMoonbeamPhaseHandler: XCM already submitted (hash: ${state.state.pendulumToMoonbeamXcmHash}) for ramp ${state.id}. Waiting for arrival on Moonbeam...` + ); + + if (await didTokensArriveOnMoonbeam()) { + logger.info(`PendulumToMoonbeamPhaseHandler: Tokens already arrived on Moonbeam for ramp ${state.id}.`); + return this.transitionToNextPhase(state, this.nextPhaseSelector(state)); + } + + const arrived = await waitForMoonbeamArrival(); + if (!arrived) { + throw this.createRecoverableError("Timeout waiting for tokens to arrive on Moonbeam after XCM was already submitted"); + } + return this.transitionToNextPhase(state, this.nextPhaseSelector(state)); + } + + // Check if tokens already left Pendulum (XCM was submitted but hash wasn't stored due to crash) + if (await didTokensLeavePendulum()) { logger.info( - `PendulumToMoonbeamPhaseHandler: Input token already arrived on Moonbeam, skipping XCM transfer for ramp ${state.id}.` + `PendulumToMoonbeamPhaseHandler: Tokens already left Pendulum for ramp ${state.id}. XCM likely submitted but hash not stored. Waiting for arrival on Moonbeam...` ); + + if (await didTokensArriveOnMoonbeam()) { + logger.info(`PendulumToMoonbeamPhaseHandler: Tokens already arrived on Moonbeam for ramp ${state.id}.`); + return this.transitionToNextPhase(state, this.nextPhaseSelector(state)); + } + + const arrived = await waitForMoonbeamArrival(); + if (!arrived) { + throw this.createRecoverableError("Timeout waiting for tokens to arrive on Moonbeam after tokens left Pendulum"); + } return this.transitionToNextPhase(state, this.nextPhaseSelector(state)); } + // No previous XCM submission detected, proceed with transfer const { txData: pendulumToMoonbeamTransaction } = this.getPresignedTransaction(state, "pendulumToMoonbeamXcm"); if (typeof pendulumToMoonbeamTransaction !== "string") { @@ -111,19 +152,24 @@ export class PendulumToMoonbeamXCMPhaseHandler extends BasePhaseHandler { logger.info( `PendulumToMoonbeamPhaseHandler: XCM transfer submitted with hash ${hash} for ramp ${state.id}. Waiting for the token to arrive on Moonbeam...` ); - await didTokensArriveOnMoonbeam(); - - // XCM is payed by the ephemeral, in GLMR, with a fixed value of MOONBEAM_XCM_FEE_GLMR - const subsidyAmount = nativeToDecimal(MOONBEAM_XCM_FEE_GLMR, 18).toNumber(); - const hashToStore = hash ?? "0x"; - await this.createSubsidy(state, subsidyAmount, SubsidyToken.GLMR, substrateEphemeralAddress, hashToStore); + // Store the hash immediately after submission to minimize crash window state.state = { ...state.state, pendulumToMoonbeamXcmHash: hash }; await state.update({ state: state.state }); + const arrived = await waitForMoonbeamArrival(); + if (!arrived) { + throw this.createRecoverableError("Timeout waiting for tokens to arrive on Moonbeam after XCM submission"); + } + + // XCM is payed by the ephemeral, in GLMR, with a fixed value of MOONBEAM_XCM_FEE_GLMR + const subsidyAmount = nativeToDecimal(MOONBEAM_XCM_FEE_GLMR, 18).toNumber(); + const hashToStore = hash ?? "0x"; + await this.createSubsidy(state, subsidyAmount, SubsidyToken.GLMR, substrateEphemeralAddress, hashToStore); + return this.transitionToNextPhase(state, this.nextPhaseSelector(state)); } catch (e) { console.error("Error in PendulumToMoonbeamPhase:", e); diff --git a/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts b/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts index db44262f0..41aec032b 100644 --- a/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts +++ b/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts @@ -1,12 +1,19 @@ import { AxelarScanStatusFees, + BalanceCheckError, + BalanceCheckErrorType, + checkEvmBalancePeriodically, EvmClientManager, + EvmNetworks, + EvmTokenDetails, FiatToken, getNetworkId, + getOnChainTokenDetails, getStatus, getStatusAxelarScan, Networks, nativeToDecimal, + OnChainToken, RampDirection, RampPhase, SquidRouterPayResponse @@ -27,6 +34,13 @@ import { BasePhaseHandler } from "../base-phase-handler"; const AXELAR_POLLING_INTERVAL_MS = 10000; // 10 seconds const SQUIDROUTER_INITIAL_DELAY_MS = 60000; // 60 seconds const AXL_GAS_SERVICE_EVM = "0x2d5d7d31F671F86C782533cc367F14109a082712"; +const BALANCE_POLLING_TIME_MS = 10000; +// NOTE: This timeout is intentionally longer (15 minutes) than the 3–5 minute balance +// checks in other handlers. For SquidRouter/Axelar bridge flows we wait for cross-chain +// settlement and gas payment on the destination chain, which can legitimately take longer +// under network congestion or bridge delays. Reducing this timeout risks premature failure +// of otherwise successful bridge operations. +const EVM_BALANCE_CHECK_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes const DEFAULT_SQUIDROUTER_GAS_ESTIMATE = "1600000"; // Estimate used to calculate part of the gas fee for SquidRouter transactions. /** * Handler for the squidRouter pay phase. Checks the status of the Axelar bridge and pays on native GLMR fee. @@ -86,7 +100,7 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { if (state.to === Networks.AssetHub) { return this.transitionToNextPhase(state, "moonbeamToPendulum"); } else { - return this.transitionToNextPhase(state, "complete"); + return this.transitionToNextPhase(state, "finalSettlementSubsidy"); } } catch (error: unknown) { logger.error(`SquidRouterPayPhaseHandler: Error in squidRouterPay phase for ramp ${state.id}:`, error); @@ -95,78 +109,153 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { } /** - * Gets the status of the Axelar bridge - * @param txHash The swap (bridgeCall) transaction hash + * Checks the status of the Axelar bridge and balances in parallel. + * If a balance arrived, we consider it a success. + * If the bridge reports success, we consider it a success. + * Only if both fail (timeout) we throw. */ private async checkStatus(state: RampState, swapHash: string, quote: QuoteTicket): Promise { + // If the destination is not an EVM network, skip the EVM balance optimization and rely on bridge status only. + if (quote.to === Networks.AssetHub) { + logger.info("SquidRouterPayPhaseHandler: Destination network is non-EVM; skipping EVM balance check optimization.", { + toNetwork: quote.to + }); + await this.checkBridgeStatus(state, swapHash, quote); + return; + } + + const toChain = quote.to as EvmNetworks; + + let balanceCheckPromise: Promise; + try { - let isExecuted = false; - let payTxHash: string | undefined = state.state.squidRouterPayTxHash; // in case of recovery, we may have already paid. - // initial delay to allow for API indexing. - await new Promise(resolve => setTimeout(resolve, SQUIDROUTER_INITIAL_DELAY_MS)); - while (!isExecuted) { - const squidRouterStatus = await this.getSquidrouterStatus(swapHash, state, quote); + const outTokenDetails = getOnChainTokenDetails(toChain, quote.outputCurrency as OnChainToken) as EvmTokenDetails; + const ephemeralAddress = state.state.evmEphemeralAddress; + + if (outTokenDetails && ephemeralAddress) { + balanceCheckPromise = checkEvmBalancePeriodically( + outTokenDetails.erc20AddressSourceChain, + ephemeralAddress, + "1", // If we passed expectedAmountRaw, we might timeout if the bridge slipped and delivered slightly less. + BALANCE_POLLING_TIME_MS, + EVM_BALANCE_CHECK_TIMEOUT_MS, + toChain + ); + } else { + logger.warn( + "SquidRouterPayPhaseHandler: Cannot perform balance check optimization (missing expected token details or address)." + ); + balanceCheckPromise = Promise.reject(new Error("Skipped balance check")); + } + } catch (err) { + logger.warn(`SquidRouterPayPhaseHandler: Error preparing balance check: ${err}`); + balanceCheckPromise = Promise.reject(err); + } - if (squidRouterStatus.status === "success") { - isExecuted = true; - logger.info(`SquidRouterPayPhaseHandler: Transaction ${swapHash} successfully executed on Squidrouter.`); - break; - } - if (!squidRouterStatus) { - logger.warn(`SquidRouterPayPhaseHandler: No squidRouter status found for swap hash ${swapHash}.`); - throw this.createRecoverableError("No squidRouter status found for swap hash."); + // Wrap both promises to prevent unhandled rejections after one succeeds + const bridgeCheckPromise = this.checkBridgeStatus(state, swapHash, quote).catch(err => { + // Re-throw to preserve the error for Promise.any + throw err; + }); + + const balanceCheckWithErrorHandling = balanceCheckPromise.catch(err => { + // Re-throw to preserve the error for Promise.any + throw err; + }); + + try { + await Promise.any([bridgeCheckPromise, balanceCheckWithErrorHandling]); + } catch (error) { + // Both failed. + if (error instanceof AggregateError) { + // Distinguish between balance check timeout and read failure + const balanceError = error.errors.find(e => e instanceof BalanceCheckError); + const bridgeError = error.errors.find(e => !(e instanceof BalanceCheckError)); + + let errorMessage = "SquidRouterPayPhaseHandler: Both bridge status check and balance check failed."; + + if (balanceError instanceof BalanceCheckError) { + if (balanceError.type === BalanceCheckErrorType.Timeout) { + errorMessage += ` Balance check timed out after ${EVM_BALANCE_CHECK_TIMEOUT_MS}ms.`; + } else if (balanceError.type === BalanceCheckErrorType.ReadFailure) { + errorMessage += ` Balance check read failure (unexpected infrastructure issue): ${balanceError.message}.`; + } } - // If route is on the same chain, we must skip the Axelar check. - if (!squidRouterStatus.isGMPTransaction) { - await new Promise(resolve => setTimeout(resolve, AXELAR_POLLING_INTERVAL_MS)); + if (bridgeError) { + errorMessage += ` Bridge check error: ${bridgeError instanceof Error ? bridgeError.message : String(bridgeError)}.`; } - const axelarScanStatus = await getStatusAxelarScan(swapHash); + throw new Error(errorMessage); + } + throw error; + } + } - //no status found is considered a recoverable error. - if (!axelarScanStatus) { - logger.warn(`SquidRouterPayPhaseHandler: No status found for swap hash ${swapHash}.`); - throw this.createRecoverableError("No status found for swap hash."); - } - if (axelarScanStatus.status === "executed" || axelarScanStatus.status === "express_executed") { + /** + * Gets the status of the Axelar bridge + * @param txHash The swap (bridgeCall) transaction hash + */ + private async checkBridgeStatus(state: RampState, swapHash: string, quote: QuoteTicket): Promise { + let isExecuted = false; + let payTxHash: string | undefined = state.state.squidRouterPayTxHash; + + await new Promise(resolve => setTimeout(resolve, SQUIDROUTER_INITIAL_DELAY_MS)); + + while (!isExecuted) { + try { + const squidRouterStatus = await this.getSquidrouterStatus(swapHash, state, quote); + + if (!squidRouterStatus) { + logger.warn(`SquidRouterPayPhaseHandler: No squidRouter status found for swap hash ${swapHash}.`); + } else if (squidRouterStatus.status === "success") { + logger.info(`SquidRouterPayPhaseHandler: Transaction ${swapHash} successfully executed on Squidrouter.`); isExecuted = true; - logger.info(`SquidRouterPayPhaseHandler: Transaction ${swapHash} successfully executed on Axelar.`); break; } - if (!payTxHash) { - const nativeToFundRaw = this.calculateGasFeeInUnits(axelarScanStatus.fees, DEFAULT_SQUIDROUTER_GAS_ESTIMATE); - const logIndex = Number(axelarScanStatus.id.split("_")[2]); + const isGmp = squidRouterStatus ? squidRouterStatus.isGMPTransaction : true; - payTxHash = await this.executeFundTransaction(nativeToFundRaw, swapHash as `0x${string}`, logIndex, state, quote); + if (isGmp) { + const axelarScanStatus = await getStatusAxelarScan(swapHash); - const isPolygon = quote.inputCurrency !== FiatToken.BRL; - const subsidyToken = isPolygon ? SubsidyToken.MATIC : SubsidyToken.GLMR; - const subsidyAmount = nativeToDecimal(nativeToFundRaw, 18).toNumber(); // Both MATIC and GLMR have 18 decimals - const payerAccount = isPolygon - ? this.polygonWalletClient.account?.address - : this.moonbeamWalletClient.account?.address; + if (!axelarScanStatus) { + logger.info(`SquidRouterPayPhaseHandler: Axelar status not found yet for hash ${swapHash}.`); + } else if (axelarScanStatus.status === "executed" || axelarScanStatus.status === "express_executed") { + logger.info(`SquidRouterPayPhaseHandler: Transaction ${swapHash} successfully executed on Axelar.`); + isExecuted = true; + break; + } else if (!payTxHash) { + logger.info("SquidRouterPayPhaseHandler: Bridge transaction detected on Axelar. Proceeding to fund gas."); - if (payerAccount) { - await this.createSubsidy(state, subsidyAmount, subsidyToken, payerAccount, payTxHash); - } + const nativeToFundRaw = this.calculateGasFeeInUnits(axelarScanStatus.fees, DEFAULT_SQUIDROUTER_GAS_ESTIMATE); + const logIndex = Number(axelarScanStatus.id.split("_")[2]); - await state.update({ - state: { - ...state.state, - squidRouterPayTxHash: payTxHash + payTxHash = await this.executeFundTransaction(nativeToFundRaw, swapHash as `0x${string}`, logIndex, state, quote); + + const isPolygon = quote.inputCurrency !== FiatToken.BRL; + const subsidyToken = isPolygon ? SubsidyToken.MATIC : SubsidyToken.GLMR; + const subsidyAmount = nativeToDecimal(nativeToFundRaw, 18).toNumber(); + const payerAccount = isPolygon + ? this.polygonWalletClient.account?.address + : this.moonbeamWalletClient.account?.address; + + if (payerAccount) { + await this.createSubsidy(state, subsidyAmount, subsidyToken, payerAccount, payTxHash); } - }); - } - await new Promise(resolve => setTimeout(resolve, AXELAR_POLLING_INTERVAL_MS)); - } - } catch (error) { - if (error && error instanceof PhaseError && error.isRecoverable) { - throw error; + await state.update({ + state: { ...state.state, squidRouterPayTxHash: payTxHash } + }); + } + } else { + logger.info("SquidRouterPayPhaseHandler: Same-chain transaction detected. Skipping Axelar check."); + } + } catch (error) { + logger.error(`SquidRouterPayPhaseHandler: Error in bridge status loop for ${swapHash}:`, error); } - throw new Error(`SquidRouterPayPhaseHandler: Error waiting checking for Axelar bridge transaction: ${error}`); + + await new Promise(resolve => setTimeout(resolve, AXELAR_POLLING_INTERVAL_MS)); } } diff --git a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts index d3ae8b916..4e7d79cdf 100644 --- a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts +++ b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts @@ -207,7 +207,7 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { await new Promise(resolve => setTimeout(resolve, delay)); } else { - throw new Error(`SquidRouterPhaseHandler: Error waiting for transaction confirmation: ${error}`); + throw this.createRecoverableError(`SquidRouterPhaseHandler: Error waiting for transaction confirmation: ${error}`); } } } @@ -220,7 +220,7 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { return await publicClient.getTransactionCount({ address }); } catch (error) { logger.error("Error getting nonce", error); - throw new Error("Failed to get transaction nonce"); + throw this.createRecoverableError("Failed to get transaction nonce"); } } } diff --git a/apps/api/src/api/services/phases/meta-state-types.ts b/apps/api/src/api/services/phases/meta-state-types.ts index 1c8073bb6..e8253868f 100644 --- a/apps/api/src/api/services/phases/meta-state-types.ts +++ b/apps/api/src/api/services/phases/meta-state-types.ts @@ -64,4 +64,6 @@ export interface StateMetadata { alfredpayTransactionId?: string; alfredpayOnrampMintTxHash?: string; fiatPaymentInstructions?: AlfredpayFiatPaymentInstructions; + destinationTransferTxHash?: string; + finalSettlementSubsidyTxHash?: string; } diff --git a/apps/api/src/api/services/phases/phase-processor.ts b/apps/api/src/api/services/phases/phase-processor.ts index b825e16b3..43f6b4169 100644 --- a/apps/api/src/api/services/phases/phase-processor.ts +++ b/apps/api/src/api/services/phases/phase-processor.ts @@ -1,5 +1,6 @@ import httpStatus from "http-status"; import logger from "../../../config/logger"; +import { runWithRampContext } from "../../../config/ramp-context"; import RampState from "../../../models/rampState.model"; import { APIError } from "../../errors/api-error"; import { PhaseError, RecoverablePhaseError } from "../../errors/phase-error"; @@ -12,7 +13,7 @@ export class PhaseProcessor { private static instance: PhaseProcessor; private retriesMap = new Map(); private readonly MAX_RETRIES = 8; - private readonly MAX_EXECUTION_TIME_MS = 20 * 60 * 1000; // 20 minutes + private readonly MAX_EXECUTION_TIME_MS = 10 * 60 * 1000; // 10 minutes private lockedRamps = new Set(); /** @@ -30,41 +31,43 @@ export class PhaseProcessor { * @param rampId The ID of the ramping process */ public async processRamp(rampId: string): Promise { - const state = await RampState.findByPk(rampId); - if (!state) { - throw new APIError({ - message: `Ramp with ID ${rampId} not found`, - status: httpStatus.NOT_FOUND - }); - } + return runWithRampContext(rampId, async () => { + const state = await RampState.findByPk(rampId); + if (!state) { + throw new APIError({ + message: `Ramp with ID ${rampId} not found`, + status: httpStatus.NOT_FOUND + }); + } - // Try to acquire the lock - let lockAcquired = await this.acquireLock(state); - if (!lockAcquired) { - if (this.isLockExpired(state)) { - logger.info(`Lock for ramp ${rampId} has expired. Ignoring previous lock and continue processing...`); - // Force release the expired lock and try to acquire it again - await this.releaseLock(state); - lockAcquired = await this.acquireLock(state); - if (!lockAcquired) { - logger.warn(`Failed to acquire lock for ramp ${rampId} even after clearing expired lock`); + // Try to acquire the lock + let lockAcquired = await this.acquireLock(state); + if (!lockAcquired) { + if (this.isLockExpired(state)) { + logger.info(`Lock for ramp ${rampId} has expired. Ignoring previous lock and continue processing...`); + // Force release the expired lock and try to acquire it again + await this.releaseLock(state); + lockAcquired = await this.acquireLock(state); + if (!lockAcquired) { + logger.warn(`Failed to acquire lock for ramp ${rampId} even after clearing expired lock`); + return; + } + } else { + logger.info(`Skipping processing for ramp ${rampId} as it's already being processed`); return; } - } else { - logger.info(`Skipping processing for ramp ${rampId} as it's already being processed`); - return; } - } - try { - await this.processPhase(state); - // We just return, since the error management should be handled in the processPhase method. - // We do not want to crash the whole process if one ramp fails. - } catch (error) { - logger.error(`Error processing ramp ${rampId}: ${error}`); - } finally { - await this.releaseLock(state); - } + try { + await this.processPhase(state); + // We just return, since the error management should be handled in the processPhase method. + // We do not want to crash the whole process if one ramp fails. + } catch (error) { + logger.error(`Error processing ramp ${rampId}: ${error}`); + } finally { + await this.releaseLock(state); + } + }); } /** diff --git a/apps/api/src/api/services/phases/post-process/stellar-post-process-handler.ts b/apps/api/src/api/services/phases/post-process/stellar-post-process-handler.ts index 4a0dc2254..298332abd 100644 --- a/apps/api/src/api/services/phases/post-process/stellar-post-process-handler.ts +++ b/apps/api/src/api/services/phases/post-process/stellar-post-process-handler.ts @@ -23,7 +23,7 @@ export class StellarPostProcessHandler extends BasePostProcessHandler { return false; } - if (state.type !== RampDirection.SELL) { + if (state.type !== RampDirection.SELL || this.getPresignedTransaction(state, "stellarCleanup") === undefined) { return false; } diff --git a/apps/api/src/api/services/phases/register-handlers.ts b/apps/api/src/api/services/phases/register-handlers.ts index bb4622f37..f7065b675 100644 --- a/apps/api/src/api/services/phases/register-handlers.ts +++ b/apps/api/src/api/services/phases/register-handlers.ts @@ -2,7 +2,9 @@ import logger from "../../../config/logger"; import alfredpayOnrampMintHandler from "./handlers/alfredpay-onramp-mint-handler"; import brlaOnrampMintHandler from "./handlers/brla-onramp-mint-handler"; import brlaPayoutMoonbeamHandler from "./handlers/brla-payout-moonbeam-handler"; +import destinationTransferHandler from "./handlers/destination-transfer-handler"; import distributeFeesHandler from "./handlers/distribute-fees-handler"; +import finalSettlementSubsidy from "./handlers/final-settlement-subsidy"; import fundEphemeralHandler from "./handlers/fund-ephemeral-handler"; import hydrationSwapHandler from "./handlers/hydration-swap-handler"; import hydrationToAssethubXcmPhaseHandler from "./handlers/hydration-to-assethub-xcm-phase-handler"; @@ -54,6 +56,8 @@ export function registerPhaseHandlers(): void { phaseRegistry.registerHandler(pendulumToHydrationXcmPhaseHandler); phaseRegistry.registerHandler(hydrationToAssethubXcmPhaseHandler); phaseRegistry.registerHandler(hydrationSwapHandler); + phaseRegistry.registerHandler(finalSettlementSubsidy); + phaseRegistry.registerHandler(destinationTransferHandler); logger.info("Phase handlers registered"); } diff --git a/apps/api/src/api/services/priceFeed.service.ts b/apps/api/src/api/services/priceFeed.service.ts index 44e7f7189..c6b3ae996 100644 --- a/apps/api/src/api/services/priceFeed.service.ts +++ b/apps/api/src/api/services/priceFeed.service.ts @@ -3,6 +3,7 @@ import { EvmToken, getPendulumDetails, getTokenOutAmount, + getTokenUsdPrice, isFiatToken, normalizeTokenSymbol, PENDULUM_USDC_AXL, @@ -453,7 +454,7 @@ export class PriceFeedService { ETH: "ethereum", GLMR: "moonbeam", HDX: "hydradx", - MATIC: "matic-network" + MATIC: "polygon-ecosystem-token" }; return tokenIdMap[currency.toUpperCase()] || null; @@ -488,6 +489,16 @@ export class PriceFeedService { } private async convertUsdToCrypto(amount: string, toCurrency: RampCurrency, decimals: number): Promise { + // Try dynamic token price first + const dynamicPrice = getTokenUsdPrice(toCurrency); + if (dynamicPrice !== undefined && dynamicPrice > 0) { + const result = new Big(amount).div(dynamicPrice).toFixed(decimals); + logger.debug(`Converted ${amount} USD to ${result} ${toCurrency} using dynamic price: ${dynamicPrice}`); + return result; + } + + // Fall back to CoinGecko + logger.debug(`No dynamic price for ${toCurrency}, falling back to CoinGecko`); const tokenId = this.getCoinGeckoTokenId(toCurrency); if (!tokenId) { throw new Error(`No CoinGecko token ID mapping for ${toCurrency}`); @@ -499,11 +510,21 @@ export class PriceFeedService { } const result = new Big(amount).div(cryptoPriceUSD).toFixed(decimals); - logger.debug(`Converted ${amount} USD to ${result} ${toCurrency} using price: ${cryptoPriceUSD}`); + logger.debug(`Converted ${amount} USD to ${result} ${toCurrency} using CoinGecko price: ${cryptoPriceUSD}`); return result; } private async convertCryptoToUsd(amount: string, fromCurrency: RampCurrency, decimals: number): Promise { + // Try dynamic token price first + const dynamicPrice = getTokenUsdPrice(fromCurrency); + if (dynamicPrice !== undefined && dynamicPrice > 0) { + const result = new Big(amount).mul(dynamicPrice).toFixed(decimals); + logger.debug(`Converted ${amount} ${fromCurrency} to ${result} USD using dynamic price: ${dynamicPrice}`); + return result; + } + + // Fall back to CoinGecko + logger.debug(`No dynamic price for ${fromCurrency}, falling back to CoinGecko`); const tokenId = this.getCoinGeckoTokenId(fromCurrency); if (!tokenId) { throw new Error(`No CoinGecko token ID mapping for ${fromCurrency}`); @@ -511,7 +532,7 @@ export class PriceFeedService { const cryptoPriceUSD = await this.getCryptoPrice(tokenId, "usd"); const result = new Big(amount).mul(cryptoPriceUSD).toFixed(decimals); - logger.debug(`Converted ${amount} ${fromCurrency} to ${result} USD using price: ${cryptoPriceUSD}`); + logger.debug(`Converted ${amount} ${fromCurrency} to ${result} USD using CoinGecko price: ${cryptoPriceUSD}`); return result; } } diff --git a/apps/api/src/api/services/quote/core/squidrouter.ts b/apps/api/src/api/services/quote/core/squidrouter.ts index 7968f7097..a9a6c98ea 100644 --- a/apps/api/src/api/services/quote/core/squidrouter.ts +++ b/apps/api/src/api/services/quote/core/squidrouter.ts @@ -237,8 +237,18 @@ export async function calculateEvmBridgeAndNetworkFee(request: EvmBridgeRequest) outputTokenDecimals }; } catch (error) { - logger.error(`Error calculating EVM bridge and network fee: ${error instanceof Error ? error.message : String(error)}`); - // We assume that the error is due to a low input amount + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`Error calculating EVM bridge and network fee: ${errorMessage}`); + + // Check for specific SquidRouter error types + if (errorMessage.toLowerCase().includes("low liquidity") || errorMessage.toLowerCase().includes("reduce swap amount")) { + throw new APIError({ + message: QuoteError.LowLiquidity, + status: httpStatus.BAD_REQUEST + }); + } + + // Default to generic error for other cases throw new APIError({ message: QuoteError.InputAmountTooLow, status: httpStatus.BAD_REQUEST diff --git a/apps/api/src/api/services/quote/index.ts b/apps/api/src/api/services/quote/index.ts index 12eb161ce..bba4852b0 100644 --- a/apps/api/src/api/services/quote/index.ts +++ b/apps/api/src/api/services/quote/index.ts @@ -131,6 +131,10 @@ export class QuoteService extends BaseRampService { ): Promise { validateChainSupport(request.rampType, request.from, request.to); + if (request.rampType === RampDirection.BUY && request.to === Networks.Ethereum) { + throw new APIError({ message: QuoteError.FailedToCalculateQuote, status: httpStatus.INTERNAL_SERVER_ERROR }); + } + let partner = null; const partnerNameToUse = request.partnerId || request.partnerName; 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 de89593e1..262562e73 100644 --- a/apps/api/src/api/services/transactions/onramp/common/transactions.ts +++ b/apps/api/src/api/services/transactions/onramp/common/transactions.ts @@ -4,6 +4,9 @@ import { AMM_MINIMUM_OUTPUT_SOFT_MARGIN, createMoonbeamToPendulumXCM, createNablaTransactionsForOnramp, + EvmClientManager, + EvmNetworks, + EvmTransactionData, encodeSubmittableExtrinsic, getNetworkId, Networks, @@ -11,6 +14,8 @@ import { UnsignedTx } from "@vortexfi/shared"; import Big from "big.js"; +import { encodeFunctionData } from "viem/utils"; +import erc20ABI from "../../../../../contracts/ERC20"; import { QuoteTicketAttributes } from "../../../../../models/quoteTicket.model"; import { StateMetadata } from "../../../phases/meta-state-types"; import { prepareMoonbeamCleanupTransaction } from "../../moonbeam/cleanup"; @@ -173,3 +178,77 @@ export async function addPendulumCleanupTx(params: { txData: encodeSubmittableExtrinsic(pendulumCleanupTransaction) }; } + +/** + * Creates transactions to handle the ephemeral account on the destination chain + * @param params Transaction parameters + * @param unsignedTxs Array to add transactions to + * @param nextNonce Next available nonce + * @returns Updated nonce + */ +export async function addOnrampDestinationChainTransactions(params: { + toAddress: string; + toToken: `0x${string}`; + amountRaw: string; + destinationNetwork: EvmNetworks; +}): Promise { + const { toAddress, amountRaw, destinationNetwork, toToken } = params; + + const evmClientManager = EvmClientManager.getInstance(); + const publicClient = evmClientManager.getClient(destinationNetwork); + + const transferCallData = encodeFunctionData({ + abi: erc20ABI, + args: [toAddress, amountRaw], + functionName: "transfer" + }); + + const { maxFeePerGas } = await publicClient.estimateFeesPerGas(); + + const txData: EvmTransactionData = { + data: transferCallData as `0x${string}`, + gas: "100000", + maxFeePerGas: String(maxFeePerGas), + maxPriorityFeePerGas: String(maxFeePerGas), + to: toToken, + value: "0" + }; + + return txData; +} + +/** + * Creates an approval transaction on the destination chain + * @param params Transaction parameters + * @returns EvmTransactionData + */ +export async function addDestinationChainApprovalTransaction(params: { + amountRaw: string; + spenderAddress: string; + tokenAddress: `0x${string}`; + destinationNetwork: EvmNetworks; +}): Promise { + const { amountRaw, spenderAddress, tokenAddress, destinationNetwork } = params; + + const evmClientManager = EvmClientManager.getInstance(); + const publicClient = evmClientManager.getClient(destinationNetwork); + + const approveCallData = encodeFunctionData({ + abi: erc20ABI, + args: [spenderAddress, amountRaw], + functionName: "approve" + }); + + const { maxFeePerGas } = await publicClient.estimateFeesPerGas(); + + const txData: EvmTransactionData = { + data: approveCallData as `0x${string}`, + gas: "100000", + maxFeePerGas: String(maxFeePerGas), + maxPriorityFeePerGas: String(maxFeePerGas), + to: tokenAddress, + value: "0" + }; + + return txData; +} diff --git a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts index b6ba996d0..a8aafebef 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts @@ -1,19 +1,34 @@ import { AXL_USDC_MOONBEAM_DETAILS, createOnrampSquidrouterTransactionsFromMoonbeamToEvm, + createOnrampSquidrouterTransactionsOnDestinationChain, createPendulumToMoonbeamTransfer, + EvmNetworks, + EvmToken, + EvmTokenDetails, EvmTransactionData, encodeSubmittableExtrinsic, + evmTokenConfig, getNetworkId, + getOnChainTokenDetailsOrDefault, getPendulumDetails, isEvmTokenDetails, + multiplyByPowerOfTen, Networks, UnsignedTx } from "@vortexfi/shared"; +import { privateKeyToAccount } from "viem/accounts"; +import { MOONBEAM_FUNDING_PRIVATE_KEY } from "../../../../../constants/constants"; import { StateMetadata } from "../../../phases/meta-state-types"; import { addFeeDistributionTransaction } from "../../common/feeDistribution"; import { encodeEvmTransactionData } from "../../index"; -import { addMoonbeamTransactions, addNablaSwapTransactions, addPendulumCleanupTx } from "../common/transactions"; +import { + addDestinationChainApprovalTransaction, + addMoonbeamTransactions, + addNablaSwapTransactions, + addOnrampDestinationChainTransactions, + addPendulumCleanupTx +} from "../common/transactions"; import { AveniaOnrampTransactionParams, OnrampTransactionsWithMeta } from "../common/types"; import { validateAveniaOnramp } from "../common/validation"; @@ -127,8 +142,10 @@ export async function prepareAveniaToEvmOnrampTransactions({ throw new Error(`Output token must be an EVM token for onramp to any EVM chain, got ${outputTokenDetails.assetSymbol}`); } + const destinationAxlUsdcDetails = getOnChainTokenDetailsOrDefault(toNetwork as Networks, EvmToken.AXLUSDC) as EvmTokenDetails; + const { approveData, swapData } = await createOnrampSquidrouterTransactionsFromMoonbeamToEvm({ - destinationAddress, + destinationAddress: evmEphemeralEntry.address, fromAddress: evmEphemeralEntry.address, fromToken: AXL_USDC_MOONBEAM_DETAILS.erc20AddressSourceChain, moonbeamEphemeralStartingNonce: moonbeamNonce, @@ -157,5 +174,82 @@ export async function prepareAveniaToEvmOnrampTransactions({ }); moonbeamNonce++; + // Fallback swap depends on the EVM chain. For Ethereum, the bridged token is USDC. For the rest, it is axlUSDC. + const bridgedTokenForFallback = + toNetwork === Networks.Ethereum + ? evmTokenConfig.ethereum.USDC!.erc20AddressSourceChain + : destinationAxlUsdcDetails.erc20AddressSourceChain; + + const { approveData: destApproveData, swapData: destSwapData } = await createOnrampSquidrouterTransactionsOnDestinationChain({ + destinationAddress: evmEphemeralEntry.address, + fromAddress: evmEphemeralEntry.address, + fromToken: bridgedTokenForFallback, + network: toNetwork as EvmNetworks, + rawAmount: quote.metadata.moonbeamToEvm.inputAmountRaw, + toToken: outputTokenDetails.erc20AddressSourceChain + }); + + let destinationNonce = 0; + + const finalAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals); + const finalSettlementTransaction = await addOnrampDestinationChainTransactions({ + amountRaw: finalAmountRaw.toString(), + destinationNetwork: toNetwork as EvmNetworks, + toAddress: destinationAddress, + toToken: outputTokenDetails.erc20AddressSourceChain + }); + + unsignedTxs.push({ + meta: {}, + network: toNetwork, + nonce: destinationNonce, + phase: "destinationTransfer", + signer: evmEphemeralEntry.address, + txData: finalSettlementTransaction + }); + + destinationNonce++; + + unsignedTxs.push({ + meta: {}, + network: toNetwork, + nonce: destinationNonce, + phase: "backupSquidRouterApprove", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(destApproveData) as EvmTransactionData + }); + destinationNonce++; + + unsignedTxs.push({ + meta: {}, + network: toNetwork, + nonce: destinationNonce, + phase: "backupSquidRouterSwap", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(destSwapData) as EvmTransactionData + }); + destinationNonce++; + + const maxUint256 = 2n ** 256n - 1n; + const fundingAccount = privateKeyToAccount(MOONBEAM_FUNDING_PRIVATE_KEY as `0x${string}`); + + const backupApproveTransaction = await addDestinationChainApprovalTransaction({ + amountRaw: maxUint256.toString(), + destinationNetwork: toNetwork as EvmNetworks, + spenderAddress: fundingAccount.address, + tokenAddress: bridgedTokenForFallback + }); + + // We set this to 0 on purpose because we don't want to risk that the required nonce is never reached + const backupApproveNonce = 0; + unsignedTxs.push({ + meta: {}, + network: toNetwork, + nonce: backupApproveNonce, + phase: "backupApprove", + signer: evmEphemeralEntry.address, + txData: backupApproveTransaction + }); + return { stateMeta, unsignedTxs }; } diff --git a/apps/api/src/api/workers/cleanup.worker.ts b/apps/api/src/api/workers/cleanup.worker.ts index 3e3e3622d..9d2a245f9 100644 --- a/apps/api/src/api/workers/cleanup.worker.ts +++ b/apps/api/src/api/workers/cleanup.worker.ts @@ -1,6 +1,7 @@ import { CronJob } from "cron"; import { Op } from "sequelize"; import logger from "../../config/logger"; +import { runWithRampContext } from "../../config/ramp-context"; import RampState from "../../models/rampState.model"; import { postProcessHandlers } from "../services/phases/post-process"; import { BaseRampService } from "../services/ramp/base.service"; @@ -170,14 +171,16 @@ class CleanupWorker { logger.info(`Found ${states.length} completed RampStates that need post-processing`); const processPromises = states.map(async state => { - try { - await this.processCleanup(state); - return { stateId: state.id, status: "fulfilled" }; - } catch (error) { - logger.error(`Error processing cleanup for state ${state.id}:`, error); - // Don't update the state here, processCleanup handles its own updates - return { reason: error, stateId: state.id, status: "rejected" }; - } + return runWithRampContext(state.id, async () => { + try { + await this.processCleanup(state); + return { stateId: state.id, status: "fulfilled" }; + } catch (error) { + logger.error("Error processing cleanup:", error); + // Don't update the state here, processCleanup handles its own updates + return { reason: error, stateId: state.id, status: "rejected" }; + } + }); }); // Use allSettled to allow individual state processing to fail without stopping others diff --git a/apps/api/src/api/workers/ramp-recovery.worker.ts b/apps/api/src/api/workers/ramp-recovery.worker.ts index d2cc7eeb1..61b048d1e 100644 --- a/apps/api/src/api/workers/ramp-recovery.worker.ts +++ b/apps/api/src/api/workers/ramp-recovery.worker.ts @@ -75,8 +75,8 @@ class RampRecoveryWorker { // Process each stale state concurrently const recoveryPromises = staleStates.map(async state => { try { - logger.info(`Attempting recovery for ramp state ${state.id} in phase ${state.currentPhase}`); - // Process the state + logger.info(`Attempting recovery in phase ${state.currentPhase} for ramp ${state.id}`); + // Process the state (processRamp already wraps execution with runWithRampContext) await phaseProcessor.processRamp(state.id); logger.info(`Successfully processed ramp state ${state.id}`); return { stateId: state.id, status: "fulfilled" }; @@ -101,7 +101,7 @@ class RampRecoveryWorker { const updateError = updateE as Error; logger.error(`Failed to update ramp state ${state.id} with error log:`, updateError); // Log the original error as well if the update fails - logger.error(`Original recovery error for state ${state.id}:`, error); + logger.error(`Original recovery error for ${state.id}:`, error); } // Return a rejected status for Promise.allSettled return { reason: error, stateId: state.id, status: "rejected" }; diff --git a/apps/api/src/config/express.ts b/apps/api/src/config/express.ts index cf068e7e1..3c27ff5e4 100644 --- a/apps/api/src/config/express.ts +++ b/apps/api/src/config/express.ts @@ -58,8 +58,8 @@ app.use(cookieParser()); app.use(morgan(logs)); // parse body params and attach them to req.body -app.use(bodyParser.json()); -app.use(bodyParser.urlencoded({ extended: true })); +app.use(bodyParser.json({ limit: "50mb" })); +app.use(bodyParser.urlencoded({ extended: true, limit: "50mb" })); // gzip compression app.use(compress()); diff --git a/apps/api/src/config/logger.ts b/apps/api/src/config/logger.ts index 3bfdfe6d7..635265fec 100644 --- a/apps/api/src/config/logger.ts +++ b/apps/api/src/config/logger.ts @@ -1,9 +1,13 @@ import { StreamOptions } from "morgan"; import winston, { format } from "winston"; +import { getRampId } from "./ramp-context"; -const customFormat = winston.format.printf( - ({ timestamp, level, message, label = "" }) => `[${timestamp}] ${level}\t ${label} ${message}` -); +const customFormat = winston.format.printf(({ timestamp, level, message, label = "" }) => { + const rampId = getRampId(); + const rampPrefix = rampId ? `[${rampId}] ` : ""; + const timestampPrefix = timestamp ? `[${timestamp}]` : ""; + return `${timestampPrefix} ${level}${label ? ` ${label}` : ""} ${rampPrefix}${message}`; +}); const logger = winston.createLogger({ level: "info", @@ -18,7 +22,7 @@ const logger = winston.createLogger({ format: format.combine(format.timestamp({ format: "MMM D, YYYY HH:mm:ss" }), format.prettyPrint(), customFormat) }), new winston.transports.Console({ - format: format.combine(format.colorize(), winston.format.simple()) + format: format.combine(format.colorize(), format.prettyPrint(), customFormat) }) ] }); diff --git a/apps/api/src/config/ramp-context.ts b/apps/api/src/config/ramp-context.ts new file mode 100644 index 000000000..6cea0c8d6 --- /dev/null +++ b/apps/api/src/config/ramp-context.ts @@ -0,0 +1,48 @@ +import { AsyncLocalStorage } from "async_hooks"; + +/** + * Context that is available during ramp processing. + * This can be extended with additional fields as needed. + */ +interface RampProcessingContext { + rampId: string; +} + +/** + * AsyncLocalStorage instance for storing ramp processing context. + * This allows us to automatically propagate the rampId through async call chains + * without explicitly passing it as a parameter. + */ +const rampContextStorage = new AsyncLocalStorage(); + +/** + * Run a function within a ramp context. + * All async operations within the callback will have access to the rampId. + * + * @param rampId The ID of the ramp being processed + * @param fn The async function to run within the context + * @returns The result of the async function + */ +export function runWithRampContext(rampId: string, fn: () => Promise): Promise { + return rampContextStorage.run({ rampId }, fn); +} + +/** + * Get the current ramp ID from the AsyncLocalStorage context. + * Returns undefined if not running within a ramp context. + * + * @returns The current ramp ID or undefined + */ +export function getRampId(): string | undefined { + return rampContextStorage.getStore()?.rampId; +} + +/** + * Get the full ramp context from AsyncLocalStorage. + * Returns undefined if not running within a ramp context. + * + * @returns The current ramp context or undefined + */ +export function getRampContext(): RampProcessingContext | undefined { + return rampContextStorage.getStore(); +} diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index c7f89cf96..3068349d9 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -18,6 +18,7 @@ interface SpreadsheetConfig { googleCredentials: GoogleCredentials; storageSheetId: string | undefined; emailSheetId: string | undefined; + contactSheetId: string | undefined; ratingSheetId: string | undefined; } @@ -101,6 +102,7 @@ export const config: Config = { rateLimitNumberOfProxies: process.env.RATE_LIMIT_NUMBER_OF_PROXIES || 1, rateLimitWindowMinutes: process.env.RATE_LIMIT_WINDOW_MINUTES || 1, spreadsheet: { + contactSheetId: process.env.GOOGLE_CONTACT_SPREADSHEET_ID, emailSheetId: process.env.GOOGLE_EMAIL_SPREADSHEET_ID, googleCredentials: { email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL, diff --git a/apps/api/src/constants/constants.ts b/apps/api/src/constants/constants.ts index 33a65d1f2..fa6ef607f 100644 --- a/apps/api/src/constants/constants.ts +++ b/apps/api/src/constants/constants.ts @@ -12,6 +12,7 @@ const POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS = "1.5"; // Amount to send to the const DEFAULT_POLLING_INTERVAL = 3000; const GLMR_FUNDING_AMOUNT_RAW = "50000000000000000"; const ASSETHUB_XCM_FEE_USDC_UNITS = 0.013124; +const MAX_FINAL_SETTLEMENT_SUBSIDY_USD = "10"; // 10 USD const WEBHOOKS_CACHE_URL = "https://webhooks-cache.pendulumchain.tech"; // EXAMPLE URL @@ -79,5 +80,6 @@ export { WEBHOOKS_CACHE_URL, DEFAULT_POLLING_INTERVAL, STELLAR_BASE_FEE, - SANDBOX_ENABLED + SANDBOX_ENABLED, + MAX_FINAL_SETTLEMENT_SUBSIDY_USD }; diff --git a/apps/api/src/database/migrations/022-update-subsidy-token-enum.ts b/apps/api/src/database/migrations/022-update-subsidy-token-enum.ts new file mode 100644 index 000000000..58eb255c1 --- /dev/null +++ b/apps/api/src/database/migrations/022-update-subsidy-token-enum.ts @@ -0,0 +1,58 @@ +import { QueryInterface } from "sequelize"; + +const OLD_ENUM_VALUES = ["GLMR", "PEN", "XLM", "axlUSDC", "BRLA", "EURC"]; +const NEW_ENUM_VALUES = ["GLMR", "PEN", "XLM", "USDC.axl", "BRLA", "EURC", "USDC", "MATIC", "BRL"]; + +export async function up(queryInterface: QueryInterface): Promise { + // Phase 1: Convert enum to VARCHAR to allow value updates + await queryInterface.sequelize.query(` + ALTER TABLE subsidies ALTER COLUMN token TYPE VARCHAR(32); + `); + + // Phase 2: Rename axlUSDC to USDC.axl + await queryInterface.sequelize.query(` + UPDATE subsidies SET token = 'USDC.axl' WHERE token = 'axlUSDC'; + `); + + // Phase 3: Replace enum type with updated values + await queryInterface.sequelize.query(` + DROP TYPE IF EXISTS enum_subsidies_token; + `); + + await queryInterface.sequelize.query(` + CREATE TYPE enum_subsidies_token AS ENUM (${NEW_ENUM_VALUES.map(value => `'${value}'`).join(", ")}); + `); + + await queryInterface.sequelize.query(` + ALTER TABLE subsidies ALTER COLUMN token TYPE enum_subsidies_token USING token::enum_subsidies_token; + `); +} + +export async function down(queryInterface: QueryInterface): Promise { + // Phase 1: Convert enum to VARCHAR to allow value updates + await queryInterface.sequelize.query(` + ALTER TABLE subsidies ALTER COLUMN token TYPE VARCHAR(32); + `); + + // Phase 2: Map unsupported values back to axlUSDC for the old enum + await queryInterface.sequelize.query(` + UPDATE subsidies SET token = 'axlUSDC' WHERE token = 'USDC.axl'; + `); + + await queryInterface.sequelize.query(` + UPDATE subsidies SET token = 'axlUSDC' WHERE token IN ('USDC', 'MATIC', 'BRL'); + `); + + // Phase 3: Restore old enum type + await queryInterface.sequelize.query(` + DROP TYPE IF EXISTS enum_subsidies_token; + `); + + await queryInterface.sequelize.query(` + CREATE TYPE enum_subsidies_token AS ENUM (${OLD_ENUM_VALUES.map(value => `'${value}'`).join(", ")}); + `); + + await queryInterface.sequelize.query(` + ALTER TABLE subsidies ALTER COLUMN token TYPE enum_subsidies_token USING token::enum_subsidies_token; + `); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 8e4bbdd87..d23c6685c 100755 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -5,7 +5,7 @@ dotenv.config({ path: [path.resolve(process.cwd(), ".env"), path.resolve(process.cwd(), "../.env")] }); -import { ApiManager, EvmClientManager, setLogger } from "@vortexfi/shared"; +import { ApiManager, EvmClientManager, initializeEvmTokens, setLogger } from "@vortexfi/shared"; import { config, testDatabaseConnection } from "./config"; import cryptoService from "./config/crypto"; import app from "./config/express"; @@ -53,6 +53,9 @@ const initializeApp = async () => { // Initialize RSA keys for webhook signing cryptoService.initializeKeys(); + // Initialize dynamic EVM tokens from SquidRouter API (falls back to static config on failure) + await initializeEvmTokens(); + // Test database connection await testDatabaseConnection(); diff --git a/apps/frontend/.storybook/main.ts b/apps/frontend/.storybook/main.ts index 7ffb65389..1be6a6a16 100644 --- a/apps/frontend/.storybook/main.ts +++ b/apps/frontend/.storybook/main.ts @@ -6,7 +6,7 @@ import { dirname, join } from "path"; * This function is used to resolve the absolute path of a package. * It is needed in projects that use Yarn PnP or are set up within a monorepo. */ -function getAbsolutePath(value: string): any { +function getAbsolutePath(value: string) { return dirname(require.resolve(join(value, "package.json"))); } const config: StorybookConfig = { diff --git a/apps/frontend/App.css b/apps/frontend/App.css index 5e4ec41ba..04d8b9f7b 100644 --- a/apps/frontend/App.css +++ b/apps/frontend/App.css @@ -6,24 +6,24 @@ :root { /* DaisyUI theme colors */ - --color-primary: #0f4dc0; - --color-primary-content: #fff; - --color-secondary: #f4f5f6; - --color-secondary-content: #58667e; - --color-accent: #1de7df; - --color-accent-content: #000; - --color-neutral: #eff2f5; - --color-neutral-content: #58667e; - --color-base-100: #f5f9fa; - --color-base-200: #fff; - --color-base-300: #e3e7eb; - --color-base-content: #58667e; + --color-primary: oklch(0.45 0.2 260); + --color-primary-content: oklch(1 0 0); + --color-secondary: oklch(0.97 0.003 260); + --color-secondary-content: oklch(0.5 0.04 250); + --color-accent: oklch(0.55 0.22 350); + --color-accent-content: oklch(0 0 0); + --color-neutral: oklch(0.96 0.005 250); + --color-neutral-content: oklch(0.5 0.04 250); + --color-base-100: oklch(0.98 0.005 210); + --color-base-200: oklch(1 0 0); + --color-base-300: oklch(0.92 0.008 250); + --color-base-content: oklch(0.5 0.04 250); /* Project-specific variables */ --radius-field: 9px; - --text: #111; - --bg-modal: #fff; - --modal-border: #e5e5e5; + --text: oklch(0.15 0 0); + --bg-modal: oklch(1 0 0); + --modal-border: oklch(0.91 0 0); --rounded-btn: 9px; --btn-text-case: none; @@ -84,7 +84,7 @@ .input-ghost[aria-readonly="true"]:focus-within { background-color: transparent !important; color: var(--color-base-content); - border-color: #0000; + border-color: oklch(0 0 0 / 0); box-shadow: none; } @@ -126,6 +126,11 @@ .btn-vortex-primary { @apply bg-blue-700 text-white rounded-[var(--radius-field)] border border-blue-700 cursor-pointer; + transition: scale 0.1s ease-in-out; +} + +.btn-vortex-primary:active { + scale: 0.98; } .btn-vortex-primary:hover { @@ -143,6 +148,11 @@ @apply border; @apply border-gray-300; @apply duration-200; + transition: scale 0.1s ease-in-out; +} + +.btn-vortex-accent:active { + scale: 0.98; } .btn-vortex-accent:hover { @@ -158,10 +168,15 @@ @apply border; @apply border-blue-700; @apply cursor-pointer; + transition: scale 0.1s ease-in-out; +} + +.btn-vortex-primary-inverse:active { + scale: 0.98; } .btn-vortex-primary-inverse:hover { - @apply bg-blue-200; + @apply bg-blue-100; @apply border-blue-700; } @@ -174,7 +189,7 @@ .btn-vortex-primary-inverse:active, .btn-vortex-primary-inverse:focus { - @apply bg-blue-200; + @apply bg-blue-100; @apply text-blue-700; @apply border-blue-700; } @@ -184,6 +199,11 @@ @apply bg-pink-600; @apply border-pink-600; @apply shadow-none; + transition: scale 0.1s ease-in-out; +} + +.btn-vortex-secondary:active { + scale: 0.98; } .btn-vortex-secondary:hover { @@ -200,6 +220,11 @@ @apply border; @apply border-red-600; @apply shadow-none; + transition: scale 0.1s ease-in-out; +} + +.btn-vortex-danger:active { + scale: 0.98; } .btn-vortex-danger:hover { diff --git a/apps/frontend/_redirects b/apps/frontend/_redirects index 4f265d0df..d6e27743b 100644 --- a/apps/frontend/_redirects +++ b/apps/frontend/_redirects @@ -2,3 +2,4 @@ /api/staging/* https://api-staging.vortexfinance.co/:splat 200 /api/sandbox/* https://api-sandbox.vortexfinance.co/:splat 200 /* /index.html 200 +https://vortexfinance.co/* https://www.vortexfinance.co/:splat 301! diff --git a/apps/frontend/android-chrome-192x192.png b/apps/frontend/android-chrome-192x192.png new file mode 100644 index 000000000..0a6776adc Binary files /dev/null and b/apps/frontend/android-chrome-192x192.png differ diff --git a/apps/frontend/android-chrome-512x512.png b/apps/frontend/android-chrome-512x512.png new file mode 100644 index 000000000..e4f9a4c82 Binary files /dev/null and b/apps/frontend/android-chrome-512x512.png differ diff --git a/apps/frontend/apple-touch-icon.png b/apps/frontend/apple-touch-icon.png new file mode 100644 index 000000000..47ea6e828 Binary files /dev/null and b/apps/frontend/apple-touch-icon.png differ diff --git a/apps/frontend/favicon-16x16.png b/apps/frontend/favicon-16x16.png new file mode 100644 index 000000000..8bdbb9e07 Binary files /dev/null and b/apps/frontend/favicon-16x16.png differ diff --git a/apps/frontend/favicon-32x32.png b/apps/frontend/favicon-32x32.png new file mode 100644 index 000000000..880c99412 Binary files /dev/null and b/apps/frontend/favicon-32x32.png differ diff --git a/apps/frontend/favicon.ico b/apps/frontend/favicon.ico new file mode 100644 index 000000000..b02003be1 Binary files /dev/null and b/apps/frontend/favicon.ico differ diff --git a/apps/frontend/favicon.png b/apps/frontend/favicon.png deleted file mode 100644 index 6f0f78240..000000000 Binary files a/apps/frontend/favicon.png and /dev/null differ diff --git a/apps/frontend/index.html b/apps/frontend/index.html index 030b08de6..7538c5a9e 100644 --- a/apps/frontend/index.html +++ b/apps/frontend/index.html @@ -2,7 +2,11 @@ - + + + + + Vortex diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 231179641..5681d7f1c 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -32,6 +32,7 @@ "@tanstack/react-query": "^5.64.2", "@tanstack/react-router": "^1.136.8", "@tanstack/react-router-devtools": "^1.136.8", + "@tanstack/react-virtual": "^3.13.18", "@tanstack/zod-adapter": "^1.144.0", "@types/crypto-js": "^4.2.2", "@vitejs/plugin-react": "^4.3.4", @@ -54,7 +55,10 @@ "lottie-react": "^2.4.1", "lucide-react": "^0.562.0", "motion": "^12.0.3", + "numora": "^3.0.2", + "numora-react": "3.0.3", "qrcode.react": "^4.2.0", + "radix-ui": "^1.4.3", "react": "=19.2.0", "react-dom": "=19.2.0", "react-hook-form": "^7.65.0", diff --git a/apps/frontend/site.webmanifest b/apps/frontend/site.webmanifest new file mode 100644 index 000000000..085af57de --- /dev/null +++ b/apps/frontend/site.webmanifest @@ -0,0 +1,19 @@ +{ + "background_color": "#f5f9fa", + "display": "standalone", + "icons": [ + { + "sizes": "192x192", + "src": "/frontend/android-chrome-192x192.png", + "type": "image/png" + }, + { + "sizes": "512x512", + "src": "/frontend/android-chrome-512x512.png", + "type": "image/png" + } + ], + "name": "Vortex", + "short_name": "Vortex", + "theme_color": "#0f4dc0" +} diff --git a/apps/frontend/src/assets/coins/DOT_ASSETHUB.svg b/apps/frontend/src/assets/coins/DOT_ASSETHUB.svg deleted file mode 100644 index 36c412bd8..000000000 --- a/apps/frontend/src/assets/coins/DOT_ASSETHUB.svg +++ /dev/null @@ -1,38 +0,0 @@ - diff --git a/apps/frontend/src/assets/coins/ETH.svg b/apps/frontend/src/assets/coins/ETH.svg deleted file mode 100644 index e332b442c..000000000 --- a/apps/frontend/src/assets/coins/ETH.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/apps/frontend/src/assets/coins/ETH_ARBITRUM.svg b/apps/frontend/src/assets/coins/ETH_ARBITRUM.svg deleted file mode 100644 index c0c41fcc9..000000000 --- a/apps/frontend/src/assets/coins/ETH_ARBITRUM.svg +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/frontend/src/assets/coins/ETH_BASE.svg b/apps/frontend/src/assets/coins/ETH_BASE.svg deleted file mode 100644 index 6467bb05f..000000000 --- a/apps/frontend/src/assets/coins/ETH_BASE.svg +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/frontend/src/assets/coins/ETH_BSC.svg b/apps/frontend/src/assets/coins/ETH_BSC.svg deleted file mode 100644 index 30865e490..000000000 --- a/apps/frontend/src/assets/coins/ETH_BSC.svg +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/frontend/src/assets/coins/ETH_ETHEREUM.svg b/apps/frontend/src/assets/coins/ETH_ETHEREUM.svg deleted file mode 100644 index 9ba32aa63..000000000 --- a/apps/frontend/src/assets/coins/ETH_ETHEREUM.svg +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/frontend/src/assets/coins/EU.png b/apps/frontend/src/assets/coins/EU.png new file mode 100644 index 000000000..1636f1313 Binary files /dev/null and b/apps/frontend/src/assets/coins/EU.png differ diff --git a/apps/frontend/src/assets/coins/EURC.png b/apps/frontend/src/assets/coins/EURC.png deleted file mode 100644 index e0881a4f7..000000000 Binary files a/apps/frontend/src/assets/coins/EURC.png and /dev/null differ diff --git a/apps/frontend/src/assets/coins/MX.png b/apps/frontend/src/assets/coins/MX.png new file mode 100644 index 000000000..93760c1f1 Binary files /dev/null and b/apps/frontend/src/assets/coins/MX.png differ diff --git a/apps/frontend/src/assets/coins/USDC.png b/apps/frontend/src/assets/coins/USDC.png deleted file mode 100644 index dd986c67f..000000000 Binary files a/apps/frontend/src/assets/coins/USDC.png and /dev/null differ diff --git a/apps/frontend/src/assets/coins/USDC_ARBITRUM.svg b/apps/frontend/src/assets/coins/USDC_ARBITRUM.svg deleted file mode 100644 index d6be8e335..000000000 --- a/apps/frontend/src/assets/coins/USDC_ARBITRUM.svg +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/frontend/src/assets/coins/USDC_ASSETHUB.svg b/apps/frontend/src/assets/coins/USDC_ASSETHUB.svg deleted file mode 100644 index a0175f726..000000000 --- a/apps/frontend/src/assets/coins/USDC_ASSETHUB.svg +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/frontend/src/assets/coins/USDC_AVALANCHE.svg b/apps/frontend/src/assets/coins/USDC_AVALANCHE.svg deleted file mode 100644 index b8ace89a3..000000000 --- a/apps/frontend/src/assets/coins/USDC_AVALANCHE.svg +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/apps/frontend/src/assets/coins/USDC_BASE.svg b/apps/frontend/src/assets/coins/USDC_BASE.svg deleted file mode 100644 index 9a38c8ea0..000000000 --- a/apps/frontend/src/assets/coins/USDC_BASE.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/apps/frontend/src/assets/coins/USDC_BSC.svg b/apps/frontend/src/assets/coins/USDC_BSC.svg deleted file mode 100644 index ccd40f12a..000000000 --- a/apps/frontend/src/assets/coins/USDC_BSC.svg +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/frontend/src/assets/coins/USDC_ETHEREUM.svg b/apps/frontend/src/assets/coins/USDC_ETHEREUM.svg deleted file mode 100644 index 2b48002b0..000000000 --- a/apps/frontend/src/assets/coins/USDC_ETHEREUM.svg +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/frontend/src/assets/coins/USDC_POLYGON.svg b/apps/frontend/src/assets/coins/USDC_POLYGON.svg deleted file mode 100644 index ddfc6ea55..000000000 --- a/apps/frontend/src/assets/coins/USDC_POLYGON.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - diff --git a/apps/frontend/src/assets/coins/USDT.svg b/apps/frontend/src/assets/coins/USDT.svg deleted file mode 100644 index f46a28f9e..000000000 --- a/apps/frontend/src/assets/coins/USDT.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - diff --git a/apps/frontend/src/assets/coins/USDT_ARBITRUM.svg b/apps/frontend/src/assets/coins/USDT_ARBITRUM.svg deleted file mode 100644 index b746ac494..000000000 --- a/apps/frontend/src/assets/coins/USDT_ARBITRUM.svg +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/frontend/src/assets/coins/USDT_ASSETHUB.svg b/apps/frontend/src/assets/coins/USDT_ASSETHUB.svg deleted file mode 100644 index d39516705..000000000 --- a/apps/frontend/src/assets/coins/USDT_ASSETHUB.svg +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/frontend/src/assets/coins/USDT_AVALANCHE.svg b/apps/frontend/src/assets/coins/USDT_AVALANCHE.svg deleted file mode 100644 index 27133ec2c..000000000 --- a/apps/frontend/src/assets/coins/USDT_AVALANCHE.svg +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/frontend/src/assets/coins/USDT_BASE.svg b/apps/frontend/src/assets/coins/USDT_BASE.svg deleted file mode 100644 index 6fc59b2a5..000000000 --- a/apps/frontend/src/assets/coins/USDT_BASE.svg +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/frontend/src/assets/coins/USDT_BSC.svg b/apps/frontend/src/assets/coins/USDT_BSC.svg deleted file mode 100644 index d017f1c18..000000000 --- a/apps/frontend/src/assets/coins/USDT_BSC.svg +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/frontend/src/assets/coins/USDT_ETHEREUM.svg b/apps/frontend/src/assets/coins/USDT_ETHEREUM.svg deleted file mode 100644 index a53920b79..000000000 --- a/apps/frontend/src/assets/coins/USDT_ETHEREUM.svg +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/frontend/src/assets/coins/USDT_POLYGON.svg b/apps/frontend/src/assets/coins/USDT_POLYGON.svg deleted file mode 100644 index 3919a1882..000000000 --- a/apps/frontend/src/assets/coins/USDT_POLYGON.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - diff --git a/apps/frontend/src/assets/coins/eurc.svg b/apps/frontend/src/assets/coins/eurc.svg deleted file mode 100644 index 8c12455aa..000000000 --- a/apps/frontend/src/assets/coins/eurc.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - diff --git a/apps/frontend/src/assets/coins/placeholder.svg b/apps/frontend/src/assets/coins/placeholder.svg new file mode 100644 index 000000000..d90614e7c --- /dev/null +++ b/apps/frontend/src/assets/coins/placeholder.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/frontend/src/assets/coins/usd.png b/apps/frontend/src/assets/coins/usd.png new file mode 100644 index 000000000..d24e4cf5f Binary files /dev/null and b/apps/frontend/src/assets/coins/usd.png differ diff --git a/apps/frontend/src/components/Accordion/index.tsx b/apps/frontend/src/components/Accordion/index.tsx index 913d28a3d..8fa71dac3 100644 --- a/apps/frontend/src/components/Accordion/index.tsx +++ b/apps/frontend/src/components/Accordion/index.tsx @@ -1,6 +1,7 @@ -import { AnimatePresence, motion } from "motion/react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { FC } from "react"; import { create } from "zustand"; +import { durations, easings } from "../../constants/animations"; import { cn } from "../../helpers/cn"; interface AccordionProps { @@ -44,6 +45,7 @@ const useAccordionStore = create(set => ({ const Accordion: FC = ({ children, className = "", defaultValue = [] }) => { const setValue = useAccordionStore(state => state.setValue); + const shouldReduceMotion = useReducedMotion(); if (defaultValue.length > 0) { setValue(defaultValue); @@ -53,8 +55,8 @@ const Accordion: FC = ({ children, className = "", defaultValue {children} @@ -63,21 +65,18 @@ const Accordion: FC = ({ children, className = "", defaultValue const AccordionItem: FC = ({ children, className = "", value }) => { const isOpen = useAccordionStore(state => state.value.includes(value)); + const shouldReduceMotion = useReducedMotion(); return ( - +
{children} - +
); }; @@ -85,61 +84,62 @@ const AccordionItem: FC = ({ children, className = "", value const AccordionTrigger: FC = ({ children, className = "", value }) => { const toggleValue = useAccordionStore(state => state.toggleValue); const isOpen = useAccordionStore(state => state.value.includes(value)); + const shouldReduceMotion = useReducedMotion(); return ( - +
toggleValue(value)} - whileHover={{ scale: 1.01 }} - whileTap={{ scale: 0.99 }} + whileHover={shouldReduceMotion ? undefined : { scale: 1.01 }} + whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }} >
- {children} + {children}
- +
); }; const AccordionContent: FC = ({ children, className = "", value }) => { const isOpen = useAccordionStore(state => state.value.includes(value)); + const shouldReduceMotion = useReducedMotion(); return ( - - {isOpen && ( - - - {children} - - - )} - +
+
+ + {isOpen && ( + + {children} + + )} + +
+
); }; diff --git a/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx b/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx index 1f47252ea..9396c9c22 100644 --- a/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx +++ b/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx @@ -22,7 +22,7 @@ export const AlfredpayKycFlow = () => { return (
-

Loading...

+

Loading...

); } @@ -31,8 +31,8 @@ export const AlfredpayKycFlow = () => { return (
-

Verifying KYC Status...

-

This may take a few moments. Please do not close this window.

+

Verifying KYC Status...

+

This may take a few moments. Please do not close this window.

); } @@ -52,7 +52,7 @@ export const AlfredpayKycFlow = () => { return (
-

Opening Link...

+

Opening Link...

); } @@ -82,7 +82,7 @@ export const AlfredpayKycFlow = () => { if (stateValue === "Done") { return (
-

KYC Completed!

+

KYC Completed!

Your account has been verified. You can now proceed.

{/* The parent component might handle navigation or updates based on this state */}
@@ -92,7 +92,7 @@ export const AlfredpayKycFlow = () => { if (stateValue === "Failure") { return (
-

KYC Failed

+

KYC Failed

{context.error?.message || "An unknown error occurred."}