vega-frontend-monorepo/libs/environment/src/hooks/use-environment.ts
Bartłomiej Głownia fde77ebccb
feat(trading): margin mode selector (#5575)
Co-authored-by: Dariusz Majcherczyk <dariusz.majcherczyk@gmail.com>
2024-01-23 16:19:49 +00:00

645 lines
17 KiB
TypeScript

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<typeof createClient>;
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<void>;
};
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<string | null> => {
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<string | null> => {
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<NodeCheckQuery>({
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<NodeCheckTimeUpdateSubscription>({
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<EnvStore>()((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();