From 52ffc054e8d7e987a75da9d0fe7c48d510c0ff60 Mon Sep 17 00:00:00 2001 From: David Totraev Date: Thu, 3 Oct 2024 18:13:54 +0500 Subject: [PATCH] feat: added new api hooks and global states --- .../components/Delegations/Delegations.tsx | 75 +++--- src/app/components/FAQ/FAQ.tsx | 6 +- src/app/components/Header/Header.tsx | 16 +- .../components/NetworkBadge/NetworkBadge.tsx | 18 +- src/app/components/Staking/Staking.tsx | 97 +++----- src/app/components/Stats/Stats.tsx | 16 +- src/app/components/Summary/Summary.tsx | 35 ++- src/app/context/api/VersionInfo.tsx | 68 ------ src/app/hooks/useDelegations.ts | 56 +++++ src/app/hooks/useUTXOs.ts | 34 +++ src/app/page.tsx | 220 +----------------- src/app/providers.tsx | 6 +- src/app/state/DelegationState.tsx | 105 +++++++++ src/app/state/index.tsx | 103 ++++++++ src/utils/createState.ts | 12 + 15 files changed, 437 insertions(+), 430 deletions(-) delete mode 100644 src/app/context/api/VersionInfo.tsx create mode 100644 src/app/hooks/useDelegations.ts create mode 100644 src/app/hooks/useUTXOs.ts create mode 100644 src/app/state/DelegationState.tsx create mode 100644 src/app/state/index.tsx create mode 100644 src/utils/createState.ts diff --git a/src/app/components/Delegations/Delegations.tsx b/src/app/components/Delegations/Delegations.tsx index 04716366..09c52568 100644 --- a/src/app/components/Delegations/Delegations.tsx +++ b/src/app/components/Delegations/Delegations.tsx @@ -3,13 +3,18 @@ import { useEffect, useState } from "react"; import InfiniteScroll from "react-infinite-scroll-component"; import { useLocalStorage } from "usehooks-ts"; -import { SignPsbtTransaction } from "@/app/common/utils/psbt"; +import { + signPsbtTransaction, + SignPsbtTransaction, +} from "@/app/common/utils/psbt"; import { LoadingTableList } from "@/app/components/Loading/Loading"; import { DelegationsPointsProvider } from "@/app/context/api/DelegationsPointsProvider"; import { useError } from "@/app/context/Error/ErrorContext"; import { useWallet } from "@/app/context/wallet/WalletProvider"; +import { useDelegations } from "@/app/hooks/useDelegations"; import { useHealthCheck } from "@/app/hooks/useHealthCheck"; -import { QueryMeta } from "@/app/types/api"; +import { useAppState } from "@/app/state"; +import { useDelegationState } from "@/app/state/DelegationState"; import { Delegation as DelegationInterface, DelegationState, @@ -32,43 +37,35 @@ import { import { Delegation } from "./Delegation"; -interface DelegationsProps { - delegationsAPI: DelegationInterface[]; - delegationsLocalStorage: DelegationInterface[]; - globalParamsVersion: GlobalParamsVersion; - signPsbtTx: SignPsbtTransaction; - pushTx: WalletProvider["pushTx"]; - queryMeta: QueryMeta; - getNetworkFees: WalletProvider["getNetworkFees"]; -} +export const Delegations = () => { + const { currentVersion } = useAppState(); + const { data: delegationsAPI } = useDelegations(); + const { + walletProvider: btcWallet, + address, + publicKeyNoCoord, + connected, + network, + } = useWallet(); -export const Delegations: React.FC = ({ - delegationsAPI, - delegationsLocalStorage, - globalParamsVersion, - signPsbtTx, - pushTx, - queryMeta, - getNetworkFees, -}) => { - const { address, publicKeyNoCoord, connected, network } = useWallet(); + if (!btcWallet || !delegationsAPI || !currentVersion || !network) { + return; + } return ( network && ( = ({ interface DelegationsContentProps { delegationsAPI: DelegationInterface[]; - delegationsLocalStorage: DelegationInterface[]; globalParamsVersion: GlobalParamsVersion; publicKeyNoCoord: string; btcWalletNetwork: networks.Network; address: string; signPsbtTx: SignPsbtTransaction; pushTx: WalletProvider["pushTx"]; - queryMeta: QueryMeta; getNetworkFees: WalletProvider["getNetworkFees"]; isWalletConnected: boolean; } const DelegationsContent: React.FC = ({ delegationsAPI, - delegationsLocalStorage, globalParamsVersion, signPsbtTx, pushTx, - queryMeta, getNetworkFees, address, btcWalletNetwork, @@ -111,6 +104,12 @@ const DelegationsContent: React.FC = ({ const { showError } = useError(); const { isApiNormal, isGeoBlocked } = useHealthCheck(); const [awaitingWalletResponse, setAwaitingWalletResponse] = useState(false); + const { + delegations = [], + fetchMoreDelegations, + hasMoreDelegations, + isLoading, + } = useDelegationState(); const shouldShowPoints = isApiNormal && !isGeoBlocked && shouldDisplayPoints(); @@ -280,9 +279,9 @@ const DelegationsContent: React.FC = ({ // combine delegations from the API and local storage, prioritizing API data const combinedDelegationsData = delegationsAPI - ? [...delegationsLocalStorage, ...delegationsAPI] + ? [...delegations, ...delegationsAPI] : // if no API data, fallback to using only local storage delegations - delegationsLocalStorage; + delegations; return (
@@ -310,9 +309,9 @@ const DelegationsContent: React.FC = ({ : null} + next={fetchMoreDelegations} + hasMore={hasMoreDelegations} + loader={isLoading ? : null} scrollableTarget="staking-history" > {combinedDelegationsData?.map((delegation) => { diff --git a/src/app/components/FAQ/FAQ.tsx b/src/app/components/FAQ/FAQ.tsx index b842d185..42af7e71 100644 --- a/src/app/components/FAQ/FAQ.tsx +++ b/src/app/components/FAQ/FAQ.tsx @@ -1,4 +1,4 @@ -import { useVersionInfo } from "@/app/context/api/VersionInfo"; +import { useAppState } from "@/app/state"; import { getNetworkConfig } from "@/config/network.config"; import { questions } from "./data/questions"; @@ -7,8 +7,8 @@ import { Section } from "./Section"; interface FAQProps {} export const FAQ: React.FC = () => { + const { currentVersion } = useAppState(); const { coinName, networkName } = getNetworkConfig(); - const versionInfo = useVersionInfo(); return (
@@ -17,7 +17,7 @@ export const FAQ: React.FC = () => { {questions( coinName, networkName, - versionInfo?.currentVersion?.confirmationDepth, + currentVersion?.confirmationDepth, ).map((question) => (
= ({ - loading, - btcWalletBalanceSat, -}) => { +export const Header = () => { const { address, open, disconnect } = useWallet(); + const { totalBalance, isLoading: loading } = useAppState(); return (
diff --git a/src/app/components/NetworkBadge/NetworkBadge.tsx b/src/app/components/NetworkBadge/NetworkBadge.tsx index 8c2605a6..1193852a 100644 --- a/src/app/components/NetworkBadge/NetworkBadge.tsx +++ b/src/app/components/NetworkBadge/NetworkBadge.tsx @@ -1,21 +1,23 @@ import Image from "next/image"; +import { twJoin } from "tailwind-merge"; +import { useWallet } from "@/app/context/wallet/WalletProvider"; import { network } from "@/config/network.config"; import { Network } from "@/utils/wallet/wallet_provider"; import testnetIcon from "./testnet-icon.png"; -// This component can also be used rendering based on the network type -interface NetworkBadgeProps { - isWalletConnected: boolean; -} +export const NetworkBadge = () => { + const { walletProvider } = useWallet(); -export const NetworkBadge: React.FC = ({ - isWalletConnected, -}) => { return (
{[Network.SIGNET, Network.TESTNET].includes(network) && ( <> diff --git a/src/app/components/Staking/Staking.tsx b/src/app/components/Staking/Staking.tsx index 6c05590e..e79499f7 100644 --- a/src/app/components/Staking/Staking.tsx +++ b/src/app/components/Staking/Staking.tsx @@ -1,6 +1,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Transaction } from "bitcoinjs-lib"; -import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Tooltip } from "react-tooltip"; import { useLocalStorage } from "usehooks-ts"; @@ -12,10 +12,10 @@ import { import { LoadingView } from "@/app/components/Loading/Loading"; import { useError } from "@/app/context/Error/ErrorContext"; import { useStakingStats } from "@/app/context/api/StakingStatsProvider"; -import { useVersionInfo } from "@/app/context/api/VersionInfo"; import { useWallet } from "@/app/context/wallet/WalletProvider"; import { useHealthCheck } from "@/app/hooks/useHealthCheck"; -import { Delegation } from "@/app/types/delegations"; +import { useAppState } from "@/app/state"; +import { useDelegationState } from "@/app/state/DelegationState"; import { ErrorHandlerParam, ErrorState } from "@/app/types/errors"; import { FinalityProvider, @@ -29,7 +29,6 @@ import { import { getFeeRateFromMempool } from "@/utils/getFeeRateFromMempool"; import { isStakingSignReady } from "@/utils/isStakingSignReady"; import { toLocalStorageDelegation } from "@/utils/local_storage/toLocalStorageDelegation"; -import type { UTXO } from "@/utils/wallet/wallet_provider"; import { FeedbackModal } from "../Modals/FeedbackModal"; import { PreviewModal } from "../Modals/PreviewModal"; @@ -52,23 +51,18 @@ interface OverflowProperties { approchingCapRange: boolean; } -interface StakingProps { - btcHeight: number | undefined; - disabled?: boolean; - isLoading: boolean; - btcWalletBalanceSat?: number; - setDelegationsLocalStorage: Dispatch>; - availableUTXOs?: UTXO[] | undefined; -} - -export const Staking: React.FC = ({ - btcHeight, - disabled = false, - isLoading, - setDelegationsLocalStorage, - btcWalletBalanceSat, - availableUTXOs, -}) => { +export const Staking = () => { + const { + availableUTXOs, + currentHeight: btcHeight, + currentVersion, + totalBalance: btcWalletBalanceSat, + firstActivationHeight, + isApprochingNextVersion, + isError, + isLoading, + } = useAppState(); + const { addDelegation } = useDelegationState(); const { connected, address, @@ -77,6 +71,8 @@ export const Staking: React.FC = ({ network: btcWalletNetwork, } = useWallet(); + const disabled = isError; + // Staking form state const [stakingAmountSat, setStakingAmountSat] = useState(0); const [stakingTimeBlocks, setStakingTimeBlocks] = useState(0); @@ -103,8 +99,6 @@ export const Staking: React.FC = ({ approchingCapRange: false, }); - const versionInfo = useVersionInfo(); - // Mempool fee rates, comes from the network // Fetch fee rates, sat/vB const { @@ -131,12 +125,12 @@ export const Staking: React.FC = ({ // Calculate the overflow properties useEffect(() => { - if (!versionInfo?.currentVersion || !btcHeight) { + if (!currentVersion || !btcHeight) { return; } const nextBlockHeight = btcHeight + 1; const { stakingCapHeight, stakingCapSat, confirmationDepth } = - versionInfo.currentVersion; + currentVersion; // Use height based cap than value based cap if it is set if (stakingCapHeight) { setOverflow({ @@ -162,12 +156,11 @@ export const Staking: React.FC = ({ stakingCapSat * OVERFLOW_TVL_WARNING_THRESHOLD < unconfirmedTVLSat, }); } - }, [versionInfo, btcHeight, stakingStats]); + }, [currentVersion, btcHeight, stakingStats]); const { coinName } = getNetworkConfig(); - const stakingParams = versionInfo?.currentVersion; - const firstActivationHeight = versionInfo?.firstActivationHeight; - const isUpgrading = versionInfo?.isApprochingNextVersion; + const stakingParams = currentVersion; + const isUpgrading = isApprochingNextVersion; const isBlockHeightUnderActivation = !stakingParams || (btcHeight && @@ -235,17 +228,15 @@ export const Staking: React.FC = ({ if (!btcWalletNetwork) throw new Error("Wallet network is not connected"); if (!finalityProvider) throw new Error("Finality provider is not selected"); - if (!versionInfo?.currentVersion) - throw new Error("Global params not loaded"); + if (!currentVersion) throw new Error("Global params not loaded"); if (!feeRate) throw new Error("Fee rates not loaded"); if (!availableUTXOs || availableUTXOs.length === 0) throw new Error("No available balance"); - const { currentVersion: globalParamsVersion } = versionInfo; // Sign the staking transaction const { stakingTxHex, stakingTerm } = await signStakingTx( btcWallet, - globalParamsVersion, + currentVersion, stakingAmountSat, stakingTimeBlocks, finalityProvider.btcPk, @@ -291,30 +282,16 @@ export const Staking: React.FC = ({ // Get the transaction ID const newTxId = Transaction.fromHex(signedTxHex).getId(); - setDelegationsLocalStorage((delegations) => { - // Check if the delegation with the same transaction ID already exists - const exists = delegations.some( - (delegation) => delegation.stakingTxHashHex === newTxId, - ); - - // If it doesn't exist, add the new delegation - if (!exists) { - return [ - toLocalStorageDelegation( - newTxId, - publicKeyNoCoord, - finalityProvider!.btcPk, - stakingAmountSat, - signedTxHex, - stakingTerm, - ), - ...delegations, - ]; - } - - // If it exists, return the existing delegations unchanged - return delegations; - }); + addDelegation( + toLocalStorageDelegation( + newTxId, + publicKeyNoCoord, + finalityProvider!.btcPk, + stakingAmountSat, + signedTxHex, + stakingTerm, + ), + ); }; // Memoize the staking fee calculation @@ -325,7 +302,7 @@ export const Staking: React.FC = ({ publicKeyNoCoord && stakingAmountSat && finalityProvider && - versionInfo?.currentVersion && + currentVersion && mempoolFeeRates && availableUTXOs ) { @@ -337,7 +314,7 @@ export const Staking: React.FC = ({ const memoizedFeeRate = selectedFeeRate || defaultFeeRate; // Calculate the staking fee const { stakingFeeSat } = createStakingTx( - versionInfo.currentVersion, + currentVersion, stakingAmountSat, stakingTimeBlocks, finalityProvider.btcPk, @@ -379,7 +356,7 @@ export const Staking: React.FC = ({ stakingAmountSat, stakingTimeBlocks, finalityProvider, - versionInfo, + currentVersion, mempoolFeeRates, selectedFeeRate, availableUTXOs, diff --git a/src/app/components/Stats/Stats.tsx b/src/app/components/Stats/Stats.tsx index 522cef09..0ea292ca 100644 --- a/src/app/components/Stats/Stats.tsx +++ b/src/app/components/Stats/Stats.tsx @@ -7,7 +7,7 @@ import { StakingStats, useStakingStats, } from "@/app/context/api/StakingStatsProvider"; -import { useVersionInfo } from "@/app/context/api/VersionInfo"; +import { useAppState } from "@/app/state"; import { GlobalParamsVersion } from "@/app/types/globalParams"; import { getNetworkConfig } from "@/config/network.config"; import { satoshiToBtc } from "@/utils/btcConversions"; @@ -81,7 +81,7 @@ export const Stats: React.FC = () => { }); const [isLoading, setIsLoading] = useState(true); const stakingStatsProvider = useStakingStats(); - const versionInfo = useVersionInfo(); + const appState = useAppState(); const { coinName } = getNetworkConfig(); @@ -91,12 +91,12 @@ export const Stats: React.FC = () => { setStakingStats(stakingStatsProvider.data); } setIsLoading( - stakingStatsProvider.isLoading || Boolean(versionInfo?.isLoading), + stakingStatsProvider.isLoading || Boolean(appState?.isLoading), ); - }, [stakingStatsProvider, versionInfo?.isLoading]); + }, [stakingStatsProvider, appState?.isLoading]); const stakingCapText = useMemo(() => { - if (!versionInfo?.currentHeight) { + if (!appState?.currentHeight) { return { title: "Staking TVL Cap", value: "-", @@ -105,8 +105,8 @@ export const Stats: React.FC = () => { const cap = buildStakingCapSection( coinName, - versionInfo.currentHeight, - versionInfo, + appState.currentHeight, + appState, ); return ( @@ -115,7 +115,7 @@ export const Stats: React.FC = () => { value: "-", } ); - }, [coinName, versionInfo]); + }, [coinName, appState]); const formatter = Intl.NumberFormat("en", { notation: "compact", diff --git a/src/app/components/Summary/Summary.tsx b/src/app/components/Summary/Summary.tsx index 75872940..d64ea0ad 100644 --- a/src/app/components/Summary/Summary.tsx +++ b/src/app/components/Summary/Summary.tsx @@ -2,8 +2,10 @@ import { AiOutlineInfoCircle } from "react-icons/ai"; import { FaBitcoin } from "react-icons/fa"; import { Tooltip } from "react-tooltip"; -import { useVersionInfo } from "@/app/context/api/VersionInfo"; +import { useWallet } from "@/app/context/wallet/WalletProvider"; import { useHealthCheck } from "@/app/hooks/useHealthCheck"; +import { useAppState } from "@/app/state"; +import { useDelegationState } from "@/app/state/DelegationState"; import { shouldDisplayPoints } from "@/config"; import { getNetworkConfig } from "@/config/network.config"; import { satoshiToBtc } from "@/utils/btcConversions"; @@ -13,25 +15,17 @@ import { Network } from "@/utils/wallet/wallet_provider"; import { LoadingSmall } from "../Loading/Loading"; import { StakerPoints } from "../Points/StakerPoints"; -interface SummaryProps { - loading?: boolean; - totalStakedSat: number; - btcWalletBalanceSat?: number; - publicKeyNoCoord: string; -} - -export const Summary: React.FC = ({ - loading = false, - totalStakedSat, - btcWalletBalanceSat, - publicKeyNoCoord, -}) => { +export const Summary = () => { const { isApiNormal, isGeoBlocked } = useHealthCheck(); - const versionInfo = useVersionInfo(); + const { totalStaked } = useDelegationState(); + const { totalBalance, currentVersion, isLoading: loading } = useAppState(); + const { address, publicKeyNoCoord } = useWallet(); const { coinName } = getNetworkConfig(); const onMainnet = getNetworkConfig().network === Network.MAINNET; + if (!address) return; + return (

Your staking summary

@@ -44,7 +38,7 @@ export const Summary: React.FC = ({ @@ -54,9 +48,7 @@ export const Summary: React.FC = ({

- {totalStakedSat - ? maxDecimals(satoshiToBtc(totalStakedSat), 8) - : 0}{" "} + {totalStaked ? maxDecimals(satoshiToBtc(totalStaked), 8) : 0}{" "} {coinName}

@@ -104,13 +96,12 @@ export const Summary: React.FC = ({
- {typeof btcWalletBalanceSat === "number" ? ( + {typeof totalBalance === "number" ? (

- {maxDecimals(satoshiToBtc(btcWalletBalanceSat), 8)}{" "} - {coinName} + {maxDecimals(satoshiToBtc(totalBalance), 8)} {coinName}

) : loading ? ( diff --git a/src/app/context/api/VersionInfo.tsx b/src/app/context/api/VersionInfo.tsx deleted file mode 100644 index 75a5d1cd..00000000 --- a/src/app/context/api/VersionInfo.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { createContext, PropsWithChildren, useContext, useMemo } from "react"; - -import { useBTCTipHeight } from "@/app/hooks/useBTCTipHeight"; -import { useVersions } from "@/app/hooks/useVersions"; -import type { GlobalParamsVersion } from "@/app/types/globalParams"; -import { getCurrentGlobalParamsVersion } from "@/utils/globalParams"; - -interface VersionInfoProps { - currentVersion?: GlobalParamsVersion; - currentHeight?: number; - nextVersion?: GlobalParamsVersion; - isApprochingNextVersion: boolean; - firstActivationHeight: number; - isError?: boolean; - isLoading?: boolean; -} - -const VersionInfoContext = createContext({ - isApprochingNextVersion: false, - firstActivationHeight: 0, -}); - -export function useVersionInfo() { - return useContext(VersionInfoContext); -} - -export function VersionInfoProvider({ children }: PropsWithChildren) { - const { - data: versions, - isError: isVersionError, - isLoading: isVersionLoading, - } = useVersions(); - const { - data: height, - isError: isHeightError, - isLoading: isHeightLoading, - } = useBTCTipHeight(); - - const context = useMemo(() => { - if (!versions || !height) - return { - isApprochingNextVersion: false, - firstActivationHeight: 0, - isError: isHeightError || isVersionError, - isLoading: isVersionLoading || isHeightLoading, - }; - - return { - currentHeight: height, - isError: isHeightError || isVersionError, - isLoading: isVersionLoading || isHeightLoading, - ...getCurrentGlobalParamsVersion(height + 1, versions), - }; - }, [ - versions, - height, - isHeightError, - isVersionError, - isVersionLoading, - isHeightLoading, - ]); - - return ( - - {children} - - ); -} diff --git a/src/app/hooks/useDelegations.ts b/src/app/hooks/useDelegations.ts new file mode 100644 index 00000000..3ecca66c --- /dev/null +++ b/src/app/hooks/useDelegations.ts @@ -0,0 +1,56 @@ +import { useInfiniteQuery } from "@tanstack/react-query"; +import { useEffect } from "react"; + +import { + getDelegations, + type PaginatedDelegations, +} from "@/app/api/getDelegations"; +import { useError } from "@/app/context/Error/ErrorContext"; +import { useWallet } from "@/app/context/wallet/WalletProvider"; +import { ErrorState } from "@/app/types/errors"; + +export const DELEGATIONS_KEY = "DELEGATIONS"; + +export function useDelegations({ enabled = true }: { enabled?: boolean } = {}) { + const { publicKeyNoCoord } = useWallet(); + const { isErrorOpen, handleError } = useError(); + + const query = useInfiniteQuery({ + queryKey: [DELEGATIONS_KEY, publicKeyNoCoord], + queryFn: ({ pageParam = "" }) => + getDelegations(pageParam, publicKeyNoCoord), + getNextPageParam: (lastPage) => + lastPage?.pagination?.next_key !== "" + ? lastPage?.pagination?.next_key + : null, + initialPageParam: "", + refetchInterval: 60000, // 1 minute + enabled: Boolean(publicKeyNoCoord) && enabled, + select: (data) => { + const flattenedData = data.pages.reduce( + (acc, page) => { + acc.delegations.push(...page.delegations); + acc.pagination = page.pagination; + return acc; + }, + { delegations: [], pagination: { next_key: "" } }, + ); + + return flattenedData; + }, + retry: (failureCount, _error) => { + return !isErrorOpen && failureCount <= 3; + }, + }); + + useEffect(() => { + handleError({ + error: query.error, + hasError: query.isError, + errorState: ErrorState.SERVER_ERROR, + refetchFunction: query.refetch, + }); + }, [query.isError, query.error, query.refetch, handleError]); + + return query; +} diff --git a/src/app/hooks/useUTXOs.ts b/src/app/hooks/useUTXOs.ts new file mode 100644 index 00000000..99b880a6 --- /dev/null +++ b/src/app/hooks/useUTXOs.ts @@ -0,0 +1,34 @@ +import { ONE_MINUTE } from "@/app/constants"; +import { useWallet } from "@/app/context/wallet/WalletProvider"; +import { useAPIQuery } from "@/app/hooks/useApi"; +import { filterOrdinals } from "@/utils/utxo"; + +export const UTXO_KEY = "UTXO"; + +export function useUTXOs({ enabled = true }: { enabled?: boolean } = {}) { + const { walletProvider, address } = useWallet(); + + const fetchAvailableUTXOs = async () => { + if (!walletProvider?.getUtxos || !address) { + return; + } + + const mempoolUTXOs = await walletProvider.getUtxos(address); + const filteredUTXOs = await filterOrdinals( + mempoolUTXOs, + address, + walletProvider.getInscriptions, + ); + + return filteredUTXOs; + }; + + const data = useAPIQuery({ + queryKey: [UTXO_KEY, address], + queryFn: fetchAvailableUTXOs, + enabled: Boolean(walletProvider?.getUtxos) && Boolean(address) && enabled, + refetchInterval: 5 * ONE_MINUTE, + }); + + return data; +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 41134c25..6663b85c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,19 +1,12 @@ "use client"; import { initBTCCurve } from "@babylonlabs-io/btc-staking-ts"; -import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { useEffect } from "react"; -import { useLocalStorage } from "usehooks-ts"; +import { twJoin } from "tailwind-merge"; import { network } from "@/config/network.config"; -import { calculateDelegationsDiff } from "@/utils/local_storage/calculateDelegationsDiff"; -import { getDelegationsLocalStorageKey } from "@/utils/local_storage/getDelegationsLocalStorageKey"; -import { filterOrdinals } from "@/utils/utxo"; import { Network } from "@/utils/wallet/wallet_provider"; -import { getDelegations, PaginatedDelegations } from "./api/getDelegations"; -import { UTXO_KEY } from "./common/constants"; -import { signPsbtTransaction } from "./common/utils/psbt"; import { Delegations } from "./components/Delegations/Delegations"; import { FAQ } from "./components/FAQ/FAQ"; import { Footer } from "./components/Footer/Footer"; @@ -22,218 +15,27 @@ import { NetworkBadge } from "./components/NetworkBadge/NetworkBadge"; import { Staking } from "./components/Staking/Staking"; import { Stats } from "./components/Stats/Stats"; import { Summary } from "./components/Summary/Summary"; -import { useVersionInfo } from "./context/api/VersionInfo"; -import { useError } from "./context/Error/ErrorContext"; -import { useWallet } from "./context/wallet/WalletProvider"; -import { Delegation, DelegationState } from "./types/delegations"; -import { ErrorState } from "./types/errors"; - -interface HomeProps {} - -const Home: React.FC = () => { - const { - walletProvider: btcWallet, - network: btcWalletNetwork, - address, - publicKeyNoCoord, - } = useWallet(); - - const { isErrorOpen, showError, handleError } = useError(); - - const versionInfo = useVersionInfo(); - - const { - data: delegations, - fetchNextPage: fetchNextDelegationsPage, - hasNextPage: hasNextDelegationsPage, - isFetchingNextPage: isFetchingNextDelegationsPage, - error: delegationsError, - isError: hasDelegationsError, - refetch: refetchDelegationData, - } = useInfiniteQuery({ - queryKey: ["delegations", address, publicKeyNoCoord], - queryFn: ({ pageParam = "" }) => - getDelegations(pageParam, publicKeyNoCoord), - getNextPageParam: (lastPage) => - lastPage?.pagination?.next_key !== "" - ? lastPage?.pagination?.next_key - : null, - initialPageParam: "", - refetchInterval: 60000, // 1 minute - enabled: !!(btcWallet && publicKeyNoCoord && address), - select: (data) => { - const flattenedData = data.pages.reduce( - (acc, page) => { - acc.delegations.push(...page.delegations); - acc.pagination = page.pagination; - return acc; - }, - { delegations: [], pagination: { next_key: "" } }, - ); - - return flattenedData; - }, - retry: (failureCount, _error) => { - return !isErrorOpen && failureCount <= 3; - }, - }); - - // Fetch all UTXOs - const { - data: availableUTXOs, - error: availableUTXOsError, - isLoading: isLoadingAvailableUTXOs, - isError: hasAvailableUTXOsError, - refetch: refetchAvailableUTXOs, - } = useQuery({ - queryKey: [UTXO_KEY, address], - queryFn: async () => { - if (btcWallet?.getUtxos && address) { - // all confirmed UTXOs from the wallet - const mempoolUTXOs = await btcWallet.getUtxos(address); - // filter out the ordinals - const filteredUTXOs = await filterOrdinals( - mempoolUTXOs, - address, - btcWallet.getInscriptions, - ); - return filteredUTXOs; - } - }, - enabled: !!(btcWallet?.getUtxos && address), - refetchInterval: 60000 * 5, // 5 minutes - retry: (failureCount) => { - return !isErrorOpen && failureCount <= 3; - }, - }); - useEffect(() => { - handleError({ - error: delegationsError, - hasError: hasDelegationsError, - errorState: ErrorState.SERVER_ERROR, - refetchFunction: refetchDelegationData, - }); - handleError({ - error: availableUTXOsError, - hasError: hasAvailableUTXOsError, - errorState: ErrorState.SERVER_ERROR, - refetchFunction: refetchAvailableUTXOs, - }); - }, [ - hasDelegationsError, - delegationsError, - refetchDelegationData, - showError, - availableUTXOsError, - hasAvailableUTXOsError, - refetchAvailableUTXOs, - handleError, - ]); - - // Initializing btc curve is a required one-time operation +const Home = () => { useEffect(() => { initBTCCurve(); }, []); - // Local storage state for delegations - const delegationsLocalStorageKey = - getDelegationsLocalStorageKey(publicKeyNoCoord); - - const [delegationsLocalStorage, setDelegationsLocalStorage] = useLocalStorage< - Delegation[] - >(delegationsLocalStorageKey, []); - - // Clean up the local storage delegations - useEffect(() => { - if (!delegations?.delegations) { - return; - } - - const updateDelegationsLocalStorage = async () => { - const { areDelegationsDifferent, delegations: newDelegations } = - await calculateDelegationsDiff( - delegations.delegations, - delegationsLocalStorage, - ); - if (areDelegationsDifferent) { - setDelegationsLocalStorage(newDelegations); - } - }; - - updateDelegationsLocalStorage(); - }, [delegations, setDelegationsLocalStorage, delegationsLocalStorage]); - - let totalStakedSat = 0; - - if (delegations) { - totalStakedSat = delegations.delegations - // using only active delegations - .filter((delegation) => delegation?.state === DelegationState.ACTIVE) - .reduce( - (accumulator: number, item) => accumulator + item?.stakingValueSat, - 0, - ); - } - - // Balance is reduced confirmed UTXOs with ordinals - const btcWalletBalanceSat = availableUTXOs?.reduce( - (accumulator, item) => accumulator + item.value, - 0, - ); - return (
- -
+ +
- {address && ( - - )} - - {btcWallet && - delegations && - versionInfo?.currentVersion && - btcWalletNetwork && ( - - )} - {/* At this point of time is not used */} - {/* */} + + +
diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 531c7669..43632544 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -8,9 +8,9 @@ import React from "react"; import { ErrorProvider } from "./context/Error/ErrorContext"; import { StakingStatsProvider } from "./context/api/StakingStatsProvider"; -import { VersionInfoProvider } from "./context/api/VersionInfo"; import { BtcHeightProvider } from "./context/mempool/BtcHeightProvider"; import { WalletProvider } from "./context/wallet/WalletProvider"; +import { AppState } from "./state"; function Providers({ children }: React.PropsWithChildren) { const [client] = React.useState(new QueryClient()); @@ -20,7 +20,7 @@ function Providers({ children }: React.PropsWithChildren) { - + @@ -28,7 +28,7 @@ function Providers({ children }: React.PropsWithChildren) { - + void; + fetchMoreDelegations: () => void; +} + +const { StateProvider, useState } = createStateUtils({ + isLoading: false, + delegations: [], + totalStaked: 0, + hasMoreDelegations: false, + addDelegation: () => null, + fetchMoreDelegations: () => null, +}); + +export function DelegationState({ children }: PropsWithChildren) { + const { publicKeyNoCoord } = useWallet(); + const { data, fetchNextPage, isFetchingNextPage, hasNextPage } = + useDelegations(); + + // States + const [delegations, setDelegations] = useLocalStorage( + getDelegationsKey(publicKeyNoCoord), + [], + ); + + // Effects + useEffect( + function syncDelegations() { + if (!data?.delegations) { + return; + } + + const updateDelegations = async () => { + const { areDelegationsDifferent, delegations: newDelegations } = + await calculateDelegationsDiff(data.delegations, delegations); + if (areDelegationsDifferent) { + setDelegations(newDelegations); + } + }; + + updateDelegations(); + }, + [data?.delegations, setDelegations, delegations], + ); + + // Computed + const totalStaked = useMemo( + () => + (data?.delegations ?? []) + .filter((delegation) => delegation?.state === DelegationEnum.ACTIVE) + .reduce( + (accumulator: number, item) => accumulator + item?.stakingValueSat, + 0, + ), + [data?.delegations], + ); + + // Methods + const addDelegation = useCallback((newDelegation: Delegation) => { + setDelegations((delegations) => { + const exists = delegations.some( + (delegation) => + delegation.stakingTxHashHex === newDelegation.stakingTxHashHex, + ); + + if (!exists) { + return [newDelegation, ...delegations]; + } + + return delegations; + }); + }, []); + + // Context + const state = useMemo( + () => ({ + delegations, + totalStaked, + isLoading: isFetchingNextPage, + hasMoreDelegations: hasNextPage, + addDelegation, + fetchMoreDelegations: fetchNextPage, + }), + [delegations, totalStaked, isFetchingNextPage, hasNextPage, addDelegation], + ); + + return {children}; +} + +export const useDelegationState = useState; diff --git a/src/app/state/index.tsx b/src/app/state/index.tsx new file mode 100644 index 00000000..23df50ea --- /dev/null +++ b/src/app/state/index.tsx @@ -0,0 +1,103 @@ +import { useMemo, type PropsWithChildren } from "react"; + +import { useBTCTipHeight } from "@/app/hooks/useBTCTipHeight"; +import { useUTXOs } from "@/app/hooks/useUTXOs"; +import { useVersions } from "@/app/hooks/useVersions"; +import { GlobalParamsVersion } from "@/app/types/globalParams"; +import { createStateUtils } from "@/utils/createState"; +import { getCurrentGlobalParamsVersion } from "@/utils/globalParams"; +import type { UTXO } from "@/utils/wallet/wallet_provider"; + +import { DelegationState } from "./DelegationState"; + +const STATE_LIST = [DelegationState]; + +interface AppState { + availableUTXOs?: UTXO[]; + totalBalance: number; + nextVersion?: GlobalParamsVersion; + currentVersion?: GlobalParamsVersion; + currentHeight?: number; + isApprochingNextVersion: boolean; + firstActivationHeight: number; + isError: boolean; + isLoading: boolean; +} + +const { StateProvider, useState } = createStateUtils({ + isLoading: false, + isError: false, + totalBalance: 0, + isApprochingNextVersion: false, + firstActivationHeight: 0, +}); + +const defaultVersionParams = { + isApprochingNextVersion: false, + firstActivationHeight: 0, + currentVersion: undefined, + nextVersion: undefined, +}; + +export function AppState({ children }: PropsWithChildren) { + // States + const { + data: availableUTXOs = [], + isLoading: isUTXOLoading, + isError: isUTXOError, + } = useUTXOs(); + const { + data: versions, + isError: isVersionError, + isLoading: isVersionLoading, + } = useVersions(); + const { + data: height, + isError: isHeightError, + isLoading: isHeightLoading, + } = useBTCTipHeight(); + + // Computed + const isLoading = isVersionLoading || isHeightLoading || isUTXOLoading; + const isError = isHeightError || isVersionError || isUTXOError; + + const totalBalance = useMemo( + () => + availableUTXOs.reduce((accumulator, item) => accumulator + item.value, 0), + [availableUTXOs], + ); + + const versionInfo = useMemo( + () => + versions && height + ? getCurrentGlobalParamsVersion(height + 1, versions) + : defaultVersionParams, + [versions, height], + ); + + // Context + const context = useMemo( + () => ({ + availableUTXOs, + currentHeight: height, + totalBalance, + ...versionInfo, + isError, + isLoading, + }), + [availableUTXOs, height, totalBalance, versionInfo, isError, isLoading], + ); + + const states = useMemo( + () => + STATE_LIST.reduceRight( + (children, State) => {children}, + children, + ), + [children], + ); + + return {states}; +} + +export const useAppState = useState; diff --git a/src/utils/createState.ts b/src/utils/createState.ts new file mode 100644 index 00000000..c834d9d1 --- /dev/null +++ b/src/utils/createState.ts @@ -0,0 +1,12 @@ +import { createContext, useContext } from "react"; + +export function createStateUtils(defaultState: S) { + const stateContext = createContext(defaultState); + + return { + StateProvider: stateContext.Provider, + useState: () => { + return useContext(stateContext); + }, + }; +}