Skip to content

Prod release 130#1682

Closed
jjramirezn wants to merge 120 commits intomainfrom
peanut-wallet-dev
Closed

Prod release 130#1682
jjramirezn wants to merge 120 commits intomainfrom
peanut-wallet-dev

Conversation

@jjramirezn
Copy link
Contributor

No description provided.

Hugo0 and others added 30 commits January 29, 2026 14:48
Implements complete Card Pioneers reservation UI with:
- 5-step purchase flow (Info → Details → Geo → Purchase → Success)
- Tier-based pricing display ($10 standard, $9 tier 2 with $1 discount)
- Enhanced Points screen with pending perk claims
- Hold-to-claim interaction with haptic feedback
- Referral graph visible to all users

## Testing Infrastructure
- Playwright E2E tests (6 test suites, 14 scenarios)
- Testing philosophy documentation
- CI integration with automated test runs

## Features
- URL state management with nuqs (page refresh resilient)
- React Query API integration
- Card landing page
- Home carousel CTA for eligible users
- Auth gating on protected routes

## Testing
- 6 E2E test files with 14 scenarios
- Unit tests: 545 passing
- All tests passing

## Breaking Changes
None. Additive changes only.

Related: Backend PR peanutprotocol/peanut-api-ts#555
- Changed onContinue prop to onPurchaseComplete
- Added purchase API integration
- Added loading and error states
- Added navigation to payment page after purchase creation

Fixes TypeScript error: 'onPurchaseComplete' does not exist
FAQsPanel requires heading prop as per component interface
Convert null to undefined to match useWebSocket interface which expects string | undefined
- Fixed Promise.race bug in auth.e2e.test.ts (issue #24)
- Fixed regex that incorrectly matched /card in auth check (issue #25)
- Added proper assertions to placeholder validation test (issue #26)
- All 545 UI tests passing
Signed-off-by: Hugo Montenegro <hugo@peanut.to>
- Fix query key cache inconsistency (add userId to card-info key)
- Display purchase errors to user instead of silent console.error
- Reset claimed perk state on user change
- Fix hydration mismatch in CardPioneers (consistent SSR initial value)
- Add peanut-pioneer.png badge asset
- Add TODO comments for deferred issues (KYC string matching, slots animation)
- Add card-payment page for legacy URL support
- Add clean POST proxy for strict schema validation endpoints
- Strip payment UUID from perk reason display
- Add ?? false fallback for isEligible and hasPurchased props
- Re-enable localStorage dismissal check (was disabled for testing)
- Coerce lastClaimedPerk to boolean in PerkClaimModal
- Make recipient nullable in ParsedURL type for chargeId flows
- Re-export PendingPerk type from websocket service
Card Pioneer and protected routes redirect unauthenticated users to
/setup for onboarding, not /login or /signin. Updated tests to expect
this redirect behavior.
E2E tests were failing because they checked URL immediately after
goto() without waiting for client-side useEffect redirect to occur.
Changed to page.waitForURL() with 10s timeout.
- Add skipBackendCall option to logoutUser for when backend is down
- Extract clearLocalAuthState helper for client-side cleanup
- BackendErrorScreen now uses authContext's logoutUser instead of
  duplicating the logout logic
- Remove internal submissionError state
- Parent component already handles submission errors
- Form now only displays validation errors (required, format)
[TASK-17713] fix: remove duplicate error display in UserDetailsForm
- Warn users to only deposit from their own account
- Matches warning style used in Bridge deposit flow
…ning

[TASK-18329] feat: add third-party deposit warning to Manteca deposit details
…onfigs

[TASK-18290] fix: sentry filters all configs
…onfigs

[TASK-18290] Fix/sentry filters all configs
- fix icon name: document -> docs, remove unsafe IconName cast
- preserve levelName across token refresh via levelNameRef
- add explicit additionalRequirements type to IUserRail.metadata
- fix needsAdditionalDocs: derive from rail status, not empty requirements
- add fallback UI when requirements array is empty
…al states, format

- aggregate additionalRequirements across all bridge rails with Array.isArray guard
- don't let additional-docs view mask failed/action_required kyc states
- run prettier on KycRequiresDocuments
feat: add support for GBP(sort-code) based bridge on/off-ramps
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

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

⚠️ Outside diff range comments (4)
src/components/AddMoney/components/AddMoneyBankDetails.tsx (2)

156-160: ⚠️ Potential issue | 🟡 Minor

Account Holder Name excluded from share text for UK but shown in UI.

Line 156 excludes UK from the Account Holder Name block in generateBankDetails, but the UI (lines 260-267) renders it for any country where accountHolderName is present. If the backend returns this field for UK, the share text and UI will diverge.

If UK deposit instructions do include accountHolderName, this condition should be !isUs && !isMexico (same as before the UK addition). If they don't, the mismatch is harmless but still confusing to future readers.

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

In `@src/components/AddMoney/components/AddMoneyBankDetails.tsx` around lines 156
- 160, The share text generation in generateBankDetails currently skips Account
Holder Name for UK (uses condition if (!isUs && !isMexico && !isUk)) causing a
mismatch with the UI which displays
onrampData?.depositInstructions?.accountHolderName for any country; update the
condition in generateBankDetails to match the UI (use if (!isUs && !isMexico) or
otherwise allow accountHolderName when present) so accountHolderName from
onrampData.depositInstructions.accountHolderName is included consistently across
share text and UI and remove the special-case exclusion of isUk.

275-282: ⚠️ Potential issue | 🟠 Major

Bug: UK users see Bank Address in the UI but not in the shared text.

In generateBankDetails (line 177), Bank Address is correctly excluded for UK with !isMexico && !isUk. However, the UI rendering here only checks currentCountryDetails?.id !== 'MX', so UK users will still see a Bank Address row — likely showing irrelevant or empty data for Faster Payments.

🐛 Proposed fix: exclude UK from the Bank Address row
-                    {currentCountryDetails?.id !== 'MX' && (
+                    {currentCountryDetails?.id !== 'MX' && !isUk && (
                         <PaymentInfoRow
                             label={'Bank Address'}
                             value={onrampData?.depositInstructions?.bankAddress || 'Loading...'}
                             allowCopy={!!onrampData?.depositInstructions?.bankAddress}
                             hideBottomBorder
                         />
                     )}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/AddMoney/components/AddMoneyBankDetails.tsx` around lines 275
- 282, The UI shows the Bank Address row for UK users because the render
condition only excludes Mexico; update the conditional around the PaymentInfoRow
that renders "Bank Address" so it matches generateBankDetails logic (exclude
both Mexico and the UK). Specifically, modify the check using
currentCountryDetails (or the existing isUk flag if available) so the row only
renders when !isMexico && !isUk (e.g., currentCountryDetails?.id !== 'MX' &&
currentCountryDetails?.id !== 'GB' or use isUk), leaving the value, allowCopy
and hideBottomBorder props unchanged.
src/hooks/useSavedAccounts.tsx (1)

7-9: ⚠️ Potential issue | 🟡 Minor

Stale comments don't mention GB.

The JSDoc on Line 8 says "limited to bank accounts with (IBAN, US, and CLABE)" and the inline comment on Line 15 says "filter out accounts that are not IBAN, US, or CLABE" — both should be updated to include GB.

Proposed fix
 /**
- * Used to get the user's saved accounts, for now limited to bank accounts with (IBAN, US, and CLABE)
+ * Used to get the user's saved accounts, for now limited to bank accounts with (IBAN, US, CLABE, and GB)
  * NOTE: This hook can be extended to support more account types in the future based on requirements
  * `@returns` {array} An array of the user's saved bank accounts
  */
-    // filter out accounts that are not IBAN, US, or CLABE
+    // filter out accounts that are not IBAN, US, CLABE, or GB

Also applies to: 15-15

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

In `@src/hooks/useSavedAccounts.tsx` around lines 7 - 9, Update the stale JSDoc
and inline comment in the useSavedAccounts hook to include GB as a supported
bank account type: change the JSDoc that currently reads "limited to bank
accounts with (IBAN, US, and CLABE)" to mention GB as well, and update the
inline comment near the filtering logic (the comment that says "filter out
accounts that are not IBAN, US, or CLABE") to include GB so both the
documentation and the comment in the useSavedAccounts hook accurately reflect
supported account types.
src/components/AddWithdraw/DynamicBankAccountForm.tsx (1)

35-45: ⚠️ Potential issue | 🟡 Minor

Make sortCode optional in IBankAccountDetails.

It's currently required despite being UK-only. This inconsistency forces non-UK callers to provide dummy values. Other similar type definitions in the codebase (src/context/OnrampFlowContext.tsx, src/interfaces/interfaces.ts, src/app/actions/types/users.types.ts) already declare it as optional. Changing to sortCode?: string aligns with the conditional usage pattern (line 236: if (isUk && data.sortCode)) and how consumers handle the field with nullish coalescing fallbacks.

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

In `@src/components/AddWithdraw/DynamicBankAccountForm.tsx` around lines 35 - 45,
IBankAccountDetails declares sortCode as required which forces non-UK callers to
supply dummy values; update the type by making sortCode optional (change
sortCode: string to sortCode?: string) in the IBankAccountDetails definition in
DynamicBankAccountForm.tsx so it matches other declarations and the conditional
usage (see isUk/data.sortCode checks) and allows consumers to rely on nullish
fallbacks.
🧹 Nitpick comments (5)
src/context/OnrampFlowContext.tsx (1)

26-29: accountNumber naming is suboptimal but doesn't create actual confusion in practice.

bankAccountNumber (line 19) and accountNumber (line 28) coexist in the same flat depositInstructions object. However, the codebase uses them in entirely separate conditional flows: bankAccountNumber is paired with bankRoutingNumber/IBAN/BIC for non-UK accounts, while accountNumber is paired with sortCode and reference specifically for UK Faster Payments (as seen in AddMoneyBankDetails.tsx lines 171–186 and 317–322).

The fields are never accessed interchangeably and the existing code structure works correctly. That said, the naming convention could be clearer. Renaming to ukAccountNumber or nesting under a ukFasterPayments object would improve maintainability and prevent future contributors from accidentally using the wrong field:

♻️ Suggested refactor (optional)
         // uk faster payments fields
         sortCode?: string
-        accountNumber?: string
+        ukAccountNumber?: string
         reference?: string

or

ukFasterPayments?: {
    sortCode?: string
    accountNumber?: string
    reference?: string
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/context/OnrampFlowContext.tsx` around lines 26 - 29, depositInstructions
contains both bankAccountNumber and accountNumber which are used in separate
flows (non-UK bankRoutingNumber/IBAN/BIC vs UK Faster Payments with sortCode and
reference), making accountNumber ambiguous; rename accountNumber to
ukAccountNumber (and update all usages in the UK Faster Payments flow such as in
AddMoneyBankDetails where sortCode/accountNumber/reference are used) or
alternatively nest the UK fields under a ukFasterPayments object
(ukFasterPayments.sortCode, ukFasterPayments.accountNumber,
ukFasterPayments.reference) and update codepaths that read
sortCode/accountNumber/reference and any types/interfaces referencing
depositInstructions accordingly to avoid breaking the non-UK bankAccountNumber
flow.
src/components/AddMoney/components/AddMoneyBankDetails.tsx (1)

132-136: Stale dependency: flow in useMemo deps is unused.

formattedCurrencyAmount depends on amount and onrampCurrency but the dependency array also includes flow, which isn't referenced in the memo body. This won't cause bugs but is misleading. Consider removing it.

Proposed fix
-    }, [amount, onrampCurrency, flow])
+    }, [amount, onrampCurrency])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/AddMoney/components/AddMoneyBankDetails.tsx` around lines 132
- 136, The useMemo creating formattedCurrencyAmount references amount and
onrampCurrency but lists flow in its dependency array even though flow isn't
used; remove flow from the dependency array for the useMemo that defines
formattedCurrencyAmount (the hook call that invokes formatCurrencyAmount) so the
deps are only [amount, onrampCurrency].
src/app/(mobile-ui)/points/page.tsx (2)

196-196: Redundant optional chaining on invites?.invitees.

Since the condition already checks invites &&, the ?. in invites?.invitees is unnecessary. Not a bug—just a minor readability nit.

-                {invites && invites?.invitees && invites.invitees.length > 0 ? (
+                {invites && invites.invitees && invites.invitees.length > 0 ? (
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(mobile-ui)/points/page.tsx at line 196, The conditional uses
redundant optional chaining: replace "invites && invites?.invitees &&
invites.invitees.length > 0" with a simpler check that uses the
already-validated object (e.g., "invites && invites.invitees &&
invites.invitees.length > 0") so remove the unnecessary "?.", updating the JSX
conditional in page.tsx where the invites and invitees array are checked.

283-287: Empty-string fallback for username could yield an invalid invite link.

When username is undefined, the fallback username ?? '' passes an empty string to InviteFriendsModal, which calls generateInviteCodeLink(''). Depending on that function's implementation, this could produce a broken invite link or QR code. Since the page requires auth and user is checked by query guards, this is unlikely to trigger in practice, but a safeguard would be cleaner.

🛡️ Suggested defensive guard
-                <InviteFriendsModal
-                    visible={isInviteModalOpen}
-                    onClose={() => setIsInviteModalOpen(false)}
-                    username={username ?? ''}
-                />
+                {username && (
+                    <InviteFriendsModal
+                        visible={isInviteModalOpen}
+                        onClose={() => setIsInviteModalOpen(false)}
+                        username={username}
+                    />
+                )}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(mobile-ui)/points/page.tsx around lines 283 - 287, The
InviteFriendsModal is being passed username={username ?? ''}, which can forward
an empty string into generateInviteCodeLink('') and produce an invalid invite;
change the call site to only render/open the modal when a valid username exists
(e.g., gate rendering on username truthiness) or pass undefined/null and update
InviteFriendsModal to reject empty values before calling generateInviteCodeLink;
locate the usage around isInviteModalOpen and InviteFriendsModal and ensure
either the modal is not shown unless username is truthy or that
InviteFriendsModal/generateInviteCodeLink guard against empty-string usernames.
src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx (1)

123-135: Consider renaming getBicAndRoutingNumber to reflect its broader scope.

This function now returns BIC, routing number, CLABE identifier, or sort code depending on account type. The current name is misleading. A name like getSecondaryAccountIdentifier or getBankRoutingInfo would better convey its purpose.

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

In `@src/app/`(mobile-ui)/withdraw/[country]/bank/page.tsx around lines 123 - 135,
The function getBicAndRoutingNumber returns different secondary account
identifiers (BIC, routingNumber, identifier, sortCode) depending on AccountType,
so rename it to a clearer name (e.g., getSecondaryAccountIdentifier or
getBankRoutingInfo) and update all references; specifically rename the function
declaration and every usage of getBicAndRoutingNumber in this file (and any
imports) to the new name, and ensure the return behavior for AccountType.IBAN,
US, CLABE, and GB remains unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/components/AddWithdraw/DynamicBankAccountForm.tsx`:
- Around line 207-216: The duplicate-check compares the raw accountNumber input
against saved identifiers but the form later pads/cleans UK account numbers into
cleanedAccountNumber before submission; to fix, move the UK-cleaning logic (the
isUk branch that strips spaces and padStart(8,'0')) to run before the duplicate
detection and use cleanedAccountNumber in the duplicate comparison (wherever the
form checks existing accounts/identifiers), leaving the countryCode logic
(country.toUpperCase()) unchanged since getCountryCodeForWithdraw already
returns ISO3 codes.

---

Outside diff comments:
In `@src/components/AddMoney/components/AddMoneyBankDetails.tsx`:
- Around line 156-160: The share text generation in generateBankDetails
currently skips Account Holder Name for UK (uses condition if (!isUs &&
!isMexico && !isUk)) causing a mismatch with the UI which displays
onrampData?.depositInstructions?.accountHolderName for any country; update the
condition in generateBankDetails to match the UI (use if (!isUs && !isMexico) or
otherwise allow accountHolderName when present) so accountHolderName from
onrampData.depositInstructions.accountHolderName is included consistently across
share text and UI and remove the special-case exclusion of isUk.
- Around line 275-282: The UI shows the Bank Address row for UK users because
the render condition only excludes Mexico; update the conditional around the
PaymentInfoRow that renders "Bank Address" so it matches generateBankDetails
logic (exclude both Mexico and the UK). Specifically, modify the check using
currentCountryDetails (or the existing isUk flag if available) so the row only
renders when !isMexico && !isUk (e.g., currentCountryDetails?.id !== 'MX' &&
currentCountryDetails?.id !== 'GB' or use isUk), leaving the value, allowCopy
and hideBottomBorder props unchanged.

In `@src/components/AddWithdraw/DynamicBankAccountForm.tsx`:
- Around line 35-45: IBankAccountDetails declares sortCode as required which
forces non-UK callers to supply dummy values; update the type by making sortCode
optional (change sortCode: string to sortCode?: string) in the
IBankAccountDetails definition in DynamicBankAccountForm.tsx so it matches other
declarations and the conditional usage (see isUk/data.sortCode checks) and
allows consumers to rely on nullish fallbacks.

In `@src/hooks/useSavedAccounts.tsx`:
- Around line 7-9: Update the stale JSDoc and inline comment in the
useSavedAccounts hook to include GB as a supported bank account type: change the
JSDoc that currently reads "limited to bank accounts with (IBAN, US, and CLABE)"
to mention GB as well, and update the inline comment near the filtering logic
(the comment that says "filter out accounts that are not IBAN, US, or CLABE") to
include GB so both the documentation and the comment in the useSavedAccounts
hook accurately reflect supported account types.

---

Nitpick comments:
In `@src/app/`(mobile-ui)/points/page.tsx:
- Line 196: The conditional uses redundant optional chaining: replace "invites
&& invites?.invitees && invites.invitees.length > 0" with a simpler check that
uses the already-validated object (e.g., "invites && invites.invitees &&
invites.invitees.length > 0") so remove the unnecessary "?.", updating the JSX
conditional in page.tsx where the invites and invitees array are checked.
- Around line 283-287: The InviteFriendsModal is being passed username={username
?? ''}, which can forward an empty string into generateInviteCodeLink('') and
produce an invalid invite; change the call site to only render/open the modal
when a valid username exists (e.g., gate rendering on username truthiness) or
pass undefined/null and update InviteFriendsModal to reject empty values before
calling generateInviteCodeLink; locate the usage around isInviteModalOpen and
InviteFriendsModal and ensure either the modal is not shown unless username is
truthy or that InviteFriendsModal/generateInviteCodeLink guard against
empty-string usernames.

In `@src/app/`(mobile-ui)/withdraw/[country]/bank/page.tsx:
- Around line 123-135: The function getBicAndRoutingNumber returns different
secondary account identifiers (BIC, routingNumber, identifier, sortCode)
depending on AccountType, so rename it to a clearer name (e.g.,
getSecondaryAccountIdentifier or getBankRoutingInfo) and update all references;
specifically rename the function declaration and every usage of
getBicAndRoutingNumber in this file (and any imports) to the new name, and
ensure the return behavior for AccountType.IBAN, US, CLABE, and GB remains
unchanged.

In `@src/components/AddMoney/components/AddMoneyBankDetails.tsx`:
- Around line 132-136: The useMemo creating formattedCurrencyAmount references
amount and onrampCurrency but lists flow in its dependency array even though
flow isn't used; remove flow from the dependency array for the useMemo that
defines formattedCurrencyAmount (the hook call that invokes
formatCurrencyAmount) so the deps are only [amount, onrampCurrency].

In `@src/context/OnrampFlowContext.tsx`:
- Around line 26-29: depositInstructions contains both bankAccountNumber and
accountNumber which are used in separate flows (non-UK
bankRoutingNumber/IBAN/BIC vs UK Faster Payments with sortCode and reference),
making accountNumber ambiguous; rename accountNumber to ukAccountNumber (and
update all usages in the UK Faster Payments flow such as in AddMoneyBankDetails
where sortCode/accountNumber/reference are used) or alternatively nest the UK
fields under a ukFasterPayments object (ukFasterPayments.sortCode,
ukFasterPayments.accountNumber, ukFasterPayments.reference) and update codepaths
that read sortCode/accountNumber/reference and any types/interfaces referencing
depositInstructions accordingly to avoid breaking the non-UK bankAccountNumber
flow.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 931a050 and 7f7c8be.

📒 Files selected for processing (17)
  • src/app/(mobile-ui)/points/page.tsx
  • src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
  • src/app/(mobile-ui)/withdraw/page.tsx
  • src/app/actions/currency.ts
  • src/app/actions/types/users.types.ts
  • src/app/api/exchange-rate/route.ts
  • src/components/AddMoney/components/AddMoneyBankDetails.tsx
  • src/components/AddMoney/consts/index.ts
  • src/components/AddWithdraw/AddWithdrawRouterView.tsx
  • src/components/AddWithdraw/DynamicBankAccountForm.tsx
  • src/components/Claim/Link/views/BankFlowManager.view.tsx
  • src/context/OnrampFlowContext.tsx
  • src/hooks/useSavedAccounts.tsx
  • src/interfaces/interfaces.ts
  • src/utils/__tests__/bridge.utils.test.ts
  • src/utils/bridge-accounts.utils.ts
  • src/utils/bridge.utils.ts

Comment on lines +207 to 216
// uk account numbers may be 6-7 digits, pad to 8 for bridge api
const cleanedAccountNumber = isUk
? accountNumber.replace(/\s/g, '').padStart(8, '0')
: accountNumber.replace(/\s/g, '')

const payload: Partial<AddBankAccountPayload> = {
accountType,
accountNumber: accountNumber.replace(/\s/g, ''),
accountNumber: cleanedAccountNumber,
countryCode: isUs ? 'USA' : country.toUpperCase(),
countryName: selectedCountry,
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Search for DynamicBankAccountForm usage to understand what values are passed as `country`
rg -n "DynamicBankAccountForm" -A 3 -B 3 src

Repository: peanutprotocol/peanut-ui

Length of output: 8722


🏁 Script executed:

# Find the DynamicBankAccountForm component file to examine it in detail
fd -t f "DynamicBankAccountForm.tsx"

Repository: peanutprotocol/peanut-ui

Length of output: 121


🏁 Script executed:

# Look for duplicate detection logic in the component
rg -n "identifier|savedAccounts" src/components/AddWithdraw/DynamicBankAccountForm.tsx -A 2 -B 2

Repository: peanutprotocol/peanut-ui

Length of output: 704


🏁 Script executed:

# Search for country code conversions, especially GB related
rg -n "GB|GBR|toUpperCase\(\)" src/components/AddWithdraw/DynamicBankAccountForm.tsx -A 1 -B 1

Repository: peanutprotocol/peanut-ui

Length of output: 1575


🏁 Script executed:

# Find getCountryCodeForWithdraw function definition
rg -n "getCountryCodeForWithdraw" -A 5 src

Repository: peanutprotocol/peanut-ui

Length of output: 4384


🏁 Script executed:

# Check BRIDGE_ALPHA3_TO_ALPHA2 mapping to see what format country codes are in
rg -n "BRIDGE_ALPHA3_TO_ALPHA2" -A 20 src

Repository: peanutprotocol/peanut-ui

Length of output: 12176


🏁 Script executed:

# Look at the complete form submission logic around line 207-216
sed -n '150,230p' src/components/AddWithdraw/DynamicBankAccountForm.tsx

Repository: peanutprotocol/peanut-ui

Length of output: 4021


🏁 Script executed:

# Check the complete getCountryCodeForWithdraw function to see what it returns
sed -n '137,160p' src/utils/withdraw.utils.ts

Repository: peanutprotocol/peanut-ui

Length of output: 898


🏁 Script executed:

# Check if BRIDGE_ALPHA3_TO_ALPHA2 includes GBR
rg -n "GBR" src/components/AddMoney/consts/index.ts

Repository: peanutprotocol/peanut-ui

Length of output: 173


🏁 Script executed:

# Look at useSavedAccounts to see what identifier format is stored
rg -n "useSavedAccounts\|identifier" -A 5 src/hooks/useSavedAccounts.tsx

Repository: peanutprotocol/peanut-ui

Length of output: 50


🏁 Script executed:

# Find useSavedAccounts hook implementation
fd -t f "useSavedAccounts" src

Repository: peanutprotocol/peanut-ui

Length of output: 98


🏁 Script executed:

# Look at what data is stored when a bank account is saved
rg -n "savedAccounts\|identifier" src/hooks/useSavedAccounts.tsx -B 3 -A 3

Repository: peanutprotocol/peanut-ui

Length of output: 50


🏁 Script executed:

# Check if there's any normalization of account numbers when saving
rg -n "addBankAccount\|identifier" src/app/actions/users.ts -B 2 -A 2

Repository: peanutprotocol/peanut-ui

Length of output: 50


🏁 Script executed:

# Read the useSavedAccounts hook file
cat src/hooks/useSavedAccounts.tsx

Repository: peanutprotocol/peanut-ui

Length of output: 1013


🏁 Script executed:

# Search for where identifier is set in bank account payloads
rg -n "identifier" src -A 2 -B 2

Repository: peanutprotocol/peanut-ui

Length of output: 50380


🏁 Script executed:

# Look at the AddBankAccountPayload type to see what fields it has
rg -n "AddBankAccountPayload\|interface.*BankAccount" -A 10 src

Repository: peanutprotocol/peanut-ui

Length of output: 50


🏁 Script executed:

# Search for where countryData is used to understand country codes better
rg -n "currentCountry\.id\|selectedCountry" src/components/AddWithdraw/AddWithdrawCountriesList.tsx -B 2 -A 2

Repository: peanutprotocol/peanut-ui

Length of output: 50


🏁 Script executed:

# Check the Account interface to see what identifier stores
rg -n "interface Account\|type Account" src -A 10 | head -50

Repository: peanutprotocol/peanut-ui

Length of output: 50


🏁 Script executed:

# Look at the exact duplicate detection logic and what savedAccounts contains
sed -n '80,180p' src/components/AddWithdraw/DynamicBankAccountForm.tsx

Repository: peanutprotocol/peanut-ui

Length of output: 4638


🏁 Script executed:

# Check if country parameter can ever be just 'GB' or is always 3-letter
rg -n "country==|country ==|country===" src/components/AddWithdraw/DynamicBankAccountForm.tsx

Repository: peanutprotocol/peanut-ui

Length of output: 50


🏁 Script executed:

# Look at what countries are actually available - check countryData structure
rg -n "id:.*GB\|id:.*GBR" src/components/AddMoney/consts/index.ts -B 2 -A 2

Repository: peanutprotocol/peanut-ui

Length of output: 50


🏁 Script executed:

# Check the complete form submission at lines 207-230 to see full context
sed -n '207,230p' src/components/AddWithdraw/DynamicBankAccountForm.tsx

Repository: peanutprotocol/peanut-ui

Length of output: 1233


🏁 Script executed:

# Verify that getCountryCodeForWithdraw always returns 3-letter codes
# and check if country can ever be 'GB'
rg -n "ALL_COUNTRIES_ALPHA3_TO_ALPHA2\|GB|GBR" src/components/AddMoney/consts/index.ts | grep -E "GB|GBR" | head -20

Repository: peanutprotocol/peanut-ui

Length of output: 173


🏁 Script executed:

# Check what countryData ids are used - specifically for UK
rg -n "id:.*'GBR'|id:.*'GB'" src/components/AddMoney/consts/index.ts -B 5 -A 5

Repository: peanutprotocol/peanut-ui

Length of output: 356


🏁 Script executed:

# Trace through to see if there's anywhere that 'GB' (2-letter) gets passed instead of 'GBR'
rg -n "'GB'\|\"GB\"" src/components/AddWithdraw -A 2 -B 2

Repository: peanutprotocol/peanut-ui

Length of output: 50


🏁 Script executed:

# Verify the complete flow - check what getCountryCodeForWithdraw returns for all inputs
sed -n '137,151p' src/utils/withdraw.utils.ts

Repository: peanutprotocol/peanut-ui

Length of output: 611


🏁 Script executed:

# Check if there's any usage where 'GB' could be passed directly instead of through getCountryCodeForWithdraw
rg -n "country=" src/components/AddWithdraw/DynamicBankAccountForm.tsx -B 3 -A 3

Repository: peanutprotocol/peanut-ui

Length of output: 50


🏁 Script executed:

# Final check - see what happens when comparing 'GB' vs 'GBR' in the isUk check
rg -n "isUk.*=\|'GB'\|'GBR'" src/components/AddWithdraw/DynamicBankAccountForm.tsx

Repository: peanutprotocol/peanut-ui

Length of output: 50


Fix duplicate detection to use cleaned account numbers; country code normalization is already correct.

The duplicate detection issue is valid: it compares raw form input against saved identifiers, but for UK accounts, the account number is cleaned and padded to 8 digits later during submission, causing duplicate detection to fail. Moving the cleaning logic before the duplicate check fixes this.

However, the country code is already normalized to ISO3 format. The country parameter always receives a 3-letter code from getCountryCodeForWithdraw(), so country.toUpperCase() already produces 'GBR' for the UK, not 'GB'. The suggested GB→GBR transformation is redundant.

🔧 Suggested fix
-                const existingAccount = savedAccounts.find(
-                    (account) => account.identifier === (data.accountNumber.toLowerCase() || data.clabe.toLowerCase())
-                )
+                const accountNumber = isMx ? data.clabe : data.accountNumber
+                const cleanedAccountNumber = isUk
+                    ? accountNumber.replace(/\s/g, '').padStart(8, '0')
+                    : accountNumber.replace(/\s/g, '')
+                const existingAccount = savedAccounts.find(
+                    (account) => account.identifier === cleanedAccountNumber.toLowerCase()
+                )
 ...
-                const accountNumber = isMx ? data.clabe : data.accountNumber
 ...
                 const payload: Partial<AddBankAccountPayload> = {
                     accountType,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/AddWithdraw/DynamicBankAccountForm.tsx` around lines 207 -
216, The duplicate-check compares the raw accountNumber input against saved
identifiers but the form later pads/cleans UK account numbers into
cleanedAccountNumber before submission; to fix, move the UK-cleaning logic (the
isUk branch that strips spaces and padStart(8,'0')) to run before the duplicate
detection and use cleanedAccountNumber in the duplicate comparison (wherever the
form checks existing accounts/identifiers), leaving the countryCode logic
(country.toUpperCase()) unchanged since getCountryCodeForWithdraw already
returns ISO3 codes.

Comment on lines +177 to +180
// UK Faster Payments: 8-digit account number (sort code stored separately)
if (/^\d{8}$/.test(accountId)) {
return 'Faster Payments (UK)'
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

8-digit heuristic may misclassify non-UK account numbers.

inferBankAccountType is documented as a heuristic for analytics/admin tools, but 8-digit all-numeric strings aren't unique to UK Faster Payments — US bank account numbers can also be 8 digits. Previously these would fall through to the generic "Bank Account" label. If you have the AccountType available at call sites, consider passing it as a hint to disambiguate, or add a comment acknowledging the ambiguity.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 8

Caution

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

⚠️ Outside diff range comments (1)
src/interfaces/interfaces.ts (1)

342-373: ⚠️ Potential issue | 🟡 Minor

Make rails optional on IUserProfile.

The API response in src/hooks/query/user.ts is cast directly to IUserProfile without transformation (line 30). If the backend omits the rails field from older API responses, TypeScript won't catch the mismatch at runtime. Downstream code already uses defensive patterns (user?.rails ?? []) across multiple components, indicating the field can be undefined. Align the type definition with actual expectations:

Fix
-    rails: IUserRail[]
+    rails?: IUserRail[]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/interfaces/interfaces.ts` around lines 342 - 373, Update the IUserProfile
declaration so the rails property is optional: change the rails field on
IUserProfile to be rails?: IUserRail[] (or rails?: IUserRail[] | null if you
prefer explicit null), leaving IUserRail unchanged; this aligns the type with
actual API responses and matches existing defensive usage (e.g., user?.rails ??
[]) across the codebase.
🧹 Nitpick comments (13)
src/components/Kyc/modals/KycProcessingModal.tsx (1)

16-17: Consider softening the “less than a minute” promise.
If KYC can take longer in practice, a less specific phrasing avoids misleading expectations.

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

In `@src/components/Kyc/modals/KycProcessingModal.tsx` around lines 16 - 17,
Update the hard promise in the KycProcessingModal component by changing the
description prop text to a softer, non-specific timeframe; locate the
description="We're reviewing your identity. This usually takes less than a
minute." in KycProcessingModal.tsx and replace it with a phrase like "We're
reviewing your identity; this may take a few minutes" or "This usually takes a
few minutes" (or remove the timeframe entirely) so the UI no longer guarantees
under-a-minute completion.
src/components/Kyc/RejectLabelsList.tsx (2)

7-13: useMemo dependency on labels is effectively a no-op.

labels is a new reference on every render (derived inline at line 8), so the useMemo at line 10 will recompute every time. Since getRejectLabelInfo is a cheap map lookup, this isn't a performance issue, but the memo is misleading. If you want actual memoization, depend on rejectLabels directly:

♻️ Suggested fix
-    const labels = rejectLabels?.length ? rejectLabels : null
-
-    const reasons = useMemo(() => {
-        if (!labels) return null
-        return labels.map((label) => getRejectLabelInfo(label))
-    }, [labels])
+    const reasons = useMemo(() => {
+        if (!rejectLabels?.length) return null
+        return rejectLabels.map((label) => getRejectLabelInfo(label))
+    }, [rejectLabels])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Kyc/RejectLabelsList.tsx` around lines 7 - 13, The useMemo in
RejectLabelsList is ineffective because labels is re-created each render; either
remove the memo or change its dependency to the original prop: compute reasons
from rejectLabels (not labels) and use useMemo(..., [rejectLabels]) if you want
memoization, or simply map rejectLabels directly without useMemo since
getRejectLabelInfo is cheap; update references to use rejectLabels in the
mapping and remove the intermediate labels variable if you switch to depending
on rejectLabels.

25-31: Prefer label string as list key instead of array index.

Using the label string itself as key is more stable and semantically correct, since reject labels are unique identifiers:

♻️ Suggested fix
-            {reasons.map((reason, i) => (
-                <InfoCard key={i} variant="info" icon="alert" title={reason.title} description={reason.description} />
+            {reasons.map((reason, idx) => (
+                <InfoCard key={rejectLabels![idx]} variant="info" icon="alert" title={reason.title} description={reason.description} />
            ))}

Alternatively, include the label in the mapped result so it's self-contained:

-        return rejectLabels.map((label) => getRejectLabelInfo(label))
+        return rejectLabels.map((label) => ({ label, ...getRejectLabelInfo(label) }))

Then use key={reason.label}.

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

In `@src/components/Kyc/RejectLabelsList.tsx` around lines 25 - 31, The map in
RejectLabelsList uses the array index as the React key which is unstable; change
the key to the unique label string by using reason.label (i.e., InfoCard key
should be reason.label) and ensure each reason object in reasons contains a
unique label property before mapping (or include the label when constructing the
mapped items) so the JSX becomes <InfoCard key={reason.label} ... /> instead of
key={i}; keep the rest of the props the same.
src/components/Profile/views/RegionsVerification.view.tsx (1)

57-60: Ref mutation during render phase.

Line 60 mutates displayRegionRef.current outside of an effect or event handler — this is a side effect during render. While this is a common pattern for keeping display data stable during modal close animations, React's concurrent/strict mode can re-invoke the render function, causing unexpected mutations.

Consider wrapping this in a useEffect or computing the display value with useMemo:

♻️ Suggested alternative using useMemo
-    const displayRegionRef = useRef<Region | null>(null)
-    if (selectedRegion) displayRegionRef.current = selectedRegion
+    const displayRegionRef = useRef<Region | null>(null)
+    const displayRegion = useMemo(() => {
+        if (selectedRegion) displayRegionRef.current = selectedRegion
+        return displayRegionRef.current
+    }, [selectedRegion])

Then use displayRegion instead of displayRegionRef.current at line 181.

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

In `@src/components/Profile/views/RegionsVerification.view.tsx` around lines 57 -
60, The render currently mutates displayRegionRef.current when selectedRegion is
truthy (selectedRegion / displayRegionRef), which is a side effect during
render; fix it by deriving the displayed region without mutating refs in
render—either (a) move the assignment into a useEffect that runs when
selectedRegion changes (useEffect(() => { displayRegionRef.current =
selectedRegion }, [selectedRegion])) or (b) compute a stable value with useMemo
(const displayRegion = useMemo(() => selectedRegion ?? displayRegionRef.current,
[selectedRegion, displayRegionRef.current])) and replace uses of
displayRegionRef.current with displayRegion (ensure the modal close animation
uses the memoized/display value).
src/components/Kyc/KycVerificationInProgressModal.tsx (1)

44-44: Unnecessary as IconName casts.

'clock' and 'check' are already members of the IconName union type (see Icon.tsx). The as IconName casts on Lines 44, 109, and 144 are redundant and can be removed.

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

In `@src/components/Kyc/KycVerificationInProgressModal.tsx` at line 44, Remove the
redundant type assertions on the icon prop in KycVerificationInProgressModal:
locate the JSX props where icon={'clock' as IconName}, icon={'check' as
IconName}, etc., and simply pass the string literals (e.g., icon='clock',
icon='check'); update the icon prop usages in the KycVerificationInProgressModal
component so the explicit "as IconName" casts are removed from all occurrences.
src/components/Kyc/states/KycFailed.tsx (2)

83-99: Duplicated terminal-rejection UI across KycFailed and KycRejectedModal.

The InfoCard + "Contact support" button pattern here (Lines 85-98) is nearly identical to the terminal branch in KycRejectedModal.tsx (Lines 50-68). Both render the same copy, same icon, and wire the same setIsSupportModalOpen(true) action.

Consider extracting a small TerminalRejectionBlock component (or similar) to keep the terminal rejection UX in one place and avoid drift between the two locations.

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

In `@src/components/Kyc/states/KycFailed.tsx` around lines 83 - 99, Extract the
duplicated terminal-rejection UI (InfoCard with variant="error", icon="lock",
the description text, and the Contact support Button that calls
setIsSupportModalOpen(true)) into a new reusable component (e.g.,
TerminalRejectionBlock) and replace the duplicated JSX in both KycFailed (the
isTerminal branch) and KycRejectedModal with <TerminalRejectionBlock
setIsSupportModalOpen={...} />; ensure the new component accepts the
setIsSupportModalOpen callback (or an onContactSupport prop) and preserves props
like className, variant, icon, description and Button props so behavior and
styling remain identical.

16-38: Props interface is inline — consider extracting a named type.

The component's props are defined as an inline object type spanning ~10 fields. Extracting a named KycFailedProps interface (similar to KycRejectedModalProps in the modal file) would improve readability and make it easier to reference from tests or parent components.

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

In `@src/components/Kyc/states/KycFailed.tsx` around lines 16 - 38, Extract the
inline props object for the KycFailed component into a named exported type or
interface (e.g., KycFailedProps) that declares rejectLabels, bridgeReason,
isSumsub, rejectType, failureCount, bridgeKycRejectedAt, countryCode, isBridge,
onRetry, and isLoading; then replace the inline type annotation on the KycFailed
function signature with KycFailedProps and export the new type so tests and
parent components can import it.
src/components/Kyc/BridgeTosStep.tsx (1)

65-82: Redundant if/else — both branches execute identical code.

Lines 75-78 and Lines 79-82 both call fetchUser() then onComplete(). The conditional is effectively a no-op. Per the retrieved learning, this is intentional resilience (the phase-transition effect handles actual validation), but the code can be simplified to make this clearer.

♻️ Suggested simplification
-                if (retry.data?.accepted) {
-                    await fetchUser()
-                    onComplete()
-                } else {
-                    // will be caught by poller/webhook eventually
-                    await fetchUser()
-                    onComplete()
-                }
+                // proceed regardless — poller/webhook will catch if bridge hasn't registered yet
+                await fetchUser()
+                onComplete()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Kyc/BridgeTosStep.tsx` around lines 65 - 82, The retry
conditional after calling confirmBridgeTos() is redundant because both branches
call fetchUser() and onComplete(); simplify BridgeTosStep by removing the
if/else and always calling await fetchUser() followed by onComplete() after the
retry (i.e., after const retry = await confirmBridgeTos()), keeping the early
return when result.data?.accepted is true and preserving the 2s delay and retry
call to confirmBridgeTos().
src/hooks/useIdentityVerification.tsx (1)

106-107: Double invocation of useUnifiedKycStatus — consider using it directly.

useKycStatus (line 106) is a thin wrapper around useUnifiedKycStatus (see src/hooks/useKycStatus.tsx). Calling both hooks means two separate useUnifiedKycStatus instances with duplicate useMemo computations. You could destructure everything from useUnifiedKycStatus directly.

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

In `@src/hooks/useIdentityVerification.tsx` around lines 106 - 107, You're calling
useUnifiedKycStatus twice via useKycStatus and directly which duplicates memo
work; replace the dual hook calls by removing useKycStatus usage and
destructuring all needed values (isUserBridgeKycApproved,
isUserMantecaKycApproved, isUserSumsubKycApproved,
sumsubVerificationRegionIntent, etc.) directly from a single
useUnifiedKycStatus() call so only one hook/memo instance runs and update any
references to the removed variables accordingly.
src/hooks/useUnifiedKycStatus.ts (1)

46-50: sumsubVerificationRegionIntent typed as string instead of KYCRegionIntent | null.

The cast on line 48 widens to string while consumers (e.g., useIdentityVerification.tsx line 168) compare it against 'LATAM'. Using KYCRegionIntent | null would provide stronger type safety and catch mismatches at compile time.

Proposed fix
+    import { type KYCRegionIntent } from '@/app/actions/types/sumsub.types'
     // region intent used during the sumsub verification (stored in metadata by initiate-kyc)
     const sumsubVerificationRegionIntent = useMemo(
-        () => (sumsubVerification?.metadata?.regionIntent as string) ?? null,
+        () => (sumsubVerification?.metadata?.regionIntent as KYCRegionIntent | undefined) ?? null,
         [sumsubVerification]
     )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useUnifiedKycStatus.ts` around lines 46 - 50,
sumsubVerificationRegionIntent is being cast to plain string, losing type
safety; change its type to KYCRegionIntent | null by replacing the cast in the
useMemo that derives sumsubVerificationRegionIntent from
sumsubVerification.metadata.regionIntent so the memo returns
(sumsubVerification?.metadata?.regionIntent as KYCRegionIntent) ?? null (or the
equivalent type guard) and update the useMemo signature so consumers like
useIdentityVerification.tsx can safely compare against 'LATAM'.
src/components/Kyc/KycStatusDrawer.tsx (1)

36-39: Casting metadata field without validation.

verification?.metadata?.regionIntent is cast directly to KYCRegionIntent | undefined. If the backend stores unexpected values, this could silently propagate invalid data. This is consistent with the pattern in useUnifiedKycStatus.ts line 48, so flagging for awareness rather than a required change.

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

In `@src/components/Kyc/KycStatusDrawer.tsx` around lines 36 - 39, The code casts
verification?.metadata?.regionIntent directly to KYCRegionIntent (const
sumsubRegionIntent) which can silently accept invalid backend values; replace
the direct cast with a validation step: check that
verification?.metadata?.regionIntent is one of the allowed KYCRegionIntent
enum/string values (or use a small type-guard function) and only set
sumsubRegionIntent when it passes, otherwise fallback to undefined; mirror the
safer pattern used in useUnifiedKycStatus by whitelisting/mapping valid region
intents before assigning.
src/constants/kyc.consts.ts (1)

54-58: Consider accepting nullable status in getKycStatusCategory.

Other helpers accept undefined | null. Allowing that here reduces guard boilerplate and avoids accidental crashes at call sites.

♻️ Suggested tweak
-export const getKycStatusCategory = (status: string): KycStatusCategory => {
-    if (APPROVED_STATUSES.has(status)) return 'completed'
+export const getKycStatusCategory = (status: string | undefined | null): KycStatusCategory => {
+    if (!status || NOT_STARTED_STATUSES.has(status)) return 'processing'
+    if (APPROVED_STATUSES.has(status)) return 'completed'
     if (FAILED_STATUSES.has(status)) return 'failed'
     if (ACTION_REQUIRED_STATUSES.has(status)) return 'action_required'
     return 'processing'
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/constants/kyc.consts.ts` around lines 54 - 58, Update
getKycStatusCategory to accept nullable inputs by changing its parameter to
status: string | null | undefined and treat null/undefined (or falsy) as the
default 'processing' case; implement an early guard (e.g., if (!status) return
'processing' or if (status == null) return 'processing') before checking
APPROVED_STATUSES, FAILED_STATUSES, and ACTION_REQUIRED_STATUSES so callers
don't need extra guards and behavior remains the same for valid status strings.
src/hooks/useSumsubKycFlow.ts (1)

35-43: Gate websocket auto-connect until username is known.

If username is undefined, some socket implementations still attempt a connection. Consider wiring autoConnect to the presence of a username to avoid noisy retries.

♻️ Suggested tweak
 useWebSocket({
     username: user?.user.username ?? undefined,
-    autoConnect: true,
+    autoConnect: Boolean(user?.user.username),
     onSumsubKycStatusUpdate: (newStatus, newRejectLabels) => {
         setLiveKycStatus(newStatus as SumsubKycStatus)
         if (newRejectLabels) setRejectLabels(newRejectLabels)
     },
 })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useSumsubKycFlow.ts` around lines 35 - 43, The websocket is
currently allowed to auto-connect even when the username is undefined; update
the useWebSocket call in useSumsubKycFlow so autoConnect is only true when a
username exists (i.e., gate autoConnect on the presence of user?.user.username)
and keep the existing username prop as-is; this ensures useWebSocket (and its
autoConnect behavior) won't attempt connections/retries until username is known.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/app/`(mobile-ui)/home/page.tsx:
- Around line 274-289: The CardPioneerModal is rendered when
hasCardPioneerPurchased is undefined because the current condition uses
(hasCardPioneerPurchased ?? false) which treats loading as “not purchased”;
update the conditional around CardPioneerModal to wait for the hook to resolve
(e.g., require hasCardPioneerPurchased !== undefined or use the hook's
isLoading/isFetched flag from useCardPioneerInfo) before rendering, and continue
to pass the resolved boolean into the CardPioneerModal's hasPurchased prop
(leave CardPioneerModal, hasCardPioneerPurchased and useCardPioneerInfo as the
referenced symbols).

In `@src/app/actions/sumsub.ts`:
- Around line 37-41: The code calls response.json() before checking response.ok,
which will throw on non-JSON error pages and lose HTTP status context; change
the logic to first check response.ok (using the existing response variable) and
only parse JSON on success, or wrap response.json() in a try/catch and fallback
to response.text() so you can return an error that includes response.status and
the raw body if JSON parsing fails; update the error return to use
response.status plus either responseJson.message/responseJson.error or the text
fallback so callers get HTTP status and body info.

In `@src/app/actions/users.ts`:
- Around line 165-205: Both getBridgeTosLink and confirmBridgeTos should guard
against a missing JWT and include the response message field as an error
fallback: first check (await getJWTCookie())?.value and if falsy return an error
like "Authentication required" rather than calling the API with Bearer
undefined; then after parsing responseJson use responseJson.message ||
responseJson.error || a default string ("Failed to fetch Bridge ToS link" /
"Failed to confirm Bridge ToS") when response.ok is false so the returned error
includes any server-provided message.

In `@src/components/Kyc/BridgeTosReminder.tsx`:
- Around line 24-27: handleComplete in BridgeTosReminder currently calls
fetchUser() after closing the step, but BridgeTosStep already awaits fetchUser()
before invoking onComplete(), causing duplicate fetches; remove the redundant
await fetchUser() from handleComplete (or alternatively remove the fetchUser()
inside BridgeTosStep) so only one fetchUser() call occurs on successful ToS
acceptance, keeping the setShowTosStep(false) and onComplete call behavior
intact and referencing the BridgeTosStep onComplete/fetchUser flow to ensure the
single remaining fetchUser() is executed in the appropriate component.

In `@src/components/Kyc/KycVerificationInProgressModal.tsx`:
- Around line 101-137: In KycVerificationInProgressModal (the block where phase
=== 'bridge_tos' using tosError for description), replace the raw tosError shown
to users with a generic user-facing message like "Something went wrong. You can
accept the terms later." and keep the real error only in logs; ensure you still
use tosError to decide CTA text/behavior (the existing ctas array and tosError ?
branches) but call the project logger or console.error to record tosError for
debugging (e.g., log tosError when truthy) rather than rendering it directly in
the description prop.

In `@src/components/Kyc/SumsubKycWrapper.tsx`:
- Around line 60-83: The code sets sdkLoaded true immediately when an existing
script element is found but that doesn't guarantee the SDK global
(window.snsWebSdk) is ready; change the existing-script branch in the useEffect
so it only sets sdkLoaded when the script has actually finished loading: if
window.snsWebSdk exists or existingScript.readyState === 'complete' (or a custom
data attribute indicating loaded), set sdkLoaded; otherwise attach onload and
onerror handlers to the existingScript (same handlers used when creating a new
script) so sdkLoaded is only set when the script's onload fires and
setSdkLoadError on error; keep the rest of the logic (script creation,
appending, and subsequent init guarded by sdkLoaded, accessToken,
sdkContainerRef) unchanged.

In `@src/hooks/useIdentityVerification.tsx`:
- Around line 162-173: The comment for isRegionUnlocked is incorrect vs. the
implemented fallback: when isSumsubApproved is true and
sumsubVerificationRegionIntent is anything other than 'LATAM' (including
null/undefined) the code currently returns BRIDGE_SUPPORTED_REGIONS plus 'Rest
of the world'. Either update the comment to state that the non-'LATAM' path
unlocks BRIDGE_SUPPORTED_REGIONS + 'Rest of the world', or change the logic by
adding an explicit branch checking for no intent (sumsubVerificationRegionIntent
== null) to return only regionName === 'Rest of the world' while keeping the
existing 'STANDARD' behavior for other non-null intents; reference the
isRegionUnlocked function, isSumsubApproved, sumsubVerificationRegionIntent,
MANTECA_SUPPORTED_REGIONS and BRIDGE_SUPPORTED_REGIONS when making the change.

In `@src/hooks/useRailStatusTracking.ts`:
- Around line 71-148: The stopTracking function currently only clears the
polling timer but doesn't close the WebSocket; update the useWebSocket call to
capture the returned socket/disconnect function (whatever useWebSocket returns)
and call that disconnect/close method inside stopTracking and in any cleanup
effect when isTracking becomes false so the socket is explicitly closed
(including if it's still connecting). Ensure references to useWebSocket,
stopTracking, startTracking, isTracking, and pollTimerRef are used to locate
where to store the disconnect handle and invoke it.

---

Outside diff comments:
In `@src/interfaces/interfaces.ts`:
- Around line 342-373: Update the IUserProfile declaration so the rails property
is optional: change the rails field on IUserProfile to be rails?: IUserRail[]
(or rails?: IUserRail[] | null if you prefer explicit null), leaving IUserRail
unchanged; this aligns the type with actual API responses and matches existing
defensive usage (e.g., user?.rails ?? []) across the codebase.

---

Nitpick comments:
In `@src/components/Kyc/BridgeTosStep.tsx`:
- Around line 65-82: The retry conditional after calling confirmBridgeTos() is
redundant because both branches call fetchUser() and onComplete(); simplify
BridgeTosStep by removing the if/else and always calling await fetchUser()
followed by onComplete() after the retry (i.e., after const retry = await
confirmBridgeTos()), keeping the early return when result.data?.accepted is true
and preserving the 2s delay and retry call to confirmBridgeTos().

In `@src/components/Kyc/KycStatusDrawer.tsx`:
- Around line 36-39: The code casts verification?.metadata?.regionIntent
directly to KYCRegionIntent (const sumsubRegionIntent) which can silently accept
invalid backend values; replace the direct cast with a validation step: check
that verification?.metadata?.regionIntent is one of the allowed KYCRegionIntent
enum/string values (or use a small type-guard function) and only set
sumsubRegionIntent when it passes, otherwise fallback to undefined; mirror the
safer pattern used in useUnifiedKycStatus by whitelisting/mapping valid region
intents before assigning.

In `@src/components/Kyc/KycVerificationInProgressModal.tsx`:
- Line 44: Remove the redundant type assertions on the icon prop in
KycVerificationInProgressModal: locate the JSX props where icon={'clock' as
IconName}, icon={'check' as IconName}, etc., and simply pass the string literals
(e.g., icon='clock', icon='check'); update the icon prop usages in the
KycVerificationInProgressModal component so the explicit "as IconName" casts are
removed from all occurrences.

In `@src/components/Kyc/modals/KycProcessingModal.tsx`:
- Around line 16-17: Update the hard promise in the KycProcessingModal component
by changing the description prop text to a softer, non-specific timeframe;
locate the description="We're reviewing your identity. This usually takes less
than a minute." in KycProcessingModal.tsx and replace it with a phrase like
"We're reviewing your identity; this may take a few minutes" or "This usually
takes a few minutes" (or remove the timeframe entirely) so the UI no longer
guarantees under-a-minute completion.

In `@src/components/Kyc/RejectLabelsList.tsx`:
- Around line 7-13: The useMemo in RejectLabelsList is ineffective because
labels is re-created each render; either remove the memo or change its
dependency to the original prop: compute reasons from rejectLabels (not labels)
and use useMemo(..., [rejectLabels]) if you want memoization, or simply map
rejectLabels directly without useMemo since getRejectLabelInfo is cheap; update
references to use rejectLabels in the mapping and remove the intermediate labels
variable if you switch to depending on rejectLabels.
- Around line 25-31: The map in RejectLabelsList uses the array index as the
React key which is unstable; change the key to the unique label string by using
reason.label (i.e., InfoCard key should be reason.label) and ensure each reason
object in reasons contains a unique label property before mapping (or include
the label when constructing the mapped items) so the JSX becomes <InfoCard
key={reason.label} ... /> instead of key={i}; keep the rest of the props the
same.

In `@src/components/Kyc/states/KycFailed.tsx`:
- Around line 83-99: Extract the duplicated terminal-rejection UI (InfoCard with
variant="error", icon="lock", the description text, and the Contact support
Button that calls setIsSupportModalOpen(true)) into a new reusable component
(e.g., TerminalRejectionBlock) and replace the duplicated JSX in both KycFailed
(the isTerminal branch) and KycRejectedModal with <TerminalRejectionBlock
setIsSupportModalOpen={...} />; ensure the new component accepts the
setIsSupportModalOpen callback (or an onContactSupport prop) and preserves props
like className, variant, icon, description and Button props so behavior and
styling remain identical.
- Around line 16-38: Extract the inline props object for the KycFailed component
into a named exported type or interface (e.g., KycFailedProps) that declares
rejectLabels, bridgeReason, isSumsub, rejectType, failureCount,
bridgeKycRejectedAt, countryCode, isBridge, onRetry, and isLoading; then replace
the inline type annotation on the KycFailed function signature with
KycFailedProps and export the new type so tests and parent components can import
it.

In `@src/components/Profile/views/RegionsVerification.view.tsx`:
- Around line 57-60: The render currently mutates displayRegionRef.current when
selectedRegion is truthy (selectedRegion / displayRegionRef), which is a side
effect during render; fix it by deriving the displayed region without mutating
refs in render—either (a) move the assignment into a useEffect that runs when
selectedRegion changes (useEffect(() => { displayRegionRef.current =
selectedRegion }, [selectedRegion])) or (b) compute a stable value with useMemo
(const displayRegion = useMemo(() => selectedRegion ?? displayRegionRef.current,
[selectedRegion, displayRegionRef.current])) and replace uses of
displayRegionRef.current with displayRegion (ensure the modal close animation
uses the memoized/display value).

In `@src/constants/kyc.consts.ts`:
- Around line 54-58: Update getKycStatusCategory to accept nullable inputs by
changing its parameter to status: string | null | undefined and treat
null/undefined (or falsy) as the default 'processing' case; implement an early
guard (e.g., if (!status) return 'processing' or if (status == null) return
'processing') before checking APPROVED_STATUSES, FAILED_STATUSES, and
ACTION_REQUIRED_STATUSES so callers don't need extra guards and behavior remains
the same for valid status strings.

In `@src/hooks/useIdentityVerification.tsx`:
- Around line 106-107: You're calling useUnifiedKycStatus twice via useKycStatus
and directly which duplicates memo work; replace the dual hook calls by removing
useKycStatus usage and destructuring all needed values (isUserBridgeKycApproved,
isUserMantecaKycApproved, isUserSumsubKycApproved,
sumsubVerificationRegionIntent, etc.) directly from a single
useUnifiedKycStatus() call so only one hook/memo instance runs and update any
references to the removed variables accordingly.

In `@src/hooks/useSumsubKycFlow.ts`:
- Around line 35-43: The websocket is currently allowed to auto-connect even
when the username is undefined; update the useWebSocket call in useSumsubKycFlow
so autoConnect is only true when a username exists (i.e., gate autoConnect on
the presence of user?.user.username) and keep the existing username prop as-is;
this ensures useWebSocket (and its autoConnect behavior) won't attempt
connections/retries until username is known.

In `@src/hooks/useUnifiedKycStatus.ts`:
- Around line 46-50: sumsubVerificationRegionIntent is being cast to plain
string, losing type safety; change its type to KYCRegionIntent | null by
replacing the cast in the useMemo that derives sumsubVerificationRegionIntent
from sumsubVerification.metadata.regionIntent so the memo returns
(sumsubVerification?.metadata?.regionIntent as KYCRegionIntent) ?? null (or the
equivalent type guard) and update the useMemo signature so consumers like
useIdentityVerification.tsx can safely compare against 'LATAM'.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7f7c8be and c214dc2.

📒 Files selected for processing (51)
  • src/app/(mobile-ui)/home/page.tsx
  • src/app/(mobile-ui)/profile/identity-verification/[region]/[country]/page.tsx
  • src/app/(mobile-ui)/profile/identity-verification/[region]/page.tsx
  • src/app/(mobile-ui)/profile/identity-verification/layout.tsx
  • src/app/(mobile-ui)/qr-pay/page.tsx
  • src/app/actions/sumsub.ts
  • src/app/actions/types/sumsub.types.ts
  • src/app/actions/users.ts
  • src/components/Global/Badges/StatusBadge.tsx
  • src/components/Global/IframeWrapper/StartVerificationView.tsx
  • src/components/Home/HomeHistory.tsx
  • src/components/Home/KycCompletedModal/index.tsx
  • src/components/IdentityVerification/StartVerificationModal.tsx
  • src/components/Kyc/BridgeTosReminder.tsx
  • src/components/Kyc/BridgeTosStep.tsx
  • src/components/Kyc/InitiateMantecaKYCModal.tsx
  • src/components/Kyc/KYCStatusDrawerItem.tsx
  • src/components/Kyc/KycFlow.tsx
  • src/components/Kyc/KycStatusDrawer.tsx
  • src/components/Kyc/KycStatusItem.tsx
  • src/components/Kyc/KycVerificationInProgressModal.tsx
  • src/components/Kyc/PeanutDoesntStoreAnyPersonalInformation.tsx
  • src/components/Kyc/RejectLabelsList.tsx
  • src/components/Kyc/SumsubKycFlow.tsx
  • src/components/Kyc/SumsubKycWrapper.tsx
  • src/components/Kyc/modals/KycActionRequiredModal.tsx
  • src/components/Kyc/modals/KycProcessingModal.tsx
  • src/components/Kyc/modals/KycRejectedModal.tsx
  • src/components/Kyc/states/KycActionRequired.tsx
  • src/components/Kyc/states/KycCompleted.tsx
  • src/components/Kyc/states/KycFailed.tsx
  • src/components/Kyc/states/KycRequiresDocuments.tsx
  • src/components/Profile/components/IdentityVerificationCountryList.tsx
  • src/components/Profile/views/IdentityVerification.view.tsx
  • src/components/Profile/views/RegionsPage.view.tsx
  • src/components/Profile/views/RegionsVerification.view.tsx
  • src/constants/bridge-requirements.consts.ts
  • src/constants/kyc.consts.ts
  • src/constants/sumsub-reject-labels.consts.ts
  • src/features/limits/views/LimitsPageView.tsx
  • src/hooks/useBridgeTosStatus.ts
  • src/hooks/useIdentityVerification.tsx
  • src/hooks/useKycStatus.tsx
  • src/hooks/useQrKycGate.ts
  • src/hooks/useRailStatusTracking.ts
  • src/hooks/useSumsubKycFlow.ts
  • src/hooks/useUnifiedKycStatus.ts
  • src/hooks/useWebSocket.ts
  • src/interfaces/interfaces.ts
  • src/services/websocket.ts
  • src/types/sumsub-websdk.d.ts
💤 Files with no reviewable changes (5)
  • src/components/Profile/views/RegionsPage.view.tsx
  • src/components/Profile/views/IdentityVerification.view.tsx
  • src/app/(mobile-ui)/profile/identity-verification/[region]/page.tsx
  • src/components/Profile/components/IdentityVerificationCountryList.tsx
  • src/app/(mobile-ui)/profile/identity-verification/[region]/[country]/page.tsx

Comment on lines +274 to +289
{/* Card Pioneer Modal - Show to all users who haven't purchased */}
{/* Eligibility check happens during the flow (geo screen), not here */}
{/* Only shows if no higher-priority modals are active */}
{!underMaintenanceConfig.disableCardPioneers &&
!showBalanceWarningModal &&
!showPermissionModal &&
!showKycModal &&
!isPostSignupActionModalVisible &&
!user?.showEarlyUserModal &&
!isPostSignupSession && (
<LazyLoadErrorBoundary>
<Suspense fallback={null}>
<CardPioneerModal hasPurchased={hasCardPioneerPurchased ?? false} />
</Suspense>
</LazyLoadErrorBoundary>
)}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

CardPioneerModal may flash for users who already purchased while hasPurchased is still loading.

useCardPioneerInfo returns hasPurchased: undefined while loading (see src/hooks/useCardPioneerInfo.ts line 29). The ?? false fallback at line 286 coerces it to false, so the modal renders immediately — then disappears once the query resolves for users who have already purchased.

Consider gating on the loading state or treating undefined as "don't render yet":

Proposed fix
-    const { hasPurchased: hasCardPioneerPurchased } = useCardPioneerInfo()
+    const { hasPurchased: hasCardPioneerPurchased, isLoading: isCardPioneerLoading } = useCardPioneerInfo()
             {!underMaintenanceConfig.disableCardPioneers &&
                 !showBalanceWarningModal &&
                 !showPermissionModal &&
                 !showKycModal &&
                 !isPostSignupActionModalVisible &&
                 !user?.showEarlyUserModal &&
-                !isPostSignupSession && (
+                !isPostSignupSession &&
+                !isCardPioneerLoading && (
                     <LazyLoadErrorBoundary>
                         <Suspense fallback={null}>
-                            <CardPioneerModal hasPurchased={hasCardPioneerPurchased ?? false} />
+                            <CardPioneerModal hasPurchased={!!hasCardPioneerPurchased} />
                         </Suspense>
                     </LazyLoadErrorBoundary>
                 )}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(mobile-ui)/home/page.tsx around lines 274 - 289, The
CardPioneerModal is rendered when hasCardPioneerPurchased is undefined because
the current condition uses (hasCardPioneerPurchased ?? false) which treats
loading as “not purchased”; update the conditional around CardPioneerModal to
wait for the hook to resolve (e.g., require hasCardPioneerPurchased !==
undefined or use the hook's isLoading/isFetched flag from useCardPioneerInfo)
before rendering, and continue to pass the resolved boolean into the
CardPioneerModal's hasPurchased prop (leave CardPioneerModal,
hasCardPioneerPurchased and useCardPioneerInfo as the referenced symbols).

Comment on lines +37 to +41
const responseJson = await response.json()

if (!response.ok) {
return { error: responseJson.message || responseJson.error || 'Failed to initiate identity verification' }
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

response.json() called before response.ok check may throw on non-JSON error responses.

If the server returns a non-JSON body (e.g., 502 HTML page), response.json() will throw, falling through to the generic catch block at line 50. This loses the HTTP status context. Consider guarding with a try/catch or checking response.ok first.

Proposed fix
-        const responseJson = await response.json()
-
         if (!response.ok) {
-            return { error: responseJson.message || responseJson.error || 'Failed to initiate identity verification' }
+            let errorMessage = 'Failed to initiate identity verification'
+            try {
+                const errorJson = await response.json()
+                errorMessage = errorJson.message || errorJson.error || errorMessage
+            } catch {
+                // non-JSON error body (e.g. 502 gateway)
+            }
+            return { error: errorMessage }
         }
 
+        const responseJson = await response.json()
+
         return {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/actions/sumsub.ts` around lines 37 - 41, The code calls
response.json() before checking response.ok, which will throw on non-JSON error
pages and lose HTTP status context; change the logic to first check response.ok
(using the existing response variable) and only parse JSON on success, or wrap
response.json() in a try/catch and fallback to response.text() so you can return
an error that includes response.status and the raw body if JSON parsing fails;
update the error return to use response.status plus either
responseJson.message/responseJson.error or the text fallback so callers get HTTP
status and body info.

Comment on lines +165 to +205
export const getBridgeTosLink = async (): Promise<{ data?: { tosLink: string }; error?: string }> => {
const jwtToken = (await getJWTCookie())?.value
try {
const response = await fetchWithSentry(`${PEANUT_API_URL}/users/bridge-tos-link`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwtToken}`,
'api-key': API_KEY,
},
})
const responseJson = await response.json()
if (!response.ok) {
return { error: responseJson.error || 'Failed to fetch Bridge ToS link' }
}
return { data: responseJson }
} catch (e: unknown) {
return { error: e instanceof Error ? e.message : 'An unexpected error occurred' }
}
}

// confirm bridge ToS acceptance after user closes the ToS iframe
export const confirmBridgeTos = async (): Promise<{ data?: { accepted: boolean }; error?: string }> => {
const jwtToken = (await getJWTCookie())?.value
try {
const response = await fetchWithSentry(`${PEANUT_API_URL}/users/bridge-tos-confirm`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwtToken}`,
'api-key': API_KEY,
},
})
const responseJson = await response.json()
if (!response.ok) {
return { error: responseJson.error || 'Failed to confirm Bridge ToS' }
}
return { data: responseJson }
} catch (e: unknown) {
return { error: e instanceof Error ? e.message : 'An unexpected error occurred' }
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Guard missing JWT and include message fallback.
Right now both actions will call the API with Bearer undefined and ignore message fields on error responses. A small guard + message fallback keeps behavior consistent and avoids confusing failures.

🛠️ Suggested fix
 export const getBridgeTosLink = async (): Promise<{ data?: { tosLink: string }; error?: string }> => {
     const jwtToken = (await getJWTCookie())?.value
+    if (!jwtToken) {
+        return { error: 'Not authenticated' }
+    }
     try {
         const response = await fetchWithSentry(`${PEANUT_API_URL}/users/bridge-tos-link`, {
             method: 'GET',
             headers: {
                 'Content-Type': 'application/json',
                 Authorization: `Bearer ${jwtToken}`,
                 'api-key': API_KEY,
             },
         })
         const responseJson = await response.json()
         if (!response.ok) {
-            return { error: responseJson.error || 'Failed to fetch Bridge ToS link' }
+            return { error: responseJson.message || responseJson.error || 'Failed to fetch Bridge ToS link' }
         }
         return { data: responseJson }
     } catch (e: unknown) {
         return { error: e instanceof Error ? e.message : 'An unexpected error occurred' }
     }
 }

 export const confirmBridgeTos = async (): Promise<{ data?: { accepted: boolean }; error?: string }> => {
     const jwtToken = (await getJWTCookie())?.value
+    if (!jwtToken) {
+        return { error: 'Not authenticated' }
+    }
     try {
         const response = await fetchWithSentry(`${PEANUT_API_URL}/users/bridge-tos-confirm`, {
             method: 'POST',
             headers: {
                 'Content-Type': 'application/json',
                 Authorization: `Bearer ${jwtToken}`,
                 'api-key': API_KEY,
             },
         })
         const responseJson = await response.json()
         if (!response.ok) {
-            return { error: responseJson.error || 'Failed to confirm Bridge ToS' }
+            return { error: responseJson.message || responseJson.error || 'Failed to confirm Bridge ToS' }
         }
         return { data: responseJson }
     } catch (e: unknown) {
         return { error: e instanceof Error ? e.message : 'An unexpected error occurred' }
     }
 }
📝 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
export const getBridgeTosLink = async (): Promise<{ data?: { tosLink: string }; error?: string }> => {
const jwtToken = (await getJWTCookie())?.value
try {
const response = await fetchWithSentry(`${PEANUT_API_URL}/users/bridge-tos-link`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwtToken}`,
'api-key': API_KEY,
},
})
const responseJson = await response.json()
if (!response.ok) {
return { error: responseJson.error || 'Failed to fetch Bridge ToS link' }
}
return { data: responseJson }
} catch (e: unknown) {
return { error: e instanceof Error ? e.message : 'An unexpected error occurred' }
}
}
// confirm bridge ToS acceptance after user closes the ToS iframe
export const confirmBridgeTos = async (): Promise<{ data?: { accepted: boolean }; error?: string }> => {
const jwtToken = (await getJWTCookie())?.value
try {
const response = await fetchWithSentry(`${PEANUT_API_URL}/users/bridge-tos-confirm`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwtToken}`,
'api-key': API_KEY,
},
})
const responseJson = await response.json()
if (!response.ok) {
return { error: responseJson.error || 'Failed to confirm Bridge ToS' }
}
return { data: responseJson }
} catch (e: unknown) {
return { error: e instanceof Error ? e.message : 'An unexpected error occurred' }
}
export const getBridgeTosLink = async (): Promise<{ data?: { tosLink: string }; error?: string }> => {
const jwtToken = (await getJWTCookie())?.value
if (!jwtToken) {
return { error: 'Not authenticated' }
}
try {
const response = await fetchWithSentry(`${PEANUT_API_URL}/users/bridge-tos-link`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwtToken}`,
'api-key': API_KEY,
},
})
const responseJson = await response.json()
if (!response.ok) {
return { error: responseJson.message || responseJson.error || 'Failed to fetch Bridge ToS link' }
}
return { data: responseJson }
} catch (e: unknown) {
return { error: e instanceof Error ? e.message : 'An unexpected error occurred' }
}
}
// confirm bridge ToS acceptance after user closes the ToS iframe
export const confirmBridgeTos = async (): Promise<{ data?: { accepted: boolean }; error?: string }> => {
const jwtToken = (await getJWTCookie())?.value
if (!jwtToken) {
return { error: 'Not authenticated' }
}
try {
const response = await fetchWithSentry(`${PEANUT_API_URL}/users/bridge-tos-confirm`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwtToken}`,
'api-key': API_KEY,
},
})
const responseJson = await response.json()
if (!response.ok) {
return { error: responseJson.message || responseJson.error || 'Failed to confirm Bridge ToS' }
}
return { data: responseJson }
} catch (e: unknown) {
return { error: e instanceof Error ? e.message : 'An unexpected error occurred' }
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/actions/users.ts` around lines 165 - 205, Both getBridgeTosLink and
confirmBridgeTos should guard against a missing JWT and include the response
message field as an error fallback: first check (await getJWTCookie())?.value
and if falsy return an error like "Authentication required" rather than calling
the API with Bearer undefined; then after parsing responseJson use
responseJson.message || responseJson.error || a default string ("Failed to fetch
Bridge ToS link" / "Failed to confirm Bridge ToS") when response.ok is false so
the returned error includes any server-provided message.

Comment on lines +24 to +27
const handleComplete = useCallback(async () => {
setShowTosStep(false)
await fetchUser()
}, [fetchUser])
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Double fetchUser() call on completion path.

BridgeTosStep already calls await fetchUser() before invoking onComplete() (see BridgeTosStep.tsx Lines 66, 76, 80). Then handleComplete here calls fetchUser() again. This results in two consecutive user-data fetches on every successful ToS acceptance.

Consider removing the fetchUser() call here and relying on the one inside BridgeTosStep, or vice versa.

♻️ Suggested fix
     const handleComplete = useCallback(async () => {
         setShowTosStep(false)
-        await fetchUser()
-    }, [fetchUser])
+    }, [])
📝 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 handleComplete = useCallback(async () => {
setShowTosStep(false)
await fetchUser()
}, [fetchUser])
const handleComplete = useCallback(async () => {
setShowTosStep(false)
}, [])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Kyc/BridgeTosReminder.tsx` around lines 24 - 27,
handleComplete in BridgeTosReminder currently calls fetchUser() after closing
the step, but BridgeTosStep already awaits fetchUser() before invoking
onComplete(), causing duplicate fetches; remove the redundant await fetchUser()
from handleComplete (or alternatively remove the fetchUser() inside
BridgeTosStep) so only one fetchUser() call occurs on successful ToS acceptance,
keeping the setShowTosStep(false) and onComplete call behavior intact and
referencing the BridgeTosStep onComplete/fetchUser flow to ensure the single
remaining fetchUser() is executed in the appropriate component.

Comment on lines +101 to +137
if (phase === 'bridge_tos') {
const description =
tosError || 'One more step: accept terms of service to enable bank transfers in the US, Europe, and Mexico.'

return (
<ActionModal
visible={isOpen}
onClose={onClose}
icon={'check' as IconName}
iconContainerClassName="bg-success-1 text-white"
title="Identity verified!"
description={description}
ctas={[
{
text: tosError ? 'Continue' : 'Accept Terms',
onClick: tosError ? (onSkipTerms ?? onClose) : (onAcceptTerms ?? onClose),
disabled: isLoadingTos,
variant: 'purple',
className: 'w-full',
shadowSize: '4',
},
...(!tosError
? [
{
text: 'Skip for now',
onClick: onSkipTerms ?? onClose,
variant: 'transparent' as const,
className: 'underline text-sm font-medium w-full h-fit mt-3',
},
]
: []),
]}
preventClose
hideModalCloseButton
/>
)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

bridge_tos phase uses raw error string as modal description.

On Line 103, tosError is used directly as the modal description when present. If the upstream error comes from a network failure or API response, it may not be user-friendly copy.

Consider wrapping it in a generic user-facing message (e.g., "Something went wrong. You can accept the terms later.") and logging the raw error, consistent with the project's preference for generic error messages in user-facing UI. Based on learnings, for hotfixes in the peanut-ui codebase, prefer generic error messages over specific validation error details until the copy can be reviewed with the team.

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

In `@src/components/Kyc/KycVerificationInProgressModal.tsx` around lines 101 -
137, In KycVerificationInProgressModal (the block where phase === 'bridge_tos'
using tosError for description), replace the raw tosError shown to users with a
generic user-facing message like "Something went wrong. You can accept the terms
later." and keep the real error only in logs; ensure you still use tosError to
decide CTA text/behavior (the existing ctas array and tosError ? branches) but
call the project logger or console.error to record tosError for debugging (e.g.,
log tosError when truthy) rather than rendering it directly in the description
prop.

Comment on lines +60 to +83
useEffect(() => {
const existingScript = document.getElementById('sumsub-websdk')
if (existingScript) {
setSdkLoaded(true)
return
}

const script = document.createElement('script')
script.id = 'sumsub-websdk'
script.src = SUMSUB_SDK_URL
script.async = true
script.onload = () => setSdkLoaded(true)
script.onerror = () => {
console.error('[sumsub] failed to load websdk script')
setSdkLoadError(true)
}
document.head.appendChild(script)
}, [])

// initialize sdk when verification starts and all deps are ready
useEffect(() => {
if (!isVerificationStarted || !accessToken || !sdkLoaded || !sdkContainerRef.current) return

// clean up previous instance
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's check if the file exists and examine it
fd "SumsubKycWrapper.tsx" --type f

Repository: peanutprotocol/peanut-ui

Length of output: 107


🌐 Web query:

Sumsub WebSDK window.snsWebSdk global variable when does it become available script onload documentation

💡 Result:

In Sumsub’s standalone/CDN integration, snsWebSdk is exposed as a global (i.e., accessible as window.snsWebSdk) after the CDN script has finished loading and executing:

  • Sumsub’s docs show you must include the builder first:
    <script src="https://static.sumsub.com/idensic/static/sns-websdk-builder.js"></script> and only then call snsWebSdk.init(...) in a subsequent script block. [1]

So window.snsWebSdk becomes available when the CDN script’s onload would fire (or earlier if it’s already cached and executed). If your code runs before that, you’ll see snsWebSdk is not defined.

If you need to load it dynamically, attach your initialization to the script’s onload (standard browser behavior), or place your init code after the <script src=...> tag as in their example. [1]

If you use the NPM package (import snsWebSdk from '@sumsub/websdk'), you don’t rely on a global window.snsWebSdk. [1]

Sources:
[1] Sumsub Developer Hub — WebSDK integration / standalone example showing script include and later snsWebSdk.init(...): https://developers.sumsub.com/web-sdk/index.html


🏁 Script executed:

# Read the SumsubKycWrapper.tsx file to examine the code
cat -n src/components/Kyc/SumsubKycWrapper.tsx | head -150

Repository: peanutprotocol/peanut-ui

Length of output: 7017


Verify existing script completion before marking SDK as loaded.

Line 62 marks sdkLoaded as true upon finding an existing script element, but window.snsWebSdk is only exposed after the script's onload fires. If the script is still loading, initialization at line 112 will fail when accessing an undefined global, triggering a silent error within the try-catch block that never retries since sdkLoaded remains true.

🔧 Suggested fix
-        const existingScript = document.getElementById('sumsub-websdk')
-        if (existingScript) {
-            setSdkLoaded(true)
-            return
-        }
+        const existingScript = document.getElementById('sumsub-websdk') as HTMLScriptElement | null
+        if (existingScript) {
+            if ((window as any).snsWebSdk) {
+                setSdkLoaded(true)
+                return
+            }
+            const handleLoad = () => setSdkLoaded(true)
+            const handleError = () => {
+                console.error('[sumsub] failed to load websdk script')
+                setSdkLoadError(true)
+            }
+            existingScript.addEventListener('load', handleLoad)
+            existingScript.addEventListener('error', handleError)
+            return () => {
+                existingScript.removeEventListener('load', handleLoad)
+                existingScript.removeEventListener('error', handleError)
+            }
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Kyc/SumsubKycWrapper.tsx` around lines 60 - 83, The code sets
sdkLoaded true immediately when an existing script element is found but that
doesn't guarantee the SDK global (window.snsWebSdk) is ready; change the
existing-script branch in the useEffect so it only sets sdkLoaded when the
script has actually finished loading: if window.snsWebSdk exists or
existingScript.readyState === 'complete' (or a custom data attribute indicating
loaded), set sdkLoaded; otherwise attach onload and onerror handlers to the
existingScript (same handlers used when creating a new script) so sdkLoaded is
only set when the script's onload fires and setSdkLoadError on error; keep the
rest of the logic (script creation, appending, and subsequent init guarded by
sdkLoaded, accessToken, sdkContainerRef) unchanged.

Comment on lines +162 to +173
// helper to check if a region should be unlocked
const isRegionUnlocked = (regionName: string) => {
// sumsub approval scoped by the regionIntent used during verification.
// 'LATAM' intent → unlocks LATAM. 'STANDARD' intent → unlocks Bridge regions + rest of world.
// no intent (or rest-of-world) → unlocks rest of world only.
if (isSumsubApproved) {
if (sumsubVerificationRegionIntent === 'LATAM') {
return MANTECA_SUPPORTED_REGIONS.includes(regionName) || regionName === 'Rest of the world'
}
// STANDARD intent covers bridge regions + rest of world
return BRIDGE_SUPPORTED_REGIONS.includes(regionName) || regionName === 'Rest of the world'
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Comment on line 166 contradicts the actual fallback behavior.

The comment says "no intent (or rest-of-world) → unlocks rest of world only", but when sumsubVerificationRegionIntent is anything other than 'LATAM' (including null/undefined), the code falls through to line 172 which unlocks BRIDGE_SUPPORTED_REGIONS plus Rest of the world — not just Rest of the world.

If the intent is to only unlock "Rest of the world" when there's no region intent, the condition needs an additional branch. Otherwise, update the comment to match the code.

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

In `@src/hooks/useIdentityVerification.tsx` around lines 162 - 173, The comment
for isRegionUnlocked is incorrect vs. the implemented fallback: when
isSumsubApproved is true and sumsubVerificationRegionIntent is anything other
than 'LATAM' (including null/undefined) the code currently returns
BRIDGE_SUPPORTED_REGIONS plus 'Rest of the world'. Either update the comment to
state that the non-'LATAM' path unlocks BRIDGE_SUPPORTED_REGIONS + 'Rest of the
world', or change the logic by adding an explicit branch checking for no intent
(sumsubVerificationRegionIntent == null) to return only regionName === 'Rest of
the world' while keeping the existing 'STANDARD' behavior for other non-null
intents; reference the isRegionUnlocked function, isSumsubApproved,
sumsubVerificationRegionIntent, MANTECA_SUPPORTED_REGIONS and
BRIDGE_SUPPORTED_REGIONS when making the change.

Comment on lines +71 to +148
useWebSocket({
username: user?.user.username ?? undefined,
autoConnect: isTracking,
onRailStatusUpdate: useCallback(
(_data: RailStatusUpdate) => {
// refetch user to get updated rails from server
if (isTracking) {
fetchUser()
}
},
[isTracking, fetchUser]
),
})

// derive provider statuses from current rails
const providers = useMemo((): ProviderStatus[] => {
const rails: IUserRail[] = user?.rails ?? []
if (rails.length === 0) return []

// group by provider
const byProvider = new Map<string, IUserRail[]>()
for (const rail of rails) {
const code = rail.rail.provider.code
const list = byProvider.get(code) ?? []
list.push(rail)
byProvider.set(code, list)
}

return Array.from(byProvider.entries()).map(([code, providerRails]) => ({
providerCode: code,
displayName: deriveProviderDisplayName(code, providerRails),
status: deriveGroupStatus(providerRails),
rails: providerRails,
}))
}, [user?.rails])

const allSettled = useMemo(() => {
if (providers.length === 0) return false
return providers.every((p) => p.status !== 'setting_up')
}, [providers])

const needsBridgeTos = useMemo(() => {
return providers.some((p) => p.providerCode === 'BRIDGE' && p.status === 'requires_tos')
}, [providers])

const needsAdditionalDocs = useMemo(() => {
return providers.some((p) => p.status === 'requires_documents')
}, [providers])

// stop polling when all settled
useEffect(() => {
if (allSettled && isTracking) {
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current)
pollTimerRef.current = null
}
}
}, [allSettled, isTracking])

const startTracking = useCallback(() => {
setIsTracking(true)

// start polling as fallback
if (pollTimerRef.current) clearInterval(pollTimerRef.current)
pollTimerRef.current = setInterval(() => {
if (isMountedRef.current) {
fetchUser()
}
}, POLL_INTERVAL_MS)
}, [fetchUser])

const stopTracking = useCallback(() => {
setIsTracking(false)
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current)
pollTimerRef.current = null
}
}, [])
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Ensure the WebSocket disconnects when tracking stops.
stopTracking only clears polling; if it’s called while the socket is still “connecting”, the connection can remain open. Explicitly disconnecting avoids lingering updates and unnecessary traffic.

🛠️ Suggested fix
-    useWebSocket({
+    const { disconnect } = useWebSocket({
         username: user?.user.username ?? undefined,
         autoConnect: isTracking,
         onRailStatusUpdate: useCallback(
             (_data: RailStatusUpdate) => {
                 // refetch user to get updated rails from server
                 if (isTracking) {
                     fetchUser()
                 }
             },
             [isTracking, fetchUser]
         ),
     })

     const stopTracking = useCallback(() => {
         setIsTracking(false)
+        disconnect()
         if (pollTimerRef.current) {
             clearInterval(pollTimerRef.current)
             pollTimerRef.current = null
         }
-    }, [])
+    }, [disconnect])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useRailStatusTracking.ts` around lines 71 - 148, The stopTracking
function currently only clears the polling timer but doesn't close the
WebSocket; update the useWebSocket call to capture the returned
socket/disconnect function (whatever useWebSocket returns) and call that
disconnect/close method inside stopTracking and in any cleanup effect when
isTracking becomes false so the socket is explicitly closed (including if it's
still connecting). Ensure references to useWebSocket, stopTracking,
startTracking, isTracking, and pollTimerRef are used to locate where to store
the disconnect handle and invoke it.

kushagrasarathe and others added 12 commits February 25, 2026 12:26
- Delete Bridge iframe integration: useBridgeKycFlow, InitiateBridgeKYCModal
- Delete Manteca widget integration: useMantecaKycFlow, InitiateMantecaKYCModal
- Extract shared useMultiPhaseKycFlow hook + SumsubKycModals component
- Replace all KYC entry points with inline Sumsub flow:
  - Bridge bank flows use regionIntent: 'STANDARD'
  - Manteca flows use regionIntent: 'LATAM'
- Add polling fallback in useSumsubKycFlow for missed WebSocket events
- Relocate KycHistoryEntry type + isKycStatusItem guard to KycStatusItem
- Net reduction: ~970 lines

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When user clicks "Accept terms of service" from the activity feed,
auto-fetch the ToS link and open the iframe directly instead of
showing a redundant "Accept Terms" confirmation modal first. The
modal now only appears as an error fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…on mount

- Add InitiateKycModal shown before opening Sumsub SDK in Manteca flows
- Remove regionIntent from hook initialization in all consumers to prevent
  useSumsubKycFlow from calling initiateSumsubKyc() on mount (which created
  backend UserKycVerification records even when user never started KYC)
- Pass regionIntent at call time: handleInitiateKyc('LATAM'/'STANDARD')

Fixes: KYC auto-opening without confirmation, phantom pending KYC entries

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove collectUserDetails step from bridge bank onramp flow (page.tsx)
  Non-KYC'd users now go directly to KYC step
- Remove updateUserById calls for name/email before handleInitiateKyc
  in AddWithdrawCountriesList and BankFlowManager
- Sumsub SDK now collects name and email, backend fills them in DB

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
No longer needed — name and email are now collected by Sumsub SDK.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add optimistic local state to BridgeTosReminder so it hides
  immediately after ToS acceptance (backend rail transition is async)
- Remove BridgeTosReminder from KycCompleted drawer (avoid duplication)
- Consolidate duplicate KYC activity entries into one per region
  (STANDARD, LATAM) via new groupKycByRegion() utility
- Add region prop to KycStatusItem/KycStatusDrawer for region-aware titles

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
fix: unify all KYC flows through Sumsub via useMultiPhaseKycFlow
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Being worked on Being worked on enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants