diff --git a/apps/api/src/api/controllers/brla.controller.ts b/apps/api/src/api/controllers/brla.controller.ts index 49f35c794..1150a4d82 100644 --- a/apps/api/src/api/controllers/brla.controller.ts +++ b/apps/api/src/api/controllers/brla.controller.ts @@ -249,6 +249,7 @@ export const getAveniaUserRemainingLimit = async ( } const taxIdRecord = await TaxId.findByPk(normalizeTaxId(taxId)); + if (!taxIdRecord) { throw new APIError({ message: "Ramp disabled", diff --git a/apps/api/src/api/controllers/mykobo.controller.ts b/apps/api/src/api/controllers/mykobo.controller.ts new file mode 100644 index 000000000..44b799727 --- /dev/null +++ b/apps/api/src/api/controllers/mykobo.controller.ts @@ -0,0 +1,88 @@ +import { Request, Response } from "express"; +import httpStatus from "http-status"; +import logger from "../../config/logger"; +import { createMykoboProfile, getMykoboProfile, MykoboProfile } from "../services/mykobo"; + +const toFrontendProfile = (p: MykoboProfile) => ({ + bankAccountNumber: p.bank_account_number, + createdAt: p.created_at, + emailAddress: p.email_address, + firstName: p.first_name, + kycStatus: { + receivedAt: p.kyc_status.received_at, + reviewStatus: p.kyc_status.review_status + }, + lastName: p.last_name +}); + +export const getProfileController = async (req: Request, res: Response): Promise => { + const { address, memo } = req.query; + if (!address || typeof address !== "string") { + res.status(httpStatus.BAD_REQUEST).json({ error: "Invalid address parameter" }); + return; + } + + try { + const profile = await getMykoboProfile(address, typeof memo === "string" ? memo : undefined); + if (!profile) { + res.status(httpStatus.NOT_FOUND).json({ error: "Profile not found" }); + return; + } + res.json({ profile: toFrontendProfile(profile) }); + } catch (error) { + logger.error("Error in getProfileController:", error); + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ error: "Internal Server Error" }); + } +}; + +const PROFILE_TEXT_FIELDS = [ + "first_name", + "last_name", + "additional_name", + "email_address", + "mobile_number", + "birth_date", + "birth_country_code", + "address_line_1", + "city", + "id_country_code", + "id_type", + "bank_account_number", + "bank_number", + "wallet_address", + "source_of_funds", + "tax_country", + "tax_id", + "tax_id_name", + "memo" +] as const; + +const PROFILE_FILE_FIELDS = ["front", "back", "face", "utility_bill"] as const; + +export const createProfileController = async (req: Request, res: Response): Promise => { + try { + const formData = new FormData(); + const body = (req.body ?? {}) as Record; + for (const field of PROFILE_TEXT_FIELDS) { + const value = body[field]; + if (typeof value === "string" && value.length > 0) { + formData.append(field, value); + } + } + + const files = (req as Request & { files?: Record }).files; + if (files && typeof files === "object") { + for (const fieldname of PROFILE_FILE_FIELDS) { + const file = files[fieldname]?.[0]; + if (!file) continue; + formData.append(fieldname, new Blob([new Uint8Array(file.buffer)], { type: file.mimetype }), file.originalname); + } + } + + const profile = await createMykoboProfile(formData); + res.status(httpStatus.CREATED).json({ profile: toFrontendProfile(profile) }); + } catch (error) { + logger.error("Error in createProfileController:", error); + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ error: "Internal Server Error" }); + } +}; diff --git a/apps/api/src/api/routes/v1/index.ts b/apps/api/src/api/routes/v1/index.ts index 8774114d1..bdb7a7101 100644 --- a/apps/api/src/api/routes/v1/index.ts +++ b/apps/api/src/api/routes/v1/index.ts @@ -15,6 +15,7 @@ import maintenanceRoutes from "./maintenance.route"; import metricsRoutes from "./metrics.route"; import moneriumRoutes from "./monerium.route"; import moonbeamRoutes from "./moonbeam.route"; +import mykoboRoutes from "./mykobo.route"; import paymentMethodsRoutes from "./payment-methods.route"; import pendulumRoutes from "./pendulum.route"; import priceRoutes from "./price.route"; @@ -175,6 +176,13 @@ router.use("/alfredpay", alfredpayRoutes); */ router.use("/monerium", moneriumRoutes); +/** + * GET v1/mykobo/profiles + * POST v1/mykobo/profiles + * POST v1/mykobo/webhook + */ +router.use("/mykobo", mykoboRoutes); + /** * POST v1/webhook * DELETE v1/webhook diff --git a/apps/api/src/api/routes/v1/mykobo.route.ts b/apps/api/src/api/routes/v1/mykobo.route.ts new file mode 100644 index 000000000..bf453fd9e --- /dev/null +++ b/apps/api/src/api/routes/v1/mykobo.route.ts @@ -0,0 +1,17 @@ +import { Router } from "express"; +import multer from "multer"; +import * as mykoboController from "../../controllers/mykobo.controller"; + +const router: Router = Router({ mergeParams: true }); +const upload = multer({ limits: { fileSize: 10 * 1024 * 1024 }, storage: multer.memoryStorage() }); +const profileUpload = upload.fields([ + { maxCount: 1, name: "front" }, + { maxCount: 1, name: "back" }, + { maxCount: 1, name: "face" }, + { maxCount: 1, name: "utility_bill" } +]); + +router.route("/profiles").get(mykoboController.getProfileController); +router.route("/profiles").post(profileUpload, mykoboController.createProfileController); + +export default router; diff --git a/apps/api/src/api/services/mykobo/index.ts b/apps/api/src/api/services/mykobo/index.ts new file mode 100644 index 000000000..0cbdba160 --- /dev/null +++ b/apps/api/src/api/services/mykobo/index.ts @@ -0,0 +1,289 @@ +import { EvmNetworks, Networks } from "@vortexfi/shared"; +import { MYKOBO_ACCESS_KEY, MYKOBO_SECRET_KEY, SANDBOX_ENABLED } from "../../../constants/constants"; + +const MYKOBO_API_URL = SANDBOX_ENABLED ? "https://api-dev.mykobo.app/v1" : "https://api.mykobo.app/v1"; + +export const MYKOBO_BASE_NETWORK: EvmNetworks = (SANDBOX_ENABLED ? Networks.BaseSepolia : Networks.Base) as EvmNetworks; + +export const isBaseEvmNetwork = (network: string | undefined): boolean => + network === Networks.Base || network === Networks.BaseSepolia; + +export const MYKOBO_CURRENCY = "EURC" as const; + +export type MykoboTransactionType = "DEPOSIT" | "WITHDRAW"; +export type MykoboFeeKind = "deposit" | "withdraw"; + +export type MykoboKycReviewStatus = "pending" | "approved" | "rejected"; + +export interface MykoboKycStatus { + received_at: string | null; + review_status: MykoboKycReviewStatus; +} + +export interface MykoboProfile { + first_name: string; + last_name: string; + email_address: string; + bank_account_number: string; + kyc_status: MykoboKycStatus; + created_at: string; +} + +export interface MykoboDepositInstructions { + bank_account_name: string; + iban: string; + bic?: string; +} + +export interface MykoboWithdrawInstructions { + address: string; +} + +export interface MykoboTransaction { + id: string; + reference: string; + transaction_type: MykoboTransactionType; + status: string; + incoming_currency?: string; + outgoing_currency?: string; + value: string; + fee?: string; + wallet_address: string; + network?: string; + tx_hash?: string; + created_at: string; + updated_at: string; +} + +export type MykoboIntent = MykoboTransaction & { + instructions?: MykoboDepositInstructions | MykoboWithdrawInstructions; +}; + +interface MykoboIntentResponse { + transaction: MykoboTransaction; + instructions?: MykoboDepositInstructions | MykoboWithdrawInstructions; +} + +export interface MykoboFees { + total: string; + percentage: string; + asset?: string; +} + +interface CreateIntentParams { + walletAddress: string; + emailAddress: string; + value: string; + ipAddress: string; + memo?: string; + clientDomain?: string; +} + +interface TokenResponse { + subject_id: string; + token: string; + refresh_token: string; +} + +interface CachedToken { + token: string; + refreshToken: string; + expiresAt: number; +} + +// JWT exp is in seconds; we refresh proactively 60 s before the documented expiry. +const TOKEN_REFRESH_LEEWAY_MS = 60_000; +let cachedToken: CachedToken | null = null; +let inflightToken: Promise | null = null; + +const parseJwtExpiryMs = (jwt: string): number => { + try { + const [, payload] = jwt.split("."); + if (!payload) return Date.now() + 5 * 60_000; + const decoded = JSON.parse(Buffer.from(payload, "base64url").toString()) as { exp?: number }; + if (typeof decoded.exp !== "number") return Date.now() + 5 * 60_000; + return decoded.exp * 1000; + } catch { + return Date.now() + 5 * 60_000; + } +}; + +const acquireToken = async (): Promise => { + if (!MYKOBO_ACCESS_KEY || !MYKOBO_SECRET_KEY) { + throw new Error("MYKOBO_ACCESS_KEY and MYKOBO_SECRET_KEY must be configured"); + } + const response = await fetch(`${MYKOBO_API_URL}/auth/token`, { + body: JSON.stringify({ access_key: MYKOBO_ACCESS_KEY, secret_key: MYKOBO_SECRET_KEY }), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`Mykobo token acquisition failed: ${response.status} ${text}`); + } + const data = (await response.json()) as TokenResponse; + return { + expiresAt: parseJwtExpiryMs(data.token), + refreshToken: data.refresh_token, + token: data.token + }; +}; + +const refreshToken = async (current: CachedToken): Promise => { + const response = await fetch(`${MYKOBO_API_URL}/auth/refresh`, { + body: JSON.stringify({ refresh_token: current.refreshToken }), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + if (!response.ok) { + return acquireToken(); + } + const data = (await response.json()) as TokenResponse; + return { + expiresAt: parseJwtExpiryMs(data.token), + refreshToken: data.refresh_token, + token: data.token + }; +}; + +const getBearerToken = async (): Promise => { + if (cachedToken && cachedToken.expiresAt - TOKEN_REFRESH_LEEWAY_MS > Date.now()) { + return cachedToken.token; + } + if (inflightToken) { + const t = await inflightToken; + return t.token; + } + inflightToken = (async () => { + const next = cachedToken ? await refreshToken(cachedToken) : await acquireToken(); + cachedToken = next; + return next; + })(); + try { + const t = await inflightToken; + return t.token; + } finally { + inflightToken = null; + } +}; + +const invalidateToken = () => { + cachedToken = null; +}; + +interface FetchOptions { + method?: "GET" | "POST"; + body?: BodyInit | null; + headers?: Record; +} + +const mykoboFetch = async (path: string, options: FetchOptions = {}): Promise => { + const url = `${MYKOBO_API_URL}${path}`; + const doFetch = async (token: string) => + fetch(url, { + body: options.body ?? null, + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + ...options.headers + }, + method: options.method ?? "GET" + }); + + let response = await doFetch(await getBearerToken()); + if (response.status === 401) { + invalidateToken(); + response = await doFetch(await getBearerToken()); + } + return response; +}; + +interface MykoboProfileResponse { + profile: MykoboProfile; + verification?: unknown; +} + +export const getMykoboProfile = async (walletAddress: string, memo?: string): Promise => { + const query = new URLSearchParams({ address: walletAddress }); + if (memo) query.append("memo", memo); + const response = await mykoboFetch(`/profiles?${query.toString()}`); + if (response.status === 404) return null; + if (!response.ok) { + throw new Error(`Mykobo profile lookup failed: ${response.status} ${response.statusText}`); + } + const wrapper = (await response.json()) as MykoboProfileResponse; + return wrapper.profile; +}; + +export const createMykoboProfile = async (formData: FormData): Promise => { + const response = await mykoboFetch("/profiles", { body: formData, method: "POST" }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`Mykobo profile creation failed: ${response.status} ${text}`); + } + const wrapper = (await response.json()) as MykoboProfileResponse; + return wrapper.profile; +}; + +const createTransactionIntent = async ( + transactionType: MykoboTransactionType, + params: CreateIntentParams +): Promise => { + const body = { + client_domain: params.clientDomain, + currency: MYKOBO_CURRENCY, + email_address: params.emailAddress, + ip_address: params.ipAddress, + memo: params.memo, + transaction_type: transactionType, + value: params.value, + wallet_address: params.walletAddress + }; + const response = await mykoboFetch("/transactions/intent", { + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`Mykobo ${transactionType} intent failed: ${response.status} ${text}`); + } + const wrapper = (await response.json()) as MykoboIntentResponse; + return { ...wrapper.transaction, instructions: wrapper.instructions }; +}; + +export const createMykoboDepositIntent = (params: CreateIntentParams): Promise => + createTransactionIntent("DEPOSIT", params); + +export const createMykoboWithdrawIntent = (params: CreateIntentParams): Promise => + createTransactionIntent("WITHDRAW", params); + +export const getMykoboTransaction = async (transactionId: string): Promise => { + const response = await mykoboFetch(`/transactions/${encodeURIComponent(transactionId)}`); + if (!response.ok) { + throw new Error(`Mykobo transaction lookup failed: ${response.status} ${response.statusText}`); + } + const wrapper = (await response.json()) as MykoboIntentResponse; + return { ...wrapper.transaction, instructions: wrapper.instructions }; +}; + +export const getMykoboFees = async (value: string, kind: MykoboFeeKind, clientDomain?: string): Promise => { + const query = new URLSearchParams({ kind, value }); + if (clientDomain) query.append("client_domain", clientDomain); + const response = await mykoboFetch(`/fees?${query.toString()}`); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`Mykobo fees lookup failed: ${response.status} ${text}`); + } + return (await response.json()) as MykoboFees; +}; + +export const isMykoboDepositInstructions = ( + instructions: MykoboDepositInstructions | MykoboWithdrawInstructions | undefined +): instructions is MykoboDepositInstructions => + !!instructions && "iban" in instructions && typeof (instructions as MykoboDepositInstructions).iban === "string"; + +export const isMykoboWithdrawInstructions = ( + instructions: MykoboDepositInstructions | MykoboWithdrawInstructions | undefined +): instructions is MykoboWithdrawInstructions => + !!instructions && "address" in instructions && typeof (instructions as MykoboWithdrawInstructions).address === "string"; diff --git a/apps/api/src/constants/constants.ts b/apps/api/src/constants/constants.ts index a7fa52aef..9d745da8c 100644 --- a/apps/api/src/constants/constants.ts +++ b/apps/api/src/constants/constants.ts @@ -47,6 +47,7 @@ const { CLIENT_DOMAIN_SECRET } = process.env; const MOONBEAM_FUNDING_PRIVATE_KEY = MOONBEAM_EXECUTOR_PRIVATE_KEY; const { BACKEND_TEST_STARTER_ACCOUNT } = process.env; const { MONERIUM_CLIENT_ID_APP, MONERIUM_CLIENT_SECRET } = process.env; +const { MYKOBO_ACCESS_KEY, MYKOBO_SECRET_KEY } = process.env; const { ALCHEMY_API_KEY } = process.env; const { SUBSCAN_API_KEY } = process.env; const SANDBOX_ENABLED = process.env.SANDBOX_ENABLED === "true"; @@ -55,6 +56,8 @@ export { ALCHEMY_API_KEY, MONERIUM_CLIENT_ID_APP, MONERIUM_CLIENT_SECRET, + MYKOBO_ACCESS_KEY, + MYKOBO_SECRET_KEY, SUBSCAN_API_KEY, POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS, ASSETHUB_XCM_FEE_USDC_UNITS, diff --git a/apps/frontend/src/components/Footer/index.tsx b/apps/frontend/src/components/Footer/index.tsx index d4826de8e..920fe204e 100644 --- a/apps/frontend/src/components/Footer/index.tsx +++ b/apps/frontend/src/components/Footer/index.tsx @@ -67,12 +67,14 @@ const FooterLink = ({ href, children, external = false, - className = "" + className = "", + preload }: { href: string; children: ReactNode; external?: boolean; className?: string; + preload?: "intent" | "render" | "viewport" | false; }) => { // Use Link for internal navigation, for external if (external || href.startsWith("mailto:") || href === "#") { @@ -88,7 +90,7 @@ const FooterLink = ({ } return ( - + {children} ); @@ -183,16 +185,19 @@ export function Footer() { {t("components.footer.buyCrypto.buyUsdt")} {t("components.footer.buyCrypto.buyUsdc")} {t("components.footer.buyCrypto.buyEth")} @@ -201,16 +206,19 @@ export function Footer() { {t("components.footer.sellCrypto.sellUsdt")} {t("components.footer.sellCrypto.sellUsdc")} {t("components.footer.sellCrypto.sellEth")} diff --git a/apps/frontend/src/components/Mykobo/MykoboKycFlow.tsx b/apps/frontend/src/components/Mykobo/MykoboKycFlow.tsx new file mode 100644 index 000000000..9bcf5f92b --- /dev/null +++ b/apps/frontend/src/components/Mykobo/MykoboKycFlow.tsx @@ -0,0 +1,52 @@ +import { type ReactNode, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { useMykoboKycActor, useMykoboKycSelector } from "../../contexts/rampState"; +import type { MykoboKycFiles, MykoboKycFormData } from "../../machines/mykoboKyc.machine"; +import type { MykoboKycSnapshot } from "../../machines/types"; +import { Spinner } from "../Spinner"; +import { MykoboKycForm } from "./MykoboKycForm"; + +const LoadingPanel = ({ message }: { message: string }) => ( +
+

{message}

+ +
+); + +const ErrorPanel = ({ message, detail }: { message: string; detail?: string }) => ( +
+

{message}

+ {detail &&

{detail}

} +
+); + +export const MykoboKycFlow = () => { + const { t } = useTranslation(); + const actor = useMykoboKycActor(); + const state = useMykoboKycSelector(); + + const submitForm = useCallback( + (formData: MykoboKycFormData, files: MykoboKycFiles) => actor?.send({ files, formData, type: "SubmitKycForm" }), + [actor] + ); + + if (!actor || !state) return null; + + const { stateValue, context } = state; + + const panels: Record ReactNode> = { + CheckingProfile: () => , + Done: () => ( +
+

{t("components.mykoboKycFlow.done")}

+
+ ), + Failure: () => , + FormFilling: () => , + Rejected: () => , + Submitting: () => , + Verifying: () => + }; + + return panels[stateValue](); +}; diff --git a/apps/frontend/src/components/Mykobo/MykoboKycForm.tsx b/apps/frontend/src/components/Mykobo/MykoboKycForm.tsx new file mode 100644 index 000000000..8707d0dac --- /dev/null +++ b/apps/frontend/src/components/Mykobo/MykoboKycForm.tsx @@ -0,0 +1,255 @@ +import { DocumentTextIcon } from "@heroicons/react/24/outline"; +import { CheckCircleIcon } from "@heroicons/react/24/solid"; +import { ChangeEvent, ReactNode, useRef, useState } from "react"; +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { cn } from "../../helpers/cn"; +import type { MykoboKycFiles, MykoboKycFormData } from "../../machines/mykoboKyc.machine"; +import { Field } from "../Field"; +import { MenuButtons } from "../MenuButtons"; +import { StepFooter } from "../StepFooter"; + +interface MykoboKycFormProps { + onSubmit: (formData: MykoboKycFormData, files: MykoboKycFiles) => void; +} + +type SourceOfFunds = MykoboKycFormData["sourceOfFunds"]; +type IdType = MykoboKycFormData["idType"]; +type FileKey = "front" | "back" | "face" | "utilityBill"; + +const SOURCE_OF_FUNDS_OPTIONS: SourceOfFunds[] = ["EMPLOYMENT", "SAVINGS", "LOANS", "INVESTMENT", "INHERITANCE"]; +const ID_TYPE_OPTIONS: IdType[] = ["PASSPORT", "ID_CARD", "DRIVERS_LICENSE"]; + +const SOURCE_OF_FUNDS_LABEL_KEY: Record = { + EMPLOYMENT: "components.mykoboKycForm.sourceOfFundsEmployment", + INHERITANCE: "components.mykoboKycForm.sourceOfFundsInheritance", + INVESTMENT: "components.mykoboKycForm.sourceOfFundsInvestment", + LOANS: "components.mykoboKycForm.sourceOfFundsLoans", + SAVINGS: "components.mykoboKycForm.sourceOfFundsSavings" +}; + +const ID_TYPE_LABEL_KEY: Record = { + DRIVERS_LICENSE: "components.mykoboKycForm.idTypeDriversLicense", + ID_CARD: "components.mykoboKycForm.idTypeIdCard", + PASSPORT: "components.mykoboKycForm.idTypePassport" +}; + +const ISO_ALPHA_2_PATTERN = /^[A-Za-z]{2}$/; + +const FieldLabel = ({ children, htmlFor, className }: { children: ReactNode; htmlFor?: string; className?: string }) => ( + +); + +const selectClass = "input-vortex-primary input-ghost w-full rounded-lg border border-neutral-300 p-2"; + +interface FileFieldProps { + label: string; + inputRef: React.RefObject; + accept: string; + onChange?: (event: ChangeEvent) => void; + fileName?: string; + placeholder: string; +} + +const FileField = ({ label, inputRef, accept, onChange, fileName, placeholder }: FileFieldProps) => { + const hasFile = Boolean(fileName); + + return ( + + ); +}; + +export const MykoboKycForm = ({ onSubmit }: MykoboKycFormProps) => { + const { t } = useTranslation(); + const { + register, + handleSubmit, + watch, + formState: { errors, isSubmitting } + } = useForm({ + defaultValues: { + idType: "PASSPORT", + sourceOfFunds: "EMPLOYMENT" + } + }); + + const frontRef = useRef(null); + const backRef = useRef(null); + const faceRef = useRef(null); + const utilityRef = useRef(null); + const [fileError, setFileError] = useState(null); + const [fileNames, setFileNames] = useState>>({}); + + const setFileName = (key: FileKey) => (event: ChangeEvent) => + setFileNames(prev => ({ ...prev, [key]: event.target.files?.[0]?.name })); + + const idType = watch("idType"); + const backRequired = idType === "ID_CARD" || idType === "DRIVERS_LICENSE"; + + const submit = handleSubmit(values => { + const front = frontRef.current?.files?.[0]; + const face = faceRef.current?.files?.[0]; + const utilityBill = utilityRef.current?.files?.[0]; + const back = backRef.current?.files?.[0]; + + if (!front || !face || !utilityBill) { + setFileError(t("components.mykoboKycForm.filesRequired")); + return; + } + if (backRequired && !back) { + setFileError(t("components.mykoboKycForm.fileBackRequired")); + return; + } + setFileError(null); + onSubmit(values, { back: backRequired ? back : undefined, face, front, utilityBill }); + }); + + const toUpperCase = (value: unknown): string => (typeof value === "string" ? value.toUpperCase() : ""); + + return ( +
+ +
+

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

+ +
+
+ {t("components.mykoboKycForm.firstName")} + +
+
+ {t("components.mykoboKycForm.lastName")} + +
+
+ {t("components.mykoboKycForm.emailAddress")} + +
+
+ {t("components.mykoboKycForm.addressLine1")} + +
+
+ {t("components.mykoboKycForm.city")} + +
+
+ {t("components.mykoboKycForm.country")} + +
+
+ {t("components.mykoboKycForm.bankAccountNumber")} + +
+
+ {t("components.mykoboKycForm.taxCountry")} + +
+
+ {t("components.mykoboKycForm.sourceOfFundsLabel")} + +
+
+ {t("components.mykoboKycForm.idTypeLabel")} + +
+
+ +

{t("components.mykoboKycForm.documents")}

+
+ + {backRequired && ( + + )} + + +
+ + {fileError &&

{fileError}

} + {Object.keys(errors).length > 0 &&

{t("components.mykoboKycForm.fixErrors")}

} +
+ + + + + + ); +}; diff --git a/apps/frontend/src/components/Navbar/DesktopNavbar.tsx b/apps/frontend/src/components/Navbar/DesktopNavbar.tsx index f9c001ec2..dfd92a7b4 100644 --- a/apps/frontend/src/components/Navbar/DesktopNavbar.tsx +++ b/apps/frontend/src/components/Navbar/DesktopNavbar.tsx @@ -59,7 +59,7 @@ export const DesktopNavbar = () => {
- + Open App
diff --git a/apps/frontend/src/components/Navbar/MobileMenu.tsx b/apps/frontend/src/components/Navbar/MobileMenu.tsx index 09ae7618f..417de237c 100644 --- a/apps/frontend/src/components/Navbar/MobileMenu.tsx +++ b/apps/frontend/src/components/Navbar/MobileMenu.tsx @@ -105,7 +105,12 @@ export const MobileMenu = ({ onMenuItemClick }: MobileMenuProps) => { - + Buy & Sell diff --git a/apps/frontend/src/components/RampSubmitButton/RampSubmitButton.tsx b/apps/frontend/src/components/RampSubmitButton/RampSubmitButton.tsx index f5431dc5a..094e4fca3 100644 --- a/apps/frontend/src/components/RampSubmitButton/RampSubmitButton.tsx +++ b/apps/frontend/src/components/RampSubmitButton/RampSubmitButton.tsx @@ -1,21 +1,24 @@ import { ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid"; import { useParams, useRouter } from "@tanstack/react-router"; import { + EPaymentMethod, FiatToken, FiatTokenDetails, getAddressForFormat, getAnyFiatTokenDetails, getOnChainTokenDetailsOrDefault, isAlfredpayToken, - RampDirection, - TokenType + isFiatToken, + Networks, + OnChainTokenDetails, + RampDirection } 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 { useMoneriumKycActor, useMykoboKycActor, useRampActor } from "../../contexts/rampState"; import { trimAddress } from "../../helpers/addressFormatter"; import { cn } from "../../helpers/cn"; import { useAlfredpayFiatAccounts } from "../../hooks/alfredpay/useFiatAccounts"; @@ -26,14 +29,63 @@ import { useFiatToken, useOnChainToken } from "../../stores/quote/useQuoteFormSt import { Spinner } from "../Spinner"; interface UseButtonContentProps { - toToken: FiatTokenDetails; + toToken: FiatTokenDetails | OnChainTokenDetails; submitButtonDisabled: boolean; } -const useButtonContent = ({ toToken, submitButtonDisabled }: UseButtonContentProps) => { +type ButtonContent = { icon: React.ReactNode; text: string }; +type TFn = (key: string, opts?: Record) => string; + +const quoteReadyLabel = ( + t: TFn, + { + isOnramp, + isAnchorWithoutRedirect, + inputCurrency + }: { isOnramp: boolean; isAnchorWithoutRedirect: boolean; inputCurrency?: FiatToken } +): ButtonContent => { + if (isOnramp && isAnchorWithoutRedirect) return { icon: null, text: t("components.SummaryPage.confirm") }; + if (isOnramp && inputCurrency === FiatToken.BRL) return { icon: null, text: t("components.SummaryPage.continue") }; + return { icon: null, text: t("components.SummaryPage.verifyWallet") }; +}; + +interface ActiveRampInputs { + isOnramp: boolean; + isOfframp: boolean; + isAnchorWithoutRedirect: boolean; + isAnchorWithRedirect: boolean; + hasPaymentInstructions: boolean; + rampPaymentConfirmed: boolean; + rampStateDefined: boolean; +} + +const activeRampLabel = (t: TFn, p: ActiveRampInputs): ButtonContent => { + if (p.isOfframp && p.isAnchorWithoutRedirect) return { icon: null, text: t("components.SummaryPage.confirm") }; + if (p.isOfframp && p.rampStateDefined) return { icon: , text: t("components.SummaryPage.processing") }; + if (p.isOnramp && p.hasPaymentInstructions && !p.rampPaymentConfirmed) { + return { icon: null, text: t("components.swapSubmitButton.confirmPayment") }; + } + if (p.isOnramp && !p.hasPaymentInstructions) return { icon: null, text: t("components.SummaryPage.confirm") }; + if (p.isOfframp && p.isAnchorWithRedirect) { + return { icon: , text: t("components.SummaryPage.continueWithPartner") }; + } + return { icon: , text: t("components.swapSubmitButton.processing") }; +}; + +const isLockedToDesignatedWallet = ( + walletLocked: string | undefined, + accountAddress: string | undefined, + isOfframp: boolean, + quoteFrom: string | undefined +): boolean => { + if (!walletLocked || !accountAddress) return false; + if (!isOfframp && quoteFrom !== EPaymentMethod.SEPA) return false; + return getAddressForFormat(accountAddress, 0) !== getAddressForFormat(walletLocked, 0); +}; + +const useButtonContent = ({ toToken, submitButtonDisabled }: UseButtonContentProps): ButtonContent => { const { t } = useTranslation(); const rampActor = useRampActor(); - const stellarData = useStellarKycSelector(); const { address: accountAddress } = useVortexAccount(); const { isQuoteExpired, rampState, rampPaymentConfirmed, machineState, walletLocked, quote } = useSelector( @@ -51,119 +103,51 @@ 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) || Boolean(rampState?.ramp?.achPaymentData); + const hasPaymentInstructions = + Boolean(rampState?.ramp?.depositQrCode) || + Boolean(rampState?.ramp?.achPaymentData) || + Boolean(rampState?.ramp?.ibanPaymentData); const hasAchPaymentData = Boolean(rampState?.ramp?.achPaymentData); - if ( - walletLocked && - (isOfframp || quote?.from === "sepa") && - accountAddress && - getAddressForFormat(accountAddress, 0) !== getAddressForFormat(walletLocked, 0) - ) { - return { - icon: null, - text: t("components.RampSubmitButton.connectDesignatedWallet", { address: trimAddress(walletLocked) }) - }; - } - - // BRL offramp has no redirect, it is the only with type moonbeam - const isAnchorWithoutRedirect = toToken.type === "moonbeam"; + // BRL (Avenia/moonbeam) and Mykobo EURC (Base) offramps complete inline. Monerium EURC (other chains) + // still uses the redirect/auth flow. EURC onramp (BUY) uses `quote.from === EPaymentMethod.SEPA`, + // so gating on `isOfframp` here keeps `isMykoboEurc` false for the onramp branch even if + // `quote.from` strings ever collide with Networks values. Mykobo's inline payment instructions + // are surfaced separately via EUROnrampDetails. + const isMykoboEurc = + isOfframp && + quote?.outputCurrency === FiatToken.EURC && + (quote?.from === Networks.Base || quote?.from === Networks.BaseSepolia); + const isAnchorWithoutRedirect = toToken.type === "moonbeam" || isMykoboEurc; const isAnchorWithRedirect = !isAnchorWithoutRedirect; - if (machineState === "QuoteReady") { - if (isOnramp && isAnchorWithoutRedirect) { - return { - icon: null, - text: t("components.SummaryPage.confirm") - }; - } else if (isOnramp && quote?.inputCurrency === FiatToken.BRL) { - return { - icon: null, - text: t("components.SummaryPage.continue") - }; - } else { - return { - icon: null, - text: t("components.SummaryPage.verifyWallet") - }; - } - } - - if (isQuoteExpired && !hasAchPaymentData) { + if (walletLocked && isLockedToDesignatedWallet(walletLocked, accountAddress, isOfframp, quote?.from)) { return { icon: null, - text: t("components.SummaryPage.quoteExpired") - }; - } - - if (machineState === "KycComplete") { - return { - icon: null, - text: t("components.SummaryPage.confirm") - }; - } - - // XSTATE migrate: we can display this on failure, generic failure. - // Add check for signing rejection - // if (signingRejected) { - // return { - // icon: null, - // text: t("components.SummaryPage.tryAgain") - // }; - // } - - if (submitButtonDisabled) { - return { - icon: , - text: t("components.swapSubmitButton.processing") - }; - } - - if (isOfframp && isAnchorWithoutRedirect) { - return { - icon: null, - text: t("components.SummaryPage.confirm") - }; - } - - if (isOfframp && rampState !== undefined) { - return { - icon: , - text: t("components.SummaryPage.processing") - }; - } - - if (isOnramp && isDepositQrCodeReady && !rampPaymentConfirmed) { - return { - icon: null, - text: t("components.swapSubmitButton.confirmPayment") - }; - } - - if (isOnramp && !isDepositQrCodeReady) { - return { - icon: null, - text: t("components.SummaryPage.confirm") + text: t("components.RampSubmitButton.connectDesignatedWallet", { address: trimAddress(walletLocked) }) }; } - - if (isOfframp && isAnchorWithRedirect) { - if (stellarData?.stateValue === "Sep24Second") { - return { - icon: , - text: t("components.SummaryPage.continueOnPartnersPage") - }; - } else { - return { - icon: , - text: t("components.SummaryPage.continueWithPartner") - }; - } + if (machineState === "QuoteReady") { + // On onramp `quote.inputCurrency` is the fiat being paid in; on offramp it's an on-chain token. + // `quoteReadyLabel` only reads `inputCurrency` when `isOnramp`, so narrow through `isFiatToken` + // for that branch instead of casting `RampCurrency` to `FiatToken`. + const inputCurrency = + isOnramp && quote?.inputCurrency && isFiatToken(quote.inputCurrency) ? quote.inputCurrency : undefined; + return quoteReadyLabel(t, { inputCurrency, isAnchorWithoutRedirect, isOnramp }); } - return { - icon: , - text: t("components.swapSubmitButton.processing") - }; + if (isQuoteExpired && !hasAchPaymentData) return { icon: null, text: t("components.SummaryPage.quoteExpired") }; + if (machineState === "KycComplete") return { icon: null, text: t("components.SummaryPage.confirm") }; + if (submitButtonDisabled) return { icon: , text: t("components.swapSubmitButton.processing") }; + + return activeRampLabel(t, { + hasPaymentInstructions, + isAnchorWithoutRedirect, + isAnchorWithRedirect, + isOfframp, + isOnramp, + rampPaymentConfirmed, + rampStateDefined: rampState !== undefined + }); }, [ submitButtonDisabled, isQuoteExpired, @@ -171,7 +155,6 @@ const useButtonContent = ({ toToken, submitButtonDisabled }: UseButtonContentPro machineState, t, toToken, - stellarData, rampPaymentConfirmed, quote, accountAddress, @@ -182,11 +165,11 @@ const useButtonContent = ({ toToken, submitButtonDisabled }: UseButtonContentPro export const RampSubmitButton = ({ className, hasValidationErrors }: { className?: string; hasValidationErrors?: boolean }) => { const rampActor = useRampActor(); const { onRampConfirm } = useRampSubmission(); - const stellarData = useStellarKycSelector(); const router = useRouter(); const params = useParams({ strict: false }); const moneriumKycActor = useMoneriumKycActor(); + const mykoboKycActor = useMykoboKycActor(); const { address: accountAddress } = useVortexAccount(); const { rampState, quote, executionInput, isQuoteExpired, machineState, walletLocked } = useSelector(rampActor, state => ({ @@ -198,9 +181,6 @@ export const RampSubmitButton = ({ className, hasValidationErrors }: { className walletLocked: state.context.walletLocked })); - const stellarContext = stellarData?.context; - const anchorUrl = stellarContext?.redirectUrl; - const isOnramp = quote?.rampType === RampDirection.BUY; const isOfframp = quote?.rampType === RampDirection.SELL; const fiatToken = useFiatToken(); @@ -219,7 +199,7 @@ export const RampSubmitButton = ({ className, hasValidationErrors }: { className if ( walletLocked && - (isOfframp || quote?.from === "sepa") && + (isOfframp || quote?.from === EPaymentMethod.SEPA) && accountAddress && getAddressForFormat(accountAddress, 0) !== getAddressForFormat(walletLocked, 0) ) { @@ -234,7 +214,7 @@ export const RampSubmitButton = ({ className, hasValidationErrors }: { className return false; } - if (machineState === "RegisterRamp" || moneriumKycActor) { + if (machineState === "RegisterRamp" || moneriumKycActor || mykoboKycActor) { return true; } @@ -244,15 +224,16 @@ export const RampSubmitButton = ({ className, hasValidationErrors }: { className if (!executionInput) return true; if (isOfframp) { - if (!anchorUrl && getAnyFiatTokenDetails(fiatToken).type === TokenType.Stellar) return true; - if (stellarData?.stateValue !== "StartSep24") return true; - if (!executionInput.brlaEvmAddress && getAnyFiatTokenDetails(fiatToken).type === "moonbeam") return true; + // On offramp `toToken` is the fiat details; reuse it instead of re-fetching. + if (!executionInput.brlaEvmAddress && toToken.type === "moonbeam") return true; } if (machineState === "UpdateRamp") { - const isDepositQrCodeReady = - Boolean(isOnramp && rampState?.ramp?.depositQrCode) || Boolean(rampState?.ramp?.achPaymentData); - if (isOnramp && !isDepositQrCodeReady) return true; + const hasPaymentInstructions = + Boolean(isOnramp && rampState?.ramp?.depositQrCode) || + Boolean(rampState?.ramp?.achPaymentData) || + Boolean(isOnramp && rampState?.ramp?.ibanPaymentData); + if (isOnramp && !hasPaymentInstructions) return true; } return false; @@ -264,12 +245,13 @@ export const RampSubmitButton = ({ className, hasValidationErrors }: { className isOnramp, rampState?.ramp?.depositQrCode, rampState?.ramp?.achPaymentData, - anchorUrl, + rampState?.ramp?.ibanPaymentData, fiatToken, + toToken, effectiveSelectedFiatAccountId, - stellarData, machineState, moneriumKycActor, + mykoboKycActor, walletLocked, accountAddress, quote?.from @@ -277,7 +259,7 @@ export const RampSubmitButton = ({ className, hasValidationErrors }: { className const buttonContent = useButtonContent({ submitButtonDisabled, - toToken: toToken as FiatTokenDetails + toToken }); const onSubmit = () => { @@ -301,27 +283,11 @@ export const RampSubmitButton = ({ className, hasValidationErrors }: { className rampActor.send({ type: "SummaryConfirm" }); - // For BRL offramps, set canRegisterRamp to true - if (isOfframp && fiatToken === FiatToken.BRL && executionInput?.quote.rampType === RampDirection.SELL) { - //setCanRegisterRamp(true); - } - if (isOnramp) { if (machineState === "UpdateRamp") { rampActor.send({ type: "PAYMENT_CONFIRMED" }); } } - - if (!isOnramp && (toToken as FiatTokenDetails).type !== "moonbeam" && anchorUrl) { - // If signing was rejected, we do not open the anchor URL again - // if (!signingRejected) { - // window.open(anchorUrl, "_blank"); - // } - } - - // if (signingRejected) { - // setSigningRejected(false); - // } }; return ( diff --git a/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx b/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx index 8d3ec5700..c50ca23d6 100644 --- a/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx +++ b/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx @@ -2,6 +2,7 @@ import { assetHubTokenConfig, doesNetworkSupportRamp, EvmNetworks, + eurcMykoboTokenConfig, FiatToken, FiatTokenDetails, freeTokenConfig, @@ -138,27 +139,41 @@ export function invalidateOnChainTokensCache(): void { cachedEvmConfigRef = null; } -function getFiatTokens(filterEurcOnly = false): ExtendedTokenDefinition[] { +const FIAT_TOKEN_DISPLAY_NETWORK: Record = { + [FiatToken.ARS]: Networks.Stellar, + [FiatToken.BRL]: Networks.Moonbeam, + [FiatToken.COP]: Networks.Stellar, + [FiatToken.EURC]: Networks.Base, + [FiatToken.MXN]: Networks.Stellar, + [FiatToken.USD]: Networks.Stellar +}; + +function getFiatTokens(): ExtendedTokenDefinition[] { + const eurcEntries = Object.entries(eurcMykoboTokenConfig); const moonbeamEntries = Object.entries(moonbeamTokenConfig); const freeFiatCurrencyEntries = Object.entries(freeTokenConfig); - const stellarEntries = filterEurcOnly - ? Object.entries(stellarTokenConfig).filter(([key]) => key === FiatToken.EURC) - : Object.entries(stellarTokenConfig); + // EURC's anchor is Mykobo on Base; restrict the Stellar list to ARS so future stellar additions don't leak through. + const stellarArsEntries = Object.entries(stellarTokenConfig).filter(([key]) => key === FiatToken.ARS); - return [...moonbeamEntries, ...freeFiatCurrencyEntries, ...stellarEntries].map(([key, value]) => ({ + return [...moonbeamEntries, ...freeFiatCurrencyEntries, ...eurcEntries, ...stellarArsEntries].map(([key, value]) => + buildFiatEntry(key, value) + ); +} + +function buildFiatEntry( + key: string, + value: { fiat: { assetIcon: string; symbol: string; name: string } } +): ExtendedTokenDefinition { + const network = FIAT_TOKEN_DISPLAY_NETWORK[key as FiatToken]; + return { assetIcon: value.fiat.assetIcon, assetSymbol: value.fiat.symbol, details: value as FiatTokenDetails, name: value.fiat.name, - network: key === FiatToken.BRL ? Networks.Moonbeam : Networks.Stellar, - networkDisplayName: - key === FiatToken.BRL ? getNetworkDisplayName(Networks.Moonbeam) : getNetworkDisplayName(Networks.Stellar), + network, + networkDisplayName: getNetworkDisplayName(network), type: getEnumKeyByStringValue(FiatToken, key) as FiatToken - })); -} - -function isFilterEurcOnly(type: "from" | "to", direction: RampDirection) { - return direction === RampDirection.BUY && type === "from"; + }; } export function useIsFiatDirection() { @@ -174,7 +189,7 @@ function isFiatDirection(type: "from" | "to", direction: RampDirection) { function getAllSupportedTokenDefinitions(type: "from" | "to", direction: RampDirection): ExtendedTokenDefinition[] { if (isFiatDirection(type, direction)) { - return getFiatTokens(isFilterEurcOnly(type, direction)); + return getFiatTokens(); } else { return getAllOnChainTokens(); } diff --git a/apps/frontend/src/components/widget-steps/SummaryStep/EUROnrampDetails.tsx b/apps/frontend/src/components/widget-steps/SummaryStep/EUROnrampDetails.tsx index f161b375c..8184762c8 100644 --- a/apps/frontend/src/components/widget-steps/SummaryStep/EUROnrampDetails.tsx +++ b/apps/frontend/src/components/widget-steps/SummaryStep/EUROnrampDetails.tsx @@ -18,13 +18,11 @@ export const EUROnrampDetails: FC = () => { signingPhase: state.context.rampSigningPhase })); - if (!rampState?.ramp?.depositQrCode) return null; - if (!rampState?.ramp?.ibanPaymentData) return null; - if (signingPhase !== "finished") return null; // Only show details if the ramp is finished + if (signingPhase !== "finished") return null; if (isQuoteExpired) return null; - const { iban, bic, receiverName } = rampState.ramp.ibanPaymentData; + const { iban, bic, receiverName, reference } = rampState.ramp.ibanPaymentData; const amount = rampState.quote.inputAmount; return ( @@ -54,6 +52,15 @@ export const EUROnrampDetails: FC = () => { + {reference && ( +
+ {t("components.SummaryPage.EUROnrampDetails.reference")} +
+ {reference} + +
+
+ )} {rampState.quote.outputCurrency === EvmToken.ETH && ( { )} -

{t("components.SummaryPage.EUROnrampDetails.hint")}

+

+ {t( + rampState.ramp?.depositQrCode + ? "components.SummaryPage.EUROnrampDetails.hint" + : "components.SummaryPage.EUROnrampDetails.hintNoQr" + )} +

{t("components.SummaryPage.EUROnrampDetails.footer")}

); diff --git a/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx b/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx index ebb9dac52..88db95fb2 100644 --- a/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx +++ b/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx @@ -8,7 +8,6 @@ import { getOnChainTokenDetailsOrDefault, isAlfredpayToken, isMoonbeamTokenDetails, - isStellarOutputTokenDetails, OnChainTokenDetails, RampDirection } from "@vortexfi/shared"; @@ -50,8 +49,7 @@ export const TransactionTokensDisplay: FC = ({ ex connectedWalletAddress: state.context.connectedWalletAddress, isQuoteExpired: state.context.isQuoteExpired, quote: state.context.quote, - quoteLocked: state.context.quoteLocked, - rampState: state.context.rampState + quoteLocked: state.context.quoteLocked })); const targetTimestampMs = quote ? new Date(quote.expiresAt).getTime() : null; @@ -72,15 +70,12 @@ export const TransactionTokensDisplay: FC = ({ ex const getPartnerUrl = (): string => { const fiatToken = (isOnramp ? fromToken : toToken) as FiatTokenDetails; - if (fromToken.assetSymbol === "EURC") { - return "https://monerium.com"; + if (executionInput.fiatToken === FiatToken.EURC) { + return "https://mykobo.co"; } if (isAlfredpayToken(executionInput.fiatToken)) { return "https://alfredpay.io"; } - if (isStellarOutputTokenDetails(fiatToken)) { - return fiatToken.anchorHomepageUrl; - } if (isMoonbeamTokenDetails(fiatToken)) { return fiatToken.partnerUrl; } @@ -131,10 +126,10 @@ export const TransactionTokensDisplay: FC = ({ ex toToken={toToken} /> {rampDirection === RampDirection.BUY && executionInput.fiatToken === FiatToken.BRL && } - {rampDirection === RampDirection.BUY && executionInput.fiatToken === FiatToken.EURC && } {rampDirection === RampDirection.BUY && executionInput.fiatToken === FiatToken.USD && } {rampDirection === RampDirection.BUY && executionInput.fiatToken === FiatToken.MXN && } {rampDirection === RampDirection.BUY && executionInput.fiatToken === FiatToken.COP && } + {rampDirection === RampDirection.BUY && executionInput.fiatToken === FiatToken.EURC && } {quoteLocked && targetTimestampMs !== null && !isQuoteExpired && (
{t("components.SummaryPage.BRLOnrampDetails.timerLabel")} {formattedTime} diff --git a/apps/frontend/src/constants/localStorage.ts b/apps/frontend/src/constants/localStorage.ts index 9cf89a129..9cee37d32 100644 --- a/apps/frontend/src/constants/localStorage.ts +++ b/apps/frontend/src/constants/localStorage.ts @@ -1,6 +1,5 @@ export const storageKeys = { ACCOUNT: "ACCOUNT", - ANCHOR_SESSION_PARAMS: "ANCHOR_SESSION_PARAMS", BRLA_KYC_PIX_KEY: "BRLA_KYC_PIX_KEY", BRLA_KYC_TAX_ID: "BRLA_KYC_TAX_ID", @@ -10,14 +9,10 @@ export const storageKeys = { PENDULUM_SEED: "PENDULUM_SEED", POOL_SETTINGS: "POOL_SETTINGS", RAMP_SETTINGS: "RAMP_SETTINGS", - SEP_RESULT: "SEP24_RESULT", - SIWE_SIGNATURE_KEY_PREFIX: "SIWE_SIGNATURE_", // Internal squidrouter recovery states SQUIDROUTER_RECOVERY_STATE_APPROVAL: "SQUIDROUTER_TRANSACTION_STATE_APPROVAL", SQUIDROUTER_RECOVERY_STATE_SWAP: "SQUIDROUTER_TRANSACTION_STATE_SWAP", - STELLAR_OPERATIONS: "STELLAR_OPERATIONS", - STELLAR_SEED: "STELLAR_SEED", TOKEN_BRIDGED_AMOUNT: "TOKEN_BRIDGED_AMOUNT" }; diff --git a/apps/frontend/src/contexts/events.tsx b/apps/frontend/src/contexts/events.tsx index 58903ee82..7bc89598d 100644 --- a/apps/frontend/src/contexts/events.tsx +++ b/apps/frontend/src/contexts/events.tsx @@ -124,7 +124,6 @@ type InitializationErrorMessage = | "node_connection_issue" | "signer_service_issue" | "moonbeam_account_issue" - | "stellar_account_issue" | "pendulum_account_issue"; export type TrackableEvent = diff --git a/apps/frontend/src/contexts/rampState.tsx b/apps/frontend/src/contexts/rampState.tsx index 9c3f210a0..bc09db81e 100644 --- a/apps/frontend/src/contexts/rampState.tsx +++ b/apps/frontend/src/contexts/rampState.tsx @@ -1,28 +1,24 @@ import { createActorContext, useSelector } from "@xstate/react"; import React, { PropsWithChildren, useEffect } from "react"; -import { AlfredpayKycContext, AveniaKycContext, MoneriumKycContext, StellarKycContext } from "../machines/kyc.states"; +import type { AnyActorRef } from "xstate"; +import { AlfredpayKycContext, AveniaKycContext, MoneriumKycContext, MykoboKycContext } from "../machines/kyc.states"; import { rampMachine } from "../machines/ramp.machine"; import { AlfredpayKycActorRef, - AlfredpayKycSnapshot, AveniaKycActorRef, - AveniaKycSnapshot, MoneriumKycActorRef, - MoneriumKycSnapshot, + MykoboKycActorRef, RampMachineSnapshot, SelectedAlfredpayData, SelectedAveniaData, SelectedMoneriumData, - SelectedStellarData, - StellarKycActorRef, - StellarKycSnapshot + SelectedMykoboData } from "../machines/types"; const RAMP_STATE_STORAGE_KEY = "rampState"; const restoredStateJSON = localStorage.getItem(RAMP_STATE_STORAGE_KEY); let restoredState = restoredStateJSON ? JSON.parse(restoredStateJSON) : undefined; -// invalidate restored state if the machine is with error status. restoredState = restoredState?.status === "error" ? undefined : restoredState; export const RampStateContext = createActorContext(rampMachine, { @@ -32,20 +28,40 @@ export const RampStateContext = createActorContext(rampMachine, { export const useRampActor = RampStateContext.useActorRef; export const useRampStateSelector = RampStateContext.useSelector; +// XState's `snapshot.children` is typed against the machine's declared invokes, but child KYC actors +// are invoked dynamically via the kycStateNode lookup table so they don't appear in that type. Cast +// to a plain id->ref map here and let each caller specialize via the generic — that keeps the unsafe +// access in one place instead of repeating `(snapshot.children as any).` throughout the file. +const getChildActor = (snapshot: RampMachineSnapshot, id: string): T | undefined => + (snapshot.children as Record)[id] as T | undefined; + +// Each KYC selector hook below produces the same `{ stateValue, context }` projection with the same +// shallow equality fn. This helper collapses that boilerplate; callers supply the snapshot/context +// types since the underlying child actor refs aren't statically inferable (see `getChildActor`). +function useKycSnapshotProjection( + actor: TActor | undefined +): { stateValue: ReturnType["value"]; context: TContext } | undefined { + return useSelector( + actor, + snapshot => { + if (!snapshot) return undefined; + return { + context: snapshot.context as TContext, + stateValue: snapshot.value as ReturnType["value"] + }; + }, + (prev, next) => { + if (!prev || !next) return prev === next; + return prev.stateValue === next.stateValue && prev.context === next.context; + } + ); +} + const PersistenceEffect = () => { const rampActor = useRampActor(); - const stellarActor = useSelector(rampActor, (snapshot: RampMachineSnapshot) => (snapshot.children as any).stellarKyc) as - | StellarKycActorRef - | undefined; - - const moneriumActor = useSelector(rampActor, (snapshot: RampMachineSnapshot) => (snapshot.children as any).moneriumKyc) as - | MoneriumKycActorRef - | undefined; - - const aveniaActor = useSelector(rampActor, (snapshot: RampMachineSnapshot) => (snapshot.children as any).aveniaKyc) as - | AveniaKycActorRef - | undefined; + const moneriumActor = useSelector(rampActor, snapshot => getChildActor(snapshot, "moneriumKyc")); + const aveniaActor = useSelector(rampActor, snapshot => getChildActor(snapshot, "aveniaKyc")); const { rampContext, rampState, isQuoteExpired, quote } = useSelector(rampActor, state => ({ isQuoteExpired: state?.context.isQuoteExpired, @@ -58,20 +74,15 @@ const PersistenceEffect = () => { moneriumState: state?.value })); - const { stellarState } = useSelector(stellarActor, state => ({ - stellarState: state?.value - })); - const { aveniaState } = useSelector(aveniaActor, state => ({ aveniaState: state?.value })); - // biome-ignore lint/correctness/useExhaustiveDependencies: + // biome-ignore lint/correctness/useExhaustiveDependencies: persistence triggers on context/state changes and on quote/expiry transitions; `rampActor` is stable. useEffect(() => { const persistedSnapshot = rampActor.getPersistedSnapshot(); localStorage.setItem("rampState", JSON.stringify(persistedSnapshot)); - // It's important to have `isQuoteExpired` and `quote` here in the deps array to persist them when they change - }, [rampContext, rampState, moneriumState, stellarState, aveniaState, isQuoteExpired, quote, rampActor.getPersistedSnapshot]); + }, [rampContext, rampState, moneriumState, aveniaState, isQuoteExpired, quote]); return null; }; @@ -85,142 +96,46 @@ export const PersistentRampStateProvider: React.FC = ({ child ); }; -export function useStellarKycActor(): StellarKycActorRef | undefined { +export function useMoneriumKycActor(): MoneriumKycActorRef | undefined { const rampActor = useRampActor(); - return useSelector(rampActor, (snapshot: RampMachineSnapshot) => (snapshot.children as any).stellarKyc) as - | StellarKycActorRef - | undefined; + return useSelector(rampActor, snapshot => getChildActor(snapshot, "moneriumKyc")); } -export function useStellarKycSelector(): SelectedStellarData | undefined { - const rampActor = useRampActor(); - - const stellarActor = useSelector(rampActor, (snapshot: RampMachineSnapshot) => (snapshot.children as any).stellarKyc) as - | StellarKycActorRef - | undefined; - - return useSelector( - stellarActor, - (snapshot: StellarKycSnapshot | undefined) => { - if (!snapshot) { - return undefined; - } - return { - context: snapshot.context as StellarKycContext, - stateValue: snapshot.value - }; - }, - (prev, next) => { - if (!prev || !next) { - return prev === next; - } - return prev.stateValue === next.stateValue && prev.context === next.context; - } - ); +export function useMoneriumKycSelector(): SelectedMoneriumData | undefined { + const actor = useMoneriumKycActor(); + return useKycSnapshotProjection(actor); } -export function useMoneriumKycActor(): MoneriumKycActorRef | undefined { +export function useMykoboKycActor(): MykoboKycActorRef | undefined { const rampActor = useRampActor(); - return useSelector(rampActor, (snapshot: RampMachineSnapshot) => (snapshot.children as any).moneriumKyc) as - | MoneriumKycActorRef - | undefined; + return useSelector(rampActor, snapshot => getChildActor(snapshot, "mykoboKyc")); } -export function useMoneriumKycSelector(): SelectedMoneriumData | undefined { - const rampActor = useRampActor(); - - const moneriumActor = useSelector(rampActor, (snapshot: RampMachineSnapshot) => (snapshot.children as any).moneriumKyc) as - | MoneriumKycActorRef - | undefined; - - return useSelector( - moneriumActor, - (snapshot: MoneriumKycSnapshot | undefined) => { - if (!snapshot) { - return undefined; - } - return { - context: snapshot.context as MoneriumKycContext, - stateValue: snapshot.value - }; - }, - (prev, next) => { - if (!prev || !next) { - return prev === next; - } - return prev.stateValue === next.stateValue && prev.context === next.context; - } - ); +export function useMykoboKycSelector(): SelectedMykoboData | undefined { + const actor = useMykoboKycActor(); + return useKycSnapshotProjection(actor); } export function useAveniaKycActor(): AveniaKycActorRef | undefined { const rampActor = useRampActor(); - return useSelector(rampActor, (snapshot: RampMachineSnapshot) => (snapshot.children as any).aveniaKyc) as - | AveniaKycActorRef - | undefined; + return useSelector(rampActor, snapshot => getChildActor(snapshot, "aveniaKyc")); } export function useAveniaKycSelector(): SelectedAveniaData | undefined { - const rampActor = useRampActor(); - - const aveniaActor = useSelector(rampActor, (snapshot: RampMachineSnapshot) => (snapshot.children as any).aveniaKyc) as - | AveniaKycActorRef - | undefined; - - return useSelector( - aveniaActor, - (snapshot: AveniaKycSnapshot | undefined) => { - if (!snapshot) { - return undefined; - } - return { - context: snapshot.context as AveniaKycContext, - stateValue: snapshot.value - }; - }, - (prev, next) => { - if (!prev || !next) { - return prev === next; - } - return prev.stateValue === next.stateValue && prev.context === next.context; - } - ); + const actor = useAveniaKycActor(); + return useKycSnapshotProjection(actor); } export function useAlfredpayKycActor(): AlfredpayKycActorRef | undefined { const rampActor = useRampActor(); - return useSelector(rampActor, (snapshot: RampMachineSnapshot) => (snapshot.children as any).alfredpayKyc) as - | AlfredpayKycActorRef - | undefined; + return useSelector(rampActor, snapshot => getChildActor(snapshot, "alfredpayKyc")); } export function useAlfredpayKycSelector(): SelectedAlfredpayData | undefined { - const rampActor = useRampActor(); - - const alfredpayActor = useSelector(rampActor, (snapshot: RampMachineSnapshot) => (snapshot.children as any).alfredpayKyc) as - | AlfredpayKycActorRef - | undefined; - - return useSelector( - alfredpayActor, - (snapshot: AlfredpayKycSnapshot | undefined) => { - if (!snapshot) { - return undefined; - } - return { - context: snapshot.context as AlfredpayKycContext, - stateValue: snapshot.value - }; - }, - (prev, next) => { - if (!prev || !next) { - return prev === next; - } - return prev.stateValue === next.stateValue && prev.context === next.context; - } - ); + const actor = useAlfredpayKycActor(); + return useKycSnapshotProjection(actor); } diff --git a/apps/frontend/src/hooks/offramp/useSEP24/useTrackSEP24Events.ts b/apps/frontend/src/hooks/offramp/useSEP24/useTrackSEP24Events.ts deleted file mode 100644 index 10ca0a4c1..000000000 --- a/apps/frontend/src/hooks/offramp/useSEP24/useTrackSEP24Events.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { getAnyFiatTokenDetails, getOnChainTokenDetailsOrDefault, Networks } from "@vortexfi/shared"; -import { createTransactionEvent, useEventsContext } from "../../../contexts/events"; -import { RampExecutionInput, RampState } from "../../../types/phases"; - -export const useTrackSEP24Events = () => { - const { trackEvent } = useEventsContext(); - - const trackKYCStarted = (executionInput: RampExecutionInput, selectedNetwork: Networks) => { - trackEvent({ - event: "kyc_started", - from_amount: executionInput.quote.inputAmount, - from_asset: getOnChainTokenDetailsOrDefault(selectedNetwork, executionInput.onChainToken).assetSymbol, - to_amount: executionInput.quote.outputAmount, - to_asset: getAnyFiatTokenDetails(executionInput.fiatToken).fiat.symbol - }); - }; - - const trackKYCCompleted = (initialState: RampState) => { - trackEvent(createTransactionEvent("kyc_completed", initialState)); - }; - - return { trackKYCCompleted, trackKYCStarted }; -}; diff --git a/apps/frontend/src/hooks/ramp/useRampSubmission.ts b/apps/frontend/src/hooks/ramp/useRampSubmission.ts index 4f2d20ad1..9668bbece 100644 --- a/apps/frontend/src/hooks/ramp/useRampSubmission.ts +++ b/apps/frontend/src/hooks/ramp/useRampSubmission.ts @@ -1,15 +1,6 @@ -import { - createMoonbeamEphemeral, - createPendulumEphemeral, - createStellarEphemeral, - FiatToken, - getNetworkId, - Networks, - RampDirection -} from "@vortexfi/shared"; +import { createMoonbeamEphemeral, createPendulumEphemeral, FiatToken, getNetworkId, Networks } from "@vortexfi/shared"; import { useSelector } from "@xstate/react"; import { useCallback, useState } from "react"; -import { useAccount } from "wagmi"; import { useEventsContext } from "../../contexts/events"; import { useRampActor } from "../../contexts/rampState"; import { usePreRampCheck } from "../../services/initialChecks"; @@ -23,10 +14,11 @@ interface SubmissionError extends Error { message: string; } +// NOTE: Stellar ephemeral omitted — see matching note in register.actor.ts. Re-add a +// stellarEphemeral here (and to the AccountMeta list in register.actor.ts) before re-enabling ARS. const createEphemerals = async () => { return { evmEphemeral: createMoonbeamEphemeral(), - stellarEphemeral: createStellarEphemeral(), substrateEphemeral: await createPendulumEphemeral() }; }; @@ -46,8 +38,6 @@ export const useRampSubmission = () => { const storeQuote = useQuote(); const quote = contextQuote || storeQuote; - const { address: connectedEvmAddress } = useAccount(); - const { inputAmount, fiatToken, onChainToken } = useQuoteFormStore(); const network = quote ? ((Object.values(Networks).includes(quote.to as Networks) ? quote.to : quote.from) as Networks) @@ -93,22 +83,11 @@ export const useRampSubmission = () => { } const ephemerals = await createEphemerals(); - // For EUR (Monerium) onramps the moneriumWalletAddress is the user's connected EVM wallet. - // Callers that don't pass it explicitly (e.g. the Onramp / RampSubmitButton flows) would - // otherwise leave it undefined and the API rejects the registerRamp request. - const isMoneriumOnramp = quote.rampType === RampDirection.BUY && fiatToken === FiatToken.EURC; - const moneriumWalletAddress = data.moneriumWalletAddress ?? (isMoneriumOnramp ? connectedEvmAddress : undefined); - - if (isMoneriumOnramp && !moneriumWalletAddress) { - throw new Error( - "No Monerium wallet address found. Please connect an EVM wallet or provide a Monerium wallet address." - ); - } const executionInput: RampExecutionInput = { ephemerals, fiatToken, - moneriumWalletAddress, + moneriumWalletAddress: data.moneriumWalletAddress, network, onChainToken, pixId: data.pixId, @@ -121,7 +100,7 @@ export const useRampSubmission = () => { }; return executionInput; }, - [validateSubmissionData, quote, onChainToken, fiatToken, connectedWalletAddress, network, connectedEvmAddress] + [validateSubmissionData, quote, onChainToken, fiatToken, connectedWalletAddress, network] ); const handleSubmissionError = useCallback( @@ -146,7 +125,6 @@ export const useRampSubmission = () => { setExecutionPreparing(true); try { - console.log("DEBUG: Ramp Submission Data: ", data); const executionInput = await prepareExecutionInput(data); // This callback is generic and used for any ramp type. @@ -163,7 +141,6 @@ export const useRampSubmission = () => { if (chainId === undefined) { throw new Error("ChainId must be defined at this stage"); } - console.log("DEBUG: Ramp Execution Input: ", { input: { chainId, executionInput, rampDirection } }); rampActor.send({ input: { chainId, executionInput, rampDirection }, type: "CONFIRM" }); } catch (error) { handleSubmissionError(error as SubmissionError); diff --git a/apps/frontend/src/hooks/useSignChallenge.ts b/apps/frontend/src/hooks/useSignChallenge.ts deleted file mode 100644 index cb220d316..000000000 --- a/apps/frontend/src/hooks/useSignChallenge.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import { ActorRefFrom } from "xstate"; -import { DEFAULT_LOGIN_EXPIRATION_TIME_HOURS, SIGNING_SERVICE_URL } from "../constants/constants"; -import { storageKeys } from "../constants/localStorage"; -import { SignInMessage } from "../helpers/siweMessageFormatter"; -import { stellarKycMachine } from "../machines/stellarKyc.machine"; -import { useVortexAccount } from "./useVortexAccount"; - -export interface SiweSignatureData { - signatureSet: boolean; - expirationDate: string; -} - -function createSiweMessage(address: string, nonce: string) { - const siweMessage = new SignInMessage({ - address: address, - domain: window.location.host, - expirationTime: new Date(Date.now() + DEFAULT_LOGIN_EXPIRATION_TIME_HOURS * 60 * 60 * 1000).getTime(), // Constructor in ms. - nonce, - scheme: "https" - }); - - return siweMessage.toMessage(); -} - -export function useSiweSignature(stellarKycActor: ActorRefFrom | undefined) { - const { address, getMessageSignature } = useVortexAccount(); - const [isSigning, setIsSigning] = useState(false); - - const storageKey = `${storageKeys.SIWE_SIGNATURE_KEY_PREFIX}${address}`; - - const checkAuthStatus = useCallback(async () => { - if (!stellarKycActor) return; - if (!address) { - stellarKycActor.send({ type: "AUTH_INVALID" }); - return; - } - - const stored = localStorage.getItem(storageKey); - if (!stored) { - stellarKycActor.send({ type: "AUTH_INVALID" }); - return; - } - - const data: SiweSignatureData = JSON.parse(stored); - if (new Date(data.expirationDate) <= new Date()) { - localStorage.removeItem(storageKey); - stellarKycActor.send({ type: "AUTH_INVALID" }); - return; - } - - try { - const authCheckResponse = await fetch(`${SIGNING_SERVICE_URL}/v1/siwe/check`, { - credentials: "include" - }); - - if (authCheckResponse.ok) { - stellarKycActor.send({ type: "AUTH_VALID" }); - } else { - localStorage.removeItem(storageKey); - stellarKycActor.send({ type: "AUTH_INVALID" }); - } - } catch (error) { - console.error("Auth check failed:", error); - stellarKycActor.send({ error: "Failed to check auth status.", type: "SIGNATURE_FAILURE" }); - } - }, [address, storageKey, stellarKycActor]); - - const promptForSignature = useCallback(async () => { - if (!address || isSigning || !stellarKycActor) return; - setIsSigning(true); - - try { - const messageResponse = await fetch(`${SIGNING_SERVICE_URL}/v1/siwe/create`, { - body: JSON.stringify({ walletAddress: address }), - credentials: "include", - headers: { "Content-Type": "application/json" }, - method: "POST" - }); - - if (!messageResponse.ok) throw new Error("Failed to create message"); - const { nonce } = await messageResponse.json(); - - const siweMessage = createSiweMessage(address, nonce); - const message = SignInMessage.fromMessage(siweMessage); - const signature = await getMessageSignature(siweMessage); - - const validationResponse = await fetch(`${SIGNING_SERVICE_URL}/v1/siwe/validate`, { - body: JSON.stringify({ nonce, signature, siweMessage }), - credentials: "include", - headers: { "Content-Type": "application/json" }, - method: "POST" - }); - - if (!validationResponse.ok) throw new Error("Failed to validate signature"); - - const signatureData: SiweSignatureData = { - expirationDate: message.expirationTime!, - signatureSet: true - }; - - localStorage.setItem(storageKey, JSON.stringify(signatureData)); - stellarKycActor.send({ type: "SIGNATURE_SUCCESS" }); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("User rejected") || errorMessage.includes("Cancelled")) { - stellarKycActor.send({ error: "User rejected signing request.", type: "SIGNATURE_FAILURE" }); - } else { - stellarKycActor.send({ error: "Failed to sign login challenge. " + errorMessage, type: "SIGNATURE_FAILURE" }); - } - } finally { - setIsSigning(false); - } - }, [address, isSigning, getMessageSignature, storageKey, stellarKycActor]); - - useEffect(() => { - if (!stellarKycActor) return; - // We react to the different state changes of the stellarKycActor. - stellarKycActor.on("CHECK_AUTH_STATUS", _event => { - checkAuthStatus(); - }); - - stellarKycActor.on("PROMPT_FOR_SIGNATURE", _event => { - promptForSignature(); - }); - - stellarKycActor.send({ type: "SIWE_READY" }); - }, [stellarKycActor, checkAuthStatus, promptForSignature]); - - return {}; -} diff --git a/apps/frontend/src/hooks/useStepBackNavigation.ts b/apps/frontend/src/hooks/useStepBackNavigation.ts index f31f069b5..e2935da43 100644 --- a/apps/frontend/src/hooks/useStepBackNavigation.ts +++ b/apps/frontend/src/hooks/useStepBackNavigation.ts @@ -7,6 +7,8 @@ import { useAlfredpayKycSelector, useAveniaKycActor, useAveniaKycSelector, + useMykoboKycActor, + useMykoboKycSelector, useRampActor } from "../contexts/rampState"; import { isInCompoundState } from "../machines/types"; @@ -18,6 +20,8 @@ export const useStepBackNavigation = () => { const aveniaState = useAveniaKycSelector(); const alfredpayKycActor = useAlfredpayKycActor(); const alfredpayKycState = useAlfredpayKycSelector(); + const mykoboKycActor = useMykoboKycActor(); + const mykoboKycState = useMykoboKycSelector(); const fiatAccountActor = useFiatAccountActor(); const showFiatAccountRegistration = useFiatAccountSelector(s => s.matches("Open")); @@ -66,6 +70,13 @@ export const useStepBackNavigation = () => { } } + if (mykoboKycActor && mykoboKycState) { + if (mykoboKycState.stateValue === "FormFilling") { + mykoboKycActor.send({ type: "CANCEL" }); + return; + } + } + if (aveniaKycActor && aveniaState) { const childState = aveniaState.stateValue; if (childState === "DocumentUpload") { diff --git a/apps/frontend/src/layouts/WidgetChrome.tsx b/apps/frontend/src/layouts/WidgetChrome.tsx new file mode 100644 index 000000000..26aa5dab4 --- /dev/null +++ b/apps/frontend/src/layouts/WidgetChrome.tsx @@ -0,0 +1,26 @@ +import Stepper from "../components/Stepper"; +import { useIsQuoteComponentDisplayed } from "../hooks/ramp/useIsQuoteComponentDisplayed"; +import { useStepper } from "../hooks/useStepper"; + +// Stepper and the quote-displayed check both read from the ramp XState actor. +// That actor only exists inside WidgetProviders, so this module is dynamic-imported +// to keep XState/rampState out of the marketing entry bundle. +const WidgetChrome = () => { + const { steps } = useStepper(); + const isQuoteComponentDisplayed = useIsQuoteComponentDisplayed(); + const isStepperHidden = isQuoteComponentDisplayed; + + return ( + <> +
+ {isStepperHidden ?
: } +
+
+
+
+
+ + ); +}; + +export default WidgetChrome; diff --git a/apps/frontend/src/layouts/index.tsx b/apps/frontend/src/layouts/index.tsx index 6deb7ae8e..bd8f776b4 100644 --- a/apps/frontend/src/layouts/index.tsx +++ b/apps/frontend/src/layouts/index.tsx @@ -1,11 +1,7 @@ -import { FC, ReactNode, useEffect } from "react"; +import { FC, lazy, ReactNode, Suspense } from "react"; import { Footer } from "../components/Footer"; import { MaintenanceBanner } from "../components/MaintenanceBanner"; import { Navbar } from "../components/Navbar"; -import Stepper from "../components/Stepper"; -import { useIsQuoteComponentDisplayed } from "../hooks/ramp/useIsQuoteComponentDisplayed"; -import { useInitTokenBalances } from "../hooks/useInitTokenBalances"; -import { useStepper } from "../hooks/useStepper"; import { useWidgetMode } from "../hooks/useWidgetMode"; import { useFetchMaintenanceStatus } from "../stores/maintenanceStore"; @@ -14,32 +10,22 @@ interface BaseLayoutProps { modals?: ReactNode; } +const WidgetChrome = lazy(() => import("./WidgetChrome")); + export const BaseLayout: FC = ({ main, modals }) => { const isWidgetMode = useWidgetMode(); - const { steps } = useStepper(); - const isQuoteComponentDisplayed = useIsQuoteComponentDisplayed(); - - useInitTokenBalances(); useFetchMaintenanceStatus(); - const isStepperHidden = isWidgetMode && isQuoteComponentDisplayed; - return ( <> {modals} {isWidgetMode && ( - <> -
- {isStepperHidden ?
: } -
-
-
-
-
- + + + )}
{main}
diff --git a/apps/frontend/src/machines/actors/register.actor.ts b/apps/frontend/src/machines/actors/register.actor.ts index e1d421d0e..d14858f45 100644 --- a/apps/frontend/src/machines/actors/register.actor.ts +++ b/apps/frontend/src/machines/actors/register.actor.ts @@ -28,9 +28,8 @@ export class RegisterRampError extends Error { } export const registerRampActor = async ({ input }: { input: RampContext }): Promise => { - const { executionInput, chainId, connectedWalletAddress, authToken, paymentData, quote, userId } = input; + const { executionInput, chainId, connectedWalletAddress, paymentData, quote, userId } = input; - // TODO there should be a way to assert types in states, given transitions should ensure the type. if (!executionInput || !quote) { throw new RegisterRampError("Execution input and quote are required to register ramp.", RegisterRampErrorType.InvalidInput); } @@ -44,16 +43,16 @@ export const registerRampActor = async ({ input }: { input: RampContext }): Prom const moonbeamApiComponents = await apiManager.getApi(Networks.Moonbeam); const hydrationApiComponents = await apiManager.getApi(Networks.Hydration); - if (!chainId) { + if (chainId === undefined) { throw new RegisterRampError("Chain ID is required to register ramp.", RegisterRampErrorType.InvalidInput); } const quoteId = quote.id; + // NOTE: Stellar ephemeral is intentionally omitted — ARS is the only fiat that still uses + // the Stellar route and it's currently disabled. Re-enabling ARS requires reinstating + // createEphemerals' stellar entry here AND in useRampSubmission.ts to avoid a "Stellar + // signer not found" server error in prepareOnrampStellarPath. const signingAccounts: AccountMeta[] = [ - { - address: executionInput.ephemerals.stellarEphemeral.address, - type: EphemeralAccountType.Stellar - }, { address: executionInput.ephemerals.evmEphemeral.address, type: EphemeralAccountType.EVM @@ -72,14 +71,18 @@ export const registerRampActor = async ({ input }: { input: RampContext }): Prom sessionId: input.externalSessionId, taxId: executionInput.taxId }; - } else if (executionInput.quote.rampType === RampDirection.BUY && executionInput.fiatToken === FiatToken.EURC) { + } else if (quote.rampType === RampDirection.BUY && executionInput.fiatToken === FiatToken.EURC) { additionalData = { destinationAddress: executionInput.sourceOrDestinationAddress, - moneriumAuthToken: authToken, - moneriumWalletAddress: executionInput.moneriumWalletAddress, - sessionId: input.externalSessionId + sessionId: input.externalSessionId, + walletAddress: connectedWalletAddress }; - } else if (executionInput.quote.rampType === RampDirection.SELL && executionInput.fiatToken === FiatToken.BRL) { + } else if (quote.rampType === RampDirection.SELL && executionInput.fiatToken === FiatToken.EURC) { + additionalData = { + sessionId: input.externalSessionId, + walletAddress: connectedWalletAddress + }; + } else if (quote.rampType === RampDirection.SELL && executionInput.fiatToken === FiatToken.BRL) { additionalData = { pixDestination: executionInput.pixId, receiverTaxId: executionInput.taxId, @@ -87,14 +90,14 @@ export const registerRampActor = async ({ input }: { input: RampContext }): Prom taxId: executionInput.taxId, walletAddress: connectedWalletAddress }; - } else if (executionInput.quote.rampType === RampDirection.BUY && isAlfredpayToken(executionInput.fiatToken)) { + } else if (quote.rampType === RampDirection.BUY && isAlfredpayToken(executionInput.fiatToken)) { additionalData = { destinationAddress: executionInput.sourceOrDestinationAddress, fiatAccountId: executionInput.selectedFiatAccountId, sessionId: input.externalSessionId, walletAddress: connectedWalletAddress }; - } else if (executionInput.quote.rampType === RampDirection.SELL && isAlfredpayToken(executionInput.fiatToken)) { + } else if (quote.rampType === RampDirection.SELL && isAlfredpayToken(executionInput.fiatToken)) { additionalData = { fiatAccountId: executionInput.selectedFiatAccountId, sessionId: input.externalSessionId, @@ -102,9 +105,6 @@ export const registerRampActor = async ({ input }: { input: RampContext }): Prom }; } else { additionalData = { - // moneriumAuthToken is only relevant after enabling Monerium offramps. - // moneriumAuthToken: authToken, - // moneriumWalletAddress: executionInput.moneriumWalletAddress, paymentData, sessionId: input.externalSessionId, walletAddress: connectedWalletAddress @@ -113,16 +113,11 @@ export const registerRampActor = async ({ input }: { input: RampContext }): Prom const rampProcess = await RampService.registerRamp(quoteId, signingAccounts, additionalData, userId); - const ephemeralTxs = (rampProcess.unsignedTxs || []).filter(tx => { - if (!connectedWalletAddress) { - return true; - } - - return chainId < 0 && - (tx.network === Networks.Pendulum || tx.network === Networks.AssetHub || tx.network === Networks.Hydration) + const ephemeralTxs = (rampProcess.unsignedTxs || []).filter(tx => + chainId < 0 && (tx.network === Networks.Pendulum || tx.network === Networks.AssetHub || tx.network === Networks.Hydration) ? getAddressForFormat(tx.signer, 0) !== getAddressForFormat(connectedWalletAddress, 0) - : tx.signer.toLowerCase() !== connectedWalletAddress.toLowerCase(); - }); + : tx.signer.toLowerCase() !== connectedWalletAddress.toLowerCase() + ); const signedTransactions = await signUnsignedTransactions( ephemeralTxs, diff --git a/apps/frontend/src/machines/actors/sign.actor.ts b/apps/frontend/src/machines/actors/sign.actor.ts index 3865512da..cda483443 100644 --- a/apps/frontend/src/machines/actors/sign.actor.ts +++ b/apps/frontend/src/machines/actors/sign.actor.ts @@ -1,4 +1,8 @@ import { + BASE_CHAIN_ID, + ERC20_EURC_BASE, + ERC20_EURC_BASE_DECIMALS, + ERC20_EURC_BASE_TOKEN_NAME, ERC20_EURE_POLYGON_DECIMALS, ERC20_EURE_POLYGON_TOKEN_NAME, ERC20_EURE_POLYGON_V2, @@ -100,6 +104,7 @@ export const signTransactionsActor = async ({ let assethubToPendulumHash: string | undefined = undefined; let moneriumOfframpSignature: string | undefined = undefined; let moneriumOnrampPermit: PermitSignature | undefined = undefined; + let mykoboOnrampPermit: PermitSignature | undefined = undefined; const sortedTxs = userTxs?.sort((a, b) => a.nonce - b.nonce); @@ -175,6 +180,18 @@ export const signTransactionsActor = async ({ ERC20_EURE_POLYGON_TOKEN_NAME ); input.parent.send({ phase: "finished", type: "SIGNING_UPDATE" }); + } else if (tx.phase === "mykoboOnrampDeposit") { + input.parent.send({ phase: "login", type: "SIGNING_UPDATE" }); + mykoboOnrampPermit = await signERC2612Permit( + connectedWalletAddress as `0x${string}`, + executionInput?.ephemerals.evmEphemeral.address as `0x${string}`, + rampState.quote.inputAmount, + ERC20_EURC_BASE, + ERC20_EURC_BASE_DECIMALS, + BASE_CHAIN_ID, + ERC20_EURC_BASE_TOKEN_NAME + ); + input.parent.send({ phase: "finished", type: "SIGNING_UPDATE" }); } else { throw new Error(`Unknown transaction received to be signed by user: ${tx.phase}`); } @@ -195,6 +212,7 @@ export const signTransactionsActor = async ({ assethubToPendulumHash, moneriumOfframpSignature, moneriumOnrampPermit, + mykoboOnrampPermit, squidRouterApproveHash, squidRouterNoPermitApproveHash, squidRouterNoPermitSwapHash, @@ -213,6 +231,7 @@ export const signTransactionsActor = async ({ userSigningMeta: { assethubToPendulumHash, moneriumOnrampPermit, + mykoboOnrampPermit, squidRouterApproveHash, squidRouterSwapHash } diff --git a/apps/frontend/src/machines/actors/stellar/sep24Second.actor.ts b/apps/frontend/src/machines/actors/stellar/sep24Second.actor.ts deleted file mode 100644 index 02730e37f..000000000 --- a/apps/frontend/src/machines/actors/stellar/sep24Second.actor.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { getTokenDetailsSpacewalk, PaymentData } from "@vortexfi/shared"; -import Big from "big.js"; -import { fromPromise } from "xstate"; -import { sep24Second } from "../../../services/anchor/sep24/second"; -import { IAnchorSessionParams, ISep24Intermediate } from "../../../types/sep"; -import { RampContext } from "../../types"; - -export const sep24SecondActor = fromPromise( - async ({ - input - }: { - input: RampContext & { - token: string; - tomlValues: any; - } & ISep24Intermediate; - }) => { - const { executionInput, token, tomlValues, id } = input; - if (!executionInput || !token || !tomlValues || !id) { - throw new Error("Missing required data for SEP-24 second step"); - } - const outputToken = getTokenDetailsSpacewalk(executionInput.fiatToken); - - const offrampAmountBeforeFees = Big(executionInput.quote.outputAmount).plus(executionInput.quote.anchorFeeFiat); - - const anchorSessionParams: IAnchorSessionParams = { - offrampAmount: offrampAmountBeforeFees.toFixed(2, 0), - token: token, - tokenConfig: outputToken, - tomlValues: tomlValues - }; - - const secondSep24Response = await sep24Second({ id, url: "" }, anchorSessionParams); - - const amountBeforeFees = Big(executionInput.quote.outputAmount).plus(executionInput.quote.anchorFeeFiat).toFixed(2); - - if (!Big(secondSep24Response.amount).eq(amountBeforeFees)) { - throw new Error("Amount mismatch"); - } - - const paymentData: PaymentData = { - amount: secondSep24Response.amount, - anchorTargetAccount: secondSep24Response.offrampingAccount, - memo: secondSep24Response.memo, - memoType: secondSep24Response.memoType as "text" | "hash" - }; - return paymentData; - } -); diff --git a/apps/frontend/src/machines/actors/stellar/startSep24.actor.ts b/apps/frontend/src/machines/actors/stellar/startSep24.actor.ts deleted file mode 100644 index 501c5726e..000000000 --- a/apps/frontend/src/machines/actors/stellar/startSep24.actor.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { getTokenDetailsSpacewalk } from "@vortexfi/shared"; -import Big from "big.js"; -import { fromCallback } from "xstate"; -import { sep10 } from "../../../services/anchor/sep10"; -import { sep24First } from "../../../services/anchor/sep24/first"; -import { fetchTomlValues } from "../../../services/stellar"; -import { IAnchorSessionParams } from "../../../types/sep"; -import { RampContext } from "../../types"; - -export const startSep24Actor = fromCallback(({ sendBack, input }) => { - let intervalId: NodeJS.Timeout; - - const { executionInput } = input; - if (!executionInput) { - throw new Error("Missing execution input"); - } - - const runSep24Logic = async () => { - try { - const stellarEphemeralSecret = executionInput.ephemerals.stellarEphemeral.secret; - const outputToken = getTokenDetailsSpacewalk(executionInput.fiatToken); - const tomlValues = await fetchTomlValues(outputToken.tomlFileUrl); - - const { token: sep10Token, sep10Account } = await sep10( - tomlValues, - stellarEphemeralSecret, - executionInput.fiatToken, - executionInput.sourceOrDestinationAddress - ); - - const offrampAmountBeforeFees = Big(executionInput.quote.outputAmount).plus(executionInput.quote.anchorFeeFiat); - - const anchorSessionParams: IAnchorSessionParams = { - offrampAmount: offrampAmountBeforeFees.toFixed(2, 0), - token: sep10Token, - tokenConfig: outputToken, - tomlValues - }; - - const fetchAndUpdateSep24Url = async () => { - const firstSep24Response = await sep24First(anchorSessionParams, sep10Account, executionInput.fiatToken); - const url = new URL(firstSep24Response.url); - url.searchParams.append("callback", "postMessage"); - sendBack({ - id: firstSep24Response.id, - type: "URL_UPDATED", - url: url.toString() - }); - }; - - sendBack({ - output: { sep10Account, token: sep10Token, tomlValues }, - type: "SEP24_STARTED" - }); - - // TODO edge case, if the Stellar actor is closed before this interval is returned, then nothing stops this interval on exit. - await fetchAndUpdateSep24Url(); - intervalId = setInterval(fetchAndUpdateSep24Url, 20000); - sendBack({ intervalId, type: "INTERVAL_STARTED" }); - } catch (error) { - sendBack({ error, type: "xstate.error" }); - } - }; - - runSep24Logic(); - - return () => { - if (intervalId) { - clearInterval(intervalId); - } - }; -}); diff --git a/apps/frontend/src/machines/kyc.states.ts b/apps/frontend/src/machines/kyc.states.ts index 1b1913f32..9e696e19d 100644 --- a/apps/frontend/src/machines/kyc.states.ts +++ b/apps/frontend/src/machines/kyc.states.ts @@ -1,4 +1,4 @@ -import { FiatToken, isAlfredpayToken, KycFailureReason, RampDirection } from "@vortexfi/shared"; +import { FiatToken, KycFailureReason } from "@vortexfi/shared"; import { assign, DoneActorEvent, sendTo } from "xstate"; import { ALFREDPAY_FIAT_TOKEN_TO_COUNTRY } from "../constants/fiatAccountMethods"; import { KYCFormData } from "../hooks/brla/useKYCForm"; @@ -12,10 +12,10 @@ import { MxnKycFormData } from "./alfredpayKyc.machine"; import { AveniaKycMachineError, UploadIds } from "./brlaKyc.machine"; -import { MoneriumKycMachineError, MoneriumKycMachineErrorType } from "./moneriumKyc.machine"; -import { RampContext, SelectedAveniaData } from "./types"; +import { MoneriumKycMachineError } from "./moneriumKyc.machine"; +import { MykoboKycFiles, MykoboKycFormData, MykoboKycMachineError, MykoboKycMachineErrorType } from "./mykoboKyc.machine"; +import { RampContext } from "./types"; -// Extended context types for child KYC machines export interface AlfredpayKycContext extends RampContext { verificationUrl?: string; submissionId?: string; @@ -40,7 +40,7 @@ export interface AveniaKycContext extends RampContext { rejectReason?: KycFailureReason | string; documentUploadIds?: UploadIds; error?: AveniaKycMachineError; - isCompany?: boolean; // Flag to identify if the user is a business (CNPJ) or individual (CPF) + isCompany?: boolean; kybAttemptId?: string; kybUrls?: { authorizedRepresentativeUrl: string; @@ -59,23 +59,26 @@ export interface MoneriumKycContext extends RampContext { redirectReady?: boolean; } -export interface StellarKycContext extends RampContext { - token?: string; - sep10Account?: any; - redirectUrl?: string; - tomlValues?: any; - id?: string; - error?: any; - sep24IntervalId?: NodeJS.Timeout; +export interface MykoboKycContext extends RampContext { + formData?: MykoboKycFormData; + files?: MykoboKycFiles; + profileApproved?: boolean; + error?: MykoboKycMachineError; } -// Logic of the KYC node: -// The node attempts to abstract the generic "Started" -> "Verifying" -> "Done" flow of any KYC process. -// The "Verifying" state will invoke child actors based on the particula ramp. -// The output of these state-machine actors will always be assigned to the RampContext's `kycResponse` property. +type MykoboKycOutput = { profileApproved?: boolean; error?: MykoboKycMachineError }; +type AlfredpayDoneEvent = DoneActorEvent<{ error?: AlfredpayKycMachineError }>; + +type KycRoute = { actorId: string; target: string }; +const KYC_ROUTE_BY_TOKEN: Partial> = { + [FiatToken.BRL]: { actorId: "aveniaKyc", target: "Avenia" }, + [FiatToken.EURC]: { actorId: "mykoboKyc", target: "Mykobo" }, + [FiatToken.USD]: { actorId: "alfredpayKyc", target: "Alfredpay" }, + [FiatToken.MXN]: { actorId: "alfredpayKyc", target: "Alfredpay" }, + [FiatToken.COP]: { actorId: "alfredpayKyc", target: "Alfredpay" } +}; + export const kycStateNode = { - entry: ({ context }: { context: RampContext }) => - console.log("DEBUG: Entering KYC state node. RampContext kycFormData:", context.kycFormData), initial: "Deciding", on: { GO_BACK: { @@ -83,27 +86,15 @@ export const kycStateNode = { target: "#ramp.QuoteReady" }, SummaryConfirm: { - actions: [ - // TODO I would prefer to have this uncoupled from the specific implementations, and based on active child. - sendTo( - ({ context }) => { - if (context.executionInput?.fiatToken === FiatToken.BRL) { - return "aveniaKyc"; - } - if (context.executionInput?.fiatToken === FiatToken.EURC && context.rampDirection === RampDirection.BUY) { - return "moneriumKyc"; - } - if (context.executionInput?.fiatToken && isAlfredpayToken(context.executionInput.fiatToken)) { - return "alfredpayKyc"; - } - return "stellarKyc"; - }, - { type: "SummaryConfirm" } - ), - ({ event }: any) => { - console.log("SummaryConfirm event:", event); - } - ] + actions: sendTo( + ({ context }: { context: RampContext }) => { + const token = context.executionInput?.fiatToken; + const route = token ? KYC_ROUTE_BY_TOKEN[token] : undefined; + if (!route) throw new Error(`No KYC actor registered for fiat token "${token ?? "unknown"}"`); + return route.actorId; + }, + { type: "SummaryConfirm" } + ) } }, states: { @@ -111,7 +102,6 @@ export const kycStateNode = { invoke: { id: "alfredpayKyc", input: ({ context }: { context: RampContext }): AlfredpayKycContext => { - console.log("Invoking Alfredpay KYC actor with RampContext input:", context); const fiatToken = context.executionInput?.fiatToken; const country = fiatToken ? (ALFREDPAY_FIAT_TOKEN_TO_COUNTRY[fiatToken] ?? "US") : "US"; return { @@ -122,18 +112,13 @@ export const kycStateNode = { onDone: [ { actions: assign({ - initializeFailedMessage: ({ event }: { event: any }) => - (event.output.error as AlfredpayKycMachineError)?.message || "An unknown error occurred" + initializeFailedMessage: ({ event }: { event: AlfredpayDoneEvent }) => + event.output.error?.message || "An unknown error occurred" }), - guard: ({ event }: { event: any }) => !!event.output.error, + guard: ({ event }: { event: AlfredpayDoneEvent }) => !!event.output.error, target: "#ramp.KycFailure" }, { - actions: assign(({ context }: { context: RampContext }) => { - return { - ...context - }; - }), target: "VerificationComplete" } ], @@ -150,12 +135,9 @@ export const kycStateNode = { invoke: { id: "aveniaKyc", input: ({ context }: { context: RampContext }): AveniaKycContext => { - console.log("Invoking Avenia KYC actor with RampContext input:", context); - return { - ...context, - kycFormData: context.kycFormData, // Pass kycFormData from parent RampContext to AveniaKycContext - taxId: context.executionInput?.taxId! - }; + const taxId = context.executionInput?.taxId; + if (!taxId) throw new Error("taxId is required for Avenia KYC"); + return { ...context, kycFormData: context.kycFormData, taxId }; }, onDone: [ { @@ -168,7 +150,7 @@ export const kycStateNode = { { actions: assign({ initializeFailedMessage: ({ event }: { event: DoneActorEvent }) => - (event.output.error as AveniaKycMachineError).message + event.output.error?.message ?? "Avenia KYC verification failed" }), target: "#ramp.KycFailure" } @@ -184,116 +166,54 @@ export const kycStateNode = { }, Deciding: { always: [ + ...(Object.entries(KYC_ROUTE_BY_TOKEN) as [FiatToken, KycRoute][]).map(([token, route]) => ({ + guard: ({ context }: { context: RampContext }) => context.executionInput?.fiatToken === token, + target: route.target + })), { - guard: ({ context }: { context: RampContext }) => - !!context.executionInput?.fiatToken && isAlfredpayToken(context.executionInput.fiatToken), - target: "Alfredpay" - }, - { - guard: ({ context }: { context: RampContext }) => context.executionInput?.fiatToken === FiatToken.BRL, - target: "Avenia" - }, - { - guard: ({ context }: { context: RampContext }) => - context.executionInput?.fiatToken === FiatToken.EURC && context.rampDirection === RampDirection.BUY, - target: "Monerium" - }, - { - target: "Stellar" + actions: assign({ + initializeFailedMessage: ({ context }: { context: RampContext }) => + `No KYC flow available for ${context.executionInput?.fiatToken ?? "unknown"} token.` + }), + target: "#ramp.KycFailure" } ] }, - Monerium: { + Mykobo: { invoke: { - id: "moneriumKyc", - input: ({ context }: { context: RampContext }): MoneriumKycContext => ({ - ...context - }), + id: "mykoboKyc", + input: ({ context }: { context: RampContext }): MykoboKycContext => context, onDone: [ { - actions: assign(({ context, event }: { context: RampContext; event: any }) => { - console.log("Monerium KYC completed with response:", event.output); - return { - ...context, - authToken: event.output.authToken - }; - }), - guard: ({ event }: { event: any }) => !!event.output.authToken, + guard: ({ event }: { event: DoneActorEvent }) => !!event.output.profileApproved, target: "VerificationComplete" }, { actions: [assign({ rampSigningPhase: undefined }), { type: "showSigningRejectedErrorToast" }], - guard: ({ event }: { event: any }) => - (event.output.error as MoneriumKycMachineError)?.type === MoneriumKycMachineErrorType.UserRejected, + guard: ({ event }: { event: DoneActorEvent }) => + event.output.error?.type === MykoboKycMachineErrorType.UserRejected, target: "#ramp.QuoteReady" }, { actions: assign({ - initializeFailedMessage: ({ event }) => - (event.output.error as MoneriumKycMachineError)?.message || "An unknown error occurred" + initializeFailedMessage: ({ event }: { event: DoneActorEvent }) => + event.output.error?.message || "An unknown error occurred" }), target: "#ramp.KycFailure" } ], onError: { actions: assign({ - initializeFailedMessage: "Monerium KYC verification failed. Please retry." + initializeFailedMessage: "Mykobo KYC verification failed. Please retry." }), target: "#ramp.KycFailure" }, - src: "moneriumKyc" - } - }, - Stellar: { - invoke: { - id: "stellarKyc", - input: ({ context }: { context: RampContext }) => context, - onDone: [ - { - actions: assign(({ context, event }: { context: RampContext; event: any }) => { - console.log("Stellar KYC completed with response:", event.output); - return { - ...context, - paymentData: event.output.paymentData - }; - }), - guard: ({ event }: { event: any }) => !!event.output.paymentData, - target: "VerificationComplete" - }, - { - actions: [{ type: "showSigningRejectedErrorToast" }], - guard: ({ event }: { event: any }) => event.output?.error.includes("User rejected"), // TODO improve to error classes, as in moneriumKyc state machine. - target: "#ramp.QuoteReady" - }, - { - // TODO we probably want to parse the KYC sub-process error before assigning it to the parent ramp state machine. - actions: assign({ - initializeFailedMessage: ({ event }: { event: any }) => event.output.error - }), - target: "#ramp.KycFailure" - } - ], - onError: [ - { - actions: assign({ - initializeFailedMessage: "Stellar KYC verification failed. Please retry." - }), - target: "#ramp.KycFailure" - } - ], - src: "stellarKyc" + src: "mykoboKyc" } }, VerificationComplete: { always: { target: "#ramp.KycComplete" - }, - entry: { - actions: [ - ({ context }: any) => { - console.log("KYC verification completed successfully:", context.kycResponse); - } - ] } } } diff --git a/apps/frontend/src/machines/mykoboKyc.machine.ts b/apps/frontend/src/machines/mykoboKyc.machine.ts new file mode 100644 index 000000000..df3dd3723 --- /dev/null +++ b/apps/frontend/src/machines/mykoboKyc.machine.ts @@ -0,0 +1,195 @@ +import { assign, fromPromise, setup } from "xstate"; + +import { isApiError } from "../services/api/api-client"; +import { MykoboProfilePayload, MykoboService } from "../services/api/mykobo.service"; +import { MykoboKycContext } from "./kyc.states"; + +export type MykoboKycFormData = Omit; + +export interface MykoboKycFiles { + front: File; + back?: File; + face: File; + utilityBill: File; +} + +export enum MykoboKycMachineErrorType { + UserRejected = "USER_REJECTED", + KycRejected = "KYC_REJECTED", + UnknownError = "UNKNOWN_ERROR" +} + +export class MykoboKycMachineError extends Error { + type: MykoboKycMachineErrorType; + constructor(message: string, type: MykoboKycMachineErrorType) { + super(message); + this.type = type; + } +} + +const POLLING_INTERVAL_MS = 5000; +const POLLING_TIMEOUT_MS = 20 * 60 * 1000; + +export const mykoboKycMachine = setup({ + actors: { + checkExistingProfile: fromPromise(async ({ input }: { input: MykoboKycContext }) => { + const address = input.connectedWalletAddress; + if (!address) throw new Error("Wallet address is required"); + return MykoboService.getProfile(address); + }), + pollProfileStatus: fromPromise(async ({ input, signal }: { input: MykoboKycContext; signal: AbortSignal }) => { + const address = input.connectedWalletAddress; + if (!address) throw new Error("Wallet address is required"); + const deadline = Date.now() + POLLING_TIMEOUT_MS; + while (Date.now() < deadline) { + if (signal.aborted) throw new Error("Polling aborted"); + try { + const profile = await MykoboService.getProfile(address); + const status = profile?.kycStatus.reviewStatus; + if (status === "approved") return { status: "approved" as const }; + if (status === "rejected") return { status: "rejected" as const }; + } catch (err) { + // 4xx (other than 404, which getProfile already maps to null) means the request is bad — fail fast. + if (isApiError(err) && err.status >= 400 && err.status < 500) throw err; + console.warn("Mykobo profile poll failed, retrying:", err); + } + // Sleep between polls, but resolve early if the actor is cancelled so the loop can exit promptly. + await new Promise(resolve => { + const timer = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, POLLING_INTERVAL_MS); + const onAbort = () => { + clearTimeout(timer); + resolve(); + }; + signal.addEventListener("abort", onAbort, { once: true }); + }); + } + throw new Error("KYC polling timed out"); + }), + submitProfile: fromPromise(async ({ input }: { input: MykoboKycContext }) => { + if (!input.formData || !input.files) { + throw new Error("Form data and files are required"); + } + const address = input.connectedWalletAddress; + if (!address) throw new Error("Wallet address is required"); + return MykoboService.createProfile({ + ...input.formData, + ...input.files, + walletAddress: address + }); + }) + }, + types: { + context: {} as MykoboKycContext, + events: {} as { type: "SubmitKycForm"; formData: MykoboKycFormData; files: MykoboKycFiles } | { type: "CANCEL" }, + input: {} as MykoboKycContext, + output: {} as { profileApproved?: boolean; error?: MykoboKycMachineError } + } +}).createMachine({ + context: ({ input }) => ({ ...input }), + id: "mykoboKyc", + initial: "CheckingProfile", + output: ({ context }) => ({ + error: context.error, + profileApproved: context.profileApproved + }), + states: { + CheckingProfile: { + invoke: { + id: "checkExistingProfile", + input: ({ context }) => context, + onDone: [ + { + actions: assign({ profileApproved: true }), + guard: ({ event }) => event.output !== null && event.output.kycStatus.reviewStatus === "approved", + target: "Done" + }, + { + guard: ({ event }) => event.output !== null && event.output.kycStatus.reviewStatus === "pending", + target: "Verifying" + }, + { + target: "FormFilling" + } + ], + onError: { + actions: assign({ + error: () => new MykoboKycMachineError("Failed to check Mykobo profile", MykoboKycMachineErrorType.UnknownError) + }), + target: "Failure" + }, + src: "checkExistingProfile" + } + }, + Done: { + type: "final" + }, + Failure: { + type: "final" + }, + FormFilling: { + on: { + CANCEL: { + actions: assign({ + error: () => new MykoboKycMachineError("Cancelled by the user", MykoboKycMachineErrorType.UserRejected) + }), + target: "Failure" + }, + SubmitKycForm: { + actions: assign(({ event }) => ({ + files: event.files, + formData: event.formData + })), + target: "Submitting" + } + } + }, + Rejected: { + type: "final" + }, + Submitting: { + invoke: { + id: "submitProfile", + input: ({ context }) => context, + onDone: { + target: "Verifying" + }, + onError: { + actions: assign({ + error: () => new MykoboKycMachineError("Failed to submit Mykobo profile", MykoboKycMachineErrorType.UnknownError) + }), + target: "Failure" + }, + src: "submitProfile" + } + }, + Verifying: { + invoke: { + id: "pollProfileStatus", + input: ({ context }) => context, + onDone: [ + { + actions: assign({ profileApproved: true }), + guard: ({ event }) => event.output.status === "approved", + target: "Done" + }, + { + actions: assign({ + error: () => new MykoboKycMachineError("KYC was rejected", MykoboKycMachineErrorType.KycRejected) + }), + target: "Rejected" + } + ], + onError: { + actions: assign({ + error: () => new MykoboKycMachineError("KYC verification failed", MykoboKycMachineErrorType.UnknownError) + }), + target: "Failure" + }, + src: "pollProfileStatus" + } + } + } +}); diff --git a/apps/frontend/src/machines/ramp.machine.ts b/apps/frontend/src/machines/ramp.machine.ts index c465c0bcd..eee139c07 100644 --- a/apps/frontend/src/machines/ramp.machine.ts +++ b/apps/frontend/src/machines/ramp.machine.ts @@ -16,7 +16,7 @@ import { alfredpayKycMachine } from "./alfredpayKyc.machine"; import { aveniaKycMachine } from "./brlaKyc.machine"; import { kycStateNode } from "./kyc.states"; import { moneriumKycMachine } from "./moneriumKyc.machine"; -import { stellarKycMachine } from "./stellarKyc.machine"; +import { mykoboKycMachine } from "./mykoboKyc.machine"; import { GetMessageSignatureCallback, RampContext, RampState } from "./types"; const QUOTE_EXPIRY_THRESHOLD_PERCENTAGE = 60; // 60% @@ -227,6 +227,7 @@ export const rampMachine = setup({ return { isExpired: new Date(quote.expiresAt) < new Date(), quote }; }), moneriumKyc: moneriumKycMachine, + mykoboKyc: mykoboKycMachine, quoteRefresher: fromCallback(({ sendBack, input }) => { const { quote, quoteLocked, apiKey, partnerId } = input.context; // Quote will exist at this stage, but to be type safe we check again. @@ -245,7 +246,6 @@ export const rampMachine = setup({ requestOTP: fromPromise(requestOTPActor), signTransactions: fromPromise(signTransactionsActor), startRamp: fromPromise(startRampActor), - stellarKyc: stellarKycMachine, urlCleaner: fromPromise( () => new Promise(resolve => { diff --git a/apps/frontend/src/machines/stellarKyc.machine.ts b/apps/frontend/src/machines/stellarKyc.machine.ts deleted file mode 100644 index bd538bd07..000000000 --- a/apps/frontend/src/machines/stellarKyc.machine.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { FiatToken, PaymentData } from "@vortexfi/shared"; -import { assign, emit, sendParent, setup } from "xstate"; -import { RampSigningPhase } from "../types/phases"; -import { sep24SecondActor } from "./actors/stellar/sep24Second.actor"; -import { startSep24Actor } from "./actors/stellar/startSep24.actor"; -import { StellarKycContext } from "./kyc.states"; -import { RampContext } from "./types"; - -export const stellarKycMachine = setup({ - actors: { - sep24Second: sep24SecondActor, - startSep24: startSep24Actor - }, - types: { - context: {} as StellarKycContext, - events: {} as - | { type: "SummaryConfirm" } - | { type: "URL_UPDATED"; url: string; id: string } - | { - type: "SEP24_STARTED"; - output: { token: string; sep10Account: any; tomlValues: any }; - } - | { type: "SEP24_FAILED"; error: any } - | { type: "INTERVAL_STARTED"; intervalId: NodeJS.Timeout } - | { type: "Cancel"; output: PaymentData } - | { type: "AUTH_VALID" } - | { type: "AUTH_INVALID" } - | { type: "SIGNATURE_SUCCESS" } - | { type: "SIGNATURE_FAILURE"; error: string } - | { type: "CHECK_AUTH_STATUS" } - | { type: "PROMPT_FOR_SIGNATURE" } - | { type: "SIWE_READY" } - | { type: "SIGNING_UPDATE"; phase: RampSigningPhase | undefined }, - input: {} as RampContext, - output: {} as { error?: any; paymentData?: PaymentData } - } -}).createMachine({ - context: ({ input }) => ({ - ...(input as RampContext), - redirectUrl: undefined, - sep24IntervalId: undefined - }), - id: "stellarKyc", - initial: "Authentication", - output: ({ context }) => ({ - error: context.error, - paymentData: context.paymentData - }), - states: { - // SIWE states. - Authentication: { - initial: "PreCheck", - on: { - SIGNATURE_FAILURE: [ - { - actions: assign({ - error: ({ event }) => event.error - }), // Maybe type this kind of error across the app. - guard: ({ event }) => event.error.includes("User rejected signing request."), - target: "Failed" - }, - { - actions: assign({ - error: ({ event }) => event.error - }), - target: "Failed" - } - ] - }, - states: { - // We emit this event which will trigger the siwe hooks to subscribe to this actor. - // Once that's ready, it will send back a SIWE_READY event - AwaitSiwe: { - on: { - SIWE_READY: { - target: "CheckingAuth" - } - } - }, - CheckingAuth: { - entry: emit({ type: "CHECK_AUTH_STATUS" }), - on: { - AUTH_INVALID: { - target: "RequestingSignature" - }, - AUTH_VALID: { - target: "#stellarKyc.StartSep24" - } - } - }, - PreCheck: { - always: [ - { - // Only ARS offramp requires SIWE - guard: ({ context }) => context.executionInput?.fiatToken === FiatToken.ARS, - target: "AwaitSiwe" - }, - { - target: "#stellarKyc.StartSep24" - } - ] - }, - RequestingSignature: { - entry: [ - emit({ type: "PROMPT_FOR_SIGNATURE" }), - sendParent(() => ({ - phase: "login", - type: "SIGNING_UPDATE" - })) - ], - exit: sendParent({ phase: undefined, type: "SIGNING_UPDATE" }), - on: { - SIGNATURE_SUCCESS: { - actions: sendParent(() => ({ - phase: "finished", - type: "SIGNING_UPDATE" - })), - target: "#stellarKyc.StartSep24" - } - } - } - } - }, - Done: { - type: "final" - }, - Failed: { - type: "final" - }, - Sep24Second: { - invoke: { - input: ({ context }) => ({ - ...context, - id: context.id!, - token: context.token!, - tomlValues: context.tomlValues!, - url: context.redirectUrl! - }), - onDone: { - actions: [ - assign({ - paymentData: ({ event }) => event.output - }) - ], - target: "Done" - }, - onError: { - actions: [ - assign({ - error: ({ event }) => event.error - }) - ], - target: "Failed" - }, - src: "sep24Second" - } - }, - StartSep24: { - invoke: { - id: "startSep24Actor", - input: ({ context }) => context, - src: "startSep24" - }, - on: { - INTERVAL_STARTED: { - actions: assign({ - sep24IntervalId: ({ event }) => event.intervalId - }) - }, - SEP24_FAILED: { - actions: assign({ - error: ({ event }) => event.error - }), - target: "Failed" - }, - SEP24_STARTED: { - actions: assign({ - sep10Account: ({ event }) => event.output.sep10Account, - token: ({ event }) => event.output.token, - tomlValues: ({ event }) => event.output.tomlValues - }) - }, - SummaryConfirm: { - actions: ({ context }) => { - if (context.redirectUrl) { - window.open(context.redirectUrl, "_blank"); - } - }, - guard: ({ context }) => !!context.redirectUrl, - target: "Sep24Second" - }, - URL_UPDATED: { - actions: assign({ - id: ({ event }) => event.id, - redirectUrl: ({ event }) => event.url - }) - } - } - } - } -}); diff --git a/apps/frontend/src/machines/types.ts b/apps/frontend/src/machines/types.ts index ecbb65a26..af9ed37f3 100644 --- a/apps/frontend/src/machines/types.ts +++ b/apps/frontend/src/machines/types.ts @@ -6,14 +6,14 @@ import { KYCFormData } from "../hooks/brla/useKYCForm"; import { RampExecutionInput, RampSigningPhase, RampState } from "../types/phases"; import { alfredpayKycMachine } from "./alfredpayKyc.machine"; import { aveniaKycMachine } from "./brlaKyc.machine"; -import { AlfredpayKycContext, AveniaKycContext, MoneriumKycContext, StellarKycContext } from "./kyc.states"; +import { AlfredpayKycContext, AveniaKycContext, MoneriumKycContext, MykoboKycContext } from "./kyc.states"; import { moneriumKycMachine } from "./moneriumKyc.machine"; -import { stellarKycMachine } from "./stellarKyc.machine"; +import { mykoboKycMachine } from "./mykoboKyc.machine"; export type { RampState } from "../types/phases"; export type GetMessageSignatureCallback = (message: string) => Promise<`0x${string}`>; export interface RampContext { - connectedWalletAddress: string | undefined; // The address of the connected wallet (EVM or Substrate) + connectedWalletAddress: string | undefined; authToken?: string; chainId: number | undefined; executionInput: RampExecutionInput | undefined; @@ -38,12 +38,10 @@ export interface RampContext { errorMessage?: string; kycFormData?: KYCFormData; enteredViaForm?: boolean; // True if user navigated from the Quote form, false if entered via direct URL - // Auth-related fields userEmail?: string; userId?: string; isAuthenticated: boolean; isAuthLoading?: boolean; - alfredpayCustomer?: any; postAuthTarget?: "QuoteReady" | "RegisterRamp"; } @@ -82,50 +80,43 @@ export type RampMachineEvents = | { type: "LOGOUT" } | { type: "GO_BACK" }; +// `ActorRefFrom` would close the snapshot type properly, but importing +// rampMachine here creates a value-level circular dependency (types.ts → ramp.machine.ts → types.ts) +// that resolves to `any` at type-eval time and surfaces dozens of latent send/event errors. Keep +// the snapshot loose; concrete narrowing happens at use sites via `RampContext`-typed selectors. +// biome-ignore lint/suspicious/noExplicitAny: see comment above on the rampMachine import cycle. export type RampMachineActor = ActorRef; export type RampMachineSnapshot = SnapshotFrom; -export type StellarKycActorRef = ActorRefFrom; -export type StellarKycSnapshot = SnapshotFrom; - export type MoneriumKycActorRef = ActorRefFrom; export type MoneriumKycSnapshot = SnapshotFrom; +export type MykoboKycActorRef = ActorRefFrom; +export type MykoboKycSnapshot = SnapshotFrom; + export type AveniaKycActorRef = ActorRefFrom; export type AveniaKycSnapshot = SnapshotFrom; export type AlfredpayKycActorRef = ActorRefFrom; export type AlfredpayKycSnapshot = SnapshotFrom; -export type SelectedStellarData = { - stateValue: StellarKycSnapshot["value"]; - context: StellarKycContext; -}; - export type SelectedMoneriumData = { stateValue: MoneriumKycSnapshot["value"]; context: MoneriumKycContext; }; +export type SelectedMykoboData = { + stateValue: MykoboKycSnapshot["value"]; + context: MykoboKycContext; +}; + export type SelectedAveniaData = { stateValue: AveniaKycSnapshot["value"]; context: AveniaKycContext; }; -/** - * Checks whether an XState v5 machine is currently in a compound (parent) state. - * - * In XState v5, `state.value` is a plain string when the machine is in a simple state, - * but becomes an object when it is in a nested state. For example, a machine in - * `KYBFlow > CompanyVerification` has `state.value === { KYBFlow: "CompanyVerification" }`. - * - * @see https://stately.ai/docs/xstate-v5/state-machine-actors#state-value - * - * @example - * isInCompoundState(state.value, "KYBFlow") - * // true when state.value === { KYBFlow: "CompanyVerification" } - * // false when state.value === "DocumentUpload" - */ +// XState v5: `state.value` is a string for simple states, an object for nested ones +// (e.g. `{ KYBFlow: "CompanyVerification" }`). This checks the parent key is present. export function isInCompoundState(stateValue: unknown, state: string): boolean { return typeof stateValue === "object" && stateValue !== null && state in (stateValue as object); } diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx index c3e8cd955..f6832db55 100644 --- a/apps/frontend/src/main.tsx +++ b/apps/frontend/src/main.tsx @@ -16,10 +16,10 @@ import { WagmiProvider } from "wagmi"; import { config } from "./config"; import { PolkadotNodeProvider } from "./contexts/polkadotNode"; import { PolkadotWalletStateProvider } from "./contexts/polkadotWallet"; +import { PersistentRampStateProvider } from "./contexts/rampState"; import { initializeEvmTokens } from "./services/tokens"; import { wagmiConfig } from "./wagmiConfig"; import "./helpers/googleTranslate"; -import { PersistentRampStateProvider } from "./contexts/rampState"; import { routeTree } from "./routeTree.gen"; import enTranslations from "./translations/en.json"; import { getBrowserLanguage, Language } from "./translations/helpers"; diff --git a/apps/frontend/src/pages/progress/index.tsx b/apps/frontend/src/pages/progress/index.tsx index b4f6a0de9..a99a4d226 100644 --- a/apps/frontend/src/pages/progress/index.tsx +++ b/apps/frontend/src/pages/progress/index.tsx @@ -40,6 +40,8 @@ const PHASE_DURATIONS: Record = { moneriumOnrampSelfTransfer: 20, moonbeamToPendulum: 40, moonbeamToPendulumXcm: 30, + mykoboOnrampDeposit: 60, + mykoboOnrampTransfer: 20, nablaApprove: 24, nablaApproveEvm: 24, nablaSwap: 24, diff --git a/apps/frontend/src/pages/progress/phaseMessages.ts b/apps/frontend/src/pages/progress/phaseMessages.ts index c6f17e109..87e89948b 100644 --- a/apps/frontend/src/pages/progress/phaseMessages.ts +++ b/apps/frontend/src/pages/progress/phaseMessages.ts @@ -87,6 +87,8 @@ export function getMessageForPhase(ramp: RampState | undefined, t: TFunction<"tr moneriumOnrampSelfTransfer: t("pages.progress.moneriumOnrampSelfTransfer"), moonbeamToPendulum: getMoonbeamToPendulumMessage(), moonbeamToPendulumXcm: getMoonbeamToPendulumMessage(), + mykoboOnrampDeposit: t("pages.progress.mykoboOnrampDeposit"), + mykoboOnrampTransfer: t("pages.progress.mykoboOnrampTransfer"), nablaApprove: getSwappingMessage(), nablaApproveEvm: getSwappingMessage(), nablaSwap: getSwappingMessage(), diff --git a/apps/frontend/src/pages/ramp/index.tsx b/apps/frontend/src/pages/ramp/index.tsx index 9eafac4c4..0c2179ddc 100644 --- a/apps/frontend/src/pages/ramp/index.tsx +++ b/apps/frontend/src/pages/ramp/index.tsx @@ -1,11 +1,10 @@ import { useSelector } from "@xstate/react"; import { useEffect } from "react"; -import { useRampActor, useStellarKycActor } from "../../contexts/rampState"; +import { useRampActor } from "../../contexts/rampState"; import { useToastMessage } from "../../helpers/notifications"; import { useMoneriumFlow } from "../../hooks/monerium/useMoneriumFlow"; import { useRampNavigation } from "../../hooks/ramp/useRampNavigation"; import { useAuthTokens } from "../../hooks/useAuthTokens"; -import { useSiweSignature } from "../../hooks/useSignChallenge"; import { useQuote, useQuoteActions } from "../../stores/quote/useQuoteStore"; import { FailurePage } from "../failure"; import { ProgressPage } from "../progress"; @@ -16,11 +15,9 @@ import { Widget } from "../widget"; export const Ramp = () => { const { getCurrentComponent } = useRampNavigation(, , , , ); const rampActor = useRampActor(); - const stellarKycActor = useStellarKycActor(); const quote = useQuote(); const { forceSetQuote } = useQuoteActions(); useMoneriumFlow(); - useSiweSignature(stellarKycActor); useAuthTokens(rampActor); const { showToast } = useToastMessage(); diff --git a/apps/frontend/src/pages/widget/index.tsx b/apps/frontend/src/pages/widget/index.tsx index 552b9225e..97aca01ac 100644 --- a/apps/frontend/src/pages/widget/index.tsx +++ b/apps/frontend/src/pages/widget/index.tsx @@ -6,6 +6,7 @@ import { LoadingScreen } from "../../components/Alfredpay/LoadingScreen"; import { AveniaKYBFlow } from "../../components/Avenia/AveniaKYBFlow"; import { AveniaKYBForm } from "../../components/Avenia/AveniaKYBForm"; import { AveniaKYCForm } from "../../components/Avenia/AveniaKYCForm"; +import { MykoboKycFlow } from "../../components/Mykobo/MykoboKycFlow"; import { HistoryMenu } from "../../components/menus/HistoryMenu"; import { SettingsMenu } from "../../components/menus/SettingsMenu"; import { AuthEmailStep } from "../../components/widget-steps/AuthEmailStep"; @@ -23,6 +24,7 @@ import { useAveniaKycActor, useAveniaKycSelector, useMoneriumKycActor, + useMykoboKycActor, useRampActor } from "../../contexts/rampState"; import { cn } from "../../helpers/cn"; @@ -55,6 +57,7 @@ const WidgetContent = () => { const moneriumKycActor = useMoneriumKycActor(); const aveniaState = useAveniaKycSelector(); const alfredpayKycActor = useAlfredpayKycActor(); + const mykoboKycActor = useMykoboKycActor(); const showFiatAccountRegistration = useFiatAccountSelector(s => s.matches("Open")); const fiatRegistrationCountry = useFiatAccountSelector(s => s.context.fiatRegistrationCountry); @@ -136,6 +139,10 @@ const WidgetContent = () => { return ; } + if (mykoboKycActor) { + return ; + } + if (isInitialQuoteFailed) { return ; } diff --git a/apps/frontend/src/services/anchor/sep10/challenge.ts b/apps/frontend/src/services/anchor/sep10/challenge.ts deleted file mode 100644 index bf09a6713..000000000 --- a/apps/frontend/src/services/anchor/sep10/challenge.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Memo, MemoType, Networks, Operation, Transaction } from "stellar-sdk"; -import { config } from "../../../config"; - -interface Sep10Challenge { - transaction: string; - network_passphrase: string; -} - -const EXPECTED_NETWORK_PASSPHRASE = config.isSandbox ? Networks.TESTNET : Networks.PUBLIC; - -async function validateChallenge( - transaction: Transaction, Operation[]>, - signingKey: string, - networkPassphrase: string -): Promise { - if (transaction.source !== signingKey) { - throw new Error(`sep10: Invalid source account: ${transaction.source}`); - } - if (transaction.sequence !== "0") { - throw new Error(`sep10: Invalid sequence number: ${transaction.sequence}`); - } - if (networkPassphrase !== EXPECTED_NETWORK_PASSPHRASE) { - throw new Error(`sep10: Invalid network passphrase: ${networkPassphrase}`); - } -} - -export async function fetchAndValidateChallenge( - webAuthEndpoint: string, - urlParams: URLSearchParams, - signingKey: string -): Promise, Operation[]>> { - const challenge = await fetch(`${webAuthEndpoint}?${urlParams.toString()}`); - if (challenge.status !== 200) { - throw new Error(`sep10: Failed to fetch SEP-10 challenge: ${challenge.statusText}`); - } - - const { transaction, network_passphrase } = (await challenge.json()) as Sep10Challenge; - const transactionSigned = new Transaction(transaction, EXPECTED_NETWORK_PASSPHRASE); - await validateChallenge(transactionSigned, signingKey, network_passphrase); - - return transactionSigned; -} diff --git a/apps/frontend/src/services/anchor/sep10/index.ts b/apps/frontend/src/services/anchor/sep10/index.ts deleted file mode 100644 index 0eb00871d..000000000 --- a/apps/frontend/src/services/anchor/sep10/index.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { FiatToken, getTokenDetailsSpacewalk } from "@vortexfi/shared"; -import { Keypair, Memo, MemoType, Operation, Transaction } from "stellar-sdk"; -import { TomlValues } from "../../../types/sep"; - -import { fetchAndValidateChallenge } from "./challenge"; -import { exists, fetchSep10Signatures, getUrlParams } from "./utils"; - -interface Sep10Response { - token: string; - sep10Account: string; -} - -interface Sep10JwtResponse { - token: string; -} - -async function submitSignedTransaction( - webAuthEndpoint: string, - transaction: Transaction, Operation[]> -): Promise { - const jwt = await fetch(webAuthEndpoint, { - body: JSON.stringify({ transaction: transaction.toXDR().toString() }), - headers: { "Content-Type": "application/json" }, - method: "POST" - }); - - if (jwt.status !== 200) { - throw new Error(`Failed to submit SEP-10 response: ${jwt.statusText}`); - } - - const { token } = (await jwt.json()) as Sep10JwtResponse; - return token; -} - -export async function sep10( - tomlValues: TomlValues, - stellarEphemeralSecret: string, - outputToken: FiatToken, - address: string -): Promise { - const { signingKey, webAuthEndpoint } = tomlValues; - - if (!exists(signingKey) || !exists(webAuthEndpoint)) { - throw new Error("sep10: Missing values in TOML file"); - } - - const ephemeralKeys = Keypair.fromSecret(stellarEphemeralSecret); - const accountId = ephemeralKeys.publicKey(); - const { usesMemo, supportsClientDomain } = getTokenDetailsSpacewalk(outputToken); - - const { urlParams, sep10Account } = await getUrlParams(accountId, usesMemo, supportsClientDomain, address); - const transactionSigned = await fetchAndValidateChallenge(webAuthEndpoint, urlParams, signingKey); - - const { masterClientSignature, clientSignature, clientPublic } = await fetchSep10Signatures({ - address: address, - challengeXDR: transactionSigned.toXDR(), - clientPublicKey: sep10Account, - outToken: outputToken, - usesMemo - }); - - if (supportsClientDomain) { - transactionSigned.addSignature(clientPublic, clientSignature); - } - - if (!usesMemo) { - transactionSigned.sign(ephemeralKeys); - } else { - transactionSigned.addSignature(sep10Account, masterClientSignature); - } - - const token = await submitSignedTransaction(webAuthEndpoint, transactionSigned); - return { sep10Account, token }; -} diff --git a/apps/frontend/src/services/anchor/sep10/utils.ts b/apps/frontend/src/services/anchor/sep10/utils.ts deleted file mode 100644 index 21b6b0c57..000000000 --- a/apps/frontend/src/services/anchor/sep10/utils.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Keyring } from "@polkadot/api"; -import { EvmAddress } from "@vortexfi/shared"; -import { keccak256 } from "viem/utils"; -import { config } from "../../../config"; -import { SIGNING_SERVICE_URL } from "../../../constants/constants"; -import { fetchSep10Signatures as fetchSignatures, SignerServiceSep10Request } from "../../signingService"; - -// Returns the hash value for the address. -// If it's a polkadot address, it will return raw data of the address. -function getHashValueForAddress(address: string) { - if (address.startsWith("0x")) { - return address as EvmAddress; - } else { - const keyring = new Keyring({ type: "sr25519" }); - return keyring.decodeAddress(address); - } -} - -// A memo derivation. -async function deriveMemoFromAddress(address: string) { - const hashValue = getHashValueForAddress(address); - const hash = keccak256(hashValue); - return BigInt(hash).toString().slice(0, 15); -} - -export const exists = (value?: string | null): value is string => !!value && value?.length > 0; - -export async function fetchSep10Signatures(args: SignerServiceSep10Request) { - try { - return await fetchSignatures(args); - } catch (_error: unknown) { - throw new Error("Could not fetch sep 10 signatures from backend"); - } -} - -// Return the URLSearchParams and the account (master/omnibus or ephemeral) that was used for SEP-10 -export async function getUrlParams( - ephemeralAccount: string, - usesMemo: boolean, - supportsClientDomain: boolean, - address: string -): Promise<{ urlParams: URLSearchParams; sep10Account: string }> { - let sep10Account: string; - const params = new URLSearchParams(); - - if (usesMemo) { - const response = await fetch(`${SIGNING_SERVICE_URL}/v1/stellar/sep10`); - if (!response.ok) { - throw new Error("Failed to fetch client master SEP-10 public account."); - } - - const { masterSep10Public } = await response.json(); - if (!masterSep10Public) { - throw new Error("masterSep10Public not found in response."); - } - - sep10Account = masterSep10Public; - params.append("account", sep10Account); - params.append("memo", await deriveMemoFromAddress(address)); - } else { - sep10Account = ephemeralAccount; - params.append("account", sep10Account); - } - - if (supportsClientDomain) { - params.append("client_domain", config.applicationClientDomain); - } - - return { sep10Account, urlParams: params }; -} diff --git a/apps/frontend/src/services/anchor/sep24/first.ts b/apps/frontend/src/services/anchor/sep24/first.ts deleted file mode 100644 index b95b79c36..000000000 --- a/apps/frontend/src/services/anchor/sep24/first.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { FiatToken, getTokenDetailsSpacewalk } from "@vortexfi/shared"; -import { config } from "../../../config"; -import { IAnchorSessionParams, ISep24Intermediate } from "../../../types/sep"; - -export async function sep24First( - sessionParams: IAnchorSessionParams, - ANCLAP_sep10Account: string, - outputToken: FiatToken -): Promise { - if (config.test.mockSep24) { - return { id: "1234", url: "https://www.example.com" }; - } - - const { token, tomlValues, offrampAmount } = sessionParams; - const { sep24Url } = tomlValues; - const { usesMemo } = getTokenDetailsSpacewalk(outputToken); - const assetCode = sessionParams.tokenConfig.stellarAsset.code.string; - - const params = { - amount: offrampAmount, - asset_code: assetCode, - ...(usesMemo && { account: ANCLAP_sep10Account }) - }; - - const response = await fetch(`${sep24Url}/transactions/withdraw/interactive`, { - body: JSON.stringify(params), - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json" - }, - method: "POST" - }); - - if (response.status !== 200) { - console.log(await response.json(), params.toString()); - throw new Error(`Failed to initiate SEP-24: ${response.statusText}`); - } - - const { type, url, id } = await response.json(); - if (type !== "interactive_customer_info_needed") { - throw new Error(`Unexpected SEP-24 type: ${type}`); - } - - return { id, url }; -} diff --git a/apps/frontend/src/services/anchor/sep24/second.ts b/apps/frontend/src/services/anchor/sep24/second.ts deleted file mode 100644 index 076cc7ec3..000000000 --- a/apps/frontend/src/services/anchor/sep24/second.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { config } from "../../../config"; -import { IAnchorSessionParams, ISep24Intermediate, SepResult } from "../../../types/sep"; - -interface Sep24TransactionStatus { - status: string; - amount_in: string; - withdraw_memo: string; - withdraw_memo_type: string; - withdraw_anchor_account: string; -} - -const POLLING_INTERVAL = 1000; - -async function fetchTransactionStatus(id: string, token: string, sep24Url: string): Promise { - const idParam = new URLSearchParams({ id }); - const statusResponse = await fetch(`${sep24Url}/transaction?${idParam.toString()}`, { - headers: { Authorization: `Bearer ${token}` } - }); - - if (statusResponse.status !== 200) { - throw new Error(`Failed to fetch SEP-24 status: ${statusResponse.statusText}`); - } - - const { transaction } = await statusResponse.json(); - return transaction; -} - -async function pollTransactionStatus(id: string, sessionParams: IAnchorSessionParams): Promise { - const { token, tomlValues } = sessionParams; - let status: Sep24TransactionStatus; - - if (!tomlValues.sep24Url) { - throw new Error("Missing SEP-24 URL in TOML values"); - } - - do { - console.log(`Polling SEP-24 transaction status for ID: ${id}`); - await new Promise(resolve => setTimeout(resolve, POLLING_INTERVAL)); - status = await fetchTransactionStatus(id, token, tomlValues.sep24Url); - } while (status.status !== "pending_user_transfer_start"); - - return status; -} - -export async function sep24Second(sep24Values: ISep24Intermediate, sessionParams: IAnchorSessionParams): Promise { - if (config.test.mockSep24) { - await new Promise(resolve => setTimeout(resolve, 10000)); - return { - amount: sessionParams.offrampAmount, - memo: "MYK1722323689", - memoType: "text", - offrampingAccount: "GBKGDLVV53YX36A32TGOGUJJPVFLL2FXBIALATAOYSQBNKLRDSNDEP3Y" - }; - } - - const status = await pollTransactionStatus(sep24Values.id, sessionParams); - - return { - amount: status.amount_in, - memo: status.withdraw_memo, - memoType: status.withdraw_memo_type, - offrampingAccount: status.withdraw_anchor_account - }; -} diff --git a/apps/frontend/src/services/api/index.ts b/apps/frontend/src/services/api/index.ts index 69fd9c7a5..45621e613 100644 --- a/apps/frontend/src/services/api/index.ts +++ b/apps/frontend/src/services/api/index.ts @@ -4,12 +4,12 @@ export * from "./contact.service"; export * from "./email.service"; export * from "./maintenance.service"; export * from "./moonbeam.service"; +export * from "./mykobo.service"; export * from "./pendulum.service"; export * from "./price.service"; export * from "./quote.service"; export * from "./ramp.service"; export * from "./rating.service"; export * from "./siwe.service"; -export * from "./stellar.service"; export * from "./storage.service"; export * from "./subsidize.service"; diff --git a/apps/frontend/src/services/api/mykobo.service.ts b/apps/frontend/src/services/api/mykobo.service.ts new file mode 100644 index 000000000..27f3c6a2f --- /dev/null +++ b/apps/frontend/src/services/api/mykobo.service.ts @@ -0,0 +1,72 @@ +import { apiClient, isApiError } from "./api-client"; + +export type MykoboKycReviewStatus = "pending" | "approved" | "rejected"; + +export interface MykoboKycStatus { + receivedAt: string | null; + reviewStatus: MykoboKycReviewStatus; +} + +export interface MykoboProfile { + firstName: string; + lastName: string; + emailAddress: string; + bankAccountNumber: string; + kycStatus: MykoboKycStatus; + createdAt: string; +} + +export interface MykoboProfilePayload { + firstName: string; + lastName: string; + emailAddress: string; + addressLine1: string; + city: string; + idCountryCode: string; + bankAccountNumber: string; + walletAddress: string; + sourceOfFunds: "EMPLOYMENT" | "SAVINGS" | "LOANS" | "INVESTMENT" | "INHERITANCE"; + taxCountry: string; + idType: "PASSPORT" | "ID_CARD" | "DRIVERS_LICENSE"; + front: File; + back?: File; + face: File; + utilityBill: File; +} + +export const MykoboService = { + async createProfile(payload: MykoboProfilePayload): Promise { + const form = new FormData(); + form.append("first_name", payload.firstName); + form.append("last_name", payload.lastName); + form.append("email_address", payload.emailAddress); + form.append("address_line_1", payload.addressLine1); + form.append("city", payload.city); + form.append("id_country_code", payload.idCountryCode); + form.append("bank_account_number", payload.bankAccountNumber); + form.append("wallet_address", payload.walletAddress); + form.append("source_of_funds", payload.sourceOfFunds); + form.append("tax_country", payload.taxCountry); + form.append("id_type", payload.idType); + form.append("front", payload.front); + if (payload.back) form.append("back", payload.back); + form.append("face", payload.face); + form.append("utility_bill", payload.utilityBill); + + const data = await apiClient.post<{ profile: MykoboProfile }>("/mykobo/profiles", form); + return data.profile; + }, + async getProfile(walletAddress: string): Promise { + try { + const data = await apiClient.get<{ profile: MykoboProfile }>("/mykobo/profiles", { + params: { address: walletAddress } + }); + return data.profile; + } catch (error: unknown) { + if (isApiError(error) && error.status === 404) { + return null; + } + throw error; + } + } +}; diff --git a/apps/frontend/src/services/api/stellar.service.ts b/apps/frontend/src/services/api/stellar.service.ts deleted file mode 100644 index 3ec15aa07..000000000 --- a/apps/frontend/src/services/api/stellar.service.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { - CreateStellarTransactionRequest, - CreateStellarTransactionResponse, - FiatToken, - GetSep10MasterPKResponse, - SignSep10ChallengeRequest, - SignSep10ChallengeResponse -} from "@vortexfi/shared"; -import { apiRequest } from "./api-client"; - -/** - * Service for interacting with Stellar API endpoints - */ -export class StellarService { - private static readonly BASE_PATH = "/stellar"; - - /** - * Create a Stellar transaction - * @param accountId The account ID - * @param maxTime The maximum time - * @param assetCode The asset code - * @param baseFee The base fee - * @returns The transaction signature, sequence, and public key - */ - static async createTransaction( - accountId: string, - maxTime: number, - assetCode: string, - baseFee: string - ): Promise { - const request: CreateStellarTransactionRequest = { - accountId, - assetCode, - baseFee, - maxTime - }; - return apiRequest("post", `${this.BASE_PATH}/create`, request); - } - - /** - * Sign a SEP-10 challenge - * @param challengeXDR The challenge XDR - * @param outToken The output token - * @param clientPublicKey The client public key - * @param derivedMemo Optional derived memo - * @returns The signed challenge - */ - static async signSep10Challenge( - challengeXDR: string, - outToken: FiatToken, - clientPublicKey: string, - derivedMemo?: string - ): Promise { - const request: SignSep10ChallengeRequest = { - challengeXDR, - clientPublicKey, - derivedMemo, - outToken - }; - return apiRequest("post", `${this.BASE_PATH}/sep10`, request); - } - - /** - * Get the SEP-10 master public key - * @returns The master public key - */ - static async getSep10MasterPK(): Promise { - return apiRequest("get", `${this.BASE_PATH}/sep10`); - } -} diff --git a/apps/frontend/src/services/signingService.tsx b/apps/frontend/src/services/signingService.tsx index ee5975636..1705edeb2 100644 --- a/apps/frontend/src/services/signingService.tsx +++ b/apps/frontend/src/services/signingService.tsx @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { BrlaCreateSubaccountRequest, BrlaGetKycStatusResponse, FiatToken, KycLevel1Payload } from "@vortexfi/shared"; +import { BrlaCreateSubaccountRequest, BrlaGetKycStatusResponse, KycLevel1Payload } from "@vortexfi/shared"; import { SIGNING_SERVICE_URL } from "../constants/constants"; interface AccountStatusResponse { @@ -9,17 +9,9 @@ interface AccountStatusResponse { interface SigningServiceStatus { pendulum: AccountStatusResponse; - stellar: AccountStatusResponse; moonbeam: AccountStatusResponse; } -interface SignerServiceSep10Response { - clientSignature: string; - clientPublic: string; - masterClientSignature: string; - masterClientPublic: string; -} - export enum KycStatus { PENDING = "PENDING", REJECTED = "REJECTED", @@ -49,14 +41,6 @@ export interface RegisterSubaccountPayload { startDate?: number; // Denoted in milliseconds since epoch } -export interface SignerServiceSep10Request { - challengeXDR: string; - outToken: FiatToken; - clientPublicKey: string; - address: string; - usesMemo?: boolean; -} - // Generic error for signing service export class SigningServiceError extends Error { constructor(message: string) { @@ -66,13 +50,6 @@ export class SigningServiceError extends Error { } // Specific errors for each funding account -export class StellarFundingAccountError extends SigningServiceError { - constructor() { - super("Stellar account is inactive"); - this.name = "StellarFundingAccountError"; - } -} - export class PendulumFundingAccountError extends SigningServiceError { constructor() { super("Pendulum account is inactive"); @@ -96,9 +73,6 @@ export const fetchSigningServiceAccountId = async (): Promise { queryFn: fetchSigningServiceAccountId, queryKey: ["signingService"], retry: (failureCount, error) => { - if ( - error instanceof StellarFundingAccountError || - error instanceof PendulumFundingAccountError || - error instanceof MoonbeamFundingAccountError - ) { + if (error instanceof PendulumFundingAccountError || error instanceof MoonbeamFundingAccountError) { return false; } return failureCount < 3; @@ -137,30 +106,6 @@ export const useSigningService = () => { }); }; -export const fetchSep10Signatures = async ({ - challengeXDR, - outToken, - clientPublicKey, - usesMemo, - address -}: SignerServiceSep10Request): Promise => { - const response = await fetch(`${SIGNING_SERVICE_URL}/v1/stellar/sep10`, { - body: JSON.stringify({ address, challengeXDR, clientPublicKey, outToken, usesMemo }), - credentials: "include", - headers: { "Content-Type": "application/json" }, - method: "POST" - }); - if (response.status !== 200) { - if (response.status === 401) { - throw new Error("Invalid signature"); - } - throw new Error(`Failed to fetch SEP10 challenge from server: ${response.statusText}`); - } - - const { clientSignature, clientPublic, masterClientSignature, masterClientPublic } = await response.json(); - return { clientPublic, clientSignature, masterClientPublic, masterClientSignature }; -}; - export const fetchKycStatus = async (taxId: string, quoteId: string, sessionId?: string) => { const statusResponse = await fetch( `${SIGNING_SERVICE_URL}/v1/brla/getKycStatus?taxId=${taxId}"eId=${quoteId}${sessionId ? `&sessionId=${sessionId}` : ""}` diff --git a/apps/frontend/src/services/stellar/index.ts b/apps/frontend/src/services/stellar/index.ts deleted file mode 100644 index 5f302db34..000000000 --- a/apps/frontend/src/services/stellar/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { TomlValues } from "../../types/sep"; - -export const fetchTomlValues = async (TOML_FILE_URL: string): Promise => { - const response = await fetch(TOML_FILE_URL); - if (response.status !== 200) { - throw new Error(`Failed to fetch TOML file: ${response.statusText}`); - } - - const tomlFileContent = (await response.text()).split("\n"); - const findValueInToml = (key: string): string | undefined => { - const keyValue = tomlFileContent.find(line => line.includes(key)); - return keyValue?.split("=")[1].trim().replaceAll('"', ""); - }; - - return { - kycServer: findValueInToml("KYC_SERVER"), - sep6Url: findValueInToml("TRANSFER_SERVER"), - sep24Url: findValueInToml("TRANSFER_SERVER_SEP0024"), - signingKey: findValueInToml("SIGNING_KEY"), - webAuthEndpoint: findValueInToml("WEB_AUTH_ENDPOINT") - }; -}; diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index 8656f2b7e..b66728a38 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -568,6 +568,45 @@ "title": "Identity Verification", "zipCode": "ZIP Code" }, + "mykoboKycFlow": { + "checkingProfile": "Checking your Mykobo profile…", + "done": "KYC complete.", + "failure": "Something went wrong with KYC.", + "rejected": "Your KYC was rejected.", + "submitting": "Submitting your KYC details…", + "verifying": "Verifying your KYC. This can take a few minutes…" + }, + "mykoboKycForm": { + "addressLine1": "Address line 1", + "backOfId": "Back of ID", + "bankAccountNumber": "IBAN", + "city": "City", + "country": "Country (ISO alpha-2)", + "documents": "Documents", + "emailAddress": "Email address", + "fileBackRequired": "Back of ID is required for ID cards and driver's licenses.", + "filePlaceholder": "PNG, JPG or PDF", + "filesRequired": "Front of ID, selfie, and utility bill are required.", + "firstName": "First name", + "fixErrors": "Please fill in all required fields.", + "frontOfId": "Front of ID", + "idTypeDriversLicense": "Driver's license", + "idTypeIdCard": "ID card", + "idTypeLabel": "ID type", + "idTypePassport": "Passport", + "lastName": "Last name", + "selfie": "Selfie", + "sourceOfFundsEmployment": "Employment", + "sourceOfFundsInheritance": "Inheritance", + "sourceOfFundsInvestment": "Investment", + "sourceOfFundsLabel": "Source of funds", + "sourceOfFundsLoans": "Loans", + "sourceOfFundsSavings": "Savings", + "submit": "Submit", + "taxCountry": "Tax country (ISO alpha-2)", + "title": "KYC verification", + "utilityBill": "Utility bill" + }, "navbar": { "api": "API", "bookDemo": "Book demo", @@ -634,8 +673,10 @@ "bic": "BIC", "footer": "Please ensure the amount is exact.", "hint": "Scan this QR code in your banking app to start the payment instantly.", + "hintNoQr": "Transfer the exact amount to the bank details above.", "iban": "IBAN", "receiver": "Recipient", + "reference": "Reference", "title": "Do an instant bank transfer with the following details" }, "headerText": { @@ -1184,6 +1225,8 @@ "moneriumOnrampMint": "Waiting to receive payment", "moneriumOnrampSelfTransfer": "Transferring EUR.e to the ephemeral account", "moonbeamToPendulum": "Transferring {{assetSymbol}} from Moonbeam --> Pendulum", + "mykoboOnrampDeposit": "Waiting to receive payment", + "mykoboOnrampTransfer": "Transferring EURC to the ephemeral account", "pendulumToAssethubXcm": "Transferring {{assetSymbol}} from Pendulum --> AssetHub", "pendulumToHydrationXcm": "Transferring {{assetSymbol}} from Pendulum --> Hydration", "pendulumToMoonbeamXcm": "Transferring {{assetSymbol}} from Pendulum --> Moonbeam", diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index 1b28095d1..cf5395d40 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -572,6 +572,45 @@ "title": "Verificação de Identidade", "zipCode": "CEP" }, + "mykoboKycFlow": { + "checkingProfile": "Verificando seu perfil Mykobo…", + "done": "KYC concluído.", + "failure": "Algo deu errado com o KYC.", + "rejected": "Seu KYC foi rejeitado.", + "submitting": "Enviando seus dados de KYC…", + "verifying": "Verificando seu KYC. Isso pode levar alguns minutos…" + }, + "mykoboKycForm": { + "addressLine1": "Linha de endereço 1", + "backOfId": "Verso do documento", + "bankAccountNumber": "IBAN", + "city": "Cidade", + "country": "País (ISO alpha-2)", + "documents": "Documentos", + "emailAddress": "Endereço de e-mail", + "fileBackRequired": "O verso do documento é obrigatório para carteiras de identidade e carteiras de motorista.", + "filePlaceholder": "PNG, JPG ou PDF", + "filesRequired": "Frente do documento, selfie e comprovante de residência são obrigatórios.", + "firstName": "Nome", + "fixErrors": "Por favor, preencha todos os campos obrigatórios.", + "frontOfId": "Frente do documento", + "idTypeDriversLicense": "Carteira de motorista", + "idTypeIdCard": "Carteira de identidade", + "idTypeLabel": "Tipo de documento", + "idTypePassport": "Passaporte", + "lastName": "Sobrenome", + "selfie": "Selfie", + "sourceOfFundsEmployment": "Emprego", + "sourceOfFundsInheritance": "Herança", + "sourceOfFundsInvestment": "Investimento", + "sourceOfFundsLabel": "Origem dos fundos", + "sourceOfFundsLoans": "Empréstimos", + "sourceOfFundsSavings": "Poupança", + "submit": "Enviar", + "taxCountry": "País fiscal (ISO alpha-2)", + "title": "Verificação KYC", + "utilityBill": "Comprovante de residência" + }, "navbar": { "api": "API", "bookDemo": "Agendar demo", @@ -638,8 +677,10 @@ "bic": "BIC", "footer": "Por favor, garanta que o valor seja exato.", "hint": "Escaneie este QR code no seu aplicativo bancário para iniciar o pagamento instantaneamente.", + "hintNoQr": "Transfira o valor exato para os dados bancários acima.", "iban": "IBAN", "receiver": "Nome do Beneficiário", + "reference": "Referência", "title": "Faça uma transferência bancária instantânea com os seguintes detalhes" }, "headerText": { @@ -1189,6 +1230,8 @@ "moneriumOnrampMint": "Aguardando pagamento", "moneriumOnrampSelfTransfer": "Transferindo EUR.e para a conta efêmera", "moonbeamToPendulum": "Transferindo {{assetSymbol}} de Moonbeam --> Pendulum", + "mykoboOnrampDeposit": "Aguardando pagamento", + "mykoboOnrampTransfer": "Transferindo EURC para a conta efêmera", "pendulumToAssethubXcm": "Transferindo {{assetSymbol}} de Pendulum --> AssetHub", "pendulumToHydrationXcm": "Transferindo {{assetSymbol}} de Pendulum --> Hydration", "pendulumToMoonbeamXcm": "Transferindo {{assetSymbol}} de Pendulum --> Moonbeam", diff --git a/apps/frontend/src/types/phases.ts b/apps/frontend/src/types/phases.ts index b186b597b..d5c6735b9 100644 --- a/apps/frontend/src/types/phases.ts +++ b/apps/frontend/src/types/phases.ts @@ -29,7 +29,6 @@ export interface RampExecutionInput { sourceOrDestinationAddress: string; // The source address for offramps, destination address for onramps moneriumWalletAddress?: string; // Only needed for Monerium offramps to non-EVM chains (e.g. Monerium -> Assethub) ephemerals: { - stellarEphemeral: EphemeralAccount; substrateEphemeral: EphemeralAccount; evmEphemeral: EphemeralAccount; }; diff --git a/apps/frontend/src/types/sep.ts b/apps/frontend/src/types/sep.ts deleted file mode 100644 index 5855234f0..000000000 --- a/apps/frontend/src/types/sep.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { StellarTokenDetails } from "@vortexfi/shared"; - -export interface TomlValues { - signingKey?: string; - webAuthEndpoint?: string; - sep24Url?: string; - sep6Url?: string; - kycServer?: string; -} - -export interface ISep24Intermediate { - url: string; - id: string; -} - -export interface IAnchorSessionParams { - token: string; - tomlValues: TomlValues; - tokenConfig: StellarTokenDetails; - offrampAmount: string; -} - -export interface SepResult { - amount: string; - memo: string; - memoType: string; - offrampingAccount: string; -} diff --git a/docs/mykobo-base-flow-chart.md b/docs/mykobo-base-flow-chart.md new file mode 100644 index 000000000..aff1803cb --- /dev/null +++ b/docs/mykobo-base-flow-chart.md @@ -0,0 +1,234 @@ +# Mykobo + EURC-on-Base — Flow Charts + +Companion to `mykobo-base-flow.md`. Diagrams render via Mermaid. + +--- + +## Legend + +| Symbol | Meaning | +|---|---| +| `User` | End user (browser + bank account + EVM wallet on Base) | +| `FE` | Frontend (`apps/frontend`) — React + XState | +| `API` | Backend (`apps/api`) — Express + Sequelize | +| `Phases` | Phase orchestrator + handlers (`apps/api/.../phases`) | +| `Mykobo` | Mykobo API (`api.mykobo.co` / sandbox) — KYC + EURC settlement | +| `Base` | Base / BaseSepolia EVM chain | +| `Squid` | Squidrouter (cross-chain swap aggregator) | +| `Dest` | Destination EVM chain (e.g. Arbitrum, Polygon) | +| `Eph` | Backend-controlled EVM ephemeral (transient wallet for this ramp) | +| `MykoboWallet` | User's EVM wallet on Base where Mykobo mints EURC | +| `Settlement` | `MYKOBO_SETTLEMENT_ADDRESS` (Mykobo's collector on Base) | + +--- + +## 1. BUY (Onramp) — Fiat → EURC on Base → destination token + +### 1.1 High-level sequence + +```mermaid +sequenceDiagram + autonumber + participant User + participant FE as Frontend + participant API as Backend API + participant Mykobo + participant Phases as Phase Orchestrator + participant Base as Base Chain + participant Squid as Squidrouter + participant Dest as Destination Chain + + Note over User,FE: 1. KYC + User->>FE: Open widget, pick EUR → token on EVM (Base/other) + FE->>Mykobo: GET /profiles?address=walletAddress + alt no profile + FE->>User: MykoboKycForm (text + 4 files) + User->>FE: Submit KYC + FE->>API: POST /v1/mykobo/profiles (multipart) + API->>Mykobo: POST /profiles + end + loop every 5s, up to 20min + FE->>Mykobo: GET /profiles?address=... + Mykobo-->>FE: { reviewStatus } + end + Note over FE: reviewStatus = approved + + Note over User,API: 2. Quote + Register + FE->>API: POST /v1/ramp/quotes (EUR→token, to=base/EVM) + API-->>FE: quote (Mykobo strategy) + FE->>API: POST /v1/ramp/register {walletAddress, destinationAddress} + API->>Mykobo: getDepositInstructions(walletAddress) + Mykobo-->>API: { iban, bic, receiverName, reference } + API->>API: prepareMykoboOnrampTransactions + API-->>FE: { unsignedTxs, ibanPaymentData, stateMeta } + + Note over User,FE: 3. User signs + pays + FE->>User: EUROnrampDetails (IBAN + reference) + FE->>User: Sign presigned transferFrom + EIP-2612 permit + User->>FE: Signatures (mykoboOnrampPermit) + FE->>API: updateRamp { mykoboOnrampPermit } + User->>User: SEPA transfer to Mykobo IBAN + User->>FE: "I have made the payment" → PAYMENT_CONFIRMED + + Note over Mykobo,Base: 4. Settlement + Mykobo->>Base: Mint EURC → MykoboWallet (user's address) + + Note over Phases,Dest: 5. On-chain orchestration + loop mykoboOnrampDeposit (≤30min) + Phases->>Base: balanceOf(EURC, MykoboWallet) + end + Phases->>Phases: 30s settle delay + Phases->>Base: permit(owner=MykoboWallet, spender=Eph) [if needed] + Phases->>Base: transferFrom(MykoboWallet → Eph) + Phases->>Base: approve(EURC → Squidrouter) + Phases->>Squid: swap(Base EURC → Dest token) + Squid->>Dest: lands token (or bridged USDC fallback) + alt backup route triggered + Phases->>Dest: backupApprove + backupSwap + end + Phases->>Dest: destinationTransfer(Eph → user) + Phases-->>FE: phase = complete +``` + +### 1.2 Transaction graph on Base ephemeral + +```mermaid +flowchart LR + subgraph Base[Base chain] + MW[Mykobo Wallet
user EVM addr] + E[Ephemeral
backend-controlled] + SQ[Squidrouter contract] + end + subgraph Dest[Destination chain] + SQD[Squidrouter dest] + OUT[Output token] + UW[User wallet] + end + + MW -- 1 transferFrom
via presigned permit --> E + E -- 2 approve EURC --> SQ + E -- 3 swap --> SQ + SQ -- bridge --> SQD + SQD -- output --> OUT + OUT -- 4 destinationTransfer --> UW + + SQD -. bridged USDC fallback .-> BK[Backup approve+swap
by funding account] + BK --> OUT +``` + +### 1.3 Phase state machine (backend) + +```mermaid +stateDiagram-v2 + [*] --> initial + initial --> mykoboOnrampDeposit: register + sign + mykoboOnrampDeposit --> mykoboOnrampDeposit: balance not yet seen
(check timeout, retry) + mykoboOnrampDeposit --> failed: 30min payment timeout + mykoboOnrampDeposit --> mykoboOnrampTransfer: EURC observed + 30s settle + mykoboOnrampTransfer --> mykoboOnrampTransfer: recoverable error retry + mykoboOnrampTransfer --> squidRouterSwap: permit + transferFrom done
(or recovery shortcut) + squidRouterSwap --> destinationTransfer + destinationTransfer --> complete + complete --> [*] + failed --> [*] +``` + +### 1.4 KYC machine (frontend — `mykoboKyc.machine.ts`) + +```mermaid +stateDiagram-v2 + [*] --> CheckingProfile + CheckingProfile --> Done: approved + CheckingProfile --> Verifying: pending + CheckingProfile --> FormFilling: 404 (no profile) + FormFilling --> Submitting: SubmitKycForm + FormFilling --> Failure: CANCEL (UserRejected) + Submitting --> Verifying + Verifying --> Done: approved + Verifying --> Rejected: rejected + Verifying --> Failure: 4xx / timeout + Done --> [*] + Rejected --> [*] + Failure --> [*] + + note right of Verifying + Poll /profiles every 5s, + 20 min cap, + AbortSignal-aware sleep + end note +``` + +--- + +## 2. SELL (Offramp) — token on Base → EURC on Base → SEPA payout + +```mermaid +sequenceDiagram + autonumber + participant User + participant FE as Frontend + participant API as Backend API + participant Base as Base Chain + participant Squid as Squidrouter + participant Mykobo + + User->>FE: Pick token on Base → EUR + FE->>API: POST /v1/ramp/quotes (Base token → EUR) + API-->>FE: quote (Mykobo offramp strategy) + FE->>User: Connect Base wallet, confirm + FE->>API: POST /v1/ramp/register {walletAddress} + API->>API: prepareEvmToMykoboOfframpTransactions + + alt input is EURC on Base + API-->>FE: 1 tx: ERC20.transfer(EURC, Settlement) + else input is any other Base ERC-20 + API-->>FE: 2 txs: approve(Squid) + swap(token → EURC, to=Settlement) + end + + FE->>User: Sign tx(s) + User->>Base: Broadcast + alt swap path + Base->>Squid: swap any → EURC + Squid->>Base: EURC → Settlement + else direct path + Base->>Base: EURC transfer → Settlement + end + + Mykobo->>Base: Observe EURC receipt at Settlement + Mykobo->>User: SEPA payout to linked bank +``` + +--- + +## 3. Routing decision (BUY vs SELL, Mykobo vs Monerium) + +```mermaid +flowchart TD + Q[Incoming quote] --> D{direction?} + D -->|BUY| B{inputCurrency = EURC
AND isBaseEvmNetwork(quote.to)?} + D -->|SELL| S{outputCurrency = EURC
AND isBaseEvmNetwork(quote.from)?} + B -->|yes| BM[Mykobo onramp strategy] + B -->|no| BO[Monerium / other] + S -->|yes| SM[Mykobo offramp strategy] + S -->|no| SO[Monerium / other
requires moneriumAuthToken] +``` + +--- + +## 4. SANDBOX_ENABLED switching + +```mermaid +flowchart LR + Env[SANDBOX_ENABLED] -->|true| Sandbox + Env -->|false| Prod + subgraph Sandbox + S1[api.sandbox.mykobo.co] + S2[MYKOBO_BASE_NETWORK = BaseSepolia] + S3[EURC = BaseSepolia EURC] + end + subgraph Prod + P1[api.mykobo.co] + P2[MYKOBO_BASE_NETWORK = Base] + P3[EURC = ERC20_EURC_BASE 0x60a3...b42] + end +``` \ No newline at end of file diff --git a/docs/mykobo-base-flow.md b/docs/mykobo-base-flow.md new file mode 100644 index 000000000..89e1d0258 --- /dev/null +++ b/docs/mykobo-base-flow.md @@ -0,0 +1,279 @@ +# Mykobo + EURC-on-Base — Implementation Summary + +Branch: `feat/mykobo-base-frontend` +Status: BUY (fiat → EURC → cross-chain on-ramp) and SELL (EVM → EURC on Base → fiat) end-to-end on Base / Base Sepolia, replacing Monerium for Base routing. Monerium remains live for Polygon EURe. + +--- + +## 1. Architecture + +Two parallel EUR ramps coexist, discriminated by destination/source network: + +| Currency | Network | Anchor | +|---|---|---| +| EURC | Base / BaseSepolia | **Mykobo** (new) | +| EURe / EURC | Polygon | Monerium (existing) | + +Routing is centralized through `isBaseEvmNetwork(network)` in `apps/api/src/api/services/mykobo/index.ts`. Every dispatcher and validator that touches EURC checks that helper before choosing the Mykobo or Monerium branch. + +### BUY (Onramp) + +``` +User → bank SEPA → Mykobo settlement + ↓ mints EURC on Base into Mykobo wallet + backend polls Base balance (mykoboOnrampDeposit) + ↓ + EIP-2612 permit + transferFrom → ephemeral (mykoboOnrampTransfer) + ↓ + Squidrouter swap → destination chain/token (squidRouterSwap) + ↓ + destinationTransfer → user wallet + ↓ + complete +``` + +### SELL (Offramp) + +``` +User (Base wallet) signs: + - if input already EURC on Base: single ERC-20 transfer to MYKOBO_SETTLEMENT_ADDRESS + - otherwise: Squidrouter swap (any Base ERC-20 → EURC on Base) with destination = MYKOBO_SETTLEMENT_ADDRESS + ↓ + Mykobo detects receipt → SEPA payout to linked bank +``` + +No substrate ephemeral, no Pendulum, no XCM on the Mykobo path — everything stays on Base. + +--- + +## 2. Backend + +### 2.1 Mykobo service (`apps/api/src/api/services/mykobo/index.ts`) + +HTTP client + helpers. Single source of truth for network constant and KYC types. + +| Export | Purpose | +|---|---| +| `MYKOBO_BASE_NETWORK: EvmNetworks` | `BaseSepolia` if `SANDBOX_ENABLED`, else `Base` | +| `isBaseEvmNetwork(n)` | Network discriminator used everywhere routing decides Mykobo vs Monerium | +| `getMykoboProfile(walletAddress)` | `GET /profiles?address=` — returns `null` on 404 | +| `createMykoboProfile(formData)` | `POST /profiles` multipart — KYC submission | +| `getMykoboDepositInstructions(walletAddress)` | Returns `{ iban, bic, receiverName, reference?, depositQrCode? }` | +| `getMykoboSettlementAddress()` | Env-configured SEPA settlement address (SELL destination) | +| `verifyMykoboWebhookSignature(payload, sig)` | HMAC-SHA256 with `timingSafeEqual` | + +Types: `MykoboProfile`, `MykoboKycStatus { reviewStatus: "pending" | "approved" | "rejected" }`, `MykoboDepositInstructions`. + +Base URL toggles between `https://api.sandbox.mykobo.co` and `https://api.mykobo.co` via `SANDBOX_ENABLED`. + +### 2.2 Controller + routes + +`apps/api/src/api/controllers/mykobo.controller.ts`: +- `getProfileController` — `GET /v1/mykobo/profiles?address=…` +- `createProfileController` — `POST /v1/mykobo/profiles` (multipart: text fields + 4 file fields: `front`, `back`, `face`, `utility_bill`) +- `kycWebhookController` — `POST /v1/mykobo/webhook`. Verifies HMAC signature, ACKs with 204. Intentional stub: frontend polls `kycStatus.reviewStatus` directly, so the webhook doesn't need to advance state yet. + +`apps/api/src/api/routes/v1/mykobo.route.ts`: registers the three endpoints with multer storing files in memory (10 MB cap). Mounted at `/v1/mykobo` from `apps/api/src/api/routes/v1/index.ts:184`. + +### 2.3 Quote engines + +| Stage | Onramp | Offramp | +|---|---|---| +| Initialize | `onramp-mykobo.ts` → `OnRampInitializeMykoboEngine` (sets `ctx.mykoboMint`) | `offramp-mykobo.ts` → `OffRampInitializeMykoboEngine` (sets `ctx.mykoboOffRamp`) | +| Fee | `onramp-mykobo-to-evm.ts` → zero fees in all components | `offramp-evm-to-mykobo.ts` → zero fees | +| SquidRouter | `OnRampSquidRouterMykoboBaseToEvmEngine` (Base → toNetwork) | n/a (handled in offramp init when input != EURC) | +| Finalize | `OnRampFinalizeEngine` (shared) | `OffRampFinalizeEngine` + `OffRampDiscountEngine` | + +Strategies: +- `onramp-mykobo-to-evm.strategy.ts` → stages `[Initialize, Fee, SquidRouter, Finalize]` +- `offramp-evm-to-mykobo.strategy.ts` → stages `[Initialize, Fee, Discount, Finalize]` + +Route resolver (`apps/api/src/api/services/quote/routes/route-resolver.ts`) selects the strategy: +- BUY + EURC + `isBaseEvmNetwork(quote.to)` → Mykobo strategy +- SELL + EURC + `isBaseEvmNetwork(quote.from)` → Mykobo strategy +- Otherwise → Monerium + +Quote context (`apps/api/src/api/services/quote/core/types.ts`) gained two optional fields: +```ts +mykoboMint?: { currency; fee: Big; inputAmountDecimal; inputAmountRaw; outputAmountDecimal; outputAmountRaw } +mykoboOffRamp?: { currency; fee: Big; inputAmountDecimal; inputAmountRaw; outputAmountDecimal; outputAmountRaw } +``` + +### 2.4 Phase handlers + +Two new handlers registered in `apps/api/src/api/services/phases/register-handlers.ts`: + +**`mykobo-onramp-deposit-handler.ts`** — phase `"mykoboOnrampDeposit"` +- Polls EURC balance on Base for `mykoboWalletAddress` via `checkEvmBalancePeriodically` (1 s tick, 5 min check window, 30 min overall payment timeout). +- On balance arrival: 30 s settle delay (avoids reading pre-finality state), transition to `mykoboOnrampTransfer`. +- On total payment timeout: transition to `failed`. +- On check timeout (still within payment window): throws recoverable error → retry. + +**`mykobo-onramp-transfer-handler.ts`** — phase `"mykoboOnrampTransfer"` +- Recovery shortcut: if EURC already on ephemeral, skip and advance to `squidRouterSwap`. +- `submitPermitIfNeeded`: sends EIP-2612 `permit(owner, spender, value, deadline, v, r, s)` on `ERC20_EURC_BASE` from `MOONBEAM_EXECUTOR_PRIVATE_KEY`. Stores `mykoboPermitTxHash` in state so reruns skip. +- `submitPresignedTransferFrom`: broadcasts the user-presigned `transferFrom` tagged `mykoboOnrampTransfer` from `state.unsignedTxs`. +- 30 s settle delay → transition to `squidRouterSwap`. +- Errors → `createRecoverableError` so the orchestrator retries. + +Both handlers use `MYKOBO_BASE_NETWORK` (auto-switches per `SANDBOX_ENABLED`). + +### 2.5 Transaction preparation + +**Onramp** — `apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm.ts`: + +`prepareMykoboToEvmOnrampTransactions(params: MykoboOnrampTransactionParams)` builds, in order, on the Base ephemeral: + +1. `mykoboOnrampTransfer` — `createMykoboPullToEphemeralOnBase` (presigned `transferFrom` Mykobo wallet → ephemeral) +2. `squidRouterApprove` — EURC → squidrouter allowance +3. `squidRouterSwap` — Base EURC → destination token on `toNetwork` +4. `destinationTransfer` — final transfer from ephemeral to user +5. **Backup route** (via `appendBackupRouteTransactions`): if the cross-chain swap lands bridged USDC/AXLUSDC instead of the user's chosen output, the funding account can finish the swap on the destination chain. + - `backupSquidRouterApprove`, `backupSquidRouterSwap`: same-chain swap from bridged token to output token + - `backupApprove`: max-uint256 approval of the bridged token to `MOONBEAM_FUNDING_PRIVATE_KEY`'s account + +Per-network nonce allocator via `Map`; same-chain destinations (Base → Base) collapse to one counter automatically. `pushTx` helper encapsulates the nonce/append pattern. + +`stateMeta` returned: `{ destinationAddress, evmEphemeralAddress, mykoboWalletAddress, squidRouterQuoteId, squidRouterReceiverId, squidRouterReceiverHash, walletAddress }`. + +Validation flows through `validateMykoboOnramp` in `apps/api/src/api/services/transactions/onramp/common/validation.ts` — returns narrowed types `{ toNetwork: EvmNetworks, outputTokenDetails, evmEphemeralEntry, inputCurrency: FiatToken.EURC }`, throws if input ≠ EURC or destination isn't EVM. + +**Offramp** — `apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo-evm.ts`: + +`prepareEvmToMykoboOfframpTransactions({ quote, userAddress })`: +- Requires `fromNetwork === MYKOBO_BASE_NETWORK`. +- If input is already EURC on Base (`addressesEqual(inputToken.erc20AddressSourceChain, ERC20_EURC_BASE)`): single ERC-20 `transfer` tx tagged `destinationTransfer`, target = `MYKOBO_SETTLEMENT_ADDRESS`. +- Otherwise: Squidrouter `approve` + `swap` with `destinationAddress = MYKOBO_SETTLEMENT_ADDRESS`, `toNetwork = MYKOBO_BASE_NETWORK`, `toToken = ERC20_EURC_BASE`. + +### 2.6 RampService dispatch + +`apps/api/src/api/services/ramp/ramp.service.ts`: + +- `prepareMykoboOnrampTransactions(quote, accounts, additionalData)` — requires `walletAddress` + `destinationAddress`. Calls `getMykoboDepositInstructions(walletAddress)` and forwards to `prepareMykoboToEvmOnrampTransactions`. Returns `{ unsignedTxs, stateMeta, depositQrCode, ibanPaymentData }`. +- `prepareMykoboOfframpTransactions(quote, accounts, additionalData)` — requires `walletAddress`, forwards to `prepareEvmToMykoboOfframpTransactions`. +- `dispatchRampTransactions` discriminates by network: + - BUY: `inputCurrency === EURC && isBaseEvmNetwork(quote.to)` → Mykobo, else → Monerium + - SELL: `outputCurrency === EURC && isBaseEvmNetwork(quote.from)` → Mykobo, else → Monerium (which still requires `moneriumAuthToken`) +- `validateRampStateData` BUY branch checks the right permit field per route: + ```ts + const isMykoboBuy = isBaseEvmNetwork(rampState.to); + const permitName = isMykoboBuy ? "mykoboOnrampPermit" : "moneriumOnrampPermit"; + const permit = isMykoboBuy ? rampState.state.mykoboOnrampPermit : rampState.state.moneriumOnrampPermit; + if (!permit) throw new APIError({ message: `Missing ${permitName} in state. Cannot proceed.`, status: BAD_REQUEST }); + ``` +- Mykobo SELL also has its own `squidRouterSwapHash` / `destinationTransferTxHash` validation branch. + +### 2.7 State metadata extensions + +`apps/api/src/api/services/phases/meta-state-types.ts`: +- `mykoboWalletAddress: string | undefined` +- `mykoboOnrampPermit?: PermitSignature` +- `mykoboPermitTxHash?: string` + +### 2.8 Constants + +`apps/api/src/constants/constants.ts`: +- `MYKOBO_API_KEY` +- `MYKOBO_SETTLEMENT_ADDRESS` +- `MYKOBO_WEBHOOK_SECRET` + +### 2.9 Phase registry + +`apps/api/src/api/services/phases/register-handlers.ts` registers both `mykoboOnrampDepositPhaseHandler` and `mykoboOnrampTransferPhaseHandler`. Phase metadata for the two new phases is seeded alongside Monerium's entries. + +--- + +## 3. Shared package (`@vortexfi/shared`) + +`packages/shared/src/endpoints/ramp.endpoints.ts`: +- `RampPhase` union gained `"mykoboOnrampDeposit"` and `"mykoboOnrampTransfer"`. +- `UpdateRampRequest.additionalData.mykoboOnrampPermit?: PermitSignature`. +- `IbanPaymentData` already supports an optional `reference?: string` (used for Mykobo SCOR; Monerium ignores it). + +`packages/shared/src/tokens/base/eurcMykoboTokenConfig.ts`: +- `eurcMykoboTokenConfig: Partial>` with `assetSymbol: "EURC"`, `decimals: 6`, EUR fiat metadata, and min/max raw amounts (`minBuy 1000`, `minSell 25000`, `maxBuy/Sell 10_000_000_000`). + +Constants exported from shared: `ERC20_EURC_BASE` (Base EURC address `0x60a3…b42`), `ERC20_EURC_BASE_DECIMALS = 6`. + +After any change here: `bun build:shared`. + +--- + +## 4. Frontend + +### 4.1 KYC machine — `apps/frontend/src/machines/mykoboKyc.machine.ts` + +XState v5 (`setup({}).createMachine`). States: +``` +CheckingProfile + → approved → Done + → pending → Verifying + → not found → FormFilling + +FormFilling + → SubmitKycForm → Submitting + → CANCEL → Failure (UserRejected) + +Submitting → Verifying + +Verifying (polls /profiles every 5 s, 20 min cap) + → approved → Done + → rejected → Rejected + → error → Failure + +Done | Rejected | Failure (final) +``` + +Polling uses an `AbortSignal`-aware sleep so cancellation propagates immediately. 4xx errors (other than 404) fail fast; other transient errors are logged and retried. Final output: `{ profileApproved?, error? }`. + +Error type `MykoboKycMachineError` with discriminants `UserRejected`, `KycRejected`, `UnknownError`. + +### 4.2 KYC flow component — `apps/frontend/src/components/Mykobo/MykoboKycFlow.tsx` + +Renders one of: `LoadingPanel` (`CheckingProfile`, `Submitting`, `Verifying`), `MykoboKycForm` (`FormFilling`), success copy (`Done`), `ErrorPanel` (`Rejected`, `Failure`). Wired through `useMykoboKycActor` / `useMykoboKycSelector`. + +`MykoboKycForm` collects text profile fields + 4 files (`front`, `back`, `face`, `utility_bill`) and POSTs via `MykoboService.createProfile` (multipart through `apps/frontend/src/services/api/mykobo.service.ts`). + +### 4.3 KYC routing — `apps/frontend/src/machines/kyc.states.ts` + +```ts +[FiatToken.EURC]: { actorId: "mykoboKyc", target: "Mykobo" } +``` + +`Mykobo` state in the parent `kyc` machine invokes `mykoboKyc`, then routes on output: approved → next phase; `UserRejected` → user-cancel state; otherwise → init-failed with the machine's error message. + +`ramp.machine.ts` registers `mykoboKyc: mykoboKycMachine` as a child actor. + +### 4.4 Deposit instructions UI + +The existing `EUROnrampDetails` component renders on `rampState.ramp.ibanPaymentData` (no longer gated on `depositQrCode`). When Mykobo returns a `reference` it's displayed with a copy-button; when no QR is returned the hint text switches to `hintNoQr`. `TransactionTokensDisplay` wires it in for `RampDirection.BUY && fiatToken === EURC`. The submit-button gate (`RampSubmitButton`) treats `ibanPaymentData` as a valid ready signal in addition to `depositQrCode` / `achPaymentData`. + +--- + +## 5. Configuration + +Required env vars: +- `MYKOBO_API_KEY` — bearer token for `api.mykobo.co` / sandbox +- `MYKOBO_SETTLEMENT_ADDRESS` — Mykobo's EVM address that receives EURC on Base for SELL payouts +- `MYKOBO_WEBHOOK_SECRET` — HMAC-SHA256 secret for `/v1/mykobo/webhook` +- `SANDBOX_ENABLED` — toggles both API base URL and `MYKOBO_BASE_NETWORK` (`Base` ↔ `BaseSepolia`) + +--- + +## 6. End-to-end flows + +### BUY +1. Widget: EUR → on-chain token (e.g. USDC) → Base. +2. `MykoboKycFlow` collects profile + docs, polls until `approved`. +3. `RegisterRamp` POSTs `/v1/ramp/register` with `{ inputCurrency: EURC, to: base, additionalData: { walletAddress, destinationAddress } }`. +4. Dispatcher → `prepareMykoboOnrampTransactions`. Response contains `ibanPaymentData` (+ optional `depositQrCode`, `reference`). +5. `EUROnrampDetails` shows bank details. User pays and clicks "I have made the payment" → `PAYMENT_CONFIRMED`. +6. Backend phases: `mykoboOnrampDeposit` (polls Base balance) → `mykoboOnrampTransfer` (permit + transferFrom) → `squidRouterSwap` → `destinationTransfer` → `complete`. + +### SELL +1. Widget: USDC (or EURC) on Base → EUR. +2. Connect Base wallet, submit. Dispatcher → `prepareMykoboOfframpTransactions` (no `moneriumAuthToken` needed because of network discrimination). +3. Unsigned txs: either a direct EURC `transfer` to `MYKOBO_SETTLEMENT_ADDRESS`, or Squidrouter (`approve` + `swap`) landing EURC on Base at the settlement address. +4. User signs. Mykobo detects receipt and pays out EUR to the linked bank. + +--- \ No newline at end of file diff --git a/packages/shared/src/endpoints/ramp.endpoints.ts b/packages/shared/src/endpoints/ramp.endpoints.ts index 9440f2bba..362faa99b 100644 --- a/packages/shared/src/endpoints/ramp.endpoints.ts +++ b/packages/shared/src/endpoints/ramp.endpoints.ts @@ -15,6 +15,8 @@ export type RampPhase = | "initial" | "moneriumOnrampSelfTransfer" | "moneriumOnrampMint" + | "mykoboOnrampDeposit" + | "mykoboOnrampTransfer" | "squidRouterPermitExecute" | "squidRouterNoPermitTransfer" | "squidRouterNoPermitApprove" @@ -164,6 +166,7 @@ export interface IbanPaymentData { receiverName: string; iban: string; bic: string; + reference?: string; } export interface RegisterRampRequest { @@ -206,6 +209,7 @@ export interface UpdateRampRequest { assethubToPendulumHash?: string; moneriumOfframpSignature?: string; // Required to trigger Monerium offramp moneriumOnrampPermit?: PermitSignature; + mykoboOnrampPermit?: PermitSignature; [key: string]: unknown; }; } diff --git a/packages/shared/src/endpoints/supported-fiat-currencies.endpoints.ts b/packages/shared/src/endpoints/supported-fiat-currencies.endpoints.ts index 8c5492cfe..0535f28e6 100644 --- a/packages/shared/src/endpoints/supported-fiat-currencies.endpoints.ts +++ b/packages/shared/src/endpoints/supported-fiat-currencies.endpoints.ts @@ -16,7 +16,7 @@ export interface GetSupportedFiatCurrenciesResponse { export const SUPPORTED_FIAT_CURRENCIES: SupportedFiatCurrency[] = [ { decimals: 2, enabled: true, name: "Euro", symbol: FiatToken.EURC }, { decimals: 2, enabled: true, name: "Brazilian Real", symbol: FiatToken.BRL }, - { decimals: 2, enabled: true, name: "Argentine Peso", symbol: FiatToken.ARS }, + { decimals: 2, enabled: false, name: "Argentine Peso", symbol: FiatToken.ARS }, { decimals: 2, enabled: true, name: "US Dollar", symbol: FiatToken.USD }, { decimals: 2, enabled: true, name: "Mexican Peso", symbol: FiatToken.MXN }, { decimals: 2, enabled: true, name: "Colombian Peso", symbol: FiatToken.COP } diff --git a/packages/shared/src/tokens/constants/misc.ts b/packages/shared/src/tokens/constants/misc.ts index 3165bc5fa..6ea0a47aa 100644 --- a/packages/shared/src/tokens/constants/misc.ts +++ b/packages/shared/src/tokens/constants/misc.ts @@ -33,6 +33,13 @@ export const ERC20_EURE_POLYGON_V2: `0x${string}` = "0xE0aEa583266584DafBB3f9C32 export const ERC20_EURE_POLYGON_TOKEN_NAME = "Monerium EURe"; export const ERC20_EURE_POLYGON_DECIMALS = 18; // EUR.e on Polygon has 18 decimals +// Constants relevant for the Mykobo ramps (EURC on Base) +export const ERC20_EURC_BASE: `0x${string}` = "0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42"; +export const ERC20_EURC_BASE_TOKEN_NAME = "EURC"; +export const ERC20_EURC_BASE_DECIMALS = 6; +export const BASE_CHAIN_ID = 8453; +export const BASE_SEPOLIA_CHAIN_ID = 84532; + export const ERC20_USDC_POLYGON_DECIMALS = 6; // USDC on Polygon has 6 decimals export const ERC20_USDT_POLYGON_DECIMALS = 6; // USDT on Polygon has 6 decimals diff --git a/packages/shared/src/tokens/evm/config.ts b/packages/shared/src/tokens/evm/config.ts index f2ea1c83a..37960518c 100644 --- a/packages/shared/src/tokens/evm/config.ts +++ b/packages/shared/src/tokens/evm/config.ts @@ -3,8 +3,8 @@ */ import { EvmNetworks, Networks } from "../../helpers"; -import { PENDULUM_USDC_AXL } from "../pendulum/config"; -import { TokenType } from "../types/base"; +import { PENDULUM_EURC_STELLAR, PENDULUM_USDC_AXL } from "../pendulum/config"; +import { FiatCurrencyDetails, FiatToken, TokenType } from "../types/base"; import { EvmToken, EvmTokenDetails } from "../types/evm"; export const evmTokenConfig: Record>> = { @@ -216,6 +216,18 @@ export const evmTokenConfig: Record> = { + [FiatToken.EURC]: { + assetSymbol: "EURC", + decimals: 6, + fiat: { + assetIcon: "eur", + name: "Euro", + symbol: "EUR" + }, + maxBuyAmountRaw: "10000000000", + maxSellAmountRaw: "10000000000", + minBuyAmountRaw: "1000", + minSellAmountRaw: "25000", + type: TokenType.Fiat + } +}; diff --git a/packages/shared/src/tokens/pendulum/config.ts b/packages/shared/src/tokens/pendulum/config.ts index a23a3e04c..4768fdb30 100644 --- a/packages/shared/src/tokens/pendulum/config.ts +++ b/packages/shared/src/tokens/pendulum/config.ts @@ -32,3 +32,20 @@ export const PENDULUM_BRLA_MOONBEAM: PendulumTokenDetails = { decimals: 18, erc20WrapperAddress: "6eRq1yvty6KorGcJ3nKpNYrCBn9FQnzsBhFn4JmAFqWUwpnh" }; + +// Stellar-backed EURC representative on Pendulum. +// Nabla pool (Pendulum EVM) for EURC: 0xE14e56f442C2d452E201214069aCB3cfD51Ad3F8 +export const PENDULUM_EURC_STELLAR: PendulumTokenDetails = { + assetSymbol: "EURC", + currency: FiatToken.EURC, + currencyId: { + Stellar: { + AlphaNum4: { + code: "0x45555243", + issuer: "0xcf4f5a26e2090bb3adcf02c7a9d73dbfe6659cc690461475b86437fa49c71136" + } + } + }, + decimals: 12, + erc20WrapperAddress: "6eNUvRWCKE3kejoyrJTXiSM7NxtWi37eRXTnKhGKPsJevAj5" +}; diff --git a/packages/shared/src/tokens/stellar/config.ts b/packages/shared/src/tokens/stellar/config.ts index c174e6726..5ddeb71f0 100644 --- a/packages/shared/src/tokens/stellar/config.ts +++ b/packages/shared/src/tokens/stellar/config.ts @@ -2,6 +2,7 @@ * Stellar token configuration */ +import { PENDULUM_EURC_STELLAR } from "../pendulum/config"; import { getTomlFileUrl } from "../tokenConfig"; import { FiatToken, TokenType } from "../types/base"; import { StellarTokenDetails } from "../types/stellar"; @@ -20,20 +21,7 @@ export const stellarTokenConfig: Partial> maxSellAmountRaw: "10000000000000000", minBuyAmountRaw: "1000000000000", minSellAmountRaw: "25000000000000", - pendulumRepresentative: { - assetSymbol: "EURC", - currency: FiatToken.EURC, - currencyId: { - Stellar: { - AlphaNum4: { - code: "0x45555243", - issuer: "0xcf4f5a26e2090bb3adcf02c7a9d73dbfe6659cc690461475b86437fa49c71136" - } - } - }, - decimals: 12, - erc20WrapperAddress: "6eNUvRWCKE3kejoyrJTXiSM7NxtWi37eRXTnKhGKPsJevAj5" - }, + pendulumRepresentative: PENDULUM_EURC_STELLAR, stellarAsset: { code: { hex: "0x45555243", @@ -86,10 +74,10 @@ export const stellarTokenConfig: Partial> hex: "0xb04f8bff207a0b001aec7b7659a8d106e54e659cdf9533528f468e079628fba1", stellarEncoding: "GCYE7C77EB5AWAA25R5XMWNI2EDOKTTFTTPZKM2SR5DI4B4WFD52DARS" } - }, // 11 ARS - supportsClientDomain: true, // 500000 ARS - tomlFileUrl: getTomlFileUrl("ARS"), // 2% - type: TokenType.Stellar, // 10 ARS + }, + supportsClientDomain: true, + tomlFileUrl: getTomlFileUrl("ARS"), + type: TokenType.Stellar, usesMemo: true, vaultAccountId: "6bE2vjpLRkRNoVDqDtzokxE34QdSJC2fz7c87R9yCVFFDNWs" } diff --git a/packages/shared/src/tokens/types/evm.ts b/packages/shared/src/tokens/types/evm.ts index e0f2fda4a..9218ca8b6 100644 --- a/packages/shared/src/tokens/types/evm.ts +++ b/packages/shared/src/tokens/types/evm.ts @@ -12,7 +12,8 @@ export enum EvmToken { USDT = "USDT", USDCE = "USDC.E", ETH = "ETH", - BRLA = "BRLA" + BRLA = "BRLA", + EURC = "EURC" } export enum UsdLikeEvmToken {