diff --git a/src/canvas/JoinPool/index.tsx b/src/canvas/JoinPool/index.tsx index 6f08abadd1..8500a588d0 100644 --- a/src/canvas/JoinPool/index.tsx +++ b/src/canvas/JoinPool/index.tsx @@ -60,7 +60,7 @@ export const JoinPool = () => { rewardPoints.every((points) => Number(points) > 0) && rewardPoints.length === MaxEraRewardPointsEras; - return pool.state === 'Open' && activeDaily; + return activeDaily; }) // Ensure the pool is currently in the active set of backers. .filter((pool) => diff --git a/src/contexts/Balances/index.tsx b/src/contexts/Balances/index.tsx index ca7c16dc7f..e5bbc3f7dc 100644 --- a/src/contexts/Balances/index.tsx +++ b/src/contexts/Balances/index.tsx @@ -14,6 +14,9 @@ import { useActiveAccounts } from 'contexts/ActiveAccounts'; import { useActiveBalances } from 'hooks/useActiveBalances'; import { useBonded } from 'contexts/Bonded'; import { SyncController } from 'controllers/SyncController'; +import { useApi } from 'contexts/Api'; +import { ActivePoolsController } from 'controllers/ActivePoolsController'; +import { useCreatePoolAccounts } from 'hooks/useCreatePoolAccounts'; export const BalancesContext = createContext( defaults.defaultBalancesContext @@ -22,8 +25,10 @@ export const BalancesContext = createContext( export const useBalances = () => useContext(BalancesContext); export const BalancesProvider = ({ children }: { children: ReactNode }) => { + const { api } = useApi(); const { getBondedAccount } = useBonded(); const { accounts } = useImportedAccounts(); + const createPoolAccounts = useCreatePoolAccounts(); const { activeAccount, activeProxy } = useActiveAccounts(); const controller = getBondedAccount(activeAccount); @@ -46,9 +51,24 @@ export const BalancesProvider = ({ children }: { children: ReactNode }) => { isCustomEvent(e) && BalancesController.isValidNewAccountBalanceEvent(e) ) { - // Update whether all account balances have been synced. Uses greater than to account for - // possible errors on the API side. + // Update whether all account balances have been synced. checkBalancesSynced(); + + const { address, ...newBalances } = e.detail; + const { poolMembership } = newBalances; + + // If a pool membership exists, let `ActivePools` know of pool membership to re-sync pool + // details and nominations. + if (api && poolMembership) { + const { poolId } = poolMembership; + const newPools = ActivePoolsController.getformattedPoolItems( + address + ).concat({ + id: String(poolId), + addresses: { ...createPoolAccounts(Number(poolId)) }, + }); + ActivePoolsController.syncPools(api, address, newPools); + } } }; diff --git a/src/contexts/Pools/ActivePool/index.tsx b/src/contexts/Pools/ActivePool/index.tsx index 6d360ce834..ed30bd33b0 100644 --- a/src/contexts/Pools/ActivePool/index.tsx +++ b/src/contexts/Pools/ActivePool/index.tsx @@ -68,35 +68,34 @@ export const ActivePoolProvider = ({ children }: { children: ReactNode }) => { setStateWithRef(id, setActivePoolIdState, activePoolIdRef); }; - // Only listen to the currently selected active pool, otherwise return an empty array. - const poolIds = activePoolIdRef.current ? [activePoolIdRef.current] : []; - - // Listen for active pools. NOTE: `activePoolsRef` is needed to check if the pool has changed - // after the async call of fetching pending rewards. - const { activePools, activePoolsRef, poolNominations } = useActivePools({ - poolIds, - onCallback: async () => { - // Sync: active pools synced once all account pools have been reported. - if (accountPoolIds.length <= ActivePoolsController.pools.length) { - SyncController.dispatch('active-pools', 'complete'); - } - }, - }); + // Only listen to the active account's active pools, otherwise return an empty array. NOTE: + // `activePoolsRef` is needed to check if the pool has changed after the async call of fetching + // pending rewards. + const { getActivePools, activePoolsRef, getPoolNominations } = useActivePools( + { + who: activeAccount, + onCallback: async () => { + // Sync: active pools synced once all account pools have been reported. + if ( + accountPoolIds.length <= + ActivePoolsController.getPools(activeAccount).length + ) { + SyncController.dispatch('active-pools', 'complete'); + } + }, + } + ); // Store the currently active pool's pending rewards for the active account. const [pendingPoolRewards, setPendingPoolRewards] = useState( new BigNumber(0) ); - const activePool = - activePoolId && activePools[activePoolId] - ? activePools[activePoolId] - : null; + const activePool = activePoolId ? getActivePools(activePoolId) : null; - const activePoolNominations = - activePoolId && poolNominations[activePoolId] - ? poolNominations[activePoolId] - : null; + const activePoolNominations = activePoolId + ? getPoolNominations(activePoolId) + : null; // Sync active pool subscriptions. const syncActivePoolSubscriptions = async () => { @@ -105,7 +104,9 @@ export const ActivePoolProvider = ({ children }: { children: ReactNode }) => { id: pool, addresses: { ...createPoolAccounts(Number(pool)) }, })); - ActivePoolsController.syncPools(api, newActivePools); + + SyncController.dispatch('active-pools', 'syncing'); + ActivePoolsController.syncPools(api, activeAccount, newActivePools); } else { // No active pools to sync. Mark as complete. SyncController.dispatch('active-pools', 'complete'); diff --git a/src/contexts/Pools/JoinPools/index.tsx b/src/contexts/Pools/JoinPools/index.tsx index 148e41fa35..73371e7580 100644 --- a/src/contexts/Pools/JoinPools/index.tsx +++ b/src/contexts/Pools/JoinPools/index.tsx @@ -56,8 +56,8 @@ export const JoinPoolsProvider = ({ children }: { children: ReactNode }) => { ({ state }) => state === 'Open' ); - // Filter pools that do not have at least double the minimum active stake in points. NOTE: - // assumes that points are a 1:1 ratio between balance and points. + // Filter pools that do not have at least double the minimum stake to earn rewards, in points. + // NOTE: assumes that points are a 1:1 ratio between balance and points. const rewardBondedPools = activeBondedPools.filter(({ points }) => { const pointsBn = new BigNumber(rmCommas(points)); const threshold = minimumActiveStake.multipliedBy(2); diff --git a/src/contexts/Staking/index.tsx b/src/contexts/Staking/index.tsx index 25885e6bf8..cb061c5adc 100644 --- a/src/contexts/Staking/index.tsx +++ b/src/contexts/Staking/index.tsx @@ -313,7 +313,7 @@ export const StakingProvider = ({ children }: { children: ReactNode }) => { } }, [apiStatus]); - // handle syncing with eraStakers + // handle syncing with eraStakers. useEffectIgnoreInitial(() => { if (isReady) { fetchActiveEraStakers(); diff --git a/src/controllers/ActivePoolsController/index.ts b/src/controllers/ActivePoolsController/index.ts index cb1a5d023c..edba26acdc 100644 --- a/src/controllers/ActivePoolsController/index.ts +++ b/src/controllers/ActivePoolsController/index.ts @@ -5,8 +5,14 @@ import type { VoidFn } from '@polkadot/api/types'; import { defaultPoolNominations } from 'contexts/Pools/ActivePool/defaults'; import type { ActivePool, PoolRoles } from 'contexts/Pools/ActivePool/types'; import { IdentitiesController } from 'controllers/IdentitiesController'; -import type { AnyApi } from 'types'; -import type { ActivePoolItem, DetailActivePool } from './types'; +import type { AnyApi, MaybeAddress } from 'types'; +import type { + AccountActivePools, + AccountPoolNominations, + AccountUnsubs, + ActivePoolItem, + DetailActivePool, +} from './types'; import { SyncController } from 'controllers/SyncController'; import type { Nominations } from 'contexts/Balances/types'; import type { ApiPromise } from '@polkadot/api'; @@ -16,17 +22,18 @@ export class ActivePoolsController { // Class members. // ------------------------------------------------------ - // Pool ids that are being subscribed to. - static pools: ActivePoolItem[] = []; + // Pool ids that are being subscribed to. Keyed by address. + static pools: Record = {}; - // Active pools that are being returned from subscriptions, keyed by pool id. - static activePools: Record = {}; + // Active pools that are being returned from subscriptions, keyed by account address, then pool + // id. + static activePools: Record = {}; - // Active pool nominations, keyed by pool id. - static poolNominations: Record = {}; + // Active pool nominations, keyed by account address, then pool id. + static poolNominations: Record = {}; - // Unsubscribe objects. - static #unsubs: Record = {}; + // Unsubscribe objects, keyed by account address, then pool id. + static #unsubs: Record = {}; // ------------------------------------------------------ // Pool membership syncing. @@ -35,23 +42,27 @@ export class ActivePoolsController { // Subscribes to pools and unsubscribes from removed pools. static syncPools = async ( api: ApiPromise, + address: MaybeAddress, newPools: ActivePoolItem[] ): Promise => { - // Sync: Checking active pools. - SyncController.dispatch('active-pools', 'syncing'); + if (!address) { + return; + } // Handle pools that have been removed. - this.handleRemovedPools(newPools); + this.handleRemovedPools(address, newPools); + + const currentPools = this.getPools(address); // Determine new pools that need to be subscribed to. const poolsAdded = newPools.filter( - (newPool) => !this.pools.find((pool) => pool.id === newPool.id) + (newPool) => !currentPools.find(({ id }) => id === newPool.id) ); if (poolsAdded.length) { // Subscribe to and add new pool data. poolsAdded.forEach(async (pool) => { - this.pools.push(pool); + this.pools[address] = currentPools.concat(pool); const unsub = await api.queryMulti( [ @@ -69,26 +80,31 @@ export class ActivePoolsController { // NOTE: async: fetches identity data for roles. await this.handleActivePoolCallback( api, + address, pool, bondedPool, rewardPool, accountData ); - this.handleNominatorsCallback(pool, nominators); + this.handleNominatorsCallback(address, pool, nominators); - if (this.activePools[pool.id] && this.poolNominations[pool.id]) { + if ( + this.activePools?.[address]?.[pool.id] && + this.poolNominations?.[address]?.[pool.id] + ) { document.dispatchEvent( new CustomEvent('new-active-pool', { detail: { - pool: this.activePools[pool.id], - nominations: this.poolNominations[pool.id], + address, + pool: this.activePools[address][pool.id], + nominations: this.poolNominations[address][pool.id], }, }) ); } } ); - this.#unsubs[pool.id] = unsub; + this.setUnsub(address, pool.id, unsub); }); } else { // Status: Pools Synced Completed. @@ -99,6 +115,7 @@ export class ActivePoolsController { // Handle active pool callback. static handleActivePoolCallback = async ( api: ApiPromise, + address: string, pool: ActivePoolItem, bondedPoolResult: AnyApi, rewardPoolResult: AnyApi, @@ -126,15 +143,16 @@ export class ActivePoolsController { rewardAccountBalance, }; - this.activePools[pool.id] = newPool; + this.setActivePool(address, pool.id, newPool); } else { // Invalid pools were returned. To signal pool was synced, set active pool to `null`. - this.activePools[pool.id] = null; + this.setActivePool(address, pool.id, null); } }; // Handle nominators callback. static handleNominatorsCallback = ( + address: string, pool: ActivePoolItem, nominatorsResult: AnyApi ): void => { @@ -148,28 +166,50 @@ export class ActivePoolsController { submittedIn: maybeNewNominations.submittedIn.toHuman(), }; - this.poolNominations[pool.id] = newNominations; + this.setPoolNominations(address, pool.id, newNominations); }; // Remove pools that no longer exist. - static handleRemovedPools = (newPools: ActivePoolItem[]): void => { + static handleRemovedPools = ( + address: string, + newPools: ActivePoolItem[] + ): void => { + const currentPools = this.getPools(address); + // Determine removed pools - current ones that no longer exist in `newPools`. - const poolsRemoved = this.pools.filter( + const poolsRemoved = currentPools.filter( (pool) => !newPools.find((newPool) => newPool.id === pool.id) ); // Unsubscribe from removed pool subscriptions. poolsRemoved.forEach((pool) => { - if (this.#unsubs[pool.id]) { - this.#unsubs[pool.id](); + if (this.#unsubs?.[address]?.[pool.id]) { + this.#unsubs[address][pool.id](); } - delete this.#unsubs[pool.id]; - delete this.activePools[pool.id]; - delete this.poolNominations[pool.id]; + delete this.activePools[address][pool.id]; + delete this.poolNominations[address][pool.id]; }); // Remove removed pools from class. - this.pools = this.pools.filter((pool) => !poolsRemoved.includes(pool)); + this.pools[address] = currentPools.filter( + (pool) => !poolsRemoved.includes(pool) + ); + + // Tidy up empty class state. + if (!this.pools[address].length) { + delete this.pools[address]; + } + + if (!this.activePools[address]) { + delete this.activePools[address]; + } + + if (!this.poolNominations[address]) { + delete this.poolNominations[address]; + } + if (!this.#unsubs[address]) { + delete this.#unsubs[address]; + } }; // ------------------------------------------------------ @@ -178,22 +218,51 @@ export class ActivePoolsController { // Unsubscribe from all subscriptions and reset class members. static unsubscribe = (): void => { - Object.values(this.#unsubs).forEach((unsub) => { - unsub(); + Object.values(this.#unsubs).forEach((accountUnsubs) => { + Object.values(accountUnsubs).forEach((unsub) => { + unsub(); + }); }); + this.#unsubs = {}; }; static resetState = (): void => { - this.pools = []; + this.pools = {}; this.activePools = {}; this.poolNominations = {}; }; // ------------------------------------------------------ - // Class helpers. + // Getters. // ------------------------------------------------------ + // Gets pools for a provided address. + static getPools = (address: MaybeAddress): ActivePoolItem[] => { + if (!address) { + return []; + } + return this.pools?.[address] || []; + }; + + // Gets active pools for a provided address. + static getActivePools = (address: MaybeAddress): AccountActivePools => { + if (!address) { + return {}; + } + return this.activePools?.[address] || {}; + }; + + // Gets active pool nominations for a provided address. + static getPoolNominations = ( + address: MaybeAddress + ): AccountPoolNominations => { + if (!address) { + return {}; + } + return this.poolNominations?.[address] || {}; + }; + // Gets unique role addresses from a bonded pool's `roles` record. static getUniqueRoleAddresses = (roles: PoolRoles): string[] => { const roleAddresses: string[] = [ @@ -202,9 +271,65 @@ export class ActivePoolsController { return roleAddresses; }; + // ------------------------------------------------------ + // Setters. + // ------------------------------------------------------ + + // Set an active pool for an address. + static setActivePool = ( + address: string, + poolId: string, + activePool: ActivePool | null + ): void => { + if (!this.activePools[address]) { + this.activePools[address] = {}; + } + this.activePools[address][poolId] = activePool; + }; + + // Set pool nominations for an address. + static setPoolNominations = ( + address: string, + poolId: string, + nominations: Nominations + ): void => { + if (!this.poolNominations[address]) { + this.poolNominations[address] = {}; + } + this.poolNominations[address][poolId] = nominations; + }; + + // Set unsub for an address and pool id. + static setUnsub = (address: string, poolId: string, unsub: VoidFn): void => { + if (!this.#unsubs[address]) { + this.#unsubs[address] = {}; + } + this.#unsubs[address][poolId] = unsub; + }; + + // ------------------------------------------------------ + // Class helpers. + // ------------------------------------------------------ + + // Format pools into active pool items (id and addresses only). + static getformattedPoolItems = (address: MaybeAddress): ActivePoolItem[] => { + if (!address) { + return []; + } + return ( + this.pools?.[address]?.map(({ id, addresses }) => ({ + id: id.toString(), + addresses, + })) || [] + ); + }; + // Checks if event detailis a valid `new-active-pool` event. static isValidNewActivePool = ( event: CustomEvent ): event is CustomEvent => - event.detail && event.detail.pool && event.detail.nominations; + event.detail && + event.detail.address && + event.detail.pool && + event.detail.nominations; } diff --git a/src/controllers/ActivePoolsController/types.ts b/src/controllers/ActivePoolsController/types.ts index 4ffe7b691f..2108f15062 100644 --- a/src/controllers/ActivePoolsController/types.ts +++ b/src/controllers/ActivePoolsController/types.ts @@ -1,10 +1,12 @@ // Copyright 2024 @paritytech/polkadot-staking-dashboard authors & contributors // SPDX-License-Identifier: GPL-3.0-only +import type { VoidFn } from '@polkadot/api/types'; import type { Nominations } from 'contexts/Balances/types'; import type { ActivePool } from 'contexts/Pools/ActivePool/types'; export interface DetailActivePool { + address: string; pool: ActivePool; nominations: Nominations; } @@ -16,3 +18,9 @@ export interface ActivePoolItem { reward: string; }; } + +export type AccountActivePools = Record; + +export type AccountPoolNominations = Record; + +export type AccountUnsubs = Record; diff --git a/src/hooks/useActivePools/index.tsx b/src/hooks/useActivePools/index.tsx index 02a80374ce..4ba1508847 100644 --- a/src/hooks/useActivePools/index.tsx +++ b/src/hooks/useActivePools/index.tsx @@ -11,33 +11,32 @@ import type { ActivePoolsProps, ActivePoolsState, } from './types'; -import { useActiveAccounts } from 'contexts/ActiveAccounts'; import { useNetwork } from 'contexts/Network'; -export const useActivePools = ({ onCallback, poolIds }: ActivePoolsProps) => { +export const useActivePools = ({ onCallback, who }: ActivePoolsProps) => { const { network } = useNetwork(); - const { activeAccount } = useActiveAccounts(); // Stores active pools. - const [activePools, setActivePools] = useState({}); + const [activePools, setActivePools] = useState( + ActivePoolsController.getActivePools(who) + ); const activePoolsRef = useRef(activePools); // Store nominations of active pools. const [poolNominations, setPoolNominations] = - useState({}); + useState( + ActivePoolsController.getPoolNominations(who) + ); const poolNominationsRef = useRef(poolNominations); // Handle report of new active pool data. const newActivePoolCallback = async (e: Event) => { if (isCustomEvent(e) && ActivePoolsController.isValidNewActivePool(e)) { - const { pool, nominations } = e.detail; + const { address, pool, nominations } = e.detail; const { id } = pool; - // Persist to active pools state if this pool is specified in `poolIds`. - if ( - poolIds === '*' || - (Array.isArray(poolIds) && poolIds.includes(String(id))) - ) { + // Persist to active pools state for the specified account. + if (address === who) { const newActivePools = { ...activePoolsRef.current }; newActivePools[id] = pool; setStateWithRef(newActivePools, setActivePools, activePoolsRef); @@ -58,42 +57,41 @@ export const useActivePools = ({ onCallback, poolIds }: ActivePoolsProps) => { } }; - // Bootstrap state on initial render. + // Get an active pool. + const getActivePools = (poolId: string) => activePools?.[poolId] || null; + + // Get an active pool's nominations. + const getPoolNominations = (poolId: string) => + poolNominations?.[poolId] || null; + + // Reset state on network change. useEffect(() => { - const initialActivePools = - poolIds === '*' - ? ActivePoolsController.activePools - : Object.fromEntries( - Object.entries(ActivePoolsController.activePools).filter(([key]) => - poolIds.includes(key) - ) - ); - setStateWithRef(initialActivePools || {}, setActivePools, activePoolsRef); + setStateWithRef({}, setActivePools, activePoolsRef); + setStateWithRef({}, setPoolNominations, poolNominationsRef); + }, [network]); - const initialPoolNominations = - poolIds === '*' - ? ActivePoolsController.poolNominations - : Object.fromEntries( - Object.entries(ActivePoolsController.poolNominations).filter( - ([key]) => poolIds.includes(key) - ) - ); + // Update state on account change. + useEffect(() => { setStateWithRef( - initialPoolNominations, + ActivePoolsController.getActivePools(who), + setActivePools, + activePoolsRef + ); + setStateWithRef( + ActivePoolsController.getPoolNominations(who), setPoolNominations, poolNominationsRef ); - }, [JSON.stringify(poolIds)]); - - // Reset state on active account or network change. - useEffect(() => { - setStateWithRef({}, setActivePools, activePoolsRef); - setStateWithRef({}, setPoolNominations, poolNominationsRef); - }, [network, activeAccount]); + }, [who]); // Listen for new active pool events. const documentRef = useRef(document); useEventListener('new-active-pool', newActivePoolCallback, documentRef); - return { activePools, activePoolsRef, poolNominations }; + return { + activePools, + activePoolsRef, + getActivePools, + getPoolNominations, + }; }; diff --git a/src/hooks/useActivePools/types.ts b/src/hooks/useActivePools/types.ts index 84d5d82e8d..c0eaa31af2 100644 --- a/src/hooks/useActivePools/types.ts +++ b/src/hooks/useActivePools/types.ts @@ -4,9 +4,10 @@ import type { Nominations } from 'contexts/Balances/types'; import type { ActivePool } from 'contexts/Pools/ActivePool/types'; import type { DetailActivePool } from 'controllers/ActivePoolsController/types'; +import type { MaybeAddress } from 'types'; export interface ActivePoolsProps { - poolIds: string[] | '*'; + who: MaybeAddress; onCallback?: (detail: DetailActivePool) => Promise; } diff --git a/src/library/Nominations/index.tsx b/src/library/Nominations/index.tsx index 5fd5f5e2c2..07ed808a14 100644 --- a/src/library/Nominations/index.tsx +++ b/src/library/Nominations/index.tsx @@ -41,7 +41,7 @@ export const Nominations = ({ modal: { openModal }, canvas: { openCanvas }, } = useOverlay(); - const { syncing } = useSyncing(); + const { syncing } = useSyncing(['balances', 'era-stakers']); const { getNominations } = useBalances(); const { isFastUnstaking } = useUnstaking(); const { formatWithPrefs } = useValidators(); @@ -77,7 +77,7 @@ export const Nominations = ({ // Determine whether buttons are disabled. const btnsDisabled = (!isPool && inSetup()) || - syncing || + (!isPool && syncing) || isReadOnlyAccount(activeAccount) || poolDestroying || isFastUnstaking; @@ -131,7 +131,7 @@ export const Nominations = ({ )} - {syncing ? ( + {!isPool && syncing ? ( {`${t('nominate.syncing')}...`} ) : !nominator ? ( {t('nominate.notNominating')}. diff --git a/src/modals/AccountPoolRoles/index.tsx b/src/modals/AccountPoolRoles/index.tsx index a9a65f77f1..a1f36b1cb8 100644 --- a/src/modals/AccountPoolRoles/index.tsx +++ b/src/modals/AccountPoolRoles/index.tsx @@ -43,7 +43,7 @@ export const AccountPoolRoles = () => { )}

{t('activeRoles', { - count: activePools?.length || 0, + count: Object.keys(activePools)?.length || 0, })}

diff --git a/src/pages/Pools/Home/ManagePool/index.tsx b/src/pages/Pools/Home/ManagePool/index.tsx index a588e32963..03adf2dbb9 100644 --- a/src/pages/Pools/Home/ManagePool/index.tsx +++ b/src/pages/Pools/Home/ManagePool/index.tsx @@ -9,18 +9,14 @@ import { useActivePool } from 'contexts/Pools/ActivePool'; import { CardHeaderWrapper, CardWrapper } from 'library/Card/Wrappers'; import { Nominations } from 'library/Nominations'; import { useOverlay } from 'kits/Overlay/Provider'; -import { useActiveAccounts } from 'contexts/ActiveAccounts'; -import { useSyncing } from 'hooks/useSyncing'; import { useValidators } from 'contexts/Validators/ValidatorEntries'; import { ButtonHelp } from 'kits/Buttons/ButtonHelp'; import { ButtonPrimary } from 'kits/Buttons/ButtonPrimary'; export const ManagePool = () => { const { t } = useTranslation(); - const { syncing } = useSyncing(['active-pools']); const { openCanvas } = useOverlay().canvas; const { formatWithPrefs } = useValidators(); - const { activeAccount } = useActiveAccounts(); const { isOwner, isNominator, activePoolNominations, activePool } = useActivePool(); @@ -38,9 +34,7 @@ export const ManagePool = () => { return ( - {syncing ? ( - - ) : canNominate && !isNominating && state !== 'Destroying' ? ( + {canNominate && !isNominating && state !== 'Destroying' ? ( <>

diff --git a/src/pages/Pools/Home/index.tsx b/src/pages/Pools/Home/index.tsx index 9ea8201108..8d3bcf104e 100644 --- a/src/pages/Pools/Home/index.tsx +++ b/src/pages/Pools/Home/index.tsx @@ -47,7 +47,7 @@ export const HomeInner = () => { const membership = getPoolMembership(activeAccount); const { activePools } = useActivePools({ - poolIds: '*', + who: activeAccount, }); // Calculate the number of _other_ pools the user has a role in.