From 8c25f7eadc82c4389c843adca0bd13a6f3e5f4b8 Mon Sep 17 00:00:00 2001 From: franm Date: Wed, 28 Jan 2026 06:35:01 -0300 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20app:=20implement=20manteca=20ramp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/shiny-points-eat.md | 5 + src/app/(main)/add-funds/_layout.tsx | 4 + src/app/(main)/add-funds/kyc.tsx | 1 + src/app/(main)/add-funds/onboard.tsx | 1 + src/app/(main)/add-funds/ramp.tsx | 1 + src/app/(main)/add-funds/status.tsx | 1 + src/assets/images/ars-usdc.svg | 1 + src/assets/images/brl-usdc.svg | 1 + src/assets/images/denied.svg | 1 + src/assets/images/face-id.svg | 1 + src/assets/images/usd-usdc.svg | 1 + src/components/add-funds/AddFiatButton.tsx | 58 +++++++ src/components/add-funds/AddFunds.tsx | 70 ++++++-- src/components/add-funds/Kyc.tsx | 121 +++++++++++++ src/components/add-funds/Onboard.tsx | 142 ++++++++++++++++ src/components/add-funds/Ramp.tsx | 189 +++++++++++++++++++++ src/components/add-funds/Status.tsx | 71 ++++++++ src/components/home/Home.tsx | 2 +- src/components/home/HomeActions.tsx | 13 +- src/i18n/es.json | 22 ++- src/utils/currencies.ts | 15 ++ src/utils/persona.ts | 102 ++++++++++- src/utils/server.ts | 71 +++++++- 23 files changed, 860 insertions(+), 34 deletions(-) create mode 100644 .changeset/shiny-points-eat.md create mode 100644 src/app/(main)/add-funds/kyc.tsx create mode 100644 src/app/(main)/add-funds/onboard.tsx create mode 100644 src/app/(main)/add-funds/ramp.tsx create mode 100644 src/app/(main)/add-funds/status.tsx create mode 100644 src/assets/images/ars-usdc.svg create mode 100644 src/assets/images/brl-usdc.svg create mode 100644 src/assets/images/denied.svg create mode 100644 src/assets/images/face-id.svg create mode 100644 src/assets/images/usd-usdc.svg create mode 100644 src/components/add-funds/AddFiatButton.tsx create mode 100644 src/components/add-funds/Kyc.tsx create mode 100644 src/components/add-funds/Onboard.tsx create mode 100644 src/components/add-funds/Ramp.tsx create mode 100644 src/components/add-funds/Status.tsx create mode 100644 src/utils/currencies.ts diff --git a/.changeset/shiny-points-eat.md b/.changeset/shiny-points-eat.md new file mode 100644 index 000000000..af825caef --- /dev/null +++ b/.changeset/shiny-points-eat.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ implement manteca ramp diff --git a/src/app/(main)/add-funds/_layout.tsx b/src/app/(main)/add-funds/_layout.tsx index 89de7277d..75447834a 100644 --- a/src/app/(main)/add-funds/_layout.tsx +++ b/src/app/(main)/add-funds/_layout.tsx @@ -11,6 +11,10 @@ export default function AddFundsLayout() { + + + + ); } diff --git a/src/app/(main)/add-funds/kyc.tsx b/src/app/(main)/add-funds/kyc.tsx new file mode 100644 index 000000000..2248330e0 --- /dev/null +++ b/src/app/(main)/add-funds/kyc.tsx @@ -0,0 +1 @@ +export { default } from "../../../components/add-funds/Kyc"; diff --git a/src/app/(main)/add-funds/onboard.tsx b/src/app/(main)/add-funds/onboard.tsx new file mode 100644 index 000000000..30eaabcf0 --- /dev/null +++ b/src/app/(main)/add-funds/onboard.tsx @@ -0,0 +1 @@ +export { default } from "../../../components/add-funds/Onboard"; diff --git a/src/app/(main)/add-funds/ramp.tsx b/src/app/(main)/add-funds/ramp.tsx new file mode 100644 index 000000000..a1fc5bd59 --- /dev/null +++ b/src/app/(main)/add-funds/ramp.tsx @@ -0,0 +1 @@ +export { default } from "../../../components/add-funds/Ramp"; diff --git a/src/app/(main)/add-funds/status.tsx b/src/app/(main)/add-funds/status.tsx new file mode 100644 index 000000000..33b76b3ba --- /dev/null +++ b/src/app/(main)/add-funds/status.tsx @@ -0,0 +1 @@ +export { default } from "../../../components/add-funds/Status"; diff --git a/src/assets/images/ars-usdc.svg b/src/assets/images/ars-usdc.svg new file mode 100644 index 000000000..d6e27e47d --- /dev/null +++ b/src/assets/images/ars-usdc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/images/brl-usdc.svg b/src/assets/images/brl-usdc.svg new file mode 100644 index 000000000..01a568544 --- /dev/null +++ b/src/assets/images/brl-usdc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/images/denied.svg b/src/assets/images/denied.svg new file mode 100644 index 000000000..894f6ebce --- /dev/null +++ b/src/assets/images/denied.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/images/face-id.svg b/src/assets/images/face-id.svg new file mode 100644 index 000000000..b8536f065 --- /dev/null +++ b/src/assets/images/face-id.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/images/usd-usdc.svg b/src/assets/images/usd-usdc.svg new file mode 100644 index 000000000..f271b99d9 --- /dev/null +++ b/src/assets/images/usd-usdc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/add-funds/AddFiatButton.tsx b/src/components/add-funds/AddFiatButton.tsx new file mode 100644 index 000000000..a6b9103b0 --- /dev/null +++ b/src/components/add-funds/AddFiatButton.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; + +import { useRouter } from "expo-router"; + +import AddFundsOption from "./AddFundsOption"; +import { currencyMap } from "../../utils/currencies"; +import Text from "../shared/Text"; + +type AddFiatButtonProperties = { + currency: string; + status: "ACTIVE" | "NOT_AVAILABLE" | "NOT_STARTED" | "ONBOARDING"; +}; + +export default function AddFiatButton({ currency, status }: AddFiatButtonProperties) { + const { t } = useTranslation(); + const router = useRouter(); + + const emoji = currency in currencyMap ? currencyMap[currency as keyof typeof currencyMap].emoji : "💰"; + + if (status === "NOT_AVAILABLE") { + return null; + } + + function handlePress() { + switch (status) { + case "NOT_STARTED": + router.push({ + pathname: "/add-funds/onboard", + params: { currency }, + }); + break; + + case "ONBOARDING": + router.push({ + pathname: "/add-funds/status", + params: { status: "ONBOARDING", currency }, + }); + break; + + case "ACTIVE": + router.push({ + pathname: "/add-funds/ramp", + params: { currency }, + }); + break; + } + } + + return ( + {emoji}} + title={currency} + subtitle={t("From accounts in your name")} + onPress={handlePress} + /> + ); +} diff --git a/src/components/add-funds/AddFunds.tsx b/src/components/add-funds/AddFunds.tsx index 98e7d5d13..df4d8c7e4 100644 --- a/src/components/add-funds/AddFunds.tsx +++ b/src/components/add-funds/AddFunds.tsx @@ -13,11 +13,15 @@ import { isAddress } from "viem"; import chain from "@exactly/common/generated/chain"; import shortenHex from "@exactly/common/shortenHex"; +import AddFiatButton from "./AddFiatButton"; import AddFundsOption from "./AddFundsOption"; import { presentArticle } from "../../utils/intercom"; +import queryClient, { type AuthMethod } from "../../utils/queryClient"; import reportError from "../../utils/reportError"; +import { getKYCStatus, getRampProviders } from "../../utils/server"; import ChainLogo from "../shared/ChainLogo"; import SafeView from "../shared/SafeView"; +import Skeleton from "../shared/Skeleton"; import Text from "../shared/Text"; import View from "../shared/View"; @@ -28,6 +32,26 @@ export default function AddFunds() { const { t } = useTranslation(); const { data: credential } = useQuery({ queryKey: ["credential"] }); const ownerAccount = credential && isAddress(credential.credentialId) ? credential.credentialId : undefined; + + const { data: method } = useQuery({ queryKey: ["method"] }); + + const { data: countryCode } = useQuery({ + queryKey: ["user", "country"], + queryFn: async () => { + await getKYCStatus("basic", true); + return queryClient.getQueryData(["user", "country"]) ?? ""; + }, + staleTime: (query) => (query.state.data ? Infinity : 0), + retry: false, + }); + + const { data: providers, isPending } = useQuery({ + queryKey: ["ramp", "providers", countryCode], + queryFn: () => getRampProviders(countryCode), + enabled: !!countryCode, + staleTime: 0, + }); + return ( @@ -58,17 +82,19 @@ export default function AddFunds() { - } - title={t("From connected wallet")} - subtitle={ - // TODO add support for ens resolution - ownerAccount ? shortenHex(ownerAccount, 4, 6) : "" - } - onPress={() => { - router.push("/add-funds/bridge"); - }} - /> + {method === "siwe" && ( + } + title={t("From connected wallet")} + subtitle={ + // TODO add support for ens resolution + ownerAccount ? shortenHex(ownerAccount, 4, 6) : "" + } + onPress={() => { + router.push("/add-funds/bridge"); + }} + /> + )} } title={t("From another wallet")} @@ -77,6 +103,28 @@ export default function AddFunds() { router.push("/add-funds/add-crypto"); }} /> + + {countryCode && isPending ? ( + + + + ) : ( + providers && ( + + {Object.entries(providers.providers).flatMap(([providerKey, providerData]) => { + const currencies = providerData.onramp.currencies; + return currencies.map((currency) => ( + + )); + })} + + ) + )} + (); + const countryCode = queryClient.getQueryData(["user", "country"]) ?? ""; + const validCurrency = isValidCurrency(currency); + + const { mutateAsync: handleContinue, isPending } = useMutation({ + mutationKey: ["kyc", "complete", "manteca"], + mutationFn: async () => { + const result = await startMantecaKYC(); + if (result.status === "cancel") return; + if (result.status === "error") { + router.replace({ + pathname: "/add-funds/status", + params: { status: "error", currency }, + }); + return; + } + await completeOnboarding(); + }, + }); + + if (!validCurrency) return ; + + async function completeOnboarding() { + try { + await startRampOnboarding({ provider: "manteca" }); + + await queryClient.invalidateQueries({ queryKey: ["ramp", "providers"] }); + + const providers = await queryClient.fetchQuery({ + queryKey: ["ramp", "providers", countryCode], + queryFn: () => getRampProviders(countryCode), + staleTime: 0, + }); + const newStatus = providers.providers.manteca.status; + if (newStatus === "ACTIVE") { + router.replace({ pathname: "/add-funds/ramp", params: { currency } }); + } else if (newStatus === "ONBOARDING") { + router.replace({ pathname: "/add-funds/status", params: { status: newStatus, currency } }); + } else { + router.replace({ pathname: "/add-funds/status", params: { status: "error", currency } }); + } + } catch (error) { + reportError(error); + router.replace({ pathname: "/add-funds/status", params: { status: "error", currency } }); + } + } + + function handlePress() { + handleContinue().catch(reportError); + } + + return ( + + + + + { + if (router.canGoBack()) { + router.back(); + } else { + router.replace("/(main)/(home)"); + } + }} + > + + + + + + + + + + + + + + {t("We need more\ninformation about you")} + + + {t("You’ll be able to add funds in {{currency}} soon.", { currency })} + + + + + + + + + + ); +} diff --git a/src/components/add-funds/Onboard.tsx b/src/components/add-funds/Onboard.tsx new file mode 100644 index 000000000..b56737399 --- /dev/null +++ b/src/components/add-funds/Onboard.tsx @@ -0,0 +1,142 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Pressable } from "react-native"; + +import { Redirect, useLocalSearchParams, useRouter } from "expo-router"; + +import { ArrowLeft, ArrowRight } from "@tamagui/lucide-icons"; +import { ScrollView, Spinner, YStack } from "tamagui"; + +import { useMutation } from "@tanstack/react-query"; + +import ARS from "../../assets/images/ars-usdc.svg"; +import BRL from "../../assets/images/brl-usdc.svg"; +import USD from "../../assets/images/usd-usdc.svg"; +import { isValidCurrency } from "../../utils/currencies"; +import queryClient, { APIError } from "../../utils/queryClient"; +import reportError from "../../utils/reportError"; +import { getKYCStatus, getRampProviders, startRampOnboarding } from "../../utils/server"; +import SafeView from "../shared/SafeView"; +import Button from "../shared/StyledButton"; +import Text from "../shared/Text"; +import View from "../shared/View"; + +const currencyImages: Record> = { ARS, BRL, USD }; + +export default function Onboard() { + const { t } = useTranslation(); + const router = useRouter(); + + const { currency } = useLocalSearchParams<{ currency: string }>(); + const countryCode = queryClient.getQueryData(["user", "country"]) ?? ""; + const validCurrency = isValidCurrency(currency); + + const { mutateAsync: handleOnboarding, isPending } = useMutation({ + mutationKey: ["ramp", "onboarding", "manteca"], + mutationFn: async () => { + const status = await getKYCStatus("manteca").catch((error: unknown) => { + if (error instanceof APIError) return { code: error.text }; + throw error; + }); + const kycCode = "code" in status && typeof status.code === "string" ? status.code : "not started"; + + if (kycCode === "not started") { + router.push({ pathname: "/add-funds/kyc", params: { currency } }); + return; + } + + if (kycCode === "ok") { + await completeOnboarding(); + return; + } + + router.replace({ + pathname: "/add-funds/status", + params: { status: "error", currency }, + }); + }, + }); + + const CurrencyImage = currency ? (currencyImages[currency] ?? ARS) : ARS; + + if (!validCurrency) return ; + + async function completeOnboarding() { + try { + await startRampOnboarding({ provider: "manteca" }); + + await queryClient.invalidateQueries({ queryKey: ["ramp", "providers"] }); + + const providers = await queryClient.fetchQuery({ + queryKey: ["ramp", "providers", countryCode], + queryFn: () => getRampProviders(countryCode), + staleTime: 0, + }); + + const newStatus = providers.providers.manteca.status; + + if (newStatus === "ACTIVE") { + router.replace({ pathname: "/add-funds/ramp", params: { currency } }); + } else if (newStatus === "ONBOARDING") { + router.replace({ pathname: "/add-funds/status", params: { status: newStatus, currency } }); + } else { + router.replace({ pathname: "/add-funds/status", params: { status: "error", currency } }); + } + } catch (error) { + reportError(error); + router.replace({ + pathname: "/add-funds/status", + params: { status: "error", currency }, + }); + } + } + + function handleContinue() { + handleOnboarding().catch(reportError); + } + + return ( + + + + + { + if (router.canGoBack()) { + router.back(); + } else { + router.replace("/(main)/(home)"); + } + }} + > + + + + + + + + + + + + + + {t("Turn {{currency}} transfers to onchain USDC", { currency })} + + + {t("Transfer from accounts in your name and automatically receive USDC in your Exa account.")} + + + + + + + + + + ); +} diff --git a/src/components/add-funds/Ramp.tsx b/src/components/add-funds/Ramp.tsx new file mode 100644 index 000000000..b0786b4c8 --- /dev/null +++ b/src/components/add-funds/Ramp.tsx @@ -0,0 +1,189 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Pressable } from "react-native"; + +import { setStringAsync } from "expo-clipboard"; +import { Redirect, useLocalSearchParams, useRouter } from "expo-router"; + +import { ArrowLeft, CalendarDays, Copy, Info, Percent, Repeat, X } from "@tamagui/lucide-icons"; +import { useToastController } from "@tamagui/toast"; +import { ScrollView, Separator, XStack, YStack } from "tamagui"; + +import { useQuery } from "@tanstack/react-query"; + +import { isValidCurrency, type Currency } from "../../utils/currencies"; +import reportError from "../../utils/reportError"; +import { getRampQuote } from "../../utils/server"; +import SafeView from "../shared/SafeView"; +import Button from "../shared/StyledButton"; +import Text from "../shared/Text"; +import View from "../shared/View"; + +type DetailRowProperties = { + isLoading: boolean; + label: string; + onCopy: () => void; + value: string | undefined; +}; + +function DetailRow({ label, value, isLoading, onCopy }: DetailRowProperties) { + const { t } = useTranslation(); + + return ( + + + + {label} + + + {isLoading ? t("Loading...") : (value ?? "")} + + + + + + + ); +} + +export default function Ramp() { + const { t } = useTranslation(); + const router = useRouter(); + const toast = useToastController(); + const { currency } = useLocalSearchParams<{ currency: string }>(); + + const validCurrency = isValidCurrency(currency); + + const { data, isPending } = useQuery({ + queryKey: ["ramp", "quote", "manteca", currency], + queryFn: () => getRampQuote({ provider: "manteca", currency: currency as Currency }), + enabled: validCurrency, + refetchInterval: 30_000, + staleTime: 10_000, + }); + + if (!validCurrency) return ; + + const depositInfo = data?.depositInfo[0]; + const quote = data?.quote; + + const beneficiaryName = depositInfo && "beneficiaryName" in depositInfo ? depositInfo.beneficiaryName : undefined; + const depositAddress = + depositInfo?.network === "ARG_FIAT_TRANSFER" + ? depositInfo.cbu + : depositInfo?.network === "PIX" + ? depositInfo.pixKey + : undefined; + const depositAlias = depositInfo?.network === "ARG_FIAT_TRANSFER" ? depositInfo.depositAlias : undefined; + + function copyToClipboard(text: string) { + setStringAsync(text).catch(reportError); + toast.show(t("Copied!"), { native: true, duration: 1000, burntOptions: { haptic: "success" } }); + } + + function handleClose() { + router.replace("/(main)/(home)"); + } + + return ( + + + + + { + if (router.canGoBack()) { + router.back(); + } else { + router.replace("/(main)/(home)"); + } + }} + > + + + + {t("Details")} + + + + + + + + + + + + {t("Account details")} + + + {t("Copy and share your account details to turn {{currency}} transfers into USDC.", { currency })} + + + + beneficiaryName && copyToClipboard(beneficiaryName)} + /> + depositAddress && copyToClipboard(depositAddress)} + /> + {depositAlias && ( + copyToClipboard(depositAlias)} + /> + )} + + + + + + + + + + + {t("Delivery time")} + + + {depositInfo?.estimatedProcessingTime ?? t("1 business day")} + + + + + + {t("Exchange rate")} + + + {quote?.buyRate ? `${currency} ${quote.buyRate} ~ US$ 1` : t("Loading...")} + + + + + + {t("Fee")} + + + {depositInfo?.fee ?? t("Loading...")} + + + + + + + + ); +} diff --git a/src/components/add-funds/Status.tsx b/src/components/add-funds/Status.tsx new file mode 100644 index 000000000..29598b350 --- /dev/null +++ b/src/components/add-funds/Status.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; + +import { Redirect, useLocalSearchParams, useRouter } from "expo-router"; + +import { X } from "@tamagui/lucide-icons"; +import { ScrollView, YStack } from "tamagui"; + +import Denied from "../../assets/images/denied.svg"; +import FaceId from "../../assets/images/face-id.svg"; +import { isValidCurrency } from "../../utils/currencies"; +import SafeView from "../shared/SafeView"; +import Button from "../shared/StyledButton"; +import Text from "../shared/Text"; +import View from "../shared/View"; + +export default function Status() { + const { t } = useTranslation(); + const router = useRouter(); + + const parameters = useLocalSearchParams<{ + currency: string; + status: string; + }>(); + + const { currency, status } = parameters; + const validCurrency = isValidCurrency(currency); + const isOnboarding = status === "ONBOARDING"; + + if (!validCurrency) return ; + + function handleClose() { + router.replace("/(main)/(home)"); + } + + return ( + + + + + + + + {isOnboarding ? : } + + + + {isOnboarding ? t("Almost there!") : t("Verification failed")} + + + {isOnboarding + ? t("We're verifying your information. You'll be able to add funds in {{currency}} soon.", { + currency, + }) + : t("There was an error verifying your information.")} + + + + + + + + + + ); +} diff --git a/src/components/home/Home.tsx b/src/components/home/Home.tsx index b333a1502..7192d3f91 100644 --- a/src/components/home/Home.tsx +++ b/src/components/home/Home.tsx @@ -101,7 +101,7 @@ export default function Home() { data: KYCStatus, isFetched: isKYCFetched, refetch: refetchKYCStatus, - } = useQuery({ queryKey: ["kyc", "status"], queryFn: async () => getKYCStatus() }); + } = useQuery({ queryKey: ["kyc", "status"], queryFn: () => getKYCStatus("basic") }); const needsMigration = Boolean(KYCStatus && "code" in KYCStatus && KYCStatus.code === "legacy kyc"); const isKYCApproved = Boolean( KYCStatus && "code" in KYCStatus && (KYCStatus.code === "ok" || KYCStatus.code === "legacy kyc"), diff --git a/src/components/home/HomeActions.tsx b/src/components/home/HomeActions.tsx index 2eb76e9a3..91516efef 100644 --- a/src/components/home/HomeActions.tsx +++ b/src/components/home/HomeActions.tsx @@ -6,7 +6,6 @@ import { useRouter } from "expo-router"; import { ArrowDownToLine, ArrowUpRight } from "@tamagui/lucide-icons"; import { XStack, YStack } from "tamagui"; -import { useQuery } from "@tanstack/react-query"; import { zeroAddress } from "viem"; import { useBytecode, useReadContract } from "wagmi"; @@ -20,12 +19,9 @@ import reportError from "../../utils/reportError"; import useAccount from "../../utils/useAccount"; import Button from "../shared/StyledButton"; -import type { AuthMethod } from "../../utils/queryClient"; - export default function HomeActions() { const router = useRouter(); const { address: account } = useAccount(); - const { data: method } = useQuery({ queryKey: ["method"] }); const { data: bytecode } = useBytecode({ address: account ?? zeroAddress, query: { enabled: !!account } }); const { t } = useTranslation(); const actions = useMemo( @@ -89,14 +85,7 @@ export default function HomeActions() { onPress={() => { switch (key) { case "deposit": - switch (method) { - case "siwe": - router.push("/add-funds"); - break; - default: - router.push("/add-funds/add-crypto"); - break; - } + router.push("/add-funds"); break; case "send": handleSend().catch(reportError); diff --git a/src/i18n/es.json b/src/i18n/es.json index 842c6fb8c..08457c7f7 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -537,5 +537,25 @@ "To upgrade your Exa Card, we first need to verify your identity so you can continue spending your onchain assets seamlessly.": "Para actualizar tu Exa Card, primero necesitamos verificar tu identidad para que puedas seguir gastando tus activos on-chain sin interrupciones.", "The transaction hash has been copied to the clipboard.": "El hash de la transacción ha sido copiado al portapapeles.", "Track your spending and see how much you’ve spent with your Exa Card so far.": "Sigue tus gastos y descubre cuánto has gastado con tu Exa Card hasta ahora.", - "Update your Exa account to support our new card provider. This quick step ensures a smooth transition to your upgraded Exa Card.": "Actualiza tu cuenta Exa para admitir nuestro nuevo proveedor de tarjetas. Este paso rápido garantiza una transición sin problemas a tu nueva Exa Card." + "Update your Exa account to support our new card provider. This quick step ensures a smooth transition to your upgraded Exa Card.": "Actualiza tu cuenta Exa para admitir nuestro nuevo proveedor de tarjetas. Este paso rápido garantiza una transición sin problemas a tu nueva Exa Card.", + "Turn {{currency}} transfers to onchain USDC": "Convierte transferencias de {{currency}} a USDC on-chain", + "Transfer from accounts in your name and automatically receive USDC in your Exa account.": "Transfiere desde cuentas a tu nombre y recibe USDC automáticamente en tu cuenta Exa.", + "Starting...": "Iniciando...", + "Accept and continue": "Aceptar y continuar", + "Details": "Detalles", + "Account details": "Detalles de la cuenta", + "Copy and share your account details to turn {{currency}} transfers into USDC.": "Copia y comparte los detalles de tu cuenta para convertir transferencias de {{currency}} a USDC.", + "Beneficiary name": "Nombre del beneficiario", + "Account": "Cuenta", + "Deposit alias": "Alias de depósito", + "Delivery time": "Tiempo de entrega", + "1 business day": "1 día hábil", + "Fee": "Comisión", + "Almost there!": "¡Ya casi!", + "There was an error verifying your information.": "Hubo un error verificando tu información.", + "We're verifying your information. You'll be able to add funds in {{currency}} soon.": "Estamos verificando tu información. Pronto podrás agregar fondos en {{currency}}.", + "From accounts in your name": "Desde cuentas a tu nombre", + "We need more\ninformation about you": "Necesitamos más\ninformación sobre ti", + "You'll be able to add funds in {{currency}} soon.": "Pronto podrás agregar fondos en {{currency}}.", + "Continue verification": "Continuar verificación" } diff --git a/src/utils/currencies.ts b/src/utils/currencies.ts new file mode 100644 index 000000000..04bb96437 --- /dev/null +++ b/src/utils/currencies.ts @@ -0,0 +1,15 @@ +import { picklist, safeParse } from "valibot"; + +export const currencyMap = { + ARS: { name: "Argentinian Pesos", emoji: "🇦🇷" }, + BRL: { name: "Brazilian Real", emoji: "🇧🇷" }, + USD: { name: "US Dollars", emoji: "🇺🇸" }, +} as const; + +export type Currency = keyof typeof currencyMap; + +export const CurrencySchema = picklist(Object.keys(currencyMap) as [Currency, ...Currency[]]); + +export function isValidCurrency(value: unknown): value is Currency { + return safeParse(CurrencySchema, value).success; +} diff --git a/src/utils/persona.ts b/src/utils/persona.ts index 17439e97a..52faad45b 100644 --- a/src/utils/persona.ts +++ b/src/utils/persona.ts @@ -12,10 +12,16 @@ import reportError from "./reportError"; import { getKYCTokens } from "./server"; export const environment = (__DEV__ || process.env.EXPO_PUBLIC_ENV === "e2e" ? "sandbox" : "production") as Environment; -let current: undefined | { controller: AbortController; promise: Promise }; + +type MantecaKYCResult = { status: "cancel" } | { status: "complete" } | { status: "error" }; + +let current: + | undefined + | { controller: AbortController; promise: Promise; type: "manteca" } + | { controller: AbortController; promise: Promise; type: "basic" }; export function startKYC() { - if (current && !current.controller.signal.aborted) return current.promise; + if (current && !current.controller.signal.aborted && current.type === "basic") return current.promise; current?.controller.abort(new Error("persona inquiry aborted")); const controller = new AbortController(); @@ -100,7 +106,7 @@ export function startKYC() { if (current?.controller === controller) current = undefined; }); - current = { controller, promise }; + current = { type: "basic", controller, promise }; return promise; } @@ -108,6 +114,96 @@ export function cancelKYC() { current?.controller.abort(new Error("persona inquiry cancelled")); } +export function startMantecaKYC() { + if (current && !current.controller.signal.aborted && current.type === "manteca") return current.promise; + + current?.controller.abort(new Error("persona inquiry aborted")); + const controller = new AbortController(); + + const promise = (async () => { + const { signal } = controller; + + if (Platform.OS === "web") { + const onPageHide = () => controller.abort(new Error("page unloaded")); + globalThis.addEventListener("pagehide", onPageHide); + signal.addEventListener("abort", () => globalThis.removeEventListener("pagehide", onPageHide), { once: true }); + } + + if (Platform.OS === "web") { + const [{ Client }, { inquiryId, sessionToken }] = await Promise.all([ + import("persona"), + getKYCTokens("manteca", await getRedirectURI()), + ]); + if (signal.aborted) throw signal.reason; + + return new Promise((resolve, reject) => { + const onAbort = () => { + client.destroy(); + reject(new Error("persona inquiry aborted", { cause: signal.reason })); + }; + const client = new Client({ + inquiryId, + sessionToken, + environment: environment as "production" | "sandbox", + onReady: () => client.open(), + onComplete: () => { + signal.removeEventListener("abort", onAbort); + client.destroy(); + queryClient.invalidateQueries({ queryKey: ["kyc", "manteca"] }).catch(reportError); + resolve({ status: "complete" }); + }, + onCancel: () => { + signal.removeEventListener("abort", onAbort); + client.destroy(); + queryClient.invalidateQueries({ queryKey: ["kyc", "manteca"] }).catch(reportError); + resolve({ status: "cancel" }); + }, + onError: (error) => { + signal.removeEventListener("abort", onAbort); + client.destroy(); + reportError(error); + resolve({ status: "error" }); + }, + }); + signal.addEventListener("abort", onAbort, { once: true }); + }); + } + + const { inquiryId, sessionToken } = await getKYCTokens("manteca", await getRedirectURI()); + if (signal.aborted) throw signal.reason; + + const { Inquiry } = await import("react-native-persona"); + return new Promise((resolve, reject) => { + const onAbort = () => reject(new Error("persona inquiry aborted", { cause: signal.reason })); + signal.addEventListener("abort", onAbort, { once: true }); + Inquiry.fromInquiry(inquiryId) + .sessionToken(sessionToken) + .onCanceled(() => { + signal.removeEventListener("abort", onAbort); + queryClient.invalidateQueries({ queryKey: ["kyc", "manteca"] }).catch(reportError); + resolve({ status: "cancel" }); + }) + .onComplete(() => { + signal.removeEventListener("abort", onAbort); + queryClient.invalidateQueries({ queryKey: ["kyc", "manteca"] }).catch(reportError); + resolve({ status: "complete" }); + }) + .onError((error) => { + signal.removeEventListener("abort", onAbort); + reportError(error); + resolve({ status: "error" }); + }) + .build() + .start(); + }); + })().finally(() => { + if (current?.controller === controller) current = undefined; + }); + + current = { type: "manteca", controller, promise }; + return promise; +} + async function getRedirectURI() { if (Platform.OS === "web" && (await sdk.isInMiniApp())) { const { client } = await sdk.context; diff --git a/src/utils/server.ts b/src/utils/server.ts index a78ec51b0..2f3e9ef3d 100644 --- a/src/utils/server.ts +++ b/src/utils/server.ts @@ -4,7 +4,7 @@ import { get as assert, create } from "react-native-passkeys"; import { sdk } from "@farcaster/miniapp-sdk"; import { getConnection, signMessage } from "@wagmi/core"; import { hc } from "hono/client"; -import { check, number, parse, pipe, safeParse, ValiError } from "valibot"; +import { check, number, parse, pipe, safeParse, ValiError, type InferInput } from "valibot"; import AUTH_EXPIRY from "@exactly/common/AUTH_EXPIRY"; import deriveAddress from "@exactly/common/deriveAddress"; @@ -17,6 +17,7 @@ import queryClient, { APIError, type AuthMethod } from "./queryClient"; import ownerConfig from "./wagmi/owner"; import type { ExaAPI } from "@exactly/server/api"; // eslint-disable-line @nx/enforce-module-boundaries +import type { ProviderInfo as ProviderInfoSchema, RampProvider } from "@exactly/server/utils/ramps/shared"; // eslint-disable-line @nx/enforce-module-boundaries queryClient.setQueryDefaults(["auth"], { retry: false, @@ -148,7 +149,7 @@ export async function setCardPIN(pin: string) { if (!response.ok) throw new APIError(response.status, stringOrLegacy(await response.json())); } -export async function getKYCTokens(scope: "basic" = "basic", redirectURI?: string) { +export async function getKYCTokens(scope: "basic" | "manteca" = "basic", redirectURI?: string) { await auth(); const response = await api.kyc.$post({ json: { scope, redirectURI } }); if (!response.ok) { @@ -158,12 +159,31 @@ export async function getKYCTokens(scope: "basic" = "basic", redirectURI?: strin return response.json(); } -export async function getKYCStatus(scope: "basic" = "basic") { +export async function getKYCStatus(scope: "basic" | "manteca" = "basic", includeCountryCode?: boolean) { await auth(); - const response = await api.kyc.$get({ query: { scope } }); - queryClient.setQueryData(["user", "country"], response.headers.get("User-Country")); + const query = { scope, countryCode: includeCountryCode ? "true" : undefined }; + const response = await api.kyc.$get({ query }); + if (includeCountryCode) { + queryClient.setQueryData(["user", "country"], response.headers.get("User-Country")); + } if (!response.ok) { - const { code } = await response.json(); + const { code } = (await response.json()) as { code: string }; + throw new APIError(response.status, code); + } + return response.json(); +} + +export type RampProvider = (typeof RampProvider)[number]; + +export async function getMantecaKYCStatus() { + return getKYCStatus("manteca"); +} + +export async function createMantecaKYC(redirectURI?: string) { + await auth(); + const response = await api.kyc.$post({ json: { scope: "manteca", redirectURI } }); + if (!response.ok) { + const { code } = (await response.json()) as { code: string }; throw new APIError(response.status, code); } return response.json(); @@ -253,3 +273,42 @@ export async function getPaxId() { queryClient.setQueryDefaults(["pax", "id"], { queryFn: getPaxId }); export type PaxId = Awaited>; + +export async function getRampProviders(countryCode?: string, redirectURL?: string) { + await auth(); + const query = { countryCode, redirectURL }; + + const response = await api.ramp.$get({ query }); + if (!response.ok) { + const { code } = (await response.json()) as { code: string }; + throw new APIError(response.status, code); + } + return response.json(); +} +export type ProviderInfo = InferInput; + +export async function getRampQuote(query: NonNullable[0]>["query"]) { + await auth(); + const response = await api.ramp.quote.$get({ query }); + + if (!response.ok) { + const { code } = (await response.json()) as { code: string }; + throw new APIError(response.status, code); + } + + return response.json(); +} + +export async function startRampOnboarding(onboardingData: { provider: "manteca" }) { + await auth(); + const response = await api.ramp.$post({ + json: onboardingData, + }); + + if (!response.ok) { + const { code } = await response.json(); + throw new APIError(response.status, code); + } + + return response.json(); +}