Skip to content

feat: add Smile ID KYC submission functionality and integrate smart camera component in KycModal#286

Open
Dprof-in-tech wants to merge 27 commits intomainfrom
feat-use-smileID-web-sdk-for-in-app-verification
Open

feat: add Smile ID KYC submission functionality and integrate smart camera component in KycModal#286
Dprof-in-tech wants to merge 27 commits intomainfrom
feat-use-smileID-web-sdk-for-in-app-verification

Conversation

@Dprof-in-tech
Copy link
Collaborator

@Dprof-in-tech Dprof-in-tech commented Nov 11, 2025

Description

This pull request introduces a comprehensive KYC (Know Your Customer) verification flow, adding new API endpoints for ID, address, and status checks, as well as transaction summaries. It also updates environment configuration to support multiple verification services. The changes enable tiered KYC verification, integrate SmileID and Dojah for ID and address checks, and provide analytics tracking and error handling throughout the process.

KYC Verification APIs and Flow

  • Added a new endpoint app/api/kyc/smile-id/route.ts for submitting SmileID biometric/enhanced ID verification jobs, validating input, updating user KYC profiles in Supabase, and handling verification results and errors.
  • Added a new endpoint app/api/kyc/tier3-verify/route.ts for Tier 3 address/proof-of-address verification using Dojah, including file upload to Supabase Storage, document verification, and KYC profile updates.
  • Added a new endpoint app/api/kyc/status/route.ts to check KYC status, including phone and SmileID verification, returning tier and verification flags for the user.
  • Added a new endpoint app/api/kyc/transaction-summary/route.ts to provide daily and monthly transaction summaries for a wallet, supporting compliance and limit checks.

API Integration and Aggregator Updates

  • Added submitSmileIDData function in app/api/aggregator.ts to call the SmileID KYC API, track analytics, and handle responses and errors. Also imported SmileIDSubmissionResponse type for type safety. [1] [2]

Environment Configuration

  • Updated .env.example to include configuration variables for KudiSMS, Twilio, SmileID, Dojah, and Supabase Storage bucket, supporting phone, ID, and address verification services.

References

closes #281
closes #282
closes #283
closes #288
closes #303

Testing

Here's a loom video recording of what this looks like.

Loom recording of the kyc flow

  • This change adds test coverage for new/changed/fixed functionality

Checklist

  • I have added documentation and tests for new/changed functionality in this PR
  • All active GitHub checks for tests, formatting, and security are passing
  • The correct base branch is being used, if not main

By submitting a PR, I agree to Paycrest's Contributor Code of Conduct and Contribution Guide.

Summary by CodeRabbit

  • New Features

    • Phone verification modal with OTP (KudiSMS & Twilio), biometric ID capture, and Tier‑3 address document upload/verification
    • Profile drawer and Transaction Limit modal; transaction summary (daily/monthly totals + last transaction)
  • Updates

    • KYC status endpoint, client KYC context, tier/limits handling and gating across the UI
    • Country lookup/search and phone‑number validation utilities; improved KYC flows and UX
  • Chores

    • Added environment variable placeholders for verification and KYC providers

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 11, 2025

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • ✅ Review completed - (🔄 Check again to review again)
📝 Walkthrough

Walkthrough

Adds end-to-end tiered KYC and phone verification: new server routes (OTP, SMS/Twilio, SmileID, tier3 Dojah), DB migrations and schema, client KYC context and UI (phone modal, KYC modal, profile/limits), helper libs (SmileID, Dojah, phone validation, countries), middleware and package deps.

Changes

Cohort / File(s) Summary
Env / Config
\.env\.example
Added placeholders for KudiSMS, Twilio, SmileID, Dojah, and KYC-related env vars.
API — Phone OTP
app/api/phone/send-otp/route.ts, app/api/phone/verify-otp/route.ts
New rate-limited endpoints for sending and verifying OTPs; provider selection (KudiSMS for NG / Twilio otherwise), DB upserts, expiry/attempts, provider-specific verification, tier updates, analytics.
API — KYC & Tier3
app/api/kyc/smile-id/route.ts, app/api/kyc/tier3-verify/route.ts, app/api/kyc/status/route.ts, app/api/kyc/transaction-summary/route.ts
New endpoints: submit SmileID job, tier-3 Dojah address verify, KYC status, transaction summary; rate-limits, Supabase updates, analytics, robust error handling.
Aggregator & Types
app/api/aggregator.ts, app/types.ts
Added submitSmileIDData wrapper and SmileIDSubmissionResponse type for authenticated submissions.
Libs: phone / SmileID / Dojah / countries / supabase
app/lib/phone-verification.ts, app/lib/smileID.ts, app/lib/dojah.ts, app/lib/countries.ts, app/lib/supabase.ts
New utility modules: phone validation & Twilio/KudiSMS flows, SmileID job submission helpers, Dojah utility-bill verification, country fetch/search with fallback, exported supabaseUrl.
DB migrations
supabase/migrations/create_user_kyc_profiles.sql, supabase/migrations/create_transactions_table.sql
Added user_kyc_profiles table (wallet PK, phone/email/id fields, otp metadata, tier/verified, indexes, trigger). Expanded transactions schema (network, order_id, 'fulfilling', RLS).
Client KYC Context & Providers
app/context/KYCContext.tsx, app/context/index.ts, app/providers.tsx
New KYCProvider and useKYC hook (tier, phone verification state, transactionSummary, canTransact, refreshStatus); provider wired into app providers.
UI Components
app/components/KycModal.tsx, app/components/PhoneVerificationModal.tsx, app/components/ProfileDrawer.tsx, app/components/TransactionLimitModal.tsx, app/components/kyc/DocumentRequirementsModal.tsx, app/components/index.ts, app/components/kyc/index.ts, app/components/SettingsDropdown.tsx
Updated KycModal to SmileID capture flow; added PhoneVerificationModal, TransactionLimitModal, ProfileDrawer, DocumentRequirementsModal, settings integration and index re-exports.
Pages & Hooks Integration
app/pages/TransactionForm.tsx, app/pages/TransactionPreview.tsx, app/hooks/useSwapButton.ts
Integrated limit modal into transaction gating, replaced prev verification gating with phone/tier checks, added save guard to preview, renamed hook prop to setIsLimitModalOpen.
Middleware & Dependencies
middleware.ts, package.json
Expanded middleware matcher for new routes; added deps: @smileid/web-components, libphonenumber-js, smile-identity-core, twilio.
Misc / Re-exports
app/components/index.ts, app/context/index.ts
Re-exported new components and KYC context hooks for public consumption.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant App as Client App
    participant OTPRoute as /api/phone/send-otp
    participant SMSProvider as SMS Service<br/>KudiSMS / Twilio
    participant DB as Supabase<br/>user_kyc_profiles

    User->>App: Submit phoneNumber
    App->>OTPRoute: POST /api/phone/send-otp
    activate OTPRoute
    OTPRoute->>OTPRoute: Validate & normalize phone (libphonenumber-js)
    OTPRoute->>OTPRoute: Choose provider (Nigeria → KudiSMS, else Twilio)
    OTPRoute->>DB: Upsert otp_code, expires_at, provider
    OTPRoute->>SMSProvider: Send OTP
    SMSProvider-->>OTPRoute: Acknowledgement
    OTPRoute-->>App: success (provider, intlPhone)
    deactivate OTPRoute

    User->>App: Submit OTP
    App->>/api/phone/verify-otp: POST
    activate OTPRoute
    OTPRoute->>DB: Fetch profile by wallet + phone
    alt provider == twilio
        OTPRoute->>SMSProvider: Verify via Twilio Verify
    else
        OTPRoute->>OTPRoute: Compare stored otp_code, check expiry, attempts
    end
    OTPRoute->>DB: Update verified, verified_at, set tier=1
    OTPRoute-->>App: verified: true
    deactivate OTPRoute
Loading
sequenceDiagram
    actor User
    participant App as Client App
    participant Camera as smart-camera-web
    participant Aggregator as app/api/aggregator.ts
    participant KYCRoute as /api/kyc/smile-id
    participant SmileIDAPI as SmileID Web API
    participant DB as Supabase<br/>user_kyc_profiles

    User->>App: Open KYC modal, select id_type/country
    App->>Camera: Initialize SmileID capture
    User->>Camera: Capture ID + selfie
    Camera-->>App: publish { images, id_info }
    App->>Aggregator: submitSmileIDData(payload, accessToken)
    activate Aggregator
    Aggregator->>KYCRoute: POST /api/kyc/smile-id
    activate KYCRoute
    KYCRoute->>SmileIDAPI: submitSmileIDJob(images, id_info, partner_params)
    SmileIDAPI-->>KYCRoute: { jobId, jobStatus, ... }
    alt verification success
        KYCRoute->>DB: Upsert profile (verified=true/tier=2, id fields, job ref)
        KYCRoute-->>Aggregator: success response
    else failure
        KYCRoute-->>Aggregator: error response
    end
    Aggregator-->>App: SmileIDSubmissionResponse
    deactivate KYCRoute
    deactivate Aggregator
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

enhancement

Suggested reviewers

  • chibie
  • onahprosper

Poem

🐰 I hopped to code and stitched a flow,

SMS hops and SmileID glow.
Tiers climb up as carrots gleam,
Limits lift and users beam.
Hooray — verified! 🥕✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 39.13% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately reflects the main changes: adding Smile ID KYC submission and smart camera component integration in KycModal.
Linked Issues check ✅ Passed The PR implements core requirements from all three linked issues: phone OTP verification (#282), Smile ID Web SDK integration (#283), and tiered KYC flow with transaction limits (#281).
Out of Scope Changes check ✅ Passed All changes are directly scoped to the KYC and phone verification requirements. Tier 3 address verification is a natural extension of the tiered KYC flow and aligns with issue #281's tier progression.
Description check ✅ Passed The PR description comprehensively covers the purpose, implementation details, breaking changes, API additions, frontend integration, testing evidence, and required checklist confirmations.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat-use-smileID-web-sdk-for-in-app-verification

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

- Implemented TransactionLimitModal to manage user transaction limits based on KYC status.
- Integrated phone verification and KYC status hooks to enhance user verification process.
- Updated SettingsDropdown to include a Profile option and handle profile drawer state.
- Created useKYCStatus and usePhoneVerification hooks for managing KYC and phone verification states.
- Added country fetching utility and phone number validation logic.
- Introduced SmileID integration for selfie verification.
- Enhanced Supabase schema to support user KYC profiles and verification processes.
- Updated TransactionForm to check transaction limits before processing swaps.
- Added necessary dependencies for phone verification and KYC functionalities.
@Dprof-in-tech Dprof-in-tech marked this pull request as ready for review November 20, 2025 14:18
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 20

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/hooks/useSwapButton.ts (1)

81-118: Fix inverted Fund Wallet condition and align KYC behaviour with button text/state

There are a couple of behavioural bugs in the updated logic:

  1. Fund wallet condition is inverted
if (!hasInsufficientBalance && !isInjectedWallet && authenticated) {
  return handleFundWallet;
}

This triggers handleFundWallet when the user has sufficient balance (and the button text in that case is usually "Swap"), which is clearly not intended. It should gate funding on insufficient balance.

  1. KYC vs text mismatch for insufficient & unverified
if (hasInsufficientBalance && !isUserVerified && (authenticated || isInjectedWallet)) {
  return setIsLimitModalOpen;
}

For an authenticated, non‑injected user with insufficient balance and !isUserVerified, buttonText is "Fund wallet" (because of the earlier text branch), but the action now opens the limit/KYC modal. That’s confusing UX and contradicts the label.

  1. Injected wallet + insufficient balance never reaches KYC

isEnabled still short‑circuits with:

if (isInjectedWallet && hasInsufficientBalance) {
  return false;
}

So for injected wallets with insufficient balance, the button is disabled and the new KYC branch in buttonAction is never called, even though the condition includes isInjectedWallet.

A minimal fix for the worst regression (1) and to make the order of actions clearer could be:

   const buttonAction = (
     handleSwap: () => void,
     login: () => void,
     handleFundWallet: () => void,
     setIsLimitModalOpen: () => void,
     isUserVerified: boolean,
   ) => {
     if (!authenticated && !isInjectedWallet) {
       return login;
     }
-    if (!hasInsufficientBalance && !isInjectedWallet && authenticated) {
-      return handleFundWallet;
-    }
-    if (hasInsufficientBalance && !isUserVerified && (authenticated || isInjectedWallet)) {
-      return setIsLimitModalOpen;
-    }
-    return handleSwap;
+    if (hasInsufficientBalance) {
+      if (!isUserVerified && (authenticated || isInjectedWallet)) {
+        return setIsLimitModalOpen;
+      }
+      if (!isInjectedWallet && authenticated) {
+        return handleFundWallet;
+      }
+    }
+    return handleSwap;
   };

Follow‑ups (recommended, not strictly required for correctness of this fix):

  • Update buttonText so that the label matches the action (e.g. show "Get started" or similar when setIsLimitModalOpen will fire, and only show "Fund wallet" when the actual action is handleFundWallet).
  • Revisit isEnabled for injected wallets with insufficient balance if you really want KYC to be reachable in that scenario.
🧹 Nitpick comments (17)
app/api/kyc/transaction-summary/route.ts (1)

22-60: Clarify period semantics and consider explicit UTC handling

The aggregation logic is correct, but two small points:

  • The comment says “last 30 days” while the query uses monthStart (start of current month). Either update the comment or change the filter to a rolling 30‑day window to avoid confusion.
  • today and monthStart are based on the server’s local timezone while created_at is likely stored as UTC. That’s usually fine, but if you care about strict UTC month/day boundaries, consider constructing these dates in UTC or documenting the intended behaviour.
app/api/kyc/status/route.ts (1)

22-45: Handle Supabase errors explicitly and tighten tier fallback logic

Two things to consider here:

  1. Supabase error handling
    supabaseAdmin.from(...).single() can return an error (e.g., network issue, Postgres error). Right now you ignore it, which means a DB failure looks like “no profile found” and you still return 200 with tier 0. That makes observability harder and can block verified users due to transient backend issues.

    Suggestion: capture { data: kycProfile, error } and, if error is truthy and not a simple “no rows” case, log/track it and return a 500, similar to the transaction-summary route.

  2. Tier fallback check
    The comment says “use database tier if available, otherwise calculate”, but:

    let tier: 0 | 1 | 2 = kycProfile?.tier || 0;
    if (!kycProfile?.tier) {
      if (fullKYCVerified) tier = 2;
      else if (phoneVerified) tier = 1;
    }

    Because 0 is falsy, a stored tier = 0 is treated as “not set”, and the fallback can overwrite it based on verification flags. If you intend to respect an explicit 0, change the condition to a null/undefined check, e.g. if (kycProfile?.tier == null) { ... }.

app/lib/countries.ts (1)

15-104: Country lookup and caching logic looks solid

The fetch → transform → cache flow, plus the hardcoded fallback list, gives you a nice balance of UX and resilience if the REST Countries API is down. searchCountries and getPopularCountries are straightforward and usable by UI components.

If this becomes performance‑critical later, you might consider adding a simple TTL to countriesCache (so long‑lived server processes can refresh occasionally), but what you have is perfectly acceptable for now.

app/components/TransactionLimitModal.tsx (2)

210-215: Wire setIsUserVerified to refresh KYC state and close parent on success

Here setIsUserVerified={() => { }} is a no-op, so a successful Smile ID KYC flow in KycModal won’t update this component’s KYC state or close the TransactionLimitModal. The user has to manually close and reopen to see updated limits.

You can use the success callback to refresh status and close the parent when verification succeeds:

-                        <KycModal
-                            setIsKycModalOpen={setIsKycModalOpen}
-                            setIsUserVerified={() => { }}
-                        />
+                        <KycModal
+                            setIsKycModalOpen={setIsKycModalOpen}
+                            setIsUserVerified={async (verified) => {
+                                if (verified) {
+                                    await refreshStatus();
+                                    onClose();
+                                }
+                            }}
+                        />

This keeps the limit UI in sync with backend KYC status and matches the expectation that the enclosing flow completes when verification passes.


54-59: Simplify tier check in auto-open effect

tier is typed as 0 | 1 | 2 and initialized to 0, so tier === undefined can never be true. The condition can be simplified for clarity:

-    useEffect(() => {
-        if (isOpen && !isLoading && (tier < 1 || tier === undefined)) {
-            setIsPhoneModalOpen(true);
-        }
-    }, [isOpen, isLoading, tier]);
+    useEffect(() => {
+        if (isOpen && !isLoading && tier < 1) {
+            setIsPhoneModalOpen(true);
+        }
+    }, [isOpen, isLoading, tier]);
app/components/ProfileDrawer.tsx (1)

42-47: Future‑proof monthlyProgress against zero monthly limits

Right now monthlyProgress is computed as:

const monthlyProgress = (transactionSummary.monthlySpent / currentLimits.monthly) * 100;

and the “Current Tier Status” card only renders when tier >= 1, where currentLimits.monthly is non‑zero, so this is safe today. If KYC tiers or getCurrentLimits are ever extended to allow a non‑zero tier with a 0 monthly limit, this could become a division‑by‑zero and produce NaN/Infinity.

Optional: add a small guard for robustness, e.g.:

const monthlyProgress =
  currentLimits.monthly > 0
    ? (transactionSummary.monthlySpent / currentLimits.monthly) * 100
    : 0;

Overall, the drawer’s integration with useKYCStatus and TransactionLimitModal looks good.

app/lib/supabase.ts (1)

3-22: Align env validation for NEXT_PUBLIC_SUPABASE_URL with other Supabase envs

supabaseAdmin throws early if SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY are missing, but supabaseUrl can be undefined if NEXT_PUBLIC_SUPABASE_URL is misconfigured, pushing the failure to wherever it’s used.

To keep failures explicit and consistent, consider adding a simple guard:

 export const supabaseAdmin = createClient(
   process.env.SUPABASE_URL,
   process.env.SUPABASE_SERVICE_ROLE_KEY,
   {
     auth: {
       autoRefreshToken: false,
       persistSession: false,
     },
   }
 );
 
-export const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
+export const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
+if (!supabaseUrl) {
+  throw new Error("Missing env.NEXT_PUBLIC_SUPABASE_URL");
+}
app/api/phone/status/route.ts (1)

22-40: Consider logging Supabase fetchError separately for better observability

Right now, if the Supabase query returns a fetchError, the route still returns 200 with verified: false and no analytics entry for the DB error (only a generic trackApiResponse above).

If distinguishing “no record” from “DB failure” would help debugging, you could log/track the fetchError explicitly before falling back to the non-verified response, for example:

if (fetchError) {
  console.error("Supabase phone status fetch error:", fetchError);
  // optionally trackApiError(...) here
}

if (!verification) {
  return NextResponse.json({
    success: true,
    verified: false,
    phoneNumber: null,
    verifiedAt: null,
    provider: null,
  });
}

This keeps the user-facing behavior the same while making operational issues easier to spot.

app/api/kyc/smile-id/route.ts (1)

71-79: Revisit storing raw images payload in image_links

image_links is being set to JSON.stringify(images). Given this is biometric KYC data, storing full image payloads directly in user_kyc_profiles can:

  • Make the table heavy and slower to query.
  • Increase exposure of sensitive content in case of accidental wide queries or logs.

If the column is intended to store references (e.g. URLs or IDs managed by SmileID or object storage), consider persisting only those references instead of the raw images array. If regulatory requirements mandate storing images, it may be cleaner to keep them in a dedicated, access-controlled table or storage bucket and store minimal metadata here.

app/pages/TransactionForm.tsx (2)

107-108: Clarify units for usdAmount used in canTransact / limit checks

In handleSwap, you compute:

const formData = getValues();
const usdAmount = formData.amountReceived || 0;

// Check transaction limits based on KYC tier
const limitCheck = canTransact(usdAmount);

The comment says “Calculate the USD amount”, but amountReceived is the fiat amount in the selected payout currency, not necessarily USD. Since useKYCStatus.canTransact and the KYC tiers appear to be expressed in a single currency (displayed with $ in TransactionLimitModal), this is slightly misleading and could cause incorrect checks if limits are indeed USD-based.

Consider either:

  • Renaming the variable to something currency-agnostic (transactionAmount) if limits are intended to be in the payout currency, or
  • Converting amountReceived to the canonical currency used by your KYC limits (likely USD) before calling canTransact, to keep the comparison consistent.

This will make the behavior clearer and reduce future confusion around what units the limit checker expects.

Also applies to: 460-477


853-857: TransactionLimitModal integration and button callback look correct

Rendering TransactionLimitModal with isOpen, onClose, and transactionAmount={blockedTransactionAmount}, and passing () => setIsLimitModalOpen(true) into buttonAction keeps all “limit exceeded / need KYC upgrade” cases funneled through a single modal.

One small improvement you might consider later: when opening the modal from buttonAction (not handleSwap), pass the current intended receive amount into blockedTransactionAmount as well, so the modal can consistently reflect the attempted transaction size.

Also applies to: 887-888

app/hooks/usePhoneVerification.ts (1)

40-84: Tighten useCallback dependencies to avoid future stale closures

checkVerificationStatus closes over fetchGuards and guardKey but only depends on walletAddress. This works today because both derive from walletAddress/window, but it can become brittle if the guard key computation changes.

To keep this resilient and satisfy react-hooks/exhaustive-deps, consider:

-  const checkVerificationStatus = useCallback(async () => {
+  const checkVerificationStatus = useCallback(async () => {
     // ...
-  }, [walletAddress]);
+  }, [walletAddress, guardKey, fetchGuards]);

This ensures the guard logic always reflects the latest key/map while preserving behavior.

app/api/phone/verify-otp/route.ts (1)

51-65: Consider tracking analytics for expiry and max-attempts branches

The OTP expiry and “maximum attempts exceeded” paths return 400/429 without calling trackApiError or trackApiResponse. If you rely on analytics for monitoring abuse or UX friction, instrumenting these branches similarly to other error paths would make the metrics more complete.

This is non-blocking but worth aligning for observability.

app/api/phone/send-otp/route.ts (1)

28-35: Optional: add analytics for invalid phone number format

When !validation.isValid, the route returns a 400 but doesn’t log via trackApiError or trackApiResponse. If you care about measuring validation failures (e.g. country code/format mistakes), consider adding analytics here similar to the “missing required fields” case.

Non-blocking, just for observability consistency.

app/components/PhoneVerificationModal.tsx (1)

166-201: Clean up handleOtpSubmit dependencies / robustness (optional)

handleOtpSubmit’s dependency list includes onVerified and onClose, but they aren’t referenced in the function body. This is harmless but can confuse readers and hook linters.

Optionally either:

  • Remove them from the deps array, or
  • Use them (e.g. pass through success state here instead of in renderVerified).

Also, for extra safety you could guard against an unexpected empty formattedPhone before POSTing (defensive check), though the current flow already sets it on successful OTP send.

app/lib/phone-verification.ts (1)

146-150: Use a cryptographically stronger source for OTP generation

generateOTP currently uses Math.random(), which isn’t suitable for security‑sensitive codes:

export function generateOTP(): string {
  return Math.floor(100000 + Math.random() * 900000).toString();
}

For verification codes that gate KYC/limits, it’s safer to use a crypto-strength RNG, e.g.:

+import { randomInt } from 'crypto';
+
 export function generateOTP(): string {
-  return Math.floor(100000 + Math.random() * 900000).toString();
+  return randomInt(100000, 1000000).toString(); // 6 digits
 }

This keeps the UX the same while hardening against prediction.

app/components/KycModal.tsx (1)

621-625: Avoid logging potentially sensitive error details to console.

Error messages and stack traces may contain sensitive information (wallet addresses, user data) that should not be logged client-side.

     } catch (error) {
-      if (error instanceof Error) {
-        console.error("Error message:", error.message);
-        console.error("Error stack:", error.stack);
-      }
+      console.error("Failed to submit verification data");
       toast.error("Failed to submit verification data");
       setStep(STEPS.STATUS.FAILED);
     }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2fc36b0 and e90c5d3.

⛔ Files ignored due to path filters (1)
  • public/icons/placeholder.png is excluded by !**/*.png
📒 Files selected for processing (24)
  • .env.example (1 hunks)
  • app/api/aggregator.ts (1 hunks)
  • app/api/kyc/smile-id/route.ts (1 hunks)
  • app/api/kyc/status/route.ts (1 hunks)
  • app/api/kyc/transaction-summary/route.ts (1 hunks)
  • app/api/phone/send-otp/route.ts (1 hunks)
  • app/api/phone/status/route.ts (1 hunks)
  • app/api/phone/verify-otp/route.ts (1 hunks)
  • app/components/KycModal.tsx (14 hunks)
  • app/components/PhoneVerificationModal.tsx (1 hunks)
  • app/components/ProfileDrawer.tsx (1 hunks)
  • app/components/SettingsDropdown.tsx (4 hunks)
  • app/components/TransactionLimitModal.tsx (1 hunks)
  • app/components/index.ts (1 hunks)
  • app/hooks/useKYCStatus.ts (1 hunks)
  • app/hooks/usePhoneVerification.ts (1 hunks)
  • app/hooks/useSwapButton.ts (1 hunks)
  • app/lib/countries.ts (1 hunks)
  • app/lib/phone-verification.ts (1 hunks)
  • app/lib/smileID.ts (1 hunks)
  • app/lib/supabase.ts (2 hunks)
  • app/pages/TransactionForm.tsx (9 hunks)
  • package.json (3 hunks)
  • supabase/migrations/create_user_kyc_profiles.sql (1 hunks)
🧰 Additional context used
🧠 Learnings (3)
📚 Learning: 2025-11-06T07:37:39.036Z
Learnt from: Dprof-in-tech
Repo: paycrest/noblocks PR: 231
File: app/components/recipient/RecipientDetailsForm.tsx:539-552
Timestamp: 2025-11-06T07:37:39.036Z
Learning: In RecipientDetailsForm (app/components/recipient/RecipientDetailsForm.tsx), when isRecipientNameEditable is true (verification failed/returned "Ok"), the recipient safety alert should display when: isRecipientNameEditable && recipientName && !errors.recipientName && !recipientNameError. The !isFetchingRecipientName check is redundant because recipientName is cleared at fetch start and only populated after fetching completes or when the user manually enters it.

Applied to files:

  • app/components/PhoneVerificationModal.tsx
  • app/pages/TransactionForm.tsx
  • app/components/KycModal.tsx
📚 Learning: 2025-11-06T07:08:54.593Z
Learnt from: Dprof-in-tech
Repo: paycrest/noblocks PR: 231
File: app/components/recipient/RecipientDetailsForm.tsx:271-280
Timestamp: 2025-11-06T07:08:54.593Z
Learning: In the RecipientDetailsForm component (app/components/recipient/RecipientDetailsForm.tsx), Mobile Money institutions always return "Ok" from the fetchAccountName verification endpoint. Therefore, checking for accountName.toLowerCase() === "ok" is sufficient to handle both verification failures and Mobile Money institutions without needing explicit institution type checks in the frontend.

Applied to files:

  • app/components/PhoneVerificationModal.tsx
  • app/pages/TransactionForm.tsx
  • app/components/KycModal.tsx
📚 Learning: 2025-11-06T07:37:39.036Z
Learnt from: Dprof-in-tech
Repo: paycrest/noblocks PR: 231
File: app/components/recipient/RecipientDetailsForm.tsx:539-552
Timestamp: 2025-11-06T07:37:39.036Z
Learning: In RecipientDetailsForm (app/components/recipient/RecipientDetailsForm.tsx), when isRecipientNameEditable is false (verification succeeded), the recipient safety alert should display when: !isRecipientNameEditable && recipientName && !recipientNameError. The !errors.recipientName check is unnecessary because in non-editable mode the recipient name is displayed as read-only text (not an input field), so form validation errors cannot occur.

Applied to files:

  • app/pages/TransactionForm.tsx
🧬 Code graph analysis (14)
app/api/kyc/smile-id/route.ts (2)
app/lib/smileID.ts (1)
  • submitSmileIDJob (6-65)
app/lib/supabase.ts (1)
  • supabaseAdmin (11-20)
app/api/phone/status/route.ts (2)
app/api/kyc/status/route.ts (1)
  • GET (5-67)
app/lib/supabase.ts (1)
  • supabaseAdmin (11-20)
app/api/kyc/transaction-summary/route.ts (3)
app/api/kyc/status/route.ts (1)
  • GET (5-67)
app/api/phone/status/route.ts (1)
  • GET (5-59)
app/lib/supabase.ts (1)
  • supabaseAdmin (11-20)
app/hooks/useKYCStatus.ts (1)
app/api/aggregator.ts (1)
  • fetchKYCStatus (306-352)
app/components/SettingsDropdown.tsx (1)
app/components/ProfileDrawer.tsx (1)
  • ProfileDrawer (28-375)
app/components/PhoneVerificationModal.tsx (1)
app/lib/countries.ts (4)
  • Country (1-6)
  • fetchCountries (15-60)
  • searchCountries (95-104)
  • getPopularCountries (88-90)
app/api/aggregator.ts (2)
app/types.ts (1)
  • InitiateKYCResponse (231-238)
app/lib/server-analytics.ts (3)
  • trackServerEvent (130-175)
  • trackApiResponse (241-258)
  • trackBusinessEvent (362-375)
app/api/kyc/status/route.ts (3)
app/api/kyc/transaction-summary/route.ts (1)
  • GET (5-81)
app/api/phone/status/route.ts (1)
  • GET (5-59)
app/lib/supabase.ts (1)
  • supabaseAdmin (11-20)
app/components/ProfileDrawer.tsx (4)
app/hooks/useKYCStatus.ts (2)
  • useKYCStatus (66-187)
  • KYC_TIERS (21-46)
app/utils.ts (3)
  • classNames (23-25)
  • formatNumberWithCommas (56-60)
  • shortenAddress (536-545)
app/components/AnimatedComponents.tsx (1)
  • sidebarAnimation (117-127)
app/components/TransactionLimitModal.tsx (1)
  • TransactionLimitModal (25-221)
app/components/TransactionLimitModal.tsx (5)
app/hooks/useKYCStatus.ts (2)
  • useKYCStatus (66-187)
  • KYC_TIERS (21-46)
app/hooks/usePhoneVerification.ts (1)
  • usePhoneVerification (19-106)
app/utils.ts (1)
  • formatNumberWithCommas (56-60)
app/components/PhoneVerificationModal.tsx (1)
  • PhoneVerificationModal (28-536)
app/components/KycModal.tsx (1)
  • KycModal (57-670)
app/pages/TransactionForm.tsx (2)
app/hooks/useKYCStatus.ts (1)
  • useKYCStatus (66-187)
app/components/TransactionLimitModal.tsx (1)
  • TransactionLimitModal (25-221)
app/api/phone/verify-otp/route.ts (3)
app/api/kyc/smile-id/route.ts (1)
  • POST (5-113)
app/api/phone/send-otp/route.ts (1)
  • POST (11-100)
app/lib/supabase.ts (1)
  • supabaseAdmin (11-20)
app/components/KycModal.tsx (4)
app/components/ImageAssets.tsx (3)
  • UserDetailsIcon (677-729)
  • SadFaceIcon (771-807)
  • VerificationPendingIcon (731-770)
app/components/Styles.ts (1)
  • secondaryBtnClasses (6-6)
app/api/aggregator.ts (1)
  • submitSmileIDData (680-724)
app/hooks/analytics/useMixpanel.ts (1)
  • trackEvent (91-110)
app/api/phone/send-otp/route.ts (2)
app/lib/phone-verification.ts (4)
  • validatePhoneNumber (37-67)
  • generateOTP (149-151)
  • sendTermiiOTP (72-115)
  • sendTwilioOTP (120-144)
app/lib/supabase.ts (1)
  • supabaseAdmin (11-20)
🔇 Additional comments (10)
package.json (1)

36-73: New KYC/phone-related dependencies align with the rest of the PR

The additions of @smileid/web-components, smile-identity-core, libphonenumber-js, and twilio match the SmileID and phone verification flows wired up elsewhere in the PR. As long as Twilio and SmileID core are only imported from server routes (never client components), this setup looks consistent.

app/components/index.ts (1)

30-31: Re-exports look consistent with new modal components

Exporting PhoneVerificationModal and TransactionLimitModal from the central components index is consistent with how other modals are exposed and will simplify imports for callers.

app/hooks/useKYCStatus.ts (1)

66-186: KYC status hook structure looks solid

The overall design of useKYCStatus—per-wallet fetch guards, consolidated KYC + transaction summary state, and helper methods for limits and canTransact—looks coherent and appropriate for the flows consuming it. I don’t see any blocking issues here.

app/components/SettingsDropdown.tsx (1)

22-29: Profile drawer wiring from settings dropdown looks correct

The new “Profile” menu item and isProfileDrawerOpen state correctly open the ProfileDrawer while closing the dropdown, and the drawer’s onClose cleanly resets its open state. No functional issues spotted with this integration.

Also applies to: 39-40, 273-288, 314-317

app/pages/TransactionForm.tsx (1)

72-87: KYC status hook + limit modal state wiring looks consistent

Using useKYCStatus for canTransact, tier, isPhoneVerified, and refreshStatus, plus the new isLimitModalOpen / blockedTransactionAmount state, fits cleanly with the transaction-limit and KYC flows (including TransactionLimitModal). No functional issues spotted here.

app/components/KycModal.tsx (5)

8-16: LGTM: Standard web component type declaration.

The global JSX intrinsic element declaration with any type is appropriate for integrating third-party web components that lack TypeScript definitions.


200-240: LGTM: Consistent icon sizing improvements.

The icon size adjustments from size-5 to size-7 improve visual consistency, and the spacer div at line 240 maintains proper alignment for the policy link.


358-469: LGTM: Status rendering updates align with new capture flow.

The status rendering functions have been appropriately updated with new iconography (Clock05Icon), revised copy, and consistent styling that aligns with the in-app SmileID capture flow.


656-666: LGTM: Clean render pattern using object literal.

The refactored render logic using an object literal indexed by step is more concise and type-safe than a switch statement.


82-82: Remove unused isCapturing state.

The isCapturing state is declared but never set to true anywhere in the component, making the conditional rendering at lines 340-344 unreachable dead code.

-  const [isCapturing, setIsCapturing] = useState(false);

And remove the dead code:

-      {isCapturing && (
-      <div className="text-center text-sm text-gray-500">
-        Processing your verification...
-      </div>
-    )}

Also applies to: 340-344

⛔ Skipped due to learnings
Learnt from: Dprof-in-tech
Repo: paycrest/noblocks PR: 231
File: app/components/recipient/RecipientDetailsForm.tsx:539-552
Timestamp: 2025-11-06T07:37:39.036Z
Learning: In RecipientDetailsForm (app/components/recipient/RecipientDetailsForm.tsx), when isRecipientNameEditable is false (verification succeeded), the recipient safety alert should display when: !isRecipientNameEditable && recipientName && !recipientNameError. The !errors.recipientName check is unnecessary because in non-editable mode the recipient name is displayed as read-only text (not an input field), so form validation errors cannot occur.
Learnt from: Dprof-in-tech
Repo: paycrest/noblocks PR: 231
File: app/components/recipient/RecipientDetailsForm.tsx:539-552
Timestamp: 2025-11-06T07:37:39.036Z
Learning: In RecipientDetailsForm (app/components/recipient/RecipientDetailsForm.tsx), when isRecipientNameEditable is true (verification failed/returned "Ok"), the recipient safety alert should display when: isRecipientNameEditable && recipientName && !errors.recipientName && !recipientNameError. The !isFetchingRecipientName check is redundant because recipientName is cleared at fetch start and only populated after fetching completes or when the user manually enters it.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (5)
app/lib/phone-verification.ts (1)

4-8: Guard Twilio client initialization against missing env vars.

The Twilio client is created at module load using non-null assertions. If TWILIO_ACCOUNT_SID or TWILIO_AUTH_TOKEN are absent (e.g., local dev, tests, preview environments), this will throw during module import and break any route that depends on this file, even if Twilio isn't used.

Consider lazily initializing the client inside sendTwilioOTP with explicit checks, as suggested in the previous review.

app/api/kyc/smile-id/route.ts (1)

19-24: Add server-side wallet signature verification.

The route requires walletAddress, signature, and nonce but never verifies that the signature corresponds to the wallet and message. Any caller who completes a SmileID job can associate that result with any walletAddress without proving control of the wallet.

Verify the signature server-side before trusting walletAddress or updating the KYC profile, as detailed in the previous review.

app/components/TransactionLimitModal.tsx (1)

113-124: Guard progress bar width calculation against zero monthly limit.

For tier 0, getCurrentLimits() returns { monthly: 0 }, so line 123's division produces Infinity/NaN, resulting in an invalid width: "NaN%" style. This will display a broken progress bar for unverified users.

Guard against zero monthly limit and clamp to 0-100%, as suggested in the previous review.

app/components/KycModal.tsx (1)

150-154: Add validation before proceeding to CAPTURE step.

The code proceeds to STEPS.CAPTURE without verifying that SmileID components have loaded (smileIdLoaded is true). If loading failed due to network issues, the user will encounter a broken capture screen.

Check smileIdLoaded before advancing to CAPTURE, as detailed in the previous review.

supabase/migrations/create_user_kyc_profiles.sql (1)

1-20: ⚠️ Plaintext OTP and PII storage — security & compliance risk (duplicate of past feedback).

This table stores sensitive fields in plaintext that create immediate DB-compromise and GDPR/compliance risk:

  • Line 6: phone_number TEXT NOT NULL — plaintext phone enables bulk harvesting on DB breach or backup theft.
  • Line 8: otp_code TEXT — plaintext OTP enables immediate reuse; attacker with DB access can verify/exhaust 2FA.
  • Lines 13–14: id_info JSONB, image_links JSONB — likely contain KYC documents (facial images, ID scans); plaintext JSONB = raw document exposure.

This issue was already raised in detail in past review comments (lines 4–18). Recommended fixes: hash/HMAC OTP with server KEK (KMS-managed), use deterministic or randomized field-level encryption for phone/PII, avoid plaintext JSONB for docs.

🧹 Nitpick comments (5)
app/components/KycModal.tsx (1)

641-641: Remove debug console.log statement.

Production code should not contain debug logging in event handlers.

Apply this diff:

   const handleBack = (event: any) => {
-    console.log("Back detail:", event.detail);
   };
supabase/migrations/create_user_kyc_profiles.sql (4)

28-34: Apply PL/pgSQL best practice: use CURRENT_TIMESTAMP instead of NOW().

Lines 31 and 34 use NOW(). PostgreSQL best practice is to prefer CURRENT_TIMESTAMP for consistency and to avoid function call overhead in triggers. Both are equivalent, but CURRENT_TIMESTAMP is standard SQL.

  CREATE OR REPLACE FUNCTION update_user_kyc_profiles_updated_at()
  RETURNS TRIGGER AS $$
  BEGIN
-   NEW.updated_at = NOW();
+   NEW.updated_at = CURRENT_TIMESTAMP;
    RETURN NEW;
  END;
  $$ language 'plpgsql';

43-52: Verify RLS context setup and document wallet_address injection point.

Line 48 RLS policy uses current_setting('app.wallet_address', true) to gate access. This assumes the application layer sets this context before issuing queries. If not set correctly:

  • RLS policy silently permits no rows (fails open? or closed?).
  • Test coverage may miss the misconfiguration.

Ensure the application layer (API routes, client library) explicitly calls:

SET app.wallet_address TO <wallet_address>;
-- or via Supabase client library (if available)

Add a comment or ADR documenting this dependency.


9-9: Add automatic cleanup trigger or scheduled job for expired OTPs.

Line 9 (expires_at) tracks OTP expiry, but there is no mechanism to delete expired or used OTP records. This accumulates stale data and increases DB size/query cost over time. Additionally, line 17 (attempts) tracks failed attempts but has no logic to reset or enforce limits.

Add a database trigger (on successful OTP verification) to NULL out otp_code and attempts, or add a pg_cron job to delete records where expires_at < NOW() and verified = true.

-- Example: auto-cleanup expired OTP records (run via pg_cron)
DELETE FROM user_kyc_profiles
WHERE expires_at < NOW() - INTERVAL '1 day'
  AND verified = true;

Also applies to: 17-17


4-5: Consider adding foreign key and additional constraints for data integrity.

  • Line 4 (wallet_address PRIMARY KEY): Assumes wallet address uniquely identifies a user. If a user can have multiple wallets or if there's a separate users table, add a FK to users(id) or clarify the identity model.
  • Line 12 (smile_job_id): Likely references Smile ID external API; consider adding a NOT NULL constraint once Tier 2 users complete Smile ID verification (or ensure nullable is intentional for Tier 1–only users).
  • Line 15 (tier CHECK): Good constraint; clarify in code comments that tier progression is: 0 (unverified) → 1 (phone verified) → 2 (full KYC via Smile ID).

Also applies to: 12-12, 15-15

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e90c5d3 and a9e355b.

📒 Files selected for processing (10)
  • app/api/aggregator.ts (2 hunks)
  • app/api/kyc/smile-id/route.ts (1 hunks)
  • app/api/phone/send-otp/route.ts (1 hunks)
  • app/api/phone/verify-otp/route.ts (1 hunks)
  • app/components/KycModal.tsx (14 hunks)
  • app/components/PhoneVerificationModal.tsx (1 hunks)
  • app/components/TransactionLimitModal.tsx (1 hunks)
  • app/lib/phone-verification.ts (1 hunks)
  • app/types.ts (1 hunks)
  • supabase/migrations/create_user_kyc_profiles.sql (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/api/phone/verify-otp/route.ts
🧰 Additional context used
🧠 Learnings (5)
📚 Learning: 2025-11-06T07:37:39.036Z
Learnt from: Dprof-in-tech
Repo: paycrest/noblocks PR: 231
File: app/components/recipient/RecipientDetailsForm.tsx:539-552
Timestamp: 2025-11-06T07:37:39.036Z
Learning: In RecipientDetailsForm (app/components/recipient/RecipientDetailsForm.tsx), when isRecipientNameEditable is true (verification failed/returned "Ok"), the recipient safety alert should display when: isRecipientNameEditable && recipientName && !errors.recipientName && !recipientNameError. The !isFetchingRecipientName check is redundant because recipientName is cleared at fetch start and only populated after fetching completes or when the user manually enters it.

Applied to files:

  • app/components/PhoneVerificationModal.tsx
  • app/components/TransactionLimitModal.tsx
  • app/lib/phone-verification.ts
  • app/components/KycModal.tsx
📚 Learning: 2025-11-06T07:37:39.036Z
Learnt from: Dprof-in-tech
Repo: paycrest/noblocks PR: 231
File: app/components/recipient/RecipientDetailsForm.tsx:539-552
Timestamp: 2025-11-06T07:37:39.036Z
Learning: In RecipientDetailsForm (app/components/recipient/RecipientDetailsForm.tsx), when isRecipientNameEditable is false (verification succeeded), the recipient safety alert should display when: !isRecipientNameEditable && recipientName && !recipientNameError. The !errors.recipientName check is unnecessary because in non-editable mode the recipient name is displayed as read-only text (not an input field), so form validation errors cannot occur.

Applied to files:

  • app/components/PhoneVerificationModal.tsx
  • app/components/TransactionLimitModal.tsx
  • app/components/KycModal.tsx
📚 Learning: 2025-10-10T16:44:32.125Z
Learnt from: Dprof-in-tech
Repo: paycrest/noblocks PR: 244
File: app/components/CopyAddressWarningModal.tsx:48-52
Timestamp: 2025-10-10T16:44:32.125Z
Learning: In the CopyAddressWarningModal component (app/components/CopyAddressWarningModal.tsx), selectedNetwork from useNetwork() is always defined and does not require null safety checks when accessing its properties like selectedNetwork.chain.name.

Applied to files:

  • app/components/PhoneVerificationModal.tsx
  • app/components/KycModal.tsx
📚 Learning: 2025-11-06T07:08:54.593Z
Learnt from: Dprof-in-tech
Repo: paycrest/noblocks PR: 231
File: app/components/recipient/RecipientDetailsForm.tsx:271-280
Timestamp: 2025-11-06T07:08:54.593Z
Learning: In the RecipientDetailsForm component (app/components/recipient/RecipientDetailsForm.tsx), Mobile Money institutions always return "Ok" from the fetchAccountName verification endpoint. Therefore, checking for accountName.toLowerCase() === "ok" is sufficient to handle both verification failures and Mobile Money institutions without needing explicit institution type checks in the frontend.

Applied to files:

  • app/components/TransactionLimitModal.tsx
  • app/components/KycModal.tsx
📚 Learning: 2025-10-13T14:37:45.409Z
Learnt from: sundayonah
Repo: paycrest/noblocks PR: 250
File: app/components/AppLayout.tsx:33-46
Timestamp: 2025-10-13T14:37:45.409Z
Learning: In TypeScript/JavaScript/React codebases, JSX whitespace expressions like `{" "}` between elements should be classified as style/nitpick issues, not "Potential issues" or problems that affect functionality. These are purely cosmetic concerns about code cleanliness.

Applied to files:

  • app/components/KycModal.tsx
🧬 Code graph analysis (6)
app/api/aggregator.ts (2)
app/types.ts (1)
  • SmileIDSubmissionResponse (240-248)
app/lib/server-analytics.ts (3)
  • trackServerEvent (130-175)
  • trackApiResponse (241-258)
  • trackBusinessEvent (362-375)
app/components/PhoneVerificationModal.tsx (1)
app/lib/countries.ts (4)
  • Country (1-6)
  • fetchCountries (15-60)
  • searchCountries (95-104)
  • getPopularCountries (88-90)
app/components/TransactionLimitModal.tsx (5)
app/hooks/useKYCStatus.ts (2)
  • useKYCStatus (66-187)
  • KYC_TIERS (21-46)
app/hooks/usePhoneVerification.ts (1)
  • usePhoneVerification (19-106)
app/utils.ts (1)
  • formatNumberWithCommas (56-60)
app/components/PhoneVerificationModal.tsx (1)
  • PhoneVerificationModal (28-536)
app/components/KycModal.tsx (1)
  • KycModal (57-673)
app/api/kyc/smile-id/route.ts (4)
app/api/phone/send-otp/route.ts (1)
  • POST (11-103)
app/api/phone/verify-otp/route.ts (1)
  • POST (7-132)
app/lib/smileID.ts (1)
  • submitSmileIDJob (6-65)
app/lib/supabase.ts (1)
  • supabaseAdmin (11-20)
app/components/KycModal.tsx (2)
app/components/Styles.ts (1)
  • secondaryBtnClasses (6-6)
app/api/aggregator.ts (1)
  • submitSmileIDData (681-724)
app/api/phone/send-otp/route.ts (2)
app/lib/phone-verification.ts (4)
  • validatePhoneNumber (43-75)
  • generateOTP (157-159)
  • sendTermiiOTP (80-123)
  • sendTwilioOTP (128-152)
app/lib/supabase.ts (1)
  • supabaseAdmin (11-20)
🔇 Additional comments (7)
app/api/aggregator.ts (1)

673-724: LGTM!

The function correctly uses SmileIDSubmissionResponse as return type and avoids logging sensitive KYC payload data. Analytics tracking includes only non-PII metadata (wallet_address, jobId).

app/api/kyc/smile-id/route.ts (1)

92-99: Good addition: Row update verification.

The check for updatedProfile.length === 0 ensures that a KYC profile row was actually updated and returns 404 if not. This prevents silent failures when the phone verification step hasn't created a profile yet.

app/api/phone/send-otp/route.ts (1)

42-75: LGTM!

The code now correctly uses provider-specific phone formats: e164Format (no spaces) for Twilio and storage, digitsOnly (no + prefix) for Termii. This addresses the previous review concerns about provider compatibility.

app/components/PhoneVerificationModal.tsx (1)

114-164: LGTM!

The handlePhoneSubmit dependency array now correctly includes selectedCountry and name, ensuring the OTP is sent with the current country code and name. This addresses the previous review concern.

app/components/KycModal.tsx (1)

597-599: Good addition: Validation before submission.

The validation checks ensure that walletAddress, kycSignature, and kycNonce are present before constructing the payload. This prevents submitting invalid data to the backend.

supabase/migrations/create_user_kyc_profiles.sql (2)

27-40: Verify that update_user_kyc_profiles_updated_at function is not duplicated elsewhere in migration.

Past review comments indicate lines 27–41 and 54–66 both define the same function and trigger. The provided code shows only lines 27–40. If the duplicate exists in the full file, remove lines 54–66 to keep one function and one trigger.


23-23: Remove this review comment—it references a non-existent security requirement.

The migration file contains no security recommendation about phone_number encryption above line 23. Phone numbers are stored plaintext by design: the verification API requires equality queries on phone_number (in send-otp, verify-otp, and status endpoints), which precludes encryption. The index on phone_number is appropriate for these queryable fields. This reflects an architectural decision, not a security oversight.

Likely an incorrect or invalid review comment.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
app/components/TransactionLimitModal.tsx (2)

55-59: Redundant undefined check for tier.

The tier === undefined check on line 56 is unnecessary because tier is typed as 0 | 1 | 2 in useKYCStatus and initialized to 0. TypeScript's type system ensures tier can never be undefined.

Apply this diff to simplify the condition:

-    useEffect(() => {
-        if (isOpen && !isLoading && (tier < 1 || tier === undefined)) {
-            setIsPhoneModalOpen(true);
-        }
-    }, [isOpen, isLoading, tier]);
+    useEffect(() => {
+        if (isOpen && !isLoading && tier < 1) {
+            setIsPhoneModalOpen(true);
+        }
+    }, [isOpen, isLoading, tier]);

67-70: Revisit: parent onClose still called twice on phone verification.

Following up on the previous discussion: when PhoneVerificationModal succeeds, it calls both onVerified(formattedPhone) and onClose() (per the component's implementation). This means:

  1. onVerified fires → handlePhoneVerified runs → calls onClose() (closes parent)
  2. onClose fires → the prop at lines 202-205 runs → calls onClose() (closes parent again)

While calling setIsPhoneModalOpen(false) twice is harmless, invoking the parent onClose() twice could cause unintended side effects depending on what that callback does elsewhere in the app.

To eliminate the redundant call while preserving explicit control over the phone modal state, consider:

 const handlePhoneVerified = async (phoneNumber: string) => {
+    await Promise.all([refreshStatus(), refreshPhoneStatus()]);
     setIsPhoneModalOpen(false);
-    onClose();
+    // onClose will be called by PhoneVerificationModal's onClose prop
 };

This way, handlePhoneVerified refreshes state and closes the phone modal, while the PhoneVerificationModal's onClose prop (lines 202–205) handles closing the parent.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a9e355b and 1069784.

📒 Files selected for processing (2)
  • .env.example (1 hunks)
  • app/components/TransactionLimitModal.tsx (1 hunks)
🧰 Additional context used
🧠 Learnings (3)
📚 Learning: 2025-11-06T07:37:39.036Z
Learnt from: Dprof-in-tech
Repo: paycrest/noblocks PR: 231
File: app/components/recipient/RecipientDetailsForm.tsx:539-552
Timestamp: 2025-11-06T07:37:39.036Z
Learning: In RecipientDetailsForm (app/components/recipient/RecipientDetailsForm.tsx), when isRecipientNameEditable is true (verification failed/returned "Ok"), the recipient safety alert should display when: isRecipientNameEditable && recipientName && !errors.recipientName && !recipientNameError. The !isFetchingRecipientName check is redundant because recipientName is cleared at fetch start and only populated after fetching completes or when the user manually enters it.

Applied to files:

  • app/components/TransactionLimitModal.tsx
📚 Learning: 2025-11-06T07:37:39.036Z
Learnt from: Dprof-in-tech
Repo: paycrest/noblocks PR: 231
File: app/components/recipient/RecipientDetailsForm.tsx:539-552
Timestamp: 2025-11-06T07:37:39.036Z
Learning: In RecipientDetailsForm (app/components/recipient/RecipientDetailsForm.tsx), when isRecipientNameEditable is false (verification succeeded), the recipient safety alert should display when: !isRecipientNameEditable && recipientName && !recipientNameError. The !errors.recipientName check is unnecessary because in non-editable mode the recipient name is displayed as read-only text (not an input field), so form validation errors cannot occur.

Applied to files:

  • app/components/TransactionLimitModal.tsx
📚 Learning: 2025-11-06T07:08:54.593Z
Learnt from: Dprof-in-tech
Repo: paycrest/noblocks PR: 231
File: app/components/recipient/RecipientDetailsForm.tsx:271-280
Timestamp: 2025-11-06T07:08:54.593Z
Learning: In the RecipientDetailsForm component (app/components/recipient/RecipientDetailsForm.tsx), Mobile Money institutions always return "Ok" from the fetchAccountName verification endpoint. Therefore, checking for accountName.toLowerCase() === "ok" is sufficient to handle both verification failures and Mobile Money institutions without needing explicit institution type checks in the frontend.

Applied to files:

  • app/components/TransactionLimitModal.tsx
🧬 Code graph analysis (1)
app/components/TransactionLimitModal.tsx (5)
app/hooks/useKYCStatus.ts (2)
  • useKYCStatus (66-187)
  • KYC_TIERS (21-46)
app/hooks/usePhoneVerification.ts (1)
  • usePhoneVerification (19-106)
app/utils.ts (1)
  • formatNumberWithCommas (56-60)
app/components/PhoneVerificationModal.tsx (1)
  • PhoneVerificationModal (28-536)
app/components/KycModal.tsx (1)
  • KycModal (57-673)
🪛 dotenv-linter (4.0.0)
.env.example

[warning] 113-113: [ValueWithoutQuotes] This value needs to be surrounded in quotes

(ValueWithoutQuotes)


[warning] 114-114: [UnorderedKey] The SMILE_IDENTITY_API_KEY key should go before the SMILE_IDENTITY_BASE_URL key

(UnorderedKey)


[warning] 116-116: [SpaceCharacter] The line has spaces around equal sign

(SpaceCharacter)


[warning] 116-116: [ValueWithoutQuotes] This value needs to be surrounded in quotes

(ValueWithoutQuotes)


[warning] 117-117: [UnorderedKey] The SMILE_IDENTITY_SERVER key should go before the SMILE_ID_CALLBACK_URL key

(UnorderedKey)


[warning] 117-117: [ValueWithoutQuotes] This value needs to be surrounded in quotes

(ValueWithoutQuotes)

🔇 Additional comments (2)
app/components/TransactionLimitModal.tsx (2)

113-132: Progress bar guard implemented correctly.

The division-by-zero guard for tier 0 users (when currentLimits.monthly is 0) has been properly implemented. The progress bar now safely displays "0%" instead of "NaN%" when the monthly limit is zero, and correctly clamps the percentage to a maximum of 100%.


209-221: Verify: Missing state refresh and user tracking after KYC completion.

Two potential issues with the KYC modal integration:

  1. Empty callback: setIsUserVerified={() => { }} (line 217) passes an empty function to KycModal. If the parent component should track when KYC verification succeeds (to potentially refresh limits or update UI), this callback may need implementation.

  2. No status refresh after KYC: Unlike handlePhoneVerified (which could call refreshStatus()), there's no explicit refresh of KYC status after the KYC modal closes successfully. While KycModal may handle internal state updates, the TransactionLimitModal won't reflect the new tier/limits until the user reopens it or the page refreshes.

Consider:

                     <AnimatedModal
                         isOpen={isKycModalOpen}
-                        onClose={() => { setIsKycModalOpen(false); onClose(); }}
+                        onClose={async () => {
+                            setIsKycModalOpen(false);
+                            await refreshStatus(); // Refresh to reflect new tier
+                            onClose();
+                        }}
                     >
                         <KycModal
                             setIsKycModalOpen={setIsKycModalOpen}
-                            setIsUserVerified={() => { }}
+                            setIsUserVerified={async (verified) => {
+                                if (verified) {
+                                    await refreshStatus();
+                                }
+                            }}
                         />
                     </AnimatedModal>

Verify whether KycModal already triggers refreshStatus via a different mechanism, or if this refresh should be added here.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (3)
app/api/phone/verify-otp/route.ts (3)

62-76: Add error tracking for observability.

The expiration and max-attempts checks return error responses but don't call trackApiError, unlike other error paths in this handler (lines 18, 28, 44, 88, 117, 136). This reduces observability.

Apply this diff to add consistent error tracking:

     // Check expiration
     if (new Date() > new Date(verification.expires_at)) {
+      trackApiError(request, '/api/phone/verify-otp', 'POST', new Error('OTP expired'), 400);
       return NextResponse.json(
         { success: false, error: 'OTP has expired. Please request a new one.' },
         { status: 400 }
       );
     }
 
     // Check attempts
     if (verification.attempts >= MAX_ATTEMPTS) {
+      trackApiError(request, '/api/phone/verify-otp', 'POST', new Error('Max attempts exceeded'), 429);
       return NextResponse.json(
         { success: false, error: 'Maximum verification attempts exceeded. Please request a new OTP.' },
         { status: 429 }
       );
     }

105-113: Minor: Fix whitespace formatting.

Line 110 has inconsistent spacing (space before the comma).

Apply this diff:

       .update({ 
         verified: true, 
-        verified_at: new Date().toISOString() ,
+        verified_at: new Date().toISOString(),
         tier: 1
       })

127-132: Consider returning the normalized phone number for consistency.

The response returns the original phoneNumber from the request body rather than the normalized E.164 format stored in the database. While this may be intentional for UX, it could lead to inconsistency if other endpoints return the normalized format.

Optionally, return the normalized format for consistency:

     return NextResponse.json({
       success: true,
       message: 'Phone number verified successfully',
       verified: true,
-      phoneNumber: phoneNumber
+      phoneNumber: validation.e164Format
     });

Or use property shorthand if keeping the original:

-      phoneNumber: phoneNumber
+      phoneNumber
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1069784 and 8beb003.

📒 Files selected for processing (1)
  • app/api/phone/verify-otp/route.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
app/api/phone/verify-otp/route.ts (3)
app/api/phone/send-otp/route.ts (1)
  • POST (11-103)
app/lib/phone-verification.ts (1)
  • validatePhoneNumber (43-75)
app/lib/supabase.ts (1)
  • supabaseAdmin (11-20)
🔇 Additional comments (5)
app/api/phone/verify-otp/route.ts (5)

1-6: LGTM!

Imports and constants are appropriate for the OTP verification flow.


17-33: LGTM!

Input validation is thorough and properly normalizes phone numbers to E.164 format for consistent querying.


35-49: LGTM!

Verification record lookup correctly uses normalized E.164 format and lowercased wallet address for consistent querying.


51-60: LGTM!

Idempotent check for already-verified records is appropriate and prevents unnecessary processing.


134-142: LGTM!

Catch-all error handling appropriately logs, tracks, and returns a generic 500 response.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (3)
app/components/KycModal.tsx (3)

489-493: Remove commented-out code.

The commented lines at 490-491 are from the old QR-based flow and are no longer needed.

Apply this diff:

       if (newStatus === STEPS.STATUS.PENDING) {
-        // setKycUrl(response.data.url);
-        // setIsKycModalOpen(true);
         return;
       }

628-630: Remove debug console.log statement.

The console.log at Line 629 should be removed for production code.

Apply this diff:

     const handleBack = (event: any) => {
-      console.log("Back detail:", event.detail);
     };

145-149: Add validation before proceeding to CAPTURE step.

The code proceeds to the CAPTURE step without verifying that SmileID components have successfully loaded. If smileIdLoaded is false (e.g., due to network issues or loading failure), the user will encounter a broken capture screen.

Apply this diff to add a guard:

        setKycSignature(sigWithoutPrefix);
        setKycNonce(nonce);

-        // Skip old KYC initiation since we're using Smile ID
-        setStep(STEPS.CAPTURE);
+        // Check if SmileID components are loaded before proceeding
+        if (smileIdLoaded) {
+          setStep(STEPS.CAPTURE);
+        } else {
+          toast.error("Verification component not ready. Please try again.");
+          setIsKycModalOpen(false);
+          setStep(STEPS.TERMS);
+        }
🧹 Nitpick comments (5)
app/components/TransactionLimitModal.tsx (2)

59-62: Refresh KYC status after phone verification.

After successful phone verification, the KYC status (tier, limits, etc.) should be refreshed so the user sees their updated tier when this modal reopens. Currently, the status remains stale until the next time the modal is opened.

Apply this diff to refresh status after verification:

 const handlePhoneVerified = async (phoneNumber: string) => {
+    await refreshStatus();
     setIsPhoneModalOpen(false);
     onClose();
 };

207-210: Consider handling KYC completion state.

The setIsUserVerified prop is passed as an empty function, meaning successful Tier 2 KYC completion won't update any state in this component. If the parent component needs to know about KYC completion, consider passing a real handler or refreshing status when KycModal closes.

If status tracking is needed:

 <KycModal
     setIsKycModalOpen={setIsKycModalOpen}
-    setIsUserVerified={() => { }}
+    setIsUserVerified={async () => {
+        await refreshStatus();
+    }}
 />
app/components/KycModal.tsx (1)

83-95: Remove debug console.log statement.

The console.log at Line 87 should be removed for production code.

Apply this diff:

     import("@smileid/web-components/smart-camera-web")
       .then(() => {
-        console.log("SmileID web components loaded");
         setSmileIdLoaded(true);
       })
app/api/phone/verify-otp/route.ts (2)

62-76: Add analytics logging for OTP expiry/limit/invalid flows

The business rules for Line 62–76 and Line 78–114 look right, but these key 4xx outcomes aren’t currently logged via trackApiError or trackApiResponse, which makes it harder to monitor OTP failures vs system errors.

Consider adding explicit error tracking in these branches, e.g.:

     // Check expiration
     if (new Date() > new Date(verification.expires_at)) {
+      trackApiError(request, '/api/phone/verify-otp', 'POST', new Error('OTP expired'), 400);
       return NextResponse.json(
         { success: false, error: 'OTP has expired. Please request a new one.' },
         { status: 400 }
       );
     }

     // Check attempts
     if (verification.attempts >= MAX_ATTEMPTS) {
+      trackApiError(request, '/api/phone/verify-otp', 'POST', new Error('Maximum verification attempts exceeded'), 429);
       return NextResponse.json(
         { success: false, error: 'Maximum verification attempts exceeded. Please request a new OTP.' },
         { status: 429 }
       );
     }

     // Verify OTP
     if (verification.otp_code !== otpCode) {
       // Atomic increment with boundary check to prevent race conditions
       const { data: updated, error: attemptsError } = await supabaseAdmin
         .from('user_kyc_profiles')
         .update({ attempts: verification.attempts + 1 })
         .eq('wallet_address', walletAddress.toLowerCase())
         .lt('attempts', MAX_ATTEMPTS)
         .select('attempts')
         .single();

       if (attemptsError) {
         console.error('Failed to increment OTP attempts:', attemptsError);
         trackApiError(request, '/api/phone/verify-otp', 'POST', attemptsError, 500);
         return NextResponse.json(
           { success: false, error: 'Failed to process verification attempt' },
           { status: 500 }
         );
       }

       // If no rows updated, attempts limit was hit mid-flight (race condition)
       if (!updated) {
+        trackApiError(request, '/api/phone/verify-otp', 'POST', new Error('Maximum verification attempts exceeded'), 429);
         return NextResponse.json(
           { success: false, error: 'Maximum verification attempts exceeded. Please request a new OTP.' },
           { status: 429 }
         );
       }

+      trackApiError(request, '/api/phone/verify-otp', 'POST', new Error('Invalid OTP code'), 400);
       return NextResponse.json(
         {
           success: false,
           error: 'Invalid OTP code',
           attemptsRemaining: MAX_ATTEMPTS - updated.attempts
         },
         { status: 400 }
       );
     }

This keeps analytics for verification failures on par with the /send-otp route.

Also applies to: 78-114


116-143: Avoid unintentionally downgrading existing KYC tier when marking phone verified

On Line 119–123 you always set tier: 1 when the OTP is correct. If this route is ever hit for a profile that already has a higher tier (e.g., after SmileID full KYC), this would silently down-tier them.

If higher tiers are possible on user_kyc_profiles, consider preserving the maximum tier instead of forcing 1, for example by only updating when the current tier is lower, or using a DB-side GREATEST:

-    const { error: updateError } = await supabaseAdmin
-      .from('user_kyc_profiles')
-      .update({ 
-        verified: true, 
-        verified_at: new Date().toISOString() ,
-        tier: 1
-      })
-      .eq('wallet_address', walletAddress.toLowerCase());
+    const { error: updateError } = await supabaseAdmin
+      .from('user_kyc_profiles')
+      .update({
+        verified: true,
+        verified_at: new Date().toISOString(),
+        // e.g. leave tier unchanged if already >= 1
+        tier: 1,
+      })
+      .eq('wallet_address', walletAddress.toLowerCase());
+      // Optionally add a condition or DB-side expression to avoid lowering a higher tier
+      // .lte('tier', 1)

Adjust the exact logic to match how tiers are modeled elsewhere in the KYC flow.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8beb003 and bc48350.

📒 Files selected for processing (3)
  • app/api/phone/verify-otp/route.ts (1 hunks)
  • app/components/KycModal.tsx (14 hunks)
  • app/components/TransactionLimitModal.tsx (1 hunks)
🧰 Additional context used
🧠 Learnings (4)
📚 Learning: 2025-11-06T07:37:39.036Z
Learnt from: Dprof-in-tech
Repo: paycrest/noblocks PR: 231
File: app/components/recipient/RecipientDetailsForm.tsx:539-552
Timestamp: 2025-11-06T07:37:39.036Z
Learning: In RecipientDetailsForm (app/components/recipient/RecipientDetailsForm.tsx), when isRecipientNameEditable is true (verification failed/returned "Ok"), the recipient safety alert should display when: isRecipientNameEditable && recipientName && !errors.recipientName && !recipientNameError. The !isFetchingRecipientName check is redundant because recipientName is cleared at fetch start and only populated after fetching completes or when the user manually enters it.

Applied to files:

  • app/components/TransactionLimitModal.tsx
  • app/components/KycModal.tsx
📚 Learning: 2025-11-06T07:37:39.036Z
Learnt from: Dprof-in-tech
Repo: paycrest/noblocks PR: 231
File: app/components/recipient/RecipientDetailsForm.tsx:539-552
Timestamp: 2025-11-06T07:37:39.036Z
Learning: In RecipientDetailsForm (app/components/recipient/RecipientDetailsForm.tsx), when isRecipientNameEditable is false (verification succeeded), the recipient safety alert should display when: !isRecipientNameEditable && recipientName && !recipientNameError. The !errors.recipientName check is unnecessary because in non-editable mode the recipient name is displayed as read-only text (not an input field), so form validation errors cannot occur.

Applied to files:

  • app/components/TransactionLimitModal.tsx
  • app/components/KycModal.tsx
📚 Learning: 2025-11-06T07:08:54.593Z
Learnt from: Dprof-in-tech
Repo: paycrest/noblocks PR: 231
File: app/components/recipient/RecipientDetailsForm.tsx:271-280
Timestamp: 2025-11-06T07:08:54.593Z
Learning: In the RecipientDetailsForm component (app/components/recipient/RecipientDetailsForm.tsx), Mobile Money institutions always return "Ok" from the fetchAccountName verification endpoint. Therefore, checking for accountName.toLowerCase() === "ok" is sufficient to handle both verification failures and Mobile Money institutions without needing explicit institution type checks in the frontend.

Applied to files:

  • app/components/TransactionLimitModal.tsx
  • app/components/KycModal.tsx
📚 Learning: 2025-10-13T14:37:45.409Z
Learnt from: sundayonah
Repo: paycrest/noblocks PR: 250
File: app/components/AppLayout.tsx:33-46
Timestamp: 2025-10-13T14:37:45.409Z
Learning: In TypeScript/JavaScript/React codebases, JSX whitespace expressions like `{" "}` between elements should be classified as style/nitpick issues, not "Potential issues" or problems that affect functionality. These are purely cosmetic concerns about code cleanliness.

Applied to files:

  • app/components/KycModal.tsx
🧬 Code graph analysis (3)
app/components/TransactionLimitModal.tsx (4)
app/hooks/useKYCStatus.ts (2)
  • useKYCStatus (66-187)
  • KYC_TIERS (21-46)
app/utils.ts (1)
  • formatNumberWithCommas (56-60)
app/components/PhoneVerificationModal.tsx (1)
  • PhoneVerificationModal (28-536)
app/components/KycModal.tsx (1)
  • KycModal (55-661)
app/api/phone/verify-otp/route.ts (3)
app/api/phone/send-otp/route.ts (1)
  • POST (11-103)
app/lib/phone-verification.ts (1)
  • validatePhoneNumber (43-75)
app/lib/supabase.ts (1)
  • supabaseAdmin (11-20)
app/components/KycModal.tsx (4)
app/components/ImageAssets.tsx (3)
  • UserDetailsIcon (677-729)
  • SadFaceIcon (771-807)
  • VerificationPendingIcon (731-770)
app/components/Styles.ts (1)
  • secondaryBtnClasses (6-6)
app/api/aggregator.ts (1)
  • submitSmileIDData (681-724)
app/hooks/analytics/useMixpanel.ts (1)
  • trackEvent (91-110)
🔇 Additional comments (1)
app/api/phone/verify-otp/route.ts (1)

25-41: Phone normalization and lookup logic looks solid

Using validatePhoneNumber plus querying by wallet_address.toLowerCase() and normalized validation.e164Format keeps /send-otp and /verify-otp in sync and avoids subtle mismatch bugs between display formats and stored values.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (5)
app/api/phone/verify-otp/route.ts (2)

60-69: Consider returning phoneNumber in already-verified response for consistency.

The already-verified response doesn't include the phoneNumber field, while the successful verification response at line 151 does. For consistency and client convenience, consider adding it:

     return NextResponse.json({
       success: true,
       message: 'Phone number already verified',
-      verified: true
+      verified: true,
+      phoneNumber: validation.internationalFormat
     });

147-152: Return normalized phone number format for consistency.

Line 151 echoes back the raw phoneNumber from the request body. For consistency with the send-otp endpoint (which returns validation.internationalFormat) and to confirm the actual verified format, return the normalized value:

     return NextResponse.json({
       success: true,
       message: 'Phone number verified successfully',
       verified: true,
-      phoneNumber: phoneNumber
+      phoneNumber: validation.internationalFormat
     });
app/api/kyc/status/route.ts (1)

36-45: Consider more explicit nullish check for tier fallback.

The condition !kycProfile?.tier will be true for both undefined and 0, meaning an explicitly stored tier: 0 would trigger recalculation. While tier 0 appears unused in the current system, a more explicit check would be clearer:

-    if (!kycProfile?.tier) {
+    if (kycProfile?.tier === undefined || kycProfile?.tier === null) {
       if (fullKYCVerified) {
app/components/KycModal.tsx (2)

145-149: Add guard to ensure SmileID components loaded before advancing to CAPTURE.

While the dynamic import typically completes quickly, proceeding to CAPTURE before smileIdLoaded is true could result in a blank screen on slow connections. Consider:

        setKycSignature(sigWithoutPrefix);
        setKycNonce(nonce);

-        // Skip old KYC initiation since we're using Smile ID
-        setStep(STEPS.CAPTURE);
+        // Ensure SmileID components are loaded before proceeding
+        if (smileIdLoaded) {
+          setStep(STEPS.CAPTURE);
+        } else {
+          toast.error("Verification component not ready. Please try again.");
+          setIsKycModalOpen(false);
+          setStep(STEPS.TERMS);
+        }

Alternatively, disable the "Accept and sign" button at line 300 until smileIdLoaded is true:

          disabled={!termsAccepted || isSigning}
+          disabled={!termsAccepted || isSigning || !smileIdLoaded}

489-493: Consider removing commented-out code.

The commented lines from the old QR-based flow are no longer needed and can be removed for cleaner code:

      if (newStatus === STEPS.STATUS.PENDING) {
-        // setKycUrl(response.data.url);
-        // setIsKycModalOpen(true);
        return;
      }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bc48350 and ee6d6ff.

📒 Files selected for processing (7)
  • app/api/aggregator.ts (2 hunks)
  • app/api/kyc/smile-id/route.ts (1 hunks)
  • app/api/kyc/status/route.ts (1 hunks)
  • app/api/phone/verify-otp/route.ts (1 hunks)
  • app/components/KycModal.tsx (15 hunks)
  • app/hooks/useKYCStatus.ts (1 hunks)
  • middleware.ts (1 hunks)
🧰 Additional context used
🧠 Learnings (4)
📚 Learning: 2025-11-06T07:37:39.036Z
Learnt from: Dprof-in-tech
Repo: paycrest/noblocks PR: 231
File: app/components/recipient/RecipientDetailsForm.tsx:539-552
Timestamp: 2025-11-06T07:37:39.036Z
Learning: In RecipientDetailsForm (app/components/recipient/RecipientDetailsForm.tsx), when isRecipientNameEditable is true (verification failed/returned "Ok"), the recipient safety alert should display when: isRecipientNameEditable && recipientName && !errors.recipientName && !recipientNameError. The !isFetchingRecipientName check is redundant because recipientName is cleared at fetch start and only populated after fetching completes or when the user manually enters it.

Applied to files:

  • app/components/KycModal.tsx
  • app/api/phone/verify-otp/route.ts
📚 Learning: 2025-11-06T07:37:39.036Z
Learnt from: Dprof-in-tech
Repo: paycrest/noblocks PR: 231
File: app/components/recipient/RecipientDetailsForm.tsx:539-552
Timestamp: 2025-11-06T07:37:39.036Z
Learning: In RecipientDetailsForm (app/components/recipient/RecipientDetailsForm.tsx), when isRecipientNameEditable is false (verification succeeded), the recipient safety alert should display when: !isRecipientNameEditable && recipientName && !recipientNameError. The !errors.recipientName check is unnecessary because in non-editable mode the recipient name is displayed as read-only text (not an input field), so form validation errors cannot occur.

Applied to files:

  • app/components/KycModal.tsx
📚 Learning: 2025-11-06T07:08:54.593Z
Learnt from: Dprof-in-tech
Repo: paycrest/noblocks PR: 231
File: app/components/recipient/RecipientDetailsForm.tsx:271-280
Timestamp: 2025-11-06T07:08:54.593Z
Learning: In the RecipientDetailsForm component (app/components/recipient/RecipientDetailsForm.tsx), Mobile Money institutions always return "Ok" from the fetchAccountName verification endpoint. Therefore, checking for accountName.toLowerCase() === "ok" is sufficient to handle both verification failures and Mobile Money institutions without needing explicit institution type checks in the frontend.

Applied to files:

  • app/components/KycModal.tsx
📚 Learning: 2025-10-13T14:37:45.409Z
Learnt from: sundayonah
Repo: paycrest/noblocks PR: 250
File: app/components/AppLayout.tsx:33-46
Timestamp: 2025-10-13T14:37:45.409Z
Learning: In TypeScript/JavaScript/React codebases, JSX whitespace expressions like `{" "}` between elements should be classified as style/nitpick issues, not "Potential issues" or problems that affect functionality. These are purely cosmetic concerns about code cleanliness.

Applied to files:

  • app/components/KycModal.tsx
🧬 Code graph analysis (6)
app/api/kyc/status/route.ts (2)
app/api/phone/status/route.ts (1)
  • GET (5-59)
app/lib/supabase.ts (1)
  • supabaseAdmin (11-20)
app/api/aggregator.ts (2)
app/types.ts (1)
  • SmileIDSubmissionResponse (240-248)
app/lib/server-analytics.ts (3)
  • trackServerEvent (130-175)
  • trackApiResponse (241-258)
  • trackBusinessEvent (362-375)
app/api/kyc/smile-id/route.ts (4)
app/api/phone/verify-otp/route.ts (1)
  • POST (8-163)
app/api/phone/send-otp/route.ts (1)
  • POST (11-103)
app/lib/smileID.ts (1)
  • submitSmileIDJob (6-65)
app/lib/supabase.ts (1)
  • supabaseAdmin (11-20)
app/hooks/useKYCStatus.ts (1)
app/api/aggregator.ts (1)
  • fetchKYCStatus (307-353)
app/components/KycModal.tsx (3)
app/components/Styles.ts (1)
  • secondaryBtnClasses (6-6)
app/api/aggregator.ts (1)
  • submitSmileIDData (682-729)
app/hooks/analytics/useMixpanel.ts (1)
  • trackEvent (91-110)
app/api/phone/verify-otp/route.ts (3)
app/api/phone/send-otp/route.ts (1)
  • POST (11-103)
app/lib/phone-verification.ts (1)
  • validatePhoneNumber (43-75)
app/lib/supabase.ts (1)
  • supabaseAdmin (11-20)
🔇 Additional comments (5)
middleware.ts (1)

267-268: LGTM! KYC routes are now properly protected.

Adding these routes to the middleware matcher ensures JWT authentication and wallet address verification before accessing KYC endpoints, addressing the security concerns raised in previous review discussions.

app/api/aggregator.ts (1)

673-729: LGTM! Function follows established patterns.

The implementation correctly:

  • Uses JWT authentication via Bearer token
  • Tracks analytics without exposing PII
  • Returns the properly typed SmileIDSubmissionResponse
  • Follows error handling patterns consistent with other functions in this file
app/api/kyc/smile-id/route.ts (1)

5-132: LGTM! Security and data integrity concerns properly addressed.

The route implementation is solid:

  • ✅ Protected by middleware JWT authentication (resolves previous security concerns)
  • ✅ Validates all required fields before submission
  • ✅ Explicitly checks that DB row was updated (lines 102-108), ensuring profile exists
  • ✅ Comprehensive error handling for SmileID job states

The signature/nonce stored in the body are audit trail data; authentication is correctly handled via Privy JWT in middleware.

app/hooks/useKYCStatus.ts (1)

66-199: LGTM! Hook implementation is well-structured.

The hook correctly:

  • Implements per-wallet fetch guards to prevent duplicate concurrent requests
  • Uses JWT authentication for secure API calls
  • Derives tier and limits from KYC status and transaction data
  • Properly chains useCallback dependencies to trigger refetch on wallet changes
app/components/KycModal.tsx (1)

563-645: LGTM! SmileID event handling is well-implemented.

The event handling correctly:

  • Guards execution to only run when in CAPTURE step with camera element ready
  • Validates all required data before submission (addresses previous validation concerns)
  • Uses JWT authentication for secure API calls
  • Provides comprehensive error handling and user feedback
  • Properly cleans up event listeners on unmount

…rate limiting

- Added rate limiting to SmileID KYC submission endpoint.
- Updated KYC submission to include id_info for biometric verification.
- Enhanced error handling for SmileID job completion and success checks.
- Modified KYC status retrieval to reflect changes in verification tiers.
- Removed deprecated phone verification status endpoint.
- Refactored KYC modal to support ID document selection and capture.
- Updated database schema for user KYC profiles to include new fields and indexes.
- Removed unused phone verification hook.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
app/components/KycModal.tsx (2)

166-182: Align KYC terms copy with actual data stored (PII/compliance risk).

The bullets state:

  • “We do not store any personal information. All personal data is handled exclusively by our third-party KYC provider.”
  • “We only store the KYC reference code and signing wallet address for verification and audit purposes.”

However, the SmileID flow persists additional PII in user_kyc_profiles (e.g. id_type, id_number, id_country, full_name, date_of_birth, email_address, platform entries). This makes the current copy misleading from a privacy/compliance standpoint.

Either:

  • update the text to accurately describe what is stored and for how long, or
  • change the backend to only store what the text promises (which seems unlikely given KYC requirements).

Right now, the mismatch should be treated as a compliance‑level issue.


570-667: Fix pending-status polling effect to depend on step (currently uses stale value).

The polling useEffect only depends on walletAddress but reads step. Since step is omitted from the dependency array (and lint is disabled), the effect captures whatever step was on first run (typically LOADING) and will never start polling when step is later set to PENDING.

To make polling work as intended:

-  useEffect(() => {
+  useEffect(() => {
+    if (!walletAddress) return;
+
     let timeoutId: NodeJS.Timeout;
     const startTime = Date.now();
@@
-    debouncedFetchStatus(); // Initial fetch
+    debouncedFetchStatus(); // Initial fetch
@@
-    if (step === STEPS.STATUS.PENDING) {
+    if (step === STEPS.STATUS.PENDING) {
       intervalId = setInterval(() => {
@@
-    return () => {
+    return () => {
@@
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [walletAddress]);
+  }, [walletAddress, step]);

This way, when step transitions to PENDING, the effect re-runs, sets up the interval, and stops polling once the status changes or times out.

♻️ Duplicate comments (3)
app/components/KycModal.tsx (2)

594-598: Remove reintroduced commented-out QR flow remnants.

The newStatus === STEPS.STATUS.PENDING branch still contains commented-out code for the old QR-based flow:

// setKycUrl(response.data.url);
// setIsKycModalOpen(true);

Since this flow has been superseded by the embedded SmileID flow, these comments are now dead code and should be removed to keep the status handler clean.

-      if (newStatus === STEPS.STATUS.PENDING) {
-        // setKycUrl(response.data.url);
-        // setIsKycModalOpen(true);
-        return;
-      }
+      if (newStatus === STEPS.STATUS.PENDING) {
+        return;
+      }

386-401: Gate transition to CAPTURE on SmileID load state to avoid a broken capture screen.

The “Continue” button in renderIdInfo moves straight to STEPS.CAPTURE without checking smileIdLoaded. If the dynamic import failed (or is still in flight), the user will see a non-functional <smart-camera-web> screen with no clear recovery.

Reuse the existing smileIdLoaded flag here, e.g.:

-        <button
-          type="button"
-          className={`${primaryBtnClasses} w-full`}
-          disabled={!selectedCountry || !selectedIdType || (!needsDocCapture && !idNumber)}
-          onClick={() => setStep(STEPS.CAPTURE)}
-        >
+        <button
+          type="button"
+          className={`${primaryBtnClasses} w-full`}
+          disabled={
+            !selectedCountry ||
+            !selectedIdType ||
+            (!needsDocCapture && !idNumber) ||
+            !smileIdLoaded
+          }
+          onClick={() => {
+            if (!smileIdLoaded) {
+              toast.error("Verification component not ready. Please try again.");
+              return;
+            }
+            setStep(STEPS.CAPTURE);
+          }}
+        >

This prevents advancing into CAPTURE when the SmileID component failed to initialize.

Also applies to: 406-437

app/lib/smileID.ts (1)

76-84: Avoid logging full SmileID error payloads to prevent KYC/PII leakage.

console.error currently logs error.response?.data wholesale, which can include sensitive KYC metadata (IDs, tokens, URLs). This is risky for logs that may be long‑lived or sent to third‑party sinks.

Recommend logging only high‑level fields and optional sanitized codes, not the raw data object, e.g.:

-  } catch (error: any) {
-    console.error('SmileID API Error:', {
-      status: error.response?.status,
-      statusText: error.response?.statusText,
-      data: error.response?.data,
-      message: error.message,
-    });
-    throw error;
-  }
+  } catch (error: any) {
+    console.error("SmileID API Error:", {
+      status: error.response?.status,
+      statusText: error.response?.statusText,
+      message: error.message,
+      // Optionally surface a non-PII error code if present
+      code: error.response?.data?.code,
+    });
+    throw error;
+  }

If you need richer diagnostics, explicitly pick/whitelist non‑sensitive fields rather than dumping the full response.

🧹 Nitpick comments (3)
app/components/PhoneVerificationModal.tsx (1)

166-201: Remove unused dependencies from handleOtpSubmit.

The onVerified and onClose dependencies are listed in the dependency array but are never called within the function body. The function only sets the step to VERIFIED; those callbacks are invoked later in renderVerified (lines 504-505).

Including unused dependencies causes unnecessary re-creation of the callback and violates React useCallback optimization best practices.

Apply this diff:

-  }, [otpCode, formattedPhone, walletAddress, onVerified, onClose]);
+  }, [otpCode, formattedPhone, walletAddress]);
app/api/kyc/smile-id/route.ts (1)

38-45: Add server-side validation for ID number on Enhanced KYC ID types.

The frontend enforces ID number input for certain ID types, but this route only checks country and id_type. For Enhanced KYC types (e.g. BVN/NIN variants that map to job_type 5 in getJobTypeForIdType), id_info.id_number is effectively required; without it, submitSmileIDJob will likely fail and you’ll surface a 500 instead of a clear 4xx.

Consider mirroring the job-type logic here and validating ID number for those types before calling SmileID, e.g.:

import { getJobTypeForIdType } from '@/app/lib/smileID';

// ...
if (!id_info?.country || !id_info?.id_type) {
  return NextResponse.json(
    { status: 'error', message: 'Missing id_info: country and id_type are required' },
    { status: 400 },
  );
}

const jobType = getJobTypeForIdType(id_info.id_type);
if (jobType === 5 && !id_info.id_number) {
  return NextResponse.json(
    { status: 'error', message: 'ID number is required for this ID type' },
    { status: 400 },
  );
}

This keeps validation consistent between FE and BE and avoids unnecessary SmileID job failures.

Also applies to: 70-81, 120-127

app/components/KycModal.tsx (1)

118-128: Remove debug console.log from SmileID component loader.

The dynamic import logs "SmileID web components loaded" on every successful load. This is useful during development but noisy in production.

You can safely drop this log and keep the error path:

-      import("@smileid/web-components/smart-camera-web")
-        .then(() => {
-          console.log("SmileID web components loaded");
-          setSmileIdLoaded(true);
-        })
+      import("@smileid/web-components/smart-camera-web")
+        .then(() => {
+          setSmileIdLoaded(true);
+        })
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ee6d6ff and 651af93.

📒 Files selected for processing (6)
  • app/api/kyc/smile-id/route.ts (1 hunks)
  • app/api/kyc/status/route.ts (1 hunks)
  • app/api/phone/send-otp/route.ts (1 hunks)
  • app/components/KycModal.tsx (14 hunks)
  • app/components/PhoneVerificationModal.tsx (1 hunks)
  • app/lib/smileID.ts (1 hunks)
🧰 Additional context used
🧠 Learnings (4)
📚 Learning: 2025-11-06T07:37:39.036Z
Learnt from: Dprof-in-tech
Repo: paycrest/noblocks PR: 231
File: app/components/recipient/RecipientDetailsForm.tsx:539-552
Timestamp: 2025-11-06T07:37:39.036Z
Learning: In RecipientDetailsForm (app/components/recipient/RecipientDetailsForm.tsx), when isRecipientNameEditable is true (verification failed/returned "Ok"), the recipient safety alert should display when: isRecipientNameEditable && recipientName && !errors.recipientName && !recipientNameError. The !isFetchingRecipientName check is redundant because recipientName is cleared at fetch start and only populated after fetching completes or when the user manually enters it.

Applied to files:

  • app/components/KycModal.tsx
  • app/components/PhoneVerificationModal.tsx
📚 Learning: 2025-11-06T07:37:39.036Z
Learnt from: Dprof-in-tech
Repo: paycrest/noblocks PR: 231
File: app/components/recipient/RecipientDetailsForm.tsx:539-552
Timestamp: 2025-11-06T07:37:39.036Z
Learning: In RecipientDetailsForm (app/components/recipient/RecipientDetailsForm.tsx), when isRecipientNameEditable is false (verification succeeded), the recipient safety alert should display when: !isRecipientNameEditable && recipientName && !recipientNameError. The !errors.recipientName check is unnecessary because in non-editable mode the recipient name is displayed as read-only text (not an input field), so form validation errors cannot occur.

Applied to files:

  • app/components/KycModal.tsx
  • app/components/PhoneVerificationModal.tsx
📚 Learning: 2025-11-06T07:08:54.593Z
Learnt from: Dprof-in-tech
Repo: paycrest/noblocks PR: 231
File: app/components/recipient/RecipientDetailsForm.tsx:271-280
Timestamp: 2025-11-06T07:08:54.593Z
Learning: In the RecipientDetailsForm component (app/components/recipient/RecipientDetailsForm.tsx), Mobile Money institutions always return "Ok" from the fetchAccountName verification endpoint. Therefore, checking for accountName.toLowerCase() === "ok" is sufficient to handle both verification failures and Mobile Money institutions without needing explicit institution type checks in the frontend.

Applied to files:

  • app/components/KycModal.tsx
  • app/components/PhoneVerificationModal.tsx
📚 Learning: 2025-10-13T14:37:45.409Z
Learnt from: sundayonah
Repo: paycrest/noblocks PR: 250
File: app/components/AppLayout.tsx:33-46
Timestamp: 2025-10-13T14:37:45.409Z
Learning: In TypeScript/JavaScript/React codebases, JSX whitespace expressions like `{" "}` between elements should be classified as style/nitpick issues, not "Potential issues" or problems that affect functionality. These are purely cosmetic concerns about code cleanliness.

Applied to files:

  • app/components/KycModal.tsx
🧬 Code graph analysis (3)
app/api/phone/send-otp/route.ts (3)
app/lib/rate-limit.ts (1)
  • rateLimit (10-30)
app/lib/phone-verification.ts (4)
  • validatePhoneNumber (43-75)
  • generateOTP (157-159)
  • sendTermiiOTP (80-123)
  • sendTwilioOTP (128-152)
app/lib/supabase.ts (1)
  • supabaseAdmin (11-20)
app/api/kyc/smile-id/route.ts (3)
app/lib/rate-limit.ts (1)
  • rateLimit (10-30)
app/lib/smileID.ts (2)
  • submitSmileIDJob (22-85)
  • SmileIDIdInfo (5-12)
app/lib/supabase.ts (1)
  • supabaseAdmin (11-20)
app/api/kyc/status/route.ts (2)
app/api/kyc/transaction-summary/route.ts (1)
  • GET (5-81)
app/lib/supabase.ts (1)
  • supabaseAdmin (11-20)
🔇 Additional comments (2)
app/api/phone/send-otp/route.ts (1)

1-111: LGTM! Clean implementation of OTP sending with proper phone normalization.

The implementation correctly addresses the previous review feedback:

  • Uses e164Format (no spaces) for database storage and Twilio
  • Uses digitsOnly (no + prefix) for Termii
  • Returns internationalFormat (with spaces) for client display

Error handling, rate limiting, and analytics tracking are all properly implemented.

app/components/PhoneVerificationModal.tsx (1)

1-538: Well-implemented phone verification flow with clean state management.

The component properly handles the three-step verification process with:

  • Comprehensive country selection with search and popular countries prioritization
  • Robust OTP input with auto-focus and backspace navigation
  • Proper state reset on close and after verification
  • Error handling with user-friendly toast notifications

The previous dependency issue in handlePhoneSubmit has been correctly addressed.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
supabase/migrations/create_user_kyc_profiles.sql (1)

38-45: Index strategy is well-aligned with expected query patterns.

Indexes cover the primary lookups: tier (verification status), id_number (KYC lookups), user_id (account association), phone_number (phone verification), email_address, verified status, and the GIN index on platform (JSONB array of verification platforms) is appropriate.

Note: The plaintext phone_number index (line 43) was flagged in a prior review as a potential security/privacy concern. This was explicitly declined; no change needed unless risk appetite changes.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 651af93 and 3692388.

📒 Files selected for processing (1)
  • supabase/migrations/create_user_kyc_profiles.sql (1 hunks)
🔇 Additional comments (1)
supabase/migrations/create_user_kyc_profiles.sql (1)

3-36: Schema design is appropriate for progressive KYC collection.

The table structure supports the tiered KYC flow with nullable fields for optional/progressive data collection, sensible defaults (tier=0, verified=false), and sound constraints on provider and tier values.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/pages/TransactionForm.tsx (1)

72-87: Fix critical currency mismatch and type safety in transaction limit check

In handleSwap, the transaction limit check has two critical issues:

  1. Currency unit mismatchamountReceived is in the selected payout currency (e.g., GHS, NGN), but transactionSummary.monthlySpent from the backend is accumulated from amount_sent values in the source currency (typically USD). Passing amountReceived directly to canTransact() compares incompatible units and breaks the limit guard. You must convert amountReceived to the same currency as monthlySpent before checking limits.

  2. Type safetygetValues() can return strings; explicitly coerce to number:

-    const formData = getValues();
-    const usdAmount = formData.amountReceived || 0;
+    const { amountReceived } = getValues();
+    const usdAmount = 
+      typeof amountReceived === "number"
+        ? amountReceived
+        : parseFloat(String(amountReceived ?? "0")) || 0;
+
+    // TODO: Convert payout currency to source currency (USD) before limit check

Additionally, TransactionLimitModal.onClose should call refreshStatus() after the user completes KYC upgrades inside the modal, matching the pattern in ProfileDrawer. Otherwise, the parent form's limit checks remain stale if the user upgrades their tier.

🧹 Nitpick comments (6)
app/api/aggregator.ts (1)

776-830: Consider adding a strongly-typed payload interface.

The function correctly implements analytics tracking (without PII), error handling, and Bearer token authentication. However, the payload parameter is typed as any, which bypasses TypeScript's type safety.

Define a proper interface in app/types.ts for the SmileID payload structure and use it here:

// In app/types.ts (example structure - adjust based on actual SmileID requirements)
export type SmileIDPayload = {
  images: {
    selfie_image: string;
    id_image?: string;
  };
  partner_params: {
    user_id: string;
    job_id: string;
    job_type: number;
  };
  // ... other required fields
};

Then update the function signature:

 export const submitSmileIDData = async (
-  payload: any,
+  payload: SmileIDPayload,
   accessToken: string,
 ): Promise<SmileIDSubmissionResponse> => {
app/api/phone/verify-otp/route.ts (3)

87-123: Consider DB-side atomic increment for stricter attempt tracking.

The current approach computes verification.attempts + 1 client-side (using the value fetched at line 36) then writes it with a .lt('attempts', MAX_ATTEMPTS) boundary check. While the boundary prevents exceeding MAX_ATTEMPTS, concurrent requests with incorrect OTPs could read the same stale value and overwrite each other, causing some failed attempts to go uncounted (e.g., three concurrent requests all read attempts=1, all write attempts=2, but the user actually made three attempts).

For strict counting in this security-sensitive context, use a Postgres function that increments atomically:

const { data: updated, error: attemptsError } = await supabaseAdmin.rpc(
  'increment_otp_attempts',
  { 
    wallet_addr: walletAddress.toLowerCase(),
    max_attempts: MAX_ATTEMPTS 
  }
);

Create the function in your migration:

CREATE OR REPLACE FUNCTION increment_otp_attempts(wallet_addr text, max_attempts int)
RETURNS TABLE(attempts int) AS $$
  UPDATE user_kyc_profiles
  SET attempts = user_kyc_profiles.attempts + 1
  WHERE wallet_address = wallet_addr AND user_kyc_profiles.attempts < max_attempts
  RETURNING user_kyc_profiles.attempts;
$$ LANGUAGE sql;

This ensures every failed attempt is counted, even under concurrency, while retaining your existing boundary logic.


126-134: Optional: Replace any type with a specific type for updateData.

The any annotation bypasses TypeScript's type checking. Consider using a more specific type or defining an inline object type to maintain type safety:

-    const updateData: any = { 
+    const updateData: { 
+      verified: boolean; 
+      verified_at: string; 
+      tier?: number; 
+    } = { 
       verified: true, 
       verified_at: new Date().toISOString()
     };

Alternatively, if you have a UserKycProfile type, use Partial<Pick<UserKycProfile, 'verified' | 'verified_at' | 'tier'>>.


157-157: Optional: Return formatted phone number for UX consistency.

The response returns phoneNumber as received in the request body, which may vary in format. The send-otp endpoint returns validation.internationalFormat for consistent, readable display (e.g., "+1 234 567 890"). Consider aligning here:

-      phoneNumber: phoneNumber
+      phoneNumber: validation.internationalFormat

This ensures clients always receive a predictable, user-friendly format.

app/hooks/useKYCStatus.ts (1)

21-38: Consider defensive handling for unknown tiers and documenting per-instance state

getCurrentLimits assumes that any tier > 0 has a corresponding entry in KYC_TIERS, and canTransact relies on that. If the backend ever introduces a new tier or returns an unexpected value, KYC_TIERS[tier] will be undefined and .limits will throw at runtime.

You could defensively fall back to { monthly: 0 } when KYC_TIERS[tier] is missing, and/or clamp tier to the known set before indexing. Also, since each useKYCStatus call maintains its own React state (only the fetch guards are shared via window.__KYC_FETCH_GUARDS__), it’s worth keeping in mind that callers must invoke their own refreshStatus() to keep local state in sync after verification flows.

Also applies to: 71-87

app/components/TransactionLimitModal.tsx (1)

18-22: Wire transactionAmount into the UI or remove it for now

TransactionLimitModal accepts an optional transactionAmount and passes onVerified={handlePhoneVerified}, but:

  • transactionAmount is never used in the component (no copy or calculations reference it).
  • handlePhoneVerified ignores its phoneNumber argument and only closes the modals.

If the intent is to explain “We blocked a $X transaction because it exceeds your limit,” it would be good to surface transactionAmount in the copy (e.g., near the header or progress text). Otherwise, consider removing the prop and the unused phoneNumber parameter from handlePhoneVerified to keep the API tighter until you actually need it.

-interface TransactionLimitModalProps {
-    isOpen: boolean;
-    onClose: () => void;
-    transactionAmount?: number;
-}
+interface TransactionLimitModalProps {
+    isOpen: boolean;
+    onClose: () => void;
+    transactionAmount?: number; // use in copy once UX is finalized
+}
...
-    const handlePhoneVerified = async (phoneNumber: string) => {
+    const handlePhoneVerified = async () => {
         setIsPhoneModalOpen(false);
         onClose();
     };

If you do plan to show the blocked amount, now is a good time to thread it through the text.

Also applies to: 24-28, 60-63, 193-200

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8dc31f1 and 73e4bfc.

📒 Files selected for processing (7)
  • app/api/aggregator.ts (2 hunks)
  • app/api/phone/send-otp/route.ts (1 hunks)
  • app/api/phone/verify-otp/route.ts (1 hunks)
  • app/components/ProfileDrawer.tsx (1 hunks)
  • app/components/TransactionLimitModal.tsx (1 hunks)
  • app/hooks/useKYCStatus.ts (1 hunks)
  • app/pages/TransactionForm.tsx (9 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/api/phone/send-otp/route.ts
🧰 Additional context used
🧠 Learnings (3)
📚 Learning: 2025-11-06T07:37:39.036Z
Learnt from: Dprof-in-tech
Repo: paycrest/noblocks PR: 231
File: app/components/recipient/RecipientDetailsForm.tsx:539-552
Timestamp: 2025-11-06T07:37:39.036Z
Learning: In RecipientDetailsForm (app/components/recipient/RecipientDetailsForm.tsx), when isRecipientNameEditable is true (verification failed/returned "Ok"), the recipient safety alert should display when: isRecipientNameEditable && recipientName && !errors.recipientName && !recipientNameError. The !isFetchingRecipientName check is redundant because recipientName is cleared at fetch start and only populated after fetching completes or when the user manually enters it.

Applied to files:

  • app/pages/TransactionForm.tsx
  • app/components/TransactionLimitModal.tsx
  • app/api/phone/verify-otp/route.ts
📚 Learning: 2025-11-06T07:37:39.036Z
Learnt from: Dprof-in-tech
Repo: paycrest/noblocks PR: 231
File: app/components/recipient/RecipientDetailsForm.tsx:539-552
Timestamp: 2025-11-06T07:37:39.036Z
Learning: In RecipientDetailsForm (app/components/recipient/RecipientDetailsForm.tsx), when isRecipientNameEditable is false (verification succeeded), the recipient safety alert should display when: !isRecipientNameEditable && recipientName && !recipientNameError. The !errors.recipientName check is unnecessary because in non-editable mode the recipient name is displayed as read-only text (not an input field), so form validation errors cannot occur.

Applied to files:

  • app/pages/TransactionForm.tsx
  • app/components/TransactionLimitModal.tsx
📚 Learning: 2025-11-06T07:08:54.593Z
Learnt from: Dprof-in-tech
Repo: paycrest/noblocks PR: 231
File: app/components/recipient/RecipientDetailsForm.tsx:271-280
Timestamp: 2025-11-06T07:08:54.593Z
Learning: In the RecipientDetailsForm component (app/components/recipient/RecipientDetailsForm.tsx), Mobile Money institutions always return "Ok" from the fetchAccountName verification endpoint. Therefore, checking for accountName.toLowerCase() === "ok" is sufficient to handle both verification failures and Mobile Money institutions without needing explicit institution type checks in the frontend.

Applied to files:

  • app/pages/TransactionForm.tsx
  • app/components/TransactionLimitModal.tsx
🧬 Code graph analysis (4)
app/pages/TransactionForm.tsx (2)
app/hooks/useKYCStatus.ts (1)
  • useKYCStatus (58-191)
app/components/TransactionLimitModal.tsx (1)
  • TransactionLimitModal (24-217)
app/api/aggregator.ts (2)
app/types.ts (1)
  • SmileIDSubmissionResponse (240-248)
app/lib/server-analytics.ts (3)
  • trackServerEvent (130-175)
  • trackApiResponse (241-258)
  • trackBusinessEvent (362-375)
app/api/phone/verify-otp/route.ts (3)
app/api/phone/send-otp/route.ts (1)
  • POST (12-124)
app/lib/phone-verification.ts (1)
  • validatePhoneNumber (43-75)
app/lib/supabase.ts (1)
  • supabaseAdmin (11-20)
app/hooks/useKYCStatus.ts (1)
app/api/aggregator.ts (1)
  • fetchKYCStatus (307-353)
🔇 Additional comments (2)
app/api/aggregator.ts (1)

10-10: LGTM!

The import of SmileIDSubmissionResponse is correctly added to support the new submitSmileIDData function and matches the type definition in app/types.ts.

app/api/phone/verify-otp/route.ts (1)

1-169: LGTM—Solid OTP verification implementation with good error handling.

The endpoint properly validates inputs, normalizes phone numbers to E.164, splits fetch errors from not-found cases, handles race conditions with a boundary check, preserves higher tiers, and tracks analytics consistently. The improvements from past reviews (error handling on increment, fetch error differentiation) are well-integrated.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (2)
app/pages/TransactionForm.tsx (2)

272-287: Ensure isUserVerified is updated after completing KYC inside TransactionLimitModal

In this component, setIsUserVerified(true) is only invoked in the checkKycStatus effect when fetchKYCStatus returns "success" (primarily on mount/address change). After wiring buttonAction to open TransactionLimitModal instead of this component’s KycModal, TransactionLimitModal no longer receives setIsUserVerified, so KYC completion inside that modal won’t directly flip isUserVerified here unless the parent updates it from some other source (e.g., a global useKYCStatus listener).

That can leave this form thinking the user is unverified during the same session (affecting useSwapButton behavior and the isUserVerified gate for RecipientDetailsForm), even though KYC has just succeeded.

Please verify that:

  • The parent does, in fact, update isUserVerified in response to KYC success (e.g., via useKYCStatus + refreshStatus), or
  • You pass a callback (or derive isUserVerified from tier) such that KYC completion in TransactionLimitModal reliably updates this prop without requiring a full reload.

If not, consider plumbing setIsUserVerified into TransactionLimitModal or deriving isUserVerified directly from useKYCStatus to avoid this desync.

Also applies to: 861-868, 898-899


78-79: Remove now-unreachable KycModal state/rendering from TransactionForm

With buttonAction now using () => setIsLimitModalOpen(true) as the KYC/limits entry point, there’s no remaining code path in this component that sets isKycModalOpen to true. The KycModal inside the AnimatePresence block therefore appears unreachable from TransactionForm itself, while TransactionLimitModal is intended to orchestrate the KYC flow.

To reduce confusion and keep the modal graph focused, consider removing:

  • isKycModalOpen state, and
  • The <AnimatedModal><KycModal … /></AnimatedModal> block here,

and letting TransactionLimitModal own the embedded KYC experience end‑to‑end.

Also applies to: 847-858, 898-899

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 73e4bfc and 37753e1.

📒 Files selected for processing (2)
  • app/components/ProfileDrawer.tsx (1 hunks)
  • app/pages/TransactionForm.tsx (9 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/components/ProfileDrawer.tsx
🧰 Additional context used
🧠 Learnings (3)
📚 Learning: 2025-11-06T07:37:39.036Z
Learnt from: Dprof-in-tech
Repo: paycrest/noblocks PR: 231
File: app/components/recipient/RecipientDetailsForm.tsx:539-552
Timestamp: 2025-11-06T07:37:39.036Z
Learning: In RecipientDetailsForm (app/components/recipient/RecipientDetailsForm.tsx), when isRecipientNameEditable is true (verification failed/returned "Ok"), the recipient safety alert should display when: isRecipientNameEditable && recipientName && !errors.recipientName && !recipientNameError. The !isFetchingRecipientName check is redundant because recipientName is cleared at fetch start and only populated after fetching completes or when the user manually enters it.

Applied to files:

  • app/pages/TransactionForm.tsx
📚 Learning: 2025-11-06T07:37:39.036Z
Learnt from: Dprof-in-tech
Repo: paycrest/noblocks PR: 231
File: app/components/recipient/RecipientDetailsForm.tsx:539-552
Timestamp: 2025-11-06T07:37:39.036Z
Learning: In RecipientDetailsForm (app/components/recipient/RecipientDetailsForm.tsx), when isRecipientNameEditable is false (verification succeeded), the recipient safety alert should display when: !isRecipientNameEditable && recipientName && !recipientNameError. The !errors.recipientName check is unnecessary because in non-editable mode the recipient name is displayed as read-only text (not an input field), so form validation errors cannot occur.

Applied to files:

  • app/pages/TransactionForm.tsx
📚 Learning: 2025-11-06T07:08:54.593Z
Learnt from: Dprof-in-tech
Repo: paycrest/noblocks PR: 231
File: app/components/recipient/RecipientDetailsForm.tsx:271-280
Timestamp: 2025-11-06T07:08:54.593Z
Learning: In the RecipientDetailsForm component (app/components/recipient/RecipientDetailsForm.tsx), Mobile Money institutions always return "Ok" from the fetchAccountName verification endpoint. Therefore, checking for accountName.toLowerCase() === "ok" is sufficient to handle both verification failures and Mobile Money institutions without needing explicit institution type checks in the frontend.

Applied to files:

  • app/pages/TransactionForm.tsx
🔇 Additional comments (1)
app/pages/TransactionForm.tsx (1)

72-86: Clarify canTransact input units and TransactionLimitModal transactionAmount semantics

handleSwap labels amountReceived as usdAmount when passing it to canTransact, but in this form amountReceived represents the payout amount in the selected currency, not necessarily USD. If the backend expects USD-equivalent values for limit checking, this will incorrectly enforce (or fail to enforce) limits for non-USD payouts and propagate a misaligned amount into blockedTransactionAmount / TransactionLimitModal.transactionAmount.

Verify:

  • Whether canTransact and backend limits expect raw USD or the local currency amount
  • Whether amountReceived is normalized to USD before calling canTransact
  • Whether TransactionLimitModal assumes transactionAmount is in USD or just displays the raw value

If limits are USD-based, explicitly convert to USD (or pass both amount and currency to the API) rather than relying on an implicit assumption that amountReceived is already normalized.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

♻️ Duplicate comments (3)
app/lib/smileID.ts (1)

84-91: Avoid logging potentially sensitive SmileID response data.

The error handler logs error.response?.data directly, which per SmileID docs may contain job metadata, presigned URLs with tokens, or reference IDs that should be treated as sensitive. This was flagged in a prior review.

Restrict logging to high-level fields only:

   } catch (error: any) {
     console.error('SmileID API Error:', {
       status: error.response?.status,
       statusText: error.response?.statusText,
-      data: error.response?.data,
       message: error.message,
     });
     throw error;
   }
app/components/TransactionLimitModal.tsx (1)

60-63: Missing status refresh after phone verification.

handlePhoneVerified closes both modals but doesn't call refreshStatus(). After successful phone verification, the KYC tier should update from 0 to 1, but without a refresh, the parent component may show stale tier information.

     const handlePhoneVerified = async (phoneNumber: string) => {
+        await refreshStatus();
         setIsPhoneModalOpen(false);
         onClose();
     };
app/components/ProfileDrawer.tsx (1)

302-350: KYC tier card & upgrade CTA logic looks correct and aligns with backend semantics

The “Current Tier Status” and “Increase limit” CTA conditions now line up with the documented behavior:

  • tier >= 1 gates the current tier card.
  • The upgrade CTA is shown only for Tier 1 (tier < 2 && !isFullyVerified), so Tier 2 users don’t see an invalid “upgrade” to a non‑existent tier.
  • The next tiers list is restricted to tierData.level > tier, so only higher tiers are shown.

This matches the tiered KYC flow (Tier 1 = phone‑verified, Tier 2 = full KYC) and avoids the undefined next‑tier issue from the earlier version.

Also applies to: 354-356

🧹 Nitpick comments (5)
app/lib/smileID.ts (2)

29-34: Consider adding types for images and partner_params.

Both parameters are typed as any, which reduces type safety. When feasible, introduce minimal interfaces (e.g., SmileIdPartnerParams, SmileIdImage) to catch shape mismatches at compile time.


62-64: Minor collision risk in job_id generation.

The job ID uses Date.now() and the first 8 characters of the wallet address. If a user submits multiple jobs within the same millisecond, IDs could collide. Consider adding a random suffix or using a UUID library if uniqueness is critical.

+  import { randomBytes } from 'crypto';
   // Generate unique IDs
   const timestamp = Date.now();
-  const job_id = `job-${timestamp}-${walletAddress.slice(0, 8)}`;
+  const job_id = `job-${timestamp}-${walletAddress.slice(0, 8)}-${randomBytes(4).toString('hex')}`;
app/components/PhoneVerificationModal.tsx (1)

501-513: State reset logic is duplicated from handleClose.

The verified success callback duplicates the state reset logic from handleClose (lines 233-239). Extract to a shared helper to reduce maintenance burden.

+  const resetState = () => {
+    setStep(STEPS.ENTER_PHONE);
+    setPhoneNumber('');
+    setFormattedPhone('');
+    setOtpCode('');
+    setAttemptsRemaining(3);
+    setIsCountryDropdownOpen(false);
+    setCountrySearch('');
+  };
+
   const handleClose = () => {
     onClose();
-    setStep(STEPS.ENTER_PHONE);
-    setPhoneNumber('');
-    // ... rest of resets
+    resetState();
   };
app/components/ProfileDrawer.tsx (2)

132-187: Tier section UX: tighten icon logic and arrow state

The tier accordion works, but there are a couple of polish opportunities:

  1. Arrow icon rotation – the className conditional currently doesn’t change anything:
className={classNames(
  "size-4 ... transition-transform",
  isExpanded ? "" : ""
)}

You likely meant to rotate when expanded:

className={classNames(
  "size-4 ... transition-transform",
  isExpanded ? "rotate-180" : "rotate-0"
)}
  1. Requirement → icon mapping – the string‑based checks (includes('number'), includes('verification'), includes('ID')) are brittle; copy changes will silently break icon selection. If possible, make requirements structured (e.g. { label, type: 'phone' | 'liveness' | 'id' }) and map type to icons instead of parsing the text.

Not strictly blocking, but it will make this section more maintainable as copy evolves.


216-236: Dialog & refresh behavior: generally solid, only minor resilience nits

Overall the modal wiring looks good:

  • Headless UI Dialog is controlled via open={isOpen} and onClose={onClose} and wrapped in AnimatePresence.
  • Close button and backdrop both call onClose, which is expected.
  • TransactionLimitModal triggers refreshStatus() on close, ensuring the drawer reflects updated limits.

Two small resilience improvements to consider:

  1. Handle refreshStatus errors – in both the useEffect and onClose callback you ignore failures. Adding a .catch(() => toast.error("Unable to refresh verification status")) (or at least console.error) would make issues more visible.

  2. Optional: use Dialog.Title for accessibility – wrapping the “Profile” heading with <Dialog.Title> would improve screen reader labeling:

<Dialog.Title as="h2" className="text-lg font-semibold ...">
  Profile
</Dialog.Title>

Neither is blocking, but they’ll make this flow more robust and accessible.

Also applies to: 239-251, 367-373

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 37753e1 and d0087a2.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (7)
  • app/components/PhoneVerificationModal.tsx (1 hunks)
  • app/components/ProfileDrawer.tsx (1 hunks)
  • app/components/TransactionLimitModal.tsx (1 hunks)
  • app/hooks/useKYCStatus.ts (1 hunks)
  • app/hooks/useSwapButton.ts (2 hunks)
  • app/lib/smileID.ts (1 hunks)
  • app/pages/TransactionForm.tsx (10 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/hooks/useKYCStatus.ts
🧰 Additional context used
🧠 Learnings (3)
📚 Learning: 2025-11-06T07:37:39.036Z
Learnt from: Dprof-in-tech
Repo: paycrest/noblocks PR: 231
File: app/components/recipient/RecipientDetailsForm.tsx:539-552
Timestamp: 2025-11-06T07:37:39.036Z
Learning: In RecipientDetailsForm (app/components/recipient/RecipientDetailsForm.tsx), when isRecipientNameEditable is true (verification failed/returned "Ok"), the recipient safety alert should display when: isRecipientNameEditable && recipientName && !errors.recipientName && !recipientNameError. The !isFetchingRecipientName check is redundant because recipientName is cleared at fetch start and only populated after fetching completes or when the user manually enters it.

Applied to files:

  • app/pages/TransactionForm.tsx
  • app/components/TransactionLimitModal.tsx
  • app/components/ProfileDrawer.tsx
  • app/components/PhoneVerificationModal.tsx
📚 Learning: 2025-11-06T07:37:39.036Z
Learnt from: Dprof-in-tech
Repo: paycrest/noblocks PR: 231
File: app/components/recipient/RecipientDetailsForm.tsx:539-552
Timestamp: 2025-11-06T07:37:39.036Z
Learning: In RecipientDetailsForm (app/components/recipient/RecipientDetailsForm.tsx), when isRecipientNameEditable is false (verification succeeded), the recipient safety alert should display when: !isRecipientNameEditable && recipientName && !recipientNameError. The !errors.recipientName check is unnecessary because in non-editable mode the recipient name is displayed as read-only text (not an input field), so form validation errors cannot occur.

Applied to files:

  • app/pages/TransactionForm.tsx
  • app/components/TransactionLimitModal.tsx
  • app/components/ProfileDrawer.tsx
  • app/components/PhoneVerificationModal.tsx
📚 Learning: 2025-11-06T07:08:54.593Z
Learnt from: Dprof-in-tech
Repo: paycrest/noblocks PR: 231
File: app/components/recipient/RecipientDetailsForm.tsx:271-280
Timestamp: 2025-11-06T07:08:54.593Z
Learning: In the RecipientDetailsForm component (app/components/recipient/RecipientDetailsForm.tsx), Mobile Money institutions always return "Ok" from the fetchAccountName verification endpoint. Therefore, checking for accountName.toLowerCase() === "ok" is sufficient to handle both verification failures and Mobile Money institutions without needing explicit institution type checks in the frontend.

Applied to files:

  • app/pages/TransactionForm.tsx
  • app/components/TransactionLimitModal.tsx
  • app/components/PhoneVerificationModal.tsx
🧬 Code graph analysis (1)
app/components/PhoneVerificationModal.tsx (2)
app/lib/countries.ts (4)
  • Country (1-6)
  • fetchCountries (15-60)
  • searchCountries (95-104)
  • getPopularCountries (88-90)
app/components/index.ts (5)
  • fadeInOut (21-21)
  • primaryBtnClasses (47-47)
  • AnimatedComponent (15-15)
  • slideInOut (19-19)
  • secondaryBtnClasses (48-48)
🔇 Additional comments (7)
app/hooks/useSwapButton.ts (1)

101-118: LGTM! Clean refactor to integrate the new limit modal.

The logic correctly gates the limit modal on !hasInsufficientBalance, ensuring users with insufficient funds are directed to fund their wallet first rather than being shown the verification flow. The parameter rename from setIsKycModalOpen to setIsLimitModalOpen aligns with the new modal naming convention.

app/components/PhoneVerificationModal.tsx (2)

400-401: Phone masking regex may not handle all formats correctly.

The regex formattedPhone.replace(/(\+\d+\s+\d{3})[\s\d]+(\d{2})/, '$1**$2') assumes a specific format with spaces. If formattedPhone uses different separators (e.g., dashes, no separators), the masking won't work as intended.


203-228: handleResendOtp omits the name field present in initial OTP request.

handlePhoneSubmit sends { phoneNumber, walletAddress, name }, but handleResendOtp only sends { phoneNumber, walletAddress }. If the backend uses name for OTP metadata or logging, this inconsistency could cause issues. Verify whether the backend requires name on resend.

#!/bin/bash
# Check if send-otp route expects name field on all requests
rg -nA 20 'export.*POST' app/api/phone/send-otp/route.ts | head -40
app/pages/TransactionForm.tsx (2)

271-282: LGTM! KYC status refresh on wallet changes.

The effect correctly triggers refreshStatus() when the wallet context changes, ensuring KYC state stays synchronized.


839-846: LGTM! Modal closes with status refresh.

The onClose handler properly refreshes KYC status after the modal closes, addressing the stale state concern from the prior review.

app/components/TransactionLimitModal.tsx (2)

112-126: LGTM! Progress bar guards against division by zero.

The progress bar width calculation properly handles the case when currentLimits.monthly is 0, avoiding NaN/Infinity.


42-47: refreshStatus is properly memoized and stable; no infinite loop concern.

refreshStatus is correctly wrapped with useCallback in the useKYCStatus hook with appropriate dependencies. The fetchGuards global object pattern ensures stable callback identities across renders, preventing infinite effect loops in TransactionLimitModal.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@app/components/PhoneVerificationModal.tsx`:
- Around line 136-167: In handlePhoneSubmit add a guard to require a non-empty
full name: check name.trim() alongside phoneNumber and walletAddress and show a
toast error like "Please enter your full name" and return if missing; also
update the UI submit control (the submit button handler/state referenced near
lines 432-436) to be disabled when name.trim() is falsy so users cannot send OTP
without providing a full name. Ensure you reference the same
selectedCountry.code/fullPhoneNumber logic and keep setIsLoading behavior
unchanged.

In `@app/lib/phone-verification.ts`:
- Around line 1-2: The OTP generation currently uses Math.random which is not
cryptographically secure; replace that logic by using Node's crypto.randomInt to
produce the required numeric OTP entropy, import crypto, and generate the digits
(e.g., loop crypto.randomInt(0,10) per digit or call crypto.randomInt(0,
10**length) and pad) instead of Math.random; update the function that constructs
the OTP (the code site using Math.random) to use crypto.randomInt and ensure
output length/padding remains the same and tests still pass.

In `@app/pages/TransactionForm.tsx`:
- Around line 71-72: The gating currently uses isFullyVerified (tier ≥2) which
blocks Tier 1 users; update the checks in TransactionForm to gate on canTransact
instead of isFullyVerified so phone‑verified users can proceed to the recipient
form and submit swaps. Locate the usages of isFullyVerified inside the
TransactionForm component (the checks around the recipient form and submit flow)
and replace them with canTransact, making sure any UI/disabled state, early
returns, or error messages that referenced isFullyVerified are adjusted to
reflect the canTransact semantics and still call refreshStatus where
appropriate.

In `@supabase/migrations/create_user_kyc_profiles.sql`:
- Around line 3-45: Add a PL/pgSQL trigger function named
update_user_kyc_profiles_updated_at() before the trigger definition so the
trigger can call it; implement it to set NEW.updated_at = now() and return NEW
(matching the pattern used in create_saved_recipients.sql), then keep the
existing trigger that calls update_user_kyc_profiles_updated_at(); ensure the
function signature is "create or replace function
public.update_user_kyc_profiles_updated_at() returns trigger language plpgsql"
so the trigger can find it.
♻️ Duplicate comments (5)
supabase/migrations/create_user_kyc_profiles.sql (1)

62-64: The trigger references a function that is not defined in this migration.

The trigger calls update_user_kyc_profiles_updated_at() but this function is not created anywhere in the file. The migration will fail with:

ERROR: function update_user_kyc_profiles_updated_at() does not exist

Add the function definition before the trigger:

Proposed fix
 create index IF not exists idx_user_kyc_profiles_verified on public.user_kyc_profiles using btree (verified) TABLESPACE pg_default;

+-- Function to automatically update updated_at timestamp
+create or replace function update_user_kyc_profiles_updated_at()
+returns trigger as $$
+begin
+  new.updated_at = now();
+  return new;
+end;
+$$ language plpgsql;
+
 create trigger update_user_kyc_profiles_updated_at BEFORE
 update on user_kyc_profiles for EACH row
 execute FUNCTION update_user_kyc_profiles_updated_at ();
.env.example (1)

98-123: Standardize KudiSMS/SmileID env formatting for consistency

The new blocks still trigger dotenv‑linter warnings (ordering, quoting, spacing). It’s cosmetic, but consistent formatting avoids subtle parsing differences across tools. This overlaps with earlier feedback, so tagging as duplicate.

♻️ Suggested tidy-up (formatting + ordering)
 # KudiSMS (for African phone numbers)
 # Get from: KudiSMS Dashboard → Settings → API Keys
-KUDISMS_API_KEY=your_kudisms_api_key
-KUDISMS_APP_NAME_CODE=your_app_name_code
-KUDISMS_TEMPLATE_CODE=your_template_code
-KUDISMS_SENDER_ID=Noblocks
+KUDISMS_API_KEY="your_kudisms_api_key"
+KUDISMS_APP_NAME_CODE="your_app_name_code"
+KUDISMS_SENDER_ID="Noblocks"
+KUDISMS_TEMPLATE_CODE="your_template_code"

 # =============================================================================
 # SmileID KYC Verification Services
 # =============================================================================
 
-SMILE_IDENTITY_BASE_URL=XXXXXX `#get` from docs
-SMILE_IDENTITY_API_KEY=your_api_key_here
-SMILE_IDENTITY_PARTNER_ID=your_partner_id_here
-SMILE_ID_CALLBACK_URL=  `#optional` callback url
-SMILE_IDENTITY_SERVER=0 # 0 for sandbox, 1 for production
+SMILE_IDENTITY_API_KEY="your_api_key_here"
+SMILE_IDENTITY_PARTNER_ID="your_partner_id_here"
+SMILE_IDENTITY_BASE_URL="https://sandbox.smileid.io" # or https://api.smileid.io for production
+SMILE_IDENTITY_SERVER="0" # 0 for sandbox, 1 for production
+SMILE_ID_CALLBACK_URL="" # optional callback URL
app/components/ProfileDrawer.tsx (1)

80-87: Guard clipboard copy and hide wallet row when address is missing

Currently shortenAddress runs on empty strings and navigator.clipboard.writeText isn’t guarded. This is a small UX/robustness improvement; similar to earlier feedback.

♻️ Suggested guardrails
   const walletAddress = user?.linkedAccounts.find(
     (account) => account.type === "smart_wallet",
   )?.address;
+  const hasWallet = Boolean(walletAddress);
@@
-  const handleCopyAddress = () => {
-    if (walletAddress) {
-      navigator.clipboard.writeText(walletAddress);
-      setIsAddressCopied(true);
-      toast.success("Address copied to clipboard");
-      setTimeout(() => setIsAddressCopied(false), 2000);
-    }
-  };
+  const handleCopyAddress = async () => {
+    if (!walletAddress || !navigator.clipboard?.writeText) {
+      toast.error("Unable to copy address");
+      return;
+    }
+    try {
+      await navigator.clipboard.writeText(walletAddress);
+      setIsAddressCopied(true);
+      toast.success("Address copied to clipboard");
+      setTimeout(() => setIsAddressCopied(false), 2000);
+    } catch {
+      toast.error("Unable to copy address");
+    }
+  };
@@
-                              {/* Wallet Address */}
-                              <div className="flex w-full items-center justify-between">
-                                <p className="text-sm font-light text-text-body dark:text-white/70">
-                                  {shortenAddress(walletAddress ?? "", 10)}
-                                </p>
-                                <button
-                                  type="button"
-                                  onClick={handleCopyAddress}
-                                  title="Copy wallet address"
-                                  className="rounded-lg p-2 transition-colors hover:bg-accent-gray dark:hover:bg-white/10"
-                                >
-                                  {isAddressCopied ? (
-                                    <PiCheck className="size-4 text-green-500" />
-                                  ) : (
-                                    <Copy01Icon className="size-4 text-outline-gray dark:text-white/50" />
-                                  )}
-                                </button>
-                              </div>
+                              {/* Wallet Address */}
+                              {hasWallet && (
+                                <div className="flex w-full items-center justify-between">
+                                  <p className="text-sm font-light text-text-body dark:text-white/70">
+                                    {shortenAddress(walletAddress!, 10)}
+                                  </p>
+                                  <button
+                                    type="button"
+                                    onClick={handleCopyAddress}
+                                    title="Copy wallet address"
+                                    className="rounded-lg p-2 transition-colors hover:bg-accent-gray dark:hover:bg-white/10"
+                                  >
+                                    {isAddressCopied ? (
+                                      <PiCheck className="size-4 text-green-500" />
+                                    ) : (
+                                      <Copy01Icon className="size-4 text-outline-gray dark:text-white/50" />
+                                    )}
+                                  </button>
+                                </div>
+                              )}

Also applies to: 314-331

app/components/PhoneVerificationModal.tsx (1)

419-421: Fix typo in consent text (“recieving”)

User‑facing typo remains.

✍️ Quick fix
-          By clicking "Verify and start", you consent to recieving transactional
+          By clicking "Verify and start", you consent to receiving transactional
app/components/TransactionLimitModal.tsx (1)

64-67: Avoid double-calling onClose after phone verification

PhoneVerificationModal calls onVerified and onClose on success. With handlePhoneVerified also calling onClose, the parent close runs twice.

🧹 Suggested adjustment
-  const handlePhoneVerified = async (phoneNumber: string) => {
-    setIsPhoneModalOpen(false);
-    onClose();
-  };
+  const handlePhoneVerified = async (_phoneNumber: string) => {
+    // Let PhoneVerificationModal's onClose handler close modals
+    await refreshStatus();
+  };

Also applies to: 220-226

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@app/components/PhoneVerificationModal.tsx`:
- Around line 262-272: The modal close handler handleClose resets phone/OTP
state but doesn't clear the user's full name, risking PII carryover; update
handleClose to also clear the name state (call the appropriate setter, e.g.,
setName or setFullName) and likewise clear the name in the flow's
success/completion handler (e.g., verify success handler or handleSuccess at the
end of the modal flow) so the name is reset both when closing and when the modal
completes successfully.
♻️ Duplicate comments (1)
app/components/PhoneVerificationModal.tsx (1)

423-424: Typo in consent copy (“recieving”).

Line 423 should be “receiving”.

✏️ Proposed fix
-          By clicking "Verify and start", you consent to recieving transactional
+          By clicking "Verify and start", you consent to receiving transactional
🧹 Nitpick comments (1)
app/components/PhoneVerificationModal.tsx (1)

60-61: Align provider type with all supported providers.

data.provider can be "termii" (see Line 183), but the state union only allows "kudisms" | "twilio". Widen the union to keep the type aligned with real responses.

♻️ Proposed fix
-  const [provider, setProvider] = useState<"kudisms" | "twilio">("kudisms");
+  const [provider, setProvider] = useState<
+    "kudisms" | "termii" | "twilio"
+  >("kudisms");

Also applies to: 176-185

Dprof-in-tech and others added 3 commits February 5, 2026 21:03
…cies

- Added new fields: network and order_id to the transactions table.
- Updated status check to include 'fulfilling'.
- Improved formatting for better readability.
- Adjusted RLS policies for clarity and consistency.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 12

🤖 Fix all issues with AI agents
In `@app/api/kyc/transaction-summary/route.ts`:
- Around line 22-32: The comment above the Supabase query is incorrect—update it
to say you're fetching transactions from the start of the current calendar month
(not "last 30 days"), and make the date boundaries timezone-safe by constructing
UTC-based dates (e.g., replace new Date(now.getFullYear(), now.getMonth(), ...)
with new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), ...)) for today
and monthStart) so the .gte('created_at', monthStart.toISOString()) call uses an
explicit UTC boundary; adjust any references to today/monthStart in this file
(route.ts) accordingly around the supabaseAdmin.from('transactions') query that
uses walletAddress and monthStart.
- Around line 5-20: The GET handler in app/api/kyc/transaction-summary/route.ts
currently trusts a walletAddress query param and is unauthenticated; update the
handler to read the authenticated wallet address from the middleware-injected
header (e.g., request.headers.get('x-wallet-address')) instead of searchParams,
return 401 when the header is missing (mirroring app/api/kyc/status/route.ts
behavior), and remove/ignore the walletAddress query param; also update
client-side call site in app/context/KYCContext.tsx (fetchTransactionSummary) to
send the session/auth header so the server can use the middleware-provided
x-wallet-address.

In `@app/api/phone/send-otp/route.ts`:
- Around line 107-109: Trim the incoming name before assigning full_name in the
response/DB payload: replace uses of the raw name variable with a trimmed
version (e.g., const trimmedName = name?.trim()) and set full_name to
trimmedName || existingProfile?.full_name || null so pure-whitespace names are
not stored; update the assignment that currently sets full_name: name ||
existingProfile?.full_name || null accordingly in the send-otp route handler.

In `@app/components/ProfileDrawer.tsx`:
- Around line 54-59: The effect using useEffect with deps [isOpen] is missing an
eslint disable comment and should explicitly suppress the
react-hooks/exhaustive-deps warning or stabilize refreshStatus; add an inline
eslint directive (// eslint-disable-next-line react-hooks/exhaustive-deps)
immediately above the useEffect to silence the warning, or alternatively ensure
refreshStatus is memoized (e.g., wrap refreshStatus in useCallback where it is
defined) so you can safely include it in the dependency array; update the effect
around useEffect, isOpen, setIsLoading, and refreshStatus accordingly.

In `@app/components/TransactionLimitModal.tsx`:
- Around line 88-94: In TransactionLimitModal, the span that renders the monthly
amount is using a hardcoded "text-white" class which will be invisible in light
mode; update the span's className on the element that wraps
formatNumberWithCommas(currentLimits.monthly) to use a theme-aware utility such
as "text-black dark:text-white" or your project's neutral text token (e.g.,
"text-text-primary dark:text-white") so the value is readable in both light and
dark modes and preserves existing font-weight styling.
- Around line 165-169: In TransactionLimitModal.tsx the arrow icon's className
ternary currently yields an empty string for both states, so add the rotation
class when the modal is expanded: update the span (the arrow icon element) to
conditionally include "rotate-180" when the expansion boolean (e.g., expanded /
isExpanded / the state variable that controls the collapsed/expanded branch) is
true, preserving any existing transition classes (e.g., "transition-transform
duration-200") for smooth animation; ensure the falsy branch remains an empty
string or "rotate-0" so the arrow returns to its default orientation when
collapsed.

In `@app/context/KYCContext.tsx`:
- Around line 118-143: fetchTransactionSummary currently calls
/api/kyc/transaction-summary without an Authorization header; update it to
obtain the current access token (e.g., from your auth/session provider or a
getAccessToken function available in this component/context) before the fetch,
return early if no token, and include Authorization: `Bearer ${accessToken}` in
the fetch headers; ensure the token source (or accessToken) is referenced in the
useCallback dependencies so fetchTransactionSummary uses the latest token and
still sets state via setTransactionSummary and updates fetchGuardsRef as before.
- Around line 187-194: The current useEffect resets fetchGuardsRef.current then
calls refreshStatus, but in-flight fetches can still resolve and call
setTier/setTransactionSummary with stale data; modify the logic to cancel or
ignore stale responses by introducing a generation counter (fetchGenRef) or an
AbortController: increment fetchGenRef.current (or create/abort controller)
whenever walletAddress changes (in the useEffect), capture the current gen (or
signal) at the start of fetchKYCStatus and fetchTransactionSummary, and before
calling any state setters (setTier, setTransactionSummary or updating
fetchGuardsRef.current) check that the captured gen matches fetchGenRef.current
(or that the signal is not aborted); this ensures only the latest wallet’s
responses update state.

In `@app/pages/TransactionForm.tsx`:
- Around line 318-329: The useEffect named updateVerificationStatus currently
sets isUserVerified based on both tier and amountSent via canTransact, which
hides the recipient form when the entered amount exceeds the tier limit; change
it so isUserVerified is derived only from tier (e.g., setIsUserVerified(tier >=
1) and remove amountSent/canTransact from that logic and dependencies) and keep
per-transaction blocking in the existing canTransact/handleSwap flow (handleSwap
remains responsible for rejecting submissions when limits are exceeded).

In `@supabase/migrations/create_transactions_table.sql`:
- Around line 78-85: The UPDATE policy "Users can update own transactions" on
table transactions currently only has a USING clause and therefore doesn't
prevent changing wallet_address in the new row; update the policy to include a
WITH CHECK clause that enforces the new row's wallet_address equals
current_setting('app.current_wallet_address', TRUE) so updates cannot reassign
transactions to another wallet; modify the policy definition for "Users can
update own transactions" to add the WITH CHECK(...) condition mirroring the
USING condition.
- Around line 52-58: The SQL currently grants EXECUTE on the function
set_current_wallet_address(TEXT) to anon and authenticated which allows any
client to impersonate wallets; remove the GRANT lines for anon and authenticated
and ensure only the service_role is granted EXECUTE (or explicitly REVOKE
EXECUTE FROM anon, authenticated and GRANT to service_role) so only the trusted
backend can call set_current_wallet_address; locate the GRANT statements
referencing set_current_wallet_address in the migration and modify them to
restrict execution to service_role only.
- Around line 29-30: The updated_at column currently only has a DEFAULT on
INSERT and won't change on UPDATE; add a trigger function (e.g.,
update_updated_at or update_timestamp) that sets NEW.updated_at =
timezone('utc', now()) and returns NEW, then create a BEFORE UPDATE trigger
(e.g., set_updated_at_trigger) on the transactions table to call that function
for each row so updated_at is refreshed automatically on every update. Ensure
the trigger function and trigger operate on the same table and use TIMESTAMP
WITH TIME ZONE to preserve UTC timestamps.
🧹 Nitpick comments (9)
supabase/migrations/create_transactions_table.sql (1)

35-37: Consider a composite index for monthly volume queries.

The transaction-summary endpoint will query by wallet_address + created_at range. A composite index covers both that query and plain wallet lookups (via leftmost prefix), making the standalone idx_transactions_wallet_address redundant.

-CREATE INDEX idx_transactions_wallet_address ON transactions (wallet_address);
-
-CREATE INDEX idx_transactions_created_at ON transactions (created_at DESC);
+CREATE INDEX idx_transactions_wallet_created ON transactions (wallet_address, created_at DESC);
app/api/phone/verify-otp/route.ts (2)

15-19: No rate limiting on the verify-otp endpoint.

The send-otp endpoint applies rateLimit(request), but verify-otp does not. While the KudiSMS path enforces MAX_ATTEMPTS = 3 and Twilio has its own rate limiting, an attacker can still send rapid concurrent requests to this endpoint. Adding the same rate limiter used in send-otp would provide defense-in-depth.

Proposed fix
 import { NextRequest, NextResponse } from "next/server";
 import { supabaseAdmin } from "@/app/lib/supabase";
+import { rateLimit } from "@/app/lib/rate-limit";
 ...

 export async function POST(request: NextRequest) {
   const startTime = Date.now();

   try {
+    const rateLimitResult = await rateLimit(request);
+    if (!rateLimitResult.success) {
+      return NextResponse.json(
+        { success: false, error: "Too many requests. Please try again later." },
+        { status: 429 },
+      );
+    }
+
     trackApiRequest(request, "/api/phone/verify-otp", "POST");

116-173: Duplicate success-update logic between Twilio and KudiSMS paths.

Lines 140–164 (Twilio success) and lines 242–264 (KudiSMS success) perform identical operations: set verified = true, verified_at, conditionally upgrade tier to 1, and handle update errors. Extract a shared helper to reduce duplication and ensure future changes apply to both paths.

Also, line 242 uses any while line 140 uses Record<string, unknown> — prefer the stricter type consistently.

Also applies to: 241-264

app/context/KYCContext.tsx (1)

12-17: Dead global type declaration — window.__KYC_FETCH_GUARDS__ is never used.

The declare global { interface Window { __KYC_FETCH_GUARDS__?: ... } } augmentation pollutes the global Window type but is never referenced. The actual fetch guard uses fetchGuardsRef (a React ref at line 74). Remove this dead declaration.

app/pages/TransactionPreview.tsx (1)

439-443: Intentionally never resetting the ref means no retry on transient failures.

If saveTransactionData throws (e.g., network blip), the ref stays true and the user must reload to retry. This is a deliberate trade-off (prevent duplicates > allow retries), but worth noting — consider whether a user-facing error toast or a manual retry button would improve UX for genuine save failures.

app/components/TransactionLimitModal.tsx (1)

19-23: transactionAmount prop is accepted but never used.

The prop is declared in the interface and has a default value, but it's never referenced in the component body. Either use it (e.g., to display the blocked amount) or remove it to avoid confusion.

Also applies to: 28-29

app/components/ProfileDrawer.tsx (3)

76-83: navigator.clipboard.writeText can reject — add error handling.

In non-HTTPS contexts or when the document lacks focus, writeText throws. A rejected promise here will be an unhandled rejection.

Suggested fix
 const handleCopyAddress = () => {
   if (walletAddress) {
-    navigator.clipboard.writeText(walletAddress);
-    setIsAddressCopied(true);
-    toast.success("Address copied to clipboard");
-    setTimeout(() => setIsAddressCopied(false), 2000);
+    navigator.clipboard.writeText(walletAddress)
+      .then(() => {
+        setIsAddressCopied(true);
+        toast.success("Address copied to clipboard");
+        setTimeout(() => setIsAddressCopied(false), 2000);
+      })
+      .catch(() => {
+        toast.error("Failed to copy address");
+      });
   }
 };

216-216: Use strict equality (===) instead of loose equality (==).

Line 216 uses tier == 0 while the rest of the codebase consistently uses ===. Since tier is typed as 0 | 1 | 2, this works, but === is the idiomatic choice.

-              {tier == 0 && tierLevel === tier + 1 && (
+              {tier === 0 && tierLevel === tier + 1 && (

333-333: tier !== undefined is a dead check.

tier is typed as 0 | 1 | 2 and initialized to 0 in KYCContext, so it's never undefined. The >= 1 check already handles everything.

-                      {tier >= 1 && tier !== undefined && (
+                      {tier >= 1 && (

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 10

🤖 Fix all issues with AI agents
In @.env.example:
- Around line 129-135: The committed Dojah credentials must be rotated and
removed from the repo: immediately revoke/rotate the keys shown and replace the
hard-coded values for the variables DOJAH_APP_ID and DOJAH_SECRET_KEY in
.env.example with non-sensitive placeholders (e.g.
DOJAH_APP_ID=<YOUR_DOJAH_APP_ID> and DOJAH_SECRET_KEY=<YOUR_DOJAH_SECRET_KEY>),
keep DOJAH_BASE_URL and KYC_DOCUMENTS_BUCKET as example/default values only, and
ensure no real secrets remain in the example file or future commits (also
consider removing the exposed values from git history if required).

In `@app/api/kyc/tier3-verify/route.ts`:
- Around line 39-54: Validate the uploaded file before reading into memory:
check the incoming file object's size and MIME type (the same file used in
Buffer.from(await file.arrayBuffer()) and for deriving ext/path) and reject if
size > 10 * 1024 * 1024 (10MB) or if file.type is not in an allowlist like
image/jpeg, image/png, image/webp; return an appropriate JSON error response
(e.g., 400 or 413) and only call file.arrayBuffer() after these checks; also
derive the file extension from a safe mapping of MIME types when file.name has
no extension to avoid trusting user-provided names.
- Around line 126-134: Before creating updatePayload with tier: 3, fetch the
user's current verification state (e.g., load the user record or call the
existing user service used in this route handler) and assert that Tier 1 and
Tier 2 prerequisites are complete (for example check phone/phone_verified or
tier >= 1 and SmileID/smileid_verified or tier >= 2); if those checks fail,
return a 4xx response and do not proceed to build or apply updatePayload. Locate
the route handler where updatePayload is constructed and add the prerequisite
checks there (verify currentUser.tier or currentUser.phoneVerified and
currentUser.smileIdVerified) before allowing tier to be set to 3.

In `@app/components/kyc/DocumentRequirementsModal.tsx`:
- Around line 39-70: The modal content currently renders directly inside a
motion.div (inside DocumentRequirementsModal) so Headless UI v2 cannot detect
the modal boundary; wrap that motion.div with Headless UI's DialogPanel
component (import DialogPanel from Headless UI if not already) so the motion.div
becomes a child of <DialogPanel> — keep existing props/children (fadeInOut,
className, close button, list rendering, onClose handler) intact and remove any
duplicate outer wrappers so click-outside-to-close behavior is restored while
preserving the Cancel01Icon button and list rendering logic.

In `@app/components/KycModal.tsx`:
- Around line 835-843: handleTier3FileChange and handleTier3Drop currently
silently return on invalid file type/size; update both handlers to set a
user-visible error state instead of silently returning: when ext not in
["JPG","PNG","PDF","DOC","JPEG","DOCX"] call the error state setter (e.g.,
setTier3Error or setTier3ErrorMessage) with a message like "Invalid file type;
allowed: JPG, PNG, PDF, DOC, JPEG, DOCX" and return; when file.size > 5 * 1024 *
1024 set the error state to "File too large; maximum 5 MB" and return; on
successful validation clear the error state before calling
setTier3UploadedFile(file). Ensure you apply the same changes to both
handleTier3FileChange and handleTier3Drop and use the same validation logic and
messages so users get clear feedback.
- Around line 957-964: The current rendering uses
tier3UploadedFile.type.split("/")[1].toUpperCase(), which throws if
tier3UploadedFile.type is empty; update the JSX that displays the file format
(the expression inside the second <p> for tier3UploadedFile) to defensively
handle missing MIME type by first checking tier3UploadedFile.type (e.g., use
optional chaining or a conditional) and falling back to a safe value derived
from the filename extension or a literal like "UNKNOWN" before calling
toUpperCase(); ensure the final value is always a string so toUpperCase() cannot
throw.

In `@app/components/ProfileDrawer.tsx`:
- Around line 380-387: The progress bar gradient in ProfileDrawer.tsx is using
identical colors ("from-white to-white") so it is invisible on light
backgrounds; update the inner div (the element with className "h-2.5
rounded-full bg-gradient-to-r from-white to-white transition-all duration-500")
to use distinct, accessible gradient colors (e.g., Tailwind classes like
from-sky-500 to-indigo-500) and add dark-mode variants (e.g., dark:from-sky-400
dark:to-indigo-400) or use semantic CSS variables (e.g., from-[var(--accent-1)]
to-[var(--accent-2)]) so the gradient is visible in both light and dark themes
while preserving the existing width transition logic.
- Around line 167-172: The ArrowDown01Icon rotation classes are missing because
classNames is given empty strings for both branches; update the classNames call
in ProfileDrawer.tsx (for ArrowDown01Icon) to conditionally add a rotation class
based on isExpanded (e.g., add "rotate-180" when isExpanded is true and "" or
"rotate-0" when false) and keep the existing transition classes so the chevron
visually rotates when toggling; reference the ArrowDown01Icon element, the
isExpanded boolean, and the classNames helper when making this change.

In `@app/components/TransactionLimitModal.tsx`:
- Around line 65-69: In handlePhoneVerified, await the async refreshStatus()
call and remove the explicit onClose() invocation to avoid closing the modal
prematurely and duplicating the close action; specifically update the
handlePhoneVerified function so it calls await refreshStatus() (ensuring the
KYC/tier state is refreshed before UI updates) and delete the onClose() call
there, relying on PhoneVerificationModal's own onClose prop (triggered alongside
onVerified) to close the modal.

In `@app/context/KYCContext.tsx`:
- Around line 136-161: The fetchTransactionSummary (and fetchKYCStatus)
functions call response.json() without checking HTTP status; update both to
first test response.ok (or response.status) after the fetch and handle non-OK
responses by reading/attempting to parse the body for error details or logging
response.status/text and treating the call as failed (set guards via
fetchGuardsRef.current using the same key pattern `${guardKey}_tx` / appropriate
key, and bail out or throw so the error is caught and logged), ensuring you
still set guards[key] = "done" in finally; this prevents attempting to parse
non-JSON error pages and surfaces 4xx/5xx failures to logs and callers.
🧹 Nitpick comments (9)
app/lib/dojah.ts (2)

37-51: Add a timeout to the Dojah API call.

The fetch to Dojah has no timeout. If the external service is slow or unresponsive, this request will hang indefinitely, tying up a serverless function invocation.

Proposed fix
   const res = await fetch(
     `${DOJAH_BASE_URL}/api/v1/document/analysis/utility_bill`,
     {
       method: "POST",
       headers: {
         "Content-Type": "application/json",
         AppId: DOJAH_APP_ID,
         Authorization: DOJAH_SECRET_KEY,
       },
       body: JSON.stringify({
         input_type: "url",
         input_value: imageUrl,
       }),
+      signal: AbortSignal.timeout(30_000),
     }
   );

53-58: Non-JSON error responses will produce a confusing error.

If Dojah returns a non-JSON response (e.g., HTML 502 page), res.json() on line 53 will throw a SyntaxError before the !res.ok check on line 55 can run. The caller's catch block will handle it, but the error message will be unhelpful (e.g., "Unexpected token < in JSON").

Consider checking res.ok before parsing, or catching the JSON parse error:

Proposed fix
+  if (!res.ok) {
+    let message = res.statusText;
+    try {
+      const errData = await res.json();
+      message = errData?.message || errData?.entity?.result?.message || message;
+    } catch { /* non-JSON body */ }
+    throw new Error(message || `Dojah request failed: ${res.status}`);
+  }
+
-  const data = (await res.json()) as DojahUtilityBillResponse & { message?: string };
-
-  if (!res.ok) {
-    const message = data?.message || data?.entity?.result?.message || res.statusText;
-    throw new Error(message || `Dojah request failed: ${res.status}`);
-  }
+  const data = (await res.json()) as DojahUtilityBillResponse;

   return data;
app/api/kyc/tier3-verify/route.ts (1)

94-103: Uploaded document persists in storage even if Dojah verification fails.

When isDojahVerificationSuccess returns false (line 95), the endpoint returns a 400 but leaves the uploaded file in the storage bucket. Over time, failed verification attempts will accumulate orphaned files, increasing storage costs.

Consider cleaning up on failure:

Proposed addition
     if (!isDojahVerificationSuccess(dojahResult)) {
+      // Clean up the uploaded file since verification failed
+      await supabaseAdmin.storage.from(KYC_BUCKET).remove([path]);
       const msg =
         dojahResult?.entity?.result?.message ||
app/context/KYCContext.tsx (1)

12-17: Unused global type declaration.

Window.__KYC_FETCH_GUARDS__ is declared but never assigned or read anywhere. The guard mechanism uses fetchGuardsRef exclusively. Remove this dead declaration to avoid confusion.

Proposed fix
-// Extend the Window interface for our in-memory fetch guard
-declare global {
-  interface Window {
-    __KYC_FETCH_GUARDS__?: Record<string, string>;
-  }
-}
app/components/ProfileDrawer.tsx (1)

346-404: Minor: redundant tier !== undefined guard and loose comparison at Line 230.

On Line 347, tier is typed KYCTierLevel (always 0|1|2|3|4), so tier !== undefined is always true. On Line 230 (tier == 0), prefer strict equality (===).

app/components/KycModal.tsx (4)

8-14: Global JSX IntrinsicElements uses any — consider a minimal shape.

Using any defeats type-checking for every attribute on <smart-camera-web>. A narrower type improves safety.

Suggested improvement
 declare global {
   namespace JSX {
     interface IntrinsicElements {
-      "smart-camera-web": any;
+      "smart-camera-web": React.DetailedHTMLProps<
+        React.HTMLAttributes<HTMLElement> & {
+          "theme-color"?: string;
+          "capture-id"?: boolean;
+        },
+        HTMLElement
+      >;
     }
   }
 }

92-96: getAllCountries() is called on every render — memoize or hoist.

getAllCountries() sorts the full country list on each render. Since idTypesData is a static JSON import, the result never changes. Hoist it to module scope.

Proposed fix
-const getAllCountries = (): Country[] => {
-  return idTypesData.continents
-    .flatMap((continent) => continent.countries)
-    .sort((a, b) => a.name.localeCompare(b.name));
-};
+const ALL_COUNTRIES: Country[] = idTypesData.continents
+  .flatMap((continent) => continent.countries)
+  .sort((a, b) => a.name.localeCompare(b.name));

 // inside component:
-const countries = getAllCountries();
+const countries = ALL_COUNTRIES;

Also applies to: 153-153


178-190: Remove console.log from production path.

Line 182 logs on every successful SmileID load. Use a debug-level logger or remove.


1259-1276: All steps rendered eagerly via object-lookup pattern.

Every render function (including tier3 forms, capture, etc.) is called on every render, regardless of which step is active. This is wasteful — the SmileID camera component gets mounted/unmounted implicitly. Consider lazy evaluation:

Sketch
-        {
-          {
-            [STEPS.TERMS]: renderTerms(),
-            ...
-          }[step]
-        }
+        {step === STEPS.TERMS && renderTerms()}
+        {step === STEPS.ID_INFO && renderIdInfo()}
+        {step === STEPS.CAPTURE && renderCapture()}
+        {/* ... etc */}

This avoids constructing unused JSX trees and prevents unintended side-effects from render functions (like renderTier3Upload defining handlers on every render).

@Dprof-in-tech
Copy link
Collaborator Author

@coderabbitai review

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/components/SettingsDropdown.tsx (1)

281-307: ⚠️ Potential issue | 🔴 Critical

Fix JSX parse error from stray */} / unmatched conditional.
Line 290 introduces an unexpected token and breaks parsing. Remove the stray */ and the extra closing )} so JSX compiles.

🛠️ Suggested fix
-              )} */}
+              )}
               <li
                 role="menuitem"
                 className="flex cursor-pointer items-center justify-between gap-2 rounded-lg transition-all duration-300 hover:bg-accent-gray dark:hover:bg-neutral-700"
               >
                 <button
                   type="button"
                   className="group flex w-full items-center gap-2.5"
                   onClick={() => {
                     setIsProfileDrawerOpen(true);
                     setIsOpen(false);
                   }}
                 >
                   <FaceIdIcon className="size-5 text-icon-outline-secondary dark:text-white/50" />
                   <p>Profile</p>
                 </button>
               </li>
-              )}
🤖 Fix all issues with AI agents
In `@app/api/kyc/tier3-verify/route.ts`:
- Around line 11-13: The server-side allowlist (ALLOWED_MIME_TYPES and
MIME_TO_EXT) only contains image MIME types while the UI accepts PDFs/DOCs;
update ALLOWED_MIME_TYPES to include "application/pdf", "application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" and
add corresponding entries to MIME_TO_EXT (e.g., "pdf" / "doc" / "docx"), then
ensure the route's validation logic (the handler referencing MAX_FILE_BYTES,
ALLOWED_MIME_TYPES, MIME_TO_EXT) uses the updated constants; also update the
other occurrence noted around lines 65-73 to reference the same allowlist so
client and server validations match.
- Around line 59-63: The error message for the file size check is inconsistent:
the check uses file.size > MAX_FILE_BYTES but the NextResponse.json message says
"maximum 10 MB"; update the message to reflect the actual MAX_FILE_BYTES limit
(e.g., "maximum 5 MB") or build the message dynamically from MAX_FILE_BYTES so
they stay in sync; modify the NextResponse.json call in the branch that checks
file.size > MAX_FILE_BYTES to use the corrected text or a computed
human-readable value based on MAX_FILE_BYTES.

In `@app/components/ProfileDrawer.tsx`:
- Around line 304-307: The <img> in ProfileDrawer.tsx is missing an alt
attribute; update the image element used for the profile avatar (in the
ProfileDrawer component) to include a descriptive alt string (e.g., the user's
name like `${user.name} avatar` or a fallback like "profile picture") and ensure
the alt is set consistently whether you're rendering the placeholder
("/icons/placeholder.png") or a real avatar URL so screen readers receive
meaningful text.
🧹 Nitpick comments (2)
app/context/KYCContext.tsx (2)

12-17: Unused global declaration.

Window.__KYC_FETCH_GUARDS__ is declared but never assigned or read anywhere in this file. The actual guards are stored in fetchGuardsRef.current. This declaration appears to be leftover from an earlier design.

Proposed fix: remove unused declaration
-// Extend the Window interface for our in-memory fetch guard
-declare global {
-  interface Window {
-    __KYC_FETCH_GUARDS__?: Record<string, string>;
-  }
-}
-

24-31: Type mismatch between KYCTierLevel and KYCTier.level.

KYCTierLevel includes 0, but KYCTier.level is typed as 1 | 2 | 3 | 4. This inconsistency could cause confusion since tier 0 (unverified) is a valid runtime state but has no corresponding KYCTier entry.

Consider aligning the types or documenting that tier 0 is intentionally excluded from KYC_TIERS:

Option: Add explicit documentation
+// Tier 0 represents unverified users and has no KYCTier entry in KYC_TIERS
 export type KYCTierLevel = 0 | 1 | 2 | 3 | 4;

Comment on lines +11 to +13
const MAX_FILE_BYTES = 5 * 1024 * 1024; // 5MB
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp"] as const;
const MIME_TO_EXT: Record<string, string> = {
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 | 🟠 Major

Backend allowlist rejects file types the UI accepts.
The Tier 3 UI accepts PDF/DOC/DOCX, but this route only allows image MIME types. Users can pass client validation and still hit a 400 here. Align server + client allowlists (either tighten the UI to images or expand the server list to match).

🛠️ Example if PDFs are intended to be allowed
-const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp"] as const;
+const ALLOWED_MIME_TYPES = [
+  "image/jpeg",
+  "image/png",
+  "image/webp",
+  "application/pdf",
+] as const;

 const MIME_TO_EXT: Record<string, string> = {
   "image/jpeg": "jpg",
   "image/png": "png",
   "image/webp": "webp",
+  "application/pdf": "pdf",
 };

Also applies to: 65-73

🤖 Prompt for AI Agents
In `@app/api/kyc/tier3-verify/route.ts` around lines 11 - 13, The server-side
allowlist (ALLOWED_MIME_TYPES and MIME_TO_EXT) only contains image MIME types
while the UI accepts PDFs/DOCs; update ALLOWED_MIME_TYPES to include
"application/pdf", "application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" and
add corresponding entries to MIME_TO_EXT (e.g., "pdf" / "doc" / "docx"), then
ensure the route's validation logic (the handler referencing MAX_FILE_BYTES,
ALLOWED_MIME_TYPES, MIME_TO_EXT) uses the updated constants; also update the
other occurrence noted around lines 65-73 to reference the same allowlist so
client and server validations match.

Comment on lines +304 to +307
<img
src="/icons/placeholder.png"
className="object-fit h-[44px] w-[44px] rounded-full"
/>
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

Missing alt attribute on image element.

The <img> element is missing an alt attribute, which is an accessibility requirement. Screen readers need alternative text to describe the image.

Proposed fix
 <img
   src="/icons/placeholder.png"
   className="object-fit h-[44px] w-[44px] rounded-full"
+  alt="User profile"
 />
🤖 Prompt for AI Agents
In `@app/components/ProfileDrawer.tsx` around lines 304 - 307, The <img> in
ProfileDrawer.tsx is missing an alt attribute; update the image element used for
the profile avatar (in the ProfileDrawer component) to include a descriptive alt
string (e.g., the user's name like `${user.name} avatar` or a fallback like
"profile picture") and ensure the alt is set consistently whether you're
rendering the placeholder ("/icons/placeholder.png") or a real avatar URL so
screen readers receive meaningful text.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@app/api/phone/verify-otp/route.ts`:
- Around line 197-205: The current read-modify-write that updates attempts using
verification.attempts is non-atomic and can lose increments under concurrent
requests; create a Postgres function (e.g., increment_kyc_attempts) that
performs "update user_kyc_profiles set attempts = attempts + 1 where
wallet_address = p_wallet_address and attempts < MAX_ATTEMPTS returning
attempts" and then replace the .update(...) call on supabaseAdmin in the verify
flow with a single .rpc('increment_kyc_attempts', { p_wallet_address:
walletAddress }) call (handle the returned data/error like the previous
updated/attemptsError and still enforce MAX_ATTEMPTS constant in the DB
function).

In `@app/components/KycModal.tsx`:
- Around line 1174-1179: In KycModal, the Typeform container div using
typeformContainerRef has an invalid empty width (style={{ width: "", minHeight:
"11px" }}) and an unusually small minHeight; remove the empty width property (or
set a sensible value like "100%" or a px value) and increase minHeight to a
reasonable size for an embed (e.g., ~200px) or replace the inline styles with a
named CSS class; also remove the redundant empty className to keep the element
clean.
- Around line 1054-1089: The onClick handler for Tier 3 submission doesn't check
HTTP status and can exit early without resetting tier3Submitting; update the
handler (the async onClick that calls getAccessToken and
fetch("/api/kyc/tier3-verify")) so that after calling getAccessToken you do not
return without clearing setTier3Submitting(false) (i.e., if no token, call
setTier3Submitting(false) then toast and return), and after fetch check res.ok
before calling res.json(); if !res.ok attempt to parse JSON inside a try/catch
and fall back to await res.text() to capture error details, then
setStep(STEPS.STATUS.FAILED) and surface a helpful toast/log including the
parsed error message; only treat success when res.ok and parsed data.success is
true, then call refreshStatus(), setIsUserVerified(true) and setStep SUCCESS.
Ensure all early-return paths clear tier3Submitting so the finally is no longer
the only mechanism relied upon.

In `@app/components/ProfileDrawer.tsx`:
- Around line 186-208: The icon container in the tierData.requirements.map
rendering can end up empty because the chained conditional returns false when no
condition matches (the `req.includes("ID") && (...)` yields false), so update
the JSX in ProfileDrawer.tsx inside the map (the <li> and its inner <div> that
currently renders icons) to ensure the expression always returns a React
element: replace the chained ternaries with a clear branching that returns the
appropriate icon for "number", "Selfie verification", "Address verification",
"Business verification", or "ID", and add an explicit fallback icon (e.g.,
DefaultIcon) for any other requirement string so the container never renders
empty. Ensure the final expression always yields a valid element (not a boolean)
so the icon placeholder is always populated.
🧹 Nitpick comments (7)
app/api/phone/verify-otp/route.ts (1)

29-32: Middleware already normalizes wallet addresses; add explicit lowercasing for defensive consistency.

The x-wallet-address header is set by middleware after normalizing through privy.ts (which returns .toLowerCase()), so wallet addresses reaching this route are already lowercase. However, other routes like /api/v1/wallets/deprecate explicitly call .toLowerCase() on this header for defensive consistency. Consider adopting the same pattern here to match the codebase convention and protect against future middleware changes.

Suggested adjustment
-    const walletAddress = request.headers.get("x-wallet-address");
+    const walletAddress = request.headers.get("x-wallet-address")?.toLowerCase();
app/components/ProfileDrawer.tsx (2)

77-84: Add error handling for clipboard write failure.

navigator.clipboard.writeText can fail in certain environments (e.g., non-HTTPS, iframe restrictions). Currently errors are silently swallowed and the success state is set unconditionally.

Proposed fix
 const handleCopyAddress = () => {
-  if (walletAddress) {
-    navigator.clipboard.writeText(walletAddress);
-    setIsAddressCopied(true);
-    toast.success("Address copied to clipboard");
-    setTimeout(() => setIsAddressCopied(false), 2000);
+  if (walletAddress && navigator.clipboard?.writeText) {
+    navigator.clipboard.writeText(walletAddress)
+      .then(() => {
+        setIsAddressCopied(true);
+        toast.success("Address copied to clipboard");
+        setTimeout(() => setIsAddressCopied(false), 2000);
+      })
+      .catch(() => {
+        toast.error("Unable to copy address");
+      });
   }
 };

93-101: Auto-expand effect only triggers for tier < 1, missing higher tiers.

The effect only auto-expands the next tier section when tier < 1 (i.e., Tier 0). Users at Tier 1, 2, or 3 won't see their next tier auto-expanded. Consider expanding the next tier for all users who have a next tier available.

Proposed fix
   // Auto-expand next tier section
   useEffect(() => {
-    if (tier < 1) {
+    const nextTier = tier + 1;
+    if (KYC_TIERS[nextTier]) {
       setExpandedTiers((prev) => ({
         ...prev,
-        [tier + 1]: true,
+        [nextTier]: true,
       }));
     }
   }, [tier]);
app/context/KYCContext.tsx (2)

202-213: refreshStatus signature change may break callers expecting a no-arg function.

The function signature is refreshStatus = useCallback(async (force = false) => {...}), but the context type at line 80 declares refreshStatus: () => Promise<void>. Callers won't get type errors when omitting the argument (due to the default), but the type mismatch could cause confusion.

Update the type to reflect the optional parameter
 interface KYCContextType {
   // ...
-  refreshStatus: () => Promise<void>;
+  refreshStatus: (force?: boolean) => Promise<void>;
 }

122-137: canTransact reason message doesn't format currency consistently.

The reason string uses $${amount} and $${remaining.monthly} without formatting. For large amounts, this could show $15000 instead of $15,000. Consider using formatNumberWithCommas for consistency with the rest of the app.

app/components/KycModal.tsx (2)

194-206: SmileID load failure only shows a toast — consider blocking progression.

When the dynamic import fails, a toast is shown but smileIdLoaded remains false. If the user somehow reaches the CAPTURE step (e.g., via rapid clicking or state manipulation), they'll see a broken camera component. Consider also setting an error state that prevents navigation to CAPTURE.

Proposed enhancement
+ const [smileIdError, setSmileIdError] = useState(false);

  useEffect(() => {
    if (targetTier === 3 || targetTier === 4) return;
    if (typeof window !== "undefined" && !smileIdLoaded) {
      import("@smileid/web-components/smart-camera-web")
        .then(() => {
          setSmileIdLoaded(true);
        })
        .catch(() => {
          toast.error("Failed to load verification component");
+         setSmileIdError(true);
        });
    }
  }, [smileIdLoaded, targetTier]);

// Then in renderIdInfo, disable Continue button if smileIdError:
- disabled={!selectedCountry || !selectedIdType || (!needsDocCapture && !idNumber)}
+ disabled={!selectedCountry || !selectedIdType || (!needsDocCapture && !idNumber) || smileIdError}

1109-1144: Typeform script loading has potential issues.

  1. The spinnerTimer sets isTypeformReady to true after 2 seconds regardless of whether the script actually loaded, potentially hiding the spinner before the form is ready.
  2. No cleanup removes the injected script on unmount, which could cause issues if the component remounts.
Proposed improvement
   useEffect(() => {
     if (step !== STEPS.TIER4_TYPEFORM) return;
     setIsTypeformReady(false);

     const loadScript = () => {
       if (typeformScriptLoaded.current && window.tf) {
         window.tf.load();
+        setIsTypeformReady(true);
         return;
       }

       const existing = document.querySelector(
         'script[src*="embed.typeform.com/next/embed.js"]',
       );
       if (existing) {
         typeformScriptLoaded.current = true;
         window.tf?.load();
+        setIsTypeformReady(true);
         return;
       }

       const script = document.createElement("script");
       script.src = "//embed.typeform.com/next/embed.js";
       script.async = true;
       script.onload = () => {
         typeformScriptLoaded.current = true;
+        setIsTypeformReady(true);
       };
       document.head.appendChild(script);
     };

     const scriptTimer = setTimeout(loadScript, 100);
-    const spinnerTimer = setTimeout(() => setIsTypeformReady(true), 2000);

     return () => {
       clearTimeout(scriptTimer);
-      clearTimeout(spinnerTimer);
     };
   }, [step]);

Comment on lines +197 to +205
if (verification.otp_code !== hashOTP(otpCode)) {
// Atomic increment with boundary check to prevent race conditions
const { data: updated, error: attemptsError } = await supabaseAdmin
.from("user_kyc_profiles")
.update({ attempts: verification.attempts + 1 })
.eq("wallet_address", walletAddress)
.lt("attempts", MAX_ATTEMPTS)
.select("attempts")
.single();
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 | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

Supabase PostgREST atomic increment update RETURNING keyword

💡 Result:

Supabase’s REST API is PostgREST, so you don’t send a SQL RETURNING keyword. Instead, you control “return updated row(s)” with the Prefer header:

  • Default for writes: no body (Prefer: return=minimal)
  • To get updated rows back: Prefer: return=representation (optionally combine with ?select=...) [1]

Example (REST / PostgREST):

curl -X PATCH 'https://<project_ref>.supabase.co/rest/v1/counters?id=eq.42&select=id,count' \
  -H "apikey: <anon>" \
  -H "Authorization: Bearer <jwt-or-anon>" \
  -H "Content-Type: application/json" \
  -H "Prefer: return=representation" \
  -d '{ "count": 123 }'

Atomic increment

A true atomic increment is SET count = count + 1 inside Postgres. Because PostgREST updates are JSON-based, the common Supabase/PostgREST approach is to do the increment in a Postgres function and call it via RPC [2][4]:

create or replace function increment_counter(row_id bigint, inc int default 1)
returns bigint
language sql
as $$
  update counters
  set count = count + inc
  where id = row_id
  returning count;
$$;

Call it:

  • JS:
const { data, error } = await supabase.rpc('increment_counter', { row_id: 42, inc: 1 })

[2]

  • REST:
    POST https://<project_ref>.supabase.co/rest/v1/rpc/increment_counter (body: {"row_id":42,"inc":1}) [4]

🌐 Web query:

Supabase JavaScript client v2 increment column value atomic update

💡 Result:

To do an atomic increment with @supabase/supabase-js v2, run the increment inside Postgres (single UPDATE ... SET col = col + n ... RETURNING ...) and call it via RPC from the client. Supabase’s JS client supports calling Postgres functions with supabase.rpc(...). [1]

1) Create a Postgres function (atomic increment)

create or replace function increment_counter(p_id uuid, p_by int)
returns bigint
language sql
as $$
  update my_table
  set counter = counter + p_by
  where id = p_id
  returning counter;
$$;

2) Call it from supabase-js v2

const { data, error } = await supabase.rpc('increment_counter', {
  p_id: someId,
  p_by: 1,
})

if (error) throw error
// data is the returned bigint (new counter value)

Notes

  • This avoids the non-atomic “select then update” race condition by doing the math in the database in one statement.
  • Adjust parameter/return types to match your table (e.g., bigint, int, etc.).

Source

  • Supabase docs: calling Postgres functions via supabase.rpc [1]

References:
[1] Supabase Docs — “JavaScript: Call a Postgres function” https://supabase.com/docs/client/rpc


🌐 Web query:

Supabase race condition concurrent updates attempts counter

💡 Result:

To avoid a race condition when multiple clients concurrently increment an “attempts” counter in Supabase, don’t do read → increment in app → write. Instead, make the increment happen inside Postgres as a single statement (atomic at the row level).

Best pattern: atomic increment (no read-modify-write)

Use:

update my_table
set attempts = attempts + 1
where id = _id
returning attempts;

This avoids lost updates because the database computes attempts + 1 during the UPDATE, rather than you sending a precomputed value from the client. [1]

Supabase: expose it via an RPC (recommended)

Since Supabase clients generally operate through PostgREST, put the update in a function and call it with rpc() [1]:

create or replace function increment_attempts(_id uuid)
returns bigint
language sql
as $$
  update my_table
  set attempts = attempts + 1
  where id = _id
  returning attempts;
$$;

JS:

const { data, error } = await supabase.rpc('increment_attempts', { _id: id })

If you need more complex logic: lock the row in a transaction

If you must read other columns and make decisions before updating, do it in a single DB function using row-level locks (e.g., SELECT ... FOR UPDATE / FOR NO KEY UPDATE) so concurrent transactions wait rather than overwrite each other. PostgreSQL documents these row locks and their blocking behavior. [3]

Supabase/RLS gotcha (common “it didn’t update” symptom)

With RLS enabled, an UPDATE may “not work as expected” unless the role also has a matching SELECT policy (because Postgres needs row visibility for updates). Supabase explicitly notes that UPDATE requires a corresponding SELECT policy. [2]

Sources: [1] [2] [3]


🏁 Script executed:

cat -n app/api/phone/verify-otp/route.ts | head -250 | tail -100

Repository: paycrest/noblocks

Length of output: 3686


🏁 Script executed:

rg "supabase.rpc|increment|attempts" app/api/phone/verify-otp/route.ts -B2 -A2

Repository: paycrest/noblocks

Length of output: 2127


🏁 Script executed:

fd "route.ts" --type f | xargs rg "rpc\(" -l | head -5

Repository: paycrest/noblocks

Length of output: 105


Replace client-side read-modify-write with a database-side atomic increment to prevent lost OTP-attempt updates under concurrent requests.

The current pattern attempts: verification.attempts + 1 performs a non-atomic read-modify-write at the application level. While the .lt("attempts", MAX_ATTEMPTS) filter prevents exceeding the limit, it does not prevent concurrent requests from reading the same stale attempts value and overwriting each other's increments. For example, if two concurrent invalid OTPs both read attempts=5, both will write 6, losing one increment and weakening the rate limit.

Create a Postgres function that increments atomically within the database:

Example atomic increment function
create or replace function increment_kyc_attempts(p_wallet_address text)
returns bigint
language sql
as $$
  update user_kyc_profiles
  set attempts = attempts + 1
  where wallet_address = p_wallet_address
    and attempts < 5  -- MAX_ATTEMPTS as constant
  returning attempts;
$$;

Then replace lines 199–205 with:

const { data: updated, error: attemptsError } = await supabaseAdmin
  .rpc('increment_kyc_attempts', { p_wallet_address: walletAddress })
  .single();

This ensures the increment and boundary check execute as a single database operation, eliminating the race condition.

🤖 Prompt for AI Agents
In `@app/api/phone/verify-otp/route.ts` around lines 197 - 205, The current
read-modify-write that updates attempts using verification.attempts is
non-atomic and can lose increments under concurrent requests; create a Postgres
function (e.g., increment_kyc_attempts) that performs "update user_kyc_profiles
set attempts = attempts + 1 where wallet_address = p_wallet_address and attempts
< MAX_ATTEMPTS returning attempts" and then replace the .update(...) call on
supabaseAdmin in the verify flow with a single .rpc('increment_kyc_attempts', {
p_wallet_address: walletAddress }) call (handle the returned data/error like the
previous updated/attemptsError and still enforce MAX_ATTEMPTS constant in the DB
function).

Comment on lines +1054 to +1089
onClick={async () => {
if (!tier3UploadedFile || tier3Submitting) return;
setTier3Submitting(true);
try {
const accessToken = await getAccessToken();
if (!accessToken) {
toast.error("Session expired. Please sign in again.");
return;
}
const formData = new FormData();
formData.append("file", tier3UploadedFile);
formData.append("countryCode", tier3CountryCode);
formData.append("documentType", tier3DocumentType);
formData.append("houseNumber", tier3HouseNumber);
formData.append("streetAddress", tier3StreetAddress);
formData.append("county", tier3County);
formData.append("postalCode", tier3PostalCode);
const res = await fetch("/api/kyc/tier3-verify", {
method: "POST",
headers: { Authorization: `Bearer ${accessToken}` },
body: formData,
});
const data = await res.json();
if (data.success) {
await refreshStatus();
setIsUserVerified(true);
setStep(STEPS.STATUS.SUCCESS);
} else {
setStep(STEPS.STATUS.FAILED);
}
} catch (e) {
setStep(STEPS.STATUS.FAILED);
} finally {
setTier3Submitting(false);
}
}}
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 | 🟠 Major

Tier 3 submission doesn't handle non-2xx responses properly.

At line 1076, await res.json() is called without first checking res.ok. If the server returns a 4xx/5xx error with a non-JSON body (e.g., HTML error page), this will throw a JSON parse error, masking the actual error. Additionally, the finally block always sets tier3Submitting to false, but if getAccessToken() returns null, the function returns early without reaching finally.

Proposed fix
               const res = await fetch("/api/kyc/tier3-verify", {
                 method: "POST",
                 headers: { Authorization: `Bearer ${accessToken}` },
                 body: formData,
               });
+              if (!res.ok) {
+                const errorText = await res.text();
+                throw new Error(errorText || `HTTP ${res.status}`);
+              }
               const data = await res.json();
               if (data.success) {
                 await refreshStatus();
                 setIsUserVerified(true);
                 setStep(STEPS.STATUS.SUCCESS);
               } else {
+                toast.error(data.message || "Verification failed");
                 setStep(STEPS.STATUS.FAILED);
               }

Also fix the early return:

               if (!accessToken) {
                 toast.error("Session expired. Please sign in again.");
+                setTier3Submitting(false);
                 return;
               }
🤖 Prompt for AI Agents
In `@app/components/KycModal.tsx` around lines 1054 - 1089, The onClick handler
for Tier 3 submission doesn't check HTTP status and can exit early without
resetting tier3Submitting; update the handler (the async onClick that calls
getAccessToken and fetch("/api/kyc/tier3-verify")) so that after calling
getAccessToken you do not return without clearing setTier3Submitting(false)
(i.e., if no token, call setTier3Submitting(false) then toast and return), and
after fetch check res.ok before calling res.json(); if !res.ok attempt to parse
JSON inside a try/catch and fall back to await res.text() to capture error
details, then setStep(STEPS.STATUS.FAILED) and surface a helpful toast/log
including the parsed error message; only treat success when res.ok and parsed
data.success is true, then call refreshStatus(), setIsUserVerified(true) and
setStep SUCCESS. Ensure all early-return paths clear tier3Submitting so the
finally is no longer the only mechanism relied upon.

Comment on lines +1174 to +1179
<div
ref={typeformContainerRef}
data-tf-live="01KH3NC0M1G4MNHBCWM320WBP9"
style={{ width: "", minHeight: "11px" }}
className=""
/>
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

Typeform container has empty width style and unclear purpose.

The inline style style={{ width: "", minHeight: "11px" }} sets an empty width string, which is likely unintentional. This may cause layout issues. Also, minHeight: "11px" seems unusually small for a Typeform embed trigger.

Proposed fix
         <div
           ref={typeformContainerRef}
           data-tf-live="01KH3NC0M1G4MNHBCWM320WBP9"
-          style={{ width: "", minHeight: "11px" }}
-          className=""
+          style={{ minHeight: "44px" }}
+          className="flex-1"
         />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div
ref={typeformContainerRef}
data-tf-live="01KH3NC0M1G4MNHBCWM320WBP9"
style={{ width: "", minHeight: "11px" }}
className=""
/>
<div
ref={typeformContainerRef}
data-tf-live="01KH3NC0M1G4MNHBCWM320WBP9"
style={{ minHeight: "44px" }}
className="flex-1"
/>
🤖 Prompt for AI Agents
In `@app/components/KycModal.tsx` around lines 1174 - 1179, In KycModal, the
Typeform container div using typeformContainerRef has an invalid empty width
(style={{ width: "", minHeight: "11px" }}) and an unusually small minHeight;
remove the empty width property (or set a sensible value like "100%" or a px
value) and increase minHeight to a reasonable size for an embed (e.g., ~200px)
or replace the inline styles with a named CSS class; also remove the redundant
empty className to keep the element clean.

Comment on lines +186 to +208
{tierData.requirements.map((req, index) => (
<li
key={index}
className="flex items-center gap-2 text-xs text-text-secondary dark:text-white/60"
>
<div className="rounded-lg bg-white/10 p-0.5">
{req.includes("number") ? (
<CallingIcon className="size-4 text-outline-gray dark:text-white/50" />
) : req.includes("Selfie verification") ? (
<FaceIdIcon className="size-4 text-outline-gray dark:text-white/70" />
) : req.includes("Address verification") ? (
<MapPinpoint01Icon className="size-4 text-outline-gray dark:text-white/50" />
) : req.includes("Business verification") ? (
<WorkAlertIcon className="size-4 text-outline-gray dark:text-white/50" />
) : (
req.includes("ID") && (
<TbIdBadge className="size-4 text-outline-gray dark:text-white/50" />
)
)}
</div>
<span>{req}</span>
</li>
))}
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

Empty icon placeholder when requirement doesn't match any condition.

When a requirement string doesn't contain "number", "Selfie verification", "Address verification", "Business verification", or "ID", no icon is rendered. The last branch req.includes("ID") && (...) returns false (not an element) when the condition isn't met, leaving an empty icon container.

Proposed fix — add a fallback icon
                       {req.includes("number") ? (
                         <CallingIcon className="size-4 text-outline-gray dark:text-white/50" />
                       ) : req.includes("Selfie verification") ? (
                         <FaceIdIcon className="size-4 text-outline-gray dark:text-white/70" />
                       ) : req.includes("Address verification") ? (
                         <MapPinpoint01Icon className="size-4 text-outline-gray dark:text-white/50" />
                       ) : req.includes("Business verification") ? (
                         <WorkAlertIcon className="size-4 text-outline-gray dark:text-white/50" />
-                      ) : (
-                        req.includes("ID") && (
-                          <TbIdBadge className="size-4 text-outline-gray dark:text-white/50" />
-                        )
+                      ) : req.includes("ID") ? (
+                        <TbIdBadge className="size-4 text-outline-gray dark:text-white/50" />
+                      ) : (
+                        <TbIdBadge className="size-4 text-outline-gray dark:text-white/50" />
                       )}
🤖 Prompt for AI Agents
In `@app/components/ProfileDrawer.tsx` around lines 186 - 208, The icon container
in the tierData.requirements.map rendering can end up empty because the chained
conditional returns false when no condition matches (the `req.includes("ID") &&
(...)` yields false), so update the JSX in ProfileDrawer.tsx inside the map (the
<li> and its inner <div> that currently renders icons) to ensure the expression
always returns a React element: replace the chained ternaries with a clear
branching that returns the appropriate icon for "number", "Selfie verification",
"Address verification", "Business verification", or "ID", and add an explicit
fallback icon (e.g., DefaultIcon) for any other requirement string so the
container never renders empty. Ensure the final expression always yields a valid
element (not a boolean) so the icon placeholder is always populated.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
package.json (1)

43-81: ⚠️ Potential issue | 🟠 Major

Upgrade twilio to 5.12.1 to patch CVE-2025-15284 in transitive dependency qs.

  • twilio@5.10.5 (line 81) is vulnerable via its qs dependency. Version 5.12.0+ patches this. Node 22 is supported.
  • smile-identity-core@3.1.0 (line 76) is current; Node 22 supported.
  • @smileid/web-components@11.0.2 (line 43) is 1 minor version behind (11.1.0 available) but no advisories found.
  • libphonenumber-js@1.12.27 (line 60) is 9 patches behind (1.12.36 available) but no advisories found.
app/components/SettingsDropdown.tsx (1)

83-92: ⚠️ Potential issue | 🟡 Minor

Fix email success toast to use user.email?.address instead of user.email.

The onSuccess callback interpolates user.email directly, but user.email is an object. This will produce "[object Object] linked successfully" in the toast.

Proposed fix
   const { linkEmail } = useLinkAccount({
     onSuccess: ({ user }) => {
-      toast.success(`${user.email} linked successfully`);
+      toast.success(`${user.email?.address} linked successfully`);
     },
🤖 Fix all issues with AI agents
In `@app/components/KycModal.tsx`:
- Around line 473-479: The Confirm/Proceed button currently enables navigation
to STEPS.CAPTURE based only on selectedCountry, selectedIdType, needsDocCapture,
and idNumber; add validation to ensure smileIdLoaded is true before allowing the
transition. Update the button's disabled condition to include !smileIdLoaded and
guard the onClick handler that calls setStep(STEPS.CAPTURE) to no-op (or show an
inline error/toast) when smileIdLoaded is false; reference the same state
variables (selectedCountry, selectedIdType, needsDocCapture, idNumber,
smileIdLoaded) and the setStep/STEPS.CAPTURE call so the capture step only
proceeds when SmileID components are loaded.
🧹 Nitpick comments (4)
app/api/kyc/tier3-verify/route.ts (1)

76-78: Prefer MIME-derived extension over filename.
Line 76-78 trusts file.name for ext; using the MIME map avoids spoofed extensions and keeps storage consistent.

♻️ Suggested tweak
-    const nameExt = file.name?.split(".").pop();
-    const ext = (nameExt && nameExt.length <= 4 ? nameExt : MIME_TO_EXT[mime]) || "jpg";
+    const ext = MIME_TO_EXT[mime] || "jpg";
app/api/kyc/smile-id/route.ts (1)

53-57: Consider replacing any with a more specific type for SmileID responses.

The local SmileIdResultType uses any for id_info and [key: string]: any. While this works, it reduces type safety. If SmileID provides response types or you have a known schema, consider using those.

app/components/ProfileDrawer.tsx (2)

77-84: Handle clipboard API errors gracefully.

navigator.clipboard.writeText can fail in some environments. Consider adding error handling to provide user feedback.

Proposed fix
   const handleCopyAddress = () => {
-    if (walletAddress) {
-      navigator.clipboard.writeText(walletAddress);
-      setIsAddressCopied(true);
-      toast.success("Address copied to clipboard");
-      setTimeout(() => setIsAddressCopied(false), 2000);
+    if (walletAddress && navigator.clipboard?.writeText) {
+      navigator.clipboard.writeText(walletAddress)
+        .then(() => {
+          setIsAddressCopied(true);
+          toast.success("Address copied to clipboard");
+          setTimeout(() => setIsAddressCopied(false), 2000);
+        })
+        .catch(() => {
+          toast.error("Unable to copy address");
+        });
     }
   };

322-325: Wallet address row renders placeholder when walletAddress is undefined.

When walletAddress is undefined, shortenAddress(walletAddress ?? "", 10) is called with an empty string, which may produce an odd placeholder. Consider hiding the wallet row when no address exists.

Proposed fix
-                            {/* Wallet Address */}
-                            <div className="flex w-full items-center justify-between">
-                              <p className="text-sm font-light text-text-body dark:text-white/70">
-                                {shortenAddress(walletAddress ?? "", 10)}
-                              </p>
+                            {/* Wallet Address */}
+                            {walletAddress && (
+                              <div className="flex w-full items-center justify-between">
+                                <p className="text-sm font-light text-text-body dark:text-white/70">
+                                  {shortenAddress(walletAddress, 10)}
+                                </p>

Comment on lines +473 to 479
disabled={
!selectedCountry ||
!selectedIdType ||
(!needsDocCapture && !idNumber)
}
onClick={() => setStep(STEPS.CAPTURE)}
>
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 | 🟠 Major

Add validation that SmileID components have loaded before proceeding to CAPTURE.

The button at line 478 proceeds to STEPS.CAPTURE without checking if smileIdLoaded is true. If the SmileID components failed to load, the user will see a broken capture screen.

Proposed fix
           disabled={
             !selectedCountry ||
             !selectedIdType ||
-            (!needsDocCapture && !idNumber)
+            (!needsDocCapture && !idNumber) ||
+            !smileIdLoaded
           }
-          onClick={() => setStep(STEPS.CAPTURE)}
+          onClick={() => {
+            if (!smileIdLoaded) {
+              toast.error("Verification component not ready. Please try again.");
+              return;
+            }
+            setStep(STEPS.CAPTURE);
+          }}
🤖 Prompt for AI Agents
In `@app/components/KycModal.tsx` around lines 473 - 479, The Confirm/Proceed
button currently enables navigation to STEPS.CAPTURE based only on
selectedCountry, selectedIdType, needsDocCapture, and idNumber; add validation
to ensure smileIdLoaded is true before allowing the transition. Update the
button's disabled condition to include !smileIdLoaded and guard the onClick
handler that calls setStep(STEPS.CAPTURE) to no-op (or show an inline
error/toast) when smileIdLoaded is false; reference the same state variables
(selectedCountry, selectedIdType, needsDocCapture, idNumber, smileIdLoaded) and
the setStep/STEPS.CAPTURE call so the capture step only proceeds when SmileID
components are loaded.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant

Comments