From 40fb1898ba84eac5d59d16948abe82f266b83187 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 24 Mar 2026 17:46:52 +0100 Subject: [PATCH] Add DFX.swiss native ramp plugin (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add DFX.swiss native ramp plugin Implement DFX.swiss as a Direct API ramp provider for buying (SEPA + Card) and selling (SEPA) crypto with EUR/CHF. Authentication via wallet signature, no API key required. - Buy SEPA: native flow with InfoDisplayScene showing bank transfer details - Buy Card: DFX webview with 3DS and deeplink callback - Sell SEPA: native SendScene2 flow with TX hash confirmation - KYC handling via DFX webview when required - Cached provider config (fiat/assets/countries) with 2min TTL - Region constraints: blocked for IR, KP, MM, US, IL - Supports Bitcoin, Ethereum, Arbitrum, Optimism, Polygon, Base, BSC, Solana, Tron, Monero, Cardano * Fix DFX plugin: API format and native asset detection - Fix quote/paymentInfos API calls to use object refs ({id, blockchain}) instead of string names — DFX API requires structured parameters - Fix native coin detection: DFX returns wrapped-token contract addresses for native coins (WETH for ETH, etc.), now detected by name match - Fix country cleaner field names: locationAllowed/bankAllowed/cardAllowed instead of mapi-prefixed names - Fix chainId cleaner to accept null values from API (asEither(asString, asNull)) * Fix native coin detection: only match by name, not null chainId Assets with chainId=null that aren't the native coin (e.g. MATIC on Arbitrum) were incorrectly treated as native. Now only assets whose name matches the blockchain's native coin name get tokenId=null. Assets without chainId that aren't native are skipped. * Add Zano blockchain support to DFX ramp plugin * Remove card payment from DFX plugin, fix SEPA settlement range DFX only supports bank transfers, not credit cards. Remove the card payment type, constraints, and webview flow. Update SEPA settlement range to 0-2 days and fix the display logic so a zero minimum shows "0 - 2 days" instead of "Instant". * Fix DFX auth message, wallet ID, and buy payment response parsing Fix auth signature message to match DFX API ("Blockchain" not "blockchain"). Use wallet ID 'arkade' for auth requests. Update buy paymentInfos response parsing for new DFX API format: iban/bic are now top-level fields, currency is a FiatDto object. Handle isValid/error fields and include response body in error messages for easier debugging. * Add buy confirm flow, email collection, support link, and fix logo On buy completion ("Done" button): - Check if user has email registered via GET /v2/user - If not, show email input modal and submit via PUT /v2/user/mail - Confirm buy order via PUT /buy/paymentInfos/{id}/confirm - Navigate back to previous scene Add optional support URL button to InfoDisplayScene, used by DFX to link to support page with auth session token. Fix partner icon URL to use app.dfx.swiss/logo.png. * Improve DFX plugin: spinner, address selection, error handling, KYC redirect - Add loading spinner (showToastSpinner) during buy paymentInfos call - Prefer segwit/transparent addresses via getBestAddress() helper for auth, buy, and sell flows - Replace silent catch blocks with console.warn logging - Include response body in sell error messages for debugging - Redirect to DFX KYC webview on LimitExceeded and other KYC errors - Use wallet ID 'edge' for auth requests - Open /kyc path in KYC webview for direct KYC flow * Harden DFX plugin: sell validation, address guard, cleanup - Add isValid/error check to sell paymentInfos response with KYC redirect (matching buy flow behavior) - Guard against empty address array in getBestAddress() - Remove console.warn statements (not allowed in production code) - Add isValid/error fields to sell payment info type * Remove unused asDfxQuoteError type * Use navigation.pop() consistently in buy flow Match the pattern used by sell flow and all other ramp plugins. * Add KYC deeplink callback for automatic resume after completion Use openExternalWebView with kyc-redirect parameter pointing to https://deep.edge.app/ramp/{direction}/dfx. After KYC completion, DFX web app redirects back to Edge via deeplink, which dismisses the browser and shows a success toast. Register deeplink handler via rampDeeplinkManager for proper lifecycle management. Pass direction to handleKycRequired for correct deeplink routing. * Fix limit check for crypto amount type Limit checks were only applied when amountType was 'fiat'. When the user enters a crypto amount, the estimated fiat equivalent from the quote is now checked against minVolume/maxVolume. * Fix amount calculation and limit checks for crypto input and sell direction DFX API returns amounts in source currency (fiat for buy, crypto for sell). Fix three logic errors: - Buy + crypto input: fiatAmount was set to crypto exchangeAmount instead of dfxQuote.amount (the fiat cost from API) - Sell + crypto input: limit check was comparing fiat against crypto min/max volumes - Limit error display code now shows correct currency per direction Rename minFiat/maxFiat to minSource/maxSource to clarify that these values are in source currency, not always fiat. * Fix quote request: use targetAmount as number, not boolean DFX API expects targetAmount as a numeric amount in target asset, not a boolean flag. Previously sent targetAmount: true which was interpreted as 1 unit. Now correctly sends the crypto amount as targetAmount when amountType is crypto. * Fix auth cache, empty catches, types, and hardcoded values - Auth cache keyed per address instead of global (prevents cross-wallet token reuse when switching between wallets) - Replace 3 empty catch {} with showError() for email flow, buy confirm, and sell confirm - Replace Record quoteBody with typed DfxQuoteBody interface - Extract EVM_CHAINS as module-level constant - Derive settlement days display string from getSettlementRange() instead of hardcoded '1-2' * Fix sell quote amount mapping and add supportedAmountTypes - Fix amount/targetAmount assignment for sell direction: buy sends fiat as amount, crypto as targetAmount; sell sends crypto as amount, fiat as targetAmount (was direction-agnostic, causing wrong quotes for sell) - Fix max-amount underLimit error to show crypto currency for sell direction instead of always showing fiat - Add supportedAmountTypes: ['fiat', 'crypto'] to checkSupport return value (consistent with other ramp plugins) * Avoid replacing urls at runtime * Fixed amount calculation * Show SEPA form in sell flow * Improved sell flow * Fixed transaction status page --------- Co-authored-by: Daniel Padrino Co-authored-by: David May --- .../scenes/RampSelectOptionScene.tsx | 9 +- src/envConfig.ts | 3 + src/locales/en_US.ts | 2 + src/locales/strings/enUS.json | 1 + src/plugins/gui/scenes/InfoDisplayScene.tsx | 15 +- src/plugins/ramps/allRampPlugins.ts | 2 + src/plugins/ramps/dfx/dfxRampPlugin.ts | 1303 +++++++++++++++++ src/plugins/ramps/dfx/dfxRampTypes.ts | 122 ++ src/plugins/ramps/rampConstraints.ts | 10 + src/plugins/ramps/utils/getSettlementRange.ts | 4 +- 10 files changed, 1465 insertions(+), 6 deletions(-) create mode 100644 src/plugins/ramps/dfx/dfxRampPlugin.ts create mode 100644 src/plugins/ramps/dfx/dfxRampTypes.ts diff --git a/src/components/scenes/RampSelectOptionScene.tsx b/src/components/scenes/RampSelectOptionScene.tsx index c55eaa066dd..25adff9becc 100644 --- a/src/components/scenes/RampSelectOptionScene.tsx +++ b/src/components/scenes/RampSelectOptionScene.tsx @@ -461,12 +461,17 @@ const formatTimeUnit = (time: { value: number; unit: string }): string => { // Format settlement range for display const formatSettlementTime = (range: SettlementRange): string => { // Handle instant settlement - if (range.min.value === 0) { + if (range.min.value === 0 && range.max.value === 0) { return `${lstrings.trade_option_settlement_label}: Instant` } - const minStr = formatTimeUnit(range.min) const maxStr = formatTimeUnit(range.max) + if (range.min.value === 0) { + return `${lstrings.trade_option_settlement_label}: 0 - ${maxStr}` + } + + const minStr = formatTimeUnit(range.min) + return `${lstrings.trade_option_settlement_label}: ${minStr} - ${maxStr}` } diff --git a/src/envConfig.ts b/src/envConfig.ts index c581de86ca2..10ad590a5fd 100644 --- a/src/envConfig.ts +++ b/src/envConfig.ts @@ -12,6 +12,7 @@ import { import { asInitOptions as asBanxaInitOptions } from './plugins/ramps/banxa/banxaRampTypes' import { asInitOptions as asBitsofgoldInitOptions } from './plugins/ramps/bitsofgold/bitsofgoldRampTypes' +import { asInitOptions as asDfxInitOptions } from './plugins/ramps/dfx/dfxRampTypes' import { asInitOptions as asInfiniteInitOptions } from './plugins/ramps/infinite/infiniteRampTypes' import { asInitOptions as asLibertyxInitOptions } from './plugins/ramps/libertyx/libertyxRampTypes' import { asInitOptions as asMoonpayInitOptions } from './plugins/ramps/moonpay/moonpayRampTypes' @@ -186,6 +187,7 @@ export const asEnvConfig = asObject({ asObject>({ banxa: asOptional(asBanxaInitOptions), bitsofgold: asOptional(asBitsofgoldInitOptions), + dfx: asOptional(asDfxInitOptions), libertyx: asOptional(asLibertyxInitOptions), moonpay: asOptional(asMoonpayInitOptions), infinite: asOptional(asInfiniteInitOptions), @@ -196,6 +198,7 @@ export const asEnvConfig = asObject({ () => ({ banxa: undefined, bitsofgold: undefined, + dfx: undefined, libertyx: undefined, moonpay: undefined, infinite: undefined, diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index f95afa368e7..1e37f1be7c8 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -2509,6 +2509,8 @@ const strings = { 'Additional information is required for KYC verification.', ramp_kyc_unknown_status: 'Unknown verification status.', ramp_kyc_complete_button: 'Complete KYC', + ramp_kyc_email_required_message: + 'Please provide your email address to complete this transaction.', ramp_signup_failed_title: 'Failed to Sign Up', ramp_signup_failed_account_existsmessage: 'An account already exists using this email address. Please contact support to recover your account.', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index fd6e1bbd1ec..2919becabcb 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1963,6 +1963,7 @@ "ramp_kyc_additional_info_required": "Additional information is required for KYC verification.", "ramp_kyc_unknown_status": "Unknown verification status.", "ramp_kyc_complete_button": "Complete KYC", + "ramp_kyc_email_required_message": "Please provide your email address to complete this transaction.", "ramp_signup_failed_title": "Failed to Sign Up", "ramp_signup_failed_account_existsmessage": "An account already exists using this email address. Please contact support to recover your account.", "ramp_otp_verification_title": "Email Verification", diff --git a/src/plugins/gui/scenes/InfoDisplayScene.tsx b/src/plugins/gui/scenes/InfoDisplayScene.tsx index e16f39efd56..9c33537a4da 100644 --- a/src/plugins/gui/scenes/InfoDisplayScene.tsx +++ b/src/plugins/gui/scenes/InfoDisplayScene.tsx @@ -1,6 +1,6 @@ import Clipboard from '@react-native-clipboard/clipboard' import * as React from 'react' -import { View } from 'react-native' +import { Linking, View } from 'react-native' import { Fontello } from '../../../assets/vector/index' import { SceneButtons } from '../../../components/buttons/SceneButtons' @@ -23,6 +23,7 @@ export interface FiatPluginSepaTransferParams { promptMessage: string transferInfo: FiatPluginSepaTransferInfo headerIconUri?: string + supportUrl?: string onDone: () => Promise } @@ -38,7 +39,8 @@ export const InfoDisplayScene = React.memo((props: Props) => { const styles = getStyles(theme) const { route } = props // TODO: headerIconUri - const { headerTitle, transferInfo, promptMessage, onDone } = route.params + const { headerTitle, transferInfo, promptMessage, supportUrl, onDone } = + route.params const displayData: InfoDisplayGroup[] = React.useMemo(() => { const { input, output, paymentDetails } = transferInfo @@ -109,6 +111,10 @@ export const InfoDisplayScene = React.memo((props: Props) => { await onDone() }) + const handleSupport = useHandler(async () => { + if (supportUrl != null) await Linking.openURL(supportUrl) + }) + const renderCopyButton = (value: string): React.ReactElement => { return ( { {renderGroups()} ) diff --git a/src/plugins/ramps/allRampPlugins.ts b/src/plugins/ramps/allRampPlugins.ts index 0555d3a1793..514700cf5e7 100644 --- a/src/plugins/ramps/allRampPlugins.ts +++ b/src/plugins/ramps/allRampPlugins.ts @@ -1,5 +1,6 @@ import { banxaRampPlugin } from './banxa/banxaRampPlugin' import { bitsofgoldRampPlugin } from './bitsofgold/bitsofgoldRampPlugin' +import { dfxRampPlugin } from './dfx/dfxRampPlugin' import { infiniteRampPlugin } from './infinite/infiniteRampPlugin' import { libertyxRampPlugin } from './libertyx/libertyxRampPlugin' import { moonpayRampPlugin } from './moonpay/moonpayRampPlugin' @@ -11,6 +12,7 @@ import { simplexRampPlugin } from './simplex/simplexRampPlugin' export const pluginFactories: Record = { banxa: banxaRampPlugin, bitsofgold: bitsofgoldRampPlugin, + dfx: dfxRampPlugin, infinite: infiniteRampPlugin, libertyx: libertyxRampPlugin, moonpay: moonpayRampPlugin, diff --git a/src/plugins/ramps/dfx/dfxRampPlugin.ts b/src/plugins/ramps/dfx/dfxRampPlugin.ts new file mode 100644 index 00000000000..6ba9efea637 --- /dev/null +++ b/src/plugins/ramps/dfx/dfxRampPlugin.ts @@ -0,0 +1,1303 @@ +import { mul } from 'biggystring' +import type { + EdgeAssetAction, + EdgeCurrencyWallet, + EdgeSpendInfo, + EdgeTokenId, + EdgeTxActionFiat +} from 'edge-core-js' +import React from 'react' +import { sprintf } from 'sprintf-js' + +import { showButtonsModal } from '../../../components/modals/ButtonsModal' +import { TextInputModal } from '../../../components/modals/TextInputModal' +import type { SendScene2Params } from '../../../components/scenes/SendScene2' +import { + Airship, + showError, + showToast, + showToastSpinner +} from '../../../components/services/AirshipInstance' +import { lstrings } from '../../../locales/strings' +import { getExchangeDenom } from '../../../selectors/DenominationSelectors' +import type { SepaInfo } from '../../../types/FormTypes' +import type { StringMap } from '../../../types/types' +import { CryptoAmount } from '../../../util/CryptoAmount' +import { findTokenIdByNetworkLocation } from '../../../util/CurrencyInfoHelpers' +import { removeIsoPrefix } from '../../../util/utils' +import { + SendErrorBackPressed, + SendErrorNoTransaction +} from '../../gui/fiatPlugin' +import type { + FiatDirection, + FiatPaymentType, + FiatPluginRegionCode, + FiatPluginSepaTransferInfo +} from '../../gui/fiatPluginTypes' +import { + FiatProviderError, + type FiatProviderExactRegions, + type ProviderToken +} from '../../gui/fiatProviderTypes' +import { + addExactRegion, + NOT_SUCCESS_TOAST_HIDE_MS, + validateExactRegion +} from '../../gui/providers/common' +import { addTokenToArray } from '../../gui/util/providerUtils' +import type { + RampApproveQuoteParams, + RampCheckSupportRequest, + RampInfo, + RampPlugin, + RampPluginConfig, + RampPluginFactory, + RampQuote, + RampQuoteRequest, + RampSupportResult +} from '../rampPluginTypes' +import { + validateRampCheckSupportRequest, + validateRampQuoteRequest +} from '../utils/constraintUtils' +import { getSettlementRange } from '../utils/getSettlementRange' +import { openExternalWebView } from '../utils/webViewUtils' +import { + asDfxAssets, + asDfxAuthResponse, + asDfxBuyPaymentInfo, + asDfxCountries, + asDfxFiats, + asDfxQuote, + asDfxSellPaymentInfo, + asInitOptions, + type DfxAsset, + type DfxFiat, + type DfxPaymentMethod +} from './dfxRampTypes' + +const pluginId = 'dfx' +const partnerIcon = 'https://app.dfx.swiss/logo.png' +const pluginDisplayName = 'DFX.swiss' +const supportEmail = 'support@dfx.swiss' + +// --------------------------------------------------------------------------- +// Blockchain mapping: DFX blockchain name → Edge pluginId +// --------------------------------------------------------------------------- + +const DFX_BLOCKCHAIN_MAP: StringMap = { + Bitcoin: 'bitcoin', + Ethereum: 'ethereum', + Arbitrum: 'arbitrum', + Optimism: 'optimism', + Polygon: 'polygon', + Base: 'base', + BinanceSmartChain: 'binancesmartchain', + Solana: 'solana', + Tron: 'tron', + Monero: 'monero', + Cardano: 'cardano', + Zano: 'zano' +} + +// Reverse map: Edge pluginId → DFX blockchain name +const EDGE_TO_DFX_BLOCKCHAIN: StringMap = Object.fromEntries( + Object.entries(DFX_BLOCKCHAIN_MAP).map(([k, v]) => [v, k]) +) + +// Native coin names per DFX blockchain. DFX returns wrapped-token contract +// addresses even for native coins (e.g. WETH address for ETH). We detect +// native coins by name and set tokenId to null instead of looking up by contract. +const DFX_NATIVE_COIN_NAMES: Record = { + Bitcoin: 'BTC', + Ethereum: 'ETH', + Arbitrum: 'ETH', + Optimism: 'ETH', + Polygon: 'POL', + Base: 'ETH', + BinanceSmartChain: 'BNB', + Solana: 'SOL', + Tron: 'TRX', + Monero: 'XMR', + Cardano: 'ADA', + Zano: 'ZANO' +} + +// Countries where DFX is not available +const BLOCKED_COUNTRIES = new Set(['IR', 'KP', 'MM', 'US', 'IL']) + +// Format settlement range as "min - max" days string for display +const formatSettlementDays = ( + range: ReturnType +): string => { + const min = range.min.unit === 'days' ? range.min.value : 0 + const max = range.max.unit === 'days' ? range.max.value : 1 + return `${min} - ${max}` +} + +// EVM chains that require hex-encoded message for signing +const EVM_CHAINS = new Set([ + 'ethereum', + 'arbitrum', + 'optimism', + 'polygon', + 'base', + 'binancesmartchain' +]) + +// --------------------------------------------------------------------------- +// Payment type mapping: DFX → Edge +// --------------------------------------------------------------------------- + +const DFX_PAYMENT_TYPE_MAP: Record = { + Bank: 'sepa' +} + +// --------------------------------------------------------------------------- +// Asset map type +// --------------------------------------------------------------------------- + +interface AssetMap { + providerId: string + fiat: Record + crypto: Record +} + +interface DfxQuoteBody { + currency: { id: number } + asset: { id: number; blockchain: string } + paymentMethod: DfxPaymentMethod + amount?: number + targetAmount?: number +} + +// --------------------------------------------------------------------------- +// Cache +// --------------------------------------------------------------------------- + +interface ProviderConfigCache { + data: { + allowedCountryCodes: Record + allowedCurrencyCodes: Record< + FiatDirection, + Partial> + > + } + timestamp: number +} + +const CACHE_TTL = 2 * 60 * 1000 + +// Auth token cache +interface AuthCache { + token: string + timestamp: number +} +const AUTH_TTL = 15 * 60 * 1000 + +// --------------------------------------------------------------------------- +// Helper: build auth message per DFX spec +// --------------------------------------------------------------------------- + +const buildAuthMessage = (address: string): string => + `By_signing_this_message,_you_confirm_that_you_are_the_sole_owner_of_the_provided_Blockchain_address._Your_ID:_${address}` + +// --------------------------------------------------------------------------- +// Plugin factory +// --------------------------------------------------------------------------- + +export const dfxRampPlugin: RampPluginFactory = ( + pluginConfig: RampPluginConfig +): RampPlugin => { + const { account, navigation, onLogEvent } = pluginConfig + const initOptions = asInitOptions(pluginConfig.initOptions) + const { apiUrl, webAppUrl } = initOptions + + let providerCache: ProviderConfigCache | null = null + const authCacheMap = new Map() + + const rampInfo: RampInfo = { + partnerIcon, + pluginDisplayName + } + + // ----------------------------------------------------------------------- + // Auth: wallet signature → JWT + // ----------------------------------------------------------------------- + + const getDfxAuth = async (wallet: EdgeCurrencyWallet): Promise => { + const address = await getBestAddress(wallet) + + const cached = authCacheMap.get(address) + if (cached != null && Date.now() - cached.timestamp < AUTH_TTL) { + return cached.token + } + + const message = buildAuthMessage(address) + + let signature: string + if (EVM_CHAINS.has(wallet.currencyInfo.pluginId)) { + const hexMessage = Buffer.from(message, 'utf8').toString('hex') + signature = await wallet.signMessage(hexMessage) + } else { + signature = await wallet.signMessage(message, { + otherParams: { publicAddress: address } + }) + } + + const response = await fetch(`${apiUrl}/v1/auth`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + address, + signature, + wallet: 'edge' + }) + }) + + if (!response.ok) { + throw new Error(`DFX auth failed: ${response.status}`) + } + + const result = asDfxAuthResponse(await response.json()) + authCacheMap.set(address, { + token: result.accessToken, + timestamp: Date.now() + }) + return result.accessToken + } + + // ----------------------------------------------------------------------- + // Provider config (cached) + // ----------------------------------------------------------------------- + + const fetchProviderConfig = async (): Promise< + ProviderConfigCache['data'] + > => { + if ( + providerCache != null && + Date.now() - providerCache.timestamp < CACHE_TTL + ) { + return providerCache.data + } + + const freshConfig: ProviderConfigCache['data'] = { + allowedCountryCodes: { buy: {}, sell: {} }, + allowedCurrencyCodes: { + buy: { + sepa: { providerId: pluginId, fiat: {}, crypto: {} } + }, + sell: { + sepa: { providerId: pluginId, fiat: {}, crypto: {} } + } + } + } + + // Fetch all three endpoints in parallel + const dfxBlockchains = Object.keys(DFX_BLOCKCHAIN_MAP).join(',') + const [fiatsRes, assetsRes, countriesRes] = await Promise.all([ + fetch(`${apiUrl}/v1/fiat`).catch(() => undefined), + fetch(`${apiUrl}/v1/asset?blockchains=${dfxBlockchains}`).catch( + () => undefined + ), + fetch(`${apiUrl}/v1/country`).catch(() => undefined) + ]) + + // Process fiats + if (fiatsRes?.ok === true) { + const fiats = asDfxFiats(await fiatsRes.json()) + for (const fiat of fiats) { + const isoCode = `iso:${fiat.name.toUpperCase()}` + + for (const dir of ['buy', 'sell'] as FiatDirection[]) { + if (dir === 'buy' && !fiat.buyable) continue + if (dir === 'sell' && !fiat.sellable) continue + + for (const pt in freshConfig.allowedCurrencyCodes[dir]) { + const assetMap = + freshConfig.allowedCurrencyCodes[dir][pt as FiatPaymentType] + if (assetMap != null) { + assetMap.fiat[isoCode] = fiat + } + } + } + } + } + + // Process crypto assets + if (assetsRes?.ok === true) { + const assets = asDfxAssets(await assetsRes.json()) + for (const asset of assets) { + const edgePluginId = DFX_BLOCKCHAIN_MAP[asset.blockchain] + if (edgePluginId == null) continue + + let tokenId: EdgeTokenId + // DFX returns wrapped-token contract addresses even for native coins + // (e.g. WETH for ETH). Detect native coins by name match. + const nativeCoinName = DFX_NATIVE_COIN_NAMES[asset.blockchain] + + if (asset.name === nativeCoinName) { + // Native coin for this blockchain + tokenId = null + } else if (asset.chainId != null) { + // Token with contract address + const resolved = findTokenIdByNetworkLocation({ + account, + pluginId: edgePluginId, + networkLocation: { contractAddress: asset.chainId } + }) + if (resolved === undefined) continue + tokenId = resolved + } else { + // No contract address and not native coin — skip + continue + } + + for (const dir of ['buy', 'sell'] as FiatDirection[]) { + if (dir === 'buy' && !asset.buyable) continue + if (dir === 'sell' && !asset.sellable) continue + + for (const pt in freshConfig.allowedCurrencyCodes[dir]) { + const assetMap = + freshConfig.allowedCurrencyCodes[dir][pt as FiatPaymentType] + if (assetMap != null) { + assetMap.crypto[edgePluginId] ??= [] + addTokenToArray( + { tokenId, otherInfo: asset }, + assetMap.crypto[edgePluginId] + ) + } + } + } + } + } + + // Process countries + if (countriesRes?.ok === true) { + const countries = asDfxCountries(await countriesRes.json()) + for (const country of countries) { + if (BLOCKED_COUNTRIES.has(country.symbol)) continue + if (country.locationAllowed !== true) continue + + if (country.bankAllowed === true) { + addExactRegion(freshConfig.allowedCountryCodes.buy, country.symbol) + addExactRegion(freshConfig.allowedCountryCodes.sell, country.symbol) + } + } + } + + providerCache = { data: freshConfig, timestamp: Date.now() } + return freshConfig + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + const isRegionSupported = ( + regionCode: FiatPluginRegionCode, + direction: FiatDirection, + allowedCountryCodes: Record + ): boolean => { + try { + validateExactRegion(pluginId, regionCode, allowedCountryCodes[direction]) + return true + } catch { + return false + } + } + + const isCryptoSupported = ( + cryptoPluginId: string, + tokenId: EdgeTokenId, + assetMap: AssetMap + ): ProviderToken | null => { + const tokens = assetMap.crypto[cryptoPluginId] + if (tokens == null) return null + return tokens.find(t => t.tokenId === tokenId) ?? null + } + + const isFiatSupported = ( + fiatCurrencyCode: string, + assetMap: AssetMap + ): DfxFiat | null => { + return assetMap.fiat[fiatCurrencyCode] ?? null + } + + const ensureIsoPrefix = (code: string): string => + code.startsWith('iso:') ? code : `iso:${code}` + + // Prefer segwit/transparent addresses where available + const getBestAddress = async ( + wallet: EdgeCurrencyWallet + ): Promise => { + const addresses = await wallet.getAddresses({ tokenId: null }) + if (addresses.length === 0) { + throw new Error('Wallet has no addresses') + } + const getPriority = (type: string | undefined): number => { + if (type === 'segwitAddress' || type === 'transparentAddress') return 1 + return 2 + } + addresses.sort( + (a, b) => getPriority(a.addressType) - getPriority(b.addressType) + ) + return addresses[0].publicAddress + } + + const getSupportedPaymentMethods = ( + direction: FiatDirection, + allowedCurrencyCodes: ProviderConfigCache['data']['allowedCurrencyCodes'] + ): Array<{ + paymentType: FiatPaymentType + dfxPaymentMethod: DfxPaymentMethod + assetMap: AssetMap + }> => { + const methods: Array<{ + paymentType: FiatPaymentType + dfxPaymentMethod: DfxPaymentMethod + assetMap: AssetMap + }> = [] + + for (const pt in allowedCurrencyCodes[direction]) { + const paymentType = pt as FiatPaymentType + const assetMap = allowedCurrencyCodes[direction][paymentType] + if (assetMap == null) continue + + // Reverse lookup DFX payment method + const dfxMethod = Object.entries(DFX_PAYMENT_TYPE_MAP).find( + ([, v]) => v === paymentType + ) + if (dfxMethod == null) continue + + methods.push({ + paymentType, + dfxPaymentMethod: dfxMethod[0] as DfxPaymentMethod, + assetMap + }) + } + return methods + } + + // ----------------------------------------------------------------------- + // KYC handler + // ----------------------------------------------------------------------- + + const handleKycRequired = async ( + wallet: EdgeCurrencyWallet, + direction: FiatDirection = 'buy' + ): Promise => { + let token: string + try { + token = await getDfxAuth(wallet) + } catch { + showToast(lstrings.ramp_kyc_error_title, NOT_SUCCESS_TOAST_HIDE_MS) + return + } + const redirectUrl = encodeURIComponent( + `https://deep.edge.app/ramp/${direction}/${pluginId}` + ) + await openExternalWebView({ + url: `${webAppUrl}/kyc?session=${token}&kyc-redirect=${redirectUrl}`, + deeplink: { + direction, + providerId: pluginId, + handler: async _link => { + showToast( + lstrings.ramp_kyc_approved_message, + NOT_SUCCESS_TOAST_HIDE_MS + ) + } + } + }) + } + + // ----------------------------------------------------------------------- + // Plugin + // ----------------------------------------------------------------------- + + const plugin: RampPlugin = { + pluginId, + rampInfo, + + checkSupport: async ( + request: RampCheckSupportRequest + ): Promise => { + const { + direction, + regionCode, + fiatAsset: { currencyCode: fiatCurrencyCode }, + cryptoAsset: { pluginId: cryptoPluginId, tokenId } + } = request + + const config = await fetchProviderConfig() + const { allowedCountryCodes, allowedCurrencyCodes } = config + + const supportedMethods = getSupportedPaymentMethods( + direction, + allowedCurrencyCodes + ) + if (supportedMethods.length === 0) return { supported: false } + + const paymentTypes = supportedMethods.map(m => m.paymentType) + const constraintOk = validateRampCheckSupportRequest( + pluginId, + request, + paymentTypes + ) + if (!constraintOk) return { supported: false } + + if (!isRegionSupported(regionCode, direction, allowedCountryCodes)) { + return { supported: false } + } + + for (const { assetMap } of supportedMethods) { + if (isCryptoSupported(cryptoPluginId, tokenId, assetMap) == null) + continue + if ( + isFiatSupported(ensureIsoPrefix(fiatCurrencyCode), assetMap) == null + ) + continue + return { supported: true, supportedAmountTypes: ['fiat', 'crypto'] } + } + + return { supported: false } + }, + + fetchQuotes: async (request: RampQuoteRequest): Promise => { + const { direction, regionCode, displayCurrencyCode, tokenId } = request + const fiatCurrencyCode = ensureIsoPrefix(request.fiatCurrencyCode) + + const isMaxAmount = + 'max' in request.amountQuery || + 'maxExchangeAmount' in request.amountQuery + const exchangeAmountString = + 'exchangeAmount' in request.amountQuery + ? request.amountQuery.exchangeAmount + : '' + const maxAmountLimitString = + 'maxExchangeAmount' in request.amountQuery + ? request.amountQuery.maxExchangeAmount + : undefined + + const config = await fetchProviderConfig() + const { allowedCountryCodes, allowedCurrencyCodes } = config + + if (!isRegionSupported(regionCode, direction, allowedCountryCodes)) { + throw new FiatProviderError({ + providerId: pluginId, + errorType: 'regionRestricted' + }) + } + + const supportedMethods = getSupportedPaymentMethods( + direction, + allowedCurrencyCodes + ) + if (supportedMethods.length === 0) { + throw new FiatProviderError({ + providerId: pluginId, + errorType: 'paymentUnsupported' + }) + } + + // Build candidates + const candidates: Array<{ + paymentType: FiatPaymentType + dfxPaymentMethod: DfxPaymentMethod + assetMap: AssetMap + cryptoToken: ProviderToken + fiatObj: DfxFiat + }> = [] + + for (const method of supportedMethods) { + const cryptoToken = isCryptoSupported( + request.wallet.currencyInfo.pluginId, + request.tokenId, + method.assetMap + ) + if (cryptoToken == null) continue + + const fiatObj = isFiatSupported(fiatCurrencyCode, method.assetMap) + if (fiatObj == null) continue + + if (!validateRampQuoteRequest(pluginId, request, method.paymentType)) + continue + + candidates.push({ + paymentType: method.paymentType, + dfxPaymentMethod: method.dfxPaymentMethod, + assetMap: method.assetMap, + cryptoToken, + fiatObj + }) + } + + if (candidates.length === 0) { + throw new FiatProviderError({ + providerId: pluginId, + errorType: 'assetUnsupported' + }) + } + + const displayFiatCurrencyCode = removeIsoPrefix(fiatCurrencyCode) + + const quotes: RampQuote[] = [] + const errors: unknown[] = [] + + for (const candidate of candidates) { + const { paymentType, dfxPaymentMethod, cryptoToken, fiatObj } = + candidate + try { + const dfxAsset = cryptoToken.otherInfo as DfxAsset + + // Determine the DFX blockchain name for this asset + const dfxBlockchain = + EDGE_TO_DFX_BLOCKCHAIN[request.wallet.currencyInfo.pluginId] + if (dfxBlockchain == null) continue + + // Build quote request body — DFX API expects object references + const endpoint = direction === 'buy' ? 'buy/quote' : 'sell/quote' + const quoteBody: DfxQuoteBody = { + currency: { id: fiatObj.id }, + asset: { id: dfxAsset.id, blockchain: dfxAsset.blockchain }, + paymentMethod: dfxPaymentMethod + } + + // Determine amount + let exchangeAmount: number + if (isMaxAmount) { + // For max, we'll request a quote with a high amount and use the + // returned maxVolume + exchangeAmount = 999999 + const maxAmountLimit = + maxAmountLimitString != null + ? parseFloat(maxAmountLimitString) + : undefined + if (maxAmountLimit != null && isFinite(maxAmountLimit)) { + exchangeAmount = maxAmountLimit + } + } else { + exchangeAmount = parseFloat(exchangeAmountString) + } + + // DFX API: amount = source currency, targetAmount = target currency + // Buy: source = fiat, target = crypto + // Sell: source = crypto, target = fiat + if (direction === 'buy') { + if (request.amountType === 'fiat') quoteBody.amount = exchangeAmount + else quoteBody.targetAmount = exchangeAmount + } else { + if (request.amountType === 'crypto') + quoteBody.amount = exchangeAmount + else quoteBody.targetAmount = exchangeAmount + } + + const quoteResponse = await fetch(`${apiUrl}/v1/${endpoint}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(quoteBody) + }).catch(() => undefined) + + if (quoteResponse == null) continue + if (quoteResponse.status === 403) { + await handleKycRequired(request.wallet, direction) + continue + } + if (!quoteResponse.ok) continue + + const dfxQuote = asDfxQuote(await quoteResponse.json()) + + // Check for KYC error + if (dfxQuote.error?.toLowerCase().includes('kyc') === true) { + await handleKycRequired(request.wallet, direction) + continue + } + + const minSource = dfxQuote.minVolume + const maxSource = dfxQuote.maxVolume + + // Handle max amount requests + if (isMaxAmount) { + exchangeAmount = maxSource * 0.98 + + const maxAmountLimit = + maxAmountLimitString != null + ? parseFloat(maxAmountLimitString) + : undefined + if (maxAmountLimit != null && isFinite(maxAmountLimit)) { + exchangeAmount = Math.min(exchangeAmount, maxAmountLimit) + } + + if (exchangeAmount < minSource) { + throw new FiatProviderError({ + providerId: pluginId, + errorType: 'underLimit', + errorAmount: minSource, + displayCurrencyCode: + direction === 'buy' + ? displayFiatCurrencyCode + : displayCurrencyCode + }) + } + + // Re-fetch quote with correct amount + quoteBody.amount = exchangeAmount + quoteBody.targetAmount = undefined + const reQuoteResponse = await fetch(`${apiUrl}/v1/${endpoint}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(quoteBody) + }).catch(() => undefined) + + if (reQuoteResponse?.ok !== true) continue + + const reQuote = asDfxQuote(await reQuoteResponse.json()) + Object.assign(dfxQuote, reQuote) + } + + // Limit checks for non-max requests + // minVolume/maxVolume are in source currency (fiat for buy, crypto for sell) + if (!isMaxAmount) { + let sourceAmount: number + if (direction === 'buy') { + sourceAmount = + request.amountType === 'fiat' + ? exchangeAmount + : dfxQuote.amount ?? exchangeAmount + } else { + sourceAmount = + request.amountType === 'crypto' + ? exchangeAmount + : dfxQuote.amount ?? exchangeAmount + } + const limitDisplayCode = + direction === 'buy' + ? displayFiatCurrencyCode + : displayCurrencyCode + if (sourceAmount > maxSource) { + throw new FiatProviderError({ + providerId: pluginId, + errorType: 'overLimit', + errorAmount: maxSource, + displayCurrencyCode: limitDisplayCode + }) + } + if (sourceAmount < minSource) { + throw new FiatProviderError({ + providerId: pluginId, + errorType: 'underLimit', + errorAmount: minSource, + displayCurrencyCode: limitDisplayCode + }) + } + } + + // Calculate amounts + // DFX API: `amount` = source currency, `estimatedAmount` = target asset + // Buy: source = fiat, target = crypto + // Sell: source = crypto, target = fiat + let fiatAmount: string + let cryptoAmount: string + + if (direction === 'buy') { + if (request.amountType === 'fiat') { + fiatAmount = exchangeAmount.toString() + cryptoAmount = dfxQuote.estimatedAmount.toString() + } else { + cryptoAmount = exchangeAmount.toString() + fiatAmount = dfxQuote.amount?.toString() ?? '0' + } + } else { + if (request.amountType === 'fiat') { + fiatAmount = exchangeAmount.toString() + cryptoAmount = dfxQuote.amount?.toString() ?? '0' + } else { + cryptoAmount = exchangeAmount.toString() + fiatAmount = dfxQuote.estimatedAmount.toString() + } + } + + const settlementRange = getSettlementRange( + paymentType, + request.direction + ) + const settlementDays = formatSettlementDays(settlementRange) + + const quote: RampQuote = { + pluginId, + partnerIcon, + pluginDisplayName, + displayCurrencyCode: request.displayCurrencyCode, + isEstimate: true, + fiatCurrencyCode, + fiatAmount, + cryptoAmount, + direction: request.direction, + expirationDate: new Date(Date.now() + 60000), + regionCode, + paymentType, + settlementRange, + approveQuote: async ( + approveParams: RampApproveQuoteParams + ): Promise => { + const { coreWallet } = approveParams + + if (direction === 'buy' && dfxPaymentMethod === 'Bank') { + // ----------------------------------------------------------- + // BUY via SEPA — native InfoDisplayScene + // ----------------------------------------------------------- + const token = await getDfxAuth(coreWallet) + + const receiveAddress = await getBestAddress(coreWallet) + + const paymentInfoBody = { + currency: { id: fiatObj.id }, + asset: { + id: dfxAsset.id, + blockchain: dfxAsset.blockchain + }, + amount: parseFloat(fiatAmount), + paymentMethod: 'Bank', + targetAddress: receiveAddress + } + + const piResponse = await showToastSpinner( + lstrings.fiat_plugin_finalizing_quote, + fetch(`${apiUrl}/v1/buy/paymentInfos`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify(paymentInfoBody) + }) + ) + + if (piResponse.status === 403) { + await handleKycRequired(coreWallet, 'buy') + return + } + if (!piResponse.ok) { + const errBody = await piResponse.text() + throw new Error( + `DFX buy paymentInfos failed: ${piResponse.status} ${errBody}` + ) + } + + const piJson = await piResponse.json() + const paymentInfo = asDfxBuyPaymentInfo(piJson) + + if (paymentInfo.isValid === false) { + const kycErrors = new Set([ + 'LimitExceeded', + 'KycRequired', + 'KycDataRequired', + 'KycRequiredInstant' + ]) + if ( + paymentInfo.error != null && + kycErrors.has(paymentInfo.error) + ) { + await handleKycRequired(coreWallet, 'buy') + return + } + throw new Error(`DFX: ${paymentInfo.error ?? 'Unknown'}`) + } + + const piCurrency = + paymentInfo.currency?.name ?? displayFiatCurrencyCode + + const transferInfo: FiatPluginSepaTransferInfo = { + input: { + amount: `${paymentInfo.amount} ${piCurrency}`, + currency: piCurrency + }, + output: { + amount: cryptoAmount, + currency: displayCurrencyCode, + walletAddress: receiveAddress + }, + paymentDetails: { + id: paymentInfo.uid, + iban: paymentInfo.iban ?? '', + swiftBic: paymentInfo.bic ?? '', + recipient: 'DFX AG', + reference: paymentInfo.remittanceInfo ?? '' + } + } + + await new Promise((resolve, _reject) => { + navigation.navigate('guiPluginInfoDisplay', { + headerTitle: lstrings.fiat_plugin_buy_complete_title, + supportUrl: `${webAppUrl}/support/issue?session=${token}`, + promptMessage: sprintf( + lstrings.fiat_plugin_buy_complete_message_s, + cryptoAmount, + displayCurrencyCode, + fiatAmount, + displayFiatCurrencyCode, + settlementDays + ), + transferInfo, + onDone: async () => { + // Check if user has email registered + try { + const userRes = await fetch(`${apiUrl}/v2/user`, { + headers: { + Authorization: `Bearer ${token}` + } + }) + if (userRes.ok) { + const user = await userRes.json() + if (user.mail == null) { + const email = await Airship.show< + string | undefined + >(bridge => + React.createElement(TextInputModal, { + bridge, + title: lstrings.form_field_title_email_address, + message: + lstrings.ramp_kyc_email_required_message, + inputLabel: + lstrings.form_field_title_email_address, + keyboardType: 'email-address' as const, + autoCapitalize: 'none' as const, + autoCorrect: false, + returnKeyType: 'go' as const, + onSubmit: async (text: string) => { + const emailRegex = + /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!emailRegex.test(text)) { + return lstrings.invalid_email + } + return true + } + }) + ) + if (email != null) { + const mailRes = await fetch( + `${apiUrl}/v2/user/mail`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ mail: email }) + } + ) + if (!mailRes.ok) { + const errBody = await mailRes + .json() + .catch(() => ({})) + showError( + errBody.message ?? + `Failed to set email: ${mailRes.status}` + ) + } + } + } + } + } catch (e: unknown) { + showError(e) + } + + // Confirm the buy order with DFX + try { + await fetch( + `${apiUrl}/v1/buy/paymentInfos/${paymentInfo.id}/confirm`, + { + method: 'PUT', + headers: { + Authorization: `Bearer ${token}` + } + } + ) + } catch (e: unknown) { + showError(e) + } + + onLogEvent('Buy_Success', { + conversionValues: { + conversionType: 'buy', + sourceFiatCurrencyCode: fiatCurrencyCode, + sourceFiatAmount: fiatAmount, + destAmount: new CryptoAmount({ + currencyConfig: coreWallet.currencyConfig, + tokenId, + exchangeAmount: cryptoAmount + }), + fiatProviderId: pluginId, + orderId: paymentInfo.uid + } + }) + navigation.pop() + resolve() + } + }) + }) + } else if (direction === 'sell') { + // ----------------------------------------------------------- + // SELL via SEPA — SendScene2 + // ----------------------------------------------------------- + + // Collect user's SEPA bank details and process sell + navigation.navigate('guiPluginSepaForm', { + headerTitle: lstrings.sepa_form_title, + doneLabel: lstrings.string_next_capitalized, + onDone: async (sepaInfo: SepaInfo) => { + const token = await getDfxAuth(coreWallet) + + const senderAddress = await getBestAddress(coreWallet) + + const sellBody = { + currency: { id: fiatObj.id }, + asset: { + id: dfxAsset.id, + blockchain: dfxAsset.blockchain + }, + amount: parseFloat(cryptoAmount), + paymentMethod: 'Bank', + sourceAddress: senderAddress, + iban: sepaInfo.iban + } + + const sellResponse = await fetch( + `${apiUrl}/v1/sell/paymentInfos?includeTx=true`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify(sellBody) + } + ) + + if (sellResponse.status === 403) { + await handleKycRequired(coreWallet, 'sell') + return + } + if (!sellResponse.ok) { + const errBody = await sellResponse.text() + throw new Error( + `DFX sell paymentInfos failed: ${sellResponse.status} ${errBody}` + ) + } + + const sellInfo = asDfxSellPaymentInfo( + await sellResponse.json() + ) + + if (sellInfo.isValid === false) { + const kycErrors = new Set([ + 'LimitExceeded', + 'KycRequired', + 'KycDataRequired', + 'KycRequiredInstant' + ]) + if ( + sellInfo.error != null && + kycErrors.has(sellInfo.error) + ) { + await handleKycRequired(coreWallet, 'sell') + return + } + throw new Error(`DFX: ${sellInfo.error ?? 'Unknown'}`) + } + + const { multiplier } = getExchangeDenom( + coreWallet.currencyConfig, + tokenId + ) + const nativeAmount = mul( + sellInfo.amount.toString(), + multiplier + ) + + const assetAction: EdgeAssetAction = { + assetActionType: 'sell' + } + const savedAction: EdgeTxActionFiat = { + actionType: 'fiat', + orderId: sellInfo.uid, + orderUri: `${webAppUrl}/tx/${sellInfo.uid}`, + isEstimate: true, + fiatPlugin: { + providerId: pluginId, + providerDisplayName: pluginDisplayName, + supportEmail + }, + payinAddress: sellInfo.depositAddress, + cryptoAsset: { + pluginId: coreWallet.currencyInfo.pluginId, + tokenId, + nativeAmount + }, + fiatAsset: { + fiatCurrencyCode, + fiatAmount + } + } + + const spendInfo: EdgeSpendInfo = { + tokenId, + assetAction, + savedAction, + spendTargets: [ + { + nativeAmount, + publicAddress: sellInfo.depositAddress + } + ] + } + + 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) + } + + // Confirm TX hash with DFX + try { + await fetch( + `${apiUrl}/v1/sell/paymentInfos/${sellInfo.id}/confirm`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ txHash: tx.txid }) + } + ) + } catch (e: unknown) { + showError(e) + } + + onLogEvent('Sell_Success', { + conversionValues: { + conversionType: 'sell', + destFiatCurrencyCode: fiatCurrencyCode, + destFiatAmount: fiatAmount, + sourceAmount: new CryptoAmount({ + currencyConfig: coreWallet.currencyConfig, + tokenId, + exchangeAmount: cryptoAmount + }), + fiatProviderId: pluginId, + orderId: sellInfo.uid + } + }) + + if (tokenId != null) { + await coreWallet.saveTxAction({ + txid: tx.txid, + tokenId, + assetAction: { + ...assetAction, + assetActionType: 'sell' + }, + savedAction + }) + } + + // Pop both send2 and SEPA form screens + navigation.pop(2) + + const message = + sprintf( + lstrings.fiat_plugin_sell_complete_message_s, + cryptoAmount, + displayCurrencyCode, + fiatAmount, + displayFiatCurrencyCode, + settlementDays + ) + + '\n\n' + + sprintf( + lstrings.fiat_plugin_sell_complete_message_2_hour_s, + '24' + ) + + '\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 + }) + }, + onBack: () => { + // User backed out of send + } + } + + try { + navigation.navigate('send2', sendParams) + } catch (e: unknown) { + if ( + e instanceof Error && + e.message === SendErrorBackPressed + ) { + // User pressed back + } else if ( + e instanceof Error && + e.message === SendErrorNoTransaction + ) { + showToast( + lstrings.fiat_plugin_sell_failed_to_send_try_again, + NOT_SUCCESS_TOAST_HIDE_MS + ) + } else { + showError(e) + } + } + }, + onClose: () => { + // User cancelled + } + }) + } + }, + closeQuote: async (): Promise => {} + } + + quotes.push(quote) + } catch (e) { + errors.push(e) + } + } + + if (quotes.length === 0 && errors.length > 0) { + throw new AggregateError(errors, 'All DFX quotes failed') + } + + return quotes + } + } + + return plugin +} diff --git a/src/plugins/ramps/dfx/dfxRampTypes.ts b/src/plugins/ramps/dfx/dfxRampTypes.ts new file mode 100644 index 00000000000..ed04a3a2e39 --- /dev/null +++ b/src/plugins/ramps/dfx/dfxRampTypes.ts @@ -0,0 +1,122 @@ +import { + asArray, + asBoolean, + asEither, + asNull, + asNumber, + asObject, + asOptional, + asString, + asValue +} from 'cleaners' + +// --------------------------------------------------------------------------- +// Init options +// --------------------------------------------------------------------------- + +export const asInitOptions = asObject({ + apiUrl: asOptional(asString, 'https://api.dfx.swiss'), + webAppUrl: asOptional(asString, 'https://app.dfx.swiss') +}) + +// --------------------------------------------------------------------------- +// Auth +// --------------------------------------------------------------------------- + +export const asDfxAuthResponse = asObject({ + accessToken: asString +}) + +// --------------------------------------------------------------------------- +// Provider config (public endpoints) +// --------------------------------------------------------------------------- + +export const asDfxFiat = asObject({ + id: asNumber, + name: asString, + buyable: asBoolean, + sellable: asBoolean +}) +export type DfxFiat = ReturnType +export const asDfxFiats = asArray(asDfxFiat) + +export const asDfxAsset = asObject({ + id: asNumber, + name: asString, + uniqueName: asString, + blockchain: asString, + chainId: asOptional(asEither(asString, asNull)), + buyable: asBoolean, + sellable: asBoolean +}) +export type DfxAsset = ReturnType +export const asDfxAssets = asArray(asDfxAsset) + +export const asDfxCountry = asObject({ + symbol: asString, + locationAllowed: asOptional(asBoolean), + bankAllowed: asOptional(asBoolean) +}) +export type DfxCountry = ReturnType +export const asDfxCountries = asArray(asDfxCountry) + +// --------------------------------------------------------------------------- +// Quote +// --------------------------------------------------------------------------- + +export const asDfxQuote = asObject({ + estimatedAmount: asNumber, + amount: asOptional(asNumber), + minVolume: asNumber, + maxVolume: asNumber, + fees: asOptional( + asObject({ + rate: asNumber + }) + ), + isValid: asOptional(asBoolean), + error: asOptional(asString) +}) +export type DfxQuote = ReturnType + +// --------------------------------------------------------------------------- +// Buy payment info +// --------------------------------------------------------------------------- + +export const asDfxBuyPaymentInfo = asObject({ + id: asNumber, + uid: asString, + iban: asOptional(asString), + bic: asOptional(asString), + remittanceInfo: asOptional(asString), + amount: asNumber, + currency: asOptional( + asObject({ + name: asString + }) + ), + isValid: asOptional(asBoolean), + error: asOptional(asString) +}) +export type DfxBuyPaymentInfo = ReturnType + +// --------------------------------------------------------------------------- +// Sell payment info +// --------------------------------------------------------------------------- + +export const asDfxSellPaymentInfo = asObject({ + id: asNumber, + uid: asString, + depositAddress: asString, + amount: asNumber, + isValid: asOptional(asBoolean), + error: asOptional(asString) +}) +export type DfxSellPaymentInfo = ReturnType + +// --------------------------------------------------------------------------- +// Payment method +// --------------------------------------------------------------------------- + +export const asDfxPaymentMethod = asValue('Bank') +export type DfxPaymentMethod = ReturnType diff --git a/src/plugins/ramps/rampConstraints.ts b/src/plugins/ramps/rampConstraints.ts index e649e7f2727..5adc4f94f65 100644 --- a/src/plugins/ramps/rampConstraints.ts +++ b/src/plugins/ramps/rampConstraints.ts @@ -115,4 +115,14 @@ export function* constraintGenerator( if (params.rampPluginId === 'infinite') { yield true } + + // + // DFX + // + + if (params.rampPluginId === 'dfx') { + // DFX blocked in IR, KP, MM, US, IL + const blockedCountries = ['IR', 'KP', 'MM', 'US', 'IL'] + yield !blockedCountries.includes(params.regionCode.countryCode) + } } diff --git a/src/plugins/ramps/utils/getSettlementRange.ts b/src/plugins/ramps/utils/getSettlementRange.ts index 9cf73b64916..f3d6fea8a16 100644 --- a/src/plugins/ramps/utils/getSettlementRange.ts +++ b/src/plugins/ramps/utils/getSettlementRange.ts @@ -74,7 +74,7 @@ export function getBuySettlementRange( case 'revolut': return RANGE(5, 'minutes', 24, 'hours') case 'sepa': - return RANGE(1, 'days', 2, 'days') + return RANGE(0, 'days', 2, 'days') case 'spei': return RANGE(5, 'minutes', 24, 'hours') case 'turkishbank': @@ -129,7 +129,7 @@ export function getSellSettlementRange( case 'revolut': return RANGE(5, 'minutes', 24, 'hours') case 'sepa': - return RANGE(1, 'days', 2, 'days') + return RANGE(0, 'days', 2, 'days') case 'spei': return RANGE(5, 'minutes', 24, 'hours') case 'turkishbank':