-
Notifications
You must be signed in to change notification settings - Fork 2
✨ app: implement manteca ramp #704
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
🦋 Changeset detectedLatest commit: c920746 The changes in this PR will be included in the next version bump. Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
WalkthroughAdds Manteca ramp/onramp: new Add Funds routes and re-exported route modules, five Add Funds UI components (Onboard, Kyc, Ramp, Status, AddFiatButton), KYC/ramp client+server APIs and Persona Manteca KYC flow, currency utilities, and Spanish translations. Changes
Sequence Diagram(s)sequenceDiagram
participant User as User
participant App as Mobile App
participant Server as API Server
participant Manteca as Manteca KYC
participant Ramp as Ramp Provider
User->>App: Open Add Funds
App->>Server: getKYCStatus("basic")
Server-->>App: kyc status (+ countryCode)
App->>Server: getRampProviders(countryCode)
Server-->>App: providers (e.g., manteca)
User->>App: Start onboard for currency
App->>Server: getKYCStatus("manteca")
Server-->>App: manteca kyc status
alt KYC not started
App->>Manteca: startMantecaKYC()
Manteca-->>App: KYC result (complete/cancel/error)
end
alt KYC complete
App->>Server: startRampOnboarding({provider:"manteca"})
Server->>Ramp: init onboarding
Ramp-->>Server: provider status
Server-->>App: provider status (ACTIVE/ONBOARDING)
App->>Server: getRampQuote(currency)
Server->>Ramp: request quote/account
Ramp-->>Server: quote + account
Server-->>App: quote + account details
App-->>User: show ramp details / account info
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Important Action Needed: IP Allowlist UpdateIf your organization protects your Git platform with IP whitelisting, please add the new CodeRabbit IP address to your allowlist:
Reviews will stop working after February 8, 2026 if the new IP is not added to your allowlist. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Summary of ChangesHello @franm91, 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 introduces a significant new feature: the Manteca fiat on-ramp. It provides a comprehensive flow for users to add funds by converting fiat currencies into on-chain USDC. The changes encompass new UI screens for onboarding, KYC verification, status tracking, and the ramp details, along with the necessary backend integrations to support these operations. Highlights
Using Gemini Code AssistThe 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
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 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
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| const router = useRouter(); | ||
|
|
||
| const { currency } = useLocalSearchParams<{ currency: string }>(); | ||
| const countryCode = queryClient.getQueryData<string>(["user", "country"]) ?? ""; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Stale countryCode captured in closure leads to incorrect provider status query
The countryCode is read synchronously from queryClient.getQueryData at component render time and captured in the completeOnboarding closure. If a user navigates directly to /add-funds/onboard or /add-funds/kyc (via deep link, browser refresh, or back navigation) without first visiting the AddFunds page, the countryCode will be an empty string because only AddFunds.tsx:41 calls getKYCStatus("basic", true) to populate it.
Click to expand
Code flow issue
In Onboard.tsx:31 and Kyc.tsx:27:
const countryCode = queryClient.getQueryData<string>(["user", "country"]) ?? "";This value is then used in completeOnboarding() at Onboard.tsx:70-73 and Kyc.tsx:54-57:
const providers = await queryClient.fetchQuery({
queryKey: ["ramp", "providers", countryCode],
queryFn: () => getRampProviders(countryCode),
staleTime: 0,
});Impact
When countryCode is empty:
- The server's
getSupportedByCountry("")returns an empty currencies array (server/utils/ramps/manteca.ts:683-684) - The query key
["ramp", "providers", ""]differs from the one used inAddFunds.tsxwith the correct country code - This may cause cache misses and inconsistent data between components
Recommendation: Use a useQuery hook to fetch the country code reactively (similar to how AddFunds.tsx does it), or ensure the country code is passed via route params when navigating to these screens.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Code Review
This pull request introduces a new fiat on-ramp feature using 'Manteca' as a provider. This includes new screens for onboarding, KYC, and displaying ramp details, along with the necessary components, assets, and API utility functions. The changes are well-structured, but there are a few areas that could be improved for clarity, maintainability, and robustness. Specifically, the Persona KYC initiation logic is duplicated, some useQuery data flow is indirect, and depositInfo extraction could be cleaner.
| const { data: countryCode } = useQuery({ | ||
| queryKey: ["user", "country"], | ||
| queryFn: async () => { | ||
| await getKYCStatus("basic", true); | ||
| return queryClient.getQueryData<string>(["user", "country"]) ?? ""; | ||
| }, | ||
| staleTime: (query) => (query.state.data ? Infinity : 0), | ||
| retry: false, | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This useQuery hook for countryCode has a confusing implementation. The queryFn calls getKYCStatus("basic", true), which has a side effect of setting the country code in the query cache via a response header, and then the queryFn returns a value read from that same cache. This indirect data flow is hard to follow. A clearer approach would be to refactor getKYCStatus to return the country code in its response body, allowing this query to directly return the value without relying on side effects.
| const beneficiaryName = depositInfo && "beneficiaryName" in depositInfo ? depositInfo.beneficiaryName : undefined; | ||
| const depositAddress = | ||
| depositInfo?.network === "ARG_FIAT_TRANSFER" | ||
| ? depositInfo.cbu | ||
| : depositInfo?.network === "PIX" | ||
| ? depositInfo.pixKey | ||
| : undefined; | ||
| const depositAlias = depositInfo?.network === "ARG_FIAT_TRANSFER" ? depositInfo.depositAlias : undefined; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The logic to extract properties like beneficiaryName, depositAddress, and depositAlias from depositInfo is spread across multiple lines with optional chaining and in checks. This can be hard to follow and maintain. Since depositInfo appears to be a discriminated union based on the network property, using a switch statement or a helper function would make this logic cleaner, more robust, and easier to read.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #704 +/- ##
===========================================
+ Coverage 15.06% 44.05% +28.98%
===========================================
Files 39 176 +137
Lines 1786 5328 +3542
Branches 454 1634 +1180
===========================================
+ Hits 269 2347 +2078
- Misses 1471 2869 +1398
- Partials 46 112 +66
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 20
🤖 Fix all issues with AI agents
In `@src/components/add-funds/AddFiatButton.tsx`:
- Around line 10-13: The AddFiatButtonProperties type declares currency as a
plain string which weakens type safety against currencyMap keys; import the
Currency type from ../../utils/currencies and change the property signature in
AddFiatButtonProperties from currency: string to currency: Currency so the
component's currency prop is strongly typed to the known currency keys (update
any usages of AddFiatButtonProperties/AddFiatButton to satisfy the new type if
necessary).
In `@src/components/add-funds/AddFunds.tsx`:
- Line 113: In AddFunds.tsx replace the hardcoded gap={16} on the YStack with
the appropriate design token (e.g., the app's spacing token such as
spacing.medium or $4) used across the codebase; update the YStack prop to use
that token (and import the token if needed) so styling relies on predefined
design tokens rather than a raw number, then run the linter/formatter to ensure
the prop type matches the token format.
- Around line 107-111: In the AddFunds component JSX where the fiat options are
rendered (the block using countryCode and isPending to decide between the
Skeleton and options), change the condition from countryCode && isPending to
show the skeleton when either the providers are loading or the country code is
not yet available (e.g., use isPending || !countryCode or !countryCode ||
isPending) so the loading skeleton is displayed while countryCode is being
fetched as well as while providers are loading.
In `@src/components/add-funds/Kyc.tsx`:
- Around line 99-101: The FaceId SVG is receiving string percentages for
dimensions which don't work in React Native SVG; update the FaceId usage in
Kyc.tsx (the View + FaceId block) to pass numeric width/height props (e.g.,
width={200} height={200} or width={iconSize} height={iconSize}) or remove those
props and control sizing via the parent View/style (flex or explicit numeric
size computed with Dimensions) so the FaceId component gets numeric literals
rather than "100%".
- Around line 79-80: The two JSX View elements are using hardcoded gap={20};
replace those with the project design token for that spacing (e.g.,
gap={tokens.spacing.md} or gap={tokens.space[20]}) by importing the
tokens/spacing object from your design tokens module and using the appropriate
token value (ensure the token name matches the project's token API and types),
updating both occurrences of gap={20} on the View components.
- Around line 54-66: The code assumes providers.providers.manteca exists and
reads its status directly; add a defensive null/undefined check for
providers.providers.manteca (after the queryClient.fetchQuery/getRampProviders
call) before accessing manteca.status and set newStatus to a safe fallback
(e.g., "error" or "UNAVAILABLE") when manteca is missing, then call
router.replace accordingly (ensure logic around newStatus, router.replace, and
the existing branches for "ACTIVE"/"ONBOARDING" handle the fallback case).
In `@src/components/add-funds/Onboard.tsx`:
- Around line 26-96: The Onboard component contains routing, mutations and
server-state logic (useMutation with mutationFn, getKYCStatus handling,
completeOnboarding, queryClient calls, router.push/replace) and must be
refactored into a smart screen in src/app/**; extract all side-effect logic
(handleOnboarding mutation, completeOnboarding function,
queryClient.fetch/invalidate, getKYCStatus, startRampOnboarding, router
navigation and error handling) into a container/screen component and convert
this file into a pure presentational component that accepts props like currency,
validCurrency, CurrencyImage, isPending, onContinue (callback) and onNavigate
(or expose status callbacks). Keep only UI rendering and call props.onContinue
from handleContinue; remove any direct use of router, queryClient, useMutation,
and reportError from this component. Ensure function names referenced (Onboard,
completeOnboarding, handleOnboarding, handleContinue) are implemented/used in
the new smart screen and the UI component only receives data and callbacks via
props.
- Around line 34-57: The inline multi-line mutationFn passed to useMutation
(alias handleOnboarding) contains complex logic (calls to getKYCStatus,
router.push/replace and completeOnboarding) and should be extracted into a named
async function (e.g., async function performRampOnboarding(currency) { ... })
declared outside the inline prop so the handler is stable and testable; replace
mutationFn: async () => { ... } with mutationFn: performRampOnboarding (or () =>
performRampOnboarding(currency)) and move the existing logic (including the
APIError catch, kycCode calculation, router.push/replace and completeOnboarding
calls) into that function. Do the same refactor for the other multi-line
callbacks noted (the handler around completeOnboarding / status flow referenced
later) so they become named functions instead of inline multi-line arrow
functions.
- Around line 1-24: The React default import is only used for the React.FC type
in the currencyImages declaration; change the import to a type-only import and
update the type usage to use a type import (e.g., import type { FC } from
"react") and then type currencyImages as Record<string, FC<{ height: string;
width: string }>> so the runtime bundle does not include React unnecessarily;
update the top import line accordingly and remove the unused default React
import.
- Around line 100-123: Replace hardcoded numeric spacing and longhand padding
with Tamagui design tokens and shorthand props: change gap={20} and gap={10} to
token-based values (e.g. gap="$s6" or "$s2" as appropriate) on the View and
inner row, replace padding="$s4" longhand on YStack with the shorthand p="$s4",
and prefer shorthands like px, py, m, mt, etc., on components such as View,
ScrollView, YStack, Pressable and the container wrapping CurrencyImage; keep
existing behavior for router.back()/router.replace() and retain the
ArrowLeft/Pressable structure while only updating spacing props to use the
tokens from tamagui.config.ts.
- Around line 30-72: Move the top-level countryCode read into the
completeOnboarding function to avoid pinning an empty-string cache key; remove
the module-scope const countryCode and instead call
queryClient.getQueryData<string>(["user","country"]) (or similar) inside
completeOnboarding before you call queryClient.fetchQuery({ queryKey:
["ramp","providers", countryCode], ... }) so the providers query is only keyed
with the actual country value (and not a default ""), and apply the same change
to the analogous code in Kyc.tsx.
In `@src/components/add-funds/Ramp.tsx`:
- Around line 181-183: The X icon inside Button.Icon is missing an explicit size
prop and should match the other icons in this component for visual consistency;
update the X component in the Button.Icon block (the X element) to include the
same size prop used by the other icons in this file (e.g., size=16 or the
project's standard icon size) so all icons in the Ramp/Add-funds component
render at the same dimensions.
- Around line 90-91: Replace hardcoded numeric gaps in the Ramp component's JSX
(the View elements using gap={20}) with the app's design tokens: locate the View
elements in Ramp.tsx (props gap={20}) and change them to use the shared spacing
token (e.g., spacing.X or tokens.space.xxx — whatever the project's spacing
token export is) so all gap props reference the token constant; update both
occurrences mentioned and any other hardcoded gap values in this file to use the
same token import.
- Around line 107-109: The Info Pressable in Ramp.tsx is missing an onPress
handler, so either remove the Pressable or add an onPress that mirrors the
behavior used elsewhere (e.g., the help/article opener in AddFunds.tsx); locate
the Pressable wrapping the Info icon and implement an onPress prop that opens
the appropriate help modal or navigator route (or call the same helper function
used by AddFunds), and keep hitSlop={15} if retaining the button for consistent
touch target.
- Around line 104-106: Replace the hardcoded style props on the Text component
(currently fontSize={15} and fontWeight="bold") with the design-system token
props used elsewhere (e.g., use the Text variant/size prop like subHeadline and
the emphasis prop like emphasized or the component's weight token) so styling
comes from predefined tokens; update the Text invocation in the Ramp component
to remove fontSize/fontWeight and pass the appropriate predefined props
(subHeadline and emphasized) to match the design system.
In `@src/components/add-funds/Status.tsx`:
- Around line 43-45: The SVG components FaceId and Denied are being passed
percentage strings for width/height which SVG in React Native doesn't support;
update the JSX in the View that renders these icons (the conditional using
isOnboarding) to pass numeric width and height values (e.g., width={...}
height={...}) or compute numbers from the container/screen dimensions before
passing them; ensure the values are numeric literals or variables (not strings)
so FaceId and Denied receive valid numeric props.
- Around line 38-40: Replace the hardcoded gap={20} uses in the Status
component's View elements with the appropriate spacing design token from
tamagui.config.ts (e.g., the spacing token equivalent like "$4" or "space-4");
update both the outer View (the fullScreen padded container) and the inner View
(the child with gap) to use that token and, if required by your setup,
import/consume the token or theme so the component uses the predefined spacing
value rather than a raw number.
In `@src/utils/persona.ts`:
- Around line 117-205: startMantecaKYC duplicates large portions of startKYC
(pagehide listener, abort handling, Persona client/inquiry setup); extract
shared behavior into small helpers to remove duplication. Create a helper like
setupPagehideAbort(controller) that installs/removes the pagehide listener and
re-use it in both startMantecaKYC and startKYC; extract a common
attachAbortHandler(controller.signal, onAbort) pattern and a factory that wires
Persona callbacks (for Client and Inquiry) to shared onComplete/onCancel/onError
logic (referencing Client, Inquiry, onAbort, controller.signal,
queryClient.invalidateQueries and reportError) so both functions call the shared
helpers and only implement the KYC-specific return/resolve logic.
In `@src/utils/server.ts`:
- Around line 170-171: The error parsing uses a TypeScript type assertion
((await response.json()) as { code: string }) which bypasses runtime validation;
define a valibot schema (e.g., APIErrorResponse = object({ code: string() }))
and replace the assertions by calling parse(APIErrorResponse, await
response.json()) and destructuring the returned { code } before throwing
APIError; import object, string, and parse from valibot and apply this change
consistently for the locations that construct APIError (the occurrences around
the current response handling code and the other mentioned spots).
- Line 176: Remove the redundant type redeclaration that shadows the imported
RampProvider: delete the line exporting "export type RampProvider = (typeof
RampProvider)[number];" so the imported RampProvider from
`@exactly/server/utils/ramps/shared` is used; if you intended to also re-export a
runtime value, instead export the imported symbol (e.g., export { RampProvider
}) rather than redefining its type.
| type AddFiatButtonProperties = { | ||
| currency: string; | ||
| status: "ACTIVE" | "NOT_AVAILABLE" | "NOT_STARTED" | "ONBOARDING"; | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Consider using the Currency type for stronger typing.
The currency prop is typed as string, but the component uses currencyMap which has specific keys. Using the Currency type from ../../utils/currencies would provide better type safety.
♻️ Suggested improvement
+import { currencyMap, type Currency } from "../../utils/currencies";
-import { currencyMap } from "../../utils/currencies";
type AddFiatButtonProperties = {
- currency: string;
+ currency: Currency | string;
status: "ACTIVE" | "NOT_AVAILABLE" | "NOT_STARTED" | "ONBOARDING";
};📝 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.
| type AddFiatButtonProperties = { | |
| currency: string; | |
| status: "ACTIVE" | "NOT_AVAILABLE" | "NOT_STARTED" | "ONBOARDING"; | |
| }; | |
| import { currencyMap, type Currency } from "../../utils/currencies"; | |
| type AddFiatButtonProperties = { | |
| currency: Currency | string; | |
| status: "ACTIVE" | "NOT_AVAILABLE" | "NOT_STARTED" | "ONBOARDING"; | |
| }; |
🤖 Prompt for AI Agents
In `@src/components/add-funds/AddFiatButton.tsx` around lines 10 - 13, The
AddFiatButtonProperties type declares currency as a plain string which weakens
type safety against currencyMap keys; import the Currency type from
../../utils/currencies and change the property signature in
AddFiatButtonProperties from currency: string to currency: Currency so the
component's currency prop is strongly typed to the known currency keys (update
any usages of AddFiatButtonProperties/AddFiatButton to satisfy the new type if
necessary).
| {countryCode && isPending ? ( | ||
| <View justifyContent="center" alignItems="center"> | ||
| <Skeleton width="100%" height={82} /> | ||
| </View> | ||
| ) : ( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Consider showing skeleton when countryCode is undefined and loading.
The current condition countryCode && isPending only shows the skeleton when countryCode exists and providers are loading. If countryCode is still being fetched, no loading indicator appears for the fiat options section.
♻️ Alternative condition
- {countryCode && isPending ? (
+ {isPending || !providers ? (This shows the skeleton while either the country or providers are loading, providing better UX feedback.
🤖 Prompt for AI Agents
In `@src/components/add-funds/AddFunds.tsx` around lines 107 - 111, In the
AddFunds component JSX where the fiat options are rendered (the block using
countryCode and isPending to decide between the Skeleton and options), change
the condition from countryCode && isPending to show the skeleton when either the
providers are loading or the country code is not yet available (e.g., use
isPending || !countryCode or !countryCode || isPending) so the loading skeleton
is displayed while countryCode is being fetched as well as while providers are
loading.
| </View> | ||
| ) : ( | ||
| providers && ( | ||
| <YStack gap={16}> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Use design tokens for gap values.
gap={16} should use a design token instead of a hardcoded number.
As per coding guidelines: "All styling must use predefined design tokens."
♻️ Suggested fix
- <YStack gap={16}>
+ <YStack gap="$s4">📝 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.
| <YStack gap={16}> | |
| <YStack gap="$s4"> |
🤖 Prompt for AI Agents
In `@src/components/add-funds/AddFunds.tsx` at line 113, In AddFunds.tsx replace
the hardcoded gap={16} on the YStack with the appropriate design token (e.g.,
the app's spacing token such as spacing.medium or $4) used across the codebase;
update the YStack prop to use that token (and import the token if needed) so
styling relies on predefined design tokens rather than a raw number, then run
the linter/formatter to ensure the prop type matches the token format.
| const providers = await queryClient.fetchQuery({ | ||
| queryKey: ["ramp", "providers", countryCode], | ||
| queryFn: () => getRampProviders(countryCode), | ||
| staleTime: 0, | ||
| }); | ||
| const newStatus = providers.providers.manteca.status; | ||
| if (newStatus === "ACTIVE") { | ||
| router.replace({ pathname: "/add-funds/ramp", params: { currency } }); | ||
| } else if (newStatus === "ONBOARDING") { | ||
| router.replace({ pathname: "/add-funds/status", params: { status: newStatus, currency } }); | ||
| } else { | ||
| router.replace({ pathname: "/add-funds/status", params: { status: "error", currency } }); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add null check for manteca provider to prevent runtime error.
providers.providers.manteca is accessed without checking if the manteca key exists. If the provider response doesn't include manteca (e.g., due to country restrictions), this will throw a runtime error.
🛡️ Add defensive check
const providers = await queryClient.fetchQuery({
queryKey: ["ramp", "providers", countryCode],
queryFn: () => getRampProviders(countryCode),
staleTime: 0,
});
- const newStatus = providers.providers.manteca.status;
+ const mantecaProvider = providers.providers.manteca;
+ if (!mantecaProvider) {
+ router.replace({ pathname: "/add-funds/status", params: { status: "error", currency } });
+ return;
+ }
+ const newStatus = mantecaProvider.status;
if (newStatus === "ACTIVE") {📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const providers = await queryClient.fetchQuery({ | |
| queryKey: ["ramp", "providers", countryCode], | |
| queryFn: () => getRampProviders(countryCode), | |
| staleTime: 0, | |
| }); | |
| const newStatus = providers.providers.manteca.status; | |
| if (newStatus === "ACTIVE") { | |
| router.replace({ pathname: "/add-funds/ramp", params: { currency } }); | |
| } else if (newStatus === "ONBOARDING") { | |
| router.replace({ pathname: "/add-funds/status", params: { status: newStatus, currency } }); | |
| } else { | |
| router.replace({ pathname: "/add-funds/status", params: { status: "error", currency } }); | |
| } | |
| const providers = await queryClient.fetchQuery({ | |
| queryKey: ["ramp", "providers", countryCode], | |
| queryFn: () => getRampProviders(countryCode), | |
| staleTime: 0, | |
| }); | |
| const mantecaProvider = providers.providers.manteca; | |
| if (!mantecaProvider) { | |
| router.replace({ pathname: "/add-funds/status", params: { status: "error", currency } }); | |
| return; | |
| } | |
| const newStatus = mantecaProvider.status; | |
| if (newStatus === "ACTIVE") { | |
| router.replace({ pathname: "/add-funds/ramp", params: { currency } }); | |
| } else if (newStatus === "ONBOARDING") { | |
| router.replace({ pathname: "/add-funds/status", params: { status: newStatus, currency } }); | |
| } else { | |
| router.replace({ pathname: "/add-funds/status", params: { status: "error", currency } }); | |
| } |
🤖 Prompt for AI Agents
In `@src/components/add-funds/Kyc.tsx` around lines 54 - 66, The code assumes
providers.providers.manteca exists and reads its status directly; add a
defensive null/undefined check for providers.providers.manteca (after the
queryClient.fetchQuery/getRampProviders call) before accessing manteca.status
and set newStatus to a safe fallback (e.g., "error" or "UNAVAILABLE") when
manteca is missing, then call router.replace accordingly (ensure logic around
newStatus, router.replace, and the existing branches for "ACTIVE"/"ONBOARDING"
handle the fallback case).
| <View gap={20} fullScreen padded> | ||
| <View gap={20}> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Use design tokens for gap values.
Hardcoded gap={20} values should use design tokens.
As per coding guidelines: "All styling must use predefined design tokens."
♻️ Suggested fix
- <View gap={20} fullScreen padded>
- <View gap={20}>
+ <View gap="$s5" fullScreen padded>
+ <View gap="$s5">📝 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.
| <View gap={20} fullScreen padded> | |
| <View gap={20}> | |
| <View gap="$s5" fullScreen padded> | |
| <View gap="$s5"> |
🤖 Prompt for AI Agents
In `@src/components/add-funds/Kyc.tsx` around lines 79 - 80, The two JSX View
elements are using hardcoded gap={20}; replace those with the project design
token for that spacing (e.g., gap={tokens.spacing.md} or gap={tokens.space[20]})
by importing the tokens/spacing object from your design tokens module and using
the appropriate token value (ensure the token name matches the project's token
API and types), updating both occurrences of gap={20} on the View components.
| <View gap={20} fullScreen padded> | ||
| <ScrollView flex={1}> | ||
| <View flex={1} gap={20}> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Use design tokens for gap values.
The gap={20} values should use predefined design tokens from tamagui.config.ts instead of hardcoded numbers.
As per coding guidelines: "All styling must use predefined design tokens from tamagui.config.ts. Never use hardcoded values for colors, spacing, fonts, etc."
♻️ Suggested fix
- <View gap={20} fullScreen padded>
- <ScrollView flex={1}>
- <View flex={1} gap={20}>
+ <View gap="$s5" fullScreen padded>
+ <ScrollView flex={1}>
+ <View flex={1} gap="$s5">📝 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.
| <View gap={20} fullScreen padded> | |
| <ScrollView flex={1}> | |
| <View flex={1} gap={20}> | |
| <View gap="$s5" fullScreen padded> | |
| <ScrollView flex={1}> | |
| <View flex={1} gap="$s5"> |
🤖 Prompt for AI Agents
In `@src/components/add-funds/Status.tsx` around lines 38 - 40, Replace the
hardcoded gap={20} uses in the Status component's View elements with the
appropriate spacing design token from tamagui.config.ts (e.g., the spacing token
equivalent like "$4" or "space-4"); update both the outer View (the fullScreen
padded container) and the inner View (the child with gap) to use that token and,
if required by your setup, import/consume the token or theme so the component
uses the predefined spacing value rather than a raw number.
| <View width="100%" aspectRatio={1} justifyContent="center" alignItems="center"> | ||
| {isOnboarding ? <FaceId width="100%" height="100%" /> : <Denied width="100%" height="100%" />} | ||
| </View> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SVG components require numeric dimensions, not string percentages.
Using width="100%" and height="100%" on SVG components won't work as expected in React Native. SVG props don't resolve percentage strings. Pass numeric values or use the parent container's dimensions.
Based on learnings: "In React Native using SVG components, pass numeric literals for width and height (width={32}, height={32}) rather than design token strings."
🛠️ Suggested fix
<View width="100%" aspectRatio={1} justifyContent="center" alignItems="center">
- {isOnboarding ? <FaceId width="100%" height="100%" /> : <Denied width="100%" height="100%" />}
+ {isOnboarding ? <FaceId width={200} height={200} /> : <Denied width={200} height={200} />}
</View>Alternatively, compute dimensions dynamically from the container or screen dimensions.
📝 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.
| <View width="100%" aspectRatio={1} justifyContent="center" alignItems="center"> | |
| {isOnboarding ? <FaceId width="100%" height="100%" /> : <Denied width="100%" height="100%" />} | |
| </View> | |
| <View width="100%" aspectRatio={1} justifyContent="center" alignItems="center"> | |
| {isOnboarding ? <FaceId width={200} height={200} /> : <Denied width={200} height={200} />} | |
| </View> |
🤖 Prompt for AI Agents
In `@src/components/add-funds/Status.tsx` around lines 43 - 45, The SVG components
FaceId and Denied are being passed percentage strings for width/height which SVG
in React Native doesn't support; update the JSX in the View that renders these
icons (the conditional using isOnboarding) to pass numeric width and height
values (e.g., width={...} height={...}) or compute numbers from the
container/screen dimensions before passing them; ensure the values are numeric
literals or variables (not strings) so FaceId and Denied receive valid numeric
props.
| export function startMantecaKYC() { | ||
| if (current && !current.controller.signal.aborted && current.type === "manteca") return current.promise; | ||
|
|
||
| current?.controller.abort(new Error("persona inquiry aborted")); | ||
| const controller = new AbortController(); | ||
|
|
||
| const promise = (async () => { | ||
| const { signal } = controller; | ||
|
|
||
| if (Platform.OS === "web") { | ||
| const onPageHide = () => controller.abort(new Error("page unloaded")); | ||
| globalThis.addEventListener("pagehide", onPageHide); | ||
| signal.addEventListener("abort", () => globalThis.removeEventListener("pagehide", onPageHide), { once: true }); | ||
| } | ||
|
|
||
| if (Platform.OS === "web") { | ||
| const [{ Client }, { inquiryId, sessionToken }] = await Promise.all([ | ||
| import("persona"), | ||
| getKYCTokens("manteca", await getRedirectURI()), | ||
| ]); | ||
| if (signal.aborted) throw signal.reason; | ||
|
|
||
| return new Promise<MantecaKYCResult>((resolve, reject) => { | ||
| const onAbort = () => { | ||
| client.destroy(); | ||
| reject(new Error("persona inquiry aborted", { cause: signal.reason })); | ||
| }; | ||
| const client = new Client({ | ||
| inquiryId, | ||
| sessionToken, | ||
| environment: environment as "production" | "sandbox", | ||
| onReady: () => client.open(), | ||
| onComplete: () => { | ||
| signal.removeEventListener("abort", onAbort); | ||
| client.destroy(); | ||
| queryClient.invalidateQueries({ queryKey: ["kyc", "manteca"] }).catch(reportError); | ||
| resolve({ status: "complete" }); | ||
| }, | ||
| onCancel: () => { | ||
| signal.removeEventListener("abort", onAbort); | ||
| client.destroy(); | ||
| queryClient.invalidateQueries({ queryKey: ["kyc", "manteca"] }).catch(reportError); | ||
| resolve({ status: "cancel" }); | ||
| }, | ||
| onError: (error) => { | ||
| signal.removeEventListener("abort", onAbort); | ||
| client.destroy(); | ||
| reportError(error); | ||
| resolve({ status: "error" }); | ||
| }, | ||
| }); | ||
| signal.addEventListener("abort", onAbort, { once: true }); | ||
| }); | ||
| } | ||
|
|
||
| const { inquiryId, sessionToken } = await getKYCTokens("manteca", await getRedirectURI()); | ||
| if (signal.aborted) throw signal.reason; | ||
|
|
||
| const { Inquiry } = await import("react-native-persona"); | ||
| return new Promise<MantecaKYCResult>((resolve, reject) => { | ||
| const onAbort = () => reject(new Error("persona inquiry aborted", { cause: signal.reason })); | ||
| signal.addEventListener("abort", onAbort, { once: true }); | ||
| Inquiry.fromInquiry(inquiryId) | ||
| .sessionToken(sessionToken) | ||
| .onCanceled(() => { | ||
| signal.removeEventListener("abort", onAbort); | ||
| queryClient.invalidateQueries({ queryKey: ["kyc", "manteca"] }).catch(reportError); | ||
| resolve({ status: "cancel" }); | ||
| }) | ||
| .onComplete(() => { | ||
| signal.removeEventListener("abort", onAbort); | ||
| queryClient.invalidateQueries({ queryKey: ["kyc", "manteca"] }).catch(reportError); | ||
| resolve({ status: "complete" }); | ||
| }) | ||
| .onError((error) => { | ||
| signal.removeEventListener("abort", onAbort); | ||
| reportError(error); | ||
| resolve({ status: "error" }); | ||
| }) | ||
| .build() | ||
| .start(); | ||
| }); | ||
| })().finally(() => { | ||
| if (current?.controller === controller) current = undefined; | ||
| }); | ||
|
|
||
| current = { type: "manteca", controller, promise }; | ||
| return promise; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Consider extracting shared logic to reduce duplication.
startMantecaKYC duplicates ~80 lines from startKYC. While the return types and post-completion behavior differ intentionally, the following patterns are identical and could be extracted:
- Pagehide listener setup (lines 126-130 mirror 33-36)
- Abort signal handling pattern
- Persona Client/Inquiry builder configuration
This would make future maintenance easier and reduce the risk of divergence bugs.
♻️ Example helper extraction
// helper for pagehide cleanup
function setupPagehideAbort(controller: AbortController) {
if (Platform.OS !== "web") return;
const onPageHide = () => controller.abort(new Error("page unloaded"));
globalThis.addEventListener("pagehide", onPageHide);
controller.signal.addEventListener(
"abort",
() => globalThis.removeEventListener("pagehide", onPageHide),
{ once: true }
);
}Similar helpers could wrap the Persona Client/Inquiry setup with callbacks for onComplete/onCancel/onError.
🤖 Prompt for AI Agents
In `@src/utils/persona.ts` around lines 117 - 205, startMantecaKYC duplicates
large portions of startKYC (pagehide listener, abort handling, Persona
client/inquiry setup); extract shared behavior into small helpers to remove
duplication. Create a helper like setupPagehideAbort(controller) that
installs/removes the pagehide listener and re-use it in both startMantecaKYC and
startKYC; extract a common attachAbortHandler(controller.signal, onAbort)
pattern and a factory that wires Persona callbacks (for Client and Inquiry) to
shared onComplete/onCancel/onError logic (referencing Client, Inquiry, onAbort,
controller.signal, queryClient.invalidateQueries and reportError) so both
functions call the shared helpers and only implement the KYC-specific
return/resolve logic.
| const { code } = (await response.json()) as { code: string }; | ||
| throw new APIError(response.status, code); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Type assertion bypasses runtime validation.
Using as { code: string } bypasses valibot's runtime validation. Consider defining a schema for API error responses and parsing with it for consistent error handling.
♻️ Proposed pattern
import { object, string, parse } from "valibot";
const APIErrorResponse = object({ code: string() });
// Then in error handling:
const { code } = parse(APIErrorResponse, await response.json());This pattern could be applied consistently across lines 170, 186, 283, 295, and 309.
🤖 Prompt for AI Agents
In `@src/utils/server.ts` around lines 170 - 171, The error parsing uses a
TypeScript type assertion ((await response.json()) as { code: string }) which
bypasses runtime validation; define a valibot schema (e.g., APIErrorResponse =
object({ code: string() })) and replace the assertions by calling
parse(APIErrorResponse, await response.json()) and destructuring the returned {
code } before throwing APIError; import object, string, and parse from valibot
and apply this change consistently for the locations that construct APIError
(the occurrences around the current response handling code and the other
mentioned spots).
| return response.json(); | ||
| } | ||
|
|
||
| export type RampProvider = (typeof RampProvider)[number]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remove redundant type redeclaration.
RampProvider is already imported from @exactly/server/utils/ramps/shared on line 20. This line redeclares it, which shadows the import and triggers the static analysis error.
🐛 Proposed fix
-export type RampProvider = (typeof RampProvider)[number];
+export type { RampProvider };Or if you need to re-export the runtime value as well:
+export { RampProvider };🧰 Tools
🪛 Biome (2.3.13)
[error] 176-176: Shouldn't redeclare 'RampProvider'. Consider to delete it or rename it.
'RampProvider' is defined here:
(lint/suspicious/noRedeclare)
🤖 Prompt for AI Agents
In `@src/utils/server.ts` at line 176, Remove the redundant type redeclaration
that shadows the imported RampProvider: delete the line exporting "export type
RampProvider = (typeof RampProvider)[number];" so the imported RampProvider from
`@exactly/server/utils/ramps/shared` is used; if you intended to also re-export a
runtime value, instead export the imported symbol (e.g., export { RampProvider
}) rather than redefining its type.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 6
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/components/add-funds/AddFunds.tsx (1)
57-58: 🧹 Nitpick | 🔵 TrivialUse design tokens for gap values.
gap={20}should use a design token instead of a hardcoded number.As per coding guidelines: "All styling must use predefined design tokens."
♻️ Suggested fix
- <View gap={20} fullScreen padded> - <YStack gap={20}> + <View gap="$s5" fullScreen padded> + <YStack gap="$s5">
🤖 Fix all issues with AI agents
In `@src/components/add-funds/AddFunds.tsx`:
- Around line 38-46: The current useQuery implementation relies on getKYCStatus
as a side-effect to populate queryClient and then reads back via
queryClient.getQueryData, which is indirect and returns "" on failure (truthy)
so downstream providers query can enable incorrectly; change the query to return
the country code directly (have queryFn call an API that returns country or call
getKYCStatus and return its country value) or use the useQuery `select` option
to extract the country from the getKYCStatus response instead of reading
queryClient; also ensure the queryFn returns undefined (not empty string) when
the country is absent or getKYCStatus throws so the providers query remains
disabled.
In `@src/components/add-funds/Kyc.tsx`:
- Around line 23-71: The Kyc component currently contains routing, mutations and
cache reads (useMutation handling via handleContinue, completeOnboarding,
startMantecaKYC, startRampOnboarding, queryClient and router usage) and must be
converted to a dumb presentational component; extract all side-effect and
stateful logic into a new screen/container (e.g., KycContainer) which owns the
mutation (mutationKey ["kyc","complete","manteca"]), completeOnboarding flow,
queryClient interactions (invalidateQueries/fetchQuery), router.replace calls
and error reporting (reportError), and pass only props into the Kyc UI component
(e.g., isPending, onContinue callback, validCurrency, currency, countryCode) so
src/components/Kyc.tsx becomes purely presentational and receives callbacks/data
via props.
- Around line 30-43: The inline multi-line mutationFn passed to useMutation and
the multi-line Pressable onPress handler should be moved into named function
declarations for stability and readability: extract the async mutation body into
a function like function handleMantecaKyc() that calls startMantecaKYC(), checks
result.status ("cancel" / "error") and calls router.replace or
completeOnboarding() as currently implemented, then pass that function as
mutationFn to useMutation; likewise extract the Pressable handler into a named
function (e.g., function onPressContinue()) and reference it in the Pressable.
Ensure you keep existing symbols (startMantecaKYC, completeOnboarding,
router.replace, mutateAsync/handleContinue) and behavior unchanged while
replacing inline arrow bodies with these function declarations.
In `@src/components/add-funds/Onboard.tsx`:
- Line 24: The currencyImages map currently types SVG components as React.FC<{
height: string; width: string }> and the ARS, BRL, USD components are being
passed string percentages like "100%"; change the prop type to React.FC<{
height: number; width: number }>, update any usages of
currencyImages/ARS/BRL/USD to compute and pass numeric pixel values (e.g.,
containerHeight or parsed values) instead of "100%", and adjust places
referenced around the same usage (lines ~120-121) to pass numbers so the SVG
components receive numeric dimensions.
In `@src/components/add-funds/Ramp.tsx`:
- Line 92: Replace the hardcoded numeric gap value on the JSX View inside the
Ramp component: locate the View element with props flexDirection="row" gap={10}
justifyContent="space-between" alignItems="center" and change gap to use the
design token (for example gap="$s3") so all styling uses predefined design
tokens instead of raw numbers.
In `@src/utils/currencies.ts`:
- Around line 1-12: Replace the manual Currency type and import with valibot's
InferInput: import InferInput from valibot and derive Currency from the already
defined CurrencySchema (e.g., type Currency = InferInput<typeof CurrencySchema>)
instead of using keyof typeof currencyMap; keep currencyMap and CurrencySchema
(picklist) as-is so the schema is the single source of truth and the derived
Currency type stays in sync with CurrencySchema and currencyMap.
| const { data: countryCode } = useQuery({ | ||
| queryKey: ["user", "country"], | ||
| queryFn: async () => { | ||
| await getKYCStatus("basic", true); | ||
| return queryClient.getQueryData<string>(["user", "country"]) ?? ""; | ||
| }, | ||
| staleTime: (query) => (query.state.data ? Infinity : 0), | ||
| retry: false, | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
The query pattern relies on side-effect data population.
The queryFn calls getKYCStatus which sets ["user", "country"] in the query client as a side effect, then reads it back. This works but is indirect. Consider returning the country code directly from the API or using select to extract it from the response.
Additionally, if getKYCStatus throws before setting the query data, the fallback empty string "" will be returned, which is truthy and may enable the providers query unexpectedly.
♻️ Potential simplification
const { data: countryCode } = useQuery({
queryKey: ["user", "country"],
queryFn: async () => {
await getKYCStatus("basic", true);
- return queryClient.getQueryData<string>(["user", "country"]) ?? "";
+ return queryClient.getQueryData<string>(["user", "country"]);
},
staleTime: (query) => (query.state.data ? Infinity : 0),
retry: false,
});This returns undefined instead of "" on failure, making the providers query stay disabled.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const { data: countryCode } = useQuery({ | |
| queryKey: ["user", "country"], | |
| queryFn: async () => { | |
| await getKYCStatus("basic", true); | |
| return queryClient.getQueryData<string>(["user", "country"]) ?? ""; | |
| }, | |
| staleTime: (query) => (query.state.data ? Infinity : 0), | |
| retry: false, | |
| }); | |
| const { data: countryCode } = useQuery({ | |
| queryKey: ["user", "country"], | |
| queryFn: async () => { | |
| await getKYCStatus("basic", true); | |
| return queryClient.getQueryData<string>(["user", "country"]); | |
| }, | |
| staleTime: (query) => (query.state.data ? Infinity : 0), | |
| retry: false, | |
| }); |
🤖 Prompt for AI Agents
In `@src/components/add-funds/AddFunds.tsx` around lines 38 - 46, The current
useQuery implementation relies on getKYCStatus as a side-effect to populate
queryClient and then reads back via queryClient.getQueryData, which is indirect
and returns "" on failure (truthy) so downstream providers query can enable
incorrectly; change the query to return the country code directly (have queryFn
call an API that returns country or call getKYCStatus and return its country
value) or use the useQuery `select` option to extract the country from the
getKYCStatus response instead of reading queryClient; also ensure the queryFn
returns undefined (not empty string) when the country is absent or getKYCStatus
throws so the providers query remains disabled.
| export default function Kyc() { | ||
| const { t } = useTranslation(); | ||
| const router = useRouter(); | ||
| const { currency } = useLocalSearchParams<{ currency: string }>(); | ||
| const countryCode = queryClient.getQueryData<string>(["user", "country"]) ?? ""; | ||
| const validCurrency = isValidCurrency(currency); | ||
|
|
||
| const { mutateAsync: handleContinue, isPending } = useMutation({ | ||
| mutationKey: ["kyc", "complete", "manteca"], | ||
| mutationFn: async () => { | ||
| const result = await startMantecaKYC(); | ||
| if (result.status === "cancel") return; | ||
| if (result.status === "error") { | ||
| router.replace({ | ||
| pathname: "/add-funds/status", | ||
| params: { status: "error", currency }, | ||
| }); | ||
| return; | ||
| } | ||
| await completeOnboarding(); | ||
| }, | ||
| }); | ||
|
|
||
| if (!validCurrency) return <Redirect href="/add-funds" />; | ||
|
|
||
| async function completeOnboarding() { | ||
| try { | ||
| await startRampOnboarding({ provider: "manteca" }); | ||
|
|
||
| await queryClient.invalidateQueries({ queryKey: ["ramp", "providers"] }); | ||
|
|
||
| const providers = await queryClient.fetchQuery({ | ||
| queryKey: ["ramp", "providers", countryCode], | ||
| queryFn: () => getRampProviders(countryCode), | ||
| staleTime: 0, | ||
| }); | ||
| const newStatus = providers.providers.manteca.status; | ||
| if (newStatus === "ACTIVE") { | ||
| router.replace({ pathname: "/add-funds/ramp", params: { currency } }); | ||
| } else if (newStatus === "ONBOARDING") { | ||
| router.replace({ pathname: "/add-funds/status", params: { status: newStatus, currency } }); | ||
| } else { | ||
| router.replace({ pathname: "/add-funds/status", params: { status: "error", currency } }); | ||
| } | ||
| } catch (error) { | ||
| reportError(error); | ||
| router.replace({ pathname: "/add-funds/status", params: { status: "error", currency } }); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
Move Kyc screen logic out of src/components.
This component drives routing, mutations, and cache reads; per project structure rules it should be a screen/container, leaving this file as a presentational UI component.
As per coding guidelines: "src/components/**/*.{ts,tsx}: Dumb components (UI) should receive data and callbacks via props with no knowledge of the application's state."
🤖 Prompt for AI Agents
In `@src/components/add-funds/Kyc.tsx` around lines 23 - 71, The Kyc component
currently contains routing, mutations and cache reads (useMutation handling via
handleContinue, completeOnboarding, startMantecaKYC, startRampOnboarding,
queryClient and router usage) and must be converted to a dumb presentational
component; extract all side-effect and stateful logic into a new
screen/container (e.g., KycContainer) which owns the mutation (mutationKey
["kyc","complete","manteca"]), completeOnboarding flow, queryClient interactions
(invalidateQueries/fetchQuery), router.replace calls and error reporting
(reportError), and pass only props into the Kyc UI component (e.g., isPending,
onContinue callback, validCurrency, currency, countryCode) so
src/components/Kyc.tsx becomes purely presentational and receives callbacks/data
via props.
| const { mutateAsync: handleContinue, isPending } = useMutation({ | ||
| mutationKey: ["kyc", "complete", "manteca"], | ||
| mutationFn: async () => { | ||
| const result = await startMantecaKYC(); | ||
| if (result.status === "cancel") return; | ||
| if (result.status === "error") { | ||
| router.replace({ | ||
| pathname: "/add-funds/status", | ||
| params: { status: "error", currency }, | ||
| }); | ||
| return; | ||
| } | ||
| await completeOnboarding(); | ||
| }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Extract multi-line inline callbacks into named functions.
The mutationFn and Pressable handler are multi-line; move them to named functions for clarity and to keep handlers stable.
As per coding guidelines: "Prefer function declarations for all multi-line functions; use function expressions or arrow functions only for single-line implementations."
♻️ Proposed refactor
- const { mutateAsync: handleContinue, isPending } = useMutation({
- mutationKey: ["kyc", "complete", "manteca"],
- mutationFn: async () => {
- const result = await startMantecaKYC();
- if (result.status === "cancel") return;
- if (result.status === "error") {
- router.replace({
- pathname: "/add-funds/status",
- params: { status: "error", currency },
- });
- return;
- }
- await completeOnboarding();
- },
- });
+ async function runKycFlow() {
+ const result = await startMantecaKYC();
+ if (result.status === "cancel") return;
+ if (result.status === "error") {
+ router.replace({ pathname: "/add-funds/status", params: { status: "error", currency } });
+ return;
+ }
+ await completeOnboarding();
+ }
+
+ const { mutateAsync: handleContinue, isPending } = useMutation({
+ mutationKey: ["kyc", "complete", "manteca"],
+ mutationFn: runKycFlow,
+ });
+
+ function handleBackPress() {
+ if (router.canGoBack()) {
+ router.back();
+ } else {
+ router.replace("/(main)/(home)");
+ }
+ }
@@
- <Pressable
- onPress={() => {
- if (router.canGoBack()) {
- router.back();
- } else {
- router.replace("/(main)/(home)");
- }
- }}
- >
+ <Pressable onPress={handleBackPress}>Also applies to: 82-89
🤖 Prompt for AI Agents
In `@src/components/add-funds/Kyc.tsx` around lines 30 - 43, The inline multi-line
mutationFn passed to useMutation and the multi-line Pressable onPress handler
should be moved into named function declarations for stability and readability:
extract the async mutation body into a function like function handleMantecaKyc()
that calls startMantecaKYC(), checks result.status ("cancel" / "error") and
calls router.replace or completeOnboarding() as currently implemented, then pass
that function as mutationFn to useMutation; likewise extract the Pressable
handler into a named function (e.g., function onPressContinue()) and reference
it in the Pressable. Ensure you keep existing symbols (startMantecaKYC,
completeOnboarding, router.replace, mutateAsync/handleContinue) and behavior
unchanged while replacing inline arrow bodies with these function declarations.
| import Text from "../shared/Text"; | ||
| import View from "../shared/View"; | ||
|
|
||
| const currencyImages: Record<string, React.FC<{ height: string; width: string }>> = { ARS, BRL, USD }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
head -150 src/components/add-funds/Onboard.tsx | tail -130Repository: exactly/exa
Length of output: 4870
🏁 Script executed:
sed -n '1,30p' src/components/add-funds/Onboard.tsxRepository: exactly/exa
Length of output: 1272
🏁 Script executed:
sed -n '115,125p' src/components/add-funds/Onboard.tsxRepository: exactly/exa
Length of output: 665
🏁 Script executed:
rg -n "width.*height" src/components/add-funds/Onboard.tsxRepository: exactly/exa
Length of output: 121
🏁 Script executed:
fd -e svg src/assets/images | grep -E "(ars|brl|usd)" | head -5Repository: exactly/exa
Length of output: 428
🏁 Script executed:
rg "CurrencyImage|ARS|BRL|USD" src/components --type tsx -A 2 | head -40Repository: exactly/exa
Length of output: 82
🏁 Script executed:
fd . src/assets/images | head -10Repository: exactly/exa
Length of output: 407
🏁 Script executed:
rg "width=" src/components -A 2 | grep -E "(CurrencyImage|SVG|svg)" -A 2 -B 2 | head -30Repository: exactly/exa
Length of output: 819
🏁 Script executed:
cat package.json | grep -E "(svg|react-native|expo)"Repository: exactly/exa
Length of output: 3534
🏁 Script executed:
cat src/components/shared/Blocky.tsx | head -50Repository: exactly/exa
Length of output: 1383
🏁 Script executed:
rg "React.FC.*height.*width" src/components -A 2 -B 2Repository: exactly/exa
Length of output: 415
🏁 Script executed:
rg "SVG.*width|SVG.*height" src/components -B 2 -A 2 | head -40Repository: exactly/exa
Length of output: 413
🏁 Script executed:
rg "currencyImages|ARS|BRL|USD" src/ -B 3 -A 3 --type ts --type tsxRepository: exactly/exa
Length of output: 82
🏁 Script executed:
cat src/components/shared/Blocky.tsx | sed -n '1,10p'Repository: exactly/exa
Length of output: 240
🏁 Script executed:
rg "currencyImages|ARS|BRL|USD" src -B 3 -A 3Repository: exactly/exa
Length of output: 50367
🏁 Script executed:
cat src/components/shared/View.tsx | head -40Repository: exactly/exa
Length of output: 516
Use numeric values for SVG component dimensions.
The currency image components (ARS, BRL, USD) are SVG-based and expect numeric dimensions, not string percentages. Update the prop type from string to number and pass computed numeric values instead of "100%".
🛠️ Example fix
-const currencyImages: Record<string, React.FC<{ height: string; width: string }>> = { ARS, BRL, USD };
+const currencyImages: Record<string, React.FC<{ height: number; width: number }>> = { ARS, BRL, USD };
@@
- <CurrencyImage width="100%" height="100%" />
+ <CurrencyImage width={200} height={200} />Also applies to: 120-121
🤖 Prompt for AI Agents
In `@src/components/add-funds/Onboard.tsx` at line 24, The currencyImages map
currently types SVG components as React.FC<{ height: string; width: string }>
and the ARS, BRL, USD components are being passed string percentages like
"100%"; change the prop type to React.FC<{ height: number; width: number }>,
update any usages of currencyImages/ARS/BRL/USD to compute and pass numeric
pixel values (e.g., containerHeight or parsed values) instead of "100%", and
adjust places referenced around the same usage (lines ~120-121) to pass numbers
so the SVG components receive numeric dimensions.
| <SafeView fullScreen> | ||
| <View gap={20} fullScreen padded> | ||
| <View gap={20}> | ||
| <View flexDirection="row" gap={10} justifyContent="space-between" alignItems="center"> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Use design tokens for gap values.
gap={10} should use a design token (e.g., gap="$s3").
As per coding guidelines: "All styling must use predefined design tokens."
♻️ Suggested fix
- <View flexDirection="row" gap={10} justifyContent="space-between" alignItems="center">
+ <View flexDirection="row" gap="$s3" justifyContent="space-between" alignItems="center">📝 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.
| <View flexDirection="row" gap={10} justifyContent="space-between" alignItems="center"> | |
| <View flexDirection="row" gap="$s3" justifyContent="space-between" alignItems="center"> |
🤖 Prompt for AI Agents
In `@src/components/add-funds/Ramp.tsx` at line 92, Replace the hardcoded numeric
gap value on the JSX View inside the Ramp component: locate the View element
with props flexDirection="row" gap={10} justifyContent="space-between"
alignItems="center" and change gap to use the design token (for example
gap="$s3") so all styling uses predefined design tokens instead of raw numbers.
| import { picklist, safeParse } from "valibot"; | ||
|
|
||
| export const currencyMap = { | ||
| ARS: { name: "Argentinian Pesos", emoji: "🇦🇷" }, | ||
| BRL: { name: "Brazilian Real", emoji: "🇧🇷" }, | ||
| USD: { name: "US Dollars", emoji: "🇺🇸" }, | ||
| } as const; | ||
|
|
||
| export type Currency = keyof typeof currencyMap; | ||
|
|
||
| export const CurrencySchema = picklist(Object.keys(currencyMap) as [Currency, ...Currency[]]); | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# First, check if valibot is available and what it exports
rg -t ts "from ['\"]valibot" src/ | head -20Repository: exactly/exa
Length of output: 1423
🏁 Script executed:
# Check the full context of the currencies.ts file
cat -n src/utils/currencies.tsRepository: exactly/exa
Length of output: 666
🏁 Script executed:
# Search for usages of Currency type to understand if changing its definition would impact anything
rg "Currency" src/ -t ts --context 2Repository: exactly/exa
Length of output: 5882
🏁 Script executed:
# Check if valibot's Input type is documented in package.json or dependencies
cat package.json | grep -A 5 "valibot"Repository: exactly/exa
Length of output: 419
🏁 Script executed:
# Check if there are other files using Input from valibot to confirm the correct export name
rg "Input|InferInput" src/ -t ts | grep "from.*valibot"Repository: exactly/exa
Length of output: 167
🏁 Script executed:
# Verify the valibot version and API
cat package.json | grep -E '"valibot":|"@hono/valibot-validator":'Repository: exactly/exa
Length of output: 81
🌐 Web query:
valibot Input type export TypeScript inference
💡 Result:
Use Valibot’s utility type InferInput and export it from the schema’s typeof:
import * as v from "valibot";
export const LoginSchema = v.object({
email: v.string(),
password: v.string(),
});
export type LoginInput = v.InferInput<typeof LoginSchema>;
export type LoginOutput = v.InferOutput<typeof LoginSchema>; // optionalInferInput<typeof Schema>= the type accepted as input to validation. (valibot.dev)InferOutput<typeof Schema>= the type returned byparse/safeParse(can differ if you use defaults/transforms/brands, etc.). (valibot.dev)- If you want a clean export pattern for both, Valibot recommends suffixing with
Schema/Input/Output. (valibot.dev)
Citations:
- 1: https://valibot.dev/guides/infer-types/?utm_source=openai
- 2: https://valibot.dev/guides/infer-types/?utm_source=openai
- 3: https://valibot.dev/guides/naming-convention/?utm_source=openai
Use InferInput instead of Input to derive Currency from CurrencySchema.
The core concept is correct—derive the type from the schema to keep them in sync and avoid the circular reference. However, valibot uses InferInput, not Input. The codebase already uses this pattern in src/utils/server.ts.
♻️ Suggested fix
-import { picklist, safeParse } from "valibot";
+import { picklist, safeParse } from "valibot";
+import type { InferInput } from "valibot";
@@
-export type Currency = keyof typeof currencyMap;
-
-export const CurrencySchema = picklist(Object.keys(currencyMap) as [Currency, ...Currency[]]);
+export const CurrencySchema = picklist(
+ Object.keys(currencyMap) as [keyof typeof currencyMap, ...(keyof typeof currencyMap)[]],
+);
+export type Currency = InferInput<typeof CurrencySchema>;📝 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.
| import { picklist, safeParse } from "valibot"; | |
| export const currencyMap = { | |
| ARS: { name: "Argentinian Pesos", emoji: "🇦🇷" }, | |
| BRL: { name: "Brazilian Real", emoji: "🇧🇷" }, | |
| USD: { name: "US Dollars", emoji: "🇺🇸" }, | |
| } as const; | |
| export type Currency = keyof typeof currencyMap; | |
| export const CurrencySchema = picklist(Object.keys(currencyMap) as [Currency, ...Currency[]]); | |
| import { picklist, safeParse } from "valibot"; | |
| import type { InferInput } from "valibot"; | |
| export const currencyMap = { | |
| ARS: { name: "Argentinian Pesos", emoji: "🇦🇷" }, | |
| BRL: { name: "Brazilian Real", emoji: "🇧🇷" }, | |
| USD: { name: "US Dollars", emoji: "🇺🇸" }, | |
| } as const; | |
| export const CurrencySchema = picklist( | |
| Object.keys(currencyMap) as [keyof typeof currencyMap, ...(keyof typeof currencyMap)[]], | |
| ); | |
| export type Currency = InferInput<typeof CurrencySchema>; |
🤖 Prompt for AI Agents
In `@src/utils/currencies.ts` around lines 1 - 12, Replace the manual Currency
type and import with valibot's InferInput: import InferInput from valibot and
derive Currency from the already defined CurrencySchema (e.g., type Currency =
InferInput<typeof CurrencySchema>) instead of using keyof typeof currencyMap;
keep currencyMap and CurrencySchema (picklist) as-is so the schema is the single
source of truth and the derived Currency type stays in sync with CurrencySchema
and currencyMap.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 5
🤖 Fix all issues with AI agents
In `@src/components/add-funds/AddFunds.tsx`:
- Around line 93-95: The onPress handler currently calls router.push with a raw
string ("/add-funds/bridge"); replace this with the Expo Router typed route
constant/function (e.g., use the generated Routes enum/const for the add-funds
bridge route) so navigation is type-safe. Update the onPress in AddFunds.tsx to
call router.push with the typed route (reference the existing useRouter()
instance) and add the import for the generated route identifiers (the Routes or
route helper used across the project) so the route is not constructed as a raw
string.
In `@src/components/add-funds/Kyc.tsx`:
- Line 27: The component reads countryCode at render time which can become
stale; move the queryClient.getQueryData<string>(["user","country"]) call into
the completeOnboarding function so the mutation always reads the latest cache
value. Update the code to remove the top-level countryCode constant and instead
call queryClient.getQueryData inside completeOnboarding (referencing the same
query key), then use that local countryCode variable in the existing mutation
payload/logic.
In `@src/components/add-funds/Onboard.tsx`:
- Around line 70-76: The code assumes providers.providers.manteca always exists
and then reads its status into newStatus, which can throw if manteca is missing;
update the logic around the fetchQuery result (the providers variable returned
by queryClient.fetchQuery / getRampProviders) to null-check or use optional
chaining for providers.providers.manteca before accessing .status and provide a
safe fallback (e.g., undefined or a default status) and handle that fallback
where newStatus is used so missing manteca entries won't cause a runtime error.
In `@src/components/add-funds/Status.tsx`:
- Around line 30-34: The code uses raw path strings in Redirect and
router.replace (see Redirect and handleClose/router.replace) which breaks the
typed-route convention; replace the string routes with the app's typed route
helpers (use the generated typed routes or route constants) so Redirect gets a
typed route prop and handleClose calls router.replace with the corresponding
typed route value instead of "/add-funds" or "/(main)/(home)"; update the
imports to pull the typed routes (or route helper functions) and use those
symbols wherever validCurrency gating currently returns or navigates.
In `@src/utils/server.ts`:
- Around line 302-314: The onboardingData parameter of startRampOnboarding is
overly restrictive (typed as { provider: "manteca" }); change its type to accept
the broader RampProvider (or a union that includes "manteca" and future
providers) so the function signature becomes something like
startRampOnboarding(onboardingData: { provider: RampProvider }) and update any
callers to pass a RampProvider value; keep the body (auth(), api.ramp.$post,
error handling and return response.json()) unchanged but ensure
imports/reference to RampProvider are added where startRampOnboarding is
defined.
| const { t } = useTranslation(); | ||
| const router = useRouter(); | ||
| const { currency } = useLocalSearchParams<{ currency: string }>(); | ||
| const countryCode = queryClient.getQueryData<string>(["user", "country"]) ?? ""; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
Move countryCode read into completeOnboarding to avoid stale cache reference.
Reading countryCode at component level captures the value at render time. If the cache updates during the mutation flow, completeOnboarding will use the stale value. Move the read inside completeOnboarding for the most current value.
♻️ Suggested fix
- const countryCode = queryClient.getQueryData<string>(["user", "country"]) ?? "";
@@
async function completeOnboarding() {
try {
await startRampOnboarding({ provider: "manteca" });
await queryClient.invalidateQueries({ queryKey: ["ramp", "providers"] });
+ const countryCode = queryClient.getQueryData<string>(["user", "country"]);
const providers = await queryClient.fetchQuery({🤖 Prompt for AI Agents
In `@src/components/add-funds/Kyc.tsx` at line 27, The component reads countryCode
at render time which can become stale; move the
queryClient.getQueryData<string>(["user","country"]) call into the
completeOnboarding function so the mutation always reads the latest cache value.
Update the code to remove the top-level countryCode constant and instead call
queryClient.getQueryData inside completeOnboarding (referencing the same query
key), then use that local countryCode variable in the existing mutation
payload/logic.
| const providers = await queryClient.fetchQuery({ | ||
| queryKey: ["ramp", "providers", countryCode], | ||
| queryFn: () => getRampProviders(countryCode), | ||
| staleTime: 0, | ||
| }); | ||
|
|
||
| const newStatus = providers.providers.manteca.status; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add null check for manteca provider to prevent runtime error.
providers.providers.manteca is accessed without checking if the manteca key exists. If the provider response doesn't include manteca (e.g., due to country restrictions), this will throw a runtime error.
🛡️ Add defensive check
const providers = await queryClient.fetchQuery({
queryKey: ["ramp", "providers", countryCode],
queryFn: () => getRampProviders(countryCode),
staleTime: 0,
});
- const newStatus = providers.providers.manteca.status;
+ const mantecaProvider = providers.providers.manteca;
+ if (!mantecaProvider) {
+ router.replace({ pathname: "/add-funds/status", params: { status: "error", currency } });
+ return;
+ }
+ const newStatus = mantecaProvider.status;🤖 Prompt for AI Agents
In `@src/components/add-funds/Onboard.tsx` around lines 70 - 76, The code assumes
providers.providers.manteca always exists and then reads its status into
newStatus, which can throw if manteca is missing; update the logic around the
fetchQuery result (the providers variable returned by queryClient.fetchQuery /
getRampProviders) to null-check or use optional chaining for
providers.providers.manteca before accessing .status and provide a safe fallback
(e.g., undefined or a default status) and handle that fallback where newStatus
is used so missing manteca entries won't cause a runtime error.
| export async function startRampOnboarding(onboardingData: { provider: "manteca" }) { | ||
| await auth(); | ||
| const response = await api.ramp.$post({ | ||
| json: onboardingData, | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| const { code } = await response.json(); | ||
| throw new APIError(response.status, code); | ||
| } | ||
|
|
||
| return response.json(); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Consider making the provider parameter more flexible.
The onboardingData parameter is typed as { provider: "manteca" } which is very restrictive. If additional providers are planned, consider using the RampProvider type.
♻️ Suggested improvement
-export async function startRampOnboarding(onboardingData: { provider: "manteca" }) {
+export async function startRampOnboarding(onboardingData: { provider: RampProvider }) {🤖 Prompt for AI Agents
In `@src/utils/server.ts` around lines 302 - 314, The onboardingData parameter of
startRampOnboarding is overly restrictive (typed as { provider: "manteca" });
change its type to accept the broader RampProvider (or a union that includes
"manteca" and future providers) so the function signature becomes something like
startRampOnboarding(onboardingData: { provider: RampProvider }) and update any
callers to pass a RampProvider value; keep the body (auth(), api.ramp.$post,
error handling and return response.json()) unchanged but ensure
imports/reference to RampProvider are added where startRampOnboarding is
defined.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/utils/server.ts (1)
178-190: 🧹 Nitpick | 🔵 Trivial
createMantecaKYCduplicatesgetKYCTokenslogic.
createMantecaKYCis nearly identical to callinggetKYCTokens("manteca", redirectURI). Consider reusing the existing function to reduce duplication.♻️ Suggested consolidation
export async function createMantecaKYC(redirectURI?: string) { - await auth(); - const response = await api.kyc.$post({ json: { scope: "manteca", redirectURI } }); - if (!response.ok) { - const { code } = (await response.json()) as { code: string }; - throw new APIError(response.status, code); - } - return response.json(); + return getKYCTokens("manteca", redirectURI); }
🤖 Fix all issues with AI agents
In `@src/components/add-funds/AddFiatButton.tsx`:
- Around line 25-46: The router.push calls in handlePress should use compact
single-line object literals; update each case (NOT_STARTED, ONBOARDING, ACTIVE)
so their router.push invocation passes a single-line object (e.g., router.push({
pathname: "/add-funds/onboard", params: { currency } })) rather than multi-line
blocks—locate handlePress and replace the multi-line objects passed to
router.push with single-line equivalents to satisfy the compact-style rule.
In `@src/components/add-funds/Onboard.tsx`:
- Line 60: CurrencyImage is being computed before the validCurrency check,
causing an undefined or invalid currency to temporarily resolve to ARS; move the
CurrencyImage resolution so it only runs after validation (i.e., compute
CurrencyImage after the validCurrency check or guard with validCurrency) by
updating the code around the CurrencyImage assignment (reference symbols:
CurrencyImage, currency, currencyImages, ARS, validCurrency) so that an
invalid/missing currency does not momentarily pick ARS before the redirect.
In `@src/components/add-funds/Ramp.tsx`:
- Line 67: The current assignment const depositInfo = data?.depositInfo[0] can
yield undefined when depositInfo is an empty array; change it to explicitly
handle empty arrays by using either data?.depositInfo?.at(0) or a length check
(e.g., data?.depositInfo?.length ? data.depositInfo[0] : undefined) so
depositInfo is deterministically undefined only when there is no element, and
update any downstream usage that assumes an element exists to handle the
explicit undefined from depositInfo in the Ramp component.
- Around line 57-63: The query currently casts currency with `currency as
Currency` even though `validCurrency` already guarantees validity; instead,
narrow the value before calling useQuery to avoid inline casts: compute a typed
variable (e.g., const typedCurrency = validCurrency ? currency as Currency :
undefined), set useQuery enabled to !!typedCurrency, and pass typedCurrency into
the queryFn for getRampQuote; reference symbols: useQuery, getRampQuote,
validCurrency, currency, and queryFn. This ensures the type is asserted once
outside the query and removes the unsafe cast inside the queryFn.
In `@src/i18n/es.json`:
- Around line 540-560: The new add-funds i18n keys were appended to the end of
es.json; move them into the existing onboarding/fiat section (or insert in
sorted order) so related strings are grouped—locate keys like "Turn {{currency}}
transfers to onchain USDC", "Transfer from accounts in your name and
automatically receive USDC in your Exa account.", "Copy and share your account
details to turn {{currency}} transfers into USDC.", "Account details",
"Beneficiary name", and "Continue verification" and insert them near the other
onboarding/fiat strings (or alphabetically among nearby keys) rather than
leaving them at the end of the file.
| function handlePress() { | ||
| switch (status) { | ||
| case "NOT_STARTED": | ||
| router.push({ | ||
| pathname: "/add-funds/onboard", | ||
| params: { currency }, | ||
| }); | ||
| break; | ||
|
|
||
| case "ONBOARDING": | ||
| router.push({ | ||
| pathname: "/add-funds/status", | ||
| params: { status: "ONBOARDING", currency }, | ||
| }); | ||
| break; | ||
|
|
||
| case "ACTIVE": | ||
| router.push({ | ||
| pathname: "/add-funds/ramp", | ||
| params: { currency }, | ||
| }); | ||
| break; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Keep router.push calls compact (single-line objects).
These objects fit within print width; keeping them on one line matches the compact-style rule.
As per coding guidelines: Enforce maximally compact code style. Do not introduce line breaks inside objects, arrays, or function arguments voluntarily.
♻️ Proposed fix
- router.push({
- pathname: "/add-funds/onboard",
- params: { currency },
- });
+ router.push({ pathname: "/add-funds/onboard", params: { currency }, });
@@
- router.push({
- pathname: "/add-funds/status",
- params: { status: "ONBOARDING", currency },
- });
+ router.push({ pathname: "/add-funds/status", params: { status: "ONBOARDING", currency }, });
@@
- router.push({
- pathname: "/add-funds/ramp",
- params: { currency },
- });
+ router.push({ pathname: "/add-funds/ramp", params: { currency }, });📝 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.
| function handlePress() { | |
| switch (status) { | |
| case "NOT_STARTED": | |
| router.push({ | |
| pathname: "/add-funds/onboard", | |
| params: { currency }, | |
| }); | |
| break; | |
| case "ONBOARDING": | |
| router.push({ | |
| pathname: "/add-funds/status", | |
| params: { status: "ONBOARDING", currency }, | |
| }); | |
| break; | |
| case "ACTIVE": | |
| router.push({ | |
| pathname: "/add-funds/ramp", | |
| params: { currency }, | |
| }); | |
| break; | |
| function handlePress() { | |
| switch (status) { | |
| case "NOT_STARTED": | |
| router.push({ pathname: "/add-funds/onboard", params: { currency }, }); | |
| break; | |
| case "ONBOARDING": | |
| router.push({ pathname: "/add-funds/status", params: { status: "ONBOARDING", currency }, }); | |
| break; | |
| case "ACTIVE": | |
| router.push({ pathname: "/add-funds/ramp", params: { currency }, }); | |
| break; |
🤖 Prompt for AI Agents
In `@src/components/add-funds/AddFiatButton.tsx` around lines 25 - 46, The
router.push calls in handlePress should use compact single-line object literals;
update each case (NOT_STARTED, ONBOARDING, ACTIVE) so their router.push
invocation passes a single-line object (e.g., router.push({ pathname:
"/add-funds/onboard", params: { currency } })) rather than multi-line
blocks—locate handlePress and replace the multi-line objects passed to
router.push with single-line equivalents to satisfy the compact-style rule.
| }, | ||
| }); | ||
|
|
||
| const CurrencyImage = currency ? (currencyImages[currency] ?? ARS) : ARS; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Consider edge case when currency is undefined.
When currency is undefined, the fallback ARS is used. However, this line executes before the validCurrency check on line 62, so an invalid/missing currency briefly resolves to ARS before redirect.
♻️ Move after validation
+ if (!validCurrency) return <Redirect href="/add-funds" />;
+
const CurrencyImage = currency ? (currencyImages[currency] ?? ARS) : ARS;
- if (!validCurrency) return <Redirect href="/add-funds" />;🤖 Prompt for AI Agents
In `@src/components/add-funds/Onboard.tsx` at line 60, CurrencyImage is being
computed before the validCurrency check, causing an undefined or invalid
currency to temporarily resolve to ARS; move the CurrencyImage resolution so it
only runs after validation (i.e., compute CurrencyImage after the validCurrency
check or guard with validCurrency) by updating the code around the CurrencyImage
assignment (reference symbols: CurrencyImage, currency, currencyImages, ARS,
validCurrency) so that an invalid/missing currency does not momentarily pick ARS
before the redirect.
| const { data, isPending } = useQuery({ | ||
| queryKey: ["ramp", "quote", "manteca", currency], | ||
| queryFn: () => getRampQuote({ provider: "manteca", currency: currency as Currency }), | ||
| enabled: validCurrency, | ||
| refetchInterval: 30_000, | ||
| staleTime: 10_000, | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Query enabled but currency cast bypasses type guard.
The query is enabled when validCurrency is true, but the queryFn uses currency as Currency which bypasses the type guard. Since validCurrency ensures the type is valid, consider using a type-safe pattern.
♻️ Suggested improvement
const { data, isPending } = useQuery({
queryKey: ["ramp", "quote", "manteca", currency],
- queryFn: () => getRampQuote({ provider: "manteca", currency: currency as Currency }),
+ queryFn: () => {
+ if (!isValidCurrency(currency)) throw new Error("invalid currency");
+ return getRampQuote({ provider: "manteca", currency });
+ },
enabled: validCurrency,
refetchInterval: 30_000,
staleTime: 10_000,
});🤖 Prompt for AI Agents
In `@src/components/add-funds/Ramp.tsx` around lines 57 - 63, The query currently
casts currency with `currency as Currency` even though `validCurrency` already
guarantees validity; instead, narrow the value before calling useQuery to avoid
inline casts: compute a typed variable (e.g., const typedCurrency =
validCurrency ? currency as Currency : undefined), set useQuery enabled to
!!typedCurrency, and pass typedCurrency into the queryFn for getRampQuote;
reference symbols: useQuery, getRampQuote, validCurrency, currency, and queryFn.
This ensures the type is asserted once outside the query and removes the unsafe
cast inside the queryFn.
|
|
||
| if (!validCurrency) return <Redirect href="/add-funds" />; | ||
|
|
||
| const depositInfo = data?.depositInfo[0]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Potential undefined access when depositInfo array is empty.
data?.depositInfo[0] will be undefined if the array exists but is empty. The downstream code handles undefined values, but consider adding an explicit check or using .at(0) for clarity.
♻️ Suggested improvement
- const depositInfo = data?.depositInfo[0];
+ const depositInfo = data?.depositInfo.at(0);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const depositInfo = data?.depositInfo[0]; | |
| const depositInfo = data?.depositInfo.at(0); |
🤖 Prompt for AI Agents
In `@src/components/add-funds/Ramp.tsx` at line 67, The current assignment const
depositInfo = data?.depositInfo[0] can yield undefined when depositInfo is an
empty array; change it to explicitly handle empty arrays by using either
data?.depositInfo?.at(0) or a length check (e.g., data?.depositInfo?.length ?
data.depositInfo[0] : undefined) so depositInfo is deterministically undefined
only when there is no element, and update any downstream usage that assumes an
element exists to handle the explicit undefined from depositInfo in the Ramp
component.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@src/components/add-funds/AddFunds.tsx`:
- Around line 114-121: Rename the variable providerData to a more concrete noun
(e.g., provider) in the map over Object.entries(providers.providers) so the
callback signature becomes ([providerKey, provider]) and update all references
inside that callback (currencies = provider.onramp.currencies,
status={provider.status}) to use the new name; ensure the key construction
(key={`${providerKey}-${currency}`}) and the AddFiatButton usage remain
unchanged.
| {Object.entries(providers.providers).flatMap(([providerKey, providerData]) => { | ||
| const currencies = providerData.onramp.currencies; | ||
| return currencies.map((currency) => ( | ||
| <AddFiatButton | ||
| key={`${providerKey}-${currency}`} | ||
| currency={currency} | ||
| status={providerData.status} | ||
| /> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Rename providerData to avoid fluff-word naming.
Line 114 uses providerData; prefer a concrete noun like provider to align with the naming guideline.
♻️ Proposed fix
- {Object.entries(providers.providers).flatMap(([providerKey, providerData]) => {
- const currencies = providerData.onramp.currencies;
+ {Object.entries(providers.providers).flatMap(([providerKey, provider]) => {
+ const currencies = provider.onramp.currencies;
return currencies.map((currency) => (
<AddFiatButton
key={`${providerKey}-${currency}`}
currency={currency}
- status={providerData.status}
+ status={provider.status}
/>
));
})}As per coding guidelines: Avoid fluff words that carry no meaningful information such as data, state, manager, engine, object, entity, instance.
📝 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.
| {Object.entries(providers.providers).flatMap(([providerKey, providerData]) => { | |
| const currencies = providerData.onramp.currencies; | |
| return currencies.map((currency) => ( | |
| <AddFiatButton | |
| key={`${providerKey}-${currency}`} | |
| currency={currency} | |
| status={providerData.status} | |
| /> | |
| {Object.entries(providers.providers).flatMap(([providerKey, provider]) => { | |
| const currencies = provider.onramp.currencies; | |
| return currencies.map((currency) => ( | |
| <AddFiatButton | |
| key={`${providerKey}-${currency}`} | |
| currency={currency} | |
| status={provider.status} | |
| /> |
🤖 Prompt for AI Agents
In `@src/components/add-funds/AddFunds.tsx` around lines 114 - 121, Rename the
variable providerData to a more concrete noun (e.g., provider) in the map over
Object.entries(providers.providers) so the callback signature becomes
([providerKey, provider]) and update all references inside that callback
(currencies = provider.onramp.currencies, status={provider.status}) to use the
new name; ensure the key construction (key={`${providerKey}-${currency}`}) and
the AddFiatButton usage remain unchanged.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| export const currencyMap = { | ||
| ARS: { name: "Argentinian Pesos", emoji: "🇦🇷" }, | ||
| BRL: { name: "Brazilian Real", emoji: "🇧🇷" }, | ||
| USD: { name: "US Dollars", emoji: "🇺🇸" }, | ||
| } as const; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 Currency validation mismatch causes redirect loop for non-ARS/BRL/USD currencies
The client-side currencyMap only defines ARS, BRL, and USD, but the server can return additional currencies (CLP, COP, PUSD, CRC, GTQ, MXN, PHP, BOB) based on the user's country.
Click to expand
Flow that triggers the bug:
AddFunds.tsxfetches providers from server viagetRampProviders()- Server returns currencies based on user's country (e.g.,
CLPfor Chile users viaCurrenciesByExchangeinserver/utils/ramps/manteca.ts:570-581) AddFiatButtonrenders withcurrency="CLP"and user clicks it- User navigates to
/add-funds/onboard?currency=CLP Onboard.tsxcallsisValidCurrency("CLP")which returnsfalsebecauseCLPis not incurrencyMap- User is immediately redirected back to
/add-fundsvia<Redirect href="/add-funds" />
Actual vs Expected:
- Actual: Users from Chile, Colombia, Panama, Costa Rica, Guatemala, Mexico, Philippines, and Bolivia cannot complete the onramp flow - they get stuck in a redirect loop.
- Expected: All currencies returned by the server should be valid on the client side.
Impact:
Users from 8 countries cannot use the manteca ramp feature at all despite the server supporting their currencies.
Recommendation: Sync the client-side currencyMap with all currencies from the server's MantecaCurrency array, or fetch the valid currencies dynamically from the server.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@src/components/add-funds/Ramp.tsx`:
- Around line 124-142: The onCopy callbacks passed to DetailRow (for DetailRow
props in Ramp.tsx) call copyToClipboard with variables of type string |
undefined (beneficiaryName, depositAddress, depositAlias) using
short-circuiting, which doesn't narrow types inside the callback; update each
onCopy to perform an explicit runtime type check (e.g., if (typeof
beneficiaryName === "string") copyToClipboard(beneficiaryName)) for
beneficiaryName, depositAddress and depositAlias so only a string is passed to
copyToClipboard and TypeScript type errors are resolved.
| <DetailRow | ||
| label={t("Beneficiary name")} | ||
| value={beneficiaryName} | ||
| isLoading={isPending} | ||
| onCopy={() => beneficiaryName && copyToClipboard(beneficiaryName)} | ||
| /> | ||
| <DetailRow | ||
| label={depositInfo?.displayName ?? t("Account")} | ||
| value={depositAddress} | ||
| isLoading={isPending} | ||
| onCopy={() => depositAddress && copyToClipboard(depositAddress)} | ||
| /> | ||
| {depositAlias && ( | ||
| <DetailRow | ||
| label={t("Deposit alias")} | ||
| value={depositAlias} | ||
| isLoading={isPending} | ||
| onCopy={() => copyToClipboard(depositAlias)} | ||
| /> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
cat -n src/components/add-funds/Ramp.tsx | sed -n '120,150p'Repository: exactly/exa
Length of output: 1615
🏁 Script executed:
cat -n src/components/add-funds/Ramp.tsx | sed -n '1,50p'Repository: exactly/exa
Length of output: 2022
🏁 Script executed:
rg -n "copyToClipboard" src/components/add-funds/Ramp.tsx -A 2Repository: exactly/exa
Length of output: 670
🏁 Script executed:
rg -n "const copyToClipboard|function copyToClipboard|export.*copyToClipboard" src/ -A 3Repository: exactly/exa
Length of output: 395
🏁 Script executed:
rg -n "beneficiaryName|depositAddress|depositAlias" src/components/add-funds/Ramp.tsx | head -20Repository: exactly/exa
Length of output: 733
🏁 Script executed:
cat -n src/components/add-funds/Ramp.tsx | sed -n '49,120p'Repository: exactly/exa
Length of output: 3156
Add explicit type guards inside onCopy callbacks.
The short-circuit operator (&&) and conditional rendering do not provide type narrowing inside callback scopes. Since beneficiaryName, depositAddress, and depositAlias are all string | undefined and copyToClipboard expects string, add explicit guards to narrow the types:
🛠️ Suggested fix
<DetailRow
label={t("Beneficiary name")}
value={beneficiaryName}
isLoading={isPending}
- onCopy={() => beneficiaryName && copyToClipboard(beneficiaryName)}
+ onCopy={() => {
+ if (!beneficiaryName) return;
+ copyToClipboard(beneficiaryName);
+ }}
/>
<DetailRow
label={depositInfo?.displayName ?? t("Account")}
value={depositAddress}
isLoading={isPending}
- onCopy={() => depositAddress && copyToClipboard(depositAddress)}
+ onCopy={() => {
+ if (!depositAddress) return;
+ copyToClipboard(depositAddress);
+ }}
/>
{depositAlias && (
<DetailRow
label={t("Deposit alias")}
value={depositAlias}
isLoading={isPending}
- onCopy={() => copyToClipboard(depositAlias)}
+ onCopy={() => {
+ if (!depositAlias) return;
+ copyToClipboard(depositAlias);
+ }}
/>
)}🤖 Prompt for AI Agents
In `@src/components/add-funds/Ramp.tsx` around lines 124 - 142, The onCopy
callbacks passed to DetailRow (for DetailRow props in Ramp.tsx) call
copyToClipboard with variables of type string | undefined (beneficiaryName,
depositAddress, depositAlias) using short-circuiting, which doesn't narrow types
inside the callback; update each onCopy to perform an explicit runtime type
check (e.g., if (typeof beneficiaryName === "string")
copyToClipboard(beneficiaryName)) for beneficiaryName, depositAddress and
depositAlias so only a string is passed to copyToClipboard and TypeScript type
errors are resolved.
1a28ba6 to
c71e562
Compare
d4d2407 to
5d070cb
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@src/components/add-funds/Onboard.tsx`:
- Around line 64-92: The completeOnboarding function is declared after it's used
in the useMutation callback which makes the flow harder to read; move the entire
async function completeOnboarding (the block that calls startRampOnboarding,
queryClient.invalidateQueries, queryClient.fetchQuery and uses router.replace
based on providers.providers.manteca.status) to be defined above the useMutation
call so the mutation references an already-declared function and the code reads
top-to-bottom.
In `@src/components/add-funds/Ramp.tsx`:
- Around line 79-82: In copyToClipboard, await the clipboard write
(setStringAsync) before showing the success toast so the user only sees
"Copied!" on actual success; on failure catch the error (reportError) and show a
failure toast or alternate feedback. Locate the copyToClipboard function and
change the flow so setStringAsync(text) is awaited (or uses .then/.catch) and
only calls toast.show(t("Copied!"), ...) inside the success branch, while error
handling calls reportError and a failure toast.
| async function completeOnboarding() { | ||
| try { | ||
| await startRampOnboarding({ provider: "manteca" }); | ||
|
|
||
| await queryClient.invalidateQueries({ queryKey: ["ramp", "providers"] }); | ||
|
|
||
| const providers = await queryClient.fetchQuery({ | ||
| queryKey: ["ramp", "providers", countryCode], | ||
| queryFn: () => getRampProviders(countryCode), | ||
| staleTime: 0, | ||
| }); | ||
|
|
||
| const newStatus = providers.providers.manteca.status; | ||
|
|
||
| if (newStatus === "ACTIVE") { | ||
| router.replace({ pathname: "/add-funds/ramp", params: { currency } }); | ||
| } else if (newStatus === "ONBOARDING") { | ||
| router.replace({ pathname: "/add-funds/status", params: { status: newStatus, currency } }); | ||
| } else { | ||
| router.replace({ pathname: "/add-funds/status", params: { status: "error", currency } }); | ||
| } | ||
| } catch (error) { | ||
| reportError(error); | ||
| router.replace({ | ||
| pathname: "/add-funds/status", | ||
| params: { status: "error", currency }, | ||
| }); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
completeOnboarding is called before it's declared.
The function completeOnboarding is invoked in the mutation at line 49 but defined at line 64. While JavaScript hoisting allows this for function declarations, the current placement after the mutation that calls it can be confusing. Consider moving the function declaration before the mutation for clarity.
♻️ Suggested reordering
Move completeOnboarding declaration (lines 64-92) before the useMutation call (lines 34-58) to improve readability and make the code flow more intuitive.
🤖 Prompt for AI Agents
In `@src/components/add-funds/Onboard.tsx` around lines 64 - 92, The
completeOnboarding function is declared after it's used in the useMutation
callback which makes the flow harder to read; move the entire async function
completeOnboarding (the block that calls startRampOnboarding,
queryClient.invalidateQueries, queryClient.fetchQuery and uses router.replace
based on providers.providers.manteca.status) to be defined above the useMutation
call so the mutation references an already-declared function and the code reads
top-to-bottom.
| function copyToClipboard(text: string) { | ||
| setStringAsync(text).catch(reportError); | ||
| toast.show(t("Copied!"), { native: true, duration: 1000, burntOptions: { haptic: "success" } }); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Toast shows success before clipboard operation completes.
The toast is shown immediately after calling setStringAsync, but the async operation is caught separately. If clipboard access fails, the user still sees "Copied!" which is misleading.
🛠️ Suggested fix
function copyToClipboard(text: string) {
- setStringAsync(text).catch(reportError);
- toast.show(t("Copied!"), { native: true, duration: 1000, burntOptions: { haptic: "success" } });
+ setStringAsync(text)
+ .then(() => toast.show(t("Copied!"), { native: true, duration: 1000, burntOptions: { haptic: "success" } }))
+ .catch(reportError);
}📝 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.
| function copyToClipboard(text: string) { | |
| setStringAsync(text).catch(reportError); | |
| toast.show(t("Copied!"), { native: true, duration: 1000, burntOptions: { haptic: "success" } }); | |
| } | |
| function copyToClipboard(text: string) { | |
| setStringAsync(text) | |
| .then(() => toast.show(t("Copied!"), { native: true, duration: 1000, burntOptions: { haptic: "success" } })) | |
| .catch(reportError); | |
| } |
🤖 Prompt for AI Agents
In `@src/components/add-funds/Ramp.tsx` around lines 79 - 82, In copyToClipboard,
await the clipboard write (setStringAsync) before showing the success toast so
the user only sees "Copied!" on actual success; on failure catch the error
(reportError) and show a failure toast or alternate feedback. Locate the
copyToClipboard function and change the flow so setStringAsync(text) is awaited
(or uses .then/.catch) and only calls toast.show(t("Copied!"), ...) inside the
success branch, while error handling calls reportError and a failure toast.
closes #470
Summary by CodeRabbit
New Features
New Screens
UX Improvements
Documentation