diff --git a/packages/extension-polkagate/src/class/endpointManager.ts b/packages/extension-polkagate/src/class/endpointManager.ts index d7f72b2a4..04754b577 100644 --- a/packages/extension-polkagate/src/class/endpointManager.ts +++ b/packages/extension-polkagate/src/class/endpointManager.ts @@ -5,35 +5,37 @@ import type { EndpointType } from '../util/types'; import { ENDPOINT_TIMEOUT } from '../util/constants'; +const ENDPOINTS_NAME_IN_STORAGE = 'endpoints2'; + // Define types for saved endpoints and listener function -type SavedEndpoints = Record>; -type Listener = (address: string, genesisHash: string, endpoint: EndpointType) => void; +type SavedEndpoints = Record; +type Listener = (genesisHash: string, endpoint: EndpointType) => void; export default class EndpointManager { // Store endpoints and listeners private endpoints: SavedEndpoints = {}; private listeners = new Set(); - constructor() { + constructor () { // Load endpoints from storage and set up storage change listener this.loadFromStorage(); chrome.storage.onChanged.addListener(this.handleStorageChange); } // Load endpoints from chrome storage - private loadFromStorage() { - chrome.storage.local.get('endpoints', (result: { endpoints?: SavedEndpoints }) => { - if (result.endpoints) { - this.endpoints = result.endpoints; + private loadFromStorage () { + chrome.storage.local.get(ENDPOINTS_NAME_IN_STORAGE, (result: { [ENDPOINTS_NAME_IN_STORAGE]?: SavedEndpoints }) => { + if (result[ENDPOINTS_NAME_IN_STORAGE]) { + this.endpoints = result[ENDPOINTS_NAME_IN_STORAGE]; this.notifyListeners(); } }); } // Save endpoints to chrome storage - private saveToStorage() { + private saveToStorage () { try { - chrome.storage.local.set({ endpoints: this.endpoints }).catch(console.error); + chrome.storage.local.set({ [ENDPOINTS_NAME_IN_STORAGE]: this.endpoints }).catch(console.error); } catch (error) { console.error('Unable to save the endpoint inside the storage!', error); } @@ -41,54 +43,50 @@ export default class EndpointManager { // Handle changes in chrome storage private handleStorageChange = (changes: Record, areaName: string) => { - if (areaName === 'local' && changes['endpoints']) { - this.endpoints = changes['endpoints'].newValue as SavedEndpoints; + if (areaName === 'local' && changes[ENDPOINTS_NAME_IN_STORAGE]) { + this.endpoints = changes[ENDPOINTS_NAME_IN_STORAGE].newValue as SavedEndpoints; this.notifyListeners(); } }; // Notify all listeners about endpoint changes - private notifyListeners() { - Object.entries(this.endpoints).forEach(([address, endpointInfo]) => { - Object.entries(endpointInfo).forEach(([genesisHash, endpoint]) => { - this.listeners.forEach((listener) => listener(address, genesisHash, endpoint)); - }); + private notifyListeners () { + Object.entries(this.endpoints).forEach(([genesisHash, endpointInfo]) => { + this.listeners.forEach((listener) => listener(genesisHash, endpointInfo)); }); } // Get a specific endpoint - get(address: string, genesisHash: string): EndpointType | undefined { - return this.endpoints[address]?.[genesisHash]; + get (genesisHash: string): EndpointType | undefined { + return this.endpoints?.[genesisHash]; } // Get all endpoints - getEndpoints(): SavedEndpoints | undefined { + getEndpoints (): SavedEndpoints | undefined { return this.endpoints; } // Set a specific endpoint - set(address: string, genesisHash: string, endpoint: EndpointType) { - if (!this.endpoints[address]) { - this.endpoints[address] = {}; + set (genesisHash: string, endpoint: EndpointType) { + if (!this.endpoints[genesisHash]) { + this.endpoints[genesisHash] = {} as EndpointType; } - this.endpoints[address][genesisHash] = endpoint; + this.endpoints[genesisHash] = endpoint; this.saveToStorage(); this.notifyListeners(); } // Check if an endpoint should be in auto mode - shouldBeOnAutoMode(endpoint: EndpointType) { + shouldBeOnAutoMode (endpoint: EndpointType) { return endpoint.isAuto && (Date.now() - (endpoint.timestamp ?? 0) > ENDPOINT_TIMEOUT); } - // Subscribe a listener to endpoint changes - subscribe(listener: Listener) { + subscribe (listener: Listener) { this.listeners.add(listener); } - // Unsubscribe a listener from endpoint changes - unsubscribe(listener: Listener) { + unsubscribe (listener: Listener) { this.listeners.delete(listener); } } diff --git a/packages/extension-polkagate/src/class/endpointManager2.ts b/packages/extension-polkagate/src/class/endpointManager2.ts deleted file mode 100644 index 5071564f0..000000000 --- a/packages/extension-polkagate/src/class/endpointManager2.ts +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright 2019-2025 @polkadot/extension-polkagate authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -import type { EndpointType } from '../util/types'; - -import { ENDPOINT_TIMEOUT } from '../util/constants'; - -const ENDPOINTS_NAME_IN_STORAGE = 'endpoints2'; - -// Define types for saved endpoints and listener function -type SavedEndpoints = Record; -type Listener = (genesisHash: string, endpoint: EndpointType) => void; - -export default class endpointManager2 { - // Store endpoints and listeners - private endpoints: SavedEndpoints = {}; - private listeners = new Set(); - - constructor () { - // Load endpoints from storage and set up storage change listener - this.loadFromStorage(); - chrome.storage.onChanged.addListener(this.handleStorageChange); - } - - // Load endpoints from chrome storage - private loadFromStorage () { - chrome.storage.local.get(ENDPOINTS_NAME_IN_STORAGE, (result: { [ENDPOINTS_NAME_IN_STORAGE]?: SavedEndpoints }) => { - if (result[ENDPOINTS_NAME_IN_STORAGE]) { - this.endpoints = result[ENDPOINTS_NAME_IN_STORAGE]; - this.notifyListeners(); - } - }); - } - - // Save endpoints to chrome storage - private saveToStorage () { - try { - chrome.storage.local.set({ [ENDPOINTS_NAME_IN_STORAGE]: this.endpoints }).catch(console.error); - } catch (error) { - console.error('Unable to save the endpoint inside the storage!', error); - } - } - - // Handle changes in chrome storage - private handleStorageChange = (changes: Record, areaName: string) => { - if (areaName === 'local' && changes[ENDPOINTS_NAME_IN_STORAGE]) { - this.endpoints = changes[ENDPOINTS_NAME_IN_STORAGE].newValue as SavedEndpoints; - this.notifyListeners(); - } - }; - - // Notify all listeners about endpoint changes - private notifyListeners () { - Object.entries(this.endpoints).forEach(([genesisHash, endpointInfo]) => { - this.listeners.forEach((listener) => listener(genesisHash, endpointInfo)); - }); - } - - // Get a specific endpoint - get (genesisHash: string): EndpointType | undefined { - return this.endpoints?.[genesisHash]; - } - - // Get all endpoints - getEndpoints (): SavedEndpoints | undefined { - return this.endpoints; - } - - // Set a specific endpoint - set (genesisHash: string, endpoint: EndpointType) { - if (!this.endpoints[genesisHash]) { - this.endpoints[genesisHash] = {} as EndpointType; - } - - this.endpoints[genesisHash] = endpoint; - this.saveToStorage(); - this.notifyListeners(); - } - - // Check if an endpoint should be in auto mode - shouldBeOnAutoMode (endpoint: EndpointType) { - return endpoint.isAuto && (Date.now() - (endpoint.timestamp ?? 0) > ENDPOINT_TIMEOUT); - } - - subscribe (listener: Listener) { - this.listeners.add(listener); - } - - unsubscribe (listener: Listener) { - this.listeners.delete(listener); - } -} diff --git a/packages/extension-polkagate/src/components/contexts.tsx b/packages/extension-polkagate/src/components/contexts.tsx index d0532fae7..3e44ed618 100644 --- a/packages/extension-polkagate/src/components/contexts.tsx +++ b/packages/extension-polkagate/src/components/contexts.tsx @@ -13,7 +13,7 @@ import { noop } from '@polkadot/util'; const AccountContext = React.createContext({ accounts: [], hierarchy: [], master: undefined }); const AccountsAssetsContext = React.createContext({ accountsAssets: undefined, setAccountsAssets: noop }); const ActionContext = React.createContext<(to?: string) => void>(noop); -const APIContext = React.createContext({ apis: {}, setIt: noop }); +const APIContext = React.createContext({ apis: {}, getApi: () => Promise.resolve(undefined) }); const AlertContext = React.createContext({ alerts: [], setAlerts: noop }); const AuthorizeReqContext = React.createContext([]); const CurrencyContext = React.createContext({ currency: undefined, setCurrency: noop }); diff --git a/packages/extension-polkagate/src/fullscreen/settings/partials/useEndpointsSetting.ts b/packages/extension-polkagate/src/fullscreen/settings/partials/useEndpointsSetting.ts index 8786f36cb..0958d61c8 100644 --- a/packages/extension-polkagate/src/fullscreen/settings/partials/useEndpointsSetting.ts +++ b/packages/extension-polkagate/src/fullscreen/settings/partials/useEndpointsSetting.ts @@ -5,7 +5,7 @@ import type React from 'react'; import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'; -import EndpointManager2 from '../../../class/endpointManager2'; +import EndpointManager from '../../../class/endpointManager'; import { useEndpoint, useEndpoints, useIsExtensionPopup } from '../../../hooks'; import CalculateNodeDelay from '../../../util/calculateNodeDelay'; import { AUTO_MODE } from '../../../util/constants'; @@ -60,7 +60,7 @@ function reducer (state: State, action: Action): State { } } -const endpointManager = new EndpointManager2(); +const endpointManager = new EndpointManager(); export default function useEndpointsSetting (genesisHash: string | undefined, isEnabled: boolean, onEnableChain?: (value: string, checked: boolean) => void, onClose?: () => void) { const isExtension = useIsExtensionPopup(); diff --git a/packages/extension-polkagate/src/hooks/useApi.ts b/packages/extension-polkagate/src/hooks/useApi.ts index 8588752e9..40b40e276 100644 --- a/packages/extension-polkagate/src/hooks/useApi.ts +++ b/packages/extension-polkagate/src/hooks/useApi.ts @@ -1,278 +1,26 @@ // Copyright 2019-2025 @polkadot/extension-polkagate authors & contributors // SPDX-License-Identifier: Apache-2.0 -import { useCallback, useContext, useEffect, useReducer } from 'react'; +import type { ApiPromise } from '@polkadot/api'; -import { ApiPromise, WsProvider } from '@polkadot/api'; +import { useContext, useEffect, useState } from 'react'; -import EndpointManager from '../class/endpointManager2'; import { APIContext } from '../components'; -import { fastestConnection } from '../util'; -import LCConnector from '../util/api/lightClient-connect'; -import { AUTO_MODE } from '../util/constants'; -import useEndpoint from './useEndpoint'; -import { useEndpoints } from './useEndpoints'; +import { useEndpoints } from '.'; -// Define types for API state and actions -interface ApiState { - api: ApiPromise | undefined; // The API object, initially undefined - isLoading: boolean; // Whether the API connection is in the loading state - error: Error | null; // Any error that occurs during the API connection process -} - -// Reducer function to manage API state -type ApiAction = - | { type: 'SET_API'; payload: ApiPromise } - | { type: 'SET_LOADING'; payload: boolean } - | { type: 'SET_ERROR'; payload: Error }; - -const apiReducer = (state: ApiState, action: ApiAction): ApiState => { - const { payload, type } = action; - - switch (type) { - case 'SET_API': - return { ...state, api: payload, error: null, isLoading: false }; - case 'SET_LOADING': - return { ...state, isLoading: payload }; - case 'SET_ERROR': - return { ...state, error: payload, isLoading: false }; - default: - return state; - } -}; - -const endpointManager = new EndpointManager(); -const isAutoMode = (e: string) => e === AUTO_MODE.value; - -export default function useApi (genesisHash: string | null | undefined, stateApi?: ApiPromise, _endpoint?: string): ApiPromise | undefined { - const { checkForNewOne, endpoint } = useEndpoint(genesisHash, _endpoint); - const apisContext = useContext(APIContext); +export default function useApi (genesisHash: string | null | undefined): ApiPromise | undefined { + const { getApi } = useContext(APIContext); const endpoints = useEndpoints(genesisHash); - const [state, dispatch] = useReducer(apiReducer, { - api: stateApi, - error: null, - isLoading: false - }); - - // This function is called exclusively in auto mode to update the account's "auto mode" endpoint - // with the fastest endpoint available. - const updateEndpoint = useCallback((chainKey: string, selectedEndpoint: string, cbFunction?: () => void) => { - try { - const newEndpoint = { - checkForNewOne: false, - endpoint: selectedEndpoint, - isAuto: true, - timestamp: Date.now() - }; - - endpointManager.set(chainKey, newEndpoint); - - cbFunction?.(); - } catch (error) { - console.error(error); - } - }, []); - - // Checks if there is an available API connection, then will change the address endpoint to the available API's endpoint - // Returns false if it was not successful to find an available API and true vice versa - const connectToExisted = useCallback((genesisHash: string): boolean => { - const apiList = apisContext.apis[genesisHash]; - - if (!apiList) { - return false; - } - - const availableApi = apiList.find(({ api }) => api?.isConnected); - - if (!availableApi?.api) { - return false; - } - - dispatch({ payload: availableApi.api, type: 'SET_API' }); - updateEndpoint(genesisHash, availableApi.endpoint); - - console.log('Successfully connected to existing API for genesis hash:', genesisHash); - - return true; - }, [apisContext.apis, updateEndpoint]); - - // Handles a new API connection and updates the context with the new API - const handleNewApi = useCallback((api: ApiPromise, endpoint: string, onAutoMode?: boolean) => { - dispatch({ payload: api, type: 'SET_API' }); - - const genesisHash = String(api.genesisHash.toHex()); - let toSaveApi = apisContext.apis[genesisHash] ?? []; - - // Remove any existing API with the same endpoint - // it happens when the API is requested and not connected yet - toSaveApi = toSaveApi.filter((sApi) => sApi.endpoint !== endpoint); - - // If in auto mode, remove any auto mode endpoint - if (onAutoMode) { - toSaveApi = toSaveApi.filter((sApi) => !isAutoMode(sApi.endpoint)); - } - - // Add the new API entry - toSaveApi.push({ - api, - endpoint, - isRequested: false - }); - - apisContext.apis[genesisHash] = toSaveApi; - apisContext.setIt({ ...apisContext.apis }); - }, [apisContext]); - - // Connects to a specific WebSocket endpoint and creates a new API instance - // when it is not on Auto Mode - const connectToEndpoint = useCallback(async (endpointToConnect: string) => { - try { - dispatch({ payload: true, type: 'SET_LOADING' }); - const wsProvider = new WsProvider(endpointToConnect); - const newApi = await ApiPromise.create({ provider: wsProvider }); - - handleNewApi(newApi, endpointToConnect); - } catch (error) { - dispatch({ payload: error as Error, type: 'SET_ERROR' }); - } finally { - dispatch({ payload: false, type: 'SET_LOADING' }); - } - }, [handleNewApi]); - - // Handles auto mode by finding the fastest endpoint and connecting to it - const handleAutoMode = useCallback(async (genesisHash: string, findNewEndpoint: boolean) => { - const apisForGenesis = apisContext.apis[genesisHash]; - - const autoModeExists = apisForGenesis?.some(({ endpoint }) => isAutoMode(endpoint)); - - if (autoModeExists) { - return; - } - - const result = !findNewEndpoint && connectToExisted(genesisHash); - - if (result) { - return; - } - - const wssEndpoints = endpoints.filter(({ value }) => String(value).startsWith('wss')); // to filter possible light client - - dispatch({ payload: true, type: 'SET_LOADING' }); - - // Finds the fastest available endpoint and connects to it - const { api, selectedEndpoint } = await fastestConnection(wssEndpoints); + const [api, setApi] = useState(undefined); - if (!api || !selectedEndpoint) { - return; - } - - updateEndpoint(genesisHash, selectedEndpoint, () => handleNewApi(api, selectedEndpoint, true)); - }, [apisContext.apis, connectToExisted, endpoints, handleNewApi, updateEndpoint]); - - const addApiRequest = useCallback((endpointToRequest: string, genesisHash: string) => { - const toSaveApi = apisContext.apis[genesisHash] ?? []; - - toSaveApi.push({ endpoint: endpointToRequest, isRequested: true }); - - apisContext.apis[genesisHash] = toSaveApi; - apisContext.setIt({ ...apisContext.apis }); - }, [apisContext]); - - // check api in the context - const isInContext = useCallback((endpoint: string, genesisHash: string) => { - // Check if there is a saved API that is already connected - const savedApi = apisContext?.apis[genesisHash]?.find((sApi) => sApi.endpoint === endpoint); - - // If the API is already being requested, skip the connection process - if (savedApi?.isRequested) { - return true; - } - - if (savedApi?.api?.isConnected) { - dispatch({ payload: savedApi.api, type: 'SET_API' }); - - return true; - } - - return false; - }, [apisContext?.apis]); - - // Handles connection request to a manual endpoint - const handleApiWithChain = useCallback((manualEndpoint: string, genesisHash: string) => { - if (isInContext(manualEndpoint, genesisHash)) { - return; - } - - addApiRequest(manualEndpoint, genesisHash); - - connectToEndpoint(manualEndpoint).catch(console.error); - }, [addApiRequest, connectToEndpoint, isInContext]); - - useEffect(() => { - // if _endpoint & genesisHash are available means useApiWithChain2 is trying to create a new connection! - if (_endpoint && genesisHash) { - handleApiWithChain(_endpoint, genesisHash); - } - }, [_endpoint, genesisHash, handleApiWithChain]); - - // Manages the API connection when the endpoint, or genesis hash changes useEffect(() => { - // @ts-expect-error to bypass access to private prop - if (!genesisHash || !endpoint || state.isLoading || state?.api?._options?.provider?.endpoint === endpoint) { - return; - } - - // Validate the endpoint format (it should start with 'wss', 'light', or be in auto mode) - if (!endpoint.startsWith('wss') && !endpoint.startsWith('light') && !isAutoMode(endpoint)) { - console.log('📌 📌 Unsupported endpoint detected 📌 📌 ', endpoint); - - return; - } - - // To address the delay issue when setting the endpoint in this hook, - // we manually compare the endpoint obtained from `useEndpoint` (local state) - // and the endpoint stored in `EndpointManager`. - // If they are not equal, it means the state has not been updated yet, - // so we log a message and return early to prevent further processing. - const endpointFromTheManager = endpointManager.get(genesisHash)?.endpoint; // Endpoint stored in the manager - - // Check if the two endpoints are not synchronized - if (endpoint !== endpointFromTheManager) { - // console.log('📌 📌 Not updated yet! The endpoint in the manager is still different from the local one.'); - - // Exit early to avoid further execution until the endpoints are in sync + if (!genesisHash || !endpoints) { return; } - // To provide api from context - if (isInContext(endpoint, genesisHash)) { - return; - } - - // If in auto mode, check existing connections or find a new one - if (isAutoMode(endpoint)) { - handleAutoMode(genesisHash, !!checkForNewOne).catch(console.error); - } - - // Connect to a WebSocket endpoint if provided - if (endpoint.startsWith('wss')) { - connectToEndpoint(endpoint).catch(console.error); - } - - // Connect to a light client endpoint if provided - if (endpoint.startsWith('light')) { - LCConnector(endpoint).then((LCapi) => { - handleNewApi(LCapi, endpoint); - console.log('🖌️ light client connected', String(LCapi.genesisHash.toHex())); - }).catch((err) => { - console.error('📌 light client failed:', err); - }); - } - - addApiRequest(endpoint, genesisHash); - // @ts-expect-error to bypass access to private prop - }, [addApiRequest, genesisHash, checkForNewOne, connectToEndpoint, endpoint, handleAutoMode, handleNewApi, isInContext, state?.api?._options?.provider?.endpoint, state.isLoading]); + getApi(genesisHash, endpoints)?.then(setApi).catch(console.error); + }, [endpoints, genesisHash, getApi]); - return state.api; + return api; } diff --git a/packages/extension-polkagate/src/hooks/useEndpoint.ts b/packages/extension-polkagate/src/hooks/useEndpoint.ts index 8cfda3850..35c16c8d6 100644 --- a/packages/extension-polkagate/src/hooks/useEndpoint.ts +++ b/packages/extension-polkagate/src/hooks/useEndpoint.ts @@ -5,11 +5,11 @@ import type { EndpointType } from '../util/types'; import { useCallback, useEffect, useState } from 'react'; -import EndpointManager2 from '../class/endpointManager2'; -import { AUTO_MODE } from '../util/constants'; +import EndpointManager from '../class/endpointManager'; +import { AUTO_MODE_DEFAULT_ENDPOINT } from '../util/constants'; // Create a singleton EndpointManager -const endpointManager = new EndpointManager2(); +const endpointManager = new EndpointManager(); const DEFAULT_ENDPOINT = { checkForNewOne: undefined, @@ -41,12 +41,7 @@ export default function useEndpoint (genesisHash: string | null | undefined, _en // If an endpoint already saved or it should be on auto mode, then save the Auto Mode endpoint in the storage if (!savedEndpoint || endpointManager.shouldBeOnAutoMode(savedEndpoint)) { - endpointManager.set(genesisHash, { - checkForNewOne: false, - endpoint: AUTO_MODE.value, - isAuto: true, - timestamp: Date.now() - }); + endpointManager.set(genesisHash, { ...AUTO_MODE_DEFAULT_ENDPOINT, timestamp: Date.now() }); } } diff --git a/packages/extension-polkagate/src/util/constants.ts b/packages/extension-polkagate/src/util/constants.ts index d68ec6815..ebed84585 100644 --- a/packages/extension-polkagate/src/util/constants.ts +++ b/packages/extension-polkagate/src/util/constants.ts @@ -270,6 +270,13 @@ export const AUTO_MODE = { value: 'AutoMode' }; +export const AUTO_MODE_DEFAULT_ENDPOINT = { + checkForNewOne: false, + endpoint: AUTO_MODE.value, + isAuto: true, + timestamp: Date.now() +}; + export const KODADOT_URL = 'https://kodadot.xyz'; export const DEMO_ACCOUNT = '1ChFWeNRLarAPRCTM3bfJmncJbSAbSS9yqjueWz7jX7iTVZ'; diff --git a/packages/extension-polkagate/src/util/getApi.ts b/packages/extension-polkagate/src/util/getApi.ts index 63e6de2fa..740d597f3 100644 --- a/packages/extension-polkagate/src/util/getApi.ts +++ b/packages/extension-polkagate/src/util/getApi.ts @@ -1,14 +1,11 @@ // Copyright 2019-2025 @polkadot/extension-polkagate authors & contributors // SPDX-License-Identifier: Apache-2.0 -// @ts-nocheck - -/* eslint-disable header/header */ import { ApiPromise, WsProvider } from '@polkadot/api'; import LCConnector from './api/lightClient-connect'; -async function getApi(endpoint: string): Promise { +async function getApi (endpoint: string): Promise { if (endpoint.startsWith('wss')) { const wsProvider = new WsProvider(endpoint); diff --git a/packages/extension-polkagate/src/util/types.ts b/packages/extension-polkagate/src/util/types.ts index 6de3ced50..39678e90e 100644 --- a/packages/extension-polkagate/src/util/types.ts +++ b/packages/extension-polkagate/src/util/types.ts @@ -805,14 +805,13 @@ export interface ApiProps extends ApiState { export interface ApiPropsNew { api?: ApiPromise; endpoint: string; - isRequested: boolean; } export type APIs = Record; export interface APIsContext { apis: APIs; - setIt: (apis: APIs) => void; + getApi: (genesisHash: string | null | undefined, endpoints: DropdownOption[]) => Promise; } export interface LatestRefs { diff --git a/packages/extension-polkagate/src/util/workers/getValidatorsInfo.js b/packages/extension-polkagate/src/util/workers/getValidatorsInfo.js index 8f4688530..7c3ef9560 100644 --- a/packages/extension-polkagate/src/util/workers/getValidatorsInfo.js +++ b/packages/extension-polkagate/src/util/workers/getValidatorsInfo.js @@ -1,24 +1,31 @@ // Copyright 2019-2025 @polkadot/extension-polkagate authors & contributors // SPDX-License-Identifier: Apache-2.0 -// @ts-nocheck -/* eslint-disable header/header */ -// @ts-nocheck +import getApi from '../getApi'; -import getApi from '../getApi.ts'; - -async function getAllValidators(endpoint) { +/** + * @param {string} endpoint + */ +async function getAllValidators (endpoint) { console.log('getting validators info from ', endpoint); try { const api = await getApi(endpoint); + if (!api) { + console.error(' Something went wrong while setting a connection.'); + + return null; + } + + const infoProps = { withController: true, withDestination: true, withExposure: true, withLedger: true, withNominations: true, withPrefs: true }; + const at = await api.rpc.chain.getFinalizedHead(); const apiAt = await api.at(at); const [elected, waiting, currentEra] = await Promise.all([ - api.derive.staking.electedInfo({ withController: true, withDestination: true, withExposure: true, withPrefs: true, withNominations: true, withLedger: true }), - api.derive.staking.waitingInfo({ withController: true, withDestination: true, withExposure: true, withPrefs: true, withNominations: true, withLedger: true }), - apiAt.query.staking.currentEra() + api.derive.staking.electedInfo(infoProps), + api.derive.staking.waitingInfo(infoProps), + apiAt.query['staking']['currentEra']() ]); const nextElectedInfo = elected.info.filter((e) => elected.nextElected.find((n) => @@ -40,8 +47,9 @@ async function getAllValidators(endpoint) { onmessage = (e) => { const { endpoint } = e.data; - // eslint-disable-next-line no-void - void getAllValidators(endpoint).then((info) => { - postMessage(info); - }); + getAllValidators(endpoint) + .then((info) => { + postMessage(info); + }) + .catch(console.error); }; diff --git a/packages/extension-ui/src/Popup/contexts/ApiProvider.tsx b/packages/extension-ui/src/Popup/contexts/ApiProvider.tsx index f28503710..e9cf13836 100644 --- a/packages/extension-ui/src/Popup/contexts/ApiProvider.tsx +++ b/packages/extension-ui/src/Popup/contexts/ApiProvider.tsx @@ -1,21 +1,279 @@ // Copyright 2019-2025 @polkadot/extension-polkagate authors & contributors // SPDX-License-Identifier: Apache-2.0 -import type { APIs } from '@polkadot/extension-polkagate/util/types'; +import type { APIs, DropdownOption, EndpointType } from '@polkadot/extension-polkagate/src/util/types'; -import React, { useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { ApiPromise, WsProvider } from '@polkadot/api'; +import EndpointManager from '@polkadot/extension-polkagate/src/class/endpointManager'; import { APIContext } from '@polkadot/extension-polkagate/src/components/contexts'; +import { fastestConnection } from '@polkadot/extension-polkagate/src/util'; +import LCConnector from '@polkadot/extension-polkagate/src/util/api/lightClient-connect'; +import { AUTO_MODE, AUTO_MODE_DEFAULT_ENDPOINT } from '@polkadot/extension-polkagate/src/util/constants'; -export default function ApiProvider({ children }: { children: React.ReactNode }) { +const isAutoMode = (e: string) => e === AUTO_MODE.value; + +const endpointManager = new EndpointManager(); + +/** + * This component centralizes connection logic, caching, automatic endpoint selection, and request deduplication. + * + * @remarks + * Behavior and responsibilities: + * - Maintains a mapping of connected or previously created ApiPromise instances per genesis hash (state: `apis`). + * - Exposes a `getApi` function that returns a Promise resolving to an ApiPromise (or undefined). + * - If a connected ApiPromise already exists for the requested genesisHash, it is returned immediately. + * - If a connection is in-flight, multiple callers share the same pending Promise. + * - If no connection exists, a new pending Promise is created and a connection attempt is started. + * + * - Keeps a queue of requested endpoints per genesis hash to avoid duplicate connection attempts (`requestedQueue`). + * + * - Tracks pending connection resolvers per genesis hash so all awaiting callers are resolved once a connection completes. + * + * - Initializes an EndpointManager instance asynchronously on mount and exposes it via internal state. + * + * - Supports "auto" mode endpoints (determined by `isAutoMode`) where the best/wss endpoint is chosen via `fastestConnection`. + * - When auto mode is requested and no auto-mode ApiPromise exists for the genesis hash, it triggers `handleAutoMode` + * to resolve/select an endpoint and create a connection. + * + * - When a new ApiPromise is created successfully, `handleNewApi`: + * - normalizes and stores the ApiPromise in the `apis` map (removing duplicates/endpoints as needed), + * - optionally clears other auto-mode endpoints if a new auto-mode API is added, + * - resolves any pending connection Promises for the corresponding genesis hash. + * + * - On connection errors, pending requests for the affected genesis hash are resolved with `undefined`. + * + * Implementation details: + * - Uses refs (`apisRef`, `requestedQueue`, `pendingConnections`) to safely read/write mutable data in async callbacks + * without causing excessive re-renders or stale closures. + * + * - Uses `useEffect` to synchronize the `apisRef` with state and to initialize the EndpointManager singleton. + * + * - All public-facing asynchronous operations (like `getApi`) depend on the EndpointManager being initialized; + * callers receive `undefined` if the manager isn't ready or the genesisHash is falsy. + * + * Notes and caveats: + * - Consumers should expect that getApi can resolve to undefined if no endpoint is available, the manager is not ready, + * or a connection failed. + * + * - This provider assumes endpoints returned from `useEndpoints(genesis)` include wss endpoints to be used for connections. + * + * - The component intentionally deduplicates concurrent requests for the same genesis hash/endpoint to avoid multiple + * identical connections. + */ +export default function ApiProvider ({ children }: { children: React.ReactNode }) { const [apis, setApis] = useState({}); - const updateApis = React.useCallback((change: APIs) => { - setApis(change); + const requestedQueue = useRef>({}); + // Store pending promises for each genesisHash + const pendingConnections = useRef< + Record; + resolve(api: ApiPromise | undefined): void; + }>> + >({}); + + const apisRef = useRef({}); + + // Keep ref in sync with state + useEffect(() => { + apisRef.current = apis; + }, [apis]); + + const updateEndpoint = useCallback((genesisHash: string, selectedEndpoint: string, cbFunction?: () => void) => { + try { + const newEndpoint = { + checkForNewOne: false, + endpoint: selectedEndpoint, + isAuto: true, + timestamp: Date.now() + }; + + endpointManager.set(genesisHash, newEndpoint); + cbFunction?.(); + } catch (error) { + console.error(error); + } + }, []); + + // Resolve all pending promises for this genesisHash + const resolvePendingConnections = useCallback((genesisHash: string, api: ApiPromise | undefined, endpoint: string | undefined) => { + const pending = endpoint && pendingConnections.current[genesisHash][endpoint]; + + if (pending) { + pending.resolve(api); + delete pendingConnections.current[genesisHash][endpoint]; + + // If there are no more pending endpoints for this genesisHash, remove the empty object + if (pendingConnections.current[genesisHash] && Object.keys(pendingConnections.current[genesisHash]).length === 0) { + delete pendingConnections.current[genesisHash]; + } + } + + // Clear request mark for this endpoint + const rq = requestedQueue.current[genesisHash]; + + if (rq) { + const filtered = rq.filter((e) => e !== endpoint); + + filtered.length ? (requestedQueue.current[genesisHash] = filtered) : delete requestedQueue.current[genesisHash]; + } }, []); + const handleNewApi = useCallback((api: ApiPromise, endpoint: string, onAutoMode?: boolean) => { + const genesisHash = String(api.genesisHash.toHex()); + + setApis((prevApis) => { + let toSaveApi = prevApis[genesisHash] ?? []; + + toSaveApi = toSaveApi.filter((sApi) => sApi.endpoint !== endpoint); + + toSaveApi.push({ + api, + endpoint + }); + + return { + ...prevApis, + [genesisHash]: toSaveApi + }; + }); + + // Resolve all waiting promises + resolvePendingConnections(genesisHash, api, onAutoMode ? AUTO_MODE.value : endpoint); + }, [resolvePendingConnections]); + + const handleAutoMode = useCallback(async (genesisHash: string, endpoints: DropdownOption[]) => { + const wssEndpoints = endpoints.filter(({ value }) => String(value).startsWith('wss')); + + const { api, selectedEndpoint } = await fastestConnection(wssEndpoints); + + if (!api || !selectedEndpoint) { + resolvePendingConnections(genesisHash, undefined, AUTO_MODE.value); + + return; + } + + updateEndpoint(genesisHash, selectedEndpoint, () => handleNewApi(api, selectedEndpoint, true)); + }, [handleNewApi, updateEndpoint, resolvePendingConnections]); + + const connectToEndpoint = useCallback(async (genesisHash: string, endpointToConnect: string) => { + try { + const wsProvider = new WsProvider(endpointToConnect); + const newApi = await ApiPromise.create({ provider: wsProvider }); + + handleNewApi(newApi, endpointToConnect); + } catch (error) { + console.error('Connection error:', error); + // Resolve pending with undefined on error + resolvePendingConnections(genesisHash, undefined, endpointToConnect); + } + }, [handleNewApi, resolvePendingConnections]); + + const connectToLightClient = useCallback(async (genesisHash: string, endpointToConnect: string) => { + try { + const newApi = await LCConnector(endpointToConnect); + + handleNewApi(newApi, endpointToConnect); + } catch (error) { + console.error('Connection error:', error); + // Resolve pending with undefined on error + resolvePendingConnections(genesisHash, undefined, endpointToConnect); + } + }, [handleNewApi, resolvePendingConnections]); + + const requestApiConnection = useCallback((genesisHash: string, endpoint: EndpointType | undefined, endpoints: DropdownOption[]) => { + const endpointValue = endpoint?.endpoint; + + if (!endpointValue || !endpointManager) { + return; + } + + const isAlreadyRequested = requestedQueue.current[genesisHash]?.includes(endpointValue); + + if (isAlreadyRequested) { + return; + } + + // Mark as requested + (requestedQueue.current[genesisHash] ??= []).push(endpointValue); + + // If in auto mode find the fastest endpoint + if (isAutoMode(endpointValue)) { + handleAutoMode(genesisHash, endpoints).catch(console.error); + + return; + } + + // Connect to a WebSocket endpoint + if (endpointValue.startsWith('wss')) { + connectToEndpoint(genesisHash, endpointValue).catch(console.error); + } + + // Connect to a light client endpoint if provided + if (endpointValue.startsWith('light')) { + connectToLightClient(genesisHash, endpointValue).catch(console.error); + } + }, [connectToEndpoint, connectToLightClient, handleAutoMode]); + + const getApi = useCallback(async (genesisHash: string | null | undefined, endpoints: DropdownOption[]): Promise => { + if (!genesisHash) { + return Promise.resolve(undefined); + } + + let endpoint = endpointManager.get(genesisHash); + + if (!endpoint) { + endpoint = { ...AUTO_MODE_DEFAULT_ENDPOINT, timestamp: Date.now() }; + + endpointManager.set(genesisHash, endpoint); + } + + const endpointValue = endpoint.endpoint; + + if (!endpoint || !endpointValue) { + console.warn('No endpoint found for', genesisHash); + + return Promise.resolve(undefined); + } + + // Check if API already exists and is connected + const apiList = apisRef.current[genesisHash]; + const availableApi = apiList?.find(({ api, endpoint }) => api?.isConnected && endpoint === endpointValue)?.api; + + if (availableApi) { + return Promise.resolve(availableApi); + } + + // Check if connection is already pending + if (pendingConnections.current[genesisHash]?.[endpointValue]) { + // Return existing promise + return pendingConnections.current[genesisHash][endpointValue].promise; + } + + // Create new promise for this connection + // initialize resolvePromise with a noop so it's always defined for assignment below, + // then overwrite it synchronously inside the Promise executor. + let resolvePromise: (api: ApiPromise | undefined) => void = () => undefined; + + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + (pendingConnections.current[genesisHash] ??= {})[endpointValue] = { + promise, + resolve: resolvePromise + }; + + // Start connection + requestApiConnection(genesisHash, endpoint, endpoints); + + return promise; + }, [requestApiConnection]); + return ( - + {children} );