diff --git a/src/ui/components/Multistaking/BsnFinalityProviderField/BsnFinalityProviderField.tsx b/src/ui/components/Multistaking/BsnFinalityProviderField/BsnFinalityProviderField.tsx index d0ffb4f1c..85247b7da 100644 --- a/src/ui/components/Multistaking/BsnFinalityProviderField/BsnFinalityProviderField.tsx +++ b/src/ui/components/Multistaking/BsnFinalityProviderField/BsnFinalityProviderField.tsx @@ -30,8 +30,9 @@ export function BsnFinalityProviderField({ disabled, }); - const handleAdd = () => { - // TODO: Implement provider selection logic + const handleAdd = (providerPk: string) => { + // Add selected provider ID to list + onChange([...selectedProviderIds, providerPk]); onClose(); }; @@ -62,7 +63,12 @@ export function BsnFinalityProviderField({ /> )} - + ); } diff --git a/src/ui/components/Multistaking/BsnFinalityProviderField/BsnModal.tsx b/src/ui/components/Multistaking/BsnFinalityProviderField/BsnModal.tsx index a6a2ef69b..78e159174 100644 --- a/src/ui/components/Multistaking/BsnFinalityProviderField/BsnModal.tsx +++ b/src/ui/components/Multistaking/BsnFinalityProviderField/BsnModal.tsx @@ -1,30 +1,87 @@ -import { DialogBody, DialogHeader } from "@babylonlabs-io/core-ui"; +import { useEffect, useMemo, useState } from "react"; import { ResponsiveDialog } from "@/ui/components/Modals/ResponsiveDialog"; +import { ChainSelectionModal } from "@/ui/components/Multistaking/ChainSelectionModal/ChainSelectionModal"; +import { FinalityProviderModal } from "@/ui/components/Multistaking/FinalityProviderField/FinalityProviderModal"; +import { + FinalityProviderBsnState, + useFinalityProviderBsnState, +} from "@/ui/state/FinalityProviderBsnState"; interface Props { open: boolean; - defaultFinalityProvider?: string; - onAdd: () => void; + onAdd: (selectedProviderPk: string) => void; onClose: () => void; + selectedProviderIds: string[]; } -export function BsnModal({ - open, - // defaultFinalityProvider, - // onAdd, - onClose, -}: Props) { +enum BsnModalPage { + CHAIN = "CHAIN", + FP = "FP", +} + +export function BsnModal({ open, onAdd, onClose, selectedProviderIds }: Props) { + const [selectedChainId, setSelectedChainId] = useState(null); + const [page, setPage] = useState(BsnModalPage.CHAIN); + + const { getRegisteredFinalityProvider } = useFinalityProviderBsnState(); + + const hasBabylonProvider = useMemo( + () => + selectedProviderIds.some((pk) => { + const fp = getRegisteredFinalityProvider(pk); + return !fp?.bsnId; + }), + [selectedProviderIds, getRegisteredFinalityProvider], + ); + + const disabledChainIds = useMemo(() => { + const set = new Set(); + selectedProviderIds.forEach((pk) => { + const fp = getRegisteredFinalityProvider(pk); + set.add(fp?.bsnId || ""); + }); + return Array.from(set); + }, [selectedProviderIds, getRegisteredFinalityProvider]); + + useEffect(() => { + if (!open) { + return; + } + setPage(BsnModalPage.CHAIN); + setSelectedChainId(null); + }, [open]); + + const handleChainNext = (chainId: string) => { + setSelectedChainId(chainId); + setPage(BsnModalPage.FP); + }; + + const handleProviderAdd = (providerPk: string) => { + onAdd(providerPk); + }; + return ( - - - {/* TODO: Implement BSN selection functionality */} - + {page === BsnModalPage.CHAIN && ( + + )} + {page === BsnModalPage.FP && selectedChainId !== null && ( + + setPage(BsnModalPage.CHAIN)} + /> + + )} ); } diff --git a/src/ui/components/Multistaking/ChainSelectionModal/ChainSelectionModal.tsx b/src/ui/components/Multistaking/ChainSelectionModal/ChainSelectionModal.tsx index c2a010f31..5e0528514 100644 --- a/src/ui/components/Multistaking/ChainSelectionModal/ChainSelectionModal.tsx +++ b/src/ui/components/Multistaking/ChainSelectionModal/ChainSelectionModal.tsx @@ -5,10 +5,12 @@ import { DialogHeader, Text, } from "@babylonlabs-io/core-ui"; +import { useQuery } from "@tanstack/react-query"; import { PropsWithChildren, useState } from "react"; import { MdOutlineInfo } from "react-icons/md"; import { twMerge } from "tailwind-merge"; +import { getBSNs } from "@/ui/api/getBsn"; import { chainLogos } from "@/ui/constants"; const SubSection = ({ @@ -80,12 +82,21 @@ const ChainButton = ({ export const ChainSelectionModal = ({ onNext, onClose, + disableNonBabylon = false, + disabledChainIds = [], }: { onNext: (selectedChain: string) => void; onClose: () => void; + disableNonBabylon?: boolean; + disabledChainIds?: string[]; }) => { const [selected, setSelected] = useState(null); + const { data: bsns, isLoading } = useQuery({ + queryKey: ["API_BSN_LIST"], + queryFn: getBSNs, + }); + return ( <>
- setSelected("babylon")} - /> - setSelected("cosmos")} - /> - setSelected("ethereum")} - /> - setSelected("sui")} - /> + {isLoading &&
Loading...
} + {bsns?.map((bsn) => { + const logo = + bsn.id === "" + ? chainLogos.babylon + : (chainLogos as Record)[bsn.id] || + chainLogos.placeholder; + + const isDisabled = + (disableNonBabylon && bsn.id !== "") || + disabledChainIds.includes(bsn.id); + + return ( + !isDisabled && setSelected(bsn.id)} + /> + ); + })}
- -
- -
-
- Babylon must be the first BSN you add before selecting others. Once - added, you can choose additional BSNs to multistake. -
-
+ {disableNonBabylon && ( + +
+ +
+
+ Babylon must be the first BSN you add before selecting others. + Once added, you can choose additional BSNs to multistake. +
+
+ )} diff --git a/src/ui/components/Multistaking/FinalityProviderField/FinalityProviders.tsx b/src/ui/components/Multistaking/FinalityProviderField/FinalityProviders.tsx index 843d03a5a..fc9dc0003 100644 --- a/src/ui/components/Multistaking/FinalityProviderField/FinalityProviders.tsx +++ b/src/ui/components/Multistaking/FinalityProviderField/FinalityProviders.tsx @@ -1,6 +1,6 @@ -import { FinalityProviderFilter } from "@/ui/components/Staking/FinalityProviders/FinalityProviderFilter"; -import { FinalityProviderSearch } from "@/ui/components/Staking/FinalityProviders/FinalityProviderSearch"; -import { FinalityProviderTable } from "@/ui/components/Staking/FinalityProviders/FinalityProviderTable"; +import { FinalityProviderFilter } from "@/ui/components/Multistaking/FinalityProviders/FinalityProviderFilter"; +import { FinalityProviderSearch } from "@/ui/components/Multistaking/FinalityProviders/FinalityProviderSearch"; +import { FinalityProviderTable } from "@/ui/components/Multistaking/FinalityProviders/FinalityProviderTable"; interface Props { selectedFP: string; diff --git a/src/ui/components/Multistaking/FinalityProviders/FinalityProviderFilter.tsx b/src/ui/components/Multistaking/FinalityProviders/FinalityProviderFilter.tsx new file mode 100644 index 000000000..9cc8ad1e3 --- /dev/null +++ b/src/ui/components/Multistaking/FinalityProviders/FinalityProviderFilter.tsx @@ -0,0 +1,23 @@ +import { Select } from "@babylonlabs-io/core-ui"; + +import { useFinalityProviderBsnState } from "@/ui/state/FinalityProviderBsnState"; + +const options = [ + { value: "active", label: "Active" }, + { value: "inactive", label: "Inactive" }, +]; + +export const FinalityProviderFilter = () => { + const { filter, handleFilter } = useFinalityProviderBsnState(); + + return ( + + ); +}; diff --git a/src/ui/components/Multistaking/FinalityProviders/FinalityProviderTable.tsx b/src/ui/components/Multistaking/FinalityProviders/FinalityProviderTable.tsx new file mode 100644 index 000000000..8cc988f3d --- /dev/null +++ b/src/ui/components/Multistaking/FinalityProviders/FinalityProviderTable.tsx @@ -0,0 +1,145 @@ +/** + * Import polyfill for array.toSorted + */ +import "core-js/features/array/to-sorted"; + +import { Button, Loader } from "@babylonlabs-io/core-ui"; + +import warningOctagon from "@/ui/assets/warning-octagon.svg"; +import warningTriangle from "@/ui/assets/warning-triangle.svg"; +import { FinalityProviderLogo } from "@/ui/components/Staking/FinalityProviders/FinalityProviderLogo"; +import { StatusView } from "@/ui/components/Staking/FinalityProviders/FinalityProviderTableStatusView"; +import { getNetworkConfigBTC } from "@/ui/config/network/btc"; +import { useFinalityProviderBsnState } from "@/ui/state/FinalityProviderBsnState"; +import { FinalityProviderStateLabels } from "@/ui/types/finalityProviders"; +import { satoshiToBtc } from "@/ui/utils/btc"; +import { maxDecimals } from "@/ui/utils/maxDecimals"; + +const { coinSymbol } = getNetworkConfigBTC(); + +interface Props { + selectedFP?: string; + onSelectRow?: (btcPk: string) => void; +} + +export const FinalityProviderTable = ({ selectedFP, onSelectRow }: Props) => { + const { isFetching, finalityProviders, hasError, isRowSelectable } = + useFinalityProviderBsnState(); + + const errorView = ( + } + title="Failed to Load" + description={ + <> + The finality provider list failed to load. Please check
+ your internet connection or try again later. + + } + /> + ); + + const loadingView = ( + } + title="Loading Finality Providers" + /> + ); + + const noMatchesView = ( + } + title="No Matches Found" + /> + ); + + if (hasError) { + return errorView; + } + + if (isFetching && (!finalityProviders || finalityProviders.length === 0)) { + return loadingView; + } + + if (!isFetching && (!finalityProviders || finalityProviders.length === 0)) { + return noMatchesView; + } + + const handleSelect = (btcPk: string) => { + if (onSelectRow) { + onSelectRow(btcPk); + } + }; + + return ( +
+ {finalityProviders.map((fp) => { + const isSelected = selectedFP === fp.btcPk; + const isSelectable = isRowSelectable(fp); + const totalDelegation = maxDecimals( + satoshiToBtc(fp.activeTVLSat || 0), + 8, + ); + const commission = maxDecimals((Number(fp.commission) || 0) * 100, 2); + const status = FinalityProviderStateLabels[fp.state] || "Unknown"; + + return ( +
+
+ +
+
+ {fp.btcPk + ? `${fp.btcPk.slice(0, 6)}...${fp.btcPk.slice(-6)}` + : ""} +
+
+ {fp.description?.moniker || "Unnamed Provider"} +
+
+
+
+
+
Status
+
{status}
+
+
+
{coinSymbol} PK
+
+ {fp.btcPk + ? `${fp.btcPk.slice(0, 5)}...${fp.btcPk.slice(-5)}` + : ""} +
+
+
+
Total Delegation
+
+ {totalDelegation} {coinSymbol} +
+
+
+
Commission
+
{commission}%
+
+ +
+ ); + })} +
+ ); +}; diff --git a/src/ui/state/FinalityProviderBsnState.tsx b/src/ui/state/FinalityProviderBsnState.tsx index a03859e1a..e16522df7 100644 --- a/src/ui/state/FinalityProviderBsnState.tsx +++ b/src/ui/state/FinalityProviderBsnState.tsx @@ -30,6 +30,8 @@ interface FinalityProviderBsnState { finalityProviderMap: Map; isFetching: boolean; hasError: boolean; + hasNextPage: boolean; + fetchNextPage: () => void; // BSN bsnList: Bsn[]; bsnLoading: boolean; @@ -85,6 +87,8 @@ const defaultState: FinalityProviderBsnState = { finalityProviders: [], isFetching: false, hasError: false, + hasNextPage: false, + fetchNextPage: () => {}, bsnList: [], bsnLoading: false, bsnError: false, @@ -102,7 +106,10 @@ const defaultState: FinalityProviderBsnState = { const { StateProvider, useState: useFpBsnState } = createStateUtils(defaultState); -export function FinalityProviderBsnState({ children }: PropsWithChildren) { +export function FinalityProviderBsnState({ + children, + bsnId, +}: PropsWithChildren & { bsnId?: string }) { const params = useSearchParams(); const fpParam = params.get("fp"); const [stakingModalPage, setStakingModalPage] = useState( @@ -116,11 +123,13 @@ export function FinalityProviderBsnState({ children }: PropsWithChildren) { const [sortState, setSortState] = useState({}); const debouncedSearch = useDebounce(filter.search, 300); - const { data, isFetching, isError } = useFinalityProvidersV2({ - sortBy: sortState.field, - order: sortState.direction, - name: debouncedSearch, - }); + const { data, isFetching, isError, hasNextPage, fetchNextPage } = + useFinalityProvidersV2({ + sortBy: sortState.field, + order: sortState.direction, + name: debouncedSearch, + bsnId, + }); const { data: dataV1 } = useFinalityProviders(); const { @@ -134,7 +143,17 @@ export function FinalityProviderBsnState({ children }: PropsWithChildren) { const finalityProviders = useMemo(() => { if (!data?.finalityProviders) return []; - return data.finalityProviders + const filteredByBsn = (data.finalityProviders ?? []).filter((fp) => { + if (bsnId === undefined) return true; + + if (bsnId === "") { + return (fp.bsnId ?? "") === ""; + } + + return fp.bsnId === bsnId; + }); + + return filteredByBsn .sort((a, b) => { const condition = FP_STATUSES[b.state] - FP_STATUSES[a.state]; @@ -149,7 +168,7 @@ export function FinalityProviderBsnState({ children }: PropsWithChildren) { rank: i + 1, id: fp.btcPk, })); - }, [data?.finalityProviders]); + }, [data?.finalityProviders, bsnId]); const finalityProviderMap = useMemo( () => @@ -226,6 +245,8 @@ export function FinalityProviderBsnState({ children }: PropsWithChildren) { bsnList, bsnLoading, bsnError, + hasNextPage, + fetchNextPage, // selectedBsnId: null, // TODO: Uncomment when implementing BSN selection isFetching, hasError: isError, @@ -244,6 +265,8 @@ export function FinalityProviderBsnState({ children }: PropsWithChildren) { bsnList, bsnLoading, bsnError, + hasNextPage, + fetchNextPage, isFetching, isError, finalityProviderMap, diff --git a/src/ui/state/MultistakingState.tsx b/src/ui/state/MultistakingState.tsx index 217d5415c..43732c76a 100644 --- a/src/ui/state/MultistakingState.tsx +++ b/src/ui/state/MultistakingState.tsx @@ -1,10 +1,19 @@ import { useMemo, useState, type PropsWithChildren } from "react"; -import { number, object, ObjectSchema, ObjectShape, Schema, string } from "yup"; +import { + array, + number, + object, + ObjectSchema, + ObjectShape, + Schema, + string, +} from "yup"; import { validateDecimalPoints } from "@/ui/components/Staking/Form/validation/validation"; import { getNetworkConfigBTC } from "@/ui/config/network/btc"; import { useBTCWallet } from "@/ui/context/wallet/BTCWalletProvider"; import { useNetworkInfo } from "@/ui/hooks/client/api/useNetworkInfo"; +import { useFinalityProviderBsnState } from "@/ui/state/FinalityProviderBsnState"; import { satoshiToBtc } from "@/ui/utils/btc"; import { createStateUtils } from "@/ui/utils/createStateUtils"; import { formatNumber, formatStakingAmount } from "@/ui/utils/formTransforms"; @@ -40,7 +49,7 @@ const { StateProvider, useState: useMultistakingState } = createStateUtils({ stakingModalPage: StakingModalPage.DEFAULT, setStakingModalPage: () => {}, - MAX_FINALITY_PROVIDERS: 1, + MAX_FINALITY_PROVIDERS: 3, validationSchema: undefined, formFields: [], }); @@ -54,6 +63,7 @@ export function MultistakingState({ children }: PropsWithChildren) { const { publicKeyNoCoord } = useBTCWallet(); const { stakableBtcBalance } = useBalanceState(); const { stakingInfo } = useStakingState(); + const { getRegisteredFinalityProvider } = useFinalityProviderBsnState(); const formFields: FieldOptions[] = useMemo( () => @@ -145,8 +155,34 @@ export function MultistakingState({ children }: PropsWithChildren) { .required("Staking fee amount is the required field.") .moreThan(0, "Staking fee amount must be greater than 0."), }, + { + field: "selectedProviders", + schema: array() + .of(string()) + .min(1, "Add at least one finality provider.") + .test( + "hasBabylonProvider", + "Add a Babylon finality provider first", + (list) => + (list as string[] | undefined)?.some( + (pk) => + typeof pk === "string" && + getRegisteredFinalityProvider(pk)?.bsnId === "", + ) ?? false, + ) + .max( + MAX_FINALITY_PROVIDERS, + `Maximum ${MAX_FINALITY_PROVIDERS} finality providers allowed.`, + ), + }, ] as const, - [publicKeyNoCoord, stakingInfo, stakableBtcBalance], + [ + publicKeyNoCoord, + stakingInfo, + stakableBtcBalance, + getRegisteredFinalityProvider, + MAX_FINALITY_PROVIDERS, + ], ); const validationSchema = useMemo(() => {