diff --git a/.gitignore b/.gitignore index 3f633518c..befe71000 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,8 @@ storybook-static CLAUDE.local.md .claude -/contracts/relayer/artifacts/* -/contracts/relayer/cache/* /.roo/* + +# hardhat generated files in workspace contract projects +contracts/*/artifacts +contracts/*/cache diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..1a977aed9 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "linear-server": { + "type": "sse", + "url": "https://mcp.linear.app/sse" + }, + "obsidian": { + "args": ["-y", "obsidian-mcp", "$OBSIDIAN_VAULT_PATH"], + "command": "npx" + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md index ed8f870d1..ec7fa3d9d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,8 @@ This is a **Bun monorepo** using workspaces: ## Essential Commands +> Always use `bun`- never `npm`, `yarn`, or `pnpm`. Run `bun lint:fix` after any code change. + ```bash # Install all dependencies bun install @@ -70,7 +72,7 @@ Phase metadata and valid transitions are stored in PostgreSQL and seeded via `se ### Frontend Architecture - **State**: Zustand stores (`stores/`) + React Context (`contexts/`) -- **Forms**: React Hook Form with Yup validation +- **Forms**: React Hook Form with Zod validation (not Yup) - **Data Fetching**: TanStack Query - **Routing**: TanStack Router (route tree auto-generated in `routeTree.gen.ts`) - **State Machines**: XState machines in `machines/` for complex flows (KYC, ramp process) @@ -95,6 +97,8 @@ Contains cross-package utilities: **Important**: Always rebuild shared when making changes: `bun build:shared` +After ANY change to `packages/shared`, run `bun build:shared` before running frontend/api. + ## Code Style Guidelines From `.clinerules/`: @@ -110,6 +114,11 @@ From `.clinerules/`: - Extract complex conditional rendering into new components - Skip useless comments; only comment race conditions, TODOs, or genuinely confusing code +### XState v5 +- Use `setup({ ... }).createMachine(...)` API- not `createMachine` directly +- Actor refs from `useActor` / `useSelector` from `@xstate/react` +- Machine files live in `apps/frontend/src/machines/` + ### Biome Configuration - Line width: 128 - Indent: 2 spaces @@ -118,6 +127,22 @@ From `.clinerules/`: - Quote style: double - Sorted Tailwind classes enforced via `useSortedClasses` rule +## Token Exhaustiveness + +`FiatToken` currently has 6 values: `EURC`, `ARS`, `BRL`, `USD`, `MXN`, `COP`. + +Any `Record` must include ALL six. Missing entries cause TypeScript errors +when shared is rebuilt. Check: tokenAvailability, mapFiatToDestination, success page +ARRIVAL_TEXT_BY_TOKEN, sep10 tokenMapping. + +## No Over-Engineering + +- Don't add features, refactors, or "improvements" beyond what was asked +- Don't add docstrings/comments to code you didn't touch +- Don't create helpers/utilities for one-time operations +- Don't validate inputs that can't be invalid (internal calls, typed params) +- Three similar lines is better than a premature abstraction + ## Testing ### Backend Integration Tests diff --git a/README.md b/README.md index e2534a312..69a730693 100644 --- a/README.md +++ b/README.md @@ -13,13 +13,16 @@ Vortex is a gateway for cross-border payments. It is built on top of the Pendulu ## Repository Structure -This is a **Bun monorepo** containing multiple sub-projects organized into apps and packages: +This is a **Bun monorepo** containing multiple sub-projects organized into apps, packages, and contracts: ### Apps - **[apps/api](apps/api)** - Backend API service providing signature services, on/off-ramping flows, quote generation, and transaction state management - **[apps/frontend](apps/frontend)** - React-based web application built with Vite for the Vortex user interface - **[apps/rebalancer](apps/rebalancer)** - Service for automated liquidity rebalancing across chains +### Contracts + +- **[contracts/relayer](contracts/relayer)** - Hardhat project for relayer smart contracts and deployment scripts ### Packages @@ -69,6 +72,11 @@ bun dev:backend bun dev:rebalancer ``` +**Relayer contract local node:** +```bash +bun dev:contracts:relayer +``` + ### Building **Build all projects:** @@ -91,6 +99,15 @@ bun build:sdk bun build:shared ``` +**Relayer contract:** +```bash +# Compile contracts +bun compile:contracts:relayer + +# Run contract tests +bun test:contracts:relayer +``` + ## Sub-Project Specific Instructions ### Frontend (apps/frontend) diff --git a/apps/api/.env.example b/apps/api/.env.example index 7cd26ef98..738c8f098 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 +ALFREDPAY_BASE_URL=your-alfredpay-base-url +ALFREDPAY_API_KEY=your-alfredpay-api-key +ALFREDPAY_API_SECRET=your-alfredpay-api-secret diff --git a/apps/api/src/api/controllers/alfredpay.controller.ts b/apps/api/src/api/controllers/alfredpay.controller.ts new file mode 100644 index 000000000..1feafa3b0 --- /dev/null +++ b/apps/api/src/api/controllers/alfredpay.controller.ts @@ -0,0 +1,420 @@ +import { + AlfredPayCountry, + AlfredPayStatus, + AlfredpayApiService, + AlfredpayCreateCustomerRequest, + AlfredpayCreateCustomerResponse, + AlfredpayCustomerType, + AlfredpayGetKybRedirectLinkResponse, + AlfredpayGetKycRedirectLinkRequest, + AlfredpayGetKycRedirectLinkResponse, + AlfredpayGetKycStatusResponse, + AlfredpayKybStatus, + AlfredpayKycStatus, + AlfredpayStatusRequest, + AlfredpayStatusResponse +} from "@vortexfi/shared"; +import { Request, Response } from "express"; +import logger from "../../config/logger"; +import AlfredPayCustomer from "../../models/alfredPayCustomer.model"; +import { SupabaseAuthService } from "../services/auth/supabase.service"; + +export class AlfredpayController { + private static mapKycStatus(status: AlfredpayKycStatus): AlfredPayStatus | null { + switch (status) { + case AlfredpayKycStatus.IN_REVIEW: + return AlfredPayStatus.Verifying; + case AlfredpayKycStatus.FAILED: + return AlfredPayStatus.Failed; + case AlfredpayKycStatus.COMPLETED: + return AlfredPayStatus.Success; + case AlfredpayKycStatus.CREATED: + default: + return null; // Do nothing + // TODO how do we map their UPDATE_REQUIRED required? what does it mean in terms of flow, for our user? + } + } + + 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({ + 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(); + + try { + const lastSubmission = await alfredpayService.getLastKycSubmission(alfredPayCustomer.alfredPayId); + + if (lastSubmission && lastSubmission.submissionId) { + const statusResponse = await alfredpayService.getKycStatus( + alfredPayCustomer.alfredPayId, + lastSubmission.submissionId + ); + + const newStatus = AlfredpayController.mapKycStatus(statusResponse.status); + const updateData: Partial = {}; + + if (newStatus && newStatus !== alfredPayCustomer.status) { + updateData.status = newStatus; + } + + if (newStatus === AlfredPayStatus.Failed && statusResponse.metadata?.failureReason) { + updateData.lastFailureReasons = [statusResponse.metadata.failureReason]; + } + + if (Object.keys(updateData).length > 0) { + await alfredPayCustomer.update(updateData); + } + } + } catch (error) { + logger.error("Error refreshing Alfredpay status:", error); + } + + const response: AlfredpayStatusResponse = { + country: alfredPayCustomer.country, + creationTime: alfredPayCustomer.createdAt.toISOString(), + status: alfredPayCustomer.status + }; + + res.json(response); + } catch (error) { + logger.error("Error finding Alfredpay customer:", error); + res.status(500).json({ error: "Internal server error" }); + } + } + + static async createIndividualCustomer(req: Request, res: Response) { + try { + const { country } = req.body as AlfredpayCreateCustomerRequest; + const userId = req.userId!; + + const user = await SupabaseAuthService.getUserProfile(userId); + if (!user || !user.email) { + return res.status(404).json({ error: "User not found or email missing" }); + } + + // Check if customer already exists in our DB + const existingDbCustomer = await AlfredPayCustomer.findOne({ + where: { country: country as AlfredPayCountry, userId } + }); + + if (existingDbCustomer) { + return res.status(400).json({ error: "Customer already exists" }); + } + + const alfredpayService = AlfredpayApiService.getInstance(); + + const newCustomer = await alfredpayService.createCustomer(user.email, AlfredpayCustomerType.INDIVIDUAL, country); + const customerId = newCustomer.customerId; + + await AlfredPayCustomer.create({ + alfredPayId: customerId, + country: country as AlfredPayCountry, + status: AlfredPayStatus.Consulted, + type: AlfredpayCustomerType.INDIVIDUAL, + userId + }); + + const response: AlfredpayCreateCustomerResponse = { + createdAt: new Date().toISOString() + }; + + res.json(response); + } catch (error) { + logger.error("Error creating Alfredpay customer:", error); + res.status(500).json({ error: "Internal server error" }); + } + } + + 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({ + where: { country: country as AlfredPayCountry, userId } + }); + + if (!alfredPayCustomer) { + return res.status(404).json({ error: "Alfredpay customer not found" }); + } + + if (alfredPayCustomer.status === AlfredPayStatus.Verifying || alfredPayCustomer.status === AlfredPayStatus.Success) { + return res.status(400).json({ error: "KYC is already verifying or completed" }); + } + + const alfredpayService = AlfredpayApiService.getInstance(); + + try { + const lastSubmission = await alfredpayService.getLastKycSubmission(alfredPayCustomer.alfredPayId); + if (lastSubmission && lastSubmission.submissionId) { + const statusRes = await alfredpayService.getKycStatus(alfredPayCustomer.alfredPayId, lastSubmission.submissionId); + if (statusRes.status === "COMPLETED" || statusRes.status === "IN_REVIEW") { + return res.status(400).json({ error: `KYC is in status ${statusRes.status}` }); + } + } + } catch (e) { + logger.info("No previous KYC submission found or error fetching it, proceeding."); + } + + const linkResponse = await alfredpayService.getKycRedirectLink(alfredPayCustomer.alfredPayId, country); + + res.json(linkResponse as AlfredpayGetKycRedirectLinkResponse); + } catch (error) { + logger.error("Error getting KYC redirect link:", error); + res.status(500).json({ error: "Internal server error" }); + } + } + + static async kycRedirectOpened(req: Request, res: Response) { + try { + const { country, type } = req.body as unknown as { country: string; type?: AlfredpayCustomerType }; + const userId = req.userId!; + const selectedType = type || AlfredpayCustomerType.INDIVIDUAL; + + const alfredPayCustomer = await AlfredPayCustomer.findOne({ + order: [["updatedAt", "DESC"]], + where: { country: country as AlfredPayCountry, type: selectedType, userId } + }); + + if (!alfredPayCustomer) { + return res.status(404).json({ error: "Alfredpay customer not found" }); + } + + await alfredPayCustomer.update({ status: AlfredPayStatus.LinkOpened }); + + res.json({ success: true }); + } catch (error) { + logger.error("Error marking KYC redirect opened:", error); + res.status(500).json({ error: "Internal server error" }); + } + } + + static async kycRedirectFinished(req: Request, res: Response) { + try { + const { country, type } = req.body as unknown as { country: string; type?: AlfredpayCustomerType }; + const userId = req.userId!; + const selectedType = type || AlfredpayCustomerType.INDIVIDUAL; + + const alfredPayCustomer = await AlfredPayCustomer.findOne({ + order: [["updatedAt", "DESC"]], + where: { country: country as AlfredPayCountry, type: selectedType, userId } + }); + + if (!alfredPayCustomer) { + return res.status(404).json({ error: "Alfredpay customer not found" }); + } + + await alfredPayCustomer.update({ status: AlfredPayStatus.UserCompleted }); + + res.json({ success: true }); + } catch (error) { + logger.error("Error marking KYC redirect finished:", error); + res.status(500).json({ error: "Internal server error" }); + } + } + + static async getKycStatus(req: Request, res: Response) { + try { + const { country, type } = req.query as unknown as { country: string; type?: AlfredpayCustomerType }; + const userId = req.userId!; + const selectedType = type || AlfredpayCustomerType.INDIVIDUAL; + + const alfredPayCustomer = await AlfredPayCustomer.findOne({ + order: [["updatedAt", "DESC"]], + where: { country: country as AlfredPayCountry, type: selectedType, userId } + }); + + if (!alfredPayCustomer) { + return res.status(404).json({ error: "Alfredpay customer not found" }); + } + + const alfredpayService = AlfredpayApiService.getInstance(); + const isBusiness = selectedType === AlfredpayCustomerType.BUSINESS; + + const lastSubmission = isBusiness + ? await alfredpayService.getLastKybSubmission(alfredPayCustomer.alfredPayId) + : await alfredpayService.getLastKycSubmission(alfredPayCustomer.alfredPayId); + + if (!lastSubmission || !lastSubmission.submissionId) { + return res.status(404).json({ error: "No KYC attempt found" }); + } + + const statusResponse = isBusiness + ? await alfredpayService.getKybStatus(alfredPayCustomer.alfredPayId, lastSubmission.submissionId) + : await alfredpayService.getKycStatus(alfredPayCustomer.alfredPayId, lastSubmission.submissionId); + + const newStatus = AlfredpayController.mapKycStatus(statusResponse.status); + const updateData: Partial = {}; + + if (newStatus && newStatus !== alfredPayCustomer.status) { + updateData.status = newStatus; + } + + if (newStatus === AlfredPayStatus.Failed && statusResponse.metadata?.failureReason) { + updateData.lastFailureReasons = [statusResponse.metadata.failureReason]; + } + + if (Object.keys(updateData).length > 0) { + await alfredPayCustomer.update(updateData); + } + + const response: AlfredpayGetKycStatusResponse = { + alfred_pay_id: alfredPayCustomer.alfredPayId, + country: alfredPayCustomer.country, + lastFailure: updateData.lastFailureReasons?.[0] || alfredPayCustomer.lastFailureReasons?.[0], // Get the latest failure reason + status: (newStatus || alfredPayCustomer.status) as AlfredPayStatus, + updated_at: alfredPayCustomer.updatedAt.toISOString() + }; + + res.json(response); + } catch (error) { + logger.error("Error getting KYC status:", error); + res.status(500).json({ error: "Internal server error" }); + } + } + + static async retryKyc(req: Request, res: Response) { + try { + const { country, type } = req.body as { country: string; type?: AlfredpayCustomerType }; + const userId = req.userId!; + const selectedType = type || AlfredpayCustomerType.INDIVIDUAL; + + const alfredPayCustomer = await AlfredPayCustomer.findOne({ + order: [["updatedAt", "DESC"]], + where: { country: country as AlfredPayCountry, type: selectedType, userId } + }); + + if (!alfredPayCustomer) { + return res.status(404).json({ error: "Alfredpay customer not found" }); + } + + const alfredpayService = AlfredpayApiService.getInstance(); + const isBusiness = selectedType === AlfredpayCustomerType.BUSINESS; + + const lastSubmission = isBusiness + ? await alfredpayService.getLastKybSubmission(alfredPayCustomer.alfredPayId) + : await alfredpayService.getLastKycSubmission(alfredPayCustomer.alfredPayId); + + if (!lastSubmission || !lastSubmission.submissionId) { + return res.status(400).json({ error: "No KYC submission found to retry" }); + } + + const statusRes = isBusiness + ? await alfredpayService.getKybStatus(alfredPayCustomer.alfredPayId, lastSubmission.submissionId) + : await alfredpayService.getKycStatus(alfredPayCustomer.alfredPayId, lastSubmission.submissionId); + + if (statusRes.status !== AlfredpayKycStatus.FAILED) { + return res.status(400).json({ error: `Cannot retry KYC. Current status is ${statusRes.status}` }); + } + + if (isBusiness) { + await alfredpayService.retryKybSubmission(alfredPayCustomer.alfredPayId, lastSubmission.submissionId); + const linkResponse = await alfredpayService.getKybRedirectLink(alfredPayCustomer.alfredPayId); + await alfredPayCustomer.update({ status: AlfredPayStatus.Consulted }); + return res.json(linkResponse as AlfredpayGetKybRedirectLinkResponse); + } else { + await alfredpayService.retryKycSubmission(alfredPayCustomer.alfredPayId, lastSubmission.submissionId); + const linkResponse = await alfredpayService.getKycRedirectLink(alfredPayCustomer.alfredPayId, country); + await alfredPayCustomer.update({ status: AlfredPayStatus.Consulted }); + return res.json(linkResponse as AlfredpayGetKycRedirectLinkResponse); + } + } catch (error) { + logger.error("Error retrying KYC:", error); + res.status(500).json({ error: "Internal server error" }); + } + } + + static async createBusinessCustomer(req: Request, res: Response) { + try { + const { country } = req.body as { country: string }; + const userId = req.userId!; + + const user = await SupabaseAuthService.getUserProfile(userId); + if (!user || !user.email) { + return res.status(404).json({ error: "User not found or email missing" }); + } + + const type = AlfredpayCustomerType.BUSINESS; + + const existingDbCustomer = await AlfredPayCustomer.findOne({ + where: { country: country as AlfredPayCountry, type, userId } + }); + + if (existingDbCustomer) { + return res.status(400).json({ error: "Business customer already exists" }); + } + + const alfredpayService = AlfredpayApiService.getInstance(); + + const newCustomer = await alfredpayService.createCustomer(user.email, type, country); + const customerId = newCustomer.customerId; + + await AlfredPayCustomer.create({ + alfredPayId: customerId, + country: country as AlfredPayCountry, + status: AlfredPayStatus.Consulted, + type, + userId + }); + + const response: AlfredpayCreateCustomerResponse = { + createdAt: new Date().toISOString() + }; + + res.json(response); + } catch (error) { + logger.error("Error creating Alfredpay business customer:", error); + res.status(500).json({ error: "Internal server error" }); + } + } + + static async getKybRedirectLink(req: Request, res: Response) { + try { + const { country } = req.query as unknown as AlfredpayGetKycRedirectLinkRequest; + const userId = req.userId!; + + const alfredPayCustomer = await AlfredPayCustomer.findOne({ + where: { country: country as AlfredPayCountry, type: AlfredpayCustomerType.BUSINESS, userId } + }); + + if (!alfredPayCustomer) { + return res.status(404).json({ error: "Alfredpay business customer not found" }); + } + + if (alfredPayCustomer.status === AlfredPayStatus.Verifying || alfredPayCustomer.status === AlfredPayStatus.Success) { + return res.status(400).json({ error: "KYB is already verifying or completed" }); + } + + const alfredpayService = AlfredpayApiService.getInstance(); + + try { + const lastSubmission = await alfredpayService.getLastKybSubmission(alfredPayCustomer.alfredPayId); + if (lastSubmission && lastSubmission.submissionId) { + const statusRes = await alfredpayService.getKybStatus(alfredPayCustomer.alfredPayId, lastSubmission.submissionId); + if (statusRes.status === AlfredpayKybStatus.COMPLETED || statusRes.status === AlfredpayKybStatus.IN_REVIEW) { + return res.status(400).json({ error: `KYB is in status ${statusRes.status}` }); + } + } + } catch (e) { + logger.info("No previous KYB submission found or error fetching it, proceeding."); + } + + const linkResponse = await alfredpayService.getKybRedirectLink(alfredPayCustomer.alfredPayId); + + res.json(linkResponse as AlfredpayGetKybRedirectLinkResponse); + } catch (error) { + logger.error("Error getting KYB redirect link:", error); + res.status(500).json({ error: "Internal server error" }); + } + } +} diff --git a/apps/api/src/api/middlewares/alfredpay.middleware.ts b/apps/api/src/api/middlewares/alfredpay.middleware.ts new file mode 100644 index 000000000..f0b23110a --- /dev/null +++ b/apps/api/src/api/middlewares/alfredpay.middleware.ts @@ -0,0 +1,16 @@ +import { AlfredPayCountry } from "@vortexfi/shared"; +import { NextFunction, Request, Response } from "express"; + +export const validateResultCountry = (req: Request, res: Response, next: NextFunction) => { + const country = (req.query.country || req.body.country) as string; + + if (!country) { + return res.status(400).json({ error: "Country is required" }); + } + + if (!Object.values(AlfredPayCountry).includes(country as AlfredPayCountry)) { + return res.status(400).json({ error: `Invalid country: ${country}` }); + } + + next(); +}; diff --git a/apps/api/src/api/middlewares/validators.ts b/apps/api/src/api/middlewares/validators.ts index e7b184d9e..303090653 100644 --- a/apps/api/src/api/middlewares/validators.ts +++ b/apps/api/src/api/middlewares/validators.ts @@ -5,6 +5,7 @@ import { Currency, GetWidgetUrlLocked, GetWidgetUrlRefresh, + isSupportedFiatCurrency, isValidAveniaAccountType, isValidCurrencyForDirection, isValidDirection, @@ -18,7 +19,7 @@ import { VALID_FIAT_CURRENCIES, VALID_PROVIDERS } from "@vortexfi/shared"; -import { RequestHandler } from "express"; +import { RequestHandler, Response } from "express"; import httpStatus from "http-status"; import logger from "../../config/logger"; import { CONTACT_SHEET_HEADER_VALUES } from "../controllers/contact.controller"; @@ -379,6 +380,20 @@ export const validateSubaccountCreation: RequestHandler = (req, res, next) => { next(); }; +const validateSupportedFiatCurrency = ( + rampType: RampDirection, + inputCurrency: unknown, + outputCurrency: unknown, + res: Response +): boolean => { + const fiatCurrency = rampType === RampDirection.BUY ? inputCurrency : outputCurrency; + if (!isSupportedFiatCurrency(fiatCurrency)) { + res.status(httpStatus.BAD_REQUEST).json({ message: QuoteError.UnsupportedCurrency }); + return false; + } + return true; +}; + export const validateCreateQuoteInput: RequestHandler = (req, res, next) => { if (req.body) { req.body.inputCurrency = normalizeAxlUsdcCurrency(req.body.inputCurrency) as CreateQuoteRequest["inputCurrency"]; @@ -397,6 +412,10 @@ export const validateCreateQuoteInput: RequestHandler { + const { alfredpayTransactionId, alfredpayOfframpTransferTxHash } = state.state as StateMetadata; + + if (!alfredpayTransactionId) { + throw new Error("AlfredpayOfframpTransferHandler: Missing alfredpayTransactionId in state."); + } + + const alfredpayApiService = AlfredpayApiService.getInstance(); + const evmClientManager = EvmClientManager.getInstance(); + + const alfredpayTx = await alfredpayApiService.getOfframpTransaction(alfredpayTransactionId); + if (!alfredpayTx) { + throw new Error(`AlfredpayOfframpTransferHandler: Transaction ${alfredpayTransactionId} not found in Alfredpay.`); + } + + const expirationDate = new Date(alfredpayTx.expiration); + if (expirationDate < new Date()) { + logger.error(`AlfredpayOfframpTransferHandler: Alfredpay transaction ${alfredpayTransactionId} has expired.`); + return this.transitionToNextPhase(state, "failed"); + } + + if (!alfredpayOfframpTransferTxHash) { + logger.info(`AlfredpayOfframpTransferHandler: Executing final transfer for Alfredpay offramp ${alfredpayTransactionId}`); + + const { txData: offrampTransfer } = this.getPresignedTransaction(state, "alfredpayOfframpTransfer"); + + const txHash = await evmClientManager.sendRawTransactionWithRetry( + Networks.Polygon as EvmNetworks, + offrampTransfer as `0x${string}` + ); + + await state.update({ + state: { + ...state.state, + alfredpayOfframpTransferTxHash: txHash + } + }); + + logger.info(`AlfredpayOfframpTransferHandler: Final transfer sent. Hash: ${txHash}`); + } else { + try { + const client = evmClientManager.getClient(Networks.Polygon as EvmNetworks); + const receipt = await client.getTransactionReceipt({ hash: alfredpayOfframpTransferTxHash as `0x${string}` }); + if (receipt.status !== "success") { + throw new Error( + `AlfredpayOfframpTransferHandler: Final transfer transaction ${alfredpayOfframpTransferTxHash} failed on chain.` + ); + } + } catch (error: any) { + if (error?.name !== "TransactionReceiptNotFoundError") { + throw error; + } + } + } + + try { + await this.pollAlfredpayOfframpStatus(alfredpayTransactionId, ALFREDPAY_POLL_INTERVAL_MS); + } catch (error: any) { + if (error?.kind === "failed") { + logger.error(`AlfredpayOfframpTransferHandler: Alfredpay offramp FAILED. Reason: ${error.failureReason ?? "unknown"}`); + return this.transitionToNextPhase(state, "failed"); + } + + throw this.createRecoverableError( + `AlfredpayOfframpTransferHandler: Error polling Alfredpay status: ${error instanceof Error ? error.message : String(error)}` + ); + } + + return this.transitionToNextPhase(state, "complete"); + } + + private async pollAlfredpayOfframpStatus(transactionId: string, intervalMs: number): Promise { + const alfredpayApiService = AlfredpayApiService.getInstance(); + const startTime = Date.now(); + + return new Promise((resolve, reject) => { + const poll = async () => { + if (Date.now() - startTime > ALFREDPAY_OFFRAMP_TIMEOUT_MS) { + reject(new Error(`AlfredpayOfframpTransferHandler: Polling timed out after ${ALFREDPAY_OFFRAMP_TIMEOUT_MS}ms`)); + return; + } + + try { + const response = await alfredpayApiService.getOfframpTransaction(transactionId); + const { status } = response; + + if (status === AlfredpayOfframpStatus.COMPLETED) { + resolve(); + return; + } + + if (status === AlfredpayOfframpStatus.FAILED) { + reject({ failureReason: "Alfredpay reported FAILED status", kind: "failed" as const }); + return; + } + + logger.debug(`AlfredpayOfframpTransferHandler: Alfredpay offramp ${transactionId} status: ${status}`); + } catch (error: any) { + logger.warn(`AlfredpayOfframpTransferHandler: Error polling Alfredpay status for ${transactionId}: ${error}`); + } + + setTimeout(poll, intervalMs); + }; + + poll(); + }); + } +} + +export default new AlfredpayOfframpTransferHandler(); diff --git a/apps/api/src/api/services/phases/handlers/alfredpay-onramp-mint-handler.ts b/apps/api/src/api/services/phases/handlers/alfredpay-onramp-mint-handler.ts new file mode 100644 index 000000000..d23b9810f --- /dev/null +++ b/apps/api/src/api/services/phases/handlers/alfredpay-onramp-mint-handler.ts @@ -0,0 +1,144 @@ +import { + AlfredpayApiService, + AlfredpayOnrampStatus, + BalanceCheckError, + BalanceCheckErrorType, + checkEvmBalancePeriodically, + ERC20_USDC_POLYGON, + ERC20_USDC_POLYGON_DECIMALS, + Networks, + RampPhase +} from "@vortexfi/shared"; +import logger from "../../../../config/logger"; +import QuoteTicket from "../../../../models/quoteTicket.model"; +import RampState from "../../../../models/rampState.model"; +import { BasePhaseHandler } from "../base-phase-handler"; +import { StateMetadata } from "../meta-state-types"; + +const ALFREDPAY_ONRAMP_MINT_TIMEOUT_MS = 5 * 60 * 1000; +const BALANCE_POLL_INTERVAL_MS = 5000; +const ALFREDPAY_POLL_INTERVAL_MS = 5000; + +export class AlfredpayOnrampMintHandler extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "alfredpayOnrampMint"; + } + + protected async executePhase(state: RampState): Promise { + const { evmEphemeralAddress, alfredpayTransactionId } = state.state as StateMetadata; + + if (!evmEphemeralAddress) { + throw new Error("AlfredpayOnrampMintHandler: Missing evmEphemeralAddress in state. This is a bug."); + } + + if (!alfredpayTransactionId) { + throw new Error("AlfredpayOnrampMintHandler: Missing alfredpayTransactionId in state. This is a bug."); + } + + const quote = await QuoteTicket.findByPk(state.quoteId); + if (!quote) { + throw new Error("AlfredpayOnrampMintHandler: Quote not found for the given state."); + } + + if (!quote.metadata.alfredpayMint?.outputAmountRaw) { + throw new Error("AlfredpayOnrampMintHandler: Missing 'alfredpayMint.outputAmountRaw' in quote metadata."); + } + + const expectedAmountRaw = quote.metadata.alfredpayMint.outputAmountRaw; + + logger.info( + `AlfredpayOnrampMintHandler: Waiting for ${expectedAmountRaw} USDC (raw, ${ERC20_USDC_POLYGON_DECIMALS} decimals) ` + + `on Polygon at ephemeral address ${evmEphemeralAddress}. Alfredpay transactionId: ${alfredpayTransactionId}` + ); + + const balanceCheckPromise = checkEvmBalancePeriodically( + ERC20_USDC_POLYGON, + evmEphemeralAddress, + expectedAmountRaw, + BALANCE_POLL_INTERVAL_MS, + ALFREDPAY_ONRAMP_MINT_TIMEOUT_MS, + Networks.Polygon + ); + + const alfredpayPollingPromise = this.pollAlfredpayOnrampStatus(alfredpayTransactionId, state, ALFREDPAY_POLL_INTERVAL_MS); + + // - balanceCheckPromise resolves when the USDC balance is met → proceed, or rejects if timeout → recoverable error. + // - alfredpayPollingPromise rejects if FAILED → transition to failed. Not recoverable + // (it does NOT resolve on ON_CHAIN_COMPLETED, because we trust the balance check) + try { + await Promise.race([balanceCheckPromise, alfredpayPollingPromise]); + } catch (error: any) { + if (error?.kind === "failed") { + logger.error(`AlfredpayOnrampMintHandler: Alfredpay onramp FAILED. Reason: ${error.failureReason ?? "unknown"}`); + return this.transitionToNextPhase(state, "failed"); + } + + if (error instanceof BalanceCheckError && error.type === BalanceCheckErrorType.Timeout) { + throw this.createRecoverableError( + `AlfredpayOnrampMintHandler: Balance check timed out after ${ALFREDPAY_ONRAMP_MINT_TIMEOUT_MS}ms` + ); + } + + // Safe to make generic recovery. + throw this.createRecoverableError( + `AlfredpayOnrampMintHandler: Failed to check balance or poll Alfredpay status: ${error instanceof Error ? error.message : String(error)}` + ); + } + + logger.info( + `AlfredpayOnrampMintHandler: USDC balance reached on Polygon ephemeral ${evmEphemeralAddress}. Proceeding to fundEphemeral.` + ); + + return this.transitionToNextPhase(state, "fundEphemeral"); + } + + private async pollAlfredpayOnrampStatus(transactionId: string, state: RampState, intervalMs: number): Promise { + const alfredpayApiService = AlfredpayApiService.getInstance(); + + return new Promise((_, reject) => { + const poll = async () => { + try { + const response = await alfredpayApiService.getOnrampTransaction(transactionId); + const { status, metadata } = response; + + if (status === AlfredpayOnrampStatus.FAILED) { + reject({ failureReason: metadata?.failureReason, kind: "failed" as const }); + return; + } + + if (status === AlfredpayOnrampStatus.ON_CHAIN_COMPLETED) { + // Save the txHash into ramp state, but do NOT resolve. + // We trust the balance check as ground truth for proceeding. + const txHash = metadata?.txHash; + if (txHash) { + const currentState = state.state as StateMetadata; + if (!currentState.alfredpayOnrampMintTxHash) { + await state.update({ + state: { + ...currentState, + alfredpayOnrampMintTxHash: txHash + } + }); + logger.info(`AlfredpayOnrampMintHandler: Saved alfredpayOnrampMintTxHash=${txHash} for ramp ${state.id}`); + } + } + return; + } + } catch (error: any) { + if (error?.kind === "failed") { + reject(error); + return; + } + + logger.warn(`AlfredpayOnrampMintHandler: Error polling Alfredpay status for ${transactionId}: ${error}`); + } + + setTimeout(poll, intervalMs); + }; + + poll(); + }); + } +} + +export default new AlfredpayOnrampMintHandler(); 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 index 29c05cf2f..b197549e5 100644 --- a/apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts +++ b/apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts @@ -24,10 +24,6 @@ export class DestinationTransferHandler extends BasePhaseHandler { 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) { 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 index 11dc9654b..da7229287 100644 --- a/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts +++ b/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts @@ -2,7 +2,9 @@ import { checkEvmBalanceForToken, EvmClientManager, EvmNetworks, + EvmToken, EvmTokenDetails, + FiatToken, getEvmBalance, getNetworkId, getOnChainTokenDetails, @@ -13,11 +15,12 @@ import { Networks, RampCurrency, RampDirection, - RampPhase + RampPhase, + TokenType } from "@vortexfi/shared"; import Big from "big.js"; import { encodeFunctionData, erc20Abi, TransactionReceipt } from "viem"; -import { privateKeyToAccount } from "viem/accounts"; +import { generatePrivateKey, privateKeyToAccount, privateKeyToAddress } 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"; @@ -47,31 +50,62 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { return "finalSettlementSubsidy"; } + private getNextPhase(state: RampState, quote: QuoteTicket): RampPhase { + return state.type === RampDirection.SELL && quote.outputCurrency === FiatToken.USD + ? "alfredpayOfframpTransfer" + : "destinationTransfer"; + } + 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("FinalSettlementSubsidyHandler: Quote not found for the given state"); } - const outTokenDetails = getOnChainTokenDetails(quote.network, quote.outputCurrency) as EvmTokenDetails; - if (!outTokenDetails) { - throw new Error( - `FinalSettlementSubsidyHandler: Unsupported output token ${quote.outputCurrency} for network ${quote.network}` - ); + const outTokenDetails = + state.type === RampDirection.BUY + ? (getOnChainTokenDetails(quote.network, quote.outputCurrency) as EvmTokenDetails) + : getOnChainTokenDetails(Networks.Polygon, EvmToken.USDC); + + if (!outTokenDetails || outTokenDetails.type === TokenType.AssetHub) { + // Should not happen. Destination onchain token or USDC must be defined. + throw new Error("FinalSettlementSubsidyHandler: Output currency is not an EVM token"); } const isNative = isNativeEvmToken(outTokenDetails); - const expectedAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outTokenDetails.decimals); - const destinationNetwork = quote.network as EvmNetworks; + let expectedAmountRaw: Big | undefined; + switch (state.type) { + case RampDirection.BUY: + if (quote.inputCurrency === FiatToken.USD) { + if (!quote.metadata.alfredpayMint) { + throw new Error("FinalSettlementSubsidyHandler: Missing AlfredPay mint metadata for USD onramp quote"); + } + expectedAmountRaw = Big(quote.metadata.alfredpayMint.outputAmountRaw); + break; + } + expectedAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outTokenDetails.decimals); + break; + + case RampDirection.SELL: + if (quote.outputCurrency === FiatToken.USD) { + if (!quote.metadata.alfredpayOfframp) { + throw new Error("FinalSettlementSubsidyHandler: Missing AlfredPay offramp metadata for USD sell quote"); + } + expectedAmountRaw = Big(quote.metadata.alfredpayOfframp.inputAmountRaw); + break; + } + break; + } + + if (!expectedAmountRaw) { + throw new Error("FinalSettlementSubsidyHandler: Unable to determine expected amount for subsidy"); + } + + const destinationNetwork = state.type === RampDirection.BUY ? (quote.network as EvmNetworks) : Networks.Polygon; const publicClient = evmClientManager.getClient(destinationNetwork); const ephemeralAddress = state.state.evmEphemeralAddress as `0x${string}`; @@ -87,7 +121,7 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { logger.info( `FinalSettlementSubsidyHandler: Transaction ${state.state.finalSettlementSubsidyTxHash} already successful. Skipping.` ); - return this.transitionToNextPhase(state, "destinationTransfer"); + return this.transitionToNextPhase(state, this.getNextPhase(state, quote)); } } @@ -114,11 +148,11 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { logger.info( `FinalSettlementSubsidyHandler: Actual balance (${actualBalance.toString()}) meets expected amount. No subsidy needed.` ); - return this.transitionToNextPhase(state, "destinationTransfer"); + return this.transitionToNextPhase(state, this.getNextPhase(state, quote)); } logger.info( - `FinalSettlementSubsidyHandler: Subsidizing ${subsidyAmountRaw.toString()} units of ${isNative ? "native token" : outTokenDetails.assetSymbol} to ${ephemeralAddress}` + `FinalSettlementSubsidyHandler: Subsidizing ${subsidyAmountRaw.toString()} raw units of ${isNative ? "native token" : outTokenDetails.assetSymbol} to ${ephemeralAddress}` ); // 4. Top up funding account if insufficient balance (ERC-20 only; native tokens are transferred directly) @@ -136,20 +170,26 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { const oneUsdInNativeRaw = multiplyByPowerOfTen(oneUsdInNative, nativeToken.decimals).toFixed(0); 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 + + // Use a placeholder address for this query to prevent rate limiting issues + const placeholderAddress = privateKeyToAddress(generatePrivateKey()); + const testRouteResult = await getRoute( + { + bypassGuardrails: true, + enableExpress: true, + fromAddress: placeholderAddress, + fromAmount: oneUsdInNativeRaw, + fromChain: chainId, + fromToken: NATIVE_TOKEN_ADDRESS, + slippageConfig: { + autoMode: 1 + }, + toAddress: placeholderAddress, + toChain: chainId, + toToken: outTokenDetails.erc20AddressSourceChain }, - toAddress: fundingAccount.address, - toChain: chainId, - toToken: outTokenDetails.erc20AddressSourceChain - }); + { useCache: true } + ); const { route: testRoute } = testRouteResult.data; const rate = new Big(testRoute.estimate.toAmount).div(new Big(oneUsdInNativeRaw)); @@ -159,7 +199,7 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { `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. + // Check the amount of native is not higher than cap, cap specified in units of usd. const requiredNative = new Big(requiredNativeRaw).div(new Big(10).pow(nativeToken.decimals)); const requiredNativeInUsd = await priceFeedService.convertCurrency( requiredNative.toString(), @@ -173,20 +213,24 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { ); } - const swapRouteResult = await getRoute({ - bypassGuardrails: true, - enableExpress: true, - fromAddress: fundingAccount.address, - fromAmount: requiredNativeRaw, - fromChain: chainId, - fromToken: NATIVE_TOKEN_ADDRESS, - slippageConfig: { - autoMode: 1 + 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 }, - toAddress: fundingAccount.address, - toChain: chainId, - toToken: outTokenDetails.erc20AddressSourceChain - }); + // Do not use cache for routes that will be executed on-chain + { useCache: false } + ); const { route: swapRoute } = swapRouteResult.data; @@ -275,7 +319,7 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { } }); - return this.transitionToNextPhase(state, "destinationTransfer"); + return this.transitionToNextPhase(state, this.getNextPhase(state, quote)); } catch (error) { throw this.createRecoverableError( `FinalSettlementSubsidyHandler: Error during phase execution - ${(error as Error).message}` 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 906967151..3ffc1b0cc 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 @@ -64,19 +64,32 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { return "fundEphemeral"; } - protected getRequiresPendulumEphemeralAddress(state: RampState, inputCurrency?: string): boolean { - // Pendulum ephemeral address is required for all cases except when doing a Monerium to EVM onramp - if (isOnramp(state) && inputCurrency === FiatToken.EURC && state.to !== Networks.AssetHub) { + protected getRequiresPendulumEphemeralAddress(state: RampState, inputCurrency?: string, outputCurrency?: string): boolean { + // Pendulum ephemeral address is required for all cases except when doing a Monerium/Alfredpay to EVM onramp, + // or alfredpay offramp + if ( + isOnramp(state) && + (inputCurrency === FiatToken.EURC || inputCurrency === FiatToken.USD) && + state.to !== Networks.AssetHub + ) { + return false; + } + + if (!isOnramp(state) && outputCurrency === FiatToken.USD) { return false; } return true; } - protected getRequiresPolygonEphemeralAddress(state: RampState, inputCurrency?: string): boolean { - // Only required for Monerium onramps. - if (isOnramp(state) && inputCurrency === FiatToken.EURC) { + protected getRequiresPolygonEphemeralAddress(state: RampState, inputCurrency?: string, outputCurrency?: string): boolean { + // Only required for Monerium and Alfredpay onramps and offramps. + if (isOnramp(state) && (inputCurrency === FiatToken.EURC || inputCurrency === FiatToken.USD)) { return true; } + if (!isOnramp(state) && outputCurrency === FiatToken.USD) { + return true; + } + return false; } @@ -110,8 +123,16 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { const moonbeamNode = await apiManager.getApi("moonbeam"); const { evmEphemeralAddress, substrateEphemeralAddress } = state.state as StateMetadata; - const requiresPendulumEphemeralAddress = this.getRequiresPendulumEphemeralAddress(state, quote.inputCurrency); - const requiresPolygonEphemeralAddress = this.getRequiresPolygonEphemeralAddress(state, quote.inputCurrency); + const requiresPendulumEphemeralAddress = this.getRequiresPendulumEphemeralAddress( + state, + quote.inputCurrency, + quote.outputCurrency + ); + const requiresPolygonEphemeralAddress = this.getRequiresPolygonEphemeralAddress( + state, + quote.inputCurrency, + quote.outputCurrency + ); const requiresMoonbeamEphemeralAddress = this.getRequiresMoonbeamEphemeralAddress(state, quote.inputCurrency); const requiresDestinationEvmFunding = this.getRequiresDestinationEvmFunding(state); @@ -178,7 +199,7 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { await fundMoonbeamEphemeralAccount(evmEphemeralAddress); } - if (isOnramp(state) && !isPolygonFunded) { + if (!isPolygonFunded) { logger.info(`Funding polygon ephemeral account ${evmEphemeralAddress}`); await this.fundPolygonEphemeralAccount(state); } else if (requiresPolygonEphemeralAddress) { @@ -213,6 +234,10 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { if (isOnramp(state) && quote.inputCurrency === FiatToken.BRL) { return "moonbeamToPendulumXcm"; } + // alfredpay onramp case + if (isOnramp(state) && quote.inputCurrency === FiatToken.USD) { + return "squidRouterSwap"; + } // monerium onramp case if (isOnramp(state) && quote.inputCurrency === FiatToken.EURC) { return "moneriumOnrampSelfTransfer"; @@ -221,6 +246,8 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { // off ramp cases if (state.type === RampDirection.SELL && state.from === Networks.AssetHub) { return "distributeFees"; + } else if (state.type === RampDirection.SELL && quote.outputCurrency === FiatToken.USD) { + return "finalSettlementSubsidy"; } else { return "moonbeamToPendulum"; // Via contract.subsidizePreSwap } diff --git a/apps/api/src/api/services/phases/handlers/initial-phase-handler.ts b/apps/api/src/api/services/phases/handlers/initial-phase-handler.ts index 657d62885..a3c3c0507 100644 --- a/apps/api/src/api/services/phases/handlers/initial-phase-handler.ts +++ b/apps/api/src/api/services/phases/handlers/initial-phase-handler.ts @@ -38,6 +38,10 @@ export class InitialPhaseHandler extends BasePhaseHandler { return this.transitionToNextPhase(state, "brlaOnrampMint"); } else if (state.type === RampDirection.BUY && quote.inputCurrency === FiatToken.EURC) { return this.transitionToNextPhase(state, "moneriumOnrampMint"); + } else if (state.type === RampDirection.BUY && quote.inputCurrency === FiatToken.USD) { + return this.transitionToNextPhase(state, "alfredpayOnrampMint"); + } else if (state.type === RampDirection.SELL && quote.outputCurrency === FiatToken.USD) { + return this.transitionToNextPhase(state, "squidRouterPermitExecute"); } return this.transitionToNextPhase(state, "fundEphemeral"); 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 28e42ade0..a1f8382c5 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 @@ -120,7 +120,7 @@ export class MoonbeamToPendulumPhaseHandler extends BasePhaseHandler { if (!receipt || receipt.status !== "success") { logger.error(`MoonbeamToPendulumPhaseHandler: Transaction ${obtainedHash} failed or was not found`); attempt++; - // Wait for 20 seconds to allow the network to settle the squidrouter transaction + // Wait for 20 seconds to allow the network to settle the squidRouter transaction await new Promise(resolve => setTimeout(resolve, 20000)); } } 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 46b514f4f..f4d9fdf3a 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 @@ -377,7 +377,8 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { private async getSquidrouterStatus(swapHash: string, state: RampState, quote: QuoteTicket): Promise { try { // Always Polygon for Monerium onramp, Moonbeam for BRL - const fromChain = quote.inputCurrency === FiatToken.EURC ? Networks.Polygon : Networks.Moonbeam; + const fromChain = + quote.inputCurrency === FiatToken.EURC || quote.inputCurrency === FiatToken.USD ? Networks.Polygon : Networks.Moonbeam; const fromChainId = getNetworkId(fromChain)?.toString(); const toChain = quote.to === Networks.AssetHub ? Networks.Moonbeam : quote.to; const toChainId = getNetworkId(toChain)?.toString(); 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 4e7d79cdf..0d43c57c0 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 @@ -1,5 +1,6 @@ import { EvmClientManager, + EvmToken, FiatToken, getNetworkFromDestination, getNetworkId, @@ -42,11 +43,21 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { protected async executePhase(state: RampState): Promise { logger.info(`Executing squidRouter phase for ramp ${state.id}`); + const quote = await QuoteTicket.findByPk(state.quoteId); + if (!quote) { + throw new Error("Quote not found for the given state"); + } + if (state.type === RampDirection.SELL) { logger.info("SquidRouter phase is not supported for off-ramp"); return state; } + // handle special "singularity" case: Alfredpay onrapm USDC in Polygon. + if (quote.to === Networks.Polygon && quote.outputCurrency === EvmToken.USDC) { + return this.transitionToNextPhase(state, "destinationTransfer"); + } + try { // Get the presigned transactions for this phase const approveTransaction = this.getPresignedTransaction(state, "squidRouterApprove"); @@ -126,7 +137,7 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { throw new Error(`Quote not found for ramp ${state.id}`); } - if (quote.inputCurrency === FiatToken.EURC) { + if (quote.inputCurrency === FiatToken.EURC || quote.inputCurrency === FiatToken.USD) { return this.polygonClient; } else if (quote.inputCurrency === FiatToken.BRL) { return this.moonbeamClient; diff --git a/apps/api/src/api/services/phases/handlers/squidrouter-permit-execution-handler.ts b/apps/api/src/api/services/phases/handlers/squidrouter-permit-execution-handler.ts new file mode 100644 index 000000000..4c71cf573 --- /dev/null +++ b/apps/api/src/api/services/phases/handlers/squidrouter-permit-execution-handler.ts @@ -0,0 +1,170 @@ +import { + EvmClientManager, + getNetworkFromDestination, + isNetworkEVM, + isSignedTypedDataArray, + Networks, + RampPhase, + SignedTypedData +} from "@vortexfi/shared"; +import { recoverTypedDataAddress } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import logger from "../../../../config/logger"; +import { MOONBEAM_EXECUTOR_PRIVATE_KEY } from "../../../../constants/constants"; +import { tokenRelayerAbi } from "../../../../contracts/TokenRelayer"; +import RampState from "../../../../models/rampState.model"; +import { PhaseError } from "../../../errors/phase-error"; +import { RELAYER_ADDRESS } from "../../transactions/offramp/routes/evm-to-alfredpay"; +import { BasePhaseHandler } from "../base-phase-handler"; +import { StateMetadata } from "../meta-state-types"; + +// Phase description: call the relayer contract's `execute` function with both the token permit and +// the signed squidrouter call. +export class SquidrouterPermitExecuteHandler extends BasePhaseHandler { + private evmClientManager: EvmClientManager; + + constructor() { + super(); + this.evmClientManager = EvmClientManager.getInstance(); + } + + public getPhaseName(): RampPhase { + return "squidRouterPermitExecute"; + } + + protected async executePhase(state: RampState): Promise { + logger.info(`Executing squidRouterPermitExecute phase for ramp ${state.id}`); + + const fromNetwork = getNetworkFromDestination(state.from); + + if (!fromNetwork || !isNetworkEVM(fromNetwork)) { + throw this.createUnrecoverableError(`Unsupported network for squidRouterPermitExecute phase: ${state.from}`); + } + + try { + const existingHash = state.state.squidRouterPermitExecutionHash || null; + + if (existingHash) { + logger.info(`Found existing squidRouter permit execution hash for ramp ${state.id}: ${existingHash}`); + + try { + const publicClient = this.evmClientManager.getClient(fromNetwork); + const receipt = await publicClient.waitForTransactionReceipt({ + hash: existingHash as `0x${string}` + }); + + if (receipt && receipt.status === "success") { + logger.info(`Existing squidRouter permit execution transaction was successful for ramp ${state.id}`); + return this.transitionToNextPhase(state, "fundEphemeral"); + } else { + logger.info( + `Existing squidRouter permit execution transaction was not successful (status: ${receipt?.status}), will retry` + ); + } + } catch (error) { + logger.info(`Could not verify existing transaction status: ${error}, will retry`); + } + } + + const permitExecuteTransaction = this.getPresignedTransaction(state, "squidRouterPermitExecute"); + if (!permitExecuteTransaction) { + throw this.createUnrecoverableError("Missing presigned transaction for squidRouterPermitExecute phase"); + } + + // For this special phase, txData is of type SignedTypedData[], where the first element is the permit typed data and the second element is the payload typed data + const signedTypedDataArray = permitExecuteTransaction.txData as SignedTypedData[]; + if (!isSignedTypedDataArray(signedTypedDataArray) || signedTypedDataArray.length !== 2) { + throw this.createUnrecoverableError("Invalid txData format: expected array of 2 SignedTypedData objects"); + } + + const [permitTypedData, payloadTypedData] = signedTypedDataArray; + + const permitSignature = permitTypedData.signature; + if (!permitSignature) { + throw this.createUnrecoverableError("Permit signature not found or invalid format"); + } + const permitSig = permitSignature as { v: number; r: `0x${string}`; s: `0x${string}` }; + const { v: permitV, r: permitR, s: permitS } = permitSig; + + const payloadSignature = payloadTypedData.signature; + if (!payloadSignature) { + throw this.createUnrecoverableError("Payload signature not found or invalid format"); + } + const payloadSig = payloadSignature as { v: number; r: `0x${string}`; s: `0x${string}` }; + const { v: payloadV, r: payloadR, s: payloadS } = payloadSig; + + const permitMessage = permitTypedData.message; + const token = permitTypedData.domain.verifyingContract as `0x${string}`; + const owner = permitMessage.owner as `0x${string}`; + const value = BigInt(permitMessage.value as string); + const deadline = BigInt(permitMessage.deadline as string); + + const payloadMessage = payloadTypedData.message; + const payloadData = payloadMessage.data as `0x${string}`; + const payloadNonce = BigInt(payloadMessage.nonce as string); + const payloadDeadline = BigInt(payloadMessage.deadline as string); + + const relayerAccount = privateKeyToAccount(MOONBEAM_EXECUTOR_PRIVATE_KEY as `0x${string}`); + const walletClient = this.evmClientManager.getWalletClient(fromNetwork, relayerAccount); + + const hash = await walletClient.writeContract({ + abi: tokenRelayerAbi, + address: RELAYER_ADDRESS as `0x${string}`, + args: [ + { + deadline: deadline, + owner: owner, + payloadData: payloadData, + payloadDeadline: payloadDeadline, + payloadNonce: payloadNonce, + payloadR: payloadR, + payloadS: payloadS, + payloadV: payloadV, + payloadValue: state.state.squidRouterPermitExecutionValue, + permitR: permitR, + permitS: permitS, + permitV: permitV, + token: token, + value: value + } + ], + functionName: "execute", + value: BigInt(state.state.squidRouterPermitExecutionValue!) + }); + + logger.info(`Relayer execute transaction sent with hash: ${hash}`); + + const updatedState = await state.update({ + state: { + ...state.state, + squidRouterPermitExecutionHash: hash + } + }); + + const publicClient = this.evmClientManager.getClient(fromNetwork); + const receipt = await publicClient.waitForTransactionReceipt({ + hash: hash as `0x${string}` + }); + + if (!receipt || receipt.status !== "success") { + throw this.createRecoverableError(`Relayer execute transaction failed: ${hash}`); + } + + logger.info(`Relayer execute transaction confirmed: ${hash}`); + + return this.transitionToNextPhase(updatedState, "fundEphemeral"); + } catch (error) { + logger.error(`Error in squidRouterPermitExecute phase for ramp ${state.id}:`, error); + + if (error instanceof PhaseError) { + throw error; + } + + // Default to recoverable error + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + throw this.createRecoverableError(`SquidrouterPermitExecuteHandler: ${errorMessage}`); + } + } +} + +export default new SquidrouterPermitExecuteHandler(); 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 d9d79fb21..1b4b71879 100644 --- a/apps/api/src/api/services/phases/meta-state-types.ts +++ b/apps/api/src/api/services/phases/meta-state-types.ts @@ -1,4 +1,10 @@ -import { ExtrinsicOptions, IbanPaymentData, PermitSignature, StellarTokenDetails } from "@vortexfi/shared"; +import { + AlfredpayFiatPaymentInstructions, + ExtrinsicOptions, + IbanPaymentData, + PermitSignature, + StellarTokenDetails +} from "@vortexfi/shared"; export interface StateMetadata { nablaSoftMinimumOutputRaw: string; @@ -53,6 +59,15 @@ export interface StateMetadata { // Final transaction hash and explorer link (computed once when ramp is complete) finalTransactionHash?: string; finalTransactionExplorerLink?: string; + // Alfredpay + alfredpayUserId?: string; + alfredpayTransactionId?: string; + alfredpayOnrampMintTxHash?: string; + fiatPaymentInstructions?: AlfredpayFiatPaymentInstructions; + fiatAccountId?: string; destinationTransferTxHash?: string; finalSettlementSubsidyTxHash?: string; + alfredpayOfframpTransferTxHash?: string; + squidRouterPermitExecutionHash?: string; + squidRouterPermitExecutionValue?: string; } diff --git a/apps/api/src/api/services/phases/register-handlers.ts b/apps/api/src/api/services/phases/register-handlers.ts index 167927628..712f9890a 100644 --- a/apps/api/src/api/services/phases/register-handlers.ts +++ b/apps/api/src/api/services/phases/register-handlers.ts @@ -1,4 +1,6 @@ import logger from "../../../config/logger"; +import alfredpayOfframpTransferHandler from "./handlers/alfredpay-offramp-transfer-handler"; +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"; @@ -20,6 +22,7 @@ import pendulumToMoonbeamXcmHandler from "./handlers/pendulum-to-moonbeam-xcm-ha import spacewalkRedeemHandler from "./handlers/spacewalk-redeem-handler"; import squidRouterPayPhaseHandler from "./handlers/squid-router-pay-phase-handler"; import squidRouterPhaseHandler from "./handlers/squid-router-phase-handler"; +import squidRouterPermitExecutionHandler from "./handlers/squidrouter-permit-execution-handler"; import stellarPaymentHandler from "./handlers/stellar-payment-handler"; import subsidizePostSwapPhaseHandler from "./handlers/subsidize-post-swap-handler"; import subsidizePreSwapPhaseHandler from "./handlers/subsidize-pre-swap-handler"; @@ -43,6 +46,8 @@ export function registerPhaseHandlers(): void { phaseRegistry.registerHandler(moonbeamToPendulumPhaseHandler); phaseRegistry.registerHandler(brlaPayoutMoonbeamHandler); phaseRegistry.registerHandler(fundEphemeralHandler); + phaseRegistry.registerHandler(alfredpayOnrampMintHandler); + phaseRegistry.registerHandler(alfredpayOfframpTransferHandler); phaseRegistry.registerHandler(brlaOnrampMintHandler); phaseRegistry.registerHandler(pendulumToAssethubPhaseHandler); phaseRegistry.registerHandler(squidRouterPayPhaseHandler); @@ -56,6 +61,7 @@ export function registerPhaseHandlers(): void { phaseRegistry.registerHandler(hydrationSwapHandler); phaseRegistry.registerHandler(finalSettlementSubsidy); phaseRegistry.registerHandler(destinationTransferHandler); + phaseRegistry.registerHandler(squidRouterPermitExecutionHandler); logger.info("Phase handlers registered"); } diff --git a/apps/api/src/api/services/quote/core/helpers.ts b/apps/api/src/api/services/quote/core/helpers.ts index 0bb6d24fa..7f68d00d3 100644 --- a/apps/api/src/api/services/quote/core/helpers.ts +++ b/apps/api/src/api/services/quote/core/helpers.ts @@ -19,10 +19,19 @@ export const SUPPORTED_CHAINS: { Networks.Ethereum, Networks.Polygon ], - to: [EPaymentMethod.PIX as DestinationType, EPaymentMethod.SEPA as DestinationType, EPaymentMethod.CBU as DestinationType] + to: [ + EPaymentMethod.PIX as DestinationType, + EPaymentMethod.SEPA as DestinationType, + EPaymentMethod.CBU as DestinationType, + EPaymentMethod.ACH as DestinationType + ] }, [RampDirection.BUY]: { - from: [EPaymentMethod.PIX as DestinationType, EPaymentMethod.SEPA as DestinationType], + from: [ + EPaymentMethod.PIX as DestinationType, + EPaymentMethod.SEPA as DestinationType, + EPaymentMethod.ACH as DestinationType + ], to: [ Networks.AssetHub, Networks.Avalanche, diff --git a/apps/api/src/api/services/quote/core/squidrouter.ts b/apps/api/src/api/services/quote/core/squidrouter.ts index ef74aad26..7089d3146 100644 --- a/apps/api/src/api/services/quote/core/squidrouter.ts +++ b/apps/api/src/api/services/quote/core/squidrouter.ts @@ -13,6 +13,7 @@ import { QuoteError, RampDirection, RouteParams, + SquidrouterCachedRoute, SquidrouterRoute, stringifyBigWithSignificantDecimals } from "@vortexfi/shared"; @@ -114,10 +115,11 @@ function prepareSquidrouterRouteParams(params: { } /** - * Helper to calculate Squidrouter network fee including GLMR price fetching and fallback + * Helper to calculate Squidrouter network fee including GLMR price fetching and fallback. + * Works with both full routes and cached routes (which include the value field needed for calculation). */ -async function calculateSquidrouterNetworkFee(routeResult: SquidrouterRoute): Promise { - const squidRouterSwapValue = multiplyByPowerOfTen(Big(routeResult.transactionRequest.value), -18); +async function calculateSquidrouterNetworkFee(route: SquidrouterRoute | SquidrouterCachedRoute): Promise { + const squidRouterSwapValue = multiplyByPowerOfTen(Big(route.transactionRequest.value), -18); try { // Get current GLMR price in USD from price feed service @@ -169,7 +171,7 @@ function buildRouteRequest(request: EvmBridgeQuoteRequest) { } async function getSquidrouterRouteData(routeParams: RouteParams) { - const routeResult = await getRoute(routeParams); + const routeResult = await getRoute(routeParams, { useCache: true }); if (!routeResult?.data?.route?.estimate) { throw new APIError({ diff --git a/apps/api/src/api/services/quote/core/types.ts b/apps/api/src/api/services/quote/core/types.ts index e57e35fbd..c7609e1b0 100644 --- a/apps/api/src/api/services/quote/core/types.ts +++ b/apps/api/src/api/services/quote/core/types.ts @@ -22,6 +22,7 @@ export enum StageKey { SquidRouter = "SquidRouter", Fee = "Fee", Discount = "Discount", + PartnerOperation = "PartnerOperation", Finalize = "Finalize" } @@ -149,6 +150,28 @@ export interface QuoteContext { currency: RampCurrency; }; + alfredpayMint?: { + inputAmountDecimal: Big; + inputAmountRaw: string; + outputAmountDecimal: Big; + outputAmountRaw: string; + fee: Big; + currency: RampCurrency; + quoteId: string; + expirationDate: Date; + }; + + alfredpayOfframp?: { + inputAmountDecimal: Big; + inputAmountRaw: string; + outputAmountDecimal: Big; + outputAmountRaw: string; + fee: Big; + currency: RampCurrency; + quoteId: string; + expirationDate: Date; + }; + aveniaMint?: { inputAmountDecimal: Big; inputAmountRaw: string; diff --git a/apps/api/src/api/services/quote/engines/fee/offramp-avenia.ts b/apps/api/src/api/services/quote/engines/fee/offramp-avenia.ts index 91ad8571b..852fb097c 100644 --- a/apps/api/src/api/services/quote/engines/fee/offramp-avenia.ts +++ b/apps/api/src/api/services/quote/engines/fee/offramp-avenia.ts @@ -20,10 +20,13 @@ export class OffRampFeeAveniaEngine extends BaseFeeEngine { const outputAmountOfframp = ctx.nablaSwap!.outputAmountDecimal.toFixed(2, 0); const brlaApiService = BrlaApiService.getInstance(); - const aveniaQuote = await brlaApiService.createPayOutQuote({ - outputAmount: outputAmountOfframp, - outputThirdParty: false - }); + const aveniaQuote = await brlaApiService.createPayOutQuote( + { + outputAmount: outputAmountOfframp, + outputThirdParty: false + }, + { useCache: true } + ); const computedAnchorFee = new Big(aveniaQuote.inputAmount).minus(aveniaQuote.outputAmount).toString(); const anchorFeeCurrency = FiatToken.BRL as RampCurrency; diff --git a/apps/api/src/api/services/quote/engines/fee/offramp-evm-to-alfredpay.ts b/apps/api/src/api/services/quote/engines/fee/offramp-evm-to-alfredpay.ts new file mode 100644 index 000000000..e0fd6401f --- /dev/null +++ b/apps/api/src/api/services/quote/engines/fee/offramp-evm-to-alfredpay.ts @@ -0,0 +1,22 @@ +import { EvmToken, FiatToken, RampCurrency, RampDirection } from "@vortexfi/shared"; +import { QuoteContext } from "../../core/types"; +import { BaseFeeEngine, FeeComputation, FeeConfig } from "./index"; + +export class OffRampEvmToAlfredpayFeeEngine extends BaseFeeEngine { + readonly config: FeeConfig = { + direction: RampDirection.SELL, + skipNote: "Skipped for off-ramp request" + }; + + protected validate(ctx: QuoteContext): void { + // No specific validation needed + } + + protected async compute(ctx: QuoteContext, anchorFee: string, feeCurrency: RampCurrency): Promise { + // TODO apply fees from quote. + return { + anchor: { amount: "0", currency: FiatToken.USD as RampCurrency }, + network: { amount: "0", currency: EvmToken.USDC as RampCurrency } + }; + } +} diff --git a/apps/api/src/api/services/quote/engines/fee/onramp-alfredpay-to-evm.ts b/apps/api/src/api/services/quote/engines/fee/onramp-alfredpay-to-evm.ts new file mode 100644 index 000000000..bf289ba25 --- /dev/null +++ b/apps/api/src/api/services/quote/engines/fee/onramp-alfredpay-to-evm.ts @@ -0,0 +1,28 @@ +import { EvmToken, FiatToken, RampCurrency, RampDirection } from "@vortexfi/shared"; +import { QuoteContext } from "../../core/types"; +import { BaseFeeEngine, FeeComputation, FeeConfig } from "./index"; + +export class OnRampAlfredpayToEvmFeeEngine extends BaseFeeEngine { + readonly config: FeeConfig = { + direction: RampDirection.BUY, + skipNote: "Skipped for off-ramp request" + }; + + protected validate(ctx: QuoteContext): void { + if (!ctx.alfredpayMint) { + throw new Error("OnRampAlfredpayToEvmFeeEngine requires alfredpayMint in context"); + } + } + + protected async compute(ctx: QuoteContext, anchorFee: string, feeCurrency: RampCurrency): Promise { + // biome-ignore lint/style/noNonNullAssertion: Context is validated in `validate` + const alfredpayFee = ctx.alfredpayMint!.fee.toString(); + // biome-ignore lint/style/noNonNullAssertion: Context is validated in `validate` + const alfredpayFeeCurrency = ctx.alfredpayMint!.currency as RampCurrency; + + return { + anchor: { amount: alfredpayFee, currency: alfredpayFeeCurrency }, + network: { amount: "0", currency: EvmToken.USDC as RampCurrency } + }; + } +} diff --git a/apps/api/src/api/services/quote/engines/finalize/offramp.ts b/apps/api/src/api/services/quote/engines/finalize/offramp.ts index f0aa4c78f..c7f93577e 100644 --- a/apps/api/src/api/services/quote/engines/finalize/offramp.ts +++ b/apps/api/src/api/services/quote/engines/finalize/offramp.ts @@ -23,11 +23,15 @@ export class OffRampFinalizeEngine extends BaseFinalizeEngine { } const offrampAmountBeforeAnchorFees = - ctx.request.to === "pix" ? ctx.pendulumToMoonbeamXcm?.outputAmountDecimal : ctx.pendulumToStellar?.outputAmountDecimal; + ctx.request.to === "pix" + ? ctx.pendulumToMoonbeamXcm?.outputAmountDecimal + : ctx.alfredpayOfframp + ? ctx.alfredpayOfframp.inputAmountDecimal + : ctx.pendulumToStellar?.outputAmountDecimal; if (!offrampAmountBeforeAnchorFees) { throw new APIError({ - message: "OffRampFinalizeEngine requires pendulumToMoonbeamXcm or pendulumToStellar output", + message: "OffRampFinalizeEngine requires pendulumToMoonbeamXcm, alfredpayOfframp or pendulumToStellar output", status: httpStatus.INTERNAL_SERVER_ERROR }); } diff --git a/apps/api/src/api/services/quote/engines/finalize/onramp.ts b/apps/api/src/api/services/quote/engines/finalize/onramp.ts index 74d3d6dfa..418d04881 100644 --- a/apps/api/src/api/services/quote/engines/finalize/onramp.ts +++ b/apps/api/src/api/services/quote/engines/finalize/onramp.ts @@ -46,6 +46,15 @@ export class OnRampFinalizeEngine extends BaseFinalizeEngine { }); } finalOutputAmountDecimal = new Big(output); + } else if (request.inputCurrency === FiatToken.USD) { + const output = ctx.alfredpayMint?.outputAmountDecimal; + if (!output) { + throw new APIError({ + message: "OnRampFinalizeEngine requires alfredpayMint output for EVM", + status: httpStatus.INTERNAL_SERVER_ERROR + }); + } + finalOutputAmountDecimal = new Big(output); } else { const output = ctx.moonbeamToEvm?.outputAmountDecimal; if (!output) { diff --git a/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-alfredpay.ts b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-alfredpay.ts new file mode 100644 index 000000000..e9e3db70c --- /dev/null +++ b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-alfredpay.ts @@ -0,0 +1,43 @@ +import { EvmToken, Networks, OnChainToken, RampDirection } from "@vortexfi/shared"; +import Big from "big.js"; +import { EvmBridgeQuoteRequest, getEvmBridgeQuote } from "../../core/squidrouter"; +import { QuoteContext } from "../../core/types"; +import { assignPreNablaContext, BaseInitializeEngine } from "./index"; + +export class AlfredpayOffRampFromEvmInitializeEngine extends BaseInitializeEngine { + readonly config = { + direction: RampDirection.SELL, + skipNote: + "AlfredpayOffRampFromEvmInitializeEngine: Skipped because rampType is BUY, this engine handles SELL operations only" + }; + + protected async executeInternal(ctx: QuoteContext): Promise { + const req = ctx.request; + + const quoteRequest: EvmBridgeQuoteRequest = { + amountDecimal: req.inputAmount, + fromNetwork: req.from as Networks, + inputCurrency: req.inputCurrency as OnChainToken, + outputCurrency: EvmToken.USDC, + rampType: req.rampType, + toNetwork: Networks.Polygon + }; + + const bridgeQuote = await getEvmBridgeQuote(quoteRequest); + + ctx.evmToEvm = { + ...quoteRequest, + fromToken: bridgeQuote.fromToken, + inputAmountDecimal: Big(quoteRequest.amountDecimal), + inputAmountRaw: bridgeQuote.inputAmountRaw, + networkFeeUSD: bridgeQuote.networkFeeUSD, + outputAmountDecimal: bridgeQuote.outputAmountDecimal, + outputAmountRaw: bridgeQuote.outputAmountRaw, + toToken: bridgeQuote.toToken + }; + + ctx.addNote?.( + `Initialized: input=${req.inputAmount} ${req.inputCurrency}, raw=${ctx.evmToPendulum?.inputAmountRaw}, output=${ctx.evmToPendulum?.outputAmountDecimal.toString()} ${ctx.evmToPendulum?.toToken}, raw=${ctx.evmToPendulum?.outputAmountRaw}` + ); + } +} diff --git a/apps/api/src/api/services/quote/engines/initialize/onramp-alfredpay.ts b/apps/api/src/api/services/quote/engines/initialize/onramp-alfredpay.ts new file mode 100644 index 000000000..516c706e7 --- /dev/null +++ b/apps/api/src/api/services/quote/engines/initialize/onramp-alfredpay.ts @@ -0,0 +1,69 @@ +import { + AlfredpayApiService, + AlfredpayChain, + AlfredpayFiatCurrency, + AlfredpayOnChainCurrency, + AlfredpayPaymentMethodType, + CreateAlfredpayOnrampQuoteRequest, + CreateAlfredpayOnrampRequest, + ERC20_USDC_POLYGON_DECIMALS, + multiplyByPowerOfTen, + Networks, + RampDirection +} from "@vortexfi/shared"; +import Big from "big.js"; +import { QuoteContext } from "../../core/types"; +import { BaseInitializeEngine } from "./index"; + +export class OnRampInitializeAlfredpayEngine extends BaseInitializeEngine { + readonly config = { + direction: RampDirection.BUY, + skipNote: "OnRampInitializeAlfredpayEngine: Skipped because rampType is SELL, this engine handles BUY operations only" + }; + + protected async executeInternal(ctx: QuoteContext): Promise { + const req = ctx.request; + + const usdTokenDecimals = ERC20_USDC_POLYGON_DECIMALS; + const inputAmountDecimal = new Big(req.inputAmount); + + const alfredpayService = AlfredpayApiService.getInstance(); + + const quoteRequest: CreateAlfredpayOnrampQuoteRequest = { + chain: AlfredpayChain.MATIC, + fromAmount: inputAmountDecimal.toString(), + fromCurrency: req.inputCurrency as unknown as AlfredpayFiatCurrency, + metadata: { + businessId: "vortex", + customerId: req.userId || "unknown" + }, // Mints hardcoded to Polygon. + paymentMethodType: AlfredpayPaymentMethodType.BANK, + toCurrency: AlfredpayOnChainCurrency.USDC // Mints hardcoded to USDC, on Polygon. + }; + + const quote = await alfredpayService.createOnrampQuote(quoteRequest); + + const fromAmount = new Big(quote.fromAmount); + const toAmount = new Big(quote.toAmount); + + const alfredpayFee = AlfredpayApiService.sumFeesByCurrency( + quote.fees, + req.inputCurrency as unknown as AlfredpayFiatCurrency + ); + + ctx.alfredpayMint = { + currency: ctx.request.inputCurrency, + expirationDate: new Date(quote.expiration), + fee: alfredpayFee, + inputAmountDecimal: fromAmount, + inputAmountRaw: multiplyByPowerOfTen(fromAmount, usdTokenDecimals).toFixed(0, 0), + outputAmountDecimal: toAmount, + outputAmountRaw: multiplyByPowerOfTen(toAmount, usdTokenDecimals).toFixed(0, 0), + quoteId: quote.quoteId + }; + + ctx.addNote?.( + `Initialized: ${inputAmountDecimal.toString()} ${req.inputCurrency} -> ${toAmount.toString()} ${req.outputCurrency}` + ); + } +} diff --git a/apps/api/src/api/services/quote/engines/initialize/onramp-avenia.ts b/apps/api/src/api/services/quote/engines/initialize/onramp-avenia.ts index 539dbe696..3249e24ea 100644 --- a/apps/api/src/api/services/quote/engines/initialize/onramp-avenia.ts +++ b/apps/api/src/api/services/quote/engines/initialize/onramp-avenia.ts @@ -26,26 +26,32 @@ export class OnRampInitializeAveniaEngine extends BaseInitializeEngine { const inputAmountRaw = multiplyByPowerOfTen(inputAmountDecimal, brlaTokenDetails.decimals).toFixed(0, 0); const brlaApiService = BrlaApiService.getInstance(); - const aveniaPayInToInternalQuote = await brlaApiService.createPayInQuote({ - inputAmount: inputAmountDecimal.toString(), - inputCurrency: BrlaCurrency.BRL, - inputPaymentMethod: AveniaPaymentMethod.PIX, - inputThirdParty: false, - outputCurrency: BrlaCurrency.BRLA, - outputPaymentMethod: AveniaPaymentMethod.INTERNAL, - outputThirdParty: false - }); + const aveniaPayInToInternalQuote = await brlaApiService.createPayInQuote( + { + inputAmount: inputAmountDecimal.toString(), + inputCurrency: BrlaCurrency.BRL, + inputPaymentMethod: AveniaPaymentMethod.PIX, + inputThirdParty: false, + outputCurrency: BrlaCurrency.BRLA, + outputPaymentMethod: AveniaPaymentMethod.INTERNAL, + outputThirdParty: false + }, + { useCache: true } + ); - const aveniaTransferToMoonbeamQuote = await brlaApiService.createPayInQuote({ - blockchainSendMethod: BlockchainSendMethod.PERMIT, - inputAmount: aveniaPayInToInternalQuote.outputAmount.toString(), - inputCurrency: BrlaCurrency.BRLA, - inputPaymentMethod: AveniaPaymentMethod.INTERNAL, - inputThirdParty: false, - outputCurrency: BrlaCurrency.BRLA, - outputPaymentMethod: AveniaPaymentMethod.MOONBEAM, - outputThirdParty: false - }); + const aveniaTransferToMoonbeamQuote = await brlaApiService.createPayInQuote( + { + blockchainSendMethod: BlockchainSendMethod.PERMIT, + inputAmount: aveniaPayInToInternalQuote.outputAmount.toString(), + inputCurrency: BrlaCurrency.BRLA, + inputPaymentMethod: AveniaPaymentMethod.INTERNAL, + inputThirdParty: false, + outputCurrency: BrlaCurrency.BRLA, + outputPaymentMethod: AveniaPaymentMethod.MOONBEAM, + outputThirdParty: false + }, + { useCache: true } + ); // We add a small buffer for the gas fees const gasFeePayIn = aveniaPayInToInternalQuote.appliedFees.find(fee => fee.type === "Gas Fee"); diff --git a/apps/api/src/api/services/quote/engines/partners/offramp-alfredpay.ts b/apps/api/src/api/services/quote/engines/partners/offramp-alfredpay.ts new file mode 100644 index 000000000..0e8d1d0ed --- /dev/null +++ b/apps/api/src/api/services/quote/engines/partners/offramp-alfredpay.ts @@ -0,0 +1,68 @@ +import { + AlfredpayApiService, + AlfredpayChain, + AlfredpayFiatCurrency, + AlfredpayOnChainCurrency, + AlfredpayPaymentMethodType, + CreateAlfredpayOfframpQuoteRequest, + ERC20_USDC_POLYGON_DECIMALS, + multiplyByPowerOfTen, + RampDirection +} from "@vortexfi/shared"; +import Big from "big.js"; +import { QuoteContext } from "../../core/types"; +import { BaseInitializeEngine } from "./../initialize/index"; + +export class OfframpTransactionAlfredpayEngine extends BaseInitializeEngine { + readonly config = { + direction: RampDirection.SELL, + skipNote: "OfframpTransactionAlfredpayEngine: Skipped because rampType is BUY, this engine handles SELL operations only" + }; + + protected async executeInternal(ctx: QuoteContext): Promise { + const req = ctx.request; + + if (!ctx.evmToEvm) { + throw new Error("OfframpTransactionAlfredpayEngine: No evmToEvm quote"); + } + + const usdTokenDecimals = ERC20_USDC_POLYGON_DECIMALS; + const inputAmountDecimal = new Big(ctx.evmToEvm.outputAmountDecimal); + + const alfredpayService = AlfredpayApiService.getInstance(); + + const quoteRequest: CreateAlfredpayOfframpQuoteRequest = { + chain: AlfredpayChain.MATIC, + fromAmount: inputAmountDecimal.toString(), + fromCurrency: AlfredpayOnChainCurrency.USDC, // Offramp deposit is USDC + metadata: { + businessId: "vortex", + customerId: req.userId || "unknown" + }, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + toCurrency: req.outputCurrency as unknown as AlfredpayFiatCurrency + }; + + const quote = await alfredpayService.createOfframpQuote(quoteRequest); + + const fromAmount = new Big(ctx.evmToEvm.outputAmountDecimal); + const toAmount = new Big(quote.toAmount); + + const alfredpayFee = Big(0); + + ctx.alfredpayOfframp = { + currency: ctx.request.outputCurrency, + expirationDate: new Date(quote.expiration), + fee: alfredpayFee, + inputAmountDecimal: fromAmount, + inputAmountRaw: multiplyByPowerOfTen(fromAmount, usdTokenDecimals).toFixed(0, 0), + outputAmountDecimal: toAmount, + outputAmountRaw: multiplyByPowerOfTen(toAmount, 2).toFixed(0, 0), // Assuming 2 decimals for fiat + quoteId: quote.quoteId + }; + + ctx.addNote?.( + `Initialized: ${inputAmountDecimal.toString()} ${req.inputCurrency} -> ${toAmount.toString()} ${req.outputCurrency}` + ); + } +} diff --git a/apps/api/src/api/services/quote/engines/squidrouter/index.ts b/apps/api/src/api/services/quote/engines/squidrouter/index.ts index 6ea7b90b0..1fa398fff 100644 --- a/apps/api/src/api/services/quote/engines/squidrouter/index.ts +++ b/apps/api/src/api/services/quote/engines/squidrouter/index.ts @@ -22,6 +22,7 @@ export interface SquidRouterData { inputAmountDecimal: Big; inputAmountRaw: string; outputDecimals: number; + skipRouteCalculation?: boolean; } export abstract class BaseSquidRouterEngine implements Stage { @@ -42,6 +43,10 @@ export abstract class BaseSquidRouterEngine implements Stage { const computation = this.compute(ctx); + if (computation.data.skipRouteCalculation) { + return; + } + const bridgeRequest = this.buildBridgeRequest(computation.data, request); const bridgeResult = await this.calculateBridge(bridgeRequest); diff --git a/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm-alfredpay.ts b/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm-alfredpay.ts new file mode 100644 index 000000000..b26f342ef --- /dev/null +++ b/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm-alfredpay.ts @@ -0,0 +1,73 @@ +import { + ERC20_USDC_POLYGON, + ERC20_USDC_POLYGON_DECIMALS, + EvmToken, + getNetworkFromDestination, + Networks, + OnChainToken, + RampDirection +} from "@vortexfi/shared"; +import httpStatus from "http-status"; +import { APIError } from "../../../../errors/api-error"; +import { getTokenDetailsForEvmDestination } from "../../core/squidrouter"; +import { QuoteContext } from "../../core/types"; +import { BaseSquidRouterEngine, SquidRouterComputation, SquidRouterConfig, SquidRouterData } from "./index"; + +export class OnRampSquidRouterUsdToEvmEngine extends BaseSquidRouterEngine { + readonly config: SquidRouterConfig = { + direction: RampDirection.BUY, + skipNote: "OnRampSquidRouterUsdToEvmEngine: Skipped because rampType is SELL, this engine handles BUY operations only" + }; + + protected validate(ctx: QuoteContext): void { + if (ctx.request.to === "assethub") { + throw new Error( + "OnRampSquidRouterUsdToEvmEngine: Skipped because destination is assethub, this engine handles EVM destinations only" + ); + } + + if (!ctx.alfredpayMint?.outputAmountDecimal) { + throw new Error( + "OnRampSquidRouterUsdToEvmEngine: Missing alfredpayMint.amountOut in context - ensure initialize stage ran successfully" + ); + } + } + + protected compute(ctx: QuoteContext): SquidRouterComputation { + if (ctx.to === Networks.Polygon && ctx.request.outputCurrency === EvmToken.USDC) { + return { + data: { + skipRouteCalculation: true + } as SquidRouterData, + type: "evm-to-evm" + }; + } + + const req = ctx.request; + const toNetwork = getNetworkFromDestination(req.to); + if (!toNetwork) { + throw new APIError({ + message: `Invalid network for destination: ${req.to} `, + status: httpStatus.BAD_REQUEST + }); + } + + const toToken = getTokenDetailsForEvmDestination(req.outputCurrency as OnChainToken, req.to).erc20AddressSourceChain; + // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate + const alfredpayMint = ctx.alfredpayMint!; + + return { + data: { + amountRaw: alfredpayMint.outputAmountRaw, + fromNetwork: Networks.Polygon, + fromToken: ERC20_USDC_POLYGON, + inputAmountDecimal: alfredpayMint.outputAmountDecimal, + inputAmountRaw: alfredpayMint.outputAmountRaw, + outputDecimals: ERC20_USDC_POLYGON_DECIMALS, + toNetwork, + toToken + }, + type: "evm-to-evm" + }; + } +} diff --git a/apps/api/src/api/services/quote/routes/route-resolver.ts b/apps/api/src/api/services/quote/routes/route-resolver.ts index 02b2558c7..9012fc271 100644 --- a/apps/api/src/api/services/quote/routes/route-resolver.ts +++ b/apps/api/src/api/services/quote/routes/route-resolver.ts @@ -4,8 +4,10 @@ import { AssetHubToken, FiatToken, Networks, RampDirection } from "@vortexfi/shared"; import type { QuoteContext } from "../core/types"; import { IRouteStrategy } from "../core/types"; +import { OfframpEvmToAlfredpayStrategy } from "./strategies/offramp-evm-to-alfredpay.strategy"; import { OfframpToPixStrategy } from "./strategies/offramp-to-pix.strategy"; import { OfframpToStellarStrategy } from "./strategies/offramp-to-stellar.strategy"; +import { OnrampAlfredpayToEvmStrategy } from "./strategies/onramp-alfredpay-to-evm.strategy"; import { OnrampAveniaToAssethubStrategy } from "./strategies/onramp-avenia-to-assethub.strategy"; import { OnrampAveniaToEvmStrategy } from "./strategies/onramp-avenia-to-evm.strategy"; import { OnrampMoneriumToAssethubStrategy } from "./strategies/onramp-monerium-to-assethub.strategy"; @@ -24,6 +26,8 @@ export class RouteResolver { } else { if (ctx.request.inputCurrency === FiatToken.EURC) { return new OnrampMoneriumToEvmStrategy(); + } else if (ctx.request.inputCurrency === FiatToken.USD) { + return new OnrampAlfredpayToEvmStrategy(); } else { return new OnrampAveniaToEvmStrategy(); } @@ -44,6 +48,8 @@ export class RouteResolver { switch (ctx.to) { case "pix": return new OfframpToPixStrategy(); + case "ach": + return new OfframpEvmToAlfredpayStrategy(); case "sepa": case "cbu": default: diff --git a/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts new file mode 100644 index 000000000..503e339a8 --- /dev/null +++ b/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts @@ -0,0 +1,23 @@ +import { EnginesRegistry, IRouteStrategy, QuoteContext, StageKey } from "../../core/types"; +import { OffRampEvmToAlfredpayFeeEngine } from "../../engines/fee/offramp-evm-to-alfredpay"; +import { OffRampFinalizeEngine } from "../../engines/finalize/offramp"; + +import { AlfredpayOffRampFromEvmInitializeEngine } from "../../engines/initialize/offramp-from-evm-alfredpay"; +import { OfframpTransactionAlfredpayEngine } from "../../engines/partners/offramp-alfredpay"; + +export class OfframpEvmToAlfredpayStrategy implements IRouteStrategy { + readonly name = "OfframpEvmToAlfredpay"; + + getStages(_ctx: QuoteContext): StageKey[] { + return [StageKey.Initialize, StageKey.Fee, StageKey.PartnerOperation, StageKey.Finalize]; + } + + getEngines(_ctx: QuoteContext): EnginesRegistry { + return { + [StageKey.Initialize]: new AlfredpayOffRampFromEvmInitializeEngine(), + [StageKey.Fee]: new OffRampEvmToAlfredpayFeeEngine(), + [StageKey.PartnerOperation]: new OfframpTransactionAlfredpayEngine(), + [StageKey.Finalize]: new OffRampFinalizeEngine() + }; + } +} diff --git a/apps/api/src/api/services/quote/routes/strategies/onramp-alfredpay-to-evm.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/onramp-alfredpay-to-evm.strategy.ts new file mode 100644 index 000000000..c88da5bad --- /dev/null +++ b/apps/api/src/api/services/quote/routes/strategies/onramp-alfredpay-to-evm.strategy.ts @@ -0,0 +1,22 @@ +import { EnginesRegistry, IRouteStrategy, QuoteContext, StageKey } from "../../core/types"; +import { OnRampAlfredpayToEvmFeeEngine } from "../../engines/fee/onramp-alfredpay-to-evm"; +import { OnRampFinalizeEngine } from "../../engines/finalize/onramp"; +import { OnRampInitializeAlfredpayEngine } from "../../engines/initialize/onramp-alfredpay"; +import { OnRampSquidRouterUsdToEvmEngine } from "../../engines/squidrouter/onramp-polygon-to-evm-alfredpay"; + +export class OnrampAlfredpayToEvmStrategy implements IRouteStrategy { + readonly name = "OnrampAlfredpayToEvm"; + + getStages(_ctx: QuoteContext): StageKey[] { + return [StageKey.Initialize, StageKey.Fee, StageKey.SquidRouter, StageKey.Finalize]; + } + + getEngines(_ctx: QuoteContext): EnginesRegistry { + return { + [StageKey.Initialize]: new OnRampInitializeAlfredpayEngine(), + [StageKey.Fee]: new OnRampAlfredpayToEvmFeeEngine(), + [StageKey.SquidRouter]: new OnRampSquidRouterUsdToEvmEngine(), // Uses same engine as monerium's. (Polygon ephemeral -> destination) + [StageKey.Finalize]: new OnRampFinalizeEngine() + }; + } +} diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index 28ff39e2b..c37db9cea 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -1,8 +1,16 @@ import { AccountMeta, + AlfredpayApiService, + AlfredpayChain, + AlfredpayFiatCurrency, + AlfredpayFiatPaymentInstructions, + AlfredpayOnChainCurrency, + AlfredpayPaymentMethodType, AveniaPaymentMethod, BrlaApiService, BrlaCurrency, + CreateAlfredpayOfframpRequest, + CreateAlfredpayOnrampRequest, EphemeralAccountType, EvmNetworks, FiatToken, @@ -29,7 +37,7 @@ import { } from "@vortexfi/shared"; import Big from "big.js"; import httpStatus from "http-status"; -import { Op } from "sequelize"; +import { Op, Transaction } from "sequelize"; import logger from "../../../config/logger"; import { SANDBOX_ENABLED, SEQUENCE_TIME_WINDOW_IN_SECONDS } from "../../../constants/constants"; import Partner from "../../../models/partner.model"; @@ -38,6 +46,7 @@ import RampState from "../../../models/rampState.model"; import TaxId from "../../../models/taxId.model"; import { APIError } from "../../errors/api-error"; import { ActivePartner, handleQuoteConsumptionForDiscountState } from "../../services/quote/engines/discount/helpers"; +import { SupabaseAuthService } from "../auth/supabase.service"; import { createEpcQrCodeData, getIbanForAddress, getMoneriumUserProfile } from "../monerium"; import { StateMetadata } from "../phases/meta-state-types"; import phaseProcessor from "../phases/phase-processor"; @@ -118,7 +127,8 @@ export class RampService extends BaseRampService { quote, normalizedSigningAccounts, additionalData, - signingAccounts + signingAccounts, + request.userId // will be undefined if not logged in. registerRamp is optional. ); await this.consumeQuote(quote.id, transaction); @@ -261,8 +271,19 @@ export class RampService extends BaseRampService { { transaction } ); + let achPaymentData: AlfredpayFiatPaymentInstructions | undefined = undefined; + if (quote.inputCurrency === FiatToken.USD) { + achPaymentData = await this.processAlfredpayOnrampStart(rampState, quote, transaction); + } + + if (quote.outputCurrency === FiatToken.USD) { + // TODO mocking. Currently failing in sandbox. + //await this.processAlfredpayOfframpStart(rampState, quote, transaction); + } + // Create response const response: UpdateRampResponse = { + achPaymentData, createdAt: rampState.createdAt.toISOString(), currentPhase: rampState.currentPhase, depositQrCode: rampState.state.depositQrCode, @@ -332,15 +353,7 @@ export class RampService extends BaseRampService { }; await validatePresignedTxs(rampState.type, rampState.presignedTxs, ephemerals); - // Find ephemeral transactions in unsigned transactions - const ephemeralTransactions = rampState.unsignedTxs.filter( - tx => - tx.signer === rampState.state.substrateEphemeralAddress || - tx.signer === rampState.state.evmEphemeralAddress || - tx.signer === rampState.state.stellarEphemeralAccountId - ); - // Ensure all unsigned transactions have a corresponding presigned transaction - if (!areAllTxsIncluded(ephemeralTransactions, rampState.presignedTxs)) { + if (!this.validateAllPresignedTransactionsSigned(rampState)) { throw new APIError({ message: "Not all unsigned transactions have a corresponding presigned transaction.", status: httpStatus.BAD_REQUEST @@ -810,13 +823,15 @@ export class RampService extends BaseRampService { private async prepareOfframpNonBrlTransactions( quote: QuoteTicket, normalizedSigningAccounts: AccountMeta[], - additionalData: RegisterRampRequest["additionalData"] + additionalData: RegisterRampRequest["additionalData"], + userId?: string ): Promise<{ unsignedTxs: UnsignedTx[]; stateMeta: Partial }> { const { unsignedTxs, stateMeta } = await prepareOfframpTransactions({ quote, signingAccounts: normalizedSigningAccounts, stellarPaymentData: additionalData?.paymentData, - userAddress: additionalData?.walletAddress + userAddress: additionalData?.walletAddress, + userId }); return { stateMeta, unsignedTxs }; @@ -862,6 +877,32 @@ export class RampService extends BaseRampService { return { aveniaTicketId, depositQrCode: brCode, stateMeta: stateMeta as Partial, unsignedTxs }; } + private async prepareAlfredpayOnrampTransactions( + quote: QuoteTicket, + normalizedSigningAccounts: AccountMeta[], + additionalData: RegisterRampRequest["additionalData"], + userId?: string + ): Promise<{ + unsignedTxs: UnsignedTx[]; + stateMeta: Partial; + }> { + if (!additionalData || !additionalData.destinationAddress) { + throw new APIError({ + message: "Parameter destinationAddress is required for Alfredpay onramp", + status: httpStatus.BAD_REQUEST + }); + } + + const { unsignedTxs, stateMeta } = await prepareOnrampTransactions({ + destinationAddress: additionalData.destinationAddress, + quote, + signingAccounts: normalizedSigningAccounts, + userId: userId! + }); + + return { stateMeta: stateMeta as Partial, unsignedTxs }; + } + private async prepareMoneriumOnrampTransactions( quote: QuoteTicket, normalizedSigningAccounts: AccountMeta[], @@ -957,7 +998,8 @@ export class RampService extends BaseRampService { quote: QuoteTicket, normalizedSigningAccounts: AccountMeta[], additionalData: RegisterRampRequest["additionalData"], - signingAccounts: AccountMeta[] + signingAccounts: AccountMeta[], + userId?: string ): Promise<{ unsignedTxs: UnsignedTx[]; stateMeta: Partial; @@ -972,20 +1014,33 @@ export class RampService extends BaseRampService { // otherwise, it is automatically assumed to be a Monerium offramp. // FIXME change to a better check once Mykobo support is dropped, or a better way to check if the transaction is a Monerium offramp arises. } else if (!additionalData?.moneriumAuthToken) { - return this.prepareOfframpNonBrlTransactions(quote, normalizedSigningAccounts, additionalData); + return this.prepareOfframpNonBrlTransactions(quote, normalizedSigningAccounts, additionalData, userId); } else { return this.prepareMoneriumOfframpTransactions(quote, normalizedSigningAccounts, additionalData); } } else { if (quote.inputCurrency === FiatToken.EURC) { return this.prepareMoneriumOnrampTransactions(quote, normalizedSigningAccounts, additionalData); + } else if (quote.inputCurrency === FiatToken.USD) { + return this.prepareAlfredpayOnrampTransactions(quote, normalizedSigningAccounts, additionalData, userId); } return this.prepareAveniaOnrampTransactions(quote, normalizedSigningAccounts, additionalData, signingAccounts); } } + private validateAllPresignedTransactionsSigned(rampState: RampState): boolean { + const ephemeralTransactions = rampState.unsignedTxs.filter( + tx => + tx.signer === rampState.state.substrateEphemeralAddress || + tx.signer === rampState.state.evmEphemeralAddress || + tx.signer === rampState.state.stellarEphemeralAccountId + ); + + return areAllTxsIncluded(ephemeralTransactions, rampState.presignedTxs || []); + } + private validateRampStateData(rampState: RampState, quote: QuoteTicket): void { - if (rampState.type === RampDirection.SELL) { + if (rampState.type === RampDirection.SELL && quote.outputCurrency !== FiatToken.USD) { if (rampState.from === Networks.AssetHub && !rampState.state.assethubToPendulumHash) { throw new APIError({ message: `Missing required additional data 'assethubToPendulumHash' for ${rampState.type} ramp. Cannot proceed.`, @@ -1055,6 +1110,145 @@ export class RampService extends BaseRampService { await this.notifyStatusChangeIfNeeded(rampState, oldPhase, newPhase); } } + + private async processAlfredpayOnrampStart( + rampState: RampState, + quote: QuoteTicket, + transaction: Transaction + ): Promise { + if (!this.validateAllPresignedTransactionsSigned(rampState)) { + return; + } + + if (rampState.state.alfredpayTransactionId) { + return; + } + + const alfredpayService = AlfredpayApiService.getInstance(); + const alfredpayQuoteId = quote.metadata.alfredpayMint?.quoteId; + + if (!alfredpayQuoteId) { + throw new APIError({ + message: "Missing Alfredpay quote ID in metadata", + status: httpStatus.BAD_REQUEST + }); + } + + if (!rampState.userId) { + throw new APIError({ + message: "Missing user ID in ramp state", + status: httpStatus.BAD_REQUEST + }); + } + + if (!rampState.state.destinationAddress) { + throw new APIError({ + message: "Destination address not found in ramp state", + status: httpStatus.BAD_REQUEST + }); + } + + if (!rampState.state.alfredpayUserId) { + throw new APIError({ + message: "Missing Alfredpay user ID in ramp state", + status: httpStatus.BAD_REQUEST + }); + } + + const orderRequest: CreateAlfredpayOnrampRequest = { + amount: quote.inputAmount, + chain: AlfredpayChain.MATIC, + customerId: rampState.state.alfredpayUserId, + depositAddress: rampState.state.evmEphemeralAddress, + fromCurrency: AlfredpayFiatCurrency.USD, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + quoteId: alfredpayQuoteId, + toCurrency: AlfredpayOnChainCurrency.USDC + }; + + const order = await alfredpayService.createOnramp(orderRequest); + + await rampState.update( + { + state: { + ...rampState.state, + alfredpayTransactionId: order.transaction.transactionId, + fiatPaymentInstructions: order.fiatPaymentInstructions + } + }, + { transaction } + ); + + return order.fiatPaymentInstructions; + } + + private async processAlfredpayOfframpStart( + rampState: RampState, + quote: QuoteTicket, + transaction: Transaction + ): Promise { + if (!this.validateAllPresignedTransactionsSigned(rampState)) { + return; + } + + if (rampState.state.alfredpayTransactionId) { + return; + } + + const alfredpayService = AlfredpayApiService.getInstance(); + const alfredpayQuoteId = quote.metadata.alfredpayOfframp?.quoteId; + + if (!alfredpayQuoteId) { + throw new APIError({ + message: "Missing Alfredpay quote ID in metadata", + status: httpStatus.BAD_REQUEST + }); + } + + if (!rampState.state.alfredpayUserId) { + throw new APIError({ + message: "Missing Alfredpay user ID in ramp state", + status: httpStatus.BAD_REQUEST + }); + } + + if (!rampState.state.fiatAccountId) { + throw new APIError({ + message: "Missing fiatAccountId in ramp state", + status: httpStatus.BAD_REQUEST + }); + } + + if (!rampState.state.walletAddress) { + throw new APIError({ + message: "Wallet address not found in ramp state", + status: httpStatus.BAD_REQUEST + }); + } + + const orderRequest: CreateAlfredpayOfframpRequest = { + amount: quote.inputAmount, + chain: AlfredpayChain.MATIC, + customerId: rampState.state.alfredpayUserId, + fiatAccountId: rampState.state.fiatAccountId, + fromCurrency: AlfredpayOnChainCurrency.USDC, + originAddress: rampState.state.walletAddress, + quoteId: alfredpayQuoteId, + toCurrency: quote.outputCurrency as unknown as AlfredpayFiatCurrency + }; + + const order = await alfredpayService.createOfframp(orderRequest); + + await rampState.update( + { + state: { + ...rampState.state, + alfredpayTransactionId: order.transactionId + } + }, + { transaction } + ); + } } export default new RampService(); diff --git a/apps/api/src/api/services/sep10/sep10.service.ts b/apps/api/src/api/services/sep10/sep10.service.ts index 7f4a32f2f..9de083b1a 100644 --- a/apps/api/src/api/services/sep10/sep10.service.ts +++ b/apps/api/src/api/services/sep10/sep10.service.ts @@ -1,4 +1,4 @@ -import { FiatToken } from "@vortexfi/shared"; +import { FiatToken, TOKEN_CONFIG } from "@vortexfi/shared"; import { Keypair, Networks, Transaction, TransactionBuilder } from "stellar-sdk"; import { CLIENT_DOMAIN_SECRET, SANDBOX_ENABLED, SEP10_MASTER_SECRET } from "../../../constants/constants"; import { fetchTomlValues } from "../../helpers/anchors"; @@ -29,8 +29,17 @@ export const signSep10Challenge = async ( const masterStellarKeypair = Keypair.fromSecret(SEP10_MASTER_SECRET); const clientDomainStellarKeypair = Keypair.fromSecret(CLIENT_DOMAIN_SECRET); - // TODO improve this mapping. Use at least the TOKEN_CONFIG key, not just the "EURC" string. - const outToken = fiatToken === FiatToken.EURC ? "EURC" : fiatToken; + // Map FiatToken enum values to TOKEN_CONFIG keys + const tokenMapping: Record = { + [FiatToken.EURC]: "EURC", + [FiatToken.ARS]: "ARS", + [FiatToken.BRL]: "BRL", + [FiatToken.USD]: "USDC", + [FiatToken.MXN]: "USDC", + [FiatToken.COP]: "USDC" + }; + + const outToken = tokenMapping[fiatToken]; const outTokenConfig = getOutToken(outToken); const { signingKey: anchorSigningKey } = (await fetchTomlValues(outTokenConfig.tomlFileUrl)) as TomlValues; diff --git a/apps/api/src/api/services/transactions/offramp/common/types.ts b/apps/api/src/api/services/transactions/offramp/common/types.ts index 77d96f26e..c97f216b3 100644 --- a/apps/api/src/api/services/transactions/offramp/common/types.ts +++ b/apps/api/src/api/services/transactions/offramp/common/types.ts @@ -11,6 +11,7 @@ export interface OfframpTransactionParams { receiverTaxId?: string; brlaEvmAddress?: string; moneriumAuthToken?: string; + userId?: string; } export interface OfframpTransactionsWithMeta { diff --git a/apps/api/src/api/services/transactions/offramp/common/validation.ts b/apps/api/src/api/services/transactions/offramp/common/validation.ts index b9a424b7e..67a1b8af9 100644 --- a/apps/api/src/api/services/transactions/offramp/common/validation.ts +++ b/apps/api/src/api/services/transactions/offramp/common/validation.ts @@ -6,7 +6,7 @@ import { getOnChainTokenDetails, isFiatToken, isOnChainToken, - isStellarOutputTokenDetails, + isStellarTokenDetails, PaymentData, StellarTokenDetails } from "@vortexfi/shared"; @@ -112,7 +112,7 @@ export function validateStellarOfframp( stellarTokenDetails: StellarTokenDetails; stellarPaymentData: PaymentData; } { - if (!isStellarOutputTokenDetails(outputTokenDetails)) { + if (!isStellarTokenDetails(outputTokenDetails)) { throw new Error("Output currency must be Stellar token for offramp, got output token details type"); } @@ -122,7 +122,7 @@ export function validateStellarOfframp( return { stellarPaymentData, - stellarTokenDetails: outputTokenDetails + stellarTokenDetails: outputTokenDetails as StellarTokenDetails }; } diff --git a/apps/api/src/api/services/transactions/offramp/index.ts b/apps/api/src/api/services/transactions/offramp/index.ts index 206e50e79..4793d7b6f 100644 --- a/apps/api/src/api/services/transactions/offramp/index.ts +++ b/apps/api/src/api/services/transactions/offramp/index.ts @@ -8,6 +8,7 @@ import { import { OfframpTransactionParams, OfframpTransactionsWithMeta } from "./common/types"; import { prepareAssethubToBRLOfframpTransactions } from "./routes/assethub-to-brl"; import { prepareAssethubToStellarOfframpTransactions } from "./routes/assethub-to-stellar"; +import { prepareEvmToAlfredpayOfframpTransactions } from "./routes/evm-to-alfredpay"; import { prepareEvmToBRLOfframpTransactions } from "./routes/evm-to-brl"; import { prepareEvmToMoneriumEvmOfframpTransactions } from "./routes/evm-to-monerium-evm"; import { prepareEvmToStellarOfframpTransactions } from "./routes/evm-to-stellar"; @@ -31,6 +32,9 @@ export async function prepareOfframpTransactions(params: OfframpTransactionParam } else if (quote.outputCurrency === FiatToken.EURC && params.moneriumAuthToken) { // Monerium EVM offramp return prepareEvmToMoneriumEvmOfframpTransactions(params); + } else if (quote.outputCurrency === FiatToken.USD) { + // Alfredpay offramp + return prepareEvmToAlfredpayOfframpTransactions(params); } else { // Stellar offramp const inputTokenDetails = getOnChainTokenDetails(fromNetwork, quote.inputCurrency as OnChainToken); diff --git a/apps/api/src/api/services/transactions/offramp/routes/assethub-to-brl.ts b/apps/api/src/api/services/transactions/offramp/routes/assethub-to-brl.ts index 06b4a0194..e7fe75aac 100644 --- a/apps/api/src/api/services/transactions/offramp/routes/assethub-to-brl.ts +++ b/apps/api/src/api/services/transactions/offramp/routes/assethub-to-brl.ts @@ -1,4 +1,4 @@ -import { encodeSubmittableExtrinsic, getPendulumDetails, Networks, UnsignedTx } from "@vortexfi/shared"; +import { encodeSubmittableExtrinsic, getPendulumDetails, MoonbeamTokenDetails, Networks, UnsignedTx } from "@vortexfi/shared"; import Big from "big.js"; import { multiplyByPowerOfTen } from "../../../pendulum/helpers"; import { StateMetadata } from "../../../phases/meta-state-types"; @@ -112,7 +112,7 @@ export async function prepareAssethubToBRLOfframpTransactions({ account: substrateAccount, brlaEvmAddress: validatedBrlaEvmAddress, outputAmountRaw: offrampAmountBeforeAnchorFeesRaw, - outputTokenPendulumDetails: outputTokenDetails.pendulumRepresentative, + outputTokenPendulumDetails: (outputTokenDetails as unknown as MoonbeamTokenDetails).pendulumRepresentative, pixDestination: validatedPixDestination, receiverTaxId: validatedReceiverTaxId, taxId: validatedTaxId diff --git a/apps/api/src/api/services/transactions/offramp/routes/assethub-to-stellar.ts b/apps/api/src/api/services/transactions/offramp/routes/assethub-to-stellar.ts index 9b6538321..357cac1e0 100644 --- a/apps/api/src/api/services/transactions/offramp/routes/assethub-to-stellar.ts +++ b/apps/api/src/api/services/transactions/offramp/routes/assethub-to-stellar.ts @@ -151,7 +151,7 @@ export async function prepareAssethubToStellarOfframpTransactions({ { ephemeralAddress: stellarEphemeralEntry.address, outputAmountUnits: offrampAmountBeforeAnchorFeesUnits, - outputTokenDetails, + outputTokenDetails: outputTokenDetails as any, stellarPaymentData }, unsignedTxs diff --git a/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts b/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts new file mode 100644 index 000000000..23cc936e7 --- /dev/null +++ b/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts @@ -0,0 +1,306 @@ +import { + AlfredPayStatus, + createOfframpSquidrouterTransactionsToEvm, + ERC20_USDC_POLYGON, + EvmClientManager, + EvmNetworks, + EvmTokenDetails, + EvmTransactionData, + getNetworkFromDestination, + getNetworkId, + getOnChainTokenDetails, + isEvmToken, + isNetworkEVM, + Networks, + SignedTypedData, + SQUDROUTER_MAIN_CONTRACT_POLYGON, + TypedDataDomain, + UnsignedTx +} from "@vortexfi/shared"; +import Big from "big.js"; +import { encodeAbiParameters, keccak256, PublicClient, pad, parseAbiParameters, toHex } from "viem"; +import AlfredPayCustomer from "../../../../../models/alfredPayCustomer.model"; +import { StateMetadata } from "../../../phases/meta-state-types"; +import { encodeEvmTransactionData } from "../../index"; +import { addOnrampDestinationChainTransactions } from "../../onramp/common/transactions"; +import { OfframpTransactionParams, OfframpTransactionsWithMeta } from "../common/types"; + +export const RELAYER_ADDRESS = "0xC9ECD03c89349B3EAe4613c7091c6c3029413785" as const; + +/** + * Resolves the EIP-712 domain for a token's permit signature. + * Some tokens (like USDT in polygon) use salt-based domain separation instead of chainId. + */ +async function resolvePermitDomain( + publicClient: PublicClient, + tokenAddress: `0x${string}`, + chainId: number, + tokenName: string +): Promise { + let version = "1"; + try { + version = (await publicClient.readContract({ + abi: [{ inputs: [], name: "version", outputs: [{ type: "string" }], type: "function" }], + address: tokenAddress, + functionName: "version" + })) as string; + } catch { + // If version() fails, we stick with "1" + } + + const standardHash = keccak256( + encodeAbiParameters(parseAbiParameters("bytes32, bytes32, bytes32, uint256, address"), [ + keccak256(toHex("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")), + keccak256(toHex(tokenName)), + keccak256(toHex(version)), + BigInt(chainId), + tokenAddress + ]) + ); + + let onChainSeparator: `0x${string}` | undefined; + try { + onChainSeparator = (await publicClient.readContract({ + abi: [{ inputs: [], name: "DOMAIN_SEPARATOR", outputs: [{ type: "bytes32" }], type: "function" }], + address: tokenAddress, + functionName: "DOMAIN_SEPARATOR" + })) as `0x${string}`; + } catch { + // If we can't read it, fall back to using standard domain separator eventually + } + + if (onChainSeparator !== undefined) { + if (onChainSeparator !== standardHash) { + // On-chain separator exists but doesn't match standard - compute salt hash for comparison + const salt = pad(toHex(chainId), { size: 32 }); + const saltHash = keccak256( + encodeAbiParameters(parseAbiParameters("bytes32, bytes32, bytes32, address, bytes32"), [ + keccak256(toHex("EIP712Domain(string name,string version,address verifyingContract,bytes32 salt)")), + keccak256(toHex(tokenName)), + keccak256(toHex(version)), + tokenAddress, + salt + ]) + ); + + if (onChainSeparator === saltHash) { + return { name: tokenName, salt, verifyingContract: tokenAddress, version }; + } + + // Neither matches - this is an error + throw new Error( + `Token ${tokenName} has unexpected DOMAIN_SEPARATOR. Expected standard: ${standardHash} or salt: ${saltHash}, got: ${onChainSeparator}` + ); + } + // use standard domain + return { chainId, name: tokenName, verifyingContract: tokenAddress, version }; + } + + // No on-chain separator available - default to standard + return { chainId, name: tokenName, verifyingContract: tokenAddress, version }; +} + +const erc20Abi = [ + { + inputs: [{ name: "account", type: "address" }], + name: "balanceOf", + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + type: "function" + }, + { + inputs: [{ name: "owner", type: "address" }], + name: "nonces", + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + type: "function" + }, + { inputs: [], name: "name", outputs: [{ name: "", type: "string" }], stateMutability: "view", type: "function" } +]; + +/** + * Prepares all transactions for an EVM to Alfredpay (USD) offramp. + * This route handles: EVM → Polygon (USDC) → Alfredpay (Fiat) + */ +export async function prepareEvmToAlfredpayOfframpTransactions({ + quote, + signingAccounts, + userAddress, + userId +}: OfframpTransactionParams): Promise { + const unsignedTxs: UnsignedTx[] = []; + let stateMeta: Partial = {}; + + const evmClientManager = EvmClientManager.getInstance(); + + const fromNetwork = getNetworkFromDestination(quote.from); + if (!fromNetwork) { + throw new Error(`Invalid network for destination ${quote.from}`); + } + + const evmEphemeralEntry = signingAccounts.find(account => account.type === "EVM"); + if (!evmEphemeralEntry) { + throw new Error("EVM ephemeral account not found"); + } + + const inputTokenDetails = getOnChainTokenDetails(fromNetwork, quote.inputCurrency); + if (!inputTokenDetails || !isEvmToken(quote.inputCurrency)) { + throw new Error(`Input token details not found for ${quote.inputCurrency} on network ${fromNetwork}`); + } + + if (!userAddress) { + throw new Error("User address must be provided for offramping."); + } + + if (!quote.metadata.alfredpayOfframp?.inputAmountRaw) { + throw new Error("Missing alfredpayOfframp.inputAmountRaw in quote metadata"); + } + + if (!isNetworkEVM(fromNetwork)) { + throw new Error(`Unsupported source network ${fromNetwork} for EVM to Alfredpay type offramp`); + } + + const customer = await AlfredPayCustomer.findOne({ + where: { userId } + }); + + if (!customer) { + throw new Error(`Alfredpay customer not found for userId ${userId}`); + } + + if (customer.status !== AlfredPayStatus.Success) { + throw new Error(`Alfredpay customer status is ${customer.status}, expected Success. Proceed first with KYC.`); + } + + const inputAmountRaw = new Big(quote.inputAmount).mul(new Big(10).pow(inputTokenDetails.decimals)).toFixed(0, 0); + + const bridgeResult = await createOfframpSquidrouterTransactionsToEvm({ + destinationAddress: evmEphemeralEntry.address, + fromAddress: userAddress, + fromNetwork, + fromToken: (inputTokenDetails as EvmTokenDetails).erc20AddressSourceChain, + rawAmount: inputAmountRaw, + toNetwork: Networks.Polygon, + toToken: ERC20_USDC_POLYGON + }); + + const permitDeadline = BigInt(Math.floor(Date.now() / 1000) + 24 * 60 * 60); // 24 hours from "now" + + const publicClient = evmClientManager.getClient(fromNetwork); + + const userNonce = (await publicClient.readContract({ + abi: erc20Abi, + address: (inputTokenDetails as EvmTokenDetails).erc20AddressSourceChain, + args: [userAddress], + functionName: "nonces" + })) as bigint; + + const tokenName = (await publicClient.readContract({ + abi: erc20Abi, + address: (inputTokenDetails as EvmTokenDetails).erc20AddressSourceChain, + functionName: "name" + })) as string; + + const chainId = getNetworkId(fromNetwork)!; + const resolvedDomain = await resolvePermitDomain( + publicClient, + (inputTokenDetails as EvmTokenDetails).erc20AddressSourceChain, + chainId, + tokenName + ); + + const permitTypedData: SignedTypedData = { + domain: resolvedDomain, + message: { + deadline: permitDeadline.toString(), + nonce: userNonce.toString(), + owner: userAddress, + spender: RELAYER_ADDRESS, + value: inputAmountRaw.toString() + }, + primaryType: "Permit", + types: { + Permit: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" } + ] + } + }; + + // Create payload typed data for the relayer + const payloadNonce = BigInt(Math.floor(Date.now() / 1000)); // Use timestamp as nonce + const payloadDeadline = BigInt(Math.floor(Date.now() / 1000) + 3600); + + const payloadTypedData: SignedTypedData = { + domain: { + chainId: getNetworkId(fromNetwork)!, + name: "TokenRelayer", + verifyingContract: RELAYER_ADDRESS, + version: "1" + }, + message: { + data: bridgeResult.swapData.data, + deadline: payloadDeadline.toString(), + destination: bridgeResult.swapData.to, + ethValue: bridgeResult.swapData.value, + nonce: payloadNonce.toString(), + owner: userAddress, + token: (inputTokenDetails as EvmTokenDetails).erc20AddressSourceChain, + value: inputAmountRaw.toString() + }, + primaryType: "Payload", + types: { + Payload: [ + { name: "destination", type: "address" }, + { name: "owner", type: "address" }, + { name: "token", type: "address" }, + { name: "value", type: "uint256" }, + { name: "data", type: "bytes" }, + { name: "ethValue", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" } + ] + } + }; + + // Bundle both signatures into a single transaction + const typedDataArray: SignedTypedData[] = [permitTypedData, payloadTypedData]; + + unsignedTxs.push({ + meta: {}, + network: fromNetwork, + nonce: 0, + phase: "squidRouterPermitExecute", + signer: userAddress, + txData: typedDataArray + }); + + stateMeta = { + ...stateMeta, + alfredpayUserId: customer.alfredPayId, + evmEphemeralAddress: evmEphemeralEntry.address, + squidRouterPermitExecutionValue: bridgeResult.swapData.value, + walletAddress: userAddress + }; + + const finalTransferTxData = await addOnrampDestinationChainTransactions({ + amountRaw: quote.metadata.alfredpayOfframp.inputAmountRaw, + destinationNetwork: Networks.Polygon as EvmNetworks, + toAddress: "0x7Ba99e99Bc669B3508AFf9CC0A898E869459F877", // TODO placeholder + toToken: ERC20_USDC_POLYGON + }); + + unsignedTxs.push({ + meta: {}, + network: Networks.Polygon, + nonce: 0, + phase: "alfredpayOfframpTransfer", + signer: evmEphemeralEntry.address, + txData: finalTransferTxData + }); + + return { stateMeta, unsignedTxs }; +} diff --git a/apps/api/src/api/services/transactions/offramp/routes/evm-to-brl.ts b/apps/api/src/api/services/transactions/offramp/routes/evm-to-brl.ts index 32ab0ba7f..d0c3ea056 100644 --- a/apps/api/src/api/services/transactions/offramp/routes/evm-to-brl.ts +++ b/apps/api/src/api/services/transactions/offramp/routes/evm-to-brl.ts @@ -1,4 +1,11 @@ -import { encodeSubmittableExtrinsic, getPendulumDetails, isEvmTokenDetails, Networks, UnsignedTx } from "@vortexfi/shared"; +import { + encodeSubmittableExtrinsic, + getPendulumDetails, + isEvmTokenDetails, + MoonbeamTokenDetails, + Networks, + UnsignedTx +} from "@vortexfi/shared"; import Big from "big.js"; import { multiplyByPowerOfTen } from "../../../pendulum/helpers"; import { StateMetadata } from "../../../phases/meta-state-types"; @@ -123,7 +130,7 @@ export async function prepareEvmToBRLOfframpTransactions({ account: substrateAccount, brlaEvmAddress: validatedBrlaEvmAddress, outputAmountRaw: offrampAmountBeforeAnchorFeesRaw, - outputTokenPendulumDetails: outputTokenDetails.pendulumRepresentative, + outputTokenPendulumDetails: (outputTokenDetails as unknown as MoonbeamTokenDetails).pendulumRepresentative, pixDestination: validatedPixDestination, receiverTaxId: validatedReceiverTaxId, taxId: validatedTaxId diff --git a/apps/api/src/api/services/transactions/offramp/routes/evm-to-stellar.ts b/apps/api/src/api/services/transactions/offramp/routes/evm-to-stellar.ts index 36eba3834..e2da5a424 100644 --- a/apps/api/src/api/services/transactions/offramp/routes/evm-to-stellar.ts +++ b/apps/api/src/api/services/transactions/offramp/routes/evm-to-stellar.ts @@ -1,11 +1,4 @@ -import { - encodeSubmittableExtrinsic, - getPendulumDetails, - isEvmTokenDetails, - isStellarOutputTokenDetails, - Networks, - UnsignedTx -} from "@vortexfi/shared"; +import { encodeSubmittableExtrinsic, getPendulumDetails, isEvmTokenDetails, Networks, UnsignedTx } from "@vortexfi/shared"; import Big from "big.js"; import { multiplyByPowerOfTen } from "../../../pendulum/helpers"; import { StateMetadata } from "../../../phases/meta-state-types"; @@ -152,10 +145,6 @@ export async function prepareEvmToStellarOfframpTransactions({ ...stellarResult.stateMeta }; - if (!isStellarOutputTokenDetails(outputTokenDetails)) { - throw new Error(`Output currency must be Stellar token for offramp, got ${quote.outputCurrency}`); - } - if (!stellarPaymentData) { throw new Error("Stellar payment data must be provided for offramp"); } @@ -164,7 +153,7 @@ export async function prepareEvmToStellarOfframpTransactions({ { ephemeralAddress: stellarEphemeralEntry.address, outputAmountUnits: offrampAmountBeforeAnchorFeesUnits, - outputTokenDetails, + outputTokenDetails: stellarTokenDetails, stellarPaymentData }, unsignedTxs 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 49c183668..4749f8c78 100644 --- a/apps/api/src/api/services/transactions/onramp/common/transactions.ts +++ b/apps/api/src/api/services/transactions/onramp/common/transactions.ts @@ -255,13 +255,13 @@ export async function addDestinationChainApprovalTransaction(params: { functionName: "approve" }); - const { maxFeePerGas } = await publicClient.estimateFeesPerGas(); + const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); const txData: EvmTransactionData = { data: approveCallData as `0x${string}`, gas: "100000", maxFeePerGas: String(maxFeePerGas), - maxPriorityFeePerGas: String(maxFeePerGas), + maxPriorityFeePerGas: String(maxPriorityFeePerGas), to: tokenAddress, value: "0" }; diff --git a/apps/api/src/api/services/transactions/onramp/common/types.ts b/apps/api/src/api/services/transactions/onramp/common/types.ts index 152f4d3e6..7bb618d7e 100644 --- a/apps/api/src/api/services/transactions/onramp/common/types.ts +++ b/apps/api/src/api/services/transactions/onramp/common/types.ts @@ -9,6 +9,8 @@ export interface OnrampTransactionParams { export type AveniaOnrampTransactionParams = OnrampTransactionParams & { taxId: string }; +export type AlfredpayOnrampTransactionParams = OnrampTransactionParams & { userId: string }; + export type MoneriumOnrampTransactionParams = OnrampTransactionParams & { moneriumWalletAddress: string }; export interface OnrampTransactionsWithMeta { diff --git a/apps/api/src/api/services/transactions/onramp/index.ts b/apps/api/src/api/services/transactions/onramp/index.ts index 435220750..4227e3184 100644 --- a/apps/api/src/api/services/transactions/onramp/index.ts +++ b/apps/api/src/api/services/transactions/onramp/index.ts @@ -1,12 +1,23 @@ import { FiatToken, Networks } from "@vortexfi/shared"; -import { AveniaOnrampTransactionParams, MoneriumOnrampTransactionParams, OnrampTransactionsWithMeta } from "./common/types"; +import { + AlfredpayOnrampTransactionParams, + AveniaOnrampTransactionParams, + MoneriumOnrampTransactionParams, + OnrampTransactionParams, + OnrampTransactionsWithMeta +} from "./common/types"; +import { prepareAlfredpayToEvmOnrampTransactions } from "./routes/alfredpay-to-evm"; import { prepareAveniaToAssethubOnrampTransactions } from "./routes/avenia-to-assethub"; import { prepareAveniaToEvmOnrampTransactions } from "./routes/avenia-to-evm"; import { prepareMoneriumToAssethubOnrampTransactions } from "./routes/monerium-to-assethub"; import { prepareMoneriumToEvmOnrampTransactions } from "./routes/monerium-to-evm"; export async function prepareOnrampTransactions( - params: AveniaOnrampTransactionParams | MoneriumOnrampTransactionParams + params: + | AveniaOnrampTransactionParams + | MoneriumOnrampTransactionParams + | AlfredpayOnrampTransactionParams + | OnrampTransactionParams ): Promise { const { quote } = params; @@ -33,6 +44,16 @@ export async function prepareOnrampTransactions( } else { return prepareMoneriumToEvmOnrampTransactions(params); } + } else if (quote.inputCurrency === FiatToken.USD) { + if (!("userId" in params)) { + throw new Error("Alfredpay onramps requires logged in user"); + } + + if (quote.to !== Networks.AssetHub) { + return prepareAlfredpayToEvmOnrampTransactions(params); + } else { + throw new Error(`Unsupported destination network for Alfredpay onramp: ${quote.to}`); + } } else { throw new Error(`Unsupported input currency: ${quote.inputCurrency}`); } diff --git a/apps/api/src/api/services/transactions/onramp/routes/alfredpay-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/alfredpay-to-evm.ts new file mode 100644 index 000000000..3eac4077d --- /dev/null +++ b/apps/api/src/api/services/transactions/onramp/routes/alfredpay-to-evm.ts @@ -0,0 +1,219 @@ +import { + AlfredPayStatus, + createOnrampSquidrouterTransactionsFromPolygonToEvm, + createOnrampSquidrouterTransactionsOnDestinationChain, + ERC20_USDC_POLYGON, + EvmNetworks, + EvmToken, + EvmTokenDetails, + EvmTransactionData, + evmTokenConfig, + getNetworkFromDestination, + getOnChainTokenDetails, + getOnChainTokenDetailsOrDefault, + isEvmToken, + isOnChainToken, + Networks, + UnsignedTx +} from "@vortexfi/shared"; +import { privateKeyToAccount } from "viem/accounts"; +import { MOONBEAM_FUNDING_PRIVATE_KEY } from "../../../../../constants/constants"; +import AlfredPayCustomer from "../../../../../models/alfredPayCustomer.model"; +import { StateMetadata } from "../../../phases/meta-state-types"; +import { encodeEvmTransactionData } from "../../index"; +import { addDestinationChainApprovalTransaction, addOnrampDestinationChainTransactions } from "../common/transactions"; +import { AlfredpayOnrampTransactionParams, OnrampTransactionsWithMeta } from "../common/types"; + +/** + * Prepares all transactions for Alfredpay (USD) onramp to EVM chain. + * This route handles: USD → Polygon (USDC/USDT) → EVM (final transfer) + */ +export async function prepareAlfredpayToEvmOnrampTransactions({ + quote, + signingAccounts, + destinationAddress, + userId +}: AlfredpayOnrampTransactionParams): Promise { + let stateMeta: Partial = {}; + const unsignedTxs: UnsignedTx[] = []; + + const evmEphemeralEntry = signingAccounts.find(ephemeral => ephemeral.type === "EVM"); + if (!evmEphemeralEntry) { + throw new Error("EVM ephemeral entry not found"); + } + + if (!quote.metadata.alfredpayMint?.outputAmountRaw) { + throw new Error("Missing alfredpay raw mint amount in quote metadata"); + } + + const toNetwork = getNetworkFromDestination(quote.to); + if (!toNetwork || toNetwork === Networks.AssetHub) { + throw new Error(`Invalid network for destination ${quote.to}`); + } + + if (!isOnChainToken(quote.outputCurrency)) { + throw new Error(`Output currency cannot be fiat token ${quote.outputCurrency} for onramp.`); + } + + const outputTokenDetails = getOnChainTokenDetails(toNetwork, quote.outputCurrency); + if (!outputTokenDetails || !isEvmToken(quote.outputCurrency)) { + throw new Error(`Output token details not found for ${quote.outputCurrency} on network ${toNetwork}`); + } + + const customer = await AlfredPayCustomer.findOne({ + where: { userId } + }); + + if (!customer) { + throw new Error(`Alfredpay customer not found for userId ${userId}`); + } + + if (customer.status !== AlfredPayStatus.Success) { + throw new Error(`Alfredpay customer status is ${customer.status}, expected Success. Proceed first with KYC.`); + } + + // Setup state metadata + stateMeta = { + alfredpayUserId: customer.alfredPayId, + destinationAddress, + evmEphemeralAddress: evmEphemeralEntry.address + }; + + let polygonAccountNonce = 0; // Starts fresh + + // Special case, onramping USDC on Polygon. We need to skip the SquidRouter step and go directly to the destination transfer. + if ((outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain === ERC20_USDC_POLYGON) { + const finalTransferTxData = await addOnrampDestinationChainTransactions({ + amountRaw: quote.metadata.alfredpayMint.outputAmountRaw, + destinationNetwork: toNetwork as EvmNetworks, + toAddress: destinationAddress, + toToken: (outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain + }); + unsignedTxs.push({ + meta: {}, + network: toNetwork, + nonce: polygonAccountNonce, + phase: "destinationTransfer", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(finalTransferTxData) as EvmTransactionData + }); + + stateMeta = { + ...stateMeta + }; + + return { stateMeta, unsignedTxs }; + } + + const { approveData, swapData, squidRouterQuoteId, squidRouterReceiverId, squidRouterReceiverHash } = + await createOnrampSquidrouterTransactionsFromPolygonToEvm({ + destinationAddress: evmEphemeralEntry.address, + fromAddress: evmEphemeralEntry.address, + fromToken: ERC20_USDC_POLYGON, + rawAmount: quote.metadata.alfredpayMint.outputAmountRaw, + toNetwork, + toToken: (outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain + }); + + unsignedTxs.push({ + meta: {}, + network: Networks.Polygon, // Hardcoded to mint on Polygon + nonce: polygonAccountNonce++, + phase: "squidRouterApprove", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(approveData) as EvmTransactionData + }); + + unsignedTxs.push({ + meta: {}, + network: Networks.Polygon, + nonce: polygonAccountNonce++, + phase: "squidRouterSwap", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(swapData) as EvmTransactionData + }); + + const finalTransferTxData = await addOnrampDestinationChainTransactions({ + amountRaw: quote.metadata.alfredpayMint.outputAmountRaw, + destinationNetwork: toNetwork as EvmNetworks, + toAddress: destinationAddress, + toToken: (outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain + }); + + let destinationNonce = toNetwork === Networks.Polygon ? polygonAccountNonce++ : 0; // If the destination is Polygon, we need to use the same nonce sequence. Otherwise, we start fresh on the new chain. + + unsignedTxs.push({ + meta: {}, + network: toNetwork, + nonce: destinationNonce++, + phase: "destinationTransfer", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(finalTransferTxData) as EvmTransactionData + }); + + // Fallback swap depends on the EVM chain. For Ethereum, the bridged token is USDC. For the rest, it is axlUSDC. + const destinationAxlUsdcDetails = getOnChainTokenDetailsOrDefault(toNetwork as Networks, EvmToken.AXLUSDC) as EvmTokenDetails; + const bridgedTokenForFallback = + toNetwork === Networks.Ethereum + ? evmTokenConfig.ethereum.USDC!.erc20AddressSourceChain + : destinationAxlUsdcDetails.erc20AddressSourceChain; + + const { approveData: destApproveData, swapData: destSwapData } = await createOnrampSquidrouterTransactionsOnDestinationChain({ + destinationAddress: evmEphemeralEntry.address, + fromAddress: evmEphemeralEntry.address, + fromToken: bridgedTokenForFallback, + network: toNetwork as EvmNetworks, + rawAmount: quote.metadata.alfredpayMint.outputAmountRaw, + toToken: (outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain + }); + + 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 + }); + + stateMeta = { + ...stateMeta, + squidRouterQuoteId, + squidRouterReceiverHash, + squidRouterReceiverId + }; + + return { stateMeta, unsignedTxs }; +} diff --git a/apps/api/src/api/services/transactions/validation.ts b/apps/api/src/api/services/transactions/validation.ts index 72823f80c..95b6cd055 100644 --- a/apps/api/src/api/services/transactions/validation.ts +++ b/apps/api/src/api/services/transactions/validation.ts @@ -5,6 +5,8 @@ import { CleanupPhase, EphemeralAccountType, getNetworkId, + isSignedTypedData, + isSignedTypedDataArray, PresignedTx, RampDirection, RampPhase, @@ -99,6 +101,11 @@ export async function validatePresignedTxs( function validateEvmTransaction(tx: PresignedTx, expectedSigner: string) { const { txData, signer } = tx; + // do not validate typed data + if (isSignedTypedData(txData) || isSignedTypedDataArray(txData)) { + return; + } + if (!expectedSigner) { throw new APIError({ message: "Expected signer for EVM transaction is not provided", @@ -224,7 +231,7 @@ async function validateStellarTransaction(tx: PresignedTx, expectedSigner: strin }); } - logger.info("Parsed Stellar transaction source:", transaction.source); + logger.debug("Parsed Stellar transaction source:", transaction.source); if (phase === "stellarCreateAccount") { if (transaction.operations.length !== 3) { diff --git a/apps/api/src/contracts/TokenRelayer.ts b/apps/api/src/contracts/TokenRelayer.ts new file mode 100644 index 000000000..412d844a0 --- /dev/null +++ b/apps/api/src/contracts/TokenRelayer.ts @@ -0,0 +1,30 @@ +export const tokenRelayerAbi = [ + { + inputs: [ + { + components: [ + { name: "token", type: "address" }, + { name: "owner", type: "address" }, + { name: "value", type: "uint256" }, + { name: "deadline", type: "uint256" }, + { name: "permitV", type: "uint8" }, + { name: "permitR", type: "bytes32" }, + { name: "permitS", type: "bytes32" }, + { name: "payloadData", type: "bytes" }, + { name: "payloadValue", type: "uint256" }, + { name: "payloadNonce", type: "uint256" }, + { name: "payloadDeadline", type: "uint256" }, + { name: "payloadV", type: "uint8" }, + { name: "payloadR", type: "bytes32" }, + { name: "payloadS", type: "bytes32" } + ], + name: "params", + type: "tuple" + } + ], + name: "execute", + outputs: [{ name: "", type: "bool" }], + stateMutability: "payable", + type: "function" + } +]; diff --git a/apps/api/src/database/migrations/023-create-alfredpay-customers-table.ts b/apps/api/src/database/migrations/023-create-alfredpay-customers-table.ts new file mode 100644 index 000000000..22e734a10 --- /dev/null +++ b/apps/api/src/database/migrations/023-create-alfredpay-customers-table.ts @@ -0,0 +1,90 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.createTable("alfredpay_customers", { + alfred_pay_id: { + allowNull: false, + type: DataTypes.STRING, + unique: true + }, + country: { + allowNull: false, + type: DataTypes.ENUM("MX", "AR", "BR", "CO", "DO", "US", "CN", "HK", "CL", "PE", "BO") + }, + created_at: { + allowNull: false, + defaultValue: DataTypes.NOW, + type: DataTypes.DATE + }, + id: { + allowNull: false, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + last_failure_reasons: { + allowNull: true, + defaultValue: [], + type: DataTypes.ARRAY(DataTypes.STRING) + }, + status: { + allowNull: false, + defaultValue: "CONSULTED", + type: DataTypes.ENUM("CONSULTED", "LINK_OPENED", "USER_COMPLETED", "VERIFYING", "FAILED", "SUCCESS") + }, + status_external: { + allowNull: true, + type: DataTypes.STRING + }, + type: { + allowNull: false, + defaultValue: "INDIVIDUAL", + type: DataTypes.ENUM("INDIVIDUAL", "BUSINESS") + }, + updated_at: { + allowNull: false, + defaultValue: DataTypes.NOW, + type: DataTypes.DATE + }, + user_id: { + allowNull: false, + type: DataTypes.UUID + } + }); + + // Add foreign key constraint for user_id + await queryInterface.addConstraint("alfredpay_customers", { + fields: ["user_id"], + name: "fk_alfredpay_customers_user_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + field: "id", + table: "profiles" + }, + type: "foreign key" + }); + + // Indexes + await queryInterface.addIndex("alfredpay_customers", ["user_id"], { + name: "idx_alfredpay_customers_user_id" + }); + + await queryInterface.addIndex("alfredpay_customers", ["alfred_pay_id"], { + name: "idx_alfredpay_customers_alfred_pay_id", + unique: true + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + // Remove FK + await queryInterface.removeConstraint("alfredpay_customers", "fk_alfredpay_customers_user_id"); + + // Drop table + await queryInterface.dropTable("alfredpay_customers"); + + // Drop enums + await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_alfredpay_customers_country";').catch(() => {}); + await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_alfredpay_customers_status";').catch(() => {}); + await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_alfredpay_customers_type";').catch(() => {}); +} diff --git a/apps/api/src/database/migrations/024-increase-currency-length.ts b/apps/api/src/database/migrations/024-increase-currency-length.ts new file mode 100644 index 000000000..f0c21ef36 --- /dev/null +++ b/apps/api/src/database/migrations/024-increase-currency-length.ts @@ -0,0 +1,53 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +export async function up(queryInterface: QueryInterface): Promise { + // Alter quote_tickets table + await queryInterface.changeColumn("quote_tickets", "input_currency", { + allowNull: false, + type: DataTypes.STRING(30) + }); + + await queryInterface.changeColumn("quote_tickets", "output_currency", { + allowNull: false, + type: DataTypes.STRING(30) + }); + + // Alter partners table + await queryInterface.changeColumn("partners", "markup_currency", { + allowNull: true, + type: DataTypes.STRING(30) + }); + + // Alter anchors table + await queryInterface.changeColumn("anchors", "currency", { + allowNull: false, + defaultValue: "USD", + type: DataTypes.STRING(30) + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + // Revert quote_tickets table + await queryInterface.changeColumn("quote_tickets", "input_currency", { + allowNull: false, + type: DataTypes.STRING(8) + }); + + await queryInterface.changeColumn("quote_tickets", "output_currency", { + allowNull: false, + type: DataTypes.STRING(8) + }); + + // Revert partners table + await queryInterface.changeColumn("partners", "markup_currency", { + allowNull: true, + type: DataTypes.STRING(8) + }); + + // Revert anchors table + await queryInterface.changeColumn("anchors", "currency", { + allowNull: false, + defaultValue: "USD", + type: DataTypes.STRING(8) + }); +} diff --git a/apps/api/src/models/alfredPayCustomer.model.ts b/apps/api/src/models/alfredPayCustomer.model.ts new file mode 100644 index 000000000..349cca1e3 --- /dev/null +++ b/apps/api/src/models/alfredPayCustomer.model.ts @@ -0,0 +1,127 @@ +import { AlfredPayCountry, AlfredPayStatus, AlfredPayType } from "@vortexfi/shared"; +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +export interface AlfredPayCustomerAttributes { + id: string; // Internal PK + userId: string; // Foreign key to User (profiles.id) + alfredPayId: string; // Alfredpay's user ID + country: AlfredPayCountry; + status: AlfredPayStatus; + statusExternal: string | null; + lastFailureReasons: string[] | null; + type: AlfredPayType; + createdAt: Date; + updatedAt: Date; +} + +type AlfredPayCustomerCreationAttributes = Optional< + AlfredPayCustomerAttributes, + "id" | "createdAt" | "updatedAt" | "statusExternal" | "lastFailureReasons" +>; + +class AlfredPayCustomer + extends Model + implements AlfredPayCustomerAttributes +{ + declare id: string; + declare userId: string; + declare alfredPayId: string; + declare country: AlfredPayCountry; + declare status: AlfredPayStatus; + declare statusExternal: string | null; + declare lastFailureReasons: string[] | null; + declare type: AlfredPayType; + declare createdAt: Date; + declare updatedAt: Date; +} + +AlfredPayCustomer.init( + { + alfredPayId: { + allowNull: false, + comment: "Alfredpay's user ID", + field: "alfred_pay_id", + type: DataTypes.STRING, + unique: true + }, + country: { + allowNull: false, + type: DataTypes.ENUM(...Object.values(AlfredPayCountry)) + }, + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + id: { + allowNull: false, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + lastFailureReasons: { + allowNull: true, + defaultValue: [], + field: "last_failure_reasons", + type: DataTypes.ARRAY(DataTypes.STRING) + }, + status: { + allowNull: false, + defaultValue: AlfredPayStatus.Consulted, + type: DataTypes.ENUM(...Object.values(AlfredPayStatus)) + }, + statusExternal: { + allowNull: true, + comment: "Alfredpay's direct status", + field: "status_external", + type: DataTypes.STRING + }, + type: { + allowNull: false, + defaultValue: AlfredPayType.INDIVIDUAL, + type: DataTypes.ENUM(...Object.values(AlfredPayType)) + }, + updatedAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "updated_at", + type: DataTypes.DATE + }, + userId: { + allowNull: false, + field: "user_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + key: "id", + model: "profiles" + }, + type: DataTypes.UUID + } + }, + { + indexes: [ + { + fields: ["user_id"], + name: "idx_alfredpay_customers_user_id" + }, + { + fields: ["alfred_pay_id"], + name: "idx_alfredpay_customers_alfred_pay_id", + unique: true + }, + { + fields: ["email"], + name: "idx_alfredpay_customers_email" + } + ], + modelName: "AlfredPayCustomer", + sequelize, // Following convention + tableName: "alfredpay_customers", + timestamps: true + } +); + +export default AlfredPayCustomer; diff --git a/apps/api/src/models/anchor.model.ts b/apps/api/src/models/anchor.model.ts index f472afa79..a8d6aa88f 100644 --- a/apps/api/src/models/anchor.model.ts +++ b/apps/api/src/models/anchor.model.ts @@ -51,7 +51,7 @@ Anchor.init( currency: { allowNull: false, defaultValue: "USD", - type: DataTypes.STRING(8) + type: DataTypes.STRING(30) }, id: { defaultValue: DataTypes.UUIDV4, diff --git a/apps/api/src/models/index.ts b/apps/api/src/models/index.ts index 797670417..6cbc45d87 100644 --- a/apps/api/src/models/index.ts +++ b/apps/api/src/models/index.ts @@ -1,4 +1,5 @@ import sequelize from "../config/database"; +import AlfredPayCustomer from "./alfredPayCustomer.model"; import Anchor from "./anchor.model"; import ApiKey from "./apiKey.model"; import KycLevel2 from "./kycLevel2.model"; @@ -32,8 +33,12 @@ KycLevel2.belongsTo(User, { as: "user", foreignKey: "userId" }); User.hasMany(TaxId, { as: "taxIds", foreignKey: "userId" }); TaxId.belongsTo(User, { as: "user", foreignKey: "userId" }); +User.hasMany(AlfredPayCustomer, { as: "alfredPayCustomers", foreignKey: "userId" }); +AlfredPayCustomer.belongsTo(User, { as: "user", foreignKey: "userId" }); + // Initialize models const models = { + AlfredPayCustomer, Anchor, ApiKey, KycLevel2, diff --git a/apps/api/src/models/partner.model.ts b/apps/api/src/models/partner.model.ts index fab0960ba..4c5138b6b 100644 --- a/apps/api/src/models/partner.model.ts +++ b/apps/api/src/models/partner.model.ts @@ -99,7 +99,7 @@ Partner.init( markupCurrency: { allowNull: true, field: "markup_currency", - type: DataTypes.STRING(8) + type: DataTypes.STRING(30) }, markupType: { allowNull: false, diff --git a/apps/api/src/models/quoteTicket.model.ts b/apps/api/src/models/quoteTicket.model.ts index cb27f94f5..d7fe8fa06 100644 --- a/apps/api/src/models/quoteTicket.model.ts +++ b/apps/api/src/models/quoteTicket.model.ts @@ -1,13 +1,6 @@ -import { - DestinationType, - Networks, - PaymentMethod, - QuoteFeeStructure, - RampCurrency, - RampDirection -} from "@vortexfi/shared"; -import {DataTypes, Model, Optional} from "sequelize"; -import {QuoteTicketMetadata} from "../api/services/quote/core/types"; +import { DestinationType, Networks, PaymentMethod, QuoteFeeStructure, RampCurrency, RampDirection } from "@vortexfi/shared"; +import { DataTypes, Model, Optional } from "sequelize"; +import { QuoteTicketMetadata } from "../api/services/quote/core/types"; import sequelize from "../config/database"; // Define the attributes of the QuoteTicket model @@ -126,7 +119,7 @@ QuoteTicket.init( inputCurrency: { allowNull: false, field: "input_currency", - type: DataTypes.STRING(8) + type: DataTypes.STRING(30) }, metadata: { allowNull: false, @@ -144,7 +137,7 @@ QuoteTicket.init( outputCurrency: { allowNull: false, field: "output_currency", - type: DataTypes.STRING(8) + type: DataTypes.STRING(30) }, partnerId: { allowNull: true, diff --git a/apps/frontend/.impeccable.md b/apps/frontend/.impeccable.md new file mode 100644 index 000000000..f4f7c146c --- /dev/null +++ b/apps/frontend/.impeccable.md @@ -0,0 +1,82 @@ +## Design Context + +### Users + +**Primary:** Crypto-native DeFi users who need to move money between fiat and crypto efficiently. They understand wallets, slippage, liquidity — but still expect fast, frictionless UX. + +**Integration partners:** Protocols like Pendle that embed Vortex as a ramping layer. Users arrive mid-flow from another product and need zero onboarding friction. + +**High-volume P2P operators:** Businesses like IaCrypto (Brazil) processing millions in daily volume. They care about reliability, speed, and trust signals — not aesthetics. Every failure costs real money. + +**Implication:** The interface must feel immediately legible to someone who has used Aave or Uniswap. No hand-holding, no consumer-app softness. Assume competence. + +--- + +### Brand Personality + +**Three words:** Reliable. Modern. Calm. + +- **Reliable** — Every element should signal that this system works, is audited, and handles real money. No flashy UI that feels like a casino. When something goes wrong, error states are clear and non-panicking. +- **Modern** — On par visually with Aave, not behind it. Clean, high-contrast, purposeful use of space. No legacy fintech feel. +- **Calm** — Payments are stressful. The UI absorbs that stress. Generous whitespace, unhurried layouts, smooth transitions — not jarring or jumpy. + +**Voice:** Concise, direct, confident. No filler copy. Labels describe exactly what they mean. Errors explain what happened and what to do. + +--- + +### Aesthetic Direction + +**Primary reference:** Aave — dark-capable, information-dense but not cluttered, strong typographic hierarchy, restrained use of color, data displayed with precision. + +**Theme:** Light mode only (current). White cards on near-white backgrounds, blue as the single action color, pink as a secondary signal only where needed. + +**Anti-references:** + +- Consumer fintech (Revolut, Cash App) — too playful, too colorful, wrong audience +- Crypto degen UIs — cluttered, aggressive, no hierarchy +- Legacy banking UIs — dated, corporate, low trust with DeFi users + +**Color discipline:** Blue for all primary actions. Pink accent used sparingly — not decoratively. Grays carry most of the layout weight. No rainbow, no gradients for decoration. + +**Motion:** Purposeful only. Appear/slide-up transitions on mount, scale feedback on press. No looping animations, no attention-grabbing movement. + +--- + +### Design Principles + +1. **Precision over decoration** — Every pixel should communicate information or create breathing room. Remove anything that doesn't do one of those two things. +2. **Trust is the product** — Visual consistency, clear number formatting, explicit fee disclosure, and legible error states are not polish — they are the core UX. High-volume users will abandon on the first sign of confusion. +3. **Assume competence** — Skip tutorials and tooltips for basic crypto concepts. Show data at the right level of detail. Let advanced users read everything they need directly from the UI. +4. **Calm under load** — Loading states, errors, and multi-step flows should feel controlled and unhurried. Use skeletons, not spinners. Use descriptive status labels, not raw error codes. +5. **Aave-level finish** — The standard is: would this component look at home in the Aave interface? Consistent border radii, aligned grids, correct font weights, no stray margins. Ship nothing that falls below that bar. + +--- + +### Design System + +**Stack:** Tailwind CSS v4 + DaisyUI v5 + Framer Motion (`motion/react`). All color tokens in OKLCH. + +**Color tokens** (defined in `apps/frontend/App.css` `:root`): +- `--color-primary` / `--color-primary-hover` — brand blue, all primary actions +- `--color-accent` — pink, secondary signal only (never decorative) +- `--color-error/success/warning` + `-hover` variants — semantic state colors +- `--color-primary-hover`, `--color-secondary-hover`, `--color-error-hover`, `--color-success-hover`, `--color-warning-hover` — darkened versions for button hover states (never invert on hover) + +**Button variants** (in `App.css`): +- `.btn-vortex-primary` — blue, main CTA +- `.btn-vortex-primary-inverse` — outlined blue +- `.btn-vortex-secondary` — pink +- `.btn-vortex-accent` — gray, tertiary +- `.btn-vortex-success` / `.btn-vortex-danger` / `.btn-vortex-warning` — state variants +- All hover states darken (use `-hover` tokens), never invert to light background + +**Spacing utilities:** +- `.pb-footer-offset` — `calc(var(--quote-summary-height) + 1rem)` for content above absolute footer +- `.bottom-above-quote` — positions elements above the QuoteSummary panel +- `.touch-target` — expands hit area by 8px on all sides via `::after` pseudo-element without affecting layout; use on small interactive elements instead of inflating their visual size + +**Z-index scale** (named utilities, never arbitrary values): +- `.z-footer` (5) → `.z-quote` (10) → `.z-overlay` (20) → `.z-modal` (50) → `.z-toast` (100) + +**Typography:** `"Red Hat Display"` only. Use `.text-widget-title`, `.text-section-title`, `.text-amount`, `.text-label`, `.text-caption` for widget UI — never raw Tailwind font sizes inside the widget. + diff --git a/apps/frontend/App.css b/apps/frontend/App.css index 32a2e9c11..377b3d0bb 100644 --- a/apps/frontend/App.css +++ b/apps/frontend/App.css @@ -19,6 +19,23 @@ --color-base-300: oklch(0.92 0.008 250); --color-base-content: oklch(0.5 0.04 250); + /* Semantic state colors */ + --color-warning: oklch(0.77 0.16 83); + --color-warning-content: oklch(0.15 0 0); + + --color-success: oklch(0.448 0.119 151.328); + --color-success-content: oklch(1 0 0); + + --color-error: oklch(0.444 0.177 26.899); + --color-error-content: oklch(1 0 0); + + /* Interaction state colors */ + --color-primary-hover: oklch(0.37 0.2 260); + --color-secondary-hover: oklch(0.46 0.22 350); + --color-error-hover: oklch(0.37 0.177 26.899); + --color-success-hover: oklch(0.37 0.119 151.328); + --color-warning-hover: oklch(0.65 0.16 83); + /* Project-specific variables */ --radius-field: 9px; --text: oklch(0.15 0 0); @@ -32,7 +49,7 @@ /* QuoteSummary layout */ --quote-summary-height: 88px; - --widget-min-height: 506px; + --widget-min-height: 620px; } @layer base { @@ -50,15 +67,13 @@ :root:has(:is(.modal-open, .modal:target, .modal-toggle:checked + .modal, .modal[open])) { scrollbar-gutter: unset; + overflow-y: scroll; } } /* Component Overrides (Unlayered to ensure precedence over DaisyUI layers) */ .modal-box { - border-bottom-right-radius: 1.25rem; - border-bottom-left-radius: 1.25rem; - border-top-left-radius: 1.25rem; - border-top-right-radius: 1.25rem; + @apply rounded-2xl; } .input-disabled { @@ -93,11 +108,11 @@ min-height: 2.5rem !important; } .step-vortex::before { - @apply bg-blue-700; + @apply bg-primary; width: 2px !important; } .step-vortex::after { - @apply text-blue-700; + @apply text-primary; } .step-primary.step-vortex::after { @apply text-white; @@ -110,7 +125,7 @@ } .collapse-title::after { - @apply text-blue-700; + @apply text-primary; @apply w-3; @apply h-3; top: 1.4rem !important; @@ -123,10 +138,11 @@ .btn { height: 3rem; box-shadow: none; + cursor: pointer !important; } .btn-vortex-primary { - @apply bg-blue-700 text-white rounded-[var(--radius-field)] border border-blue-700 cursor-pointer; + @apply bg-primary text-primary-content rounded-[var(--radius-field)] border border-primary cursor-pointer; transition: scale 0.1s ease-in-out; } @@ -135,11 +151,13 @@ } .btn-vortex-primary:hover { - @apply bg-blue-200 text-blue-700; + background-color: var(--color-primary-hover); + color: var(--color-primary-content); + border-color: var(--color-primary-hover); } .btn-vortex-primary:disabled { - @apply opacity-40; + @apply opacity-40 cursor-not-allowed; } .btn-vortex-accent { @@ -157,17 +175,64 @@ } .btn-vortex-accent:hover { - @apply bg-blue-100; - @apply text-blue-700; - @apply border-blue-300; + @apply bg-gray-300; + @apply text-gray-800; + @apply border-gray-400; +} + +.btn-vortex-accent:disabled { + @apply opacity-40 cursor-not-allowed; +} + +.btn-vortex-success { + @apply bg-success; + @apply text-success-content; + @apply rounded-[var(--radius-field)]; + @apply border; + @apply border-success; + @apply duration-200; + transition: scale 0.1s ease-in-out; +} + +.btn-vortex-success:active { + scale: 0.98; +} + +.btn-vortex-success:hover { + background-color: var(--color-success-hover); + color: var(--color-success-content); + border-color: var(--color-success-hover); +} + +.btn-vortex-success:disabled { + @apply opacity-40 cursor-not-allowed; +} + +.btn-vortex-warning { + @apply bg-warning text-warning-content rounded-[var(--radius-field)] border border-warning; + transition: scale 0.1s ease-in-out; +} + +.btn-vortex-warning:active { + scale: 0.98; +} + +.btn-vortex-warning:hover { + background-color: var(--color-warning-hover); + color: var(--color-warning-content); + border-color: var(--color-warning-hover); +} + +.btn-vortex-warning:disabled { + @apply opacity-40 cursor-not-allowed; } .btn-vortex-primary-inverse { @apply bg-white; - @apply text-blue-700; - @apply rounded-xl; + @apply text-primary; + @apply rounded-[var(--radius-field)]; @apply border; - @apply border-blue-700; + @apply border-primary; @apply cursor-pointer; transition: scale 0.1s ease-in-out; } @@ -177,27 +242,29 @@ } .btn-vortex-primary-inverse:hover { - @apply bg-blue-200; - @apply border-blue-700; + @apply bg-primary/20; + @apply border-primary; } .btn-vortex-primary-inverse:disabled { @apply bg-white; - @apply text-blue-700; - @apply border-blue-700; + @apply text-primary; + @apply border-primary; @apply opacity-40; + @apply cursor-not-allowed; } .btn-vortex-primary-inverse:active, .btn-vortex-primary-inverse:focus { - @apply bg-blue-200; - @apply text-blue-700; - @apply border-blue-700; + @apply bg-primary/20; + @apply text-primary; + @apply border-primary; } .btn-vortex-secondary { @apply text-white; @apply bg-pink-600; + @apply rounded-[var(--radius-field)]; @apply border-pink-600; @apply shadow-none; transition: scale 0.1s ease-in-out; @@ -208,18 +275,21 @@ } .btn-vortex-secondary:hover { - @apply bg-pink-100; - @apply text-pink-600; - @apply border; - @apply border-pink-600; + background-color: var(--color-secondary-hover); + color: white; + border-color: var(--color-secondary-hover); +} + +.btn-vortex-secondary:disabled { + @apply opacity-40 cursor-not-allowed; } .btn-vortex-danger { - @apply bg-red-600; - @apply text-white; + @apply bg-error; + @apply text-error-content; @apply rounded-xl; @apply border; - @apply border-red-600; + @apply border-error; @apply shadow-none; transition: scale 0.1s ease-in-out; } @@ -229,23 +299,24 @@ } .btn-vortex-danger:hover { - @apply bg-white; - @apply text-red-800; - @apply border-red-800; + background-color: var(--color-error-hover); + color: var(--color-error-content); + border-color: var(--color-error-hover); } .btn-vortex-danger:disabled { - @apply bg-red-600; - @apply text-white; - @apply border-red-600; + @apply bg-error; + @apply text-error-content; + @apply border-error; @apply opacity-40; + @apply cursor-not-allowed; } .btn-vortex-danger:active, .btn-vortex-danger:focus { - @apply bg-red-100; - @apply text-red-800; - @apply border-red-800; + @apply bg-error/10; + @apply text-error; + @apply border-error; } @layer utilities { @@ -253,6 +324,37 @@ bottom: calc(var(--quote-summary-height) + 1rem); } + .pb-footer-offset { + padding-bottom: calc(var(--quote-summary-height) + 1rem); + } + + /* Z-index scale */ + .z-footer { + z-index: 5; + } + .z-quote { + z-index: 10; + } + .z-overlay { + z-index: 20; + } + .z-modal { + z-index: 50; + } + .z-toast { + z-index: 100; + } + + /* Touch target expansion — expands hit area by 8px on all sides without affecting layout */ + .touch-target { + position: relative; + } + .touch-target::after { + content: ""; + position: absolute; + inset: -8px; + } + .shadow-custom { box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); } @@ -323,6 +425,27 @@ .text-body { @apply text-base; } + + /* Widget UI typography scale */ + .text-widget-title { + @apply text-3xl; + } + + .text-section-title { + @apply text-xl; + } + + .text-amount { + @apply text-lg; + } + + .text-label { + @apply text-sm; + } + + .text-caption { + @apply text-xs; + } } @keyframes appear { @@ -387,9 +510,11 @@ animation: caret-blink 1.2s ease-out infinite; } +/* Border-radius scale: sm=6px · md=8px · lg=10px · xl=14px · 2xl=20px */ @theme inline { --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 10px); } diff --git a/apps/frontend/package.json b/apps/frontend/package.json index ddf8b2926..5681d7f1c 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -58,6 +58,7 @@ "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/src/assets/business-handshake.svg b/apps/frontend/src/assets/business-handshake.svg new file mode 100644 index 000000000..e554b8c96 --- /dev/null +++ b/apps/frontend/src/assets/business-handshake.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/frontend/src/assets/coins/COP.png b/apps/frontend/src/assets/coins/COP.png new file mode 100644 index 000000000..f6826a475 Binary files /dev/null and b/apps/frontend/src/assets/coins/COP.png differ 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/EUR.svg b/apps/frontend/src/assets/coins/EUR.svg deleted file mode 100644 index 9275f9dfa..000000000 --- a/apps/frontend/src/assets/coins/EUR.svg +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - - - - - - - - - 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/MXN.png b/apps/frontend/src/assets/coins/MXN.png new file mode 100644 index 000000000..93760c1f1 Binary files /dev/null and b/apps/frontend/src/assets/coins/MXN.png differ 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/assets/coins/placeholder.svg b/apps/frontend/src/assets/coins/placeholder.svg index d90614e7c..e278354f2 100644 --- a/apps/frontend/src/assets/coins/placeholder.svg +++ b/apps/frontend/src/assets/coins/placeholder.svg @@ -1,5 +1,3 @@ - - - - + + diff --git a/apps/frontend/src/assets/document_ready.svg b/apps/frontend/src/assets/document_ready.svg new file mode 100644 index 000000000..b8533a6f8 --- /dev/null +++ b/apps/frontend/src/assets/document_ready.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/apps/frontend/src/assets/document_verified.svg b/apps/frontend/src/assets/document_verified.svg new file mode 100644 index 000000000..5e2ba19dd --- /dev/null +++ b/apps/frontend/src/assets/document_verified.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/frontend/src/assets/exclamation_mark_error.svg b/apps/frontend/src/assets/exclamation_mark_error.svg new file mode 100644 index 000000000..4cfb13b03 --- /dev/null +++ b/apps/frontend/src/assets/exclamation_mark_error.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/frontend/src/assets/quote-request-preview.mov b/apps/frontend/src/assets/videos/quote-request-preview.mov similarity index 100% rename from apps/frontend/src/assets/quote-request-preview.mov rename to apps/frontend/src/assets/videos/quote-request-preview.mov diff --git a/apps/frontend/src/components/Accordion/index.tsx b/apps/frontend/src/components/Accordion/index.tsx index 8fa71dac3..1a6814310 100644 --- a/apps/frontend/src/components/Accordion/index.tsx +++ b/apps/frontend/src/components/Accordion/index.tsx @@ -90,7 +90,7 @@ const AccordionTrigger: FC = ({ children, className = "",
toggleValue(value)} @@ -101,7 +101,7 @@ const AccordionTrigger: FC = ({ children, className = "", {children} = { + error: { container: "bg-error/10", text: "text-error" }, + success: { container: "bg-success/10", text: "text-success" }, + warning: { container: "bg-warning/10", text: "text-warning" } +}; + +interface AlertBannerProps { + icon: React.ReactNode; + title: string; + description?: string; + children?: React.ReactNode; + className?: string; + variant?: AlertVariant; +} + +export function AlertBanner({ icon, title, description, children, className, variant = "warning" }: AlertBannerProps) { + const { container, text } = variantStyles[variant]; + + return ( +
+
+ {icon} +
+

{title}

+ {description &&

{description}

} +
+
+ {children} +
+ ); +} diff --git a/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx b/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx new file mode 100644 index 000000000..9b43d8150 --- /dev/null +++ b/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx @@ -0,0 +1,94 @@ +import { useCallback } from "react"; +import { useAlfredpayKycActor, useAlfredpayKycSelector } from "../../contexts/rampState"; +import { CustomerDefinitionScreen } from "./CustomerDefinitionScreen"; +import { DoneScreen } from "./DoneScreen"; +import { FailureKycScreen } from "./FailureKycScreen"; +import { FailureScreen } from "./FailureScreen"; +import { FillingScreen } from "./FillingScreen"; +import { LinkReadyScreen } from "./LinkReadyScreen"; +import { LoadingScreen } from "./LoadingScreen"; +import { OpeningLinkScreen } from "./OpeningLinkScreen"; +import { PollingScreen } from "./PollingScreen"; + +export const AlfredpayKycFlow = () => { + const actor = useAlfredpayKycActor(); + const state = useAlfredpayKycSelector(); + + const confirmSuccess = useCallback(() => actor?.send({ type: "CONFIRM_SUCCESS" }), [actor]); + const openLink = useCallback(() => actor?.send({ type: "OPEN_LINK" }), [actor]); + const completedFilling = useCallback(() => actor?.send({ type: "COMPLETED_FILLING" }), [actor]); + const toggleBusiness = useCallback(() => actor?.send({ type: "TOGGLE_BUSINESS" }), [actor]); + const userAccept = useCallback(() => actor?.send({ type: "USER_ACCEPT" }), [actor]); + const userRetry = useCallback(() => actor?.send({ type: "USER_RETRY" }), [actor]); + const userCancel = useCallback(() => actor?.send({ type: "USER_CANCEL" }), [actor]); + const retryProcess = useCallback(() => actor?.send({ type: "RETRY_PROCESS" }), [actor]); + const cancelProcess = useCallback(() => actor?.send({ type: "CANCEL_PROCESS" }), [actor]); + + if (!actor || !state) return null; + + const { stateValue, context } = state; + const kycOrKyb = context.business ? "KYB" : "KYC"; + + if ( + stateValue === "CheckingStatus" || + stateValue === "CreatingCustomer" || + stateValue === "GettingKycLink" || + stateValue === "Retrying" + ) { + return ; + } + + if (stateValue === "PollingStatus") { + return ; + } + + if (stateValue === "LinkReady") { + return ; + } + + if (stateValue === "OpeningLink") { + return ; + } + + if (stateValue === "FillingKyc" || stateValue === "FinishingFilling") { + return ( + + ); + } + + if (stateValue === "VerificationDone") { + return ; + } + + if (stateValue === "Done") { + return ; + } + + if (stateValue === "FailureKyc") { + return ( + + ); + } + + if (stateValue === "Failure") { + return ; + } + + if (stateValue === "CostumerDefinition") { + return ( + + ); + } + + return null; +}; diff --git a/apps/frontend/src/components/Alfredpay/CustomerDefinitionScreen.tsx b/apps/frontend/src/components/Alfredpay/CustomerDefinitionScreen.tsx new file mode 100644 index 000000000..88232499a --- /dev/null +++ b/apps/frontend/src/components/Alfredpay/CustomerDefinitionScreen.tsx @@ -0,0 +1,56 @@ +import { memo } from "react"; +import { Trans, useTranslation } from "react-i18next"; +import businessHandshake from "../../assets/business-handshake.svg"; +import livenessCheck from "../../assets/liveness-check.svg"; +import { MenuButtons } from "../MenuButtons"; +import { StepFooter } from "../StepFooter"; + +interface CustomerDefinitionScreenProps { + kycOrKyb: string; + isBusiness: boolean; + onAccept: () => void; + onToggleBusiness: () => void; +} + +const toggleLinkClass = + "cursor-pointer border-0 bg-transparent p-0 font-[inherit] text-primary underline touch-manipulation [@media(hover:hover)]:hover:text-primary/80"; + +export const CustomerDefinitionScreen = memo( + ({ kycOrKyb, isBusiness, onAccept, onToggleBusiness }: CustomerDefinitionScreenProps) => { + const { t } = useTranslation(); + + return ( +
+ + {isBusiness ? ( + Business Handshake + ) : ( + Liveness Check + )} + +

+ {t("components.alfredpayKycFlow.continueWithPartner", { kycOrKyb })} +

+ +

+ + }} + i18nKey={ + isBusiness ? "components.alfredpayKycFlow.registerAsIndividual" : "components.alfredpayKycFlow.registerAsBusiness" + } + /> +

+ + + + +
+ ); + } +); + +CustomerDefinitionScreen.displayName = "CustomerDefinitionScreen"; diff --git a/apps/frontend/src/components/Alfredpay/DoneScreen.tsx b/apps/frontend/src/components/Alfredpay/DoneScreen.tsx new file mode 100644 index 000000000..510ff7703 --- /dev/null +++ b/apps/frontend/src/components/Alfredpay/DoneScreen.tsx @@ -0,0 +1,32 @@ +import { memo } from "react"; +import { useTranslation } from "react-i18next"; +import documentVerified from "../../assets/document_verified.svg"; +import { MenuButtons } from "../MenuButtons"; +import { StepFooter } from "../StepFooter"; + +interface DoneScreenProps { + kycOrKyb: string; + onContinue?: () => void; +} + +export const DoneScreen = memo(({ kycOrKyb, onContinue }: DoneScreenProps) => { + const { t } = useTranslation(); + + return ( +
+ + Business Handshake +

{t("components.alfredpayKycFlow.completed", { kycOrKyb })}

+

{t("components.alfredpayKycFlow.accountVerified")}

+ {onContinue && ( + + + + )} +
+ ); +}); + +DoneScreen.displayName = "DoneScreen"; diff --git a/apps/frontend/src/components/Alfredpay/FailureKycScreen.tsx b/apps/frontend/src/components/Alfredpay/FailureKycScreen.tsx new file mode 100644 index 000000000..b3757d214 --- /dev/null +++ b/apps/frontend/src/components/Alfredpay/FailureKycScreen.tsx @@ -0,0 +1,30 @@ +import { memo } from "react"; +import { useTranslation } from "react-i18next"; + +interface FailureKycScreenProps { + kycOrKyb: string; + errorMessage: string | undefined; + onRetry: () => void; + onCancel: () => void; +} + +export const FailureKycScreen = memo(({ kycOrKyb, errorMessage, onRetry, onCancel }: FailureKycScreenProps) => { + const { t } = useTranslation(); + + return ( +
+

{t("components.alfredpayKycFlow.failed", { kycOrKyb })}

+

{errorMessage ?? "An unknown error occurred."}

+
+ + +
+
+ ); +}); + +FailureKycScreen.displayName = "FailureKycScreen"; diff --git a/apps/frontend/src/components/Alfredpay/FailureScreen.tsx b/apps/frontend/src/components/Alfredpay/FailureScreen.tsx new file mode 100644 index 000000000..08f89caf3 --- /dev/null +++ b/apps/frontend/src/components/Alfredpay/FailureScreen.tsx @@ -0,0 +1,39 @@ +import { memo } from "react"; +import { useTranslation } from "react-i18next"; +import { StepFooter } from "../StepFooter"; + +interface FailureScreenProps { + errorMessage: string | undefined; + onRetry: () => void; + onCancel: () => void; +} + +export const FailureScreen = memo(({ errorMessage, onRetry, onCancel }: FailureScreenProps) => { + const { t } = useTranslation(); + + return ( +
+ + + +

{errorMessage ?? "An unknown error occurred."}

+ +
+ + +
+
+
+ ); +}); + +FailureScreen.displayName = "FailureScreen"; diff --git a/apps/frontend/src/components/Alfredpay/FillingScreen.tsx b/apps/frontend/src/components/Alfredpay/FillingScreen.tsx new file mode 100644 index 000000000..89d1368c5 --- /dev/null +++ b/apps/frontend/src/components/Alfredpay/FillingScreen.tsx @@ -0,0 +1,44 @@ +import { memo } from "react"; +import { useTranslation } from "react-i18next"; +import documentReady from "../../assets/document_ready.svg"; +import { MenuButtons } from "../MenuButtons"; +import { Spinner } from "../Spinner"; +import { StepFooter } from "../StepFooter"; + +interface FillingScreenProps { + kycOrKyb: string; + isSubmitting: boolean; + onCompletedFilling: () => void; + onOpenLink?: () => void; +} + +export const FillingScreen = memo(({ kycOrKyb, isSubmitting, onCompletedFilling, onOpenLink }: FillingScreenProps) => { + const { t } = useTranslation(); + + return ( +
+ + Business Handshake +

{t("components.alfredpayKycFlow.completeInNewWindow", { kycOrKyb })}

+ {onOpenLink && ( + + )} + + + +
+ ); +}); + +FillingScreen.displayName = "FillingScreen"; diff --git a/apps/frontend/src/components/Alfredpay/LinkReadyScreen.tsx b/apps/frontend/src/components/Alfredpay/LinkReadyScreen.tsx new file mode 100644 index 000000000..3c226053a --- /dev/null +++ b/apps/frontend/src/components/Alfredpay/LinkReadyScreen.tsx @@ -0,0 +1,29 @@ +import { memo } from "react"; +import { useTranslation } from "react-i18next"; +import livenessCheck from "../../assets/liveness-check.svg"; +import { MenuButtons } from "../MenuButtons"; +import { StepFooter } from "../StepFooter"; + +interface LinkReadyScreenProps { + kycOrKyb: string; + onOpenLink: () => void; +} + +export const LinkReadyScreen = memo(({ kycOrKyb, onOpenLink }: LinkReadyScreenProps) => { + const { t } = useTranslation(); + + return ( +
+ + Liveness Check +

{t("components.alfredpayKycFlow.completeProcess", { kycOrKyb })}

+ + + +
+ ); +}); + +LinkReadyScreen.displayName = "LinkReadyScreen"; diff --git a/apps/frontend/src/components/Alfredpay/LoadingScreen.tsx b/apps/frontend/src/components/Alfredpay/LoadingScreen.tsx new file mode 100644 index 000000000..5e5aa48f9 --- /dev/null +++ b/apps/frontend/src/components/Alfredpay/LoadingScreen.tsx @@ -0,0 +1,12 @@ +import { memo } from "react"; +import { Spinner } from "../Spinner"; + +export const LoadingScreen = memo(() => { + return ( +
+ +
+ ); +}); + +LoadingScreen.displayName = "LoadingScreen"; diff --git a/apps/frontend/src/components/Alfredpay/OpeningLinkScreen.tsx b/apps/frontend/src/components/Alfredpay/OpeningLinkScreen.tsx new file mode 100644 index 000000000..407edc5c6 --- /dev/null +++ b/apps/frontend/src/components/Alfredpay/OpeningLinkScreen.tsx @@ -0,0 +1,16 @@ +import { memo } from "react"; +import { useTranslation } from "react-i18next"; +import { Spinner } from "../Spinner"; + +export const OpeningLinkScreen = memo(() => { + const { t } = useTranslation(); + + return ( +
+ +

{t("components.alfredpayKycFlow.openingLink")}

+
+ ); +}); + +OpeningLinkScreen.displayName = "OpeningLinkScreen"; diff --git a/apps/frontend/src/components/Alfredpay/PollingScreen.tsx b/apps/frontend/src/components/Alfredpay/PollingScreen.tsx new file mode 100644 index 000000000..470eb7d18 --- /dev/null +++ b/apps/frontend/src/components/Alfredpay/PollingScreen.tsx @@ -0,0 +1,21 @@ +import { memo } from "react"; +import { useTranslation } from "react-i18next"; +import { Spinner } from "../Spinner"; + +interface PollingScreenProps { + kycOrKyb: string; +} + +export const PollingScreen = memo(({ kycOrKyb }: PollingScreenProps) => { + const { t } = useTranslation(); + + return ( +
+ +

{t("components.alfredpayKycFlow.verifyingStatus", { kycOrKyb })}

+

{t("components.alfredpayKycFlow.verifyingStatusDescription")}

+
+ ); +}); + +PollingScreen.displayName = "PollingScreen"; diff --git a/apps/frontend/src/components/AnimatedRemoveFiatAccountLabel/index.tsx b/apps/frontend/src/components/AnimatedRemoveFiatAccountLabel/index.tsx new file mode 100644 index 000000000..6d0eabda2 --- /dev/null +++ b/apps/frontend/src/components/AnimatedRemoveFiatAccountLabel/index.tsx @@ -0,0 +1,25 @@ +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { durations } from "../../constants/animations"; + +interface AnimatedLabelProps { + children: React.ReactNode; + motionKey: string; +} + +export function AnimatedRemoveFiatAccountLabel({ children, motionKey }: AnimatedLabelProps) { + const shouldReduceMotion = useReducedMotion(); + + return ( + + + {children} + + + ); +} diff --git a/apps/frontend/src/components/Avenia/AveniaField/index.tsx b/apps/frontend/src/components/Avenia/AveniaField/index.tsx index 8b038926f..6a227a90e 100644 --- a/apps/frontend/src/components/Avenia/AveniaField/index.tsx +++ b/apps/frontend/src/components/Avenia/AveniaField/index.tsx @@ -71,12 +71,12 @@ export const AveniaField: FC = ({ id, label, index, validation {label}
- + {id === ExtendedAveniaFieldOptions.BIRTHDATE && ( )}
- {errorMessage && {errorMessage}} + {errorMessage && {errorMessage}} ); }; diff --git a/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerificationStatus.tsx b/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerificationStatus.tsx index eaee8f9ce..b3142c346 100644 --- a/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerificationStatus.tsx +++ b/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerificationStatus.tsx @@ -12,7 +12,6 @@ export const AveniaKYBVerificationStatus: React.FC = () => { if (!aveniaState || !aveniaKycActor) return null; - console.log(aveniaState.context.kycStatus); return ( <>
diff --git a/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyStep.tsx b/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyStep.tsx index 4eaedbb91..a5be09f5f 100644 --- a/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyStep.tsx +++ b/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyStep.tsx @@ -28,7 +28,6 @@ export const AveniaKYBVerifyStep = ({ instructionsKey = "components.aveniaKYB.instructions", cancelButtonKey = "components.aveniaKYB.buttons.cancel" }: AveniaKYBVerifyStepProps) => { - const quote = useQuote(); const { t } = useTranslation(); return ( @@ -36,7 +35,7 @@ export const AveniaKYBVerifyStep = ({
-

{t(titleKey)}

+

{t(titleKey)}

Business Check
- +
); }; diff --git a/apps/frontend/src/components/Avenia/AveniaKYCForm.tsx b/apps/frontend/src/components/Avenia/AveniaKYCForm.tsx index 2e23100c6..4d02236d7 100644 --- a/apps/frontend/src/components/Avenia/AveniaKYCForm.tsx +++ b/apps/frontend/src/components/Avenia/AveniaKYCForm.tsx @@ -2,7 +2,6 @@ import { isValidCnpj } from "@vortexfi/shared"; import { useTranslation } from "react-i18next"; import { useAveniaKycActor, useAveniaKycSelector } from "../../contexts/rampState"; import { useKYCForm } from "../../hooks/brla/useKYCForm"; -import { useQuote } from "../../stores/quote/useQuoteStore"; import { QuoteSummary } from "../QuoteSummary"; import { StepBackButton } from "../StepBackButton"; import { AveniaLivenessStep } from "../widget-steps/AveniaLivenessStep"; @@ -14,7 +13,6 @@ import { VerificationStatus } from "./VerificationStatus"; export const AveniaKYCForm = () => { const aveniaKycActor = useAveniaKycActor(); const aveniaState = useAveniaKycSelector(); - const quote = useQuote(); const { t } = useTranslation(); const { kycForm } = useKYCForm({ cpfApiError: null, initialData: aveniaState?.context.kycFormData }); @@ -154,7 +152,7 @@ export const AveniaKYCForm = () => { {content}
- {quote && } +
); }; diff --git a/apps/frontend/src/components/Avenia/AveniaVerificationForm/index.tsx b/apps/frontend/src/components/Avenia/AveniaVerificationForm/index.tsx index 1df0d9896..8f8929836 100644 --- a/apps/frontend/src/components/Avenia/AveniaVerificationForm/index.tsx +++ b/apps/frontend/src/components/Avenia/AveniaVerificationForm/index.tsx @@ -39,7 +39,7 @@ export const AveniaVerificationForm = ({ form, fields, aveniaKycActor, isCompany transition={{ duration: 0.3 }} >
-

+

{isCompany ? t("components.aveniaKYB.title.default") : t("components.aveniaKYC.title")}

@@ -77,7 +77,7 @@ export const AveniaVerificationForm = ({ form, fields, aveniaKycActor, isCompany
)}
- + )} diff --git a/apps/frontend/src/components/Field/index.tsx b/apps/frontend/src/components/Field/index.tsx index a5c71dda4..fe3031695 100644 --- a/apps/frontend/src/components/Field/index.tsx +++ b/apps/frontend/src/components/Field/index.tsx @@ -11,7 +11,7 @@ export const Field = ({ className, register, error, ...rest }: FieldProps) => ( ( +
{children}
+); diff --git a/apps/frontend/src/components/InputOTP/index.tsx b/apps/frontend/src/components/InputOTP/index.tsx index 2e6da5325..85d0b1085 100644 --- a/apps/frontend/src/components/InputOTP/index.tsx +++ b/apps/frontend/src/components/InputOTP/index.tsx @@ -41,7 +41,7 @@ function InputOTPSlot({ index, className, ...props }: React.ComponentProps<"div" {char} {hasFakeCaret && (
-
+
)}
diff --git a/apps/frontend/src/components/KycLevel2Toggle/index.tsx b/apps/frontend/src/components/KycLevel2Toggle/index.tsx index 098f28617..0d1f0d2e1 100644 --- a/apps/frontend/src/components/KycLevel2Toggle/index.tsx +++ b/apps/frontend/src/components/KycLevel2Toggle/index.tsx @@ -1,5 +1,6 @@ import { AveniaDocumentType } from "@vortexfi/shared"; import { motion, useReducedMotion } from "motion/react"; +import { cn } from "../../helpers/cn"; interface KycLevel2ToggleProps { activeDocType: AveniaDocumentType; @@ -13,24 +14,26 @@ export const KycLevel2Toggle = ({ activeDocType, onToggle }: KycLevel2ToggleProp return (
void; token: ExtendedTokenDefinition; balance?: string; + onFocus?: FocusEventHandler; } function formatBalance(balance: string): string { @@ -25,7 +27,7 @@ function formatBalance(balance: string): string { } } -export const ListItem = memo(function ListItem({ token, isSelected, onSelect, balance }: ListItemProps) { +export const ListItem = memo(function ListItem({ token, isSelected, onSelect, balance, onFocus }: ListItemProps) { const { t } = useTranslation(); const isFiat = isFiatToken(token.type); // Use assetIcon for fiat lookup, with network for on-chain tokens @@ -39,11 +41,16 @@ export const ListItem = memo(function ListItem({ token, isSelected, onSelect, ba return ( +
+ ); +} diff --git a/apps/frontend/src/components/PoweredBy/index.tsx b/apps/frontend/src/components/PoweredBy/index.tsx index bf7b8e870..1ac80fa11 100644 --- a/apps/frontend/src/components/PoweredBy/index.tsx +++ b/apps/frontend/src/components/PoweredBy/index.tsx @@ -23,7 +23,7 @@ const paymentImages = [ const Image = ({ src, alt, comingSoon, additionalClass }: ImageProps) => (
{alt} - {comingSoon &&
Coming soon
} + {comingSoon &&
Coming soon
}
); diff --git a/apps/frontend/src/components/QuoteSummary/index.tsx b/apps/frontend/src/components/QuoteSummary/index.tsx index 46ea5c9fc..9e3b9f46b 100644 --- a/apps/frontend/src/components/QuoteSummary/index.tsx +++ b/apps/frontend/src/components/QuoteSummary/index.tsx @@ -1,9 +1,10 @@ import { QuoteResponse, RampDirection } from "@vortexfi/shared"; -import Big from "big.js"; import { useTranslation } from "react-i18next"; import { cn } from "../../helpers/cn"; +import { parseBig } from "../../helpers/numbers"; import { useTokenIcon } from "../../hooks/useTokenIcon"; import { formatPrice } from "../../sections/individuals/FeeComparison/helpers"; +import { useQuote } from "../../stores/quote/useQuoteStore"; import { CollapsibleCard, CollapsibleDetails, CollapsibleSummary, useCollapsibleCard } from "../CollapsibleCard"; import { CurrencyExchange } from "../CurrencyExchange"; import { ToggleButton } from "../ToggleButton"; @@ -11,7 +12,6 @@ import { TokenIconWithNetwork } from "../TokenIconWithNetwork"; import { TransactionId } from "../TransactionId"; interface QuoteSummaryProps { - quote: QuoteResponse; className?: string; } @@ -83,7 +83,7 @@ const QuoteSummaryDetails = ({ quote }: { quote: QuoteResponse }) => { showNetworkOverlay={!!inputIcon.network} tokenSymbol={quote.inputCurrency} /> - {formatPrice(Big(quote.inputAmount))} {inputCurrencyUpper} + {formatPrice(parseBig(quote.inputAmount))} {inputCurrencyUpper}
@@ -97,7 +97,7 @@ const QuoteSummaryDetails = ({ quote }: { quote: QuoteResponse }) => { showNetworkOverlay={!!outputIcon.network} tokenSymbol={quote.outputCurrency} /> - {APPROX_SIGN} {formatPrice(Big(quote.outputAmount))} {outputCurrencyUpper} + {APPROX_SIGN} {formatPrice(parseBig(quote.outputAmount))} {outputCurrencyUpper}
@@ -107,9 +107,13 @@ const QuoteSummaryDetails = ({ quote }: { quote: QuoteResponse }) => { ); }; -export const QuoteSummary = ({ quote, className }: QuoteSummaryProps) => { +export const QuoteSummary = ({ className }: QuoteSummaryProps) => { + const quote = useQuote(); + + if (!quote) return null; + return ( -
+
diff --git a/apps/frontend/src/components/RampFeeCollapse/index.tsx b/apps/frontend/src/components/RampFeeCollapse/index.tsx index 97052b7e7..e3a9478a4 100644 --- a/apps/frontend/src/components/RampFeeCollapse/index.tsx +++ b/apps/frontend/src/components/RampFeeCollapse/index.tsx @@ -106,7 +106,7 @@ export function RampFeeCollapse() {
-
+
diff --git a/apps/frontend/src/components/RampSubmitButton/RampSubmitButton.tsx b/apps/frontend/src/components/RampSubmitButton/RampSubmitButton.tsx index 52ddecb60..0ea5a9f9c 100644 --- a/apps/frontend/src/components/RampSubmitButton/RampSubmitButton.tsx +++ b/apps/frontend/src/components/RampSubmitButton/RampSubmitButton.tsx @@ -6,16 +6,19 @@ import { getAddressForFormat, getAnyFiatTokenDetails, getOnChainTokenDetailsOrDefault, + isAlfredpayToken, RampDirection, TokenType } from "@vortexfi/shared"; import { useSelector } from "@xstate/react"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; +import { useFiatAccountSelector } from "../../contexts/FiatAccountMachineContext"; import { useNetwork } from "../../contexts/network"; import { useMoneriumKycActor, useRampActor, useStellarKycSelector } from "../../contexts/rampState"; import { trimAddress } from "../../helpers/addressFormatter"; import { cn } from "../../helpers/cn"; +import { useAlfredpayFiatAccounts } from "../../hooks/alfredpay/useFiatAccounts"; import { useRampSubmission } from "../../hooks/ramp/useRampSubmission"; import { useVortexAccount } from "../../hooks/useVortexAccount"; import { navigateToCleanOrigin } from "../../lib/navigation"; @@ -48,7 +51,7 @@ const useButtonContent = ({ toToken, submitButtonDisabled }: UseButtonContentPro return useMemo(() => { const isOnramp = quote?.rampType === RampDirection.BUY; const isOfframp = quote?.rampType === RampDirection.SELL; - const isDepositQrCodeReady = Boolean(rampState?.ramp?.depositQrCode); + const isDepositQrCodeReady = Boolean(rampState?.ramp?.depositQrCode) || Boolean(rampState?.ramp?.achPaymentData); if ( walletLocked && @@ -108,8 +111,6 @@ const useButtonContent = ({ toToken, submitButtonDisabled }: UseButtonContentPro // }; // } - console.log("submitButtonDisabled", submitButtonDisabled); - if (submitButtonDisabled) { return { icon: , @@ -202,6 +203,9 @@ export const RampSubmitButton = ({ className, hasValidationErrors }: { className const isOnramp = quote?.rampType === RampDirection.BUY; const isOfframp = quote?.rampType === RampDirection.SELL; const fiatToken = useFiatToken(); + const selectedFiatAccountId = useFiatAccountSelector(s => s.context.selectedFiatAccountId); + const { data: fiatAccounts = [] } = useAlfredpayFiatAccounts(); + const effectiveSelectedFiatAccountId = selectedFiatAccountId ?? fiatAccounts[0]?.fiatAccountId ?? null; const onChainToken = useOnChainToken(); const { selectedNetwork } = useNetwork(); @@ -220,7 +224,12 @@ export const RampSubmitButton = ({ className, hasValidationErrors }: { className ) { return true; } - if (machineState === "QuoteReady" || machineState === "KycComplete") { + if (machineState === "QuoteReady") { + return false; + } + + if (machineState === "KycComplete") { + if (isAlfredpayToken(fiatToken) && !effectiveSelectedFiatAccountId) return true; return false; } @@ -240,7 +249,8 @@ export const RampSubmitButton = ({ className, hasValidationErrors }: { className } if (machineState === "UpdateRamp") { - const isDepositQrCodeReady = Boolean(isOnramp && rampState?.ramp?.depositQrCode); + const isDepositQrCodeReady = + Boolean(isOnramp && rampState?.ramp?.depositQrCode) || Boolean(rampState?.ramp?.achPaymentData); if (isOnramp && !isDepositQrCodeReady) return true; } @@ -252,8 +262,10 @@ export const RampSubmitButton = ({ className, hasValidationErrors }: { className isOfframp, isOnramp, rampState?.ramp?.depositQrCode, + rampState?.ramp?.achPaymentData, anchorUrl, fiatToken, + effectiveSelectedFiatAccountId, stellarData, machineState, moneriumKycActor, @@ -280,7 +292,7 @@ export const RampSubmitButton = ({ className, hasValidationErrors }: { className } if (machineState === "KycComplete") { - rampActor.send({ type: "PROCEED_TO_REGISTRATION" }); + rampActor.send({ selectedFiatAccountId: effectiveSelectedFiatAccountId ?? undefined, type: "PROCEED_TO_REGISTRATION" }); return; } @@ -310,11 +322,7 @@ export const RampSubmitButton = ({ className, hasValidationErrors }: { className }; return ( - - + + set((e.target as HTMLInputElement).value)} placeholder={placeholder || t("components.searchInput.placeholder")} @@ -32,6 +34,6 @@ export const SearchInput = ({ set, placeholder, className, ...p }: SearchInputPr type="text" {...p} /> - +
); }; diff --git a/apps/frontend/src/components/Spinner/index.tsx b/apps/frontend/src/components/Spinner/index.tsx index 19945f5b9..047d3e5a1 100644 --- a/apps/frontend/src/components/Spinner/index.tsx +++ b/apps/frontend/src/components/Spinner/index.tsx @@ -3,7 +3,15 @@ import { cn } from "../../helpers/cn"; export type SpinnerSize = "sm" | "md" | "lg"; export type SpinnerTheme = "light" | "dark"; -export function Spinner({ size = "sm", theme = "light" }: { size?: SpinnerSize; theme?: SpinnerTheme }) { +export function Spinner({ + size = "sm", + theme = "light", + className +}: { + size?: SpinnerSize; + theme?: SpinnerTheme; + className?: string; +}) { const sizeClasses = { lg: "w-10 h-10", md: "w-8 h-8", @@ -11,13 +19,18 @@ export function Spinner({ size = "sm", theme = "light" }: { size?: SpinnerSize; }; const themeClasses = { - dark: "border-gray-600 ", + dark: "border-primary", light: "border-white" }; return (
); } diff --git a/apps/frontend/src/components/StatusBadge/index.tsx b/apps/frontend/src/components/StatusBadge/index.tsx index 1ca098421..6f4c20fd5 100644 --- a/apps/frontend/src/components/StatusBadge/index.tsx +++ b/apps/frontend/src/components/StatusBadge/index.tsx @@ -16,9 +16,9 @@ export const StatusBadge: FC = ({ status, explorerLink, isHove const normalizedStatus = status.toLowerCase(); const colors = { - complete: "bg-green-100 text-green-800", - failed: "bg-red-100 text-red-800", - pending: "bg-yellow-100 text-yellow-800" + complete: "bg-success/10 text-success", + failed: "bg-error/10 text-error", + pending: "bg-warning/10 text-warning" } as const; const colorClass = colors[normalizedStatus as keyof typeof colors] || colors.pending; diff --git a/apps/frontend/src/components/StepFooter/index.tsx b/apps/frontend/src/components/StepFooter/index.tsx index e5ddd3d4d..79dfc6c14 100644 --- a/apps/frontend/src/components/StepFooter/index.tsx +++ b/apps/frontend/src/components/StepFooter/index.tsx @@ -1,27 +1,28 @@ -import { QuoteResponse } from "@vortexfi/shared"; import { ReactNode } from "react"; import { cn } from "../../helpers/cn"; +import { useQuote } from "../../stores/quote/useQuoteStore"; import { QuoteSummary } from "../QuoteSummary"; interface StepFooterProps { - quote?: QuoteResponse; - aboveQuote?: boolean; children?: ReactNode; className?: string; + hideQuoteSummary?: boolean; } -export function StepFooter({ quote, aboveQuote, children, className }: StepFooterProps) { - const showAboveQuote = !!quote || aboveQuote; +export function StepFooter({ children, className, hideQuoteSummary = false }: StepFooterProps) { + const quote = useQuote(); + const showAboveQuote = hideQuoteSummary ? false : Boolean(quote); + return ( <> {children && (
{children}
)} - {quote && } + {hideQuoteSummary ? <> : } ); } diff --git a/apps/frontend/src/components/Stepper/StepCircle.tsx b/apps/frontend/src/components/Stepper/StepCircle.tsx index 212228569..753040c51 100644 --- a/apps/frontend/src/components/Stepper/StepCircle.tsx +++ b/apps/frontend/src/components/Stepper/StepCircle.tsx @@ -8,8 +8,8 @@ export const getStepCircleStyles = (status: Step["status"], isClickable: boolean const statusStyles = { active: "bg-blue-500 text-white", - complete: "bg-green-500 text-white", - error: "bg-red-100 text-red-800", + complete: "bg-success text-success-content", + error: "bg-error/10 text-error", incomplete: "bg-gray-300 text-gray-600" }; diff --git a/apps/frontend/src/components/Stepper/StepConnector.tsx b/apps/frontend/src/components/Stepper/StepConnector.tsx index d08139efd..2b20c534d 100644 --- a/apps/frontend/src/components/Stepper/StepConnector.tsx +++ b/apps/frontend/src/components/Stepper/StepConnector.tsx @@ -4,10 +4,11 @@ import { durations, easings } from "../../constants/animations"; import { Step, StepConnectorProps } from "./types"; const getConnectorColor = (currentStatus: Step["status"], nextStatus: Step["status"]): string => { - if (currentStatus === "error") return "#f87171"; - if (currentStatus === "complete" && nextStatus === "complete") return "#22c55e"; - if (currentStatus === "complete") return "#3b82f6"; - return "#d1d5db"; + const style = getComputedStyle(document.documentElement); + if (currentStatus === "error") return style.getPropertyValue("--color-error").trim(); + if (currentStatus === "complete" && nextStatus === "complete") return style.getPropertyValue("--color-success").trim(); + if (currentStatus === "complete") return style.getPropertyValue("--color-primary").trim(); + return style.getPropertyValue("--color-base-300").trim(); }; export const StepConnector: React.FC = ({ currentStepStatus, nextStepStatus }) => { diff --git a/apps/frontend/src/components/Stepper/StepTitle.tsx b/apps/frontend/src/components/Stepper/StepTitle.tsx index dd4c475e8..a9db93724 100644 --- a/apps/frontend/src/components/Stepper/StepTitle.tsx +++ b/apps/frontend/src/components/Stepper/StepTitle.tsx @@ -10,9 +10,9 @@ export const getStepTitleStyles = (status: Step["status"]): string => { const baseStyles = "mt-2 text-center text-xs leading-tight break-words"; const statusStyles = { - active: "font-medium text-blue-600", - complete: "font-medium text-green-600", - error: "font-medium text-red-600", + active: "font-medium text-primary", + complete: "font-medium text-success", + error: "font-medium text-error", incomplete: "text-gray-500" }; diff --git a/apps/frontend/src/components/TextArea/index.tsx b/apps/frontend/src/components/TextArea/index.tsx index bc47396b2..d5f0b1bdd 100644 --- a/apps/frontend/src/components/TextArea/index.tsx +++ b/apps/frontend/src/components/TextArea/index.tsx @@ -11,7 +11,7 @@ export const TextArea = ({ className, register, error, ...rest }: TextAreaProps)