Skip to content

Conversation

@davedumto
Copy link
Contributor

@davedumto davedumto commented Jan 31, 2026

KYC Tiers, Withdrawal Limits, and Terms Acceptance System

Summary

Implements a comprehensive compliance system with KYC verification tiers, withdrawal limit enforcement, and terms acceptance flow. This addresses issue #74 by adding the infrastructure needed to manage user verification levels, enforce withdrawal limits, and ensure legal compliance through terms acceptance.

Changes

Features

  • KYC Tier System: Bronze/Silver/Gold/Platinum tiers with configurable withdrawal limits
  • Withdrawal Validation: Real-time validation with limit checks before submission
  • Terms Acceptance: Scroll-to-read enforcement with acceptance tracking (IP, timestamp, user agent)
  • Hold States: Support for account holds (temporary, permanent, pending review) with reason display
  • Geo-Restrictions: Country-based access control with restricted regions handling
  • Tier Upgrades: Multi-step document upload flow for users requesting higher tiers
  • Appeal System: Framework for users to appeal rejected verification requests
  • Mobile Responsive: Wallet page centered layout for mobile devices

API Routes

Compliance

  • GET /api/compliance/status - Fetch user's compliance status, remaining limits, terms status, and next available tier
  • GET /api/compliance/terms - Get current terms version
  • POST /api/compliance/terms - Accept terms and conditions
  • GET /api/compliance/upgrade - Get verification status
  • POST /api/compliance/upgrade - Request tier upgrade

Withdrawals

  • POST /api/withdrawal/validate - Validate withdrawal amount against limits and compliance rules
  • POST /api/withdrawal/submit - Submit withdrawal request after validation

Components

Compliance Components

  • TierUpgradeDialog - Multi-step dialog for tier upgrades with document upload
  • TermsDialog - Scrollable terms and conditions with scroll-to-read enforcement
  • LimitsDisplay - Real-time visualization of limit usage with progress bars
  • HoldMessage - Alert component for displaying account hold states
  • DocumentUpload - File upload component for verification documents

Hooks

  • useComplianceStatus - Query hook for fetching compliance status
  • useAcceptTerms - Mutation hook for accepting terms
  • useUpgradeTier - Mutation hook for requesting tier upgrades
  • useValidateWithdrawal - Mutation hook for validating withdrawals
  • useSubmitWithdrawal - Mutation hook for submitting withdrawals

Services

Backend Services

  • ComplianceService - User compliance, tier configs, and limit calculations
  • VerificationService - Verification request management and document handling
  • WithdrawalService - Withdrawal validation and submission logic
  • TermsService - Terms version management and acceptance tracking
  • GeoRestrictionService - Country-based access control
  • EmailService - Email notifications for compliance events
  • AppealService - Verification appeal management

Types

  • compliance.ts - KYC tiers, verification, compliance status types
  • withdrawal.ts - Withdrawal request and validation types
  • terms.ts - Terms version and acceptance types
  • geography.ts - Country and geo-restriction types

UI Flow

Terms Acceptance Flow

  1. User enters withdrawal amount
  2. If terms require acceptance, "Accept Terms & Conditions" button appears
  3. User clicks button → Terms dialog opens
  4. User scrolls to bottom of terms (enforced)
  5. User checks "I agree" checkbox
  6. User clicks "Accept & Continue"
  7. Button disappears, "Complete Withdrawal" button becomes enabled
  8. User manually clicks to complete withdrawal

Tier Upgrade Flow

  1. User views their current limits in the Limits Display
  2. User clicks "Upgrade Tier" button
  3. Tier upgrade dialog shows new limits and requirements
  4. User clicks "Request Upgrade"
  5. If documents required, step 2 shows document upload forms
  6. User uploads required documents
  7. User clicks "Complete" to submit verification request

Testing

Manual Testing Checklist

  • Withdrawal validation enforces daily/weekly/monthly limits
  • Terms acceptance button appears when required
  • Terms dialog enforces scroll-to-read (checkbox disabled until scrolled)
  • Withdrawal button remains disabled until terms accepted
  • Tier upgrade dialog shows correct limits for each tier
  • Document upload accepts valid file types
  • Hold message displays when account has hold state
  • Mobile layout is centered and responsive
  • All API routes return proper error messages for unauthorized access

Edge Cases Tested

  • Withdrawal amount exceeding balance
  • Withdrawal amount below minimum ($10)
  • Multiple withdrawal attempts within same period
  • Terms acceptance with network failure
  • Document upload with invalid file types
  • Geo-restricted country access

Screenshots

Wallet interface

Screenshot 2026-01-31 at 9 29 06 AM

Tier Upgrade Dialog

Screenshot 2026-01-31 at 9 29 54 AM

Limits Display

Screenshot 2026-01-31 at 10 25 03 AM

Breaking Changes

None. This is a new feature addition.

Migration Required

No database migrations required for this PR. Uses mock data services that can be replaced with real database implementations.

Next Steps

  • Replace mock services with real database implementations
  • Integrate with actual KYC verification provider API
  • Add automated verification checks for lower tiers
  • Implement email notifications for tier changes
  • Add analytics tracking for compliance events
  • Set up admin dashboard for reviewing verification requests

Summary by CodeRabbit

  • New Features
    • Introduced KYC verification system with tiered account levels and tiered withdrawal limits.
    • Added withdrawal submission with real-time validation against compliance restrictions.
    • Implemented terms acceptance and management workflow.
    • Enabled tier upgrade requests with document uploads.
    • Added appeal process for verification decisions.

✏️ Tip: You can customize this high-level summary in your review settings.

…cceptance

Add comprehensive compliance system with KYC verification tiers, withdrawal limit enforcement, and terms acceptance flow.

Features:
- KYC tier system (Bronze/Silver/Gold/Platinum) with configurable limits
- Real-time withdrawal validation with limit checks
- Terms acceptance dialog with scroll-to-read enforcement
- Hold states and geo-restriction handling
- Document upload flow for tier upgrades
- Mobile-responsive wallet page with centered layout
- Appeal system for rejected verifications

API Routes:
- /api/compliance/status - Get user compliance and limits
- /api/compliance/terms - Get/accept terms and conditions
- /api/compliance/upgrade - Request tier upgrade
- /api/withdrawal/validate - Validate withdrawal amounts
- /api/withdrawal/submit - Submit withdrawal requests

Components:
- TierUpgradeDialog - Multi-step tier upgrade with document upload
- TermsDialog - Scrollable terms with acceptance tracking
- LimitsDisplay - Real-time limit usage visualization
- HoldMessage - Alert for account holds
…cceptance

Add comprehensive compliance system with KYC verification tiers, withdrawal limit enforcement, and terms acceptance flow.

Features:
- KYC tier system (Bronze/Silver/Gold/Platinum) with configurable limits
- Real-time withdrawal validation with limit checks
- Terms acceptance dialog with scroll-to-read enforcement
- Hold states and geo-restriction handling
- Document upload flow for tier upgrades
- Mobile-responsive wallet page with centered layout
- Appeal system for rejected verifications

API Routes:
- /api/compliance/status - Get user compliance and limits
- /api/compliance/terms - Get/accept terms and conditions
- /api/compliance/upgrade - Request tier upgrade
- /api/withdrawal/validate - Validate withdrawal amounts
- /api/withdrawal/submit - Submit withdrawal requests

Components:
- TierUpgradeDialog - Multi-step tier upgrade with document upload
- TermsDialog - Scrollable terms with acceptance tracking
- LimitsDisplay - Real-time limit usage visualization
- HoldMessage - Alert for account holds
…cceptance

Add comprehensive compliance system with KYC verification tiers, withdrawal limit enforcement, and terms acceptance flow.

Features:
- KYC tier system (Bronze/Silver/Gold/Platinum) with configurable limits
- Real-time withdrawal validation with limit checks
- Terms acceptance dialog with scroll-to-read enforcement
- Hold states and geo-restriction handling
- Document upload flow for tier upgrades
- Mobile-responsive wallet page with centered layout
- Appeal system for rejected verifications

API Routes:
- /api/compliance/status - Get user compliance and limits
- /api/compliance/terms - Get/accept terms and conditions
- /api/compliance/upgrade - Request tier upgrade
- /api/withdrawal/validate - Validate withdrawal amounts
- /api/withdrawal/submit - Submit withdrawal requests

Components:
- TierUpgradeDialog - Multi-step tier upgrade with document upload
- TermsDialog - Scrollable terms with acceptance tracking
- LimitsDisplay - Real-time limit usage visualization
- HoldMessage - Alert for account holds
…cceptance

Add comprehensive compliance system with KYC verification tiers, withdrawal limit enforcement, and terms acceptance flow.

Features:
- KYC tier system (Bronze/Silver/Gold/Platinum) with configurable limits
- Real-time withdrawal validation with limit checks
- Terms acceptance dialog with scroll-to-read enforcement
- Hold states and geo-restriction handling
- Document upload flow for tier upgrades
- Mobile-responsive wallet page with centered layout
- Appeal system for rejected verifications

API Routes:
- /api/compliance/status - Get user compliance and limits
- /api/compliance/terms - Get/accept terms and conditions
- /api/compliance/upgrade - Request tier upgrade
- /api/withdrawal/validate - Validate withdrawal amounts
- /api/withdrawal/submit - Submit withdrawal requests

Components:
- TierUpgradeDialog - Multi-step tier upgrade with document upload
- TermsDialog - Scrollable terms with acceptance tracking
- LimitsDisplay - Real-time limit usage visualization
- HoldMessage - Alert for account holds
@coderabbitai
Copy link

coderabbitai bot commented Jan 31, 2026

📝 Walkthrough

Walkthrough

This PR implements a comprehensive KYC/compliance verification system featuring user tier management with tiered withdrawal limits, terms acceptance workflows, document-based identity verification with appeals, withdrawal validation and submission flows, geographic restrictions, and account tier upgrades backed by new API routes, service layers, React components, and hooks.

Changes

Cohort / File(s) Summary
Compliance & Withdrawal API Routes
app/api/compliance/status/route.ts, app/api/compliance/terms/route.ts, app/api/compliance/upgrade/route.ts, app/api/withdrawal/submit/route.ts, app/api/withdrawal/validate/route.ts
Five new Next.js API endpoints handling user authentication, compliance status retrieval, terms acceptance, tier upgrade requests, and withdrawal validation/submission with appropriate error handling.
Service Layer: Core Business Logic
lib/services/compliance.ts, lib/services/verification.ts, lib/services/terms.ts, lib/services/withdrawal.ts
Four major in-memory service modules implementing KYC tier configurations with withdrawal limit tracking, document-based verification workflows, terms versioning with user acceptance tracking, and withdrawal validation against multiple compliance constraints.
Service Layer: Supporting Services
lib/services/appeal.ts, lib/services/email.ts, lib/services/geo-restriction.ts
Three supporting services enabling verification appeal submission, email notifications, and geographic restriction checking with VPN/proxy detection.
Type Definitions
types/compliance.ts, types/terms.ts, types/withdrawal.ts, types/geography.ts
Four new TypeScript modules defining domain models for KYC tiers, verification workflows, withdrawal requests, terms versioning, and geolocation restrictions.
React Hooks
hooks/use-compliance.ts, hooks/use-withdrawal.ts
Two custom React Query hooks encapsulating API interactions for compliance status, terms acceptance, tier upgrades, and withdrawal validation/submission.
Compliance UI Components
components/compliance/terms-dialog.tsx, components/compliance/tier-upgrade-dialog.tsx, components/compliance/document-upload.tsx, components/compliance/hold-message.tsx, components/compliance/limits-display.tsx, components/compliance/appeal-dialog.tsx
Six React components implementing user-facing compliance workflows: terms acceptance with scroll-to-bottom requirement, tier upgrade with document submission, drag-and-drop document upload with validation, compliance hold alerts, usage limits display, and appeal submission forms.
Withdrawal Integration & Infrastructure
components/wallet/withdrawal-section.tsx, lib/query/query-keys.ts
Updated withdrawal component with integrated compliance checks (hold states, terms acceptance, tier upgrades), validation workflows, and new query key factories for cache management.
Styling Updates
app/wallet/page.tsx, components/ui/checkbox.tsx
Minor layout adjustments for responsive text alignment and checkbox border radius styling.

Sequence Diagrams

sequenceDiagram
    actor User
    participant Client as Client (Browser)
    participant API as API Routes
    participant Service as Service Layer
    participant Store as In-Memory Store

    User->>Client: Initiate withdrawal
    Client->>API: POST /api/withdrawal/validate
    API->>Service: WithdrawalService.validate()
    Service->>Store: Check compliance limits
    Service->>Store: Verify hold state
    Service->>Store: Check geo-restrictions
    Service-->>API: Validation result
    API-->>Client: Valid/Invalid response

    alt Validation passes
        User->>Client: Submit withdrawal
        Client->>API: POST /api/withdrawal/submit
        API->>Service: WithdrawalService.submit()
        Service->>Store: Create withdrawal record
        Service->>Service: ComplianceService.trackWithdrawal()
        Service-->>API: Withdrawal confirmation
        API-->>Client: Success with transaction ID
        Client->>User: Show confirmation
    else Validation fails
        Client->>User: Display validation errors
    end
Loading
sequenceDiagram
    actor User
    participant Client as Client (Browser)
    participant API as API Routes
    participant VerifService as VerificationService
    participant CompService as ComplianceService
    participant Store as In-Memory Store

    User->>Client: Request tier upgrade
    Client->>API: POST /api/compliance/upgrade
    API->>VerifService: createVerificationRequest()
    VerifService->>Store: Create verification request
    VerifService-->>API: Request with ID
    API-->>Client: Upgrade initiated

    Client->>Client: Render document upload
    loop For each required document
        User->>Client: Upload document
        Client->>VerifService: uploadDocument()
        VerifService->>Store: Store document
        VerifService-->>Client: Upload confirmed
    end

    Client->>VerifService: checkCompletion()
    VerifService->>VerifService: hasRequiredDocuments()
    alt All documents uploaded
        Client->>CompService: upgradeTier()
        CompService->>Store: Update user tier
        CompService-->>Client: Tier upgraded
    else Missing documents
        Client->>User: Show missing items
    end
Loading
sequenceDiagram
    actor User
    participant Client as Client (Browser)
    participant TermsDialog as TermsDialog Component
    participant API as API Routes
    participant Service as TermsService
    participant Store as In-Memory Store

    User->>Client: Open app (terms required)
    Client->>TermsDialog: Render modal
    TermsDialog->>API: GET /api/compliance/terms
    API->>Service: getCurrentTermsVersion()
    Service->>Store: Fetch latest version
    Service-->>API: Terms content
    API-->>TermsDialog: Terms data
    TermsDialog->>User: Display terms + require scroll

    User->>TermsDialog: Scroll to bottom + check acceptance
    TermsDialog->>TermsDialog: Enable Accept button

    User->>TermsDialog: Click Accept & Continue
    TermsDialog->>API: POST /api/compliance/terms
    API->>Service: acceptTerms()
    Service->>Store: Record acceptance + metadata
    Service-->>API: Acceptance confirmed
    API-->>TermsDialog: Success
    TermsDialog->>Client: Close modal, resume app
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~65 minutes

Possibly Related Issues

  • Implement Withdrawal Limits and Compliance Checks System #74: This PR implements the complete withdrawal limit system, KYC tier management, geographic restrictions, terms/consent workflows, and document-based verification that directly address the feature request for compliance and verification systems with tiered withdrawal limits.

Possibly Related PRs

Poem

🐰 A rabbit hops through tiers so grand,
With limits, terms, and docs at hand,
Appeals and holds, geographically wise,
We've built compliance paradise!

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'Feat/kyc tier setup' is vague and uses a naming pattern (Feat/) that doesn't convey the full scope of the comprehensive compliance system implemented. Consider a more descriptive title like 'Add KYC verification tiers and compliance system with withdrawal limits and terms acceptance' to clearly communicate the primary change and scope.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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: 16

Caution

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

⚠️ Outside diff range comments (1)
components/wallet/withdrawal-section.tsx (1)

56-83: ⚠️ Potential issue | 🟡 Minor

Guard handleWithdraw with the same conditions as the UI.
Relying only on a disabled button leaves a programmatic bypass; reusing canWithdraw keeps the handler consistent with the UI state.

✅ Suggested fix
-    const handleWithdraw = async () => {
-        if (!isValidAmount || !complianceData) return;
+    const handleWithdraw = async () => {
+        if (!canWithdraw) return;
🤖 Fix all issues with AI agents
In `@app/api/compliance/terms/route.ts`:
- Around line 21-27: The code is storing the full x-forwarded-for header value
in ip (from request.headers.get('x-forwarded-for')) which may contain multiple
comma-separated IPs; update the extraction logic used before calling
TermsService.acceptTerms (and mirror the same change in the withdrawal/validate
and withdrawal/submit endpoints) to parse the header, split on commas, trim and
take the first non-empty token as the client IP (fall back to a safe default
like '0.0.0.0' if none), keep userAgent extraction unchanged, and ensure
comments note that the proxy must be configured to set/trust X-Forwarded-For.

In `@app/api/compliance/upgrade/route.ts`:
- Around line 5-26: The POST handler doesn't validate the incoming body and
targetTier which can lead to generic errors; update the POST function to parse
and validate request.json() early (ensure body exists and that targetTier is
present and of the expected type/value) before calling
VerificationService.createVerificationRequest, and return a clear
NextResponse.json({ error: "Invalid request: targetTier is required" }, {
status: 400 }) for bad input; keep the existing getCurrentUser check and error
catch but perform this guard right after parsing the body to provide explicit
400 responses.

In `@app/api/withdrawal/submit/route.ts`:
- Around line 12-21: Validate and sanitize the incoming payload before calling
WithdrawalService.submit: parse and assert that amount is a positive number
(e.g., Number(amount) and > 0), that currency matches your allowed ISO/currency
regex or whitelist, and that destinationId is present and a valid ID format; if
validation fails, return a 4xx response. Normalize the client IP from
request.headers.get('x-forwarded-for') by splitting on commas, taking the first
non-empty value and trimming it (fallback to '0.0.0.0' only if none), and pass
that normalized ip into WithdrawalService.submit. Ensure you perform these
checks where request.json() is consumed and only call
WithdrawalService.submit(user.id, amount, currency, destinationId, ip) with the
validated/normalized values.

In `@app/api/withdrawal/validate/route.ts`:
- Around line 12-15: The handler currently reads amount from request.json() and
uses request.headers.get('x-forwarded-for') directly before calling
WithdrawalService.validate(user.id, amount, ip); validate and normalize these
first: parse and validate amount (ensure it exists, is numeric, finite, positive
and within allowed bounds) and coerce it into the expected numeric type before
passing to WithdrawalService.validate, and normalize the client IP by taking the
first entry of the x-forwarded-for header (split on commas, trim) falling back
to request.connection/remoteAddress or '0.0.0.0' if absent; reject the request
early with a 400/appropriate error when validation fails rather than forwarding
invalid amount or malformed IP to WithdrawalService.validate.

In `@components/compliance/document-upload.tsx`:
- Around line 28-48: The handleFileChange handler leaves the file input value
set when a selected file is rejected (e.g., too large), preventing re-selecting
the same file from triggering onChange; update handleFileChange to clear both
the component file state (setFile) and the input element’s value when rejecting
a file (e.g., on size > 10MB) and also when an upload fails, then proceed to
setError and setUploading as before and still call onUpload for valid files.
Ensure you reference the input element (via ref or e.target.value = '') so the
same filename can be re-selected, and keep using setFile, setError,
setUploading, and onUpload in the remaining logic.

In `@components/compliance/limits-display.tsx`:
- Around line 93-97: The progress-bar width uses the raw percent value which can
be NaN, negative or >100; in the component (limits-display.tsx) sanitize and
clamp the percent before using it in the inline style: compute a safePercent =
Number.isFinite(percent) ? Math.max(0, Math.min(100, percent)) : 0 (or similar)
and use safePercent for the style width and any displayed labels; update any
references to percent inside the render (the div with style={{ width: `${...}%`
}}) to use this clamped value to avoid invalid CSS and misleading visuals.

In `@components/compliance/terms-dialog.tsx`:
- Around line 3-75: The checkbox is disabled when the terms container never
scrolls (short content), so update TermsDialog to detect non-scrollable content
and mark scrolled=true; add a ref to the scrollable div (used by handleScroll),
then in a useEffect that runs when terms changes (or on mount) measure
ref.current.scrollHeight <= ref.current.clientHeight and if true call
setScrolled(true) (otherwise leave scrolled false so handleScroll still works);
ensure the Checkbox's disabled prop still uses the scrolled state and keep
existing handleScroll logic intact.

In `@components/compliance/tier-upgrade-dialog.tsx`:
- Around line 65-66: The dialog keeps internal state (step, requestId,
uploadedDocs) when closed which causes stale data on reopen; update the
component that renders <Dialog open={open} onOpenChange={onOpenChange}> to reset
those state variables whenever open becomes false — either by adding a useEffect
watching the open prop and clearing step/requestId/uploadedDocs when open ===
false, or by handling it inside the onOpenChange callback (when it signals
closing) to set the states back to their initial values; reference the state
variables (step, requestId, uploadedDocs) and the <Dialog> props (open,
onOpenChange) when making the change.

In `@hooks/use-withdrawal.ts`:
- Around line 18-19: Replace the hard-coded query key array used in the
onSuccess handler of the withdrawal mutation with the centralized query-key
helper: import complianceKeys from "@/lib/query/query-keys" and call
queryClient.invalidateQueries using complianceKeys.all instead of
['compliance']; update the onSuccess callback (where
queryClient.invalidateQueries is invoked) to reference complianceKeys.all to
keep query-key usage consistent.

In `@lib/services/appeal.ts`:
- Around line 7-28: Before creating a new appeal in submitAppeal, check
MOCK_APPEALS for an existing appeal with the same verificationRequestId (and
userId if appeals should be per-user) and either return that existing
VerificationAppeal or throw a meaningful error to prevent duplicates; only
generate a new appealId, save to MOCK_APPEALS and call
EmailService.sendAppealConfirmation when no duplicate is found. Also ensure any
helper getAppeal logic returns a deterministic match (e.g., by id or by the same
verificationRequestId/userId pair) so retrieval is unambiguous.

In `@lib/services/compliance.ts`:
- Around line 347-348: The forEach + delete pattern in static clearAllData()
(operating on MOCK_COMPLIANCE_DB) triggers Biome’s useIterableCallbackReturn
lint rule; replace the Object.keys(...).forEach(...) callback with a simple
for...of loop over Object.keys(MOCK_COMPLIANCE_DB) (or use a normal for loop)
and perform delete MOCK_COMPLIANCE_DB[key] inside that block so the delete is
not executed from a callback expression.

In `@lib/services/email.ts`:
- Around line 73-124: The buildVerificationEmail function can emit "undefined"
for PENDING/APPROVED/REJECTED because tier may be missing; update
buildVerificationEmail to guard tier usage (e.g., compute a safeTier = tier ??
'requested' or 'selected tier' before building templates) and use safeTier
everywhere "${tier}" currently appears, or alternatively branch to a non-tier
template when tier is absent; ensure the templates object (used for statuses
PENDING, APPROVED, REJECTED) references that safe variable so emails never
render "undefined".

In `@lib/services/terms.ts`:
- Around line 251-253: The forEach callback in static clearAllData() currently
uses a concise arrow that returns the boolean result of the delete expression;
change the callback to a block body that performs the deletion without returning
a value (e.g., replace key => delete MOCK_TERMS_ACCEPTANCES[key] with key => {
delete MOCK_TERMS_ACCEPTANCES[key]; } or use an explicit for..of loop) so the
callback returns void and the Biome lint error is resolved while still clearing
MOCK_TERMS_ACCEPTANCES and resetting MOCK_TERMS_VERSIONS to length 1.

In `@lib/services/verification.ts`:
- Around line 164-170: notifyStatusChange currently looks up a request by
userId+status and can pick an old request; change the call site in this block to
pass the specific request (or request id) returned/used by
ComplianceService.updateVerificationStatus instead of only userId and status so
notifyStatusChange can send tier/reason from the exact request; update
notifyStatusChange signature and its callers (including the other occurrence at
lines ~227-240) to accept the request or requestId and use that directly when
composing emails.
- Around line 300-303: The Biome lint error is caused by using delete as the
expression in the forEach callback inside the static method clearAllData;
replace the forEach calls over Object.keys(MOCK_VERIFICATION_REQUESTS) and
Object.keys(MOCK_DOCUMENTS) with explicit for...of loops (e.g., for (const key
of Object.keys(MOCK_VERIFICATION_REQUESTS)) { delete
MOCK_VERIFICATION_REQUESTS[key]; }) or wrap the delete in a block-bodied arrow
callback to avoid returning the delete expression, ensuring
MOCK_VERIFICATION_REQUESTS and MOCK_DOCUMENTS are cleared without triggering the
lint rule.

In `@lib/services/withdrawal.ts`:
- Around line 18-78: The validation currently only checks amount >
mockWalletWithAssets.balance and submit blindly subtracts a fixed fee (fee and
netAmount), which allows amount to equal balance or be non-positive and produce
negative net amounts; update validate(userId, amount, ip) to reject non-positive
amounts (amount <= 0) and to check fee-inclusive balance by ensuring amount +
fee <= mockWalletWithAssets.balance (compute or reference the same fee used in
submit), setting result.valid=false and appropriate errors/blockers (e.g.,
'Invalid amount' and 'Insufficient balance for fees'); also ensure
submit(userId, amount, ...) revalidates or recalculates fee and netAmount safely
(throw if netAmount < 0) and use the same fee constant to avoid inconsistencies
between validate and submit.
🧹 Nitpick comments (3)
lib/services/geo-restriction.ts (1)

11-13: Remove or use VPN_INDICATORS to avoid dead code.

It’s currently unused; consider wiring it into detectVPN/detectProxy or removing it.

hooks/use-compliance.ts (1)

13-40: Centralize query keys to prevent drift.
Prefer the shared key factory so queries and invalidations stay in sync across the app.

♻️ Suggested refactor
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { complianceKeys } from '@/lib/query/query-keys';
@@
-        queryKey: ['compliance', 'status'],
+        queryKey: complianceKeys.status(),
@@
-            queryClient.invalidateQueries({ queryKey: ['compliance'] });
+            queryClient.invalidateQueries({ queryKey: complianceKeys.all });
@@
-            queryClient.invalidateQueries({ queryKey: ['compliance'] });
+            queryClient.invalidateQueries({ queryKey: complianceKeys.all });
lib/services/compliance.ts (1)

87-96: Avoid leaking mutable nested state from getters.

getUserCompliance/getTierConfig return shallow copies, so callers can mutate limits, usage, or requirements and accidentally alter the in-memory store/config. Consider cloning nested objects/arrays before returning.

♻️ Suggested defensive copy
-        return { ...MOCK_COMPLIANCE_DB[userId] };
+        const compliance = MOCK_COMPLIANCE_DB[userId];
+        return {
+            ...compliance,
+            limits: { ...compliance.limits },
+            usage: { ...compliance.usage },
+        };
...
-    static getTierConfig(tier: KYCTier): KYCTierConfig {
-        return { ...this.TIER_CONFIGS[tier] };
-    }
+    static getTierConfig(tier: KYCTier): KYCTierConfig {
+        const config = this.TIER_CONFIGS[tier];
+        return {
+            ...config,
+            limits: { ...config.limits },
+            requirements: [...config.requirements],
+        };
+    }

Also applies to: 312-314

Comment on lines +21 to +27
const { termsVersionId } = await request.json();
const ip = request.headers.get('x-forwarded-for') || '0.0.0.0';
const userAgent = request.headers.get('user-agent') || 'unknown';

const acceptance = await TermsService.acceptTerms(user.id, termsVersionId, {
ipAddress: ip,
userAgent,
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's locate and examine the file
find . -name "route.ts" -path "*/compliance/terms/*" 2>/dev/null | head -5

Repository: boundlessfi/bounties

Length of output: 99


🏁 Script executed:

# Get the file structure to understand the project layout
ls -la app/api/compliance/terms/ 2>/dev/null || echo "Path not found, searching for the file..."
git ls-files | grep -i "compliance.*terms.*route"

Repository: boundlessfi/bounties

Length of output: 266


🏁 Script executed:

# Search for the specific file more broadly
find . -type f -name "*.ts" | grep -i "compliance" | head -10

Repository: boundlessfi/bounties

Length of output: 251


🏁 Script executed:

# Read the file to see the actual code
cat -n ./app/api/compliance/terms/route.ts

Repository: boundlessfi/bounties

Length of output: 1549


🏁 Script executed:

# Search for other x-forwarded-for usage in the codebase
rg "x-forwarded-for" -i -A 2 -B 2

Repository: boundlessfi/bounties

Length of output: 1216


🏁 Script executed:

# Search for general IP header handling patterns
rg "request\.headers" -A 1 -B 1 | head -50

Repository: boundlessfi/bounties

Length of output: 894


🏁 Script executed:

# Check for middleware or utility functions that might handle x-forwarded-for
rg "x-forwarded-for|getClientIp|parseForwarded|ipAddress" -i --type ts --type js | grep -v node_modules | head -30

Repository: boundlessfi/bounties

Length of output: 600


🏁 Script executed:

# Look at the package.json to understand the framework version
cat package.json | grep -A 5 -B 5 "next"

Repository: boundlessfi/bounties

Length of output: 1032


🏁 Script executed:

# Search for any IP utility or helper functions
find . -type f \( -name "*ip*" -o -name "*address*" -o -name "*header*" \) -name "*.ts" 2>/dev/null | grep -E "(lib|utils|helpers)" | head -10

Repository: boundlessfi/bounties

Length of output: 46


🏁 Script executed:

# Check if there's any middleware or configuration that validates x-forwarded-for
rg "x-forwarded-for|trusted.*proxy|proxy.*config" -i --type ts --type js | grep -v node_modules

Repository: boundlessfi/bounties

Length of output: 385


🏁 Script executed:

# Look at next.config or any server configuration
find . -maxdepth 2 -name "next.config*" -o -name "middleware.ts" -o -name "*.config.ts" 2>/dev/null

Repository: boundlessfi/bounties

Length of output: 80


🏁 Script executed:

# Check the services to understand how ipAddress is used in audit context
cat -n lib/services/terms.ts | grep -A 10 -B 10 "ipAddress"

Repository: boundlessfi/bounties

Length of output: 1620


🏁 Script executed:

# Check next.config.ts to see if there's any proxy trust configuration
cat -n ./next.config.ts | head -40

Repository: boundlessfi/bounties

Length of output: 245


🏁 Script executed:

# Get a count of how many endpoints have this issue
rg "request\.headers\.get\('x-forwarded-for'\)" --count-matches

Repository: boundlessfi/bounties

Length of output: 175


Harden IP capture from x-forwarded-for across multiple endpoints.

The entire header is stored without parsing the first hop, which may contain multiple comma-separated IPs or spoofed values. This affects audit trail integrity in three endpoints: compliance/terms, withdrawal/validate, and withdrawal/submit. Parse the first hop and ensure this header is trusted by your proxy config.

🔧 Suggested adjustment
-        const ip = request.headers.get('x-forwarded-for') || '0.0.0.0';
+        const forwardedFor = request.headers.get('x-forwarded-for');
+        const ip = forwardedFor?.split(',')[0]?.trim() || '0.0.0.0';
🤖 Prompt for AI Agents
In `@app/api/compliance/terms/route.ts` around lines 21 - 27, The code is storing
the full x-forwarded-for header value in ip (from
request.headers.get('x-forwarded-for')) which may contain multiple
comma-separated IPs; update the extraction logic used before calling
TermsService.acceptTerms (and mirror the same change in the withdrawal/validate
and withdrawal/submit endpoints) to parse the header, split on commas, trim and
take the first non-empty token as the client IP (fall back to a safe default
like '0.0.0.0' if none), keep userAgent extraction unchanged, and ensure
comments note that the proxy must be configured to set/trust X-Forwarded-For.

Comment on lines +5 to +26
export async function POST(request: NextRequest) {
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const { targetTier } = await request.json();

const verificationRequest = await VerificationService.createVerificationRequest(
user.id,
targetTier
);

return NextResponse.json(verificationRequest);
} catch (error: any) {
console.error("Error creating verification request:", error);
return NextResponse.json(
{ error: error.message || "Failed to create verification request" },
{ status: 400 }
);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Validate targetTier early to return clear 400s.
If the body is missing or malformed, VerificationService may throw a generic error. An explicit guard gives cleaner client feedback.

💡 Suggested fix
-        const { targetTier } = await request.json();
+        const body = await request.json();
+        const { targetTier } = body ?? {};
+        if (!targetTier) {
+            return NextResponse.json({ error: "targetTier is required" }, { status: 400 });
+        }
🤖 Prompt for AI Agents
In `@app/api/compliance/upgrade/route.ts` around lines 5 - 26, The POST handler
doesn't validate the incoming body and targetTier which can lead to generic
errors; update the POST function to parse and validate request.json() early
(ensure body exists and that targetTier is present and of the expected
type/value) before calling VerificationService.createVerificationRequest, and
return a clear NextResponse.json({ error: "Invalid request: targetTier is
required" }, { status: 400 }) for bad input; keep the existing getCurrentUser
check and error catch but perform this guard right after parsing the body to
provide explicit 400 responses.

Comment on lines +12 to +21
const { amount, currency, destinationId } = await request.json();
const ip = request.headers.get('x-forwarded-for') || '0.0.0.0';

const withdrawal = await WithdrawalService.submit(
user.id,
amount,
currency,
destinationId,
ip
);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Validate submission payload and normalize client IP.

amount, currency, and destinationId are unvalidated; malformed payloads can reach the service. Also, normalize x-forwarded-for before geo checks.

🔧 Proposed fix (payload validation + IP normalization)
-        const { amount, currency, destinationId } = await request.json();
-        const ip = request.headers.get('x-forwarded-for') || '0.0.0.0';
+        const { amount, currency, destinationId } = await request.json();
+        if (typeof amount !== "number" || !Number.isFinite(amount) || amount <= 0) {
+            return NextResponse.json({ error: "Invalid amount" }, { status: 400 });
+        }
+        if (typeof currency !== "string" || !currency.trim()) {
+            return NextResponse.json({ error: "Invalid currency" }, { status: 400 });
+        }
+        if (typeof destinationId !== "string" || !destinationId.trim()) {
+            return NextResponse.json({ error: "Invalid destination" }, { status: 400 });
+        }
+        const forwardedFor = request.headers.get("x-forwarded-for");
+        const ip = forwardedFor?.split(",")[0]?.trim() || "0.0.0.0";
📝 Committable suggestion

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

Suggested change
const { amount, currency, destinationId } = await request.json();
const ip = request.headers.get('x-forwarded-for') || '0.0.0.0';
const withdrawal = await WithdrawalService.submit(
user.id,
amount,
currency,
destinationId,
ip
);
const { amount, currency, destinationId } = await request.json();
if (typeof amount !== "number" || !Number.isFinite(amount) || amount <= 0) {
return NextResponse.json({ error: "Invalid amount" }, { status: 400 });
}
if (typeof currency !== "string" || !currency.trim()) {
return NextResponse.json({ error: "Invalid currency" }, { status: 400 });
}
if (typeof destinationId !== "string" || !destinationId.trim()) {
return NextResponse.json({ error: "Invalid destination" }, { status: 400 });
}
const forwardedFor = request.headers.get("x-forwarded-for");
const ip = forwardedFor?.split(",")[0]?.trim() || "0.0.0.0";
const withdrawal = await WithdrawalService.submit(
user.id,
amount,
currency,
destinationId,
ip
);
🤖 Prompt for AI Agents
In `@app/api/withdrawal/submit/route.ts` around lines 12 - 21, Validate and
sanitize the incoming payload before calling WithdrawalService.submit: parse and
assert that amount is a positive number (e.g., Number(amount) and > 0), that
currency matches your allowed ISO/currency regex or whitelist, and that
destinationId is present and a valid ID format; if validation fails, return a
4xx response. Normalize the client IP from
request.headers.get('x-forwarded-for') by splitting on commas, taking the first
non-empty value and trimming it (fallback to '0.0.0.0' only if none), and pass
that normalized ip into WithdrawalService.submit. Ensure you perform these
checks where request.json() is consumed and only call
WithdrawalService.submit(user.id, amount, currency, destinationId, ip) with the
validated/normalized values.

Comment on lines +12 to +15
const { amount } = await request.json();
const ip = request.headers.get('x-forwarded-for') || '0.0.0.0';

const validation = await WithdrawalService.validate(user.id, amount, ip);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Validate request body and normalize client IP before checks.

amount is unvalidated and x-forwarded-for can contain a comma‑separated list (or be spoofed). This can lead to NaN/invalid amounts passing through or incorrect geo checks.

🔧 Proposed fix (validation + IP normalization)
-        const { amount } = await request.json();
-        const ip = request.headers.get('x-forwarded-for') || '0.0.0.0';
+        const { amount } = await request.json();
+        if (typeof amount !== "number" || !Number.isFinite(amount) || amount <= 0) {
+            return NextResponse.json({ error: "Invalid amount" }, { status: 400 });
+        }
+        const forwardedFor = request.headers.get("x-forwarded-for");
+        const ip = forwardedFor?.split(",")[0]?.trim() || "0.0.0.0";
🤖 Prompt for AI Agents
In `@app/api/withdrawal/validate/route.ts` around lines 12 - 15, The handler
currently reads amount from request.json() and uses
request.headers.get('x-forwarded-for') directly before calling
WithdrawalService.validate(user.id, amount, ip); validate and normalize these
first: parse and validate amount (ensure it exists, is numeric, finite, positive
and within allowed bounds) and coerce it into the expected numeric type before
passing to WithdrawalService.validate, and normalize the client IP by taking the
first entry of the x-forwarded-for header (split on commas, trim) falling back
to request.connection/remoteAddress or '0.0.0.0' if absent; reject the request
early with a 400/appropriate error when validation fails rather than forwarding
invalid amount or malformed IP to WithdrawalService.validate.

Comment on lines +28 to +48
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (!selectedFile) return;

if (selectedFile.size > 10 * 1024 * 1024) {
setError("File too large. Max 10MB.");
return;
}

setFile(selectedFile);
setError(null);

setUploading(true);
try {
await onUpload(selectedFile);
} catch (err: any) {
setError(err.message || "Upload failed");
} finally {
setUploading(false);
}
};
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Clear invalid selections so re-uploads work reliably.
If a file is too large, the input value remains set, so re-selecting the same file won’t trigger onChange. Clearing the input (and file state) avoids that trap.

🛠️ Suggested fix
-    const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
-        const selectedFile = e.target.files?.[0];
+    const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
+        const input = e.currentTarget;
+        const selectedFile = input.files?.[0];
         if (!selectedFile) return;
 
         if (selectedFile.size > 10 * 1024 * 1024) {
             setError("File too large. Max 10MB.");
+            setFile(null);
+            input.value = "";
             return;
         }
@@
-        } finally {
+        } finally {
             setUploading(false);
+            input.value = "";
         }
     };
📝 Committable suggestion

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

Suggested change
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (!selectedFile) return;
if (selectedFile.size > 10 * 1024 * 1024) {
setError("File too large. Max 10MB.");
return;
}
setFile(selectedFile);
setError(null);
setUploading(true);
try {
await onUpload(selectedFile);
} catch (err: any) {
setError(err.message || "Upload failed");
} finally {
setUploading(false);
}
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const input = e.currentTarget;
const selectedFile = input.files?.[0];
if (!selectedFile) return;
if (selectedFile.size > 10 * 1024 * 1024) {
setError("File too large. Max 10MB.");
setFile(null);
input.value = "";
return;
}
setFile(selectedFile);
setError(null);
setUploading(true);
try {
await onUpload(selectedFile);
} catch (err: any) {
setError(err.message || "Upload failed");
} finally {
setUploading(false);
input.value = "";
}
};
🤖 Prompt for AI Agents
In `@components/compliance/document-upload.tsx` around lines 28 - 48, The
handleFileChange handler leaves the file input value set when a selected file is
rejected (e.g., too large), preventing re-selecting the same file from
triggering onChange; update handleFileChange to clear both the component file
state (setFile) and the input element’s value when rejecting a file (e.g., on
size > 10MB) and also when an upload fails, then proceed to setError and
setUploading as before and still call onUpload for valid files. Ensure you
reference the input element (via ref or e.target.value = '') so the same
filename can be re-selected, and keep using setFile, setError, setUploading, and
onUpload in the remaining logic.

Comment on lines +73 to +124
private static buildVerificationEmail(
userEmail: string,
status: VerificationStatus,
tier?: string,
reason?: string
): EmailData {
const templates: Record<VerificationStatus, { subject: string; body: string }> = {
PENDING: {
subject: "Verification Request Received",
body: `
<h2>Verification Request Received</h2>
<p>We've received your request to upgrade to ${tier} tier.</p>
<p>Our team will review your documents within 2-5 business days.</p>
<p>You'll receive an email once the review is complete.</p>
`,
},
APPROVED: {
subject: "Verification Approved!",
body: `
<h2>Congratulations!</h2>
<p>Your verification for ${tier} tier has been approved.</p>
<p>Your new withdrawal limits are now active.</p>
<p><a href="${process.env.NEXT_PUBLIC_APP_URL}/wallet">View Your Limits</a></p>
`,
},
REJECTED: {
subject: "Verification Update Required",
body: `
<h2>Additional Information Needed</h2>
<p>We couldn't complete your verification for ${tier} tier.</p>
${reason ? `<p><strong>Reason:</strong> ${reason}</p>` : ''}
<p>You can submit an appeal or resubmit your documents.</p>
<p><a href="${process.env.NEXT_PUBLIC_APP_URL}/wallet">Take Action</a></p>
`,
},
NOT_STARTED: {
subject: "Start Your Verification",
body: `
<h2>Increase Your Withdrawal Limits</h2>
<p>Complete verification to increase your withdrawal limits.</p>
<p><a href="${process.env.NEXT_PUBLIC_APP_URL}/wallet">Get Started</a></p>
`,
},
};

const template = templates[status];
return {
to: userEmail,
subject: template.subject,
body: template.body,
};
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Ensure tier is present for tier-specific templates.
For PENDING/APPROVED/REJECTED, ${tier} can render as “undefined”. A small guard avoids sending broken copy.

🔧 Suggested fix
     private static buildVerificationEmail(
         userEmail: string,
         status: VerificationStatus,
         tier?: string,
         reason?: string
     ): EmailData {
+        if ((status === 'PENDING' || status === 'APPROVED' || status === 'REJECTED') && !tier) {
+            throw new Error("tier is required for verification emails");
+        }
         const templates: Record<VerificationStatus, { subject: string; body: string }> = {
📝 Committable suggestion

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

Suggested change
private static buildVerificationEmail(
userEmail: string,
status: VerificationStatus,
tier?: string,
reason?: string
): EmailData {
const templates: Record<VerificationStatus, { subject: string; body: string }> = {
PENDING: {
subject: "Verification Request Received",
body: `
<h2>Verification Request Received</h2>
<p>We've received your request to upgrade to ${tier} tier.</p>
<p>Our team will review your documents within 2-5 business days.</p>
<p>You'll receive an email once the review is complete.</p>
`,
},
APPROVED: {
subject: "Verification Approved!",
body: `
<h2>Congratulations!</h2>
<p>Your verification for ${tier} tier has been approved.</p>
<p>Your new withdrawal limits are now active.</p>
<p><a href="${process.env.NEXT_PUBLIC_APP_URL}/wallet">View Your Limits</a></p>
`,
},
REJECTED: {
subject: "Verification Update Required",
body: `
<h2>Additional Information Needed</h2>
<p>We couldn't complete your verification for ${tier} tier.</p>
${reason ? `<p><strong>Reason:</strong> ${reason}</p>` : ''}
<p>You can submit an appeal or resubmit your documents.</p>
<p><a href="${process.env.NEXT_PUBLIC_APP_URL}/wallet">Take Action</a></p>
`,
},
NOT_STARTED: {
subject: "Start Your Verification",
body: `
<h2>Increase Your Withdrawal Limits</h2>
<p>Complete verification to increase your withdrawal limits.</p>
<p><a href="${process.env.NEXT_PUBLIC_APP_URL}/wallet">Get Started</a></p>
`,
},
};
const template = templates[status];
return {
to: userEmail,
subject: template.subject,
body: template.body,
};
}
private static buildVerificationEmail(
userEmail: string,
status: VerificationStatus,
tier?: string,
reason?: string
): EmailData {
if ((status === 'PENDING' || status === 'APPROVED' || status === 'REJECTED') && !tier) {
throw new Error("tier is required for verification emails");
}
const templates: Record<VerificationStatus, { subject: string; body: string }> = {
PENDING: {
subject: "Verification Request Received",
body: `
<h2>Verification Request Received</h2>
<p>We've received your request to upgrade to ${tier} tier.</p>
<p>Our team will review your documents within 2-5 business days.</p>
<p>You'll receive an email once the review is complete.</p>
`,
},
APPROVED: {
subject: "Verification Approved!",
body: `
<h2>Congratulations!</h2>
<p>Your verification for ${tier} tier has been approved.</p>
<p>Your new withdrawal limits are now active.</p>
<p><a href="${process.env.NEXT_PUBLIC_APP_URL}/wallet">View Your Limits</a></p>
`,
},
REJECTED: {
subject: "Verification Update Required",
body: `
<h2>Additional Information Needed</h2>
<p>We couldn't complete your verification for ${tier} tier.</p>
${reason ? `<p><strong>Reason:</strong> ${reason}</p>` : ''}
<p>You can submit an appeal or resubmit your documents.</p>
<p><a href="${process.env.NEXT_PUBLIC_APP_URL}/wallet">Take Action</a></p>
`,
},
NOT_STARTED: {
subject: "Start Your Verification",
body: `
<h2>Increase Your Withdrawal Limits</h2>
<p>Complete verification to increase your withdrawal limits.</p>
<p><a href="${process.env.NEXT_PUBLIC_APP_URL}/wallet">Get Started</a></p>
`,
},
};
const template = templates[status];
return {
to: userEmail,
subject: template.subject,
body: template.body,
};
}
🤖 Prompt for AI Agents
In `@lib/services/email.ts` around lines 73 - 124, The buildVerificationEmail
function can emit "undefined" for PENDING/APPROVED/REJECTED because tier may be
missing; update buildVerificationEmail to guard tier usage (e.g., compute a
safeTier = tier ?? 'requested' or 'selected tier' before building templates) and
use safeTier everywhere "${tier}" currently appears, or alternatively branch to
a non-tier template when tier is absent; ensure the templates object (used for
statuses PENDING, APPROVED, REJECTED) references that safe variable so emails
never render "undefined".

Comment on lines +251 to +253
static clearAllData(): void {
Object.keys(MOCK_TERMS_ACCEPTANCES).forEach(key => delete MOCK_TERMS_ACCEPTANCES[key]);
MOCK_TERMS_VERSIONS.length = 1; // Keep only the first version
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Fix Biome lint error in clearAllData.

forEach callback currently returns a value from delete. Use a block body to return void.

🛠️ Proposed fix
-        Object.keys(MOCK_TERMS_ACCEPTANCES).forEach(key => delete MOCK_TERMS_ACCEPTANCES[key]);
+        Object.keys(MOCK_TERMS_ACCEPTANCES).forEach(key => {
+            delete MOCK_TERMS_ACCEPTANCES[key];
+        });
📝 Committable suggestion

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

Suggested change
static clearAllData(): void {
Object.keys(MOCK_TERMS_ACCEPTANCES).forEach(key => delete MOCK_TERMS_ACCEPTANCES[key]);
MOCK_TERMS_VERSIONS.length = 1; // Keep only the first version
static clearAllData(): void {
Object.keys(MOCK_TERMS_ACCEPTANCES).forEach(key => {
delete MOCK_TERMS_ACCEPTANCES[key];
});
MOCK_TERMS_VERSIONS.length = 1; // Keep only the first version
🧰 Tools
🪛 Biome (2.3.13)

[error] 252-252: This callback passed to forEach() iterable method should not return a value.

Either remove this return or remove the returned value.

(lint/suspicious/useIterableCallbackReturn)

🤖 Prompt for AI Agents
In `@lib/services/terms.ts` around lines 251 - 253, The forEach callback in static
clearAllData() currently uses a concise arrow that returns the boolean result of
the delete expression; change the callback to a block body that performs the
deletion without returning a value (e.g., replace key => delete
MOCK_TERMS_ACCEPTANCES[key] with key => { delete MOCK_TERMS_ACCEPTANCES[key]; }
or use an explicit for..of loop) so the callback returns void and the Biome lint
error is resolved while still clearing MOCK_TERMS_ACCEPTANCES and resetting
MOCK_TERMS_VERSIONS to length 1.

Comment on lines +164 to +170
// Update user's verification status in compliance
await ComplianceService.updateVerificationStatus(request.userId, status);

// Send notification (in real implementation)
await this.notifyStatusChange(request.userId, status);

return true;
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Notify with the exact request to avoid mismatched tier/reason emails.

notifyStatusChange looks up a request by userId + status, so it can pick an older request with the same status and send the wrong tier/reason. Pass the current request (or its id) directly from updateVerificationStatus.

✅ Suggested change
-        await this.notifyStatusChange(request.userId, status);
+        await this.notifyStatusChange(request, status);
...
-    static async notifyStatusChange(
-        userId: string,
-        newStatus: VerificationStatus
-    ): Promise<void> {
-        const userEmail = `user-${userId}@example.com`;
-        const request = Object.values(MOCK_VERIFICATION_REQUESTS)
-            .find(req => req.userId === userId && req.status === newStatus);
-
-        await EmailService.sendVerificationStatusEmail(
-            userEmail,
-            newStatus,
-            request?.targetTier,
-            request?.rejectionReason
-        );
-    }
+    static async notifyStatusChange(
+        request: VerificationRequest,
+        newStatus: VerificationStatus
+    ): Promise<void> {
+        const userEmail = `user-${request.userId}@example.com`;
+        await EmailService.sendVerificationStatusEmail(
+            userEmail,
+            newStatus,
+            request.targetTier,
+            request.rejectionReason
+        );
+    }

Also applies to: 227-240

🤖 Prompt for AI Agents
In `@lib/services/verification.ts` around lines 164 - 170, notifyStatusChange
currently looks up a request by userId+status and can pick an old request;
change the call site in this block to pass the specific request (or request id)
returned/used by ComplianceService.updateVerificationStatus instead of only
userId and status so notifyStatusChange can send tier/reason from the exact
request; update notifyStatusChange signature and its callers (including the
other occurrence at lines ~227-240) to accept the request or requestId and use
that directly when composing emails.

Comment on lines +300 to +303
static clearAllData(): void {
Object.keys(MOCK_VERIFICATION_REQUESTS).forEach(key => delete MOCK_VERIFICATION_REQUESTS[key]);
Object.keys(MOCK_DOCUMENTS).forEach(key => delete MOCK_DOCUMENTS[key]);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Fix Biome lint error on forEach delete callbacks.

Biome flags the implicit return value from delete in forEach. Wrap in a block or use for...of to keep lint clean.

🧹 Suggested cleanup
-        Object.keys(MOCK_VERIFICATION_REQUESTS).forEach(key => delete MOCK_VERIFICATION_REQUESTS[key]);
-        Object.keys(MOCK_DOCUMENTS).forEach(key => delete MOCK_DOCUMENTS[key]);
+        for (const key of Object.keys(MOCK_VERIFICATION_REQUESTS)) {
+            delete MOCK_VERIFICATION_REQUESTS[key];
+        }
+        for (const key of Object.keys(MOCK_DOCUMENTS)) {
+            delete MOCK_DOCUMENTS[key];
+        }
📝 Committable suggestion

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

Suggested change
static clearAllData(): void {
Object.keys(MOCK_VERIFICATION_REQUESTS).forEach(key => delete MOCK_VERIFICATION_REQUESTS[key]);
Object.keys(MOCK_DOCUMENTS).forEach(key => delete MOCK_DOCUMENTS[key]);
}
static clearAllData(): void {
for (const key of Object.keys(MOCK_VERIFICATION_REQUESTS)) {
delete MOCK_VERIFICATION_REQUESTS[key];
}
for (const key of Object.keys(MOCK_DOCUMENTS)) {
delete MOCK_DOCUMENTS[key];
}
}
🧰 Tools
🪛 Biome (2.3.13)

[error] 301-301: This callback passed to forEach() iterable method should not return a value.

Either remove this return or remove the returned value.

(lint/suspicious/useIterableCallbackReturn)


[error] 302-302: This callback passed to forEach() iterable method should not return a value.

Either remove this return or remove the returned value.

(lint/suspicious/useIterableCallbackReturn)

🤖 Prompt for AI Agents
In `@lib/services/verification.ts` around lines 300 - 303, The Biome lint error is
caused by using delete as the expression in the forEach callback inside the
static method clearAllData; replace the forEach calls over
Object.keys(MOCK_VERIFICATION_REQUESTS) and Object.keys(MOCK_DOCUMENTS) with
explicit for...of loops (e.g., for (const key of
Object.keys(MOCK_VERIFICATION_REQUESTS)) { delete
MOCK_VERIFICATION_REQUESTS[key]; }) or wrap the delete in a block-bodied arrow
callback to avoid returning the delete expression, ensuring
MOCK_VERIFICATION_REQUESTS and MOCK_DOCUMENTS are cleared without triggering the
lint rule.

Comment on lines +18 to +78
// Check balance
if (amount > mockWalletWithAssets.balance) {
result.valid = false;
result.errors.push('Insufficient balance');
result.blockers.insufficientBalance = true;
}

// Check limits
const compliance = await ComplianceService.getUserCompliance(userId);
const limitCheck = await ComplianceService.validateWithdrawalAmount(userId, amount);

if (!limitCheck.valid) {
result.valid = false;
result.errors.push(`Exceeds ${limitCheck.exceededLimit} limit`);
result.blockers.exceedsLimit = true;
result.blockers.limitType = limitCheck.exceededLimit;
}

// Check hold state
if (compliance.holdState !== 'NONE') {
result.valid = false;
result.errors.push(`Account is ${compliance.holdState.toLowerCase()}`);
result.blockers.complianceHold = true;
}

// Check terms
const termsStatus = await TermsService.getUserTermsStatus(userId);
if (termsStatus.requiresAcceptance) {
result.valid = false;
result.errors.push('Terms must be accepted');
result.blockers.termsNotAccepted = true;
}

// Check location
const location = await GeoRestrictionService.checkLocation(ip);
if (location.isRestricted) {
result.valid = false;
result.errors.push('Withdrawals not available in your region');
result.blockers.restrictedJurisdiction = true;
}

return result;
}

static async submit(userId: string, amount: number, currency: string, destinationId: string, ip: string): Promise<WithdrawalRequest> {
const validation = await this.validate(userId, amount, ip);
if (!validation.valid) {
throw new Error(validation.errors[0] || 'Withdrawal validation failed');
}

const compliance = await ComplianceService.getUserCompliance(userId);

const withdrawal: WithdrawalRequest = {
id: `wd-${Date.now()}`,
userId,
amount,
currency,
destinationId,
fee: 2.50,
netAmount: amount - 2.50,
status: 'PENDING',
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Validate positive amounts and fee-inclusive balance to avoid negative net payouts.

validate checks only amount > balance, and submit subtracts a fixed fee. This allows amount === balance (or small amounts) to pass but produce negative netAmount. Please reject non-positive amounts and ensure amount + fee <= balance.

🛠️ Suggested fix
+ const WITHDRAWAL_FEE = 2.5;
...
- // Check balance
- if (amount > mockWalletWithAssets.balance) {
+ // Check amount
+ if (amount <= 0) {
+     result.valid = false;
+     result.errors.push('Amount must be greater than zero');
+ }
+
+ // Check balance (including fee)
+ if (amount + WITHDRAWAL_FEE > mockWalletWithAssets.balance) {
      result.valid = false;
-     result.errors.push('Insufficient balance');
+     result.errors.push('Insufficient balance');
      result.blockers.insufficientBalance = true;
  }
...
- fee: 2.50,
- netAmount: amount - 2.50,
+ fee: WITHDRAWAL_FEE,
+ netAmount: amount - WITHDRAWAL_FEE,
🤖 Prompt for AI Agents
In `@lib/services/withdrawal.ts` around lines 18 - 78, The validation currently
only checks amount > mockWalletWithAssets.balance and submit blindly subtracts a
fixed fee (fee and netAmount), which allows amount to equal balance or be
non-positive and produce negative net amounts; update validate(userId, amount,
ip) to reject non-positive amounts (amount <= 0) and to check fee-inclusive
balance by ensuring amount + fee <= mockWalletWithAssets.balance (compute or
reference the same fee used in submit), setting result.valid=false and
appropriate errors/blockers (e.g., 'Invalid amount' and 'Insufficient balance
for fees'); also ensure submit(userId, amount, ...) revalidates or recalculates
fee and netAmount safely (throw if netAmount < 0) and use the same fee constant
to avoid inconsistencies between validate and submit.

@0xdevcollins 0xdevcollins merged commit 3584780 into boundlessfi:main Jan 31, 2026
1 of 2 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.

2 participants