From 5b3e2f74c077c95eea2bb4ebed18734c45bd7b6e Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Tue, 16 Jun 2026 14:56:41 -0700 Subject: [PATCH] Add BTC Direct fiat buy provider Integrate BTC Direct as a fiat on-ramp using their Unified Checkout REST API: partner-authenticate, fetch prices, then mint a single-use checkout URL that opens in a webview for payment-method selection. Supports EUR buys for EU/EEA regions across credit card, iDEAL, and SEPA. --- CHANGELOG.md | 1 + src/envConfig.ts | 8 + src/plugins/gui/amountQuotePlugin.ts | 2 + .../gui/providers/btcdirectProvider.ts | 312 ++++++++++++++++++ src/plugins/gui/util/fetchBtcDirect.ts | 188 +++++++++++ 5 files changed, 511 insertions(+) create mode 100644 src/plugins/gui/providers/btcdirectProvider.ts create mode 100644 src/plugins/gui/util/fetchBtcDirect.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f93116f004..7adc079286a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased (develop) +- added: BTC Direct fiat buy provider (Unified Checkout) for EUR on-ramps in the EU/EEA. - added: Home screen long-press shortcuts for "⚠️ Save 2FA First!" warning and "Contact Support". - added: Logbox disable option to env.json - added: Reverse-resolve recipient addresses to ENS / Unstoppable Domains / ZNS names in the send flow, address modal, and transaction history. diff --git a/src/envConfig.ts b/src/envConfig.ts index 149d1383f28..14d61401724 100644 --- a/src/envConfig.ts +++ b/src/envConfig.ts @@ -115,6 +115,13 @@ export const asEnvConfig = asObject({ }) ), Bitrefill: asOptional(asString), + btcdirect: asOptional( + asObject({ + username: asString, + password: asString, + sandbox: asOptional(asBoolean) + }) + ), kado: asOptional( asObject({ apiKey: asString @@ -171,6 +178,7 @@ export const asEnvConfig = asObject({ () => ({ banxa: undefined, Bitrefill: undefined, + btcdirect: undefined, kado: undefined, kadoOtc: undefined, moonpay: undefined, diff --git a/src/plugins/gui/amountQuotePlugin.ts b/src/plugins/gui/amountQuotePlugin.ts index 435a15a2b74..d445890cf7a 100644 --- a/src/plugins/gui/amountQuotePlugin.ts +++ b/src/plugins/gui/amountQuotePlugin.ts @@ -34,6 +34,7 @@ import type { StateManager } from './hooks/useStateManager' import { type BestError, getBestError, getRateFromQuote } from './pluginUtils' import { banxaProvider } from './providers/banxaProvider' import { bityProvider } from './providers/bityProvider' +import { btcdirectProvider } from './providers/btcdirectProvider' import { kadoOtcProvider } from './providers/kadoOtcProvider' import { kadoProvider } from './providers/kadoProvider' import { moonpayProvider } from './providers/moonpayProvider' @@ -79,6 +80,7 @@ type InternalFiatPluginEnterAmountParams = FiatPluginEnterAmountParams & { const providerFactories = [ banxaProvider, bityProvider, + btcdirectProvider, kadoProvider, kadoOtcProvider, moonpayProvider, diff --git a/src/plugins/gui/providers/btcdirectProvider.ts b/src/plugins/gui/providers/btcdirectProvider.ts new file mode 100644 index 00000000000..1edd0ba2ee0 --- /dev/null +++ b/src/plugins/gui/providers/btcdirectProvider.ts @@ -0,0 +1,312 @@ +import { div } from 'biggystring' +import type { EdgeTokenId } from 'edge-core-js' +import { sprintf } from 'sprintf-js' + +import { showError } from '../../../components/services/AirshipInstance' +import { lstrings } from '../../../locales/strings' +import type { FiatProviderLink } from '../../../types/DeepLinkTypes' +import { CryptoAmount } from '../../../util/CryptoAmount' +import type { FiatPaymentType } from '../fiatPluginTypes' +import { + type FiatProvider, + type FiatProviderApproveQuoteParams, + type FiatProviderAssetMap, + FiatProviderError, + type FiatProviderExactRegions, + type FiatProviderFactory, + type FiatProviderFactoryParams, + type FiatProviderGetQuoteParams, + type FiatProviderGetSupportedAssetsParams, + type FiatProviderGetTokenId, + type FiatProviderQuote +} from '../fiatProviderTypes' +import { + createBtcDirectCheckout, + fetchBtcDirectPrices +} from '../util/fetchBtcDirect' +import { validateExactRegion } from './common' + +const providerId = 'btcdirect' +const partnerIcon = 'btcdirect.png' +const pluginDisplayName = 'BTC Direct' + +// BTC Direct currently quotes against EUR only. +const FIAT_CURRENCY_CODE = 'iso:EUR' +const QUOTE_CURRENCY = 'EUR' + +// BTC Direct base-currency code -> Edge native asset. Token assets are omitted +// until BTC Direct exposes contract addresses we can map to Edge tokenIds. +const BTC_DIRECT_NATIVE_ASSETS: Record< + string, + { pluginId: string; currencyCode: string } +> = { + BTC: { pluginId: 'bitcoin', currencyCode: 'BTC' }, + ETH: { pluginId: 'ethereum', currencyCode: 'ETH' }, + LTC: { pluginId: 'litecoin', currencyCode: 'LTC' }, + XRP: { pluginId: 'ripple', currencyCode: 'XRP' }, + SOL: { pluginId: 'solana', currencyCode: 'SOL' }, + BCH: { pluginId: 'bitcoincash', currencyCode: 'BCH' }, + ADA: { pluginId: 'cardano', currencyCode: 'ADA' }, + DOGE: { pluginId: 'dogecoin', currencyCode: 'DOGE' }, + ALGO: { pluginId: 'algorand', currencyCode: 'ALGO' }, + XLM: { pluginId: 'stellar', currencyCode: 'XLM' }, + TRX: { pluginId: 'tron', currencyCode: 'TRX' }, + AVAX: { pluginId: 'avalanche', currencyCode: 'AVAX' }, + POL: { pluginId: 'polygon', currencyCode: 'POL' }, + ETC: { pluginId: 'ethereumclassic', currencyCode: 'ETC' }, + BNB: { pluginId: 'binancesmartchain', currencyCode: 'BNB' }, + HBAR: { pluginId: 'hedera', currencyCode: 'HBAR' } +} + +// Edge pluginId -> BTC Direct base-currency code (reverse of the map above). +const EDGE_PLUGIN_ID_TO_CODE: Record = Object.fromEntries( + Object.entries(BTC_DIRECT_NATIVE_ASSETS).map(([code, asset]) => [ + asset.pluginId, + code + ]) +) + +// Edge payment type -> BTC Direct payment method code. +const PAYMENT_METHOD_MAP: Partial> = { + credit: 'creditCard', + ideal: 'iDeal', + sepa: 'sepa' +} + +// BTC Direct serves EU / EEA customers (EUR only). +const allowedCountryCodes: FiatProviderExactRegions = { + AT: true, + BE: true, + BG: true, + HR: true, + CY: true, + CZ: true, + DK: true, + EE: true, + FI: true, + FR: true, + DE: true, + GR: true, + HU: true, + IE: true, + IT: true, + LV: true, + LI: true, + LT: true, + LU: true, + MT: true, + NL: true, + NO: true, + PL: true, + PT: true, + RO: true, + SK: true, + SI: true, + ES: true, + SE: true +} + +const RETURN_URL = `https://return.edge.app/fiatprovider/buy/${providerId}?transactionStatus=success` + +export const btcdirectProvider: FiatProviderFactory = { + providerId, + storeId: providerId, + makeProvider: async ( + params: FiatProviderFactoryParams + ): Promise => { + const { getTokenId } = params + + const provider: FiatProvider = { + providerId, + partnerIcon, + pluginDisplayName, + getSupportedAssets: async ( + params: FiatProviderGetSupportedAssetsParams + ): Promise => { + const paymentType = params.paymentTypes[0] + const assetMap: FiatProviderAssetMap = { + providerId, + crypto: {}, + fiat: {}, + requiredAmountType: 'fiat' + } + + // BTC Direct only supports buying with the payment methods we map. + if ( + params.direction !== 'buy' || + PAYMENT_METHOD_MAP[paymentType] == null + ) { + return assetMap + } + validateExactRegion(providerId, params.regionCode, allowedCountryCodes) + + const prices = await fetchBtcDirectPrices() + for (const pair of Object.values(prices)) { + if (pair == null || pair.targetCurrency.code !== QUOTE_CURRENCY) { + continue + } + const asset = getEdgeAsset(getTokenId, pair.sourceCurrency.code) + if (asset == null) continue + assetMap.crypto[asset.pluginId] = [{ tokenId: asset.tokenId }] + } + assetMap.fiat[FIAT_CURRENCY_CODE] = true + + return assetMap + }, + getQuote: async ( + params: FiatProviderGetQuoteParams + ): Promise => { + const paymentType = params.paymentTypes[0] + const paymentMethod = PAYMENT_METHOD_MAP[paymentType] + + if (params.direction !== 'buy' || paymentMethod == null) { + throw new FiatProviderError({ + providerId, + errorType: 'paymentUnsupported' + }) + } + if (params.fiatCurrencyCode !== FIAT_CURRENCY_CODE) { + throw new FiatProviderError({ + providerId, + errorType: 'fiatUnsupported', + fiatCurrencyCode: params.fiatCurrencyCode, + paymentMethod, + pluginDisplayName + }) + } + validateExactRegion(providerId, params.regionCode, allowedCountryCodes) + + const baseCurrency = EDGE_PLUGIN_ID_TO_CODE[params.pluginId] + if (baseCurrency == null || params.tokenId != null) { + throw new FiatProviderError({ + providerId, + errorType: 'assetUnsupported' + }) + } + + const prices = await fetchBtcDirectPrices() + const pair = prices[`${baseCurrency}-${QUOTE_CURRENCY}`] + if (pair == null) { + throw new FiatProviderError({ + providerId, + errorType: 'assetUnsupported' + }) + } + + // BTC Direct requires the user to enter the fiat amount. The final + // crypto amount is locked in on the BTC Direct checkout page, so this + // quote is an estimate derived from the current buy price. + const decimals = pair.sourceCurrency.decimals ?? 8 + const fiatAmount = params.exchangeAmount + const cryptoAmount = div(fiatAmount, pair.buy.toString(), decimals) + + return { + providerId, + partnerIcon, + pluginDisplayName, + displayCurrencyCode: params.displayCurrencyCode, + cryptoAmount, + isEstimate: true, + fiatCurrencyCode: params.fiatCurrencyCode, + fiatAmount, + direction: params.direction, + expirationDate: new Date(Date.now() + 1000 * 60), + regionCode: params.regionCode, + paymentTypes: params.paymentTypes, + + approveQuote: async ( + approveParams: FiatProviderApproveQuoteParams + ): Promise => { + const { showUi, coreWallet } = approveParams + const walletAddresses = await coreWallet.getAddresses({ + tokenId: params.tokenId + }) + const walletAddress = walletAddresses[0]?.publicAddress + if (walletAddress == null) { + throw new Error('No wallet address found') + } + + const { checkoutUrl } = await createBtcDirectCheckout({ + baseCurrency, + quoteCurrency: QUOTE_CURRENCY, + paymentMethod, + walletAddress, + quoteCurrencyAmount: parseFloat(fiatAmount), + returnUrl: RETURN_URL + }) + + const deeplinkHandlerAsync = async ( + link: FiatProviderLink + ): Promise => { + await showUi.trackConversion('Buy_Success', { + conversionValues: { + conversionType: 'buy', + sourceFiatCurrencyCode: params.fiatCurrencyCode, + sourceFiatAmount: fiatAmount, + destAmount: new CryptoAmount({ + currencyConfig: coreWallet.currencyConfig, + tokenId: params.tokenId, + exchangeAmount: cryptoAmount + }), + fiatProviderId: providerId, + orderId: link.query.partnerOrderIdentifier ?? undefined + } + }) + const message = + sprintf( + lstrings.fiat_plugin_buy_complete_message_s, + cryptoAmount, + params.displayCurrencyCode, + fiatAmount, + params.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 showUi.buttonModal({ + buttons: { + ok: { label: lstrings.string_ok, type: 'primary' } + }, + title: lstrings.fiat_plugin_buy_complete_title, + message + }) + } + + await showUi.openExternalWebView({ + url: checkoutUrl, + providerId, + deeplinkHandler: link => { + deeplinkHandlerAsync(link).catch((error: unknown) => { + showError(error) + }) + } + }) + }, + closeQuote: async (): Promise => {} + } + }, + otherMethods: null + } + + return provider + } +} + +function getEdgeAsset( + getTokenId: FiatProviderGetTokenId, + btcDirectCode: string +): { pluginId: string; tokenId: EdgeTokenId } | undefined { + const asset = BTC_DIRECT_NATIVE_ASSETS[btcDirectCode] + if (asset == null) return undefined + + const tokenId = getTokenId(asset.pluginId, asset.currencyCode) + // `undefined` means this asset is not present in the current Edge build. + if (tokenId === undefined) return undefined + + return { pluginId: asset.pluginId, tokenId } +} diff --git a/src/plugins/gui/util/fetchBtcDirect.ts b/src/plugins/gui/util/fetchBtcDirect.ts new file mode 100644 index 00000000000..8c1b7ba272a --- /dev/null +++ b/src/plugins/gui/util/fetchBtcDirect.ts @@ -0,0 +1,188 @@ +import { asMaybe, asNumber, asObject, asOptional, asString } from 'cleaners' + +import { ENV } from '../../../env' + +// BTC Direct Unified Checkout REST API. +// Docs: https://developer.btcdirect.eu/unified-checkout/ +const PRODUCTION_BASE_URL = 'https://api.btcdirect.eu' +const SANDBOX_BASE_URL = 'https://api-sandbox.btcdirect.eu' + +// Partner tokens are valid for one hour. Refresh a minute early to avoid +// using a token that expires mid-request. +const TOKEN_LIFETIME_MS = 1000 * 60 * 59 +const PRICES_CACHE_MS = 1000 * 30 + +interface BtcDirectKeys { + username: string + password: string + sandbox: boolean +} + +interface AuthState { + token: string + expiresAt: number +} +let authState: AuthState | undefined + +interface PricesCache { + data: BtcDirectPrices + expiresAt: number +} +let pricesCache: PricesCache | undefined + +function getBtcDirectKeys(): BtcDirectKeys { + const keys = ENV.PLUGIN_API_KEYS.btcdirect + if (keys == null) { + throw new Error('No BTC Direct API keys found') + } + return { + username: keys.username, + password: keys.password, + sandbox: keys.sandbox ?? false + } +} + +function getBaseUrl(sandbox: boolean): string { + return sandbox ? SANDBOX_BASE_URL : PRODUCTION_BASE_URL +} + +async function btcDirectFetch( + baseUrl: string, + endpoint: string, + init: RequestInit +): Promise { + const response = await fetch(`${baseUrl}${endpoint}`, { + ...init, + headers: { + // This will blow up if we pass `headers` as an array: + // eslint-disable-next-line @typescript-eslint/no-misused-spread + ...init.headers, + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }) + if (!response.ok) { + const text = await response.text() + throw new Error( + `Failed to fetch BTC Direct ${endpoint}: ${response.status} - ${text}` + ) + } + return await response.json() +} + +// ----------------------------------------------------------------------------- +// Authentication +// ----------------------------------------------------------------------------- + +export const asBtcDirectAuth = asObject({ + token: asString, + refreshToken: asOptional(asString) +}) + +async function getAuthToken(keys: BtcDirectKeys): Promise { + const now = Date.now() + if (authState != null && authState.expiresAt > now) { + return authState.token + } + + const data = await btcDirectFetch( + getBaseUrl(keys.sandbox), + '/api/v1/authenticate', + { + method: 'POST', + body: JSON.stringify({ + username: keys.username, + password: keys.password + }) + } + ) + + const { token } = asBtcDirectAuth(data) + authState = { token, expiresAt: now + TOKEN_LIFETIME_MS } + return token +} + +// ----------------------------------------------------------------------------- +// Prices +// ----------------------------------------------------------------------------- + +export type BtcDirectCurrency = ReturnType +const asBtcDirectCurrency = asObject({ + code: asString, + name: asOptional(asString), + decimals: asOptional(asNumber), + smartContractAddress: asOptional(asString), + caip19: asOptional(asString) +}).withRest + +export type BtcDirectPricePair = ReturnType +const asBtcDirectPricePair = asObject({ + sourceCurrency: asBtcDirectCurrency, + targetCurrency: asObject({ code: asString }).withRest, + buy: asNumber, + sell: asNumber +}).withRest + +export type BtcDirectPrices = ReturnType +// Keyed by pair, e.g. "BTC-EUR". Unknown shapes clean to undefined so a single +// malformed pair does not blow up the whole map. +const asBtcDirectPrices = asObject(asMaybe(asBtcDirectPricePair)) + +export async function fetchBtcDirectPrices(): Promise { + const now = Date.now() + if (pricesCache != null && pricesCache.expiresAt > now) { + return pricesCache.data + } + + const keys = getBtcDirectKeys() + const token = await getAuthToken(keys) + const data = await btcDirectFetch( + getBaseUrl(keys.sandbox), + '/api/v2/prices', + { + method: 'GET', + headers: { Authorization: `Bearer ${token}` } + } + ) + + const prices = asBtcDirectPrices(data) + pricesCache = { data: prices, expiresAt: now + PRICES_CACHE_MS } + return prices +} + +// ----------------------------------------------------------------------------- +// Checkout +// ----------------------------------------------------------------------------- + +export interface BtcDirectCheckoutParams { + baseCurrency: string + quoteCurrency: string + paymentMethod: string + walletAddress: string + quoteCurrencyAmount?: number + baseCurrencyAmount?: number + returnUrl?: string + partnerOrderIdentifier?: string +} + +export type BtcDirectCheckout = ReturnType +const asBtcDirectCheckout = asObject({ + checkoutUrl: asString +}).withRest + +export async function createBtcDirectCheckout( + params: BtcDirectCheckoutParams +): Promise { + const keys = getBtcDirectKeys() + const token = await getAuthToken(keys) + const data = await btcDirectFetch( + getBaseUrl(keys.sandbox), + '/api/v2/buy/checkout', + { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify(params) + } + ) + return asBtcDirectCheckout(data) +}