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 @@ -723,14 +723,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 +726 to 741
Copy link
Copy Markdown
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 @@ -747,7 +761,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, currency } =
const { name, institution, institutionCode, accountIdentifier, type, currency walletAddress: walletAddressFromBody } =
body;
Comment on lines 155 to 157
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

fd "route.ts" -x sh -c 'echo "=== {} ===" && wc -l {} && sed -n "150,160p" {} | cat -n' | grep -A 15 "recipients"

Repository: paycrest/noblocks

Length of output: 1222


Fix the request-body destructuring syntax.

Line 156 is missing a comma between currency and walletAddress, causing a parse error that will prevent this route from compiling.

Suggested fix
-    const { name, institution, institutionCode, accountIdentifier, type, currency walletAddress: walletAddressFromBody } =
-      body;
+    const {
+      name,
+      institution,
+      institutionCode,
+      accountIdentifier,
+      type,
+      currency,
+      walletAddress: walletAddressFromBody,
+    } = body;
🧰 Tools
🪛 Biome (2.4.9)

[error] 156-156: expected , but instead found walletAddress

(parse)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/v1/recipients/route.ts` around lines 155 - 157, The object
destructuring for the request body in the route handler is missing a comma
between currency and walletAddress, causing a syntax error; update the
destructuring assignment (the const body = await request.json(); line and the
subsequent destructure that declares name, institution, institutionCode,
accountIdentifier, type, currency and walletAddressFromBody) to include the
missing comma and preserve the walletAddress alias (walletAddress:
walletAddressFromBody) so the handler compiles.


// 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 },
);
}
Comment on lines +196 to +241
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Only evict when a new wallet recipient will actually be inserted.

Lines 208-222 can delete the oldest saved wallet before Line 226 validates name, and before the later upsert proves this is a new normalized_recipient_wallet_address. At the 100-recipient limit, an invalid request or a rename of an existing wallet can silently drop an unrelated beneficiary.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/v1/recipients/route.ts` around lines 196 - 241, Currently the code
evicts the oldest row from saved_wallet_recipients as soon as recipientCount >=
100, which can drop a beneficiary for requests that later fail validation (name)
or end up updating an existing recipient; change the flow in route.ts so
eviction happens only after request validation (the name check using
trackApiError/NextResponse.json) and after you determine the upsert will insert
a new normalized_recipient_wallet_address (use the same supabaseAdmin
upsert/select logic that checks for an existing
normalized_recipient_wallet_address); specifically, keep the recipient count
query (recipientCount) but move the deletion logic that references
oldestRecipient so it runs only when you know the request is valid and the
upsert will create a new row.


// 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 @@ -343,16 +509,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