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();
+}