From 56dcadeb01f79279ea1c8beeeee1e431a3e138ee Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 4 Sep 2026 13:13:54 -0700 Subject: [PATCH 1/2] Read giftCardInfo from the raw info rollup asInfoRollup drops every key it does not know, and edge-info-server 3.12.0 has no giftCardInfo, so the remote gift card enable/disable config never reached the app: the store always reported every provider enabled no matter what the info server served. Keep the raw payload alongside the cleaned rollup and parse the field there with the cleaner the app already owns. edge-info-server 3.13.0 does define giftCardInfo, but it also star-exports an attestation module that pulls jose's node build, whose require('crypto') Metro cannot resolve, so the bump is not currently an option. --- src/actions/GiftCardInfoActions.ts | 18 ++++++++++++++---- src/locales/strings/enUS.json | 1 + src/util/network.ts | 14 +++++++++++++- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/actions/GiftCardInfoActions.ts b/src/actions/GiftCardInfoActions.ts index 64fc44ec6c2..0ba84cbc90f 100644 --- a/src/actions/GiftCardInfoActions.ts +++ b/src/actions/GiftCardInfoActions.ts @@ -17,15 +17,25 @@ export const asGiftCardInfo = asObject({ disablePlugins: asMaybe(asNestedDisableMap, () => ({})) }) +// Provider IDs used as keys in the info-server giftCardInfo.disablePlugins map. +// Phaze supports per-brand granularity (keyed by productId); Bitrefill is a +// webview, so only whole-provider disabling applies. +export const PHAZE_PLUGIN_ID = 'phaze' +export const BITREFILL_PLUGIN_ID = 'bitrefill' + export type GiftCardInfo = ReturnType export function updateGiftCardInfo(): ThunkAction> { return async dispatch => { try { - // `giftCardInfo` is a forward-compatible read: the field arrives once the - // edge-info-server dependency that defines it is published and bumped. - // Until then the rollup omits it and we fall back to an empty config. - const rollup = infoServerData.rollup as + // Read `giftCardInfo` from the RAW rollup, not the cleaned one: + // `asInfoRollup` in edge-info-server 3.12.0 has no such key and drops it, + // so the cleaned rollup reports every provider enabled no matter what the + // info server serves. 3.13.0 does define the field, but it also exports an + // attestation module that pulls `jose`'s node build, which Metro cannot + // resolve, so the bump is blocked. This cleaner is ours, so parsing the + // raw payload here needs neither. + const rollup = infoServerData.rollupRaw as | { giftCardInfo?: unknown } | undefined const data = asGiftCardInfo(rollup?.giftCardInfo ?? {}) diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index 2f05699d6e4..64f708cfc20 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1576,6 +1576,7 @@ "gift_card_no_payment_address": "Order created but no payment address was returned. Please try again.", "gift_card_network_error": "Unable to load gift cards. Please check your network connection.", "gift_card_service_error": "Gift card service is temporarily unavailable. Please try again later.", + "gift_card_providers_unavailable": "Gift cards are temporarily unavailable. Please check back later.", "gift_card_refresh_error": "Unable to refresh. Card information may not be up to date.", "gift_card_refresh_service_error": "Gift card service unavailable. Card information may not be up to date.", "gift_card_minimum_warning_title": "Below Minimum", diff --git a/src/util/network.ts b/src/util/network.ts index ff7144ed17a..2ee262ee01e 100644 --- a/src/util/network.ts +++ b/src/util/network.ts @@ -134,7 +134,18 @@ export const fetchPush = async ( ) } -export const infoServerData: { rollup?: InfoRollup } = {} +export const infoServerData: { + rollup?: InfoRollup + + /** + * The same payload before `asInfoRollup` ran. That cleaner drops every key it + * does not know, so a field the info server already serves is invisible to + * the app until the `edge-info-server` dependency defining it is published + * and bumped. Readers of such a field parse it from here with their own + * cleaner; everything else uses the typed `rollup`. + */ + rollupRaw?: unknown +} = {} export const initInfoServer = async (): Promise => { // Start the background attestation engine at boot (best-effort, non-blocking) @@ -160,6 +171,7 @@ export const initInfoServer = async (): Promise => { ) } else { const infoData = await response.json() + infoServerData.rollupRaw = infoData infoServerData.rollup = asInfoRollup(infoData) await runOnce('checkAppVersion', checkAppVersion) } From 576a826ee02564fe8ac930fc27ff4dbea41fdfb5 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 4 Sep 2026 13:14:35 -0700 Subject: [PATCH 2/2] Fall back to Bitrefill when Phaze is disabled Every gift card scene is backed by Phaze, so a remote disable through the info server's giftCardInfo still left the Spend flow entering those scenes: the market scene built the Phaze provider (which registers a Phaze identity) and fetched a catalog whose brands it then filtered away entirely, and an account with purchase history landed on the list scene, which polls the Phaze API every ten seconds. Route both entry points through pickGiftCardDestination, which sends an unusable Phaze straight to the Bitrefill webview, and gate the market scene's provider and queries on the same check so nothing touches Phaze while it is off. With Bitrefill disabled as well the market scene says so instead of rendering an empty grid. --- CHANGELOG.md | 3 + src/__tests__/GiftCardActions.test.ts | 75 ++++++++++++++++++ src/actions/GiftCardActions.tsx | 77 +++++++++++++++++-- src/components/scenes/GiftCardMarketScene.tsx | 71 ++++++++++++----- src/components/scenes/HomeScene.tsx | 26 ++++--- src/hooks/useGiftCardProvider.ts | 11 ++- src/locales/en_US.ts | 2 + 7 files changed, 226 insertions(+), 39 deletions(-) create mode 100644 src/__tests__/GiftCardActions.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d619f314a08..e26ab09eaac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased (develop) +- fixed: The info server's `giftCardInfo` config now reaches the app. It was read from the rollup after `asInfoRollup` had already dropped the field, so remotely disabling a gift card provider did nothing at all. +- fixed: Remotely disabling the Phaze gift card provider now drops the Spend flow straight to Bitrefill instead of into the Phaze scenes. The app no longer builds the Phaze provider, registers a Phaze identity, or fetches its catalog while the provider is disabled. + ## 4.51.0 (staging) - added: Push info-server attestation tokens into edge-core-js via `setAttestationToken` so the login server can skip CAPTCHA for attested devices, and allow `LOGIN_SERVER` / `INFO_SERVER` env overrides for local E2E stacks. diff --git a/src/__tests__/GiftCardActions.test.ts b/src/__tests__/GiftCardActions.test.ts new file mode 100644 index 00000000000..92533b0142e --- /dev/null +++ b/src/__tests__/GiftCardActions.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from '@jest/globals' + +import { pickGiftCardDestination } from '../actions/GiftCardActions' + +describe('pickGiftCardDestination', () => { + test('opens the market when Phaze is available and unused', () => { + expect( + pickGiftCardDestination({ + disablePlugins: {}, + hasPhazeApiKey: true, + hasPhazeOrders: false + }) + ).toBe('giftCardMarket') + }) + + test('opens the list when the account already holds Phaze orders', () => { + expect( + pickGiftCardDestination({ + disablePlugins: {}, + hasPhazeApiKey: true, + hasPhazeOrders: true + }) + ).toBe('giftCardList') + }) + + test('falls back to Bitrefill when Phaze is remotely disabled', () => { + expect( + pickGiftCardDestination({ + disablePlugins: { phaze: true }, + hasPhazeApiKey: true, + hasPhazeOrders: false + }) + ).toBe('bitrefill') + }) + + test('falls back to Bitrefill when there is no Phaze API key', () => { + expect( + pickGiftCardDestination({ + disablePlugins: {}, + hasPhazeApiKey: false, + hasPhazeOrders: false + }) + ).toBe('bitrefill') + }) + + test('a disabled Phaze skips the list scene even with past orders', () => { + expect( + pickGiftCardDestination({ + disablePlugins: { phaze: true }, + hasPhazeApiKey: true, + hasPhazeOrders: true + }) + ).toBe('bitrefill') + }) + + test('a per-brand Phaze disable leaves the provider usable', () => { + expect( + pickGiftCardDestination({ + disablePlugins: { phaze: { '12345': true } }, + hasPhazeApiKey: true, + hasPhazeOrders: false + }) + ).toBe('giftCardMarket') + }) + + test('falls back to the market when both providers are disabled', () => { + expect( + pickGiftCardDestination({ + disablePlugins: { bitrefill: true, phaze: true }, + hasPhazeApiKey: true, + hasPhazeOrders: false + }) + ).toBe('giftCardMarket') + }) +}) diff --git a/src/actions/GiftCardActions.tsx b/src/actions/GiftCardActions.tsx index 0780e78866f..c962c6199a7 100644 --- a/src/actions/GiftCardActions.tsx +++ b/src/actions/GiftCardActions.tsx @@ -1,12 +1,60 @@ +import { guiPlugins } from '../constants/plugins/GuiPlugins' +import { ENV } from '../env' import { hasStoredPhazeIdentity } from '../plugins/gift-cards/phazeGiftCardProvider' import type { ThunkAction } from '../types/reduxTypes' import type { NavigationBase } from '../types/routerTypes' import { showCountrySelectionModal } from './CountryListActions' +import type { NestedDisableMap } from './ExchangeInfoActions' +import { + BITREFILL_PLUGIN_ID, + isGiftCardProviderDisabled, + PHAZE_PLUGIN_ID +} from './GiftCardInfoActions' import { readSyncedSettings } from './SettingsActions' +export type GiftCardDestination = + | 'bitrefill' + | 'giftCardList' + | 'giftCardMarket' + +interface GiftCardDestinationParams { + disablePlugins: NestedDisableMap + hasPhazeApiKey: boolean + /** The account already holds Phaze identities, so it has purchase history. */ + hasPhazeOrders: boolean +} + +/** + * Picks which gift card destination the Spend entry points open. + * + * Phaze backs every gift card scene, so when it is unavailable — no API key, or + * remotely disabled through the info server — Bitrefill is the whole offering + * and the Phaze scenes are skipped entirely. That includes the list scene: it + * polls the Phaze API every ten seconds, which is exactly the traffic a remote + * disable exists to stop. + */ +export const pickGiftCardDestination = ( + params: GiftCardDestinationParams +): GiftCardDestination => { + const { disablePlugins, hasPhazeApiKey, hasPhazeOrders } = params + + if ( + !hasPhazeApiKey || + isGiftCardProviderDisabled(disablePlugins, PHAZE_PLUGIN_ID) + ) { + // Bitrefill can be remotely disabled too. With both providers off there is + // nothing to open, so the market scene shows its unavailable state. + return isGiftCardProviderDisabled(disablePlugins, BITREFILL_PLUGIN_ID) + ? 'giftCardMarket' + : 'bitrefill' + } + + return hasPhazeOrders ? 'giftCardList' : 'giftCardMarket' +} + /** - * Navigates to the appropriate gift card scene (list or market) after ensuring - * a country is selected. Shows a country selection modal if needed. + * Navigates to the appropriate gift card destination after ensuring a country is + * selected. Shows a country selection modal if needed. * * @returns true if navigation occurred, false if user cancelled country selection */ @@ -15,8 +63,27 @@ export const navigateToGiftCards = async (dispatch, getState) => { const state = getState() const { account } = state.core + const { disablePlugins } = state.ui.giftCardInfo let { countryCode } = state.ui.settings + const hasPhazeApiKey = ENV.PLUGIN_API_KEYS?.phaze?.apiKey != null + const destination = pickGiftCardDestination({ + disablePlugins, + hasPhazeApiKey, + hasPhazeOrders: hasPhazeApiKey && (await hasStoredPhazeIdentity(account)) + }) + + // Going through the Phaze scenes to reach Bitrefill would register a Phaze + // identity and fetch a catalog that is thrown away, which is what surfaced + // an error to the user once the provider was remotely disabled. + if (destination === 'bitrefill') { + navigation.navigate('edgeAppStack', { + screen: 'pluginView', + params: { plugin: guiPlugins.bitrefill } + }) + return true + } + // Ensure country is set before proceeding if (countryCode === '') { await dispatch( @@ -36,11 +103,7 @@ export const navigateToGiftCards = return false } - // Navigate to list if user has purchased before, otherwise market - const hasIdentity = await hasStoredPhazeIdentity(account) - navigation.navigate('edgeAppStack', { - screen: hasIdentity ? 'giftCardList' : 'giftCardMarket' - }) + navigation.navigate('edgeAppStack', { screen: destination }) return true } diff --git a/src/components/scenes/GiftCardMarketScene.tsx b/src/components/scenes/GiftCardMarketScene.tsx index 54eec5f82cb..0eed406cfca 100644 --- a/src/components/scenes/GiftCardMarketScene.tsx +++ b/src/components/scenes/GiftCardMarketScene.tsx @@ -7,8 +7,10 @@ import Animated from 'react-native-reanimated' import { showCountrySelectionModal } from '../../actions/CountryListActions' import { + BITREFILL_PLUGIN_ID, isGiftCardBrandDisabled, - isGiftCardProviderDisabled + isGiftCardProviderDisabled, + PHAZE_PLUGIN_ID } from '../../actions/GiftCardInfoActions' import { readSyncedSettings } from '../../actions/SettingsActions' import { EDGE_CONTENT_SERVER_URI } from '../../constants/CdnConstants' @@ -47,12 +49,6 @@ type ViewMode = 'grid' | 'list' // Internal constant for "All" category comparison - display uses lstrings.string_all const CATEGORY_ALL = 'All' -// Provider IDs used as keys in the info-server giftCardInfo.disablePlugins map. -// Phaze supports per-brand granularity (keyed by productId); Bitrefill is a -// webview, so only whole-provider disabling applies. -const PHAZE_PLUGIN_ID = 'phaze' -const BITREFILL_PLUGIN_ID = 'bitrefill' - /** * Formats a normalized category for display: * - Replaces dashes with " & " @@ -125,12 +121,27 @@ export const GiftCardMarketScene: React.FC = props => { state => state.ui.giftCardInfo.disablePlugins ) - // Provider (requires API key configured) + // Provider config (the Phaze scenes need an API key to do anything) const phazeConfig = ENV.PLUGIN_API_KEYS?.phaze + + // Phaze is the catalog behind this scene. While it is off the scene is a + // Bitrefill shortcut, so skip the provider (which would register a Phaze + // identity) and every query that feeds off it. This matches the condition + // pickGiftCardDestination routes on, so a build with no key lands here in the + // same state a remote disable produces rather than on a permanent loader. + const isPhazeOff = + phazeConfig?.apiKey == null || + isGiftCardProviderDisabled(giftCardDisablePlugins, PHAZE_PLUGIN_ID) + const isBitrefillDisabled = isGiftCardProviderDisabled( + giftCardDisablePlugins, + BITREFILL_PLUGIN_ID + ) + const { provider, isReady } = useGiftCardProvider({ account, apiKey: phazeConfig?.apiKey ?? '', - baseUrl: phazeConfig?.baseUrl ?? '' + baseUrl: phazeConfig?.baseUrl ?? '', + enabled: !isPhazeOff }) // Cache for gift card brands (accessed via provider) @@ -269,7 +280,12 @@ export const GiftCardMarketScene: React.FC = props => { return allBrands }, - enabled: isConnected && isReady && provider != null && countryCode !== '', + enabled: + isConnected && + isReady && + provider != null && + countryCode !== '' && + !isPhazeOff, staleTime: 5 * 60 * 1000, // 5 minutes gcTime: 10 * 60 * 1000, retry: 1 @@ -282,11 +298,18 @@ export const GiftCardMarketScene: React.FC = props => { } }, [apiBrands, updateFromBrands]) + // A disabled Phaze is never queried, so its half of the market is a settled + // empty list rather than one that is still loading or failed: + const phazeItems = React.useMemo( + () => (isPhazeOff ? [] : items), + [isPhazeOff, items] + ) + // Remove phaze brands disabled by the info server, either because the whole // phaze provider is disabled or because the brand's productId is listed. const enabledItems = React.useMemo(() => { - if (items == null) return null - return items.filter( + if (phazeItems == null) return null + return phazeItems.filter( item => !isGiftCardBrandDisabled( giftCardDisablePlugins, @@ -294,7 +317,7 @@ export const GiftCardMarketScene: React.FC = props => { String(item.productId) ) ) - }, [items, giftCardDisablePlugins]) + }, [phazeItems, giftCardDisablePlugins]) // Build the category list from the enabled items only, so a category whose // brands are all disabled by the info server does not show a chip that leads @@ -498,13 +521,14 @@ export const GiftCardMarketScene: React.FC = props => { // Bitrefill provider is remotely disabled) const listData = React.useMemo(() => { const base = filteredItems ?? [] - if ( - isGiftCardProviderDisabled(giftCardDisablePlugins, BITREFILL_PLUGIN_ID) - ) { - return base - } + if (isBitrefillDisabled) return base return [...base, BITREFILL_ITEM] - }, [filteredItems, giftCardDisablePlugins]) + }, [filteredItems, isBitrefillDisabled]) + + // Both providers off leaves nothing to browse, which is a different state + // from a search that matched nothing: + const hasNoProviders = + isBitrefillDisabled && enabledItems != null && enabledItems.length === 0 return ( = props => { headerTitle={lstrings.title_gift_card_market} headerTitleChildren={} > - {items == null && isBrandsError ? ( + {phazeItems == null && isBrandsError ? ( = props => { : lstrings.gift_card_network_error } /> - ) : items == null ? ( + ) : phazeItems == null ? ( + ) : hasNoProviders ? ( + ) : ( <> diff --git a/src/components/scenes/HomeScene.tsx b/src/components/scenes/HomeScene.tsx index e8b141579e3..e9e31a7ba13 100644 --- a/src/components/scenes/HomeScene.tsx +++ b/src/components/scenes/HomeScene.tsx @@ -6,8 +6,11 @@ import Animated from 'react-native-reanimated' import { useSafeAreaFrame } from 'react-native-safe-area-context' import { navigateToGiftCards } from '../../actions/GiftCardActions' +import { + isGiftCardProviderDisabled, + PHAZE_PLUGIN_ID +} from '../../actions/GiftCardInfoActions' import { SCROLL_INDICATOR_INSET_FIX } from '../../constants/constantSettings' -import { guiPlugins } from '../../constants/plugins/GuiPlugins' import { ENV } from '../../env' import { useHandler } from '../../hooks/useHandler' import { lstrings } from '../../locales/strings' @@ -87,6 +90,15 @@ export const HomeScene: React.FC = props => { const dispatch = useDispatch() const countryCode = useSelector(state => state.ui.countryCode) + const giftCardDisablePlugins = useSelector( + state => state.ui.giftCardInfo.disablePlugins + ) + + // The Phaze catalog is what makes this tile more than a Bitrefill shortcut, + // so the footer describes Bitrefill alone whenever Phaze is unavailable: + const isPhazeAvailable = + ENV.PLUGIN_API_KEYS?.phaze?.apiKey != null && + !isGiftCardProviderDisabled(giftCardDisablePlugins, PHAZE_PLUGIN_ID) const { width: screenWidth } = useSafeAreaFrame() @@ -115,11 +127,7 @@ export const HomeScene: React.FC = props => { navigation.navigate('swapTab') }) const handleSpendPress = useHandler(async () => { - // If Phaze API key is not configured, go directly to Bitrefill - if (ENV.PLUGIN_API_KEYS?.phaze?.apiKey == null) { - navigation.navigate('pluginView', { plugin: guiPlugins.bitrefill }) - return - } + // navigateToGiftCards owns the Bitrefill fallback for an unavailable Phaze await dispatch(navigateToGiftCards(navigation as NavigationBase)) }) const handleViewAssetsPress = useHandler(() => { @@ -281,9 +289,9 @@ export const HomeScene: React.FC = props => {