Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
27 changes: 22 additions & 5 deletions app/api/aggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}
}),
);
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 +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 };
}
});
Expand Down
226 changes: 210 additions & 16 deletions app/api/v1/recipients/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
trackApiError,
trackBusinessEvent,
} from "@/app/lib/server-analytics";
import { isValidEvmAddressCaseInsensitive } from "@/app/lib/validation";
import type {
RecipientDetailsWithId,
SavedRecipientsResponse,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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 ||
Expand Down Expand Up @@ -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 = {
Expand Down
Loading