Skip to content

Comments

fix: resolve all lint issues and setup husky hooks#91

Merged
Benjtalkshow merged 3 commits intoboundlessfi:mainfrom
Benjtalkshow:fix-lint
Feb 20, 2026
Merged

fix: resolve all lint issues and setup husky hooks#91
Benjtalkshow merged 3 commits intoboundlessfi:mainfrom
Benjtalkshow:fix-lint

Conversation

@Benjtalkshow
Copy link
Contributor

@Benjtalkshow Benjtalkshow commented Feb 20, 2026

Summary by CodeRabbit

  • New Features

    • Real-time debounced withdrawal validation, refreshed withdrawal UI with MAX, fee/receive breakdown, and two-column layout.
    • Enhanced tier upgrade flow with upgrade validation, document upload step, and contextual UI feedback.
    • Jurisdiction restriction utilities surfaced to the app.
  • Bug Fixes

    • Stricter input validation for withdrawals and terms acceptance; improved error handling and feedback.
  • Chores

    • Added pre-commit/pre-push hooks, lint-staged, Prettier and ESLint integration; broad formatting and refactors.

@vercel
Copy link

vercel bot commented Feb 20, 2026

@Benjtalkshow is attempting to deploy a commit to the Threadflow Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link

coderabbitai bot commented Feb 20, 2026

📝 Walkthrough

Walkthrough

Adds Husky and lint-staged tooling; tightens API route signatures and request validation; expands Compliance and GeoRestriction services with new public APIs and state mutations; refactors withdrawal UI and compliance dialogs (tier upgrade, document upload); and updates formatting/lint configs and dependencies.

Changes

Cohort / File(s) Summary
Git Hooks & Formatting
/.husky/pre-commit, /.husky/pre-push, /.lintstagedrc, package.json, eslint.config.mjs
Adds Husky hooks (pre-commit runs lint-staged, pre-push runs build), lint-staged config for ESLint+Prettier, prepare script, prettier in ESLint config, and adds related deps.
API Routes - Compliance & Withdrawal
app/api/compliance/status/route.ts, app/api/compliance/terms/route.ts, app/api/compliance/upgrade/route.ts, app/api/withdrawal/submit/route.ts
Removes NextRequest from several GET handlers, standardizes error handling/logging, adds input validation (termsVersionId, targetTier, withdrawal payload), enhances IP/user-agent extraction, and returns structured error statuses.
UI - Compliance Dialogs & Uploads
components/compliance/appeal-dialog.tsx, components/compliance/document-upload.tsx, components/compliance/limits-display.tsx, components/compliance/terms-dialog.tsx
Mostly JSX/formatting refactors; minor prop/signature cleanup; reorganizes render branches, preserves behavior; small error-handling and import adjustments.
UI - Tier Upgrade Flow
components/compliance/tier-upgrade-dialog.tsx
Adds tier ordering and isUpgradeValid guard, step state reset on open/target change, document upload flow, requestId tracking, try/catch handling, and UI disable/messages for invalid upgrades.
UI - Wallet / Withdrawal
components/wallet/withdrawal-section.tsx, components/wallet/wallet-sheet.tsx
Large refactor: adds sync + debounced async validation, formatCurrency export, two-column layout, fee/exchange calculations, submission flow with toast, integrates compliance dialogs; removes unused import.
Service Layer - Compliance
lib/services/compliance.ts
Major rewrite: introduces TIER_CONFIGS, centralized in-memory store, getters (getUserCompliance, getTierConfig, getAllTierConfigs, getNextTier), mutation APIs (trackWithdrawal, upgradeTier, setHoldState, updateVerificationStatus, setJurisdictionRestriction), reset logic, and utility methods (simulateDelay, clearAllData).
Service Layer - GeoRestriction & Appeal
lib/services/geo-restriction.ts, lib/services/appeal.ts
GeoRestriction: refactors RESTRICTED shape; adds checkLocation with overrides, detectVPN/detectProxy stubs, isRestricted/getRestrictionReason/getRestrictedJurisdictions helpers. Appeal: removed unused import and minor reformatting.
Miscellaneous Formatting / Small Edits
components/compliance/*, lib/services/* (various)
Multiple files updated for formatting, import reordering, small signature/prop tweaks, and minor refactors across components and services.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant UI as WithdrawalSection
  participant API as POST /api/withdrawal/submit
  participant WithdrawalService
  participant Compliance as ComplianceService

  User->>UI: fill amount, currency, destination → submit
  UI->>API: POST { amount, currency, destinationId, ip }
  API->>WithdrawalService: submit(userId, amount, currency, destinationId, ip)
  WithdrawalService->>Compliance: validateWithdrawalAmount(userId, amount)
  Compliance-->>WithdrawalService: validationResult
  alt validation ok
    WithdrawalService->>Compliance: trackWithdrawal(userId, amount)
    Compliance-->>WithdrawalService: tracked
    WithdrawalService-->>API: success payload
  else validation failed
    WithdrawalService-->>API: error (status, message)
  end
  API-->>UI: JSON response
  UI-->>User: show success or error
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • Feat/kyc tier setup #77: Overlaps many compliance API routes, services, and components (parameter and error-handling changes).
  • Wallet page #76: Overlaps wallet/withdrawal UI changes and related component implementations.

Poem

🐰 I hopped through hooks and lint so neat,

Pre-commit tidy, pre-push builds complete.
Tiers and limits, services now grow,
Withdrawals checked before they go,
A little rabbit cheers — code on the go!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.69% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: resolving lint issues and setting up husky hooks, which aligns with the substantial refactoring and new tooling infrastructure across the codebase.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

Copy link

@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: 6

🧹 Nitpick comments (16)
components/wallet/withdrawal-section.tsx (1)

78-92: handleWithdraw lacks the compliance guards that canWithdraw enforces.

The button is disabled by canWithdraw, but handleWithdraw itself only checks isValidAmount and complianceData existence. If the button's disabled state is ever bypassed (e.g., programmatic call, future refactor), withdrawals could be submitted during a hold or with unaccepted terms. Consider mirroring the canWithdraw check at the top of this function.

Also, alert() for success/failure is fine as a placeholder but should be replaced with a toast/notification system before release.

Proposed fix
  const handleWithdraw = async () => {
-   if (!isValidAmount || !complianceData) return;
+   if (!canWithdraw || !complianceData) return;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/wallet/withdrawal-section.tsx` around lines 78 - 92,
handleWithdraw currently only checks isValidAmount and complianceData but not
the full set of guards enforced by canWithdraw; add the same guard at the top of
handleWithdraw (preferably call canWithdraw and return early if false) so
programmatic calls cannot bypass hold/terms checks, then proceed with
submitMutation.mutateAsync using parsedAmount, "USD", and bankAccounts[0].id; on
success call setAmount("") and trigger the existing success notification (later
replace alert with toast/notification system). Ensure you reference the existing
symbols handleWithdraw, canWithdraw, isValidAmount, complianceData,
parsedAmount, bankAccounts, submitMutation, and setAmount when making the
change.
components/compliance/terms-dialog.tsx (1)

51-56: Use e.currentTarget instead of e.target as HTMLDivElement.

e.target is the innermost element that received the event (could be a child <p> inside the scrollable div), while e.currentTarget is always the element the handler is attached to. React.UIEvent<HTMLDivElement> types currentTarget as HTMLDivElement directly, so no cast is needed.

♻️ Proposed fix
  const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
-   const target = e.target as HTMLDivElement;
-   const bottom =
-     target.scrollHeight - target.scrollTop <= target.clientHeight + 50;
+   const { scrollHeight, scrollTop, clientHeight } = e.currentTarget;
+   const bottom = scrollHeight - scrollTop <= clientHeight + 50;
    if (bottom) setScrolled(true);
  };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/compliance/terms-dialog.tsx` around lines 51 - 56, The scroll
handler handleScroll uses e.target cast to HTMLDivElement which can be a child
element; replace that with e.currentTarget (typed as HTMLDivElement) to reliably
refer to the scrollable div and remove the unnecessary cast; update the bottom
calculation to use currentTarget.scrollHeight, currentTarget.scrollTop, and
currentTarget.clientHeight and keep the setScrolled(true) behavior.
app/api/compliance/status/route.ts (1)

6-7: Drop the unused _request parameter entirely instead of suppressing the lint warning.

Next.js App Router route handlers do not require declaring the request parameter if it is unused. Omitting it removes both the eslint-disable comment and the NextRequest import, which is cleaner than the current double-suppression approach.

♻️ Proposed fix
-import { NextRequest, NextResponse } from "next/server";
+import { NextResponse } from "next/server";
 import { getCurrentUser } from "@/lib/server-auth";
 import { ComplianceService } from "@/lib/services/compliance";
 import { TermsService } from "@/lib/services/terms";

-// eslint-disable-next-line `@typescript-eslint/no-unused-vars`
-export async function GET(_request: NextRequest) {
+export async function GET() {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/compliance/status/route.ts` around lines 6 - 7, The GET route handler
currently declares an unused parameter and silences linting; remove the unused
parameter from the function signature (change "export async function
GET(_request: NextRequest)" to "export async function GET()") and delete the
corresponding eslint-disable comment and the NextRequest import so the handler
is clean and no longer suppresses the rule.
package.json (2)

93-93: Pin prettier to an exact version to prevent formatting drift.

Prettier docs recommend installing an exact version locally. Even a patch release of Prettier can result in slightly different formatting, causing team members to format each other's changes back and forth.

♻️ Proposed fix
-    "prettier": "^3.8.1",
+    "prettier": "3.8.1",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@package.json` at line 93, The prettier dependency in package.json is using a
caret range ("prettier": "^3.8.1") which allows patch/minor upgrades and can
cause formatting drift; change the dependency value to an exact version string
("prettier": "3.8.1") in package.json (look for the "prettier" entry) and then
reinstall/update the lockfile (npm install / yarn install) so the lockfile
reflects the pinned version.

12-13: Running npm run build in the pre-commit hook is too heavy for routine commits.

Per the AI summary, .husky/pre-commit executes lint-staged --concurrent false && npm run build. A full Next.js build on every commit will significantly slow down developer workflow and will block commits even for trivial changes. Pre-commit hooks should only run fast checks (lint, type-check). Move the build gate to CI instead.

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

In `@package.json` around lines 12 - 13, Pre-commit currently runs a full Next.js
build (via `npm run build`) which is too slow; edit the `.husky/pre-commit` hook
to remove the `&& npm run build` step so it only runs fast checks (e.g.,
`lint-staged --concurrent false` and any quick type-checks), and keep the
`prepare`/husky script in package.json unchanged; move the full `npm run build`
gate into your CI pipeline (e.g., add a build job in your CI config) so commits
remain fast while CI enforces the build.
app/api/withdrawal/submit/route.ts (1)

24-29: Prefer instanceof Error over the unsafe type assertion; consider distinguishing validation vs. server errors.

(error as Error).message is a bare type assertion that silently produces undefined if a non-Error value is thrown. The fallback string handles it, but the pattern is fragile. More importantly, every exception—including unexpected failures from ComplianceService, TermsService, or GeoRestrictionService—returns { status: 400 }, which misclassifies internal server errors.

🛡️ Proposed fix
- } catch (error) {
+ } catch (error: unknown) {
    console.error("Error submitting withdrawal:", error);
+   const isClientError =
+     error instanceof Error &&
+     ["Insufficient balance", "Exceeds", "Account is", "Terms must", "Withdrawals not"].some((s) =>
+       error.message.startsWith(s)
+     );
    return NextResponse.json(
-     { error: (error as Error).message || "Withdrawal failed" },
-     { status: 400 },
+     { error: error instanceof Error ? error.message : "Withdrawal failed" },
+     { status: isClientError ? 400 : 500 },
    );
  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/withdrawal/submit/route.ts` around lines 24 - 29, Replace the unsafe
assertion in the catch block with a robust check: if (error instanceof Error)
extract message = error.message and log error.stack; derive the HTTP status by
preferring an explicit numeric status on the thrown error (e.g., (error as
any).status || (error as any).statusCode) and fall back to 400 for known
validation errors (check error.name === 'ValidationError' or error.name ===
'BadRequestError') or 500 for all other/internal errors; for non-Error throws
convert to a string for the message and return status 500. Update the
NextResponse.json call to use the computed message and status, and ensure you
still call console.error with the full error (stack when available) so failures
from ComplianceService, TermsService, or GeoRestrictionService are correctly
classified as server errors when appropriate.
app/api/compliance/upgrade/route.ts (2)

30-46: Same silent error swallowing in GET as in the terms route.

The GET handler discards the error (_error), while app/api/compliance/status/route.ts (line 26) logs with console.error. Standardize error handling across all compliance routes — either always log or use a shared utility.

As with the terms route, the _request parameter + eslint-disable can be replaced by simply omitting the parameter from the signature.

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

In `@app/api/compliance/upgrade/route.ts` around lines 30 - 46, The GET handler
currently swallows errors and keeps an unused _request param; update the GET
function to omit the unused parameter, and in the catch block capture the error
(e.g., error) and log it consistently (console.error or the shared logging
utility used by other compliance routes) before returning the 500
NextResponse.json; locate the GET function, getCurrentUser call, and
VerificationService.getVerificationStatus reference to implement the change and
mirror the error handling pattern used in app/api/compliance/status/route.ts.

12-12: No validation on targetTier before passing to the service layer.

targetTier is destructured directly from user-supplied JSON with no type or value check. If the body is missing it or provides an invalid tier, the error will surface as an opaque service-layer failure. Consider validating that targetTier is one of the expected KYCTier values before proceeding.

Example validation
     const { targetTier } = await request.json();
+
+    const validTiers = ['BASIC', 'VERIFIED', 'ENHANCED'];
+    if (!validTiers.includes(targetTier)) {
+      return NextResponse.json(
+        { error: "Invalid target tier" },
+        { status: 400 },
+      );
+    }
+
     const verificationRequest =
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/compliance/upgrade/route.ts` at line 12, The handler in route.ts
pulls targetTier directly from request.json() without validation; update the
POST handler to validate that targetTier exists, is a string/expected type, and
matches one of the permitted KYCTier values before calling the service layer
(use Object.values(KYCTier) or your validation utility/zod schema to check
membership), return a 400/422 response for missing/invalid input, and
import/reference the KYCTier enum where validation occurs so the service never
receives an unvalidated targetTier.
lib/services/compliance.ts (4)

329-331: getAllTierConfigs returns config objects without any copying — even shallower than getTierConfig.

Object.values(this.TIER_CONFIGS) returns direct references to the static config objects. Any caller mutation corrupts the shared state. Consider reusing getTierConfig for consistency.

Proposed fix
 static getAllTierConfigs(): KYCTierConfig[] {
-  return Object.values(this.TIER_CONFIGS);
+  return (Object.keys(this.TIER_CONFIGS) as KYCTier[]).map((tier) =>
+    this.getTierConfig(tier),
+  );
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/services/compliance.ts` around lines 329 - 331, getAllTierConfigs
currently returns direct references from TIER_CONFIGS which allows callers to
mutate shared static state; change it to return deep/shallow copies by reusing
the existing getTierConfig method for each tier (call getTierConfig(directKey)
for each key in TIER_CONFIGS) so callers receive independent config objects and
shared state is protected (refer to static members TIER_CONFIGS,
getAllTierConfigs, and getTierConfig).

204-237: validateWithdrawalAmount has a side effect: it resets expired usage windows.

A validation/read method should not mutate state. Calling resetExpiredWindows here means that simply checking whether a withdrawal is valid can alter the user's usage counters and timestamps. If this is intentional (lazy reset), document it clearly; otherwise, separate the reset from validation.

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

In `@lib/services/compliance.ts` around lines 204 - 237, validateWithdrawalAmount
currently calls resetExpiredWindows and thus mutates state during a pure
validation; remove the side-effect by deleting the await
this.resetExpiredWindows(userId, compliance) call from validateWithdrawalAmount,
leave getUserCompliance and the limit checks as-is, and either (a) require
callers to call resetExpiredWindows(userId, compliance) before invoking
validateWithdrawalAmount or (b) add a separate method (e.g.,
validateWithdrawalAmountWithReset) that performs resetExpiredWindows then
delegates to validateWithdrawalAmount; also add a brief doc comment to
validateWithdrawalAmount stating it does not mutate state and that callers must
reset windows beforehand if needed.

322-324: getTierConfig returns a shallow copy — nested limits and requirements are shared references to the static config.

Callers mutating the returned object's limits (e.g., config.limits.daily = 999) would silently corrupt TIER_CONFIGS for all subsequent reads. Consider a deep copy or at minimum spreading the nested limits.

Proposed fix
 static getTierConfig(tier: KYCTier): KYCTierConfig {
-  return { ...this.TIER_CONFIGS[tier] };
+  const config = this.TIER_CONFIGS[tier];
+  return {
+    ...config,
+    limits: { ...config.limits },
+    requirements: [...config.requirements],
+  };
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/services/compliance.ts` around lines 322 - 324, getTierConfig currently
returns a shallow copy so nested objects like limits and requirements on
this.TIER_CONFIGS[tier] are still shared; change getTierConfig to return a deep
copy of the config (or at minimum spread/copy nested properties) so mutations to
the returned KYCTierConfig do not mutate the static TIER_CONFIGS—e.g., clone the
object for the given tier inside getTierConfig (use structuredClone if available
or a reliable deep-copy helper) and ensure limits and requirements are copied
rather than referenced.

85-94: getUserCompliance returns a shallow copy — nested objects (limits, usage) are shared with the DB.

Methods like trackWithdrawal and resetExpiredWindows rely on this shared-reference behavior to mutate the DB through the "copy." This is fragile and will break silently if anyone later changes to a deep copy. For a mock store this is tolerable, but worth a comment noting the intentional shallow semantics.

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

In `@lib/services/compliance.ts` around lines 85 - 94, getUserCompliance currently
returns a shallow copy which intentionally preserves shared nested objects like
limits and usage so methods such as trackWithdrawal and resetExpiredWindows can
mutate the mock DB via that reference; add a clear comment above
getUserCompliance (referencing getUserCompliance, MOCK_COMPLIANCE_DB,
createDefaultCompliance, trackWithdrawal, and resetExpiredWindows) stating this
intentional shallow-copy semantics and warning that converting to a deep copy
will break those mutating callers, or alternatively change the implementation to
return the actual DB object if you prefer explicit shared-state behavior.
lib/services/geo-restriction.ts (2)

42-80: checkLocation always returns a hardcoded US/CA mock — restriction logic within it is unreachable.

The country/state restriction checks on lines 58–77 will never trigger because the mock always returns countryCode: "US" and regionCode: "CA", neither of which are in the RESTRICTED list. This makes the method unable to test restriction behavior. Consider accepting an optional override or parameterizing the mock to enable testing restricted scenarios.

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

In `@lib/services/geo-restriction.ts` around lines 42 - 80, checkLocation
currently always returns a hardcoded US/CA mock so RESTRICTED checks never
trigger; change checkLocation to accept an optional override (e.g., a
Partial<UserLocation> or explicit countryCode/regionCode params) and merge the
override into the generated mock before running detectVPN/detectProxy and the
RESTRICTED checks, ensuring functions named detectVPN and detectProxy are still
awaited and RESTRICTED lookup uses the final countryCode/regionCode; update call
sites/tests to pass overrides when simulating restricted scenarios.

82-91: VPN detection uses private IP ranges, not VPN ranges.

The comment says "common VPN ranges" but 10., 172.16., 192.168. are RFC 1918 private/LAN address ranges. In practice, traffic arriving at a server would rarely have these source IPs (they'd be NATed). This mock will essentially never flag a VPN from real traffic.

Consider updating the comment to reflect that this is a placeholder, or use a more representative mock heuristic.

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

In `@lib/services/geo-restriction.ts` around lines 82 - 91, The detectVPN mock
incorrectly treats RFC1918 private ranges as "VPN ranges" — update the detectVPN
function and its comment to accurately reflect that it's a placeholder and not
real VPN detection: change the comment to state this is a simple
placeholder/mock and add a TODO suggesting integration with a real VPN detection
service (e.g., IPHub, IP2Proxy, VPNApi), and either replace the vpnRanges array
with a more representative mock (e.g., a small list of known public VPN provider
CIDRs or IPs) or implement a simple heuristic (e.g., check against a
configurable list of known VPN ASNs/public IPs); ensure the code and comment
clearly reference detectVPN so future maintainers don't confuse private LAN
ranges for actual VPN detection.
app/api/compliance/terms/route.ts (1)

5-16: GET swallows errors silently while POST logs them — inconsistent error handling.

The GET handler catches the error into _error and discards it (line 11), whereas the POST handler logs via console.error (line 37). This makes debugging GET failures harder. Consider logging the error in GET as well, or using a shared error-handling pattern.

Also, since _request is unused, you could simply omit the parameter (export async function GET()) instead of adding the eslint-disable comment — Next.js App Router route handlers don't require it.

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

In `@app/api/compliance/terms/route.ts` around lines 5 - 16, The GET route handler
currently swallows errors and keeps an unused _request parameter; update export
async function GET to remove the unused parameter, and inside the catch for
TermsService.getCurrentTermsVersion log the caught error (do not discard _error)
before returning NextResponse.json({ error: "Failed to fetch terms" }, { status:
500 }); — use console.error or your app logger to include the error details so
failures are visible and consistent with the POST handler.
.husky/pre-commit (1)

1-1: Running a full build in the pre-commit hook will significantly slow down every commit.

npm run build for a Next.js project can take minutes. This is excessive for a pre-commit hook—developers will be tempted to bypass it with --no-verify. Consider moving the build check to CI or to a pre-push hook and keeping only lint-staged here.

Proposed fix
-npx lint-staged --concurrent false && npm run build
+npx lint-staged --concurrent false
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.husky/pre-commit at line 1, The pre-commit hook currently runs a full build
("npx lint-staged --concurrent false && npm run build"), which slows commits;
update the .husky/pre-commit hook to only run lint-staged (remove the "&& npm
run build" part) and instead add the build step to a pre-push hook
(create/update .husky/pre-push to run "npm run build") or enforce the build in
CI; locate the string "npx lint-staged --concurrent false && npm run build" in
the .husky/pre-commit file and replace it with just the lint-staged invocation,
and add a corresponding pre-push/CI job to run "npm run build".
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.lintstagedrc:
- Line 2: Install eslint-config-prettier as a devDependency (npm install
--save-dev eslint-config-prettier) and update your flat config in
eslint.config.mjs to import the Prettier flat share (import prettier from
"eslint-config-prettier/flat") and include the imported prettier entry as the
last element in the array passed to defineConfig (after ...nextVitals and
...nextTs) so Prettier disables conflicting ESLint formatting rules; also keep
your lint-staged entry ("*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier
--write"]) as-is in .lintstagedrc.

In `@components/compliance/tier-upgrade-dialog.tsx`:
- Around line 20-38: TierUpgradeDialogProps declares currentTier but
TierUpgradeDialog doesn't destructure or use it; either remove currentTier from
the interface or update the component to accept and use it. Best fix: include
currentTier in the TierUpgradeDialog parameter list (function
TierUpgradeDialog({ open, onOpenChange, currentTier, targetTier }:
TierUpgradeDialogProps)) and add a simple validation/guard using currentTier
(e.g., compare currentTier and targetTier to ensure targetTier is higher and
show an error/disable confirm if not) so callers passing currentTier are not
ignored.

In `@components/wallet/withdrawal-section.tsx`:
- Around line 172-178: The UI currently shows validationError before
syncValidationError which can surface a stale async error; update the rendering
in withdrawal-section.tsx to prefer syncValidationError when present (i.e., use
syncValidationError || validationError) so the AlertDescription displays the
synchronous validation message first; locate the JSX that references
validationError and syncValidationError (inside the Alert / AlertDescription)
and swap the order or conditional so syncValidationError takes display priority.
- Around line 101-107: canWithdraw currently allows submission before the async
validation completes; update its logic to block submission while the server
validation is pending and to ensure validation corresponds to the current
amount. Specifically, include a check for !validateMutation.isPending (or
validateMutation.isSuccess) in the canWithdraw expression and add a tie between
the last-validated amount and parsedAmount (e.g., compare parsedAmount to the
value stored with validateMutation.data or a local lastValidatedAmount) so that
validation must have completed for the current parsedAmount before canWithdraw
becomes true; keep the existing checks (isValidAmount, parsedAmount <=
walletInfo.balance, !validationError, !syncValidationError, complianceData
checks) and add the new pending/amount-match conditions referencing canWithdraw,
validateMutation, validationError, syncValidationError, parsedAmount, and
walletInfo.balance.
- Around line 47-56: parsedAmount becomes NaN when amount is empty which makes
parsedAmount unstable in the useEffect dependency array and causes
validateMutation.mutate(NaN) to be called; update the syncValidationError
calculation (used by the effect) to treat empty/NaN parsedAmount as an
early-return case (i.e., return null if amount is falsy or parsedAmount is NaN)
so the effect won't consider parsedAmount changing on every render and won't
call validateMutation.mutate with NaN; adjust the useMemo for
syncValidationError (referencing parsedAmount, isValidAmount,
walletInfo.balance) to explicitly check Number.isNaN(parsedAmount) or
!isFinite(parsedAmount) and return null before any mutate calls in the effect.
- Around line 58-76: The effect is re-running because validateMutation (the
whole mutation object) is in the dependency array; remove validateMutation and
depend on the stable mutate function instead (use validateMutation.mutate in the
deps) so the debounce only schedules when parsedAmount or syncValidationError
change, and ensure that when syncValidationError is truthy you immediately clear
any async error by calling setValidationError(null) (or set to the sync error)
before returning; keep parsedAmount and syncValidationError in the deps and
reference validateMutation.mutate inside the effect to call the API.

---

Nitpick comments:
In @.husky/pre-commit:
- Line 1: The pre-commit hook currently runs a full build ("npx lint-staged
--concurrent false && npm run build"), which slows commits; update the
.husky/pre-commit hook to only run lint-staged (remove the "&& npm run build"
part) and instead add the build step to a pre-push hook (create/update
.husky/pre-push to run "npm run build") or enforce the build in CI; locate the
string "npx lint-staged --concurrent false && npm run build" in the
.husky/pre-commit file and replace it with just the lint-staged invocation, and
add a corresponding pre-push/CI job to run "npm run build".

In `@app/api/compliance/status/route.ts`:
- Around line 6-7: The GET route handler currently declares an unused parameter
and silences linting; remove the unused parameter from the function signature
(change "export async function GET(_request: NextRequest)" to "export async
function GET()") and delete the corresponding eslint-disable comment and the
NextRequest import so the handler is clean and no longer suppresses the rule.

In `@app/api/compliance/terms/route.ts`:
- Around line 5-16: The GET route handler currently swallows errors and keeps an
unused _request parameter; update export async function GET to remove the unused
parameter, and inside the catch for TermsService.getCurrentTermsVersion log the
caught error (do not discard _error) before returning NextResponse.json({ error:
"Failed to fetch terms" }, { status: 500 }); — use console.error or your app
logger to include the error details so failures are visible and consistent with
the POST handler.

In `@app/api/compliance/upgrade/route.ts`:
- Around line 30-46: The GET handler currently swallows errors and keeps an
unused _request param; update the GET function to omit the unused parameter, and
in the catch block capture the error (e.g., error) and log it consistently
(console.error or the shared logging utility used by other compliance routes)
before returning the 500 NextResponse.json; locate the GET function,
getCurrentUser call, and VerificationService.getVerificationStatus reference to
implement the change and mirror the error handling pattern used in
app/api/compliance/status/route.ts.
- Line 12: The handler in route.ts pulls targetTier directly from request.json()
without validation; update the POST handler to validate that targetTier exists,
is a string/expected type, and matches one of the permitted KYCTier values
before calling the service layer (use Object.values(KYCTier) or your validation
utility/zod schema to check membership), return a 400/422 response for
missing/invalid input, and import/reference the KYCTier enum where validation
occurs so the service never receives an unvalidated targetTier.

In `@app/api/withdrawal/submit/route.ts`:
- Around line 24-29: Replace the unsafe assertion in the catch block with a
robust check: if (error instanceof Error) extract message = error.message and
log error.stack; derive the HTTP status by preferring an explicit numeric status
on the thrown error (e.g., (error as any).status || (error as any).statusCode)
and fall back to 400 for known validation errors (check error.name ===
'ValidationError' or error.name === 'BadRequestError') or 500 for all
other/internal errors; for non-Error throws convert to a string for the message
and return status 500. Update the NextResponse.json call to use the computed
message and status, and ensure you still call console.error with the full error
(stack when available) so failures from ComplianceService, TermsService, or
GeoRestrictionService are correctly classified as server errors when
appropriate.

In `@components/compliance/terms-dialog.tsx`:
- Around line 51-56: The scroll handler handleScroll uses e.target cast to
HTMLDivElement which can be a child element; replace that with e.currentTarget
(typed as HTMLDivElement) to reliably refer to the scrollable div and remove the
unnecessary cast; update the bottom calculation to use
currentTarget.scrollHeight, currentTarget.scrollTop, and
currentTarget.clientHeight and keep the setScrolled(true) behavior.

In `@components/wallet/withdrawal-section.tsx`:
- Around line 78-92: handleWithdraw currently only checks isValidAmount and
complianceData but not the full set of guards enforced by canWithdraw; add the
same guard at the top of handleWithdraw (preferably call canWithdraw and return
early if false) so programmatic calls cannot bypass hold/terms checks, then
proceed with submitMutation.mutateAsync using parsedAmount, "USD", and
bankAccounts[0].id; on success call setAmount("") and trigger the existing
success notification (later replace alert with toast/notification system).
Ensure you reference the existing symbols handleWithdraw, canWithdraw,
isValidAmount, complianceData, parsedAmount, bankAccounts, submitMutation, and
setAmount when making the change.

In `@lib/services/compliance.ts`:
- Around line 329-331: getAllTierConfigs currently returns direct references
from TIER_CONFIGS which allows callers to mutate shared static state; change it
to return deep/shallow copies by reusing the existing getTierConfig method for
each tier (call getTierConfig(directKey) for each key in TIER_CONFIGS) so
callers receive independent config objects and shared state is protected (refer
to static members TIER_CONFIGS, getAllTierConfigs, and getTierConfig).
- Around line 204-237: validateWithdrawalAmount currently calls
resetExpiredWindows and thus mutates state during a pure validation; remove the
side-effect by deleting the await this.resetExpiredWindows(userId, compliance)
call from validateWithdrawalAmount, leave getUserCompliance and the limit checks
as-is, and either (a) require callers to call resetExpiredWindows(userId,
compliance) before invoking validateWithdrawalAmount or (b) add a separate
method (e.g., validateWithdrawalAmountWithReset) that performs
resetExpiredWindows then delegates to validateWithdrawalAmount; also add a brief
doc comment to validateWithdrawalAmount stating it does not mutate state and
that callers must reset windows beforehand if needed.
- Around line 322-324: getTierConfig currently returns a shallow copy so nested
objects like limits and requirements on this.TIER_CONFIGS[tier] are still
shared; change getTierConfig to return a deep copy of the config (or at minimum
spread/copy nested properties) so mutations to the returned KYCTierConfig do not
mutate the static TIER_CONFIGS—e.g., clone the object for the given tier inside
getTierConfig (use structuredClone if available or a reliable deep-copy helper)
and ensure limits and requirements are copied rather than referenced.
- Around line 85-94: getUserCompliance currently returns a shallow copy which
intentionally preserves shared nested objects like limits and usage so methods
such as trackWithdrawal and resetExpiredWindows can mutate the mock DB via that
reference; add a clear comment above getUserCompliance (referencing
getUserCompliance, MOCK_COMPLIANCE_DB, createDefaultCompliance, trackWithdrawal,
and resetExpiredWindows) stating this intentional shallow-copy semantics and
warning that converting to a deep copy will break those mutating callers, or
alternatively change the implementation to return the actual DB object if you
prefer explicit shared-state behavior.

In `@lib/services/geo-restriction.ts`:
- Around line 42-80: checkLocation currently always returns a hardcoded US/CA
mock so RESTRICTED checks never trigger; change checkLocation to accept an
optional override (e.g., a Partial<UserLocation> or explicit
countryCode/regionCode params) and merge the override into the generated mock
before running detectVPN/detectProxy and the RESTRICTED checks, ensuring
functions named detectVPN and detectProxy are still awaited and RESTRICTED
lookup uses the final countryCode/regionCode; update call sites/tests to pass
overrides when simulating restricted scenarios.
- Around line 82-91: The detectVPN mock incorrectly treats RFC1918 private
ranges as "VPN ranges" — update the detectVPN function and its comment to
accurately reflect that it's a placeholder and not real VPN detection: change
the comment to state this is a simple placeholder/mock and add a TODO suggesting
integration with a real VPN detection service (e.g., IPHub, IP2Proxy, VPNApi),
and either replace the vpnRanges array with a more representative mock (e.g., a
small list of known public VPN provider CIDRs or IPs) or implement a simple
heuristic (e.g., check against a configurable list of known VPN ASNs/public
IPs); ensure the code and comment clearly reference detectVPN so future
maintainers don't confuse private LAN ranges for actual VPN detection.

In `@package.json`:
- Line 93: The prettier dependency in package.json is using a caret range
("prettier": "^3.8.1") which allows patch/minor upgrades and can cause
formatting drift; change the dependency value to an exact version string
("prettier": "3.8.1") in package.json (look for the "prettier" entry) and then
reinstall/update the lockfile (npm install / yarn install) so the lockfile
reflects the pinned version.
- Around line 12-13: Pre-commit currently runs a full Next.js build (via `npm
run build`) which is too slow; edit the `.husky/pre-commit` hook to remove the
`&& npm run build` step so it only runs fast checks (e.g., `lint-staged
--concurrent false` and any quick type-checks), and keep the `prepare`/husky
script in package.json unchanged; move the full `npm run build` gate into your
CI pipeline (e.g., add a build job in your CI config) so commits remain fast
while CI enforces the build.

Copy link

@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: 9

🧹 Nitpick comments (13)
components/wallet/withdrawal-section.tsx (3)

97-102: formatCurrency is re-created on every render — extract it or memoize.

This pure helper has no dependency on component state. Moving it outside the component (or wrapping in useCallback with no deps) avoids unnecessary allocations on each render.

Proposed fix
+const formatCurrency = (value: number) =>
+  new Intl.NumberFormat("en-US", {
+    style: "currency",
+    currency: "USD",
+  }).format(value);
+
 export function WithdrawalSection({ walletInfo }: WithdrawalSectionProps) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/wallet/withdrawal-section.tsx` around lines 97 - 102, The
formatCurrency helper is being re-created on every render inside the component;
move it out of the component scope (or wrap it with useCallback(() => ..., [])
if you must keep it inside) so it’s defined once and not reallocated on each
render; update references in the component (e.g., where formatCurrency is used
in WithdrawalSection / withdrawal-section.tsx) to use the extracted/memoized
function.

43-45: Hardcoded bank accounts — placeholder data leaking into the component.

bankAccounts is a hardcoded array with mock data. This is referenced both in the UI (lines 193–215) and in handleWithdraw (line 88) where it's used as the actual destinationId sent to the server. If this is intentional scaffolding, consider adding a // TODO comment and disabling the withdraw button when no real accounts are loaded; otherwise wire this to an API.

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

In `@components/wallet/withdrawal-section.tsx` around lines 43 - 45, The component
currently contains a hardcoded bankAccounts array which leaks placeholder data
into the UI and is used as the destinationId in handleWithdraw; remove this mock
array and either fetch real accounts (or accept them via props) and use the
selected account's id as the destinationId in handleWithdraw, or if this is
scaffolding add a clear TODO comment and ensure the Withdraw button (the button
that triggers handleWithdraw) is disabled when no real accounts are loaded;
update references to bankAccounts in the UI (where accounts are listed) to use
the real data source and ensure handleWithdraw reads a single selectedAccount.id
(not the entire array) for destinationId.

81-95: handleWithdraw silently uses alert() for user feedback.

Using window.alert() blocks the UI thread and provides no styled feedback. This is likely a placeholder — consider replacing with a toast notification or in-page feedback consistent with the rest of the UI.

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

In `@components/wallet/withdrawal-section.tsx` around lines 81 - 95, The handler
handleWithdraw currently uses blocking window.alert() for success/error which is
inconsistent with the app UI; replace those alerts by invoking the app's
toast/in-page notification system (e.g., the useToast or Notification component
used elsewhere) inside the try/catch around submitMutation.mutateAsync so
success uses toast.success("Withdrawal submitted successfully!") and errors use
toast.error(error.message || "Withdrawal failed"), keep the same state updates
(setAmount("")) and destinationId logic (bankAccounts[0].id) and ensure the
toast hook/import is added to the component and used instead of alert().
app/api/withdrawal/submit/route.ts (2)

27-28: Default status of 400 is misleading for the Error branch.

status is initialized to 400, but this default is never actually reached for Error instances — the || chain on lines 33–38 always resolves. However, if someone later refactors the chain, this silent default could mask server errors as client errors. Consider initializing to 500 for safety and only downgrading to 400 when the error is explicitly a validation/bad-request error.

Proposed fix
     let message = "Withdrawal failed";
-    let status = 400;
+    let status = 500;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/withdrawal/submit/route.ts` around lines 27 - 28, The default error
status should be 500 instead of 400 to avoid silently classifying server errors
as client errors; change the initialization of status from 400 to 500 (the
variables message and status at the top of the route handler) and only overwrite
status to 400 when you detect an explicit validation/bad-request error (the
branch that currently builds status via the || chain from the caught Error).
Ensure the catch block preserves the actual Error behavior by setting status to
400 only for known client-error types and otherwise leaving it as 500 while
still populating message with the error details.

12-12: No input validation before calling the service layer.

amount, currency, and destinationId are used directly from the parsed body without any type or presence checks. If the body is malformed (e.g., amount is a string, currency is missing), the error will surface as an opaque service-layer failure rather than a clear 400. Consider adding basic guards before invoking WithdrawalService.submit.

Proposed fix
     const { amount, currency, destinationId } = await request.json();
+
+    if (typeof amount !== "number" || !isFinite(amount) || amount <= 0) {
+      return NextResponse.json({ error: "Invalid amount" }, { status: 400 });
+    }
+    if (!currency || typeof currency !== "string") {
+      return NextResponse.json({ error: "Currency is required" }, { status: 400 });
+    }
+    if (!destinationId) {
+      return NextResponse.json({ error: "Destination is required" }, { status: 400 });
+    }
+
     const ip = request.headers.get("x-forwarded-for") || "0.0.0.0";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/withdrawal/submit/route.ts` at line 12, The route currently reads
const { amount, currency, destinationId } = await request.json() and calls
WithdrawalService.submit without validating inputs; add guards in the route
handler to ensure amount is present and a finite number (coerce/parse and reject
NaN), currency is a non-empty string (optionally check against allowed
currencies), and destinationId is a non-empty string (or valid id format), and
return a 400 response with a clear error message when validation fails; only
call WithdrawalService.submit after these checks and include the
validated/parsed values (e.g., numeric amount) when invoking
WithdrawalService.submit.
.husky/pre-commit (1)

1-1: Consider adding a #!/usr/bin/env sh shebang for portability.

Husky no longer automatically adds shebang lines to hook scripts; you need to ensure your scripts start with an appropriate shebang if they require a specific interpreter. While Husky v9 invokes hooks via sh by default making it optional for simple commands, a shebang makes the interpreter explicit and avoids surprising behavior on non-standard environments.

🔧 Proposed addition
+#!/usr/bin/env sh
 npx lint-staged --concurrent false
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.husky/pre-commit at line 1, Add an explicit shell shebang to the pre-commit
hook so the interpreter is deterministic: update the .husky/pre-commit script
that currently runs "npx lint-staged --concurrent false" to start with a shebang
line like "#!/usr/bin/env sh" (or another appropriate shell) and ensure the file
is executable; this makes the hook run under the intended shell even on
environments where Husky's invocation differs.
.husky/pre-push (1)

1-1: Adding shebang lines is optional but recommended for Husky v9.

The .husky/pre-push and .husky/pre-commit files lack shebang lines. With Husky v9.1.7 (installed in this repo), the shebang is optional since Husky invokes hooks with sh -e internally and does not require the .husky.sh source line. The hooks will work correctly as-is.

However, adding #!/usr/bin/env sh at the top of each file is still a best practice for editor recognition and clarity:

+#!/usr/bin/env sh
+
 npm run build
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.husky/pre-push at line 1, Add a POSIX shebang to the top of the husky hook
files to improve editor recognition and clarity: open the pre-push and
pre-commit hook files and prepend a single line with the POSIX shebang
(#!/usr/bin/env sh) as the very first line so the hooks remain compatible with
Husky v9 while being explicitly recognized by editors and tools.
app/api/compliance/upgrade/route.ts (1)

34-43: Catch block returns 400 for all errors, masking server-side failures.

If VerificationService.createVerificationRequest throws due to an internal error (e.g., database failure), returning 400 misleads the client into thinking the request was malformed. Reserve 400 for known validation errors and use 500 for unexpected failures.

Suggested fix
   } catch (error) {
     console.error("Error creating verification request:", error);
+    const message = (error as Error).message || "Failed to create verification request";
+    const isValidationError = message.includes("Invalid") || message.includes("missing");
     return NextResponse.json(
       {
-        error:
-          (error as Error).message || "Failed to create verification request",
+        error: message,
       },
-      { status: 400 },
+      { status: isValidationError ? 400 : 500 },
     );
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/compliance/upgrade/route.ts` around lines 34 - 43, The catch in
route.ts around VerificationService.createVerificationRequest currently returns
a 400 for all errors; change it to detect validation/expected errors (e.g.,
check for a ValidationError type or an error.code from
VerificationService.createVerificationRequest) and return NextResponse.json(...,
{ status: 400 }) only for those cases, otherwise log the error and return a 500
response via NextResponse.json(..., { status: 500 }); ensure you reference the
catch that wraps VerificationService.createVerificationRequest and use
NextResponse.json for both branches so clients get 400 for client-side
validation failures and 500 for unexpected server-side failures.
components/compliance/tier-upgrade-dialog.tsx (1)

197-201: "Already at this tier" message renders after DialogFooter, which may appear visually disconnected.

Consider moving this message above the footer (inside the content area) so it's adjacent to the upgrade button it explains.

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

In `@components/compliance/tier-upgrade-dialog.tsx` around lines 197 - 201, Move
the "already at this tier" validation message from below the DialogFooter to
inside the dialog content area just above the upgrade button so it visually
associates with the action; specifically, relocate the block that renders when
!isUpgradeValid && step === "info" into the content container (near the upgrade
button JSX and before the DialogFooter component) so the message appears
adjacent to the upgrade button and still conditionally renders based on
isUpgradeValid/step.
lib/services/geo-restriction.ts (3)

77-86: State-level restriction check in checkLocation is US-only, but isRestricted is generic.

checkLocation only evaluates state restrictions when countryCode === "US" (Line 77), while isRestricted (Line 119) constructs ${countryCode}-${regionCode} for any country. If a non-US state-level restriction is added to RESTRICTED, checkLocation will silently miss it.

Consider making checkLocation generic or delegating to isRestricted/getRestrictionReason to avoid divergence:

Suggested refactor
-    // Check country-level restrictions
-    const countryRestriction = RESTRICTED.find(
-      (r) => r.code === mockLocation.countryCode && r.type === "COUNTRY",
-    );
-    if (countryRestriction) {
-      mockLocation.isRestricted = true;
-      mockLocation.restrictionReason = countryRestriction.reason;
-    }
-
-    // Check state-level restrictions
-    if (mockLocation.countryCode === "US" && mockLocation.regionCode) {
-      const stateCode = `US-${mockLocation.regionCode}`;
-      const stateRestriction = RESTRICTED.find(
-        (r) => r.code === stateCode && r.type === "STATE",
-      );
-      if (stateRestriction) {
-        mockLocation.isRestricted = true;
-        mockLocation.restrictionReason = stateRestriction.reason;
-      }
-    }
+    // Delegate to centralized helpers
+    mockLocation.isRestricted = this.isRestricted(
+      mockLocation.countryCode,
+      mockLocation.regionCode,
+    );
+    const reason = this.getRestrictionReason(
+      mockLocation.countryCode,
+      mockLocation.regionCode,
+    );
+    if (reason) {
+      mockLocation.restrictionReason = reason;
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/services/geo-restriction.ts` around lines 77 - 86, The state-level
restriction branch in checkLocation currently only runs for countryCode === "US"
which misses non-US region codes; update checkLocation to either construct the
stateCode generically (const stateCode =
`${mockLocation.countryCode}-${mockLocation.regionCode}`) and look it up in
RESTRICTED, or better yet delegate to the existing
isRestricted/getRestrictionReason helpers so the same logic is reused; adjust
the block that sets mockLocation.isRestricted and mockLocation.restrictionReason
to use getRestrictionReason(stateCode) or isRestricted(...) and remove the
hard-coded "US" check to handle state-level restrictions for any country.

49-55: Falsy-string fields fall through to defaults due to ||.

Using || for countryCode, countryName, etc. means an explicitly passed empty string "" in override is treated as missing and falls back to the default. If this is intentional (empty strings are never valid), this is fine; otherwise, use ?? (nullish coalescing).

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

In `@lib/services/geo-restriction.ts` around lines 49 - 55, The mockLocation
construction uses the logical OR operator which treats empty strings as falsy
and will fall back to defaults; update the assignments in the mockLocation
object (the properties countryCode, countryName, regionCode, regionName, city)
to use the nullish coalescing operator on override (e.g., override?.countryCode
?? "US") so that explicit empty strings are preserved while still defaulting
when override is null/undefined; modify the mockLocation initialization inside
lib/services/geo-restriction.ts accordingly, leaving other logic untouched.

91-100: Mock VPN list includes well-known DNS resolver IPs.

8.8.8.8 (Google DNS) and 1.1.1.1 (Cloudflare DNS) are not VPN endpoints. Using them as mock VPN IPs may cause confusing false positives during development/testing if these IPs appear in real request headers.

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

In `@lib/services/geo-restriction.ts` around lines 91 - 100, The mock VPN list in
detectVPN currently uses real public DNS resolver IPs via the vpnIps array
(e.g., "8.8.8.8", "1.1.1.1"), which can produce false positives; replace those
entries in the detectVPN function with non-routable/reserved example addresses
(e.g., addresses from 192.0.2.0/24, 198.51.100.0/24, or 203.0.113.0/24) or make
vpnIps configurable via environment/test fixtures so development tests never
mark real public resolvers as VPNs.
lib/services/compliance.ts (1)

89-98: Shallow copy leaks mutable references to nested objects (usage, limits).

{ ...MOCK_COMPLIANCE_DB[userId] } only copies top-level properties. Any external caller of getUserCompliance gets direct references to the same usage and limits objects stored in MOCK_COMPLIANCE_DB, and can mutate the DB unintentionally. The internal mutating methods (e.g., trackWithdrawal) rely on this shared-reference behavior, but it breaks the encapsulation claimed by the JSDoc.

Consider deep-cloning for the public return path and using a separate internal accessor for methods that need to mutate:

Suggested approach
+  /** Internal: returns the live DB record (mutable). */
+  private static getComplianceRecord(userId: string): UserCompliance {
+    if (!MOCK_COMPLIANCE_DB[userId]) {
+      MOCK_COMPLIANCE_DB[userId] = this.createDefaultCompliance(userId);
+    }
+    return MOCK_COMPLIANCE_DB[userId];
+  }
+
   static async getUserCompliance(userId: string): Promise<UserCompliance> {
     await this.simulateDelay();
-    if (!MOCK_COMPLIANCE_DB[userId]) {
-      MOCK_COMPLIANCE_DB[userId] = this.createDefaultCompliance(userId);
-    }
-    return { ...MOCK_COMPLIANCE_DB[userId] };
+    const record = this.getComplianceRecord(userId);
+    return JSON.parse(JSON.stringify(record)); // deep clone for external callers
   }

Then have internal mutating methods call getComplianceRecord directly.

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

In `@lib/services/compliance.ts` around lines 89 - 98, getUserCompliance currently
returns a shallow copy ({ ...MOCK_COMPLIANCE_DB[userId] }) which leaks mutable
nested objects (usage, limits); replace that public-facing shallow copy with a
deep-clone of the compliance record (so callers cannot mutate nested state) and
add/use an internal accessor (e.g., getComplianceRecord or similar) that returns
the actual object for internal mutating methods like trackWithdrawal and any
other internal updates; ensure createDefaultCompliance remains unchanged except
that getUserCompliance uses deep clone when returning to callers while internal
mutators call the internal accessor to mutate the DB directly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/api/compliance/terms/route.ts`:
- Line 25: The route handler in app/api/compliance/terms/route.ts should
validate the incoming termsVersionId before calling TermsService.acceptTerms:
check that req body (or parsed JSON) contains a non-null, defined,
correctly-typed termsVersionId (e.g., a non-empty string or expected type) and
immediately return a 400 Bad Request with a clear error message if the value is
missing/invalid; only call TermsService.acceptTerms when validation passes to
avoid forwarding malformed data and producing a 500 error downstream.
- Line 26: The X-Forwarded-For header can contain a comma-separated list of IPs;
update the request handler (e.g., the route handler function in route.ts, such
as the exported GET/POST handler) to parse req.headers.get('x-forwarded-for') by
splitting on ',' and taking the first element .trim() as the client IP before
storing it (use or replace the current complianceIp or ip variable), and fall
back to req.ip or remoteAddress only if the header is missing; ensure the stored
value is the single parsed IP string used in downstream audit/save logic.

In `@app/api/compliance/upgrade/route.ts`:
- Around line 15-28: VALID_TIERS currently includes "UNVERIFIED" which allows
downgrades; remove "UNVERIFIED" from VALID_TIERS and instead derive the allowed
upgrade targets from ComplianceService.TIER_CONFIGS to avoid duplication. Update
the validation around targetTier (the check that uses VALID_TIERS and the
variable targetTier) to build VALID_TIERS dynamically from
Object.keys(ComplianceService.TIER_CONFIGS) or a filtered list of those keys
excluding "UNVERIFIED", then use that derived array for the includes check so
upgrades cannot target UNVERIFIED and the list stays DRY.

In `@app/api/withdrawal/submit/route.ts`:
- Line 13: The request header "x-forwarded-for" may contain a comma-separated
list of IPs; change how `ip` is derived in route.ts so you parse
request.headers.get("x-forwarded-for"), split on commas, trim entries and select
the first non-empty entry as the client IP (falling back to
request.socket.remoteAddress or "0.0.0.0" if none). Update the `ip` assignment
that currently sets const ip = request.headers.get("x-forwarded-for") ||
"0.0.0.0" to perform this split/trim/select logic so downstream checks (rate
limiting, geo) receive only the client IP.

In `@components/compliance/tier-upgrade-dialog.tsx`:
- Around line 47-51: The dialog's internal state (step, requestId, uploadedDocs)
isn't reset when reopened or when targetTier changes; add a useEffect that
watches open and targetTier and, when open is true, calls setStep("info"),
setRequestId(null), and setUploadedDocs(new Set()) to reset state; reference the
existing state variables (step, setStep, requestId, setRequestId, uploadedDocs,
setUploadedDocs) and add the effect after their declarations inside the
component.
- Around line 73-83: The handleDocumentUpload function lacks error handling;
wrap the await VerificationService.uploadDocument(...) call in a try/catch
similar to handleUpgrade, so any thrown error is caught and handled (e.g., log
or surface via the same user-facing error/toast mechanism used in handleUpgrade)
and only update setUploadedDocs(...) on success; ensure the catch captures the
error and uses the same error reporting/path as handleUpgrade for consistency.

In `@components/wallet/withdrawal-section.tsx`:
- Around line 226-242: The UI is using a hardcoded $2.50 fee in
WithdrawalSection which can drift from server-side logic; update the fee display
and the "You'll Receive" calculation to use the server-provided fee (e.g., from
the validation response object like withdrawValidation.fee or a config value
returned by fetchWithdrawValidation()) instead of the literal 2.5, ensure you
parse/normalize that fee to a number and fall back to 0 if missing, and replace
references to the hardcoded string/number in the JSX (the span showing "$2.50"
and the expression formatCurrency(Math.max(0, (parsedAmount || 0) - 2.5))) so
the UI always reflects the authoritative server fee and currency formatting.

In `@lib/services/compliance.ts`:
- Around line 209-241: The validateWithdrawalAmount method can make decisions
based on stale windows; update it to ensure windows are reset before checking
limits by invoking resetExpiredWindows for the user (same approach as
getRemainingLimits) or by calling a public wrapper that resets windows first;
specifically, inside validateWithdrawalAmount (before reading
compliance.limits/usage) call the existing resetExpiredWindows(userId) helper
(or a newly exposed public reset method) then fetch compliance via
getUserCompliance so validations use up-to-date window data.
- Around line 258-272: The upgradeTier implementation currently applies any
KYCTier unconditionally (allowing downgrades); change it to validate tier
progression by first reading the user's currentTier via
this.getUserCompliance(userId), compute the allowed progression using
this.getNextTier(currentTier) (or compare indexes from
this.TIER_CONFIGS/TIER_ORDER), and only allow setting compliance.currentTier to
the same tier or a strictly higher tier (i.e., equal to the next tier or any
tier with a higher order); if the requested newTier is a downgrade (lower order)
or otherwise invalid, do not modify the record and return false or throw an
error to reject the operation. Ensure you still update limits,
verificationStatus, and updatedAt only when the validation passes.

---

Duplicate comments:
In `@components/wallet/withdrawal-section.tsx`:
- Around line 57-79: The effect currently depends on the entire validateMutation
object which gets a new reference on mutation state changes and causes re-render
loops; extract the stable mutate function from validateMutation (e.g., const {
mutate } = validateMutation or store mutate in a ref/useCallback) and use that
in the effect dependency list instead of validateMutation, then update the
dependency array to [syncValidationError, mutate, parsedAmount] so the
debounce/mutate logic uses the stable mutate reference (ensure the extracted
mutate is stable across renders or wrap it in a ref/useCallback if necessary).

---

Nitpick comments:
In @.husky/pre-commit:
- Line 1: Add an explicit shell shebang to the pre-commit hook so the
interpreter is deterministic: update the .husky/pre-commit script that currently
runs "npx lint-staged --concurrent false" to start with a shebang line like
"#!/usr/bin/env sh" (or another appropriate shell) and ensure the file is
executable; this makes the hook run under the intended shell even on
environments where Husky's invocation differs.

In @.husky/pre-push:
- Line 1: Add a POSIX shebang to the top of the husky hook files to improve
editor recognition and clarity: open the pre-push and pre-commit hook files and
prepend a single line with the POSIX shebang (#!/usr/bin/env sh) as the very
first line so the hooks remain compatible with Husky v9 while being explicitly
recognized by editors and tools.

In `@app/api/compliance/upgrade/route.ts`:
- Around line 34-43: The catch in route.ts around
VerificationService.createVerificationRequest currently returns a 400 for all
errors; change it to detect validation/expected errors (e.g., check for a
ValidationError type or an error.code from
VerificationService.createVerificationRequest) and return NextResponse.json(...,
{ status: 400 }) only for those cases, otherwise log the error and return a 500
response via NextResponse.json(..., { status: 500 }); ensure you reference the
catch that wraps VerificationService.createVerificationRequest and use
NextResponse.json for both branches so clients get 400 for client-side
validation failures and 500 for unexpected server-side failures.

In `@app/api/withdrawal/submit/route.ts`:
- Around line 27-28: The default error status should be 500 instead of 400 to
avoid silently classifying server errors as client errors; change the
initialization of status from 400 to 500 (the variables message and status at
the top of the route handler) and only overwrite status to 400 when you detect
an explicit validation/bad-request error (the branch that currently builds
status via the || chain from the caught Error). Ensure the catch block preserves
the actual Error behavior by setting status to 400 only for known client-error
types and otherwise leaving it as 500 while still populating message with the
error details.
- Line 12: The route currently reads const { amount, currency, destinationId } =
await request.json() and calls WithdrawalService.submit without validating
inputs; add guards in the route handler to ensure amount is present and a finite
number (coerce/parse and reject NaN), currency is a non-empty string (optionally
check against allowed currencies), and destinationId is a non-empty string (or
valid id format), and return a 400 response with a clear error message when
validation fails; only call WithdrawalService.submit after these checks and
include the validated/parsed values (e.g., numeric amount) when invoking
WithdrawalService.submit.

In `@components/compliance/tier-upgrade-dialog.tsx`:
- Around line 197-201: Move the "already at this tier" validation message from
below the DialogFooter to inside the dialog content area just above the upgrade
button so it visually associates with the action; specifically, relocate the
block that renders when !isUpgradeValid && step === "info" into the content
container (near the upgrade button JSX and before the DialogFooter component) so
the message appears adjacent to the upgrade button and still conditionally
renders based on isUpgradeValid/step.

In `@components/wallet/withdrawal-section.tsx`:
- Around line 97-102: The formatCurrency helper is being re-created on every
render inside the component; move it out of the component scope (or wrap it with
useCallback(() => ..., []) if you must keep it inside) so it’s defined once and
not reallocated on each render; update references in the component (e.g., where
formatCurrency is used in WithdrawalSection / withdrawal-section.tsx) to use the
extracted/memoized function.
- Around line 43-45: The component currently contains a hardcoded bankAccounts
array which leaks placeholder data into the UI and is used as the destinationId
in handleWithdraw; remove this mock array and either fetch real accounts (or
accept them via props) and use the selected account's id as the destinationId in
handleWithdraw, or if this is scaffolding add a clear TODO comment and ensure
the Withdraw button (the button that triggers handleWithdraw) is disabled when
no real accounts are loaded; update references to bankAccounts in the UI (where
accounts are listed) to use the real data source and ensure handleWithdraw reads
a single selectedAccount.id (not the entire array) for destinationId.
- Around line 81-95: The handler handleWithdraw currently uses blocking
window.alert() for success/error which is inconsistent with the app UI; replace
those alerts by invoking the app's toast/in-page notification system (e.g., the
useToast or Notification component used elsewhere) inside the try/catch around
submitMutation.mutateAsync so success uses toast.success("Withdrawal submitted
successfully!") and errors use toast.error(error.message || "Withdrawal
failed"), keep the same state updates (setAmount("")) and destinationId logic
(bankAccounts[0].id) and ensure the toast hook/import is added to the component
and used instead of alert().

In `@lib/services/compliance.ts`:
- Around line 89-98: getUserCompliance currently returns a shallow copy ({
...MOCK_COMPLIANCE_DB[userId] }) which leaks mutable nested objects (usage,
limits); replace that public-facing shallow copy with a deep-clone of the
compliance record (so callers cannot mutate nested state) and add/use an
internal accessor (e.g., getComplianceRecord or similar) that returns the actual
object for internal mutating methods like trackWithdrawal and any other internal
updates; ensure createDefaultCompliance remains unchanged except that
getUserCompliance uses deep clone when returning to callers while internal
mutators call the internal accessor to mutate the DB directly.

In `@lib/services/geo-restriction.ts`:
- Around line 77-86: The state-level restriction branch in checkLocation
currently only runs for countryCode === "US" which misses non-US region codes;
update checkLocation to either construct the stateCode generically (const
stateCode = `${mockLocation.countryCode}-${mockLocation.regionCode}`) and look
it up in RESTRICTED, or better yet delegate to the existing
isRestricted/getRestrictionReason helpers so the same logic is reused; adjust
the block that sets mockLocation.isRestricted and mockLocation.restrictionReason
to use getRestrictionReason(stateCode) or isRestricted(...) and remove the
hard-coded "US" check to handle state-level restrictions for any country.
- Around line 49-55: The mockLocation construction uses the logical OR operator
which treats empty strings as falsy and will fall back to defaults; update the
assignments in the mockLocation object (the properties countryCode, countryName,
regionCode, regionName, city) to use the nullish coalescing operator on override
(e.g., override?.countryCode ?? "US") so that explicit empty strings are
preserved while still defaulting when override is null/undefined; modify the
mockLocation initialization inside lib/services/geo-restriction.ts accordingly,
leaving other logic untouched.
- Around line 91-100: The mock VPN list in detectVPN currently uses real public
DNS resolver IPs via the vpnIps array (e.g., "8.8.8.8", "1.1.1.1"), which can
produce false positives; replace those entries in the detectVPN function with
non-routable/reserved example addresses (e.g., addresses from 192.0.2.0/24,
198.51.100.0/24, or 203.0.113.0/24) or make vpnIps configurable via
environment/test fixtures so development tests never mark real public resolvers
as VPNs.

Copy link

@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: 5

🧹 Nitpick comments (8)
lib/services/geo-restriction.ts (3)

150-152: [...RESTRICTED] is a shallow array copy — returned RestrictedJurisdiction objects are still mutable references into the module-level RESTRICTED array.

A caller can do getRestrictedJurisdictions()[0].code = 'XX' and silently corrupt all subsequent isRestricted, getRestrictionReason, and checkLocation calls for the lifetime of the module. const RESTRICTED prevents binding reassignment, not element mutation.

♻️ Proposed fix — shallow-copy each entry
   static getRestrictedJurisdictions(): RestrictedJurisdiction[] {
-    return [...RESTRICTED];
+    return RESTRICTED.map((r) => ({ ...r }));
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/services/geo-restriction.ts` around lines 150 - 152,
getRestrictedJurisdictions currently returns a shallow copy of the module array
(RESTRICTED) but preserves object references so callers can mutate the returned
RestrictedJurisdiction objects; change getRestrictedJurisdictions to return new
object copies for each entry (e.g., map over RESTRICTED and clone each
RestrictedJurisdiction) so callers cannot mutate module-level state—update the
method that references RESTRICTED and consider using object spread or a cloning
utility (or Object.freeze on each returned item) to ensure immutability of
returned entries.

91-107: Placeholder VPN/proxy detection integrated into the production code path with no indication to callers that results are unreliable.

detectVPN checks against RFC 5737 documentation IPs (192.0.2.1, 198.51.100.1) — these are reserved ranges, never routed on the internet. Any real user IP always resolves to false. detectProxy unconditionally returns false. Both results are silently propagated into UserLocation.isVPN / isProxy, indistinguishable from a real detection result.

Consider surfacing the stub status so callers can act accordingly, e.g., adding a detectionConfidence field or throwing in environments where real detection is expected.

Do you want me to open an issue to track integration of a real VPN/proxy detection service (e.g. IPHub, IP2Proxy, VPNApi)?

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

In `@lib/services/geo-restriction.ts` around lines 91 - 107, detectVPN and
detectProxy are currently silent placeholders so callers (UserLocation.isVPN /
isProxy) can't tell results are unreliable; change both detectVPN and
detectProxy to return a structured DetectionResult (e.g., { isDetected: boolean,
detectionConfidence: number, isStub: boolean }) instead of a bare boolean, set
isStub = true and detectionConfidence = 0 for the current mocked logic, and
update UserLocation.isVPN / isProxy to read the new fields (or fail if an
environment flag like REQUIRE_REAL_DETECTION is set) so callers can distinguish
stubbed results from real detections and act accordingly.

76-86: State-level check silently drops state restriction reason when country is also restricted.

getRestrictionReason(countryCode, regionCode) evaluates country first (lines 130–135), then state. Inside this "state-level check" block:

  • When the country is already restricted (set by lines 67–74), getRestrictionReason returns the country reason again — the block becomes a redundant no-op.
  • When both country and state are restricted, the state's reason is silently ignored because getRestrictionReason short-circuits on the country match. The comment promises "state-level restrictions" but the implementation can never surface a state reason in that case.

Use a direct state-code lookup instead, consistent with isRestricted:

♻️ Proposed fix — direct state-only lookup
-    // Check state-level restrictions (generic support for any country)
-    if (mockLocation.regionCode) {
-      const reason = this.getRestrictionReason(
-        mockLocation.countryCode,
-        mockLocation.regionCode,
-      );
-      if (reason) {
-        mockLocation.isRestricted = true;
-        mockLocation.restrictionReason = reason;
-      }
-    }
+    // Check state-level restrictions (generic support for any country)
+    if (mockLocation.regionCode) {
+      const stateCode = `${mockLocation.countryCode}-${mockLocation.regionCode}`;
+      const stateRestriction = RESTRICTED.find(
+        (r) => r.code === stateCode && r.type === "STATE",
+      );
+      if (stateRestriction) {
+        mockLocation.isRestricted = true;
+        mockLocation.restrictionReason = stateRestriction.reason;
+      }
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/services/geo-restriction.ts` around lines 76 - 86, When checking
state-level restrictions you must perform a state-only lookup instead of calling
getRestrictionReason(countryCode, regionCode) which returns a country match
first and hides state reasons; update the region block that reads
mockLocation.regionCode to fetch the state restriction reason directly (use the
same state-only lookup mechanism used by isRestricted or add/call a
getStateRestrictionReason(countryCode, regionCode) that checks only the region
map) and then set mockLocation.isRestricted and mockLocation.restrictionReason
from that state-specific result so state reasons aren’t overwritten by
country-level matches.
components/compliance/tier-upgrade-dialog.tsx (1)

20-25: TIER_ORDER is duplicated across files.

The same tier ordering map exists in lib/services/compliance.ts (line 272, inside upgradeTier). Consider exporting a single source of truth from the compliance service or types module to avoid them drifting apart.

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

In `@components/compliance/tier-upgrade-dialog.tsx` around lines 20 - 25,
TIER_ORDER is duplicated; instead create and export a single canonical tier
ordering constant (e.g., export const TIER_ORDER or enum) from the compliance
service or shared types module and import it where needed; update the local
references in this file (the TIER_ORDER constant and any usage expecting
KYCTier) to use the exported symbol, and remove the duplicated definition so
upgradeTier and tier-upgrade-dialog both consume the single source of truth.
lib/services/compliance.ts (2)

95-102: simulateDelay is only applied to getUserCompliance.

Other public async methods (getRemainingLimits, validateWithdrawalAmount, trackWithdrawal, upgradeTier, etc.) skip the simulated delay. If the delay is meant to mimic real DB latency for testing, the inconsistency could mask timing-related bugs in callers that chain multiple service calls.

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

In `@lib/services/compliance.ts` around lines 95 - 102, The simulated latency is
only invoked in getUserCompliance via simulateDelay, causing inconsistent async
behavior across the service; update the service so all public async methods
(e.g., getRemainingLimits, validateWithdrawalAmount, trackWithdrawal,
upgradeTier, and any other exported async functions) also await simulateDelay at
their start (or refactor delay invocation into a shared wrapper/helper that each
public method calls) to ensure consistent DB-latency simulation and prevent
timing-related inconsistencies in callers.

85-90: getComplianceRecord returns a mutable DB reference — mutation methods redundantly re-assign it.

Since getComplianceRecord returns the actual object stored in MOCK_COMPLIANCE_DB, the MOCK_COMPLIANCE_DB[userId] = compliance writes in trackWithdrawal (line 263), upgradeTier (line 290), setHoldState (line 308), etc., are no-ops. This is harmless now, but if getComplianceRecord ever returns a clone, all those methods silently stop persisting changes.

Consider either: (a) making the pattern explicit with a comment, or (b) always writing back as a defensive practice and documenting that getComplianceRecord must return a direct reference.

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

In `@lib/services/compliance.ts` around lines 85 - 90, getComplianceRecord
currently returns the live object from MOCK_COMPLIANCE_DB so mutations persist
implicitly, but callers like trackWithdrawal, upgradeTier and setHoldState
assume and redundantly reassign MOCK_COMPLIANCE_DB[userId]; make this explicit
and defensive by adding a short comment on getComplianceRecord that it returns a
direct reference (or alternatively returns a clone), and update mutation methods
(trackWithdrawal, upgradeTier, setHoldState) to always write the mutated object
back to MOCK_COMPLIANCE_DB[userId] after changing fields so persistence does not
depend on reference semantics; reference getComplianceRecord,
MOCK_COMPLIANCE_DB, trackWithdrawal, upgradeTier and setHoldState when applying
the change.
components/wallet/withdrawal-section.tsx (1)

111-114: serverFee ternary is a no-op — both branches return 2.5.

Line 114: validateMutation.data?.valid ? 2.5 : 2.5 always evaluates to 2.5. The TODO acknowledges this, but the conditional is misleading — a plain const serverFee = 2.5 would be clearer until the real fee is available.

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

In `@components/wallet/withdrawal-section.tsx` around lines 111 - 114, The
serverFee ternary in withdrawal-section.tsx is a no-op
(validateMutation.data?.valid ? 2.5 : 2.5); replace it with a simple constant
assignment and keep the TODO: change the line that defines serverFee to a plain
const serverFee = 2.5 and leave a comment/TODO indicating to pull the real fee
from validateMutation.data when available (referencing serverFee,
validateMutation, parsedAmount, and isAmountValidated to locate the code).
app/api/withdrawal/submit/route.ts (1)

40-60: Malformed JSON body will return 500 instead of 400.

If request.json() on line 12 throws (e.g., invalid JSON), execution falls into the catch block. Since the resulting SyntaxError doesn't have name === "ValidationError" or a .status property, the client gets a 500 — misleading for a client-side input error.

Suggested fix — catch JSON parse errors explicitly
+    let body;
+    try {
+      body = await request.json();
+    } catch {
+      return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
+    }
+
-    const { amount, currency, destinationId } = await request.json();
+    const { amount, currency, destinationId } = body;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/withdrawal/submit/route.ts` around lines 40 - 60, The catch block
that returns NextResponse.json currently maps most errors to 500, so JSON parse
failures from request.json() become 500; update the error handling in the catch
to explicitly detect JSON parse errors (e.g., error instanceof SyntaxError or
error.name === "SyntaxError") and set status to 400 with a clear message like
"Invalid JSON body" (or use error.message), leaving other errors handled as
before; modify the catch logic around request.json(), the error/type checks you
already have (error instanceof Error, err.status/err.statusCode, error.name
checks), and the return NextResponse.json(...) to return 400 for SyntaxError
cases.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/api/compliance/upgrade/route.ts`:
- Around line 35-47: The response uses (error as Error).message which can be
undefined if the thrown value isn't an Error; update the handler to derive a
safe error string before calling NextResponse.json by checking error instanceof
Error and using error.message, otherwise use String(error) if present or a
static fallback like "Failed to create verification request"; keep the existing
isValidationError logic that uses instanceof Error and reuse that safe message
variable in the NextResponse.json payload and status decision so the response
never contains { error: undefined } (refer to isValidationError, the error
variable, and the NextResponse.json call).
- Around line 16-26: The route currently only checks that targetTier is a valid
non-UNVERIFIED tier but not that it is a progression from the user’s current
tier; fetch the user’s current tier (e.g., from the request user/session or the
ComplianceService method that returns the user’s current KYCTier), compare the
rank/order using ComplianceService.TIER_CONFIGS (or an explicit ranking/array of
tiers) and reject the request with a 400 if targetTier is not strictly higher
than the current tier (mirroring the client-side isUpgradeValid logic); update
the validation around VALID_TIERS/targetTier to perform this progression check
and return an error response when the upgrade is not actually an upgrade.

In `@components/compliance/tier-upgrade-dialog.tsx`:
- Around line 206-209: The "Back" button currently always sets setStep("info")
which lets the user re-submit and call handleUpgrade again after requestId was
set; change the Back click handler so that if requestId is truthy it navigates
directly to the documents step (e.g., setStep("documents")) instead of "info",
and also prevent duplicate submits by making the "Request Upgrade" action/button
check requestId (disable the button or short-circuit handleUpgrade when
requestId exists) to ensure handleUpgrade is not called again once a requestId
has been created.

In `@components/wallet/withdrawal-section.tsx`:
- Around line 71-93: In the useEffect inside the WithdrawalSection component
(the effect that depends on syncValidationError, validate, parsedAmount), ensure
any stale async validation error is cleared as soon as a syncValidationError is
present: when syncValidationError is truthy, call setValidationError(null)
before returning so the UI won’t briefly show a previous async error; keep the
existing debounce/validate logic unchanged for the non-sync-error path (use the
existing parsedAmount, validate, and debounceTimer).

In `@lib/services/geo-restriction.ts`:
- Around line 44-50: The override.ip value is ignored when building
mockLocation.ip; update the assignment so mockLocation.ip uses override?.ip if
present (e.g., set mockLocation.ip = override?.ip ?? ip) or alternatively change
the parameter type to exclude ip (do not accept ip in Partial<UserLocation>),
and ensure the function that creates mockLocation (the mockLocation object and
the override parameter) reflects this decision so callers' intent is honored.

---

Nitpick comments:
In `@app/api/withdrawal/submit/route.ts`:
- Around line 40-60: The catch block that returns NextResponse.json currently
maps most errors to 500, so JSON parse failures from request.json() become 500;
update the error handling in the catch to explicitly detect JSON parse errors
(e.g., error instanceof SyntaxError or error.name === "SyntaxError") and set
status to 400 with a clear message like "Invalid JSON body" (or use
error.message), leaving other errors handled as before; modify the catch logic
around request.json(), the error/type checks you already have (error instanceof
Error, err.status/err.statusCode, error.name checks), and the return
NextResponse.json(...) to return 400 for SyntaxError cases.

In `@components/compliance/tier-upgrade-dialog.tsx`:
- Around line 20-25: TIER_ORDER is duplicated; instead create and export a
single canonical tier ordering constant (e.g., export const TIER_ORDER or enum)
from the compliance service or shared types module and import it where needed;
update the local references in this file (the TIER_ORDER constant and any usage
expecting KYCTier) to use the exported symbol, and remove the duplicated
definition so upgradeTier and tier-upgrade-dialog both consume the single source
of truth.

In `@components/wallet/withdrawal-section.tsx`:
- Around line 111-114: The serverFee ternary in withdrawal-section.tsx is a
no-op (validateMutation.data?.valid ? 2.5 : 2.5); replace it with a simple
constant assignment and keep the TODO: change the line that defines serverFee to
a plain const serverFee = 2.5 and leave a comment/TODO indicating to pull the
real fee from validateMutation.data when available (referencing serverFee,
validateMutation, parsedAmount, and isAmountValidated to locate the code).

In `@lib/services/compliance.ts`:
- Around line 95-102: The simulated latency is only invoked in getUserCompliance
via simulateDelay, causing inconsistent async behavior across the service;
update the service so all public async methods (e.g., getRemainingLimits,
validateWithdrawalAmount, trackWithdrawal, upgradeTier, and any other exported
async functions) also await simulateDelay at their start (or refactor delay
invocation into a shared wrapper/helper that each public method calls) to ensure
consistent DB-latency simulation and prevent timing-related inconsistencies in
callers.
- Around line 85-90: getComplianceRecord currently returns the live object from
MOCK_COMPLIANCE_DB so mutations persist implicitly, but callers like
trackWithdrawal, upgradeTier and setHoldState assume and redundantly reassign
MOCK_COMPLIANCE_DB[userId]; make this explicit and defensive by adding a short
comment on getComplianceRecord that it returns a direct reference (or
alternatively returns a clone), and update mutation methods (trackWithdrawal,
upgradeTier, setHoldState) to always write the mutated object back to
MOCK_COMPLIANCE_DB[userId] after changing fields so persistence does not depend
on reference semantics; reference getComplianceRecord, MOCK_COMPLIANCE_DB,
trackWithdrawal, upgradeTier and setHoldState when applying the change.

In `@lib/services/geo-restriction.ts`:
- Around line 150-152: getRestrictedJurisdictions currently returns a shallow
copy of the module array (RESTRICTED) but preserves object references so callers
can mutate the returned RestrictedJurisdiction objects; change
getRestrictedJurisdictions to return new object copies for each entry (e.g., map
over RESTRICTED and clone each RestrictedJurisdiction) so callers cannot mutate
module-level state—update the method that references RESTRICTED and consider
using object spread or a cloning utility (or Object.freeze on each returned
item) to ensure immutability of returned entries.
- Around line 91-107: detectVPN and detectProxy are currently silent
placeholders so callers (UserLocation.isVPN / isProxy) can't tell results are
unreliable; change both detectVPN and detectProxy to return a structured
DetectionResult (e.g., { isDetected: boolean, detectionConfidence: number,
isStub: boolean }) instead of a bare boolean, set isStub = true and
detectionConfidence = 0 for the current mocked logic, and update
UserLocation.isVPN / isProxy to read the new fields (or fail if an environment
flag like REQUIRE_REAL_DETECTION is set) so callers can distinguish stubbed
results from real detections and act accordingly.
- Around line 76-86: When checking state-level restrictions you must perform a
state-only lookup instead of calling getRestrictionReason(countryCode,
regionCode) which returns a country match first and hides state reasons; update
the region block that reads mockLocation.regionCode to fetch the state
restriction reason directly (use the same state-only lookup mechanism used by
isRestricted or add/call a getStateRestrictionReason(countryCode, regionCode)
that checks only the region map) and then set mockLocation.isRestricted and
mockLocation.restrictionReason from that state-specific result so state reasons
aren’t overwritten by country-level matches.

@Benjtalkshow Benjtalkshow merged commit 5772cad into boundlessfi:main Feb 20, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant