diff --git a/src/ui/common/api/getBsn.ts b/src/ui/common/api/getBsn.ts index 156d8eeba..84377fb4e 100644 --- a/src/ui/common/api/getBsn.ts +++ b/src/ui/common/api/getBsn.ts @@ -1,34 +1,29 @@ -import { Bsn } from "../types/bsn"; -import { getBsnLogoUrl } from "../utils/bsnLogo"; +import { BsnType } from "../types/bsn"; import { apiWrapper } from "./apiWrapper"; -interface BsnAPI { +export const BSN_TYPE_COSMOS: BsnType = "COSMOS"; +export const BSN_TYPE_ROLLUP: BsnType = "ROLLUP"; + +export interface BsnAPI { id: string; name: string; description: string; active_tvl: number; + type: BsnType; + allowlist?: string[]; // BTC FP pubkeys (hex) } interface BsnDataResponse { data: BsnAPI[]; } -const createBSN = (bsn: BsnAPI): Bsn => ({ - id: bsn.id, - name: bsn.name, - description: bsn.description, - activeTvl: bsn.active_tvl, - logoUrl: getBsnLogoUrl(bsn.id), -}); - -export const getBSNs = async (): Promise => { +export const getBsnAPI = async (): Promise => { const response = await apiWrapper( "GET", "/v2/bsn", "Error getting BSN list", ); - const { data } = response.data; - return data.map(createBSN); + return response.data.data; }; diff --git a/src/ui/common/components/Multistaking/FinalityProviderField/FinalityProviderModal.tsx b/src/ui/common/components/Multistaking/FinalityProviderField/FinalityProviderModal.tsx index 6e7af5dfc..dbbcd73fa 100644 --- a/src/ui/common/components/Multistaking/FinalityProviderField/FinalityProviderModal.tsx +++ b/src/ui/common/components/Multistaking/FinalityProviderField/FinalityProviderModal.tsx @@ -8,6 +8,7 @@ import { useState } from "react"; import { ResponsiveDialog } from "@/ui/common/components/Modals/ResponsiveDialog"; import { FinalityProviders } from "@/ui/common/components/Multistaking/FinalityProviderField/FinalityProviders"; +import { useFinalityProviderBsnState } from "@/ui/common/state/FinalityProviderBsnState"; interface Props { open: boolean; @@ -27,6 +28,7 @@ export const FinalityProviderModal = ({ onBack, }: Props) => { const [selectedFP, setSelectedFp] = useState(defaultFinalityProvider); + const { modalTitle } = useFinalityProviderBsnState(); const handleClose = () => { onClose(); @@ -36,7 +38,7 @@ export const FinalityProviderModal = ({ return ( diff --git a/src/ui/common/components/Multistaking/FinalityProviders/FinalityProviderFilter.tsx b/src/ui/common/components/Multistaking/FinalityProviders/FinalityProviderFilter.tsx index 7dc97bf11..c519d7571 100644 --- a/src/ui/common/components/Multistaking/FinalityProviders/FinalityProviderFilter.tsx +++ b/src/ui/common/components/Multistaking/FinalityProviders/FinalityProviderFilter.tsx @@ -2,21 +2,16 @@ import { Select } from "@babylonlabs-io/core-ui"; import { useFinalityProviderBsnState } from "@/ui/common/state/FinalityProviderBsnState"; -const options = [ - { value: "active", label: "Active" }, - { value: "inactive", label: "Inactive" }, -]; - export const FinalityProviderFilter = () => { - const { filter, handleFilter } = useFinalityProviderBsnState(); + const { filter, handleFilter, filterOptions } = useFinalityProviderBsnState(); return ( diff --git a/src/ui/common/hooks/client/api/useBsn.ts b/src/ui/common/hooks/client/api/useBsn.ts index 78f0a8384..efc32fbc1 100644 --- a/src/ui/common/hooks/client/api/useBsn.ts +++ b/src/ui/common/hooks/client/api/useBsn.ts @@ -1,10 +1,16 @@ -import { getBSNs } from "@/ui/common/api/getBsn"; +import { getBsnAPI } from "@/ui/common/api/getBsn"; import { ONE_MINUTE } from "@/ui/common/constants"; import { useClientQuery } from "@/ui/common/hooks/client/useClient"; +import { createBSN } from "@/ui/common/services/bsnService"; import { Bsn } from "@/ui/common/types/bsn"; export const BSN_KEY = "BSN"; +const getBSNs = async (): Promise => { + const data = await getBsnAPI(); + return data.map(createBSN); +}; + export function useBsn({ enabled = true }: { enabled?: boolean } = {}) { return useClientQuery({ queryKey: [BSN_KEY], diff --git a/src/ui/common/services/bsnService.ts b/src/ui/common/services/bsnService.ts new file mode 100644 index 000000000..1f9d49e6c --- /dev/null +++ b/src/ui/common/services/bsnService.ts @@ -0,0 +1,91 @@ +import logger from "@/infrastructure/logger"; +import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn"; + +import { Bsn, BsnType } from "../types/bsn"; +import { getBsnLogoUrl } from "../utils/bsnLogo"; + +const { chainId: BBN_CHAIN_ID } = getNetworkConfigBBN(); + +export interface BsnFilterOption { + value: string; + label: string; +} + +export interface BsnConfig { + modalTitle: string; + filterOptions: BsnFilterOption[]; +} + +export const BSN_CONFIGS: Record = { + // Babylon Genesis + [BBN_CHAIN_ID]: { + modalTitle: "Select Babylon Genesis Finality Provider", + filterOptions: [ + { value: "active", label: "Active" }, + { value: "inactive", label: "Inactive" }, + { value: "jailed", label: "Jailed" }, + { value: "slashed", label: "Slashed" }, + ], + }, + COSMOS: { + modalTitle: "Select Cosmos Finality Provider", + filterOptions: [ + { value: "registered", label: "Registered" }, + { value: "slashed", label: "Slashed" }, + ], + }, + // Roll-up BSN config + ROLLUP: { + modalTitle: "Select Roll Up Finality Provider", + filterOptions: [ + { value: "allowlisted", label: "Allow Listed" }, + { value: "non-allowlisted", label: "Non-Allow Listed" }, + { value: "slashed", label: "Slashed" }, + { value: "jailed", label: "Jailed" }, + ], + }, +}; + +/** + * Get BSN configuration based on BSN type + */ +export const getBsnConfig = (bsn?: Bsn): BsnConfig => { + if (!bsn) return BSN_CONFIGS[BBN_CHAIN_ID]; + + // Check if it's Babylon Genesis first + if (bsn.id === BBN_CHAIN_ID) { + return BSN_CONFIGS[BBN_CHAIN_ID]; + } + + // Use type-based config + const config = BSN_CONFIGS[bsn.type]; + if (!config) { + logger.error(new Error(`BSN config not found for type: ${bsn.type}`), { + tags: { service: "bsnService", function: "getBsnConfig" }, + data: { + bsnType: bsn.type, + bsnId: bsn.id, + fallback: "Babylon Genesis config", + }, + }); + return BSN_CONFIGS[BBN_CHAIN_ID]; + } + return config; +}; + +export const createBSN = (bsn: { + id: string; + name: string; + description: string; + active_tvl: number; + type: BsnType; + allowlist?: string[]; +}): Bsn => ({ + id: bsn.id, + name: bsn.name, + description: bsn.description, + activeTvl: bsn.active_tvl, + logoUrl: getBsnLogoUrl(bsn.id), + type: bsn.type, + allowlist: bsn.allowlist, +}); diff --git a/src/ui/common/services/finalityProviderFilterService.ts b/src/ui/common/services/finalityProviderFilterService.ts new file mode 100644 index 000000000..d280b5bb9 --- /dev/null +++ b/src/ui/common/services/finalityProviderFilterService.ts @@ -0,0 +1,169 @@ +import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn"; +import { Bsn } from "@/ui/common/types/bsn"; +import { + FinalityProviderState as FinalityProviderStateEnum, + type FinalityProvider, +} from "@/ui/common/types/finalityProviders"; + +const { chainId: BBN_CHAIN_ID } = getNetworkConfigBBN(); + +/** + * Normalizes hex string by removing 0x prefix and converting to lowercase + */ +export const normalizeHex = (hex?: string): string => + (hex ?? "").trim().toLowerCase().replace(/^0x/, ""); + +// BSN-specific status filters to handle different BSN behaviors +const BABYLON_STATUS_FILTERS = { + active: (fp: FinalityProvider) => + fp.state === FinalityProviderStateEnum.ACTIVE, + inactive: (fp: FinalityProvider) => + fp.state === FinalityProviderStateEnum.INACTIVE, + jailed: (fp: FinalityProvider) => + fp.state === FinalityProviderStateEnum.JAILED, + slashed: (fp: FinalityProvider) => + fp.state === FinalityProviderStateEnum.SLASHED, +}; + +const COSMOS_STATUS_FILTERS = { + registered: (fp: FinalityProvider) => + fp.state === FinalityProviderStateEnum.INACTIVE, + slashed: (fp: FinalityProvider) => + fp.state === FinalityProviderStateEnum.SLASHED, +}; + +const BSN_STATUS_FILTERS = { + [BBN_CHAIN_ID]: BABYLON_STATUS_FILTERS, + COSMOS: COSMOS_STATUS_FILTERS, +}; + +/** + * Creates allowlist filters for Rollup BSN-based filtering + */ +export const createAllowlistFilters = (selectedBsn: Bsn | undefined) => { + const allowSet = new Set((selectedBsn?.allowlist || []).map(normalizeHex)); + + return { + allowlisted: (fp: FinalityProvider) => allowSet.has(normalizeHex(fp.btcPk)), + "non-allowlisted": (fp: FinalityProvider) => + !allowSet.has(normalizeHex(fp.btcPk)), + }; +}; + +/** + * Applies standard status filtering for BABYLON and COSMOS BSNs + */ +export const applyStandardStatusFilter = ( + providers: FinalityProvider[], + filterValue: string, + bsnKey: string, +): FinalityProvider[] => { + const bsnStatusFilters = + BSN_STATUS_FILTERS[bsnKey] || BSN_STATUS_FILTERS[BBN_CHAIN_ID]; + const statusFilter = + bsnStatusFilters[filterValue as keyof typeof bsnStatusFilters]; + return statusFilter ? providers.filter(statusFilter) : providers; +}; + +export interface FinalityProviderFilterState { + searchTerm: string; + providerStatus: + | "active" + | "inactive" + | "registered" + | "allowlisted" + | "non-allowlisted" + | "slashed" + | "jailed" + | ""; + allowlistStatus: "allowlisted" | "non-allowlisted" | ""; +} + +/** + * Main filtering function that applies BSN-aware filtering logic + * Determines filtering behavior based on BSN type: ROLLUP BSNs use allowlist-based filtering, + * while BABYLON and COSMOS BSNs use status-based filtering + */ +export const filterFinalityProvidersByBsn = ( + providers: FinalityProvider[], + filter: FinalityProviderFilterState, + selectedBsn: Bsn | undefined, +): FinalityProvider[] => { + let filtered = providers; + + // Apply BSN-aware filtering based on provider status + if (filter.providerStatus) { + // Determine filtering behavior based on BSN type + const isRollupBsn = selectedBsn?.type === "ROLLUP"; + + if (isRollupBsn) { + // For rollup BSNs: filter FPs by allowlist status or specific states + const allowlist = selectedBsn?.allowlist || []; + const allowSet = new Set(allowlist.map(normalizeHex)); + const hasAllowlist = allowlist.length > 0; + + filtered = filtered.filter((fp) => { + const isAllowlisted = allowSet.has(normalizeHex(fp.btcPk)); + + if (filter.providerStatus === "allowlisted") { + // If no allowlist is defined, show non-allowlisted FPs (all FPs) + return hasAllowlist ? isAllowlisted : true; + } else if (filter.providerStatus === "non-allowlisted") { + // If no allowlist is defined, show all FPs as non-allowlisted + return hasAllowlist ? !isAllowlisted : true; + } else if (filter.providerStatus === "slashed") { + return fp.state === FinalityProviderStateEnum.SLASHED; + } else if (filter.providerStatus === "jailed") { + return fp.state === FinalityProviderStateEnum.JAILED; + } + return true; + }); + } else { + // For Babylon and Cosmos BSNs: filter by finality provider state using BSN-specific filters + const bsnKey = + selectedBsn?.id === BBN_CHAIN_ID + ? BBN_CHAIN_ID + : selectedBsn?.type || BBN_CHAIN_ID; + + filtered = applyStandardStatusFilter( + filtered, + filter.providerStatus, + bsnKey, + ); + } + } + + if (filter.allowlistStatus) { + const allowlistFilters = createAllowlistFilters(selectedBsn); + const allowlistFilter = + allowlistFilters[filter.allowlistStatus as keyof typeof allowlistFilters]; + if (allowlistFilter) { + filtered = filtered.filter(allowlistFilter); + } + } + + return filtered; +}; + +/** + * Determines if a finality provider row should be selectable based on BSN rules + */ +export const isFinalityProviderRowSelectable = ( + row: FinalityProvider, + selectedBsnId: string | undefined, + selectedBsn: Bsn | undefined, +): boolean => { + const statusAllowed = + row.state === FinalityProviderStateEnum.ACTIVE || + row.state === FinalityProviderStateEnum.INACTIVE; + + // For selection (not filtering), only restrict based on allowlist for non-Babylon BSNs + const allowlistAllowed = + !selectedBsnId || + selectedBsnId === BBN_CHAIN_ID || + (selectedBsn?.allowlist || []).some( + (allowedPk) => normalizeHex(allowedPk) === normalizeHex(row.btcPk), + ); + + return statusAllowed && allowlistAllowed; +}; diff --git a/src/ui/common/state/FinalityProviderBsnState.tsx b/src/ui/common/state/FinalityProviderBsnState.tsx index fddcbfc41..750ca8dd5 100644 --- a/src/ui/common/state/FinalityProviderBsnState.tsx +++ b/src/ui/common/state/FinalityProviderBsnState.tsx @@ -2,9 +2,19 @@ import { useDebounce } from "@uidotdev/usehooks"; import { useCallback, useMemo, useState, type PropsWithChildren } from "react"; import { useSearchParams } from "react-router"; +import { BSN_TYPE_COSMOS } from "@/ui/common/api/getBsn"; import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn"; import { useBsn } from "@/ui/common/hooks/client/api/useBsn"; import { useFinalityProvidersV2 } from "@/ui/common/hooks/client/api/useFinalityProvidersV2"; +import { + getBsnConfig, + type BsnFilterOption, +} from "@/ui/common/services/bsnService"; +import { + filterFinalityProvidersByBsn, + isFinalityProviderRowSelectable, + type FinalityProviderFilterState, +} from "@/ui/common/services/finalityProviderFilterService"; import { Bsn } from "@/ui/common/types/bsn"; import { FinalityProviderState as FinalityProviderStateEnum, @@ -19,10 +29,7 @@ interface SortState { direction?: "asc" | "desc"; } -interface FilterState { - search: string; - status: "active" | "inactive" | ""; -} +type FilterState = FinalityProviderFilterState; const { chainId: BBN_CHAIN_ID } = getNetworkConfigBBN(); @@ -31,6 +38,7 @@ const defaultBabylonBsn: Bsn = { name: "Babylon Genesis", description: "Babylon Genesis", logoUrl: getBsnLogoUrl(BBN_CHAIN_ID), + type: BSN_TYPE_COSMOS, }; interface FinalityProviderBsnState { @@ -45,7 +53,13 @@ interface FinalityProviderBsnState { bsnLoading: boolean; bsnError: boolean; selectedBsnId: string | undefined; + // selectedBsn contains allowlist and type data needed for filtering logic + // Cannot be derived from selectedBsnId alone as it requires the full BSN object + selectedBsn: Bsn | undefined; setSelectedBsnId: (id: string | undefined) => void; + // BSN Configuration (moved from components to state) + modalTitle: string; + filterOptions: BsnFilterOption[]; // Modal stakingModalPage: StakingModalPage; setStakingModalPage: (page: StakingModalPage) => void; @@ -73,29 +87,23 @@ const SORT_DIRECTIONS = { asc: undefined, } as const; -const STATUS_FILTERS = { - active: (fp: FinalityProvider) => - fp.state === FinalityProviderStateEnum.ACTIVE, - inactive: (fp: FinalityProvider) => - fp.state !== FinalityProviderStateEnum.ACTIVE, -}; +// Component-specific constants remain in the state file const FILTERS = { - search: (fp: FinalityProvider, filter: FilterState) => { - const searchTerm = filter.search.toLowerCase(); + searchTerm: (fp: FinalityProvider, filter: FilterState) => { + const searchTerm = filter.searchTerm.toLowerCase(); return ( (fp.description?.moniker?.toLowerCase().includes(searchTerm) ?? false) || fp.btcPk.toLowerCase().includes(searchTerm) ); }, - status: (fp: FinalityProvider, filter: FilterState) => - filter.status && !filter.search ? STATUS_FILTERS[filter.status](fp) : true, }; const defaultState: FinalityProviderBsnState = { filter: { - search: "", - status: "active", + searchTerm: "", + providerStatus: "active", + allowlistStatus: "", }, finalityProviders: [], isFetching: false, @@ -106,7 +114,16 @@ const defaultState: FinalityProviderBsnState = { bsnLoading: false, bsnError: false, selectedBsnId: undefined, + selectedBsn: undefined, setSelectedBsnId: () => {}, + // Default BSN configuration (Babylon Genesis) + modalTitle: "Select Babylon Genesis Finality Provider", + filterOptions: [ + { value: "active", label: "Active" }, + { value: "inactive", label: "Inactive" }, + { value: "jailed", label: "Jailed" }, + { value: "slashed", label: "Slashed" }, + ], isRowSelectable: () => false, handleSort: () => {}, handleFilter: () => {}, @@ -126,11 +143,12 @@ export function FinalityProviderBsnState({ children }: PropsWithChildren) { ); const [filter, setFilter] = useState({ - search: fpParam || "", - status: "active", + searchTerm: fpParam || "", + providerStatus: "active", + allowlistStatus: "", }); const [sortState, setSortState] = useState({}); - const debouncedSearch = useDebounce(filter.search, 300); + const debouncedSearch = useDebounce(filter.searchTerm, 300); const [selectedBsnId, setSelectedBsnId] = useState( BBN_CHAIN_ID, @@ -155,6 +173,23 @@ export function FinalityProviderBsnState({ children }: PropsWithChildren) { enabled: stakingModalPage === StakingModalPage.BSN, }); + const selectedBsn = useMemo( + () => bsnList.find((bsn) => bsn.id === selectedBsnId), + [bsnList, selectedBsnId], + ); + + // BSN configuration derived from selected BSN (moved from components) + const bsnConfig = useMemo(() => getBsnConfig(selectedBsn), [selectedBsn]); + + const modalTitle = useMemo( + () => bsnConfig.modalTitle, + [bsnConfig.modalTitle], + ); + const filterOptions = useMemo( + () => bsnConfig.filterOptions, + [bsnConfig.filterOptions], + ); + const finalityProviders = useMemo(() => { if (!data?.finalityProviders) { return []; @@ -195,18 +230,25 @@ export function FinalityProviderBsnState({ children }: PropsWithChildren) { ); }, []); - const isRowSelectable = useCallback((row: FinalityProvider) => { - return ( - row.state === FinalityProviderStateEnum.ACTIVE || - row.state === FinalityProviderStateEnum.INACTIVE - ); - }, []); + const isRowSelectable = useCallback( + (row: FinalityProvider) => + isFinalityProviderRowSelectable(row, selectedBsnId, selectedBsn), + [selectedBsnId, selectedBsn], + ); const filteredFinalityProviders = useMemo(() => { - return finalityProviders.filter((fp: FinalityProvider) => - Object.values(FILTERS).every((filterFn) => filterFn(fp, filter)), + // First apply search filter + const filtered = finalityProviders.filter((fp: FinalityProvider) => + FILTERS.searchTerm(fp, filter), ); - }, [finalityProviders, filter]); + + // If search is active, return all matching results without status filtering + if (filter.searchTerm) { + return filtered; + } + + return filterFinalityProvidersByBsn(filtered, filter, selectedBsn); + }, [finalityProviders, filter, selectedBsn]); const state = useMemo( () => ({ @@ -218,11 +260,15 @@ export function FinalityProviderBsnState({ children }: PropsWithChildren) { hasNextPage, fetchNextPage, selectedBsnId, + selectedBsn, setSelectedBsnId, selectedProviderIds, setSelectedProviderIds, isFetching, hasError: isError, + // BSN Configuration (moved from components to state) + modalTitle, + filterOptions, handleSort, handleFilter, isRowSelectable, @@ -238,11 +284,14 @@ export function FinalityProviderBsnState({ children }: PropsWithChildren) { hasNextPage, fetchNextPage, selectedBsnId, + selectedBsn, setSelectedBsnId, selectedProviderIds, setSelectedProviderIds, isFetching, isError, + modalTitle, + filterOptions, handleSort, handleFilter, isRowSelectable, diff --git a/src/ui/common/types/bsn.ts b/src/ui/common/types/bsn.ts index 0a93f6d4e..a69f1afdb 100644 --- a/src/ui/common/types/bsn.ts +++ b/src/ui/common/types/bsn.ts @@ -1,7 +1,11 @@ +export type BsnType = "COSMOS" | "ROLLUP"; + export interface Bsn { id: string; name: string; description: string; logoUrl: string; activeTvl?: number; + type: BsnType; + allowlist?: string[]; // BTC FP pubkeys (hex) }