Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions app/api/aggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -722,14 +722,20 @@ 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") {
return `wallet-${r.walletAddress}`;
} else {
return `${r.institutionCode}-${r.accountIdentifier}`;
}
}),
);
Comment on lines +725 to 740
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Verify recipient data shape before key generation.

The key generation logic assumes that walletAddress exists when type === "wallet" and that institutionCode/accountIdentifier exist for bank/mobile_money types. If the runtime data doesn't match the type guards, this could produce malformed keys like "wallet-undefined".

Consider adding defensive checks or validation before key generation to handle potentially malformed data from localStorage.

🔎 Suggested defensive handling
 const existingKeys = new Set(
   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}`;
     }
   }),
 );
🤖 Prompt for AI Agents
In app/api/aggregator.ts around lines 725 to 732, the code generates recipient
keys assuming walletAddress exists for type === "wallet" and
institutionCode/accountIdentifier exist for other types; add defensive
validation before key generation to avoid producing strings like
"wallet-undefined". Fix by checking the required fields per type (e.g., if
r.type === "wallet" ensure r.walletAddress is a non-empty string; otherwise
ensure r.institutionCode and r.accountIdentifier are present), filter out or
skip invalid recipients (or provide a safe default and log a warning), and only
then map to the key format; keep behavior consistent (returning an array of only
valid keys) and include a brief debug/warn log when malformed entries are
dropped.


// 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);
});

Expand All @@ -746,7 +752,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 };
}
});
Expand Down
201 changes: 185 additions & 16 deletions app/api/v1/recipients/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,20 +42,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;
}

// Transform database format to frontend format
const transformedRecipients: RecipientDetailsWithId[] =
recipients?.map((recipient) => ({
// 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 bank/mobile_money recipients
const transformedBankRecipients: RecipientDetailsWithId[] =
bankRecipients?.map((recipient) => ({
id: recipient.id,
name: recipient.name,
institution: recipient.institution,
Expand All @@ -64,6 +77,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,
Expand Down Expand Up @@ -124,9 +152,128 @@ 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 },
);
}

// 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 ||
Expand Down Expand Up @@ -315,16 +462,38 @@ 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 } = await supabaseAdmin
.from("saved_wallet_recipients")
.select("id")
.eq("id", recipientId)
.eq("normalized_wallet_address", walletAddress);
.eq("normalized_wallet_address", walletAddress)
.single();

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 = {
Expand Down
58 changes: 33 additions & 25 deletions app/components/MainPageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Preloader,
TransactionForm,
TransactionPreview,
MakePayment,
TransactionStatus,
NetworkSelectionModal,
CookieConsent,
Expand Down Expand Up @@ -68,7 +69,7 @@ const PageLayout = ({
const walletAddress = isInjectedWallet
? injectedAddress
: user?.linkedAccounts.find((account) => account.type === "smart_wallet")
?.address;
?.address;

return (
<>
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -391,6 +392,13 @@ export function MainPageContent() {
createdAt={createdAt}
/>
);
case STEPS.MAKE_PAYMENT:
return (
<MakePayment
handleBackButtonClick={handleBackToForm}
stateProps={stateProps}
/>
);
case STEPS.STATUS:
return (
<TransactionStatus
Expand Down
1 change: 1 addition & 0 deletions app/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export { Disclaimer } from "./Disclaimer";

export { TransactionForm } from "../pages/TransactionForm";
export { TransactionPreview } from "../pages/TransactionPreview";
export { MakePayment } from "../pages/MakePayment";
export { TransactionStatus } from "../pages/TransactionStatus";

export { InputError } from "./InputError";
Expand Down
Loading