-
Notifications
You must be signed in to change notification settings - Fork 19
Feat/kyc tier setup #77
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
…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
📝 WalkthroughWalkthroughThis 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
Sequence DiagramssequenceDiagram
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
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
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
Estimated Code Review Effort🎯 4 (Complex) | ⏱️ ~65 minutes Possibly Related Issues
Possibly Related PRs
Poem
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this 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 | 🟡 MinorGuard
handleWithdrawwith the same conditions as the UI.
Relying only on a disabled button leaves a programmatic bypass; reusingcanWithdrawkeeps 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 useVPN_INDICATORSto avoid dead code.It’s currently unused; consider wiring it into
detectVPN/detectProxyor 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/getTierConfigreturn shallow copies, so callers can mutatelimits,usage, orrequirementsand 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
| 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, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# First, let's locate and examine the file
find . -name "route.ts" -path "*/compliance/terms/*" 2>/dev/null | head -5Repository: 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 -10Repository: boundlessfi/bounties
Length of output: 251
🏁 Script executed:
# Read the file to see the actual code
cat -n ./app/api/compliance/terms/route.tsRepository: 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 2Repository: boundlessfi/bounties
Length of output: 1216
🏁 Script executed:
# Search for general IP header handling patterns
rg "request\.headers" -A 1 -B 1 | head -50Repository: 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 -30Repository: 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 -10Repository: 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_modulesRepository: 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/nullRepository: 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 -40Repository: 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-matchesRepository: 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.
| 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 } | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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 | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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.
| 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); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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); | ||
| } | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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.
| 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, | ||
| }; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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".
| 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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.
| // 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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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]); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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.
| // 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', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
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
API Routes
Compliance
GET /api/compliance/status- Fetch user's compliance status, remaining limits, terms status, and next available tierGET /api/compliance/terms- Get current terms versionPOST /api/compliance/terms- Accept terms and conditionsGET /api/compliance/upgrade- Get verification statusPOST /api/compliance/upgrade- Request tier upgradeWithdrawals
POST /api/withdrawal/validate- Validate withdrawal amount against limits and compliance rulesPOST /api/withdrawal/submit- Submit withdrawal request after validationComponents
Compliance Components
Hooks
Services
Backend Services
Types
UI Flow
Terms Acceptance Flow
Tier Upgrade Flow
Testing
Manual Testing Checklist
Edge Cases Tested
Screenshots
Wallet interface
Tier Upgrade Dialog
Limits Display
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
Summary by CodeRabbit
✏️ Tip: You can customize this high-level summary in your review settings.