Skip to content

feat(anyspend): add KYC verification flow#474

Open
aqt-dev wants to merge 15 commits intomainfrom
anyspend-kyc
Open

feat(anyspend): add KYC verification flow#474
aqt-dev wants to merge 15 commits intomainfrom
anyspend-kyc

Conversation

@aqt-dev
Copy link
Contributor

@aqt-dev aqt-dev commented Feb 27, 2026

Summary

  • Added KycGate component with Persona SDK integration for identity verification
  • Added useKycStatus, useCreateKycInquiry, and useVerifyKyc hooks for KYC state management
  • Integrated KYC gate into FiatCheckoutPanel — blocks order creation until identity is verified
  • Added persona dependency to package.json

Split from anyspend-upgrades branch which retains the variable pricing input feature (PR #472).

Test plan

  • Verify KYC gate renders before fiat checkout when user is not verified
  • Verify Persona inquiry flow opens and completes successfully
  • Verify approved KYC status allows order creation to proceed
  • Verify polling picks up status changes if verification completes externally
  • Verify fiat checkout still works end-to-end after KYC approval

🤖 Generated with Claude Code

Adds KYC gate to the fiat checkout panel that verifies user identity
before allowing order creation. Includes useKycStatus hooks and KycGate component.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@aqt-dev aqt-dev requested a review from a team as a code owner February 27, 2026 22:02
@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request implements a crucial KYC (Know Your Customer) verification system, which is now a prerequisite for fiat checkouts. It introduces a dedicated UI component and a set of custom hooks to manage the identity verification process, ensuring regulatory compliance and enhanced security by preventing order creation until a user's identity is confirmed. The changes provide a seamless, guided flow for users to complete their verification.

Highlights

  • KYC Verification Flow: Introduced a comprehensive Know Your Customer (KYC) verification flow, integrating the Persona SDK for identity verification.
  • New Components and Hooks: Added a KycGate component to manage the UI for the verification process and new hooks (useKycStatus, useCreateKycInquiry, useVerifyKyc) for state management.
  • Fiat Checkout Integration: Integrated the KYC gate into the FiatCheckoutPanel, ensuring that order creation is blocked until a user's identity is successfully verified.
  • Dependency Update: Added the persona SDK as a new dependency to the project.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • packages/sdk/package.json
    • Added persona package dependency.
  • packages/sdk/src/anyspend/react/components/checkout/FiatCheckoutPanel.tsx
    • Imported KycGate component.
    • Added state management for KYC approval.
    • Modified order creation useEffect to depend on kycApproved status.
    • Conditionally rendered KycGate to block checkout if KYC is not approved.
  • packages/sdk/src/anyspend/react/components/checkout/KycGate.tsx
    • Added new KycGate component to handle the Persona SDK integration and display various KYC states (loading, pending, needs review, prompt, error).
  • packages/sdk/src/anyspend/react/hooks/index.ts
    • Exported the new useKycStatus hook.
  • packages/sdk/src/anyspend/react/hooks/useKycStatus.ts
    • Added new file containing useKycStatus, useCreateKycInquiry, and useVerifyKyc hooks for fetching KYC status, initiating inquiries, and verifying results using React Query.
Activity
  • The pull request was generated using Claude Code, indicating an AI-assisted development process.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a KYC verification flow using the Persona SDK. It adds a KycGate component that blocks the fiat checkout process until the user's identity is verified. The implementation is well-structured, with new hooks for managing KYC state (useKycStatus, useCreateKycInquiry, useVerifyKyc) and clean integration into the FiatCheckoutPanel. My review includes suggestions to improve code clarity by removing redundant useMemo calls in the new hooks, enhancing error handling in the KYC completion callback for a better user experience, and making a type assertion safer. Overall, this is a solid implementation of a critical feature.

Comment on lines 95 to 97
} catch {
// Will be picked up by polling via refetch
}
Copy link
Contributor

Choose a reason for hiding this comment

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

high

The catch block for the verifyKyc call is empty. Silently swallowing this error can lead to a confusing user experience. If the verification call fails, the user won't receive any immediate feedback. While the status will be refetched, it's better to inform the user about the error by setting an error state.

Suggested change
} catch {
// Will be picked up by polling via refetch
}
} catch (error) {
setPersonaError(error instanceof Error ? error.message : "Failed to complete verification. We will retry automatically.");
}

inquiryId: config.inquiryId,
sessionToken: config.sessionToken,
...(config.templateId && { templateId: config.templateId }),
environment: (config.environment as "sandbox" | "production") || "sandbox",
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The type assertion (config.environment as "sandbox" | "production") is not entirely safe. If the API were to return a different string for the environment, it could lead to unexpected behavior. It's safer to explicitly validate the value and default to "sandbox" if it's not "production".

Suggested change
environment: (config.environment as "sandbox" | "production") || "sandbox",
environment: config.environment === "production" ? "production" : "sandbox",

Comment on lines +68 to +76
return useMemo(
() => ({
kycStatus: data || null,
isLoadingKycStatus: isLoading,
kycStatusError: error,
refetchKycStatus: refetch,
}),
[data, isLoading, error, refetch],
);
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The useMemo hook here is redundant. The values returned from react-query's useQuery hook (data, isLoading, error, refetch) are already stable. You can simplify the code by returning the object directly. This improves readability and removes unnecessary memoization. This also applies to useCreateKycInquiry and useVerifyKyc hooks in this file.

  return {
    kycStatus: data || null,
    isLoadingKycStatus: isLoading,
    kycStatusError: error,
    refetchKycStatus: refetch,
  };

Comment on lines +84 to +90
return useMemo(
() => ({
createInquiry: mutateAsync,
isCreatingInquiry: isPending,
}),
[mutateAsync, isPending],
);
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The useMemo hook here is not necessary. The mutateAsync and isPending values from useMutation are stable across re-renders. You can return the object directly to simplify the code.

  return {
    createInquiry: mutateAsync,
    isCreatingInquiry: isPending,
  };

Comment on lines +99 to +105
return useMemo(
() => ({
verifyKyc: mutateAsync,
isVerifying: isPending,
}),
[mutateAsync, isPending],
);
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

Similar to the other hooks in this file, the useMemo here is redundant. mutateAsync and isPending from useMutation are stable. Returning the object directly makes the code cleaner.

  return {
    verifyKyc: mutateAsync,
    isVerifying: isPending,
  };

aqt-dev and others added 13 commits February 27, 2026 17:21
persona@^6.0.0 does not exist — latest is 5.7.0. Updated package.json
and regenerated pnpm-lock.yaml to fix CI frozen-lockfile failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove unused KycStatusResponse import and useCallback import
- Replace non-null assertion with type assertion for walletAddress
- Fix missing openPersonaFlow dependency by reordering useCallback hooks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…te for card payments

- Switch KYC identification from wallet address to B3 userId (JWT-based)
- KycGate: add Sign In button when user is not authenticated
- AnySpend: require login before creating stripe-web2 orders, open sign-in modal if not authenticated
- useKycStatus: remove walletAddress param, send auth header for all KYC API calls
- BottomNavigation: fix React DOM SVG camelCase attribute warnings
- anyspend-demo-vite: configure VITE_ANYSPEND_BASE_URL to point to local backend

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace B3 JWT (Authorization: Bearer) with EIP-191 wallet signature
  in useKycStatus hooks — headers: X-Wallet-Address, X-Wallet-Signature,
  X-Wallet-Timestamp via wagmi's useAccount + useSignMessage
- Cache signed headers for 4 min to avoid repeated wallet prompts
  (server allows 5-min window)
- Add FIAT_AUTH panel (LoginStep inline) to AnySpend for B3 sign-in
  without losing flow state; detect auth via useEffect on isAuthenticated
- Fix Persona Client: remove templateId when resuming with inquiryId
  (mutually exclusive fields)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Strip border/bg/rounded wrapper from all KycGate states so content
renders flat inside the parent checkout card instead of adding a
second nested card layer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ntity

- Defer wallet signature prompt until user clicks Buy (not on panel mount)
- Use kycApprovedRef (useRef) instead of useState to avoid stale closure
  when handleFiatOrder is called synchronously after KycGate resolves
- Fix MaxListenersExceeded: cache wagmi config per partnerId in
  getCachedWagmiConfig to avoid recreating connectors on every render
- Use verifyMessage (EIP-1271 + EIP-6492) via Base public client instead
  of recoverMessageAddress to support Coinbase Smart Wallet and
  thirdweb smart accounts (B3 Global Account)
- Pass wallet auth headers with stripe-web2 order requests so backend
  KYC lookup uses the signing wallet address, not the B3 JWT address
  (fixes identity mismatch for users with multiple wallets)
- Use friendlier wallet signature message for KYC auth

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the user is on the FIAT_KYC panel (Persona iframe), clicking outside
the iframe was closing the entire AnySpend modal, forcing the user to restart
the flow. Now the modal is non-closable while activePanel === FIAT_KYC and
automatically becomes closable again when the user leaves that panel.

Uses the existing setClosable action from useModalStore (same pattern as
CryptoPaymentMethod's connect-wallet flow).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ESLint @typescript-eslint/no-non-null-assertion blocked the build.
Replace wagmiConfigCache.get(key)! with a local variable pattern
that avoids the assertion while keeping the same semantics.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When closable=false, using onPointerDownOutside and onInteractOutside with
e.preventDefault() caused Radix to intercept focus-related events and block
focus from moving to the Persona iframe — making it non-interactable.

Fix: remove the two outside-interaction handlers and rely solely on
handleOpenChange to prevent the modal from closing. handleOpenChange already
has the guard `if (!open && !isClosable) return` which achieves the same
close-prevention without touching focus or pointer propagation.

onEscapeKeyDown is kept to prevent Escape from closing during KYC.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The setClosable(false) approach caused Radix's FocusScope to reinitialize
on re-render, stealing focus from the Persona iframe and making it
non-interactable.

New approach:
- Remove setClosable from AnySpend.tsx entirely (Persona iframe works again)
- Auto-resume Persona in KycGate when the gate activates and there is a
  pending inquiry (status==="pending" && inquiry exists). This handles the
  case where the user accidentally closed the modal mid-KYC: returning to
  the FIAT_KYC panel automatically reopens Persona at the same step.
- autoResumedRef guards against firing more than once per gate mount.
- personaCancelled guard prevents auto-resume after the user explicitly
  cancelled, so they still see the manual Resume button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The wallet signing prompt was triggered inside React Query's queryFn
(an async, non-user-gesture context), which caused browsers and wallets
(especially Coinbase Smart Wallet) to block the popup — showing only a
loading spinner with no action possible.

Fix: call getKycHeaders() inside handleFiatOrder (which runs directly from
a button click) before navigating to FIAT_KYC. The 4-minute module-level
cache in useWalletAuthHeaders means useKycStatus's queryFn reuses the
cached signature immediately, skipping the signing prompt entirely.

The .catch(() => {}) is intentional: if signing fails (e.g., user cancels),
we still navigate to the panel where the error state is shown gracefully.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When Persona opens as a fullscreen overlay, clicks on the Persona UI or
accidentally outside it were dismissing the AnySpend checkout modal.
After KYC completed the order state was lost and users had no way back.

Fix: call setClosable(false) immediately before client.open() and
setClosable(true) in every exit path (onComplete, onCancel, onError,
and component unmount). This keeps the checkout modal open throughout
the entire Persona session.

Because B3DynamicModal no longer sets onPointerDownOutside or
onInteractOutside when closable=false (relying on handleOpenChange
instead), the Persona iframe itself remains fully interactive.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…king

Locking the modal with setClosable(false) prevents the Persona iframe
from receiving click events. Instead, let Radix close the modal when
the user clicks inside the Persona iframe (outside-click), then reopen
it in onComplete after KYC approval via useModalStore.getState().isOpen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant