From 460aff30f235abdd6951c0e5b488a94e0d30e1a2 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Tue, 18 Aug 2026 17:27:26 -0700 Subject: [PATCH 1/7] Open partner-website plugin rows in the device browser The legacy buy/sell list could only launch a row into the in-app WebView or a native fiat plugin. Rows that point at a whole partner website rather than an embedded purchase flow belong in the device browser, so add an `externalBrowser` flag to `GuiPlugin` and hand those rows to `openBrowserUri`. --- src/components/scenes/GuiPluginListScene.tsx | 13 ++++++++++++- src/types/GuiPluginTypes.ts | 7 +++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/components/scenes/GuiPluginListScene.tsx b/src/components/scenes/GuiPluginListScene.tsx index 306b872456e..b54be778e4f 100644 --- a/src/components/scenes/GuiPluginListScene.tsx +++ b/src/components/scenes/GuiPluginListScene.tsx @@ -52,12 +52,13 @@ import type { import type { PluginTweak } from '../../types/TweakTypes' import { getPartnerIconUri } from '../../util/CdnUris' import { getCurrencyCodeWithAccount } from '../../util/CurrencyInfoHelpers' -import { filterGuiPluginJson } from '../../util/GuiPluginTools' +import { filterGuiPluginJson, makePluginUri } from '../../util/GuiPluginTools' import { getDisplayInfoCards } from '../../util/infoUtils' import { infoServerData } from '../../util/network' import { bestOfPlugins } from '../../util/ReferralHelpers' import { logEvent, type OnLogEvent } from '../../util/tracking' import { base58ToUuid, getOsVersion } from '../../util/utils' +import { openBrowserUri } from '../../util/WebUtils' import { EdgeCard } from '../cards/EdgeCard' import { PaymentOptionCard } from '../cards/PaymentOptionCard' import { @@ -294,6 +295,16 @@ class GuiPluginList extends React.PureComponent { const { pluginId, paymentType, deepQuery = {} } = listRow const plugin = guiPlugins[pluginId] + // Some rows point at a partner website rather than an embedded purchase + // flow, so hand them to the device browser and stop here: + if (plugin.externalBrowser === true) { + await openBrowserUri( + makePluginUri(plugin, { deepPath: listRow.deepPath, deepQuery }) + ) + onPluginOpened() + return + } + // Don't allow light accounts to enter buy webview plugins const direction = this.getSceneDirection() if ( diff --git a/src/types/GuiPluginTypes.ts b/src/types/GuiPluginTypes.ts index 831fd943152..ba2068b3cf8 100644 --- a/src/types/GuiPluginTypes.ts +++ b/src/types/GuiPluginTypes.ts @@ -108,6 +108,13 @@ export interface GuiPlugin { * Plugin would show only with BETA_FEATURE env flag */ betaOnly?: boolean + + /** + * Hand the URI to the device browser instead of opening the in-app WebView. + * Use for partner destinations that are whole websites of their own rather + * than an embedded purchase flow. + */ + externalBrowser?: boolean } /** From 9398dac8a46d91f95f5850606243ec8395842ac6 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Tue, 18 Aug 2026 17:28:18 -0700 Subject: [PATCH 2/7] Update the Coinhub buy and sell menus Removes the Bitcoin Teller buy row, adds Coinhub Exchange / OTC bank-wire rows to both menus and a Coinhub Funded row to the buy menu, all of which open in the device browser, and restores the ACH sell row so MoonPay and the other ACH providers can quote a sell. --- src/constants/plugins/GuiPlugins.ts | 16 ++++++++ .../plugins/buyPluginListOverride.json | 26 ++++++++---- .../plugins/sellPluginListOverride.json | 40 +++++++++++++++++++ 3 files changed, 75 insertions(+), 7 deletions(-) diff --git a/src/constants/plugins/GuiPlugins.ts b/src/constants/plugins/GuiPlugins.ts index 7ec784ae1c4..081db335d8f 100644 --- a/src/constants/plugins/GuiPlugins.ts +++ b/src/constants/plugins/GuiPlugins.ts @@ -294,6 +294,22 @@ export const guiPlugins: Record = { baseUri: 'https://coinhubbitcoinwallet.app', displayName: 'Coinhub ATMs' }, + coinhubexchange: { + pluginId: 'coinhubexchange', + storeId: 'coinhubexchange', + baseUri: 'https://coinhubexchange.com', + lockUriPath: true, + externalBrowser: true, + displayName: 'Coinhub Exchange' + }, + coinhubfunded: { + pluginId: 'coinhubfunded', + storeId: 'coinhubfunded', + baseUri: 'https://coinhubfunded.com', + lockUriPath: true, + externalBrowser: true, + displayName: 'Coinhub Funded' + }, custom: { pluginId: 'custom', storeId: 'custom', diff --git a/src/constants/plugins/buyPluginListOverride.json b/src/constants/plugins/buyPluginListOverride.json index 88c5c3aea3f..57fe6d3c52c 100644 --- a/src/constants/plugins/buyPluginListOverride.json +++ b/src/constants/plugins/buyPluginListOverride.json @@ -14,18 +14,30 @@ "paymentTypeLogoKey": "cash" }, { - "id": "coinhub-teller", - "pluginId": "coinhub", - "deepPath": "/tellers", + "id": "coinhub-exchange", + "pluginId": "coinhubexchange", "paymentTypes": [ - "cash" + "bank" ], - "description": "Teller Transaction Limit: $500\nTotal Daily Limit: $1500\nTotal Monthly Limit: $5000", - "title": "Cash via Bitcoin Teller", + "description": "Bank wire and OTC desk\nOpens in your browser", + "title": "Coinhub Exchange / OTC", "forCountries": [ "US" ], - "paymentTypeLogoKey": "cash" + "paymentTypeLogoKey": "bank" + }, + { + "id": "coinhub-funded", + "pluginId": "coinhubfunded", + "paymentTypes": [ + "bank" + ], + "description": "Funded prop trading accounts\nOpens in your browser", + "title": "Coinhub Funded", + "forCountries": [ + "US" + ], + "paymentTypeLogoKey": "bank" }, { "id": "creditcard", diff --git a/src/constants/plugins/sellPluginListOverride.json b/src/constants/plugins/sellPluginListOverride.json index 411e2af5dfd..f3b49d18adb 100644 --- a/src/constants/plugins/sellPluginListOverride.json +++ b/src/constants/plugins/sellPluginListOverride.json @@ -12,5 +12,45 @@ "US" ], "paymentTypeLogoKey": "cash" + }, + { + "id": "ach", + "pluginId": "ach", + "paymentType": "ach", + "paymentTypes": [ + "ach" + ], + "title": "ACH Bank Transfer", + "description": "Fee: 1.5%\nSettlement: 2 - 3 days", + "forCountries": [ + "US" + ], + "notStateProvinces": { + "US": [ + "AK", + "AR", + "CT", + "LA", + "NC", + "NY", + "TX", + "FL" + ] + }, + "cryptoCodes": [], + "paymentTypeLogoKey": "bank" + }, + { + "id": "coinhub-exchange", + "pluginId": "coinhubexchange", + "paymentTypes": [ + "bank" + ], + "description": "Bank wire and OTC desk\nOpens in your browser", + "title": "Coinhub Exchange / OTC", + "forCountries": [ + "US" + ], + "paymentTypeLogoKey": "bank" } ] From 1f03cb8545c31a6f1a78254bbb79e812744b13f4 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Tue, 18 Aug 2026 17:28:47 -0700 Subject: [PATCH 3/7] Use Coinhub Wallet as the in-app name The iOS `PRODUCT_NAME` and the Android `app_name` resource already read Coinhub Wallet, so the config strings behind every in-app mention were the only place still saying Coinhub Bitcoin Wallet. --- src/__tests__/modals/__snapshots__/HelpModal.test.tsx.snap | 4 ++-- src/__tests__/modals/__snapshots__/LogsModal.test.tsx.snap | 2 +- src/__tests__/scenes/__snapshots__/RequestScene.test.tsx.snap | 2 +- .../scenes/__snapshots__/SendScene2.ui.test.tsx.snap | 2 +- src/theme/coinhubConfig.ts | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/__tests__/modals/__snapshots__/HelpModal.test.tsx.snap b/src/__tests__/modals/__snapshots__/HelpModal.test.tsx.snap index f8bf7046fbc..5d081a69e8c 100644 --- a/src/__tests__/modals/__snapshots__/HelpModal.test.tsx.snap +++ b/src/__tests__/modals/__snapshots__/HelpModal.test.tsx.snap @@ -242,7 +242,7 @@ exports[`HelpModal should render with loading props 1`] = ` ] } > - Thanks for using Coinhub Bitcoin Wallet! + Thanks for using Coinhub Wallet! @@ -799,7 +799,7 @@ exports[`HelpModal should render with loading props 1`] = ` ] } > - Learn more about Coinhub Bitcoin Wallet + Learn more about Coinhub Wallet diff --git a/src/__tests__/modals/__snapshots__/LogsModal.test.tsx.snap b/src/__tests__/modals/__snapshots__/LogsModal.test.tsx.snap index c0644cc18bb..5f7a4369fc7 100644 --- a/src/__tests__/modals/__snapshots__/LogsModal.test.tsx.snap +++ b/src/__tests__/modals/__snapshots__/LogsModal.test.tsx.snap @@ -1166,7 +1166,7 @@ exports[`LogsModal should render with a logs modal 1`] = ` ] } > - Send to Coinhub Bitcoin Wallet + Send to Coinhub Wallet diff --git a/src/__tests__/scenes/__snapshots__/RequestScene.test.tsx.snap b/src/__tests__/scenes/__snapshots__/RequestScene.test.tsx.snap index e04ee7cb37c..0f971976e70 100644 --- a/src/__tests__/scenes/__snapshots__/RequestScene.test.tsx.snap +++ b/src/__tests__/scenes/__snapshots__/RequestScene.test.tsx.snap @@ -211,7 +211,7 @@ exports[`Request should render a blank scene with loaded props 1`] = ` ] } > - To buy, sell, and receive funds, please create a full account. Coinhub Bitcoin Wallet full accounts require no personal information and use a familiar username and password method that will safeguard your assets and help prevent loss of funds. + To buy, sell, and receive funds, please create a full account. Coinhub Wallet full accounts require no personal information and use a familiar username and password method that will safeguard your assets and help prevent loss of funds. Never share your username and password, and store your credentials securely! diff --git a/src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap b/src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap index 6fe60706e52..d71770f0464 100644 --- a/src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap +++ b/src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap @@ -15725,7 +15725,7 @@ exports[`SendScene2 Render SendScene 1`] = ` ] } > - Coinhub Bitcoin Wallet will not give financial advice. + Coinhub Wallet will not give financial advice. Date: Tue, 18 Aug 2026 17:58:17 -0700 Subject: [PATCH 4/7] Surface errors from a buy/sell plugin row tap Only the long-press path forwarded rejections to the region-aware error handler, so a plain tap that failed (a payment method with no available provider, an unopenable partner URL) left the user on the list with no feedback at all. --- src/components/scenes/GuiPluginListScene.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/scenes/GuiPluginListScene.tsx b/src/components/scenes/GuiPluginListScene.tsx index b54be778e4f..646cbc09ff0 100644 --- a/src/components/scenes/GuiPluginListScene.tsx +++ b/src/components/scenes/GuiPluginListScene.tsx @@ -510,13 +510,13 @@ class GuiPluginList extends React.PureComponent { settlementTime={settlementTime} partner={partner} onPress={async () => { - await this.openPlugin(item) + await this.openPlugin(item).catch(this.handleError) }} onLongPress={async () => { await this.openPlugin(item, true).catch(this.handleError) }} onProviderPress={async () => { - await this.openPlugin(item) + await this.openPlugin(item).catch(this.handleError) }} /> From 9f5201df70c407f48cc3a4d75abf690730b3410f Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Tue, 18 Aug 2026 18:19:37 -0700 Subject: [PATCH 5/7] Honor externalBrowser on plugin deep links A partner-website plugin reached over `edge://plugin/` still pushed the EdgeProvider WebView, which both contradicts the row's stated behavior and hands a full third-party site the wallet bridge. --- src/actions/DeepLinkingActions.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/actions/DeepLinkingActions.tsx b/src/actions/DeepLinkingActions.tsx index 1c0568768e3..0c52bcc0e1f 100644 --- a/src/actions/DeepLinkingActions.tsx +++ b/src/actions/DeepLinkingActions.tsx @@ -28,8 +28,10 @@ import type { DeepLink } from '../types/DeepLinkTypes' import type { Dispatch, RootState, ThunkAction } from '../types/reduxTypes' import type { NavigationBase } from '../types/routerTypes' import type { EdgeAsset } from '../types/types' +import { makePluginUri } from '../util/GuiPluginTools' import { logEvent } from '../util/tracking' import { base58ToUuid, isEmail } from '../util/utils' +import { openBrowserUri } from '../util/WebUtils' import { activatePromotion } from './AccountReferralActions' import { checkAndShowLightBackupModal } from './BackupModalActions' import { logoutRequest } from './LoginActions' @@ -190,6 +192,15 @@ async function handleLink( break } + // A partner-website plugin must never reach the EdgeProvider WebView, + // which would hand a full third-party site the wallet bridge: + if (plugin.externalBrowser === true) { + await openBrowserUri( + makePluginUri(plugin, { deepPath: path, deepQuery: query }) + ) + break + } + navigation.push('pluginView', { plugin, deepPath: path, From 52475d6777ca5423313dd1ad426af35220ead809 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 20 Aug 2026 17:18:04 -0700 Subject: [PATCH 6/7] Add Coinhub ramp plugins Coinhub's ATM network, exchange/OTC desk and Funded prop-trading site become external ramp plugins so the app can retire the legacy buy/sell list. The external ramp factory gains two things they need: a device-browser launch for partner destinations that are whole websites of their own, and an optional crypto asset list, since a cash ATM takes whatever the customer brings. These carry no credentials, so they load without a RAMP_PLUGIN_INITS entry, and an app config can now name the ramp plugins it sells through. --- src/hooks/useRampPlugins.ts | 25 +++++++++--- src/plugins/ramps/allRampPlugins.ts | 17 ++++++++ .../ramps/coinhub/coinhubAtmRampPlugin.ts | 39 +++++++++++++++++++ .../coinhub/coinhubExchangeRampPlugin.ts | 39 +++++++++++++++++++ .../ramps/coinhub/coinhubFundedRampPlugin.ts | 35 +++++++++++++++++ src/plugins/ramps/coinhub/coinhubRampTypes.ts | 20 ++++++++++ .../ramps/utils/createExternalRampPlugin.ts | 27 +++++++++++-- src/types/types.ts | 6 +++ 8 files changed, 199 insertions(+), 9 deletions(-) create mode 100644 src/plugins/ramps/coinhub/coinhubAtmRampPlugin.ts create mode 100644 src/plugins/ramps/coinhub/coinhubExchangeRampPlugin.ts create mode 100644 src/plugins/ramps/coinhub/coinhubFundedRampPlugin.ts create mode 100644 src/plugins/ramps/coinhub/coinhubRampTypes.ts diff --git a/src/hooks/useRampPlugins.ts b/src/hooks/useRampPlugins.ts index 1480266c14b..222faa34401 100644 --- a/src/hooks/useRampPlugins.ts +++ b/src/hooks/useRampPlugins.ts @@ -5,13 +5,17 @@ import { useDispatch } from 'react-redux' import { updateFiatPurchaseCount } from '../actions/RequestReviewActions' import { ENV } from '../env' -import { pluginFactories } from '../plugins/ramps/allRampPlugins' +import { + credentiallessPluginIds, + pluginFactories +} from '../plugins/ramps/allRampPlugins' import type { RampPlugin, RampPluginConfig } from '../plugins/ramps/rampPluginTypes' import { createStore } from '../plugins/ramps/utils/createStore' import { getRampPluginStoreId } from '../plugins/ramps/utils/rampStoreIds' +import { config } from '../theme/appConfig' import type { Dispatch as ReduxDispatch } from '../types/reduxTypes' import type { NavigationBase } from '../types/routerTypes' import { logEvent } from '../util/tracking' @@ -44,6 +48,14 @@ export function useRampPlugins({ account }: UseRampPluginsOptions): { for (const [pluginId, factory] of Object.entries(pluginFactories)) { try { + // A white-label app carries only the providers it sells through: + if ( + config.rampPluginIds != null && + !config.rampPluginIds.includes(pluginId) + ) { + continue + } + // 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 @@ -53,13 +65,14 @@ export function useRampPlugins({ account }: UseRampPluginsOptions): { // Create a minimal config for the plugin const initOptions = ENV.RAMP_PLUGIN_INITS[pluginId] - // If there is no init option defined for the plugin, simply skip over it - if (initOptions == null) { + // If there is no init option defined for the plugin, simply skip + // over it, unless the plugin needs no credentials at all + if (initOptions == null && !credentiallessPluginIds.has(pluginId)) { continue } - const config: RampPluginConfig = { - initOptions, + const pluginConfig: RampPluginConfig = { + initOptions: initOptions ?? {}, store, account, navigation, @@ -72,7 +85,7 @@ export function useRampPlugins({ account }: UseRampPluginsOptions): { disklet: account.disklet } - const plugin = factory(config) + const plugin = factory(pluginConfig) loadedPlugins.push(plugin) } catch (error) { console.warn(`Failed to load plugin ${pluginId}:`, error) diff --git a/src/plugins/ramps/allRampPlugins.ts b/src/plugins/ramps/allRampPlugins.ts index 0555d3a1793..223ad091981 100644 --- a/src/plugins/ramps/allRampPlugins.ts +++ b/src/plugins/ramps/allRampPlugins.ts @@ -1,5 +1,8 @@ import { banxaRampPlugin } from './banxa/banxaRampPlugin' import { bitsofgoldRampPlugin } from './bitsofgold/bitsofgoldRampPlugin' +import { coinhubAtmRampPlugin } from './coinhub/coinhubAtmRampPlugin' +import { coinhubExchangeRampPlugin } from './coinhub/coinhubExchangeRampPlugin' +import { coinhubFundedRampPlugin } from './coinhub/coinhubFundedRampPlugin' import { infiniteRampPlugin } from './infinite/infiniteRampPlugin' import { libertyxRampPlugin } from './libertyx/libertyxRampPlugin' import { moonpayRampPlugin } from './moonpay/moonpayRampPlugin' @@ -11,6 +14,9 @@ import { simplexRampPlugin } from './simplex/simplexRampPlugin' export const pluginFactories: Record = { banxa: banxaRampPlugin, bitsofgold: bitsofgoldRampPlugin, + coinhubatm: coinhubAtmRampPlugin, + coinhubexchange: coinhubExchangeRampPlugin, + coinhubfunded: coinhubFundedRampPlugin, infinite: infiniteRampPlugin, libertyx: libertyxRampPlugin, moonpay: moonpayRampPlugin, @@ -18,3 +24,14 @@ export const pluginFactories: Record = { revolut: revolutRampPlugin, simplex: simplexRampPlugin } + +/** + * Plugins that hold no credentials and therefore need no `RAMP_PLUGIN_INITS` + * entry to load. Everything else stays off until the build's env.json supplies + * its keys. + */ +export const credentiallessPluginIds = new Set([ + 'coinhubatm', + 'coinhubexchange', + 'coinhubfunded' +]) diff --git a/src/plugins/ramps/coinhub/coinhubAtmRampPlugin.ts b/src/plugins/ramps/coinhub/coinhubAtmRampPlugin.ts new file mode 100644 index 00000000000..464abb41b0f --- /dev/null +++ b/src/plugins/ramps/coinhub/coinhubAtmRampPlugin.ts @@ -0,0 +1,39 @@ +import { guiPlugins } from '../../../constants/plugins/GuiPlugins' +import type { RampPluginFactory, SettlementRange } from '../rampPluginTypes' +import { createExternalRampPlugin } from '../utils/createExternalRampPlugin' +import { asInitOptions } from './coinhubRampTypes' + +const SETTLEMENT_INSTANT: SettlementRange = { + min: { value: 0, unit: 'minutes' }, + max: { value: 0, unit: 'minutes' } +} + +/** + * The Coinhub ATM network, reached through the Coinhub website. The machines + * take whichever asset the customer brings, so neither direction restricts the + * crypto assets. + */ +export const coinhubAtmRampPlugin: RampPluginFactory = config => { + const initOptions = asInitOptions(config.initOptions) + + return createExternalRampPlugin( + 'coinhubatm', + { + partnerIcon: initOptions.partnerIcon, + guiPlugin: guiPlugins.coinhub, + buy: { + paymentTypes: ['cash'], + countries: ['US'], + settlementRange: SETTLEMENT_INSTANT, + deepPath: '/buy-atms' + }, + sell: { + paymentTypes: ['cash'], + countries: ['US'], + settlementRange: SETTLEMENT_INSTANT, + deepPath: '/sell-atms' + } + }, + config + ) +} diff --git a/src/plugins/ramps/coinhub/coinhubExchangeRampPlugin.ts b/src/plugins/ramps/coinhub/coinhubExchangeRampPlugin.ts new file mode 100644 index 00000000000..3119dd50c41 --- /dev/null +++ b/src/plugins/ramps/coinhub/coinhubExchangeRampPlugin.ts @@ -0,0 +1,39 @@ +import { guiPlugins } from '../../../constants/plugins/GuiPlugins' +import type { RampPluginFactory, SettlementRange } from '../rampPluginTypes' +import { createExternalRampPlugin } from '../utils/createExternalRampPlugin' +import { asInitOptions } from './coinhubRampTypes' + +const SETTLEMENT_ONE_TO_FORTY_EIGHT_HOURS: SettlementRange = { + min: { value: 1, unit: 'hours' }, + max: { value: 48, unit: 'hours' } +} + +/** + * The Coinhub bank-wire and OTC desk. It is a full trading site rather than an + * embedded purchase flow, so `guiPlugins.coinhubexchange` sends it to the + * device browser. + */ +export const coinhubExchangeRampPlugin: RampPluginFactory = config => { + const initOptions = asInitOptions(config.initOptions) + + return createExternalRampPlugin( + 'coinhubexchange', + { + partnerIcon: initOptions.partnerIcon, + guiPlugin: guiPlugins.coinhubexchange, + buy: { + paymentTypes: ['wire'], + countries: ['US'], + settlementRange: SETTLEMENT_ONE_TO_FORTY_EIGHT_HOURS, + deepPath: '/' + }, + sell: { + paymentTypes: ['wire'], + countries: ['US'], + settlementRange: SETTLEMENT_ONE_TO_FORTY_EIGHT_HOURS, + deepPath: '/' + } + }, + config + ) +} diff --git a/src/plugins/ramps/coinhub/coinhubFundedRampPlugin.ts b/src/plugins/ramps/coinhub/coinhubFundedRampPlugin.ts new file mode 100644 index 00000000000..2d4f5e65b1d --- /dev/null +++ b/src/plugins/ramps/coinhub/coinhubFundedRampPlugin.ts @@ -0,0 +1,35 @@ +import { guiPlugins } from '../../../constants/plugins/GuiPlugins' +import type { RampPluginFactory, SettlementRange } from '../rampPluginTypes' +import { createExternalRampPlugin } from '../utils/createExternalRampPlugin' +import { asInitOptions } from './coinhubRampTypes' + +const SETTLEMENT_ONE_TO_FORTY_EIGHT_HOURS: SettlementRange = { + min: { value: 1, unit: 'hours' }, + max: { value: 48, unit: 'hours' } +} + +/** + * Coinhub Funded prop-trading accounts. Buy only, and like the exchange it is a + * whole website of its own, so it opens in the device browser. Its payment type + * differs from the exchange's on purpose: the quote scene renders one card per + * payment type, so sharing `wire` would bury one Coinhub destination inside the + * other's provider dropdown. + */ +export const coinhubFundedRampPlugin: RampPluginFactory = config => { + const initOptions = asInitOptions(config.initOptions) + + return createExternalRampPlugin( + 'coinhubfunded', + { + partnerIcon: initOptions.partnerIcon, + guiPlugin: guiPlugins.coinhubfunded, + buy: { + paymentTypes: ['iobank'], + countries: ['US'], + settlementRange: SETTLEMENT_ONE_TO_FORTY_EIGHT_HOURS, + deepPath: '/' + } + }, + config + ) +} diff --git a/src/plugins/ramps/coinhub/coinhubRampTypes.ts b/src/plugins/ramps/coinhub/coinhubRampTypes.ts new file mode 100644 index 00000000000..fe4f80c853b --- /dev/null +++ b/src/plugins/ramps/coinhub/coinhubRampTypes.ts @@ -0,0 +1,20 @@ +import { asObject, asOptional, asString } from 'cleaners' + +/** + * The Coinhub icon mark, inlined so the quote row's partner badge renders + * without a CDN round trip. Edge's content server carries no Coinhub asset, and + * the partner's own logo lives on a WordPress upload path that moves. + */ +const COINHUB_PARTNER_ICON = `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPwAAAEUCAYAAAAPy1s8AAAACXBIWXMAACE4AAAhOAFFljFgAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAfTSURBVHgB7d1PchTnGcfxt3smlHfmACCkBQo7cwOUGziLrK0jqIT3KHtbVk4Qskw2sU/g4QZkR1Cq9Mc5AN6lUlJ3tEjPtChjjUTPSMzv81m9jaYGWHzreRCj7urx3063y/9VbbtegJXS1M2kO9cFiCF4CDK+GPjb3UVbVc8KsFLqZjbXTXgIIngIIngIIngIIngIIngIIngIIngIIngIIngIIngIIngIIngIIngIIngIIngIIngIIngIIngIIngIIngIIngIIngIIngIIngIIngIIngIIngIIngIMi4LV/1jdm7eFeCSqlTr3bkt5VFZIBMegggegix8pW+r853u/K8/bEwKcMnmX0/2phdVeVEWyISHIIKHIIKHIIKHIIKHIIKHIIKHIIKHIEv4LP3yHO2s3+/O4/FnTwtcw8Nv3kzKijPhIYjgIchKrfT9Nb5tmh8LXE9VVpwJD0EED0EED0EED0EED0EED0EED0EED0EED0EED0EED0EED0EED0EED0EED0EED0EED0EED0EED0EED0EED0EED0EED0EED0EED0EED0HGF82/7C6qtp2UgY3Oy3EBPqipm0l3rpvhZ/Cl9y9ADMFDkJV6WuZPXz/Z6s6eHst1re2/9fRYYHUIHoIIHoIIHoIIHoKMC3dU9epaLy/ts6tfM897tuu9i0eFlWLCQxDBQxAr/R21tv/Preu8/nR3s73qNfO858nu5l53rkp5UUL9e+fx0+7c1PVBGUDdNDvd+cHB4etyC0x4CCJ4CGKlh1/Qjkf3pxdNM8f/gFzzPW+JCQ9BBA9BBA9BBA9BBA9BBA9BBA9BBA9BBA9BBA9BBA9BBA9BBA9BBA9B/HjsTVXlL9NjNXsC720Z6jFJTTP7u4zHZfLLr2mnd4Opqtm5tOXL3ss+L9w5JjwEETwEsdLfUNuW4+689u2bSVkRGwdvjnuXxx942aRc4XT38fbsqtrrfcm97m+RCQ9BBA9BrPQsxNr+4cvufLSz/n13Ho3uze7x3pavCktlwkMQwUMQK/0NVaXa6s79xzN9SNP8d7rKbhwcvytB3vv7bneH093Np71f/6KwcCY8BBE8BLHS31g7ffxQVcqVjyIa1femnzO/+K71Vncear2f5+mxczqZnqoyfdrp2rdvvy9Dq8re9NyWvxcWzoSHIIKHIFb65Zl+F3o8/qz/3elJuVtmn3Xvrdmnzzd/352HWu/773PxT5LC4pnwEETwEMRKz3zactC7Gv479iyFCQ9BBA9BrPTMa8F3qqlezc7tlR9k4mZMeAgieAhipeeOsMYvgwkPQQQPQaz0zOuHMrDL965nGUx4CCJ4CGKl59f83B3Om3qnDOBoZ/3+7Ko6KCyVCQ9BBA9BrPS8Z/aZ9rppZjexvPxU2Wvpr/Gj+t6k96XPC0tlwkMQwUMQK/2KWNt/W5U76r013iOlbpEJD0EED0Gs9CzceVNPH7M1GjV70y+05avCUpnwEETwEMRKz8JtXP7QznZ3ON19PJn9cvXnwsKZ8BBE8BDESr8iTnY398rAqqq87s5DPTG2b23/8GV3vljv+7+z9X5BTHgIIngIYqVfEVUpL8rQ2tnxdHfzpDvXTTv9IM2Dg8PXZQCX1vvnm1u9P4MP5wzIhIcggocgVnrmNX16bFNXk+58tPPkaXfe+Ii74vSdn9d73XlUN1b6AZnwEETwEMRKz01Mbz45rs7796sf5N71/X8a9P93oPT+WcHNmPAQRPAQxErPR2mr+mlZqOq497tZ6T+SCQ9BBA9BrPR8pPZd4ZNhwkMQwUMQKz0fqR38TjgsjgkPQQQPQaz03MQP3aF/p5qhHO2s359dtc8KgzHhIYjgIYiVfnl+7g5nZ+W4fGKqtv1Td3743eEgPwb7IaP6N18WFsKEhyCChyBW+gG0pfzxqteMmtkHVNYGutljX1XXvysDOzv7z/Se8xsHxwv9zPzl78xXe4WFMOEhiOAhiJV+AI/23+6VW/bwmzeT8onpr/Gj+t6k9yV3tlkQEx6CCB6CWOlZqqOdJ+vdeVQ3/R+t/aKwcCY8BBE8BLHSsxCnzzf7n4efnVtPg71NJjwEETwEsdIP4GR3c68MrGnql915Y47P3s/zZ5jnA0Knu4+3Z1f1drmW3t1p2sIdZMJDEMFDECv9AKpSXpSBjcdl0rs8vur1c/4Z9q56QVuq9dl7uoHkqjHhIYjgIYjgIYjgIYjgIYjgIYjgIYjgIYjgIYjgIYjgIYjgIYjgIYjgIYgfj72jmqZ92p1/+vrJla9vm+bK11y8z9aV79M267OLwoox4SGI4CGIlf6Oqkr7XXdum2F264u1/8dCNBMegggegggegggegggegggegggegggegggegggegggegggegggegggegggegggegggegggegggegggegggegggegggegggegggegggegggegggegggegggegggegggegggegggegggegggegggegggegozLCqnOzt9157auXxXgEhMegggegqzUSv/g4PB173KrAJeY8BBE8BBkpVb6ZTrd/e1kdtU+K6yUtmnKKjLhIYjgIYjgIYjgIYjgIYjgIYjgIYjgIYjgIYjgIYjgIYjgIYjgIYjgIYjgIYjgIYjgIYg73txY87I7taWaFLjC+Vk5LrfMhIcggocg/wNwIkuQbkZPRQAAAABJRU5ErkJggg==` + +export interface InitOptions { + readonly partnerIcon: string +} + +/** + * The Coinhub destinations carry no credentials, so the init options exist only + * to let the build swap the icon without an app release. + */ +export const asInitOptions = asObject({ + partnerIcon: asOptional(asString, COINHUB_PARTNER_ICON) +}).withRest diff --git a/src/plugins/ramps/utils/createExternalRampPlugin.ts b/src/plugins/ramps/utils/createExternalRampPlugin.ts index 27322ba2c4c..95aecf37afd 100644 --- a/src/plugins/ramps/utils/createExternalRampPlugin.ts +++ b/src/plugins/ramps/utils/createExternalRampPlugin.ts @@ -3,6 +3,8 @@ import type { EdgeTokenId } from 'edge-core-js' import type { PluginViewParams } from '../../../components/scenes/GuiPluginViewScene' import { lstrings } from '../../../locales/strings' import type { GuiPlugin } from '../../../types/GuiPluginTypes' +import { makePluginUri } from '../../../util/GuiPluginTools' +import { openBrowserUri } from '../../../util/WebUtils' import type { FiatPaymentType } from '../../gui/fiatPluginTypes' import type { RampCheckSupportRequest, @@ -50,8 +52,11 @@ export interface DirectionSupportData { readonly excludedStates?: Record /** The fiat currency codes supported by the plugin */ readonly fiatCurrencyCodes?: string[] - /** The crypto assets supported by the plugin */ - readonly cryptoAssets: ReadonlyArray<{ + /** + * The crypto assets supported by the plugin. Omit for a provider that takes + * any asset the app carries, such as a cash ATM network. + */ + readonly cryptoAssets?: ReadonlyArray<{ readonly pluginId: string readonly tokenId: EdgeTokenId }> @@ -165,6 +170,19 @@ export function createExternalRampPlugin( deepQuery: support.deepQuery } + // A partner destination that is a whole website of its own never + // enters the EdgeProvider WebView, which would hand a third-party + // site the wallet bridge: + if (externalConfig.guiPlugin.externalBrowser === true) { + await openBrowserUri( + makePluginUri(externalConfig.guiPlugin, { + deepPath: support.deepPath, + deepQuery: support.deepQuery + }) + ) + return + } + if (request.direction === 'buy') { config.navigation.navigate('pluginViewBuy', navigationProps) } else { @@ -213,9 +231,12 @@ function normalizeFiatCode(currencyCode: string): string { } function matchesCryptoAsset( - assets: ReadonlyArray<{ pluginId: string; tokenId: string | null }>, + assets: + | ReadonlyArray<{ pluginId: string; tokenId: string | null }> + | undefined, asset: { pluginId: string; tokenId: string | null } ): boolean { + if (assets == null) return true return assets.some( item => item.pluginId === asset.pluginId && item.tokenId === asset.tokenId ) diff --git a/src/types/types.ts b/src/types/types.ts index 62b30e9c0cf..d27e2fd3cb6 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -385,6 +385,12 @@ export interface AppConfig { disableSwaps?: boolean disableSurveyModal?: boolean useLegacyBuySell?: boolean + /** + * Ramp plugin ids this app sells through. Leave unset to carry every ramp + * plugin the build has credentials for; a white-label app lists exactly the + * providers its partner has signed. + */ + rampPluginIds?: string[] /** * Support article for pending transactions "Learn more" link. */ From 302ed93754e67556d8c1571f06b86fb3e7e712eb Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 20 Aug 2026 17:19:36 -0700 Subject: [PATCH 7/7] Move Coinhub buy and sell to ramp plugins The buy and sell tabs now open the ramp create scenes like every other build, so the legacy scene shim and its useLegacyBuySell config flag come out. Coinhub sells through its own three destinations plus MoonPay, which is what the info server already allowed the app under the legacy plugin priority. --- src/components/Main.tsx | 18 ++---------- src/components/scenes/GuiPluginListScene.tsx | 31 +++++--------------- src/theme/coinhubConfig.ts | 4 ++- src/types/types.ts | 1 - 4 files changed, 13 insertions(+), 41 deletions(-) diff --git a/src/components/Main.tsx b/src/components/Main.tsx index cfcdf833378..90b6c02013b 100644 --- a/src/components/Main.tsx +++ b/src/components/Main.tsx @@ -14,7 +14,6 @@ import { Platform } from 'react-native' import { getDeviceSettings } from '../actions/DeviceSettingsActions' import { SwapCreateScene as SwapCreateSceneComponent } from '../components/scenes/SwapCreateScene' import { ENV } from '../env' -import { config } from '../theme/appConfig' import { useExperimentConfig } from '../hooks/useExperimentConfig' import { useMount } from '../hooks/useMount' import { lstrings } from '../locales/strings' @@ -103,9 +102,7 @@ import { GiftCardMarketScene as GiftCardMarketSceneComponent } from './scenes/Gi import { GiftCardPurchaseScene as GiftCardPurchaseSceneComponent } from './scenes/GiftCardPurchaseScene' import { BuyScene as BuySceneComponent, - BuySceneLegacy as BuySceneLegacyComponent, - SellScene as SellSceneComponent, - SellSceneLegacy as SellSceneLegacyComponent + SellScene as SellSceneComponent } from './scenes/GuiPluginListScene' import { GuiPluginViewScene as GuiPluginViewSceneComponent } from './scenes/GuiPluginViewScene' import { HomeScene as HomeSceneComponent } from './scenes/HomeScene' @@ -312,17 +309,8 @@ const SweepPrivateKeySelectCryptoScene = ifLoggedIn( const TransactionDetailsScene = ifLoggedIn(TransactionDetailsSceneComponent) const TransactionList = ifLoggedIn(TransactionListComponent) const TransactionsExportScene = ifLoggedIn(TransactionsExportSceneComponent) -const BuySceneLegacy = ifLoggedIn(BuySceneLegacyComponent) -const SellSceneLegacy = ifLoggedIn(SellSceneLegacyComponent) const RampCreateBuyScene = ifLoggedIn(RampCreateBuySceneComponent) const RampCreateSellScene = ifLoggedIn(RampCreateSellSceneComponent) - -const BuyInitialScene = config.useLegacyBuySell === true - ? BuySceneLegacy - : RampCreateBuyScene -const SellInitialScene = config.useLegacyBuySell === true - ? SellSceneLegacy - : RampCreateSellScene const RampSelectOptionScene = ifLoggedIn(RampSelectOptionSceneComponent) const UpgradeUsernameScene = ifLoggedIn(UpgradeUsernameSceneComponent) const WalletDetails = ifLoggedIn(WalletDetailsComponent) @@ -414,7 +402,7 @@ const EdgeBuyTabScreen: React.FC = () => { > { > = { moonpay: 'guiPluginLogoMoonpay' } -type BuyProps = - | BuySellTabSceneProps<'pluginListBuyOld'> - | BuySellTabSceneProps<'pluginListBuy'> -type SellProps = - | BuySellTabSceneProps<'pluginListSellOld'> - | BuySellTabSceneProps<'pluginListSell'> +type BuyProps = BuySellTabSceneProps<'pluginListBuyOld'> +type SellProps = BuySellTabSceneProps<'pluginListSellOld'> type OwnProps = BuyProps | SellProps function isBuyProps(props: OwnProps): props is BuyProps { - return ( - props.route.name === 'pluginListBuyOld' || - props.route.name === 'pluginListBuy' - ) + return props.route.name === 'pluginListBuyOld' } interface StateProps { @@ -845,19 +838,9 @@ const GuiPluginListSceneComponent = React.memo((props: OwnProps) => { }) // Export separate components for buy and sell routes -export const BuyScene = ( - props: BuySellTabSceneProps<'pluginListBuyOld'> -): React.ReactElement => -export const BuySceneLegacy = ( - props: BuySellTabSceneProps<'pluginListBuy'> -): React.ReactElement => ( - +export const BuyScene: React.FC = props => ( + ) -export const SellScene = ( - props: BuySellTabSceneProps<'pluginListSellOld'> -): React.ReactElement => -export const SellSceneLegacy = ( - props: BuySellTabSceneProps<'pluginListSell'> -): React.ReactElement => ( - +export const SellScene: React.FC = props => ( + ) diff --git a/src/theme/coinhubConfig.ts b/src/theme/coinhubConfig.ts index 6c50c0c4611..c58d1550e28 100644 --- a/src/theme/coinhubConfig.ts +++ b/src/theme/coinhubConfig.ts @@ -36,7 +36,9 @@ export const coinhubConfig: AppConfig = { website: 'https://coinhubatm.app', disableSwaps: true, disableSurveyModal: true, - useLegacyBuySell: true, + // Coinhub's own destinations, plus MoonPay for the card and ACH quotes its + // customers already had under the legacy buy/sell list: + rampPluginIds: ['coinhubatm', 'coinhubexchange', 'coinhubfunded', 'moonpay'], extraTab: { webviewUrl: 'https://coinhubbitcoinwallet.app/buy-atms', tabTitleKey: 'title_map', diff --git a/src/types/types.ts b/src/types/types.ts index d27e2fd3cb6..6f5b1cb61ac 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -384,7 +384,6 @@ export interface AppConfig { referralAppShareUrl?: string disableSwaps?: boolean disableSurveyModal?: boolean - useLegacyBuySell?: boolean /** * Ramp plugin ids this app sells through. Leave unset to carry every ramp * plugin the build has credentials for; a white-label app lists exactly the