diff --git a/IPFS.json b/IPFS.json index 9c1feb582..3be584f63 100644 --- a/IPFS.json +++ b/IPFS.json @@ -1,7 +1,10 @@ { "1": { "cid": "bafybeib3zmyqlmantvdd6i5q4ehmo4larvorgquyanne3uoqdbedwgh3aq", - "leastSafeVersion": "0.36.1" + "leastSafeVersion": "0.36.1", + "config": { + "enabledWithdrawalDexes": ["one-inch", "paraswap", "bebop"] + } }, "5": { "__warning__": "For testing purposes only", @@ -13,7 +16,7 @@ "cid": "bafybeibbsoqlofslw273b4ih2pdxfaz2zbjmred2ijog725tcmfoewix7y", "leastSafeVersion": "0.36.1", "config": { - "enabledWithdrawalDexes": ["one-inch", "paraswap", "bebop"] + "enabledWithdrawalDexes": ["one-inch", "paraswap"] } } } diff --git a/config/external-config/index.ts b/config/external-config/index.ts new file mode 100644 index 000000000..c7d70a8b7 --- /dev/null +++ b/config/external-config/index.ts @@ -0,0 +1,7 @@ +export { useExternalConfigContext } from './use-external-config-context'; +export type { + ManifestConfig, + Manifest, + ManifestEntry, + ExternalConfig, +} from './types'; diff --git a/config/external-config/types.ts b/config/external-config/types.ts new file mode 100644 index 000000000..92ea1ad04 --- /dev/null +++ b/config/external-config/types.ts @@ -0,0 +1,20 @@ +import type { DexWithdrawalApi } from 'features/withdrawals/request/withdrawal-rates'; +import { SWRResponse } from 'swr'; + +export type Manifest = Record; + +export type ManifestEntry = { + cid?: string; + ens?: string; + leastSafeVersion?: string; + config: ManifestConfig; +}; + +export type ManifestConfig = { + enabledWithdrawalDexes: DexWithdrawalApi[]; +}; + +export type ExternalConfig = Omit & + ManifestConfig & { + fetchMeta: SWRResponse; + }; diff --git a/config/external-config/use-external-config-context.ts b/config/external-config/use-external-config-context.ts new file mode 100644 index 000000000..dc3dceca0 --- /dev/null +++ b/config/external-config/use-external-config-context.ts @@ -0,0 +1,59 @@ +import { useMemo } from 'react'; +import useSWR from 'swr'; + +import { STRATEGY_LAZY } from 'consts/swr-strategies'; +import { getConfig } from '../get-config'; +import { standardFetcher } from 'utils/standardFetcher'; +import { IPFS_MANIFEST_URL } from 'consts/external-links'; +import { + getBackwardCompatibleConfig, + isManifestEntryValid, + useFallbackManifestEntry, +} from './utils'; + +import type { ExternalConfig, ManifestEntry } from './types'; + +const onFetchError = (error: unknown) => { + console.warn( + '[useExternalConfigContext] while fetching external config:', + error, + ); +}; + +export const useExternalConfigContext = ( + prefetchedManifest?: unknown, +): ExternalConfig => { + const { defaultChain } = getConfig(); + const fallbackData = useFallbackManifestEntry( + prefetchedManifest, + defaultChain, + ); + + const swr = useSWR( + ['swr:external-config', defaultChain], + async () => { + const result = await standardFetcher>( + IPFS_MANIFEST_URL, + { + headers: { Accept: 'application/json' }, + }, + ); + const entry = result[defaultChain.toString()]; + if (isManifestEntryValid(entry)) return entry; + throw new Error( + '[useExternalConfigContext] received invalid manifest', + result, + ); + }, + { + ...STRATEGY_LAZY, + onError: onFetchError, + }, + ); + + return useMemo(() => { + const { config, ...rest } = swr.data ?? fallbackData; + const cleanConfig = getBackwardCompatibleConfig(config); + return { ...cleanConfig, ...rest, fetchMeta: swr }; + }, [swr, fallbackData]); +}; diff --git a/config/external-config/utils.ts b/config/external-config/utils.ts new file mode 100644 index 000000000..9dc7e8d86 --- /dev/null +++ b/config/external-config/utils.ts @@ -0,0 +1,67 @@ +import { useMemo } from 'react'; +import type { Manifest, ManifestEntry } from './types'; +import { getDexConfig } from 'features/withdrawals/request/withdrawal-rates'; + +import FallbackLocalManifest from 'IPFS.json' assert { type: 'json' }; + +// TODO: refactor on config expansion +export const isManifestEntryValid = ( + entry?: unknown, +): entry is ManifestEntry => { + if ( + // entry = {} + entry && + typeof entry === 'object' && + // entry.config = {} + 'config' in entry && + typeof entry.config === 'object' && + entry.config + ) { + const config = entry.config; + if ( + 'enabledWithdrawalDexes' in config && + Array.isArray(config.enabledWithdrawalDexes) + ) { + const enabledWithdrawalDexes = config.enabledWithdrawalDexes; + return ( + new Set(enabledWithdrawalDexes).size === enabledWithdrawalDexes.length + ); + } + return false; + } + return false; +}; + +export const getBackwardCompatibleConfig = ( + config: ManifestEntry['config'], +): ManifestEntry['config'] => { + return { + enabledWithdrawalDexes: config.enabledWithdrawalDexes.filter( + (dex) => !!getDexConfig(dex), + ), + }; +}; + +export const isManifestValid = ( + manifest: unknown, + chain: number, +): manifest is Manifest => { + const stringChain = chain.toString(); + if (manifest && typeof manifest === 'object' && stringChain in manifest) + return isManifestEntryValid( + (manifest as Record)[stringChain], + ); + return false; +}; + +export const useFallbackManifestEntry = ( + prefetchedManifest: unknown, + chain: number, +): ManifestEntry => { + return useMemo(() => { + const isValid = isManifestValid(prefetchedManifest, chain); + return isValid + ? prefetchedManifest[chain] + : (FallbackLocalManifest as unknown as Manifest)[chain]; + }, [prefetchedManifest, chain]); +}; diff --git a/config/get-config.ts b/config/get-config.ts index 42375e98a..136eb6372 100644 --- a/config/get-config.ts +++ b/config/get-config.ts @@ -3,6 +3,7 @@ import * as cache from './groups/cache'; import * as ipfs from './groups/ipfs'; import * as locale from './groups/locale'; import * as stake from './groups/stake'; +import * as revalidation from './groups/revalidation'; import * as web3 from './groups/web3'; import * as withdrawalQueueEstimate from './groups/withdrawal-queue-estimate'; @@ -14,6 +15,7 @@ export type ConfigType = { typeof web3 & typeof locale & typeof stake & + typeof revalidation & typeof withdrawalQueueEstimate & PreConfigType; @@ -27,6 +29,7 @@ export const getConfig = (): ConfigType => { ...ipfs, ...locale, ...stake, + ...revalidation, ...withdrawalQueueEstimate, // highest priority diff --git a/config/groups/cache.ts b/config/groups/cache.ts index ffdfbe42b..8ce640bc9 100644 --- a/config/groups/cache.ts +++ b/config/groups/cache.ts @@ -19,6 +19,10 @@ export const CACHE_ONE_INCH_RATE_TTL = ms('5m'); export const CACHE_TOTAL_SUPPLY_KEY = 'cache-total-supply'; export const CACHE_TOTAL_SUPPLY_TTL = ms('1m'); + +export const CACHE_EXTERNAL_CONFIG_KEY = 'cache-external-config'; +export const CACHE_EXTERNAL_CONFIG_TTL = ms('10m'); + export const CACHE_TOTAL_SUPPLY_HEADERS = 'public, max-age=60, stale-if-error=1200, stale-while-revalidate=30'; diff --git a/config/groups/revalidation.ts b/config/groups/revalidation.ts new file mode 100644 index 000000000..59c55f761 --- /dev/null +++ b/config/groups/revalidation.ts @@ -0,0 +1,15 @@ +import type { ManifestConfig, ManifestEntry } from 'config/external-config'; + +export const DEFAULT_REVALIDATION = 60 * 15; // 15 minutes +export const ERROR_REVALIDATION_SECONDS = 60; // 1 minute + +export const FALLBACK_CONFIG: ManifestConfig = { + enabledWithdrawalDexes: [], +}; + +export const FALLBACK_MANIFEST_ENTRY: ManifestEntry = { + cid: undefined, + ens: undefined, + leastSafeVersion: undefined, + config: FALLBACK_CONFIG, +}; diff --git a/config/provider.tsx b/config/provider.tsx index 3c07a9784..fe42b1d49 100644 --- a/config/provider.tsx +++ b/config/provider.tsx @@ -6,26 +6,45 @@ import { useFeatureFlagsContext, FeatureFlagsContextType, } from './feature-flags'; +import { + type ExternalConfig, + useExternalConfigContext, +} from './external-config'; type ConfigProviderType = { config: ConfigType; userConfig: UserConfigContextType; featureFlags: FeatureFlagsContextType; + externalConfig: ExternalConfig; }; export const ConfigContext = createContext(null); -export const ConfigProvider = ({ children }: PropsWithChildren) => { +type ConfigProviderProps = { + prefetchedManifest?: unknown; +}; + +export const ConfigProvider = ({ + children, + prefetchedManifest, +}: PropsWithChildren) => { const userConfigContextValue = useUserConfigContext(); const featureFlagsContextValue = useFeatureFlagsContext(); + const externalConfigContextValue = + useExternalConfigContext(prefetchedManifest); const contextValue = useMemo( () => ({ config: getConfig(), userConfig: userConfigContextValue, featureFlags: featureFlagsContextValue, + externalConfig: externalConfigContextValue, }), - [userConfigContextValue, featureFlagsContextValue], + [ + userConfigContextValue, + featureFlagsContextValue, + externalConfigContextValue, + ], ); return ( diff --git a/consts/external-links.ts b/consts/external-links.ts index 89910dfa2..af4db36ed 100644 --- a/consts/external-links.ts +++ b/consts/external-links.ts @@ -4,3 +4,7 @@ export const LINK_ADD_NFT_GUIDE = `${config.helpOrigin}/en/articles/7858367-how- export const OPEN_OCEAN_REFERRAL_ADDRESS = '0xbb1263222b2c020f155d409dba05c4a3861f18f8'; + +// for dev and local testing you can set to 'http:/localhost:3000/runtime/IPFS.json' and have file at /public/runtime/IPFS.json +export const IPFS_MANIFEST_URL = + 'https://raw.githubusercontent.com/lidofinance/ethereum-staking-widget/main/IPFS.json'; diff --git a/consts/metrics.ts b/consts/metrics.ts index 574e821f1..b1a9ba104 100644 --- a/consts/metrics.ts +++ b/consts/metrics.ts @@ -2,6 +2,8 @@ export const METRICS_PREFIX = 'eth_stake_widget_ui_'; export const enum METRIC_NAMES { REQUESTS_TOTAL = 'requests_total', + STARTUP_CHECKS_RPC = 'startup_checks_rpc', API_RESPONSE = 'api_response', ETH_CALL_ADDRESS_TO = 'eth_call_address_to', + SSR_COUNT = 'ssr_count', } diff --git a/features/ipfs/security-status-banner/use-remote-version.ts b/features/ipfs/security-status-banner/use-remote-version.ts index 9d27b74c4..d970860bc 100644 --- a/features/ipfs/security-status-banner/use-remote-version.ts +++ b/features/ipfs/security-status-banner/use-remote-version.ts @@ -1,66 +1,75 @@ import { useLidoSWR } from '@lido-sdk/react'; -import { config } from 'config'; +import { useConfig } from 'config'; import { STRATEGY_LAZY } from 'consts/swr-strategies'; import { useMainnetStaticRpcProvider } from 'shared/hooks/use-mainnet-static-rpc-provider'; -import { standardFetcher } from 'utils/standardFetcher'; type EnsHashCheckReturn = { cid: string; ens?: string; leastSafeVersion?: string; link: string; -} | null; - -type ReleaseInfoData = Record; - -type ReleaseInfo = { - cid?: string; - ens?: string; - leastSafeVersion?: string; }; -// for dev and local testing you can set to '/runtime/IPFS.json' and have file at /public/runtime/ -const IPFS_RELEASE_URL = - 'https://raw.githubusercontent.com/lidofinance/ethereum-staking-widget/main/IPFS.json'; - export const useRemoteVersion = () => { const provider = useMainnetStaticRpcProvider(); - // ens cid extraction - return useLidoSWR( - ['swr:use-remote-version'], - async (): Promise => { - const releaseInfoData = await standardFetcher( - IPFS_RELEASE_URL, - { - headers: { Accept: 'application/json' }, - }, - ); - const releaseInfo = releaseInfoData[config.defaultChain.toString()]; - if (releaseInfo?.ens) { - const resolver = await provider.getResolver(releaseInfo.ens); + // we use directly non-optimistic manifest data + // can't trust static props(in IPFS esp) to generate warnings/disconnect wallet + const externalConfigSwr = useConfig().externalConfig.fetchMeta; + const { data, error } = externalConfigSwr; + + // we only need this as swr because of possible future ENS support + // otherwise there is no fetch + const swr = useLidoSWR( + ['swr:use-remote-version', externalConfigSwr.data], + async (): Promise => { + if (data?.ens) { + const resolver = await provider.getResolver(data.ens); if (resolver) { const contentHash = await resolver.getContentHash(); if (contentHash) { return { cid: contentHash, - ens: releaseInfo.ens, - link: `https://${releaseInfo.ens}.limo`, - leastSafeVersion: releaseInfo.leastSafeVersion, + ens: data.ens, + link: `https://${data.ens}.limo`, + leastSafeVersion: data.leastSafeVersion, }; } } } - if (releaseInfo?.cid) { + if (data?.cid) { return { - cid: releaseInfo.cid, - link: `https://${releaseInfo.cid}.ipfs.cf-ipfs.com`, - leastSafeVersion: releaseInfo.leastSafeVersion, + cid: data.cid, + link: `https://${data.cid}.ipfs.cf-ipfs.com`, + leastSafeVersion: data.leastSafeVersion, }; } - throw new Error('invalid IPFS manifest content'); + throw new Error('[useRemoteVersion] invalid IPFS manifest content'); + }, + { + ...STRATEGY_LAZY, + // we postpone fetch if we don't have external data and don't have error + // empty data will force fetcher to produce correct error + isPaused: () => !(data || error), }, - { ...STRATEGY_LAZY }, ); + + // merged externalConfigSwr && cidSwr + return { + data: swr.data, + get initialLoading() { + return ( + swr.initialLoading || + (externalConfigSwr.data == null && externalConfigSwr.isValidating) + ); + }, + get loading() { + return swr.loading || externalConfigSwr.isValidating; + }, + get error() { + return swr.error || error; + }, + update: swr.update, + }; }; diff --git a/features/withdrawals/request/form/options/options-picker.tsx b/features/withdrawals/request/form/options/options-picker.tsx index c6e07ef2b..287d5eb85 100644 --- a/features/withdrawals/request/form/options/options-picker.tsx +++ b/features/withdrawals/request/form/options/options-picker.tsx @@ -7,7 +7,6 @@ import { MATOMO_CLICK_EVENTS_TYPES } from 'consts/matomo-click-events'; import { DATA_UNAVAILABLE } from 'consts/text'; import { useWaitingTime } from 'features/withdrawals/hooks/useWaitingTime'; import { RequestFormInputType } from 'features/withdrawals/request/request-form-context'; -import { ENABLED_WITHDRAWAL_DEXES } from 'features/withdrawals/withdrawals-constants'; import { getDexConfig, useWithdrawalRates, @@ -99,7 +98,7 @@ const DexButton: React.FC = ({ isActive, onClick }) => { Use DEXs - {ENABLED_WITHDRAWAL_DEXES.map((dexKey) => { + {enabledDexes.map((dexKey) => { const Icon = getDexConfig(dexKey).icon; return ; })} diff --git a/features/withdrawals/request/withdrawal-rates/use-withdrawal-rates.ts b/features/withdrawals/request/withdrawal-rates/use-withdrawal-rates.ts index 6c3a154e0..36d9da023 100644 --- a/features/withdrawals/request/withdrawal-rates/use-withdrawal-rates.ts +++ b/features/withdrawals/request/withdrawal-rates/use-withdrawal-rates.ts @@ -11,13 +11,12 @@ import { useDebouncedValue } from 'shared/hooks/useDebouncedValue'; import type { RequestFormInputType } from '../request-form-context'; import { getDexConfig } from './integrations'; -import { ENABLED_WITHDRAWAL_DEXES } from 'features/withdrawals/withdrawals-constants'; - import type { DexWithdrawalApi, GetWithdrawalRateParams, GetWithdrawalRateResult, } from './types'; +import { useConfig } from 'config'; export type useWithdrawalRatesOptions = { fallbackValue?: BigNumber; @@ -57,15 +56,11 @@ export const useWithdrawalRates = ({ const [token, amount] = useWatch({ name: ['token', 'amount'], }); + const enabledDexes = useConfig().externalConfig.enabledWithdrawalDexes; const fallbackedAmount = amount ?? fallbackValue; const debouncedAmount = useDebouncedValue(fallbackedAmount, 1000); const swr = useLidoSWR( - [ - 'swr:withdrawal-rates', - debouncedAmount.toString(), - token, - ENABLED_WITHDRAWAL_DEXES, - ], + ['swr:withdrawal-rates', debouncedAmount.toString(), token, enabledDexes], // eslint-disable-next-line @typescript-eslint/no-explicit-any (_, amount, token, enabledDexes) => getWithdrawalRates({ @@ -78,7 +73,7 @@ export const useWithdrawalRates = ({ isPaused: () => !debouncedAmount || !debouncedAmount._isBigNumber || - ENABLED_WITHDRAWAL_DEXES.length === 0, + enabledDexes.length === 0, }, ); @@ -89,7 +84,7 @@ export const useWithdrawalRates = ({ return { amount: fallbackedAmount, bestRate, - enabledDexes: ENABLED_WITHDRAWAL_DEXES, + enabledDexes, selectedToken: token, data: swr.data, get initialLoading() { diff --git a/features/withdrawals/withdrawals-constants/index.ts b/features/withdrawals/withdrawals-constants/index.ts index 1fcc9bbdc..72fd896a5 100644 --- a/features/withdrawals/withdrawals-constants/index.ts +++ b/features/withdrawals/withdrawals-constants/index.ts @@ -1,6 +1,3 @@ -import { overrideWithQAMockArray } from 'utils/qa'; -import type { DexWithdrawalApi } from '../request/withdrawal-rates'; - // max requests count for one tx export const MAX_REQUESTS_COUNT = 256; export const MAX_REQUESTS_COUNT_LEDGER_LIMIT = 2; @@ -15,9 +12,3 @@ export const WHAT_IS_TURBO = '#whatIsTurboMode'; // time that validation function waits for context data to resolve // should be enough to load token balances/tvl/max&min amounts and other contract data export const VALIDATION_CONTEXT_TIMEOUT = 4000; - -export const ENABLED_WITHDRAWAL_DEXES: DexWithdrawalApi[] = - overrideWithQAMockArray( - ['one-inch', 'paraswap', 'bebop'], - 'mock-qa-helpers-enabled-withdrawal-dexes', - ); diff --git a/next.config.mjs b/next.config.mjs index 9cd936eb3..ce2c62c2f 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,9 +1,17 @@ import NextBundleAnalyzer from '@next/bundle-analyzer'; import buildDynamics from './scripts/build-dynamics.mjs'; +import { logEnvironmentVariables } from './scripts/log-environment-variables.mjs'; import generateBuildId from './scripts/generate-build-id.mjs'; +import { startupCheckRPCs } from './scripts/startup-checks/rpc.mjs'; +logEnvironmentVariables(); buildDynamics(); +if (process.env.RUN_STARTUP_CHECKS === 'true' && typeof window === 'undefined') { + void startupCheckRPCs(); +} + + // https://nextjs.org/docs/pages/api-reference/next-config-js/basePath const basePath = process.env.BASE_PATH; diff --git a/package.json b/package.json index 1876485f2..5ca71f1a9 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "build": "NODE_OPTIONS='--no-warnings=ExperimentalWarning' next build", "build:analyze": "ANALYZE_BUNDLE=true yarn build", "build:ipfs": "IPFS_MODE=true yarn build && IPFS_MODE=true next export", - "start": "NODE_ENV=production node -r next-logger --no-warnings=ExperimentalWarning server.mjs", + "start": "NODE_ENV=production RUN_STARTUP_CHECKS=true node -r next-logger --no-warnings=ExperimentalWarning server.mjs", "lint": "eslint --ext ts,tsx,js,mjs .", "lint:fix": "yarn lint --fix", "types": "tsc --noEmit", diff --git a/pages/_app.tsx b/pages/_app.tsx index 32a0b7c15..6d7e54ea2 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -41,11 +41,11 @@ const App = (props: AppProps) => { const MemoApp = memo(App); -const AppWrapper = (props: AppProps): JSX.Element => { - const { ...rest } = props; - +const AppWrapper = ( + props: AppProps<{ ___prefetch_manifest___?: object }>, +): JSX.Element => { return ( - + {/* see https://nextjs.org/docs/messages/no-document-viewport-meta */} { }} /> - + diff --git a/pages/api/metrics.ts b/pages/api/metrics.ts index 44913d34a..02902da85 100644 --- a/pages/api/metrics.ts +++ b/pages/api/metrics.ts @@ -1,13 +1,13 @@ import { wrapRequest as wrapNextRequest } from '@lidofinance/next-api-wrapper'; +import { metricsFactory } from '@lidofinance/next-pages'; + import { API_ROUTES } from 'consts/api'; import { responseTimeMetric, errorAndCacheDefaultWrappers, rateLimit, } from 'utilsApi'; - import Metrics from 'utilsApi/metrics'; -import { metricsFactory } from '@lidofinance/next-pages'; const metrics = metricsFactory({ registry: Metrics.registry, diff --git a/pages/index.tsx b/pages/index.tsx index 4a2b17c67..b66cd7855 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -1,13 +1,9 @@ -import { GetStaticProps } from 'next'; import { config } from 'config'; import { StakePage } from 'features/stake'; import { HomePageIpfs } from 'features/ipfs'; -export default config.ipfsMode ? HomePageIpfs : StakePage; +import { getDefaultStaticProps } from 'utilsApi/get-default-static-props'; + +export const getStaticProps = getDefaultStaticProps(); -export const getStaticProps: GetStaticProps = async () => { - return { - props: {}, - revalidate: 60, - }; -}; +export default config.ipfsMode ? HomePageIpfs : StakePage; diff --git a/pages/referral.tsx b/pages/referral.tsx index f60165f9a..3ab13965b 100644 --- a/pages/referral.tsx +++ b/pages/referral.tsx @@ -1,5 +1,6 @@ import { FC } from 'react'; -import { GetStaticProps } from 'next'; + +import { getDefaultStaticProps } from 'utilsApi/get-default-static-props'; import { Banner } from 'features/referral'; import { Layout } from 'shared/components'; @@ -10,12 +11,6 @@ const Referral: FC = () => { ); }; +export const getStaticProps = getDefaultStaticProps(); export default Referral; - -export const getStaticProps: GetStaticProps = async () => { - return { - props: {}, - revalidate: 60, - }; -}; diff --git a/pages/rewards.tsx b/pages/rewards.tsx index 609d89a62..5e1184e33 100644 --- a/pages/rewards.tsx +++ b/pages/rewards.tsx @@ -1,5 +1,4 @@ import { FC } from 'react'; -import { GetStaticProps } from 'next'; import Head from 'next/head'; import { TopCard, RewardsList } from 'features/rewards/features'; @@ -7,6 +6,8 @@ import RewardsHistoryProvider from 'providers/rewardsHistory'; import { Layout } from 'shared/components'; +import { getDefaultStaticProps } from 'utilsApi/get-default-static-props'; + const Rewards: FC = () => { return ( { ); }; -export default Rewards; +export const getStaticProps = getDefaultStaticProps(); -export const getStaticProps: GetStaticProps = async () => { - return { - props: {}, - revalidate: 60, - }; -}; +export default Rewards; diff --git a/pages/settings.tsx b/pages/settings.tsx index 9d88b7d4a..273833e93 100644 --- a/pages/settings.tsx +++ b/pages/settings.tsx @@ -1,9 +1,9 @@ import { FC } from 'react'; -import { GetStaticProps } from 'next'; import { config } from 'config'; import { Layout } from 'shared/components'; import { SettingsForm } from 'features/settings/settings-form'; +import { getDefaultStaticProps } from 'utilsApi/get-default-static-props'; const Settings: FC = () => { return ( @@ -13,13 +13,10 @@ const Settings: FC = () => { ); }; -export default Settings; - -export const getStaticProps: GetStaticProps = async () => { +export const getStaticProps = getDefaultStaticProps(async () => { if (!config.ipfsMode) return { notFound: true }; - return { - props: {}, - revalidate: 60, - }; -}; + return { props: {} }; +}); + +export default Settings; diff --git a/pages/withdrawals/[mode].tsx b/pages/withdrawals/[mode].tsx index 54a5af0b1..3dacb7c42 100644 --- a/pages/withdrawals/[mode].tsx +++ b/pages/withdrawals/[mode].tsx @@ -1,5 +1,5 @@ -import { FC } from 'react'; -import { GetStaticPaths, GetStaticProps } from 'next'; +import type { FC } from 'react'; +import type { GetStaticPaths } from 'next'; import Head from 'next/head'; import { Layout } from 'shared/components'; @@ -7,6 +7,7 @@ import { Layout } from 'shared/components'; import { WithdrawalsTabs } from 'features/withdrawals'; import { WithdrawalsProvider } from 'features/withdrawals/contexts/withdrawals-context'; import { useWeb3Key } from 'shared/hooks/useWeb3Key'; +import { getDefaultStaticProps } from 'utilsApi/get-default-static-props'; const Withdrawals: FC = ({ mode }) => { const key = useWeb3Key(); @@ -41,10 +42,10 @@ export const getStaticPaths: GetStaticPaths< }; }; -export const getStaticProps: GetStaticProps< +export const getStaticProps = getDefaultStaticProps< WithdrawalsModePageParams, WithdrawalsModePageParams -> = async ({ params }) => { +>(async ({ params }) => { if (!params?.mode) return { notFound: true }; - return { props: { mode: params.mode }, revalidate: 60 }; -}; + return { props: { mode: params.mode } }; +}); diff --git a/pages/wrap/[[...mode]].tsx b/pages/wrap/[[...mode]].tsx index 34255198c..651097bf9 100644 --- a/pages/wrap/[[...mode]].tsx +++ b/pages/wrap/[[...mode]].tsx @@ -1,9 +1,10 @@ import { FC } from 'react'; -import { GetStaticPaths, GetStaticProps } from 'next'; +import { GetStaticPaths } from 'next'; import Head from 'next/head'; import { Layout } from 'shared/components'; import { WrapUnwrapTabs } from 'features/wsteth/wrap-unwrap-tabs'; import { useWeb3Key } from 'shared/hooks/useWeb3Key'; +import { getDefaultStaticProps } from 'utilsApi/get-default-static-props'; const WrapPage: FC = ({ mode }) => { const key = useWeb3Key(); @@ -38,14 +39,13 @@ export const getStaticPaths: GetStaticPaths = async () => { }; // we need [[...]] pattern for / and /unwrap -export const getStaticProps: GetStaticProps< +export const getStaticProps = getDefaultStaticProps< WrapModePageProps, WrapModePageParams -> = async ({ params }) => { +>(async ({ params }) => { const mode = params?.mode; - if (!mode) return { props: { mode: 'wrap' }, revalidate: 60 }; - if (mode[0] === 'unwrap') - return { props: { mode: 'unwrap' }, revalidate: 60 }; + if (!mode) return { props: { mode: 'wrap' } }; + if (mode[0] === 'unwrap') return { props: { mode: 'unwrap' } }; return { notFound: true }; -}; +}); diff --git a/providers/index.tsx b/providers/index.tsx index 5da5a1bdc..abc7d966d 100644 --- a/providers/index.tsx +++ b/providers/index.tsx @@ -10,8 +10,15 @@ import { InpageNavigationProvider } from './inpage-navigation'; import { ModalProvider } from './modal-provider'; import Web3Provider from './web3'; -export const Providers: FC = ({ children }) => ( - +type ProvidersProps = { + prefetchedManifest?: unknown; +}; + +export const Providers: FC> = ({ + children, + prefetchedManifest, +}) => ( + diff --git a/scripts/log-environment-variables.mjs b/scripts/log-environment-variables.mjs new file mode 100644 index 000000000..e019db116 --- /dev/null +++ b/scripts/log-environment-variables.mjs @@ -0,0 +1,80 @@ +export const openKeys = [ + 'SELF_ORIGIN', + 'ROOT_ORIGIN', + 'DOCS_ORIGIN', + 'HELP_ORIGIN', + 'RESEARCH_ORIGIN', + + 'SUPPORTED_CHAINS', + 'DEFAULT_CHAIN', + + 'CSP_TRUSTED_HOSTS', + 'CSP_REPORT_ONLY', + 'CSP_REPORT_URI', + + 'ENABLE_QA_HELPERS', + + 'REWARDS_BACKEND', + + 'RATE_LIMIT', + 'RATE_LIMIT_TIME_FRAME', + + 'ETH_API_BASE_PATH', + 'WQ_API_BASE_PATH', + 'MATOMO_URL', + 'WALLETCONNECT_PROJECT_ID', + 'REWARDS_BACKEND_BASE_PATH', +]; + +export const secretKeys = [ + 'EL_RPC_URLS_1', + 'EL_RPC_URLS_5', + 'EL_RPC_URLS_17000', + 'EL_RPC_URLS_11155111', +] + + +export const logOpenEnvironmentVariables = () => { + console.log('---------------------------------------------'); + console.log('Log environment variables (without secrets):'); + console.log('---------------------------------------------'); + + for (const key of openKeys) { + if (!process.env.hasOwnProperty(key)) { + console.error(`${key} - ERROR (not exist in process.env)`); + continue; + } + + console.info(`${key} = ${process.env[key]}`); + } + + console.log('---------------------------------------------'); + console.log(''); +}; + +export const logSecretEnvironmentVariables = () => { + console.log('---------------------------------------------'); + console.log('Log secret environment variables:'); + console.log('---------------------------------------------'); + + // console.log('process.env:', process.env) + for (const key of secretKeys) { + if (!process.env.hasOwnProperty(key)) { + console.error(`Secret ${key} - ERROR (not exist in process.env)`); + continue; + } + + if (process.env[key].length > 0) { + console.info(`Secret ${key} - OK (exist and not empty)`); + } else { + console.warn(`Secret ${key} - WARN (exist but empty)`); + } + } + + console.log('---------------------------------------------'); +}; + +export const logEnvironmentVariables = () => { + logOpenEnvironmentVariables(); + logSecretEnvironmentVariables(); +}; diff --git a/scripts/startup-checks/rpc.mjs b/scripts/startup-checks/rpc.mjs new file mode 100644 index 000000000..7bb55cb4e --- /dev/null +++ b/scripts/startup-checks/rpc.mjs @@ -0,0 +1,78 @@ +import { createClient, http } from 'viem'; +import { getChainId } from 'viem/actions' + +// Safely initialize a global variable +let globalRPCCheckResults = globalThis.__rpcCheckResults || []; +globalThis.__rpcCheckResults = globalRPCCheckResults; + +export const BROKEN_URL = 'BROKEN_URL'; +export const RPC_TIMEOUT_SECONDS = 10_000; + +const pushRPCCheckResult = (domain, success) => { + globalRPCCheckResults.push({ domain, success }); +}; + +export const getRPCCheckResults = () => globalThis.__rpcCheckResults || []; + +const getRpcUrls = (chainId) => { + const rpcUrls = process.env[`EL_RPC_URLS_${chainId}`]?.split(','); + return rpcUrls?.filter((url) => url); +}; + +export const startupCheckRPCs = async () => { + console.info('[startupCheckRPCs] Starting...'); + + try { + const defaultChain = parseInt(process.env.DEFAULT_CHAIN, 10); + const rpcUrls = getRpcUrls(defaultChain); + + if (!rpcUrls || rpcUrls.length === 0) { + throw new Error('[startupCheckRPCs] No RPC URLs found!'); + } + + let errorCount = 0; + + for (const url of rpcUrls) { + let domain; + try { + domain = new URL(url).hostname; + } catch (err) { + errorCount += 1; + console.error('There is a broken URL.'); + pushRPCCheckResult(BROKEN_URL, false); + continue; + } + + try { + const client = createClient({ + transport: http(url, { retryCount: 0, timeout: RPC_TIMEOUT_SECONDS }) + }); + + const chainId = await getChainId(client); + + if (defaultChain === chainId) { + pushRPCCheckResult(domain, true); + console.info(`[startupCheckRPCs] RPC ${domain} works!`); + } else { + throw(`[startupCheckRPCs] RPC ${domain} does not work!`); + } + } catch (err) { + errorCount += 1; + pushRPCCheckResult(domain, false); + console.error(`[startupCheckRPCs] Error with RPC ${domain}:`); + console.error(String(err).replaceAll(rpcUrls, domain)); + console.error(`[startupCheckRPCs] Timeout: ${RPC_TIMEOUT_SECONDS} seconds`); + } + } + + if (errorCount > 0) { + console.info(`[startupCheckRPCs] Number of working RPCs: ${rpcUrls.length - errorCount}`); + console.info(`[startupCheckRPCs] Number of broken RPCs: ${errorCount}`); + } else { + console.info('[startupCheckRPCs] All RPC works!'); + } + } catch (err) { + console.error('[startupCheckRPCs] Error during startup check:'); + console.error(err); + } +}; diff --git a/utilsApi/fetch-external-manifest.ts b/utilsApi/fetch-external-manifest.ts new file mode 100644 index 000000000..a388c1efb --- /dev/null +++ b/utilsApi/fetch-external-manifest.ts @@ -0,0 +1,77 @@ +import { Cache } from 'memory-cache'; +import { IPFS_MANIFEST_URL } from 'consts/external-links'; +import { responseTimeExternalMetricWrapper } from './fetchApiWrapper'; +import { standardFetcher } from 'utils/standardFetcher'; +import { config } from 'config'; + +import FallbackLocalManifest from 'IPFS.json' assert { type: 'json' }; + +export type ExternalConfigResult = { + ___prefetch_manifest___: object | null; + revalidate: number; +}; + +const cache = new Cache< + typeof config.CACHE_EXTERNAL_CONFIG_KEY, + ExternalConfigResult +>(); + +export const fetchExternalManifest = async () => { + const cachedConfig = cache.get(config.CACHE_EXTERNAL_CONFIG_KEY); + if (cachedConfig) return cachedConfig; + + // for IPFS build we use local manifest + // this allows local CID verification + if (config.ipfsMode) { + return { + ___prefetch_manifest___: FallbackLocalManifest, + revalidate: config.DEFAULT_REVALIDATION, + }; + } + + let retries = 3; + while (retries > 0) { + try { + const data = await responseTimeExternalMetricWrapper({ + payload: IPFS_MANIFEST_URL, + request: () => + standardFetcher(IPFS_MANIFEST_URL, { + headers: { Accept: 'application/json' }, + }), + }); + if (!data || typeof data !== 'object') + throw new Error(`invalid config received: ${data}`); + + const result = { + ___prefetch_manifest___: data, + revalidate: config.DEFAULT_REVALIDATION, + }; + + cache.put( + config.CACHE_EXTERNAL_CONFIG_KEY, + result, + config.CACHE_EXTERNAL_CONFIG_TTL, + ); + + console.debug( + `[fetchExternalManifest] fetched external manifest`, + result, + ); + + return result; + } catch (error) { + console.error( + `[fetchExternalManifest] failed to fetch external manifest`, + error, + ); + retries -= 1; + } + } + console.error( + `[fetchExternalManifest] failed to fetch external manifest after retries, revalidation is set to ${config.ERROR_REVALIDATION_SECONDS}`, + ); + return { + revalidate: config.ERROR_REVALIDATION_SECONDS, + ___prefetch_manifest___: FallbackLocalManifest, + }; +}; diff --git a/utilsApi/get-default-static-props.ts b/utilsApi/get-default-static-props.ts new file mode 100644 index 000000000..aefc98079 --- /dev/null +++ b/utilsApi/get-default-static-props.ts @@ -0,0 +1,45 @@ +import type { GetStaticProps, GetStaticPropsResult, PreviewData } from 'next'; +import type { ParsedUrlQuery } from 'querystring'; + +import Metrics from 'utilsApi/metrics'; +import { fetchExternalManifest } from './fetch-external-manifest'; + +export const getDefaultStaticProps = < + P extends { [key: string]: any } = { [key: string]: any }, + Q extends ParsedUrlQuery = ParsedUrlQuery, + D extends PreviewData = PreviewData, +>( + custom?: GetStaticProps, +): GetStaticProps

=> { + let shouldZeroRevalidate = true; + return async (context) => { + /// common props + const { ___prefetch_manifest___, revalidate } = + await fetchExternalManifest(); + const props = ___prefetch_manifest___ ? { ___prefetch_manifest___ } : {}; + const base = { + props, + revalidate: shouldZeroRevalidate ? 1 : revalidate, + }; + + /// custom getStaticProps + let result = base as GetStaticPropsResult

; + if (custom) { + const { props: customProps, ...rest } = (await custom(context)) as any; + result = { + ...base, + ...rest, + props: { ...base.props, ...customProps }, + }; + } + + /// metrics + console.debug( + `[getDefaultStaticProps] running revalidation, next revalidation in ${base.revalidate}`, + ); + Metrics.request.ssrCounter.labels({ revalidate: base.revalidate }).inc(1); + + shouldZeroRevalidate = false; + return result; + }; +}; diff --git a/utilsApi/metrics/metrics.ts b/utilsApi/metrics/metrics.ts index 3c5b2337e..d75d75c4d 100644 --- a/utilsApi/metrics/metrics.ts +++ b/utilsApi/metrics/metrics.ts @@ -1,10 +1,7 @@ import { collectDefaultMetrics, Registry } from 'prom-client'; -import { collectStartupMetrics } from '@lidofinance/api-metrics'; - -import { config } from 'config'; import { METRICS_PREFIX } from 'consts/metrics'; -import buildInfoJson from 'build-info.json'; +import { collectStartupMetrics } from './startup-metrics'; import { RequestMetrics } from './request'; class Metrics { @@ -14,23 +11,9 @@ class Metrics { request = new RequestMetrics(this.registry); constructor() { - this.collectStartupMetricsInit(); + collectStartupMetrics(this.registry); collectDefaultMetrics({ prefix: METRICS_PREFIX, register: this.registry }); } - - collectStartupMetricsInit() { - collectStartupMetrics({ - prefix: METRICS_PREFIX, - registry: this.registry, - defaultChain: `${config.defaultChain}`, - supportedChains: config.supportedChains.map( - (chain: number) => `${chain}`, - ), - version: buildInfoJson.version, - commit: buildInfoJson.commit, - branch: buildInfoJson.branch, - }); - } } export default new Metrics(); diff --git a/utilsApi/metrics/request.ts b/utilsApi/metrics/request.ts index 1553528e4..c966fa9fb 100644 --- a/utilsApi/metrics/request.ts +++ b/utilsApi/metrics/request.ts @@ -6,12 +6,14 @@ export class RequestMetrics { apiTimingsExternal: Histogram<'hostname' | 'route' | 'entity' | 'status'>; requestCounter: Counter<'route'>; ethCallToAddress: Counter<'address' | 'referrer'>; + ssrCounter: Counter<'revalidate'>; constructor(public registry: Registry) { this.apiTimings = this.apiTimingsInit('internal'); this.apiTimingsExternal = this.apiTimingsInit('external'); this.requestCounter = this.requestsCounterInit(); this.ethCallToAddress = this.ethCallToAddressInit(); + this.ssrCounter = this.ssrCounterInit(); } apiTimingsInit(postfix: string) { @@ -53,4 +55,13 @@ export class RequestMetrics { registers: [this.registry], }); } + + ssrCounterInit() { + return new Counter({ + name: METRICS_PREFIX + METRIC_NAMES.SSR_COUNT, + help: 'Counts of running getDefaultStaticProps with revalidation param', + labelNames: ['revalidate'], + registers: [this.registry], + }); + } } diff --git a/utilsApi/metrics/startup-checks.ts b/utilsApi/metrics/startup-checks.ts new file mode 100644 index 000000000..aa1e4f5dc --- /dev/null +++ b/utilsApi/metrics/startup-checks.ts @@ -0,0 +1,22 @@ +import { Counter, Registry } from 'prom-client'; +import { METRICS_PREFIX, METRIC_NAMES } from 'consts/metrics'; + +export class StartupChecksRPCMetrics { + requestCounter: Counter<'rpc_domain' | 'success'>; + + constructor(public registry: Registry) { + this.requestCounter = this.requestsCounterInit(); + } + + requestsCounterInit() { + const requestsCounterName = + METRICS_PREFIX + METRIC_NAMES.STARTUP_CHECKS_RPC; + + return new Counter({ + name: requestsCounterName, + help: 'The total number of RPC checks after the app started.', + labelNames: ['rpc_domain', 'success'], + registers: [this.registry], + }); + } +} diff --git a/utilsApi/metrics/startup-metrics.ts b/utilsApi/metrics/startup-metrics.ts new file mode 100644 index 000000000..9fd417851 --- /dev/null +++ b/utilsApi/metrics/startup-metrics.ts @@ -0,0 +1,54 @@ +import { Gauge, type Registry } from 'prom-client'; +import { collectStartupMetrics as collectBuildInfoMetrics } from '@lidofinance/api-metrics'; + +import buildInfoJson from 'build-info.json'; +import { openKeys } from 'scripts/log-environment-variables.mjs'; +import { getRPCCheckResults } from 'scripts/startup-checks/rpc.mjs'; + +import { config } from 'config'; +import { METRICS_PREFIX } from 'consts/metrics'; + +import { StartupChecksRPCMetrics } from './startup-checks'; + +const collectStartupChecksRPCMetrics = (registry: Registry): void => { + const rpcMetrics = new StartupChecksRPCMetrics(registry); + + getRPCCheckResults().forEach( + (_check: { domain: string; success: boolean }) => { + rpcMetrics.requestCounter + .labels(_check.domain, _check.success.toString()) + .inc(); + }, + ); +}; + +const collectEnvInfoMetrics = (registry: Registry): void => { + const labelPairs = openKeys.map((key) => ({ + name: key, + value: process.env[key] ?? '', + })); + + const envInfo = new Gauge({ + name: METRICS_PREFIX + 'env_info', + help: 'Environment variables of the current runtime', + labelNames: labelPairs.map((pair) => pair.name), + registers: [registry], + }); + envInfo.labels(...labelPairs.map((pair) => pair.value)).set(1); +}; + +export const collectStartupMetrics = (registry: Registry): void => { + collectEnvInfoMetrics(registry); + + collectBuildInfoMetrics({ + prefix: METRICS_PREFIX, + registry: registry, + defaultChain: `${config.defaultChain}`, + supportedChains: config.supportedChains.map((chain: number) => `${chain}`), + version: buildInfoJson.version, + commit: buildInfoJson.commit, + branch: buildInfoJson.branch, + }); + + collectStartupChecksRPCMetrics(registry); +};