diff --git a/app/api/aggregator.ts b/app/api/aggregator.ts
index 1c1b949b..c0f37230 100644
--- a/app/api/aggregator.ts
+++ b/app/api/aggregator.ts
@@ -722,14 +722,28 @@ export async function migrateLocalStorageRecipients(
// First, fetch existing recipients from DB to check for duplicates
const existingRecipients = await fetchSavedRecipients(accessToken);
const existingKeys = new Set(
- existingRecipients.map(
- (r) => `${r.institutionCode}-${r.accountIdentifier}`,
- ),
+ existingRecipients.map((r) => {
+ if (r.type === "wallet") {
+ if (!r.walletAddress) {
+ console.warn("Wallet recipient missing walletAddress", r);
+ return `wallet-invalid-${r.id}`;
+ }
+ return `wallet-${r.walletAddress}`;
+ } else {
+ if (!r.institutionCode || !r.accountIdentifier) {
+ console.warn("Bank/mobile_money recipient missing required fields", r);
+ return `${r.type}-invalid-${r.id}`;
+ }
+ return `${r.institutionCode}-${r.accountIdentifier}`;
+ }
+ }),
);
// Filter out duplicates - only migrate recipients that don't exist in DB
const recipientsToMigrate = recipients.filter((recipient) => {
- const key = `${recipient.institutionCode}-${recipient.accountIdentifier}`;
+ const key = recipient.type === "wallet"
+ ? `wallet-${recipient.walletAddress}`
+ : `${recipient.institutionCode}-${recipient.accountIdentifier}`;
return !existingKeys.has(key);
});
@@ -746,7 +760,10 @@ export async function migrateLocalStorageRecipients(
await saveRecipient(recipient, accessToken);
return { success: true, recipient };
} catch (error) {
- console.error(`Failed to migrate recipient ${recipient.name}:`, error);
+ const recipientName = recipient.type === "wallet"
+ ? recipient.walletAddress
+ : recipient.name;
+ console.error(`Failed to migrate recipient ${recipientName}:`, error);
return { success: false, recipient, error };
}
});
diff --git a/app/api/v1/recipients/route.ts b/app/api/v1/recipients/route.ts
index 3e11eb19..72fe71ee 100644
--- a/app/api/v1/recipients/route.ts
+++ b/app/api/v1/recipients/route.ts
@@ -7,6 +7,7 @@ import {
trackApiError,
trackBusinessEvent,
} from "@/app/lib/server-analytics";
+import { isValidEvmAddressCaseInsensitive } from "@/app/lib/validation";
import type {
RecipientDetailsWithId,
SavedRecipientsResponse,
@@ -42,20 +43,33 @@ export const GET = withRateLimit(async (request: NextRequest) => {
wallet_address: walletAddress,
});
- const { data: recipients, error } = await supabaseAdmin
+ // Fetch bank/mobile_money recipients
+ const { data: bankRecipients, error: bankError } = await supabaseAdmin
.from("saved_recipients")
.select("*")
.eq("normalized_wallet_address", walletAddress)
.order("created_at", { ascending: false });
- if (error) {
- console.error("Supabase query error:", error);
- throw error;
+ if (bankError) {
+ console.error("Supabase query error:", bankError);
+ throw bankError;
+ }
+
+ // Fetch wallet recipients
+ const { data: walletRecipients, error: walletError } = await supabaseAdmin
+ .from("saved_wallet_recipients")
+ .select("*")
+ .eq("normalized_wallet_address", walletAddress)
+ .order("created_at", { ascending: false });
+
+ if (walletError) {
+ console.error("Supabase query error:", walletError);
+ throw walletError;
}
- // Transform database format to frontend format
- const transformedRecipients: RecipientDetailsWithId[] =
- recipients?.map((recipient) => ({
+ // Transform bank/mobile_money recipients
+ const transformedBankRecipients: RecipientDetailsWithId[] =
+ bankRecipients?.map((recipient) => ({
id: recipient.id,
name: recipient.name,
institution: recipient.institution,
@@ -64,6 +78,21 @@ export const GET = withRateLimit(async (request: NextRequest) => {
type: recipient.type,
})) || [];
+ // Transform wallet recipients
+ const transformedWalletRecipients: RecipientDetailsWithId[] =
+ walletRecipients?.map((recipient) => ({
+ id: recipient.id,
+ type: "wallet" as const,
+ walletAddress: recipient.recipient_wallet_address,
+ name: recipient.name || "",
+ })) || [];
+
+ // Combine both types of recipients
+ const transformedRecipients: RecipientDetailsWithId[] = [
+ ...transformedBankRecipients,
+ ...transformedWalletRecipients,
+ ];
+
const response: SavedRecipientsResponse = {
success: true,
data: transformedRecipients,
@@ -124,9 +153,146 @@ export const POST = withRateLimit(async (request: NextRequest) => {
});
const body = await request.json();
- const { name, institution, institutionCode, accountIdentifier, type } =
+ const { name, institution, institutionCode, accountIdentifier, type, walletAddress: walletAddressFromBody } =
body;
+ // Handle wallet recipients (onramp)
+ if (type === "wallet") {
+ if (!walletAddressFromBody) {
+ trackApiError(
+ request,
+ "/api/v1/recipients",
+ "POST",
+ new Error("Missing required field: walletAddress"),
+ 400,
+ );
+ return NextResponse.json(
+ {
+ success: false,
+ error: "Missing required field: walletAddress",
+ },
+ { status: 400 },
+ );
+ }
+
+ // Validate wallet address format
+ if (!isValidEvmAddressCaseInsensitive(walletAddressFromBody.trim())) {
+ trackApiError(
+ request,
+ "/api/v1/recipients",
+ "POST",
+ new Error("Invalid wallet address format"),
+ 400,
+ );
+ return NextResponse.json(
+ {
+ success: false,
+ error: "Invalid wallet address format",
+ },
+ { status: 400 },
+ );
+ }
+
+ // Check recipient count limit (100 max per wallet)
+ const { count: recipientCount, error: countError } = await supabaseAdmin
+ .from("saved_wallet_recipients")
+ .select("*", { count: "exact", head: true })
+ .eq("normalized_wallet_address", walletAddress);
+
+ if (countError) {
+ console.error("Error checking recipient count:", countError);
+ throw countError;
+ }
+
+ // If at limit, remove the oldest recipient before adding new one
+ if (recipientCount && recipientCount >= 100) {
+ const { data: oldestRecipient } = await supabaseAdmin
+ .from("saved_wallet_recipients")
+ .select("id")
+ .eq("normalized_wallet_address", walletAddress)
+ .order("created_at", { ascending: true })
+ .limit(1)
+ .single();
+
+ if (oldestRecipient) {
+ await supabaseAdmin
+ .from("saved_wallet_recipients")
+ .delete()
+ .eq("id", oldestRecipient.id);
+ }
+ }
+
+ // Validate name for wallet recipients
+ if (!name || !name.trim()) {
+ trackApiError(
+ request,
+ "/api/v1/recipients",
+ "POST",
+ new Error("Missing required field: name"),
+ 400,
+ );
+ return NextResponse.json(
+ {
+ success: false,
+ error: "Missing required field: name",
+ },
+ { status: 400 },
+ );
+ }
+
+ // Insert wallet recipient into saved_wallet_recipients table
+ const { data, error } = await supabaseAdmin
+ .from("saved_wallet_recipients")
+ .upsert(
+ {
+ wallet_address: walletAddress,
+ normalized_wallet_address: walletAddress,
+ recipient_wallet_address: walletAddressFromBody.trim(),
+ normalized_recipient_wallet_address: walletAddressFromBody.toLowerCase().trim(),
+ name: name.trim(),
+ },
+ {
+ onConflict: "normalized_wallet_address,normalized_recipient_wallet_address",
+ },
+ )
+ .select()
+ .single();
+
+ if (error) {
+ console.error("Supabase insert error:", error);
+ throw error;
+ }
+
+ // Transform to frontend format
+ const transformedRecipient: RecipientDetailsWithId = {
+ id: data.id,
+ type: "wallet",
+ walletAddress: data.recipient_wallet_address,
+ name: data.name,
+ };
+
+ const response: SaveRecipientResponse = {
+ success: true,
+ data: transformedRecipient,
+ };
+
+ // Track successful API response
+ const responseTime = Date.now() - startTime;
+ trackApiResponse("/api/v1/recipients", "POST", 200, responseTime, {
+ wallet_address: walletAddress,
+ type: "wallet",
+ });
+
+ // Track business event
+ trackBusinessEvent("Recipient Saved", {
+ wallet_address: walletAddress,
+ type: "wallet",
+ });
+
+ return NextResponse.json(response, { status: 201 });
+ }
+
+ // Handle bank/mobile_money recipients (offramp)
// Validate request body
if (
!name ||
@@ -315,16 +481,44 @@ export const DELETE = withRateLimit(async (request: NextRequest) => {
recipient_id: recipientId,
});
- // Delete the recipient (RLS policies ensure only owner can delete)
- const { error: deleteError } = await supabaseAdmin
- .from("saved_recipients")
- .delete()
+ // Check which table the recipient is in by trying to find it first
+ const { data: walletRecipient, error: walletQueryError } = await supabaseAdmin
+ .from("saved_wallet_recipients")
+ .select("id")
.eq("id", recipientId)
- .eq("normalized_wallet_address", walletAddress);
+ .eq("normalized_wallet_address", walletAddress)
+ .maybeSingle();
+
+ // Handle query errors (except "no rows found" which is expected)
+ if (walletQueryError && walletQueryError.code !== "PGRST116") {
+ console.error("Error querying wallet recipient:", walletQueryError);
+ throw walletQueryError;
+ }
- if (deleteError) {
- console.error("Error deleting recipient:", deleteError);
- throw deleteError;
+ if (walletRecipient) {
+ // Delete from saved_wallet_recipients
+ const { error: walletDeleteError } = await supabaseAdmin
+ .from("saved_wallet_recipients")
+ .delete()
+ .eq("id", recipientId)
+ .eq("normalized_wallet_address", walletAddress);
+
+ if (walletDeleteError) {
+ console.error("Error deleting wallet recipient:", walletDeleteError);
+ throw walletDeleteError;
+ }
+ } else {
+ // Delete from saved_recipients (bank/mobile_money)
+ const { error: bankDeleteError } = await supabaseAdmin
+ .from("saved_recipients")
+ .delete()
+ .eq("id", recipientId)
+ .eq("normalized_wallet_address", walletAddress);
+
+ if (bankDeleteError) {
+ console.error("Error deleting recipient:", bankDeleteError);
+ throw bankDeleteError;
+ }
}
const response = {
diff --git a/app/components/MainPageContent.tsx b/app/components/MainPageContent.tsx
index 6949cdc4..1394c15f 100644
--- a/app/components/MainPageContent.tsx
+++ b/app/components/MainPageContent.tsx
@@ -10,6 +10,7 @@ import {
Preloader,
TransactionForm,
TransactionPreview,
+ MakePayment,
TransactionStatus,
NetworkSelectionModal,
CookieConsent,
@@ -68,7 +69,7 @@ const PageLayout = ({
const walletAddress = isInjectedWallet
? injectedAddress
: user?.linkedAccounts.find((account) => account.type === "smart_wallet")
- ?.address;
+ ?.address;
return (
<>
@@ -149,30 +150,30 @@ export function MainPageContent() {
// State props for child components
const stateProps: StateProps = {
- formValues,
- setFormValues,
-
- rate,
- setRate,
- isFetchingRate,
- setIsFetchingRate,
- rateError,
- setRateError,
-
- institutions,
- setInstitutions,
- isFetchingInstitutions,
- setIsFetchingInstitutions,
-
- selectedRecipient,
- setSelectedRecipient,
-
- orderId,
- setOrderId,
- setCreatedAt,
- setTransactionStatus,
- }
-
+ formValues,
+ setFormValues,
+
+ rate,
+ setRate,
+ isFetchingRate,
+ setIsFetchingRate,
+ rateError,
+ setRateError,
+
+ institutions,
+ setInstitutions,
+ isFetchingInstitutions,
+ setIsFetchingInstitutions,
+
+ selectedRecipient,
+ setSelectedRecipient,
+
+ orderId,
+ setOrderId,
+ setCreatedAt,
+ setTransactionStatus,
+ }
+
useEffect(function setPageLoadingState() {
setOrderId("");
setIsPageLoading(false);
@@ -391,6 +392,13 @@ export function MainPageContent() {
createdAt={createdAt}
/>
);
+ case STEPS.MAKE_PAYMENT:
+ return (
+
+ );
case STEPS.STATUS:
return (
void;
+ onSave: (name: string) => void;
+ walletAddress: string;
+ isSaving?: boolean;
+}
+
+export function AddBeneficiaryModal({
+ isOpen,
+ onClose,
+ onSave,
+ walletAddress,
+ isSaving = false,
+}: AddBeneficiaryModalProps) {
+ const [recipientName, setRecipientName] = useState("");
+
+ // Reset name when modal opens/closes
+ useEffect(() => {
+ if (!isOpen) {
+ setRecipientName("");
+ }
+ }, [isOpen]);
+
+ const handleSave = () => {
+ const trimmedName = recipientName.trim();
+ if (trimmedName) {
+ onSave(trimmedName);
+ }
+ };
+
+ const handleClose = () => {
+ setRecipientName("");
+ onClose();
+ };
+
+ return (
+
+
+
+
+ Add to beneficiary list
+
+
+
+
+ {/* Avatar - centered at top */}
+
+
+
+ setRecipientName(e.target.value)}
+ placeholder="Enter name"
+ autoFocus={isOpen}
+ className="w-full rounded-lg border-0 bg-transparent px-4 py-3 text-center text-sm text-neutral-900 placeholder-neutral-500 outline-none dark:text-white dark:placeholder-white/40"
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && recipientName.trim() && !isSaving) {
+ handleSave();
+ }
+ }}
+ />
+
+
+ {shortenAddress(walletAddress, 6, 4)}
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/components/recipient/RecipientDetailsForm.tsx b/app/components/recipient/RecipientDetailsForm.tsx
index 3ac117d7..1d74251e 100644
--- a/app/components/recipient/RecipientDetailsForm.tsx
+++ b/app/components/recipient/RecipientDetailsForm.tsx
@@ -3,6 +3,7 @@ import { ImSpinner } from "react-icons/im";
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useMemo, useRef, useState } from "react";
import { ArrowDown01Icon, Tick02Icon } from "hugeicons-react";
+import Image from "next/image";
import { AnimatedFeedbackItem } from "../AnimatedComponents";
import { InstitutionProps } from "@/app/types";
@@ -22,6 +23,10 @@ import {
} from "@/app/api/aggregator";
import { SavedBeneficiariesModal } from "@/app/components/recipient/SavedBeneficiariesModal";
import { SelectBankModal } from "@/app/components/recipient/SelectBankModal";
+import { isValidEvmAddressCaseInsensitive } from "@/app/lib/validation";
+import { getNetworkImageUrl } from "@/app/utils";
+import { useActualTheme } from "@/app/hooks/useActualTheme";
+import { useNetwork } from "@/app/context";
export const RecipientDetailsForm = ({
formMethods,
@@ -31,6 +36,9 @@ export const RecipientDetailsForm = ({
selectedRecipient,
setSelectedRecipient,
},
+ isSwapped = false,
+ token,
+ networkName,
}: RecipientDetailsFormProps) => {
const {
watch,
@@ -40,11 +48,13 @@ export const RecipientDetailsForm = ({
} = formMethods;
const { getAccessToken, ready, authenticated, user } = usePrivy();
+ const { selectedNetwork } = useNetwork();
const { currency } = watch();
const institution = watch("institution");
const accountIdentifier = watch("accountIdentifier");
const recipientName = watch("recipientName");
+ const walletAddress = watch("walletAddress");
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -78,6 +88,7 @@ export const RecipientDetailsForm = ({
const [isReturningFromPreview, setIsReturningFromPreview] = useState(false);
const prevCurrencyRef = useRef(currency);
+ const isDark = useActualTheme();
/**
* Array of institutions filtered and sorted alphabetically based on the bank search term.
@@ -109,21 +120,32 @@ export const RecipientDetailsForm = ({
const selectSavedRecipient = (recipient: RecipientDetails) => {
setSelectedRecipient(recipient);
- setSelectedInstitution({
- name: recipient.institution,
- code: recipient.institutionCode,
- type: recipient.type,
- });
- setValue("institution", recipient.institutionCode, { shouldDirty: true });
- setValue("accountIdentifier", recipient.accountIdentifier, {
- shouldDirty: true,
- });
- setValue("accountType", recipient.type, { shouldDirty: true });
- // Remove extra spaces from recipient name
- recipient.name = recipient.name.replace(/\s+/g, " ").trim();
- setValue("recipientName", recipient.name, { shouldDirty: true });
- setIsManualEntry(false);
+ if (recipient.type === "wallet") {
+ // Handle wallet address selection for onramp
+ setValue("walletAddress", recipient.walletAddress, {
+ shouldDirty: true,
+ shouldValidate: true,
+ });
+ } else {
+ // Handle bank/mobile money selection for offramp
+ setSelectedInstitution({
+ name: recipient.institution,
+ code: recipient.institutionCode,
+ type: recipient.type,
+ });
+ setValue("institution", recipient.institutionCode, { shouldDirty: true });
+ setValue("accountIdentifier", recipient.accountIdentifier, {
+ shouldDirty: true,
+ });
+ setValue("accountType", recipient.type, { shouldDirty: true });
+
+ // Remove extra spaces from recipient name
+ recipient.name = recipient.name.replace(/\s+/g, " ").trim();
+ setValue("recipientName", recipient.name, { shouldDirty: true });
+ setIsManualEntry(false);
+ }
+
setIsModalOpen(false);
};
@@ -134,8 +156,11 @@ export const RecipientDetailsForm = ({
// Find the recipient with ID from the saved recipients
const recipientWithId = savedRecipients.find(
(r) =>
- r.accountIdentifier === recipientToDeleteParam.accountIdentifier &&
- r.institutionCode === recipientToDeleteParam.institutionCode,
+ recipientToDeleteParam.type === "wallet"
+ ? r.type === "wallet" && r.walletAddress === recipientToDeleteParam.walletAddress
+ : r.type !== "wallet" &&
+ r.accountIdentifier === recipientToDeleteParam.accountIdentifier &&
+ r.institutionCode === recipientToDeleteParam.institutionCode,
);
if (!recipientWithId) {
@@ -163,13 +188,16 @@ export const RecipientDetailsForm = ({
setSavedRecipients(updatedRecipients);
- if (
- selectedRecipient?.accountIdentifier ===
- recipientToDeleteParam.accountIdentifier &&
- selectedRecipient?.institutionCode ===
- recipientToDeleteParam.institutionCode
- ) {
- setSelectedRecipient(null);
+ if (selectedRecipient) {
+ if (
+ recipientToDeleteParam.type === "wallet"
+ ? selectedRecipient.type === "wallet" && selectedRecipient.walletAddress === recipientToDeleteParam.walletAddress
+ : selectedRecipient.type !== "wallet" &&
+ selectedRecipient.accountIdentifier === recipientToDeleteParam.accountIdentifier &&
+ selectedRecipient.institutionCode === recipientToDeleteParam.institutionCode
+ ) {
+ setSelectedRecipient(null);
+ }
}
} else {
setRecipientsError("Failed to delete recipient");
@@ -210,15 +238,26 @@ export const RecipientDetailsForm = ({
try {
const accessToken = await getAccessToken();
if (!accessToken) {
- if (!isCancelled) setRecipientsError("Authentication required");
+ if (!isCancelled) {
+ setSavedRecipients([]);
+ setRecipientsError("Authentication required");
+ setIsLoadingRecipients(false);
+ }
return;
}
const recipients = await fetchSavedRecipients(accessToken);
- if (!isCancelled) setSavedRecipients(recipients);
+
+ if (!isCancelled) {
+ setSavedRecipients(recipients);
+ setRecipientsError(null);
+ }
} catch (error) {
console.error("Error loading recipients:", error);
- if (!isCancelled) setRecipientsError("Failed to load saved recipients");
+ if (!isCancelled) {
+ setSavedRecipients([]);
+ setRecipientsError("Failed to load saved recipients");
+ }
} finally {
if (!isCancelled) setIsLoadingRecipients(false);
}
@@ -253,7 +292,7 @@ export const RecipientDetailsForm = ({
!institution ||
!accountIdentifier ||
accountIdentifier.toString().length <
- (selectedInstitution?.code === "SAFAKEPC" ? 6 : 10)
+ (selectedInstitution?.code === "SAFAKEPC" ? 6 : 10)
)
return;
@@ -337,117 +376,195 @@ export const RecipientDetailsForm = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
+ // Format network name for display (e.g., "Binance Smartchain" from "bsc")
+ const formatNetworkName = (network?: string): string => {
+ if (!network) return "";
+ const networkMap: Record = {
+ "bsc": "Binance Smartchain",
+ "arbitrum-one": "Arbitrum One",
+ "polygon": "Polygon",
+ "base": "Base",
+ "ethereum": "Ethereum",
+ };
+ return networkMap[network.toLowerCase()] || network;
+ };
+
+ // Filter saved recipients by type
+ const walletRecipients = useMemo(
+ () => savedRecipients.filter((r) => r.type === "wallet"),
+ [savedRecipients],
+ );
+
+ const bankRecipients = useMemo(
+ () => savedRecipients.filter((r) => r.type !== "wallet"),
+ [savedRecipients],
+ );
+
return (
<>
Recipient
-
-
-
-
- {/* Bank */}
-
+ {/* Show Select beneficiary button when there are saved recipients */}
+ {((isSwapped && walletRecipients.length > 0) || (!isSwapped && bankRecipients.length > 0)) && (
-
+ )}
+
- {/* Account number */}
-
+ {isSwapped ? (
+ /* Wallet address input for onramp */
+
{
+ if (!value) return true;
+ if (!isValidEvmAddressCaseInsensitive(value)) {
+ return "Invalid wallet address format";
+ }
+ return true;
},
- onChange: () => setIsManualEntry(true),
})}
className={classNames(
"w-full rounded-xl border bg-transparent px-4 py-2.5 text-sm outline-none transition-all duration-300 placeholder:text-text-placeholder focus:outline-none dark:text-white/80 dark:placeholder:text-white/30",
- errors.accountIdentifier
+ errors.walletAddress
? "border-input-destructive focus:border-gray-400 dark:border-input-destructive"
: "border-border-input dark:border-white/20 dark:focus:border-white/40 dark:focus:ring-offset-neutral-900",
)}
/>
+ {errors.walletAddress && (
+
+ )}
+ {networkName && (
+
+
+
+
+
+ You are on {formatNetworkName(networkName)} network
+
+
+ )}
-
-
- {/* Account details feedback */}
-
- {isFetchingRecipientName ? (
-
-
-
- Verifying account name...
-
-
- ) : (
- <>
- {recipientName ? (
-
-
-
- {recipientName.toLowerCase()}
+ ) : (
+ /* Bank/Mobile Money fields for offramp */
+ <>
+
+ {/* Bank */}
+
+
+
+
+ {/* Account number */}
+
+ setIsManualEntry(true),
+ })}
+ className={classNames(
+ "w-full rounded-xl border bg-transparent px-4 py-2.5 text-sm outline-none transition-all duration-300 placeholder:text-text-placeholder focus:outline-none dark:text-white/80 dark:placeholder:text-white/30",
+ errors.accountIdentifier
+ ? "border-input-destructive focus:border-gray-400 dark:border-input-destructive"
+ : "border-border-input dark:border-white/20 dark:focus:border-white/40 dark:focus:ring-offset-neutral-900",
+ )}
+ />
+
+
+
+ {/* Account details feedback */}
+
+ {isFetchingRecipientName ? (
+
+
+
+ Verifying account name...
+
+
+ ) : (
+ <>
+ {recipientName ? (
+
+
+
+ {recipientName.toLowerCase()}
+
+
+
+
+
+ ) : recipientNameError ? (
+
+ ) : null}
+ >
+ )}
+
+ >
+ )}
setIsModalOpen(false)}
onSelectRecipient={selectSavedRecipient}
- savedRecipients={savedRecipients}
+ savedRecipients={isSwapped ? walletRecipients : bankRecipients}
onDeleteRecipient={deleteRecipient}
recipientToDelete={recipientToDelete}
currency={currency}
institutions={institutions}
isLoading={isLoadingRecipients}
error={recipientsError}
+ isSwapped={isSwapped}
+ networkName={networkName}
/>
>
);
diff --git a/app/components/recipient/RecipientListItem.tsx b/app/components/recipient/RecipientListItem.tsx
index 69919262..e0bb811a 100644
--- a/app/components/recipient/RecipientListItem.tsx
+++ b/app/components/recipient/RecipientListItem.tsx
@@ -1,73 +1,135 @@
+"use client";
+
import { Button } from "@headlessui/react";
import { RecipientListItemProps } from "@/app/components/recipient/types";
-import { classNames, getRandomColor } from "@/app/utils";
+import { classNames, getRandomColor, shortenAddress, resolveEnsNameOrShorten, getAvatarImage, isWalletRecipient } from "@/app/utils";
import { Delete01Icon } from "hugeicons-react";
import { ImSpinner } from "react-icons/im";
+import Image from "next/image";
+import { useEffect, useState } from "react";
export const RecipientListItem = ({
recipient,
onSelect,
onDelete,
isBeingDeleted,
-}: RecipientListItemProps) => (
- onSelect(recipient)}
- onKeyDown={(e) => {
- if (e.key === "Enter" || e.key === " ") {
- onSelect(recipient);
- }
- }}
- >
-
-
{
+ const [displayName, setDisplayName] = useState
("");
+ const [isResolvingEns, setIsResolvingEns] = useState(false);
+
+ // Use type predicate for better type narrowing
+ const walletRecipient = isWalletRecipient(recipient);
+ const avatarSrc = walletRecipient ? getAvatarImage(index) : null;
+
+ // Type guard: safely access walletAddress only for wallet recipients
+ const walletAddress = walletRecipient ? recipient.walletAddress : "";
+
+ // Resolve ENS name for wallet recipients (onramp)
+ useEffect(() => {
+ if (walletRecipient && walletAddress) {
+ setIsResolvingEns(true);
+ resolveEnsNameOrShorten(walletAddress)
+ .then((name) => {
+ setDisplayName(name);
+ setIsResolvingEns(false);
+ })
+ .catch(() => {
+ // Fallback to first 5 chars if resolution fails
+ setDisplayName(walletAddress.slice(2, 7));
+ setIsResolvingEns(false);
+ });
+ }
+ }, [recipient, walletAddress]);
+
+ return (
+ onSelect(recipient)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ onSelect(recipient);
+ }
+ }}
+ >
+
+ {!walletRecipient ? (
+ <>
+
+ {recipient.name
+ .split(" ")
+ .filter((name) => name)
+ .slice(0, 2)
+ .map((name) => name[0].toUpperCase())
+ .join("")}
+
+
+
+ {recipient.name.toLowerCase()}
+
+
+ {recipient.accountIdentifier}
+ •
+ {recipient.institution}
+
+
+ >
+ ) : (
+ <>
+ {avatarSrc && (
+
+
+
+ )}
+
+
+ {recipient.name || (isResolvingEns ? (
+ Resolving...
+ ) : (
+ displayName || shortenAddress(walletAddress, 6, 4)
+ ))}
+
+
+ {shortenAddress(walletAddress, 6, 4)}
+
+
+ >
)}
- >
- {recipient.name
- .split(" ")
- .filter((name) => name)
- .slice(0, 2)
- .map((name) => name[0].toUpperCase())
- .join("")}
-
-
- {recipient.name.toLowerCase()}
-
-
- {recipient.accountIdentifier}
- •
- {recipient.institution}
-
-
-
-
-);
+ }`}
+ disabled={isBeingDeleted}
+ >
+ {isBeingDeleted ? (
+
+ ) : (
+
+ )}
+
+
+ );
+};
\ No newline at end of file
diff --git a/app/components/recipient/SavedBeneficiariesModal.tsx b/app/components/recipient/SavedBeneficiariesModal.tsx
index 835582fe..183c4929 100644
--- a/app/components/recipient/SavedBeneficiariesModal.tsx
+++ b/app/components/recipient/SavedBeneficiariesModal.tsx
@@ -9,6 +9,7 @@ import { AnimatedModal } from "../AnimatedComponents";
import { RecipientListItem } from "./RecipientListItem";
import { SearchInput } from "./SearchInput";
import { SavedBeneficiariesModalProps } from "./types";
+import { isBankOrMobileMoneyRecipient } from "@/app/utils";
export const SavedBeneficiariesModal = ({
isOpen,
@@ -21,14 +22,43 @@ export const SavedBeneficiariesModal = ({
institutions,
isLoading = false,
error = null,
+ isSwapped = false,
+ networkName,
}: SavedBeneficiariesModalProps) => {
const [beneficiarySearchTerm, setBeneficiarySearchTerm] = useState("");
const filteredSavedRecipients = useMemo(() => {
- if (!currency) return [];
+ if (!currency && !isSwapped) return [];
const allRecipients = [...savedRecipients];
- const uniqueRecipients = allRecipients.filter(
+ if (isSwapped) {
+ const walletRecipients = allRecipients.filter((r) => r.type === "wallet");
+
+ const uniqueRecipients = walletRecipients.filter(
+ (recipient, index, self) =>
+ index ===
+ self.findIndex(
+ (r) => r.type === "wallet" && recipient.type === "wallet" && r.walletAddress === recipient.walletAddress,
+ ),
+ );
+
+ return uniqueRecipients.filter(
+ (recipient) => {
+ if (recipient.type === "wallet") {
+ const walletAddress = recipient.walletAddress.toLowerCase();
+ const searchTerm = beneficiarySearchTerm.toLowerCase();
+ return walletAddress.includes(searchTerm);
+ }
+ return false;
+ }
+ );
+ }
+
+ const bankRecipientsOnly = allRecipients.filter(
+ isBankOrMobileMoneyRecipient
+ );
+
+ const uniqueRecipients = bankRecipientsOnly.filter(
(recipient, index, self) =>
index ===
self.findIndex(
@@ -52,13 +82,13 @@ export const SavedBeneficiariesModal = ({
.filter((recipient) =>
currentBankCodes.includes(recipient.institutionCode),
);
- }, [savedRecipients, beneficiarySearchTerm, currency, institutions]);
+ }, [savedRecipients, beneficiarySearchTerm, currency, institutions, isSwapped]);
return (
- Saved beneficiaries
+ Saved beneficiaries{networkName ? ` (${networkName})` : ""}
@@ -84,7 +114,7 @@ export const SavedBeneficiariesModal = ({
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.2 }}
- className="mt-2 h-[21rem] overflow-y-auto sm:h-[14rem]"
+ className="mt-2 max-h-[21rem] overflow-y-auto sm:max-h-[14rem] [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
>
{isLoading ? (
@@ -115,7 +145,7 @@ export const SavedBeneficiariesModal = ({
) : filteredSavedRecipients.length > 0 ? (
filteredSavedRecipients.map((recipient, index) => (
))
diff --git a/app/components/recipient/types.ts b/app/components/recipient/types.ts
index 10537942..11173ea2 100644
--- a/app/components/recipient/types.ts
+++ b/app/components/recipient/types.ts
@@ -16,6 +16,7 @@ export interface RecipientListItemProps {
onSelect: (recipient: RecipientDetails) => void;
onDelete: (recipient: RecipientDetails) => void;
isBeingDeleted: boolean;
+ index?: number;
}
export interface SavedBeneficiariesModalProps {
@@ -29,6 +30,8 @@ export interface SavedBeneficiariesModalProps {
institutions: InstitutionProps[];
isLoading?: boolean;
error?: string | null;
+ isSwapped?: boolean; // For onramp mode
+ networkName?: string; // Network name for wallet recipients
}
export type SelectBankModalProps = {
diff --git a/app/hooks/useSwapButton.ts b/app/hooks/useSwapButton.ts
index 98be7fb7..e03be725 100644
--- a/app/hooks/useSwapButton.ts
+++ b/app/hooks/useSwapButton.ts
@@ -11,6 +11,7 @@ interface UseSwapButtonProps {
isUserVerified: boolean;
rate?: number | null;
tokenDecimals?: number;
+ isSwapped?: boolean; // true when in onramp mode (fiat in Send, token in Receive)
}
export function useSwapButton({
@@ -21,10 +22,11 @@ export function useSwapButton({
isUserVerified,
rate,
tokenDecimals = 18,
+ isSwapped = false,
}: UseSwapButtonProps) {
const { authenticated } = usePrivy();
const { isInjectedWallet } = useInjectedWallet();
- const { amountSent, currency, recipientName } = watch();
+ const { amountSent, currency, recipientName, walletAddress } = watch();
const isAmountValid = Number(amountSent) >= 0.5;
const isCurrencySelected = Boolean(currency);
@@ -37,7 +39,11 @@ export function useSwapButton({
);
const totalRequired = (Number(amountSent) || 0) + senderFeeAmount;
- const hasInsufficientBalance = totalRequired > balance;
+ // Skip balance check in onramp mode (isSwapped = true)
+ const hasInsufficientBalance = isSwapped ? false : totalRequired > balance;
+
+ // Check recipient based on mode: walletAddress for onramp, recipientName for offramp
+ const hasRecipient = isSwapped ? Boolean(walletAddress) : Boolean(recipientName);
const isEnabled = (() => {
if (!rate) return false;
@@ -64,7 +70,7 @@ export function useSwapButton({
if (!isDirty || !isValid || !isCurrencySelected || !isAmountValid) {
return false;
}
- return Boolean(recipientName);
+ return hasRecipient;
}
if (!isDirty || !isValid || !isCurrencySelected || !isAmountValid) {
@@ -75,7 +81,7 @@ export function useSwapButton({
return true; // Enable for login if amount and currency are valid
}
- return Boolean(recipientName); // Additional check for authenticated users
+ return hasRecipient; // Check walletAddress for onramp, recipientName for offramp
})();
const buttonText = (() => {
diff --git a/app/mocks.ts b/app/mocks.ts
index 00809cb9..f53de333 100644
--- a/app/mocks.ts
+++ b/app/mocks.ts
@@ -17,7 +17,7 @@ export const acceptedCurrencies = [
name: "TZS",
label: "Tanzanian Shilling (TZS)",
},
- {
+ {
name: "MWK",
label: "Malawian Kwacha (MWK)",
disabled: true,
diff --git a/app/pages/MakePayment.tsx b/app/pages/MakePayment.tsx
new file mode 100644
index 00000000..24c9e409
--- /dev/null
+++ b/app/pages/MakePayment.tsx
@@ -0,0 +1,323 @@
+"use client";
+import { useEffect, useState, useRef } from "react";
+import { Copy01Icon } from "hugeicons-react";
+import { PiCheck } from "react-icons/pi";
+import { ImSpinner } from "react-icons/im";
+import {
+ classNames,
+ formatCurrency,
+ formatNumberWithCommas,
+ copyToClipboard,
+ getCurrencySymbol,
+} from "../utils";
+import { primaryBtnClasses, secondaryBtnClasses } from "../components";
+import type { TransactionPreviewProps } from "../types";
+import { useStep, useNetwork } from "../context";
+import { fetchOrderDetails } from "../api/aggregator";
+
+interface PaymentAccountDetails {
+ provider: string;
+ accountNumber: string;
+ amount: number;
+ currency: string;
+ expiresAt: Date;
+}
+
+export const MakePayment = ({
+ stateProps,
+ handleBackButtonClick,
+}: {
+ stateProps: TransactionPreviewProps["stateProps"];
+ handleBackButtonClick: () => void;
+}) => {
+ const { formValues, rate, setTransactionStatus, setCreatedAt, orderId } = stateProps;
+ const { setCurrentStep } = useStep();
+ const { selectedNetwork } = useNetwork();
+ const { amountSent, currency, token, amountReceived } = formValues;
+
+ // Mock payment account details - will be replaced with actual API call
+ const [paymentDetails, setPaymentDetails] = useState({
+ provider: "(4Bay7 Enterprise)",
+ accountNumber: "952157815",
+ amount: amountSent || 29000,
+ currency: currency || "KES",
+ expiresAt: new Date(Date.now() + 30 * 60 * 1000), // 30 minutes from now
+ });
+
+ const [timeRemaining, setTimeRemaining] = useState("");
+ const [isPaymentSent, setIsPaymentSent] = useState(false);
+ const [isCheckingPayment, setIsCheckingPayment] = useState(false);
+ const [isAccountNumberCopied, setIsAccountNumberCopied] = useState(false);
+ const [isAmountCopied, setIsAmountCopied] = useState(false);
+ const pollingIntervalRef = useRef(null);
+ const pollingTimeoutRef = useRef(null);
+
+ // Calculate time remaining
+ useEffect(() => {
+ const updateTimer = () => {
+ const now = new Date();
+ const diff = paymentDetails.expiresAt.getTime() - now.getTime();
+
+ if (diff <= 0) {
+ setTimeRemaining("00:00");
+ return;
+ }
+
+ const minutes = Math.floor(diff / 60000);
+ const seconds = Math.floor((diff % 60000) / 1000);
+ setTimeRemaining(
+ `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`,
+ );
+ };
+
+ updateTimer();
+ const interval = setInterval(updateTimer, 1000);
+
+ return () => clearInterval(interval);
+ }, [paymentDetails.expiresAt]);
+
+ const handlePaymentSent = async () => {
+ setIsPaymentSent(true);
+ setIsCheckingPayment(true);
+
+ // Mark the transaction as pending while the aggregator indexes the deposit
+ setTransactionStatus("pending");
+ setCreatedAt(new Date().toISOString());
+
+ // If no orderId, navigate immediately (shouldn't happen, but safety check)
+ if (!orderId) {
+ setCurrentStep("status");
+ setIsCheckingPayment(false);
+ return;
+ }
+
+ // Poll backend to check if payment has been detected
+ const checkPaymentStatus = async () => {
+ try {
+ const orderDetailsResponse = await fetchOrderDetails(
+ selectedNetwork.chain.id,
+ orderId,
+ );
+
+ const status = orderDetailsResponse.data.status;
+
+ // If status is no longer "pending", payment has been detected
+ if (status !== "pending") {
+ // TODO: Validate amount - compare UI input with backend amount
+ // Once backend response structure is known, add validation here:
+ // - Extract fiat amount from orderDetailsResponse.data (check which field contains it)
+ // - Compare with amountSent from UI (Number(amountSent))
+ // Example structure (to be updated based on actual backend response):
+ // const backendAmount = /* extract from backend response */;
+ // const uiAmount = Number(amountSent) || 0;
+ // if (Math.abs(backendAmount - uiAmount) > tolerance) {
+ // // Handle mismatch
+ // }
+
+ // Clear polling
+ if (pollingIntervalRef.current) {
+ clearInterval(pollingIntervalRef.current);
+ pollingIntervalRef.current = null;
+ }
+ if (pollingTimeoutRef.current) {
+ clearTimeout(pollingTimeoutRef.current);
+ pollingTimeoutRef.current = null;
+ }
+
+ // Update status and navigate
+ setTransactionStatus(
+ status as
+ | "processing"
+ | "fulfilled"
+ | "validated"
+ | "settled"
+ | "refunded",
+ );
+ setIsCheckingPayment(false);
+ setCurrentStep("status");
+ }
+ } catch (error) {
+ console.error("Error checking payment status:", error);
+ // On error, still navigate to status page (it will handle polling there)
+ setIsCheckingPayment(false);
+ setCurrentStep("status");
+ }
+ };
+
+ // Start polling every 3 seconds
+ checkPaymentStatus(); // Check immediately
+ pollingIntervalRef.current = setInterval(checkPaymentStatus, 3000);
+
+ // Set a timeout to stop polling after 30 seconds and navigate anyway
+ pollingTimeoutRef.current = setTimeout(() => {
+ if (pollingIntervalRef.current) {
+ clearInterval(pollingIntervalRef.current);
+ pollingIntervalRef.current = null;
+ }
+ setIsCheckingPayment(false);
+ setCurrentStep("status");
+ }, 30000);
+ };
+
+ // Cleanup polling on unmount
+ useEffect(() => {
+ return () => {
+ if (pollingIntervalRef.current) {
+ clearInterval(pollingIntervalRef.current);
+ }
+ if (pollingTimeoutRef.current) {
+ clearTimeout(pollingTimeoutRef.current);
+ }
+ };
+ }, []);
+
+ const currencySymbol = getCurrencySymbol(paymentDetails.currency);
+ const formattedAmount = `${currencySymbol} ${formatNumberWithCommas(paymentDetails.amount)}`;
+
+ return (
+
+ {/* Header */}
+
+
+ Make payment
+
+
+ Use the payment details below to make payment
+
+
+
+ {/* Account Details Card */}
+
+
+
+ Account Details
+
+
+
+ Send to {formattedAmount} to the account below
+
+
+
+ {/* Fields Container */}
+
+ {/* Provider */}
+
+
+
+ Provider Bank {paymentDetails.provider}
+
+
+
+
+ {/* Account Number */}
+
+
+
+
+ {paymentDetails.accountNumber}
+
+
{
+ copyToClipboard(
+ paymentDetails.accountNumber,
+ "Account number",
+ );
+ setIsAccountNumberCopied(true);
+ setTimeout(() => setIsAccountNumberCopied(false), 2000);
+ }}
+ className="flex items-center justify-center rounded-lg p-1.5 transition-colors hover:bg-gray-100 dark:hover:bg-white/10"
+ title="Copy account number"
+ >
+ {isAccountNumberCopied ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {/* Amount */}
+
+
+
+
+ {formattedAmount}
+
+
{
+ copyToClipboard(
+ paymentDetails.amount.toString(),
+ "Amount",
+ );
+ setIsAmountCopied(true);
+ setTimeout(() => setIsAmountCopied(false), 2000);
+ }}
+ className="flex items-center justify-center rounded-lg p-1.5 transition-colors hover:bg-gray-100 dark:hover:bg-white/10"
+ title="Copy amount"
+ >
+ {isAmountCopied ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {/* Expiry Timer */}
+
+
+ This account is only for this payment. Expires in
+
+
+ {timeRemaining}
+
+
+
+
+ {/* Action Buttons */}
+
+
+ Awaiting payment
+
+
+ {isPaymentSent ? (
+
+
+ {isCheckingPayment ? "Checking payment..." : "Processing..."}
+
+ ) : (
+ "I have sent the money"
+ )}
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/app/pages/TransactionForm.tsx b/app/pages/TransactionForm.tsx
index 2f7117a0..58328d44 100644
--- a/app/pages/TransactionForm.tsx
+++ b/app/pages/TransactionForm.tsx
@@ -26,8 +26,10 @@ import {
formatDecimalPrecision,
currencyToCountryCode,
reorderCurrenciesByLocation,
+ fetchUserCountryCode,
+ mapCountryToCurrency,
} from "../utils";
-import { ArrowDown02Icon, NoteEditIcon, Wallet01Icon } from "hugeicons-react";
+import { ArrowUpDownIcon, NoteEditIcon, Wallet01Icon } from "hugeicons-react";
import { useSwapButton } from "../hooks/useSwapButton";
import { fetchKYCStatus } from "../api/aggregator";
import { useCNGNRate } from "../hooks/useCNGNRate";
@@ -78,7 +80,9 @@ export const TransactionForm = ({
const [formattedSentAmount, setFormattedSentAmount] = useState("");
const [formattedReceivedAmount, setFormattedReceivedAmount] = useState("");
const isFirstRender = useRef(true);
+ const hasRestoredStateRef = useRef(false);
const [rateError, setRateError] = useState(null);
+ const [isSwapped, setIsSwapped] = useState(false); // Track if swap mode is active (onramp)
const currencies = useMemo(
() =>
@@ -101,7 +105,7 @@ export const TransactionForm = ({
setValue,
formState: { errors, isValid, isDirty },
} = formMethods;
- const { amountSent, amountReceived, token, currency } = watch();
+ const { amountSent, amountReceived, token, currency, walletAddress } = watch();
// Custom hook for CNGN rate fetching (used for validation limits when token is cNGN)
const { rate: cngnRate, error: cngnRateError } = useCNGNRate({
@@ -315,19 +319,49 @@ export const TransactionForm = ({
useEffect(
function calculateReceiveAmount() {
if (rate && (amountSent || amountReceived)) {
+ // Rate format: currency per token (e.g., 1400 NGN per 1 USDC)
+ // When NOT swapped (offramp): Send = Token, Receive = Currency
+ // Formula: Receive = Send * Rate (1 USDC * 1400 = 1400 NGN)
+ // When swapped (onramp): Send = Currency, Receive = Token
+ // Formula: Receive = Send / Rate (463,284 NGN / 1400 = 330.917 USDC)
+
if (isReceiveInputActive) {
- const calculatedAmount = Number(
- (Number(amountReceived) / rate).toFixed(4),
- );
- setValue("amountSent", calculatedAmount, { shouldDirty: true });
+ // User is typing in Receive field
+ if (isSwapped) {
+ // Swapped: Receive = Token, so calculate Send (Currency)
+ // Send = Receive * Rate (20.4 USDC * 1400 = 28,560 NGN)
+ const calculatedAmount = Number(
+ (Number(amountReceived) * rate).toFixed(2),
+ );
+ setValue("amountSent", calculatedAmount, { shouldDirty: true });
+ } else {
+ // Not swapped: Receive = Currency, so calculate Send (Token)
+ // Send = Receive / Rate (1400 NGN / 1400 = 1 USDC)
+ const calculatedAmount = Number(
+ (Number(amountReceived) / rate).toFixed(4),
+ );
+ setValue("amountSent", calculatedAmount, { shouldDirty: true });
+ }
} else {
- const calculatedAmount = Number((rate * amountSent).toFixed(2));
- setValue("amountReceived", calculatedAmount, { shouldDirty: true });
+ // User is typing in Send field
+ if (isSwapped) {
+ // Swapped: Send = Currency, so calculate Receive (Token)
+ // Receive = Send / Rate (463,284 NGN / 1400 = 330.917 USDC)
+ const calculatedAmount = Number(
+ (Number(amountSent) / rate).toFixed(4),
+ );
+ setValue("amountReceived", calculatedAmount, { shouldDirty: true });
+ } else {
+ // Not swapped: Send = Token, so calculate Receive (Currency)
+ // Receive = Send * Rate (1 USDC * 1400 = 1400 NGN)
+ const calculatedAmount = Number((rate * amountSent).toFixed(2));
+ setValue("amountReceived", calculatedAmount, { shouldDirty: true });
+ }
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
- [amountSent, amountReceived, rate],
+ [amountSent, amountReceived, rate, isSwapped],
);
// Register form fields
@@ -440,6 +474,8 @@ export const TransactionForm = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currencies]);
+
+
const { isEnabled, buttonText, buttonAction } = useSwapButton({
watch,
balance,
@@ -448,6 +484,7 @@ export const TransactionForm = ({
isUserVerified,
rate,
tokenDecimals,
+ isSwapped,
});
const handleSwap = () => {
@@ -455,6 +492,88 @@ export const TransactionForm = ({
handleSubmit(onSubmit)();
};
+ useEffect(() => {
+ // Only run once to restore state
+ if (hasRestoredStateRef.current) {
+ return;
+ }
+
+ const shouldBeSwapped = Boolean(walletAddress);
+ if (shouldBeSwapped !== isSwapped) {
+ setIsSwapped(shouldBeSwapped);
+ hasRestoredStateRef.current = true;
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // Handle swap button click to switch between token/currency dropdowns
+ const handleSwapFields = async () => {
+ const currentAmountSent = amountSent;
+ const currentAmountReceived = amountReceived;
+ const willBeSwapped = !isSwapped;
+
+ // Toggle swap mode FIRST
+ setIsSwapped(willBeSwapped);
+
+ // Swap amounts
+ setValue("amountSent", currentAmountReceived || 0, { shouldDirty: true });
+ setValue("amountReceived", currentAmountSent || 0, { shouldDirty: true });
+
+ // Swap formatted amounts
+ setFormattedSentAmount(formattedReceivedAmount);
+ setFormattedReceivedAmount(formattedSentAmount);
+
+ // Set defaults only if not already selected
+ if (willBeSwapped) {
+ // Switching to onramp mode: Set currency based on user location
+ if (!currency) {
+ try {
+ const countryCode = await fetchUserCountryCode();
+ const locationCurrency = countryCode
+ ? mapCountryToCurrency(countryCode)
+ : null;
+
+ // Use location-based currency if available and supported, otherwise default to NGN
+ const defaultCurrency = locationCurrency &&
+ currencies.find(c => c.name === locationCurrency && !c.disabled)
+ ? locationCurrency
+ : "NGN";
+
+ setValue("currency", defaultCurrency, { shouldDirty: true });
+ } catch {
+ // Fallback to NGN if location detection fails
+ setValue("currency", "NGN", { shouldDirty: true });
+ }
+ }
+ // Only set default token if not already selected
+ if (!token && fetchedTokens.length > 0) {
+ const usdcToken = fetchedTokens.find((t) => t.symbol === "USDC");
+ const defaultToken = usdcToken?.symbol || fetchedTokens[0]?.symbol;
+ if (defaultToken) {
+ setValue("token", defaultToken, { shouldDirty: true });
+ }
+ }
+ // Clear walletAddress when switching to onramp mode
+ if (!walletAddress) {
+ setValue("walletAddress", "", { shouldDirty: true });
+ }
+ } else {
+ // Switching back to offramp mode
+ if (!token && fetchedTokens.length > 0) {
+ const usdcToken = fetchedTokens.find((t) => t.symbol === "USDC");
+ const defaultToken = usdcToken?.symbol || fetchedTokens[0]?.symbol;
+ if (defaultToken) {
+ setValue("token", defaultToken, { shouldDirty: true });
+ }
+ }
+ // Clear walletAddress when switching to offramp mode
+ setValue("walletAddress", "", { shouldDirty: true });
+ }
+
+ // Reset rate to trigger recalculation
+ stateProps.setRate(0);
+ };
+
// Handle sent amount input changes
interface SentAmountChangeEvent extends React.ChangeEvent {
target: HTMLInputElement;
@@ -582,6 +701,7 @@ export const TransactionForm = ({
@@ -601,7 +722,7 @@ export const TransactionForm = ({
Send
- {token && activeBalance && (
+ {token && activeBalance && !isSwapped && (
balance || errors.amountSent)
- ? "text-red-500 dark:text-red-500"
- : "text-neutral-900 dark:text-white/80"
- }`}
+ className={`w-full rounded-xl border-b border-transparent bg-transparent py-2 text-2xl outline-none transition-all placeholder:text-gray-400 focus:outline-none disabled:cursor-not-allowed dark:placeholder:text-white/30 ${authenticated && !isSwapped && (amountSent > balance || errors.amountSent)
+ ? "text-red-500 dark:text-red-500"
+ : "text-neutral-900 dark:text-white/80"
+ }`}
placeholder="0"
title="Enter amount to send"
/>
-
- setValue("token", selectedToken, { shouldDirty: true })
- }
- className="min-w-32"
- dropdownWidth={160}
- />
+ {isSwapped ? (
+
+ setValue("currency", selectedCurrency, { shouldDirty: true })
+ }
+ className="min-w-80"
+ dropdownWidth={320}
+ isCTA={!currency}
+ />
+ ) : (
+
+ setValue("token", selectedToken, { shouldDirty: true })
+ }
+ className="min-w-32"
+ dropdownWidth={160}
+ />
+ )}
{(errors.amountSent ||
- (authenticated && totalRequired > balance)) && (
-
- {errors.amountSent?.message ||
- (authenticated && totalRequired > balance
- ? `Insufficient balance${senderFeeAmount > 0 ? ` (includes ${formatNumberWithCommas(senderFeeAmount)} ${token} fee)` : ""}`
- : null)}
-
- )}
+ (authenticated && !isSwapped && totalRequired > balance)) && (
+
+ {errors.amountSent?.message ||
+ (authenticated && !isSwapped && totalRequired > balance
+ ? `Insufficient balance${senderFeeAmount > 0 ? ` (includes ${formatNumberWithCommas(senderFeeAmount)} ${token} fee)` : ""}`
+ : null)}
+
+ )}
{/* Arrow showing swap direction */}
-
+
{isFetchingRate ? (
) : (
-
+
)}
-
+
{/* Amount to receive & currency */}
@@ -730,14 +868,6 @@ export const TransactionForm = ({
type="text"
inputMode="decimal"
onChange={handleReceivedAmountChange}
- onFocus={() => {
- if (
- formattedReceivedAmount === "0" ||
- formattedReceivedAmount === "0.00"
- ) {
- setFormattedReceivedAmount("");
- }
- }}
onKeyDown={(e) => {
// Special handling for the decimal point key
if (e.key === "." && !formattedReceivedAmount.includes(".")) {
@@ -756,30 +886,47 @@ export const TransactionForm = ({
}
}}
value={formattedReceivedAmount}
- className={`w-full rounded-xl border-b border-transparent bg-transparent py-2 text-2xl outline-none transition-all placeholder:text-gray-400 focus:outline-none disabled:cursor-not-allowed dark:placeholder:text-white/30 ${
- errors.amountReceived
- ? "text-red-500 dark:text-red-500"
- : "text-neutral-900 dark:text-white/80"
- }`}
+ className={`w-full rounded-xl border-b border-transparent bg-transparent py-2 text-2xl outline-none transition-all placeholder:text-gray-400 focus:outline-none disabled:cursor-not-allowed dark:placeholder:text-white/30 ${errors.amountReceived
+ ? "text-red-500 dark:text-red-500"
+ : "text-neutral-900 dark:text-white/80"
+ }`}
placeholder="0"
title="Enter amount to receive"
/>
-
- setValue("currency", selectedCurrency, { shouldDirty: true })
- }
- className="min-w-80"
- isCTA={
- !currency &&
- (!authenticated ||
- (authenticated && !(totalRequired > balance)))
- }
- dropdownWidth={320}
- />
+ {isSwapped ? (
+ balance)))
+ }
+ onSelect={(selectedToken) =>
+ setValue("token", selectedToken, { shouldDirty: true })
+ }
+ className="min-w-32"
+ dropdownWidth={160}
+ />
+ ) : (
+
+ setValue("currency", selectedCurrency, { shouldDirty: true })
+ }
+ className="min-w-80"
+ isCTA={
+ !currency &&
+ (!authenticated ||
+ (authenticated && !(totalRequired > balance)))
+ }
+ dropdownWidth={320}
+ />
+ )}
@@ -797,27 +944,31 @@ export const TransactionForm = ({
- {/* Memo */}
-
+ )}
)}
@@ -884,11 +1035,22 @@ export const TransactionForm = ({
<>No available quote>
) : rate > 0 ? (
<>
- 1 {token} ~{" "}
- {isFetchingRate
- ? "..."
- : formatNumberWithCommasForDisplay(rate)}{" "}
- {currency}
+ {isSwapped ? (
+ <>
+ {isFetchingRate
+ ? "..."
+ : formatNumberWithCommasForDisplay(rate)}{" "}
+ {currency} ~ 1 {token}
+ >
+ ) : (
+ <>
+ 1 {token} ~{" "}
+ {isFetchingRate
+ ? "..."
+ : formatNumberWithCommasForDisplay(rate)}{" "}
+ {currency}
+ >
+ )}
>
) : null}
diff --git a/app/pages/TransactionPreview.tsx b/app/pages/TransactionPreview.tsx
index 19cadd77..ac3bbfab 100644
--- a/app/pages/TransactionPreview.tsx
+++ b/app/pages/TransactionPreview.tsx
@@ -10,11 +10,13 @@ import {
classNames,
formatCurrency,
formatNumberWithCommas,
+ getCurrencySymbol,
getGatewayContractAddress,
getInstitutionNameByCode,
getNetworkImageUrl,
getRpcUrl,
publicKeyEncrypt,
+ shortenAddress,
} from "../utils";
import { useNetwork, useTokens } from "../context";
import type {
@@ -88,8 +90,12 @@ export const TransactionPreview = ({
recipientName,
accountIdentifier,
memo,
+ walletAddress,
} = formValues;
+ // Detect onramp mode: if walletAddress exists, it's an onramp transaction
+ const isOnramp = !!walletAddress;
+
const [errorMessage, setErrorMessage] = useState("");
const [errorCount, setErrorCount] = useState(0); // Used to trigger toast
const [isConfirming, setIsConfirming] = useState(false);
@@ -137,36 +143,60 @@ export const TransactionPreview = ({
feeRecipient: senderFeeRecipientAddress,
} = calculateSenderFee(amountSent, rate, tokenDecimals ?? 18);
- // Rendered tsx info
- const renderedInfo = {
- amount: `${formatNumberWithCommas(amountSent ?? 0)} ${token}`,
- totalValue: `${formatCurrency(amountReceived ?? 0, currency, `en-${currency.slice(0, 2)}`)}`,
- recipient: recipientName
- .toLowerCase()
- .split(" ")
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
- .join(" "),
- account: `${accountIdentifier} • ${getInstitutionNameByCode(institution, supportedInstitutions)}`,
- ...(memo && { description: memo }),
- ...(senderFeeAmount > 0 && {
- fee: `${formatNumberWithCommas(senderFeeAmount)} ${token}`,
- }),
- network: selectedNetwork.chain.name,
- };
+ // Rendered tsx info - different for onramp vs offramp
+ const currencySymbol = getCurrencySymbol(currency);
+ const renderedInfo = isOnramp
+ ? {
+ // For onramp: You send fiat currency, receive token
+ amount: `${currencySymbol} ${formatNumberWithCommas(amountSent ?? 0)}`,
+ totalValue: `${formatNumberWithCommas(amountReceived ?? 0)} ${token}`,
+ rate: `${formatNumberWithCommas(rate)} ${currencySymbol} ~ 1 ${token}`,
+ network: selectedNetwork.chain.name,
+ recipient: walletAddress ? shortenAddress(walletAddress, 6, 4) : "",
+ ...(senderFeeAmount > 0 && {
+ fee: `${formatNumberWithCommas(senderFeeAmount)} ${token}`,
+ }),
+ }
+ : {
+ // For offramp: You send token, receive fiat currency
+ amount: `${formatNumberWithCommas(amountSent ?? 0)} ${token}`,
+ totalValue: `${formatCurrency(amountReceived ?? 0, currency, `en-${currency.slice(0, 2)}`)}`,
+ recipient: recipientName
+ .toLowerCase()
+ .split(" ")
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
+ .join(" "),
+ account: `${accountIdentifier} • ${getInstitutionNameByCode(institution, supportedInstitutions)}`,
+ ...(memo && { description: memo }),
+ ...(senderFeeAmount > 0 && {
+ fee: `${formatNumberWithCommas(senderFeeAmount)} ${token}`,
+ }),
+ network: selectedNetwork.chain.name,
+ };
const prepareCreateOrderParams = async () => {
const providerId =
searchParams.get("provider") || searchParams.get("PROVIDER");
- // Prepare recipient data
- const recipient = {
- accountIdentifier: formValues.accountIdentifier,
- accountName: recipientName,
- institution: formValues.institution,
- memo: formValues.memo,
- ...(providerId && { providerId }),
- nonce: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`,
- };
+ // Prepare recipient data - different for onramp vs offramp
+ const recipient = isOnramp
+ ? {
+ // For onramp: wallet address is the recipient
+ accountIdentifier: walletAddress || "",
+ accountName: recipientName || walletAddress || "",
+ institution: "Wallet",
+ ...(providerId && { providerId }),
+ nonce: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`,
+ }
+ : {
+ // For offramp: bank/mobile money details
+ accountIdentifier: formValues.accountIdentifier,
+ accountName: recipientName,
+ institution: formValues.institution,
+ memo: formValues.memo,
+ ...(providerId && { providerId }),
+ nonce: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`,
+ };
// Fetch aggregator public key
const publicKey = await fetchAggregatorPublicKey();
@@ -361,6 +391,12 @@ export const TransactionPreview = ({
};
const handlePaymentConfirmation = async () => {
+ // For onramp, navigate to Make Payment screen instead of creating order immediately
+ if (isOnramp) {
+ setCurrentStep("make_payment");
+ return;
+ }
+
// Check balance including sender fee
const totalRequired = amountSent + senderFeeAmount;
if (totalRequired > balance) {
@@ -399,21 +435,27 @@ export const TransactionPreview = ({
const transaction: TransactionCreateInput = {
walletAddress: embeddedWallet.address,
- transactionType: "swap",
- fromCurrency: token,
- toCurrency: currency,
+ transactionType: isOnramp ? "onramp" : "swap",
+ fromCurrency: isOnramp ? currency : token,
+ toCurrency: isOnramp ? token : currency,
amountSent: Number(amountSent),
amountReceived: Number(amountReceived),
fee: Number(rate),
- recipient: {
- account_name: recipientName,
- institution: getInstitutionNameByCode(
- institution,
- supportedInstitutions,
- ) as string,
- account_identifier: accountIdentifier,
- ...(memo && { memo }),
- },
+ recipient: isOnramp
+ ? {
+ account_name: recipientName || walletAddress || "",
+ institution: "Wallet",
+ account_identifier: walletAddress || "",
+ }
+ : {
+ account_name: recipientName,
+ institution: getInstitutionNameByCode(
+ institution,
+ supportedInstitutions,
+ ) as string,
+ account_identifier: accountIdentifier,
+ ...(memo && { memo }),
+ },
status: "pending",
network: selectedNetwork.chain.name,
orderId: orderId,
@@ -534,45 +576,72 @@ export const TransactionPreview = ({
- {Object.entries(renderedInfo).map(([key, value]) => (
-
-
- {key === "totalValue"
- ? "Total value"
- : key.charAt(0).toUpperCase() + key.slice(1)}
-
-
-
- {(key === "amount" || key === "fee") && (
-
- )}
-
- {key === "network" && (
-
- )}
-
- {value}
-
-
- ))}
+ {Object.entries(renderedInfo).map(([key, value]) => {
+ // For onramp, show token logo next to totalValue (which is the token amount)
+ const showTokenLogo =
+ (isOnramp && key === "totalValue") ||
+ (!isOnramp && (key === "amount" || key === "fee"));
+
+ return (
+
+
+ {key === "totalValue"
+ ? isOnramp
+ ? "Receive amount"
+ : "Total value"
+ : key === "amount"
+ ? "You send"
+ : key.charAt(0).toUpperCase() + key.slice(1)}
+ {key === "rate" && (
+
+
+
+
+ Rate changes dynamically after 5 minutes
+
+
+
+
+ )}
+
+
+
+
+ {showTokenLogo && (
+
+ )}
+
+ {key === "network" && (
+
+ )}
+
+ {value}
+
+
+
+ );
+ })}
{/* Transaction detail disclaimer */}
- Ensure the details above are correct. Failed transaction due to wrong
- details may attract a refund fee
+ {isOnramp
+ ? "Ensure the details above is correct. Lost funds due to incorrect wallet details cannot be recovered."
+ : "Ensure the details above are correct. Failed transaction due to wrong details may attract a refund fee"}
@@ -610,9 +679,8 @@ export const TransactionPreview = ({
) : (
)}
Create Order
diff --git a/app/pages/TransactionStatus.tsx b/app/pages/TransactionStatus.tsx
index 5f64c26a..099929dd 100644
--- a/app/pages/TransactionStatus.tsx
+++ b/app/pages/TransactionStatus.tsx
@@ -28,8 +28,10 @@ import {
classNames,
formatCurrency,
formatNumberWithCommas,
+ getCurrencySymbol,
getExplorerLink,
getInstitutionNameByCode,
+ shortenAddress,
} from "../utils";
import {
fetchOrderDetails,
@@ -43,6 +45,7 @@ import {
STEPS,
type OrderDetailsData,
type TransactionStatusProps,
+ type RecipientDetails,
} from "../types";
import { toast } from "sonner";
import { trackEvent } from "../hooks/analytics/client";
@@ -57,6 +60,7 @@ import { BlockFestCashbackComponent } from "../components/blockfest";
import { useBlockFestClaim } from "../context/BlockFestClaimContext";
import { useRocketStatus } from "../context/RocketStatusContext";
import { isBlockFestActive } from "../utils";
+import { AddBeneficiaryModal } from "../components/recipient/AddBeneficiaryModal";
// Allowed tokens for BlockFest cashback
const ALLOWED_CASHBACK_TOKENS = new Set(["USDC", "USDT"]);
@@ -136,6 +140,7 @@ export function TransactionStatus({
const [isSavingRecipient, setIsSavingRecipient] = useState(false);
const [showSaveSuccess, setShowSaveSuccess] = useState(false);
const [hasReindexed, setHasReindexed] = useState(false);
+ const [isOnrampModalOpen, setIsOnrampModalOpen] = useState(false);
const reindexTimeoutRef = useRef(null);
const latestRequestIdRef = useRef(0);
@@ -145,10 +150,37 @@ export function TransactionStatus({
const token = watch("token") || "";
const currency = String(watch("currency")) || "USD";
const amount = watch("amountSent") || 0;
- const fiat = Number(watch("amountReceived")) || 0;
+ const amountReceived = Number(watch("amountReceived")) || 0;
const recipientName = String(watch("recipientName")) || "";
const accountIdentifier = watch("accountIdentifier") || "";
const institution = watch("institution") || "";
+ const walletAddress = String(watch("walletAddress") || "");
+
+ // Detect if this is an onramp transaction
+ const isOnramp = !!walletAddress;
+ const recipientDisplayName = isOnramp
+ ? walletAddress
+ ? shortenAddress(walletAddress, 6, 4)
+ : "your wallet"
+ : recipientName
+ .toLowerCase()
+ .split(" ")
+ .map((name) => name.charAt(0).toUpperCase() + name.slice(1))
+ .join(" ");
+ const primaryAmountDisplay = isOnramp
+ ? `${formatNumberWithCommas(amountReceived)} ${token}`
+ : `${formatNumberWithCommas(amount)} ${token}`;
+ const fundStatusLabel = !isOnramp
+ ? null
+ : transactionStatus === "refunded"
+ ? "Refunded"
+ : ["validated", "settled"].includes(transactionStatus)
+ ? "Deposited"
+ : "Processing";
+ const shareDuration = calculateDuration(createdAt, completedAt);
+ const shareMessage = isOnramp
+ ? `Yay! I just swapped ${currency} for ${token} in ${shareDuration} on noblocks.xyz`
+ : `Yay! I just swapped ${token} for ${currency} in ${shareDuration} on noblocks.xyz`;
// Check if recipient is already saved in the database
const [isRecipientInBeneficiaries, setIsRecipientInBeneficiaries] =
@@ -157,11 +189,6 @@ export function TransactionStatus({
// Check if recipient exists in saved beneficiaries
useEffect(() => {
const checkRecipientExists = async () => {
- if (!accountIdentifier || !institution) {
- setIsRecipientInBeneficiaries(false);
- return;
- }
-
try {
const accessToken = await getAccessToken();
if (!accessToken) {
@@ -170,12 +197,8 @@ export function TransactionStatus({
}
const savedRecipients = await fetchSavedRecipients(accessToken);
- const exists = savedRecipients.some(
- (r) =>
- r.accountIdentifier === accountIdentifier &&
- r.institutionCode === institution,
- );
- setIsRecipientInBeneficiaries(exists);
+ const recipient = findRecipientInSaved(savedRecipients);
+ setIsRecipientInBeneficiaries(!!recipient);
} catch (error) {
console.error("Error checking if recipient exists:", error);
setIsRecipientInBeneficiaries(false);
@@ -183,7 +206,7 @@ export function TransactionStatus({
};
checkRecipientExists();
- }, [accountIdentifier, institution, getAccessToken]);
+ }, [accountIdentifier, institution, walletAddress, isOnramp, getAccessToken]);
// Scroll to top on mount
useEffect(() => {
@@ -272,11 +295,11 @@ export function TransactionStatus({
if (transactionStatus !== status) {
setTransactionStatus(
status as
- | "processing"
- | "fulfilled"
- | "validated"
- | "settled"
- | "refunded",
+ | "processing"
+ | "fulfilled"
+ | "validated"
+ | "settled"
+ | "refunded",
);
}
@@ -494,15 +517,14 @@ export function TransactionStatus({
{transactionStatus}
@@ -524,22 +546,23 @@ export function TransactionStatus({
}
};
- const handleAddToBeneficiariesChange = async (checked: boolean) => {
- setAddToBeneficiaries(checked);
- if (checked) {
- await addBeneficiary();
- } else {
- await removeRecipient();
+ // Helper function to build recipient object based on transaction type
+ const buildRecipient = (name: string): RecipientDetails | null => {
+ if (isOnramp) {
+ if (!walletAddress) {
+ return null;
+ }
+ return {
+ type: "wallet",
+ walletAddress: walletAddress as string,
+ name: name,
+ };
}
- };
-
- const addBeneficiary = async () => {
- setIsSavingRecipient(true);
+ // Handle bank/mobile_money recipients (offramp)
const institutionCode = formMethods.watch("institution");
if (!institutionCode) {
- setIsSavingRecipient(false);
- return;
+ return null;
}
const institutionName = getInstitutionNameByCode(
@@ -549,58 +572,117 @@ export function TransactionStatus({
if (!institutionName) {
console.error("Institution name not found");
- setIsSavingRecipient(false);
- return;
+ return null;
}
- const newRecipient = {
- name: recipientName,
+ return {
+ name: name,
institution: institutionName,
institutionCode: String(institutionCode),
accountIdentifier: String(formMethods.watch("accountIdentifier") || ""),
type:
(formMethods.watch("accountType") as "bank" | "mobile_money") || "bank",
};
+ };
- // Save recipient via API
- const accessToken = await getAccessToken();
- if (accessToken) {
- try {
- const success = await saveRecipient(newRecipient, accessToken);
- if (success) {
- // Show success state
- setIsSavingRecipient(false);
- setShowSaveSuccess(true);
-
- // Hide after 2 seconds with fade out animation
- setTimeout(() => {
- setShowSaveSuccess(false);
- // Add a small delay to allow fade out animation to complete
- setTimeout(() => {
- setIsRecipientInBeneficiaries(true);
- }, 300);
- }, 2000);
- } else {
- setIsSavingRecipient(false);
- }
- } catch (error) {
- console.error("Error saving recipient:", error);
- setIsSavingRecipient(false);
+ // Helper function to find recipient in saved recipients list
+ const findRecipientInSaved = (
+ savedRecipients: Array,
+ ): (RecipientDetails & { id: string }) | undefined => {
+ if (isOnramp) {
+ if (!walletAddress) {
+ return undefined;
}
- } else {
- setIsSavingRecipient(false);
+ return savedRecipients.find(
+ (r) => r.type === "wallet" && r.walletAddress === walletAddress,
+ );
}
- };
- const removeRecipient = async () => {
+ // Handle bank/mobile_money recipients (offramp)
const accountIdentifier = formMethods.watch("accountIdentifier");
const institutionCode = formMethods.watch("institution");
if (!accountIdentifier || !institutionCode) {
- console.error("Missing account identifier or institution code");
+ return undefined;
+ }
+
+ return savedRecipients.find(
+ (r) =>
+ r.type !== "wallet" &&
+ r.accountIdentifier === accountIdentifier &&
+ r.institutionCode === institutionCode,
+ );
+ };
+
+ // Helper function to handle save success state
+ const handleSaveSuccess = () => {
+ setIsSavingRecipient(false);
+ setShowSaveSuccess(true);
+
+ // Hide after 2 seconds with fade out animation
+ setTimeout(() => {
+ setShowSaveSuccess(false);
+ // Add a small delay to allow fade out animation to complete
+ setTimeout(() => {
+ setIsRecipientInBeneficiaries(true);
+ setIsOnrampModalOpen(false);
+ }, 300);
+ }, 2000);
+ };
+
+ const handleAddToBeneficiariesChange = async (checked: boolean) => {
+ setAddToBeneficiaries(checked);
+ if (checked) {
+ if (isOnramp) {
+ setIsOnrampModalOpen(true);
+ return;
+ }
+ await addBeneficiary(recipientName);
+ } else {
+ await removeRecipient();
+ }
+ };
+
+ const handleOnrampModalClose = () => {
+ setIsOnrampModalOpen(false);
+ setAddToBeneficiaries(false);
+ };
+
+ const handleOnrampModalSave = async (name: string) => {
+ await addBeneficiary(name);
+ setIsOnrampModalOpen(false);
+ };
+
+ const addBeneficiary = async (name: string) => {
+ setIsSavingRecipient(true);
+
+ const newRecipient = buildRecipient(name);
+ if (!newRecipient) {
+ setIsSavingRecipient(false);
+ return;
+ }
+
+ // Save recipient via API
+ const accessToken = await getAccessToken();
+ if (!accessToken) {
+ setIsSavingRecipient(false);
return;
}
+ try {
+ const success = await saveRecipient(newRecipient, accessToken);
+ if (success) {
+ handleSaveSuccess();
+ } else {
+ setIsSavingRecipient(false);
+ }
+ } catch (error) {
+ console.error("Error saving recipient:", error);
+ setIsSavingRecipient(false);
+ }
+ };
+
+ const removeRecipient = async () => {
try {
const accessToken = await getAccessToken();
if (!accessToken) {
@@ -610,11 +692,7 @@ export function TransactionStatus({
// Fetch saved recipients to find the recipient ID
const savedRecipients = await fetchSavedRecipients(accessToken);
- const recipientToDelete = savedRecipients.find(
- (r) =>
- r.accountIdentifier === accountIdentifier &&
- r.institutionCode === institutionCode,
- );
+ const recipientToDelete = findRecipientInSaved(savedRecipients);
if (!recipientToDelete) {
console.error("Recipient not found in saved recipients");
@@ -629,7 +707,6 @@ export function TransactionStatus({
if (success) {
// Update state to show the checkbox again since recipient is now removed
setIsRecipientInBeneficiaries(false);
- console.log("Recipient removed successfully");
}
} catch (error) {
console.error("Error removing recipient:", error);
@@ -637,13 +714,42 @@ export function TransactionStatus({
};
const getPaymentMessage = () => {
- const formattedRecipientName = recipientName
- ? recipientName
- .toLowerCase()
- .split(" ")
- .map((name) => name.charAt(0).toUpperCase() + name.slice(1))
- .join(" ")
- : "";
+ if (isOnramp) {
+ if (transactionStatus === "refunded") {
+ return (
+ <>
+ Your payment to {recipientDisplayName} was unsuccessful.
+
+
+ The funds will be refunded to your account.
+ >
+ );
+ }
+
+ if (!["validated", "settled"].includes(transactionStatus)) {
+ return (
+ <>
+ Processing payment to{" "}
+
+ {recipientDisplayName}
+
+ . Hang on, this will only take a few seconds
+ >
+ );
+ }
+
+ return (
+ <>
+
+ {formatNumberWithCommas(amountReceived)} {token}
+ {" "}
+ has been successfully deposited into recipient wallet address{" "}
+ {recipientDisplayName} on {selectedNetwork.chain.name} network.
+ >
+ );
+ }
+
+ const formattedRecipientName = recipientDisplayName;
if (transactionStatus === "refunded") {
return (
@@ -651,7 +757,8 @@ export function TransactionStatus({
Your transfer of{" "}
{formatNumberWithCommas(amount)} {token} (
- {formatCurrency(fiat ?? 0, currency, `en-${currency.slice(0, 2)}`)})
+ {getCurrencySymbol(currency)}{" "}
+ {formatNumberWithCommas(amountReceived ?? 0)})
{" "}
to {formattedRecipientName} was unsuccessful.
@@ -667,7 +774,8 @@ export function TransactionStatus({
Processing payment of{" "}
{formatNumberWithCommas(amount)} {token} (
- {formatCurrency(fiat ?? 0, currency, `en-${currency.slice(0, 2)}`)})
+ {getCurrencySymbol(currency)}{" "}
+ {formatNumberWithCommas(amountReceived ?? 0)})
{" "}
to {formattedRecipientName}. Hang on, this will only take a few
seconds
@@ -680,7 +788,8 @@ export function TransactionStatus({
Your transfer of{" "}
{formatNumberWithCommas(amount)} {token} (
- {formatCurrency(fiat ?? 0, currency, `en-${currency.slice(0, 2)}`)})
+ {getCurrencySymbol(currency)}{" "}
+ {formatNumberWithCommas(amountReceived ?? 0)})
{" "}
to {formattedRecipientName} has been completed successfully.
>
@@ -746,7 +855,7 @@ export function TransactionStatus({
/>
)}
- {formatNumberWithCommas(amount)} {token}
+ {primaryAmountDisplay}
- {(recipientName ?? "").toLowerCase().split(" ")[0]}
+ {recipientDisplayName}
@@ -775,9 +884,13 @@ export function TransactionStatus({
className="text-xl font-medium text-neutral-900 dark:text-white/80"
>
{transactionStatus === "refunded"
- ? "Oops! Transaction failed"
+ ? isOnramp
+ ? "Oops! Deposit failed"
+ : "Oops! Transaction failed"
: !["validated", "settled"].includes(transactionStatus)
- ? "Processing payment..."
+ ? isOnramp
+ ? "Indexing by aggregator..."
+ : "Processing payment..."
: "Transaction successful"}
@@ -796,7 +909,7 @@ export function TransactionStatus({
/>
)}
- {amount} {token}
+ {primaryAmountDisplay}
- {(recipientName ?? "").toLowerCase().split(" ")[0]}
+ {recipientDisplayName}
@@ -847,33 +960,34 @@ export function TransactionStatus({
orderDetails,
orderId,
) && (
-
-
-
- )}
+
+
+
+ )}
- {["validated", "settled"].includes(transactionStatus) && (
-
- {isGettingReceipt ? "Generating..." : "Get receipt"}
-
- )}
+ {["validated", "settled"].includes(transactionStatus) &&
+ !isOnramp && (
+
+ {isGettingReceipt ? "Generating..." : "Get receipt"}
+
+ )}
)}
@@ -994,6 +1110,12 @@ export function TransactionStatus({
+ {isOnramp && (
+
+
Fund status
+
{fundStatusLabel}
+
+ )}
Time spent
@@ -1040,8 +1162,7 @@ export function TransactionStatus({
- Yay! I just swapped {token} for {currency} in{" "}
- {calculateDuration(createdAt, completedAt)} on noblocks.xyz
+ {shareMessage}
@@ -1051,7 +1172,7 @@ export function TransactionStatus({
aria-label="Share on Twitter"
rel="noopener noreferrer"
target="_blank"
- href={`https://x.com/intent/tweet?text=I%20just%20swapped%20${token}%20for%20${currency}%20in%20${calculateDuration(createdAt, completedAt)}%20on%20noblocks.xyz`}
+ href={`https://x.com/intent/tweet?text=${encodeURIComponent(shareMessage)}`}
className={`min-h-9 !rounded-full ${secondaryBtnClasses} flex gap-2 text-neutral-900 dark:text-white/80`}
>
{resolvedTheme === "dark" ? (
@@ -1065,7 +1186,7 @@ export function TransactionStatus({
aria-label="Share on Warpcast"
rel="noopener noreferrer"
target="_blank"
- href={`https://warpcast.com/~/compose?text=Yay%21%20I%20just%20swapped%20${token}%20for%20${currency}%20in%20${calculateDuration(createdAt, completedAt)}%20on%20noblocks.xyz`}
+ href={`https://warpcast.com/~/compose?text=${encodeURIComponent(shareMessage)}`}
className={`min-h-9 !rounded-full ${secondaryBtnClasses} flex gap-2 text-neutral-900 dark:text-white/80`}
>
{resolvedTheme === "dark" ? (
@@ -1080,6 +1201,15 @@ export function TransactionStatus({
)}
+
+ {/* Onramp beneficiary modal */}
+
);
}
diff --git a/app/types.ts b/app/types.ts
index 32361212..e57947b5 100644
--- a/app/types.ts
+++ b/app/types.ts
@@ -21,6 +21,7 @@ export type FormData = {
accountIdentifier: string;
recipientName: string;
accountType: "bank" | "mobile_money";
+ walletAddress?: string; // For onramp: stablecoin wallet address
memo: string;
amountSent: number;
amountReceived: number;
@@ -29,6 +30,7 @@ export type FormData = {
export const STEPS = {
FORM: "form",
PREVIEW: "preview",
+ MAKE_PAYMENT: "make_payment",
STATUS: "status",
} as const;
@@ -49,15 +51,25 @@ export type TransactionPreviewProps = {
export type RecipientDetailsFormProps = {
formMethods: UseFormReturn;
stateProps: StateProps;
+ isSwapped?: boolean; // For onramp mode detection
+ token?: string; // Token symbol for onramp
+ networkName?: string; // Network name for display
};
-export type RecipientDetails = {
- name: string;
- institution: string;
- institutionCode: string;
- accountIdentifier: string;
- type: "bank" | "mobile_money";
-};
+export type RecipientDetails =
+ | {
+ type: "wallet";
+ walletAddress: string;
+ name: string;
+ }
+ | {
+ type: "bank" | "mobile_money";
+ name: string;
+ institution: string;
+ institutionCode: string;
+ accountIdentifier: string;
+ walletAddress?: never;
+ };
export type FormMethods = {
handleSubmit: UseFormHandleSubmit;
@@ -261,11 +273,11 @@ export type Config = {
export type Network = {
chain: any;
imageUrl:
- | string
- | {
- light: string;
- dark: string;
- };
+ | string
+ | {
+ light: string;
+ dark: string;
+ };
};
export interface TransactionResponse {
@@ -290,7 +302,7 @@ export type TransactionStatus =
| "processing"
| "fulfilled"
| "refunded";
-export type TransactionHistoryType = "swap" | "transfer";
+export type TransactionHistoryType = "swap" | "transfer" | "onramp";
export interface Recipient {
account_name: string;
@@ -386,7 +398,7 @@ export type Currency = {
};
// Saved Recipients API Types
-export interface RecipientDetailsWithId extends RecipientDetails {
+export type RecipientDetailsWithId = RecipientDetails & {
id: string;
}
diff --git a/app/utils.ts b/app/utils.ts
index 99bb9113..4f35556a 100644
--- a/app/utils.ts
+++ b/app/utils.ts
@@ -5,15 +5,45 @@ import type {
Token,
Currency,
APIToken,
+ RecipientDetails,
} from "./types";
import type { SanityPost, SanityCategory } from "./blog/types";
-import { erc20Abi } from "viem";
+import { erc20Abi, createPublicClient, http } from "viem";
+import { mainnet } from "viem/chains";
+import { getEnsName } from "viem/actions";
+import { isValidEvmAddressCaseInsensitive } from "./lib/validation";
import { colors } from "./mocks";
import { fetchTokens } from "./api/aggregator";
import { toast } from "sonner";
import config from "./lib/config";
import { feeRecipientAddress } from "./lib/config";
+/**
+ * Type predicate to narrow RecipientDetails to bank/mobile_money types.
+ * Used for type-safe filtering and property access.
+ *
+ * @param recipient - The recipient to check.
+ * @returns True if recipient is bank or mobile_money type.
+ */
+export function isBankOrMobileMoneyRecipient(
+ recipient: RecipientDetails,
+): recipient is Extract {
+ return recipient.type !== "wallet";
+}
+
+/**
+ * Type predicate to narrow RecipientDetails to wallet type.
+ * Used for type-safe filtering and property access.
+ *
+ * @param recipient - The recipient to check.
+ * @returns True if recipient is wallet type.
+ */
+export function isWalletRecipient(
+ recipient: RecipientDetails,
+): recipient is Extract {
+ return recipient.type === "wallet";
+}
+
/**
* Concatenates and returns a string of class names.
*
@@ -82,6 +112,31 @@ export const formatCurrency = (
}).format(value); // Format the provided value as a currency string.
};
+/**
+ * Gets the currency symbol for a given currency code.
+ * @param currency - The currency code (e.g., "NGN", "KES", "USD")
+ * @returns The currency symbol (e.g., "₦", "KSh", "$")
+ */
+export const getCurrencySymbol = (currency: string): string => {
+ const currencySymbols: Record = {
+ NGN: "₦",
+ KES: "KSh",
+ UGX: "USh",
+ TZS: "TSh",
+ GHS: "₵",
+ BRL: "R$",
+ ARS: "$",
+ USD: "$",
+ GBP: "£",
+ EUR: "€",
+ MWK: "MK",
+ XOF: "CFA",
+ XAF: "FCFA",
+ };
+
+ return currencySymbols[currency.toUpperCase()] || currency;
+};
+
/**
* Encrypts data using the provided public key.
* @param data - The data to be encrypted.
@@ -571,6 +626,56 @@ export function shortenAddress(
return `${address.slice(0, startChars)}...${address.slice(-endChars)}`;
}
+/**
+ * Resolves ENS name from wallet address for supported networks
+ * Falls back to first 5 chars if no ENS name found
+ * @param address - The wallet address to resolve
+ * @param networkName - Optional network name (Lisk doesn't support ENS)
+ * @returns Promise - ENS name or shortened address (first 5 chars after 0x)
+ */
+export async function resolveEnsNameOrShorten(
+ address: string,
+ networkName?: string,
+): Promise {
+ if (!address) {
+ return "";
+ }
+
+ if (!isValidEvmAddressCaseInsensitive(address)) {
+ return address.slice(0, 5);
+ }
+
+ // Lisk doesn't support ENS, return shortened address immediately
+ if (networkName === "Lisk") {
+ return address.slice(2, 7); // First 5 chars (skip 0x)
+ }
+
+ try {
+
+ // ENS reverse resolution works on Ethereum mainnet
+ // But names can resolve to addresses on L2 networks (Base, Arbitrum, Polygon)
+ const publicClient = createPublicClient({
+ chain: mainnet,
+ transport: http("https://eth.llamarpc.com"), // Public Ethereum RPC
+ });
+
+ const ensName = await getEnsName(publicClient, {
+ address: address.toLowerCase() as `0x${string}`,
+ });
+
+ if (ensName) {
+ return ensName;
+ }
+
+ // Fallback to first 5 chars (skip 0x)
+ return address.slice(2, 7);
+ } catch (error) {
+ console.error("Error resolving ENS name:", error);
+ // Fallback to first 5 chars (skip 0x)
+ return address.slice(2, 7);
+ }
+}
+
/**
* Normalizes network name for rate fetching API.
* Maps "Hedera Mainnet" to "hedera" instead of "hedera-mainnet".
@@ -1223,3 +1328,29 @@ export function calculateSenderFee(
return { feeAmount, feeAmountInBaseUnits, feeRecipient };
}
+
+/**
+ * Gets the avatar image path based on index, cycling through 1-4
+ */
+export const getAvatarImage = (index: number): string => {
+ const avatarNumber = (index % 4) + 1;
+ return `/images/onramp-avatar/avatar${avatarNumber}.png`;
+};
+
+/**
+ * Copies text to clipboard and shows a toast notification
+ * @param text - The text to copy to clipboard
+ * @param label - Optional label for the toast message (e.g., "Account number", "Amount")
+ * @returns Promise that resolves when copy is complete
+ */
+export const copyToClipboard = async (
+ text: string,
+ label?: string,
+): Promise => {
+ try {
+ await navigator.clipboard.writeText(text);
+ toast.success(label ? `${label} copied to clipboard` : "Copied to clipboard");
+ } catch (error) {
+ toast.error("Failed to copy");
+ }
+};
diff --git a/public/images/onramp-avatar/avatar1.png b/public/images/onramp-avatar/avatar1.png
new file mode 100644
index 00000000..315c0b75
Binary files /dev/null and b/public/images/onramp-avatar/avatar1.png differ
diff --git a/public/images/onramp-avatar/avatar2.png b/public/images/onramp-avatar/avatar2.png
new file mode 100644
index 00000000..b9edcb7e
Binary files /dev/null and b/public/images/onramp-avatar/avatar2.png differ
diff --git a/public/images/onramp-avatar/avatar3.png b/public/images/onramp-avatar/avatar3.png
new file mode 100644
index 00000000..4973330f
Binary files /dev/null and b/public/images/onramp-avatar/avatar3.png differ
diff --git a/public/images/onramp-avatar/avatar4.png b/public/images/onramp-avatar/avatar4.png
new file mode 100644
index 00000000..4158c33f
Binary files /dev/null and b/public/images/onramp-avatar/avatar4.png differ
diff --git a/supabase/migrations/create_saved_recipients.sql b/supabase/migrations/create_saved_recipients.sql
index 48df411f..1d7cb698 100644
--- a/supabase/migrations/create_saved_recipients.sql
+++ b/supabase/migrations/create_saved_recipients.sql
@@ -86,3 +86,89 @@ create policy "Service role can delete recipients"
on public.saved_recipients for delete
to service_role
using (true);
+
+-- ============================================
+-- Create table to store saved wallet recipients (onramp)
+-- ============================================
+create table if not exists public.saved_wallet_recipients (
+ id uuid default gen_random_uuid() primary key,
+ wallet_address text not null, -- The user's wallet address
+ normalized_wallet_address text not null check (normalized_wallet_address ~* '^0x[0-9a-f]{40}$'),
+ recipient_wallet_address text not null, -- The recipient's wallet address
+ normalized_recipient_wallet_address text not null check (normalized_recipient_wallet_address ~* '^0x[0-9a-f]{40}$'),
+ name text not null, -- The name/label for the recipient wallet
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+
+-- Unique constraint to prevent duplicate wallet recipients per user
+alter table public.saved_wallet_recipients
+add constraint unique_wallet_recipient_per_user
+unique (normalized_wallet_address, normalized_recipient_wallet_address);
+
+-- Indices for performance
+create index if not exists idx_saved_wallet_recipients_wallet_address
+ on public.saved_wallet_recipients(normalized_wallet_address);
+
+create index if not exists idx_saved_wallet_recipients_created_at
+ on public.saved_wallet_recipients(created_at desc);
+
+-- Function to auto-populate normalized_wallet_address and normalized_recipient_wallet_address
+create or replace function public.normalize_wallet_addresses_wallet_recipients()
+returns trigger language plpgsql as $$
+begin
+ new.normalized_wallet_address := lower(new.wallet_address);
+ new.normalized_recipient_wallet_address := lower(new.recipient_wallet_address);
+ return new;
+end;
+$$;
+
+-- Trigger to automatically normalize wallet addresses on insert/update
+create trigger saved_wallet_recipients_normalize_addresses
+before insert or update on public.saved_wallet_recipients
+for each row execute function public.normalize_wallet_addresses_wallet_recipients();
+
+-- Function to update updated_at timestamp for wallet recipients
+create or replace function public.update_updated_at_column_wallet_recipients()
+returns trigger language plpgsql as $$
+begin
+ new.updated_at = now();
+ return new;
+end;
+$$;
+
+-- Trigger to automatically update updated_at
+create trigger saved_wallet_recipients_update_updated_at
+before update on public.saved_wallet_recipients
+for each row execute function public.update_updated_at_column_wallet_recipients();
+
+-- Enable Row Level Security (RLS)
+alter table public.saved_wallet_recipients enable row level security;
+
+-- Drop existing policies if they exist
+drop policy if exists "Service role can insert wallet recipients" on public.saved_wallet_recipients;
+drop policy if exists "Service role can update wallet recipients" on public.saved_wallet_recipients;
+drop policy if exists "Service role can read wallet recipients" on public.saved_wallet_recipients;
+drop policy if exists "Service role can delete wallet recipients" on public.saved_wallet_recipients;
+
+-- RLS policies for service role only
+create policy "Service role can insert wallet recipients"
+on public.saved_wallet_recipients for insert
+to service_role
+with check (true);
+
+create policy "Service role can update wallet recipients"
+on public.saved_wallet_recipients for update
+to service_role
+using (true)
+with check (true);
+
+create policy "Service role can read wallet recipients"
+on public.saved_wallet_recipients for select
+to service_role
+using (true);
+
+create policy "Service role can delete wallet recipients"
+on public.saved_wallet_recipients for delete
+to service_role
+using (true);
\ No newline at end of file