Skip to content

Integrate fiat currency limits API in onramp amount#121

Open
ikem-legend wants to merge 1 commit intodevelopmentfrom
LISK-2773-integrate-fiat-currency-limit-api
Open

Integrate fiat currency limits API in onramp amount#121
ikem-legend wants to merge 1 commit intodevelopmentfrom
LISK-2773-integrate-fiat-currency-limit-api

Conversation

@ikem-legend
Copy link
Copy Markdown
Member

Summary

This PR integrates the Fiat Currency Limits API into the onramp flow, replacing hardcoded min/max transaction limits with dynamic values fetched from the Panna API. The buy flow now displays currency-specific limits based on the user's selected country and validates amounts against these limits in real-time.

Rationale

Previously, the onramp buy flow used hardcoded transaction limits (e.g., $25, $50, $100), which didn't reflect actual provider constraints or support multiple currencies. This created a poor user experience where:

  • Users could enter amounts outside the supported range
  • The same limits were shown regardless of currency
  • Limits couldn't be updated without a code deployment

By integrating with the Fiat Currency Limits API, the SDK now provides:

  • Dynamic limits: Min/max values are fetched from the API based on currency and transaction type
  • Better validation: Users receive immediate feedback when entering invalid amounts
  • Currency awareness: Each fiat currency (USD, EUR, etc.) can have different limits
  • Future-proof: Limit changes can be made server-side without SDK updates

Changes

Core SDK (packages/panna-sdk/src/core/)

  • New types in util/types.ts (lines 174-200):

    • FiatLimitsEnum: Enum for limit types (onramp, offramp, both)
    • FiatLimitsParams: Type for API request parameters
    • FiatLimitsData: Response data structure with min/max per currency
    • FiatLimitsResponse: API response wrapper with success flag
  • New API method in util/api-service.ts (lines 567-620):

    • getFiatCurrencyLimits(): Fetches limits from /onramp/limits?type={type} endpoint
    • Includes mock data support for development/testing
    • Requires authentication token

React Components (packages/panna-sdk/src/react/)

  • New hook: hooks/use-fiat-currency-limits.ts:

    • React Query hook wrapping the API service call
    • Automatic retry logic with auth error handling
    • Caching with default stale time
    • Validates params before making requests
  • New utility: utils/amount.ts:

    • formatWithCommas(): Formats numbers with thousands separators
    • Handles both integer and decimal values
    • Preserves decimal precision for currency display
  • Updated: components/buy/specify-buy-amount-step.tsx:

    • Integrates useFiatCurrencyLimits hook
    • Dynamic validation schema based on API-provided limits
    • Preset amount buttons now use min, min * 2, and max from API
    • Graceful fallback to hardcoded values (25, 50, 100) if API fails
    • Error messages display formatted amounts with currency symbols
    • Loading state while limits are being fetched

Impact

User Experience

  • Positive: Users see accurate, currency-specific transaction limits
  • Positive: Better error messages with formatted currency values (e.g., "$1,000" instead of "1000")
  • Positive: Preset buttons adapt to available limits for each currency
  • Neutral: Requires API call on buy flow initialization (cached after first load)

Existing Functionality

  • No breaking changes: Component maintains same interface
  • Backwards compatible: Falls back to hardcoded limits if API fails
  • Auth requirement: Requires user to be authenticated (already the case for buy flow)

Performance

  • Minimal impact: Single API call per currency/session (React Query caching)
  • Optimistic: Form remains usable while limits are loading

Testing

This PR has been tested manually and using unit tests.

Screenshots/Video

UI changes are minimal - preset buttons now show dynamic values (e.g., $10, $20, $1,000 for USD instead of $25, $50, $100), and error messages include formatted currency amounts.

Checklist

  • Code follows the project's coding standards

  • Unit tests covering the new feature have been added

  • All existing tests pass

  • The documentation has been updated to reflect the new feature

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR integrates the Fiat Currency Limits API into the onramp buy flow, replacing hardcoded transaction limits ($25, $50, $100) with dynamic, currency-specific values fetched from the Panna API. The implementation includes new API types, a service method for fetching limits, a React Query hook for data management, and UI updates that validate amounts in real-time and display dynamic preset buttons.

Key changes:

  • New API integration for fetching currency-specific min/max transaction limits from /onramp/limits endpoint
  • Dynamic validation schema using Zod that adapts based on fetched limits for each currency
  • Enhanced user experience with formatted currency display and fallback to hardcoded values on API failure

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 12 comments.

Show a summary per file
File Description
packages/panna-sdk/src/core/util/types.ts Added type definitions for fiat limits API including enum, params, and response structures
packages/panna-sdk/src/core/util/api-service.ts Implemented getFiatCurrencyLimits method with authentication, mock data support, and error handling
packages/panna-sdk/src/react/hooks/use-fiat-currency-limits.ts Created React Query hook for fetching fiat limits with caching and retry logic
packages/panna-sdk/src/react/utils/amount.ts Added formatWithCommas utility for formatting numbers with thousands separators
packages/panna-sdk/src/react/utils/index.ts Exported new amount utilities module
packages/panna-sdk/src/react/components/buy/specify-buy-amount-step.tsx Integrated limits API with dynamic validation, preset buttons based on API values, and formatted error messages

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

});
}

return baseSchema.optional();
Copy link

Copilot AI Dec 15, 2025

Choose a reason for hiding this comment

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

The validation schema uses .optional() at the end, which makes the entire field optional and allows undefined values to pass validation. This conflicts with the intent to validate minimum and maximum limits. When the user hasn't entered an amount (fiatAmount is undefined), the validation will succeed, allowing users to proceed without entering an amount. The optional should be removed or the validation logic should explicitly check for the presence of a value before calling next().

Suggested change
return baseSchema.optional();
return baseSchema;

Copilot uses AI. Check for mistakes.
} from 'src/core';
import { z } from 'zod';
import { useFiatCurrencyLimits } from '@/hooks/use-fiat-currency-limits';
import '@/utils/amount';
Copy link

Copilot AI Dec 15, 2025

Choose a reason for hiding this comment

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

This import statement appears to be unused. The import does not use any exports from the 'amount' module and should either be removed or corrected to import the specific function being used (formatWithCommas).

Copilot uses AI. Check for mistakes.
Comment on lines +562 to +565
* Fetch fiat-to-crypto onramp quote data
* @param request - The onramp quote request payload
* @param authToken - JWT authentication token
* @returns Promise resolving to the quote data
Copy link

Copilot AI Dec 15, 2025

Choose a reason for hiding this comment

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

The JSDoc comment is incorrect. It describes this function as "Fetch fiat-to-crypto onramp quote data" and uses "quote" terminology throughout, but this function actually fetches currency limits, not quotes. The documentation should accurately describe the function's purpose.

Suggested change
* Fetch fiat-to-crypto onramp quote data
* @param request - The onramp quote request payload
* @param authToken - JWT authentication token
* @returns Promise resolving to the quote data
* Fetch fiat-to-crypto onramp fiat currency limits
* @param request - The fiat currency limits request parameters
* @param authToken - JWT authentication token
* @returns Promise resolving to the fiat currency limits data

Copilot uses AI. Check for mistakes.

if (!authToken) {
throw new Error(
'Authentication token is required to fetch onramp quotes.'
Copy link

Copilot AI Dec 15, 2025

Choose a reason for hiding this comment

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

The error message says "Authentication token is required to fetch onramp quotes" but this function fetches currency limits, not quotes. The error message should accurately reflect the operation being performed.

Suggested change
'Authentication token is required to fetch onramp quotes.'
'Authentication token is required to fetch fiat currency limits.'

Copilot uses AI. Check for mistakes.
</Button>
))}
{isFiatLimitsLoading
? 'Loading...'
Copy link

Copilot AI Dec 15, 2025

Choose a reason for hiding this comment

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

The "Loading..." text will be displayed inline with the button container div but without any styling or structure, making it appear oddly placed in the UI. Consider wrapping this text in a proper loading component or skeleton to maintain consistent UI presentation.

Suggested change
? 'Loading...'
? (
<div className="flex items-center justify-center w-20 h-10">
<svg className="animate-spin h-5 w-5 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
</svg>
</div>
)

Copilot uses AI. Check for mistakes.
Comment on lines +233 to +245
: [minFiatLimit!, minFiatLimit! * 2, maxFiatLimit!].map((value) => (
<Button
key={value}
type="button"
variant={
form.watch('fiatAmount') === value ? 'default' : 'secondary'
}
onClick={() => form.setValue('fiatAmount', value)}
>
{currencySymbol}
{formatWithCommas(value)}
</Button>
))}
Copy link

Copilot AI Dec 15, 2025

Choose a reason for hiding this comment

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

The non-null assertion operators (!) on lines 233 are unsafe. If the API returns undefined or null for minFiatLimit or maxFiatLimit, these assertions will cause runtime errors. While the code structure suggests these values should exist when not loading or in error state, there's no guarantee the API response includes limits for the specific currency being used. Add proper null checks before rendering these buttons.

Suggested change
: [minFiatLimit!, minFiatLimit! * 2, maxFiatLimit!].map((value) => (
<Button
key={value}
type="button"
variant={
form.watch('fiatAmount') === value ? 'default' : 'secondary'
}
onClick={() => form.setValue('fiatAmount', value)}
>
{currencySymbol}
{formatWithCommas(value)}
</Button>
))}
: minFiatLimit != null && maxFiatLimit != null
? [minFiatLimit, minFiatLimit * 2, maxFiatLimit].map((value) => (
<Button
key={value}
type="button"
variant={
form.watch('fiatAmount') === value ? 'default' : 'secondary'
}
onClick={() => form.setValue('fiatAmount', value)}
>
{currencySymbol}
{formatWithCommas(value)}
</Button>
))
: null}

Copilot uses AI. Check for mistakes.
Comment on lines +6 to +35
export function useFiatCurrencyLimits(params: FiatLimitsParams) {
const { pannaApiService, siweAuth } = usePanna();

const hasValidParams =
params === FiatLimitsEnum.ONRAMP ||
params === FiatLimitsEnum.OFFRAMP ||
params === FiatLimitsEnum.BOTH;

return useQuery<FiatLimitsData, Error>({
queryKey: ['fiat-limits', params],
queryFn: async () => {
const authToken = await siweAuth.getValidAuthToken();

if (!authToken) {
throw new Error('Missing authentication token for onramp quotes.');
}

return pannaApiService.getFiatCurrencyLimits(params, authToken);
},
enabled: hasValidParams,
staleTime: DEFAULT_STALE_TIME,
retry: (failureCount, error) => {
// Don't retry if it's an authentication error to avoid multiple retry attempts
if (error.message.includes('authentication token')) {
return false;
}
return failureCount < 3;
}
});
}
Copy link

Copilot AI Dec 15, 2025

Choose a reason for hiding this comment

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

The new hook useFiatCurrencyLimits lacks test coverage. Other hooks in this directory have comprehensive test files (e.g., use-onramp-quotes.test.tsx, use-create-onramp-session.test.tsx). Given the critical role of this hook in validation and the repository's pattern of testing hooks, this should have corresponding tests.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +16
export function formatWithCommas(value: number | string): string {
const str = typeof value === 'number' ? value.toString() : value;

if (str === '' || str === '.') return str;

const [integerPart, decimalPart] = str.split('.');
const digitsOnly = integerPart.replace(/\D/g, '');

if (digitsOnly === '') return decimalPart ? `0.${decimalPart}` : '0';

const withCommas = digitsOnly.replace(/\B(?=(\d{3})+(?!\d))/g, ',');

return decimalPart !== undefined
? `${withCommas}.${decimalPart}`
: withCommas;
}
Copy link

Copilot AI Dec 15, 2025

Choose a reason for hiding this comment

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

The new utility function formatWithCommas lacks test coverage. Other utility functions in this directory have comprehensive test files (e.g., countries.test.ts, utils.test.ts, auth.test.ts). Given the repository's pattern of thoroughly testing utilities and the critical nature of currency formatting, this function should have corresponding tests.

Copilot uses AI. Check for mistakes.
Comment on lines +567 to +620
public async getFiatCurrencyLimits(
request: FiatLimitsParams,
authToken: string
): Promise<FiatLimitsData> {
const { baseUrl, isMockMode } = this.config;

if (!baseUrl) {
throw new Error('Panna API base URL is not configured.');
}

const url = `${baseUrl}/onramp/limits?type=${request}`;

if (!authToken) {
throw new Error(
'Authentication token is required to fetch onramp quotes.'
);
}

const headers: HeadersInit = {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`
};

if (isMockMode) {
return createMockFiatLimitsData(request);
}

try {
const response = await fetch(url, {
method: 'GET',
headers
});

if (!response.ok) {
const errorText = await response.text();
throw new Error(
`Panna API fiat limit failed: ${response.status} ${response.statusText} - ${errorText}`
);
}

const payload: FiatLimitsResponse = await response.json();

if (!payload.success) {
throw new Error(
'Panna API fiat limit response marked as unsuccessful.'
);
}

return payload;
} catch (error) {
console.error('Failed to fetch fiat limit from Panna API:', error);
throw error;
}
}
Copy link

Copilot AI Dec 15, 2025

Choose a reason for hiding this comment

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

The new API method getFiatCurrencyLimits lacks test coverage. The api-service.test.ts file has 1475 lines of tests covering other methods. Given the repository's comprehensive testing pattern and the critical nature of this API integration for validation, this method should have corresponding tests.

Copilot uses AI. Check for mistakes.
Comment on lines +74 to +101
const createMockFiatLimitsData = (limitType: string): FiatLimitsData => ({
...(limitType === FiatLimitsEnum.ONRAMP &&
({
onramp: {
[FiatCurrency.USD]: {
min: 10,
max: 1000
},
[FiatCurrency.EUR]: {
min: 10,
max: 900
}
}
} as FiatLimitsData)),
...(limitType === FiatLimitsEnum.OFFRAMP &&
({
offramp: {
[FiatCurrency.USD]: {
min: 5,
max: 800
},
[FiatCurrency.EUR]: {
min: 5,
max: 700
}
}
} as FiatLimitsData))
});
Copy link

Copilot AI Dec 15, 2025

Choose a reason for hiding this comment

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

The mock data function doesn't handle the FiatLimitsEnum.BOTH case. When limitType is 'both', both spread operations evaluate to undefined/false, resulting in an empty object being returned. This will cause the mock mode to fail when the BOTH parameter is used. Add a case to handle when limitType is 'both' to return both onramp and offramp data.

Suggested change
const createMockFiatLimitsData = (limitType: string): FiatLimitsData => ({
...(limitType === FiatLimitsEnum.ONRAMP &&
({
onramp: {
[FiatCurrency.USD]: {
min: 10,
max: 1000
},
[FiatCurrency.EUR]: {
min: 10,
max: 900
}
}
} as FiatLimitsData)),
...(limitType === FiatLimitsEnum.OFFRAMP &&
({
offramp: {
[FiatCurrency.USD]: {
min: 5,
max: 800
},
[FiatCurrency.EUR]: {
min: 5,
max: 700
}
}
} as FiatLimitsData))
});
const createMockFiatLimitsData = (limitType: string): FiatLimitsData => {
const onramp = {
onramp: {
[FiatCurrency.USD]: {
min: 10,
max: 1000
},
[FiatCurrency.EUR]: {
min: 10,
max: 900
}
}
};
const offramp = {
offramp: {
[FiatCurrency.USD]: {
min: 5,
max: 800
},
[FiatCurrency.EUR]: {
min: 5,
max: 700
}
}
};
if (limitType === FiatLimitsEnum.BOTH) {
return { ...onramp, ...offramp };
}
if (limitType === FiatLimitsEnum.ONRAMP) {
return onramp as FiatLimitsData;
}
if (limitType === FiatLimitsEnum.OFFRAMP) {
return offramp as FiatLimitsData;
}
return {};
};

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants