diff --git a/.gitignore b/.gitignore index 13a4349fc8f..83538b75386 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ IDEWorkspaceChecks.plist android-release.bundle.map ios-release.bundle.map keystores/ +.run.env # Debugging overrideTheme.json diff --git a/AGENTS.md b/AGENTS.md index 0ec5988b2d0..659a83f9d51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,22 @@ # Edge React GUI - Agent Guidelines +## Initialization + +**Before starting any task, ensure `docs/` is in context:** + +1. **Use `find docs/ -name "*.md" -type f`** to recursively list all `.md` files in `docs/` folder to get an index of available documentation +2. **Read relevant docs** to understand existing conventions, patterns, and business logic before implementing features + +## Workflow + +### Documentation Management + +- **Document lessons learned** when prompts contain "always", "remember", "never" or similar memory keywords +- **Create markdown files** in `docs/` folder for conventions, business logic, and codebase patterns discovered +- **Amend existing docs** rather than creating duplicates to keep knowledge base organized and succinct +- **Prioritize documenting** coding conventions, architectural patterns, and business rules +- **All `.md` files in `docs/` must be indexed** in the Documentation section below with "When to read" and "Summary" descriptions + ## Package Manager - **Use Yarn v1** instead of npm for all package management and script execution @@ -9,7 +26,8 @@ ## Build/Test/Lint Commands -- `yarn lint` - Run ESLint on entire codebase +- `yarn lint` - Run ESLint on entire codebase (only use when working on warning cleanup) +- `yarn lint --quiet` - Run ESLint on entire codebase and only get error (Prefer this usage always) - `yarn fix` - Auto-fix linting issues and deduplicate yarn - `yarn test` - Run Jest tests (single run) - `yarn watch` - Run Jest tests in watch mode @@ -51,3 +69,42 @@ - Create pseudo-merge commits with "future! branch-name" for dependent features - Use `git rebase --onto` to update dependent branches when base changes - Remove future commits by rebasing onto master once dependencies are merged + +## Documentation + +The following documentation files provide detailed guidance for specific areas of development. **Read the relevant documentation before starting work** in these areas: + +### `docs/component-styling-guidelines.md` + +**When to read**: Before styling components or converting inline styles to styled components +**Summary**: Guidelines for using the `styled` HOC, file structure patterns, and avoiding inline styles. Essential for maintaining consistent component styling across the codebase. + +### `docs/localization-guidelines.md` + +**When to read**: Before adding any UI text or working with user-facing strings +**Summary**: Mandatory guidelines for localizing all UI strings using `lstrings` from `en_US.ts`. Covers naming conventions, parameter handling, and implementation steps for internationalization. + +### `docs/MAESTRO.md` + +**When to read**: When setting up or running end-to-end tests, or when working on test automation +**Summary**: Complete setup guide for Maestro mobile testing framework. Includes installation instructions, running tests, and creating new tests with Maestro Studio. + +### `docs/GUI_PLUGINS_ARCHITECTURE.md` + +**When to read**: When working on fiat on/off ramp features, payment integrations, or plugin system +**Summary**: Comprehensive architecture guide for the fiat plugin system. Covers provider implementations, payment method configurations, regional restrictions, and integration patterns for buy/sell cryptocurrency features. + +### `docs/scene-architecture-patterns.md` + +**When to read**: Before creating new scenes or modifying existing scene components +**Summary**: Critical architectural patterns for Edge scenes. Covers the fundamental rule that scenes must never implement custom headers (managed by react-navigation), proper SceneWrapper usage, and navigation configuration patterns. Includes TradeCreateScene case study showing common architectural violations to avoid. + +### `docs/payment-type-icons.md` + +**When to read**: When working with payment type icons in fiat plugins or payment method displays +**Summary**: Explains the payment type icon mapping system for displaying appropriate icons for different payment methods. Covers usage with `getPaymentTypeIcon` utility, integration with PaymentOptionCard, direct and fallback mappings, and how to add new payment types. + +### `docs/ramp-plugin-migration-guide.md` + +**When to read**: Before migrating ramp plugins from legacy provider architecture to new ramp plugin architecture or when creating new ramp plugins +**Summary**: Comprehensive migration guide for removing FiatPluginUi abstraction and using direct API imports. Covers migration of toasts, modals, navigation, permissions (with important boolean logic inversion note), wallet operations, and environment configuration requirements. Includes detailed steps for creating init options cleaners, validating plugin initialization, and registering plugins in envConfig. Also explains how to migrate getSupportedAssets initialization logic to an internal fetchProviderConfig function with 2-minute TTL caching. Essential for converting legacy fiat providers to new ramp plugins and ensuring proper type safety. diff --git a/bity-ramp-plugin-verification.md b/bity-ramp-plugin-verification.md new file mode 100644 index 00000000000..47196fe0871 --- /dev/null +++ b/bity-ramp-plugin-verification.md @@ -0,0 +1,100 @@ +# Bity Ramp Plugin Verification Report + +## Overview +The Bity ramp plugin implementation has been verified to correctly support the new `checkSupport` API as described in the ramp plugin architecture documentation. + +## Verification Results + +### 1. Plugin Interface ✅ +The plugin correctly implements both required methods: +- `checkSupport: (request: RampCheckSupportRequest) => Promise` +- `fetchQuote: (request: RampQuoteRequest) => Promise` + +### 2. Shared Validation Logic ✅ +Both methods share the same validation logic through helper functions: +- `isRegionSupported()` - Used by both checkSupport (line 607) and fetchQuote (line 671) +- `isCryptoSupported()` - Used by both checkSupport (lines 612-618) and fetchQuote (line 676) +- `findCryptoCurrency()` - Used for finding crypto in provider's currency list +- `findFiatCurrency()` - Used for finding fiat in provider's currency list + +### 3. Return Types ✅ +- `checkSupport` correctly returns `{ supported: boolean }` (RampSupportResult type) +- `fetchQuote` correctly returns `RampQuoteResult[]` array + +### 4. Error Handling ✅ +- **checkSupport**: Catches all errors and returns `{ supported: false }` instead of throwing (lines 648-652) +- **fetchQuote**: Returns empty array `[]` for all error conditions (lines 673, 677, 686, 699, 711, 743, 751, 759, 781, 791, 810) + +### 5. Architecture Compliance ✅ +The implementation follows all patterns from the documentation: +- Fast local checks in `checkSupport` before API calls +- Shared provider config fetching with 2-minute TTL cache +- No unnecessary API calls for unsupported pairs +- Clear separation of concerns between support checking and quote fetching + +## Key Implementation Details + +### Helper Functions +```typescript +// Region validation (line 520) +function isRegionSupported(regionCode: FiatPluginRegionCode): boolean + +// Crypto validation (line 527) +function isCryptoSupported( + pluginId: string, + tokenId: EdgeTokenId, + direction: 'buy' | 'sell' +): boolean + +// Currency finders (lines 539 & 575) +function findCryptoCurrency(...) +function findFiatCurrency(...) +``` + +### Caching Strategy +- Provider config cached for 2 minutes (line 277: `CACHE_TTL_MS = 2 * 60 * 1000`) +- Cache checked before API calls (line 409) +- Graceful fallback to cached data on API failures + +### Support Checking Flow +1. Quick local region check +2. Quick local crypto check against no-KYC list +3. Fetch provider config (cached) +4. Check fiat currency support +5. Return boolean result + +### Quote Fetching Flow +1. Reuse same validation helpers as checkSupport +2. Skip API calls if validation fails +3. Return empty array for any failures +4. Only throw for actual critical errors + +## Example Usage + +```typescript +// Check support across multiple plugins +const supportResults = await Promise.all( + plugins.map(plugin => plugin.checkSupport(request)) +) + +// Filter to supported plugins +const supportedPlugins = plugins.filter( + (plugin, index) => supportResults[index].supported +) + +// Only fetch quotes from supported plugins +const quotes = await Promise.all( + supportedPlugins.map(plugin => plugin.fetchQuote(quoteRequest)) +) +``` + +## Conclusion + +The Bity ramp plugin implementation fully complies with the new `checkSupport` API architecture. It demonstrates: +- Proper type safety with TypeScript +- Efficient caching to minimize API calls +- Shared validation logic between methods +- Correct error handling patterns +- Clean separation of concerns + +The implementation serves as a good example for migrating other ramp plugins to the new architecture. diff --git a/design.png b/design.png new file mode 100644 index 00000000000..dc788aa3ddc Binary files /dev/null and b/design.png differ diff --git a/docs/component-styling-guidelines.md b/docs/component-styling-guidelines.md new file mode 100644 index 00000000000..e5583ce059a --- /dev/null +++ b/docs/component-styling-guidelines.md @@ -0,0 +1,54 @@ +# Component Styling Guidelines + +## File Structure + +- **Types first**: Type definitions at the top serve as documentation +- **Exports second**: Component exports immediately after types for visibility +- **Styled components third**: All styled components after the main export (more relevant to component structure) +- **Utility functions fourth**: Helper functions and components scoped to the file come after styled components +- **Styles last**: cacheStyles objects at the bottom of the file + +## Styling Patterns + +- **Always use `styled` HOC** from `@src/components/hoc/styled.tsx` instead of inline styles +- **Run `yarn eslint --fix`** on all files to format and fix lint errors automatically +- **EdgeText with styled**: EdgeText can be used with styled HOC since it accepts a `style` prop +- **Raw text fallback**: If styled EdgeText causes raw text ESLint errors, use regular EdgeText with cacheStyles +- **Avoid inline styles**: Use styled HOC or cacheStyles, never inline style objects + +## Example File Structure + +```tsx +// Types first +interface Props { + // ... +} + +// Exports second +export const MyComponent = (props: Props) => { + return ( + + {formatText('Hello')} + + ) +} + +// Styled components third (more relevant to component structure) +const Container = styled(View)({ + // styles +}) + +const StyledText = styled(EdgeText)({ + // styles +}) + +// Utility functions fourth (scoped to this file) +const formatText = (text: string): string => { + return text.toUpperCase() +} + +// Styles last (if needed for complex cases) +const styles = cacheStyles({ + // fallback styles +}) +``` diff --git a/docs/localization-guidelines.md b/docs/localization-guidelines.md new file mode 100644 index 00000000000..f01c82e5fa9 --- /dev/null +++ b/docs/localization-guidelines.md @@ -0,0 +1,82 @@ +# Localization Guidelines + +## Core Principle + +**ALWAYS put strings displayed in the UI in the `@src/locales/en_US.ts` file for localization.** Use `lstrings.string_name` to access the string. + +## String Naming Convention + +### Basic Strings + +- Use descriptive, hierarchical naming: `component_context_description` +- Example: `trade_region_select_buy_crypto`, `settings_account_title` + +### Parameterized Strings + +If a string uses sprintf and `%s` or replacements, suffix the string with parameter indicators: + +- **Single parameter**: `_s` suffix + - Example: `buy_1s: 'Buy %1$s'` +- **Two parameters**: `_2s` suffix + - Example: `error_balance_below_minimum_to_stake_2s: 'Your balance of %1$s does not meet the minimum %2$s required to stake.'` +- **Multiple parameters**: `_ns` suffix (where n is the number) + - Example: `_3s`, `_4s`, `_5s` etc. + +## Implementation Steps + +1. **Identify hardcoded strings** in UI components +2. **Add strings to `en_US.ts`** with appropriate naming and parameter suffixes +3. **Replace hardcoded strings** with `lstrings.string_name` references +4. **Import lstrings** from `'../../locales/strings'` (adjust path as needed) + +## Examples + +### Before (Hardcoded) + +```tsx +Buy Crypto +Start in 4 Easy Steps +{`Step 1: Select Your Region`} +``` + +### After (Localized) + +```tsx +// In en_US.ts +export const strings = { + trade_region_select_buy_crypto: 'Buy Crypto', + trade_region_select_start_steps: 'Start in 4 Easy Steps', + trade_region_select_step_1: ' Select Your Region for personalized options', + // ... +} + +// In component +import { lstrings } from '../../locales/strings' + +{lstrings.trade_region_select_buy_crypto} +{lstrings.trade_region_select_start_steps} +{lstrings.trade_region_select_step_1} +``` + +### Parameterized Example + +```tsx +// In en_US.ts +buy_1s: 'Buy %1$s', +error_balance_below_minimum_2s: 'Balance %1$s below minimum %2$s', + +// In component +{sprintf(lstrings.buy_1s, currencyCode)} +{sprintf(lstrings.error_balance_below_minimum_2s, balance, minimum)} +``` + +## Benefits + +- **Internationalization ready**: All strings can be translated to other languages +- **Consistency**: Centralized string management prevents duplicates +- **Maintainability**: Easy to update strings across the entire app +- **Type safety**: TypeScript ensures string keys exist + +## Remember + +This is a **mandatory** practice for all UI strings. No exceptions should be made for hardcoded strings in user-facing components. diff --git a/docs/payment-type-icons.md b/docs/payment-type-icons.md new file mode 100644 index 00000000000..253de890e1a --- /dev/null +++ b/docs/payment-type-icons.md @@ -0,0 +1,122 @@ +# Payment Type Icons + +This document explains how to use the payment type icon mapping system for displaying appropriate icons for different payment methods in the fiat plugin system. + +## Overview + +The payment type icon system provides a consistent way to map `FiatPaymentType` values to their corresponding theme icon keys. This ensures that payment methods are displayed with the correct visual representation across the application. + +## Usage + +### Basic Usage + +```typescript +import { getPaymentTypeIcon } from '../util/paymentTypeIcons' +import { useTheme } from '../services/ThemeContext' + +const MyComponent = () => { + const theme = useTheme() + const paymentType = 'applepay' // FiatPaymentType + + const icon = getPaymentTypeIcon(paymentType, theme) + // Returns: { uri: 'path/to/apple-pay-icon.png' } +} +``` + +### Integration with PaymentOptionCard + +When rendering payment options from quotes, use the first payment type to determine the icon: + +```typescript +const QuoteResult = ({ quote }) => { + const theme = useTheme() + + // Get icon for the first payment type, fallback to partner icon + const paymentTypeIcon = quote.paymentTypes[0] + ? getPaymentTypeIcon(quote.paymentTypes[0], theme) + : undefined + const icon = paymentTypeIcon ?? { uri: quote.partnerIcon } + + return ( + + ) +} +``` + +## Payment Type Mappings + +### Direct Mappings + +These payment types have dedicated icons in the theme: + +- `applepay` → `paymentTypeLogoApplePay` +- `credit` → `paymentTypeLogoCreditCard` +- `fasterpayments` → `paymentTypeLogoFasterPayments` +- `googlepay` → `paymentTypeLogoGooglePay` +- `ideal` → `paymentTypeLogoIdeal` +- `interac` → `paymentTypeLogoInterac` +- `payid` → `paymentTypeLogoPayid` +- `paypal` → `paymentTypeLogoPaypal` +- `pix` → `paymentTypeLogoPix` +- `revolut` → `paymentTypeLogoRevolut` +- `venmo` → `paymentTypeLogoVenmo` + +### Fallback Mappings + +These payment types use the generic bank transfer icon as a fallback: + +- `ach` → `paymentTypeLogoBankTransfer` +- `colombiabank` → `paymentTypeLogoBankTransfer` +- `directtobank` → `paymentTypeLogoBankTransfer` +- `iach` → `paymentTypeLogoBankTransfer` +- `iobank` → `paymentTypeLogoBankTransfer` +- `mexicobank` → `paymentTypeLogoBankTransfer` +- `pse` → `paymentTypeLogoBankTransfer` +- `sepa` → `paymentTypeLogoBankTransfer` +- `spei` → `paymentTypeLogoBankTransfer` +- `turkishbank` → `paymentTypeLogoBankTransfer` +- `wire` → `paymentTypeLogoBankTransfer` + +## API Reference + +### `getPaymentTypeIcon(paymentType: FiatPaymentType, theme: Theme): ImageProp | undefined` + +Returns the theme icon for a given payment type. + +**Parameters:** +- `paymentType`: The payment type to get the icon for +- `theme`: The theme object containing the icon images + +**Returns:** +- `ImageProp`: The icon image object (`{ uri: string } | number`) +- `undefined`: If the payment type doesn't have a corresponding icon + +### `getPaymentTypeThemeKey(paymentType: FiatPaymentType): keyof Theme | null` + +Returns just the theme key for a payment type without requiring the theme object. + +**Parameters:** +- `paymentType`: The payment type to get the theme key for + +**Returns:** +- `keyof Theme`: The theme key string +- `null`: If the payment type doesn't have a corresponding theme key + +## Adding New Payment Types + +To add support for a new payment type: + +1. Add the payment type to the `FiatPaymentType` union in `fiatPluginTypes.ts` +2. Add the corresponding icon to the theme in `types/Theme.ts` +3. Update the `paymentTypeToThemeKey` mapping in `util/paymentTypeIcons.ts` +4. Add the icon assets to all theme variations (edgeLight, edgeDark, etc.) + +## Notes + +- Payment types that are primarily bank-based use the generic bank transfer icon as a reasonable fallback +- The system is designed to be extensible - new payment types can be added without breaking existing functionality +- Always provide a fallback (like the partner icon) when using payment type icons in case the mapping returns undefined \ No newline at end of file diff --git a/docs/ramp-plugin-architecture.md b/docs/ramp-plugin-architecture.md new file mode 100644 index 00000000000..18b7e449f72 --- /dev/null +++ b/docs/ramp-plugin-architecture.md @@ -0,0 +1,390 @@ +# Ramp Plugin Architecture + +This document describes the hybrid two-method ramp plugin architecture for Edge React GUI, which combines fast support checking with streamlined quote fetching. + +## Overview + +The ramp plugin system provides a unified interface for integrating fiat on/off ramp providers (buy/sell cryptocurrency). The architecture uses a two-method approach that optimizes both user experience and API efficiency. + +## Architecture Flow + +### Previous Architecture (Complex) +1. User selects crypto/fiat pair +2. UI calls `useSupportedPlugins` hook +3. Hook calls `getSupportedAssets` on each plugin (with payment type complexity) +4. UI filters to only supported plugins +5. UI calls `fetchQuote` on supported plugins only +6. Display quotes to user + +### Current Architecture (Two-Method Hybrid) +1. User selects crypto/fiat pair +2. UI calls `checkSupport` on all plugins in parallel +3. UI filters to only supported plugins +4. UI calls `fetchQuote` only on supported plugins +5. Display quotes to user + +This hybrid approach provides immediate feedback about provider availability while minimizing unnecessary API calls. + +## Plugin Interface + +```typescript +export interface RampPlugin { + readonly pluginId: string + readonly rampInfo: RampInfo + + readonly checkSupport: ( + request: RampSupportRequest + ) => Promise + + readonly fetchQuote: ( + request: RampQuoteRequest, + opts?: unknown + ) => Promise +} +``` + +### Method Documentation + +#### checkSupport +- **Purpose**: Quickly determine if a plugin supports a crypto/fiat pair +- **Returns**: `true` if supported, `false` otherwise +- **Note**: Should be fast and avoid expensive API calls when possible +- **Parameters**: Simple request with fiatCurrencyCode, tokenId, pluginId, regionCode, and direction + +#### fetchQuote +- **Purpose**: Fetch actual quotes for supported pairs +- **Returns**: Array of quotes, or empty array `[]` only when provider supports the request but has no quotes available at the moment +- **Note**: Only called after `checkSupport` returns `true` +- **Throws**: + - `FiatProviderError` for unsupported regions, payment methods, or assets (maintains consistency with legacy `getSupportedAssets` behavior) + - Other errors for actual API failures or network issues + - Never throw for "temporarily no quotes available" - return empty array instead + +## Implementation Guide + +### Creating a Ramp Plugin + +```typescript +export const myRampPlugin: RampPluginFactory = (config: RampPluginConfig) => { + const { account, navigation, onLogEvent, disklet } = config + + const plugin: RampPlugin = { + pluginId: 'myplugin', + rampInfo: { + partnerIcon: 'https://example.com/icon.png', + pluginDisplayName: 'My Plugin' + }, + + checkSupport: async (request: RampSupportRequest): Promise => { + const { + direction, + regionCode, + fiatCurrencyCode, + tokenId, + pluginId: currencyPluginId + } = request + + // Quick checks without API calls + if (!isRegionSupported(regionCode)) { + return false + } + + if (!isAssetSupported(currencyPluginId, tokenId)) { + return false + } + + if (!isFiatSupported(fiatCurrencyCode)) { + return false + } + + // All checks passed + return true + }, + + fetchQuote: async (request: RampQuoteRequest): Promise => { + const { + direction, + regionCode, + fiatCurrencyCode, + displayCurrencyCode, + tokenId, + pluginId: currencyPluginId + } = request + + // Note: Support checking already done by checkSupport + // This method focuses only on fetching quotes + + try { + const quotes = await fetchFromProvider(request) + + // If provider doesn't support this request, throw FiatProviderError + // This should be rare since checkSupport already validated + if (isUnsupportedRegion(regionCode)) { + throw new FiatProviderError('Unsupported region: ' + regionCode) + } + if (isUnsupportedPaymentMethod(paymentMethod)) { + throw new FiatProviderError('Unsupported payment method: ' + paymentMethod) + } + if (isUnsupportedAsset(currencyPluginId, tokenId)) { + throw new FiatProviderError('Unsupported asset: ' + currencyPluginId + '/' + tokenId) + } + + // Return empty array only when provider supports but has no quotes right now + if (quotes.length === 0) { + return [] + } + + return quotes.map(quote => convertToRampQuoteResult(quote)) + } catch (error) { + // Re-throw all errors (including FiatProviderError) + console.error(`Failed to fetch quotes: ${error}`) + throw error + } + } + } + + return plugin +} +``` + +## UI Integration + +### TradeCreateScene + +```typescript +export const TradeCreateScene = () => { + // Get all plugins directly from Redux + const rampPlugins = useSelector(state => state.rampPlugins.plugins) + const isPluginsLoading = useSelector(state => state.rampPlugins.isLoading) + + // Create support request (simpler than quote request) + const rampSupportRequest: RampSupportRequest = { + direction, + regionCode, + fiatCurrencyCode, + tokenId, + pluginId: currencyPluginId + } + + // Check support on all plugins in parallel + const { supportedPlugins, isCheckingSupport } = useSupportedPlugins({ + rampSupportRequest, + plugins: rampPlugins + }) + + // Show immediate feedback if no providers available + if (!isCheckingSupport && supportedPlugins.length === 0) { + return + } + + // Create quote request for supported plugins only + const rampQuoteRequest: RampQuoteRequest = { + // ... full request parameters including amounts + } + + // Fetch quotes only from supported plugins + const { quotes, isLoading, errors } = useRampQuotes({ + rampQuoteRequest, + plugins: supportedPlugins + }) + + // Render UI + return ( + // ... UI components + ) +} +``` + +### useSupportedPlugins Hook + +The hook handles: +- Parallel support checking from all plugins +- Fast filtering to supported plugins only +- Caching support results for performance +- No unnecessary API calls for unsupported pairs + +### useRampQuotes Hook + +The hook handles: +- Parallel quote fetching from supported plugins only +- Filtering out empty results (temporarily no quotes available) +- Error handling for FiatProviderError (unsupported cases) and other failures +- Quote expiration and refresh +- Result caching and deduplication +- Distinguishing between unsupported (error) vs unavailable (empty array) + +## Benefits of Two-Method Architecture + +### Better User Experience +1. **Immediate Feedback**: Users see "no providers available" instantly without waiting for quote API calls +2. **Progressive Loading**: Show supported providers first, then load quotes +3. **Clear Communication**: Distinguish between "not supported" (FiatProviderError) vs "loading quotes" vs "temporarily no quotes available" (empty array) + +### Reduced API Calls +1. **No Wasted Requests**: Never call quote APIs for unsupported pairs +2. **Lower Latency**: Support checks can use cached/local data without API calls +3. **Cost Savings**: Fewer API calls to third-party providers +4. **Better Rate Limiting**: Conserve API quota for actual quote requests + +### Cleaner Separation of Concerns +1. **Simple Support Check**: `checkSupport` has one job - return true/false +2. **Focused Quote Fetching**: `fetchQuote` only deals with getting quotes +3. **No Payment Type Complexity**: Support checking doesn't need payment type arrays +4. **Easier Implementation**: Each method has clear, focused responsibility + +### Performance Optimization +1. **Parallel Support Checks**: All plugins checked simultaneously +2. **Early Filtering**: Only fetch quotes from supported plugins +3. **Cacheable Support**: Support results can be cached longer than quotes +4. **Predictable Behavior**: Support rarely changes, quotes change frequently + +### Developer Experience +1. **Easier Testing**: Test support logic separately from quote logic +2. **Better Error Handling**: Different error strategies for each method +3. **Simpler Types**: No complex `RampAssetMap` or payment type arrays +4. **Clear Intent**: Method names clearly indicate their purpose + +## Migration from Legacy Architecture + +See [Ramp Plugin Migration Guide](./ramp-plugin-migration-guide.md) for detailed migration instructions. + +## Best Practices + +### For checkSupport Method +1. **Fast Response**: Use local/cached data whenever possible, avoid API calls +2. **Simple Logic**: Return boolean only, no complex data structures +3. **Cache Results**: Support data changes infrequently, cache aggressively +4. **No Side Effects**: Pure function that only checks, doesn't modify state + +### For fetchQuote Method +1. **Throw FiatProviderError**: Throw `FiatProviderError` for unsupported regions, payment methods, or assets +2. **Return Empty Array**: Return `[]` only when provider supports the request but temporarily has no quotes available +3. **Throw on All Errors**: Throw exceptions for both unsupported cases (FiatProviderError) and actual API failures +4. **Assume Support**: Don't recheck support - UI already filtered (but still validate and throw if needed) +5. **Include All Options**: Return all available payment methods in quotes + +### General Guidelines +1. **Parallel Processing**: Both methods designed for parallel execution +2. **Error Logging**: Log errors for debugging but handle gracefully +3. **Type Safety**: Use TypeScript types for all requests/responses +4. **Performance First**: Optimize for speed, especially in `checkSupport` + +### Understanding FiatProviderError vs Empty Arrays + +The distinction between throwing `FiatProviderError` and returning empty arrays is critical for maintaining consistency with legacy behavior: + +#### When to throw FiatProviderError +- **Unsupported regions**: Provider doesn't operate in the user's region +- **Unsupported payment methods**: Provider doesn't support the requested payment type +- **Unsupported assets**: Provider doesn't support the crypto/fiat pair +- **Invalid configuration**: Missing API keys or misconfigured settings + +#### When to return empty array [] +- **Temporary unavailability**: Provider supports the request but has no quotes at this moment +- **Rate limits**: Temporary inability to fetch quotes due to rate limiting +- **Maintenance windows**: Provider is temporarily offline but normally supports the request +- **No matching quotes**: All quotes filtered out by amount limits or other temporary criteria + +This maintains backward compatibility with code that expects `FiatProviderError` for truly unsupported cases while allowing graceful handling of temporary conditions. + +## Example Quote Flow + +```mermaid +sequenceDiagram + participant User + participant UI + participant Redux + participant Plugin1 + participant Plugin2 + participant Plugin3 + + User->>UI: Select crypto/fiat pair + UI->>Redux: Get all plugins + Redux->>UI: Return plugins + + Note over UI: Phase 1: Check Support + par Parallel Support Check + UI->>Plugin1: checkSupport(request) + UI->>Plugin2: checkSupport(request) + UI->>Plugin3: checkSupport(request) + end + + Plugin1->>UI: true + Plugin2->>UI: false + Plugin3->>UI: true + + Note over UI: Filter to supported plugins + Note over UI: Can show "2 providers available" + + Note over UI: Phase 2: Fetch Quotes + par Parallel Quote Fetch (only supported) + UI->>Plugin1: fetchQuote(request) + UI->>Plugin3: fetchQuote(request) + end + + Note over Plugin2: No quote API call made + + Plugin1->>UI: Return quotes + Plugin3->>UI: Return quotes or [] + + UI->>User: Display available quotes +``` + +### Key Advantages Illustrated + +1. **Immediate Feedback**: After support check, UI can show "2 providers available" +2. **Efficient API Usage**: Plugin2 never makes a quote API call +3. **Better UX**: Users aren't left wondering if any providers support their selection +4. **Clear Phases**: Support checking is separate from quote fetching + +## Plugin State Management + +Plugins are initialized once when the app starts and stored in Redux: + +```typescript +interface RampPluginState { + readonly isLoading: boolean + readonly plugins: Record +} +``` + +The `RampPluginManager` component handles: +- Loading plugin factories +- Initializing plugins with configuration +- Updating Redux state when ready + +## Why This Hybrid Approach? + +The two-method architecture combines the best aspects of both previous approaches: + +### From the Complex Architecture (getSupportedAssets) +- ✅ **Immediate feedback** about provider availability +- ✅ **No wasted API calls** to unsupported providers +- ❌ ~~Complex payment type arrays~~ +- ❌ ~~Confusing asset map structures~~ + +### From the Simplified Architecture (fetchQuote only) +- ✅ **Simple implementation** for plugin developers +- ✅ **Single source of truth** for quote data +- ❌ ~~No way to show "no providers" immediately~~ +- ❌ ~~Unnecessary API calls to all providers~~ + +### The Best of Both Worlds +- **Simple boolean support check** instead of complex asset maps +- **Efficient API usage** by filtering before fetching quotes +- **Better user experience** with progressive loading states +- **Clean separation** between availability and pricing +- **Flexible caching strategies** for each method + +## Migration from Legacy Architecture + +See [Ramp Plugin Migration Guide](./ramp-plugin-migration-guide.md) for detailed migration instructions. + +## Future Considerations + +1. **Plugin Discovery**: Dynamic plugin loading based on user region +2. **Quote Caching**: Shared quote cache across plugins +3. **WebSocket Support**: Real-time quote updates +4. **Plugin Versioning**: Support for multiple plugin versions +5. **Analytics**: Unified analytics across all plugins +6. **Support Caching**: Intelligent caching of support results with TTL \ No newline at end of file diff --git a/docs/ramp-plugin-migration-guide.md b/docs/ramp-plugin-migration-guide.md new file mode 100644 index 00000000000..01b401727d6 --- /dev/null +++ b/docs/ramp-plugin-migration-guide.md @@ -0,0 +1,1242 @@ +# Ramp Plugin Migration Guide + +This document describes how to migrate ramp plugins from the legacy fiat provider architecture to the new ramp plugin architecture, removing the `showUi` dependency and using direct API access instead. + +## Overview + +The `FiatPluginUi` interface wraps many different APIs and services. To improve code clarity and reduce abstraction, ramp plugins should directly import and use the specific services they need. + +## Migration Map + +### Toast & Error Messages + +**Before:** + +```typescript +await showUi.showToast(message) +await showUi.showError(error) +await showUi.showToastSpinner(message, promise) +``` + +**After:** + +```typescript +import { + showToast, + showError, + showToastSpinner +} from '../../../components/services/AirshipInstance' + +showToast(message) +showError(error) +await showToastSpinner(message, promise) +``` + +### Modals + +**Before:** + +```typescript +await showUi.buttonModal({ buttons, title, message }) +await showUi.listModal(params) +``` + +**After:** + +```typescript +import { Airship } from '../../../components/services/AirshipInstance' +import { ButtonsModal } from '../../../components/modals/ButtonsModal' +import { RadioListModal } from '../../../components/modals/RadioListModal' + +await Airship.show(bridge => ( + +)) + +await Airship.show(bridge => ) +``` + +### Navigation + +**Before:** + +```typescript +showUi.enterAmount(params) +showUi.openWebView(params) +showUi.exitScene() +``` + +**After:** + +```typescript +// Use the navigation prop from RampApproveQuoteParams +navigation.navigate('guiPluginEnterAmount', params) +navigation.navigate('guiPluginWebView', params) +navigation.pop() +``` + +### External WebView & Deeplinks + +**Before:** + +```typescript +await showUi.openExternalWebView({ + url, + providerId, + deeplinkHandler: async (link) => { ... } +}) +``` + +**After:** + +```typescript +import { Platform, Linking } from 'react-native' +import SafariView from 'react-native-safari-view' +import { CustomTabs } from 'react-native-custom-tabs' +import { + registerRampDeeplinkHandler, + unregisterRampDeeplinkHandler +} from '../rampDeeplinkHandler' + +// Register handler +registerRampDeeplinkHandler(direction, providerId, deeplinkHandler) + +// Open external webview +if (redirectExternal) { + await Linking.openURL(url) +} else if (Platform.OS === 'ios') { + await SafariView.show({ url }) +} else { + await CustomTabs.openURL(url) +} + +// Cleanup when done +unregisterRampDeeplinkHandler() +``` + +### Permissions + +**Before:** + +```typescript +const success = await showUi.requestPermission(['camera'], displayName, true) +if (!success) { + // Permission was denied + await showUi.showToast(lstrings.fiat_plugin_cannot_continue_camera_permission) +} +``` + +**After:** + +```typescript +import { requestPermissionOnSettings } from '../../../components/services/PermissionsManager' + +const deniedPermission = await requestPermissionOnSettings( + disklet, + 'camera', + displayName, + true +) +if (deniedPermission) { + // Permission was denied + showToast(lstrings.fiat_plugin_cannot_continue_camera_permission) + return +} +``` + +**⚠️ IMPORTANT: Boolean Logic Inversion** + +The `showUi.requestPermission` function returns `true` when permission is **granted** and `false` when **denied**. + +The `requestPermissionOnSettings` function returns `true` when permission is **denied** and `false` when **granted**. + +This inverted boolean logic must be handled correctly during migration to avoid incorrect permission handling. + +### Wallet Operations + +**Before:** + +```typescript +await showUi.send(sendParams) +await showUi.saveTxMetadata(params) +await showUi.saveTxAction(params) +``` + +**After:** + +```typescript +// For send, navigate to send scene +navigation.navigate('send2', { + ...sendParams, + onDone: (error, tx) => { ... }, + onBack: () => { ... } +}) + +// For tx operations, use the wallet directly +await wallet.saveTxMetadata({ txid, tokenId, metadata }) +await wallet.saveTxAction({ txid, tokenId, assetAction, savedAction }) +``` + +### Analytics + +**Before:** + +```typescript +await showUi.trackConversion(event, opts) +``` + +**After:** + +```typescript +// Use the onLogEvent prop from RampApproveQuoteParams +onLogEvent(event, opts) +``` + +### Utilities + +**Before:** + +```typescript +await showUi.setClipboard(value) +await showUi.waitForAnimationFrame() +``` + +**After:** + +```typescript +import Clipboard from '@react-native-clipboard/clipboard' + +Clipboard.setString(value) +await new Promise(resolve => requestAnimationFrame(resolve)) +``` + +## RampApproveQuoteParams Interface + +The updated interface provides direct access to needed dependencies: + +```typescript +export interface RampApproveQuoteParams { + coreWallet: EdgeCurrencyWallet + account: EdgeAccount + navigation: NavigationBase + onLogEvent: OnLogEvent + disklet: Disklet +} +``` + +## Implementation Notes + +1. **Import statements**: Add necessary imports at the top of the plugin file +2. **Permission boolean logic**: Pay special attention to the inverted boolean logic for `requestPermissionOnSettings` +3. **Error handling**: Ensure proper error handling when using direct APIs +4. **Cleanup**: Remember to unregister deeplink handlers when appropriate +5. **Testing**: Test all flows thoroughly after migration, especially permission flows +6. **Type safety**: Use proper TypeScript types for all imported functions + +## Environment Configuration + +When creating a new ramp plugin, you must properly configure the plugin's initialization options to ensure type safety and centralized configuration management. This involves three steps: + +### 1. Create Init Options Cleaner + +In your plugin's types file (e.g., `moonpayRampTypes.ts`), create an `asInitOptions` cleaner that validates the structure of your plugin's initialization options: + +```typescript +// Init options cleaner for moonpay ramp plugin +export const asInitOptions = asString // For simple API key + +// For more complex init options: +export const asInitOptions = asObject({ + apiKey: asString, + environment: asOptional(asValue('production', 'sandbox'), 'production'), + webhookUrl: asOptional(asString) +}) +``` + +The cleaner should match the expected initialization options structure for your specific plugin. + +### 2. Use Cleaner in Plugin Factory + +In your plugin factory function (e.g., `moonpayRampPlugin.ts`), import and use the cleaner to validate the `initOptions` parameter: + +```typescript +import { asInitOptions } from './moonpayRampTypes' + +export const moonpayRampPlugin: RampPluginFactory = { + pluginId: 'moonpay', + storeId: 'com.moonpay', + displayName: 'Moonpay', + + create: async (params: RampPluginFactoryParams): Promise => { + const { initOptions } = params + + // Validate and extract init options + const apiKey = asInitOptions(initOptions) + + // Use the validated options in your plugin + // ... + } +} +``` + +### 3. Register in envConfig + +Add an entry to `RAMP_PLUGIN_INITS` in `src/envConfig.ts` that uses your cleaner. Import with an alias to avoid naming conflicts: + +```typescript +import { asInitOptions as asMoonpayInitOptions } from './plugins/ramps/moonpay/moonpayRampTypes' +import { asInitOptions as asPaybisInitOptions } from './plugins/ramps/paybis/paybisRampTypes' + +export const ENV_CONFIG = makeConfig( + asObject({ + // ... other config + RAMP_PLUGIN_INITS: asOptional( + asObject>({ + moonpay: asOptional(asMoonpayInitOptions), + paybis: asOptional(asPaybisInitOptions) + // Add your plugin here with its cleaner + }) + ) + }) +) +``` + +### Why This Is Required + +- **Type Safety**: Ensures initialization options are properly typed and validated at runtime +- **Centralized Configuration**: All plugin configurations are managed in one place (`envConfig.ts`) +- **Environment Management**: Allows different configurations for development, staging, and production +- **Error Prevention**: Catches configuration errors early with clear error messages + +### Complete Example: Moonpay Plugin + +Here's how the Moonpay plugin implements environment configuration: + +**1. In `moonpayRampTypes.ts`:** + +```typescript +import { asString } from 'cleaners' + +// Simple string cleaner for API key +export const asInitOptions = asString +``` + +**2. In `moonpayRampPlugin.ts`:** + +```typescript +import { asInitOptions } from './moonpayRampTypes' + +export const moonpayRampPlugin: RampPluginFactory = { + // ... plugin metadata + + create: async (params: RampPluginFactoryParams): Promise => { + const { initOptions } = params + const apiKey = asInitOptions(initOptions) + + // Now apiKey is guaranteed to be a string + const client = new MoonpayClient({ apiKey }) + // ... + } +} +``` + +**3. In `envConfig.ts`:** + +```typescript +import { asInitOptions as asMoonpayInitOptions } from './plugins/ramps/moonpay/moonpayRampTypes' + +// In the ENV_CONFIG: +RAMP_PLUGIN_INITS: asOptional( + asObject>({ + moonpay: asOptional(asMoonpayInitOptions) + // Other plugins... + }) +) +``` + +This setup ensures that when the app loads plugin configurations from environment variables or config files, they are properly validated before being passed to the plugin factories. + +## Complete Migration Example + +Here's a comparison showing a typical permission request migration: + +**Legacy Provider (paybisProvider.ts):** + +```typescript +const success = await showUi.requestPermission( + ['camera'], + pluginDisplayName, + true +) +if (!success) { + await showUi.showToast(lstrings.fiat_plugin_cannot_continue_camera_permission) +} +``` + +**New Ramp Plugin (paybisRampPlugin.ts):** + +```typescript +const deniedPermission = await requestPermissionOnSettings( + disklet, + 'camera', + pluginDisplayName, + true +) +if (deniedPermission) { + showToast(lstrings.fiat_plugin_cannot_continue_camera_permission) + return +} +``` + +Note the inverted boolean logic: `!success` becomes `deniedPermission`. + +## New Features in Ramp Plugin Architecture + +### Settlement Range + +The new ramp plugin architecture introduces a `settlementRange` field in quote results that provides users with transparency about transaction completion times. This feature was not available in the legacy provider architecture. + +#### Interface + +The `settlementRange` field uses the following structure: + +```typescript +interface SettlementRange { + min: { + value: number + unit: 'minutes' | 'hours' | 'days' + } + max: { + value: number + unit: 'minutes' | 'hours' | 'days' + } +} +``` + +#### Purpose + +This field helps users understand when they can expect their transaction to complete, improving the user experience by setting clear expectations for settlement times. + +#### Example Implementation + +Here's an example of how to include settlement range in your ramp plugin's quote response: + +```typescript +// Example from Simplex plugin +const quote: RampQuote = { + pluginId: 'simplex', + direction: 'buy', + fiatAmount: 100, + cryptoAmount: 0.0025, + // ... other quote fields + settlementRange: { + min: { value: 10, unit: 'minutes' }, + max: { value: 60, unit: 'minutes' } + } +} +``` + +This example indicates that the transaction will typically complete between 10 and 60 minutes. + +#### Migration Note + +When migrating from the legacy provider architecture, you'll need to: +1. Add the `settlementRange` field to your quote responses +2. Map provider-specific settlement time data to the standardized format +3. Use reasonable defaults if the provider doesn't supply exact settlement times + +The settlement range feature enhances user trust and reduces support inquiries by providing upfront visibility into transaction processing times. + +## Replacing getSupportedAssets with checkSupport + +The ramp plugin architecture has been simplified by replacing the `getSupportedAssets` method with a simpler `checkSupport` method. The new `checkSupport` method serves the same purpose of validating whether a plugin supports a specific request, but with a simpler interface. + +### Migration Steps + +1. **Replace `getSupportedAssets` method with `checkSupport` method** +2. **Extract validation logic into reusable helper functions** +3. **Have `checkSupport` return `{ supported: true/false }` instead of asset maps** +4. **Include ALL `checkSupport` logic as guards in `fetchQuote`** +5. **Different error handling**: + - `checkSupport`: Never throws, returns `{ supported: false }` for any validation failure + - `fetchQuote`: Throws errors when support checks fail or for API/network errors + +### Important: Guard Logic Must Be Duplicated + +**ALL validation logic from `checkSupport` MUST be included as guards in `fetchQuote`.** This is a critical architectural requirement. The `checkSupport` method is called by the UI to filter available plugins, but `fetchQuote` can still be called directly and must enforce the same validation rules. + +#### Error Handling Differences + +The two methods handle validation failures differently: + +- **`checkSupport`**: NEVER throws errors. Always returns `{ supported: false }` for any validation failure +- **`fetchQuote`**: SHOULD throw `FiatProviderError` when validation fails, allowing the UI to handle and display appropriate error messages + +This difference exists because: + +- `checkSupport` is used for filtering and should fail silently +- `fetchQuote` is used for actual operations and should provide clear error feedback + +#### Using FiatProviderError + +When throwing errors from `fetchQuote`, always use `FiatProviderError` with the appropriate error type: + +```typescript +import { FiatProviderError } from '../gui/fiatProviderTypes' + +// Asset not supported +throw new FiatProviderError({ + providerId: pluginId, + errorType: 'assetUnsupported' +}) + +// Payment method not supported +throw new FiatProviderError({ + providerId: pluginId, + errorType: 'paymentUnsupported' +}) + +// Region restricted +throw new FiatProviderError({ + providerId: pluginId, + errorType: 'regionRestricted', + displayCurrencyCode: request.displayCurrencyCode +}) + +// Fiat currency not supported +throw new FiatProviderError({ + providerId: pluginId, + errorType: 'fiatUnsupported', + fiatCurrencyCode: request.fiatCurrencyCode, + paymentMethod: 'credit', + pluginDisplayName: 'Plugin Name' +}) + +// Over limit +throw new FiatProviderError({ + providerId: pluginId, + errorType: 'overLimit', + errorAmount: 10000, + displayCurrencyCode: 'USD' +}) + +// Under limit +throw new FiatProviderError({ + providerId: pluginId, + errorType: 'underLimit', + errorAmount: 10, + displayCurrencyCode: 'USD' +}) +``` + +### Important: Migrating Provider Initialization Logic + +When migrating from the legacy provider architecture, the initialization logic that `getSupportedAssets` performed (fetching supported assets, countries, payment methods, etc.) should be preserved but moved to an internal `fetchProviderConfig` function with caching. + +#### Implementation Pattern + +**1. Create a cache structure with TTL:** + +```typescript +interface ProviderConfigCache { + data: ProviderConfig | null + timestamp: number +} + +const CACHE_TTL_MS = 2 * 60 * 1000 // 2 minutes +let configCache: ProviderConfigCache = { + data: null, + timestamp: 0 +} +``` + +**2. Implement `fetchProviderConfig` as an internal function:** + +```typescript +async function fetchProviderConfig(): Promise { + const now = Date.now() + + // Check if cache is valid + if (configCache.data && now - configCache.timestamp < CACHE_TTL_MS) { + return configCache.data + } + + // Fetch fresh configuration + const config = await fetchProviderConfigFromAPI() + + // Update cache + configCache = { + data: config, + timestamp: now + } + + return config +} +``` + +**3. Call `fetchProviderConfig` from within `fetchQuote`:** + +```typescript +fetchQuote: async request => { + try { + // Fetch provider configuration (will use cache if valid) + const providerConfig = await fetchProviderConfig() + + // Use the config to validate the request + const { supportedAssets, supportedCountries, paymentMethods } = + providerConfig + + // Validate region + if (!supportedCountries.includes(request.regionCode.countryCode)) { + return [] // Return empty array for unsupported regions + } + + // Check if assets are supported + if (!isAssetSupported(request, supportedAssets)) { + return [] // Return empty array for unsupported assets + } + + // Proceed with quote fetching... + } catch (error) { + // Only throw for actual API/network failures + throw error + } +} +``` + +#### Key Benefits of This Pattern + +1. **Preserves initialization logic**: The same workflow that `getSupportedAssets` performed is maintained +2. **Efficient caching**: Provider configuration is cached for 2 minutes to reduce API calls +3. **Automatic refresh**: Cache automatically refreshes when TTL expires +4. **Internal implementation**: Configuration fetching is an implementation detail, not exposed in the plugin interface +5. **Consistent state**: All quote requests use the same provider configuration within the cache window + +### Example Migration + +**Before (Legacy Provider):** + +```typescript +getSupportedAssets: async (request) => { + const { direction, paymentTypes, regionCode } = request + + // Fetch provider configuration + const config = await api.getConfiguration() + + // Initialize provider state + const supportedAssets = config.assets + const supportedCountries = config.countries + + // Validate region + validateRegion(pluginId, regionCode, supportedCountries) + + // Check country restrictions + if (regionCode.countryCode === 'GB') { + throw new FiatProviderError({ errorType: 'assetUnsupported' }) + } + + // Return supported assets + return supportedAssets +}, + +fetchQuote: async (request) => { + // Fetch quotes... +} +``` + +**After (Ramp Plugin with Internal Config):** + +```typescript +// Internal cache structure +interface ConfigCache { + data: { + assets: AssetMap + countries: string[] + paymentMethods: PaymentMethod[] + } | null + timestamp: number +} + +const CACHE_TTL_MS = 2 * 60 * 1000 +let configCache: ConfigCache = { data: null, timestamp: 0 } + +// Internal function to fetch provider configuration +async function fetchProviderConfig() { + const now = Date.now() + + // Return cached data if still valid + if (configCache.data && now - configCache.timestamp < CACHE_TTL_MS) { + return configCache.data + } + + // Fetch fresh configuration from API + const config = await api.getConfiguration() + + // Update cache + configCache = { + data: { + assets: config.assets, + countries: config.countries, + paymentMethods: config.paymentMethods + }, + timestamp: now + } + + return configCache.data +} + +fetchQuote: async request => { + const { regionCode, direction } = request + + try { + // Get provider configuration (cached or fresh) + const config = await fetchProviderConfig() + + // Validate region using cached config + if (!config.countries.includes(regionCode.countryCode)) { + return [] // Return empty array for unsupported regions + } + + // Check country restrictions + if (regionCode.countryCode === 'GB') { + return [] // Return empty array for unsupported countries + } + + // Check if assets are supported using cached config + if (!isAssetSupported(request, config.assets)) { + return [] // Return empty array for unsupported assets + } + + // Proceed with quote fetching... + } catch (error) { + // Only throw for actual API/network failures + console.error('Failed to fetch provider config:', error) + throw error + } +} +``` + +### UI Integration + +The UI no longer needs a separate hook to check plugin support. Instead, it passes all plugins to `useRampQuotes`: + +**Before:** + +```typescript +import { useSupportedPlugins } from '../../hooks/useSupportedPlugins' + +const { data: supportedPlugins } = useSupportedPlugins({ ... }) +const quotes = useRampQuotes({ plugins: supportedPlugins }) +``` + +**After:** + +```typescript +const rampPlugins = useSelector(state => state.rampPlugins.plugins) +const quotes = useRampQuotes({ plugins: rampPlugins }) +``` + +### Benefits of the New Architecture + +1. **Simpler Interface**: `checkSupport` returns a simple boolean response instead of complex asset maps +2. **Focused Purpose**: Clear separation between support checking (`checkSupport`) and quote fetching (`fetchQuote`) +3. **Better Performance**: Lightweight support checks can be done quickly without fetching full asset configurations +4. **Cleaner Code**: Less data transformation and simpler return types +5. **Easier Plugin Development**: Clear distinction between validation and business logic + +## checkSupport Method - Replacement for getSupportedAssets + +The new ramp plugin architecture replaces `getSupportedAssets` with a simpler `checkSupport` method that validates whether a specific request is supported. + +### Purpose of checkSupport vs getSupportedAssets + +The old `getSupportedAssets` method served two purposes: + +1. Initializing provider configuration (supported assets, countries, payment methods) +2. Returning a complete asset map for the UI to filter + +The new `checkSupport` method has a single, focused purpose: + +- Validate whether a specific buy/sell request is supported by the plugin + +Key differences: + +- **No payment types needed**: The request doesn't include payment types +- **Boolean response**: Simply returns `{ supported: true/false }` +- **No asset maps**: Doesn't return full asset configuration +- **Faster checks**: Can return early without fetching full provider config if basic validation fails + +### Extracting Validation Logic + +When migrating from `getSupportedAssets` to `checkSupport`, extract the validation logic into reusable helper functions that can be shared between `checkSupport` and `fetchQuote`. + +### Implementation Pattern + +**1. Create internal helper functions for validation:** + +```typescript +// Internal helper to validate the support request +function validateSupportRequest(request: CheckSupportRequest): void { + const { direction, paymentMethods, regionCode } = request + + // Basic validation + if (!['buy', 'sell'].includes(direction)) { + throw new Error(`Invalid direction: ${direction}`) + } + + if (!regionCode.countryCode) { + throw new Error('Country code is required') + } + + // Validate payment methods if provided + if (paymentMethods && paymentMethods.length === 0) { + throw new Error('At least one payment method must be specified') + } +} + +// Internal helper to check if assets are supported +async function checkAssetSupport( + request: CheckSupportRequest, + providerConfig: ProviderConfig +): Promise { + const { fiatCurrencyCode, tokenId, direction, regionCode } = request + const { supportedAssets, blockedCountries } = providerConfig + + // Check country restrictions + if (blockedCountries.includes(regionCode.countryCode)) { + return false + } + + // Check if the asset pair is supported + const assetKey = `${direction}:${fiatCurrencyCode}:${tokenId}` + return supportedAssets.has(assetKey) +} + +// Internal helper to check payment method support +function checkPaymentMethodSupport( + request: CheckSupportRequest, + providerConfig: ProviderConfig +): boolean { + const { paymentMethods, direction } = request + + // If no payment methods specified, assume all are acceptable + if (!paymentMethods || paymentMethods.length === 0) { + return true + } + + // Check if at least one requested payment method is supported + const supportedMethods = providerConfig.paymentMethods[direction] || [] + return paymentMethods.some(method => supportedMethods.includes(method)) +} +``` + +**2. Implement checkSupport using the helper functions:** + +```typescript +checkSupport: async ( + request: CheckSupportRequest +): Promise => { + try { + // Validate the request structure + validateSupportRequest(request) + + // Quick checks before fetching provider config + const { regionCode, fiatCurrencyCode } = request + + // Example: Early return for known unsupported regions + if (UNSUPPORTED_REGIONS.includes(regionCode.countryCode)) { + return { supported: false } + } + + // Example: Early return for known unsupported currencies + if (!SUPPORTED_FIAT_CODES.includes(fiatCurrencyCode)) { + return { supported: false } + } + + // Fetch provider configuration (with caching) + const providerConfig = await fetchProviderConfig() + + // Check asset support + const assetSupported = await checkAssetSupport(request, providerConfig) + if (!assetSupported) { + return { supported: false } + } + + // Check payment method support + const paymentSupported = checkPaymentMethodSupport(request, providerConfig) + if (!paymentSupported) { + return { supported: false } + } + + // All checks passed + return { supported: true } + } catch (error) { + // Important: Return { supported: false } for validation failures + // Only throw for actual system errors (network issues, etc.) + if (error instanceof ValidationError) { + console.warn('Validation failed in checkSupport:', error.message) + return { supported: false } + } + + // Rethrow system errors + console.error('System error in checkSupport:', error) + throw error + } +} +``` + +**3. Reuse the same helpers in fetchQuote with different error handling:** + +```typescript +fetchQuote: async (request: FetchQuoteRequest): Promise => { + // IMPORTANT: Include ALL checkSupport validation logic as guards + // But throw errors instead of returning empty arrays + + // Use the same validation helper + validateSupportRequest(request) + + // Fetch provider configuration + const providerConfig = await fetchProviderConfig() + + // Use the same support checking helpers - but THROW on failure + const assetSupported = await checkAssetSupport(request, providerConfig) + if (!assetSupported) { + throw new FiatProviderError({ + providerId: pluginId, + errorType: 'assetUnsupported' + }) + } + + const paymentSupported = checkPaymentMethodSupport(request, providerConfig) + if (!paymentSupported) { + throw new FiatProviderError({ + providerId: pluginId, + errorType: 'paymentUnsupported' + }) + } + + // Check region support - THROW on failure + if (!checkRegionSupport(request.regionCode, providerConfig)) { + throw new FiatProviderError({ + providerId: pluginId, + errorType: 'regionRestricted', + displayCurrencyCode: request.displayCurrencyCode + }) + } + + // Proceed with quote fetching + const quotes = await fetchQuotesFromAPI(request, providerConfig) + + return quotes.map(quote => ({ + // Map to RampQuote format + ...quote, + pluginId, + direction: request.direction + })) +} +``` + +### Complete Example: Moonpay Plugin + +Here's a complete example showing how to implement `checkSupport` with proper error handling and shared validation logic: + +```typescript +// Example implementation - adapt types to your actual plugin structure +import { FiatProviderError } from '../gui/fiatProviderTypes' + +// Constants +const SUPPORTED_FIAT_CODES = ['USD', 'EUR', 'GBP', 'CAD', 'AUD'] +const BLOCKED_REGIONS = ['US-NY', 'US-WA'] // New York and Washington state +const CACHE_TTL_MS = 2 * 60 * 1000 + +// Cache structure +interface MoonpayConfig { + supportedAssets: Map + blockedCountries: string[] + paymentMethods: { + buy: PaymentMethodId[] + sell: PaymentMethodId[] + } +} + +let configCache: { data: MoonpayConfig | null; timestamp: number } = { + data: null, + timestamp: 0 +} + +// Validation error class +class ValidationError extends Error { + constructor(message: string) { + super(message) + this.name = 'ValidationError' + } +} + +// Helper: Validate request structure +function validateSupportRequest(request: any): void { + const { direction, regionCode, fiatCurrencyCode, cryptoAsset } = request + + if (!direction || !['buy', 'sell'].includes(direction)) { + throw new ValidationError(`Invalid direction: ${direction}`) + } + + if (!regionCode?.countryCode) { + throw new ValidationError('Country code is required') + } + + if (!fiatCurrencyCode) { + throw new ValidationError('Fiat currency code is required') + } + + if (!cryptoAsset) { + throw new ValidationError('Crypto asset is required') + } +} + +// Helper: Check region support +function checkRegionSupport( + regionCode: { countryCode: string; stateCode?: string }, + config: MoonpayConfig +): boolean { + // Check blocked countries + if (config.blockedCountries.includes(regionCode.countryCode)) { + return false + } + + // Check blocked regions (state level) + if (regionCode.stateCode) { + const regionKey = `${regionCode.countryCode}-${regionCode.stateCode}` + if (BLOCKED_REGIONS.includes(regionKey)) { + return false + } + } + + return true +} + +// Helper: Check asset support +function checkAssetSupport(request: any, config: MoonpayConfig): boolean { + const { direction, fiatCurrencyCode, cryptoAsset } = request + + // Quick check for supported fiat + if (!SUPPORTED_FIAT_CODES.includes(fiatCurrencyCode)) { + return false + } + + // Check in provider's asset map + const assetKey = `${direction}:${fiatCurrencyCode}:${cryptoAsset.pluginId}:${cryptoAsset.tokenId}` + return config.supportedAssets.has(assetKey) +} + +// Helper: Check payment method support +function checkPaymentMethodSupport( + request: any, + config: MoonpayConfig +): boolean { + const { paymentTypes, direction } = request + + // If no payment methods specified, consider it supported + if (!paymentTypes || paymentTypes.length === 0) { + return true + } + + // Check if any requested method is supported + const supportedMethods = config.paymentMethods[direction] || [] + return paymentTypes.some(method => supportedMethods.includes(method)) +} + +// Helper: Fetch provider configuration with caching +async function fetchProviderConfig(): Promise { + const now = Date.now() + + // Return cached data if still valid + if (configCache.data && now - configCache.timestamp < CACHE_TTL_MS) { + return configCache.data + } + + // Fetch fresh configuration + const response = await moonpayApi.getConfiguration() + + // Transform API response to internal format + const config: MoonpayConfig = { + supportedAssets: new Map( + response.currencies.map(c => [ + `${c.type}:${c.fiatCode}:${c.cryptoCode}`, + c + ]) + ), + blockedCountries: response.blockedCountries, + paymentMethods: { + buy: response.buyMethods, + sell: response.sellMethods + } + } + + // Update cache + configCache = { data: config, timestamp: now } + + return config +} + +// Main plugin implementation (simplified example) +export const moonpayRampPlugin = { + pluginId: 'moonpay', + + checkSupport: async (request: any): Promise<{ supported: boolean }> => { + try { + // Step 1: Validate request structure + validateSupportRequest(request) + + // Step 2: Quick local checks (no API calls) + if (!SUPPORTED_FIAT_CODES.includes(request.fiatCurrencyCode)) { + return { supported: false } + } + + // Step 3: Fetch provider configuration + const config = await fetchProviderConfig() + + // Step 4: Check region support + if (!checkRegionSupport(request.regionCode, config)) { + return { supported: false } + } + + // Step 5: Check asset support + if (!checkAssetSupport(request, config)) { + return { supported: false } + } + + // Step 6: Check payment method support + if (!checkPaymentMethodSupport(request, config)) { + return { supported: false } + } + + // All checks passed + return { supported: true } + } catch (error) { + // Validation errors = not supported + if (error instanceof ValidationError) { + console.warn('checkSupport validation failed:', error.message) + return { supported: false } + } + + // Network/API errors should be thrown + console.error('checkSupport system error:', error) + throw error + } + }, + + fetchQuote: async (request: any): Promise => { + // IMPORTANT: Include ALL checkSupport logic as guards + // But THROW errors instead of returning { supported: false } + + // Reuse the same validation + validateSupportRequest(request) + + // Reuse the same config fetching + const config = await fetchProviderConfig() + + // Reuse the same support checks - but THROW FiatProviderError on failure + if (!checkRegionSupport(request.regionCode, config)) { + throw new FiatProviderError({ + providerId: 'moonpay', + errorType: 'regionRestricted', + displayCurrencyCode: request.displayCurrencyCode + }) + } + + if (!checkAssetSupport(request, config)) { + throw new FiatProviderError({ + providerId: 'moonpay', + errorType: 'assetUnsupported' + }) + } + + if (!checkPaymentMethodSupport(request, config)) { + throw new FiatProviderError({ + providerId: 'moonpay', + errorType: 'paymentUnsupported' + }) + } + + // Fetch actual quotes + const apiQuotes = await moonpayApi.getQuotes({ + baseCurrency: request.fiatCurrencyCode, + quoteCurrency: request.displayCurrencyCode, + baseCurrencyAmount: request.fiatAmount, + paymentMethod: request.paymentTypes?.[0] || 'card', + areFeesIncluded: true + }) + + // Transform to RampQuote format + return apiQuotes.map(quote => ({ + pluginId: 'moonpay', + direction: request.direction, + fiatAmount: quote.baseCurrencyAmount, + cryptoAmount: quote.quoteCurrencyAmount, + fiatCurrencyCode: request.fiatCurrencyCode, + displayCurrencyCode: request.displayCurrencyCode, + paymentMethodId: quote.paymentMethod, + partnerFee: quote.feeAmount, + totalFee: quote.totalFeeAmount, + rate: quote.quoteCurrencyPrice, + expirationDate: new Date(quote.expiresAt) + })) + } +} +``` + +### Key Implementation Guidelines + +1. **Different error handling between methods**: + - `checkSupport`: NEVER throws - returns `{ supported: false }` for any failure + - `fetchQuote`: ALWAYS throws `FiatProviderError` when validation fails +2. **Duplicate ALL validation logic**: Every check in `checkSupport` MUST also exist as a guard in `fetchQuote` +3. **Use FiatProviderError**: Always throw `FiatProviderError` with appropriate error types in `fetchQuote` +4. **Share validation logic**: Extract common validation into helper functions used by both methods +5. **Early returns in checkSupport**: Perform quick local checks before making API calls +6. **Provide specific error details**: Use the correct `errorType` and include relevant fields (amounts, currency codes) +7. **Cache provider config**: Reuse the cached provider configuration pattern for both methods +8. **Consistent validation**: Both methods must enforce the exact same business rules + +## UI Integration + +The UI now uses the `useSupportedPlugins` hook which calls `checkSupport` on all plugins to filter for supported ones: + +**Current flow:** + +```typescript +import { useSupportedPlugins } from '../../hooks/useSupportedPlugins' + +// The hook internally calls checkSupport on each plugin +const { data: supportedPlugins } = useSupportedPlugins({ + direction: 'buy', + regionCode: { countryCode: 'US', stateCode: 'CA' }, + fiatCurrencyCode: 'USD', + tokenId: 'ethereum:null', + paymentMethods: ['credit', 'bank'] +}) + +// Only supported plugins are passed to quote fetching +const quotes = useRampQuotes({ + plugins: supportedPlugins, + request: quoteRequest +}) +``` + +The `useSupportedPlugins` hook: + +- Calls `checkSupport` on all available plugins in parallel +- Filters out plugins that return `{ supported: false }` +- Only passes supported plugins to the quote fetching stage +- Provides better user experience by not showing unsupported providers + +## Benefits + +- **Reduced abstraction**: Direct usage of APIs makes code easier to understand +- **Better type safety**: TypeScript can properly type-check direct API usage +- **Improved maintainability**: Less wrapper code to maintain +- **Clearer dependencies**: It's obvious what external APIs each plugin uses diff --git a/docs/scene-architecture-patterns.md b/docs/scene-architecture-patterns.md new file mode 100644 index 00000000000..5adcb84710d --- /dev/null +++ b/docs/scene-architecture-patterns.md @@ -0,0 +1,83 @@ +# Scene Architecture Patterns + +## Overview + +Edge scenes follow specific architectural patterns that must be adhered to for proper integration with the navigation system. This document outlines the critical patterns discovered during TradeCreateScene development. + +## Critical Rule: No Custom Headers in Scenes + +**NEVER implement custom header UI within scene components.** Headers are managed by `react-navigation` in `src/components/Main.tsx`. + +### ❌ Incorrect Pattern (Architecture Violation) +```tsx +// DON'T DO THIS - Custom header in scene +const TradeCreateScene = () => { + return ( + + + + + + Edge + + + {/* Scene content */} + + ) +} +``` + +### ✅ Correct Pattern +```tsx +// DO THIS - Let react-navigation handle headers +const TradeCreateScene = () => { + return ( + + {/* Scene content only - no header elements */} + + ) +} +``` + +## SceneWrapper Usage + +All scenes should use `SceneWrapper` from `src/components/common/SceneWrapper.tsx`: + +- **With scrolling content**: `` +- **Without scrolling**: `` +- **Never include header elements** inside SceneWrapper + +## Navigation Configuration + +Headers are configured in `src/components/Main.tsx` using react-navigation patterns. Scene components should focus solely on content, not navigation UI. + +## TradeCreateScene Case Study + +### What We Built +- Complete "Buy Crypto" interface matching design.png +- Location selector, fiat/crypto inputs, exchange rates, next button +- Proper dark theme styling and TypeScript integration + +### Architecture Issue Discovered +- Initially implemented custom header UI within the scene +- This violates Edge's scene architecture patterns +- Headers must be managed by react-navigation, not individual scenes + +### Resolution Required +1. Remove all custom header code from TradeCreateScene +2. Update SceneWrapper usage with proper props +3. Ensure react-navigation handles header display +4. Test integration with Edge's navigation system + +## Key Takeaways + +1. **Scenes handle content only** - never navigation UI +2. **SceneWrapper is the root container** for all scene content +3. **react-navigation manages headers** via Main.tsx configuration +4. **Always check existing scenes** for architectural patterns before implementing new ones + +## Reference Files + +- `src/components/Main.tsx` - Navigation configuration +- `src/components/common/SceneWrapper.tsx` - Scene wrapper patterns +- Existing scene files for architectural examples \ No newline at end of file diff --git a/docs/simplex-provider-comparison-report.md b/docs/simplex-provider-comparison-report.md new file mode 100644 index 00000000000..abdefc1c697 --- /dev/null +++ b/docs/simplex-provider-comparison-report.md @@ -0,0 +1,137 @@ +# Simplex Provider vs Ramp Plugin Comparison Report + +This document provides a detailed comparison between the legacy `simplexProvider.ts` implementation and the new `simplexRampPlugin.ts` implementation, highlighting key differences and potential inconsistencies. + +## 1. Direction Support Differences + +### Old Provider (simplexProvider.ts) +- **Buy only**: Strictly enforces buy-only support +- Direction check in `getSupportedAssets`: `if (direction !== 'buy' || regionCode.countryCode === 'GB')` +- Direction check in `getQuote`: `if (direction !== 'buy')` +- Direction check in deeplink handler: `if (link.direction !== 'buy') return` + +### New Plugin (simplexRampPlugin.ts) +- **Buy only**: Same restriction but implemented differently +- Separate validation function: `validateDirection(direction: 'buy' | 'sell'): boolean` +- Consistent checks in both `checkSupport` and `fetchQuote` +- Same deeplink handler check: `if (link.direction !== 'buy') return` + +**Consistency**: ✅ Both implementations only support 'buy' direction + +## 2. Region/Country Validation Differences + +### Old Provider (simplexProvider.ts) +- **GB restriction**: Combined with direction check: `if (direction !== 'buy' || regionCode.countryCode === 'GB')` +- **Daily check**: Uses `isDailyCheckDue(lastChecked)` for 24-hour caching +- **Storage**: Uses module-level variables for caching +- **Validation**: Direct `validateExactRegion` call in `getSupportedAssets` and `getQuote` + +### New Plugin (simplexRampPlugin.ts) +- **GB restriction**: Separate check in `validateRegion`: `if (regionCode.countryCode === 'GB') return false` +- **TTL-based caching**: Uses 2-minute TTL (`PROVIDER_CONFIG_TTL_MS = 2 * 60 * 1000`) +- **Storage**: Uses instance-level `providerConfig` with `lastUpdated` timestamp +- **Validation**: Wrapped in try-catch for better error handling + +**Inconsistency**: ⚠️ Caching duration differs significantly (24 hours vs 2 minutes) + +## 3. Fiat Currency Support Differences + +### Old Provider (simplexProvider.ts) +- **Format**: Stores with 'iso:' prefix: `allowedCurrencyCodes.fiat['iso:' + fc.ticker_symbol] = fc` +- **API endpoint**: `https://api.simplexcc.com/v2/supported_fiat_currencies` +- **Storage**: Global `allowedCurrencyCodes` object + +### New Plugin (simplexRampPlugin.ts) +- **Format**: Same 'iso:' prefix: `newConfig.fiat['iso:' + fc.ticker_symbol] = fc` +- **API endpoint**: Same URL via constant `SIMPLEX_API_URL` +- **Validation**: Adds 'iso:' prefix during validation: `validateFiat('iso:${fiatAsset.currencyCode}')` + +**Consistency**: ✅ Both use the same fiat currency format and API + +## 4. Crypto Currency Support Differences + +### Old Provider (simplexProvider.ts) +- **Token support**: Uses `getTokenId` to check token validity +- **Storage**: Adds tokens with `addTokenToArray({ tokenId }, tokens)` +- **Validation**: Implicit through presence in `allowedCurrencyCodes` + +### New Plugin (simplexRampPlugin.ts) +- **Token support**: Only supports native currencies (tokenId === null) +- **Storage**: Adds with `addTokenToArray({ tokenId: null }, tokens)` +- **Validation**: Explicit check: `if (cryptoAsset.tokenId === null)` +- **Error handling**: Returns `{ supported: false }` for tokens + +**Inconsistency**: ⚠️ New plugin explicitly rejects tokens while old provider could support them + +## 5. Payment Type Support Differences + +### Old Provider (simplexProvider.ts) +- **Supported types**: `applepay: true, credit: true, googlepay: true` +- **Validation**: Checks array of payment types +- **Error**: Throws `FiatProviderError` with `errorType: 'paymentUnsupported'` + +### New Plugin (simplexRampPlugin.ts) +- **Supported types**: Defined in types file with same values +- **Quote response**: Always returns `paymentType: 'credit'` in quotes +- **No validation**: Doesn't validate payment types in `checkSupport` or `fetchQuote` + +**Inconsistency**: ⚠️ New plugin doesn't validate payment types and always returns 'credit' + +## 6. Quote Workflow Differences + +### Old Provider (simplexProvider.ts) +- **JWT endpoint**: `v1/jwtSign/simplex` for quote JWT +- **Quote URL**: `https://partners.simplex.com/api/quote?partner=${partner}&t=${token}` +- **Expiration**: 8 seconds (`Date.now() + 8000`) +- **Multiple payment type checks**: Validates payment types twice + +### New Plugin (simplexRampPlugin.ts) +- **JWT endpoint**: Same endpoint via centralized function +- **Quote URL**: Same URL structure +- **Expiration**: Same 8 seconds +- **Settlement range**: Adds new feature: `10-60 minutes` + +**Enhancement**: ✅ New plugin adds settlement range information + +## 7. Error Handling Differences + +### Old Provider (simplexProvider.ts) +- **Network errors**: Uses `.catch(e => undefined)` pattern +- **Generic errors**: Throws `new Error('Simplex unknown error')` +- **Toast messages**: Uses `NOT_SUCCESS_TOAST_HIDE_MS` constant + +### New Plugin (simplexRampPlugin.ts) +- **Network errors**: More explicit error logging +- **Error propagation**: Re-throws `FiatProviderError` instances +- **Toast messages**: Direct `showToast` calls with same timeout + +**Improvement**: ✅ New plugin has better error logging and handling + +## 8. API Endpoint Differences + +### Old Provider (simplexProvider.ts) +- **Hardcoded URLs**: Direct string literals in code +- **JWT endpoint**: `v1/jwtSign/${jwtTokenProvider}` for approval + +### New Plugin (simplexRampPlugin.ts) +- **Centralized URLs**: Constants in types file +- **Same endpoints**: Uses identical API URLs +- **Better organization**: All URLs in one place + +**Improvement**: ✅ New plugin has better URL management + +## Key Inconsistencies Summary + +1. **Caching Duration**: 24 hours vs 2 minutes - This could impact API rate limits +2. **Token Support**: Old supports tokens, new explicitly rejects them +3. **Payment Type Validation**: New plugin doesn't validate payment types +4. **WebView Handling**: New uses platform-specific libraries (SafariView/CustomTabs) +5. **User ID Generation**: Different fallback patterns when makeUuid unavailable + +## Recommendations + +1. **Align caching duration**: Consider if 2-minute TTL is too aggressive +2. **Clarify token support**: Document why tokens are not supported in new plugin +3. **Add payment type validation**: Implement validation in checkSupport/fetchQuote +4. **Document behavior changes**: Create migration guide for these differences +5. **Test edge cases**: Verify behavior when makeUuid is unavailable diff --git a/eslint.config.mjs b/eslint.config.mjs index 9b52b258ffd..bd9fabfe1c1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -65,7 +65,7 @@ export default [ 'src/actions/CategoriesActions.ts', 'src/actions/CountryListActions.tsx', 'src/actions/CreateWalletActions.tsx', - 'src/actions/DeepLinkingActions.tsx', + 'src/actions/DeviceSettingsActions.ts', 'src/actions/ExchangeRateActions.ts', 'src/actions/FioActions.tsx', @@ -90,7 +90,7 @@ export default [ 'src/actions/WalletListActions.tsx', 'src/actions/WalletListMenuActions.tsx', 'src/app.ts', - 'src/components/App.tsx', + 'src/components/buttons/ButtonsView.tsx', 'src/components/buttons/EdgeSwitch.tsx', 'src/components/buttons/IconButton.tsx', @@ -158,7 +158,7 @@ export default [ 'src/components/modals/AirshipFullScreenSpinner.tsx', 'src/components/modals/AutoLogoutModal.tsx', 'src/components/modals/BackupModal.tsx', - 'src/components/modals/ButtonsModal.tsx', + 'src/components/modals/CategoryModal.tsx', 'src/components/modals/ConfirmContinueModal.tsx', 'src/components/modals/ContactListModal.tsx', @@ -189,7 +189,7 @@ export default [ 'src/components/modals/TextInputModal.tsx', 'src/components/modals/TransferModal.tsx', 'src/components/modals/WalletListMenuModal.tsx', - 'src/components/modals/WalletListModal.tsx', + 'src/components/modals/WalletListSortModal.tsx', 'src/components/modals/WcSmartContractModal.tsx', 'src/components/modals/WebViewModal.tsx', @@ -272,7 +272,7 @@ export default [ 'src/components/scenes/Fio/FioStakingOverviewScene.tsx', 'src/components/scenes/FormScene.tsx', 'src/components/scenes/GettingStartedScene.tsx', - 'src/components/scenes/GuiPluginListScene.tsx', + 'src/components/scenes/HomeScene.tsx', 'src/components/scenes/inputs/DigitInput.tsx', 'src/components/scenes/inputs/DigitInput/PinDots.tsx', @@ -298,7 +298,7 @@ export default [ 'src/components/scenes/RequestScene.tsx', 'src/components/scenes/ReviewTriggerTestScene.tsx', 'src/components/scenes/SecurityAlertsScene.tsx', - 'src/components/scenes/SendScene2.tsx', + 'src/components/scenes/SettingsScene.tsx', 'src/components/scenes/SpendingLimitsScene.tsx', 'src/components/scenes/Staking/EarnScene.tsx', @@ -473,21 +473,20 @@ export default [ 'src/plugins/borrow-plugins/plugins/aave/index.ts', 'src/plugins/gui/amountQuotePlugin.ts', 'src/plugins/gui/components/GuiFormField.tsx', - 'src/plugins/gui/fiatPlugin.tsx', + 'src/plugins/gui/pluginUtils.ts', 'src/plugins/gui/providers/banxaProvider.ts', 'src/plugins/gui/providers/bityProvider.ts', 'src/plugins/gui/providers/ioniaProvider.ts', 'src/plugins/gui/providers/kadoOtcProvider.ts', - 'src/plugins/gui/providers/kadoProvider.ts', 'src/plugins/gui/providers/moonpayProvider.ts', 'src/plugins/gui/providers/mtpelerinProvider.ts', - 'src/plugins/gui/providers/paybisProvider.ts', + 'src/plugins/gui/providers/revolutProvider.ts', 'src/plugins/gui/providers/simplexProvider.ts', 'src/plugins/gui/RewardsCardPlugin.tsx', 'src/plugins/gui/scenes/FiatPluginEnterAmountScene.tsx', - 'src/plugins/gui/scenes/FiatPluginWebView.tsx', + 'src/plugins/gui/scenes/InfoDisplayScene.tsx', 'src/plugins/gui/scenes/RewardsCardDashboardScene.tsx', 'src/plugins/gui/scenes/RewardsCardWelcomeScene.tsx', @@ -538,7 +537,7 @@ export default [ 'src/util/cryptoTextUtils.ts', 'src/util/CurrencyInfoHelpers.ts', 'src/util/CurrencyWalletHelpers.ts', - 'src/util/DeepLinkParser.ts', + 'src/util/exchangeRates.ts', 'src/util/fake/FakeProviders.tsx', 'src/util/FioAddressUtils.ts', diff --git a/opencode.json b/opencode.json new file mode 100644 index 00000000000..89c6aab1c4c --- /dev/null +++ b/opencode.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://opencode.ai/config.json", + "formatter": { + "prettier": { + "disabled": true + }, + "lint-formatter": { + "command": ["yarn", "eslint", "--fix", "$FILE"], + "extensions": [".js", ".ts", ".jsx", ".tsx"] + } + }, + "lsp": { + "eslint": { + "command": ["vscode-eslint-language-server", "--stdio"], + "extensions": [".js", ".jsx", ".ts", ".tsx"] + } + } +} diff --git a/package.json b/package.json index 63c429d338a..34d4470434b 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "theme": "node -r sucrase/register ./scripts/themeServer.ts", "updateVersion": "node -r sucrase/register scripts/updateVersion.ts", "updot": "EDGE_MODE=development updot", - "verify": "npm run lint && npm run typechain && npm run tsc && npm run test", + "verify": "npm run lint && npm run typechain && tsc && npm run test", "watch": "npm test -- --watch", "update-eslint-warnings": "node -r sucrase/register ./scripts/updateEslintWarnings.ts" }, @@ -82,6 +82,7 @@ "@react-navigation/native": "^6.1.3", "@react-navigation/stack": "^6.3.12", "@sentry/react-native": "^6.14.0", + "@tanstack/react-query": "^5.84.2", "@types/jsrsasign": "^10.5.13", "@unstoppabledomains/resolution": "^9.3.0", "@walletconnect/react-native-compat": "^2.21.6", diff --git a/simulator_screenshot.png b/simulator_screenshot.png new file mode 100644 index 00000000000..84f44266a6f Binary files /dev/null and b/simulator_screenshot.png differ diff --git a/src/__tests__/DeepLink.test.ts b/src/__tests__/DeepLink.test.ts index 79b2f0d4ab3..c4e2d359360 100644 --- a/src/__tests__/DeepLink.test.ts +++ b/src/__tests__/DeepLink.test.ts @@ -263,6 +263,43 @@ describe('parseDeepLink', function () { }) }) + describe('ramp', function () { + makeLinkTests({ + 'edge://ramp/buy/paybis?transactionStatus=success': { + type: 'ramp', + providerId: 'paybis', + direction: 'buy', + path: '', + query: { transactionStatus: 'success' }, + uri: 'edge://ramp/buy/paybis?transactionStatus=success' + }, + 'https://deep.edge.app/ramp/buy/paybis?transactionStatus=success': { + type: 'ramp', + providerId: 'paybis', + direction: 'buy', + path: '', + query: { transactionStatus: 'success' }, + uri: 'edge://ramp/buy/paybis?transactionStatus=success' + }, + 'https://return.edge.app/ramp/buy/paybis?transactionStatus=success': { + type: 'ramp', + providerId: 'paybis', + direction: 'buy', + path: '', + query: { transactionStatus: 'success' }, + uri: 'edge://ramp/buy/paybis?transactionStatus=success' + }, + 'edge://ramp/sell/paybis?transactionStatus=fail': { + type: 'ramp', + providerId: 'paybis', + direction: 'sell', + path: '', + query: { transactionStatus: 'fail' }, + uri: 'edge://ramp/sell/paybis?transactionStatus=fail' + } + }) + }) + describe('promotion', function () { makeLinkTests({ 'edge://promotion/bob': { diff --git a/src/__tests__/plugins/ramps/bityRampPlugin.test.ts b/src/__tests__/plugins/ramps/bityRampPlugin.test.ts new file mode 100644 index 00000000000..5ee311ebd1f --- /dev/null +++ b/src/__tests__/plugins/ramps/bityRampPlugin.test.ts @@ -0,0 +1,328 @@ +import { bityRampPlugin } from '../../../plugins/ramps/bity/bityRampPlugin' +import type { + RampCheckSupportRequest, + RampPlugin, + RampPluginConfig, + RampQuoteRequest +} from '../../../plugins/ramps/rampPluginTypes' + +// Mock account with currency configs +const mockAccount = { + currencyConfig: { + ethereum: { + currencyInfo: { + currencyCode: 'ETH' + }, + allTokens: { + a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48: { + currencyCode: 'USDC' + }, + dac17f958d2ee523a2206206994597c13d831ec7: { + currencyCode: 'USDT' + } + } + } + } +} as any + +// Mock the config +const mockConfig: RampPluginConfig = { + initOptions: { clientId: 'test-client-id' }, + account: mockAccount, + navigation: {} as any, + onLogEvent: jest.fn(), + disklet: {} as any, + store: {} as any +} + +// Mock fetch globally +global.fetch = jest.fn() + +describe('Bity Ramp Plugin Implementation', () => { + let plugin: RampPlugin + + beforeEach(() => { + jest.clearAllMocks() + plugin = bityRampPlugin(mockConfig) + }) + + describe('Plugin Interface', () => { + it('should have all required properties', () => { + expect(plugin).toHaveProperty('pluginId') + expect(plugin).toHaveProperty('rampInfo') + expect(plugin).toHaveProperty('checkSupport') + expect(plugin).toHaveProperty('fetchQuote') + }) + + it('should have correct plugin metadata', () => { + expect(plugin.pluginId).toBe('bity') + expect(plugin.rampInfo).toEqual({ + partnerIcon: expect.stringContaining('logoBity.png'), + pluginDisplayName: 'Bity' + }) + }) + }) + + describe('checkSupport method', () => { + it('should return RampSupportResult type', async () => { + const request: RampCheckSupportRequest = { + direction: 'buy', + regionCode: { countryCode: 'CH', stateProvinceCode: undefined }, + fiatAsset: { currencyCode: 'EUR' }, + cryptoAsset: { pluginId: 'bitcoin', tokenId: null } + } + + const result = await plugin.checkSupport(request) + + expect(result).toHaveProperty('supported') + expect(typeof result.supported).toBe('boolean') + }) + + it('should return false for unsupported region', async () => { + const request: RampCheckSupportRequest = { + direction: 'buy', + regionCode: { countryCode: 'US', stateProvinceCode: 'CA' }, + fiatAsset: { currencyCode: 'USD' }, + cryptoAsset: { pluginId: 'bitcoin', tokenId: null } + } + + const result = await plugin.checkSupport(request) + expect(result).toEqual({ supported: false }) + }) + + it('should return false for unsupported crypto', async () => { + const request: RampCheckSupportRequest = { + direction: 'buy', + regionCode: { countryCode: 'CH', stateProvinceCode: undefined }, + fiatAsset: { currencyCode: 'EUR' }, + cryptoAsset: { pluginId: 'dogecoin', tokenId: null } + } + + const result = await plugin.checkSupport(request) + expect(result).toEqual({ supported: false }) + }) + + it('should handle API errors gracefully', async () => { + ;(global.fetch as jest.Mock).mockRejectedValueOnce( + new Error('Network error') + ) + + const request: RampCheckSupportRequest = { + direction: 'buy', + regionCode: { countryCode: 'CH', stateProvinceCode: undefined }, + fiatAsset: { currencyCode: 'EUR' }, + cryptoAsset: { pluginId: 'bitcoin', tokenId: null } + } + + const result = await plugin.checkSupport(request) + expect(result).toEqual({ supported: false }) + }) + }) + + describe('fetchQuote method', () => { + it('should return empty array for unsupported requests', async () => { + const request: RampQuoteRequest = { + direction: 'buy', + regionCode: { countryCode: 'US', stateProvinceCode: 'CA' }, + fiatCurrencyCode: 'iso:USD', + exchangeAmount: '100', + amountType: 'fiat', + displayCurrencyCode: 'BTC', + pluginId: 'bitcoin', + tokenId: null + } + + const quotes = await plugin.fetchQuote(request) + expect(quotes).toEqual([]) + }) + + it('should return empty array on API errors', async () => { + ;(global.fetch as jest.Mock).mockRejectedValueOnce( + new Error('Network error') + ) + + const request: RampQuoteRequest = { + direction: 'buy', + regionCode: { countryCode: 'CH', stateProvinceCode: undefined }, + fiatCurrencyCode: 'iso:EUR', + exchangeAmount: '100', + amountType: 'fiat', + displayCurrencyCode: 'BTC', + pluginId: 'bitcoin', + tokenId: null + } + + const quotes = await plugin.fetchQuote(request) + expect(quotes).toEqual([]) + }) + }) + + describe('Shared validation logic', () => { + it('should use consistent region validation', async () => { + const unsupportedRegion = { + countryCode: 'JP', + stateProvinceCode: undefined + } + + // Test checkSupport + const supportResult = await plugin.checkSupport({ + direction: 'buy', + regionCode: unsupportedRegion, + fiatAsset: { currencyCode: 'EUR' }, + cryptoAsset: { pluginId: 'bitcoin', tokenId: null } + }) + expect(supportResult.supported).toBe(false) + + // Test fetchQuote + const quotes = await plugin.fetchQuote({ + direction: 'buy', + regionCode: unsupportedRegion, + fiatCurrencyCode: 'iso:EUR', + exchangeAmount: '100', + amountType: 'fiat', + displayCurrencyCode: 'BTC', + pluginId: 'bitcoin', + tokenId: null + }) + expect(quotes).toEqual([]) + }) + + it('should use consistent crypto validation', async () => { + const unsupportedCrypto = { pluginId: 'dogecoin', tokenId: null } + const supportedRegion = { + countryCode: 'CH', + stateProvinceCode: undefined + } + + // Test checkSupport + const supportResult = await plugin.checkSupport({ + direction: 'buy', + regionCode: supportedRegion, + fiatAsset: { currencyCode: 'EUR' }, + cryptoAsset: unsupportedCrypto + }) + expect(supportResult.supported).toBe(false) + + // Test fetchQuote + const quotes = await plugin.fetchQuote({ + direction: 'buy', + regionCode: supportedRegion, + fiatCurrencyCode: 'iso:EUR', + exchangeAmount: '100', + amountType: 'fiat', + displayCurrencyCode: 'DOGE', + pluginId: unsupportedCrypto.pluginId, + tokenId: unsupportedCrypto.tokenId + }) + expect(quotes).toEqual([]) + }) + }) +}) + +// Example usage demonstrating how checkSupport would be used +describe('Dynamic Token ID Resolution', () => { + it('should work with token IDs from account currency config', async () => { + const plugin = bityRampPlugin(mockConfig) + + // Mock successful API response with USDC + ;(global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + currencies: [ + { + tags: ['fiat'], + code: 'EUR', + max_digits_in_decimal_part: 2 + }, + { + tags: ['crypto', 'ethereum', 'erc20'], + code: 'USDC', + max_digits_in_decimal_part: 6 + } + ] + }) + }) + + // Test with a token that's in the hardcoded no-KYC list + const usdcRequest: RampCheckSupportRequest = { + direction: 'sell', + regionCode: { countryCode: 'CH', stateProvinceCode: undefined }, + fiatAsset: { currencyCode: 'EUR' }, + cryptoAsset: { + pluginId: 'ethereum', + tokenId: 'a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' // USDC + } + } + + // USDC is in the hardcoded no-KYC list, so it should still be supported + const usdcResult = await plugin.checkSupport(usdcRequest) + expect(usdcResult.supported).toBe(true) + + // Test with a token that's NOT in the hardcoded list + const randomTokenRequest: RampCheckSupportRequest = { + direction: 'sell', + regionCode: { countryCode: 'CH', stateProvinceCode: undefined }, + fiatAsset: { currencyCode: 'EUR' }, + cryptoAsset: { + pluginId: 'ethereum', + tokenId: 'randomtokenid123' // Random token not in no-KYC list + } + } + + // Random tokens not in the no-KYC list should not be supported + const randomResult = await plugin.checkSupport(randomTokenRequest) + expect(randomResult.supported).toBe(false) + }) +}) + +describe('Example: Using checkSupport API', () => { + it('should check multiple plugins in parallel', async () => { + const plugin1 = bityRampPlugin(mockConfig) + const plugin2 = bityRampPlugin(mockConfig) // Imagine this is another plugin + + const request: RampCheckSupportRequest = { + direction: 'buy', + regionCode: { countryCode: 'CH', stateProvinceCode: undefined }, + fiatAsset: { currencyCode: 'EUR' }, + cryptoAsset: { pluginId: 'bitcoin', tokenId: null } + } + + // Check support on all plugins in parallel + const supportResults = await Promise.all([ + plugin1.checkSupport(request), + plugin2.checkSupport(request) + ]) + + // Filter to supported plugins + const supportedPlugins = [plugin1, plugin2].filter( + (_, index) => supportResults[index].supported + ) + + console.log(`${supportedPlugins.length} providers support this pair`) + + // Only fetch quotes from supported plugins + if (supportedPlugins.length > 0) { + const quoteRequest: RampQuoteRequest = { + direction: 'buy', + regionCode: request.regionCode, + fiatCurrencyCode: `iso:${request.fiatAsset.currencyCode}`, + exchangeAmount: '100', + amountType: 'fiat', + displayCurrencyCode: 'BTC', + pluginId: request.cryptoAsset.pluginId, + tokenId: request.cryptoAsset.tokenId + } + + const quotePromises = supportedPlugins.map( + async plugin => await plugin.fetchQuote(quoteRequest) + ) + + const allQuotes = await Promise.all(quotePromises) + const flatQuotes = allQuotes.flat() + + console.log( + `Got ${flatQuotes.length} quotes from ${supportedPlugins.length} providers` + ) + } + }) +}) diff --git a/src/actions/DeepLinkingActions.tsx b/src/actions/DeepLinkingActions.tsx index e1bb3fc4e6d..91c4f224755 100644 --- a/src/actions/DeepLinkingActions.tsx +++ b/src/actions/DeepLinkingActions.tsx @@ -20,6 +20,7 @@ import { executePlugin, fiatProviderDeeplinkHandler } from '../plugins/gui/fiatPlugin' +import { rampDeeplinkManager } from '../plugins/ramps/rampDeeplinkHandler' import { config } from '../theme/appConfig' import type { DeepLink } from '../types/DeepLinkTypes' import type { Dispatch, RootState, ThunkAction } from '../types/reduxTypes' @@ -156,10 +157,19 @@ async function handleLink( } case 'fiatProvider': { + // Handle with legacy fiat plugin handler fiatProviderDeeplinkHandler(link) break } + case 'ramp': { + const handled = rampDeeplinkManager.handleDeeplink(link) + if (!handled) { + showError(`No ramp plugin handler registered for ${link.providerId}`) + } + break + } + case 'promotion': await dispatch(activatePromotion(link.installerId ?? '')) break @@ -272,14 +282,13 @@ async function handleLink( const parseWallets = async (): Promise => { // Try to parse with all wallets for (const wallet of Object.values(currencyWallets)) { + const { pluginId } = wallet.currencyInfo // Ignore disabled wallets: - const { keysOnlyMode = false } = SPECIAL_CURRENCY_INFO + const { keysOnlyMode = false } = SPECIAL_CURRENCY_INFO[pluginId] ?? {} if (keysOnlyMode) return - - const { pluginId } = wallet.currencyInfo const parsedUri = await wallet .parseUri(link.uri) - .catch(e => undefined) + .catch((_: unknown) => undefined) if (parsedUri != null) { const { tokenId = null } = parsedUri matchingWalletIdsAndUris.push({ diff --git a/src/app.ts b/src/app.ts index 36ec6bdba5a..8b8e0cdaddc 100644 --- a/src/app.ts +++ b/src/app.ts @@ -53,7 +53,7 @@ if (ENV.SENTRY_ORGANIZATION_SLUG.includes('SENTRY_ORGANIZATION')) { } // Uncomment the next line to remove popup warning/error boxes. -// LogBox.ignoreAllLogs() +if (!ENV.DEBUG_LOGBOX) LogBox.ignoreAllLogs() LogBox.ignoreLogs([ 'Require cycle:', 'Attempted to end a Span which has already ended.' diff --git a/src/components/App.tsx b/src/components/App.tsx index 6d2e4bbd9a3..66d02125618 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1,6 +1,7 @@ import '@ethersproject/shims' import { ErrorBoundary, type Scope, wrap } from '@sentry/react-native' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import * as React from 'react' import { StyleSheet } from 'react-native' import { GestureHandlerRootView } from 'react-native-gesture-handler' @@ -12,26 +13,37 @@ import { EdgeCoreManager } from './services/EdgeCoreManager' import { StatusBarManager } from './services/StatusBarManager' import { ThemeProvider } from './services/ThemeContext' -function MainApp() { +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 2, + refetchOnWindowFocus: false + } + } +}) + +function MainApp(): React.ReactElement { const handleBeforeCapture = useHandler((scope: Scope) => { scope.setLevel('fatal') scope.setTag('handled', false) }) return ( - - - - } - > - - - - - - + + + + + } + > + + + + + + + ) } diff --git a/src/components/Main.tsx b/src/components/Main.tsx index 0817478db50..136c64994d0 100644 --- a/src/components/Main.tsx +++ b/src/components/Main.tsx @@ -96,10 +96,7 @@ import { FioSentRequestDetailsScene as FioSentRequestDetailsSceneComponent } fro import { FioStakingChangeScene as FioStakingChangeSceneComponent } from './scenes/Fio/FioStakingChangeScene' import { FioStakingOverviewScene as FioStakingOverviewSceneComponent } from './scenes/Fio/FioStakingOverviewScene' import { GettingStartedScene } from './scenes/GettingStartedScene' -import { - BuyScene as BuySceneComponent, - SellScene as SellSceneComponent -} from './scenes/GuiPluginListScene' +import { SellScene as SellSceneComponent } from './scenes/GuiPluginListScene' import { GuiPluginViewScene as GuiPluginViewSceneComponent } from './scenes/GuiPluginViewScene' import { HomeScene as HomeSceneComponent } from './scenes/HomeScene' import { LoanCloseScene as LoanCloseSceneComponent } from './scenes/Loans/LoanCloseScene' @@ -138,6 +135,8 @@ import { SweepPrivateKeyCalculateFeeScene as SweepPrivateKeyCalculateFeeSceneCom import { SweepPrivateKeyCompletionScene as SweepPrivateKeyCompletionSceneComponent } from './scenes/SweepPrivateKeyCompletionScene' import { SweepPrivateKeyProcessingScene as SweepPrivateKeyProcessingSceneComponent } from './scenes/SweepPrivateKeyProcessingScene' import { SweepPrivateKeySelectCryptoScene as SweepPrivateKeySelectCryptoSceneComponent } from './scenes/SweepPrivateKeySelectCryptoScene' +import { TradeCreateScene as TradeCreateSceneComponent } from './scenes/TradeCreateScene' +import { TradeOptionSelectScene as TradeOptionSelectSceneComponent } from './scenes/TradeOptionSelectScene' import { TransactionDetailsScene as TransactionDetailsSceneComponent } from './scenes/TransactionDetailsScene' import { TransactionList as TransactionListComponent, @@ -161,7 +160,6 @@ import { MenuTabs } from './themed/MenuTabs' import { SideMenu } from './themed/SideMenu' const AssetSettingsScene = ifLoggedIn(AssetSettingsSceneComponent) -const BuyScene = ifLoggedIn(BuySceneComponent) const ChangeMiningFeeScene = ifLoggedIn(ChangeMiningFeeSceneComponent) const ChangePasswordScene = ifLoggedIn(ChangePasswordSceneComponent) const ChangePinScene = ifLoggedIn(ChangePinSceneComponent) @@ -284,6 +282,8 @@ const SweepPrivateKeySelectCryptoScene = ifLoggedIn( const TransactionDetailsScene = ifLoggedIn(TransactionDetailsSceneComponent) const TransactionList = ifLoggedIn(TransactionListComponent) const TransactionsExportScene = ifLoggedIn(TransactionsExportSceneComponent) +const TradeCreateScene = ifLoggedIn(TradeCreateSceneComponent) +const TradeOptionSelectScene = ifLoggedIn(TradeOptionSelectSceneComponent) const UpgradeUsernameScene = ifLoggedIn(UpgradeUsernameSceneComponent) const WalletDetails = ifLoggedIn(WalletDetailsComponent) const WalletListScene = ifLoggedIn(WalletListSceneComponent) @@ -374,9 +374,13 @@ const EdgeBuyTabScreen: React.FC = () => { > + void | Promise + testID?: string +} + +export const DropDownInputButton: React.FC = ( + props: DropDownInputButtonProps +) => { + const { children, onPress, testID } = props + const theme = useTheme() + + return ( + + {children} + + + ) +} + +const Container = styled(EdgeTouchableOpacity)(theme => ({ + backgroundColor: theme.textInputBackgroundColor, + borderRadius: theme.rem(0.5), + padding: theme.rem(1), + flexDirection: 'row', + alignItems: 'center', + gap: theme.rem(0.25), + minWidth: theme.rem(4), + height: theme.rem(3.25) +})) diff --git a/src/components/buttons/PillButton.tsx b/src/components/buttons/PillButton.tsx new file mode 100644 index 00000000000..883d3e6d6f5 --- /dev/null +++ b/src/components/buttons/PillButton.tsx @@ -0,0 +1,51 @@ +import React from 'react' +import LinearGradient from 'react-native-linear-gradient' + +import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity' +import { styled } from '../hoc/styled' +import { useTheme } from '../services/ThemeContext' +import { EdgeText } from '../themed/EdgeText' + +export interface PillButtonProps { + label: string + onPress: () => void | Promise + icon?: () => React.ReactElement | null + disabled?: boolean +} + +export const PillButton: React.FC = ( + props: PillButtonProps +) => { + const { label, onPress, icon, disabled = false } = props + + const theme = useTheme() + + return ( + + + {icon == null ? null : icon()} + + + + ) +} + +const Gradient = styled(LinearGradient)(theme => ({ + alignItems: 'center', + borderRadius: theme.rem(100), + flexDirection: 'row', + paddingHorizontal: theme.rem(0.75), + paddingVertical: theme.rem(0.25), + gap: theme.rem(0.5) +})) + +const Label = styled(EdgeText)(theme => ({ + fontSize: theme.rem(0.75), + lineHeight: theme.rem(1.5) +})) diff --git a/src/components/cards/PaymentOptionCard.tsx b/src/components/cards/PaymentOptionCard.tsx new file mode 100644 index 00000000000..5336a9f6934 --- /dev/null +++ b/src/components/cards/PaymentOptionCard.tsx @@ -0,0 +1,140 @@ +import * as React from 'react' +import { Image, View } from 'react-native' +import FastImage from 'react-native-fast-image' + +// TradeOptionSelectScene - Updated layout for design requirements +import { lstrings } from '../../locales/strings' +import type { ImageProp } from '../../types/Theme' +import { PillButton } from '../buttons/PillButton' +import { EdgeCard } from '../cards/EdgeCard' +import { styled } from '../hoc/styled' +import { EdgeText } from '../themed/EdgeText' + +interface Props { + title: React.ReactNode + icon: ImageProp + totalAmount: string + settlementTime: string + + // Optional: + partner?: { + displayName: string + icon: ImageProp + } + /** Content rendered on the right side of the card in the title row. */ + renderRight?: () => React.ReactNode + /** Whether the provider button should be disabled */ + disableProviderButton?: boolean + + // Events: + onPress: () => Promise | void + onLongPress?: () => Promise | void + onProviderPress: () => Promise | void +} + +export const PaymentOptionCard: React.FC = (props: Props) => { + return ( + + + + + + {props.title} + + {props.renderRight?.()} + + + {props.totalAmount} + {props.settlementTime} + + {props.partner == null ? null : ( + + {lstrings.plugin_powered_by_space} + + props.partner?.icon == null ? null : ( + + ) + } + label={props.partner?.displayName ?? ''} + onPress={props.onProviderPress} + disabled={props.disableProviderButton} + /> + + )} + + + ) +} + +// Styled Components + +const CardContent = styled(View)(theme => ({ + flex: 1, + padding: theme.rem(0.5) +})) + +const TitleRow = styled(View)(theme => ({ + flexDirection: 'row', + alignItems: 'center', + margin: theme.rem(0.5), + justifyContent: 'space-between', + gap: theme.rem(1) +})) + +const TitleContainer = styled(View)(theme => ({ + flexDirection: 'row', + gap: theme.rem(1), + alignItems: 'center', + flexShrink: 1, + overflow: 'hidden' +})) + +const TitleIcon = styled(Image)(theme => ({ + width: theme.rem(2), + height: theme.rem(2), + aspectRatio: 1, + resizeMode: 'contain' +})) + +const TitleText = styled(EdgeText)(theme => ({ + fontSize: theme.rem(1), + fontWeight: '500', + color: theme.primaryText, + flexShrink: 1 +})) + +const InfoRow = styled(View)(theme => ({ + margin: theme.rem(0.5) +})) + +const TotalText = styled(EdgeText)(theme => ({ + fontSize: theme.rem(0.875), + color: theme.positiveText +})) + +const SettlementText = styled(EdgeText)(theme => ({ + fontSize: theme.rem(0.875), + color: theme.secondaryText +})) + +const PoweredByRow = styled(View)(theme => ({ + flexDirection: 'row', + alignItems: 'center', + paddingTop: theme.rem(0.5) +})) + +const PoweredByText = styled(EdgeText)(theme => ({ + fontSize: theme.rem(0.875), + color: theme.primaryText, + margin: theme.rem(0.5) +})) + +const ProviderIcon = styled(FastImage)(theme => ({ + aspectRatio: 1, + width: theme.rem(1), + height: theme.rem(1) +})) diff --git a/src/components/icons/BestRateBadge.tsx b/src/components/icons/BestRateBadge.tsx new file mode 100644 index 00000000000..4cc6023b9b0 --- /dev/null +++ b/src/components/icons/BestRateBadge.tsx @@ -0,0 +1,86 @@ +import * as React from 'react' +import { type LayoutChangeEvent, StyleSheet, View } from 'react-native' +import Svg, { Polygon } from 'react-native-svg' + +import { lstrings } from '../../locales/strings' +import { styled } from '../hoc/styled' +import { useTheme } from '../services/ThemeContext' +import { EdgeText } from '../themed/EdgeText' + +// TODO: Render a badge icon +export const BestRateBadge: React.FC = () => { + const theme = useTheme() + + const [dimensions, setDimensions] = React.useState({ width: 70, height: 100 }) + const { width, height } = dimensions + + const handleLayout = (event: LayoutChangeEvent): void => { + const { width, height } = event.nativeEvent.layout + setDimensions({ width, height }) + } + + // Compute a 5-point star sized around the text box + const { svgWidth, svgHeight, points } = React.useMemo(() => { + const padding = theme.rem(0.75) + const svgWidth = width + padding * 2 + const svgHeight = height + padding * 2 + + const centerX = svgWidth / 2 + const centerY = svgHeight / 2 + const outerRadius = Math.max(width, height) / 2 + padding * 0.9 + const innerRadius = outerRadius * 0.75 + + const numPoints = 14 + const totalVertices = numPoints * 2 // outer+inner alternating + const startAngle = -Math.PI / 2 // Point up + + const pts: Array<{ x: number; y: number }> = [] + for (let i = 0; i < totalVertices; i++) { + const isOuter = i % 2 === 0 + const radius = isOuter ? outerRadius : innerRadius + const angle = startAngle + (i * Math.PI) / numPoints + const x = centerX + radius * Math.cos(angle) + const y = centerY + radius * Math.sin(angle) + pts.push({ x, y }) + } + + const points = pts.map(p => `${p.x},${p.y}`).join(' ') + return { svgWidth, svgHeight, points } + }, [height, theme, width]) + + return ( + + + + + {lstrings.string_best_rate_badge_text} + + ) +} + +const BestRateBadgeContainer = styled(View)(() => ({ + alignItems: 'center', + justifyContent: 'center' +})) + +const BestRateText = styled(EdgeText)(theme => ({ + fontSize: theme.rem(0.5), + fontWeight: 'bold', + color: theme.primaryText, + textAlign: 'center', + letterSpacing: 0, + zIndex: 1 +})) diff --git a/src/components/modals/ButtonsModal.tsx b/src/components/modals/ButtonsModal.tsx index f0c654d3639..933688b653d 100644 --- a/src/components/modals/ButtonsModal.tsx +++ b/src/components/modals/ButtonsModal.tsx @@ -9,7 +9,7 @@ import type { AirshipBridge } from 'react-native-airship' import { useHandler } from '../../hooks/useHandler' import { ModalButtons } from '../buttons/ModalButtons' -import { showError } from '../services/AirshipInstance' +import { Airship, showError } from '../services/AirshipInstance' import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' import { Paragraph } from '../themed/EdgeText' import { EdgeModal } from './EdgeModal' @@ -139,3 +139,17 @@ const getStyles = cacheStyles((theme: Theme) => ({ justifyContent: 'flex-start' } })) + +/** + * Utility function to show a ButtonsModal without JSX. + * Useful for non-React contexts or TypeScript files. + */ +export async function showButtonsModal< + Buttons extends Record +>( + params: Omit, 'bridge'> +): Promise { + return await Airship.show(bridge => ( + + )) +} diff --git a/src/components/modals/WalletListModal.tsx b/src/components/modals/WalletListModal.tsx index 9813d373124..1b2d3e03662 100644 --- a/src/components/modals/WalletListModal.tsx +++ b/src/components/modals/WalletListModal.tsx @@ -35,19 +35,36 @@ import { ButtonsModal } from './ButtonsModal' import { EdgeModal } from './EdgeModal' export const ErrorNoMatchingWallets = 'ErrorNoMatchingWallets' + +// User cancelled. +// This is consistent with other modals that return `T | undefined`: export type WalletListResult = - | { - type: 'wallet' - walletId: string - tokenId: EdgeTokenId - } - | { type: 'wyre'; fiatAccountId: string } - | { type: 'bankSignupRequest' } - | { type: 'custom'; customAsset?: CustomAsset } - // User cancelled. - // This is consistent with other modals that return `T | undefined`: + | WalletListWalletResult + | WalletListWyreResult + | WalletListBankSignupRequestResult + | WalletListCustomResult | undefined +export interface WalletListWalletResult { + type: 'wallet' + walletId: string + tokenId: EdgeTokenId +} + +export interface WalletListWyreResult { + type: 'wyre' + fiatAccountId: string +} + +export interface WalletListBankSignupRequestResult { + type: 'bankSignupRequest' +} + +export interface WalletListCustomResult { + type: 'custom' + customAsset?: CustomAsset +} + interface Props { bridge: AirshipBridge navigation: NavigationBase @@ -76,7 +93,7 @@ const keysOnlyModeAssets: EdgeAsset[] = Object.keys(SPECIAL_CURRENCY_INFO) tokenId: null })) -export function WalletListModal(props: Props) { +export function WalletListModal(props: Props): React.ReactElement { const { bridge, navigation, @@ -140,11 +157,7 @@ export function WalletListModal(props: Props) { bridge.resolve({ type: 'wyre', fiatAccountId }) }) const handleWalletListPress = useHandler( - async ( - walletId: string, - tokenId: EdgeTokenId, - customAsset?: CustomAsset - ) => { + (walletId: string, tokenId: EdgeTokenId, customAsset?: CustomAsset) => { if (walletId === '') { handleCancel() showError(lstrings.network_alert_title) @@ -295,7 +308,9 @@ export function WalletListModal(props: Props) { showCreateWallet={showCreateWallet} createWalletId={createWalletId} parentWalletId={parentWalletId} - onPress={handleWalletListPress} + onPress={async (...args) => { + handleWalletListPress(...args) + }} navigation={navigation} /> diff --git a/src/components/scenes/GuiPluginListScene.tsx b/src/components/scenes/GuiPluginListScene.tsx index cbe322a1f57..c172a1010ac 100644 --- a/src/components/scenes/GuiPluginListScene.tsx +++ b/src/components/scenes/GuiPluginListScene.tsx @@ -60,6 +60,7 @@ import { bestOfPlugins } from '../../util/ReferralHelpers' import { logEvent, type OnLogEvent } from '../../util/tracking' import { base58ToUuid, getOsVersion } from '../../util/utils' import { EdgeCard } from '../cards/EdgeCard' +import { PaymentOptionCard } from '../cards/PaymentOptionCard' import { EdgeAnim, fadeInUp20, @@ -80,7 +81,6 @@ import { type ThemeProps, useTheme } from '../services/ThemeContext' -import { DividerLine } from '../themed/DividerLine' import { EdgeText } from '../themed/EdgeText' import { SceneHeader } from '../themed/SceneHeader' import { SelectableRow } from '../themed/SelectableRow' @@ -133,12 +133,12 @@ const pluginPartnerLogos: Record = { moonpay: 'guiPluginLogoMoonpay' } -type BuyProps = BuyTabSceneProps<'pluginListBuy'> +type BuyProps = BuyTabSceneProps<'pluginListBuyOld'> type SellProps = SellTabSceneProps<'pluginListSell'> type OwnProps = BuyProps | SellProps function isBuyProps(props: OwnProps): props is BuyProps { - return props.route.name === 'pluginListBuy' + return props.route.name === 'pluginListBuyOld' } interface StateProps { @@ -182,7 +182,7 @@ class GuiPluginList extends React.PureComponent { this.componentMounted = true } - async componentDidMount() { + componentDidMount(): void { this.updatePlugins() const { developerPluginUri } = getDeviceSettings() if (developerPluginUri != null) { @@ -190,7 +190,7 @@ class GuiPluginList extends React.PureComponent { } } - componentWillUnmount() { + componentWillUnmount(): void { this.componentMounted = false if (this.timeoutId != null) clearTimeout(this.timeoutId) } @@ -206,7 +206,7 @@ class GuiPluginList extends React.PureComponent { } } - updatePlugins() { + updatePlugins(): void { // Create new array objects so we aren't patching the original JSON const currentPlugins: BuySellPlugins = { buy: [...(buySellPlugins.buy ?? [])], @@ -225,9 +225,7 @@ class GuiPluginList extends React.PureComponent { continue } const currentDirection = currentPlugins[direction] ?? [] - if (currentPlugins[direction] == null) { - currentPlugins[direction] = currentDirection - } + currentPlugins[direction] ??= currentDirection for (const patch of patches) { // Skip comment rows if (typeof patch === 'string') continue @@ -268,7 +266,10 @@ class GuiPluginList extends React.PureComponent { /** * Launch the provided plugin, including pre-flight checks. */ - async openPlugin(listRow: GuiPluginRow, longPress: boolean = false) { + async openPlugin( + listRow: GuiPluginRow, + longPress: boolean = false + ): Promise { const { account, accountReferral, @@ -318,9 +319,7 @@ class GuiPluginList extends React.PureComponent { this.setState({ developerUri: deepPath }) // Write to disk lazily: - writeDeveloperPluginUri(deepPath).catch(error => { - showError(error) - }) + writeDeveloperPluginUri(deepPath).catch(showError) } } if (plugin.nativePlugin != null) { @@ -387,7 +386,7 @@ class GuiPluginList extends React.PureComponent { onPluginOpened() } - renderTitle = (guiPluginRow: GuiPluginRow) => { + renderTitle = (guiPluginRow: GuiPluginRow): React.ReactElement => { const styles = getStyles(this.props.theme) const { title, customTitleKey } = guiPluginRow @@ -426,22 +425,22 @@ class GuiPluginList extends React.PureComponent { )(error) if (regionError != null && regionError.length > 0) { const country = COUNTRY_CODES.find(c => c['alpha-2'] === countryCode) - const countryName = country ? country.name : countryCode // Fallback to countryCode if not found + const countryName = country != null ? country.name : countryCode // Fallback to countryCode if not found // Attempt to find the stateProvince name if stateProvinceCode is provided let stateProvinceName = stateProvinceCode - if (country?.stateProvinces && stateProvinceCode) { + if (country?.stateProvinces != null && stateProvinceCode != null) { const stateProvince = country.stateProvinces.find( sp => sp['alpha-2'] === stateProvinceCode ) - stateProvinceName = stateProvince - ? stateProvince.name - : stateProvinceCode // Fallback to stateProvinceCode if not found + stateProvinceName = + stateProvince != null ? stateProvince.name : stateProvinceCode // Fallback to stateProvinceCode if not found } - const text = stateProvinceName - ? `${stateProvinceName}, ${countryName}` - : countryName + const text = + stateProvinceName != null + ? `${stateProvinceName}, ${countryName}` + : countryName Airship.show<'ok' | undefined>(bridge => ( { } } - renderPlugin = ({ item, index }: ListRenderItemInfo) => { + renderPlugin = ({ + item, + index + }: ListRenderItemInfo): React.ReactElement | null => { const { theme } = this.props const { pluginId } = item const plugin = guiPlugins[pluginId] @@ -464,66 +466,45 @@ class GuiPluginList extends React.PureComponent { const styles = getStyles(this.props.theme) const partnerLogoThemeKey = pluginPartnerLogos[pluginId] - const pluginPartnerLogo = partnerLogoThemeKey - ? theme[partnerLogoThemeKey] - : { uri: getPartnerIconUri(item.partnerIconPath ?? '') } const poweredBy = plugin.poweredBy ?? plugin.displayName + const partner = + poweredBy == null || item.partnerIconPath == null + ? undefined + : { + displayName: poweredBy, + icon: + partnerLogoThemeKey != null + ? theme[partnerLogoThemeKey] + : { uri: getPartnerIconUri(item.partnerIconPath ?? '') } + } + const [totalAmount, settlementTime] = item.description.split('\n') return ( - - } + { - await this.openPlugin(item).catch(error => { - this.handleError(error) - }) + await this.openPlugin(item) }} onLongPress={async () => { - await this.openPlugin(item, true).catch(error => { - this.handleError(error) - }) + await this.openPlugin(item, true).catch(this.handleError) }} - paddingRem={[1, 0.5, 1, 0.5]} - > - - {this.renderTitle(item)} - {item.description === '' ? null : ( - - {item.description} - - )} - {poweredBy != null && item.partnerIconPath != null ? ( - <> - - - - {lstrings.plugin_powered_by_space} - - - - {' ' + poweredBy} - - - - ) : null} - - + onProviderPress={async () => { + await this.openPlugin(item) + }} + /> ) } - renderTop = () => { + renderTop = (): React.ReactElement => { const { account, countryCode, @@ -541,7 +522,9 @@ class GuiPluginList extends React.PureComponent { sp => sp['alpha-2'] === stateProvinceCode ) const uri = `${FLAG_LOGO_URL}/${ - countryData?.filename || countryData?.name.toLowerCase().replace(' ', '-') + countryData?.filename ?? + countryData?.name.toLowerCase().replace(' ', '-') ?? + '' }.png` const hasCountryData = countryData != null @@ -621,7 +604,7 @@ class GuiPluginList extends React.PureComponent { ) } - renderEmptyList = () => { + renderEmptyList = (): React.ReactElement | null => { const { countryCode, theme } = this.props const styles = getStyles(theme) if (countryCode === '') return null @@ -635,7 +618,7 @@ class GuiPluginList extends React.PureComponent { ) } - render() { + render(): React.ReactElement { const { accountPlugins, accountReferral, @@ -695,23 +678,11 @@ const getStyles = cacheStyles((theme: Theme) => ({ // TODO: Make SceneHeader work right under UI4 overflow: 'visible' }, - cardContentContainer: { - flexDirection: 'column', - flexShrink: 1, - marginRight: theme.rem(0.5) - }, hackContainer: { // HACK: Required for the header underline to span all the way to the right // TODO: Make SceneHeader work right under UI4 paddingHorizontal: theme.rem(0.5) }, - selectedCountryRow: { - marginTop: theme.rem(1.5), - marginBottom: theme.rem(1.5), - marginHorizontal: theme.rem(1.5), - flexDirection: 'row', - alignItems: 'center' - }, selectedCountryFlag: { height: theme.rem(2), width: theme.rem(2), @@ -733,18 +704,6 @@ const getStyles = cacheStyles((theme: Theme) => ({ emptyPluginText: { textAlign: 'center' }, - pluginRowPoweredByRow: { - flexDirection: 'row', - justifyContent: 'flex-start', - alignItems: 'center' - }, - logo: { - margin: theme.rem(0.5), - width: theme.rem(2), - height: theme.rem(2), - aspectRatio: 1, - resizeMode: 'contain' - }, titleText: { fontFamily: theme.fontFaceMedium }, @@ -760,20 +719,6 @@ const getStyles = cacheStyles((theme: Theme) => ({ aspectRatio: 150 / 64, resizeMode: 'contain', marginBottom: 1 - }, - subtitleText: { - marginTop: theme.rem(0.25), - fontSize: theme.rem(0.75), - color: theme.secondaryText - }, - footerText: { - fontSize: theme.rem(0.75), - color: theme.secondaryText - }, - partnerIconImage: { - aspectRatio: 1, - width: theme.rem(0.75), - height: theme.rem(0.75) } })) @@ -881,9 +826,9 @@ const GuiPluginListSceneComponent = React.memo((props: OwnProps) => { }) // Export separate components for buy and sell routes -export const BuyScene = (props: BuyProps) => ( +export const BuyScene = (props: BuyProps): React.ReactElement => ( ) -export const SellScene = (props: SellProps) => ( +export const SellScene = (props: SellProps): React.ReactElement => ( ) diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index 84a85a2e4b5..2df8090ec17 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -25,8 +25,7 @@ import { playSendSound } from '../../actions/SoundActions' import { SCROLL_INDICATOR_INSET_FIX } from '../../constants/constantSettings' import { FIO_STR, - getSpecialCurrencyInfo, - SPECIAL_CURRENCY_INFO + getSpecialCurrencyInfo } from '../../constants/WalletAndCurrencyConstants' import { useAsyncEffect } from '../../hooks/useAsyncEffect' import { useDisplayDenom } from '../../hooks/useDisplayDenom' @@ -136,7 +135,10 @@ export interface SendScene2Params { infoTiles?: Array<{ label: string; value: string }> fioPendingRequest?: FioRequest onBack?: () => void - onDone?: (error: Error | null, edgeTransaction?: EdgeTransaction) => void + onDone?: ( + error: Error | null, + edgeTransaction?: EdgeTransaction + ) => void | Promise beforeTransaction?: () => Promise alternateBroadcast?: ( edgeTransaction: EdgeTransaction @@ -852,7 +854,7 @@ const SendComponent = (props: Props): React.ReactElement => { maxLength = 2 * memoOption.maxBytes } - const handleMemo = async () => { + const handleMemo = async (): Promise => { await Airship.show(bridge => ( { if (onDone != null) { navigation.pop() - onDone(null, broadcastedTx) + const p = onDone(null, broadcastedTx) + if (p != null) p.catch(showError) } else { navigation.replace('transactionDetails', { edgeTransaction: broadcastedTx, @@ -1497,7 +1500,7 @@ const SendComponent = (props: Props): React.ReactElement => { processingAmountChanged || error != null || (zeroString(spendInfo.spendTargets[0].nativeAmount) && - !SPECIAL_CURRENCY_INFO[pluginId].allowZeroTx) + getSpecialCurrencyInfo(pluginId).allowZeroTx !== true) ) { disableSlider = true } else if ( diff --git a/src/components/scenes/TradeCreateScene.tsx b/src/components/scenes/TradeCreateScene.tsx new file mode 100644 index 00000000000..15c49d502f6 --- /dev/null +++ b/src/components/scenes/TradeCreateScene.tsx @@ -0,0 +1,795 @@ +import { div, mul } from 'biggystring' +import * as React from 'react' +import { useState } from 'react' +import { ActivityIndicator, Text, View } from 'react-native' +import FastImage from 'react-native-fast-image' +import Feather from 'react-native-vector-icons/Feather' +import { sprintf } from 'sprintf-js' + +import { showCountrySelectionModal } from '../../actions/CountryListActions' +import { FLAG_LOGO_URL } from '../../constants/CdnConstants' +import { COUNTRY_CODES, FIAT_COUNTRY } from '../../constants/CountryConstants' +import { useHandler } from '../../hooks/useHandler' +import { useRampPlugins } from '../../hooks/useRampPlugins' +import { useRampQuotes } from '../../hooks/useRampQuotes' +import { useSupportedPlugins } from '../../hooks/useSupportedPlugins' +import { useWatch } from '../../hooks/useWatch' +import { lstrings } from '../../locales/strings' +import type { + RampPlugin, + RampQuoteRequest +} from '../../plugins/ramps/rampPluginTypes' +import { getDefaultFiat } from '../../selectors/SettingsSelectors' +import { useDispatch, useSelector } from '../../types/reactRedux' +import type { BuyTabSceneProps, NavigationBase } from '../../types/routerTypes' +import type { GuiFiatType } from '../../types/types' +import { getCurrencyCode } from '../../util/CurrencyInfoHelpers' +import { DECIMAL_PRECISION, mulToPrecision } from '../../util/utils' +import { DropDownInputButton } from '../buttons/DropDownInputButton' +import { PillButton } from '../buttons/PillButton' +import { AlertCardUi4 } from '../cards/AlertCard' +import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity' +import { SceneWrapper } from '../common/SceneWrapper' +import { styled } from '../hoc/styled' +import { CryptoIcon } from '../icons/CryptoIcon' +import { FiatIcon } from '../icons/FiatIcon' +import { KavButton } from '../keyboard/KavButton' +import { SceneContainer } from '../layout/SceneContainer' +import { FiatListModal } from '../modals/FiatListModal' +import { + WalletListModal, + type WalletListResult, + type WalletListWalletResult +} from '../modals/WalletListModal' +import { Airship } from '../services/AirshipInstance' +import { useTheme } from '../services/ThemeContext' +import { EdgeText } from '../themed/EdgeText' +import { FilledTextInput } from '../themed/FilledTextInput' + +export interface TradeCreateParams { + forcedWalletResult?: WalletListWalletResult + regionCode?: string +} + +interface Props extends BuyTabSceneProps<'pluginListBuy'> {} + +export const TradeCreateScene: React.FC = (props: Props) => { + const { navigation, route } = props + const { regionCode: initialRegionCode, forcedWalletResult } = + route?.params ?? {} + + const theme = useTheme() + const dispatch = useDispatch() + + const account = useSelector(state => state.core.account) + const currencyWallets = useWatch(account, 'currencyWallets') + + // State for trade form + const [userInput, setUserInput] = useState('') + const [lastUsedInput, setLastUsedInput] = useState<'fiat' | 'crypto' | null>( + null + ) + const [isMaxAmount, setIsMaxAmount] = useState(false) + + // Selected currencies + const defaultFiat = useSelector(state => getDefaultFiat(state)) + const [selectedFiatCurrencyCode, setSelectedFiatCurrencyCode] = + useState(defaultFiat) + + // Get first wallet as default if no forcedWalletResult + const firstWallet = React.useMemo((): WalletListWalletResult | undefined => { + const walletIds = Object.keys(currencyWallets) + if (walletIds.length > 0) { + return { + type: 'wallet', + walletId: walletIds[0], + tokenId: null + } + } + return undefined + }, [currencyWallets]) + + const [selectedCrypto, setSelectedCrypto] = useState< + WalletListWalletResult | undefined + >(forcedWalletResult ?? firstWallet) + + const [selectedWallet, selectedCryptoCurrencyCode] = + selectedCrypto != null + ? [ + currencyWallets[selectedCrypto.walletId], + getCurrencyCode( + currencyWallets[selectedCrypto.walletId], + selectedCrypto?.tokenId ?? null + ) + ] + : [undefined, undefined] + + // Get the select crypto denomination for exchange rate + const denomination = React.useMemo(() => { + if (selectedCrypto == null || selectedWallet == null) return null + if (selectedCrypto.tokenId == null) { + return selectedWallet.currencyInfo.denominations[0] + } else { + return selectedWallet.currencyConfig.allTokens[selectedCrypto.tokenId] + .denominations[0] + } + }, [selectedCrypto, selectedWallet]) + + // Get user's current country settings + const { countryCode, stateProvinceCode } = useSelector( + state => state.ui.settings + ) + + const countryData = COUNTRY_CODES.find(c => c['alpha-2'] === countryCode) + + // Determine whether to show the region selection scene variant + const shouldShowRegionSelect = + initialRegionCode == null && (countryCode === '' || countryData == null) + + // Get ramp plugins + const { data: rampPluginArray = [], isLoading: isPluginsLoading } = + useRampPlugins({ account }) + const rampPlugins = React.useMemo(() => { + const map: Record = {} + for (const plugin of rampPluginArray) { + map[plugin.pluginId] = plugin + } + return map + }, [rampPluginArray]) + + // Use supported plugins hook + const { + supportedPlugins, + isLoading: isCheckingSupport, + error: supportedPluginsError + } = useSupportedPlugins({ + selectedWallet, + selectedCrypto: + selectedCrypto != null && selectedWallet != null + ? { + pluginId: selectedWallet.currencyInfo.pluginId, + tokenId: selectedCrypto.tokenId + } + : undefined, + selectedFiatCurrencyCode, // Without 'iso:' prefix + countryCode, + stateProvinceCode, + plugins: rampPlugins, + direction: 'buy' + }) + + const getRegionText = (): string => { + if (countryCode === '' || countryData == null) { + return lstrings.buy_sell_crypto_select_country_button + } + + if (stateProvinceCode != null && countryData.stateProvinces != null) { + const stateProvince = countryData.stateProvinces.find( + sp => sp['alpha-2'] === stateProvinceCode + ) + if (stateProvince != null) { + return `${stateProvince.name}, ${countryData['alpha-3']}` + } + } + + return countryData.name + } + + const flagUri = + countryData != null + ? `${FLAG_LOGO_URL}/${ + countryData.filename ?? + countryData.name.toLowerCase().replace(' ', '-') + }.png` + : null + + // Compute fiat flag URL for selected fiat currency code + const selectedFiatFlagUri = React.useMemo(() => { + const info = FIAT_COUNTRY[selectedFiatCurrencyCode?.toUpperCase() ?? ''] + return info?.logoUrl ?? '' + }, [selectedFiatCurrencyCode]) + + // Create rampQuoteRequest based on current form state + const rampQuoteRequest: RampQuoteRequest | null = React.useMemo(() => { + if ( + selectedWallet == null || + selectedCryptoCurrencyCode == null || + lastUsedInput == null || + (userInput === '' && !isMaxAmount) || + countryCode === '' + ) { + return null + } + + return { + wallet: selectedWallet, + pluginId: selectedWallet.currencyInfo.pluginId, + tokenId: selectedCrypto?.tokenId ?? null, + displayCurrencyCode: selectedCryptoCurrencyCode, + exchangeAmount: isMaxAmount ? { max: true } : userInput, + fiatCurrencyCode: selectedFiatCurrencyCode, + amountType: lastUsedInput, + direction: 'buy', + regionCode: { + countryCode, + stateProvinceCode + } + } + }, [ + selectedWallet, + selectedCryptoCurrencyCode, + selectedCrypto, + userInput, + isMaxAmount, + selectedFiatCurrencyCode, + lastUsedInput, + countryCode, + stateProvinceCode + ]) + + // Fetch quotes using the custom hook + const { + quotes: sortedQuotes, + isLoading: isLoadingQuotes, + isFetching: isFetchingQuotes, + errors: quoteErrors + } = useRampQuotes({ + rampQuoteRequest, + plugins: Object.fromEntries( + supportedPlugins.map(plugin => [plugin.pluginId, plugin]) + ) + }) + + // Get the best quote + const bestQuote = sortedQuotes[0] + + // Calculate exchange rate from best quote + const quoteExchangeRate = React.useMemo(() => { + if (bestQuote?.cryptoAmount == null || bestQuote.fiatAmount == null) + return 0 + + try { + const cryptoAmount = parseFloat(bestQuote.cryptoAmount) + const fiatAmount = parseFloat(bestQuote.fiatAmount) + + // Check for division by zero or invalid numbers + if ( + cryptoAmount === 0 || + !isFinite(cryptoAmount) || + !isFinite(fiatAmount) + ) { + return 0 + } + + return fiatAmount / cryptoAmount + } catch { + return 0 + } + }, [bestQuote]) + + // Helper function to convert crypto amount to fiat using quote rate + const convertCryptoToFiat = React.useCallback( + (cryptoAmt: string): string => { + if (cryptoAmt === '' || quoteExchangeRate === 0) return '' + + try { + return div(mul(cryptoAmt, quoteExchangeRate.toString()), '1', 2) + } catch { + return '' + } + }, + [quoteExchangeRate] + ) + + // Helper function to convert fiat amount to crypto using quote rate + const convertFiatToCrypto = React.useCallback( + (fiatAmt: string): string => { + if (fiatAmt === '' || quoteExchangeRate === 0) return '' + + const decimals = + denomination != null + ? mulToPrecision(denomination.multiplier) + : DECIMAL_PRECISION + try { + return div(fiatAmt, quoteExchangeRate.toString(), decimals) + } catch { + return '' + } + }, + [denomination, quoteExchangeRate] + ) + + // Derived state for display values + const displayFiatAmount = React.useMemo(() => { + if (isMaxAmount && bestQuote != null) { + return bestQuote.fiatAmount + } + if (userInput === '' || lastUsedInput === null) return '' + + if (lastUsedInput === 'fiat') { + return userInput // User entered fiat, show as-is + } else { + // User entered crypto, convert to fiat only if we have a quote + return convertCryptoToFiat(userInput) + } + }, [userInput, lastUsedInput, convertCryptoToFiat, isMaxAmount, bestQuote]) + + const displayCryptoAmount = React.useMemo(() => { + if (isMaxAmount && bestQuote != null) { + return bestQuote.cryptoAmount + } + if (userInput === '' || lastUsedInput === null) return '' + + if (lastUsedInput === 'crypto') { + return userInput // User entered crypto, show as-is + } else { + // User entered fiat, convert to crypto only if we have a quote + return convertFiatToCrypto(userInput) + } + }, [userInput, lastUsedInput, convertFiatToCrypto, isMaxAmount, bestQuote]) + + // + // Handlers + // + + const handleRegionSelect = useHandler(async () => { + if (account != null) { + await dispatch( + showCountrySelectionModal({ + account, + countryCode: countryCode !== '' ? countryCode : '', + stateProvinceCode + }) + ) + // After selection, the settings will update and shouldShowRegionSelect will recompute to false + } + }) + + const handleCryptDropdown = useHandler(async () => { + if (account == null) return + const result = await Airship.show(bridge => ( + + )) + if (result?.type === 'wallet') { + const { walletId, tokenId } = result + const wallet = account.currencyWallets[walletId] + if (wallet != null) { + setSelectedCrypto({ + type: 'wallet', + walletId, + tokenId + }) + } + } + }) + + const handleFiatDropdown = useHandler(async () => { + const result = await Airship.show(bridge => ( + + )) + if (result != null) { + setSelectedFiatCurrencyCode(result.value) + } + }) + + const handleNext = useHandler(() => { + // This handler shouldn't be invoked if these conditions aren't met: + if ( + selectedWallet == null || + selectedCryptoCurrencyCode == null || + lastUsedInput == null || + (userInput === '' && !isMaxAmount) || + rampQuoteRequest == null + ) { + return + } + + navigation.navigate('rampSelectOption', { + rampQuoteRequest + }) + }) + + const exchangeRateText = React.useMemo(() => { + return sprintf( + '1 %s = %s %s', + selectedCryptoCurrencyCode, + quoteExchangeRate.toFixed(2), + selectedFiatCurrencyCode + ) + }, [selectedCryptoCurrencyCode, quoteExchangeRate, selectedFiatCurrencyCode]) + + const handleFiatChangeText = useHandler((text: string) => { + setIsMaxAmount(false) + setUserInput(text) + setLastUsedInput('fiat') + }) + + const handleCryptoChangeText = useHandler((text: string) => { + setIsMaxAmount(false) + setUserInput(text) + setLastUsedInput('crypto') + }) + + const handleMaxPress = useHandler(() => { + if (isMaxAmount) { + // Toggle off max mode + setIsMaxAmount(false) + setUserInput('') + // Keep lastUsedInput as is so the UI remains focused on the same field + } else { + // Toggle on max mode + setIsMaxAmount(true) + setLastUsedInput('fiat') + setUserInput('') // Clear input when using max + } + }) + + // Render region selection view + if (shouldShowRegionSelect) { + return ( + + + + {lstrings.trade_region_select_start_steps} + + + + + + + {sprintf(lstrings.step_prefix_s, '1')} + + + {lstrings.trade_region_select_step_1} + + + + + {sprintf(lstrings.step_prefix_s, '2')} + + + {lstrings.trade_region_select_step_2} + + + + + {sprintf(lstrings.step_prefix_s, '3')} + + + {lstrings.trade_region_select_step_3} + + + + + {sprintf(lstrings.step_prefix_s, '4')} + + + {lstrings.trade_region_select_step_4} + + + + + + + + {flagUri != null ? ( + + ) : ( + + )} + {getRegionText()} + + + + + + ) + } + + // Render trade form view + return ( + <> + + + flagUri != null ? : null + } + label={getRegionText()} + onPress={handleRegionSelect} + /> + } + > + {/* Amount Inputs */} + + {/* Top Input (Fiat by design) */} + + + {selectedFiatFlagUri !== '' ? ( + + ) : ( + + )} + + + + + + + + {/* Bottom Input (Crypto by design) */} + + + {selectedCrypto == null || selectedWallet == null ? null : ( + + )} + + + + + {/* MAX Button */} + + {lstrings.trade_create_max} + + + + + + {/* Exchange Rate */} + {selectedCrypto == null || + selectedWallet == null || + denomination == null || + (userInput === '' && !isMaxAmount) || + lastUsedInput == null || + (!isLoadingQuotes && sortedQuotes.length === 0) ? null : ( + + + {lstrings.trade_create_exchange_rate} + + {bestQuote != null ? ( + + {exchangeRateText} + + ) : null} + + + )} + + {/* Alert for no supported plugins */} + {!isCheckingSupport && + supportedPlugins.length === 0 && + userInput !== '' && + lastUsedInput != null && + selectedWallet != null && + selectedCryptoCurrencyCode != null ? ( + + ) : null} + + {/* Error Alert for Failed Quotes */} + {!isFetchingQuotes && + !isCheckingSupport && + supportedPluginsError != null && + quoteErrors.length > 0 && + sortedQuotes.length === 0 && + supportedPlugins.length > 0 && + (userInput !== '' || isMaxAmount) ? ( + + ) : null} + + + {/* Next Button - Must be sibling of SceneWrapper for proper keyboard positioning */} + + + ) +} + +const FlagIcon = styled(FastImage)<{ sizeRem?: number }>( + theme => + ({ sizeRem = 1 }) => ({ + width: theme.rem(sizeRem), + height: theme.rem(sizeRem), + borderRadius: theme.rem(0.75) + }) +) + +const InputsContainer = styled(View)(theme => ({ + paddingHorizontal: theme.rem(0.5), + gap: theme.rem(1) +})) + +const InputRow = styled(View)(theme => ({ + flexDirection: 'row', + alignItems: 'flex-start', + gap: theme.rem(1) +})) + +const MaxButton = styled(EdgeTouchableOpacity)<{ active?: boolean }>( + theme => props => ({ + alignSelf: 'flex-end', + padding: theme.rem(0.25), + margin: theme.rem(0.25), + borderWidth: 1, + borderRadius: theme.rem(0.5), + borderColor: props.active === true ? theme.escapeButtonText : 'transparent' + }) +) + +const MaxButtonText = styled(Text)(theme => ({ + color: theme.escapeButtonText, + fontFamily: theme.fontFaceDefault, + fontSize: theme.rem(0.75), + includeFontPadding: false +})) + +const ExchangeRateContainer = styled(View)(theme => ({ + paddingHorizontal: theme.rem(1), + paddingVertical: theme.rem(2), + alignItems: 'center' +})) + +const ExchangeRateTitle = styled(EdgeText)(theme => ({ + fontSize: theme.rem(1), + color: theme.primaryText, + textAlign: 'center', + marginBottom: theme.rem(0.5) +})) + +const ExchangeRateValueText = styled(EdgeText)(theme => ({ + fontSize: theme.rem(1.125), + fontWeight: 'bold', + color: theme.primaryText, + textAlign: 'center', + marginBottom: theme.rem(0.5) +})) + +const InputContainer = styled(View)(() => ({ + flex: 1 +})) + +// +// Region Select Primitives +// + +const StepsCard = styled(View)(theme => ({ + margin: theme.rem(1), + marginHorizontal: theme.rem(0.5), + padding: theme.rem(1), + backgroundColor: theme.cardBaseColor, + borderRadius: theme.rem(0.5), + borderWidth: theme.thinLineWidth, + borderColor: theme.cardBorderColor +})) + +const StepContainer = styled(View)(theme => ({ + gap: theme.rem(0.75) +})) + +const StepRow = styled(View)(theme => ({ + flexDirection: 'row', + alignItems: 'flex-start', + gap: theme.rem(0.5) +})) + +const StepNumberText = styled(EdgeText)(theme => ({ + fontWeight: '600', + minWidth: theme.rem(1.25) +})) +const StepText = styled(EdgeText)(() => ({ + flex: 1 +})) + +const RegionButton = styled(EdgeTouchableOpacity)(theme => ({ + marginTop: theme.rem(1.5), + marginHorizontal: theme.rem(0.5) +})) + +const RegionButtonContent = styled(View)(theme => ({ + flexDirection: 'row', + alignItems: 'center', + backgroundColor: theme.cardBaseColor, + borderRadius: theme.rem(0.5), + padding: theme.rem(1), + borderWidth: theme.thinLineWidth, + borderColor: theme.cardBorderColor, + gap: theme.rem(0.5) +})) + +const GlobeIcon = styled(Feather)(theme => ({ + marginRight: theme.rem(0.75) +})) + +const SubtitleText = styled(EdgeText)(theme => ({ + color: theme.primaryText, + fontSize: theme.rem(1.25), + fontFamily: theme.fontFaceDefault, + marginTop: theme.rem(1), + marginBottom: theme.rem(0.5), + marginHorizontal: theme.rem(0.5) +})) + +const RegionButtonText = styled(EdgeText)(theme => ({ + flex: 1, + color: theme.primaryText, + fontSize: theme.rem(1.1), + fontFamily: theme.fontFaceDefault +})) diff --git a/src/components/scenes/TradeOptionSelectScene.tsx b/src/components/scenes/TradeOptionSelectScene.tsx new file mode 100644 index 00000000000..50e44e3297f --- /dev/null +++ b/src/components/scenes/TradeOptionSelectScene.tsx @@ -0,0 +1,423 @@ +import * as React from 'react' +import { ActivityIndicator, Image, View } from 'react-native' +import { sprintf } from 'sprintf-js' + +// TradeOptionSelectScene - Updated layout for design requirements +import paymentTypeLogoApplePay from '../../assets/images/paymentTypes/paymentTypeLogoApplePay.png' +import { useRampPlugins } from '../../hooks/useRampPlugins' +import { useRampQuotes } from '../../hooks/useRampQuotes' +import { useSupportedPlugins } from '../../hooks/useSupportedPlugins' +import { lstrings } from '../../locales/strings' +import type { + RampPlugin, + RampQuoteRequest, + RampQuoteResult, + SettlementRange +} from '../../plugins/ramps/rampPluginTypes' +import { useSelector } from '../../types/reactRedux' +import type { BuyTabSceneProps } from '../../types/routerTypes' +import { getPaymentTypeIcon } from '../../util/paymentTypeIcons' +import { getPaymentTypeDisplayName } from '../../util/paymentTypeUtils' +import { AlertCardUi4 } from '../cards/AlertCard' +import { PaymentOptionCard } from '../cards/PaymentOptionCard' +import { EdgeAnim } from '../common/EdgeAnim' +import { SceneWrapper } from '../common/SceneWrapper' +import { SectionHeader } from '../common/SectionHeader' +import { styled } from '../hoc/styled' +import { BestRateBadge } from '../icons/BestRateBadge' +import { SceneContainer } from '../layout/SceneContainer' +import { RadioListModal } from '../modals/RadioListModal' +import { Shimmer } from '../progress-indicators/Shimmer' +import { Airship } from '../services/AirshipInstance' +import { useTheme } from '../services/ThemeContext' +import { EdgeText } from '../themed/EdgeText' + +export interface RampSelectOptionParams { + rampQuoteRequest: RampQuoteRequest +} + +interface Props extends BuyTabSceneProps<'rampSelectOption'> {} + +export const TradeOptionSelectScene: React.FC = (props: Props) => { + const { route } = props + const { rampQuoteRequest } = route.params + + const theme = useTheme() + const account = useSelector(state => state.core.account) + // Get ramp plugins + const { data: rampPluginArray = [], isLoading: isPluginsLoading } = + useRampPlugins({ account }) + const rampPlugins = React.useMemo(() => { + const map: Record = {} + for (const plugin of rampPluginArray) { + map[plugin.pluginId] = plugin + } + return map + }, [rampPluginArray]) + + // Use supported plugins hook + const { supportedPlugins } = useSupportedPlugins({ + selectedWallet: rampQuoteRequest.wallet, + selectedCrypto: + rampQuoteRequest.wallet != null + ? { + pluginId: rampQuoteRequest.pluginId, + tokenId: rampQuoteRequest.tokenId + } + : undefined, + selectedFiatCurrencyCode: rampQuoteRequest.fiatCurrencyCode.replace( + 'iso:', + '' + ), + countryCode: rampQuoteRequest.regionCode?.countryCode, + stateProvinceCode: rampQuoteRequest.regionCode?.stateProvinceCode, + plugins: rampPlugins, + direction: rampQuoteRequest.direction + }) + + // Use supported plugins + const pluginsToUse = Object.fromEntries( + supportedPlugins.map(plugin => [plugin.pluginId, plugin]) + ) + + // Use the ramp quotes hook + const { + quotes: allQuotes, + isLoading: isLoadingQuotes, + isFetching: isFetchingQuotes, + errors: failedQuotes + } = useRampQuotes({ + rampQuoteRequest, + plugins: pluginsToUse + }) + + const handleQuotePress = async (quote: RampQuoteResult): Promise => { + try { + await quote.approveQuote({ + coreWallet: rampQuoteRequest.wallet! + }) + } catch (error) { + console.error('Failed to approve quote:', error) + } + } + + // Get the best quote overall + const bestQuoteOverall = allQuotes[0] + + // Group quotes by payment type and sort within each group + const quotesByPaymentType = React.useMemo(() => { + const grouped = new Map() + + allQuotes.forEach(quote => { + const paymentType = quote.paymentType + const existing = grouped.get(paymentType) ?? [] + grouped.set(paymentType, [...existing, quote]) + }) + + // Sort quotes within each payment type group + grouped.forEach(quotes => { + quotes.sort((a, b) => { + const cryptoAmountA = parseFloat(a.cryptoAmount) + const cryptoAmountB = parseFloat(b.cryptoAmount) + + // Guard against division by zero + if (cryptoAmountA === 0 || cryptoAmountB === 0) { + // If either crypto amount is zero, sort that quote to the end + if (cryptoAmountA === 0 && cryptoAmountB === 0) return 0 + if (cryptoAmountA === 0) return 1 + return -1 + } + + const rateA = parseFloat(a.fiatAmount) / cryptoAmountA + const rateB = parseFloat(b.fiatAmount) / cryptoAmountB + return rateA - rateB + }) + }) + + return grouped + }, [allQuotes]) + + // Only show loading state if we have no quotes to display + const showLoadingState = + isPluginsLoading || (isLoadingQuotes && allQuotes.length === 0) + + return ( + + + {rampQuoteRequest.wallet != null && ( + + {sprintf( + lstrings.buying_into_wallet_1s, + rampQuoteRequest.wallet.name + )} + + )} + + + + ) : undefined + } + /> + {showLoadingState ? ( + <> + + + + + + + + + + + ) : ( + <> + {allQuotes.length === 0 && failedQuotes.length === 0 ? ( + + ) : null} + {Array.from(quotesByPaymentType.entries()).map( + ([paymentType, quotes]) => ( + + ) + )} + {failedQuotes.map(error => { + const errorMessage = + error.error instanceof Error + ? error.error.message + : String(error.error) + + return ( + + ) + })} + + )} + + + ) +} + +const QuoteResult: React.FC<{ + quotes: RampQuoteResult[] + onPress: (quote: RampQuoteResult) => Promise + bestQuoteOverall?: RampQuoteResult +}> = ({ quotes, onPress, bestQuoteOverall }) => { + const theme = useTheme() + + // State for selected quote + const [selectedQuoteIndex, setSelectedQuoteIndex] = React.useState(0) + const selectedQuote = quotes[selectedQuoteIndex] + + if (quotes.length === 0 || selectedQuote == null) { + return null + } + + // Check if the currently selected quote is the best rate + const isBestRate = + bestQuoteOverall != null && + selectedQuote.pluginId === bestQuoteOverall.pluginId && + selectedQuote.paymentType === bestQuoteOverall.paymentType && + selectedQuote.fiatAmount === bestQuoteOverall.fiatAmount + + const fiatCurrencyCode = selectedQuote.fiatCurrencyCode.replace('iso:', '') + + // Get the icon for the payment type + const paymentTypeIcon = getPaymentTypeIcon(selectedQuote.paymentType, theme) + const icon = paymentTypeIcon ?? { uri: selectedQuote.partnerIcon } + + // Determine custom title rendering + const customTitleKey = paymentTypeToCustomTitleKey[selectedQuote.paymentType] + const defaultTitle = getPaymentTypeDisplayName(selectedQuote.paymentType) + + // Render custom title based on payment type + let titleComponent: React.ReactNode + switch (customTitleKey) { + case 'applepay': + // Per Apple branding guidelines, "Pay with" is NOT to be translated + titleComponent = ( + + + {/* eslint-disable-next-line react-native/no-raw-text */} + {'Pay with '} + + + + ) + break + default: + titleComponent = {defaultTitle} + } + + // Handle provider press - show modal to select between providers + const handleProviderPress = async (): Promise => { + if (quotes.length <= 1) { + // No other providers to choose from + return + } + + // Create items array for the RadioListModal + const items = quotes.map(quote => { + // Format the crypto amount for each provider + // const localeAmount = formatNumber(toFixed(quote.cryptoAmount, 0, 6)) + const amount = + quote.direction === 'buy' ? quote.fiatAmount : quote.cryptoAmount + const currencyCode = + quote.direction === 'buy' + ? quote.fiatCurrencyCode.replace('iso:', '') + : quote.displayCurrencyCode + const text = `(${amount} ${currencyCode})` + + return { + name: quote.pluginDisplayName, + icon: quote.partnerIcon, // Already full path + text, + key: quote.pluginId // Use stable key for selection + } + }) + + const selectedName = await Airship.show(bridge => ( + + )) + + if (selectedName != null) { + const selectedIndex = quotes.findIndex( + quote => quote.pluginDisplayName === selectedName + ) + if (selectedIndex !== -1) { + setSelectedQuoteIndex(selectedIndex) + } + } + } + + return ( + : undefined} + onPress={async () => { + await onPress(selectedQuote) + }} + onProviderPress={handleProviderPress} + disableProviderButton={quotes.length <= 1} + /> + ) +} + +// Styled components for Apple Pay title +const TitleAppleContainer = styled(View)(() => ({ + flexDirection: 'row' as const, + justifyContent: 'flex-start' as const, + alignItems: 'flex-end' as const, + flexShrink: 1 +})) + +const TitleText = styled(EdgeText)(theme => ({ + fontFamily: theme.fontFaceMedium +})) + +const TitleAppleLogo = styled(Image)(theme => ({ + height: theme.rem(1), + width: 'auto' as any, + aspectRatio: 150 / 64, + resizeMode: 'contain' as const, + marginBottom: 1 +})) + +const ShimmerCard = styled(View)(theme => ({ + height: theme.rem(10), + marginHorizontal: theme.rem(0.5), + marginVertical: theme.rem(0.25), + borderRadius: theme.cardBorderRadius, + position: 'relative' +})) + +const WalletInfoText = styled(EdgeText)(theme => ({ + color: theme.secondaryText, + fontSize: theme.rem(0.875), + textAlign: 'center', + marginTop: theme.rem(0.5), + marginBottom: theme.rem(0.5), + marginHorizontal: theme.rem(1) +})) + +// Utility mapping for payment types to custom title keys +const paymentTypeToCustomTitleKey: Record = { + applepay: 'applepay' + // Add other mappings as needed +} + +// Format time unit for display +const formatTimeUnit = (time: { value: number; unit: string }): string => { + const { value, unit } = time + + // Handle singular vs plural + const unitLabel = value === 1 ? unit.slice(0, -1) : unit + + // Abbreviate common units + const abbreviations: Record = { + minute: 'min', + minutes: 'min', + hour: 'hr', + hours: 'hrs', + day: 'day', + days: 'days' + } + + const displayUnit = abbreviations[unitLabel] ?? unitLabel + return `${value} ${displayUnit}` +} + +// Format settlement range for display +const formatSettlementTime = (range: SettlementRange): string => { + // Handle instant settlement + if (range.min.value === 0) { + return `${lstrings.trade_option_settlement_label}: Instant` + } + + const minStr = formatTimeUnit(range.min) + const maxStr = formatTimeUnit(range.max) + + return `${lstrings.trade_option_settlement_label}: ${minStr} - ${maxStr}` +} diff --git a/src/components/themed/SwapInput.tsx b/src/components/themed/SwapInput.tsx index f53ef74f834..406239223c6 100644 --- a/src/components/themed/SwapInput.tsx +++ b/src/components/themed/SwapInput.tsx @@ -20,7 +20,7 @@ import { precisionAdjust, removeIsoPrefix } from '../../util/utils' -import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity' +import { PillButton } from '../buttons/PillButton' import { styled } from '../hoc/styled' import { CryptoIcon } from '../icons/CryptoIcon' import { Space } from '../layout/Space' @@ -318,17 +318,18 @@ const SwapInputComponent = React.forwardRef(
{heading} - - - - {walletPlaceholderText} - - + ( + + )} + />
) @@ -376,20 +377,6 @@ const CardHeading = styled(EdgeText)(theme => ({ color: theme.secondaryText })) -const WalletPlaceHolder = styled(EdgeTouchableOpacity)(theme => ({ - alignItems: 'center', - backgroundColor: theme.cardBaseColor, - borderRadius: 100, - flexDirection: 'row', - paddingHorizontal: theme.rem(0.75), - paddingVertical: theme.rem(0.25) -})) - -const WalletPlaceHolderText = styled(EdgeText)(theme => ({ - fontSize: theme.rem(0.75), - lineHeight: theme.rem(1.5) -})) - // This space is used to give the FlipInput2 roughly 1 rem bottom padding to // match the top padding from the header. const FooterSpace = styled(View)(theme => ({ diff --git a/src/envConfig.ts b/src/envConfig.ts index da7d997879c..761514b3e18 100644 --- a/src/envConfig.ts +++ b/src/envConfig.ts @@ -10,6 +10,12 @@ import { type Cleaner } from 'cleaners' +import { asInitOptions as asBanxaInitOptions } from './plugins/ramps/banxa/banxaRampTypes' +import { asInitOptions as asBityInitOptions } from './plugins/ramps/bity/bityRampTypes' +import { asInitOptions as asMoonpayInitOptions } from './plugins/ramps/moonpay/moonpayRampTypes' +import { asInitOptions as asPaybisInitOptions } from './plugins/ramps/paybis/paybisRampTypes' +import { asInitOptions as asRevolutInitOptions } from './plugins/ramps/revolut/revolutRampTypes' +import { asInitOptions as asSimplexInitOptions } from './plugins/ramps/simplex/simplexRampTypes' import { asBase16 } from './util/cleaners/asHex' function asNullable(cleaner: Cleaner): Cleaner { @@ -100,7 +106,7 @@ export const asEnvConfig = asObject({ ), paybis: asOptional( asObject({ - partnerUrl: asString, + partnerUrl: asOptional(asString, 'https://widget-api.paybis.com'), apiKey: asString, privateKeyB64: asString }) @@ -140,6 +146,24 @@ export const asEnvConfig = asObject({ ionia: undefined }) ), + RAMP_PLUGIN_INITS: asOptional( + asObject>({ + banxa: asOptional(asBanxaInitOptions), + bity: asOptional(asBityInitOptions), + moonpay: asOptional(asMoonpayInitOptions), + paybis: asOptional(asPaybisInitOptions), + revolut: asOptional(asRevolutInitOptions), + simplex: asOptional(asSimplexInitOptions) + }), + () => ({ + banxa: undefined, + bity: undefined, + moonpay: undefined, + paybis: undefined, + revolut: undefined, + simplex: undefined + }) + ), WYRE_CLIENT_INIT: asOptional( asObject({ baseUri: asString @@ -424,6 +448,7 @@ export const asEnvConfig = asObject({ DEBUG_EXCHANGES: asOptional(asBoolean, false), DEBUG_VERBOSE_ERRORS: asOptional(asBoolean, false), DEBUG_THEME: asOptional(asBoolean, false), + DEBUG_LOGBOX: asOptional(asBoolean, true), MUTE_CONSOLE_OUTPUT: asOptional( asArray( asValue( diff --git a/src/hooks/useRampPlugins.ts b/src/hooks/useRampPlugins.ts new file mode 100644 index 00000000000..dd477f5e365 --- /dev/null +++ b/src/hooks/useRampPlugins.ts @@ -0,0 +1,87 @@ +import type { EdgeAccount } from 'edge-core-js' +import * as React from 'react' + +import { ENV } from '../env' +import { pluginFactories } from '../plugins/ramps/allRampPlugins' +import type { RampPlugin } from '../plugins/ramps/rampPluginTypes' +import { createStore } from '../plugins/ramps/utils/createStore' +import { getRampPluginStoreId } from '../plugins/ramps/utils/rampStoreIds' + +interface UseRampPluginsOptions { + account: EdgeAccount +} + +export function useRampPlugins({ account }: UseRampPluginsOptions): { + data: RampPlugin[] + isLoading: boolean + error: Error | null + isError: boolean +} { + const [plugins, setPlugins] = React.useState([]) + const [isLoading, setIsLoading] = React.useState(true) + const [error, setError] = React.useState(null) + + React.useEffect(() => { + let mounted = true + + const loadPlugins = async (): Promise => { + try { + setIsLoading(true) + setError(null) + + const loadedPlugins: RampPlugin[] = [] + + for (const [pluginId, factory] of Object.entries(pluginFactories)) { + try { + // Get the appropriate store ID: + // - Legacy plugins (e.g., paybis) use their old store IDs for backward compatibility + // - New plugins automatically get 'ramp:${pluginId}' format + const storeId = getRampPluginStoreId(pluginId) + const store = createStore(storeId, account.dataStore) + + // Create a minimal config for the plugin + const initOptions = ENV.RAMP_PLUGIN_INITS[pluginId] ?? {} + const config = { + initOptions, + store, + account, + navigation: null as any, // Navigation will be provided by components that need it + onLogEvent: () => {}, + disklet: account.disklet + } + + const plugin = factory(config) + loadedPlugins.push(plugin) + } catch (error) { + console.warn(`Failed to load plugin ${pluginId}:`, error) + } + } + + if (mounted) { + setPlugins(loadedPlugins) + setIsLoading(false) + } + } catch (err) { + if (mounted) { + setError( + err instanceof Error ? err : new Error('Failed to load plugins') + ) + setIsLoading(false) + } + } + } + + loadPlugins().catch(console.error) + + return () => { + mounted = false + } + }, [account]) + + return { + data: plugins, + isLoading, + error, + isError: error != null + } +} diff --git a/src/hooks/useRampQuotes.ts b/src/hooks/useRampQuotes.ts new file mode 100644 index 00000000000..ef7fa2f4949 --- /dev/null +++ b/src/hooks/useRampQuotes.ts @@ -0,0 +1,211 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query' +import * as React from 'react' + +import type { + RampPlugin, + RampQuoteRequest, + RampQuoteResult +} from '../plugins/ramps/rampPluginTypes' +import type { Result } from '../types/types' + +interface QuoteError { + pluginId: string + pluginDisplayName: string + error: unknown +} + +interface UseRampQuotesOptions { + /** The quote request to fetch quotes for. If null, no quotes will be fetched. */ + rampQuoteRequest: RampQuoteRequest | null + plugins: Record + staleTime?: number +} + +interface UseRampQuotesResult { + quotes: RampQuoteResult[] + isLoading: boolean + isFetching: boolean + errors: QuoteError[] +} + +// Helper function to check if a quote is expired +const isQuoteExpired = (quote: RampQuoteResult): boolean => { + if (quote.expirationDate == null) return false + return new Date() > new Date(quote.expirationDate) +} + +// Helper function to check if a quote is expiring soon +const isQuoteExpiringSoon = ( + quote: RampQuoteResult, + minutesUntilExpiry = 1 +): boolean => { + if (quote.expirationDate == null) return false + const now = new Date() + const expirationTime = new Date(quote.expirationDate).getTime() + const timeUntilExpiration = expirationTime - now.getTime() + return ( + timeUntilExpiration > 0 && timeUntilExpiration < minutesUntilExpiry * 60000 + ) +} + +export const useRampQuotes = ({ + rampQuoteRequest, + plugins, + staleTime = 30000 +}: UseRampQuotesOptions): UseRampQuotesResult => { + const queryClient = useQueryClient() + + // Stable query key that doesn't change based on expired quotes + const pluginIds = Object.keys(plugins).sort() // Sort for stability + const queryKey = ['rampQuotes', rampQuoteRequest, pluginIds] + + const { + data: quoteResults = [], + isLoading, + isFetching + } = useQuery>>({ + queryKey, + queryFn: async () => { + if (rampQuoteRequest == null) return [] + + // Get previous results + const prevResults = + queryClient.getQueryData>>( + queryKey + ) ?? [] + + // Create a map of previous results by plugin ID + const prevResultsMap = new Map< + string, + Result + >() + prevResults.forEach(result => { + const pluginId = result.ok + ? result.value[0]?.pluginId + : result.error.pluginId + if (pluginId !== '') prevResultsMap.set(pluginId, result) + }) + + // Fetch quotes from all plugins, reusing valid cached quotes + const resultPromises = Object.entries(plugins).map( + async ([pluginId, plugin]): Promise< + Result + > => { + const prevResult = prevResultsMap.get(pluginId) + + // If we have valid non-expired quotes, use them + if (prevResult?.ok === true) { + const validQuotes = prevResult.value.filter( + quote => !isQuoteExpired(quote) + ) + if (validQuotes.length > 0) { + return { ok: true, value: validQuotes } + } + } + + // Otherwise fetch fresh quotes + try { + const quotes = await plugin.fetchQuote(rampQuoteRequest) + return { ok: true, value: quotes } + } catch (error) { + console.warn(`Failed to get quote from ${pluginId}:`, error) + return { + ok: false, + error: { + pluginId, + pluginDisplayName: plugin.rampInfo.pluginDisplayName, + error + } + } + } + } + ) + + return await Promise.all(resultPromises) + }, + refetchOnMount: 'always', + refetchInterval: query => { + const results = query.state.data + if (results == null || results.length === 0) return false + + const now = Date.now() + let minTimeToExpiration = Infinity + + // Find the minimum expiration time among all quotes + results.forEach(result => { + if (result.ok) { + result.value.forEach(quote => { + if (quote.expirationDate != null) { + const timeToExpiration = + new Date(quote.expirationDate).getTime() - now + if ( + timeToExpiration > 0 && + timeToExpiration < minTimeToExpiration + ) { + minTimeToExpiration = timeToExpiration + } + } + }) + } + }) + + // If no valid expiration dates found, don't refetch + if (minTimeToExpiration === Infinity) return false + + // Refetch based on the minimum expiration time + return minTimeToExpiration + }, + enabled: rampQuoteRequest != null, + staleTime, + gcTime: 300000, + // Keep showing previous data while refetching + placeholderData: previousData => previousData, + refetchOnWindowFocus: false + }) + + // Extract and sort all quotes from results + const quotes: RampQuoteResult[] = React.useMemo(() => { + const allQuotes = quoteResults + .filter( + (result): result is { ok: true; value: RampQuoteResult[] } => result.ok + ) + .flatMap(result => result.value) + + // Sort by best rate (lowest fiat amount for same crypto amount) + return allQuotes.sort((a, b) => { + const cryptoAmountA = parseFloat(a.cryptoAmount) + const cryptoAmountB = parseFloat(b.cryptoAmount) + + // Guard against division by zero + if (cryptoAmountA === 0 || cryptoAmountB === 0) { + // If either crypto amount is zero, sort that quote to the end + if (cryptoAmountA === 0 && cryptoAmountB === 0) return 0 + if (cryptoAmountA === 0) return 1 + return -1 + } + + const rateA = parseFloat(a.fiatAmount) / cryptoAmountA + const rateB = parseFloat(b.fiatAmount) / cryptoAmountB + return rateA - rateB + }) + }, [quoteResults]) + + // Extract errors from failed results + const errors: QuoteError[] = React.useMemo(() => { + return quoteResults + .filter( + (result): result is { ok: false; error: QuoteError } => !result.ok + ) + .map(result => result.error) + }, [quoteResults]) + + return { + quotes, + isLoading, + isFetching, + errors + } +} + +// Export helper functions for use in components +export { isQuoteExpired, isQuoteExpiringSoon } diff --git a/src/hooks/useSupportedPlugins.ts b/src/hooks/useSupportedPlugins.ts new file mode 100644 index 00000000000..7c57d166cd1 --- /dev/null +++ b/src/hooks/useSupportedPlugins.ts @@ -0,0 +1,131 @@ +import { useQuery } from '@tanstack/react-query' +import type { EdgeCurrencyWallet, EdgeTokenId } from 'edge-core-js' +import * as React from 'react' + +import type { FiatPluginRegionCode } from '../plugins/gui/fiatPluginTypes' +import type { + RampCheckSupportRequest, + RampPlugin +} from '../plugins/ramps/rampPluginTypes' + +interface UseSupportedPluginsParams { + selectedWallet?: EdgeCurrencyWallet + selectedCrypto?: { + pluginId: string + tokenId: EdgeTokenId + } + selectedFiatCurrencyCode?: string + countryCode?: string + stateProvinceCode?: string + plugins: Record + direction?: 'buy' | 'sell' +} + +interface UseSupportedPluginsResult { + supportedPlugins: RampPlugin[] + isLoading: boolean + error: Error | null +} + +export const useSupportedPlugins = ({ + selectedWallet, + selectedCrypto, + selectedFiatCurrencyCode, + countryCode, + stateProvinceCode, + plugins, + direction = 'buy' +}: UseSupportedPluginsParams): UseSupportedPluginsResult => { + // Build region code + const regionCode: FiatPluginRegionCode | undefined = React.useMemo(() => { + if (countryCode == null) return undefined + + return { + countryCode, + stateProvinceCode + } + }, [countryCode, stateProvinceCode]) + + // Create query key + const queryKey = [ + 'supportedPlugins', + selectedCrypto?.pluginId, + selectedCrypto?.tokenId, + selectedFiatCurrencyCode, + regionCode, + direction + ] + + const { + data: supportedPlugins = [], + isLoading, + error + } = useQuery({ + queryKey, + queryFn: async () => { + // Early return if required params are missing + if ( + selectedCrypto == null || + selectedFiatCurrencyCode == null || + regionCode == null || + selectedWallet == null + ) { + return [] + } + + // Build check support request + const checkSupportRequest: RampCheckSupportRequest = { + direction, + regionCode, + fiatAsset: { + currencyCode: selectedFiatCurrencyCode // Without 'iso:' prefix + }, + cryptoAsset: { + pluginId: selectedCrypto.pluginId, + tokenId: selectedCrypto.tokenId + } + } + + // Check support for all plugins in parallel + const supportChecks = await Promise.all( + Object.values(plugins).map(async plugin => { + try { + const result = await plugin.checkSupport(checkSupportRequest) + return { + plugin, + supported: result.supported + } + } catch (error) { + console.warn( + `Failed to check support for plugin ${plugin.pluginId}:`, + error + ) + return { + plugin, + supported: false + } + } + }) + ) + + // Filter only supported plugins + return supportChecks + .filter(check => check.supported) + .map(check => check.plugin) + }, + enabled: + selectedWallet != null && + selectedCrypto != null && + selectedFiatCurrencyCode != null && + regionCode != null, + staleTime: 5 * 60 * 1000, // 5 minutes + gcTime: 10 * 60 * 1000, // 10 minutes cache time + refetchOnWindowFocus: false + }) + + return { + supportedPlugins, + isLoading, + error + } +} diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index e509d13fe9c..d49c675c8e0 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -1381,6 +1381,7 @@ const strings = { string_status: 'Status', string_fee: 'Fee', string_total_amount: 'Total Amount', + string_total_amount_s: 'Total Amount: %s', string_expiration: 'Expiration', export_transaction_error: 'Start date should be earlier than the end date', export_transaction_export_error: 'No transactions in the date range chosen', @@ -1391,8 +1392,9 @@ const strings = { string_warning: 'Warning', // Generic string. Same with wc_smartcontract_warning_title string_report_error: 'Report Error', string_report_sent: 'Report sent.', + string_best_rate_badge_text: 'Best\nRate', - step: 'Step', + step_prefix_s: 'Step %s:', scan_as_in_scan_barcode: 'Scan', enter_as_in_enter_address_with_keyboard: 'Enter', @@ -1938,6 +1940,22 @@ const strings = { see_all: 'See All', sell_crypto: 'Sell Crypto', sell_crypto_footer: 'Crypto to bank or cash', + + // Trade Option Select Scene + buy_cryptocurrency_scene_title: 'Buy Cryptocurrency', + trade_option_buy_title: 'Buy Cryptocurrency', + trade_option_sell_title: 'Sell Cryptocurrency', + trade_option_select_payment_method: 'Select Payment Method', + buying_into_wallet_1s: 'Buying into wallet: %s', + trade_option_choose_provider: 'Choose Provider', + trade_option_no_quotes_title: 'No quotes available', + trade_option_no_quotes_body: + 'Please try again later. No providers are currently available.', + trade_option_total_label: 'Total', + trade_option_settlement_label: 'Settlement', + trade_option_powered_by_label: 'Powered By', + trade_option_best_rate_label: 'BEST\nRATE', + trade_option_provider_failed_s: '%s Failed', swap_crypto: 'Swap Crypto', swap_crypto_footer: 'Crypto to another crypto', fio_web3: 'Web3 Handle', @@ -1988,6 +2006,24 @@ const strings = { education: 'Education', enter_value: 'Enter Value', + // Trade Region Select Scene + + trade_region_select_start_steps: 'Start in 4 Easy Steps', + trade_region_select_step_1: 'Select Your Region for personalized options', + trade_region_select_step_2: 'Create Your Quote', + trade_region_select_step_3: 'Choose Payment Method', + trade_region_select_step_4: 'Fund Your Account', + + // Trade Create Scene + trade_buy_unavailable_title: 'Buy Unavailable', + trade_buy_unavailable_body_2s: + 'Support to buy %1$s with %2$s is not available at this time.', + + // Trade Create Scene + trade_create_amount_s: 'Amount %s', + trade_create_exchange_rate: 'Exchange Rate', + trade_create_next: 'Next', + trade_create_max: 'MAX', // Currency Labels currency_label_AFN: 'Afghani', currency_label_ALL: 'Lek', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index 5350fae9f75..53f7dd5fc69 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1085,6 +1085,7 @@ "string_status": "Status", "string_fee": "Fee", "string_total_amount": "Total Amount", + "string_total_amount_s": "Total Amount: %s", "string_expiration": "Expiration", "export_transaction_error": "Start date should be earlier than the end date", "export_transaction_export_error": "No transactions in the date range chosen", @@ -1095,7 +1096,8 @@ "string_warning": "Warning", "string_report_error": "Report Error", "string_report_sent": "Report sent.", - "step": "Step", + "string_best_rate_badge_text": "Best\nRate", + "step_prefix_s": "Step %s:", "scan_as_in_scan_barcode": "Scan", "enter_as_in_enter_address_with_keyboard": "Enter", "delete_account_title": "Delete Account", @@ -1478,6 +1480,19 @@ "see_all": "See All", "sell_crypto": "Sell Crypto", "sell_crypto_footer": "Crypto to bank or cash", + "buy_cryptocurrency_scene_title": "Buy Cryptocurrency", + "trade_option_buy_title": "Buy Cryptocurrency", + "trade_option_sell_title": "Sell Cryptocurrency", + "trade_option_select_payment_method": "Select Payment Method", + "buying_into_wallet_1s": "Buying into wallet: %s", + "trade_option_choose_provider": "Choose Provider", + "trade_option_no_quotes_title": "No quotes available", + "trade_option_no_quotes_body": "Please try again later. No providers are currently available.", + "trade_option_total_label": "Total", + "trade_option_settlement_label": "Settlement", + "trade_option_powered_by_label": "Powered By", + "trade_option_best_rate_label": "BEST\nRATE", + "trade_option_provider_failed_s": "%s Failed", "swap_crypto": "Swap Crypto", "swap_crypto_footer": "Crypto to another crypto", "fio_web3": "Web3 Handle", @@ -1513,6 +1528,17 @@ "edge_ucation": "Edge-ucation", "education": "Education", "enter_value": "Enter Value", + "trade_region_select_start_steps": "Start in 4 Easy Steps", + "trade_region_select_step_1": "Select Your Region for personalized options", + "trade_region_select_step_2": "Create Your Quote", + "trade_region_select_step_3": "Choose Payment Method", + "trade_region_select_step_4": "Fund Your Account", + "trade_buy_unavailable_title": "Buy Unavailable", + "trade_buy_unavailable_body_2s": "Support to buy %1$s with %2$s is not available at this time.", + "trade_create_amount_s": "Amount %s", + "trade_create_exchange_rate": "Exchange Rate", + "trade_create_next": "Next", + "trade_create_max": "MAX", "currency_label_AFN": "Afghani", "currency_label_ALL": "Lek", "currency_label_DZD": "Algerian Dinar", diff --git a/src/plugins/gui/fiatPlugin.tsx b/src/plugins/gui/fiatPlugin.tsx index 98ad77b981f..0765b6c5924 100644 --- a/src/plugins/gui/fiatPlugin.tsx +++ b/src/plugins/gui/fiatPlugin.tsx @@ -79,7 +79,7 @@ const deeplinkListeners: { } | null } = { listener: null } -export const fiatProviderDeeplinkHandler = (link: FiatProviderLink) => { +export const fiatProviderDeeplinkHandler = (link: FiatProviderLink): void => { if (deeplinkListeners.listener == null) { showError( `No buy/sell interface currently open to handle fiatProvider deeplink` @@ -105,7 +105,8 @@ export const fiatProviderDeeplinkHandler = (link: FiatProviderLink) => { if (Platform.OS === 'ios') { SafariView.dismiss() } - deeplinkHandler(link) + const p = deeplinkHandler(link) + if (p != null) p.catch(showError) } export const executePlugin = async (params: { @@ -149,7 +150,7 @@ export const executePlugin = async (params: { const tabSceneKey = isBuy ? 'buyTab' : 'sellTab' - function maybeNavigateToCorrectTabScene() { + function maybeNavigateToCorrectTabScene(): void { const navPath = getNavigationAbsolutePath(navigation) if (!navPath.includes(`/edgeTabs/${tabSceneKey}`)) { // Navigate to the correct tab first @@ -196,7 +197,9 @@ export const executePlugin = async (params: { openExternalWebView: async (params): Promise => { const { deeplinkHandler, providerId, redirectExternal, url } = params datelog( - `**** openExternalWebView ${url} deeplinkHandler:${deeplinkHandler}` + `**** openExternalWebView ${url} deeplinkHandler:${JSON.stringify( + deeplinkHandler + )}` ) if (deeplinkHandler != null) { if (providerId == null) @@ -216,17 +219,16 @@ export const executePlugin = async (params: { const { headerTitle, allowedAssets, showCreateWallet } = params const result = - forcedWalletResult == null - ? await Airship.show(bridge => ( - - )) - : forcedWalletResult + forcedWalletResult ?? + (await Airship.show(bridge => ( + + ))) if (result?.type === 'wallet') return result }, @@ -262,7 +264,7 @@ export const executePlugin = async (params: { ) => { resolve({ email, firstName, lastName }) }, - onClose: async () => { + onClose: () => { resolve(undefined) } }) @@ -280,7 +282,7 @@ export const executePlugin = async (params: { if (onSubmit != null) await onSubmit(homeAddress) resolve(homeAddress) }, - onClose: async () => { + onClose: () => { resolve(undefined) } }) diff --git a/src/plugins/gui/fiatPluginTypes.ts b/src/plugins/gui/fiatPluginTypes.ts index d407ce038a0..29390a37b96 100644 --- a/src/plugins/gui/fiatPluginTypes.ts +++ b/src/plugins/gui/fiatPluginTypes.ts @@ -67,7 +67,7 @@ export const asFiatPaymentType = asValue( ) export type FiatPaymentType = ReturnType -export type LinkHandler = (url: FiatProviderLink) => void +export type LinkHandler = (url: FiatProviderLink) => void | Promise export interface FiatPluginSepaTransferInfo { input: { diff --git a/src/plugins/gui/fiatProviderTypes.ts b/src/plugins/gui/fiatProviderTypes.ts index a222c8e6ac2..87dc7fac973 100644 --- a/src/plugins/gui/fiatProviderTypes.ts +++ b/src/plugins/gui/fiatProviderTypes.ts @@ -33,6 +33,7 @@ export interface FiatProviderQuote { type FiatProviderQuoteErrorTypesLimit = 'overLimit' | 'underLimit' type FiatProviderQuoteErrorTypesRegion = 'regionRestricted' type FiatProviderQuoteErrorTypesOther = + | 'amountTypeUnsupported' | 'assetUnsupported' | 'paymentUnsupported' type FiatProviderQuoteErrorTypesFiat = 'fiatUnsupported' @@ -83,6 +84,8 @@ export class FiatProviderError extends Error { return `Under limit: ${info.errorAmount} ${info.displayCurrencyCode}` case 'regionRestricted': return `Region restricted: ${info.displayCurrencyCode}` + case 'amountTypeUnsupported': + return 'Amount type unsupported' case 'assetUnsupported': return `Asset unsupported` case 'paymentUnsupported': diff --git a/src/plugins/gui/pluginUtils.ts b/src/plugins/gui/pluginUtils.ts index bef5927dda7..33184f686c8 100644 --- a/src/plugins/gui/pluginUtils.ts +++ b/src/plugins/gui/pluginUtils.ts @@ -34,7 +34,8 @@ const ERROR_PRIORITIES: Record = { paymentUnsupported: 3, regionRestricted: 4, assetUnsupported: 5, - fiatUnsupported: 6 + fiatUnsupported: 6, + amountTypeUnsupported: 7 } export const getRateFromQuote = ( diff --git a/src/plugins/gui/providers/kadoProvider.ts b/src/plugins/gui/providers/kadoProvider.ts index 2ceb0bc88cf..9657bd181dc 100644 --- a/src/plugins/gui/providers/kadoProvider.ts +++ b/src/plugins/gui/providers/kadoProvider.ts @@ -533,7 +533,7 @@ export const kadoProvider: FiatProviderFactory = { for (const asset of blockchain.associatedAssets) { const { isNative, address } = asset - if (!asset.rampProducts?.includes(direction)) continue + if (asset.rampProducts?.includes(direction) !== true) continue if (isNative) { tokens.push({ tokenId: null, @@ -758,7 +758,7 @@ export const kadoProvider: FiatProviderFactory = { // If Kado needs to launch the Plaid bank linking widget, it needs it in an external // webview to prevent some glitchiness. When needed, Kado will send an onMessage // trigger with the url to open. The below code is derived from Kado's sample code - const onMessage = (data: string) => { + const onMessage = (data: string): void => { datelog(`**** Kado onMessage ${data}`) const message = asMaybe(asWebviewMessage)(JSON.parse(data)) if (message?.type === 'PLAID_NEW_ACH_LINK') { @@ -767,9 +767,7 @@ export const kadoProvider: FiatProviderFactory = { url: message.payload.link, redirectExternal: true }) - .catch(async error => { - await showUi.showError(error) - }) + .catch(showUi.showError) } } @@ -785,11 +783,11 @@ export const kadoProvider: FiatProviderFactory = { let inPayment = false - const openWebView = async () => { + const openWebView = async (): Promise => { await showUi.openWebView({ url: url.href, onMessage, - onUrlChange: async newUrl => { + onUrlChange: async (newUrl): Promise => { console.log(`*** onUrlChange: ${newUrl}`) if (!newUrl.startsWith(`${urls.widget[MODE]}/ramp/order`)) { @@ -816,7 +814,7 @@ export const kadoProvider: FiatProviderFactory = { if (!response.ok) { const text = await response.text() console.warn(`Error fetching kado blockchains: ${text}`) - return allowedCurrencyCodes + return } const result = await response.json() diff --git a/src/plugins/gui/providers/paybisProvider.ts b/src/plugins/gui/providers/paybisProvider.ts index 5d5d6573c0b..12167f0b3c2 100644 --- a/src/plugins/gui/providers/paybisProvider.ts +++ b/src/plugins/gui/providers/paybisProvider.ts @@ -446,7 +446,7 @@ export const paybisProvider: FiatProviderFactory = { let partnerUserId = await store .getItem('partnerUserId') - .catch(e => undefined) + .catch((_e: unknown) => undefined) if (partnerUserId == null || partnerUserId === '') { partnerUserId = await makeUuid() await store.setItem('partnerUserId', partnerUserId) @@ -517,7 +517,7 @@ export const paybisProvider: FiatProviderFactory = { const { hasTransactions } = asUserStatus(response) userIdHasTransactions = hasTransactions } catch (e) { - console.log(`Paybis: Error getting user status: ${e}`) + console.log(`Paybis: Error getting user status: ${String(e)}`) } const out = allowedCurrencyCodes[direction][paymentType] @@ -743,6 +743,7 @@ export const paybisProvider: FiatProviderFactory = { direction, regionCode, paymentTypes, + expirationDate: new Date(Date.now() + 60000), approveQuote: async ( approveParams: FiatProviderApproveQuoteParams ): Promise => { @@ -828,7 +829,7 @@ export const paybisProvider: FiatProviderFactory = { await showUi.openExternalWebView({ url: `${widgetUrl}?requestId=${requestId}${ott}${promoCodeParam}&successReturnURL=${successReturnURL}&failureReturnURL=${failureReturnURL}`, providerId, - deeplinkHandler: async link => { + deeplinkHandler: async (link): Promise => { const { query, uri } = link console.log('Paybis WebView launch buy success: ' + uri) const { transactionStatus } = query @@ -870,7 +871,7 @@ export const paybisProvider: FiatProviderFactory = { title: lstrings.fiat_plugin_buy_complete_title, message }) - } else if (transactionStatus === 'failure') { + } else if (transactionStatus === 'fail') { await showUi.showToast( lstrings.fiat_plugin_buy_failed_try_again, NOT_SUCCESS_TOAST_HIDE_MS @@ -893,10 +894,10 @@ export const paybisProvider: FiatProviderFactory = { console.log(`webviewUrl: ${webviewUrl}`) let inPayment = false - const openWebView = async () => { + const openWebView = async (): Promise => { await showUi.openWebView({ url: webviewUrl, - onUrlChange: async newUrl => { + onUrlChange: async (newUrl): Promise => { console.log(`*** onUrlChange: ${newUrl}`) if (newUrl.startsWith(RETURN_URL_FAIL)) { await showUi.exitScene() @@ -1155,7 +1156,7 @@ const initializeBuyPairs = async ({ .then(response => { paybisPairs.buy = asPaybisBuyPairs(response) }) - .catch(e => { + .catch((e: unknown) => { console.error(String(e)) }) ] @@ -1228,7 +1229,7 @@ const initializeSellPairs = async ({ .then(response => { paybisPairs.sell = asPaybisSellPairs(response) }) - .catch(e => { + .catch((e: unknown) => { console.error(String(e)) }) ] @@ -1254,9 +1255,7 @@ const initializeSellPairs = async ({ if (edgeTokenId == null) continue const { pluginId: currencyPluginId } = edgeTokenId let { currencyCode: ccode } = edgeTokenId - if (ccode == null) { - ccode = fromAssetId - } + ccode ??= fromAssetId // If the edgeTokenId has a tokenId, use it. If not use the currencyCode. // If no currencyCode, use the key of PAYBIS_TO_EDGE_CURRENCY_MAP diff --git a/src/plugins/gui/scenes/FiatPluginWebView.tsx b/src/plugins/gui/scenes/FiatPluginWebView.tsx index 523642b495b..fd9a4610211 100644 --- a/src/plugins/gui/scenes/FiatPluginWebView.tsx +++ b/src/plugins/gui/scenes/FiatPluginWebView.tsx @@ -3,6 +3,7 @@ import * as React from 'react' import { WebView, type WebViewNavigation } from 'react-native-webview' import { SceneWrapper } from '../../../components/common/SceneWrapper' +import { showError } from '../../../components/services/AirshipInstance' import { useHandler } from '../../../hooks/useHandler' import type { BuyTabSceneProps } from '../../../types/routerTypes' @@ -11,7 +12,7 @@ export interface FiatPluginOpenWebViewParams { injectedJs?: string onClose?: (() => boolean) | (() => void) onMessage?: (message: string, injectJs: (js: string) => void) => void - onUrlChange?: (url: string) => void + onUrlChange?: (url: string) => void | Promise } interface Props extends BuyTabSceneProps<'guiPluginWebView'> {} @@ -25,10 +26,13 @@ export function FiatPluginWebViewComponent(props: Props): React.ReactElement { const handleNavigationStateChange = useHandler((event: WebViewNavigation) => { console.log('FiatPluginWebView navigation: ', event) - if (onUrlChange != null) onUrlChange(event.url) + if (onUrlChange != null) { + const p = onUrlChange(event.url) + if (p != null) p.catch(showError) + } }) - const injectJs = (js: string) => { + const injectJs = (js: string): void => { if (webViewRef.current != null) webViewRef.current.injectJavaScript(js) } diff --git a/src/plugins/ramps/README.md b/src/plugins/ramps/README.md new file mode 100644 index 00000000000..e45564257b6 --- /dev/null +++ b/src/plugins/ramps/README.md @@ -0,0 +1,54 @@ +# Ramp Plugins + +This directory contains the new ramp plugin architecture for buy/sell integrations. + +## Overview + +The ramp plugin system provides a standardized interface for integrating fiat on/off ramp providers. Unlike the legacy fiat plugin system, ramp plugins: + +- Have a simpler, more focused API +- Return quote objects that can be approved later +- Use direct API access instead of UI wrappers + +## Plugin Interface + +### RampPlugin +The main plugin interface with: +- `pluginId`: Unique identifier +- `rampInfo`: Display information (name, icon) +- `fetchQuote()`: Returns an array of quotes for all supported payment types + +### RampQuoteResult +Quote objects returned by plugins with: +- Quote details (amounts, currencies, etc.) +- `approveQuote()`: Executes the quote +- `closeQuote()`: Cleanup function + +### RampApproveQuoteParams +Parameters passed to `approveQuote()`: +- `coreWallet`: The wallet to use for the transaction + +### RampPluginConfig +Configuration passed to plugin factories: +- `initOptions`: Provider-specific initialization options +- `store`: Optional storage interface for persistent data +- `makeUuid`: Optional UUID generator +- `account`: EdgeAccount for wallet operations +- `navigation`: Navigation object for scene transitions +- `onLogEvent`: Analytics tracking function +- `disklet`: Disklet for file operations and permissions + +## Available Plugins + +- **paybis**: Paybis integration supporting multiple payment types + +## Architecture + +Ramp plugins receive their dependencies through the `RampPluginConfig` during initialization. This allows plugins to: +- Use navigation directly for scene transitions +- Track analytics events +- Request permissions +- Show toasts and errors +- Handle deeplinks + +The plugin system uses a centralized deeplink handler (`rampDeeplinkHandler`) for managing provider callbacks from external webviews. \ No newline at end of file diff --git a/src/plugins/ramps/allRampPlugins.ts b/src/plugins/ramps/allRampPlugins.ts new file mode 100644 index 00000000000..14e29cc0a6e --- /dev/null +++ b/src/plugins/ramps/allRampPlugins.ts @@ -0,0 +1,16 @@ +import { banxaRampPlugin } from './banxa/banxaRampPlugin' +import { bityRampPlugin } from './bity/bityRampPlugin' +import { moonpayRampPlugin } from './moonpay/moonpayRampPlugin' +import { paybisRampPlugin } from './paybis/paybisRampPlugin' +import type { RampPluginFactory } from './rampPluginTypes' +import { revolutRampPlugin } from './revolut/revolutRampPlugin' +import { simplexRampPlugin } from './simplex/simplexRampPlugin' + +export const pluginFactories: Record = { + banxa: banxaRampPlugin, + bity: bityRampPlugin, + moonpay: moonpayRampPlugin, + paybis: paybisRampPlugin, + revolut: revolutRampPlugin, + simplex: simplexRampPlugin +} diff --git a/src/plugins/ramps/banxa/banxaRampPlugin.ts b/src/plugins/ramps/banxa/banxaRampPlugin.ts new file mode 100644 index 00000000000..766d90f99ba --- /dev/null +++ b/src/plugins/ramps/banxa/banxaRampPlugin.ts @@ -0,0 +1,1493 @@ +import { gt, lt, mul } from 'biggystring' +import { + asArray, + asEither, + asMaybe, + asNumber, + asObject, + asString, + asValue +} from 'cleaners' +import type { EdgeTokenId } from 'edge-core-js' +import { Platform } from 'react-native' +import { CustomTabs } from 'react-native-custom-tabs' +import SafariView from 'react-native-safari-view' +import URL from 'url-parse' + +import type { SendScene2Params } from '../../../components/scenes/SendScene2' +import { + showError, + showToast, + showToastSpinner +} from '../../../components/services/AirshipInstance' +import { requestPermissionOnSettings } from '../../../components/services/PermissionsManager' +import { EDGE_CONTENT_SERVER_URI } from '../../../constants/CdnConstants' +import { lstrings } from '../../../locales/strings' +import type { StringMap } from '../../../types/types' +import { CryptoAmount } from '../../../util/CryptoAmount' +import { + getCurrencyCodeMultiplier, + getTokenId +} from '../../../util/CurrencyInfoHelpers' +import { fetchInfo } from '../../../util/network' +import { consify, removeIsoPrefix } from '../../../util/utils' +import { + SendErrorBackPressed, + SendErrorNoTransaction +} from '../../gui/fiatPlugin' +import type { + FiatDirection, + FiatPaymentType, + FiatPluginRegionCode +} from '../../gui/fiatPluginTypes' +import type { + FiatProviderAssetMap, + FiatProviderExactRegions +} from '../../gui/fiatProviderTypes' +import { + addExactRegion, + NOT_SUCCESS_TOAST_HIDE_MS, + RETURN_URL_CANCEL, + RETURN_URL_FAIL, + RETURN_URL_SUCCESS, + validateExactRegion +} from '../../gui/providers/common' +import { addTokenToArray } from '../../gui/util/providerUtils' +import { rampDeeplinkManager, type RampLink } from '../rampDeeplinkHandler' +import type { + RampApproveQuoteParams, + RampCheckSupportRequest, + RampInfo, + RampPlugin, + RampPluginConfig, + RampPluginFactory, + RampQuoteRequest, + RampQuoteResult, + RampSupportResult +} from '../rampPluginTypes' +import { asInitOptions } from './banxaRampTypes' + +const pluginId = 'banxa' +const partnerIcon = `${EDGE_CONTENT_SERVER_URI}/banxa.png` +const pluginDisplayName = 'Banxa' + +const TESTNET_ADDRESS = 'bc1qv752cnr3rcht3yyfq2nn6nv7zwczqjmcm80y6w' + +type AllowedPaymentTypes = Record< + FiatDirection, + Partial> +> + +const allowedPaymentTypes: AllowedPaymentTypes = { + buy: { + applepay: true, + credit: true, + googlepay: true, + ideal: true, + interac: true, + iobank: true, + payid: true, + sepa: false, // Leave this to Bity for now + turkishbank: true + }, + sell: { + directtobank: true, + interac: true, + iobank: true, + payid: true, + sepa: false, // Leave this to Bity for now + turkishbank: true + } +} + +const asBanxaCryptoCoin = asObject({ + coin_code: asString, + blockchains: asArray( + asObject({ + code: asString + }) + ) +}) + +const asBanxaCryptoCoins = asObject({ + data: asObject({ + coins: asArray(asBanxaCryptoCoin) + }) +}) + +const asBanxaFiat = asObject({ + fiat_code: asString +}) + +const asBanxaFiats = asObject({ + data: asObject({ + fiats: asArray(asBanxaFiat) + }) +}) + +const asBanxaTxLimit = asObject({ + fiat_code: asString, + min: asString, + max: asString +}) + +const asBanxaPaymentType = asValue( + 'CLEARJCNSELLFP', + 'CLEARJCNSELLSEPA', + 'CLEARJUNCTION', + 'CLEARJUNCTIONFP', + 'DCINTERAC', + 'DCINTERACSELL', + 'DIRECTCREDIT', + 'DLOCALPIX', + 'DLOCALZAIO', + 'IDEAL', + 'MANUALPAYMENT', + 'MONOOVAPAYID', + 'PRIMERAP', + 'PRIMERCC', + 'WORLDPAYGOOGLE' +) + +const asBanxaStatus = asValue('ACTIVE', 'INACTIVE') + +const asBanxaPaymentMethod = asObject({ + id: asNumber, + paymentType: asMaybe(asBanxaPaymentType), + name: asString, + status: asBanxaStatus, + type: asString, + supported_fiat: asArray(asString), + supported_coin: asArray(asString), + transaction_limits: asArray(asBanxaTxLimit) +}) + +const asBanxaPricesResponse = asObject({ + data: asObject({ + spot_price: asString, + prices: asArray( + asObject({ + payment_method_id: asNumber, + type: asString, + spot_price_fee: asString, + spot_price_including_fee: asString, + coin_amount: asString, + coin_code: asString, + fiat_amount: asString, + fiat_code: asString, + fee_amount: asString, + network_fee: asString + }) + ) + }) +}) + +const asBanxaQuote = asObject({ + id: asString, + checkout_url: asString +}) + +const asBanxaError = asObject({ + errors: asObject({ + title: asString + }) +}) + +const asBanxaQuoteResponse = asEither( + asObject({ + data: asObject({ + order: asBanxaQuote + }) + }), + asBanxaError +) + +const asBanxaOrderStatus = asValue( + 'pendingPayment', + 'waitingPayment', + 'paymentReceived', + 'inProgress', + 'coinTransferred', + 'cancelled', + 'declined', + 'expired', + 'complete', + 'refunded' +) + +const asBanxaOrderResponse = asObject({ + data: asObject({ + order: asObject({ + id: asString, + coin_amount: asNumber, + wallet_address: asMaybe(asString), + wallet_address_tag: asMaybe(asString), + status: asBanxaOrderStatus + }) + }) +}) + +const asBanxaPaymentMethods = asObject({ + data: asObject({ + payment_methods: asArray(asBanxaPaymentMethod) + }) +}) + +const asBanxaCountry = asObject({ + country_code: asString +}) + +const asBanxaCountries = asObject({ + data: asObject({ + countries: asArray(asBanxaCountry) + }) +}) + +const asBanxaState = asObject({ + state_code: asString +}) + +const asBanxaStates = asObject({ + data: asObject({ + states: asArray(asBanxaState) + }) +}) + +// Utility function to ensure fiat currency codes have the 'iso:' prefix +const ensureIsoPrefix = (currencyCode: string): string => { + return currencyCode.startsWith('iso:') ? currencyCode : `iso:${currencyCode}` +} + +interface BanxaPaymentIdLimit { + id: number + type: FiatPaymentType + min: string + max: string +} + +type BanxaPaymentMap = Record< + string, + Record> +> + +type BanxaTxLimit = ReturnType +type BanxaCryptoCoin = ReturnType +type BanxaPaymentType = ReturnType +type BanxaPaymentMethods = ReturnType + +// https://support.banxa.com/en/support/solutions/articles/44002459218-supported-cryptocurrencies-and-blockchains +// This maps the Banxa blockchain codes to Edge pluginIds +const CURRENCY_PLUGINID_MAP: Record = { + 'AVAX-C': 'avalanche', + BCH: 'bitcoincash', + BNB: 'binancechain', + BSC: 'binancesmartchain', + BTC: 'bitcoin', + CELO: 'celo', + DASH: 'dash', + DGB: 'digibyte', + DOGE: 'dogecoin', + DOT: 'polkadot', + EOS: 'eos', + ETC: 'ethereumclassic', + ETH: 'ethereum', + FIL: 'filecoin', + HBAR: 'hedera', + LTC: 'litecoin', + MATIC: 'polygon', + QTUM: 'qtum', + RVN: 'ravencoin', + SOL: 'solana', + SUI: 'sui', + TON: 'ton', + XLM: 'stellar', + XRP: 'ripple', + XTZ: 'tezos' +} + +const COIN_TO_CURRENCY_CODE_MAP: StringMap = { BTC: 'BTC' } + +const asInfoCreateHmacResponse = asObject({ signature: asString }) + +const typeMap: Record = { + CLEARJCNSELLFP: 'fasterpayments', + CLEARJCNSELLSEPA: 'sepa', + CLEARJUNCTION: 'sepa', + CLEARJUNCTIONFP: 'fasterpayments', + DCINTERAC: 'interac', + DCINTERACSELL: 'interac', + DIRECTCREDIT: 'directtobank', + DLOCALPIX: 'pix', + DLOCALZAIO: 'iobank', + IDEAL: 'ideal', + MANUALPAYMENT: 'turkishbank', + MONOOVAPAYID: 'payid', + PRIMERAP: 'applepay', + PRIMERCC: 'credit', + WORLDPAYGOOGLE: 'googlepay' +} + +// Provider configuration cache +interface ProviderConfigCache { + data: { + allowedCountryCodes: FiatProviderExactRegions + allowedCurrencyCodes: Record + banxaPaymentsMap: Record + } | null + timestamp: number +} + +const CACHE_TTL_MS = 2 * 60 * 1000 // 2 minutes +let configCache: ProviderConfigCache = { + data: null, + timestamp: 0 +} + +// Helper functions + +// Validation helpers that return boolean values and handle errors gracefully +const isRegionSupported = ( + regionCode: FiatPluginRegionCode, + allowedCountryCodes: FiatProviderExactRegions +): boolean => { + try { + validateExactRegion(pluginId, regionCode, allowedCountryCodes) + return true + } catch (error) { + return false + } +} + +const isCryptoAssetSupported = ( + pluginId: string, + direction: FiatDirection, + tokenId: EdgeTokenId, + allowedCurrencyCodes: Record +): boolean => { + try { + edgeToBanxaCrypto(pluginId, direction, tokenId, allowedCurrencyCodes) + return true + } catch (error) { + return false + } +} + +const isFiatSupported = ( + direction: FiatDirection, + fiatCurrencyCode: string, + allowedCurrencyCodes: Record +): boolean => { + try { + const fiatAssets = allowedCurrencyCodes[direction].fiat + return fiatAssets[fiatCurrencyCode] === true + } catch (error) { + return false + } +} + +const hasAnyPaymentTypeSupport = (direction: FiatDirection): boolean => { + try { + const supportedPaymentTypes = Object.keys( + allowedPaymentTypes[direction] + ).filter( + pt => allowedPaymentTypes[direction][pt as FiatPaymentType] === true + ) as FiatPaymentType[] + + return supportedPaymentTypes.length > 0 + } catch (error) { + return false + } +} + +const generateHmac = async ( + apiKey: string, + hmacUser: string, + data: string, + nonce: string +): Promise => { + const body = JSON.stringify({ data }) + const response = await fetchInfo( + `v1/createHmac/${hmacUser}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body + }, + 3000 + ) + const reply = await response.json() + const { signature } = asInfoCreateHmacResponse(reply) + + return `${apiKey}:${signature}:${nonce}` +} + +const banxaFetch = async (params: { + method: 'POST' | 'GET' + url: string + path: string + apiKey: string + hmacUser: string + bodyParams?: object + queryParams?: object +}): Promise => { + const { hmacUser, method, url, path, apiKey, bodyParams, queryParams } = + params + const urlObj = new URL(url + '/' + path, true) + const body = bodyParams != null ? JSON.stringify(bodyParams) : undefined + + if (method === 'GET' && typeof queryParams === 'object') { + urlObj.set('query', queryParams) + } + + const hmacpath = urlObj.href.replace(urlObj.origin + '/', '') + + const nonce = Date.now().toString() + let hmacData = method + '\n' + hmacpath + '\n' + nonce + hmacData += method === 'POST' ? '\n' + (body ?? '') : '' + + const hmac = await generateHmac(apiKey, hmacUser, hmacData, nonce) + const options = { + method, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${hmac}` + }, + body + } + const response = await fetch(urlObj.href, options) + const reply = await response.json() + return reply +} + +const findLimit = ( + fiatCode: string, + banxaLimits: BanxaTxLimit[] +): BanxaTxLimit | undefined => { + for (const limit of banxaLimits) { + if (limit.fiat_code === fiatCode) { + return limit + } + } +} + +const buildPaymentsMap = ( + banxaPayments: BanxaPaymentMethods, + banxaPaymentsMap: BanxaPaymentMap +): void => { + const { payment_methods: methods } = banxaPayments.data + for (const pm of methods) { + const { paymentType } = pm + if (paymentType == null) continue + const pt = typeMap[paymentType] + if (pm.status !== 'ACTIVE') { + continue + } + if (pt != null) { + for (const fiat of pm.supported_fiat) { + banxaPaymentsMap[fiat] ??= {} + for (const coin of pm.supported_coin) { + banxaPaymentsMap[fiat][coin] ??= {} + + const limit = findLimit(fiat, pm.transaction_limits) + if (limit == null) { + console.error( + `Missing limits for id:${pm.id} ${pm.paymentType} ${fiat}` + ) + } else { + const newMap: BanxaPaymentIdLimit = { + id: pm.id, + min: limit.min, + max: limit.max, + type: pt + } + if (banxaPaymentsMap[fiat][coin][pm.id] != null) { + if ( + JSON.stringify(banxaPaymentsMap[fiat][coin][pm.id]) !== + JSON.stringify(newMap) + ) { + console.error( + `Payment already exists with different values: ${fiat} ${coin} ${pt}` + ) + continue + } + } + banxaPaymentsMap[fiat][coin][pm.id] = newMap + } + } + } + } + } +} + +const getPaymentIdLimit = ( + direction: FiatDirection, + fiat: string, + banxaCoin: string, + type: FiatPaymentType, + banxaPaymentsMap: Record +): BanxaPaymentIdLimit | undefined => { + try { + const payments = banxaPaymentsMap[direction][fiat][banxaCoin] + const paymentId = Object.values(payments).find(p => p.type === type) + return paymentId + } catch (e) {} +} + +// Takes an EdgeAsset and returns the corresponding Banxa chain code and coin code +const edgeToBanxaCrypto = ( + pluginId: string, + direction: FiatDirection, + tokenId: EdgeTokenId, + allowedCurrencyCodes: Record +): { banxaChain: string; banxaCoin: string } => { + const tokens = allowedCurrencyCodes[direction].crypto[pluginId] + if (tokens == null) + throw new Error(`edgeToBanxaCrypto ${pluginId} not allowed`) + const providerToken = tokens.find(t => t.tokenId === tokenId) + const banxaCoin = asBanxaCryptoCoin(providerToken?.otherInfo) + if (banxaCoin == null) + throw new Error(`edgeToBanxaCrypto ${pluginId} ${tokenId} not allowed`) + for (const chain of banxaCoin.blockchains) { + const edgePluginId = CURRENCY_PLUGINID_MAP[chain.code] + if (edgePluginId === pluginId) { + return { banxaChain: chain.code, banxaCoin: banxaCoin.coin_code } + } + } + throw new Error(`edgeToBanxaCrypto No matching pluginId ${pluginId}`) +} + +export const banxaRampPlugin: RampPluginFactory = ( + config: RampPluginConfig +): RampPlugin => { + const { apiKey, hmacUser, apiUrl } = asInitOptions(config.initOptions) + const { account, navigation, onLogEvent, disklet } = config + + let testnet = false + if (apiUrl.includes('sandbox')) { + testnet = true + CURRENCY_PLUGINID_MAP.BTC = 'bitcointestnet' + COIN_TO_CURRENCY_CODE_MAP.BTC = 'TESTBTC' + } + + let banxaUsername: string | undefined + + const rampInfo: RampInfo = { + partnerIcon, + pluginDisplayName + } + + const initializeBanxaUsername = async (): Promise => { + if (banxaUsername != null) return banxaUsername + + if (config.store != null) { + banxaUsername = await config.store + .getItem('username') + .catch(() => undefined) + if (banxaUsername == null || banxaUsername === '') { + banxaUsername = + config.makeUuid != null + ? await config.makeUuid() + : `banxa-user-${Date.now()}` + await config.store.setItem('username', banxaUsername) + } + } else { + banxaUsername = `banxa-user-${Date.now()}` + } + return banxaUsername + } + + const addToAllowedCurrencies = ( + pluginId: string, + direction: FiatDirection, + currencyCode: string, + coin: BanxaCryptoCoin, + allowedCurrencyCodes: Record + ): void => { + let tokenId: EdgeTokenId = null + const currencyConfig = account.currencyConfig[pluginId] + if (currencyConfig != null) { + const resolvedTokenId = getTokenId(currencyConfig, currencyCode) + if (resolvedTokenId !== undefined) { + tokenId = resolvedTokenId + } + } + + allowedCurrencyCodes[direction].crypto[pluginId] ??= [] + const tokens = allowedCurrencyCodes[direction].crypto[pluginId] + addTokenToArray({ tokenId, otherInfo: coin }, tokens) + } + + const fetchProviderConfig = async (): Promise<{ + allowedCountryCodes: FiatProviderExactRegions + allowedCurrencyCodes: Record + banxaPaymentsMap: Record + }> => { + const now = Date.now() + + // Check if cache is valid + if ( + configCache.data != null && + now - configCache.timestamp < CACHE_TTL_MS + ) { + return configCache.data + } + + // Initialize empty data structures + const allowedCountryCodes: FiatProviderExactRegions = {} + const allowedCurrencyCodes: Record = { + buy: { providerId: pluginId, fiat: {}, crypto: {} }, + sell: { providerId: pluginId, fiat: {}, crypto: {} } + } + const banxaPaymentsMap: Record = { + buy: {}, + sell: {} + } + + // Fetch configuration in parallel + const promises = [ + // Fetch countries + banxaFetch({ + method: 'GET', + url: apiUrl, + hmacUser, + path: 'api/countries', + apiKey + }).then(response => { + const countries = asBanxaCountries(response) + for (const { country_code: countryCode } of countries.data.countries) { + if (countryCode !== 'US') { + addExactRegion(allowedCountryCodes, countryCode) + } + } + }), + + // Fetch US states + banxaFetch({ + method: 'GET', + url: apiUrl, + hmacUser, + path: 'api/countries/us/states', + apiKey + }).then(response => { + const states = asBanxaStates(response) + for (const { state_code: stateCode } of states.data.states) { + addExactRegion(allowedCountryCodes, 'US', stateCode) + } + }), + + // Fetch sell crypto + banxaFetch({ + method: 'GET', + url: apiUrl, + hmacUser, + path: `api/coins/sell`, + apiKey + }).then(response => { + const cryptoCurrencies = asBanxaCryptoCoins(response) + for (const coin of cryptoCurrencies.data.coins) { + for (const chain of coin.blockchains) { + const currencyPluginId = CURRENCY_PLUGINID_MAP[chain.code] + if (currencyPluginId != null) { + const edgeCurrencyCode = + COIN_TO_CURRENCY_CODE_MAP[coin.coin_code] ?? coin.coin_code + addToAllowedCurrencies( + currencyPluginId, + 'sell', + edgeCurrencyCode, + coin, + allowedCurrencyCodes + ) + } + } + } + }), + + // Fetch sell fiat + banxaFetch({ + method: 'GET', + url: apiUrl, + hmacUser, + path: `api/fiats/sell`, + apiKey + }).then(response => { + const fiatCurrencies = asBanxaFiats(response) + for (const fiat of fiatCurrencies.data.fiats) { + allowedCurrencyCodes.sell.fiat['iso:' + fiat.fiat_code] = true + } + }), + + // Fetch buy crypto + banxaFetch({ + method: 'GET', + url: apiUrl, + hmacUser, + path: `api/coins/buy`, + apiKey + }).then(response => { + const cryptoCurrencies = asBanxaCryptoCoins(response) + for (const coin of cryptoCurrencies.data.coins) { + for (const chain of coin.blockchains) { + const currencyPluginId = CURRENCY_PLUGINID_MAP[chain.code] + if (currencyPluginId != null) { + const edgeCurrencyCode = + COIN_TO_CURRENCY_CODE_MAP[coin.coin_code] ?? coin.coin_code + addToAllowedCurrencies( + currencyPluginId, + 'buy', + edgeCurrencyCode, + coin, + allowedCurrencyCodes + ) + } + } + } + }), + + // Fetch buy fiat + banxaFetch({ + method: 'GET', + url: apiUrl, + hmacUser, + path: `api/fiats/buy`, + apiKey + }).then(response => { + const fiatCurrencies = asBanxaFiats(response) + for (const fiat of fiatCurrencies.data.fiats) { + allowedCurrencyCodes.buy.fiat['iso:' + fiat.fiat_code] = true + } + }), + + // Fetch buy payment methods + banxaFetch({ + method: 'GET', + url: apiUrl, + hmacUser, + path: 'api/payment-methods', + apiKey + }).then(response => { + const banxaPayments = asBanxaPaymentMethods(response) + buildPaymentsMap(banxaPayments, banxaPaymentsMap.buy) + }), + + // Fetch sell payment methods (with BTC hack for better coverage) + banxaFetch({ + method: 'GET', + url: apiUrl, + hmacUser, + path: 'api/payment-methods?source=BTC', + apiKey + }).then(response => { + const banxaPayments = asBanxaPaymentMethods(response) + buildPaymentsMap(banxaPayments, banxaPaymentsMap.sell) + }) + ] + + await Promise.all(promises) + + // Update cache + const newConfig = { + allowedCountryCodes, + allowedCurrencyCodes, + banxaPaymentsMap + } + + configCache = { + data: newConfig, + timestamp: now + } + + return newConfig + } + + const plugin: RampPlugin = { + pluginId, + rampInfo, + + checkSupport: async ( + request: RampCheckSupportRequest + ): Promise => { + try { + const config = await fetchProviderConfig() + const { allowedCountryCodes, allowedCurrencyCodes } = config + + // Check region support + if (!isRegionSupported(request.regionCode, allowedCountryCodes)) { + return { supported: false } + } + + // Check if any payment types are supported for this direction + if (!hasAnyPaymentTypeSupport(request.direction)) { + return { supported: false } + } + + // Check fiat support + const fiatCurrencyCode = ensureIsoPrefix(request.fiatAsset.currencyCode) + if ( + !isFiatSupported( + request.direction, + fiatCurrencyCode, + allowedCurrencyCodes + ) + ) { + return { supported: false } + } + + // Check crypto asset support + if ( + !isCryptoAssetSupported( + request.cryptoAsset.pluginId, + request.direction, + request.cryptoAsset.tokenId, + allowedCurrencyCodes + ) + ) { + return { supported: false } + } + + return { supported: true } + } catch (error) { + console.error('Banxa: Error in checkSupport:', error) + return { supported: false } + } + }, + + fetchQuote: async ( + request: RampQuoteRequest + ): Promise => { + const { + direction, + regionCode, + exchangeAmount, + amountType, + pluginId: currencyPluginId, + fiatCurrencyCode, + displayCurrencyCode, + tokenId + } = request + + const isMaxAmount = + typeof exchangeAmount === 'object' && exchangeAmount.max + const exchangeAmountString = isMaxAmount ? '' : (exchangeAmount as string) + + try { + // Fetch provider configuration (cached or fresh) + const config = await fetchProviderConfig() + const { allowedCountryCodes, allowedCurrencyCodes, banxaPaymentsMap } = + config + + // Validate region + if (!isRegionSupported(regionCode, allowedCountryCodes)) { + return [] + } + + // Check if any payment types are supported for this direction + if (!hasAnyPaymentTypeSupport(direction)) { + return [] + } + + // Check if fiat is supported + const isoFiatCurrencyCode = ensureIsoPrefix(fiatCurrencyCode) + if ( + !isFiatSupported(direction, isoFiatCurrencyCode, allowedCurrencyCodes) + ) { + return [] + } + + // Check if crypto is supported and get the mapping + if ( + !isCryptoAssetSupported( + currencyPluginId, + direction, + tokenId, + allowedCurrencyCodes + ) + ) { + return [] + } + + // Get supported payment types for this direction + const supportedPaymentTypes = Object.keys( + allowedPaymentTypes[direction] + ).filter( + pt => allowedPaymentTypes[direction][pt as FiatPaymentType] === true + ) as FiatPaymentType[] + + // Get the crypto mapping (we know it's supported at this point) + const fiatCode = removeIsoPrefix(isoFiatCurrencyCode) + let banxaChain: string + let banxaCoin: string + + try { + const banxaCrypto = edgeToBanxaCrypto( + currencyPluginId, + direction, + tokenId, + allowedCurrencyCodes + ) + banxaChain = banxaCrypto.banxaChain + banxaCoin = banxaCrypto.banxaCoin + } catch (error) { + // This shouldn't happen since we already validated support above + return [] + } + + // Initialize username + const username = await initializeBanxaUsername() + + // Collect quotes for all payment types + const quotes: RampQuoteResult[] = [] + + for (const paymentType of supportedPaymentTypes) { + try { + // Find payment method for this type + let paymentObj: BanxaPaymentIdLimit | undefined + let hasFetched = false + + while (true) { + paymentObj = getPaymentIdLimit( + direction, + fiatCode, + banxaCoin, + paymentType, + banxaPaymentsMap + ) + + if (paymentObj != null) break + + // If buying, all payment methods were already queried + if (direction === 'buy' || hasFetched) { + break // Skip this payment type + } + + // For sell, fetch payment methods for specific crypto + const pmResponse = await banxaFetch({ + method: 'GET', + url: apiUrl, + hmacUser, + path: `api/payment-methods?source=${banxaCoin}`, + apiKey + }) + const banxaPayments = asBanxaPaymentMethods(pmResponse) + buildPaymentsMap(banxaPayments, banxaPaymentsMap.sell) + hasFetched = true + } + + if (paymentObj == null) continue // Skip unsupported payment type + + // Check limits + const checkMinMax = ( + amount: string, + paymentIdLimit: BanxaPaymentIdLimit + ): boolean => { + if ( + gt(amount, paymentIdLimit.max) || + lt(amount, paymentIdLimit.min) + ) { + return false + } + return true + } + + // Build query parameters + const queryParams: any = { + account_reference: username, + payment_method_id: paymentObj.id + } + + let maxAmountString = '' + if (isMaxAmount) { + if (amountType === 'fiat') { + maxAmountString = paymentObj.max + } else { + // For crypto, we need to fetch a quote with max fiat to get the crypto amount + const maxFiatQueryParams: any = { + account_reference: username, + payment_method_id: paymentObj.id, + source: direction === 'buy' ? fiatCode : banxaCoin, + target: direction === 'buy' ? banxaCoin : fiatCode + } + if (direction === 'buy') { + maxFiatQueryParams.source_amount = paymentObj.max + } else { + maxFiatQueryParams.target_amount = paymentObj.max + } + const maxResponse = await banxaFetch({ + method: 'GET', + url: apiUrl, + hmacUser, + path: 'api/prices', + apiKey, + queryParams: maxFiatQueryParams + }) + const maxPrices = asBanxaPricesResponse(maxResponse) + maxAmountString = maxPrices.data.prices[0].coin_amount + } + } + + if (direction === 'buy') { + queryParams.source = fiatCode + queryParams.target = banxaCoin + if (amountType === 'fiat') { + queryParams.source_amount = isMaxAmount + ? maxAmountString + : exchangeAmountString + if ( + !isMaxAmount && + !checkMinMax(exchangeAmountString, paymentObj) + ) { + if (gt(exchangeAmountString, paymentObj.max)) { + console.warn( + `Banxa: ${paymentType} over limit for ${fiatCode}: ${exchangeAmountString} > ${paymentObj.max}` + ) + } else if (lt(exchangeAmountString, paymentObj.min)) { + console.warn( + `Banxa: ${paymentType} under limit for ${fiatCode}: ${exchangeAmountString} < ${paymentObj.min}` + ) + } + continue + } + } else { + queryParams.target_amount = isMaxAmount + ? maxAmountString + : exchangeAmountString + } + } else { + queryParams.source = banxaCoin + queryParams.target = fiatCode + if (amountType === 'fiat') { + queryParams.target_amount = isMaxAmount + ? maxAmountString + : exchangeAmountString + if ( + !isMaxAmount && + !checkMinMax(exchangeAmountString, paymentObj) + ) { + if (gt(exchangeAmountString, paymentObj.max)) { + console.warn( + `Banxa: ${paymentType} over limit for ${fiatCode}: ${exchangeAmountString} > ${paymentObj.max}` + ) + } else if (lt(exchangeAmountString, paymentObj.min)) { + console.warn( + `Banxa: ${paymentType} under limit for ${fiatCode}: ${exchangeAmountString} < ${paymentObj.min}` + ) + } + continue + } + } else { + queryParams.source_amount = isMaxAmount + ? maxAmountString + : exchangeAmountString + } + } + + // Fetch price quote + const response = await banxaFetch({ + method: 'GET', + url: apiUrl, + hmacUser, + path: 'api/prices', + apiKey, + queryParams + }) + const banxaPrices = asBanxaPricesResponse(response) + const priceQuote = banxaPrices.data.prices[0] + console.log('Got Banxa Quote:') + consify(priceQuote) + + // Check final amounts against limits + if (!checkMinMax(priceQuote.fiat_amount, paymentObj)) { + if (gt(priceQuote.fiat_amount, paymentObj.max)) { + console.warn( + `Banxa: ${paymentType} over limit for ${fiatCode}: ${priceQuote.fiat_amount} > ${paymentObj.max}` + ) + } else if (lt(priceQuote.fiat_amount, paymentObj.min)) { + console.warn( + `Banxa: ${paymentType} under limit for ${fiatCode}: ${priceQuote.fiat_amount} < ${paymentObj.min}` + ) + } + continue + } + + // Create quote result + const quote: RampQuoteResult = { + pluginId, + partnerIcon, + pluginDisplayName, + displayCurrencyCode, + cryptoAmount: priceQuote.coin_amount, + isEstimate: false, + fiatCurrencyCode, + fiatAmount: priceQuote.fiat_amount, + direction, + regionCode, + paymentType, + expirationDate: new Date(Date.now() + 50000), + settlementRange: { + min: { value: 5, unit: 'minutes' }, + max: { value: 24, unit: 'hours' } + }, + approveQuote: async ( + approveParams: RampApproveQuoteParams + ): Promise => { + const { coreWallet } = approveParams + const deniedPermission = await requestPermissionOnSettings( + disklet, + 'camera', + pluginDisplayName, + true + ) + if (deniedPermission) { + showToast( + lstrings.fiat_plugin_cannot_continue_camera_permission + ) + return + } + // TODO: getReceiveAddress is deprecated but no replacement API exists yet. + // This method is still required to get wallet addresses for ramp providers. + // Once edge-core-js provides a replacement API, this should be updated. + // Tracked for future update when core team provides alternative. + const receiveAddress = await coreWallet.getReceiveAddress({ + tokenId: null + }) + + const bodyParams: any = { + payment_method_id: paymentObj?.id ?? '', + account_reference: username, + source: queryParams.source, + target: queryParams.target, + blockchain: banxaChain, + return_url_on_success: + direction === 'buy' + ? `https://deep.edge.app/fiatprovider/buy/banxa?status=success` + : RETURN_URL_SUCCESS, + return_url_on_cancelled: + direction === 'buy' + ? `https://deep.edge.app/fiatprovider/buy/banxa?status=cancelled` + : RETURN_URL_CANCEL, + return_url_on_failure: + direction === 'buy' + ? `https://deep.edge.app/fiatprovider/buy/banxa?status=failure` + : RETURN_URL_FAIL + } + if (direction === 'buy') { + if (testnet && banxaChain === 'BTC') { + bodyParams.wallet_address = TESTNET_ADDRESS + } else { + bodyParams.wallet_address = receiveAddress.publicAddress + } + } else { + if (testnet && banxaChain === 'BTC') { + bodyParams.refund_address = TESTNET_ADDRESS + } else { + bodyParams.refund_address = receiveAddress.publicAddress + } + } + + if (queryParams.source_amount != null) { + bodyParams.source_amount = queryParams.source_amount + } else { + bodyParams.target_amount = queryParams.target_amount + } + + const promise = banxaFetch({ + method: 'POST', + url: apiUrl, + hmacUser, + path: 'api/orders', + apiKey, + bodyParams + }) + const response = await showToastSpinner( + lstrings.fiat_plugin_finalizing_quote, + promise + ) + const banxaQuote = asBanxaQuoteResponse(response) + + if ('errors' in banxaQuote) { + throw new Error(banxaQuote.errors.title) + } + + let interval: ReturnType | undefined + let insideInterval = false + + if (direction === 'buy') { + // Register deeplink handler + rampDeeplinkManager.register( + direction, + pluginId, + async (link: RampLink): Promise => { + const orderResponse = await banxaFetch({ + method: 'GET', + url: apiUrl, + hmacUser, + path: `api/orders/${banxaQuote.data.order.id}`, + apiKey + }) + const order = asBanxaOrderResponse(orderResponse) + // Banxa will incorrectly add their query string parameters + // to the url with a simple concatenation of '?orderId=...', + // and this will break our query string. + const status = link.query.status?.replace('?', '') + + switch (status) { + case 'success': { + onLogEvent('Buy_Success', { + conversionValues: { + conversionType: 'buy', + sourceFiatCurrencyCode: fiatCurrencyCode, + sourceFiatAmount: priceQuote.fiat_amount, + destAmount: new CryptoAmount({ + currencyConfig: coreWallet.currencyConfig, + currencyCode: displayCurrencyCode, + exchangeAmount: order.data.order.coin_amount + }), + fiatProviderId: pluginId, + orderId: banxaQuote.data.order.id + } + }) + navigation.pop() + break + } + case 'cancelled': { + console.log( + 'Banxa WebView launch buy cancelled: ' + link.uri + ) + showToast( + lstrings.fiat_plugin_buy_cancelled, + NOT_SUCCESS_TOAST_HIDE_MS + ) + navigation.pop() + break + } + case 'failure': { + console.log( + 'Banxa WebView launch buy failure: ' + link.uri + ) + showToast( + lstrings.fiat_plugin_buy_failed_try_again, + NOT_SUCCESS_TOAST_HIDE_MS + ) + navigation.pop() + break + } + default: { + showToast( + lstrings.fiat_plugin_buy_unknown_status, + NOT_SUCCESS_TOAST_HIDE_MS + ) + navigation.pop() + } + } + } + ) + + // Open external webview + const checkoutUrl = banxaQuote.data.order.checkout_url + if (Platform.OS === 'ios') { + await SafariView.show({ url: checkoutUrl }) + } else { + await CustomTabs.openURL(checkoutUrl) + } + } else { + // Sell flow with internal webview + const { checkout_url: checkoutUrl, id } = + banxaQuote.data.order + const banxaUrl = new URL(checkoutUrl) + const { origin: banxaOrigin } = banxaUrl + + navigation.navigate('guiPluginWebView', { + url: checkoutUrl, + onClose: () => { + clearInterval(interval) + }, + onUrlChange: async (changeUrl: string): Promise => { + console.log(`onUrlChange url=${changeUrl}`) + if (changeUrl === RETURN_URL_SUCCESS) { + clearInterval(interval) + navigation.pop() + } else if (changeUrl === RETURN_URL_CANCEL) { + clearInterval(interval) + showToast( + lstrings.fiat_plugin_sell_cancelled, + NOT_SUCCESS_TOAST_HIDE_MS + ) + navigation.pop() + } else if (changeUrl === RETURN_URL_FAIL) { + clearInterval(interval) + showToast( + lstrings.fiat_plugin_sell_failed_try_again, + NOT_SUCCESS_TOAST_HIDE_MS + ) + navigation.pop() + } else if ( + changeUrl.startsWith(`${banxaOrigin}/status/`) + ) { + interval ??= setInterval(() => { + checkOrderStatus().catch((err: unknown) => { + console.warn( + `Failed to check banxa order status: ${String( + err + )}` + ) + }) + }, 3000) + async function checkOrderStatus(): Promise { + try { + if (insideInterval) return + insideInterval = true + const orderResponse = await banxaFetch({ + method: 'GET', + url: apiUrl, + hmacUser, + path: `api/orders/${id}`, + apiKey + }) + const order = asBanxaOrderResponse(orderResponse) + const { + coin_amount: coinAmount, + status, + wallet_address: publicAddress + } = order.data.order + const nativeAmount = mul( + coinAmount.toString(), + getCurrencyCodeMultiplier( + coreWallet.currencyConfig, + displayCurrencyCode + ) + ) + if (status === 'waitingPayment') { + // Launch the SendScene to make payment + const sendParams: SendScene2Params = { + walletId: coreWallet.id, + tokenId, + spendInfo: { + tokenId, + spendTargets: [ + { + nativeAmount, + publicAddress + } + ] + }, + lockTilesMap: { + address: true, + amount: true, + wallet: true + }, + hiddenFeaturesMap: { + address: true + } + } + + // Navigate to send scene + const edgeTx = await new Promise( + (resolve, reject) => { + navigation.navigate('send2', { + ...sendParams, + onDone: (error: unknown, tx?: any) => { + if (error != null) { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + reject(error) + } else if (tx != null) { + resolve(tx) + } else { + reject( + new Error(SendErrorNoTransaction) + ) + } + }, + onBack: () => { + reject(new Error(SendErrorBackPressed)) + } + }) + } + ) + + // At this point we'll call it success + clearInterval(interval) + interval = undefined + + onLogEvent('Sell_Success', { + conversionValues: { + conversionType: 'sell', + destFiatCurrencyCode: fiatCurrencyCode, + destFiatAmount: priceQuote.fiat_amount, + sourceAmount: new CryptoAmount({ + currencyConfig: coreWallet.currencyConfig, + currencyCode: displayCurrencyCode, + exchangeAmount: coinAmount + }), + fiatProviderId: pluginId, + orderId: id + } + }) + + // Below is an optional step + const { txid } = edgeTx + // Post the txid back to Banxa + const bodyParams = { + tx_hash: txid, + source_address: receiveAddress.publicAddress, + destination_address: publicAddress + } + await banxaFetch({ + method: 'POST', + url: apiUrl, + hmacUser, + path: `api/orders/${id}/confirm`, + apiKey, + bodyParams + }).catch((e: unknown) => { + console.error(String(e)) + }) + } + insideInterval = false + } catch (e: unknown) { + if ( + e instanceof Error && + e.message === SendErrorBackPressed + ) { + navigation.pop() + } else if ( + e instanceof Error && + e.message === SendErrorNoTransaction + ) { + navigation.pop() + showToast( + lstrings.fiat_plugin_sell_failed_to_send_try_again + ) + } else { + showError(e) + } + insideInterval = false + } + } + } + } + }) + } + }, + closeQuote: async (): Promise => {} + } + + quotes.push(quote) + } catch (error) { + console.warn( + `Banxa: Failed to get quote for ${paymentType}:`, + error + ) + // Continue with other payment types + } + } + + return quotes + } catch (error) { + console.error('Banxa: Error in fetchQuote:', error) + // Return empty array for any errors + return [] + } + } + } + + return plugin +} diff --git a/src/plugins/ramps/banxa/banxaRampTypes.ts b/src/plugins/ramps/banxa/banxaRampTypes.ts new file mode 100644 index 00000000000..b634e561166 --- /dev/null +++ b/src/plugins/ramps/banxa/banxaRampTypes.ts @@ -0,0 +1,8 @@ +import { asObject, asOptional, asString } from 'cleaners' + +// Init options cleaner for banxa ramp plugin +export const asInitOptions = asObject({ + apiKey: asString, + hmacUser: asString, + apiUrl: asOptional(asString, 'https://edge3.banxa.com') +}) diff --git a/src/plugins/ramps/bity/bityRampPlugin.ts b/src/plugins/ramps/bity/bityRampPlugin.ts new file mode 100644 index 00000000000..031ba354f6e --- /dev/null +++ b/src/plugins/ramps/bity/bityRampPlugin.ts @@ -0,0 +1,1345 @@ +import { gt, lt, mul, toFixed } from 'biggystring' +import { + asArray, + asEither, + asMaybe, + asNumber, + asObject, + asOptional, + asString, + asValue +} from 'cleaners' +import type { + EdgeAccount, + EdgeCurrencyWallet, + EdgeSpendInfo, + EdgeTokenId, + EdgeTransaction +} from 'edge-core-js' +import { sprintf } from 'sprintf-js' + +import type { SendScene2Params } from '../../../components/scenes/SendScene2' +import { showError } from '../../../components/services/AirshipInstance' +import { EDGE_CONTENT_SERVER_URI } from '../../../constants/CdnConstants' +import { lstrings } from '../../../locales/strings' +import type { HomeAddress, SepaInfo } from '../../../types/FormTypes' +import type { StringMap } from '../../../types/types' +import { + getCurrencyCodeMultiplier, + getTokenId +} from '../../../util/CurrencyInfoHelpers' +import { utf8 } from '../../../util/encoding' +import { removeIsoPrefix } from '../../../util/utils' +import { + SendErrorBackPressed, + SendErrorNoTransaction +} from '../../gui/fiatPlugin' +import type { + FiatDirection, + FiatPaymentType, + FiatPluginRegionCode +} from '../../gui/fiatPluginTypes' +import type { + FiatProviderAssetMap, + ProviderToken +} from '../../gui/fiatProviderTypes' +import { addTokenToArray } from '../../gui/util/providerUtils' +import type { + RampApproveQuoteParams, + RampCheckSupportRequest, + RampInfo, + RampPlugin, + RampPluginConfig, + RampQuoteRequest, + RampQuoteResult, + RampSupportResult +} from '../rampPluginTypes' +import { asInitOptions } from './bityRampTypes' + +const pluginId = 'bity' +const partnerIcon = `${EDGE_CONTENT_SERVER_URI}/logoBity.png` +const pluginDisplayName = 'Bity' +const providerDisplayName = pluginDisplayName +const supportEmail = 'support_edge@bity.com' +const supportedPaymentType: FiatPaymentType = 'sepa' +const partnerFee = 0.005 + +// Default Edge client ID for backward compatibility +const EDGE_CLIENT_ID = '4949bf59-c23c-4d71-949e-f5fd56ff815b' + +const noKycCurrencyCodes: Record = { + buy: { + providerId: pluginId, + fiat: {}, + crypto: { + bitcoin: [{ tokenId: null }], + ethereum: [{ tokenId: null }], + litecoin: [{ tokenId: null }] + } + }, + sell: { + providerId: pluginId, + fiat: {}, + crypto: { + bitcoin: [{ tokenId: null }], + // Add USDT and USDC for no-KYC sell + ethereum: [ + { tokenId: null }, + { tokenId: 'dac17f958d2ee523a2206206994597c13d831ec7' }, + { tokenId: 'a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' } + ] + } + } +} + +const supportedRegionCodes = [ + 'AT', + 'BE', + 'BG', + 'CH', + 'CZ', + 'DK', + 'EE', + 'FI', + 'FR', + 'DE', + 'GR', + 'HU', + 'IE', // Ireland + 'IT', + 'LV', + 'LT', + 'LU', + 'NL', + 'PL', + 'PT', + 'RO', + 'SK', + 'SI', + 'ES', + 'SE', + 'HR', + 'LI', + 'NO', + 'SM', + 'GB' +] + +const CURRENCY_PLUGINID_MAP: StringMap = { + BTC: 'bitcoin', + ETH: 'ethereum', + LTC: 'litecoin', + USDC: 'ethereum', + USDT: 'ethereum' +} + +const asBityCurrencyTag = asValue('crypto', 'erc20', 'ethereum', 'fiat') +const asBityCurrency = asObject({ + tags: asArray(asBityCurrencyTag), + code: asString, + max_digits_in_decimal_part: asNumber +}) +const asBityCurrencyResponse = asObject({ currencies: asArray(asBityCurrency) }) + +const asBityError = asObject({ code: asString, message: asString }) +const asBityErrorResponse = asObject({ errors: asArray(asBityError) }) + +// Main cleaner for the input object +const asInputObject = asObject({ + amount: asString, + currency: asString, + minimum_amount: asOptional(asString) +}) + +// Main cleaner for the output object +const asOutputObject = asObject({ + amount: asString, + currency: asString, + minimum_amount: asOptional(asString) +}) + +// Complete data cleaner +const asBityQuote = asObject({ + input: asInputObject, + output: asOutputObject +}) + +export type BityCurrency = ReturnType +export type BityCurrencyTag = ReturnType + +interface BityQuoteRequest { + input: { + amount?: string + currency: string + } + output: { + amount?: string + currency: string + } +} + +interface BityBuyOrderRequest { + client_value: number + input: { + amount: string + currency: string + type: 'bank_account' + iban: string + bic_swift: string + } + output: { + currency: string + type: 'crypto_address' + crypto_address: string + } + partner_fee: { factor: number } +} + +interface BitySellOrderRequest { + client_value: number + input: { + amount: string + currency: string + type: 'crypto_address' + crypto_address: string + } + output: { + currency: string + type: 'bank_account' + iban: string + bic_swift: string + owner: { + name: string + street_name: string + building_number: string + town_name: string + country: string + country_subdivision: string + post_code: string + } + } + partner_fee: { factor: number } +} + +const asBitySellApproveQuoteResponse = asObject({ + id: asString, + input: asObject({ + amount: asString, + currency: asString, + crypto_address: asString + }), + output: asObject({ + amount: asString, + currency: asString + }), + payment_details: asObject({ + crypto_address: asString, + type: asValue('crypto_address') + }) +}) + +const asBityBuyApproveQuoteResponse = asObject({ + id: asString, + input: asObject({ + amount: asString, + currency: asString + }), + output: asObject({ + amount: asString, + currency: asString, + crypto_address: asString + }), + payment_details: asObject({ + iban: asString, + swift_bic: asString, + reference: asString, + recipient_name: asString, + recipient: asString + }) +}) + +const asBityApproveQuoteResponse = asEither( + asBityBuyApproveQuoteResponse, + asBitySellApproveQuoteResponse +) + +type BityApproveQuoteResponse = ReturnType + +class BityError extends Error { + code: string + constructor(message: string, code: string) { + super(message) + this.code = code + } +} + +/** + * Ensures that a fiat currency code has the 'iso:' prefix. + * If the code already has the prefix, it returns the code unchanged. + * Otherwise, it adds the 'iso:' prefix. + */ +const ensureIsoPrefix = (currencyCode: string): string => { + return currencyCode.startsWith('iso:') ? currencyCode : `iso:${currencyCode}` +} + +// Provider configuration cache +interface BityConfigCache { + data: { + currencies: BityCurrency[] + } | null + timestamp: number +} + +const CACHE_TTL_MS = 60 * 60 * 1000 // 1 hour +let configCache: BityConfigCache = { data: null, timestamp: 0 } + +// Supported assets cache +interface SupportedAssetsCache { + fiat: Set + crypto: Map +} + +const supportedAssetsCache: SupportedAssetsCache = { + fiat: new Set(), + crypto: new Map() +} + +const fetchBityQuote = async ( + bodyData: BityQuoteRequest, + apiUrl: string +): Promise => { + const request = { + method: 'POST', + headers: { + Host: 'exchange.api.bity.com', + Accept: 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(bodyData) + } + const result = await fetch(`${apiUrl}/v2/orders/estimate`, request) + if (result.ok) { + const newData = await result.json() + return newData + } else { + let bityErrorRes + try { + bityErrorRes = asBityErrorResponse(await result.json()) + } catch (e) { + throw new Error('Bity: Unable to fetch quote: ' + (await result.text())) + } + if ( + bityErrorRes.errors.some( + bityError => bityError.code === 'amount_too_large' + ) + ) { + throw new Error('Bity: Amount too large') + } + throw new Error('Bity: ' + bityErrorRes.errors[0].message) + } +} + +const approveBityQuote = async ( + wallet: EdgeCurrencyWallet, + data: BityBuyOrderRequest | BitySellOrderRequest, + clientId: string, + apiUrl: string +): Promise => { + const baseUrl = apiUrl + const orderUrl = `${apiUrl}/v2/orders` + const orderReq: RequestInit = { + method: 'POST', + headers: { + Host: 'exchange.api.bity.com', + Accept: 'application/json', + 'Content-Type': 'application/json', + 'Client-Id': clientId + }, + credentials: 'include', + body: JSON.stringify(data) + } + + const orderRes = await fetch(orderUrl, orderReq) + + if (orderRes.status !== 201) { + const errorData = await orderRes.json() + throw new BityError(errorData.errors[0].message, errorData.errors[0].code) + } + // "location": "https://...bity.com/v2/orders/[orderid]" + const locationHeader = orderRes.headers.get('Location') + + const locationUrl = baseUrl + locationHeader + const locationReq: RequestInit = { + method: 'GET', + credentials: 'include' + } + const locationRes = await fetch(locationUrl, locationReq) + + if (locationRes.status !== 200) { + console.error(JSON.stringify({ locationRes }, null, 2)) + throw new Error('Problem confirming order: Code n200') + } + const orderData = await locationRes.json() + + if (orderData.message_to_sign != null) { + const { body } = orderData.message_to_sign + const { publicAddress } = await wallet.getReceiveAddress({ tokenId: null }) + const signedMessage = isUtxoWallet(wallet) + ? await wallet.signMessage(body, { otherParams: { publicAddress } }) + : await wallet.signBytes(utf8.parse(body), { + otherParams: { publicAddress } + }) + const signUrl = baseUrl + orderData.message_to_sign.signature_submission_url + const request = { + method: 'POST', + headers: { + Host: 'exchange.api.bity.com', + 'Content-Type': '*/*' + }, + body: signedMessage + } + const signedTransactionResponse = await fetch(signUrl, request) + if (signedTransactionResponse.status === 400) { + throw new Error('Could not complete transaction. Code: 400') + } + if (signedTransactionResponse.status === 204) { + const bankDetailsReq = { + method: 'GET', + credentials: 'include' + } + const detailUrl = orderUrl + '/' + orderData.id + // @ts-expect-error - fetch type mismatch with bankDetailsReq + const bankDetailRes = await fetch(detailUrl, bankDetailsReq) + if (bankDetailRes.status === 200) { + const bankDetailResJson = await bankDetailRes.json() + return asBityApproveQuoteResponse(bankDetailResJson) + } + } + } + return asBityApproveQuoteResponse(orderData) +} + +async function fetchProviderConfig( + account: EdgeAccount, + apiUrl: string +): Promise<{ currencies: BityCurrency[] }> { + const now = Date.now() + + // Check if cache is valid + if (configCache.data != null && now - configCache.timestamp < CACHE_TTL_MS) { + return configCache.data + } + + // Fetch fresh configuration + const response = await fetch(`${apiUrl}/v2/currencies`).catch( + (_e: unknown) => undefined + ) + + if (response?.ok !== true) { + console.error( + `Bity fetchProviderConfig response error: ${await response?.text()}` + ) + // Return cached data if available, even if expired + if (configCache.data != null) return configCache.data + throw new Error('Failed to fetch Bity currencies') + } + + const result = await response.json() + let bityCurrencies: BityCurrency[] = [] + try { + bityCurrencies = asBityCurrencyResponse(result).currencies + } catch (error) { + console.error(error) + // Return cached data if available, even if expired + if (configCache.data != null) return configCache.data + throw new Error('Failed to parse Bity currencies') + } + + // Update supported assets cache + supportedAssetsCache.fiat.clear() + supportedAssetsCache.crypto.clear() + + for (const currency of bityCurrencies) { + if (currency.tags.length === 1 && currency.tags[0] === 'fiat') { + const fiatCurrencyCode = 'iso:' + currency.code.toUpperCase() + supportedAssetsCache.fiat.add(fiatCurrencyCode) + } else if (currency.tags.includes('crypto')) { + // Bity reports cryptos with a set of multiple tags such that there is + // overlap, such as USDC being 'crypto', 'ethereum', 'erc20'. + const pluginId = + currency.tags.includes('erc20') && currency.tags.includes('ethereum') + ? 'ethereum' + : CURRENCY_PLUGINID_MAP[currency.code] + if (pluginId == null) continue + + // Map Bity currency code to tokenId using dynamic resolution + let tokenId: EdgeTokenId = null + const currencyConfig = account.currencyConfig[pluginId] + if (currencyConfig != null) { + const resolvedTokenId = getTokenId(currencyConfig, currency.code) + if (resolvedTokenId !== undefined) { + tokenId = resolvedTokenId + } + } + + // Skip if we couldn't resolve the token ID for ERC20 tokens + if (currency.tags.includes('erc20') && tokenId === null) { + continue + } + + // Check if in no-KYC list + const buyList = noKycCurrencyCodes.buy.crypto[pluginId] + const sellList = noKycCurrencyCodes.sell.crypto[pluginId] + const isInBuyList = buyList?.some(t => t.tokenId === tokenId) + const isInSellList = sellList?.some(t => t.tokenId === tokenId) + + if (!isInBuyList && !isInSellList) { + continue + } + + // Add to crypto cache + const tokens = supportedAssetsCache.crypto.get(pluginId) ?? [] + addTokenToArray({ tokenId }, tokens) + supportedAssetsCache.crypto.set(pluginId, tokens) + } + } + + // Update cache + const data = { currencies: bityCurrencies } + configCache = { + data, + timestamp: now + } + + return data +} + +function isUtxoWallet(wallet: EdgeCurrencyWallet): boolean { + return [ + 'wallet:badcoin', + 'wallet:bitcoin', + 'wallet:bitcoincash', + 'wallet:bitcoincashtestnet', + 'wallet:bitcoingold', + 'wallet:bitcoingoldtestnet', + 'wallet:bitcoinsv', + 'wallet:bitcointestnet', + 'wallet:bitcointestnet4', + 'wallet:dash', + 'wallet:digibyte', + 'wallet:dogecoin', + 'wallet:eboost', + 'wallet:feathercoin', + 'wallet:groestlcoin', + 'wallet:litecoin', + 'wallet:qtum', + 'wallet:ravencoin', + 'wallet:smartcash', + 'wallet:ufo', + 'wallet:vertcoin', + 'wallet:zcoin' + ].includes(wallet.currencyInfo.walletType) +} + +/** + * Check if a region is supported by checking if the country code is in supportedRegionCodes. + * Supports both country-only format (e.g., "US") and country:state format (e.g., "US:CA"). + */ +function isRegionSupported(regionCode: FiatPluginRegionCode): boolean { + // Extract country code from the regionCode + // Handle both "US" and "US:CA" formats + const countryCode = regionCode.countryCode.includes(':') + ? regionCode.countryCode.split(':')[0] + : regionCode.countryCode + + // Check if the country is supported + return supportedRegionCodes.includes(countryCode) +} + +/** + * Check if a crypto asset is supported in the noKycCurrencyCodes list + */ +function isCryptoSupported( + pluginId: string, + tokenId: EdgeTokenId, + direction: 'buy' | 'sell' +): boolean { + const cryptoList = noKycCurrencyCodes[direction].crypto[pluginId] + return cryptoList?.some(t => t.tokenId === tokenId) ?? false +} + +/** + * Find matching crypto currency in provider's currency list + */ +function findCryptoCurrency( + currencies: BityCurrency[], + pluginId: string, + tokenId: EdgeTokenId, + account: EdgeAccount +): BityCurrency | undefined { + for (const currency of currencies) { + if (currency.tags.includes('crypto')) { + const mappedPluginId = + currency.tags.includes('erc20') && currency.tags.includes('ethereum') + ? 'ethereum' + : CURRENCY_PLUGINID_MAP[currency.code] + + if (mappedPluginId === pluginId) { + // Check tokenId match + if (tokenId === null && !currency.tags.includes('erc20')) { + return currency + } else if (tokenId !== null && currency.tags.includes('erc20')) { + // For ERC20 tokens, use dynamic resolution to match + const currencyConfig = account.currencyConfig[pluginId] + if (currencyConfig != null) { + const resolvedTokenId = getTokenId(currencyConfig, currency.code) + if (resolvedTokenId === tokenId) { + return currency + } + } + } + } + } + } + return undefined +} + +/** + * Find matching fiat currency in provider's currency list + */ +function findFiatCurrency( + currencies: BityCurrency[], + fiatCode: string +): BityCurrency | undefined { + return currencies.find( + currency => + currency.tags.length === 1 && + currency.tags[0] === 'fiat' && + currency.code === fiatCode + ) +} + +export const bityRampPlugin = (pluginConfig: RampPluginConfig): RampPlugin => { + const initOptions = asInitOptions(pluginConfig.initOptions) + // Use fallback client ID if not provided in configuration + const clientId = initOptions.clientId ?? EDGE_CLIENT_ID + const apiUrl = initOptions.apiUrl + const { account, navigation, onLogEvent } = pluginConfig + + const rampInfo: RampInfo = { + partnerIcon, + pluginDisplayName + } + + const plugin: RampPlugin = { + pluginId, + rampInfo, + + checkSupport: async ( + request: RampCheckSupportRequest + ): Promise => { + try { + const { direction, regionCode, fiatAsset, cryptoAsset } = request + + // Quick local check: region support + if (!isRegionSupported(regionCode)) { + return { supported: false } + } + + // Quick local check: crypto support in no-KYC list + if ( + !isCryptoSupported( + cryptoAsset.pluginId, + cryptoAsset.tokenId, + direction + ) + ) { + return { supported: false } + } + + // Need to fetch provider config to check fiat support + let providerConfig + try { + providerConfig = await fetchProviderConfig(account, apiUrl) + } catch (error) { + // Log error but return false instead of throwing + console.error( + 'Bity checkSupport: Failed to fetch provider config:', + error + ) + return { supported: false } + } + + // Check if fiat currency is supported + const fiatCode = removeIsoPrefix( + ensureIsoPrefix(fiatAsset.currencyCode) + ) + const fiatCurrency = findFiatCurrency( + providerConfig.currencies, + fiatCode + ) + + if (fiatCurrency == null) { + return { supported: false } + } + + // All checks passed + return { supported: true } + } catch (error) { + // Log error and return false for any unexpected errors + console.error('Bity checkSupport error:', error) + return { supported: false } + } + }, + + fetchQuote: async ( + request: RampQuoteRequest + ): Promise => { + const { + amountType, + direction, + exchangeAmount, + fiatCurrencyCode, + regionCode, + pluginId: currencyPluginId, + tokenId, + displayCurrencyCode + } = request + const isBuy = direction === 'buy' + + const isMaxAmount = + typeof exchangeAmount === 'object' && exchangeAmount.max + const exchangeAmountString = isMaxAmount ? '' : (exchangeAmount as string) + + // Validate region using helper function + if (!isRegionSupported(regionCode)) { + console.error('Bity fetchQuote error: Region not supported', { + regionCode + }) + return [] + } + + // Validate crypto using helper function + if (!isCryptoSupported(currencyPluginId, tokenId, direction)) { + console.error('Bity fetchQuote error: Crypto not supported', { + currencyPluginId, + tokenId, + direction + }) + return [] + } + + // Get provider configuration (cached) + let providerConfig + try { + providerConfig = await fetchProviderConfig(account, apiUrl) + } catch (error) { + // Return empty array for provider config failures + console.error( + 'Bity fetchQuote error: Failed to fetch provider config', + error + ) + return [] + } + + // Find the crypto currency in Bity's supported list + const cryptoCurrencyObj = findCryptoCurrency( + providerConfig.currencies, + currencyPluginId, + tokenId, + account + ) + const cryptoCode = cryptoCurrencyObj?.code + + if (cryptoCurrencyObj == null || cryptoCode == null) { + // Crypto not found in provider's list + console.error( + 'Bity fetchQuote error: Crypto currency not found in provider list', + { currencyPluginId, tokenId } + ) + return [] + } + + // Find the fiat currency + const fiatCode = removeIsoPrefix(ensureIsoPrefix(fiatCurrencyCode)) + const fiatCurrencyObj = findFiatCurrency( + providerConfig.currencies, + fiatCode + ) + + if (fiatCurrencyObj == null) { + // Fiat not supported + console.error('Bity fetchQuote error: Fiat currency not supported', { + fiatCode + }) + return [] + } + + const inputCurrencyCode = isBuy ? fiatCode : cryptoCode + const outputCurrencyCode = isBuy ? cryptoCode : fiatCode + + const amountPrecision = + amountType === 'fiat' + ? fiatCurrencyObj.max_digits_in_decimal_part + : cryptoCurrencyObj.max_digits_in_decimal_part + + let amount = '' + if (isMaxAmount) { + // Use 1000 as max fiat (their no-KYC limit) + if (amountType === 'fiat') { + amount = '1000' + } else { + // For crypto, fetch a quote with 1000 fiat to get crypto amount + const maxFiatRequest: BityQuoteRequest = { + input: { + amount: isBuy ? '1000' : undefined, + currency: isBuy ? fiatCode : cryptoCode + }, + output: { + amount: isBuy ? undefined : '1000', + currency: isBuy ? cryptoCode : fiatCode + } + } + try { + const maxRaw = await fetchBityQuote(maxFiatRequest, apiUrl) + const maxQuote = asBityQuote(maxRaw) + amount = isBuy ? maxQuote.output.amount : maxQuote.input.amount + } catch (error) { + console.error( + 'Bity fetchQuote error: Failed to fetch max quote', + error + ) + return [] + } + } + } else { + amount = toFixed(exchangeAmountString, amountPrecision) + } + const isReverseQuote = + (isBuy && amountType === 'crypto') || (!isBuy && amountType === 'fiat') + const quoteRequest: BityQuoteRequest = { + input: { + amount: isReverseQuote ? undefined : amount, + currency: inputCurrencyCode + }, + output: { + amount: isReverseQuote ? amount : undefined, + currency: outputCurrencyCode + } + } + + let bityQuote: ReturnType + try { + const raw = await fetchBityQuote(quoteRequest, apiUrl) + bityQuote = asBityQuote(raw) + console.log('Got Bity quote:\n', JSON.stringify(bityQuote, null, 2)) + } catch (error) { + // Return empty array for quote fetching failures + console.error('Bity fetchQuote error: Failed to fetch quote', { + quoteRequest, + error + }) + return [] + } + + const minimumAmount = isReverseQuote + ? bityQuote.output.minimum_amount + : bityQuote.input.minimum_amount + if (minimumAmount != null && lt(amount, minimumAmount)) { + // Under minimum + console.error('Bity fetchQuote error: Amount under minimum', { + amount, + minimumAmount + }) + return [] + } + + // Because Bity only supports <=1k transactions w/o KYC and we have no + // way to KYC a user, add a 1k limit + if (!isMaxAmount) { + if (amountType === 'fiat') { + if (gt(exchangeAmountString, '1000')) { + // Over limit + console.error( + 'Bity fetchQuote error: Fiat amount exceeds 1000 limit', + { exchangeAmount: exchangeAmountString } + ) + return [] + } + } else { + // User entered a crypto amount. Get the crypto amount for 1k fiat + // so we can compare crypto amounts. + const kRequest: BityQuoteRequest = { + input: { + amount: isBuy ? '1000' : undefined, + currency: isBuy ? fiatCode : cryptoCode + }, + output: { + amount: isBuy ? undefined : '1000', + currency: isBuy ? cryptoCode : fiatCode + } + } + + try { + const kRaw = await fetchBityQuote(kRequest, apiUrl) + const kBityQuote = asBityQuote(kRaw) + if (isBuy) { + if (lt(kBityQuote.output.amount, exchangeAmountString)) { + // Over limit + console.error( + 'Bity fetchQuote error: Buy crypto amount exceeds 1000 fiat equivalent', + { + exchangeAmount: exchangeAmountString, + maxCryptoAmount: kBityQuote.output.amount + } + ) + return [] + } + } else { + if (lt(kBityQuote.input.amount, exchangeAmountString)) { + // Over limit + console.error( + 'Bity fetchQuote error: Sell crypto amount exceeds 1000 fiat equivalent', + { + exchangeAmount: exchangeAmountString, + maxCryptoAmount: kBityQuote.input.amount + } + ) + return [] + } + } + } catch (error) { + // Return empty array for 1k limit check failures + console.error( + 'Bity fetchQuote error: Failed to check 1000 fiat limit', + { error } + ) + return [] + } + } + } + + // Check for a max amount limit from the API + let quoteAmount + if (isBuy) { + quoteAmount = + amountType === 'fiat' + ? bityQuote.input.amount + : bityQuote.output.amount + } else { + quoteAmount = + amountType === 'fiat' + ? bityQuote.output.amount + : bityQuote.input.amount + } + if (lt(quoteAmount, amount)) { + // Over limit from API + console.error( + 'Bity fetchQuote error: Quote amount less than requested amount (API limit)', + { quoteAmount, requestedAmount: amount } + ) + return [] + } + + const quote: RampQuoteResult = { + pluginId, + partnerIcon, + pluginDisplayName, + displayCurrencyCode, + cryptoAmount: isBuy ? bityQuote.output.amount : bityQuote.input.amount, + isEstimate: false, + fiatCurrencyCode, + fiatAmount: isBuy ? bityQuote.input.amount : bityQuote.output.amount, + direction, + regionCode, + paymentType: supportedPaymentType, + expirationDate: new Date(Date.now() + 50000), + settlementRange: { + min: { value: 15, unit: 'minutes' }, + max: { value: 2, unit: 'hours' } + }, + approveQuote: async ( + approveParams: RampApproveQuoteParams + ): Promise => { + const { coreWallet } = approveParams + const cryptoAddress = ( + await coreWallet.getReceiveAddress({ tokenId: null }) + ).publicAddress + + // Navigate to SEPA form + await new Promise((resolve, reject) => { + navigation.navigate('guiPluginSepaForm', { + headerTitle: lstrings.sepa_form_title, + doneLabel: isBuy + ? lstrings.submit + : lstrings.string_next_capitalized, + onDone: async (sepaInfo: SepaInfo) => { + let approveQuoteRes: BityApproveQuoteResponse | null = null + try { + if (isBuy) { + approveQuoteRes = await executeBuyOrderFetch( + coreWallet, + bityQuote, + fiatCode, + sepaInfo, + outputCurrencyCode, + cryptoAddress, + clientId, + apiUrl + ) + } else { + // Sell approval - Needs extra address input step + await new Promise((resolve, reject) => { + navigation.navigate('guiPluginAddressForm', { + countryCode: regionCode.countryCode, + headerTitle: lstrings.home_address_title, + onSubmit: async (homeAddress: HomeAddress) => { + try { + approveQuoteRes = await executeSellOrderFetch( + coreWallet, + bityQuote, + inputCurrencyCode, + cryptoAddress, + outputCurrencyCode, + sepaInfo, + homeAddress, + clientId, + apiUrl + ) + resolve() + } catch (e) { + reject(e) + } + }, + onClose: () => { + reject(new Error('User cancelled')) + } + }) + }) + } + } catch (e) { + console.error('Bity order error: ', e) + + const bityError = asMaybe(asBityError)(e) + if (bityError?.code === 'exceeds_quota') { + showError( + sprintf(lstrings.error_kyc_required_s, bityError.message) + ) + reject(new Error('KYC required')) + return + } + showError(lstrings.error_unexpected_title) + reject(e) + return + } + + if (approveQuoteRes == null) { + reject(new Error('No approval response')) + return + } + + try { + if (isBuy) { + await completeBuyOrder(approveQuoteRes, navigation) + } else { + await completeSellOrder( + approveQuoteRes, + coreWallet, + navigation, + account, + onLogEvent, + fiatCurrencyCode, + tokenId + ) + } + resolve() + } catch (e: unknown) { + if ( + e instanceof Error && + e.message === SendErrorBackPressed + ) { + // User cancelled + reject(e) + } else { + throw e + } + } + }, + onClose: () => { + reject(new Error('User cancelled')) + } + }) + }) + + // Pop back to original scene + navigation.pop() + }, + closeQuote: async () => {} + } + + return [quote] + } + } + + return plugin +} + +/** + * Transition to the send scene pre-populated with the payment address from the + * previously opened/approved sell order + */ +const completeSellOrder = async ( + approveQuoteRes: BityApproveQuoteResponse, + coreWallet: EdgeCurrencyWallet, + navigation: any, + account: any, + onLogEvent: any, + fiatCurrencyCode: string, + tokenId: EdgeTokenId +): Promise => { + const { + input, + id, + payment_details: paymentDetails, + output + } = asBitySellApproveQuoteResponse(approveQuoteRes) + const { amount: inputAmount, currency: inputCurrencyCode } = input + const { amount: fiatAmount } = output + + const nativeAmount = mul( + inputAmount, + getCurrencyCodeMultiplier(coreWallet.currencyConfig, inputCurrencyCode) + ) + + if (nativeAmount == null) { + throw new Error( + 'Bity: Could not find input denomination: ' + inputCurrencyCode + ) + } + + const spendInfo: EdgeSpendInfo = { + tokenId, + assetAction: { + assetActionType: 'sell' + }, + savedAction: { + actionType: 'fiat', + orderId: id, + isEstimate: true, + fiatPlugin: { + providerId: pluginId, + providerDisplayName, + supportEmail + }, + payinAddress: paymentDetails.crypto_address, + cryptoAsset: { + pluginId: coreWallet.currencyInfo.pluginId, + tokenId, + nativeAmount + }, + fiatAsset: { + fiatCurrencyCode, + fiatAmount + } + }, + spendTargets: [ + { + nativeAmount, + publicAddress: paymentDetails.crypto_address + } + ] + } + + const sendParams: SendScene2Params = { + walletId: coreWallet.id, + tokenId, + spendInfo, + lockTilesMap: { + address: true, + amount: true, + wallet: true + }, + hiddenFeaturesMap: { + address: true + } + } + + // Navigate to send scene + const tx = await new Promise((resolve, reject) => { + navigation.navigate('send2', { + ...sendParams, + onDone: (error: Error | null, edgeTransaction?: EdgeTransaction) => { + if (error != null) { + reject(error) + } else if (edgeTransaction != null) { + resolve(edgeTransaction) + } else { + reject(new Error(SendErrorNoTransaction)) + } + }, + onBack: () => { + reject(new Error(SendErrorBackPressed)) + } + }) + }) + + // Track conversion + onLogEvent('Sell_Success', { + conversionValues: { + conversionType: 'sell', + destFiatCurrencyCode: fiatCurrencyCode, + destFiatAmount: fiatAmount, + fiatProviderId: pluginId, + orderId: id + } + }) + + // Save tx action + if (tokenId != null) { + const params = { + walletId: coreWallet.id, + tokenId, + txid: tx.txid, + savedAction: spendInfo.savedAction, + assetAction: spendInfo.assetAction + } + await account.currencyWallets[coreWallet.id].saveTxAction(params) + } +} + +/** + * Transition to the transfer scene to display the bank transfer information + * from the previously opened/approved buy order + */ +const completeBuyOrder = async ( + approveQuoteRes: BityApproveQuoteResponse, + navigation: any +): Promise => { + const { + input, + output, + id, + payment_details: paymentDetails + } = asBityBuyApproveQuoteResponse(approveQuoteRes) + + const { iban, swift_bic: swiftBic, recipient, reference } = paymentDetails + + await new Promise((resolve, reject) => { + navigation.navigate('guiPluginSepaTransferInfo', { + headerTitle: lstrings.payment_details, + promptMessage: sprintf(lstrings.sepa_transfer_prompt_s, id), + transferInfo: { + input: { + amount: input.amount, + currency: input.currency + }, + output: { + amount: output.amount, + currency: output.currency, + walletAddress: output.crypto_address + }, + paymentDetails: { + id, + iban, + swiftBic, + recipient, + reference + } + }, + onDone: async () => { + resolve() + } + }) + }) +} + +/** + * Physically opens the sell order, resulting in payment information detailing + * where to send crypto (payment address) in order to complete the order. + */ +const executeSellOrderFetch = async ( + coreWallet: EdgeCurrencyWallet, + bityQuote: any, + inputCurrencyCode: string, + cryptoAddress: string, + outputCurrencyCode: string, + sepaInfo: { name: string; iban: string; swift: string }, + homeAddress: { + address: string + address2: string | undefined + city: string + country: string + state: string + postalCode: string + }, + clientId: string, + apiUrl: string +): Promise => { + return await approveBityQuote( + coreWallet, + { + client_value: 0, + input: { + amount: bityQuote.input.amount, + currency: inputCurrencyCode, + type: 'crypto_address', + crypto_address: cryptoAddress + }, + output: { + currency: outputCurrencyCode, + type: 'bank_account', + iban: sepaInfo.iban, + bic_swift: sepaInfo.swift, + owner: { + name: sepaInfo.name, + street_name: homeAddress.address, + building_number: homeAddress.address2 ?? '', + town_name: homeAddress.city, + country: homeAddress.country, + country_subdivision: homeAddress.state, + post_code: homeAddress.postalCode + } + }, + partner_fee: { factor: partnerFee } + }, + clientId, + apiUrl + ) +} + +/** + * Physically opens the buy order, resulting in payment information detailing + * where to send fiat (bank details) in order to complete the order. + */ +const executeBuyOrderFetch = async ( + coreWallet: EdgeCurrencyWallet, + bityQuote: any, + fiatCode: string, + sepaInfo: { name: string; iban: string; swift: string }, + outputCurrencyCode: string, + cryptoAddress: string, + clientId: string, + apiUrl: string +): Promise => { + return await approveBityQuote( + coreWallet, + { + client_value: 0, + input: { + amount: bityQuote.input.amount, + currency: fiatCode, + type: 'bank_account', + iban: sepaInfo.iban, + bic_swift: sepaInfo.swift + }, + output: { + currency: outputCurrencyCode, + type: 'crypto_address', + crypto_address: cryptoAddress + }, + partner_fee: { factor: partnerFee } + }, + clientId, + apiUrl + ) +} diff --git a/src/plugins/ramps/bity/bityRampTypes.ts b/src/plugins/ramps/bity/bityRampTypes.ts new file mode 100644 index 00000000000..ec57b2f6ac9 --- /dev/null +++ b/src/plugins/ramps/bity/bityRampTypes.ts @@ -0,0 +1,6 @@ +import { asObject, asOptional, asString } from 'cleaners' + +export const asInitOptions = asObject({ + clientId: asOptional(asString), + apiUrl: asOptional(asString, 'https://exchange.api.bity.com') +}) diff --git a/src/plugins/ramps/moonpay/moonpayRampPlugin.ts b/src/plugins/ramps/moonpay/moonpayRampPlugin.ts new file mode 100644 index 00000000000..44a374a4a5f --- /dev/null +++ b/src/plugins/ramps/moonpay/moonpayRampPlugin.ts @@ -0,0 +1,1099 @@ +import { mul } from 'biggystring' +import { asMaybe, asString } from 'cleaners' +import type { + EdgeAssetAction, + EdgeMemo, + EdgeSpendInfo, + EdgeTokenId, + EdgeTxActionFiat +} from 'edge-core-js' +import { Platform } from 'react-native' +import { CustomTabs } from 'react-native-custom-tabs' +import SafariView from 'react-native-safari-view' +import { sprintf } from 'sprintf-js' +import URL from 'url-parse' + +import { showButtonsModal } from '../../../components/modals/ButtonsModal' +import type { SendScene2Params } from '../../../components/scenes/SendScene2' +import { + showError, + showToast +} from '../../../components/services/AirshipInstance' +import { EDGE_CONTENT_SERVER_URI } from '../../../constants/CdnConstants' +import { lstrings } from '../../../locales/strings' +import type { StringMap } from '../../../types/types' +import { CryptoAmount } from '../../../util/CryptoAmount' +import { + findTokenIdByNetworkLocation, + getCurrencyCodeMultiplier +} from '../../../util/CurrencyInfoHelpers' +import { removeIsoPrefix } from '../../../util/utils' +import { + SendErrorBackPressed, + SendErrorNoTransaction +} from '../../gui/fiatPlugin' +import type { + FiatDirection, + FiatPaymentType, + FiatPluginRegionCode, + SaveTxActionParams +} from '../../gui/fiatPluginTypes' +import { + FiatProviderError, + type FiatProviderExactRegions, + type ProviderToken +} from '../../gui/fiatProviderTypes' +import { + addExactRegion, + NOT_SUCCESS_TOAST_HIDE_MS, + RETURN_URL_PAYMENT, + validateExactRegion +} from '../../gui/providers/common' +import { addTokenToArray } from '../../gui/util/providerUtils' +import { rampDeeplinkManager } from '../rampDeeplinkHandler' +import type { + RampApproveQuoteParams, + RampCheckSupportRequest, + RampInfo, + RampPlugin, + RampPluginConfig, + RampPluginFactory, + RampQuoteRequest, + RampQuoteResult, + RampSupportResult +} from '../rampPluginTypes' +import { + asInitOptions, + asMoonpayCountries, + asMoonpayCurrencies, + asMoonpayCurrency, + asMoonpayQuote, + type MoonpayBuyWidgetQueryParams, + type MoonpayPaymentMethod, + type MoonpaySellWidgetQueryParams +} from './moonpayRampTypes' + +const pluginId = 'moonpay' +const partnerIcon = `${EDGE_CONTENT_SERVER_URI}/moonpay_symbol_prp.png` +const pluginDisplayName = 'Moonpay' +const supportEmail = 'support@moonpay.com' + +// Local asset map type +interface AssetMap { + providerId: string + fiat: Record + crypto: Record +} + +const MOONPAY_PAYMENT_TYPE_MAP: Partial< + Record +> = { + applepay: 'credit_debit_card', + credit: 'credit_debit_card', + googlepay: 'credit_debit_card', + ach: 'ach_bank_transfer', + paypal: 'paypal', + venmo: 'venmo' +} + +const NETWORK_CODE_PLUGINID_MAP: StringMap = { + algorand: 'algorand', + arbitrum: 'arbitrum', + avalanche_c_chain: 'avalanche', + base: 'base', + binance_smart_chain: 'binancesmartchain', + bitcoin: 'bitcoin', + bitcoin_cash: 'bitcoincash', + cardano: 'cardano', + cosmos: 'cosmoshub', + dogecoin: 'dogecoin', + ethereum: 'ethereum', + hedera: 'hedera', + litecoin: 'litecoin', + optimism: 'optimism', + osmosis: 'osmosis', + polygon: 'polygon', + ripple: 'ripple', + solana: 'solana', + s_sonic: 'sonic', + stellar: 'stellar', + sui: 'sui', + tezos: 'tezos', + tron: 'tron', + ton: 'ton', + zksync: 'zksync' +} + +const ensureIsoPrefix = (currencyCode: string): string => { + return currencyCode.startsWith('iso:') ? currencyCode : `iso:${currencyCode}` +} + +const createMemo = (pluginId: string, value: string): EdgeMemo => { + const memo: EdgeMemo = { + type: 'text', + value, + hidden: true + } + + switch (pluginId) { + case 'ripple': { + memo.type = 'number' + memo.memoName = 'destination tag' + } + } + return memo +} + +// Cache structure with TTL +interface ProviderConfigCache { + data: { + allowedCountryCodes: Record + allowedCurrencyCodes: Record< + FiatDirection, + Partial> + > + } + timestamp: number +} + +const CACHE_TTL = 2 * 60 * 1000 // 2 minutes in milliseconds + +export const moonpayRampPlugin: RampPluginFactory = ( + pluginConfig: RampPluginConfig +): RampPlugin => { + const { account, navigation, onLogEvent } = pluginConfig + const initOptions = asInitOptions(pluginConfig.initOptions) + const { apiKey, apiUrl, buyWidgetUrl, sellWidgetUrl } = initOptions + if (apiKey == null) throw new Error('Moonpay missing apiKey') + + // Cache variable scoped to the plugin instance + let providerCache: ProviderConfigCache | null = null + + const rampInfo: RampInfo = { + partnerIcon, + pluginDisplayName + } + + // Internal function to fetch and cache provider configuration + const fetchProviderConfig = async (): Promise< + ProviderConfigCache['data'] + > => { + // Check if cache is valid + if ( + providerCache != null && + Date.now() - providerCache.timestamp < CACHE_TTL + ) { + return providerCache.data + } + + // Initialize fresh configuration + const freshConfig: ProviderConfigCache['data'] = { + allowedCountryCodes: { buy: {}, sell: {} }, + allowedCurrencyCodes: { + buy: { + credit: { providerId: pluginId, fiat: {}, crypto: {} }, + paypal: { providerId: pluginId, fiat: {}, crypto: {} }, + venmo: { providerId: pluginId, fiat: {}, crypto: {} } + }, + sell: { + ach: { providerId: pluginId, fiat: {}, crypto: {} }, + credit: { providerId: pluginId, fiat: {}, crypto: {} }, + paypal: { providerId: pluginId, fiat: {}, crypto: {} }, + venmo: { providerId: pluginId, fiat: {}, crypto: {} } + } + } + } + + // Fetch currencies + const currenciesResponse = await fetch( + `${apiUrl}/v3/currencies?apiKey=${apiKey}` + ).catch(() => undefined) + + if (currenciesResponse?.ok === true) { + try { + const result = await currenciesResponse.json() + let moonpayCurrencies = asMoonpayCurrencies(result) + + // Fix burn address + moonpayCurrencies = moonpayCurrencies.map(currency => { + if ( + currency.metadata?.contractAddress === + '0x0000000000000000000000000000000000000000' + ) { + currency.metadata.contractAddress = null + } + return currency + }) + + // Process currencies + for (const currency of moonpayCurrencies) { + if (currency.type === 'crypto') { + const { metadata } = currency + if (metadata == null) continue + const { contractAddress, networkCode } = metadata + const currencyPluginId = NETWORK_CODE_PLUGINID_MAP[networkCode] + if (currencyPluginId == null) continue + + const tokenId: EdgeTokenId = + (contractAddress != null + ? findTokenIdByNetworkLocation({ + account, + pluginId, + networkLocation: { contractAddress } + }) + : null) ?? null + + // Add to all payment types + for (const dir of ['buy', 'sell'] as FiatDirection[]) { + if (dir === 'sell' && currency.isSellSupported !== true) continue + + for (const pt in freshConfig.allowedCurrencyCodes[dir]) { + const assetMap = + freshConfig.allowedCurrencyCodes[dir][pt as FiatPaymentType] + if (assetMap != null) { + assetMap.crypto[currencyPluginId] ??= [] + addTokenToArray( + { tokenId, otherInfo: currency }, + assetMap.crypto[currencyPluginId] + ) + } + } + } + } else { + // Add fiat to all payment types + for (const dir of ['buy', 'sell'] as FiatDirection[]) { + for (const pt in freshConfig.allowedCurrencyCodes[dir]) { + const assetMap = + freshConfig.allowedCurrencyCodes[dir][pt as FiatPaymentType] + if (assetMap != null) { + assetMap.fiat['iso:' + currency.code.toUpperCase()] = currency + } + } + } + } + } + } catch (error) { + console.log('Failed to update moonpay currencies:', error) + } + } + + // Fetch countries + const countriesResponse = await fetch( + `${apiUrl}/v3/countries?apiKey=${apiKey}` + ).catch(() => undefined) + + if (countriesResponse?.ok === true) { + try { + const result = await countriesResponse.json() + const countries = asMoonpayCountries(result) + + for (const country of countries) { + if (country.isAllowed) { + if (country.states == null) { + if (country.isBuyAllowed) { + freshConfig.allowedCountryCodes.buy[country.alpha2] = true + } + if (country.isSellAllowed) { + freshConfig.allowedCountryCodes.sell[country.alpha2] = true + } + } else { + const countryCode = country.alpha2 + for (const state of country.states) { + if (state.isAllowed) { + const stateProvinceCode = state.code + if (state.isBuyAllowed) { + addExactRegion( + freshConfig.allowedCountryCodes.buy, + countryCode, + stateProvinceCode + ) + } + if (state.isSellAllowed) { + addExactRegion( + freshConfig.allowedCountryCodes.sell, + countryCode, + stateProvinceCode + ) + } + } + } + } + } + } + } catch (error) { + console.log('Failed to update moonpay countries:', error) + } + } + + // Update cache + providerCache = { + data: freshConfig, + timestamp: Date.now() + } + + return freshConfig + } + + // Helper function to check if region is supported + const isRegionSupported = ( + regionCode: FiatPluginRegionCode, + direction: FiatDirection, + allowedCountryCodes: Record + ): boolean => { + // Check country restrictions + if (regionCode.countryCode === 'GB') { + return false + } + + try { + validateExactRegion(pluginId, regionCode, allowedCountryCodes[direction]) + return true + } catch { + return false + } + } + + // Helper function to check if crypto asset is supported + const isCryptoSupported = ( + pluginId: string, + tokenId: EdgeTokenId, + assetMap: AssetMap, + regionCode?: FiatPluginRegionCode + ): ProviderToken | null => { + const tokens = assetMap.crypto[pluginId] + if (tokens == null) { + return null + } + + const token = tokens.find( + (token: ProviderToken) => token.tokenId === tokenId + ) + if (token == null) { + return null + } + + // Check if currency is suspended + const currency = asMoonpayCurrency(token.otherInfo) + if (currency.isSuspended === true) { + return null + } + + // Check US region support + if ( + regionCode?.countryCode === 'US' && + currency.isSupportedInUS === false + ) { + return null + } + + return token + } + + // Helper function to check if fiat is supported + const isFiatSupported = ( + fiatCurrencyCode: string, + assetMap: AssetMap + ): any | null => { + const fiatCurrencyObj = assetMap.fiat[fiatCurrencyCode] + if (fiatCurrencyObj == null) { + return null + } + + try { + return asMoonpayCurrency(fiatCurrencyObj) + } catch { + return null + } + } + + // Helper function to get supported payment methods + const getSupportedPaymentMethods = ( + direction: FiatDirection, + allowedCurrencyCodes: Record< + FiatDirection, + Partial> + > + ): Array<{ + paymentType: FiatPaymentType + paymentMethod: MoonpayPaymentMethod + assetMap: AssetMap + }> => { + const supportedMethods: Array<{ + paymentType: FiatPaymentType + paymentMethod: MoonpayPaymentMethod + assetMap: AssetMap + }> = [] + + const paymentTypes = Object.keys(allowedCurrencyCodes[direction]).filter( + pt => allowedCurrencyCodes[direction][pt as FiatPaymentType] != null + ) as FiatPaymentType[] + + for (const paymentType of paymentTypes) { + const paymentMethod = MOONPAY_PAYMENT_TYPE_MAP[paymentType] + const assetMap = allowedCurrencyCodes[direction][paymentType] + + if (paymentMethod != null && assetMap != null) { + supportedMethods.push({ paymentType, paymentMethod, assetMap }) + } + } + + return supportedMethods + } + + const plugin: RampPlugin = { + pluginId, + rampInfo, + + checkSupport: async ( + request: RampCheckSupportRequest + ): Promise => { + const { + direction, + regionCode, + fiatAsset: { currencyCode: fiatCurrencyCode }, + cryptoAsset: { pluginId: cryptoPluginId, tokenId } + } = request + + try { + // Fetch provider configuration (with caching) + const config = await fetchProviderConfig() + const { allowedCountryCodes, allowedCurrencyCodes } = config + + // Check region support + if (!isRegionSupported(regionCode, direction, allowedCountryCodes)) { + return { supported: false } + } + + // Get supported payment methods + const supportedMethods = getSupportedPaymentMethods( + direction, + allowedCurrencyCodes + ) + if (supportedMethods.length === 0) { + return { supported: false } + } + + // Check support across all payment methods + for (const { assetMap } of supportedMethods) { + // Check crypto support + const cryptoSupported = isCryptoSupported( + cryptoPluginId, + tokenId, + assetMap, + regionCode + ) + if (cryptoSupported == null) { + continue + } + + // Check fiat support + const fiatSupported = isFiatSupported( + ensureIsoPrefix(fiatCurrencyCode), + assetMap + ) + if (fiatSupported == null) { + continue + } + + // If we found a payment method that supports both crypto and fiat, return supported + return { supported: true } + } + + // No payment method supports this combination + return { supported: false } + } catch (error) { + console.log('Moonpay checkSupport error:', error) + // For any errors, return not supported rather than throwing + return { supported: false } + } + }, + + fetchQuote: async ( + request: RampQuoteRequest + ): Promise => { + const { direction, regionCode, displayCurrencyCode, tokenId } = request + const fiatCurrencyCode = ensureIsoPrefix(request.fiatCurrencyCode) + + const isMaxAmount = + typeof request.exchangeAmount === 'object' && request.exchangeAmount.max + const exchangeAmountString = isMaxAmount + ? '' + : (request.exchangeAmount as string) + + // Fetch provider configuration (with caching) + const config = await fetchProviderConfig() + const { allowedCountryCodes, allowedCurrencyCodes } = config + + // Check region support + if (!isRegionSupported(regionCode, direction, allowedCountryCodes)) { + throw new FiatProviderError({ + providerId: pluginId, + errorType: 'regionRestricted' + }) + } + + // Get supported payment methods + const supportedPaymentMethods = getSupportedPaymentMethods( + direction, + allowedCurrencyCodes + ) + + if (supportedPaymentMethods.length === 0) { + throw new FiatProviderError({ + providerId: pluginId, + errorType: 'paymentUnsupported' + }) + } + + // Find the first payment method that supports both crypto and fiat + let selectedMethod: { + paymentType: FiatPaymentType + paymentMethod: MoonpayPaymentMethod + assetMap: AssetMap + moonpayCurrency: ProviderToken + fiatCurrencyObj: any + } | null = null + + for (const method of supportedPaymentMethods) { + // Check if crypto is supported + const cryptoSupported = isCryptoSupported( + request.pluginId, + request.tokenId, + method.assetMap, + regionCode + ) + if (cryptoSupported == null) { + continue + } + + // Check if fiat is supported + const fiatSupported = isFiatSupported(fiatCurrencyCode, method.assetMap) + if (fiatSupported == null) { + continue + } + + // Found a payment method that supports both + selectedMethod = { + paymentType: method.paymentType, + paymentMethod: method.paymentMethod, + assetMap: method.assetMap, + moonpayCurrency: cryptoSupported, + fiatCurrencyObj: fiatSupported + } + break + } + + // If no payment method supports both crypto and fiat, throw error + if (selectedMethod == null) { + throw new FiatProviderError({ + providerId: pluginId, + errorType: 'assetUnsupported' + }) + } + + const { paymentType, paymentMethod, moonpayCurrency, fiatCurrencyObj } = + selectedMethod + + const cryptoCurrencyObj = asMoonpayCurrency(moonpayCurrency.otherInfo) + if (cryptoCurrencyObj == null) { + throw new FiatProviderError({ + providerId: pluginId, + errorType: 'assetUnsupported' + }) + } + + let amountParam = '' + + let maxFiat: number + let minFiat: number + let maxCrypto: number + let minCrypto: number + + if (direction === 'buy') { + maxFiat = fiatCurrencyObj.maxBuyAmount ?? fiatCurrencyObj.maxAmount ?? 0 + minFiat = + fiatCurrencyObj.minBuyAmount ?? fiatCurrencyObj.minAmount ?? Infinity + maxCrypto = + cryptoCurrencyObj.maxBuyAmount ?? cryptoCurrencyObj.maxAmount ?? 0 + minCrypto = + cryptoCurrencyObj.minBuyAmount ?? + cryptoCurrencyObj.minAmount ?? + Infinity + } else { + maxFiat = + fiatCurrencyObj.maxSellAmount ?? fiatCurrencyObj.maxAmount ?? 0 + minFiat = + fiatCurrencyObj.minSellAmount ?? fiatCurrencyObj.minAmount ?? Infinity + maxCrypto = + cryptoCurrencyObj.maxSellAmount ?? cryptoCurrencyObj.maxAmount ?? 0 + minCrypto = + cryptoCurrencyObj.minSellAmount ?? + cryptoCurrencyObj.minAmount ?? + Infinity + } + + let exchangeAmount: number + if (isMaxAmount) { + // Use the max amounts based on amountType + exchangeAmount = request.amountType === 'fiat' ? maxFiat : maxCrypto + } else { + exchangeAmount = parseFloat(exchangeAmountString) + } + + const displayFiatCurrencyCode = removeIsoPrefix(fiatCurrencyCode) + if (!isMaxAmount) { + if (request.amountType === 'fiat') { + if (exchangeAmount > maxFiat) { + throw new FiatProviderError({ + providerId: pluginId, + errorType: 'overLimit', + errorAmount: maxFiat, + displayCurrencyCode: displayFiatCurrencyCode + }) + } + if (exchangeAmount < minFiat) { + throw new FiatProviderError({ + providerId: pluginId, + errorType: 'underLimit', + errorAmount: minFiat, + displayCurrencyCode: displayFiatCurrencyCode + }) + } + } else { + if (exchangeAmount > maxCrypto) { + throw new FiatProviderError({ + providerId: pluginId, + errorType: 'overLimit', + errorAmount: maxCrypto, + displayCurrencyCode + }) + } + if (exchangeAmount < minCrypto) { + throw new FiatProviderError({ + providerId: pluginId, + errorType: 'underLimit', + errorAmount: minCrypto, + displayCurrencyCode + }) + } + } + } + + if (request.amountType === 'fiat') { + if (direction === 'buy') { + amountParam = `baseCurrencyAmount=${exchangeAmount}` + } else { + amountParam = `quoteCurrencyAmount=${exchangeAmount}` + } + } else { + if (direction === 'buy') { + amountParam = `quoteCurrencyAmount=${exchangeAmount}` + } else { + amountParam = `baseCurrencyAmount=${exchangeAmount}` + } + } + + const fiatCode = removeIsoPrefix(fiatCurrencyCode).toLowerCase() + let url + const walletAddress = ( + await request.wallet?.getAddresses({ tokenId: null }) + )?.[0]?.publicAddress + const walletAddressParam = + walletAddress == null ? '' : `&walletAddress=${walletAddress}` + + if (direction === 'buy') { + url = `${apiUrl}/v3/currencies/${cryptoCurrencyObj.code}/buy_quote/?apiKey=${apiKey}"eCurrencyCode=${cryptoCurrencyObj.code}&baseCurrencyCode=${fiatCode}&paymentMethod=${paymentMethod}&areFeesIncluded=true&${amountParam}${walletAddressParam}` + } else { + url = `${apiUrl}/v3/currencies/${cryptoCurrencyObj.code}/sell_quote/?apiKey=${apiKey}"eCurrencyCode=${fiatCode}&payoutMethod=${paymentMethod}&areFeesIncluded=true&${amountParam}` + } + + const response = await fetch(url).catch((e: unknown) => { + console.log(e) + return undefined + }) + + if (response == null) { + throw new Error('Moonpay failed to fetch quote: empty response') + } + + if (!response.ok) { + const errorJson = await response.json() + const errorMessage = asMaybe(asString)(errorJson?.message) + + if ( + errorMessage?.includes( + `is not supported for ${fiatCode.toLowerCase()}` + ) === true + ) { + throw new FiatProviderError({ + providerId: pluginId, + errorType: 'fiatUnsupported', + fiatCurrencyCode: fiatCode.toUpperCase(), + paymentMethod, + pluginDisplayName + }) + } + throw new Error(`Moonpay failed to fetch quote: ${errorJson.message}`) + } + + const result = await response.json() + const moonpayQuote = asMoonpayQuote(result) + + console.log('Got Moonpay quote') + console.log(JSON.stringify(moonpayQuote, null, 2)) + + const fiatAmount = + 'totalAmount' in moonpayQuote + ? moonpayQuote.totalAmount.toString() + : moonpayQuote.quoteCurrencyAmount.toString() + const cryptoAmount = + direction === 'buy' + ? moonpayQuote.quoteCurrencyAmount.toString() + : moonpayQuote.baseCurrencyAmount.toString() + + const quote: RampQuoteResult = { + pluginId, + partnerIcon, + pluginDisplayName, + displayCurrencyCode: request.displayCurrencyCode, + isEstimate: false, + fiatCurrencyCode, + fiatAmount, + cryptoAmount, + direction: request.direction, + expirationDate: new Date(Date.now() + 8000), + regionCode, + paymentType, + settlementRange: { + min: { value: 1, unit: 'hours' }, + max: { value: 1, unit: 'hours' } + }, + approveQuote: async ( + approveParams: RampApproveQuoteParams + ): Promise => { + const { coreWallet } = approveParams + const addresses = await coreWallet.getAddresses({ tokenId: null }) + const receiveAddress = addresses[0] + + if (direction === 'buy') { + const urlObj = new URL(`${buyWidgetUrl}?`, true) + const queryObj: MoonpayBuyWidgetQueryParams = { + apiKey, + walletAddress: receiveAddress.publicAddress, + currencyCode: cryptoCurrencyObj.code, + paymentMethod, + baseCurrencyCode: fiatCurrencyObj.code, + lockAmount: true, + showAllCurrencies: false, + enableRecurringBuys: false, + redirectURL: `https://deep.edge.app/fiatprovider/buy/moonpay` + } + if (request.amountType === 'crypto') { + queryObj.quoteCurrencyAmount = moonpayQuote.quoteCurrencyAmount + } else { + queryObj.baseCurrencyAmount = + 'totalAmount' in moonpayQuote + ? moonpayQuote.totalAmount + : undefined + } + urlObj.set('query', queryObj) + console.log('Approving moonpay buy quote url=' + urlObj.href) + + rampDeeplinkManager.register('buy', pluginId, async link => { + const { query, uri } = link + console.log('Moonpay WebView launch buy success: ' + uri) + const { transactionId, transactionStatus } = query + if (transactionId == null || transactionStatus == null) { + return + } + if (transactionStatus !== 'pending') { + return + } + + onLogEvent('Buy_Success', { + conversionValues: { + conversionType: 'buy', + sourceFiatCurrencyCode: fiatCurrencyCode, + sourceFiatAmount: fiatAmount, + destAmount: new CryptoAmount({ + currencyConfig: coreWallet.currencyConfig, + currencyCode: displayCurrencyCode, + exchangeAmount: cryptoAmount + }), + fiatProviderId: pluginId, + orderId: transactionId + } + }) + + const message = + sprintf( + lstrings.fiat_plugin_buy_complete_message_s, + cryptoAmount, + displayCurrencyCode, + fiatAmount, + displayFiatCurrencyCode, + '1' + ) + + '\n\n' + + sprintf( + lstrings.fiat_plugin_buy_complete_message_2_hour_s, + '1' + ) + + '\n\n' + + lstrings.fiat_plugin_sell_complete_message_3 + + await showButtonsModal({ + buttons: { + ok: { label: lstrings.string_ok, type: 'primary' } + }, + title: lstrings.fiat_plugin_buy_complete_title, + message + }) + }) + + if (Platform.OS === 'ios') { + await SafariView.show({ url: urlObj.href }) + } else { + await CustomTabs.openURL(urlObj.href) + } + } else { + const urlObj = new URL(`${sellWidgetUrl}?`, true) + const queryObj: MoonpaySellWidgetQueryParams = { + apiKey, + refundWalletAddress: receiveAddress.publicAddress, + quoteCurrencyCode: fiatCurrencyObj.code, + paymentMethod, + baseCurrencyCode: cryptoCurrencyObj.code, + lockAmount: true, + showAllCurrencies: false, + redirectURL: RETURN_URL_PAYMENT + } + if (request.amountType === 'crypto') { + queryObj.baseCurrencyAmount = moonpayQuote.baseCurrencyAmount + } else { + queryObj.quoteCurrencyAmount = moonpayQuote.quoteCurrencyAmount + } + urlObj.set('query', queryObj) + console.log('Approving moonpay sell quote url=' + urlObj.href) + + let inPayment = false + + const openWebView = async (): Promise => { + await new Promise((resolve, reject) => { + navigation.navigate('guiPluginWebView', { + url: urlObj.href, + onUrlChange: async (uri: string): Promise => { + console.log('Moonpay WebView url change: ' + uri) + + if (uri.startsWith(RETURN_URL_PAYMENT)) { + console.log('Moonpay WebView launch payment: ' + uri) + const urlObj = new URL(uri, true) + const { query } = urlObj + const { + baseCurrencyAmount, + baseCurrencyCode, + depositWalletAddress, + depositWalletAddressTag, + transactionId + } = query + + if (inPayment) return + inPayment = true + + try { + if ( + baseCurrencyAmount == null || + baseCurrencyCode == null || + depositWalletAddress == null || + transactionId == null + ) { + throw new Error('Moonpay missing parameters') + } + + const nativeAmount = mul( + baseCurrencyAmount, + getCurrencyCodeMultiplier( + coreWallet.currencyConfig, + displayCurrencyCode + ) + ) + + const assetAction: EdgeAssetAction = { + assetActionType: 'sell' + } + const savedAction: EdgeTxActionFiat = { + actionType: 'fiat', + orderId: transactionId, + orderUri: `${sellWidgetUrl}/transaction_receipt?transactionId=${transactionId}`, + isEstimate: true, + fiatPlugin: { + providerId: pluginId, + providerDisplayName: pluginDisplayName, + supportEmail + }, + payinAddress: depositWalletAddress, + cryptoAsset: { + pluginId: coreWallet.currencyInfo.pluginId, + tokenId, + nativeAmount + }, + fiatAsset: { + fiatCurrencyCode, + fiatAmount + } + } + + const spendInfo: EdgeSpendInfo = { + tokenId, + assetAction, + savedAction, + spendTargets: [ + { + nativeAmount, + publicAddress: depositWalletAddress + } + ] + } + + if (depositWalletAddressTag != null) { + spendInfo.memos = [ + createMemo( + coreWallet.currencyInfo.pluginId, + depositWalletAddressTag + ) + ] + } + + const sendParams: SendScene2Params = { + walletId: coreWallet.id, + tokenId, + spendInfo, + dismissAlert: true, + lockTilesMap: { + address: true, + amount: true, + wallet: true + }, + hiddenFeaturesMap: { + address: true + }, + onDone: async (error, tx): Promise => { + if (error != null) { + throw error + } + if (tx == null) { + throw new Error(SendErrorNoTransaction) + } + + onLogEvent('Sell_Success', { + conversionValues: { + conversionType: 'sell', + destFiatCurrencyCode: fiatCurrencyCode, + destFiatAmount: fiatAmount, + sourceAmount: new CryptoAmount({ + currencyConfig: coreWallet.currencyConfig, + currencyCode: displayCurrencyCode, + exchangeAmount: baseCurrencyAmount + }), + fiatProviderId: pluginId, + orderId: transactionId + } + }) + + if (tokenId != null) { + const params: SaveTxActionParams = { + walletId: coreWallet.id, + tokenId, + txid: tx.txid, + savedAction, + assetAction: { + ...assetAction, + assetActionType: 'sell' + } + } + await coreWallet.saveTxAction({ + txid: params.txid, + tokenId: params.tokenId, + assetAction: params.assetAction, + savedAction: params.savedAction + }) + } + + navigation.pop() + + const message = + sprintf( + lstrings.fiat_plugin_sell_complete_message_s, + cryptoAmount, + displayCurrencyCode, + fiatAmount, + displayFiatCurrencyCode, + '1' + ) + + '\n\n' + + sprintf( + lstrings.fiat_plugin_sell_complete_message_2_hour_s, + '1' + ) + + '\n\n' + + lstrings.fiat_plugin_sell_complete_message_3 + + await showButtonsModal({ + buttons: { + ok: { + label: lstrings.string_ok, + type: 'primary' + } + }, + title: lstrings.fiat_plugin_sell_complete_title, + message + }) + resolve() + }, + onBack: () => { + reject(new Error(SendErrorBackPressed)) + } + } + + navigation.navigate('send2', sendParams) + } catch (e: unknown) { + navigation.pop() + await openWebView() + + if ( + e instanceof Error && + e.message === SendErrorNoTransaction + ) { + showToast( + lstrings.fiat_plugin_sell_failed_to_send_try_again, + NOT_SUCCESS_TOAST_HIDE_MS + ) + } else if ( + e instanceof Error && + e.message === SendErrorBackPressed + ) { + // Do nothing + } else { + showError(e) + } + } finally { + inPayment = false + } + } + } + }) + }) + } + await openWebView() + } + }, + closeQuote: async (): Promise => { + rampDeeplinkManager.unregister() + } + } + return [quote] + } + } + + return plugin +} diff --git a/src/plugins/ramps/moonpay/moonpayRampTypes.ts b/src/plugins/ramps/moonpay/moonpayRampTypes.ts new file mode 100644 index 00000000000..a9f3a5f07ac --- /dev/null +++ b/src/plugins/ramps/moonpay/moonpayRampTypes.ts @@ -0,0 +1,109 @@ +import { + asArray, + asBoolean, + asEither, + asNull, + asNumber, + asObject, + asOptional, + asString, + asValue +} from 'cleaners' + +export const asMetadata = asObject({ + contractAddress: asEither(asString, asNull), + networkCode: asString +}) + +export const asMoonpayCurrency = asObject({ + type: asValue('crypto', 'fiat'), + code: asString, + name: asString, + maxAmount: asEither(asNumber, asNull), + minAmount: asEither(asNumber, asNull), + maxBuyAmount: asEither(asNumber, asNull), + minBuyAmount: asEither(asNumber, asNull), + maxSellAmount: asOptional(asNumber), + minSellAmount: asOptional(asNumber), + metadata: asOptional(asMetadata), + isSellSupported: asOptional(asBoolean), + isSuspended: asOptional(asBoolean), + isSupportedInUS: asOptional(asBoolean) +}) +export type MoonpayCurrency = ReturnType + +export const asMoonpayCurrencies = asArray(asMoonpayCurrency) + +export const asMoonpaySellQuote = asObject({ + baseCurrencyCode: asString, + baseCurrencyAmount: asNumber, + quoteCurrencyAmount: asNumber +}) + +export const asMoonpayBuyQuote = asObject({ + baseCurrencyCode: asString, + baseCurrencyAmount: asNumber, + quoteCurrencyAmount: asNumber, + quoteCurrencyCode: asString, + totalAmount: asNumber +}) + +export const asMoonpayQuote = asEither(asMoonpayBuyQuote, asMoonpaySellQuote) + +export const asState = asObject({ + code: asString, + isBuyAllowed: asBoolean, + isSellAllowed: asBoolean, + isAllowed: asBoolean +}) + +export const asMoonpayCountry = asObject({ + alpha2: asString, + isAllowed: asBoolean, + isBuyAllowed: asBoolean, + isSellAllowed: asBoolean, + states: asOptional(asArray(asState)) +}) + +export const asMoonpayCountries = asArray(asMoonpayCountry) + +export const asApiKeys = asString + +// Init options cleaner for moonpay ramp plugin +export const asInitOptions = asObject({ + apiKey: asOptional(asString), + apiUrl: asOptional(asString, 'https://api.moonpay.com'), + buyWidgetUrl: asOptional(asString, 'https://buy.moonpay.com'), + sellWidgetUrl: asOptional(asString, 'https://sell.moonpay.com') +}) + +export type MoonpayPaymentMethod = + | 'ach_bank_transfer' + | 'credit_debit_card' + | 'paypal' + | 'venmo' + +export interface MoonpayWidgetQueryParams { + apiKey: string + lockAmount: true + showAllCurrencies: false + paymentMethod: MoonpayPaymentMethod + redirectURL: string +} + +export type MoonpayBuyWidgetQueryParams = MoonpayWidgetQueryParams & { + currencyCode: string + baseCurrencyCode: string + walletAddress: string + enableRecurringBuys: false + quoteCurrencyAmount?: number + baseCurrencyAmount?: number +} + +export type MoonpaySellWidgetQueryParams = MoonpayWidgetQueryParams & { + quoteCurrencyCode: string + baseCurrencyCode: string + refundWalletAddress: string + quoteCurrencyAmount?: number + baseCurrencyAmount?: number +} diff --git a/src/plugins/ramps/paybis/paybisRampPlugin.ts b/src/plugins/ramps/paybis/paybisRampPlugin.ts new file mode 100644 index 00000000000..a20103fb431 --- /dev/null +++ b/src/plugins/ramps/paybis/paybisRampPlugin.ts @@ -0,0 +1,1343 @@ +import { eq, lte, mul, round } from 'biggystring' +import { + asArray, + asBoolean, + asDate, + asMaybe, + asObject, + asOptional, + asString, + asValue +} from 'cleaners' +import type { + EdgeAssetAction, + EdgeFetchOptions, + EdgeSpendInfo, + EdgeTransaction, + EdgeTxActionFiat, + JsonObject +} from 'edge-core-js' +import { Platform } from 'react-native' +import { CustomTabs } from 'react-native-custom-tabs' +import SafariView from 'react-native-safari-view' +import { sprintf } from 'sprintf-js' +import URL from 'url-parse' + +import { showButtonsModal } from '../../../components/modals/ButtonsModal' +import type { SendScene2Params } from '../../../components/scenes/SendScene2' +import { + showError, + showToast, + showToastSpinner +} from '../../../components/services/AirshipInstance' +import { requestPermissionOnSettings } from '../../../components/services/PermissionsManager' +import { EDGE_CONTENT_SERVER_URI } from '../../../constants/CdnConstants' +import { locale } from '../../../locales/intl' +import { lstrings } from '../../../locales/strings' +import type { EdgeAsset, StringMap } from '../../../types/types' +import { sha512HashAndSign } from '../../../util/crypto' +import { CryptoAmount } from '../../../util/CryptoAmount' +import { getCurrencyCodeMultiplier } from '../../../util/CurrencyInfoHelpers' +import { + getHistoricalCryptoRate, + getHistoricalFiatRate +} from '../../../util/exchangeRates' +import { makeUuid } from '../../../util/rnUtils' +import { removeIsoPrefix } from '../../../util/utils' +import { + SendErrorBackPressed, + SendErrorNoTransaction +} from '../../gui/fiatPlugin' +import type { + FiatDirection, + FiatPaymentType, + SaveTxActionParams +} from '../../gui/fiatPluginTypes' +import type { + FiatProviderAssetMap, + FiatProviderSupportedRegions +} from '../../gui/fiatProviderTypes' +import { assert, isWalletTestnet } from '../../gui/pluginUtils' +import { + NOT_SUCCESS_TOAST_HIDE_MS, + RETURN_URL_FAIL, + RETURN_URL_PAYMENT, + RETURN_URL_SUCCESS, + validateRegion +} from '../../gui/providers/common' +import { addTokenToArray } from '../../gui/util/providerUtils' +import { rampDeeplinkManager, type RampLink } from '../rampDeeplinkHandler' +import type { + RampApproveQuoteParams, + RampCheckSupportRequest, + RampInfo, + RampPlugin, + RampPluginConfig, + RampPluginFactory, + RampQuoteRequest, + RampQuoteResult, + RampSupportResult +} from '../rampPluginTypes' +import { asInitOptions } from './paybisRampTypes' + +const pluginId = 'paybis' +const partnerIcon = `${EDGE_CONTENT_SERVER_URI}/paybis.png` +const pluginDisplayName = 'Paybis' +const providerDisplayName = pluginDisplayName +const supportEmail = 'support@paybis.com' + +type AllowedPaymentTypes = Record< + FiatDirection, + Partial> +> + +const allowedPaymentTypes: AllowedPaymentTypes = { + buy: { + iach: true, + applepay: true, + credit: true, + googlepay: true, + pix: true, + pse: true, + revolut: true, + spei: true + }, + sell: { + iach: true, + colombiabank: true, + credit: true, + mexicobank: true, + pix: true + } +} + +const asPaymentMethodId = asValue( + 'method-id-credit-card', + 'method-id-credit-card-out', + 'method-id_bridgerpay_revolutpay', + 'method-id-trustly', + 'fake-id-googlepay', + 'fake-id-applepay', + 'method-id_bridgerpay_directa24_pse', + 'method-id_bridgerpay_directa24_colombia_payout', + 'method-id_bridgerpay_directa24_spei', + 'method-id_bridgerpay_directa24_mexico_payout', + 'method-id_bridgerpay_directa24_pix', + 'method-id_bridgerpay_directa24_pix_payout' +) + +const asCurrencyAndCode = asObject({ + currency: asString, + currencyCode: asString +}) + +const asPaymentMethodPair = asObject({ + from: asString, + to: asArray(asCurrencyAndCode) +}) + +const asPaymentMethodPairs = asObject({ + name: asMaybe(asPaymentMethodId), + pairs: asArray(asPaymentMethodPair) +}) + +const asPaybisBuyPairs = asObject({ + data: asArray(asPaymentMethodPairs) +}) + +const asSellPair = asObject({ + fromAssetId: asString, + to: asArray(asString) +}) + +const asSellPaymentMethodPairs = asObject({ + name: asMaybe(asPaymentMethodId), + pairs: asArray(asSellPair) +}) + +const asPaybisSellPairs = asObject({ + data: asArray(asSellPaymentMethodPairs) +}) + +const asAmountCurrency = asObject({ + amount: asString, + currencyCode: asString +}) + +const asQuotePaymentMethod = asObject({ + id: asPaymentMethodId, + amountTo: asAmountCurrency, + amountFrom: asAmountCurrency, + fees: asObject({ + networkFee: asAmountCurrency, + serviceFee: asAmountCurrency, + totalFee: asAmountCurrency + }), + expiration: asDate, + expiresAt: asDate +}) + +const asQuotePaymentErrors = asObject({ + paymentMethod: asOptional(asPaymentMethodId), + payoutMethod: asOptional(asPaymentMethodId), + error: asObject({ + message: asString + }) +}) + +const asQuote = asObject({ + id: asString, + currencyCodeTo: asString, + currencyCodeFrom: asString, + requestedAmount: asObject({ + amount: asString, + currencyCode: asString + }), + requestedAmountType: asValue('from', 'to'), + paymentMethods: asOptional(asArray(asQuotePaymentMethod)), + payoutMethods: asOptional(asArray(asQuotePaymentMethod)), + paymentMethodErrors: asOptional(asArray(asQuotePaymentErrors)), + payoutMethodErrors: asOptional(asArray(asQuotePaymentErrors)) +}) + +const asPaymentDetails = asObject({ + assetId: asString, + blockchain: asString, + network: asString, + depositAddress: asString, + destinationTag: asOptional(asString), + currencyCode: asString, + amount: asString +}) + +const asPublicRequestResponse = asObject({ + requestId: asString, + oneTimeToken: asOptional(asString) +}) + +const asUserStatus = asObject({ + hasTransactions: asBoolean +}) + +type PaymentMethodId = ReturnType +type PaybisBuyPairs = ReturnType +type PaybisSellPairs = ReturnType + +interface ExtendedTokenId extends EdgeAsset { + currencyCode?: string +} + +const ensureIsoPrefix = (currencyCode: string): string => { + return currencyCode.startsWith('iso:') ? currencyCode : `iso:${currencyCode}` +} + +const FIAT_DECIMALS = -2 +const CRYPTO_DECIMALS = -8 + +const PAYBIS_TO_EDGE_CURRENCY_MAP: Record = { + AAVE: { + pluginId: 'ethereum', + tokenId: '7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9' + }, + ADA: { pluginId: 'cardano', tokenId: null }, + BAT: { + pluginId: 'ethereum', + tokenId: '0d8775f648430679a709e98d2b0cb6250d2887ef' + }, + BCH: { pluginId: 'bitcoincash', tokenId: null }, + BNB: { pluginId: 'binancechain', tokenId: null }, + BTC: { pluginId: 'bitcoin', tokenId: null }, + 'BTC-TESTNET': { + currencyCode: 'TESTBTC', + pluginId: 'bitcointestnet', + tokenId: null + }, + BUSD: { + pluginId: 'binancesmartchain', + tokenId: 'e9e7cea3dedca5984780bafc599bd69add087d56' + }, + COMP: { + pluginId: 'ethereum', + tokenId: 'c00e94cb662c3520282e6f5717214004a7f26888' + }, + CRV: { + pluginId: 'ethereum', + tokenId: 'd533a949740bb3306d119cc777fa900ba034cd52' + }, + DAI: { + pluginId: 'ethereum', + tokenId: '6b175474e89094c44da98b954eedeac495271d0f' + }, + DOGE: { pluginId: 'dogecoin', tokenId: null }, + DOT: { pluginId: 'polkadot', tokenId: null }, + ETH: { pluginId: 'ethereum', tokenId: null }, + KNC: { + pluginId: 'ethereum', + tokenId: 'defa4e8a7bcba345f687a2f1456f5edd9ce97202' + }, + LINK: { + pluginId: 'ethereum', + tokenId: '514910771af9ca656af840dff83e8264ecf986ca' + }, + LTC: { pluginId: 'litecoin', tokenId: null }, + MKR: { + pluginId: 'ethereum', + tokenId: '9f8f72aa9304c8b593d555f12ef6589cc3a579a2' + }, + POL: { currencyCode: 'POL', pluginId: 'polygon', tokenId: null }, + SHIB: { + pluginId: 'ethereum', + tokenId: '95ad61b0a150d79219dcf64e1e6cc01f0b64c4ce' + }, + SOL: { pluginId: 'solana', tokenId: null }, + SUSHI: { + pluginId: 'ethereum', + tokenId: '6b3595068778dd592e39a122f4f5a5cf09c90fe2' + }, + TON: { pluginId: 'ton', tokenId: null }, + TRX: { pluginId: 'tron', tokenId: null }, + USDC: { + pluginId: 'ethereum', + tokenId: 'a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + }, + USDT: { + pluginId: 'ethereum', + tokenId: 'dac17f958d2ee523a2206206994597c13d831ec7' + }, + 'USDT-TRC20': { + currencyCode: 'USDT', + pluginId: 'tron', + tokenId: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t' + }, + WBTC: { + pluginId: 'ethereum', + tokenId: '2260fac5e5542a773aa44fbcfedf7c193bc2c599' + }, + XLM: { pluginId: 'stellar', tokenId: null }, + XRP: { pluginId: 'ripple', tokenId: null }, + XTZ: { pluginId: 'tezos', tokenId: null }, + YFI: { + pluginId: 'ethereum', + tokenId: '0bc529c00c6401aef6d220be8c6ea1667f6ad93e' + } +} + +const EDGE_TO_PAYBIS_CURRENCY_MAP: StringMap = Object.entries( + PAYBIS_TO_EDGE_CURRENCY_MAP +).reduce((prev, [paybisCc, edgeToken]) => { + return { + ...prev, + [`${edgeToken.pluginId}_${edgeToken.tokenId ?? ''}`]: paybisCc + } +}, {}) + +const PAYMENT_METHOD_MAP: Record = { + 'method-id-trustly': 'iach', + 'method-id-credit-card': 'credit', + 'method-id-credit-card-out': 'credit', + 'method-id_bridgerpay_revolutpay': 'revolut', + 'fake-id-googlepay': 'googlepay', + 'fake-id-applepay': 'applepay', + 'method-id_bridgerpay_directa24_pse': 'pse', + 'method-id_bridgerpay_directa24_colombia_payout': 'colombiabank', + 'method-id_bridgerpay_directa24_spei': 'spei', + 'method-id_bridgerpay_directa24_mexico_payout': 'mexicobank', + 'method-id_bridgerpay_directa24_pix': 'pix', + 'method-id_bridgerpay_directa24_pix_payout': 'pix' +} + +const REVERSE_PAYMENT_METHOD_MAP: Partial< + Record +> = { + iach: 'method-id-trustly', + applepay: 'method-id-credit-card', + credit: 'method-id-credit-card', + googlepay: 'method-id-credit-card', + pix: 'method-id_bridgerpay_directa24_pix', + pse: 'method-id_bridgerpay_directa24_pse', + revolut: 'method-id_bridgerpay_revolutpay', + spei: 'method-id_bridgerpay_directa24_spei' +} + +const SELL_REVERSE_PAYMENT_METHOD_MAP: Partial< + Record +> = { + credit: 'method-id-credit-card-out', + colombiabank: 'method-id_bridgerpay_directa24_colombia_payout', + mexicobank: 'method-id_bridgerpay_directa24_mexico_payout', + pix: 'method-id_bridgerpay_directa24_pix_payout' +} + +const SUPPORTED_REGIONS: FiatProviderSupportedRegions = { + US: { + notStateProvinces: ['HI', 'NY'] + } +} + +interface PaybisPairs { + buy: PaybisBuyPairs | undefined + sell: PaybisSellPairs | undefined +} + +interface PaybisPluginState { + apiKey: string + apiUrl: string + privateKeyB64: string + partnerUserId: string +} + +const paybisFetch = async (params: { + method: 'POST' | 'GET' + url: string + path: string + apiKey: string + bodyParams?: object + queryParams?: JsonObject + privateKey?: string + promoCode?: string +}): Promise => { + const { + method, + url, + path, + apiKey, + bodyParams, + queryParams = {}, + promoCode, + privateKey + } = params + const urlObj = new URL(url + '/' + path, true) + const body = bodyParams != null ? JSON.stringify(bodyParams) : undefined + + let signature: string | undefined + if (privateKey != null) { + if (body == null) throw new Error('Paybis: Cannot sign without body') + // Wait for next animation frame + await new Promise(resolve => requestAnimationFrame(resolve)) + signature = sha512HashAndSign(body, privateKey) + } + queryParams.apikey = apiKey + + if (promoCode != null) { + queryParams.promoCode = promoCode + } + urlObj.set('query', queryParams) + + const options: EdgeFetchOptions = { + method, + headers: { + 'Content-Type': 'application/json' + } + } + if (signature != null) { + options.headers = { + ...options.headers, + 'x-request-signature': signature + } + } + + if (body != null) { + options.body = body + } + const response = await fetch(urlObj.href, options) + if (!response.ok) { + const text = await response.text() + throw new Error(text) + } + + const reply = await response.json() + return reply +} + +export const paybisRampPlugin: RampPluginFactory = ( + pluginConfig: RampPluginConfig +) => { + const initOptions = asInitOptions(pluginConfig.initOptions) + const { account, navigation, onLogEvent, disklet } = pluginConfig + + const rampInfo: RampInfo = { + partnerIcon, + pluginDisplayName + } + + let state: PaybisPluginState | undefined + const paybisPairs: PaybisPairs = { buy: undefined, sell: undefined } + let userIdHasTransactions: boolean | undefined + const allowedCurrencyCodes: Record< + FiatDirection, + Partial> + > = { + buy: { credit: { providerId: pluginId, fiat: {}, crypto: {} } }, + sell: { credit: { providerId: pluginId, fiat: {}, crypto: {} } } + } + + const initializeBuyPairs = async (): Promise => { + if (state == null) throw new Error('Plugin not initialized') + const { apiKey, apiUrl: url } = state + + if (paybisPairs.buy == null) { + const response = await paybisFetch({ + method: 'GET', + url, + path: `v2/public/currency/pairs/buy-crypto`, + apiKey + }) + paybisPairs.buy = asPaybisBuyPairs(response) + } + + if (paybisPairs.buy != null) { + const ccMethod = paybisPairs.buy.data.find( + pair => pair.name === 'method-id-credit-card' + ) + if (ccMethod != null) { + paybisPairs.buy.data.push({ + name: 'fake-id-googlepay', + pairs: ccMethod.pairs + }) + paybisPairs.buy.data.push({ + name: 'fake-id-applepay', + pairs: ccMethod.pairs + }) + } + + for (const paymentMethodPairs of paybisPairs.buy.data) { + const { name, pairs } = paymentMethodPairs + if (name == null) continue + const edgePaymentType = PAYMENT_METHOD_MAP[name] + if (edgePaymentType == null) continue + for (const pair of pairs) { + const { from, to } = pair + + let paymentMethodObj = allowedCurrencyCodes.buy[edgePaymentType] + if (paymentMethodObj == null) { + paymentMethodObj = { providerId: pluginId, crypto: {}, fiat: {} } + allowedCurrencyCodes.buy[edgePaymentType] = paymentMethodObj + } + paymentMethodObj.fiat[`iso:${from}`] = true + + for (const code of to) { + const edgeTokenId = PAYBIS_TO_EDGE_CURRENCY_MAP[code.currencyCode] + if (edgeTokenId != null) { + const { pluginId: currencyPluginId } = edgeTokenId + let tokens = paymentMethodObj.crypto[currencyPluginId] + if (tokens == null) { + tokens = [] + paymentMethodObj.crypto[currencyPluginId] = tokens + } + addTokenToArray({ tokenId: edgeTokenId.tokenId }, tokens) + } + } + } + } + } + } + + const initializeSellPairs = async (): Promise => { + if (state == null) throw new Error('Plugin not initialized') + const { apiKey, apiUrl: url } = state + + if (paybisPairs.sell == null) { + const response = await paybisFetch({ + method: 'GET', + url, + path: `v2/public/currency/pairs/sell-crypto`, + apiKey + }) + paybisPairs.sell = asPaybisSellPairs(response) + } + + if (paybisPairs.sell != null) { + for (const paymentMethodPairs of paybisPairs.sell.data) { + const { name, pairs } = paymentMethodPairs + if (name == null) continue + const edgePaymentType = PAYMENT_METHOD_MAP[name] + if (edgePaymentType == null) continue + for (const pair of pairs) { + const { fromAssetId, to } = pair + + let paymentMethodObj = allowedCurrencyCodes.sell[edgePaymentType] + if (paymentMethodObj == null) { + paymentMethodObj = { providerId: pluginId, crypto: {}, fiat: {} } + allowedCurrencyCodes.sell[edgePaymentType] = paymentMethodObj + } + + const edgeTokenId = PAYBIS_TO_EDGE_CURRENCY_MAP[fromAssetId] + if (edgeTokenId == null) continue + const { pluginId: currencyPluginId } = edgeTokenId + + let tokens = paymentMethodObj.crypto[currencyPluginId] + if (tokens == null) { + tokens = [] + paymentMethodObj.crypto[currencyPluginId] = tokens + } + addTokenToArray({ tokenId: edgeTokenId.tokenId }, tokens) + + for (const fiat of to) { + paymentMethodObj.fiat[`iso:${fiat}`] = true + } + } + } + } + } + + const ensureStateInitialized = async (): Promise => { + if (state == null) { + const { apiKey, apiUrl, privateKeyB64 } = initOptions + + let partnerUserId: string + if (pluginConfig.store != null) { + partnerUserId = await pluginConfig.store + .getItem('partnerUserId') + .catch(() => '') + if (partnerUserId === '') { + partnerUserId = await makeUuid() + await pluginConfig.store.setItem('partnerUserId', partnerUserId) + } + } else { + partnerUserId = await makeUuid() + } + + state = { + apiKey, + apiUrl, + privateKeyB64, + partnerUserId + } + } + } + + const ensureAssetsInitialized = async ( + direction: 'buy' | 'sell' + ): Promise => { + await ensureStateInitialized() + + if (direction === 'buy') { + await initializeBuyPairs() + } else { + await initializeSellPairs() + } + } + + const checkAssetSupport = ( + direction: FiatDirection, + fiatCurrencyCode: string, + asset: EdgeAsset + ): { supported: false } | undefined => { + // Check if crypto is supported + const paybisCc = + EDGE_TO_PAYBIS_CURRENCY_MAP[`${asset.pluginId}_${asset.tokenId ?? ''}`] + if (paybisCc == null) { + return { supported: false } + } + + // Check if fiat/crypto pair is supported in any payment type + const fiat = removeIsoPrefix(fiatCurrencyCode) + const pairs = paybisPairs[direction]?.data + if (pairs == null) { + return { supported: false } + } + + // Check if the pair exists in any payment method + let pairSupported = false + for (const paymentMethodPairs of pairs) { + if (direction === 'buy') { + const buyPairs = paymentMethodPairs as ReturnType< + typeof asPaymentMethodPairs + > + for (const pair of buyPairs.pairs) { + if ( + pair.from === fiat && + pair.to.some(to => to.currencyCode === paybisCc) + ) { + pairSupported = true + break + } + } + } else { + const sellPairs = paymentMethodPairs as ReturnType< + typeof asSellPaymentMethodPairs + > + for (const pair of sellPairs.pairs) { + if (pair.fromAssetId === paybisCc && pair.to.includes(fiat)) { + pairSupported = true + break + } + } + } + if (pairSupported) break + } + + if (!pairSupported) { + return { supported: false } + } + + return undefined + } + + const plugin: RampPlugin = { + pluginId, + rampInfo, + + checkSupport: async ( + request: RampCheckSupportRequest + ): Promise => { + const { direction, regionCode, fiatAsset, cryptoAsset } = request + + // Ensure assets are initialized for the direction + await ensureAssetsInitialized(direction) + + // Validate region restrictions + if (regionCode != null) { + try { + validateRegion(pluginId, regionCode, SUPPORTED_REGIONS) + } catch (error) { + return { supported: false } + } + } + + // Check asset support + const assetResult = checkAssetSupport( + direction, + ensureIsoPrefix(fiatAsset.currencyCode), + cryptoAsset + ) + if (assetResult != null) { + return assetResult + } + + // If we get here, it's supported + return { supported: true } + }, + + fetchQuote: async ( + request: RampQuoteRequest + ): Promise => { + await ensureStateInitialized() + if (state == null) throw new Error('Plugin state not initialized') + + const { + amountType, + exchangeAmount, + regionCode, + pluginId: currencyPluginId, + promoCode: maybePromoCode, + fiatCurrencyCode, + displayCurrencyCode, + direction, + tokenId + } = request + + const isMaxAmount = + typeof exchangeAmount === 'object' && exchangeAmount.max + const exchangeAmountString = isMaxAmount ? '' : (exchangeAmount as string) + + // Validate region restrictions + if (regionCode != null) { + validateRegion(pluginId, regionCode, SUPPORTED_REGIONS) + } + + // Initialize assets for the direction + await ensureAssetsInitialized(direction) + + // Get all supported payment types for the direction + let allPaymentTypes = Object.keys(allowedPaymentTypes[direction]).filter( + key => allowedPaymentTypes[direction][key as FiatPaymentType] === true + ) as FiatPaymentType[] + + // Filter out credit for sell in US + if (direction === 'sell' && regionCode.countryCode === 'US') { + allPaymentTypes = allPaymentTypes.filter(pt => pt !== 'credit') + } + + if (allPaymentTypes.length === 0) { + // Return empty array if no payment types supported + return [] + } + + // Update user transaction status + try { + const response = await paybisFetch({ + method: 'GET', + url: state.apiUrl, + path: `v2/public/user/${state.partnerUserId}/status`, + apiKey: state.apiKey + }) + const { hasTransactions } = asUserStatus(response) + userIdHasTransactions = hasTransactions + } catch (e) { + console.warn(`Paybis: Error getting user status: ${String(e)}`) + } + + const pairs = paybisPairs[direction]?.data + if (pairs == null) { + // Return empty array if pairs not loaded + return [] + } + + const fiat = removeIsoPrefix(fiatCurrencyCode) + + // Check asset support using helper + const cryptoAsset: EdgeAsset = { + pluginId: currencyPluginId, + tokenId: tokenId ?? null + } + const assetResult = checkAssetSupport( + direction, + ensureIsoPrefix(fiatCurrencyCode), + cryptoAsset + ) + if (assetResult != null) { + // Return empty array for unsupported asset pairs + return [] + } + + const paybisCc = + EDGE_TO_PAYBIS_CURRENCY_MAP[`${currencyPluginId}_${tokenId ?? ''}`] + + // Create array to store all quotes + const quotes: RampQuoteResult[] = [] + + // Get quote for each supported payment type + for (const paymentType of allPaymentTypes) { + try { + const paymentMethod = + direction === 'buy' + ? REVERSE_PAYMENT_METHOD_MAP[paymentType] + : SELL_REVERSE_PAYMENT_METHOD_MAP[paymentType] + + if (paymentMethod == null) continue // Skip unsupported payment types + + let currencyCodeFrom + let currencyCodeTo + let directionChange: 'from' | 'to' + let amount + + if (isMaxAmount) { + // Use default max amounts + amount = amountType === 'fiat' ? '10000' : '10' + } else { + amount = exchangeAmountString + } + + if (direction === 'buy') { + currencyCodeFrom = fiat + currencyCodeTo = paybisCc + if (amountType === 'fiat') { + directionChange = 'from' + amount = isMaxAmount ? amount : round(amount, FIAT_DECIMALS) + } else { + directionChange = 'to' + amount = isMaxAmount ? amount : round(amount, CRYPTO_DECIMALS) + } + } else { + currencyCodeFrom = paybisCc + currencyCodeTo = fiat + if (amountType === 'fiat') { + amount = isMaxAmount ? amount : round(amount, FIAT_DECIMALS) + directionChange = 'to' + } else { + amount = isMaxAmount ? amount : round(amount, CRYPTO_DECIMALS) + directionChange = 'from' + } + } + + const bodyParams = { + currencyCodeFrom, + amount, + currencyCodeTo, + directionChange, + isReceivedAmount: directionChange === 'to', + paymentMethod: direction === 'buy' ? paymentMethod : undefined, + payoutMethod: direction === 'sell' ? paymentMethod : undefined + } + + let promoCode: string | undefined + if (maybePromoCode != null && !isMaxAmount) { + const isoNow = new Date().toISOString() + let amountUsd: string + const convertFromCc = + amountType === 'fiat' ? fiatCurrencyCode : displayCurrencyCode + if (convertFromCc === 'iso:USD') { + amountUsd = exchangeAmountString + } else if (convertFromCc.startsWith('iso:')) { + const rate = await getHistoricalFiatRate( + convertFromCc, + 'iso:USD', + isoNow + ) + amountUsd = mul(exchangeAmountString, String(rate)) + } else { + const rate = await getHistoricalCryptoRate( + currencyPluginId, + tokenId, + 'iso:USD', + isoNow + ) + amountUsd = mul(exchangeAmountString, String(rate)) + } + // Only use the promo code if the user is requesting $1000 USD or less + if (lte(amountUsd, '1000')) { + // Only use the promoCode if this is the user's first purchase + if (userIdHasTransactions === false) { + promoCode = maybePromoCode + } + } + } + + const response = await paybisFetch({ + method: 'POST', + url: state.apiUrl, + path: 'v2/public/quote', + apiKey: state.apiKey, + bodyParams, + promoCode + }) + + const { + id: quoteId, + paymentMethods, + paymentMethodErrors, + payoutMethods, + payoutMethodErrors + } = asQuote(response) + + const pmErrors = paymentMethodErrors ?? payoutMethodErrors + if (pmErrors != null) { + // Skip this payment type if there are errors + console.warn(`Paybis: Quote error for ${paymentType}:`, pmErrors) + continue + } + + let pmQuote + if (direction === 'buy' && paymentMethods?.length === 1) { + pmQuote = paymentMethods[0] + } else if (direction === 'sell' && payoutMethods?.length === 1) { + pmQuote = payoutMethods[0] + } else { + console.warn( + `Paybis: Invalid number of quoted payment methods for ${paymentType}` + ) + continue + } + + const { id: paymentMethodId, amountFrom, amountTo } = pmQuote + + let cryptoAmount: string + let fiatAmount: string + + if (directionChange === 'from') { + assert( + eq(amount, amountFrom.amount), + 'Quote not equal to requested from amount' + ) + } else { + assert( + eq(amount, amountTo.amount), + 'Quote not equal to requested to amount' + ) + } + + if (direction === 'buy') { + fiatAmount = amountFrom.amount + cryptoAmount = amountTo.amount + } else { + fiatAmount = amountTo.amount + cryptoAmount = amountFrom.amount + } + + const quote: RampQuoteResult = { + pluginId, + partnerIcon, + pluginDisplayName: 'Paybis', + displayCurrencyCode, + cryptoAmount, + isEstimate: false, + fiatCurrencyCode, + fiatAmount, + direction, + regionCode, + paymentType, + expirationDate: new Date(Date.now() + 60000), + settlementRange: { + min: { value: 5, unit: 'minutes' }, + max: { value: 24, unit: 'hours' } + }, + approveQuote: async ( + approveParams: RampApproveQuoteParams + ): Promise => { + const { coreWallet } = approveParams + const deniedPermission = await requestPermissionOnSettings( + disklet, + 'camera', + pluginDisplayName, + true + ) + if (deniedPermission) { + showToast( + lstrings.fiat_plugin_cannot_continue_camera_permission + ) + return + } + const receiveAddress = await coreWallet.getReceiveAddress({ + tokenId: null + }) + + let bodyParams + if (direction === 'buy') { + bodyParams = { + cryptoWalletAddress: { + currencyCode: paybisCc, + address: + receiveAddress.segwitAddress ?? + receiveAddress.publicAddress + }, + partnerUserId: state!.partnerUserId, + locale: locale.localeIdentifier.slice(0, 2), + passwordless: true, + trustedKyc: false, + quoteId, + flow: 'buyCrypto', + paymentMethod: paymentMethodId + } + } else { + bodyParams = { + cryptoPaymentMethod: 'partner_controlled_with_redirect', + partnerUserId: state!.partnerUserId, + locale: locale.localeIdentifier.slice(0, 2), + passwordless: true, + trustedKyc: false, + quoteId, + flow: 'sellCrypto', + depositCallbackUrl: RETURN_URL_PAYMENT, + paymentMethod: paymentMethodId + } + } + + const privateKey = atob(state!.privateKeyB64) + const promise = paybisFetch({ + method: 'POST', + url: state!.apiUrl, + path: 'v2/public/request', + apiKey: state!.apiKey, + bodyParams, + promoCode, + privateKey + }) + const response = await showToastSpinner( + lstrings.fiat_plugin_finalizing_quote, + promise + ) + const { oneTimeToken, requestId } = + asPublicRequestResponse(response) + + const widgetUrl = isWalletTestnet(coreWallet) + ? initOptions.widgetTestnetUrl + : initOptions.widgetUrl + + const ott = + oneTimeToken != null ? `&oneTimeToken=${oneTimeToken}` : '' + const promoCodeParam = + promoCode != null ? `&promoCode=${promoCode}` : '' + + if (direction === 'buy') { + const successReturnURL = encodeURIComponent( + 'https://return.edge.app/ramp/buy/paybis?transactionStatus=success' + ) + const failureReturnURL = encodeURIComponent( + 'https://return.edge.app/ramp/buy/paybis?transactionStatus=fail' + ) + + // Register deeplink handler + rampDeeplinkManager.register( + direction, + pluginId, + async (link: RampLink) => { + const { query, uri } = link + console.log('Paybis WebView launch buy success: ' + uri) + const { transactionStatus } = query + if (transactionStatus === 'success') { + onLogEvent('Buy_Success', { + conversionValues: { + conversionType: 'buy', + sourceFiatCurrencyCode: fiatCurrencyCode, + sourceFiatAmount: fiatAmount, + destAmount: new CryptoAmount({ + currencyConfig: coreWallet.currencyConfig, + currencyCode: displayCurrencyCode, + exchangeAmount: cryptoAmount + }), + fiatProviderId: pluginId, + orderId: requestId + } + }) + const message = + sprintf( + lstrings.fiat_plugin_buy_complete_message_s, + cryptoAmount, + displayCurrencyCode, + fiatAmount, + fiat, + '1' + ) + + '\n\n' + + sprintf( + lstrings.fiat_plugin_buy_complete_message_2_hour_s, + '1' + ) + + '\n\n' + + lstrings.fiat_plugin_sell_complete_message_3 + + // Show success modal + await showButtonsModal({ + buttons: { + ok: { label: lstrings.string_ok } + }, + title: lstrings.fiat_plugin_buy_complete_title, + message + }) + } else if (transactionStatus === 'fail') { + showToast( + lstrings.fiat_plugin_buy_failed_try_again, + NOT_SUCCESS_TOAST_HIDE_MS + ) + } else { + showError( + new Error( + `Paybis: Invalid transactionStatus "${transactionStatus}".` + ) + ) + } + } + ) + + // Open external webview + const url = `${widgetUrl}?requestId=${requestId}${ott}${promoCodeParam}&successReturnURL=${successReturnURL}&failureReturnURL=${failureReturnURL}` + if (Platform.OS === 'ios') { + await SafariView.show({ url }) + } else { + await CustomTabs.openURL(url) + } + + return + } + + const successReturnURL = encodeURIComponent(RETURN_URL_SUCCESS) + const failureReturnURL = encodeURIComponent(RETURN_URL_FAIL) + const webviewUrl = `${widgetUrl}?requestId=${requestId}&successReturnURL=${successReturnURL}&failureReturnURL=${failureReturnURL}${ott}${promoCodeParam}` + console.log(`webviewUrl: ${webviewUrl}`) + let inPayment = false + + const openWebView = async (): Promise => { + navigation.navigate('guiPluginWebView', { + url: webviewUrl, + onUrlChange: newUrl => { + handleUrlChange(newUrl).catch(showError) + } + }) + async function handleUrlChange(newUrl: string): Promise { + console.log(`*** onUrlChange: ${newUrl}`) + if (newUrl.startsWith(RETURN_URL_FAIL)) { + navigation.pop() + showToast( + lstrings.fiat_plugin_sell_failed_try_again, + NOT_SUCCESS_TOAST_HIDE_MS + ) + } else if (newUrl.startsWith(RETURN_URL_PAYMENT)) { + if (inPayment) return + inPayment = true + try { + const payDetails = await paybisFetch({ + method: 'GET', + url: state!.apiUrl, + path: `v2/request/${requestId}/payment-details`, + apiKey: state!.apiKey, + promoCode + }) + const { + assetId, + amount, + currencyCode: pbCurrencyCode, + network, + depositAddress, + destinationTag + } = asPaymentDetails(payDetails) + const { pluginId, tokenId } = + PAYBIS_TO_EDGE_CURRENCY_MAP[assetId] + + console.log(`Creating Paybis payment`) + console.log(` amount: ${amount}`) + console.log(` assetId: ${assetId}`) + console.log(` pbCurrencyCode: ${pbCurrencyCode}`) + console.log(` network: ${network}`) + console.log(` pluginId: ${pluginId}`) + console.log(` tokenId: ${tokenId}`) + const nativeAmount = mul( + amount, + getCurrencyCodeMultiplier( + coreWallet.currencyConfig, + displayCurrencyCode + ) + ) + + const assetAction: EdgeAssetAction = { + assetActionType: 'sell' + } + const savedAction: EdgeTxActionFiat = { + actionType: 'fiat', + orderId: requestId, + orderUri: `${widgetUrl}?requestId=${requestId}`, + isEstimate: true, + fiatPlugin: { + providerId: pluginId, + providerDisplayName, + supportEmail + }, + payinAddress: depositAddress, + cryptoAsset: { + pluginId: coreWallet.currencyInfo.pluginId, + tokenId, + nativeAmount + }, + fiatAsset: { + fiatCurrencyCode, + fiatAmount + } + } + + const spendInfo: EdgeSpendInfo = { + tokenId, + assetAction, + savedAction, + spendTargets: [ + { + nativeAmount, + publicAddress: depositAddress + } + ] + } + + if (destinationTag != null) { + spendInfo.memos = [ + { + type: 'text', + value: destinationTag, + hidden: true + } + ] + } + + const sendParams: SendScene2Params = { + walletId: coreWallet.id, + tokenId, + spendInfo, + lockTilesMap: { + address: true, + amount: true, + wallet: true + }, + hiddenFeaturesMap: { + address: true + } + } + // Navigate to send scene + const tx = await new Promise( + (resolve, reject) => { + navigation.navigate('send2', { + ...sendParams, + onDone: ( + error: Error | null, + edgeTransaction?: EdgeTransaction + ) => { + if (error != null) { + reject(error) + } else if (edgeTransaction != null) { + resolve(edgeTransaction) + } else { + reject(new Error(SendErrorNoTransaction)) + } + }, + onBack: () => { + reject(new Error(SendErrorBackPressed)) + } + }) + } + ) + + // Track conversion + onLogEvent('Sell_Success', { + conversionValues: { + conversionType: 'sell', + destFiatCurrencyCode: fiatCurrencyCode, + destFiatAmount: fiatAmount, + sourceAmount: new CryptoAmount({ + currencyConfig: coreWallet.currencyConfig, + currencyCode: displayCurrencyCode, + exchangeAmount: amount + }), + fiatProviderId: pluginId, + orderId: requestId + } + }) + + if (tokenId != null) { + const params: SaveTxActionParams = { + walletId: coreWallet.id, + tokenId, + txid: tx.txid, + savedAction, + assetAction: { + ...assetAction, + assetActionType: 'sell' + } + } + await account.currencyWallets[ + coreWallet.id + ].saveTxAction(params) + } + + navigation.pop() + await openWebView() + } catch (e: unknown) { + navigation.pop() + await openWebView() + if ( + e instanceof Error && + e.message === SendErrorNoTransaction + ) { + showToast( + lstrings.fiat_plugin_sell_failed_to_send_try_again, + NOT_SUCCESS_TOAST_HIDE_MS + ) + } else if ( + e instanceof Error && + e.message === SendErrorBackPressed + ) { + // Do nothing + } else { + showError(e) + } + } finally { + inPayment = false + } + } + } + } + await openWebView() + }, + closeQuote: async () => {} + } + + quotes.push(quote) + } catch (error) { + console.warn(`Paybis: Failed to get quote for ${paymentType}:`, error) + // Continue with other payment types + } + } + + // Return the quotes array (empty if no quotes found) + return quotes + } + } + + return plugin +} diff --git a/src/plugins/ramps/paybis/paybisRampTypes.ts b/src/plugins/ramps/paybis/paybisRampTypes.ts new file mode 100644 index 00000000000..03be230e720 --- /dev/null +++ b/src/plugins/ramps/paybis/paybisRampTypes.ts @@ -0,0 +1,9 @@ +import { asObject, asOptional, asString } from 'cleaners' + +export const asInitOptions = asObject({ + apiKey: asString, + apiUrl: asOptional(asString, 'https://widget-api.paybis.com'), + privateKeyB64: asString, + widgetUrl: asOptional(asString, 'https://widget.paybis.com'), + widgetTestnetUrl: asOptional(asString, 'https://widget.sandbox.paybis.com') +}) diff --git a/src/plugins/ramps/rampDeeplinkHandler.ts b/src/plugins/ramps/rampDeeplinkHandler.ts new file mode 100644 index 00000000000..979c3559790 --- /dev/null +++ b/src/plugins/ramps/rampDeeplinkHandler.ts @@ -0,0 +1,61 @@ +import { Platform } from 'react-native' +import SafariView from 'react-native-safari-view' + +import { showError } from '../../components/services/AirshipInstance' + +// Copied types to decouple from gui/ +export type FiatDirection = 'buy' | 'sell' + +export interface RampLink { + type: 'ramp' + direction: FiatDirection + providerId: string + path: string + query: Record + uri: string +} + +export type LinkHandler = (url: RampLink) => void | Promise + +interface DeeplinkListener { + direction: FiatDirection + providerId: string + deeplinkHandler: LinkHandler +} + +class RampDeeplinkManager { + private listener: DeeplinkListener | null = null + + register( + direction: FiatDirection, + providerId: string, + deeplinkHandler: LinkHandler + ): void { + this.listener = { direction, providerId, deeplinkHandler } + } + + unregister(): void { + this.listener = null + } + + handleDeeplink(link: RampLink): boolean { + if (this.listener == null) return false + const { direction, providerId, deeplinkHandler } = this.listener + if (link.providerId !== providerId) return false + if (link.direction !== direction) return false + if (Platform.OS === 'ios') SafariView.dismiss() + this.unregister() + + // Handle the promise and catch any errors + const result = deeplinkHandler(link) + if (result instanceof Promise) { + result.catch((error: unknown) => { + showError(error) + }) + } + + return true + } +} + +export const rampDeeplinkManager = new RampDeeplinkManager() diff --git a/src/plugins/ramps/rampPluginTypes.ts b/src/plugins/ramps/rampPluginTypes.ts new file mode 100644 index 00000000000..85db4596317 --- /dev/null +++ b/src/plugins/ramps/rampPluginTypes.ts @@ -0,0 +1,119 @@ +import type { Disklet } from 'disklet' +import type { EdgeAccount, EdgeCurrencyWallet, EdgeTokenId } from 'edge-core-js' + +import type { NavigationBase } from '../../types/routerTypes' +import type { OnLogEvent } from '../../util/tracking' +import type { + FiatPaymentType, + FiatPluginRegionCode +} from '../gui/fiatPluginTypes' +import type { RampPluginStore } from './utils/createStore' + +// Token support type (kept for internal plugin use if needed) +export interface ProviderToken { + tokenId: EdgeTokenId + otherInfo?: unknown +} + +// Support checking types +export interface RampCheckSupportRequest { + direction: 'buy' | 'sell' + regionCode: FiatPluginRegionCode + fiatAsset: { + // ISO currency code (without 'iso:' prefix) + currencyCode: string + } + cryptoAsset: { + pluginId: string + tokenId: EdgeTokenId + } +} + +export interface RampSupportResult { + supported: boolean +} + +export interface RampQuoteRequest { + wallet?: EdgeCurrencyWallet + pluginId: string + tokenId: EdgeTokenId + displayCurrencyCode: string + exchangeAmount: string | { max: true } + fiatCurrencyCode: string + amountType: 'fiat' | 'crypto' + direction: 'buy' | 'sell' + regionCode: FiatPluginRegionCode + promoCode?: string +} + +export interface SettlementRange { + min: { + value: number + unit: 'minutes' | 'hours' | 'days' + } + max: { + value: number + unit: 'minutes' | 'hours' | 'days' + } +} + +export interface RampQuoteResult { + readonly pluginId: string + readonly partnerIcon: string + readonly pluginDisplayName: string + readonly displayCurrencyCode: string + readonly cryptoAmount: string + readonly isEstimate: boolean + readonly fiatCurrencyCode: string + readonly fiatAmount: string + readonly direction: 'buy' | 'sell' + readonly expirationDate?: Date + readonly regionCode: FiatPluginRegionCode + readonly paymentType: FiatPaymentType + readonly settlementRange: SettlementRange + + approveQuote: (params: RampApproveQuoteParams) => Promise + closeQuote: () => Promise +} + +/** + * Parameters passed to the approveQuote function. + */ +export interface RampApproveQuoteParams { + coreWallet: EdgeCurrencyWallet +} + +export interface RampInfo { + readonly partnerIcon: string + readonly pluginDisplayName: string +} + +export interface RampPluginConfig { + initOptions?: unknown + store: RampPluginStore + makeUuid?: () => Promise + + // Dependencies for plugin operations + account: EdgeAccount + navigation: NavigationBase + onLogEvent: OnLogEvent + disklet: Disklet +} + +export interface RampPlugin { + readonly pluginId: string + readonly rampInfo: RampInfo + + /** Used to check if a plugin supports certain direction, region, and asset pair */ + readonly checkSupport: ( + request: RampCheckSupportRequest + ) => Promise + + readonly fetchQuote: ( + request: RampQuoteRequest, + /* to be defined later */ + opts?: unknown + ) => Promise +} + +export type RampPluginFactory = (config: RampPluginConfig) => RampPlugin diff --git a/src/plugins/ramps/revolut/__tests__/revolutRampPlugin.test.ts b/src/plugins/ramps/revolut/__tests__/revolutRampPlugin.test.ts new file mode 100644 index 00000000000..3d2d919a70d --- /dev/null +++ b/src/plugins/ramps/revolut/__tests__/revolutRampPlugin.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from '@jest/globals' + +// Test the validateRegion function behavior +describe('revolutRampPlugin region handling', () => { + // Since validateRegion is not exported, we'll test the behavior indirectly + // by checking the region string construction logic + + it('should create region string with state when stateProvinceCode is present', () => { + const regionCode = { + countryCode: 'US', + stateProvinceCode: 'CA' + } + + // Test the region string construction logic + const region = + regionCode.stateProvinceCode == null + ? regionCode.countryCode + : `${regionCode.countryCode}:${regionCode.stateProvinceCode}` + + expect(region).toBe('US:CA') + }) + + it('should use only country code when stateProvinceCode is not present', () => { + const regionCode: { + countryCode: string + stateProvinceCode: string | undefined + } = { + countryCode: 'US', + stateProvinceCode: undefined + } + + // Test the region string construction logic + const region = + regionCode.stateProvinceCode == null + ? regionCode.countryCode + : `${regionCode.countryCode}:${regionCode.stateProvinceCode}` + + expect(region).toBe('US') + }) + + it('should extract country code from region string with colon', () => { + const regionString = 'US:CA' + + // Test the country extraction logic used in validateRegion + const countryCode = regionString.includes(':') + ? regionString.split(':')[0] + : regionString + + expect(countryCode).toBe('US') + }) + + it('should handle region string without colon', () => { + const regionString = 'US' + + // Test the country extraction logic used in validateRegion + const countryCode = regionString.includes(':') + ? regionString.split(':')[0] + : regionString + + expect(countryCode).toBe('US') + }) +}) diff --git a/src/plugins/ramps/revolut/revolutRampPlugin.ts b/src/plugins/ramps/revolut/revolutRampPlugin.ts new file mode 100644 index 00000000000..b3a485f59cf --- /dev/null +++ b/src/plugins/ramps/revolut/revolutRampPlugin.ts @@ -0,0 +1,623 @@ +import { asMaybe } from 'cleaners' +import type { EdgeTokenId } from 'edge-core-js' +import { Platform } from 'react-native' +import { CustomTabs } from 'react-native-custom-tabs' +import SafariView from 'react-native-safari-view' +import { sprintf } from 'sprintf-js' + +import { showButtonsModal } from '../../../components/modals/ButtonsModal' +import { EDGE_CONTENT_SERVER_URI } from '../../../constants/CdnConstants' +import { lstrings } from '../../../locales/strings' +import { CryptoAmount } from '../../../util/CryptoAmount' +import { findTokenIdByNetworkLocation } from '../../../util/CurrencyInfoHelpers' +import { FiatProviderError } from '../../gui/fiatProviderTypes' +import { ProviderSupportStore } from '../../gui/providers/ProviderSupportStore' +import { rampDeeplinkManager, type RampLink } from '../rampDeeplinkHandler' +import type { + RampApproveQuoteParams, + RampCheckSupportRequest, + RampInfo, + RampPlugin, + RampPluginConfig, + RampPluginFactory, + RampQuoteRequest, + RampQuoteResult, + RampSupportResult +} from '../rampPluginTypes' +import { asInitOptions } from './revolutRampTypes' +import { + asRevolutCrypto, + asRevolutFiat, + fetchRevolutConfig, + fetchRevolutQuote, + fetchRevolutRedirectUrl, + type RevolutConfig, + type RevolutCrypto, + type RevolutFiat +} from './util/fetchRevolut' + +const pluginId = 'revolut' +const partnerIcon = `${EDGE_CONTENT_SERVER_URI}/revolut.png` +const pluginDisplayName = 'Revolut' + +interface ProviderConfigCache { + data: RevolutConfig | null + timestamp: number +} + +const CACHE_TTL_MS = 60 * 60 * 1000 // 1 hour + +export const revolutRampPlugin: RampPluginFactory = ( + config: RampPluginConfig +) => { + // Validate and extract API configuration + const initOptions = asInitOptions(config.initOptions) + const { apiKey, apiUrl } = initOptions + const { account, onLogEvent, makeUuid } = config + + const rampInfo: RampInfo = { + partnerIcon, + pluginDisplayName + } + + // Utility function to ensure fiat currency codes have 'iso:' prefix + const ensureIsoPrefix = (currencyCode: string): string => { + return currencyCode.startsWith('iso:') + ? currencyCode + : `iso:${currencyCode}` + } + + // Cache for provider configuration + let configCache: ProviderConfigCache = { + data: null, + timestamp: 0 + } + + // ProviderSupportStore to manage supported assets + let supportedAssets: ProviderSupportStore | null = null + + async function fetchProviderConfig(): Promise { + const now = Date.now() + + // Check if cache is valid + if ( + configCache.data != null && + now - configCache.timestamp < CACHE_TTL_MS + ) { + return configCache.data + } + + // Fetch fresh configuration with API configuration + const config = await fetchRevolutConfig({ apiKey, baseUrl: apiUrl }) + + // Update cache + configCache = { + data: config, + timestamp: now + } + + // Reset supported assets to force reprocessing + supportedAssets = null + + return config + } + + function processSupportedAssets( + config: RevolutConfig, + account: RampPluginConfig['account'] + ): ProviderSupportStore { + if (supportedAssets != null) return supportedAssets + + supportedAssets = new ProviderSupportStore(pluginId) + supportedAssets.add.direction('buy') + + // Process the configuration using the helper functions at the bottom + processRevolutConfig(config, account, supportedAssets) + + return supportedAssets + } + + const plugin: RampPlugin = { + pluginId, + rampInfo, + + checkSupport: async ( + request: RampCheckSupportRequest + ): Promise => { + const { direction, regionCode, fiatAsset, cryptoAsset } = request + + // Check direction support + if (!validateDirection(direction)) { + return { supported: false } + } + + try { + // Fetch provider configuration + const config = await fetchProviderConfig() + + // Create region string with state if available + const region = + regionCode.stateProvinceCode == null + ? regionCode.countryCode + : `${regionCode.countryCode}:${regionCode.stateProvinceCode}` + + // Check region support + if (!validateRegion(region, config.countries)) { + return { supported: false } + } + + // Process supported assets + const store = processSupportedAssets(config, account) + + // Get asset map for validation + const assetMap = store.getFiatProviderAssetMap({ + direction: 'buy', + region, + payment: 'revolut' + }) + + // Check asset support + const assetsSupported = validateAssets({ + currencyPluginId: cryptoAsset.pluginId, + tokenId: cryptoAsset.tokenId, + fiatCurrencyCode: ensureIsoPrefix(fiatAsset.currencyCode), + assetMap + }) + + return { supported: assetsSupported } + } catch (error) { + console.error('Failed to check Revolut support:', error) + return { supported: false } + } + }, + + fetchQuote: async ( + request: RampQuoteRequest + ): Promise => { + const { + exchangeAmount, + fiatCurrencyCode, + regionCode, + pluginId: currencyPluginId, + tokenId, + displayCurrencyCode, + direction + } = request + + const isMaxAmount = + typeof exchangeAmount === 'object' && exchangeAmount.max + const exchangeAmountString = isMaxAmount ? '' : (exchangeAmount as string) + + // Check direction support + if (!validateDirection(direction)) { + return [] + } + + // Only support fiat amount type (Revolut requires fiat-based quotes) + if (request.amountType !== 'fiat') { + throw new FiatProviderError({ + providerId: pluginId, + errorType: 'amountTypeUnsupported' + }) + } + + try { + // Fetch provider configuration (will use cache if valid) + const config = await fetchProviderConfig() + + // Create region string with state if available + const region = + regionCode.stateProvinceCode == null + ? regionCode.countryCode + : `${regionCode.countryCode}:${regionCode.stateProvinceCode}` + + // Check region support + if (!validateRegion(region, config.countries)) { + return [] + } + + // Process supported assets + const store = processSupportedAssets(config, account) + + // Get asset map for validation + const assetMap = store.getFiatProviderAssetMap({ + direction: 'buy', + region, + payment: 'revolut' + }) + + // Check asset support + if ( + !validateAssets({ + currencyPluginId, + tokenId, + fiatCurrencyCode: ensureIsoPrefix(fiatCurrencyCode), + assetMap + }) + ) { + return [] + } + + // Get crypto and fiat info from the store + const cryptoKey = `${currencyPluginId}:${tokenId}` + const revolutCrypto = asMaybe(asRevolutCrypto)( + store.getCryptoInfo(cryptoKey) + ) + const revolutFiat = asMaybe(asRevolutFiat)( + store.getFiatInfo(ensureIsoPrefix(fiatCurrencyCode)) + ) + + if (revolutCrypto == null || revolutFiat == null) { + return [] + } + + // Handle max amount + let amount: string + if (isMaxAmount) { + amount = revolutFiat.max_limit.toString() + } else { + amount = exchangeAmountString + // Check if amount is within limits + const amountNum = parseFloat(amount) + if ( + amountNum < revolutFiat.min_limit || + amountNum > revolutFiat.max_limit + ) { + // Return empty array for amounts outside supported range + return [] + } + } + + // Fetch quote from Revolut (API only needs country code) + const quoteData = await fetchRevolutQuote( + { + fiat: revolutFiat.currency, + amount, + crypto: revolutCrypto.id, + payment: 'revolut', // Only revolut is supported at the moment + region: regionCode.countryCode + }, + { apiKey, baseUrl: apiUrl } + ) + + const cryptoAmount = quoteData.crypto.amount.toString() + const fiatAmount = amount + + // Assume 1 minute expiration + const expirationDate = new Date(Date.now() + 1000 * 60) + + const quote: RampQuoteResult = { + pluginId, + partnerIcon, + pluginDisplayName, + displayCurrencyCode, + cryptoAmount, + isEstimate: false, + fiatCurrencyCode, + fiatAmount, + direction, + expirationDate, + regionCode, + paymentType: 'revolut', + settlementRange: { + min: { value: 5, unit: 'minutes' }, + max: { value: 1, unit: 'hours' } + }, + + approveQuote: async ( + approveParams: RampApproveQuoteParams + ): Promise => { + const { coreWallet } = approveParams + const walletAddresses = await coreWallet.getAddresses({ + tokenId + }) + const walletAddress = walletAddresses[0]?.publicAddress + + if (walletAddress == null) { + throw new Error('No wallet address found') + } + + const successReturnURL = encodeURIComponent( + 'https://return.edge.app/fiatprovider/buy/revolut?transactionStatus=success' + ) + + const orderId = + makeUuid != null ? await makeUuid() : `revolut-${Date.now()}` + + const { ramp_redirect_url: redirectUrl } = + await fetchRevolutRedirectUrl( + { + fiat: revolutFiat.currency, + amount: parseFloat(fiatAmount), + crypto: quoteData.crypto.currencyId, + payment: 'revolut', + region: regionCode.countryCode, // API only needs country code + wallet: walletAddress, + partnerRedirectUrl: successReturnURL, + orderId + }, + { apiKey, baseUrl: apiUrl } + ) + + // Register deeplink handler + rampDeeplinkManager.register( + direction, + pluginId, + async (link: RampLink): Promise => { + if (link.direction === 'sell') { + throw new FiatProviderError({ + providerId: pluginId, + errorType: 'paymentUnsupported' + }) + } + const { transactionStatus } = link.query + if (transactionStatus === 'success') { + onLogEvent('Buy_Success', { + conversionValues: { + conversionType: 'buy', + sourceFiatCurrencyCode: fiatCurrencyCode, + sourceFiatAmount: fiatAmount, + destAmount: new CryptoAmount({ + currencyConfig: coreWallet.currencyConfig, + currencyCode: displayCurrencyCode, + exchangeAmount: cryptoAmount + }), + fiatProviderId: pluginId, + orderId + } + }) + const message = + sprintf( + lstrings.fiat_plugin_buy_complete_message_s, + cryptoAmount, + displayCurrencyCode, + fiatAmount, + fiatCurrencyCode, + '1' + ) + + '\n\n' + + sprintf( + lstrings.fiat_plugin_buy_complete_message_2_hour_s, + '1' + ) + + '\n\n' + + lstrings.fiat_plugin_sell_complete_message_3 + + await showButtonsModal({ + buttons: { + ok: { label: lstrings.string_ok } + }, + title: lstrings.fiat_plugin_buy_complete_title, + message + }) + } else { + throw new Error( + `Unexpected return link status: ${transactionStatus}` + ) + } + } + ) + + try { + // Open external webview + if (Platform.OS === 'ios') { + await SafariView.show({ url: redirectUrl }) + } else { + await CustomTabs.openURL(redirectUrl) + } + } catch (error) { + // Cleanup deeplink handler on error + rampDeeplinkManager.unregister() + throw error + } + }, + + closeQuote: async () => { + // Cleanup deeplink handler + rampDeeplinkManager.unregister() + } + } + + return [quote] + } catch (error) { + // Only throw for actual API/network failures + console.error('Failed to fetch Revolut quote:', error) + + // Check if it's a known unsupported case + if (error instanceof FiatProviderError) { + return [] + } + + // Re-throw actual errors + throw error + } + } + } + + return plugin +} + +// ----------------------------------------------------------------------------- +// Helper Functions (moved from revolutProvider.ts) +// ----------------------------------------------------------------------------- + +function addRevolutCrypto( + supportedAssets: ProviderSupportStore, + account: RampPluginConfig['account'], + crypto: RevolutCrypto +): void { + let pluginId: string | undefined + let tokenId: EdgeTokenId | undefined + + switch (crypto.blockchain) { + case 'ALGORAND': + case 'AVALANCHE': + case 'BITCOIN': + case 'BITCOINCASH': + case 'CARDANO': + case 'DOGECOIN': + case 'ETHEREUM': + case 'LITECOIN': + case 'OPTIMISM': + case 'POLKADOT': + case 'POLYGON': + case 'RIPPLE': + case 'SOLANA': + case 'STELLAR': + case 'TEZOS': + case 'TRON': + pluginId = crypto.blockchain.toLowerCase() + break + default: + console.warn(`Unknown blockchain from Revolut: ${crypto.blockchain}`) + return + } + + if (crypto.smartContractAddress != null) { + tokenId = findTokenIdByNetworkLocation({ + account, + pluginId, + networkLocation: { contractAddress: crypto.smartContractAddress } + }) + } else { + switch (crypto.currency) { + case 'ADA': + case 'ALGO': + case 'AVAX': + case 'BCH': + case 'BTC': + case 'DOGE': + case 'DOT': + case 'ETH': + case 'LTC': + case 'POL': + case 'SOL': + case 'XLM': + case 'XRP': + case 'XTZ': + tokenId = null + break + default: + // Skip unknown currencies + return + } + } + + if (tokenId === undefined) { + console.warn(`Unknown crypto currency from Revolut: ${crypto.currency}`) + return + } + + const cryptoKey = `${pluginId}:${tokenId}` + supportedAssets.add + .direction('*') + .region('*') + .fiat('*') + .payment('*') + .crypto(cryptoKey) + supportedAssets.addCryptoInfo(cryptoKey, crypto) +} + +function addRevolutPaymentMethod( + supportedAssets: ProviderSupportStore, + method: string +): void { + switch (method) { + case 'revolut': + supportedAssets.add + .direction('*') + .region('*') + .fiat('*') + .payment('revolut') + return + case 'card': + case 'apple-pay': + case 'google-pay': + // Intentionally not supported + return + default: + console.warn(`Unknown payment method from Revolut: ${method}`) + } +} + +function addRevolutFiat( + supportedAssets: ProviderSupportStore, + fiat: RevolutFiat +): void { + const fiatKey = `iso:${fiat.currency}` + supportedAssets.add.direction('*').region('*').fiat(fiatKey).payment('*') + supportedAssets.addFiatInfo(fiatKey, fiat) +} + +function processRevolutConfig( + configData: RevolutConfig, + account: RampPluginConfig['account'], + supportedAssets: ProviderSupportStore +): void { + configData.countries.forEach(country => { + supportedAssets.add.direction('*').region(country).fiat('*').payment('*') + }) + configData.fiat.forEach(fiat => { + addRevolutFiat(supportedAssets, fiat) + }) + configData.crypto.forEach(crypto => { + addRevolutCrypto(supportedAssets, account, crypto) + }) + configData.payment_methods.forEach(method => { + addRevolutPaymentMethod(supportedAssets, method) + }) +} + +// ----------------------------------------------------------------------------- +// Validation Helper Functions +// ----------------------------------------------------------------------------- + +/** + * Validates if the direction is supported by Revolut + * @param direction - The ramp direction ('buy' or 'sell') + * @returns true if direction is 'buy', false otherwise + */ +function validateDirection(direction: 'buy' | 'sell'): boolean { + return direction === 'buy' +} + +/** + * Validates if the region is supported by Revolut + * @param regionCode - The ISO country code + * @param supportedCountries - Array of supported country codes from Revolut config + * @returns true if the country is supported, false otherwise + */ +function validateRegion( + regionCode: string, + supportedCountries: string[] +): boolean { + // Extract country code from region string (handles both "US" and "US:CA" formats) + const countryCode = regionCode.includes(':') + ? regionCode.split(':')[0] + : regionCode + return supportedCountries.includes(countryCode) +} + +/** + * Validates if the crypto/fiat pair is supported by Revolut + * @param params - Validation parameters + * @returns true if both crypto and fiat assets are supported, false otherwise + */ +function validateAssets(params: { + currencyPluginId: string + tokenId: EdgeTokenId + fiatCurrencyCode: string + assetMap: ReturnType +}): boolean { + const { currencyPluginId, tokenId, fiatCurrencyCode, assetMap } = params + + // Check if crypto is supported + const cryptoSupported = assetMap.crypto[currencyPluginId]?.some( + token => token.tokenId === tokenId + ) + + // Check if fiat is supported + const fiatSupported = assetMap.fiat[fiatCurrencyCode] != null + + return cryptoSupported && fiatSupported +} diff --git a/src/plugins/ramps/revolut/revolutRampTypes.ts b/src/plugins/ramps/revolut/revolutRampTypes.ts new file mode 100644 index 00000000000..4cd7450147b --- /dev/null +++ b/src/plugins/ramps/revolut/revolutRampTypes.ts @@ -0,0 +1,20 @@ +import { asObject, asOptional, asString } from 'cleaners' + +// Re-export types from fetchRevolut for convenience +export type { + RevolutConfig, + RevolutCrypto, + RevolutFiat, + RevolutQuoteParams, + PartnerQuote, + RevolutRedirectUrlParams, + RevolutRedirectUrl +} from '../../gui/util/fetchRevolut' + +// Init options cleaner for revolut ramp plugin +export const asInitOptions = asObject({ + apiKey: asString, + apiUrl: asOptional(asString, 'https://ramp-partners.revolut.com') +}) + +export type InitOptions = ReturnType diff --git a/src/plugins/ramps/revolut/util/fetchRevolut.ts b/src/plugins/ramps/revolut/util/fetchRevolut.ts new file mode 100644 index 00000000000..b762ce76c9b --- /dev/null +++ b/src/plugins/ramps/revolut/util/fetchRevolut.ts @@ -0,0 +1,272 @@ +import { + asArray, + asNumber, + asObject, + asOptional, + asString, + asValue +} from 'cleaners' + +interface FetchRevolutOptions { + apiKey: string + /** + * The base URL to use for Revolut API requests. + * Production: https://ramp-partners.revolut.com + * Testing: https://ramp-partners.revolut.codes + **/ + baseUrl: string +} + +const fetchRevolut = (options: FetchRevolutOptions) => { + const { apiKey, baseUrl } = options + return async (endpoint: string, init?: RequestInit): Promise => { + const url = `${baseUrl}${endpoint}` + const response = await fetch(url, { + method: 'GET', + ...init, + headers: { + // The RequestInit type is very hard to work with, if you've won + // the metal as a type-gymnast, then be my guest to fix it. + // eslint-disable-next-line @typescript-eslint/no-misused-spread + ...init?.headers, + Accept: 'application/json', + 'X-API-KEY': apiKey + } + }) + if (!response.ok) { + const text = await response.text() + throw new Error( + `Failed to fetch Revolut ${url}: ${response.status} - ${text}` + ) + } + const data = await response.json() + return data + } +} + +// ----------------------------------------------------------------------------- +// Revolut Config +// ----------------------------------------------------------------------------- + +export async function fetchRevolutConfig( + options: FetchRevolutOptions +): Promise { + const data = await fetchRevolut(options)('/partners/api/2.0/config') + return asRevolutConfig(data) +} + +export type RevolutCrypto = ReturnType +export const asRevolutCrypto = asObject({ + id: asString, + currency: asString, + blockchain: asString, + smartContractAddress: asOptional(asString) +}) + +export type RevolutFiat = ReturnType +export const asRevolutFiat = asObject({ + currency: asString, + min_limit: asNumber, + max_limit: asNumber +}) + +export type RevolutConfig = ReturnType +const asRevolutConfig = asObject({ + version: asString, + countries: asArray(asString), + fiat: asArray(asRevolutFiat), + crypto: asArray(asRevolutCrypto), + payment_methods: asArray( + asValue('card', 'revolut', 'apple-pay', 'google-pay') + ) +}) + +// ----------------------------------------------------------------------------- +// Revolut Quote +// ----------------------------------------------------------------------------- + +export interface RevolutQuoteParams { + /** + * The ISO 4217 code of the selected fiat currency to use for the purchase. + */ + fiat: string + + /** + * The fiat amount to exchange for crypto. + */ + amount: string + + /** + * The ID of the crypto token to purchase, obtained from the /config endpoint. + */ + crypto: string + + /** + * The selected payment option. + * @example 'card' | 'revolut' | 'apple-pay' | 'google-pay' + */ + payment: string + + /** + * The ISO 3166 Alpha-2 code of the country of residence of the customer + * (end user) ordering the exchange. + * @example 'GB' + */ + region: string + + /** + * The fee percentage that will be applied for the order as partner fee. + */ + feePercentage?: number + + /** + * The address of the crypto wallet into which the purchased token should + * be transferred. + */ + walletAddress?: string +} + +export async function fetchRevolutQuote( + params: RevolutQuoteParams, + options: FetchRevolutOptions +): Promise { + const urlParams = new URLSearchParams() + urlParams.set('fiat', params.fiat) + urlParams.set('amount', params.amount) + urlParams.set('crypto', params.crypto) + urlParams.set('payment', params.payment) + urlParams.set('region', params.region) + if (params.feePercentage != null) { + urlParams.set('feePercentage', params.feePercentage.toString()) + } + if (params.walletAddress != null) { + urlParams.set('walletAddress', params.walletAddress) + } + const data = await fetchRevolut(options)( + `/partners/api/2.0/quote?${urlParams.toString()}` + ) + return asPartnerQuote(data) +} + +export type PartnerQuote = ReturnType +const asPartnerQuote = asObject({ + service_fee: asObject({ + amount: asNumber, + currency: asString + }), + network_fee: asObject({ + amount: asNumber, + currency: asString + }), + crypto: asObject({ + amount: asNumber, + currencyId: asString + }), + partner_fee: asObject({ + amount: asNumber, + currency: asString + }) +}) + +// ----------------------------------------------------------------------------- +// Revolut Redirect URL +// ----------------------------------------------------------------------------- + +export interface RevolutRedirectUrlParams { + /** + * The ISO 4217 code of the selected fiat currency to use for the purchase. + */ + fiat: string + + /** + * The fiat amount to exchange for crypto. + */ + amount: number + + /** + * The ID of the crypto token to purchase, obtained from the /config endpoint. + */ + crypto: string + + /** + * The selected payment option. + * Possible values: [card, revolut, apple-pay, google-pay] + */ + payment: string + + /** + * The ISO 3166 Alpha-2 code of the country of residence of the customer + * (end user) ordering the exchange. + * Example: "GB" + */ + region: string + + /** + * The address of the crypto wallet into which to transfer the purchased + * token. + */ + wallet: string + + /** + * The external identifier of the order to be made. Should be either UUID or + * ULID. + */ + orderId?: string + + /** + * The fee percentage that will be applied for the order as partner fee. + */ + feePercentage?: number + + /** + * The URL to which to redirect the customer after the purchase – for example, + * your website. + * If not provided, the customer is shown transaction result in Revolut Ramp. + */ + partnerRedirectUrl?: string + + /** + * A prefix that allows passing arbitrary key-value pairs. For each pair, the + * prefix and key should be separated by . + * Pattern: Value must match regular expression + * `additionalProperties.[key]=[value] + * If such additional properties are provided, when you call the /orders + * endpoint, they are returned along with the order details. + */ + additionalProperties?: string +} + +export async function fetchRevolutRedirectUrl( + params: RevolutRedirectUrlParams, + options: FetchRevolutOptions +): Promise { + const urlParams = new URLSearchParams() + urlParams.set('fiat', params.fiat) + urlParams.set('amount', params.amount.toString()) + urlParams.set('crypto', params.crypto) + urlParams.set('payment', params.payment) + urlParams.set('region', params.region) + urlParams.set('wallet', params.wallet) + if (params.orderId != null) { + urlParams.set('orderId', params.orderId) + } + if (params.feePercentage != null) { + urlParams.set('feePercentage', params.feePercentage.toString()) + } + if (params.partnerRedirectUrl != null) { + urlParams.set('partnerRedirectUrl', params.partnerRedirectUrl) + } + if (params.additionalProperties != null) { + urlParams.set('additionalProperties', params.additionalProperties) + } + + const data = await fetchRevolut(options)( + `/partners/api/2.0/buy?${urlParams.toString()}` + ) + return asRevolutRedirectUrl(data) +} + +export type RevolutRedirectUrl = ReturnType +const asRevolutRedirectUrl = asObject({ + ramp_redirect_url: asString +}) diff --git a/src/plugins/ramps/simplex/simplexRampPlugin.ts b/src/plugins/ramps/simplex/simplexRampPlugin.ts new file mode 100644 index 00000000000..a07cd6d6b68 --- /dev/null +++ b/src/plugins/ramps/simplex/simplexRampPlugin.ts @@ -0,0 +1,763 @@ +import { gt, lt } from 'biggystring' +import { Platform } from 'react-native' +import { CustomTabs } from 'react-native-custom-tabs' +import SafariView from 'react-native-safari-view' + +import { showToast } from '../../../components/services/AirshipInstance' +import { EDGE_CONTENT_SERVER_URI } from '../../../constants/CdnConstants' +import { lstrings } from '../../../locales/strings' +import { CryptoAmount } from '../../../util/CryptoAmount' +import { fetchInfo } from '../../../util/network' +import { FiatProviderError } from '../../gui/fiatProviderTypes' +import { addExactRegion, validateExactRegion } from '../../gui/providers/common' +import { addTokenToArray } from '../../gui/util/providerUtils' +import { rampDeeplinkManager, type RampLink } from '../rampDeeplinkHandler' +import type { + ProviderToken, + RampApproveQuoteParams, + RampCheckSupportRequest, + RampInfo, + RampPlugin, + RampPluginConfig, + RampPluginFactory, + RampQuoteRequest, + RampQuoteResult, + RampSupportResult +} from '../rampPluginTypes' +import { + asInfoJwtSignResponse, + asInitOptions, + asSimplexCountries, + asSimplexFiatCurrencies, + asSimplexFiatCurrency, + asSimplexQuote, + asSimplexQuoteSuccess, + SIMPLEX_ERROR_TYPES, + type SimplexJwtData, + type SimplexQuoteJwtData, + type SimplexQuoteSuccess +} from './simplexRampTypes' + +const pluginId = 'simplex' +const partnerIcon = `${EDGE_CONTENT_SERVER_URI}/simplex-logo-sm-square.png` +const pluginDisplayName = 'Simplex' +const NOT_SUCCESS_TOAST_HIDE_MS = 3000 + +// 24 hour TTL for provider config cache +const PROVIDER_CONFIG_TTL_MS = 24 * 60 * 60 * 1000 + +// https://integrations.simplex.com/docs/supported_currencies +const SIMPLEX_ID_MAP: Record> = { + algorand: { ALGO: 'ALGO' }, + avalanche: { AVAX: 'AVAX-C' }, + binance: { AVA: 'AVA', BNB: 'BNB' }, + binancesmartchain: { + BABYDOGE: 'BABYDOGE', + BAKE: 'BAKE', + BNB: 'BNB', + BUSD: 'BUSD-SC', + CAKE: 'CAKE', + EGC: 'EGC', + KMON: 'KMON', + SATT: 'SATT-SC', + TCT: 'TCT', + ULTI: 'ULTI', + USDC: 'USDC-SC', + XVS: 'XVS' + }, + bitcoin: { BTC: 'BTC' }, + bitcoincash: { BCH: 'BCH' }, + bitcoinsv: { BSV: 'BSV' }, + cardano: { ADA: 'ADA' }, + celo: { CELO: 'CELO', CEUR: 'CEUR', CUSD: 'CUSD' }, + digibyte: { DGB: 'DGB' }, + dogecoin: { DOGE: 'DOGE' }, + eos: { EOS: 'EOS' }, + ethereum: { + '1EARTH': '1EARTH', + AAVE: 'AAVE', + AXS: 'AXS-ERC20', + BAT: 'BAT', + BUSD: 'BUSD', + CEL: 'CEL', + CHZ: 'CHZ', + COMP: 'COMP', + COTI: 'COTI-ERC20', + CRO: 'CRO-ERC20', + DAI: 'DAI', + DEP: 'DEP', + DFT: 'DFT', + ELON: 'ELON', + ENJ: 'ENJ', + ETH: 'ETH', + GALA: 'GALA', + GHX: 'GHX', + GMT: 'GMT-ERC20', + GOVI: 'GOVI', + HEDG: 'HEDG', + HGOLD: 'HGOLD', + HUSD: 'HUSD', + KCS: 'KCS', + LINK: 'LINK', + MANA: 'MANA', + MATIC: 'MATIC-ERC20', + MKR: 'MKR', + PEPE: 'PEPE', + PRT: 'PRT', + REVV: 'REVV', + RFOX: 'RFOX', + RFUEL: 'RFUEL', + RLY: 'RLY-ERC20', + SAND: 'SAND', + SATT: 'SATT-ERC20', + SHIB: 'SHIB', + SUSHI: 'SUSHI', + TRU: 'TRU', + TUSD: 'TUSD', + UNI: 'UNI', + UOS: 'UOS-ERC20', + USDC: 'USDC', + USDK: 'USDK', + USDP: 'USDP', + USDT: 'USDT', + VNDC: 'VNDC', + WBTC: 'WBTC', + XAUT: 'XAUT', + XYO: 'XYO' + }, + fantom: { FTM: 'FTM' }, + filecoin: { FIL: 'FIL' }, + hedera: { HBAR: 'HBAR' }, + litecoin: { LTC: 'LTC' }, + one: { ONE: 'ONE' }, + optimism: { ETH: 'ETH-OPTIMISM', OP: 'OP' }, + polkadot: { DOT: 'DOT' }, + polygon: { GMEE: 'GMEE', POL: 'POL', USDC: 'USDC-MATIC' }, + qtum: { QTUM: 'QTUM' }, + ravencoin: { RVN: 'RVN' }, + ripple: { XRP: 'XRP' }, + solana: { KIN: 'KIN', MELANIA: 'MELANIA', SOL: 'SOL', TRUMP: 'TRUMP' }, + stellar: { XLM: 'XLM' }, + sui: { SUI: 'SUI' }, + tezos: { XTZ: 'XTZ' }, + ton: { TON: 'TON', USDT: 'USDT-TON' }, + tron: { + BTT: 'BTT', + KLV: 'KLV', + TRX: 'TRX', + USDC: 'USDC-TRC20', + USDT: 'USDT-TRC20' + }, + wax: { WAX: 'WAXP' } +} + +interface SimplexPluginState { + partner: string + jwtTokenProvider: string + publicKey: string + simplexUserId: string +} + +interface ProviderConfig { + crypto: Record + fiat: Record + countries: Record + lastUpdated: number +} + +export const simplexRampPlugin: RampPluginFactory = ( + config: RampPluginConfig +) => { + const initOptions = asInitOptions(config.initOptions) + const { apiUrl, widgetUrl } = initOptions + const { navigation, onLogEvent } = config + + const rampInfo: RampInfo = { + partnerIcon, + pluginDisplayName + } + + let state: SimplexPluginState | undefined + let providerConfig: ProviderConfig | undefined + + const ensureIsoPrefix = (currencyCode: string): string => { + return currencyCode.startsWith('iso:') + ? currencyCode + : `iso:${currencyCode}` + } + + const ensureStateInitialized = async (): Promise => { + if (state == null) { + const { partner, jwtTokenProvider, publicKey } = initOptions + + let simplexUserId: string + if (config.store != null) { + simplexUserId = await config.store + .getItem('simplex_user_id') + .catch(() => '') + if (simplexUserId === '') { + // Always try makeUuid first when generating a new ID + if (config.makeUuid != null) { + simplexUserId = await config.makeUuid() + } else { + // Fallback to timestamp-based ID only if makeUuid is not available + // This is an edge case that shouldn't happen in normal Edge wallet usage + simplexUserId = `simplex-user-${Date.now()}` + } + await config.store.setItem('simplex_user_id', simplexUserId) + } + } else { + simplexUserId = `simplex-user-${Date.now()}` + } + + state = { + partner, + jwtTokenProvider, + publicKey, + simplexUserId + } + } + } + + const fetchProviderConfig = async (): Promise => { + if (state == null) throw new Error('Plugin not initialized') + const { publicKey } = state + + // Check cache TTL + if ( + providerConfig != null && + Date.now() - providerConfig.lastUpdated < PROVIDER_CONFIG_TTL_MS + ) { + return + } + + // Initialize new config + const newConfig: ProviderConfig = { + crypto: {}, + fiat: {}, + countries: {}, + lastUpdated: Date.now() + } + + // Initialize crypto mappings + for (const pluginId in SIMPLEX_ID_MAP) { + const codesObject = SIMPLEX_ID_MAP[pluginId] + // We need at least one supported currency in this plugin + if (Object.keys(codesObject).length > 0) { + newConfig.crypto[pluginId] ??= [] + const tokens = newConfig.crypto[pluginId] + // For simplex, we just need to indicate that this plugin is supported + // The actual tokenId mapping is handled by displayCurrencyCode matching + addTokenToArray({ tokenId: null }, tokens) + } + } + + try { + // Fetch supported fiat currencies + const response = await fetch( + `${apiUrl}/supported_fiat_currencies?public_key=${publicKey}` + ) + if (!response?.ok) { + console.error('Simplex: Failed to fetch supported fiat currencies') + return + } + const result = await response.json() + + const fiatCurrencies = asSimplexFiatCurrencies(result) + for (const fc of fiatCurrencies) { + newConfig.fiat['iso:' + fc.ticker_symbol] = fc + } + + // Fetch supported countries + const response2 = await fetch( + `${apiUrl}/supported_countries?public_key=${publicKey}&payment_methods=credit_card` + ) + if (!response2?.ok) { + console.error('Simplex: Failed to fetch supported countries') + return + } + const result2 = await response2.json() + const countries = asSimplexCountries(result2) + + for (const country of countries) { + const [countryCode, stateProvinceCode] = country.split('-') + addExactRegion(newConfig.countries, countryCode, stateProvinceCode) + } + + // Update the cached config + providerConfig = newConfig + } catch (e) { + console.error('Simplex: Failed to fetch provider config:', e) + // Keep using existing config if available + } + } + + const fetchJwtToken = async ( + endpoint: string, + data: SimplexJwtData | SimplexQuoteJwtData + ): Promise => { + const response = await fetchInfo( + `v1/jwtSign/${endpoint}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data }) + }, + 3000 + ) + if (!response?.ok) throw new Error('Simplex: Failed to fetch JWT token') + const result = await response.json() + const { token } = asInfoJwtSignResponse(result) + return token + } + + const validateDirection = (direction: 'buy' | 'sell'): boolean => { + // Only support buy direction + return direction === 'buy' + } + + const validateRegion = (regionCode: { + countryCode: string + stateProvinceCode?: string + }): boolean => { + // GB is not supported + if (regionCode.countryCode === 'GB') { + return false + } + + // Check exact region validation + if (providerConfig == null) return false + try { + validateExactRegion(pluginId, regionCode, providerConfig.countries) + return true + } catch (error) { + return false + } + } + + const validateCrypto = ( + currencyPluginId: string, + displayCurrencyCode: string + ): string | null => { + // Check if crypto is supported + const simplexCryptoCode = + SIMPLEX_ID_MAP[currencyPluginId]?.[displayCurrencyCode] + if (simplexCryptoCode == null) { + return null + } + + // Check if we have this crypto in our provider config + if (providerConfig == null) return null + const supportedTokens = providerConfig.crypto[currencyPluginId] + if (supportedTokens == null || supportedTokens.length === 0) { + return null + } + + return simplexCryptoCode + } + + const validateFiat = (fiatCurrencyCode: string): string | null => { + // Check if fiat is supported + if (providerConfig == null) return null + const fiatInfo = providerConfig.fiat[fiatCurrencyCode] + if (fiatInfo == null) { + return null + } + + const simplexFiatCode = asSimplexFiatCurrency(fiatInfo).ticker_symbol + return simplexFiatCode + } + + const approveQuote = async ( + params: RampApproveQuoteParams, + quote: SimplexQuoteSuccess, + simplexCryptoCode: string, + simplexFiatCode: string + ): Promise => { + if (state == null) throw new Error('Plugin state not initialized') + const { coreWallet } = params + + const receiveAddress = await coreWallet.getReceiveAddress({ + tokenId: null + }) + + const data: SimplexJwtData = { + ts: Math.floor(Date.now() / 1000), + euid: state.simplexUserId, + crad: receiveAddress.publicAddress, + crcn: simplexCryptoCode, + ficn: simplexFiatCode, + fiam: quote.fiat_money.amount + } + + try { + const token = await fetchJwtToken(state.jwtTokenProvider, data) + const url = `${widgetUrl}/?partner=${state.partner}&t=${token}` + + // Register deeplink handler + rampDeeplinkManager.register( + 'buy', + pluginId, + async (link: RampLink): Promise => { + if (link.direction !== 'buy') return + + const orderId = link.query.orderId ?? 'unknown' + const status = link.query.status?.replace('?', '') + + try { + switch (status) { + case 'success': { + onLogEvent('Buy_Success', { + conversionValues: { + conversionType: 'buy', + sourceFiatCurrencyCode: simplexFiatCode, + sourceFiatAmount: quote.fiat_money.amount.toString(), + destAmount: new CryptoAmount({ + currencyConfig: coreWallet.currencyConfig, + currencyCode: coreWallet.currencyInfo.currencyCode, + exchangeAmount: quote.digital_money.amount.toString() + }), + fiatProviderId: pluginId, + orderId + } + }) + navigation.pop() + break + } + case 'failure': { + showToast( + lstrings.fiat_plugin_buy_failed_try_again, + NOT_SUCCESS_TOAST_HIDE_MS + ) + navigation.pop() + break + } + default: { + showToast( + lstrings.fiat_plugin_buy_unknown_status, + NOT_SUCCESS_TOAST_HIDE_MS + ) + navigation.pop() + } + } + } finally { + // Always unregister the handler when done + rampDeeplinkManager.unregister() + } + } + ) + + // Open external webview + try { + if (Platform.OS === 'ios') { + await SafariView.show({ url }) + } else { + await CustomTabs.openURL(url) + } + } catch (error) { + // If webview fails to open, unregister the handler + rampDeeplinkManager.unregister() + throw error + } + } catch (error) { + console.error('Simplex approve quote error:', error) + throw error + } + } + + const plugin: RampPlugin = { + pluginId, + rampInfo, + + checkSupport: async ( + request: RampCheckSupportRequest + ): Promise => { + try { + const { direction, regionCode, fiatAsset, cryptoAsset } = request + + // Validate direction + if (!validateDirection(direction)) { + return { supported: false } + } + + // Initialize state and fetch provider config if needed + await ensureStateInitialized() + await fetchProviderConfig() + + // Ensure we have provider config + if (providerConfig == null) { + console.error('Simplex: Provider config not available') + return { supported: false } + } + + // Validate region + if (!validateRegion(regionCode)) { + return { supported: false } + } + + // For tokenId support, we need to get the display currency code + // Since Simplex uses display currency codes, we'll need to check if the tokenId + // matches any supported currency for the plugin + let displayCurrencyCode: string | null = null + + /** + * Simplex Token Support Limitation: + * + * Simplex only supports native currencies (where tokenId === null), not tokens, + * due to fundamental limitations in their API architecture: + * + * 1. API Currency Code System: + * - Simplex uses their own proprietary currency codes (e.g., 'BTC', 'ETH', 'AVAX-C') + * - These codes map to native blockchain currencies, not token contract addresses + * - There's no mechanism in their API to specify token contracts + * + * 2. SIMPLEX_ID_MAP Structure: + * - Maps Edge plugin IDs and display currency codes to Simplex currency codes + * - Only contains entries for native currencies of each blockchain + * - Example: ethereum: { ETH: 'ETH' } but no mapping for ERC-20 tokens + * + * 3. Legacy Provider Comparison: + * - The old fiat provider architecture had a getTokenId method that could + * theoretically support tokens by returning contract addresses + * - However, even with that capability, Simplex's API never actually + * supported purchasing tokens - only native currencies + * - This plugin maintains the same limitation but makes it explicit + * + * Therefore, we must check tokenId === null to ensure only native currencies + * are processed, returning unsupported for any token requests. + */ + if (cryptoAsset.tokenId === null) { + // Native currency - check if we have any mapping for this plugin + const pluginMappings = SIMPLEX_ID_MAP[cryptoAsset.pluginId] + if ( + pluginMappings != null && + Object.keys(pluginMappings).length > 0 + ) { + // For checkSupport, we just need to know if the plugin is supported + // The actual currency code mapping happens during quote + displayCurrencyCode = Object.keys(pluginMappings)[0] + } + } else { + // Simplex doesn't support tokens, only native currencies + return { supported: false } + } + + if (displayCurrencyCode === '') { + return { supported: false } + } + + // Validate crypto - we use any valid display code for the plugin + const simplexCryptoCode = + displayCurrencyCode != null + ? validateCrypto(cryptoAsset.pluginId, displayCurrencyCode) + : null + if (simplexCryptoCode == null) { + return { supported: false } + } + + // Validate fiat - ensure 'iso:' prefix + const simplexFiatCode = validateFiat( + ensureIsoPrefix(fiatAsset.currencyCode) + ) + if (simplexFiatCode == null) { + return { supported: false } + } + + // All validations passed + return { supported: true } + } catch (error) { + // Only throw for actual errors (network issues, etc) + // Never throw for unsupported combinations + console.error('Simplex checkSupport error:', error) + throw error + } + }, + + fetchQuote: async ( + request: RampQuoteRequest + ): Promise => { + const { + amountType, + exchangeAmount, + regionCode, + pluginId: currencyPluginId, + fiatCurrencyCode, + displayCurrencyCode, + direction + } = request + + const isMaxAmount = + typeof exchangeAmount === 'object' && exchangeAmount.max + const exchangeAmountString = isMaxAmount ? '' : (exchangeAmount as string) + + // Validate direction + if (!validateDirection(direction)) { + return [] + } + + // Initialize state and fetch provider config if needed + await ensureStateInitialized() + await fetchProviderConfig() + + // Ensure we have provider config + if (providerConfig == null) { + console.error('Simplex: Provider config not available') + return [] + } + + // Validate region + if (!validateRegion(regionCode)) { + return [] + } + + // Validate crypto + const simplexCryptoCode = validateCrypto( + currencyPluginId, + displayCurrencyCode + ) + if (simplexCryptoCode == null) { + return [] + } + + // Validate fiat - ensure 'iso:' prefix + const simplexFiatCode = validateFiat(ensureIsoPrefix(fiatCurrencyCode)) + if (simplexFiatCode == null) { + return [] + } + + // All checks passed, now fetch the actual quote + if (state == null) throw new Error('Plugin state not initialized') + + // Prepare quote request + const ts = Math.floor(Date.now() / 1000) + let sourceCurrencyName: string + let targetCurrencyName: string + let sourceAmount: number + + if (isMaxAmount) { + // Use reasonable max amounts + sourceAmount = amountType === 'fiat' ? 50000 : 100 + } else { + sourceAmount = parseFloat(exchangeAmountString) + } + + if (amountType === 'fiat') { + sourceCurrencyName = simplexFiatCode + targetCurrencyName = simplexCryptoCode + } else { + sourceCurrencyName = simplexCryptoCode + targetCurrencyName = simplexFiatCode + } + + const jwtData: SimplexQuoteJwtData = { + euid: state.simplexUserId, + ts, + soam: sourceAmount, + socn: sourceCurrencyName, + tacn: targetCurrencyName + } + + try { + // Get JWT token + const token = await fetchJwtToken('simplex', jwtData) + + // Fetch quote + const url = `${widgetUrl}/api/quote?partner=${state.partner}&t=${token}` + const response = await fetch(url) + if (response == null) throw new Error('Simplex: Failed to fetch quote') + + const result = await response.json() + const quote = asSimplexQuote(result) + + if ('error' in quote) { + // Handle error cases + if ( + quote.type === SIMPLEX_ERROR_TYPES.INVALID_AMOUNT_LIMIT || + quote.type === SIMPLEX_ERROR_TYPES.AMOUNT_LIMIT_EXCEEDED + ) { + const result = /The (.*) amount must be between (.*) and (.*)/.exec( + quote.error + ) + if (result != null && result.length >= 4) { + const [, fiatCode, minLimit, maxLimit] = result + if (!isMaxAmount && gt(exchangeAmountString, maxLimit)) { + throw new FiatProviderError({ + providerId: pluginId, + errorType: 'overLimit', + errorAmount: parseFloat(maxLimit), + displayCurrencyCode: fiatCode + }) + } + if (!isMaxAmount && lt(exchangeAmountString, minLimit)) { + throw new FiatProviderError({ + providerId: pluginId, + errorType: 'underLimit', + errorAmount: parseFloat(minLimit), + displayCurrencyCode: fiatCode + }) + } + } + } else if ( + quote.type === SIMPLEX_ERROR_TYPES.QUOTE_ERROR && + quote.error.includes('fees for this transaction exceed') + ) { + throw new FiatProviderError({ + providerId: pluginId, + errorType: 'underLimit' + }) + } + // For other errors, return empty array (not supported) + console.error(`Simplex quote error: ${quote.error}`) + return [] + } + + const goodQuote = asSimplexQuoteSuccess(quote) + const quoteFiatAmount = goodQuote.fiat_money.amount.toString() + const quoteCryptoAmount = goodQuote.digital_money.amount.toString() + + // Return quote for credit card payment type + const rampQuote: RampQuoteResult = { + pluginId, + partnerIcon, + pluginDisplayName, + displayCurrencyCode, + cryptoAmount: quoteCryptoAmount, + isEstimate: false, + fiatCurrencyCode, + fiatAmount: quoteFiatAmount, + direction, + expirationDate: new Date(Date.now() + 8000), + regionCode, + paymentType: 'credit', // Simplex supports 'applepay', 'credit', and 'googlepay' but we always return credit for now + settlementRange: { + min: { value: 10, unit: 'minutes' }, + max: { value: 60, unit: 'minutes' } + }, + approveQuote: async ( + params: RampApproveQuoteParams + ): Promise => { + await approveQuote( + params, + goodQuote, + simplexCryptoCode, + simplexFiatCode + ) + }, + closeQuote: async (): Promise => {} + } + + return [rampQuote] + } catch (error) { + // Check if it's a known error we should throw + if (error instanceof FiatProviderError) { + throw error + } + // For other errors, log and throw + console.error('Simplex quote error:', error) + throw error + } + } + } + + return plugin +} diff --git a/src/plugins/ramps/simplex/simplexRampTypes.ts b/src/plugins/ramps/simplex/simplexRampTypes.ts new file mode 100644 index 00000000000..b552c9be732 --- /dev/null +++ b/src/plugins/ramps/simplex/simplexRampTypes.ts @@ -0,0 +1,102 @@ +import { + asArray, + asEither, + asNumber, + asObject, + asOptional, + asString +} from 'cleaners' + +import type { ProviderToken } from '../rampPluginTypes' + +// Init options for Simplex plugin +export const asInitOptions = asObject({ + partner: asString, + jwtTokenProvider: asString, + publicKey: asString, + apiUrl: asOptional(asString, 'https://api.simplexcc.com/v2'), + widgetUrl: asOptional(asString, 'https://partners.simplex.com') +}) + +export type InitOptions = ReturnType + +// Simplex API response types +export const asSimplexFiatCurrency = asObject({ + ticker_symbol: asString, + min_amount: asString, + max_amount: asString +}) + +export const asSimplexQuoteError = asObject({ + error: asString, + type: asString +}) + +export const asSimplexQuoteSuccess = asObject({ + digital_money: asObject({ + currency: asString, + amount: asNumber + }), + fiat_money: asObject({ + currency: asString, + amount: asNumber + }) +}) + +export const asSimplexQuote = asEither( + asSimplexQuoteSuccess, + asSimplexQuoteError +) +export const asSimplexFiatCurrencies = asArray(asSimplexFiatCurrency) +export const asSimplexCountries = asArray(asString) +export const asInfoJwtSignResponse = asObject({ token: asString }) + +export type SimplexQuote = ReturnType +export type SimplexQuoteSuccess = ReturnType +export type SimplexFiatCurrency = ReturnType + +// Extended token interface for Simplex mappings +export interface SimplexTokenMapping extends ProviderToken { + pluginId: string + simplexCode: string +} + +// Simplex-specific constants +export const SIMPLEX_SUPPORTED_PAYMENT_TYPES = { + applepay: true, + credit: true, + googlepay: true +} as const + +export type SimplexPaymentType = keyof typeof SIMPLEX_SUPPORTED_PAYMENT_TYPES + +// JWT data structures +export interface SimplexJwtData { + ts: number + euid: string + crad: string + crcn: string + ficn: string + fiam: number +} + +export interface SimplexQuoteJwtData { + euid: string + ts: number + soam: number + socn: string + tacn: string +} + +// Caching duration (24 hours in milliseconds) +export const CACHE_DURATION_MS = 24 * 60 * 60 * 1000 + +// Error types +export const SIMPLEX_ERROR_TYPES = { + INVALID_AMOUNT_LIMIT: 'invalidAmountLimit', + AMOUNT_LIMIT_EXCEEDED: 'amount_Limit_exceeded', + QUOTE_ERROR: 'quote_error' +} as const + +export type SimplexErrorType = + (typeof SIMPLEX_ERROR_TYPES)[keyof typeof SIMPLEX_ERROR_TYPES] diff --git a/src/plugins/ramps/utils/README.md b/src/plugins/ramps/utils/README.md new file mode 100644 index 00000000000..17f097b85af --- /dev/null +++ b/src/plugins/ramps/utils/README.md @@ -0,0 +1,65 @@ +# Ramp Plugin Store IDs + +## Store ID Convention + +All ramp plugins require a unique store ID for persisting data via EdgeDataStore. The store ID is determined by the following rules: + +### New Plugins (Standard Convention) +All NEW ramp plugins automatically use the namespaced format: +``` +ramp:${pluginId} +``` + +Examples: +- Plugin ID: `newexchange` → Store ID: `ramp:newexchange` +- Plugin ID: `cryptopay` → Store ID: `ramp:cryptopay` +- Plugin ID: `onramp2024` → Store ID: `ramp:onramp2024` + +### Legacy Plugins (Backward Compatibility) +Plugins migrated from the old fiat provider system MUST maintain their original store IDs to preserve access to existing user data. These are defined in `RAMP_PLUGIN_STORE_ID_OVERRIDE`. + +**⚠️ IMPORTANT: The override map should NEVER be edited or have new entries added.** + +Legacy examples: +- `paybis` → `paybis` (no prefix) +- `kado` → `money.kado` (domain prefix) +- `kadoOtc` → `money.kado` (shares store with kado) +- `simplex` → `co.edgesecure.simplex` (full domain) +- `moonpay` → `com.moonpay` (domain prefix) + +## Implementation Guide + +### Adding a New Ramp Plugin + +1. **Create your plugin factory** - No special store ID configuration needed +2. **Register in allRampPlugins.ts** - The plugin will automatically use `ramp:${pluginId}` + +```typescript +// Your new plugin - store ID will be 'ramp:mynewplugin' +export const pluginFactories: Record = { + mynewplugin: myNewPluginFactory +} +``` + +### Migrating from Old Fiat System + +If you're migrating a provider from the old fiat system: + +1. **Check if already in override map** - It should already be listed in `RAMP_PLUGIN_STORE_ID_OVERRIDE` +2. **Use existing store ID** - Do NOT add new entries to the override map +3. **If not listed** - This is likely a mistake. The map contains ALL old fiat providers + +## Technical Details + +The store ID resolution is handled by `getRampPluginStoreId()` which: +1. First checks the legacy override map +2. Falls back to the new convention `ramp:${pluginId}` + +This ensures backward compatibility while maintaining a clean convention for all future plugins. + +## Why This Approach? + +- **Backward Compatibility**: Users don't lose data when providers migrate to the new system +- **Clean Namespace**: New plugins are clearly identified with the `ramp:` prefix +- **No Collisions**: Prevents store ID conflicts between old and new systems +- **Future-Proof**: Clear separation between legacy and modern plugins \ No newline at end of file diff --git a/src/plugins/ramps/utils/__tests__/rampStoreIds.test.ts b/src/plugins/ramps/utils/__tests__/rampStoreIds.test.ts new file mode 100644 index 00000000000..d9f876acdc4 --- /dev/null +++ b/src/plugins/ramps/utils/__tests__/rampStoreIds.test.ts @@ -0,0 +1,66 @@ +// This test file intentionally tests deprecated functionality for backward compatibility +import { + getRampPluginStoreId, + RAMP_PLUGIN_STORE_ID_OVERRIDE +} from '../rampStoreIds' + +describe('getRampPluginStoreId', () => { + describe('legacy plugins', () => { + it('should return legacy store IDs for migrated fiat providers', () => { + // Providers with matching providerId and storeId + expect(getRampPluginStoreId('banxa')).toBe('banxa') + expect(getRampPluginStoreId('paybis')).toBe('paybis') + expect(getRampPluginStoreId('ionia')).toBe('ionia') + expect(getRampPluginStoreId('revolut')).toBe('revolut') + + // Providers with domain-prefixed storeIds + expect(getRampPluginStoreId('bity')).toBe('com.bity') + expect(getRampPluginStoreId('kado')).toBe('money.kado') + expect(getRampPluginStoreId('moonpay')).toBe('com.moonpay') + expect(getRampPluginStoreId('mtpelerin')).toBe('com.mtpelerin') + expect(getRampPluginStoreId('simplex')).toBe('co.edgesecure.simplex') + }) + + it('should handle kadoOtc sharing store with kado', () => { + expect(getRampPluginStoreId('kadoOtc')).toBe('money.kado') + expect(getRampPluginStoreId('kado')).toBe('money.kado') + // Both should resolve to the same store ID + expect(getRampPluginStoreId('kadoOtc')).toBe(getRampPluginStoreId('kado')) + }) + }) + + describe('new plugins', () => { + it('should use ramp: prefix for plugins not in override map', () => { + expect(getRampPluginStoreId('newexchange')).toBe('ramp:newexchange') + expect(getRampPluginStoreId('cryptopay')).toBe('ramp:cryptopay') + expect(getRampPluginStoreId('onramp2024')).toBe('ramp:onramp2024') + expect(getRampPluginStoreId('futurePlugin')).toBe('ramp:futurePlugin') + }) + }) + + describe('override map integrity', () => { + it('should contain all expected legacy providers', () => { + const expectedLegacyProviders = [ + 'banxa', + 'paybis', + 'ionia', + 'revolut', + 'bity', + 'kado', + 'kadoOtc', + 'moonpay', + 'mtpelerin', + 'simplex' + ] + + expectedLegacyProviders.forEach(provider => { + expect(RAMP_PLUGIN_STORE_ID_OVERRIDE).toHaveProperty(provider) + }) + }) + + it('should have exactly 10 legacy providers', () => { + // This ensures no new providers are accidentally added + expect(Object.keys(RAMP_PLUGIN_STORE_ID_OVERRIDE)).toHaveLength(10) + }) + }) +}) diff --git a/src/plugins/ramps/utils/createStore.ts b/src/plugins/ramps/utils/createStore.ts new file mode 100644 index 00000000000..bc7af55e063 --- /dev/null +++ b/src/plugins/ramps/utils/createStore.ts @@ -0,0 +1,24 @@ +import type { EdgeDataStore } from 'edge-core-js' + +export interface RampPluginStore { + readonly deleteItem: (itemId: string) => Promise + readonly listItemIds: () => Promise + readonly getItem: (itemId: string) => Promise + readonly setItem: (itemId: string, value: string) => Promise +} + +export const createStore = ( + storeId: string, + store: EdgeDataStore +): RampPluginStore => { + return { + deleteItem: async (itemId: string) => { + await store.deleteItem(storeId, itemId) + }, + listItemIds: async () => await store.listItemIds(storeId), + getItem: async (itemId: string) => await store.getItem(storeId, itemId), + setItem: async (itemId: string, value: string) => { + await store.setItem(storeId, itemId, value) + } + } +} diff --git a/src/plugins/ramps/utils/rampStoreIds.ts b/src/plugins/ramps/utils/rampStoreIds.ts new file mode 100644 index 00000000000..6b010491897 --- /dev/null +++ b/src/plugins/ramps/utils/rampStoreIds.ts @@ -0,0 +1,62 @@ +/** + * LEGACY STORE ID OVERRIDES - DO NOT EDIT + * + * This map contains store ID overrides for ramp plugins that were migrated from + * the old fiat provider system. These overrides exist ONLY to maintain backward + * compatibility with existing user data that was stored under the old store IDs. + * + * ⚠️ IMPORTANT: + * - This map should NEVER be edited or have new entries added + * - This is ONLY for plugins migrated from the old fiat provider system + * - All NEW ramp plugins MUST use the standard convention: `ramp:${pluginId}` + * - These overrides are technical debt that we maintain for backward compatibility + * + * The entries below represent ALL the old fiat providers that will eventually + * be migrated to the new ramp plugin system. Once migrated, they must continue + * using their legacy store IDs to access existing user data. + * + * @deprecated This entire map is deprecated. New plugins should not be added here. + */ +export const RAMP_PLUGIN_STORE_ID_OVERRIDE: Record = { + // Providers with matching providerId and storeId: + banxa: 'banxa', + paybis: 'paybis', + ionia: 'ionia', + revolut: 'revolut', + + // Providers with domain-prefixed storeIds: + bity: 'com.bity', + kado: 'money.kado', + kadoOtc: 'money.kado', // NOTE: Shares store with kado + moonpay: 'com.moonpay', + mtpelerin: 'com.mtpelerin', + simplex: 'co.edgesecure.simplex' +} as const + +/** + * Get the store ID for a ramp plugin. + * + * This function implements the store ID convention for ramp plugins: + * - Legacy plugins (migrated from old fiat system): Use override from RAMP_PLUGIN_STORE_ID_OVERRIDE + * - New plugins: Use the convention `ramp:${pluginId}` + * + * @param pluginId - The unique identifier for the ramp plugin + * @returns The store ID to use for EdgeDataStore operations + * + * @example + * // Legacy plugin (migrated from old system) + * getRampPluginStoreId('paybis') // Returns: 'paybis' + * getRampPluginStoreId('moonpay') // Returns: 'com.moonpay' + * + * @example + * // New plugin (not in legacy map) + * getRampPluginStoreId('newexchange') // Returns: 'ramp:newexchange' + * getRampPluginStoreId('cryptopay') // Returns: 'ramp:cryptopay' + */ +export function getRampPluginStoreId(pluginId: string): string { + // Check if this is a legacy plugin that needs backward compatibility + const legacyStoreId = RAMP_PLUGIN_STORE_ID_OVERRIDE[pluginId] + + // Use legacy store ID if it exists, otherwise use new convention + return legacyStoreId ?? `ramp:${pluginId}` +} diff --git a/src/theme/variables/edgeDark.ts b/src/theme/variables/edgeDark.ts index 3a5fbf20eab..8ce81ebc8a8 100644 --- a/src/theme/variables/edgeDark.ts +++ b/src/theme/variables/edgeDark.ts @@ -267,6 +267,7 @@ export const edgeDark: Theme = { secondaryButtonOutline: palette.graySecondary, secondaryButtonOutlineWidth: 0, secondaryButton: [palette.graySecondary, palette.graySecondary], + secondaryButtonDisabled: [palette.transparent, palette.transparent], secondaryButtonColorStart: { x: 0, y: 0 }, secondaryButtonColorEnd: { x: 1, y: 1 }, secondaryButtonText: palette.white, diff --git a/src/theme/variables/edgeLight.ts b/src/theme/variables/edgeLight.ts index 53eef4350b4..854167fb9cf 100644 --- a/src/theme/variables/edgeLight.ts +++ b/src/theme/variables/edgeLight.ts @@ -226,6 +226,7 @@ export const edgeLight: Theme = { secondaryButtonOutline: palette.edgeBlue, secondaryButtonOutlineWidth: 1, secondaryButton: [palette.transparent, palette.transparent], + secondaryButtonDisabled: [palette.transparent, palette.transparent], secondaryButtonColorStart: { x: 0, y: 0 }, secondaryButtonColorEnd: { x: 1, y: 1 }, secondaryButtonText: palette.edgeBlue, diff --git a/src/theme/variables/testDark.ts b/src/theme/variables/testDark.ts index 1f47c6b750f..d4209840817 100644 --- a/src/theme/variables/testDark.ts +++ b/src/theme/variables/testDark.ts @@ -261,6 +261,7 @@ export const testDark: Theme = { secondaryButtonOutline: palette.graySecondary, secondaryButtonOutlineWidth: 0, secondaryButton: [palette.graySecondary, palette.graySecondary], + secondaryButtonDisabled: [palette.transparent, palette.transparent], secondaryButtonColorStart: { x: 0, y: 0 }, secondaryButtonColorEnd: { x: 1, y: 1 }, secondaryButtonText: palette.white, diff --git a/src/theme/variables/testLight.ts b/src/theme/variables/testLight.ts index b7c6d557c76..87cae155c5d 100644 --- a/src/theme/variables/testLight.ts +++ b/src/theme/variables/testLight.ts @@ -226,6 +226,7 @@ export const testLight: Theme = { secondaryButtonOutline: palette.edgeBlue, secondaryButtonOutlineWidth: 1, secondaryButton: [palette.transparent, palette.transparent], + secondaryButtonDisabled: [palette.transparent, palette.transparent], secondaryButtonColorStart: { x: 0, y: 0 }, secondaryButtonColorEnd: { x: 1, y: 1 }, secondaryButtonText: palette.edgeBlue, diff --git a/src/types/DeepLinkTypes.ts b/src/types/DeepLinkTypes.ts index 19d99f6f64d..a0818842769 100644 --- a/src/types/DeepLinkTypes.ts +++ b/src/types/DeepLinkTypes.ts @@ -85,6 +85,15 @@ export interface FiatProviderLink { uri: string } +export interface RampLink { + type: 'ramp' + direction: FiatDirection + providerId: string + path: string + query: Record + uri: string +} + export interface PromotionLink { type: 'promotion' installerId?: string @@ -151,6 +160,7 @@ export type DeepLink = | PluginLink | PriceChangeLink | PromotionLink + | RampLink | RequestAddressLink | SwapLink | WalletConnectLink diff --git a/src/types/Theme.ts b/src/types/Theme.ts index d9042a50603..c989a54d0cf 100644 --- a/src/types/Theme.ts +++ b/src/types/Theme.ts @@ -211,6 +211,7 @@ export interface Theme { secondaryButtonOutline: string secondaryButtonOutlineWidth: number secondaryButton: string[] + secondaryButtonDisabled: string[] secondaryButtonColorStart: GradientCoords secondaryButtonColorEnd: GradientCoords secondaryButtonText: string diff --git a/src/types/routerTypes.tsx b/src/types/routerTypes.tsx index 3fb9ba05985..e72d300d6d5 100644 --- a/src/types/routerTypes.tsx +++ b/src/types/routerTypes.tsx @@ -63,6 +63,8 @@ import type { SweepPrivateKeyCalculateFeeParams } from '../components/scenes/Swe import type { SweepPrivateKeyCompletionParams } from '../components/scenes/SweepPrivateKeyCompletionScene' import type { SweepPrivateKeyProcessingParams } from '../components/scenes/SweepPrivateKeyProcessingScene' import type { SweepPrivateKeySelectCryptoParams } from '../components/scenes/SweepPrivateKeySelectCryptoScene' +import type { TradeCreateParams } from '../components/scenes/TradeCreateScene' +import type { RampSelectOptionParams } from '../components/scenes/TradeOptionSelectScene' import type { TransactionDetailsParams } from '../components/scenes/TransactionDetailsScene' import type { TransactionListParams } from '../components/scenes/TransactionListScene' import type { TransactionsExportParams } from '../components/scenes/TransactionsExportScene' @@ -97,9 +99,13 @@ export type WalletsTabParamList = {} & { export type BuyTabParamList = {} & { // Buy-specific navigation - pluginListBuy?: GuiPluginListParams + pluginListBuy?: TradeCreateParams + pluginListBuyOld?: GuiPluginListParams pluginViewBuy: PluginViewParams + // Ramp plugin + rampSelectOption: RampSelectOptionParams + // Shared GUI plugin forms/displays guiPluginAddressForm: FiatPluginAddressFormParams guiPluginContactForm: FiatPluginEmailFormParams @@ -128,6 +134,10 @@ export type SwapTabParamList = {} & { swapProcessing: SwapProcessingParams } +export interface TradeTabParamList { + pluginListBuy: TradeCreateParams +} + export type EdgeTabsParamList = {} & { home: undefined walletsTab: diff --git a/src/types/types.ts b/src/types/types.ts index 31ef10bf245..95c3d2aa5e2 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -40,6 +40,17 @@ export type NumberMap = Record export type StringMap = Record export type MapObject = Record +/** Inspired by Rust's Result type. */ +export type Result = Ok | NotOK +export interface Ok { + ok: true + value: T +} +export interface NotOK { + ok: false + error: E +} + export interface GuiContact { // These are all we use. // See 'react-native-contacts' for other possible fields. diff --git a/src/util/DeepLinkParser.ts b/src/util/DeepLinkParser.ts index ca79d28cf8b..ba78c80792c 100644 --- a/src/util/DeepLinkParser.ts +++ b/src/util/DeepLinkParser.ts @@ -38,7 +38,7 @@ export function parseDeepLink( if (url.protocol === 'dev:') { return { type: 'scene', - // @ts-expect-error + // @ts-expect-error - sceneName cannot have slashes in it apparently sceneName: url.pathname.replace('/', ''), query: parseQuery(url.query) } @@ -63,7 +63,7 @@ export function parseDeepLink( // besides the specific currency defined in the uri's scheme. // Even if a specific currency is found in the protocol, the payment protocol // does not care what currency the payment steps start with. - if (betterUrl.query.r != null && betterUrl.query.r.includes('http')) { + if (betterUrl.query.r?.includes('http') === true) { // If the URI started with 'bitcoin:', etc. uri = betterUrl.query.r return { type: 'paymentProto', uri } @@ -140,6 +140,20 @@ function parseEdgeProtocol(url: URL): DeepLink { } } + case 'ramp': { + const [directionString, providerId, ...deepPath] = pathParts + const direction = asFiatDirection(directionString) + + return { + type: 'ramp', + direction, + path: stringifyPath(deepPath), + providerId, + query: parseQuery(url.query), + uri: url.href + } + } + case 'plugin': { const [pluginId, ...deepPath] = pathParts @@ -256,7 +270,7 @@ function parseDownloadLink(url: URL): PromotionLink { */ function parseEdgeAppLink(url: URL): DeepLink { const [, ...pathParts] = url.pathname.split('/') - const firstPath = pathParts[0] || '' + const firstPath = pathParts[0] ?? '' const query = parseQuery(url.query) // Handle rewards links @@ -268,7 +282,7 @@ function parseEdgeAppLink(url: URL): DeepLink { // Parse data in format{{REWARDS:ethereum:a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48}} const dataMatch = /{{([^:]+):([^:]+)(?::([^}]+))?}}/.exec(data) - if (dataMatch) { + if (dataMatch != null) { const [, type, pluginId, tokenId = null] = dataMatch // Currently only handling REWARDS type diff --git a/src/util/__tests__/paymentTypeIcons.test.ts b/src/util/__tests__/paymentTypeIcons.test.ts new file mode 100644 index 00000000000..088c8c93b43 --- /dev/null +++ b/src/util/__tests__/paymentTypeIcons.test.ts @@ -0,0 +1,57 @@ +import { getPaymentTypeIcon, getPaymentTypeThemeKey } from '../paymentTypeIcons' + +describe('paymentTypeIcons', () => { + const mockTheme = { + paymentTypeLogoApplePay: { uri: 'apple-pay-icon.png' }, + paymentTypeLogoBankTransfer: { uri: 'bank-transfer-icon.png' }, + paymentTypeLogoCreditCard: { uri: 'credit-card-icon.png' }, + paymentTypeLogoFasterPayments: { uri: 'faster-payments-icon.png' }, + paymentTypeLogoGooglePay: { uri: 'google-pay-icon.png' }, + paymentTypeLogoIdeal: { uri: 'ideal-icon.png' }, + paymentTypeLogoInterac: { uri: 'interac-icon.png' }, + paymentTypeLogoPayid: { uri: 'payid-icon.png' }, + paymentTypeLogoPaypal: { uri: 'paypal-icon.png' }, + paymentTypeLogoPix: { uri: 'pix-icon.png' }, + paymentTypeLogoRevolut: { uri: 'revolut-icon.png' }, + paymentTypeLogoVenmo: { uri: 'venmo-icon.png' } + } as any + + describe('getPaymentTypeThemeKey', () => { + it('should return correct theme key for known payment types', () => { + expect(getPaymentTypeThemeKey('applepay')).toBe('paymentTypeLogoApplePay') + expect(getPaymentTypeThemeKey('credit')).toBe('paymentTypeLogoCreditCard') + expect(getPaymentTypeThemeKey('paypal')).toBe('paymentTypeLogoPaypal') + }) + + it('should return bank transfer key for fallback payment types', () => { + expect(getPaymentTypeThemeKey('ach')).toBe('paymentTypeLogoBankTransfer') + expect(getPaymentTypeThemeKey('sepa')).toBe('paymentTypeLogoBankTransfer') + expect(getPaymentTypeThemeKey('wire')).toBe('paymentTypeLogoBankTransfer') + }) + }) + + describe('getPaymentTypeIcon', () => { + it('should return correct icon for known payment types', () => { + expect(getPaymentTypeIcon('applepay', mockTheme)).toEqual({ + uri: 'apple-pay-icon.png' + }) + expect(getPaymentTypeIcon('credit', mockTheme)).toEqual({ + uri: 'credit-card-icon.png' + }) + }) + + it('should return bank transfer icon for fallback payment types', () => { + expect(getPaymentTypeIcon('ach', mockTheme)).toEqual({ + uri: 'bank-transfer-icon.png' + }) + expect(getPaymentTypeIcon('sepa', mockTheme)).toEqual({ + uri: 'bank-transfer-icon.png' + }) + }) + + it('should return undefined for invalid theme key', () => { + const invalidTheme = {} as any + expect(getPaymentTypeIcon('applepay', invalidTheme)).toBeUndefined() + }) + }) +}) diff --git a/src/util/__tests__/paymentTypeUtils.test.ts b/src/util/__tests__/paymentTypeUtils.test.ts new file mode 100644 index 00000000000..78108ae7158 --- /dev/null +++ b/src/util/__tests__/paymentTypeUtils.test.ts @@ -0,0 +1,53 @@ +import { + formatPaymentTypes, + getPaymentTypeDisplayName +} from '../paymentTypeUtils' + +describe('paymentTypeUtils', () => { + describe('getPaymentTypeDisplayName', () => { + it('should return display name for known payment types', () => { + expect(getPaymentTypeDisplayName('ach')).toBe('ACH Bank Transfer') + expect(getPaymentTypeDisplayName('credit')).toBe('Credit and Debit Card') + expect(getPaymentTypeDisplayName('venmo')).toBe('Venmo') + expect(getPaymentTypeDisplayName('sepa')).toBe('SEPA Bank Transfer') + }) + + it('should return original value for unknown payment types', () => { + expect(getPaymentTypeDisplayName('unknown')).toBe('unknown') + expect(getPaymentTypeDisplayName('newpaymenttype')).toBe('newpaymenttype') + }) + }) + + describe('formatPaymentTypes', () => { + it('should handle empty array', () => { + expect(formatPaymentTypes([])).toBe('') + }) + + it('should format single payment type', () => { + expect(formatPaymentTypes(['ach'])).toBe('ACH Bank Transfer') + expect(formatPaymentTypes(['credit'])).toBe('Credit and Debit Card') + }) + + it('should format two payment types with "or"', () => { + expect(formatPaymentTypes(['ach', 'credit'])).toBe( + 'ACH Bank Transfer or Credit and Debit Card' + ) + expect(formatPaymentTypes(['venmo', 'paypal'])).toBe('Venmo or Paypal') + }) + + it('should format multiple payment types with commas and "or"', () => { + expect(formatPaymentTypes(['ach', 'credit', 'venmo'])).toBe( + 'ACH Bank Transfer, Credit and Debit Card, or Venmo' + ) + expect(formatPaymentTypes(['sepa', 'wire', 'ach', 'credit'])).toBe( + 'SEPA Bank Transfer, Bank Wire Transfer, ACH Bank Transfer, or Credit and Debit Card' + ) + }) + + it('should handle unknown payment types', () => { + expect(formatPaymentTypes(['unknown1', 'unknown2'])).toBe( + 'unknown1 or unknown2' + ) + }) + }) +}) diff --git a/src/util/paymentTypeIcons.ts b/src/util/paymentTypeIcons.ts new file mode 100644 index 00000000000..93365185b07 --- /dev/null +++ b/src/util/paymentTypeIcons.ts @@ -0,0 +1,58 @@ +import type { FiatPaymentType } from '../plugins/gui/fiatPluginTypes' +import type { ImageProp, Theme } from '../types/Theme' + +// Payment type to theme key mapping +// Note: Some payment types from GuiPluginListScene (auspost, bank, cash, debit, giftcard, paynow, poli, sofort, upi, visa) +// are not in FiatPaymentType, so they are not included here +const paymentTypeToThemeKey: Record = { + ach: 'paymentTypeLogoBankTransfer', // Using bank transfer as fallback + applepay: 'paymentTypeLogoApplePay', + colombiabank: 'paymentTypeLogoBankTransfer', // Using bank transfer as fallback + credit: 'paymentTypeLogoCreditCard', + directtobank: 'paymentTypeLogoBankTransfer', // Using bank transfer as fallback + fasterpayments: 'paymentTypeLogoFasterPayments', + googlepay: 'paymentTypeLogoGooglePay', + iach: 'paymentTypeLogoBankTransfer', // Using bank transfer as fallback + ideal: 'paymentTypeLogoIdeal', + interac: 'paymentTypeLogoInterac', + iobank: 'paymentTypeLogoBankTransfer', // Using bank transfer as fallback + mexicobank: 'paymentTypeLogoBankTransfer', // Using bank transfer as fallback + payid: 'paymentTypeLogoPayid', + paypal: 'paymentTypeLogoPaypal', + pix: 'paymentTypeLogoPix', + pse: 'paymentTypeLogoBankTransfer', // Using bank transfer as fallback + revolut: 'paymentTypeLogoRevolut', + sepa: 'paymentTypeLogoBankTransfer', // Using bank transfer as fallback + spei: 'paymentTypeLogoBankTransfer', // Using bank transfer as fallback + turkishbank: 'paymentTypeLogoBankTransfer', // Using bank transfer as fallback + venmo: 'paymentTypeLogoVenmo', + wire: 'paymentTypeLogoBankTransfer' // Using bank transfer as fallback +} + +/** + * Get the theme icon for a payment type + * @param paymentType - The payment type to get the icon for + * @param theme - The theme object containing the icon images + * @returns The icon image or undefined if not found + */ +export function getPaymentTypeIcon( + paymentType: FiatPaymentType, + theme: Theme +): ImageProp | undefined { + const themeKey = paymentTypeToThemeKey[paymentType] + if (themeKey == null) return undefined + + // Type assertion needed because TypeScript can't narrow the union type + return theme[themeKey] as ImageProp +} + +/** + * Get the theme key for a payment type + * @param paymentType - The payment type to get the theme key for + * @returns The theme key or null if not found + */ +export function getPaymentTypeThemeKey( + paymentType: FiatPaymentType +): keyof Theme | null { + return paymentTypeToThemeKey[paymentType] +} diff --git a/src/util/paymentTypeUtils.ts b/src/util/paymentTypeUtils.ts new file mode 100644 index 00000000000..aeaa6ac2971 --- /dev/null +++ b/src/util/paymentTypeUtils.ts @@ -0,0 +1,54 @@ +// Payment type display name mapping based on plugin configurations +const paymentTypeDisplayNames: Record = { + ach: 'ACH Bank Transfer', + applepay: 'Apple Pay', + bank: 'Bank Transfer', + colombiabank: 'Colombia Bank Transfer', + credit: 'Credit and Debit Card', + debit: 'Debit Card', + directtobank: 'Direct to Bank', + fasterpayments: 'Faster Payments', + googlepay: 'Google Pay', + iach: 'Instant ACH Bank Transfer', + ideal: 'iDEAL', + interac: 'Interac e-Transfer', + iobank: 'Bank Transfer', + mexicobank: 'Mexico Bank Transfer', + payid: 'PayID', + paypal: 'Paypal', + pix: 'PIX', + pse: 'PSE Payment', + revolut: 'Revolut', + sepa: 'SEPA Bank Transfer', + spei: 'SPEI Bank Transfer', + turkishbank: 'Turkish Bank Transfer', + venmo: 'Venmo', + wire: 'Bank Wire Transfer' +} + +/** + * Get the display name for a payment type + * @param paymentType - The payment type identifier + * @returns The human-readable display name + */ +export const getPaymentTypeDisplayName = (paymentType: string): string => { + return paymentTypeDisplayNames[paymentType] ?? paymentType +} + +/** + * Format multiple payment types into a display string + * @param paymentTypes - Array of payment type identifiers + * @returns Formatted string of payment type display names + */ +export const formatPaymentTypes = (paymentTypes: string[]): string => { + if (paymentTypes.length === 0) return '' + + const displayNames = paymentTypes.map(type => getPaymentTypeDisplayName(type)) + + if (displayNames.length === 1) return displayNames[0] + if (displayNames.length === 2) return displayNames.join(' or ') + + // For 3+ items: "Item1, Item2, or Item3" + const lastItem = displayNames.pop() + return `${displayNames.join(', ')}, or ${lastItem}` +} diff --git a/yarn.lock b/yarn.lock index e510fd9932f..53236f012b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5182,6 +5182,18 @@ dependencies: defer-to-connect "^2.0.1" +"@tanstack/query-core@5.83.1": + version "5.83.1" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.83.1.tgz#eed82970b30cb24536f561613b5630e03d349628" + integrity sha512-OG69LQgT7jSp+5pPuCfzltq/+7l2xoweggjme9vlbCPa/d7D7zaqv5vN/S82SzSYZ4EDLTxNO1PWrv49RAS64Q== + +"@tanstack/react-query@^5.84.2": + version "5.84.2" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.84.2.tgz#008a8cd26b1e258f87f54cf00cbae14e9c3c84d2" + integrity sha512-cZadySzROlD2+o8zIfbD978p0IphuQzRWiiH3I2ugnTmz4jbjc0+TdibpwqxlzynEen8OulgAg+rzdNF37s7XQ== + dependencies: + "@tanstack/query-core" "5.83.1" + "@testing-library/react-native@^13.2.0": version "13.2.0" resolved "https://registry.yarnpkg.com/@testing-library/react-native/-/react-native-13.2.0.tgz#b4f53c69a889728abe8bc3115ba803824bcafe10"