import { parse as tomlParse } from 'toml'; import { isValidUrl, LocalStorage } from '@vegaprotocol/utils'; import { useEffect } from 'react'; import { create } from 'zustand'; import { createClient } from '@vegaprotocol/apollo-client'; import { NodeCheckDocument, NodeCheckTimeUpdateDocument, type NodeCheckTimeUpdateSubscription, type NodeCheckQuery, } from '../utils/__generated__/NodeCheck'; import { type CosmicElevatorFlags, type Environment, type FeatureFlags, } from '../types'; import { Networks } from '../types'; import { compileErrors } from '../utils/compile-errors'; import { envSchema } from '../utils/validate-environment'; import { tomlConfigSchema } from '../utils/validate-configuration'; import uniq from 'lodash/uniq'; type Client = ReturnType; type ClientCollection = { [node: string]: Client; }; type EnvState = { nodes: string[]; status: 'default' | 'pending' | 'success' | 'failed'; error: string | null; }; type Actions = { setUrl: (url: string) => void; initialize: () => Promise; }; export type Env = Environment & EnvState; export type EnvStore = Env & Actions; const VERSION = 1; export const STORAGE_KEY = `vega_url_${VERSION}`; const SUBSCRIPTION_TIMEOUT = 3000; /** * Fetch and validate a vega node configuration */ const fetchConfig = async (url?: string) => { if (!url) return []; const res = await fetch(url); const content = await res.text(); const parsed = tomlParse(content); const tomlResults = tomlConfigSchema.parse(parsed); const { API: { GraphQL: { Hosts }, }, } = tomlResults; return Hosts; }; /** * Find a suitable node by running a test query and test * subscription, against a list of clients, first to resolve wins */ const findNode = async (clients: ClientCollection): Promise => { const tests = Object.entries(clients).map((args) => testNode(...args)); try { const url = await Promise.any(tests); return url; } catch { // All tests rejected, no suitable node found return null; } }; /** * Test a node for suitability for connection */ const testNode = async ( url: string, client: Client ): Promise => { const results = await Promise.all([ // these promises will only resolve with true/false testQuery(client), testSubscription(client), ]); if (results[0] && results[1]) { return url; } const message = `Tests failed for node: ${url}`; console.warn(message); // throwing here will mean this tests is ignored and a different // node that hopefully does resolve will fulfill the Promise.any throw new Error(message); }; /** * Run a test query on a client */ const testQuery = async (client: Client) => { try { const result = await client.query({ query: NodeCheckDocument, }); if (!result || result.error) { return false; } return true; } catch (err) { return false; } }; /** * Run a test subscription on a client. A subscription * that takes longer than SUBSCRIPTION_TIMEOUT ms to respond * is deemed a failure */ const testSubscription = (client: Client) => { return new Promise((resolve) => { const sub = client .subscribe({ query: NodeCheckTimeUpdateDocument, errorPolicy: 'all', }) .subscribe({ next: () => { resolve(true); sub.unsubscribe(); }, error: () => { resolve(false); sub.unsubscribe(); }, }); setTimeout(() => { resolve(false); sub.unsubscribe(); }, SUBSCRIPTION_TIMEOUT); }); }; export const userControllableFeatureFlags: (keyof FeatureFlags)[] = []; /** * Retrieve env vars, parsing where needed some type casting is needed * here to appease the environment store interface */ const compileEnvVars = () => { const VEGA_ENV = windowOrDefault( 'VEGA_ENV', process.env['NX_VEGA_ENV'] ) as Networks; const env: Environment = { VEGA_URL: windowOrDefault('VEGA_URL', process.env['NX_VEGA_URL']), VEGA_ENV, VEGA_CONFIG_URL: windowOrDefault( 'VEGA_CONFIG_URL', process.env['NX_VEGA_CONFIG_URL'] as string ), VEGA_NETWORKS: parseNetworks( windowOrDefault('VEGA_NETWORKS', process.env['NX_VEGA_NETWORKS']) ), VEGA_WALLET_URL: windowOrDefault( 'VEGA_WALLET_URL', process.env['NX_VEGA_WALLET_URL'] as string ), HOSTED_WALLET_URL: windowOrDefault( 'HOSTED_WALLET_URL', process.env['NX_HOSTED_WALLET_URL'] ), ETHERSCAN_URL: getEtherscanUrl( VEGA_ENV, windowOrDefault('ETHERSCAN_URL', process.env['NX_ETHERSCAN_URL']) ), ETHEREUM_PROVIDER_URL: getEthereumProviderUrl( VEGA_ENV, windowOrDefault( 'ETHEREUM_PROVIDER_URL', process.env['NX_ETHEREUM_PROVIDER_URL'] ) ), ETH_LOCAL_PROVIDER_URL: windowOrDefault( 'ETH_LOCAL_PROVIDER_URL', process.env['NX_ETH_LOCAL_PROVIDER_URL'] ), ETH_WALLET_MNEMONIC: windowOrDefault( 'ETH_WALLET_MNEMONIC', process.env['NX_ETH_WALLET_MNEMONIC'] ), ORACLE_PROOFS_URL: windowOrDefault( 'ORACLE_PROOFS_URL', process.env['NX_ORACLE_PROOFS_URL'] ), VEGA_DOCS_URL: windowOrDefault( 'VEGA_DOCS_URL', process.env['NX_VEGA_DOCS_URL'] ), VEGA_CONSOLE_URL: windowOrDefault( 'VEGA_CONSOLE_URL', process.env['NX_VEGA_CONSOLE_URL'] ), VEGA_EXPLORER_URL: windowOrDefault( 'VEGA_EXPLORER_URL', process.env['NX_VEGA_EXPLORER_URL'] ), VEGA_TOKEN_URL: windowOrDefault( 'VEGA_TOKEN_URL', process.env['NX_VEGA_TOKEN_URL'] ), GITHUB_FEEDBACK_URL: windowOrDefault( 'GITHUB_FEEDBACK_URL', process.env['NX_GITHUB_FEEDBACK_URL'] ), GIT_BRANCH: windowOrDefault( 'GIT_COMMIT_BRANCH', process.env['GIT_COMMIT_BRANCH'] ), GIT_COMMIT_HASH: windowOrDefault( 'GIT_COMMIT_HASH', process.env['GIT_COMMIT_HASH'] ), GIT_ORIGIN_URL: windowOrDefault( 'GIT_ORIGIN_URL', process.env['GIT_ORIGIN_URL'] ), ANNOUNCEMENTS_CONFIG_URL: windowOrDefault( 'ANNOUNCEMENTS_CONFIG_URL', process.env['NX_ANNOUNCEMENTS_CONFIG_URL'] ), VEGA_INCIDENT_URL: windowOrDefault( 'VEGA_INCIDENT_URL', process.env['NX_VEGA_INCIDENT_URL'] ), APP_VERSION: windowOrDefault('APP_VERSION', process.env['NX_APP_VERSION']), SENTRY_DSN: windowOrDefault('SENTRY_DSN', process.env['NX_SENTRY_DSN']), TENDERMINT_URL: windowOrDefault( 'NX_TENDERMINT_URL', process.env['NX_TENDERMINT_URL'] ), TENDERMINT_WEBSOCKET_URL: windowOrDefault( 'NX_TENDERMINT_WEBSOCKET_URL', process.env['NX_TENDERMINT_WEBSOCKET_URL'] ), CHROME_EXTENSION_URL: windowOrDefault( 'NX_CHROME_EXTENSION_URL', process.env['NX_CHROME_EXTENSION_URL'] ), MOZILLA_EXTENSION_URL: windowOrDefault( 'NX_MOZILLA_EXTENSION_URL', process.env['NX_MOZILLA_EXTENSION_URL'] ), CHARTING_LIBRARY_PATH: windowOrDefault( 'NX_CHARTING_LIBRARY_PATH', process.env['NX_CHARTING_LIBRARY_PATH'] ), CHARTING_LIBRARY_HASH: windowOrDefault( 'NX_CHARTING_LIBRARY_HASH', process.env['NX_CHARTING_LIBRARY_HASH'] ), }; return env; }; export const featureFlagsLocalStorageKey = 'vega_feature_flags'; let userEnabledFeatureFlags: (keyof FeatureFlags)[] | undefined = undefined; export const setUserEnabledFeatureFlag = ( flag: keyof FeatureFlags, enabled = false ) => { const enabledFlags = getUserEnabledFeatureFlags(); if (enabled && !enabledFlags.includes(flag)) { enabledFlags.push(flag); } if (!enabled && enabledFlags.includes(flag)) { enabledFlags.splice(enabledFlags.indexOf(flag), 1); } userEnabledFeatureFlags = enabledFlags; if (typeof window !== 'undefined') { window.localStorage.setItem( featureFlagsLocalStorageKey, enabledFlags.join(',') ); } }; export const getUserEnabledFeatureFlags = ( refresh = false, allowedFlags = userControllableFeatureFlags ): (keyof FeatureFlags)[] => { if (typeof window === 'undefined') { return []; } if (typeof userEnabledFeatureFlags !== 'undefined' && !refresh) { return userEnabledFeatureFlags; } const enabledFlags = window.localStorage.getItem(featureFlagsLocalStorageKey); userEnabledFeatureFlags = enabledFlags ? uniq( (enabledFlags.split(',') as (keyof FeatureFlags)[]).filter((flag) => allowedFlags.includes(flag) ) ) : []; return userEnabledFeatureFlags; }; const TRUTHY = ['1', 'true']; export const compileFeatureFlags = (refresh = false): FeatureFlags => { const COSMIC_ELEVATOR_FLAGS: CosmicElevatorFlags = { ICEBERG_ORDERS: TRUTHY.includes( windowOrDefault( 'NX_ICEBERG_ORDERS', process.env['NX_ICEBERG_ORDERS'] ) as string ), STOP_ORDERS: TRUTHY.includes( windowOrDefault('NX_STOP_ORDERS', process.env['NX_STOP_ORDERS']) as string ), ISOLATED_MARGIN: TRUTHY.includes( windowOrDefault('NX_STOP_ORDERS', process.env['NX_STOP_ORDERS']) as string ), SUCCESSOR_MARKETS: TRUTHY.includes( windowOrDefault( 'NX_SUCCESSOR_MARKETS', process.env['NX_SUCCESSOR_MARKETS'] ) as string ), PRODUCT_PERPETUALS: TRUTHY.includes( windowOrDefault( 'NX_PRODUCT_PERPETUALS', process.env['NX_PRODUCT_PERPETUALS'] ) as string ), METAMASK_SNAPS: TRUTHY.includes( windowOrDefault( 'NX_METAMASK_SNAPS', process.env['NX_METAMASK_SNAPS'] ) as string ), REFERRALS: TRUTHY.includes( windowOrDefault('NX_REFERRALS', process.env['NX_REFERRALS']) as string ), DISABLE_CLOSE_POSITION: TRUTHY.includes( windowOrDefault( 'NX_DISABLE_CLOSE_POSITION', process.env['NX_DISABLE_CLOSE_POSITION'] ) as string ), UPDATE_MARKET_STATE: TRUTHY.includes( windowOrDefault( 'NX_UPDATE_MARKET_STATE', process.env['NX_UPDATE_MARKET_STATE'] ) as string ), GOVERNANCE_TRANSFERS: TRUTHY.includes( windowOrDefault( 'NX_GOVERNANCE_TRANSFERS', process.env['NX_GOVERNANCE_TRANSFERS'] ) as string ), VOLUME_DISCOUNTS: TRUTHY.includes( windowOrDefault( 'NX_VOLUME_DISCOUNTS', process.env['NX_VOLUME_DISCOUNTS'] ) as string ), TEAM_COMPETITION: TRUTHY.includes( windowOrDefault( 'NX_TEAM_COMPETITION', process.env['NX_TEAM_COMPETITION'] ) as string ), }; const EXPLORER_FLAGS = { EXPLORER_ASSETS: TRUTHY.includes( windowOrDefault( 'NX_EXPLORER_ASSETS', process.env['NX_EXPLORER_ASSETS'] ) as string ), EXPLORER_GENESIS: TRUTHY.includes( windowOrDefault( 'NX_EXPLORER_GENESIS', process.env['NX_EXPLORER_GENESIS'] ) as string ), EXPLORER_GOVERNANCE: TRUTHY.includes( windowOrDefault( 'NX_EXPLORER_GOVERNANCE', process.env['NX_EXPLORER_GOVERNANCE'] ) as string ), EXPLORER_MARKETS: TRUTHY.includes( windowOrDefault( 'NX_EXPLORER_MARKETS', process.env['NX_EXPLORER_MARKETS'] ) as string ), EXPLORER_ORACLES: TRUTHY.includes( windowOrDefault( 'NX_EXPLORER_ORACLES', process.env['NX_EXPLORER_ORACLES'] ) as string ), EXPLORER_TXS_LIST: TRUTHY.includes( windowOrDefault( 'NX_EXPLORER_TXS_LIST', process.env['NX_EXPLORER_TXS_LIST'] ) as string ), EXPLORER_NETWORK_PARAMETERS: TRUTHY.includes( windowOrDefault( 'NX_EXPLORER_NETWORK_PARAMETERS', process.env['NX_EXPLORER_NETWORK_PARAMETERS'] ) as string ), EXPLORER_PARTIES: TRUTHY.includes( windowOrDefault( 'NX_EXPLORER_PARTIES', process.env['NX_EXPLORER_PARTIES'] ) as string ), EXPLORER_VALIDATORS: TRUTHY.includes( windowOrDefault( 'NX_EXPLORER_VALIDATORS', process.env['NX_EXPLORER_VALIDATORS'] ) as string ), }; const GOVERNANCE_FLAGS = { GOVERNANCE_NETWORK_DOWN: TRUTHY.includes( windowOrDefault( 'NX_NETWORK_DOWN', process.env['NX_NETWORK_DOWN'] ) as string ), GOVERNANCE_NETWORK_LIMITS: TRUTHY.includes( windowOrDefault( 'NX_GOVERNANCE_NETWORK_LIMITS', process.env['NX_GOVERNANCE_NETWORK_LIMITS'] ) as string ), }; const flags = { ...COSMIC_ELEVATOR_FLAGS, ...EXPLORER_FLAGS, ...GOVERNANCE_FLAGS, }; getUserEnabledFeatureFlags(refresh).forEach((flag) => (flags[flag] = true)); return flags; }; const parseNetworks = (value?: string) => { if (value) { try { return JSON.parse(value); } catch (e) { return {}; } } return {}; }; /** * Provides a fallback ethereum provider url for test purposes in some apps */ const getEthereumProviderUrl = ( network: Networks | undefined, envvar: string | undefined ) => { if (envvar) return envvar; return network === Networks.MAINNET ? 'https://mainnet.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8' : 'https://sepolia.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8'; }; /** * Provide a fallback etherscan url for test purposes in some apps */ const getEtherscanUrl = ( network: Networks | undefined, envvar: string | undefined ) => { if (envvar) return envvar; return network === Networks.MAINNET ? 'https://etherscan.io' : 'https://sepolia.etherscan.io'; }; const windowOrDefault = (key: string, defaultValue?: string) => { if (typeof window !== 'undefined') { // @ts-ignore avoid conflict in env if (window._env_ && window._env_[key]) { // @ts-ignore presence has been check above return window._env_[key]; } } return defaultValue || undefined; }; export const useFeatureFlags = create<{ flags: FeatureFlags; setFeatureFlag: (flag: keyof FeatureFlags, enabled: boolean) => void; }>()((set, get) => ({ flags: compileFeatureFlags(), setFeatureFlag: (flag: keyof FeatureFlags, enabled: boolean) => { if (userControllableFeatureFlags.includes(flag)) { setUserEnabledFeatureFlag(flag, enabled); set({ flags: { ...get().flags, [flag]: enabled } }); } }, })); export const useEnvironment = create()((set, get) => ({ ...compileEnvVars(), nodes: [], status: 'default', error: null, setUrl: (url) => { set({ VEGA_URL: url, status: 'success', error: null }); LocalStorage.setItem(STORAGE_KEY, url); }, initialize: async () => { set({ status: 'pending' }); // validate env vars try { const rawVars = compileEnvVars(); const safeVars = envSchema.parse(rawVars); set({ ...safeVars }); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err: any) { const headline = 'Error processing the Vega environment'; set({ status: 'failed', error: headline, }); console.error(compileErrors(headline, err)); return; } const state = get(); const storedUrl = LocalStorage.getItem(STORAGE_KEY); let nodes: string[] | undefined; try { nodes = await fetchConfig(state.VEGA_CONFIG_URL); const enrichedNodes = uniq( [...nodes, state.VEGA_URL, storedUrl].filter(Boolean) as string[] ); set({ nodes: enrichedNodes }); } catch (err) { console.warn(`Could not fetch node config from ${state.VEGA_CONFIG_URL}`); } // Node url found in localStorage, if its valid attempt to connect if (storedUrl) { if (isValidUrl(storedUrl)) { set({ VEGA_URL: storedUrl, status: 'success' }); return; } else { LocalStorage.removeItem(STORAGE_KEY); } } // VEGA_URL env var is set and is a valid url no need to proceed if (state.VEGA_URL) { set({ status: 'success' }); return; } // No url found in env vars or localStorage, AND no nodes were found in // the config fetched from VEGA_CONFIG_URL, app initialization has failed if (!nodes || !nodes.length) { set({ status: 'failed', error: `Failed to fetch node config from ${state.VEGA_CONFIG_URL}`, }); return; } // Create a map of node urls to client instances const clients: ClientCollection = {}; nodes.forEach((url) => { clients[url] = createClient({ url, cacheConfig: undefined, retry: false, connectToDevTools: false, }); }); // Find a suitable node to connect to by attempting a query and a // subscription, first to fulfill both will be the resulting url. const url = await findNode(clients); if (url !== null) { set({ status: 'success', VEGA_URL: url, }); LocalStorage.setItem(STORAGE_KEY, url); } // Every node failed either to make a query or retrieve data from // a subscription else { set({ status: 'failed', error: 'No node found', }); console.warn('No suitable vega node was found'); } }, })); /** * Initialize Vega app to dynamically select a node from the * VEGA_CONFIG_URL * * This can be omitted if you intend to only use a single node, * in those cases be sure to set NX_VEGA_URL */ export const useInitializeEnv = () => { const { initialize, status } = useEnvironment((store) => ({ status: store.status, initialize: store.initialize, })); useEffect(() => { if (status === 'default') { initialize(); } }, [status, initialize]); }; export const ENV = compileEnvVars();