= ({ title, content }) => {
+ return (
+
+
+
+ expanded ? (
+
+ ) : (
+
+ )
+ }
+ >
+
+ {title}
+
+
+
+ {content}
+
+
+
+ );
+};
diff --git a/src/ui/common/components/FAQ/data/questions.tsx b/src/ui/common/components/FAQ/data/questions.tsx
new file mode 100644
index 000000000..564f03e91
--- /dev/null
+++ b/src/ui/common/components/FAQ/data/questions.tsx
@@ -0,0 +1,220 @@
+import { ReactNode } from "react";
+
+export interface Question {
+ title: string;
+ content: ReactNode;
+}
+
+export const questions = (coinName: string): Question[] => {
+ const questionList = [
+ {
+ title: "What is Babylon?",
+ content: (
+
+ Babylon is a suite of security-sharing protocols that bring
+ Bitcoin's unparalleled security to the decentralized world. The
+ latest protocol, Bitcoin Staking, enables Bitcoin holders to stake
+ their Bitcoin to provide crypto-economic security to PoS
+ (proof-of-stake) systems in a trustless and self-custodial way.
+
+ ),
+ },
+ {
+ title: "How does Bitcoin Staking work?",
+ content: (
+ <>
+
+ {coinName} holders lock their {coinName} using the trustless and
+ self-custodial Bitcoin Staking script for a predetermined time
+ (timelock) in exchange for voting power in an underlying PoS
+ protocol. In return, Bitcoin holders will earn PoS staking rewards.
+
+
+
+ Finality Providers perform the voting. A Finality Provider is an
+ entity responsible for casting votes on behalf of {coinName}{" "}
+ stakers, helping secure the PoS protocol.
+
+
+
+ If a Finality Provider attacks the PoS system, the {coinName}s
+ behind the voting powers delegated to it will be subject to protocol
+ slashing. This deters {coinName} stakers and Finality Providers from
+ attacking the PoS system.
+
+ >
+ ),
+ },
+ {
+ title: "What does this staking dApp allow me to do?",
+ content: (
+
+ The staking dApp is an interface to the Babylon Bitcoin Staking
+ protocol. It interacts with both the Bitcoin and Babylon Genesis
+ blockchains to create Bitcoin staking transactions, and to register
+ the stake and delegation of voting power to a selected Finality
+ Provider on the Babylon Genesis chain. The staked Bitcoin provides
+ slashable proof-of-stake security to Babylon Genesis and earns BABY as
+ staking reward.
+
+ ),
+ },
+ {
+ title: `Does my ${coinName} leave my wallet once staked?`,
+ content: (
+
+ Your {coinName} does not leave your custody. It is important to note
+ that once your {coinName} is staked, your wallet will not display your
+ locked {coinName} balance. This is because the current wallet software
+ has not been updated to display staked {coinName} balances. When
+ staking, you do not send the {coinName} to a third party. It is locked
+ in a self-custodial Bitcoin Staking script that you control. This
+ means that any subsequent movement of the {coinName} will need your
+ approval. You are the only one who can unbond the stake and withdraw.
+
+ ),
+ },
+ {
+ title: "Are there any other ways to stake?",
+ content: (
+
+ Users with a technical background can use the{" "}
+
+ btc-staker CLI program
+ {" "}
+ to create {coinName} staking transactions from the CLI.
+
+ ),
+ },
+ {
+ title:
+ "Is it ok to use a wallet holding fungible tokens built on Bitcoin (e.g. BRC-20/ARC-20/Runes)?",
+ content: (
+
+ No, this should be avoided. Please do not connect or use a Bitcoin
+ wallet holding BRC-20, ARC-20, Runes, or other NFTs or Bitcoin-native
+ assets (other than {coinName}). They are still in their infancy and in
+ an experimental phase. Software built for the detection of such tokens
+ to avoid their misspending may not work, and you could lose all such
+ tokens.
+
+ ),
+ },
+ {
+ title:
+ "If I have multiple stakes funded by the same BTC address but using different BABY addresses, is there a way for me to view all?",
+ content: (
+
+ Yes. Click the dropdown next to the 'Connect Wallets' button
+ or the 'Wallets Connected' area, and toggle on "Linked
+ Wallet Stakes". This will display all delegations associated with
+ the connected {coinName} key, regardless of which Babylon account the
+ stakes are associated with.
+
+ ),
+ },
+ {
+ title: "Is there a staking cap?",
+ content: (
+
+ The Babylon Genesis launch included a two-week period (April 10 – 24,
+ 2025) during which registration was capped at Phase-1 Cap-1 stakes
+ (1,000 bitcoins). The system is now permissionless, with no cap.
+
+ ),
+ },
+ {
+ title: "Why do I need to connect two wallets?",
+ content: (
+
+ Because Bitcoin and Babylon Genesis are different networks with
+ different address formats, you'll need to connect two wallets.
+ However, If your wallet supports both networks, you can use it for
+ both connections.
+
+ ),
+ },
+ {
+ title: "Are there any geo-restrictions for accessing Babylon Genesis?",
+ content: (
+
+ Due to applicable laws and regulations, Babylon Genesis may not be
+ available in all jurisdictions. Users are advised to consult the{" "}
+
+ Terms of Use
+ {" "}
+ to determine access eligibility based on their location.
+
+ ),
+ },
+ {
+ title: "How should I choose my finality provider?",
+ content: (
+
+ If you'd like to learn more about a specific Finality Provider,
+ we recommend doing your own due diligence by starting with their
+ website or social channels.
+
+ ),
+ },
+ {
+ title: "How long will it take for my stake to become active?",
+ content: (
+
+ Your stake becomes active after it receives at least 10 Bitcoin block
+ confirmations and is registered and verified by the Babylon Genesis
+ chain. This process typically takes around 100 minutes, depending on
+ Bitcoin network conditions.
+
+ ),
+ },
+ {
+ title: "What is slashing and can it happen to me?",
+ content: (
+
+ When you stake {coinName} in Babylon Genesis, your {coinName} remains
+ locked in a self-custodial Bitcoin script — you do not transfer
+ custody to Babylon or any third party. However, staking carries
+ slashing risk. When you stake, you pre-authorize a slashing condition
+ within the Bitcoin script. If a cryptographic offense occurs — such as
+ a Finality Provider (FP) you staked against double-signing — a
+ predefined percentage of your staked {coinName} can be slashed
+ (burned) without needing further authorization from you.
+
+ ),
+ },
+ {
+ title: "Will I pay any fees for staking?",
+ content: (
+ <>
+ Yes. There are two types of fees:
+
+ Babylon Genesis Network Fees
+
+ You'll pay a gas fee when registering your stake and when
+ claiming rewards.
+
+
+ Bitcoin Network Fees
+
+ {coinName} is required to cover fees for staking, unbonding, and
+ withdrawing.
+
+
+ Fees vary depending on network conditions.
+ >
+ ),
+ },
+ ];
+ return questionList;
+};
diff --git a/src/ui/common/components/FAQ/index.ts b/src/ui/common/components/FAQ/index.ts
new file mode 100644
index 000000000..5054f901d
--- /dev/null
+++ b/src/ui/common/components/FAQ/index.ts
@@ -0,0 +1 @@
+export { FAQ } from "./FAQ";
diff --git a/src/ui/common/components/Footer/Footer.tsx b/src/ui/common/components/Footer/Footer.tsx
new file mode 100644
index 000000000..1d69ed363
--- /dev/null
+++ b/src/ui/common/components/Footer/Footer.tsx
@@ -0,0 +1,116 @@
+import { Text } from "@babylonlabs-io/core-ui";
+import {
+ BsDiscord,
+ BsGithub,
+ BsLinkedin,
+ BsMedium,
+ BsTelegram,
+} from "react-icons/bs";
+import { FaXTwitter } from "react-icons/fa6";
+import { GoHome } from "react-icons/go";
+import { IoMdBook } from "react-icons/io";
+import { MdAlternateEmail, MdForum } from "react-icons/md";
+
+import { Container } from "@/ui/common/components/Container/Container";
+
+import { Logo } from "../Logo/Logo";
+
+const iconLinks = [
+ {
+ name: "Website",
+ url: "https://babylonlabs.io",
+ Icon: GoHome,
+ },
+ {
+ name: "X",
+ url: "https://x.com/babylonlabs_io",
+ Icon: FaXTwitter,
+ },
+ {
+ name: "GitHub",
+ url: "https://github.com/babylonlabs-io",
+ Icon: BsGithub,
+ },
+ {
+ name: "Telegram",
+ url: "https://t.me/babyloncommunity",
+ Icon: BsTelegram,
+ },
+ {
+ name: "LinkedIn",
+ url: "https://www.linkedin.com/company/babylon-labs-official",
+ Icon: BsLinkedin,
+ },
+ {
+ name: "Medium",
+ url: "https://medium.com/babylonlabs-io",
+ Icon: BsMedium,
+ },
+ {
+ name: "Docs",
+ url: "https://docs.babylonlabs.io/",
+ Icon: IoMdBook,
+ },
+ {
+ name: "Forum",
+ url: "https://forum.babylonlabs.io/",
+ Icon: MdForum,
+ },
+ {
+ name: "Email",
+ url: "mailto:contact@babylonlabs.io",
+ Icon: MdAlternateEmail,
+ },
+ {
+ name: "Discord",
+ url: "https://discord.com/invite/babylonglobal",
+ Icon: BsDiscord,
+ },
+];
+
+export const Footer: React.FC = () => {
+ return (
+
+ );
+};
diff --git a/src/ui/common/components/Hash/Hash.tsx b/src/ui/common/components/Hash/Hash.tsx
new file mode 100644
index 000000000..07e1647a8
--- /dev/null
+++ b/src/ui/common/components/Hash/Hash.tsx
@@ -0,0 +1,88 @@
+import { Text } from "@babylonlabs-io/core-ui";
+import { useEffect, useState } from "react";
+import { FiCopy } from "react-icons/fi";
+import { IoIosCheckmarkCircle } from "react-icons/io";
+import { twMerge } from "tailwind-merge";
+import { useCopyToClipboard } from "usehooks-ts";
+
+import { trim } from "@/ui/common/utils/trim";
+
+interface HashProps {
+ value: string;
+ noFade?: boolean;
+ address?: boolean;
+ small?: boolean;
+ fullWidth?: boolean;
+ symbols?: number;
+ size?: React.ComponentProps["variant"];
+ className?: string;
+ noCopy?: boolean;
+}
+
+export const Hash: React.FC = ({
+ value,
+ noFade,
+ address,
+ small,
+ fullWidth,
+ className,
+ symbols = 8,
+ noCopy = false,
+ size = "body2",
+}) => {
+ const [, copy] = useCopyToClipboard();
+ const [copiedText, setCopiedText] = useState("");
+
+ const handleCopy = () => {
+ if (!value || noCopy) return;
+ setCopiedText("Copied!");
+ copy(value);
+ };
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setCopiedText("");
+ }, 2000);
+ return () => clearTimeout(timer);
+ }, [copiedText]);
+
+ if (!value) {
+ return - ;
+ }
+
+ return (
+
+
+ {copiedText ? (
+ copiedText
+ ) : (
+ <>
+ {!address && 0x }
+ {trim(value, symbols) ?? value}
+ >
+ )}
+
+ {!noCopy &&
+ (copiedText ? (
+
+ ) : (
+
+ ))}
+
+ );
+};
diff --git a/src/ui/common/components/Header/Header.tsx b/src/ui/common/components/Header/Header.tsx
new file mode 100644
index 000000000..8e51bc3f9
--- /dev/null
+++ b/src/ui/common/components/Header/Header.tsx
@@ -0,0 +1,24 @@
+import { useWalletConnect } from "@babylonlabs-io/wallet-connector";
+
+import { Container } from "@/ui/common/components/Container/Container";
+import { useAppState } from "@/ui/common/state";
+
+import { SmallLogo } from "../Logo/SmallLogo";
+import { Connect } from "../Wallet/Connect";
+
+export const Header = () => {
+ const { open } = useWalletConnect();
+ const { isLoading: loading } = useAppState();
+
+ return (
+
+ );
+};
diff --git a/src/ui/common/components/Header/SimplifiedHeader.tsx b/src/ui/common/components/Header/SimplifiedHeader.tsx
new file mode 100644
index 000000000..75da9b221
--- /dev/null
+++ b/src/ui/common/components/Header/SimplifiedHeader.tsx
@@ -0,0 +1,25 @@
+import { twJoin } from "tailwind-merge";
+
+import { Container } from "../Container/Container";
+import { Logo } from "../Logo/Logo";
+
+export const SimplifiedHeader = ({
+ isMinimal = false,
+}: {
+ isMinimal?: boolean;
+}) => {
+ return (
+
+
+
+ );
+};
diff --git a/src/ui/common/components/Icons/ThemedIcon.tsx b/src/ui/common/components/Icons/ThemedIcon.tsx
new file mode 100644
index 000000000..87fa92ed1
--- /dev/null
+++ b/src/ui/common/components/Icons/ThemedIcon.tsx
@@ -0,0 +1,55 @@
+import { twJoin } from "tailwind-merge";
+
+interface ThemedIconProps {
+ children: React.ReactNode;
+ className?: string;
+ size?: number;
+ variant?: "default" | "primary" | "secondary" | "error" | "success";
+ background?: boolean;
+ rounded?: boolean;
+}
+
+export const ThemedIcon = ({
+ children,
+ className = "",
+ size = 40,
+ variant = "default",
+ background = false,
+ rounded = false,
+}: ThemedIconProps) => {
+ const variants = {
+ default: "text-accent-secondary",
+ primary: "text-primary-light",
+ secondary: "text-accent-secondary",
+ error: "text-error-main",
+ success: "text-success-main",
+ };
+
+ const backgroundStyles = {
+ default: "bg-accent-secondary/10",
+ primary: "bg-primary-light/10 dark:bg-[#47484A]",
+ secondary: "bg-accent-secondary/10",
+ error: "bg-error-main/10",
+ success: "bg-success-main/10",
+ };
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/ui/common/components/Icons/common/CopyIcon.tsx b/src/ui/common/components/Icons/common/CopyIcon.tsx
new file mode 100644
index 000000000..3dcdfdadf
--- /dev/null
+++ b/src/ui/common/components/Icons/common/CopyIcon.tsx
@@ -0,0 +1,19 @@
+import { BaseIconProps } from "../index";
+
+export const CopyIcon = ({ className = "", size = 16 }: BaseIconProps) => {
+ return (
+
+
+
+ );
+};
diff --git a/src/ui/common/components/Icons/index.ts b/src/ui/common/components/Icons/index.ts
new file mode 100644
index 000000000..488747ddd
--- /dev/null
+++ b/src/ui/common/components/Icons/index.ts
@@ -0,0 +1,20 @@
+// Base icon props interface
+export interface BaseIconProps {
+ className?: string;
+ size?: number;
+}
+
+// Extended icon props with variant support
+export interface IconProps extends BaseIconProps {
+ variant?: "default" | "primary" | "secondary" | "error" | "success";
+}
+
+export { ThemedIcon } from "./ThemedIcon";
+
+// Wallet icons
+export { BitcoinPublicKeyIcon } from "./wallet/BitcoinPublicKeyIcon";
+export { LinkWalletIcon } from "./wallet/LinkWalletIcon";
+export { UsingInscriptionIcon } from "./wallet/UsingInscriptionIcon";
+
+// Common icons
+export { CopyIcon } from "./common/CopyIcon";
diff --git a/src/ui/common/components/Icons/wallet/BitcoinPublicKeyIcon.tsx b/src/ui/common/components/Icons/wallet/BitcoinPublicKeyIcon.tsx
new file mode 100644
index 000000000..ccd569735
--- /dev/null
+++ b/src/ui/common/components/Icons/wallet/BitcoinPublicKeyIcon.tsx
@@ -0,0 +1,20 @@
+import { BaseIconProps } from "../index";
+import { ThemedIcon } from "../ThemedIcon";
+
+export const BitcoinPublicKeyIcon = ({
+ className = "",
+ size = 24,
+}: BaseIconProps) => {
+ return (
+
+
+
+
+
+ );
+};
diff --git a/src/ui/common/components/Icons/wallet/LinkWalletIcon.tsx b/src/ui/common/components/Icons/wallet/LinkWalletIcon.tsx
new file mode 100644
index 000000000..012c0ddb5
--- /dev/null
+++ b/src/ui/common/components/Icons/wallet/LinkWalletIcon.tsx
@@ -0,0 +1,20 @@
+import { BaseIconProps } from "../index";
+import { ThemedIcon } from "../ThemedIcon";
+
+export const LinkWalletIcon = ({
+ className = "",
+ size = 24,
+}: BaseIconProps) => {
+ return (
+
+
+
+
+
+ );
+};
diff --git a/src/ui/common/components/Icons/wallet/UsingInscriptionIcon.tsx b/src/ui/common/components/Icons/wallet/UsingInscriptionIcon.tsx
new file mode 100644
index 000000000..9f03d5fac
--- /dev/null
+++ b/src/ui/common/components/Icons/wallet/UsingInscriptionIcon.tsx
@@ -0,0 +1,21 @@
+import { BaseIconProps } from "../index";
+import { ThemedIcon } from "../ThemedIcon";
+
+export const UsingInscriptionIcon = ({
+ className = "",
+ size = 24,
+}: BaseIconProps) => {
+ return (
+
+
+
+
+
+
+ );
+};
diff --git a/src/ui/common/components/Loading/Loading.tsx b/src/ui/common/components/Loading/Loading.tsx
new file mode 100644
index 000000000..1653397fe
--- /dev/null
+++ b/src/ui/common/components/Loading/Loading.tsx
@@ -0,0 +1,38 @@
+import { twJoin } from "tailwind-merge";
+
+interface LoadingProps {
+ text?: string;
+ noBorder?: boolean;
+}
+
+export const LoadingView: React.FC = ({ text, noBorder }) => {
+ return (
+
+
+
{text || "Please wait..."}
+
+ );
+};
+
+export const LoadingTableList: React.FC = () => {
+ return (
+
+ );
+};
+
+export const LoadingSmall: React.FC = ({ text }) => {
+ return (
+
+
{text || "Please wait..."}
+
+
+ );
+};
diff --git a/src/ui/common/components/Logo/Icon.tsx b/src/ui/common/components/Logo/Icon.tsx
new file mode 100644
index 000000000..7073e41fb
--- /dev/null
+++ b/src/ui/common/components/Logo/Icon.tsx
@@ -0,0 +1,33 @@
+import { useTheme } from "next-themes";
+import { useEffect, useState } from "react";
+
+import darkIcon from "@/ui/common/assets/icon-black.svg";
+import lightIcon from "@/ui/common/assets/icon-white.svg";
+
+export const Icon = () => {
+ const [mounted, setMounted] = useState(false);
+ const { resolvedTheme } = useTheme();
+ const lightSelected = resolvedTheme === "light";
+
+ // useEffect only runs on the client, so now we can safely show the UI
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ // uses placeholder of babylon logo with primary color
+ // since before theme is resolved, we don't know which logo to show
+ if (!mounted) {
+ return ;
+ }
+
+ return (
+
+
+
+ );
+};
diff --git a/src/ui/common/components/Logo/Logo.tsx b/src/ui/common/components/Logo/Logo.tsx
new file mode 100644
index 000000000..7bb26e7cd
--- /dev/null
+++ b/src/ui/common/components/Logo/Logo.tsx
@@ -0,0 +1,52 @@
+import { twMerge } from "tailwind-merge";
+
+interface LogoProps {
+ className?: string;
+}
+
+export const Logo: React.FC = ({ className }) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/src/ui/common/components/Logo/SmallLogo.tsx b/src/ui/common/components/Logo/SmallLogo.tsx
new file mode 100644
index 000000000..36fbeadc7
--- /dev/null
+++ b/src/ui/common/components/Logo/SmallLogo.tsx
@@ -0,0 +1,48 @@
+export const SmallLogo: React.FC = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/src/ui/common/components/Menu/SettingMenu/components/SettingMenuButton.tsx b/src/ui/common/components/Menu/SettingMenu/components/SettingMenuButton.tsx
new file mode 100644
index 000000000..4a2de16e3
--- /dev/null
+++ b/src/ui/common/components/Menu/SettingMenu/components/SettingMenuButton.tsx
@@ -0,0 +1,32 @@
+import type { HTMLAttributes } from "react";
+import { forwardRef } from "react";
+import { twJoin } from "tailwind-merge";
+
+import cogIcon from "@/ui/common/assets/cog.svg";
+
+interface SettingMenuButtonProps extends HTMLAttributes {
+ toggleMenu: () => void;
+}
+export const SettingMenuButton = forwardRef<
+ HTMLButtonElement,
+ SettingMenuButtonProps
+>(({ className, toggleMenu, ...props }, ref) => {
+ return (
+ {
+ toggleMenu();
+ props.onClick?.(e);
+ }}
+ className={twJoin(
+ "flex items-center justify-center w-10 h-10 p-1 border-secondary-contrast text-secondary-contrast",
+ className,
+ )}
+ >
+
+
+ );
+});
+
+SettingMenuButton.displayName = "SettingMenuButton";
diff --git a/src/ui/common/components/Menu/SettingMenu/components/SettingMenuContainer.tsx b/src/ui/common/components/Menu/SettingMenu/components/SettingMenuContainer.tsx
new file mode 100644
index 000000000..5ad49c747
--- /dev/null
+++ b/src/ui/common/components/Menu/SettingMenu/components/SettingMenuContainer.tsx
@@ -0,0 +1,42 @@
+import { MobileDialog, Popover } from "@babylonlabs-io/core-ui";
+
+import { useIsMobileView } from "@/ui/common/hooks/useBreakpoint";
+
+interface SettingMenuContainerProps {
+ anchorEl: HTMLElement | null;
+ children: React.ReactNode;
+ className?: string;
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+export const SettingMenuContainer = ({
+ anchorEl,
+ children,
+ className,
+ isOpen,
+ onClose,
+}: SettingMenuContainerProps) => {
+ const isMobileView = useIsMobileView();
+
+ if (isMobileView) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/ui/common/components/Menu/SettingMenu/components/SettingMenuContent.tsx b/src/ui/common/components/Menu/SettingMenu/components/SettingMenuContent.tsx
new file mode 100644
index 000000000..4ffa39f75
--- /dev/null
+++ b/src/ui/common/components/Menu/SettingMenu/components/SettingMenuContent.tsx
@@ -0,0 +1,14 @@
+import { ThemeToggle } from "@/ui/common/components/ThemeToggle/ThemeToggle";
+interface SettingMenuContentProps {
+ className?: string;
+}
+
+export const SettingMenuContent = ({ className }: SettingMenuContentProps) => {
+ return (
+
+ );
+};
diff --git a/src/ui/common/components/Menu/SettingMenu/index.ts b/src/ui/common/components/Menu/SettingMenu/index.ts
new file mode 100644
index 000000000..a87f4b7e9
--- /dev/null
+++ b/src/ui/common/components/Menu/SettingMenu/index.ts
@@ -0,0 +1,3 @@
+export { SettingMenuButton } from "./components/SettingMenuButton";
+export { SettingMenuContainer } from "./components/SettingMenuContainer";
+export { SettingMenuContent } from "./components/SettingMenuContent";
diff --git a/src/ui/common/components/Menu/WalletMenu/components/Toggle.tsx b/src/ui/common/components/Menu/WalletMenu/components/Toggle.tsx
new file mode 100644
index 000000000..f5eaab401
--- /dev/null
+++ b/src/ui/common/components/Menu/WalletMenu/components/Toggle.tsx
@@ -0,0 +1,49 @@
+import { twJoin } from "tailwind-merge";
+
+interface NewToggleProps {
+ value: boolean;
+ onChange: (value: boolean) => void;
+ className?: string;
+ disabled?: boolean;
+}
+
+export const NewToggle = ({
+ value,
+ onChange,
+ className = "",
+ disabled = false,
+}: NewToggleProps) => {
+ const handleClick = () => {
+ if (!disabled) {
+ onChange(!value);
+ }
+ };
+
+ return (
+
+
+
+ );
+};
diff --git a/src/ui/common/components/Menu/WalletMenu/components/WalletInfoSection.tsx b/src/ui/common/components/Menu/WalletMenu/components/WalletInfoSection.tsx
new file mode 100644
index 000000000..f40e342bb
--- /dev/null
+++ b/src/ui/common/components/Menu/WalletMenu/components/WalletInfoSection.tsx
@@ -0,0 +1,106 @@
+import { Avatar, Text } from "@babylonlabs-io/core-ui";
+
+import bbnIcon from "@/ui/common/assets/bbn.svg";
+import bitcoin from "@/ui/common/assets/bitcoin.png";
+import { Hash } from "@/ui/common/components/Hash/Hash";
+import { CopyIcon } from "@/ui/common/components/Icons";
+
+interface WalletInfoProps {
+ btcAddress: string;
+ bbnAddress: string;
+ selectedWallets: Record;
+}
+
+export const WalletInfoSection = ({
+ btcAddress,
+ bbnAddress,
+ selectedWallets,
+}: WalletInfoProps) => {
+ const copyToClipboard = (text: string) => {
+ navigator.clipboard.writeText(text);
+ };
+
+ return (
+
+ {/* Bitcoin Wallet */}
+
+
+
+
+
+
+ Bitcoin Wallet
+
+
+
+
+
+ copyToClipboard(btcAddress)}
+ className="flex-shrink-0 ml-3 p-1 rounded hover:bg-surface-tertiary transition-colors h-6 w-6 flex items-center justify-center hover:opacity-80"
+ >
+
+
+
+
+
+
+ {/* Babylon Wallet */}
+
+
+
+
+
+
+ Babylon Wallet
+
+
+
+
+
+ copyToClipboard(bbnAddress)}
+ className="flex-shrink-0 ml-3 p-1 rounded hover:bg-surface-tertiary transition-colors h-6 w-6 flex items-center justify-center hover:opacity-80"
+ >
+
+
+
+
+
+
+ );
+};
diff --git a/src/ui/common/components/Menu/WalletMenu/components/WalletMenuContainer.tsx b/src/ui/common/components/Menu/WalletMenu/components/WalletMenuContainer.tsx
new file mode 100644
index 000000000..adafc034a
--- /dev/null
+++ b/src/ui/common/components/Menu/WalletMenu/components/WalletMenuContainer.tsx
@@ -0,0 +1,157 @@
+import { MobileDialog, Popover } from "@babylonlabs-io/core-ui";
+import { useWalletConnect } from "@babylonlabs-io/wallet-connector";
+import { useCallback, useEffect, useState } from "react";
+import { twJoin } from "tailwind-merge";
+
+import { WalletDisconnectModal } from "@/ui/common/components/Modals/WalletDisconnectModal";
+import { useIsMobileView } from "@/ui/common/hooks/useBreakpoint";
+
+import { WalletInfoSection } from "./WalletInfoSection";
+import { WalletSettingsSection } from "./WalletSettingsSection";
+
+interface WalletMenuProps {
+ trigger: React.ReactNode;
+ btcAddress: string;
+ bbnAddress: string;
+ selectedWallets: Record;
+ ordinalsExcluded: boolean;
+ linkedDelegationsVisibility: boolean;
+ onIncludeOrdinals: () => void;
+ onExcludeOrdinals: () => void;
+ onDisplayLinkedDelegations: (value: boolean) => void;
+ publicKeyNoCoord: string;
+ forceOpen?: boolean;
+ onOpenChange?: (isOpen: boolean) => void;
+}
+
+export const WalletMenuContainer = ({
+ trigger,
+ btcAddress,
+ bbnAddress,
+ selectedWallets,
+ ordinalsExcluded,
+ linkedDelegationsVisibility,
+ onIncludeOrdinals,
+ onExcludeOrdinals,
+ onDisplayLinkedDelegations,
+ publicKeyNoCoord,
+ forceOpen = false,
+ onOpenChange,
+}: WalletMenuProps) => {
+ const isMobile = useIsMobileView();
+ const [isPopoverOpen, setIsPopoverOpen] = useState(forceOpen);
+ const [mobileDialogOpen, setMobileDialogOpen] = useState(forceOpen);
+ const [anchorEl, setAnchorEl] = useState(null);
+ const [showDisconnectModal, setShowDisconnectModal] = useState(false);
+
+ const { disconnect } = useWalletConnect();
+
+ // Notify parent when menu open state changes
+ useEffect(() => {
+ const isOpen = isMobile ? mobileDialogOpen : isPopoverOpen;
+ onOpenChange?.(isOpen);
+ }, [isPopoverOpen, mobileDialogOpen, isMobile, onOpenChange]);
+
+ const handleDisconnectClick = useCallback(() => {
+ setShowDisconnectModal(true);
+ }, []);
+
+ const handleDisconnectCancel = useCallback(() => {
+ setShowDisconnectModal(false);
+ }, []);
+
+ const handleDisconnectConfirm = useCallback(() => {
+ setShowDisconnectModal(false);
+ setIsPopoverOpen(false);
+ setMobileDialogOpen(false);
+ disconnect();
+ }, [disconnect]);
+
+ const menuContent = (
+
+
+
+
+
+
+
+ Disconnect Wallets
+
+
+
+ );
+
+ const handleTriggerClick = (event: React.MouseEvent) => {
+ event.stopPropagation();
+ if (isMobile) {
+ setMobileDialogOpen(true);
+ } else {
+ setAnchorEl(event.currentTarget);
+ setIsPopoverOpen(!isPopoverOpen);
+ }
+ };
+
+ const handleCloseMenu = useCallback(() => {
+ setIsPopoverOpen(false);
+ setMobileDialogOpen(false);
+ }, []);
+
+ if (isMobile) {
+ return (
+ <>
+ {trigger}
+
+ {menuContent}
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+ {trigger}
+
+ {menuContent}
+
+
+ >
+ );
+};
diff --git a/src/ui/common/components/Menu/WalletMenu/components/WalletSettingsSection.tsx b/src/ui/common/components/Menu/WalletMenu/components/WalletSettingsSection.tsx
new file mode 100644
index 000000000..4011b4a6c
--- /dev/null
+++ b/src/ui/common/components/Menu/WalletMenu/components/WalletSettingsSection.tsx
@@ -0,0 +1,112 @@
+import { Text } from "@babylonlabs-io/core-ui";
+
+import { Hash } from "@/ui/common/components/Hash/Hash";
+import {
+ BitcoinPublicKeyIcon,
+ CopyIcon,
+ LinkWalletIcon,
+ UsingInscriptionIcon,
+} from "@/ui/common/components/Icons";
+
+import { NewToggle } from "./Toggle";
+
+interface WalletSettingsProps {
+ ordinalsExcluded: boolean;
+ linkedDelegationsVisibility: boolean;
+ onIncludeOrdinals: () => void;
+ onExcludeOrdinals: () => void;
+ onDisplayLinkedDelegations: (value: boolean) => void;
+ publicKeyNoCoord: string;
+}
+
+export const WalletSettingsSection = ({
+ ordinalsExcluded,
+ linkedDelegationsVisibility,
+ onIncludeOrdinals,
+ onExcludeOrdinals,
+ onDisplayLinkedDelegations,
+ publicKeyNoCoord,
+}: WalletSettingsProps) => {
+ const copyToClipboard = (text: string) => {
+ navigator.clipboard.writeText(text);
+ };
+
+ return (
+
+ {/* Using Inscriptions Toggle */}
+
+
+
+
+
+ Using Inscriptions
+
+
+ {ordinalsExcluded ? "Off" : "On"}
+
+
+
+
+ value ? onIncludeOrdinals() : onExcludeOrdinals()
+ }
+ />
+
+
+ {/* Linked Wallet Stakes Toggle */}
+
+
+
+
+
+ Linked Wallet Stakes
+
+
+ {linkedDelegationsVisibility ? "On" : "Off"}
+
+
+
+
+
+
+ {/* Bitcoin Public Key */}
+
+
+
+
+
+ Bitcoin Public Key
+
+
+
+
+
copyToClipboard(publicKeyNoCoord)}
+ className="flex-shrink-0 ml-3 p-1 rounded hover:bg-surface-tertiary transition-colors h-6 w-6 flex items-center justify-center hover:opacity-80"
+ >
+
+
+
+
+ );
+};
diff --git a/src/ui/common/components/Menu/WalletMenu/index.ts b/src/ui/common/components/Menu/WalletMenu/index.ts
new file mode 100644
index 000000000..6dbd786ee
--- /dev/null
+++ b/src/ui/common/components/Menu/WalletMenu/index.ts
@@ -0,0 +1,4 @@
+export { NewToggle } from "./components/Toggle";
+export { WalletInfoSection } from "./components/WalletInfoSection";
+export { WalletMenuContainer } from "./components/WalletMenuContainer";
+export { WalletSettingsSection } from "./components/WalletSettingsSection";
diff --git a/src/ui/common/components/Menu/index.ts b/src/ui/common/components/Menu/index.ts
new file mode 100644
index 000000000..df1ca8cd2
--- /dev/null
+++ b/src/ui/common/components/Menu/index.ts
@@ -0,0 +1,3 @@
+export * from "./SettingMenu";
+
+export * from "./WalletMenu";
diff --git a/src/ui/common/components/Modals/CancelFeedbackModal.tsx b/src/ui/common/components/Modals/CancelFeedbackModal.tsx
new file mode 100644
index 000000000..9dafb066e
--- /dev/null
+++ b/src/ui/common/components/Modals/CancelFeedbackModal.tsx
@@ -0,0 +1,68 @@
+import { LuPartyPopper } from "react-icons/lu";
+
+import { SubmitModal } from "./SubmitModal";
+
+interface FeedbackModalProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+export const CancelFeedbackModal: React.FC = ({
+ open,
+ onClose,
+}) => (
+ }
+ title="We Value Your Feedback"
+ open={open}
+ submitButton="Done"
+ cancelButton=""
+ onSubmit={onClose}
+ >
+
+ It looks like you didn’t complete your staking journey. We'd love to
+ help you get back on track or hear about any issues you faced.
+
+
+
+
+ Need Assistance? Reach out on our{" "}
+
+ Discord
+ {" "}
+ (#support channel).
+
+
+ Feedback: Let us know your thoughts on our{" "}
+
+ Feedback Forum
+ {" "}
+ or{" "}
+
+ Discord
+ {" "}
+ (#feedback channel).
+
+
+
+
+ Your feedback is crucial for us to improve and provide a seamless
+ experience. Thank you for being a part of our Bitcoin Staking Protocol!
+
+
+);
diff --git a/src/ui/common/components/Modals/ClaimRewardModal.tsx b/src/ui/common/components/Modals/ClaimRewardModal.tsx
new file mode 100644
index 000000000..de30a974d
--- /dev/null
+++ b/src/ui/common/components/Modals/ClaimRewardModal.tsx
@@ -0,0 +1,85 @@
+import { Heading, Text } from "@babylonlabs-io/core-ui";
+import { PropsWithChildren } from "react";
+
+import { shouldDisplayTestingMsg } from "@/ui/common/config";
+import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn";
+import { ubbnToBaby } from "@/ui/common/utils/bbn";
+import { trim } from "@/ui/common/utils/trim";
+
+import { LoadingSmall } from "../Loading/Loading";
+
+import { ConfirmationModal } from "./ConfirmationModal";
+
+interface ConfirmationModalProps {
+ processing: boolean;
+ open: boolean;
+ address: string;
+ receivingValue: string;
+ transactionFee: number;
+ onClose: () => void;
+ onSubmit: () => void;
+}
+
+const { coinSymbol } = getNetworkConfigBBN();
+
+export const ClaimRewardModal = ({
+ open,
+ processing,
+ receivingValue,
+ address,
+ transactionFee,
+ onClose,
+ onSubmit,
+}: PropsWithChildren) => {
+ return (
+
+
+
+
+
+ Receiving
+
+
+ {receivingValue} {coinSymbol}
+
+
+
+
+
+ Babylon {shouldDisplayTestingMsg() ? "Test" : ""} Chain Address
+
+ {trim(address, 14)}
+
+
+ Transaction Fees
+ {transactionFee === 0 ? (
+
+ ) : (
+
+ {ubbnToBaby(transactionFee)} {coinSymbol}
+
+ )}
+
+
+
+ Attention!
+
+ Processing your claim will take approximately 2 blocks to complete.
+
+ {shouldDisplayTestingMsg() && (
+
+ {coinSymbol} is a test token without any real world value.
+
+ )}
+
+
+
+ );
+};
diff --git a/src/ui/common/components/Modals/ClaimStatusModal/ClaimStatusModal.tsx b/src/ui/common/components/Modals/ClaimStatusModal/ClaimStatusModal.tsx
new file mode 100644
index 000000000..d763c3bd9
--- /dev/null
+++ b/src/ui/common/components/Modals/ClaimStatusModal/ClaimStatusModal.tsx
@@ -0,0 +1,57 @@
+import { Loader } from "@babylonlabs-io/core-ui";
+import { BiSolidBadgeCheck } from "react-icons/bi";
+
+import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn";
+
+import { SubmitModal } from "../SubmitModal";
+
+import { SuccessContent } from "./SuccessContent";
+
+interface ClaimStatusModalProps {
+ open: boolean;
+ onClose?: () => void;
+ loading: boolean;
+ transactionHash?: string;
+}
+
+const { coinSymbol } = getNetworkConfigBBN();
+
+const MODAL_STEP = {
+ processing: {
+ icon: ,
+ title: "Processing Claim",
+ submitButton: "",
+ cancelButton: "",
+ content: null,
+ },
+ success: {
+ icon: ,
+ title: `Successfully Claimed ${coinSymbol}`,
+ submitButton: "Done",
+ cancelButton: "",
+ content: (txHash?: string) => ,
+ },
+};
+
+export const ClaimStatusModal = ({
+ open,
+ onClose,
+ loading,
+ transactionHash,
+}: ClaimStatusModalProps) => {
+ const config = loading ? MODAL_STEP.processing : MODAL_STEP.success;
+
+ return (
+
+ {config.content?.(transactionHash)}
+
+ );
+};
diff --git a/src/ui/common/components/Modals/ClaimStatusModal/SuccessContent.tsx b/src/ui/common/components/Modals/ClaimStatusModal/SuccessContent.tsx
new file mode 100644
index 000000000..9b96181c1
--- /dev/null
+++ b/src/ui/common/components/Modals/ClaimStatusModal/SuccessContent.tsx
@@ -0,0 +1,38 @@
+import { Text } from "@babylonlabs-io/core-ui";
+
+import { Hash } from "@/ui/common/components/Hash/Hash";
+import { BABYLON_EXPLORER } from "@/ui/common/constants";
+import { trim } from "@/ui/common/utils/trim";
+
+export const SuccessContent = ({
+ transactionHash,
+}: {
+ transactionHash?: string;
+}) => (
+
+
+ Your claim has been submitted and will be processed in 2 blocks.
+
+ {transactionHash && (
+
+ )}
+
+);
diff --git a/src/ui/common/components/Modals/ConfirmationModal.tsx b/src/ui/common/components/Modals/ConfirmationModal.tsx
new file mode 100644
index 000000000..f7f625ffb
--- /dev/null
+++ b/src/ui/common/components/Modals/ConfirmationModal.tsx
@@ -0,0 +1,63 @@
+import {
+ Button,
+ DialogBody,
+ DialogFooter,
+ DialogHeader,
+ Loader,
+} from "@babylonlabs-io/core-ui";
+import { PropsWithChildren } from "react";
+
+import { ResponsiveDialog } from "./ResponsiveDialog";
+
+interface ConfirmationModalProps {
+ className?: string;
+ processing: boolean;
+ open: boolean;
+ title: string;
+ onClose: () => void;
+ onSubmit: () => void;
+}
+
+export const ConfirmationModal = ({
+ className,
+ processing,
+ open,
+ title,
+ children,
+ onClose,
+ onSubmit,
+}: PropsWithChildren) => (
+
+
+
+ {children}
+
+
+
+ Cancel
+
+
+
+ {processing ? (
+
+ ) : (
+ "Proceed"
+ )}
+
+
+
+);
diff --git a/src/ui/common/components/Modals/ErrorModal.tsx b/src/ui/common/components/Modals/ErrorModal.tsx
new file mode 100644
index 000000000..1e1de359a
--- /dev/null
+++ b/src/ui/common/components/Modals/ErrorModal.tsx
@@ -0,0 +1,154 @@
+import {
+ Button,
+ DialogBody,
+ DialogFooter,
+ Heading,
+ Text,
+} from "@babylonlabs-io/core-ui";
+import { useEffect, useState } from "react";
+import { FiCheck, FiCopy } from "react-icons/fi";
+
+import WarningTriangle from "@/ui/common/assets/warning-triangle.svg";
+import { useError } from "@/ui/common/context/Error/ErrorProvider";
+import { ErrorType, ShowErrorParams } from "@/ui/common/types/errors";
+import { getCommitHash } from "@/ui/common/utils/version";
+
+import { ResponsiveDialog } from "./ResponsiveDialog";
+
+export const ErrorModal: React.FC = () => {
+ const { error, modalOptions, dismissError, isOpen } = useError();
+ const { retryAction, noCancel } = modalOptions;
+ const [copied, setCopied] = useState(false);
+ const version = getCommitHash();
+
+ const handleRetry = () => {
+ const retryErrorParam: ShowErrorParams = {
+ error: {
+ message: error.message,
+ type: error.type,
+ },
+ retryAction: retryAction,
+ };
+
+ dismissError();
+
+ setTimeout(() => {
+ if (retryErrorParam.retryAction) {
+ retryErrorParam.retryAction();
+ }
+ }, 300);
+ };
+
+ const ERROR_TITLES = {
+ [ErrorType.SERVER]: "Server Error",
+ [ErrorType.WITHDRAW]: "Withdraw Error",
+ [ErrorType.STAKING]: "Stake Error",
+ [ErrorType.UNBONDING]: "Unbonding Error",
+ [ErrorType.REGISTRATION]: "Transition Error",
+ [ErrorType.DELEGATIONS]: "Delegations Error",
+ [ErrorType.WALLET]: "Wallet Error",
+ [ErrorType.UNKNOWN]: "System Error",
+ };
+
+ const ERROR_MESSAGES = {
+ [ErrorType.SERVER]: "Error fetching data due to:",
+ [ErrorType.UNBONDING]: "Your request to unbond failed due to:",
+ [ErrorType.WITHDRAW]: "Failed to withdraw due to:",
+ [ErrorType.STAKING]: "Failed to stake due to:",
+ [ErrorType.DELEGATIONS]: "Failed to fetch delegations due to:",
+ [ErrorType.REGISTRATION]: "Failed to transition due to:",
+ [ErrorType.WALLET]: "Failed to perform wallet action due to:",
+ [ErrorType.UNKNOWN]: "A system error occurred:",
+ };
+
+ const getErrorTitle = () => {
+ return ERROR_TITLES[error.type ?? ErrorType.UNKNOWN];
+ };
+
+ const getErrorMessage = () => {
+ const prefix = ERROR_MESSAGES[error.type ?? ErrorType.UNKNOWN];
+ return `${prefix} ${error.displayMessage || error.message}`;
+ };
+
+ const copyErrorDetails = () => {
+ const errorDetails = JSON.stringify(
+ {
+ date: new Date().toISOString(),
+ device: navigator.userAgent,
+ version,
+ release: version,
+ environment: process.env.NODE_ENV,
+ ...error,
+ },
+ null,
+ 2,
+ );
+
+ navigator.clipboard.writeText(errorDetails);
+ setCopied(true);
+ };
+
+ useEffect(() => {
+ if (copied) {
+ const timer = setTimeout(() => setCopied(false), 2000);
+ return () => clearTimeout(timer);
+ }
+ }, [copied]);
+
+ return (
+
+
+
+
+
+
+
+ {getErrorTitle()}
+
+
+
+
+ {getErrorMessage()}
+
+
+
+
+ {copied ? (
+
+ ) : (
+
+ )}
+ {copied ? "Copied!" : "Copy error details"}
+
+
+
+
+
+
+ {!noCancel && ( // Only show the cancel button if noCancel is false or undefined
+
+ Cancel
+
+ )}
+ {retryAction && (
+
+ Try Again
+
+ )}
+
+
+ );
+};
diff --git a/src/ui/common/components/Modals/InfoModal.tsx b/src/ui/common/components/Modals/InfoModal.tsx
new file mode 100644
index 000000000..bbd3d187b
--- /dev/null
+++ b/src/ui/common/components/Modals/InfoModal.tsx
@@ -0,0 +1,65 @@
+import {
+ Button,
+ DialogBody,
+ DialogFooter,
+ DialogHeader,
+ Text,
+} from "@babylonlabs-io/core-ui";
+
+import { getNetworkConfigBTC } from "@/ui/common/config/network/btc";
+import { useNetworkInfo } from "@/ui/common/hooks/client/api/useNetworkInfo";
+import { blocksToDisplayTime } from "@/ui/common/utils/time";
+
+import { ResponsiveDialog } from "./ResponsiveDialog";
+
+interface InfoModalProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+const { coinName } = getNetworkConfigBTC();
+
+export function InfoModal({ open, onClose }: InfoModalProps) {
+ const { data: networkInfo } = useNetworkInfo();
+
+ const unbondingTime = blocksToDisplayTime(
+ networkInfo?.params.bbnStakingParams?.latestParam?.unbondingTime,
+ );
+ const maxStakingPeriod = blocksToDisplayTime(
+ networkInfo?.params.bbnStakingParams?.latestParam?.maxStakingTimeBlocks,
+ );
+
+ return (
+
+
+
+
+
+ Stakes made through this dashboard are locked for up to{" "}
+ {maxStakingPeriod}. You can on-demand unbond at any time, with
+ withdrawal available after a {unbondingTime} unbonding period. If
+ the maximum staking period expires, your stake becomes withdrawable
+ automatically, with no need for prior unbonding.
+
+
+ Note: Timeframes are approximate, based on an average {coinName}{" "}
+ block time of 10 minutes.
+
+
+
+
+
+ Done
+
+
+
+ );
+}
diff --git a/src/ui/common/components/Modals/MultistakingModal/MultistakingStartModal.tsx b/src/ui/common/components/Modals/MultistakingModal/MultistakingStartModal.tsx
new file mode 100644
index 000000000..daa3e468e
--- /dev/null
+++ b/src/ui/common/components/Modals/MultistakingModal/MultistakingStartModal.tsx
@@ -0,0 +1,180 @@
+import {
+ Button,
+ Card,
+ DialogBody,
+ DialogFooter,
+ DialogHeader,
+ Heading,
+ Text,
+} from "@babylonlabs-io/core-ui";
+import { PropsWithChildren } from "react";
+
+import { ResponsiveDialog } from "../ResponsiveDialog";
+
+interface Info {
+ icon: React.ReactNode;
+ name: string;
+}
+
+interface StakingTerm {
+ blocks: string;
+ duration: string;
+}
+
+interface StakingDetails {
+ stakeAmount: string;
+ feeRate: string;
+ transactionFees: string;
+ term: StakingTerm;
+ unbonding: string;
+ unbondingFee: string;
+}
+
+interface MultistakingPreviewModalProps {
+ open: boolean;
+ processing?: boolean;
+ onClose: () => void;
+ onProceed: () => void;
+ bsns: Info[];
+ finalityProviders: Info[];
+ details: StakingDetails;
+}
+
+export const MultistakingPreviewModal = ({
+ open,
+ processing = false,
+ onClose,
+ onProceed,
+ bsns,
+ finalityProviders,
+ details,
+}: PropsWithChildren) => {
+ const fields = [
+ { label: "Stake Amount", value: details.stakeAmount },
+ { label: "Fee Rate", value: details.feeRate },
+ { label: "Transaction Fees", value: details.transactionFees },
+ {
+ label: "Term",
+ value: (
+ <>
+ {details.term.blocks}
+
+
+ {details.term.duration}
+
+ >
+ ),
+ },
+ { label: "Unbonding", value: details.unbonding },
+ { label: "Unbonding Fee", value: details.unbondingFee },
+ ];
+
+ return (
+
+
+
+
+
+ {bsns.length > 1 ? (
+
+
+ BSNs
+
+
+ Finality Provider
+
+
+ ) : null}
+
+
+ {bsns.map((bsnItem, index) => (
+
+ {bsnItem.icon}
+
+ {bsnItem.name}
+
+
+ ))}
+
+
+ {finalityProviders.map((fpItem, index) => (
+
+ {fpItem.icon}
+
+ {fpItem.name}
+
+
+ ))}
+
+
+
+
+
+
+ {fields.map((field, index) => (
+
+
+ {field.label}
+
+
+ {field.value}
+
+
+ ))}
+
+
+
+
+
+
+ Attention!
+
+
+ 1. No third party possesses your staked BTC. You are the only one
+ who can unbond and withdraw your stake.
+
+
+
+
+ 2. Your stake will first be sent to Babylon Genesis for verification
+ (~20 seconds), then you will be prompted to submit it to the Bitcoin
+ ledger. It will be marked as 'Pending' until it receives
+ 10 Bitcoin confirmations.
+
+
+
+
+
+ Cancel
+
+
+ {processing ? "Processing..." : "Proceed to Signing"}
+
+
+
+ );
+};
diff --git a/src/ui/common/components/Modals/PreviewModal.tsx b/src/ui/common/components/Modals/PreviewModal.tsx
new file mode 100644
index 000000000..810c51b4f
--- /dev/null
+++ b/src/ui/common/components/Modals/PreviewModal.tsx
@@ -0,0 +1,243 @@
+import {
+ Avatar,
+ Button,
+ DialogBody,
+ DialogFooter,
+ DialogHeader,
+ Heading,
+ Loader,
+ Text,
+} from "@babylonlabs-io/core-ui";
+import { Fragment } from "react";
+
+import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn";
+import { getNetworkConfigBTC } from "@/ui/common/config/network/btc";
+import { useNetworkInfo } from "@/ui/common/hooks/client/api/useNetworkInfo";
+import { usePrice } from "@/ui/common/hooks/client/api/usePrices";
+import { useIsMobileView } from "@/ui/common/hooks/useBreakpoint";
+import { satoshiToBtc } from "@/ui/common/utils/btc";
+import { calculateTokenValueInCurrency } from "@/ui/common/utils/formatCurrency";
+import { maxDecimals } from "@/ui/common/utils/maxDecimals";
+import { blocksToDisplayTime } from "@/ui/common/utils/time";
+
+import { ResponsiveDialog } from "./ResponsiveDialog";
+
+interface PreviewModalProps {
+ open: boolean;
+ onClose: () => void;
+ onSign: () => void;
+ finalityProvider: string | undefined;
+ finalityProviderAvatar: string | undefined;
+ stakingAmountSat: number;
+ stakingTimelock: number;
+ stakingFeeSat: number;
+ feeRate: number;
+ unbondingFeeSat: number;
+ processing: boolean;
+}
+
+const { networkFullName: bbnNetworkFullName } = getNetworkConfigBBN();
+const { coinSymbol, networkName, displayUSD } = getNetworkConfigBTC();
+
+export const PreviewModal = ({
+ open,
+ onClose,
+ finalityProvider,
+ finalityProviderAvatar,
+ stakingAmountSat,
+ stakingTimelock,
+ onSign,
+ stakingFeeSat,
+ feeRate,
+ unbondingFeeSat,
+ processing,
+}: PreviewModalProps) => {
+ const isMobileView = useIsMobileView();
+
+ const { data: networkInfo } = useNetworkInfo();
+ const confirmationDepth =
+ networkInfo?.params.btcEpochCheckParams?.latestParam
+ ?.btcConfirmationDepth || 30;
+ const unbondingTime =
+ blocksToDisplayTime(
+ networkInfo?.params.bbnStakingParams?.latestParam?.unbondingTime,
+ ) || "7 days";
+
+ const btcInUsd = usePrice(coinSymbol);
+
+ const FinalityProviderValue = isMobileView ? (
+
+ {finalityProviderAvatar && (
+
+ )}
+ {finalityProvider || "-"}
+
+ ) : (
+ {finalityProvider || "-"}
+ );
+
+ const previewFields = [
+ {
+ key: "Finality Provider",
+ value: FinalityProviderValue,
+ },
+ {
+ key: "Stake Amount",
+ value: (
+ <>
+
+ {maxDecimals(satoshiToBtc(stakingAmountSat), 8)} {coinSymbol}
+
+ {displayUSD && (
+
+ {calculateTokenValueInCurrency(
+ satoshiToBtc(stakingAmountSat),
+ btcInUsd,
+ )}
+
+ )}
+ >
+ ),
+ },
+ {
+ key: "Fee rate",
+ value: {feeRate} sat/vB ,
+ },
+ {
+ key: "Transaction fee",
+ value: (
+ <>
+
+ {maxDecimals(satoshiToBtc(stakingFeeSat), 8)} {coinSymbol}
+
+ {displayUSD && (
+
+ {calculateTokenValueInCurrency(
+ satoshiToBtc(stakingFeeSat),
+ btcInUsd,
+ )}
+
+ )}
+ >
+ ),
+ },
+ {
+ key: "Term",
+ value: (
+ <>
+ {stakingTimelock} blocks
+
+ ~ {blocksToDisplayTime(stakingTimelock)}
+
+ >
+ ),
+ },
+ {
+ key: "On Demand Unbonding",
+ value: (
+ Enabled (~ {unbondingTime} unbonding time)
+ ),
+ },
+ {
+ key: "Unbonding fee",
+ value: (
+ <>
+
+ {maxDecimals(satoshiToBtc(unbondingFeeSat), 8)} {coinSymbol}
+
+ {displayUSD && (
+
+ {calculateTokenValueInCurrency(
+ satoshiToBtc(unbondingFeeSat),
+ btcInUsd,
+ )}
+
+ )}
+ >
+ ),
+ },
+ ];
+
+ return (
+
+
+
+
+
+ {previewFields.map((field, index) => (
+
+
+
{field.key}
+
{field.value}
+
+ {index < previewFields.length - 1 && (
+
+ )}
+
+ ))}
+
+
+
+ Attention!
+
+ 1. No third party possesses your staked {coinSymbol}. You are the
+ only one who can unbond and withdraw your stake.
+
+
+ 2. Your stake will first be sent to {bbnNetworkFullName} for
+ verification (~20 seconds), then you will be prompted to submit it
+ to the {networkName} ledger. It will be marked as
+ "Pending" until it receives {confirmationDepth} Bitcoin
+ confirmations.
+
+
+
+
+
+
+ Cancel
+
+
+ {processing ? (
+
+ ) : (
+ <>
+ Proceed to Signing
+ >
+ )}
+
+
+
+ );
+};
diff --git a/src/ui/common/components/Modals/PreviewMultistakingModal.tsx b/src/ui/common/components/Modals/PreviewMultistakingModal.tsx
new file mode 100644
index 000000000..61c8c0624
--- /dev/null
+++ b/src/ui/common/components/Modals/PreviewMultistakingModal.tsx
@@ -0,0 +1,252 @@
+import {
+ Avatar,
+ Button,
+ DialogBody,
+ DialogFooter,
+ DialogHeader,
+ Heading,
+ Loader,
+ Text,
+} from "@babylonlabs-io/core-ui";
+import { Fragment } from "react";
+
+import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn";
+import { getNetworkConfigBTC } from "@/ui/common/config/network/btc";
+import { useNetworkInfo } from "@/ui/common/hooks/client/api/useNetworkInfo";
+import { usePrice } from "@/ui/common/hooks/client/api/usePrices";
+import { useIsMobileView } from "@/ui/common/hooks/useBreakpoint";
+import { satoshiToBtc } from "@/ui/common/utils/btc";
+import { calculateTokenValueInCurrency } from "@/ui/common/utils/formatCurrency";
+import { maxDecimals } from "@/ui/common/utils/maxDecimals";
+import { blocksToDisplayTime } from "@/ui/common/utils/time";
+
+import { ResponsiveDialog } from "./ResponsiveDialog";
+
+interface ProviderInfo {
+ name: string;
+ avatar?: string;
+}
+
+interface PreviewMultistakingModalProps {
+ open: boolean;
+ onClose: () => void;
+ onSign: () => void;
+ providers: ProviderInfo[];
+ stakingAmountSat: number;
+ stakingTimelock: number;
+ stakingFeeSat: number;
+ feeRate: number;
+ unbondingFeeSat: number;
+ processing: boolean;
+}
+
+const { networkFullName: bbnNetworkFullName } = getNetworkConfigBBN();
+const { coinSymbol, networkName, displayUSD } = getNetworkConfigBTC();
+
+export const PreviewMultistakingModal = ({
+ open,
+ onClose,
+ onSign,
+ providers,
+ stakingAmountSat,
+ stakingTimelock,
+ stakingFeeSat,
+ feeRate,
+ unbondingFeeSat,
+ processing,
+}: PreviewMultistakingModalProps) => {
+ const isMobileView = useIsMobileView();
+
+ const { data: networkInfo } = useNetworkInfo();
+ const confirmationDepth =
+ networkInfo?.params.btcEpochCheckParams?.latestParam
+ ?.btcConfirmationDepth || 30;
+ const unbondingTime =
+ blocksToDisplayTime(
+ networkInfo?.params.bbnStakingParams?.latestParam?.unbondingTime,
+ ) || "7 days";
+
+ const btcInUsd = usePrice(coinSymbol);
+
+ const FinalityProvidersValue = isMobileView ? (
+
+ {providers.map((p) => (
+
+ {p.avatar && (
+
+ )}
+ {p.name}
+
+ ))}
+
+ ) : (
+
+ {providers.map((p) => p.name).join(", ") || "-"}
+
+ );
+
+ const previewFields = [
+ {
+ key: "Finality Providers",
+ value: FinalityProvidersValue,
+ },
+ {
+ key: "Stake Amount",
+ value: (
+ <>
+
+ {maxDecimals(satoshiToBtc(stakingAmountSat), 8)} {coinSymbol}
+
+ {displayUSD && (
+
+ {calculateTokenValueInCurrency(
+ satoshiToBtc(stakingAmountSat),
+ btcInUsd,
+ )}
+
+ )}
+ >
+ ),
+ },
+ {
+ key: "Fee rate",
+ value: {feeRate} sat/vB ,
+ },
+ {
+ key: "Transaction fee",
+ value: (
+ <>
+
+ {maxDecimals(satoshiToBtc(stakingFeeSat), 8)} {coinSymbol}
+
+ {displayUSD && (
+
+ {calculateTokenValueInCurrency(
+ satoshiToBtc(stakingFeeSat),
+ btcInUsd,
+ )}
+
+ )}
+ >
+ ),
+ },
+ {
+ key: "Term",
+ value: (
+ <>
+ {stakingTimelock} blocks
+
+ ~ {blocksToDisplayTime(stakingTimelock)}
+
+ >
+ ),
+ },
+ {
+ key: "On Demand Unbonding",
+ value: (
+ Enabled (~ {unbondingTime} unbonding time)
+ ),
+ },
+ {
+ key: "Unbonding fee",
+ value: (
+ <>
+
+ {maxDecimals(satoshiToBtc(unbondingFeeSat), 8)} {coinSymbol}
+
+ {displayUSD && (
+
+ {calculateTokenValueInCurrency(
+ satoshiToBtc(unbondingFeeSat),
+ btcInUsd,
+ )}
+
+ )}
+ >
+ ),
+ },
+ ];
+
+ return (
+
+
+
+
+
+ {previewFields.map((field, index) => (
+
+
+
{field.key}
+
{field.value}
+
+ {index < previewFields.length - 1 && (
+
+ )}
+
+ ))}
+
+
+
+ Attention!
+
+ 1. No third party possesses your staked {coinSymbol}. You are the
+ only one who can unbond and withdraw your stake.
+
+
+ 2. Your stake will first be sent to {bbnNetworkFullName} for
+ verification (~20 seconds), then you will be prompted to submit it
+ to the {networkName} ledger. It will be marked as
+ "Pending" until it receives {confirmationDepth} Bitcoin
+ confirmations.
+
+
+
+
+
+
+ Cancel
+
+
+ {processing ? (
+
+ ) : (
+ <>
+ Proceed to Signing
+ >
+ )}
+
+
+
+ );
+};
diff --git a/src/ui/common/components/Modals/RegistrationModal/RegistrationEndModal.tsx b/src/ui/common/components/Modals/RegistrationModal/RegistrationEndModal.tsx
new file mode 100644
index 000000000..41d892094
--- /dev/null
+++ b/src/ui/common/components/Modals/RegistrationModal/RegistrationEndModal.tsx
@@ -0,0 +1,49 @@
+import { Text } from "@babylonlabs-io/core-ui";
+
+import { getNetworkConfig } from "@/ui/common/config/network";
+
+import { SubmitModal } from "../SubmitModal";
+
+interface RegistrationEndModalProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+const SuccessIcon = () => (
+
+
+
+);
+
+const { bbn } = getNetworkConfig();
+
+export function RegistrationEndModal({
+ open,
+ onClose,
+}: RegistrationEndModalProps) {
+ return (
+ }
+ title="Registration Submitted"
+ submitButton="Done"
+ cancelButton=""
+ >
+
+ Your staking transaction has been successfully registered to the{" "}
+ {bbn.networkFullName}.
+
+
+ );
+}
diff --git a/src/ui/common/components/Modals/RegistrationModal/RegistrationStartModal.tsx b/src/ui/common/components/Modals/RegistrationModal/RegistrationStartModal.tsx
new file mode 100644
index 000000000..65e9f337c
--- /dev/null
+++ b/src/ui/common/components/Modals/RegistrationModal/RegistrationStartModal.tsx
@@ -0,0 +1,40 @@
+import { Text } from "@babylonlabs-io/core-ui";
+import { BiSolidEditAlt } from "react-icons/bi";
+
+import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn";
+import { getNetworkConfigBTC } from "@/ui/common/config/network/btc";
+
+import { SubmitModal } from "../SubmitModal";
+
+interface RegistrationStartModalProps {
+ open: boolean;
+ onClose: () => void;
+ onProceed?: () => void;
+}
+
+const { networkName } = getNetworkConfigBTC();
+const { networkFullName } = getNetworkConfigBBN();
+
+export function RegistrationStartModal({
+ open,
+ onClose,
+ onProceed,
+}: RegistrationStartModalProps) {
+ return (
+ }
+ title={`Register to ${networkFullName}`}
+ submitButton="Proceed"
+ >
+
+ You are about to register your {networkName} stake to the{" "}
+ {networkFullName}. The registration requires consenting to slashing and
+ the association of your {networkFullName} account with your{" "}
+ {networkName} address.
+
+
+ );
+}
diff --git a/src/ui/common/components/Modals/ResponsiveDialog.tsx b/src/ui/common/components/Modals/ResponsiveDialog.tsx
new file mode 100644
index 000000000..aa74d033d
--- /dev/null
+++ b/src/ui/common/components/Modals/ResponsiveDialog.tsx
@@ -0,0 +1,16 @@
+import { Dialog, DialogProps, MobileDialog } from "@babylonlabs-io/core-ui";
+import { twMerge } from "tailwind-merge";
+
+import { useIsMobileView } from "@/ui/common/hooks/useBreakpoint";
+
+export function ResponsiveDialog(props: DialogProps) {
+ const isMobileView = useIsMobileView();
+ const DialogComponent = isMobileView ? MobileDialog : Dialog;
+
+ return (
+
+ );
+}
diff --git a/src/ui/common/components/Modals/SignDetailsModal.tsx b/src/ui/common/components/Modals/SignDetailsModal.tsx
new file mode 100644
index 000000000..42b24acbc
--- /dev/null
+++ b/src/ui/common/components/Modals/SignDetailsModal.tsx
@@ -0,0 +1,57 @@
+import { EventData } from "@babylonlabs-io/btc-staking-ts";
+import {
+ Button,
+ DialogBody,
+ DialogFooter,
+ DialogHeader,
+} from "@babylonlabs-io/core-ui";
+
+import { SignDetails } from "@/ui/common/components/SignDetails/SignDetails";
+
+import { ResponsiveDialog } from "./ResponsiveDialog";
+
+interface SignDetailsModalProps {
+ open: boolean;
+ onClose: () => void;
+ details?: EventData;
+ title?: string;
+}
+
+export const SignDetailsModal: React.FC = ({
+ open,
+ onClose,
+ details,
+ title = "Sign Details",
+}) => {
+ if (!details) {
+ return null;
+ }
+
+ const capitalizedTitle = title
+ .split("-")
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
+ .join(" ");
+
+ return (
+
+
+
+
+
+
+
+ Close
+
+
+
+ );
+};
diff --git a/src/ui/common/components/Modals/SignModal/SignModal.tsx b/src/ui/common/components/Modals/SignModal/SignModal.tsx
new file mode 100644
index 000000000..ccf61282c
--- /dev/null
+++ b/src/ui/common/components/Modals/SignModal/SignModal.tsx
@@ -0,0 +1,97 @@
+import { EventData } from "@babylonlabs-io/btc-staking-ts";
+import {
+ Button,
+ DialogBody,
+ DialogFooter,
+ DialogHeader,
+ Loader,
+ Text,
+} from "@babylonlabs-io/core-ui";
+
+import { ResponsiveDialog } from "@/ui/common/components/Modals/ResponsiveDialog";
+import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn";
+import { getNetworkConfigBTC } from "@/ui/common/config/network/btc";
+
+import { Step } from "./Step";
+
+interface SignModalProps {
+ processing?: boolean;
+ open: boolean;
+ title: string;
+ step: number;
+ onClose?: () => void;
+ onSubmit?: () => void;
+ options?: EventData;
+}
+
+const { coinSymbol } = getNetworkConfigBTC();
+const { coinSymbol: bbnCoinSymbol } = getNetworkConfigBBN();
+
+export const SignModal = ({
+ processing = false,
+ open,
+ title,
+ step,
+ onClose,
+ onSubmit,
+ options,
+}: SignModalProps) => (
+
+
+
+
+
+ Please sign the following messages
+
+
+
+
+ Consent to slashing
+
+
+ Consent to slashing during unbonding
+
+
+ {coinSymbol}-{bbnCoinSymbol} address binding for receiving staking
+ rewards
+
+ {/* There are no details to show on staking transaction registration */}
+
+ Staking transaction registration
+
+
+
+
+
+ {onClose && (
+
+ Cancel
+
+ )}
+
+ {onSubmit && (
+
+ {processing ? (
+
+ ) : (
+ "Sign"
+ )}
+
+ )}
+
+
+);
diff --git a/src/ui/common/components/Modals/SignModal/Step.tsx b/src/ui/common/components/Modals/SignModal/Step.tsx
new file mode 100644
index 000000000..f32339796
--- /dev/null
+++ b/src/ui/common/components/Modals/SignModal/Step.tsx
@@ -0,0 +1,99 @@
+import { EventData } from "@babylonlabs-io/btc-staking-ts";
+import {
+ Accordion,
+ AccordionDetails,
+ AccordionSummary,
+ Loader,
+ Text,
+} from "@babylonlabs-io/core-ui";
+import { type PropsWithChildren, type ReactNode } from "react";
+import { AiOutlineMinus, AiOutlinePlus } from "react-icons/ai";
+import { IoCheckmarkSharp } from "react-icons/io5";
+import { twMerge } from "tailwind-merge";
+
+import { SignDetails } from "../../SignDetails/SignDetails";
+
+interface StepProps {
+ step: number;
+ currentStep: number;
+ children: ReactNode;
+ shouldShowDetails?: boolean;
+ options?: EventData;
+}
+
+const renderIcon = (step: number, currentStep: number) => {
+ if (currentStep > step) {
+ return (
+
+
+
+ );
+ }
+
+ if (currentStep === step) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ {step}
+
+
+ );
+};
+
+export const Step = ({
+ step,
+ currentStep,
+ children,
+ shouldShowDetails,
+ options,
+}: PropsWithChildren) => {
+ return (
+
+ {shouldShowDetails && options ? (
+
+
+ expanded ? (
+
+ ) : (
+
+ )
+ }
+ >
+
+ {renderIcon(step, currentStep)}
+
+ Step {step}: {children}
+
+
+
+
+
+
+
+ ) : (
+
+
+ {renderIcon(step, currentStep)}
+
+ Step {step}: {children}
+
+
+
+ )}
+
+ );
+};
diff --git a/src/ui/common/components/Modals/SlashingModal.tsx b/src/ui/common/components/Modals/SlashingModal.tsx
new file mode 100644
index 000000000..626cc688e
--- /dev/null
+++ b/src/ui/common/components/Modals/SlashingModal.tsx
@@ -0,0 +1,31 @@
+import { Text } from "@babylonlabs-io/core-ui";
+
+import { NetworkConfig } from "@/ui/common/config/network";
+import { BbnStakingParamsVersion } from "@/ui/common/types/networkInfo";
+
+import { ConfirmationModal } from "./ConfirmationModal";
+
+interface UnbondModalProps {
+ processing: boolean;
+ open: boolean;
+ onClose: () => void;
+ onSubmit: () => void;
+ networkConfig: NetworkConfig;
+ param: BbnStakingParamsVersion | null;
+}
+
+export const SlashingModal = (props: UnbondModalProps) => {
+ const { networkConfig, param } = props;
+ const slashingRate = (param?.slashing?.slashingRate ?? 0) * 100;
+
+ return (
+
+
+ Your finality provider equivocated (double-voted) leading to{" "}
+ {slashingRate}% of your stake getting slashed. You are about to withdraw
+ the remaining balance. A transaction fee will be deducted from your
+ stake by the {networkConfig.btc.networkName} network.
+
+
+ );
+};
diff --git a/src/ui/common/components/Modals/StakeModal.tsx b/src/ui/common/components/Modals/StakeModal.tsx
new file mode 100644
index 000000000..043376a0d
--- /dev/null
+++ b/src/ui/common/components/Modals/StakeModal.tsx
@@ -0,0 +1,39 @@
+import { BiSolidBadgeCheck } from "react-icons/bi";
+
+import { getNetworkConfig } from "@/ui/common/config/network";
+
+import { SubmitModal } from "./SubmitModal";
+
+interface StakeModalProps {
+ processing?: boolean;
+ open: boolean;
+ onSubmit?: () => void;
+ onClose?: () => void;
+}
+
+const { btc, bbn } = getNetworkConfig();
+
+export const StakeModal = ({
+ processing,
+ open,
+ onSubmit,
+ onClose,
+}: StakeModalProps) => (
+ }
+ title="Verified"
+ submitButton={
+ <>
+ Stake {btc.coinName}
+ >
+ }
+ cancelButton="Later"
+ onSubmit={onSubmit}
+ onClose={onClose}
+ >
+ Your request has been verified by the {bbn.networkFullName}. You can now
+ stake!
+
+);
diff --git a/src/ui/common/components/Modals/SubmitModal.tsx b/src/ui/common/components/Modals/SubmitModal.tsx
new file mode 100644
index 000000000..4750a5f22
--- /dev/null
+++ b/src/ui/common/components/Modals/SubmitModal.tsx
@@ -0,0 +1,75 @@
+import {
+ Button,
+ DialogBody,
+ DialogFooter,
+ Heading,
+ Loader,
+ Text,
+} from "@babylonlabs-io/core-ui";
+import type { JSX, PropsWithChildren } from "react";
+
+import { ResponsiveDialog } from "./ResponsiveDialog";
+
+interface SubmitModalProps {
+ className?: string;
+ processing?: boolean;
+ disabled?: boolean;
+ open: boolean;
+ icon: JSX.Element;
+ title: string | JSX.Element;
+ cancelButton?: string | JSX.Element;
+ submitButton?: string | JSX.Element;
+ onClose?: () => void;
+ onSubmit?: () => void;
+}
+
+export const SubmitModal = ({
+ className,
+ processing = false,
+ disabled = false,
+ icon,
+ title,
+ children,
+ open,
+ cancelButton = "Cancel",
+ submitButton = "Submit",
+ onClose,
+ onSubmit,
+}: PropsWithChildren) => (
+
+
+
+ {icon}
+
+
+
+ {title}
+
+
+ {children}
+
+
+
+ {cancelButton && (
+
+ {cancelButton}
+
+ )}
+
+ {submitButton && (
+
+ {processing ? (
+
+ ) : (
+ submitButton
+ )}
+
+ )}
+
+
+);
diff --git a/src/ui/common/components/Modals/SuccessFeedbackModal.tsx b/src/ui/common/components/Modals/SuccessFeedbackModal.tsx
new file mode 100644
index 000000000..bb399cefb
--- /dev/null
+++ b/src/ui/common/components/Modals/SuccessFeedbackModal.tsx
@@ -0,0 +1,45 @@
+import { LuPartyPopper } from "react-icons/lu";
+
+import { SubmitModal } from "./SubmitModal";
+
+interface FeedbackModalProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+export const SuccessFeedbackModal: React.FC = ({
+ open,
+ onClose,
+}) => (
+ }
+ title="Congratulations"
+ open={open}
+ submitButton="Done"
+ cancelButton=""
+ onSubmit={onClose}
+ >
+
+ Share feedback or report issues on our{" "}
+
+ Forums
+ {" "}
+ or{" "}
+
+ Discord
+ {" "}
+ (#feedback and #support) – thank you for being part of the Babylon
+ community!
+
+
+);
diff --git a/src/ui/common/components/Modals/UnbondModal.tsx b/src/ui/common/components/Modals/UnbondModal.tsx
new file mode 100644
index 000000000..2d2b2af6b
--- /dev/null
+++ b/src/ui/common/components/Modals/UnbondModal.tsx
@@ -0,0 +1,54 @@
+import { Text } from "@babylonlabs-io/core-ui";
+
+import { getNetworkConfigBTC } from "@/ui/common/config/network/btc";
+import { satoshiToBtc } from "@/ui/common/utils/btc";
+import { maxDecimals } from "@/ui/common/utils/maxDecimals";
+import { blocksToDisplayTime } from "@/ui/common/utils/time";
+
+import { ConfirmationModal } from "./ConfirmationModal";
+
+interface UnbondModalProps {
+ processing: boolean;
+ open: boolean;
+ onClose: () => void;
+ onSubmit: () => void;
+ unbondingFeeSat: number | undefined;
+ unbondingTimeInBlocks: number | undefined;
+}
+const { networkName, coinSymbol } = getNetworkConfigBTC();
+
+export const UnbondModal = ({
+ open,
+ onClose,
+ onSubmit,
+ processing,
+ unbondingFeeSat,
+ unbondingTimeInBlocks,
+}: UnbondModalProps) => {
+ if (!unbondingTimeInBlocks || !unbondingFeeSat) {
+ return null;
+ }
+
+ const formattedUnbondingTime = blocksToDisplayTime(unbondingTimeInBlocks);
+ const unbondingFeeBtc = maxDecimals(satoshiToBtc(unbondingFeeSat), 8);
+
+ return (
+
+
+ You are about to unbond your stake before its expiration. A transaction
+ fee of {unbondingFeeBtc} {coinSymbol} will be deduced from your stake by
+ the {networkName} network.
+
+ The expected unbonding time will be about {formattedUnbondingTime}.
+ After unbonded, you will need to use this dashboard to withdraw your
+ stake for it to appear in your wallet.
+
+
+ );
+};
diff --git a/src/ui/common/components/Modals/VerificationModal.tsx b/src/ui/common/components/Modals/VerificationModal.tsx
new file mode 100644
index 000000000..9393c8e5b
--- /dev/null
+++ b/src/ui/common/components/Modals/VerificationModal.tsx
@@ -0,0 +1,49 @@
+import { Loader } from "@babylonlabs-io/core-ui";
+
+import { getNetworkConfig } from "@/ui/common/config/network";
+
+import { SubmitModal } from "./SubmitModal";
+
+interface VerificationModalProps {
+ processing: boolean;
+ open: boolean;
+ step: 1 | 2;
+}
+
+const { btc, bbn } = getNetworkConfig();
+
+const VERIFICATION_STEPS = {
+ 1: {
+ title: (
+ <>
+ 1/2 Processing Confirmation
+ >
+ ),
+ description: `Waiting for the staking registration to be confirmed on ${bbn.networkFullName}.`,
+ },
+ 2: {
+ title: (
+ <>
+ 2/2 Pending Verification
+ >
+ ),
+ description: `The ${bbn.networkFullName} is verifying your staking transaction.`,
+ },
+} as const;
+
+export const VerificationModal = ({
+ processing,
+ open,
+ step,
+}: VerificationModalProps) => (
+ }
+ title={VERIFICATION_STEPS[step].title}
+ submitButton={`Stake ${btc.coinName}`}
+ cancelButton=""
+ >
+ {VERIFICATION_STEPS[step].description}
+
+);
diff --git a/src/ui/common/components/Modals/WalletDisconnectModal.tsx b/src/ui/common/components/Modals/WalletDisconnectModal.tsx
new file mode 100644
index 000000000..21c490002
--- /dev/null
+++ b/src/ui/common/components/Modals/WalletDisconnectModal.tsx
@@ -0,0 +1,75 @@
+import {
+ Button,
+ DialogBody,
+ DialogFooter,
+ Heading,
+ Text,
+} from "@babylonlabs-io/core-ui";
+import { useCallback } from "react";
+import { MdCancel } from "react-icons/md";
+
+import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn";
+import { getNetworkConfigBTC } from "@/ui/common/config/network/btc";
+
+import { ResponsiveDialog } from "./ResponsiveDialog";
+
+interface WalletDisconnectModalProps {
+ isOpen: boolean;
+ closeMenu: () => void;
+ onClose: () => void;
+ onDisconnect: () => void;
+}
+
+const { networkName } = getNetworkConfigBTC();
+const { networkFullName: bbnNetworkFullName } = getNetworkConfigBBN();
+
+export const WalletDisconnectModal = ({
+ isOpen,
+ closeMenu,
+ onClose,
+ onDisconnect,
+}: WalletDisconnectModalProps) => {
+ const handleDisconnect = useCallback(() => {
+ onDisconnect();
+ closeMenu();
+ }, [onDisconnect, closeMenu]);
+
+ return (
+
+
+
+
+ Disconnect Wallets
+
+
+ Disconnecting will log you out of both your {bbnNetworkFullName} Chain
+ and {networkName} wallets. You'll need to reconnect them to
+ access your staking account.
+
+
+
+
+
+ Cancel
+
+
+ Disconnect
+
+
+
+ );
+};
diff --git a/src/ui/common/components/Modals/WithdrawModal.tsx b/src/ui/common/components/Modals/WithdrawModal.tsx
new file mode 100644
index 000000000..40b305c4a
--- /dev/null
+++ b/src/ui/common/components/Modals/WithdrawModal.tsx
@@ -0,0 +1,25 @@
+import { Text } from "@babylonlabs-io/core-ui";
+
+import { getNetworkConfigBTC } from "@/ui/common/config/network/btc";
+
+import { ConfirmationModal } from "./ConfirmationModal";
+
+interface WithdrawModalProps {
+ processing: boolean;
+ open: boolean;
+ onClose: () => void;
+ onSubmit: () => void;
+}
+
+const { networkName } = getNetworkConfigBTC();
+
+export const WithdrawModal = (props: WithdrawModalProps) => {
+ return (
+
+
+ You are about to withdraw your stake. A transaction fee will be
+ deduced from your stake by the {networkName} network.
+
+
+ );
+};
diff --git a/src/ui/common/components/Multistaking/BsnFinalityProviderField/BsnFinalityProviderField.tsx b/src/ui/common/components/Multistaking/BsnFinalityProviderField/BsnFinalityProviderField.tsx
new file mode 100644
index 000000000..a279fa372
--- /dev/null
+++ b/src/ui/common/components/Multistaking/BsnFinalityProviderField/BsnFinalityProviderField.tsx
@@ -0,0 +1,131 @@
+import { useField } from "@babylonlabs-io/core-ui";
+import { useMemo } from "react";
+
+import { CounterButton } from "@/ui/common/components/Multistaking/CounterButton";
+import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn";
+import {
+ StakingModalPage,
+ useFinalityProviderBsnState,
+} from "@/ui/common/state/FinalityProviderBsnState";
+
+import { ChainSelectionModal } from "../ChainSelectionModal/ChainSelectionModal";
+import { FinalityProviderModal } from "../FinalityProviderField/FinalityProviderModal";
+import { SubSection } from "../MultistakingForm/SubSection";
+
+import { SelectedProvidersList } from "./SelectedProvidersList";
+
+interface Props {
+ max: number;
+}
+
+const { chainId: BBN_CHAIN_ID } = getNetworkConfigBBN();
+
+export function BsnFinalityProviderField({ max }: Props) {
+ const { value: selectedProviderMap = {}, onChange } = useField<
+ Record
+ >({
+ name: "finalityProviders",
+ defaultValue: {},
+ });
+
+ const count = useMemo(
+ () => Object.keys(selectedProviderMap).length,
+ [selectedProviderMap],
+ );
+
+ const {
+ bsnList,
+ bsnLoading,
+ stakingModalPage,
+ selectedBsnId,
+ setStakingModalPage,
+ setSelectedBsnId,
+ } = useFinalityProviderBsnState();
+
+ const allowsMultipleBsns = max > 1;
+
+ const handleAdd = (selectedBsnId: string, providerPk: string) => {
+ onChange({ ...selectedProviderMap, [selectedBsnId]: providerPk });
+ setStakingModalPage(StakingModalPage.DEFAULT);
+ };
+
+ const handleRemove = (selectedBsnId?: string) => {
+ if (selectedBsnId !== undefined) {
+ const map = { ...selectedProviderMap };
+ Reflect.deleteProperty(map, selectedBsnId);
+ onChange(map);
+ }
+ };
+
+ const handleOpen = () => {
+ if (allowsMultipleBsns) {
+ setStakingModalPage(StakingModalPage.BSN);
+ } else {
+ setSelectedBsnId(BBN_CHAIN_ID);
+ setStakingModalPage(StakingModalPage.FINALITY_PROVIDER);
+ }
+ };
+
+ const handleClose = () => {
+ setStakingModalPage(StakingModalPage.DEFAULT);
+ setSelectedBsnId(undefined);
+ };
+
+ const handleNext = () => {
+ setStakingModalPage(StakingModalPage.FINALITY_PROVIDER);
+ };
+
+ const handleSelectBsn = (chainId: string) => {
+ setSelectedBsnId(chainId);
+ };
+
+ const handleBack = () => {
+ if (allowsMultipleBsns) {
+ setStakingModalPage(StakingModalPage.BSN);
+ } else {
+ setStakingModalPage(StakingModalPage.DEFAULT);
+ }
+ };
+
+ const actionText = allowsMultipleBsns
+ ? "Add BSN and Finality Provider"
+ : "Add Finality Provider";
+
+ return (
+
+
+
+ {count > 0 && (
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/src/ui/common/components/Multistaking/BsnFinalityProviderField/BsnFinalityProviderItem.tsx b/src/ui/common/components/Multistaking/BsnFinalityProviderField/BsnFinalityProviderItem.tsx
new file mode 100644
index 000000000..fed15f797
--- /dev/null
+++ b/src/ui/common/components/Multistaking/BsnFinalityProviderField/BsnFinalityProviderItem.tsx
@@ -0,0 +1,79 @@
+import { Avatar } from "@babylonlabs-io/core-ui";
+import { useMemo } from "react";
+
+import { FinalityProviderLogo } from "@/ui/common/components/Staking/FinalityProviders/FinalityProviderLogo";
+import { chainLogos } from "@/ui/common/constants";
+import { useFinalityProviderBsnState } from "@/ui/common/state/FinalityProviderBsnState";
+import { useFinalityProviderState } from "@/ui/common/state/FinalityProviderState";
+
+export const BsnFinalityProviderItem = ({
+ bsnId,
+ providerId,
+ onRemove,
+}: {
+ bsnId: string;
+ providerId: string;
+ onRemove: (bsnId?: string) => void;
+}) => {
+ const { bsnList } = useFinalityProviderBsnState();
+ const { finalityProviderMap } = useFinalityProviderState();
+ const provider = finalityProviderMap.get(providerId);
+
+ const bsn = useMemo(
+ () => bsnList.find((bsn) => bsn.id === bsnId),
+ [bsnList, bsnId],
+ );
+
+ const renderBsnLogo = () => {
+ if (!bsn || !provider) {
+ return null;
+ }
+
+ const logoUrl = chainLogos[provider.chain || "babylon"];
+
+ return (
+
+ );
+ };
+
+ if (!provider) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ {renderBsnLogo()}
+ {bsn?.name}
+
+
+ {provider.description?.moniker}
+
+
+
+
+
{
+ onRemove(bsnId);
+ }}
+ className="text-accent-primary text-xs tracking-[0.4px] bg-accent-secondary/20 px-2 py-0.5 rounded cursor-pointer"
+ >
+ Remove
+
+
+ );
+};
diff --git a/src/ui/common/components/Multistaking/BsnFinalityProviderField/SelectedProvidersList.tsx b/src/ui/common/components/Multistaking/BsnFinalityProviderField/SelectedProvidersList.tsx
new file mode 100644
index 000000000..ac79da2ea
--- /dev/null
+++ b/src/ui/common/components/Multistaking/BsnFinalityProviderField/SelectedProvidersList.tsx
@@ -0,0 +1,30 @@
+import { useMemo } from "react";
+
+import { BsnFinalityProviderItem } from "@/ui/common/components/Multistaking/BsnFinalityProviderField/BsnFinalityProviderItem";
+
+interface SelectedProvidersListProps {
+ selectedFPs: Record;
+ onRemove: (bsnId?: string) => void;
+}
+
+export function SelectedProvidersList({
+ selectedFPs,
+ onRemove,
+}: SelectedProvidersListProps) {
+ const values = useMemo(() => Object.entries(selectedFPs), [selectedFPs]);
+
+ if (values.length === 0) return null;
+
+ return (
+
+ {values.map(([bsnId, providerId]) => (
+
+ ))}
+
+ );
+}
diff --git a/src/ui/common/components/Multistaking/ChainSelectionModal/ChainSelectionModal.tsx b/src/ui/common/components/Multistaking/ChainSelectionModal/ChainSelectionModal.tsx
new file mode 100644
index 000000000..9eb3b5af8
--- /dev/null
+++ b/src/ui/common/components/Multistaking/ChainSelectionModal/ChainSelectionModal.tsx
@@ -0,0 +1,178 @@
+import {
+ Button,
+ DialogBody,
+ DialogFooter,
+ DialogHeader,
+ Text,
+} from "@babylonlabs-io/core-ui";
+import { PropsWithChildren, useMemo } from "react";
+import { MdOutlineInfo } from "react-icons/md";
+import { twMerge } from "tailwind-merge";
+
+import { ResponsiveDialog } from "@/ui/common/components/Modals/ResponsiveDialog";
+import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn";
+import { chainLogos } from "@/ui/common/constants";
+import { Bsn } from "@/ui/common/types/bsn";
+
+const BSN_ID = getNetworkConfigBBN().chainId;
+
+const SubSection = ({
+ children,
+ style,
+ className,
+}: {
+ children: React.ReactNode;
+ style?: React.CSSProperties;
+ className?: string;
+}) => (
+
+ {children}
+
+);
+
+interface ChainButtonProps extends PropsWithChildren {
+ className?: string;
+ disabled?: boolean;
+ logo?: string;
+ title?: string | JSX.Element;
+ alt?: string;
+ selected?: boolean;
+ onClick?: () => void;
+}
+
+const ChainButton = ({
+ className,
+ disabled,
+ title,
+ logo,
+ selected,
+ onClick,
+}: ChainButtonProps) => (
+
+
+
+ {logo && (
+
+ )}
+ {title}
+
+
+
+);
+
+interface ChainSelectionModalProps {
+ open: boolean;
+ loading?: boolean;
+ activeBsnId?: string;
+ selectedBsns?: Record;
+ bsns?: Bsn[];
+ onNext: () => void;
+ onClose: () => void;
+ onSelect: (bsnId: string) => void;
+}
+
+export const ChainSelectionModal = ({
+ bsns = [],
+ open,
+ loading,
+ activeBsnId,
+ selectedBsns = {},
+ onSelect,
+ onNext,
+ onClose,
+}: ChainSelectionModalProps) => {
+ const babylonBsn = useMemo(
+ () => bsns.find((bsn) => bsn.id === BSN_ID),
+ [bsns],
+ );
+ const externalBsns = useMemo(
+ () => bsns.filter((bsn) => bsn.id !== BSN_ID),
+ [bsns],
+ );
+ const isBabylonSelected = babylonBsn
+ ? Boolean(selectedBsns[babylonBsn.id])
+ : false;
+
+ return (
+
+
+
+
+
+ Bitcoin Supercharged Networks (BSNs) are Proof-of-Stake systems
+ secured by Bitcoin staking. Select a network to delegate your stake
+ and earn rewards.
+
+
+ {loading &&
Loading...
}
+ {babylonBsn && (
+
onSelect(babylonBsn.id)}
+ />
+ )}
+ {externalBsns.map((bsn) => (
+ onSelect(bsn.id)}
+ />
+ ))}
+
+ {!isBabylonSelected && (
+
+
+
+
+
+ Babylon Genesis must be the first BSN you add before selecting
+ others. Once added, you can choose additional BSNs to multi-stake.
+
+
+ )}
+
+
+
+
+ Next
+
+
+
+ );
+};
diff --git a/src/ui/common/components/Multistaking/CounterButton.tsx b/src/ui/common/components/Multistaking/CounterButton.tsx
new file mode 100644
index 000000000..9e081db31
--- /dev/null
+++ b/src/ui/common/components/Multistaking/CounterButton.tsx
@@ -0,0 +1,42 @@
+import { AiOutlinePlus } from "react-icons/ai";
+import { twJoin } from "tailwind-merge";
+
+interface CounterButtonProps {
+ counter: number;
+ max: number;
+ onAdd: () => void;
+ alwaysShowCounter?: boolean;
+}
+
+export function CounterButton({
+ counter,
+ max,
+ onAdd,
+ alwaysShowCounter = false,
+}: CounterButtonProps) {
+ const isClickable = counter < max;
+ const showsCounter =
+ (0 < counter && 1 < max) || (alwaysShowCounter && counter === 0);
+
+ return (
+
+ {isClickable && (
+
+ )}
+ {showsCounter && (
+
+ {counter}/{max}
+
+ )}
+
+ );
+}
diff --git a/src/ui/common/components/Multistaking/FinalityProviderField/FinalityProviderModal.tsx b/src/ui/common/components/Multistaking/FinalityProviderField/FinalityProviderModal.tsx
new file mode 100644
index 000000000..7030eaad0
--- /dev/null
+++ b/src/ui/common/components/Multistaking/FinalityProviderField/FinalityProviderModal.tsx
@@ -0,0 +1,78 @@
+import {
+ Button,
+ DialogBody,
+ DialogFooter,
+ DialogHeader,
+} from "@babylonlabs-io/core-ui";
+import { useState } from "react";
+
+import { ResponsiveDialog } from "@/ui/common/components/Modals/ResponsiveDialog";
+import { FinalityProviders } from "@/ui/common/components/Multistaking/FinalityProviderField/FinalityProviders";
+
+interface Props {
+ open: boolean;
+ defaultFinalityProvider?: string;
+ selectedBsnId?: string;
+ onClose: () => void;
+ onAdd: (selectedBsnId: string, selectedProviderKey: string) => void;
+ onBack?: () => void;
+}
+
+export const FinalityProviderModal = ({
+ defaultFinalityProvider = "",
+ open,
+ selectedBsnId,
+ onClose,
+ onAdd,
+ onBack,
+}: Props) => {
+ const [selectedFP, setSelectedFp] = useState(defaultFinalityProvider);
+
+ const handleClose = () => {
+ onClose();
+ setSelectedFp("");
+ };
+
+ return (
+
+
+
+
+
+ Finality Providers play a key role in securing Proof-of-Stake networks
+ by validating and finalising transactions. Select one to delegate your
+ stake and earn rewards.
+
+
+
+
+
+
+
+ {onBack ? (
+
+ Back
+
+ ) : (
+
+ )}
+ {
+ if (selectedBsnId !== undefined) {
+ onAdd(selectedBsnId, selectedFP);
+ handleClose();
+ }
+ }}
+ disabled={!selectedFP}
+ >
+ Add
+
+
+
+ );
+};
diff --git a/src/ui/common/components/Multistaking/FinalityProviderField/FinalityProviders.tsx b/src/ui/common/components/Multistaking/FinalityProviderField/FinalityProviders.tsx
new file mode 100644
index 000000000..a5d7b0cc0
--- /dev/null
+++ b/src/ui/common/components/Multistaking/FinalityProviderField/FinalityProviders.tsx
@@ -0,0 +1,25 @@
+import { FinalityProviderFilter } from "@/ui/common/components/Multistaking/FinalityProviders/FinalityProviderFilter";
+import { FinalityProviderSearch } from "@/ui/common/components/Multistaking/FinalityProviders/FinalityProviderSearch";
+import { FinalityProviderTable } from "@/ui/common/components/Multistaking/FinalityProviders/FinalityProviderTable";
+
+interface Props {
+ selectedFP: string;
+ onChange: (value: string) => void;
+}
+
+export const FinalityProviders = ({ selectedFP, onChange }: Props) => {
+ return (
+
+ );
+};
diff --git a/src/ui/common/components/Multistaking/FinalityProviders/FinalityProviderFilter.tsx b/src/ui/common/components/Multistaking/FinalityProviders/FinalityProviderFilter.tsx
new file mode 100644
index 000000000..92332d7e4
--- /dev/null
+++ b/src/ui/common/components/Multistaking/FinalityProviders/FinalityProviderFilter.tsx
@@ -0,0 +1,23 @@
+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();
+
+ return (
+ handleFilter("status", value.toString())}
+ placeholder="Select Status"
+ value={filter.search ? "" : filter.status}
+ disabled={Boolean(filter.search)}
+ renderSelectedOption={(option) => `Showing ${option.label}`}
+ />
+ );
+};
diff --git a/src/ui/common/components/Multistaking/FinalityProviders/FinalityProviderSearch.tsx b/src/ui/common/components/Multistaking/FinalityProviders/FinalityProviderSearch.tsx
new file mode 100644
index 000000000..5ee5bf10a
--- /dev/null
+++ b/src/ui/common/components/Multistaking/FinalityProviders/FinalityProviderSearch.tsx
@@ -0,0 +1,43 @@
+import { Input } from "@babylonlabs-io/core-ui";
+import { useCallback } from "react";
+import { MdCancel } from "react-icons/md";
+import { RiSearchLine } from "react-icons/ri";
+
+import { useFinalityProviderBsnState } from "@/ui/common/state/FinalityProviderBsnState";
+
+export const FinalityProviderSearch = () => {
+ const { filter, handleFilter } = useFinalityProviderBsnState();
+
+ const onSearchChange = useCallback(
+ (e: React.ChangeEvent) => {
+ handleFilter("search", e.target.value);
+ },
+ [handleFilter],
+ );
+
+ const onClearSearch = useCallback(() => {
+ handleFilter("search", "");
+ }, [handleFilter]);
+
+ const searchSuffix = filter.search ? (
+
+
+
+ ) : (
+
+
+
+ );
+
+ return (
+
+ );
+};
diff --git a/src/ui/common/components/Multistaking/FinalityProviders/FinalityProviderTable.tsx b/src/ui/common/components/Multistaking/FinalityProviders/FinalityProviderTable.tsx
new file mode 100644
index 000000000..e0abe6452
--- /dev/null
+++ b/src/ui/common/components/Multistaking/FinalityProviders/FinalityProviderTable.tsx
@@ -0,0 +1,147 @@
+/**
+ * Import polyfill for array.toSorted
+ */
+import "core-js/features/array/to-sorted";
+
+import { Button, Loader } from "@babylonlabs-io/core-ui";
+
+import warningOctagon from "@/ui/common/assets/warning-octagon.svg";
+import warningTriangle from "@/ui/common/assets/warning-triangle.svg";
+import { FinalityProviderLogo } from "@/ui/common/components/Staking/FinalityProviders/FinalityProviderLogo";
+import { StatusView } from "@/ui/common/components/Staking/FinalityProviders/FinalityProviderTableStatusView";
+import { getNetworkConfigBTC } from "@/ui/common/config/network/btc";
+import { useFinalityProviderBsnState } from "@/ui/common/state/FinalityProviderBsnState";
+import { FinalityProviderStateLabels } from "@/ui/common/types/finalityProviders";
+import { satoshiToBtc } from "@/ui/common/utils/btc";
+import { maxDecimals } from "@/ui/common/utils/maxDecimals";
+
+interface Props {
+ selectedFP?: string;
+ onSelectRow?: (btcPk: string) => void;
+}
+
+export const FinalityProviderTable = ({ selectedFP, onSelectRow }: Props) => {
+ const { isFetching, finalityProviders, hasError, isRowSelectable } =
+ useFinalityProviderBsnState();
+
+ const { coinSymbol } = getNetworkConfigBTC();
+
+ 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"}
+
+
+
+
+
+
+
{coinSymbol} PK
+
+ {fp.btcPk
+ ? `${fp.btcPk.slice(0, 5)}...${fp.btcPk.slice(-5)}`
+ : ""}
+
+
+
+
Total Delegation
+
+ {totalDelegation} {coinSymbol}
+
+
+
+
Commission
+
+ {commission}%
+
+
+
handleSelect(fp.btcPk)}
+ disabled={!isSelectable}
+ variant={isSelected ? "contained" : "outlined"}
+ >
+ {isSelected ? "Selected" : "Select"}
+
+
+ );
+ })}
+
+ );
+};
diff --git a/src/ui/common/components/Multistaking/MultistakingForm/AmountBalanceInfo.tsx b/src/ui/common/components/Multistaking/MultistakingForm/AmountBalanceInfo.tsx
new file mode 100644
index 000000000..4d8ce8dc5
--- /dev/null
+++ b/src/ui/common/components/Multistaking/MultistakingForm/AmountBalanceInfo.tsx
@@ -0,0 +1,46 @@
+import { useWatch } from "@babylonlabs-io/core-ui";
+
+import { getNetworkConfigBTC } from "@/ui/common/config/network/btc";
+import { usePrice } from "@/ui/common/hooks/client/api/usePrices";
+import { useBalanceState } from "@/ui/common/state/BalanceState";
+import { satoshiToBtc } from "@/ui/common/utils/btc";
+import { calculateTokenValueInCurrency } from "@/ui/common/utils/formatCurrency";
+import { maxDecimals } from "@/ui/common/utils/maxDecimals";
+
+const { coinSymbol, displayUSD } = getNetworkConfigBTC();
+
+//TODO: Temporary disable max button until we implement https://github.com/babylonlabs-io/simple-staking/issues/1119
+export const AmountBalanceInfo = () => {
+ const { stakableBtcBalance } = useBalanceState();
+
+ const btcAmount = useWatch({ name: "amount", defaultValue: "" });
+ // const { setValue } = useFormContext();
+ const btcInUsd = usePrice(coinSymbol);
+
+ const btcAmountValue = parseFloat(btcAmount || "0");
+ const btcAmountUsd = calculateTokenValueInCurrency(btcAmountValue, btcInUsd, {
+ zeroDisplay: "$0.00",
+ });
+ const formattedBalance = satoshiToBtc(stakableBtcBalance);
+
+ // const handleSetMaxBalance = () => {
+ // setValue("amount", formattedBalance.toString(), {
+ // shouldValidate: true,
+ // shouldDirty: true,
+ // shouldTouch: true,
+ // });
+ // };
+
+ return (
+
+
+ Stakable:{" "}
+
+ {maxDecimals(formattedBalance, 8)}
+ {" "}
+ {coinSymbol}
+
+ {displayUSD &&
{btcAmountUsd} USD
}
+
+ );
+};
diff --git a/src/ui/common/components/Multistaking/MultistakingForm/AmountSubsection.tsx b/src/ui/common/components/Multistaking/MultistakingForm/AmountSubsection.tsx
new file mode 100644
index 000000000..828abc4c6
--- /dev/null
+++ b/src/ui/common/components/Multistaking/MultistakingForm/AmountSubsection.tsx
@@ -0,0 +1,59 @@
+import { HiddenField, useFormContext, useWatch } from "@babylonlabs-io/core-ui";
+
+import { AuthGuard } from "@/ui/common/components/Common/AuthGuard";
+import { getNetworkConfigBTC } from "@/ui/common/config/network/btc";
+
+import { AmountBalanceInfo } from "./AmountBalanceInfo";
+import { SubSection } from "./SubSection";
+
+const { icon, name } = getNetworkConfigBTC();
+
+export const AmountSubsection = () => {
+ const btcAmount = useWatch({ name: "amount", defaultValue: "" });
+ const { setValue } = useFormContext();
+
+ const handleInputChange = (e: React.ChangeEvent) => {
+ setValue("amount", e.target.value, {
+ shouldValidate: true,
+ shouldDirty: true,
+ shouldTouch: true,
+ });
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "ArrowUp" || e.key === "ArrowDown") {
+ e.preventDefault();
+ }
+ };
+
+ return (
+
+
+
+
+
{name}
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/ui/common/components/Multistaking/MultistakingForm/BTCFeeRate.tsx b/src/ui/common/components/Multistaking/MultistakingForm/BTCFeeRate.tsx
new file mode 100644
index 000000000..751bf41be
--- /dev/null
+++ b/src/ui/common/components/Multistaking/MultistakingForm/BTCFeeRate.tsx
@@ -0,0 +1,125 @@
+import { Button, useFormContext, useWatch } from "@babylonlabs-io/core-ui";
+import { useEffect, useMemo, useState } from "react";
+import { FaPen } from "react-icons/fa6";
+
+import { FeeItem } from "@/ui/common/components/Staking/DelegationForm/components/FeeItem";
+import { FeeModal } from "@/ui/common/components/Staking/FeeModal";
+import { useStakingService } from "@/ui/common/hooks/services/useStakingService";
+
+interface FeeFiledProps {
+ defaultRate?: number;
+}
+
+export function BTCFeeRate({ defaultRate = 0 }: FeeFiledProps) {
+ const [visible, setVisibility] = useState(false);
+ const feeRate = useWatch({ name: "feeRate" });
+ const { setValue, setError, clearErrors } = useFormContext();
+ const { calculateFeeAmount } = useStakingService();
+
+ const amount = useWatch({ name: "amount" });
+ const term = useWatch({ name: "term" });
+ const finalityProviders = useWatch({ name: "finalityProviders" });
+
+ const validFinalityProviders = useMemo(
+ () => Object.values(finalityProviders ?? {}) as string[],
+ [finalityProviders],
+ );
+
+ useEffect(() => {
+ setValue("feeRate", defaultRate.toString(), {
+ shouldValidate: true,
+ shouldDirty: true,
+ shouldTouch: true,
+ });
+ }, [defaultRate, setValue]);
+
+ // TODO: useFieldState instead of multiple useWatch
+ useEffect(() => {
+ let cancelled = false;
+
+ const run = () => {
+ try {
+ if (!validFinalityProviders.length || !amount || !term || !feeRate) {
+ if (cancelled) return;
+ setValue("feeAmount", "0", {
+ shouldValidate: false,
+ shouldDirty: false,
+ shouldTouch: false,
+ });
+ return;
+ }
+
+ const feeAmount = calculateFeeAmount({
+ finalityProviders: validFinalityProviders,
+ amount: Number(amount),
+ term: Number(term),
+ feeRate: Number(feeRate),
+ });
+
+ if (cancelled) return;
+
+ clearErrors("feeAmount");
+ setValue("feeAmount", feeAmount.toString(), {
+ shouldValidate: true,
+ shouldDirty: true,
+ shouldTouch: true,
+ });
+ } catch (e: any) {
+ if (cancelled) return;
+ setValue("feeAmount", "0", {
+ shouldValidate: false,
+ shouldDirty: false,
+ shouldTouch: false,
+ });
+ setError("feeAmount", {
+ type: "custom",
+ message: e.message,
+ });
+ }
+ };
+
+ Promise.resolve().then(run);
+
+ return () => {
+ cancelled = true;
+ };
+ }, [
+ feeRate,
+ amount,
+ term,
+ validFinalityProviders,
+ setValue,
+ setError,
+ clearErrors,
+ calculateFeeAmount,
+ ]);
+
+ return (
+
+ {feeRate} sats/vB
+
+ void setVisibility(true)}
+ >
+
+
+
+ void setVisibility(false)}
+ onSubmit={(value) =>
+ setValue("feeRate", value.toString(), {
+ shouldValidate: true,
+ shouldDirty: true,
+ shouldTouch: true,
+ })
+ }
+ />
+
+ );
+}
diff --git a/src/ui/common/components/Multistaking/MultistakingForm/ConnectButton.tsx b/src/ui/common/components/Multistaking/MultistakingForm/ConnectButton.tsx
new file mode 100644
index 000000000..481eb9404
--- /dev/null
+++ b/src/ui/common/components/Multistaking/MultistakingForm/ConnectButton.tsx
@@ -0,0 +1,15 @@
+import { Button } from "@babylonlabs-io/core-ui";
+
+import { useBTCWallet } from "@/ui/common/context/wallet/BTCWalletProvider";
+import { useStakingState } from "@/ui/common/state/StakingState";
+
+export function ConnectButton() {
+ const { open } = useBTCWallet();
+ const { blocked: isGeoBlocked } = useStakingState();
+
+ return (
+
+ Connect Wallet
+
+ );
+}
diff --git a/src/ui/common/components/Multistaking/MultistakingForm/FeesSection.tsx b/src/ui/common/components/Multistaking/MultistakingForm/FeesSection.tsx
new file mode 100644
index 000000000..60ce8e1c8
--- /dev/null
+++ b/src/ui/common/components/Multistaking/MultistakingForm/FeesSection.tsx
@@ -0,0 +1,24 @@
+import { BBNFeeAmount } from "@/ui/common/components/Staking/DelegationForm/components/BBNFeeAmount";
+import { BTCFeeAmount } from "@/ui/common/components/Staking/DelegationForm/components/BTCFeeAmount";
+import { Total } from "@/ui/common/components/Staking/DelegationForm/components/Total";
+import { BBN_FEE_AMOUNT } from "@/ui/common/constants";
+import { useStakingState } from "@/ui/common/state/StakingState";
+
+import { BTCFeeRate } from "./BTCFeeRate";
+import { SubSection } from "./SubSection";
+
+export const FeesSection = () => {
+ const { stakingInfo } = useStakingState();
+
+ return (
+
+
+
+
+ {BBN_FEE_AMOUNT &&
}
+
+
+
+
+ );
+};
diff --git a/src/ui/common/components/Multistaking/MultistakingForm/FormAlert.tsx b/src/ui/common/components/Multistaking/MultistakingForm/FormAlert.tsx
new file mode 100644
index 000000000..f462bc0d2
--- /dev/null
+++ b/src/ui/common/components/Multistaking/MultistakingForm/FormAlert.tsx
@@ -0,0 +1,46 @@
+import { MdErrorOutline } from "react-icons/md";
+
+import { Alert } from "@/ui/common/components/Alerts/Alert";
+import { STAKING_DISABLED } from "@/ui/common/constants";
+
+interface FormAlertProps {
+ address: string | undefined;
+ isGeoBlocked: boolean;
+ geoBlockMessage: string | undefined;
+}
+
+export const FormAlert = ({
+ address,
+ isGeoBlocked,
+ geoBlockMessage,
+}: FormAlertProps) => {
+ const stakingDisabledMessage = (
+ <>
+ The Babylon network is temporarily halted. New stakes are paused until the
+ network resumes.
+ >
+ );
+
+ const shouldShowAlert = (address && STAKING_DISABLED) || isGeoBlocked;
+
+ if (!shouldShowAlert) {
+ return null;
+ }
+
+ return (
+
+
}
+ title={
+ isGeoBlocked ? (
+
Unavailable In Your Region.
+ ) : (
+
Staking Currently Unavailable.
+ )
+ }
+ >
+ {isGeoBlocked ? geoBlockMessage : stakingDisabledMessage}
+
+
+ );
+};
diff --git a/src/ui/common/components/Multistaking/MultistakingForm/MultistakingForm.tsx b/src/ui/common/components/Multistaking/MultistakingForm/MultistakingForm.tsx
new file mode 100644
index 000000000..407cabbc1
--- /dev/null
+++ b/src/ui/common/components/Multistaking/MultistakingForm/MultistakingForm.tsx
@@ -0,0 +1,89 @@
+import { Form, HiddenField } from "@babylonlabs-io/core-ui";
+import { useCallback } from "react";
+
+import { AuthGuard } from "@/ui/common/components/Common/AuthGuard";
+import { BsnFinalityProviderField } from "@/ui/common/components/Multistaking/BsnFinalityProviderField/BsnFinalityProviderField";
+import { AmountSubsection } from "@/ui/common/components/Multistaking/MultistakingForm/AmountSubsection";
+import { FeesSection } from "@/ui/common/components/Multistaking/MultistakingForm/FeesSection";
+import { MultistakingModal } from "@/ui/common/components/Multistaking/MultistakingModal/MultistakingModal";
+import { useBTCWallet } from "@/ui/common/context/wallet/BTCWalletProvider";
+import {
+ useMultistakingState,
+ type MultistakingFormFields,
+} from "@/ui/common/state/MultistakingState";
+import { StakingStep, useStakingState } from "@/ui/common/state/StakingState";
+import FeatureFlagService from "@/ui/common/utils/FeatureFlagService";
+
+import { ConnectButton } from "./ConnectButton";
+import { FormAlert } from "./FormAlert";
+import { SubmitButton } from "./SubmitButton";
+
+export function MultistakingForm() {
+ const { address } = useBTCWallet();
+ const {
+ stakingInfo,
+ setFormData,
+ goToStep,
+ blocked: isGeoBlocked,
+ errorMessage: geoBlockMessage,
+ } = useStakingState();
+ const { validationSchema, maxFinalityProviders } = useMultistakingState();
+
+ const handlePreview = useCallback(
+ (formValues: MultistakingFormFields) => {
+ // Persist form values into global staking state
+ // For multistaking, pass all selected finality providers
+ setFormData({
+ finalityProviders: Object.values(formValues.finalityProviders),
+ term: Number(formValues.term),
+ amount: Number(formValues.amount),
+ feeRate: Number(formValues.feeRate),
+ feeAmount: Number(formValues.feeAmount),
+ });
+
+ goToStep(StakingStep.PREVIEW);
+ },
+ [setFormData, goToStep],
+ );
+
+ if (!stakingInfo) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/src/ui/common/components/Multistaking/MultistakingForm/MultistakingFormWrapper.tsx b/src/ui/common/components/Multistaking/MultistakingForm/MultistakingFormWrapper.tsx
new file mode 100644
index 000000000..87c017e61
--- /dev/null
+++ b/src/ui/common/components/Multistaking/MultistakingForm/MultistakingFormWrapper.tsx
@@ -0,0 +1,17 @@
+import { FinalityProviderBsnState } from "@/ui/common/state/FinalityProviderBsnState";
+import { MultistakingState } from "@/ui/common/state/MultistakingState";
+import { StakingState } from "@/ui/common/state/StakingState";
+
+import { MultistakingForm } from "./MultistakingForm";
+
+export function MultistakingFormWrapper() {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/ui/common/components/Multistaking/MultistakingForm/SubSection.tsx b/src/ui/common/components/Multistaking/MultistakingForm/SubSection.tsx
new file mode 100644
index 000000000..fd988958c
--- /dev/null
+++ b/src/ui/common/components/Multistaking/MultistakingForm/SubSection.tsx
@@ -0,0 +1,22 @@
+import type { CSSProperties, ReactNode } from "react";
+import { twJoin } from "tailwind-merge";
+
+export const SubSection = ({
+ children,
+ style,
+ className,
+}: {
+ children: ReactNode;
+ style?: CSSProperties;
+ className?: string;
+}) => (
+
+ {children}
+
+);
diff --git a/src/ui/common/components/Multistaking/MultistakingForm/SubmitButton.tsx b/src/ui/common/components/Multistaking/MultistakingForm/SubmitButton.tsx
new file mode 100644
index 000000000..d970e942b
--- /dev/null
+++ b/src/ui/common/components/Multistaking/MultistakingForm/SubmitButton.tsx
@@ -0,0 +1,53 @@
+import { Button, useFormState } from "@babylonlabs-io/core-ui";
+import { twMerge } from "tailwind-merge";
+
+import { STAKING_DISABLED } from "@/ui/common/constants";
+import { useFormError } from "@/ui/common/hooks/useFormError";
+import { useStakingState } from "@/ui/common/state/StakingState";
+
+const BUTTON_STYLES: Record = {
+ error: "disabled:!text-error-main disabled:!bg-error-main/10",
+ default: "",
+};
+
+export function SubmitButton() {
+ const { isValid, isValidating, isLoading } = useFormState();
+ const { blocked: isGeoBlocked } = useStakingState();
+ const error = useFormError();
+
+ const renderText = () => {
+ if (isValidating) {
+ return "Calculating...";
+ }
+
+ if (isLoading) {
+ return "Loading...";
+ }
+
+ if (error) {
+ return error.message;
+ }
+
+ return "Preview";
+ };
+
+ return (
+
+ {renderText()}
+
+ );
+}
diff --git a/src/ui/common/components/Multistaking/MultistakingModal/MultistakingModal.tsx b/src/ui/common/components/Multistaking/MultistakingModal/MultistakingModal.tsx
new file mode 100644
index 000000000..f0c1dbc8f
--- /dev/null
+++ b/src/ui/common/components/Multistaking/MultistakingModal/MultistakingModal.tsx
@@ -0,0 +1,263 @@
+import { Avatar, useFormContext, useWatch } from "@babylonlabs-io/core-ui";
+import { useMemo } from "react";
+
+import { CancelFeedbackModal } from "@/ui/common/components/Modals/CancelFeedbackModal";
+import { MultistakingPreviewModal } from "@/ui/common/components/Modals/MultistakingModal/MultistakingStartModal";
+import { SignModal } from "@/ui/common/components/Modals/SignModal/SignModal";
+import { StakeModal } from "@/ui/common/components/Modals/StakeModal";
+import { SuccessFeedbackModal } from "@/ui/common/components/Modals/SuccessFeedbackModal";
+import { VerificationModal } from "@/ui/common/components/Modals/VerificationModal";
+import { FinalityProviderLogo } from "@/ui/common/components/Staking/FinalityProviders/FinalityProviderLogo";
+import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn";
+import { getNetworkConfigBTC } from "@/ui/common/config/network/btc";
+import { chainLogos } from "@/ui/common/constants";
+import { useNetworkInfo } from "@/ui/common/hooks/client/api/useNetworkInfo";
+import { usePrice } from "@/ui/common/hooks/client/api/usePrices";
+import { useStakingService } from "@/ui/common/hooks/services/useStakingService";
+import { useFinalityProviderBsnState } from "@/ui/common/state/FinalityProviderBsnState";
+import { useFinalityProviderState } from "@/ui/common/state/FinalityProviderState";
+import { useStakingState } from "@/ui/common/state/StakingState";
+import { satoshiToBtc } from "@/ui/common/utils/btc";
+import { calculateTokenValueInCurrency } from "@/ui/common/utils/formatCurrency";
+import { maxDecimals } from "@/ui/common/utils/maxDecimals";
+import { blocksToDisplayTime } from "@/ui/common/utils/time";
+import { trim } from "@/ui/common/utils/trim";
+
+const EOI_INDEXES: Record = {
+ "eoi-staking-slashing": 1,
+ "eoi-unbonding-slashing": 2,
+ "eoi-proof-of-possession": 3,
+ "eoi-sign-bbn": 4,
+};
+
+const VERIFICATION_STEPS: Record = {
+ "eoi-send-bbn": 1,
+ verifying: 2,
+};
+
+const { chainId: BBN_CHAIN_ID } = getNetworkConfigBBN();
+const { displayUSD } = getNetworkConfigBTC();
+
+export function MultistakingModal() {
+ const {
+ processing,
+ step,
+ formData,
+ stakingInfo,
+ verifiedDelegation,
+ reset: resetState,
+ stakingStepOptions,
+ } = useStakingState();
+ const { getRegisteredFinalityProvider } = useFinalityProviderState();
+ const { bsnList } = useFinalityProviderBsnState();
+ const { createEOI, stakeDelegation } = useStakingService();
+
+ const {
+ reset: resetForm,
+ trigger: revalidateForm,
+ setValue: setFieldValue,
+ } = useFormContext();
+
+ const { coinSymbol } = getNetworkConfigBTC();
+ const { data: networkInfo } = useNetworkInfo();
+ const btcInUsd = usePrice(coinSymbol);
+
+ const currentFinalityProviders = useWatch({ name: "finalityProviders" });
+
+ const { bsnInfos, finalityProviderInfos } = useMemo(() => {
+ const bsns: Array<{ icon: React.ReactNode; name: string }> = [];
+ const fps: Array<{ icon: React.ReactNode; name: string }> = [];
+
+ if (currentFinalityProviders) {
+ if (
+ typeof currentFinalityProviders === "object" &&
+ !Array.isArray(currentFinalityProviders)
+ ) {
+ const providerMap = currentFinalityProviders as Record;
+
+ Object.entries(providerMap).forEach(([bsnId, fpPublicKey]) => {
+ const bsn = bsnList.find((bsn) => bsn.id === bsnId);
+ if (bsn || bsnId === BBN_CHAIN_ID) {
+ const logoUrl =
+ chainLogos[bsn?.id || "babylon"] || chainLogos.placeholder;
+ bsns.push({
+ icon: (
+
+ ),
+ name: bsn?.name || "Babylon Genesis",
+ });
+ }
+
+ const provider = getRegisteredFinalityProvider(fpPublicKey);
+ if (provider) {
+ fps.push({
+ icon: (
+
+ ),
+ name: provider.description?.moniker || trim(fpPublicKey, 8),
+ });
+ }
+ });
+ } else {
+ const fpArray = Array.isArray(currentFinalityProviders)
+ ? currentFinalityProviders
+ : [];
+
+ fpArray.forEach((fpPublicKey) => {
+ const logoUrl = chainLogos["babylon"];
+ bsns.push({
+ icon: (
+
+ ),
+ name: "Babylon Genesis",
+ });
+
+ const provider = getRegisteredFinalityProvider(fpPublicKey);
+ if (provider) {
+ fps.push({
+ icon: (
+
+ ),
+ name: provider.description?.moniker || trim(fpPublicKey, 8),
+ });
+ }
+ });
+ }
+ }
+
+ return { bsnInfos: bsns, finalityProviderInfos: fps };
+ }, [currentFinalityProviders, bsnList, getRegisteredFinalityProvider]);
+
+ const details = useMemo(() => {
+ if (!formData || !stakingInfo) return null;
+
+ const unbondingTime =
+ blocksToDisplayTime(
+ networkInfo?.params.bbnStakingParams?.latestParam?.unbondingTime,
+ ) || "7 days";
+
+ const stakeAmountBtc = maxDecimals(satoshiToBtc(formData.amount), 8);
+ const stakeAmountUsd = calculateTokenValueInCurrency(
+ satoshiToBtc(formData.amount),
+ btcInUsd,
+ );
+
+ const feeAmountBtc = maxDecimals(satoshiToBtc(formData.feeAmount), 8);
+ const feeAmountUsd = calculateTokenValueInCurrency(
+ satoshiToBtc(formData.feeAmount),
+ btcInUsd,
+ );
+
+ const unbondingFeeBtc = maxDecimals(
+ satoshiToBtc(stakingInfo.unbondingFeeSat),
+ 8,
+ );
+ const unbondingFeeUsd = calculateTokenValueInCurrency(
+ satoshiToBtc(stakingInfo.unbondingFeeSat),
+ btcInUsd,
+ );
+
+ return {
+ stakeAmount: `${stakeAmountBtc} ${coinSymbol}${displayUSD ? ` (${stakeAmountUsd})` : ""}`,
+ feeRate: `${formData.feeRate} sat/vB`,
+ transactionFees: `${feeAmountBtc} ${coinSymbol}${displayUSD ? ` (${feeAmountUsd})` : ""}`,
+ term: {
+ blocks: `${formData.term} blocks`,
+ duration: `~ ${blocksToDisplayTime(formData.term)}`,
+ },
+ unbonding: `~ ${unbondingTime}`,
+ unbondingFee: `${unbondingFeeBtc} ${coinSymbol}${displayUSD ? ` (${unbondingFeeUsd})` : ""}`,
+ };
+ }, [formData, stakingInfo, networkInfo, btcInUsd, coinSymbol]);
+
+ if (!step) return null;
+
+ return (
+ <>
+ {step === "preview" && stakingInfo && details && (
+ {
+ if (!formData) return;
+ await createEOI(formData);
+ resetForm({
+ finalityProviders: undefined,
+ term: "",
+ amount: "",
+ feeRate: stakingInfo?.defaultFeeRate?.toString() ?? "0",
+ feeAmount: "0",
+ });
+ if (stakingInfo?.defaultStakingTimeBlocks) {
+ setFieldValue("term", stakingInfo?.defaultStakingTimeBlocks, {
+ shouldDirty: true,
+ shouldTouch: true,
+ });
+ }
+ revalidateForm();
+ }}
+ />
+ )}
+
+ {Boolean(EOI_INDEXES[step]) && (
+
+ )}
+
+ {Boolean(VERIFICATION_STEPS[step]) && (
+
+ )}
+
+ {verifiedDelegation && (
+ stakeDelegation(verifiedDelegation)}
+ onClose={resetState}
+ />
+ )}
+
+
+
+ >
+ );
+}
diff --git a/src/ui/common/components/NetworkBadge/NetworkBadge.tsx b/src/ui/common/components/NetworkBadge/NetworkBadge.tsx
new file mode 100644
index 000000000..25f608ed7
--- /dev/null
+++ b/src/ui/common/components/NetworkBadge/NetworkBadge.tsx
@@ -0,0 +1,31 @@
+import { twJoin } from "tailwind-merge";
+
+import { getNetworkConfigBTC } from "@/ui/common/config/network/btc";
+import { Network } from "@/ui/common/types/network";
+
+import testnetIcon from "./testnet-icon.png";
+
+const { network } = getNetworkConfigBTC();
+
+export const NetworkBadge = () => {
+ return (
+
+ {[Network.SIGNET, Network.TESTNET].includes(network) && (
+ <>
+
+ {/*
+ currently the text is absolutely positioned
+ since the image has a shadow
+ */}
+
+ Testnet
+
+ >
+ )}
+
+ );
+};
diff --git a/src/ui/common/components/NetworkBadge/testnet-icon.png b/src/ui/common/components/NetworkBadge/testnet-icon.png
new file mode 100644
index 000000000..ffdef3fea
Binary files /dev/null and b/src/ui/common/components/NetworkBadge/testnet-icon.png differ
diff --git a/src/ui/common/components/Notification/DetailsButton.tsx b/src/ui/common/components/Notification/DetailsButton.tsx
new file mode 100644
index 000000000..45121990b
--- /dev/null
+++ b/src/ui/common/components/Notification/DetailsButton.tsx
@@ -0,0 +1,10 @@
+export const DetailsButton = () => {
+ return (
+ {}}
+ >
+ Details
+
+ );
+};
diff --git a/src/ui/common/components/Notification/FloatingTopBar.tsx b/src/ui/common/components/Notification/FloatingTopBar.tsx
new file mode 100644
index 000000000..13447db38
--- /dev/null
+++ b/src/ui/common/components/Notification/FloatingTopBar.tsx
@@ -0,0 +1,22 @@
+import { TypeOptions } from "react-toastify";
+import { twJoin } from "tailwind-merge";
+
+interface Props {
+ type: TypeOptions;
+}
+
+const BG_COLOR = {
+ success: "bg-[#49B149]",
+ warning: "bg-[#C5882D]",
+ error: "bg-[#DD6464]",
+ info: "bg-[#919191]",
+ default: "bg-[#919191]",
+} as const;
+
+export const FloatingTopBar = ({ type }: Props) => {
+ return (
+
+ );
+};
diff --git a/src/ui/common/components/Notification/IconWrapper.tsx b/src/ui/common/components/Notification/IconWrapper.tsx
new file mode 100644
index 000000000..daccb9b08
--- /dev/null
+++ b/src/ui/common/components/Notification/IconWrapper.tsx
@@ -0,0 +1,24 @@
+import { IconType } from "react-icons";
+import { TypeOptions } from "react-toastify";
+import { twJoin } from "tailwind-merge";
+
+interface Props {
+ ReactIcon: IconType;
+ type: TypeOptions;
+}
+
+const BG_TEXT_COLOR = {
+ success: "bg-[#49B149]/15 text-[#49B149]",
+ warning: "bg-[#C5882D]/15 text-[#C5882D]",
+ error: "bg-[#DD6464]/15 text-[#DD6464]",
+ info: "bg-[#919191]/15 text-[#919191]",
+ default: "bg-[#919191]/15 text-[#919191]",
+} as const;
+
+export const IconWrapper = ({ ReactIcon, type }: Props) => {
+ return (
+
+
+
+ );
+};
diff --git a/src/ui/common/components/Notification/Notification.tsx b/src/ui/common/components/Notification/Notification.tsx
new file mode 100644
index 000000000..030fb3765
--- /dev/null
+++ b/src/ui/common/components/Notification/Notification.tsx
@@ -0,0 +1,52 @@
+import { ReactNode } from "react";
+import { IconType } from "react-icons";
+import { IoClose } from "react-icons/io5";
+import { ToastContentProps } from "react-toastify";
+
+import { DetailsButton } from "./DetailsButton";
+import { FloatingTopBar } from "./FloatingTopBar";
+import { IconWrapper } from "./IconWrapper";
+import { NotificationText } from "./NotificationText";
+import { NotificationTitle } from "./NotificationTitle";
+
+interface Props extends Partial {
+ title: string;
+ text: string;
+ actionComponent?: ReactNode;
+ reactIcon: IconType;
+}
+
+export const Notification = ({
+ closeToast,
+ toastProps,
+ title,
+ text,
+ actionComponent = ,
+ reactIcon,
+}: Props) => {
+ if (closeToast === undefined || toastProps === undefined) {
+ throw new SyntaxError(
+ "Notification should only be used with toast from react-toastify",
+ );
+ }
+ return (
+
+
+
+
+
+
{title}
+
{text}
+
{actionComponent}
+
+
{actionComponent}
+
+
+
+
+
+ );
+};
diff --git a/src/ui/common/components/Notification/NotificationContainer.tsx b/src/ui/common/components/Notification/NotificationContainer.tsx
new file mode 100644
index 000000000..2cae3efae
--- /dev/null
+++ b/src/ui/common/components/Notification/NotificationContainer.tsx
@@ -0,0 +1,34 @@
+import { ToastContainer } from "react-toastify";
+import { twJoin } from "tailwind-merge";
+
+import { useIsMobileView } from "@/ui/common/hooks/useBreakpoint";
+
+const commonClassName = "relative overflow-hidden rounded-lg px-4 py-3 md:p-3";
+
+const BG_COLOR = {
+ success:
+ "dark:bg-[linear-gradient(0deg,rgba(73,177,73,0.1),rgba(73,177,73,0.1))] dark:bg-[#191919] bg-[linear-gradient(0deg,rgba(73,177,73,0.05),rgba(73,177,73,0.05))] bg-[#FFFFFF]",
+ warning:
+ "bg-[linear-gradient(0deg,rgba(197,136,45,0.05),rgba(197,136,45,0.05))] bg-[#FFFFFF] dark:bg-[linear-gradient(0deg,rgba(197,136,45,0.1),rgba(197,136,45,0.1))] dark:bg-[#191919]",
+ error:
+ "bg-[linear-gradient(0deg,rgba(221,100,100,0.05),rgba(221,100,100,0.05))] bg-[#FFFFFF] dark:bg-[linear-gradient(0deg,rgba(221,100,100,0.1),rgba(221,100,100,0.1))] dark:bg-[#191919]",
+ info: "bg-[#FFFFFF] dark:bg-[#191919]",
+ default: "bg-[#FFFFFF] dark:bg-[#191919]",
+} as const;
+
+export const NotificationContainer = () => {
+ const isMobileView = useIsMobileView();
+
+ return (
+ {
+ return twJoin(commonClassName, BG_COLOR[context?.type ?? "default"]);
+ }}
+ autoClose={false}
+ closeButton={false}
+ icon={false}
+ hideProgressBar={true}
+ position={isMobileView ? "top-center" : "bottom-center"}
+ />
+ );
+};
diff --git a/src/ui/common/components/Notification/NotificationText.tsx b/src/ui/common/components/Notification/NotificationText.tsx
new file mode 100644
index 000000000..a97bffdb4
--- /dev/null
+++ b/src/ui/common/components/Notification/NotificationText.tsx
@@ -0,0 +1,9 @@
+interface Props {
+ children: string;
+}
+
+export const NotificationText = ({ children }: Props) => {
+ return (
+ {children}
+ );
+};
diff --git a/src/ui/common/components/Notification/NotificationTitle.tsx b/src/ui/common/components/Notification/NotificationTitle.tsx
new file mode 100644
index 000000000..c9fce9157
--- /dev/null
+++ b/src/ui/common/components/Notification/NotificationTitle.tsx
@@ -0,0 +1,9 @@
+interface Props {
+ children: string;
+}
+
+export const NotificationTitle = ({ children }: Props) => {
+ return (
+ {children}
+ );
+};
diff --git a/src/ui/common/components/PersonalBalance/PersonalBalance.tsx b/src/ui/common/components/PersonalBalance/PersonalBalance.tsx
new file mode 100644
index 000000000..93670a904
--- /dev/null
+++ b/src/ui/common/components/PersonalBalance/PersonalBalance.tsx
@@ -0,0 +1,135 @@
+import { AuthGuard } from "@/ui/common/components/Common/AuthGuard";
+import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn";
+import { getNetworkConfigBTC } from "@/ui/common/config/network/btc";
+import { useUTXOs } from "@/ui/common/hooks/client/api/useUTXOs";
+import { useRewardsService } from "@/ui/common/hooks/services/useRewardsService";
+import { useIsMobileView } from "@/ui/common/hooks/useBreakpoint";
+import { useBalanceState } from "@/ui/common/state/BalanceState";
+import { useRewardsState } from "@/ui/common/state/RewardState";
+import { ubbnToBaby } from "@/ui/common/utils/bbn";
+import { satoshiToBtc } from "@/ui/common/utils/btc";
+
+import { ClaimRewardModal } from "../Modals/ClaimRewardModal";
+import { ClaimStatusModal } from "../Modals/ClaimStatusModal/ClaimStatusModal";
+import { Section } from "../Section/Section";
+import { ActionComponent } from "../Stats/ActionComponent";
+import { LoadingStyle, StatItem } from "../Stats/StatItem";
+
+const { networkName: bbnNetworkName, coinSymbol: bbnCoinSymbol } =
+ getNetworkConfigBBN();
+const { coinSymbol, networkName } = getNetworkConfigBTC();
+
+export function PersonalBalance() {
+ // Load reward state
+ const {
+ loading: rewardLoading,
+ processing,
+ showRewardModal,
+ showProcessingModal,
+ closeProcessingModal,
+ closeRewardModal,
+ bbnAddress,
+ rewardBalance,
+ transactionFee,
+ transactionHash,
+ setTransactionHash,
+ } = useRewardsState();
+
+ // Load balance state
+ const {
+ bbnBalance,
+ stakableBtcBalance,
+ stakedBtcBalance,
+ inscriptionsBtcBalance,
+ loading: isBalanceLoading,
+ } = useBalanceState();
+
+ const { allUTXOs = [], confirmedUTXOs = [] } = useUTXOs();
+ const hasUnconfirmedUTXOs = allUTXOs.length > confirmedUTXOs.length;
+
+ const { claimRewards, showPreview } = useRewardsService();
+
+ const isMobile = useIsMobileView();
+ const formattedRewardBalance = ubbnToBaby(rewardBalance);
+
+ return (
+
+
+
+
+
+
+
+ {
+ closeProcessingModal();
+ setTransactionHash("");
+ }}
+ loading={processing}
+ transactionHash={transactionHash}
+ />
+
+ );
+}
diff --git a/src/ui/common/components/Section/Section.tsx b/src/ui/common/components/Section/Section.tsx
new file mode 100644
index 000000000..3521e4f9d
--- /dev/null
+++ b/src/ui/common/components/Section/Section.tsx
@@ -0,0 +1,33 @@
+import { Heading } from "@babylonlabs-io/core-ui";
+import { PropsWithChildren } from "react";
+import { twMerge } from "tailwind-merge";
+
+interface SectionProps {
+ className?: string;
+ titleClassName?: string;
+ title: string;
+}
+
+export function Section({
+ className,
+ titleClassName,
+ title,
+ children,
+}: PropsWithChildren) {
+ return (
+
+
+ {title}
+
+
+ {children}
+
+ );
+}
diff --git a/src/ui/common/components/SignDetails/SignDetails.tsx b/src/ui/common/components/SignDetails/SignDetails.tsx
new file mode 100644
index 000000000..6239efcfb
--- /dev/null
+++ b/src/ui/common/components/SignDetails/SignDetails.tsx
@@ -0,0 +1,149 @@
+import { EventData } from "@babylonlabs-io/btc-staking-ts";
+import { Text } from "@babylonlabs-io/core-ui";
+import { ReactNode } from "react";
+import { twMerge } from "tailwind-merge";
+
+import { Hash } from "@/ui/common/components/Hash/Hash";
+import { getNetworkConfigBTC } from "@/ui/common/config/network/btc";
+import { satoshiToBtc } from "@/ui/common/utils/btc";
+import { maxDecimals } from "@/ui/common/utils/maxDecimals";
+import { blocksToDisplayTime } from "@/ui/common/utils/time";
+
+interface SignDetailsProps {
+ details?: EventData;
+ shouldHaveMargin?: boolean;
+}
+
+const keyDisplayMappings: Record = {
+ stakerPk: "Staker Public Key",
+ finalityProviders: "Finality Providers",
+ covenantPks: "Covenant Public Keys",
+ covenantThreshold: "Covenant Threshold",
+ minUnbondingTime: "Unbonding Time",
+ stakingDuration: "Staking Duration",
+ unbondingTimeBlocks: "Unbonding Time",
+ address: "Address",
+ type: "Type",
+ timelockBlocks: "Timelock",
+ bech32Address: "BABY Address",
+ unbondingFeeSat: "Unbonding Fee",
+ slashingFeeSat: "Slashing Fee",
+ slashingPkScriptHex: "Slashing Script Hex",
+};
+
+const formatDisplayKey = (key: string): string => {
+ return keyDisplayMappings[key] || key;
+};
+
+// Format the display value based on the key and value type
+const formatDisplayValue = (key: string, value: any): ReactNode => {
+ const { coinName } = getNetworkConfigBTC();
+
+ // Staking duration, unbonding time
+ if (
+ typeof value === "number" &&
+ (key.toLowerCase().includes("time") ||
+ key.toLowerCase().includes("duration"))
+ ) {
+ return (
+
+ {blocksToDisplayTime(value)}
+
+ );
+ }
+ // Finality providers, covenant public keys
+ if (Array.isArray(value)) {
+ return (
+
+ {value.map((item, index) => (
+
+ ))}
+
+ );
+ }
+ // Public keys and addresses
+ if (
+ key.toLowerCase().includes("pk") ||
+ key.toLowerCase().includes("address")
+ ) {
+ return ;
+ }
+ // Title
+ if (key.toLowerCase() === "type") {
+ const capitalizedTitle = (value as string)
+ .split("-")
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
+ .join(" ");
+ return (
+
+ {capitalizedTitle}
+
+ );
+ }
+ // Fees, convert from satoshis to BTC
+ if (key.toLowerCase().includes("fee") && typeof value === "number") {
+ return (
+
+ {maxDecimals(satoshiToBtc(value), 8)} {coinName}
+
+ );
+ }
+ // Default case for other values
+ return (
+
+ {String(value)}
+
+ );
+};
+
+const getOrderedKeys = (details: EventData): string[] => {
+ // Provide an order for specific keys
+ const orderedKeys = ["type", "stakerPk"];
+ // Then add any remaining keys from details that aren't already included
+ const allKeys = orderedKeys.filter((key) => key in details);
+ Object.keys(details).forEach((key) => {
+ if (!allKeys.includes(key)) {
+ allKeys.push(key);
+ }
+ });
+ return allKeys;
+};
+
+export const SignDetails: React.FC = ({
+ details,
+ shouldHaveMargin,
+}) => {
+ if (!details || Object.keys(details).length === 0) {
+ return null;
+ }
+
+ return (
+
+ {getOrderedKeys(details).map((key) => (
+
+
+ {formatDisplayKey(key)}:
+
+ {formatDisplayValue(key, details[key])}
+
+ ))}
+
+ );
+};
diff --git a/src/ui/common/components/Stakers/Staker.tsx b/src/ui/common/components/Stakers/Staker.tsx
new file mode 100644
index 000000000..a8dfb5cc3
--- /dev/null
+++ b/src/ui/common/components/Stakers/Staker.tsx
@@ -0,0 +1,46 @@
+import { getNetworkConfigBTC } from "@/ui/common/config/network/btc";
+import { satoshiToBtc } from "@/ui/common/utils/btc";
+import { maxDecimals } from "@/ui/common/utils/maxDecimals";
+
+import { Hash } from "../Hash/Hash";
+
+interface StakerProps {
+ pkHex: string;
+ delegations: number;
+ activeTVLSat: number;
+}
+
+export const Staker: React.FC = ({
+ pkHex,
+ delegations,
+ activeTVLSat,
+}) => {
+ const { coinName } = getNetworkConfigBTC();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ Delegations
+
+
{delegations || 0}
+
+
+
Stake
+
+ {activeTVLSat
+ ? `${maxDecimals(satoshiToBtc(activeTVLSat), 8)} ${coinName}`
+ : 0}
+
+
+
+
+ );
+};
diff --git a/src/ui/common/components/Staking/DelegationForm/components/AmountField.tsx b/src/ui/common/components/Staking/DelegationForm/components/AmountField.tsx
new file mode 100644
index 000000000..cce1c7953
--- /dev/null
+++ b/src/ui/common/components/Staking/DelegationForm/components/AmountField.tsx
@@ -0,0 +1,33 @@
+import { NumberField, Text } from "@babylonlabs-io/core-ui";
+
+import { getNetworkConfigBTC } from "@/ui/common/config/network/btc";
+import { satoshiToBtc } from "@/ui/common/utils/btc";
+
+interface AmountFieldProps {
+ min?: number;
+ max?: number;
+}
+
+const { coinSymbol } = getNetworkConfigBTC();
+
+export function AmountField({ min = 0, max = 0 }: AmountFieldProps) {
+ const label = (
+
+
+ Amount
+
+
+ min/max: {satoshiToBtc(min)}/{satoshiToBtc(max)} {coinSymbol}
+
+
+ );
+
+ return (
+
+ );
+}
diff --git a/src/ui/common/components/Staking/DelegationForm/components/BBNFeeAmount.tsx b/src/ui/common/components/Staking/DelegationForm/components/BBNFeeAmount.tsx
new file mode 100644
index 000000000..1127936c3
--- /dev/null
+++ b/src/ui/common/components/Staking/DelegationForm/components/BBNFeeAmount.tsx
@@ -0,0 +1,24 @@
+import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn";
+import { usePrice } from "@/ui/common/hooks/client/api/usePrices";
+import { calculateTokenValueInCurrency } from "@/ui/common/utils/formatCurrency";
+
+import { FeeItem } from "./FeeItem";
+interface FeeStatsProps {
+ amount?: string;
+}
+
+const { coinSymbol, displayUSD } = getNetworkConfigBBN();
+
+export function BBNFeeAmount({ amount = "0" }: FeeStatsProps) {
+ const bbnInUsd = usePrice(coinSymbol);
+ const feeInUsd = calculateTokenValueInCurrency(parseFloat(amount), bbnInUsd);
+
+ return (
+
+ {amount} {coinSymbol}
+
+ );
+}
diff --git a/src/ui/common/components/Staking/DelegationForm/components/BTCFeeAmount.tsx b/src/ui/common/components/Staking/DelegationForm/components/BTCFeeAmount.tsx
new file mode 100644
index 000000000..32c5c81e0
--- /dev/null
+++ b/src/ui/common/components/Staking/DelegationForm/components/BTCFeeAmount.tsx
@@ -0,0 +1,30 @@
+import { useWatch } from "@babylonlabs-io/core-ui";
+
+import { getNetworkConfigBTC } from "@/ui/common/config/network/btc";
+import { usePrice } from "@/ui/common/hooks/client/api/usePrices";
+import { satoshiToBtc } from "@/ui/common/utils/btc";
+import { calculateTokenValueInCurrency } from "@/ui/common/utils/formatCurrency";
+
+import { FeeItem } from "./FeeItem";
+
+const { coinSymbol, displayUSD } = getNetworkConfigBTC();
+
+export function BTCFeeAmount() {
+ const feeAmount = useWatch({ name: "feeAmount" });
+
+ const btcInUsd = usePrice(coinSymbol);
+ const formattedFeeAmount = parseFloat(feeAmount || "0");
+ const feeInUsd = calculateTokenValueInCurrency(
+ satoshiToBtc(formattedFeeAmount),
+ btcInUsd,
+ );
+
+ return (
+
+ {satoshiToBtc(formattedFeeAmount)} {coinSymbol}
+
+ );
+}
diff --git a/src/ui/common/components/Staking/DelegationForm/components/BTCFeeRate.tsx b/src/ui/common/components/Staking/DelegationForm/components/BTCFeeRate.tsx
new file mode 100644
index 000000000..5f0b01800
--- /dev/null
+++ b/src/ui/common/components/Staking/DelegationForm/components/BTCFeeRate.tsx
@@ -0,0 +1,112 @@
+import { Button, useFormContext, useWatch } from "@babylonlabs-io/core-ui";
+import { useEffect, useState } from "react";
+import { FaPen } from "react-icons/fa6";
+
+import { FeeModal } from "@/ui/common/components/Staking/FeeModal";
+import { useStakingService } from "@/ui/common/hooks/services/useStakingService";
+
+import { FeeItem } from "./FeeItem";
+
+interface FeeFiledProps {
+ defaultRate?: number;
+}
+
+export function BTCFeeRate({ defaultRate = 0 }: FeeFiledProps) {
+ const [visible, setVisibility] = useState(false);
+ const feeRate = useWatch({ name: "feeRate" });
+ const finalityProviders = useWatch({ name: "finalityProviders" });
+ const amount = useWatch({ name: "amount" });
+ const term = useWatch({ name: "term" });
+ const { setValue, setError, clearErrors } = useFormContext();
+ const { calculateFeeAmount } = useStakingService();
+
+ useEffect(() => {
+ setValue("feeRate", defaultRate.toString(), {
+ shouldValidate: true,
+ shouldDirty: true,
+ shouldTouch: true,
+ });
+ }, [defaultRate, setValue]);
+
+ useEffect(() => {
+ try {
+ if (
+ !finalityProviders ||
+ !Array.isArray(finalityProviders) ||
+ finalityProviders.length === 0 ||
+ !amount ||
+ !term ||
+ !feeRate
+ ) {
+ setValue("feeAmount", "0", {
+ shouldValidate: false,
+ shouldDirty: false,
+ shouldTouch: false,
+ });
+ return;
+ }
+
+ const feeAmount = calculateFeeAmount({
+ finalityProviders,
+ amount,
+ term,
+ feeRate,
+ });
+
+ clearErrors("feeAmount");
+ setValue("feeAmount", feeAmount.toString(), {
+ shouldValidate: true,
+ shouldDirty: true,
+ shouldTouch: true,
+ });
+ } catch (e: any) {
+ setValue("feeAmount", "0", {
+ shouldValidate: false,
+ shouldDirty: false,
+ shouldTouch: false,
+ });
+ setError("feeAmount", {
+ type: "custom",
+ message: e.message,
+ });
+ }
+ }, [
+ finalityProviders,
+ amount,
+ term,
+ feeRate,
+ calculateFeeAmount,
+ setValue,
+ setError,
+ clearErrors,
+ ]);
+
+ return (
+
+ {feeRate} sats/vB
+
+ void setVisibility(true)}
+ >
+
+
+
+ void setVisibility(false)}
+ onSubmit={(value) =>
+ setValue("feeRate", value.toString(), {
+ shouldValidate: true,
+ shouldDirty: true,
+ shouldTouch: true,
+ })
+ }
+ />
+
+ );
+}
diff --git a/src/ui/common/components/Staking/DelegationForm/components/FeeItem.tsx b/src/ui/common/components/Staking/DelegationForm/components/FeeItem.tsx
new file mode 100644
index 000000000..2bd5090cd
--- /dev/null
+++ b/src/ui/common/components/Staking/DelegationForm/components/FeeItem.tsx
@@ -0,0 +1,40 @@
+import { Text } from "@babylonlabs-io/core-ui";
+import { PropsWithChildren } from "react";
+import { twMerge } from "tailwind-merge";
+
+interface FeeItemProps extends PropsWithChildren {
+ title: string;
+ className?: string;
+ hint?: string;
+}
+
+export function FeeItem({ title, children, className, hint }: FeeItemProps) {
+ return (
+
+
+ {title}
+
+
+ {!hint ? (
+
+ {children}
+
+ ) : (
+
+
+ {children}
+
+
+ {hint}
+
+
+ )}
+
+ );
+}
diff --git a/src/ui/common/components/Staking/DelegationForm/components/FeeSection.tsx b/src/ui/common/components/Staking/DelegationForm/components/FeeSection.tsx
new file mode 100644
index 000000000..11f813137
--- /dev/null
+++ b/src/ui/common/components/Staking/DelegationForm/components/FeeSection.tsx
@@ -0,0 +1,14 @@
+import { useFieldState } from "@babylonlabs-io/core-ui";
+import { PropsWithChildren } from "react";
+
+const FIELDS = ["finalityProviders", "term", "amount"];
+
+export const FeeSection = ({ children }: PropsWithChildren) => {
+ const fieldStates = useFieldState(FIELDS);
+
+ if (fieldStates.some((field) => field.invalid || !field.isDirty)) {
+ return null;
+ }
+
+ return <>{children}>;
+};
diff --git a/src/ui/common/components/Staking/DelegationForm/components/InfoAlert.tsx b/src/ui/common/components/Staking/DelegationForm/components/InfoAlert.tsx
new file mode 100644
index 000000000..d0fa6f3b0
--- /dev/null
+++ b/src/ui/common/components/Staking/DelegationForm/components/InfoAlert.tsx
@@ -0,0 +1,39 @@
+import { Text } from "@babylonlabs-io/core-ui";
+import { useState } from "react";
+import { MdErrorOutline } from "react-icons/md";
+
+import { InfoModal } from "@/ui/common/components/Modals/InfoModal";
+import { useStakingState } from "@/ui/common/state/StakingState";
+import { blocksToDisplayTime } from "@/ui/common/utils/time";
+
+export function InfoAlert() {
+ const [showMore, setShowMore] = useState(false);
+ const { stakingInfo } = useStakingState();
+
+ return (
+
+
+
+
+
+
+
+ Info
+
+
+ You can unbond and withdraw your stake anytime with an unbonding time
+ of {blocksToDisplayTime(stakingInfo?.unbondingTime)}.
+ {" "}
+
setShowMore(true)}
+ >
+ Learn More
+
+
+
+
setShowMore(false)} />
+
+ );
+}
diff --git a/src/ui/common/components/Staking/DelegationForm/components/Overlay.tsx b/src/ui/common/components/Staking/DelegationForm/components/Overlay.tsx
new file mode 100644
index 000000000..0561fed0a
--- /dev/null
+++ b/src/ui/common/components/Staking/DelegationForm/components/Overlay.tsx
@@ -0,0 +1,33 @@
+import { useFieldState } from "@babylonlabs-io/core-ui";
+import { type PropsWithChildren } from "react";
+import { twJoin, twMerge } from "tailwind-merge";
+
+interface OverlayProps {
+ className?: string;
+}
+
+export function FormOverlay({
+ className,
+ children,
+}: PropsWithChildren) {
+ const fpState = useFieldState("finalityProviders");
+ const available = !fpState.invalid && fpState.isTouched;
+
+ return (
+
+ );
+}
diff --git a/src/ui/common/components/Staking/DelegationForm/components/SubmitButton.tsx b/src/ui/common/components/Staking/DelegationForm/components/SubmitButton.tsx
new file mode 100644
index 000000000..6f9c25e27
--- /dev/null
+++ b/src/ui/common/components/Staking/DelegationForm/components/SubmitButton.tsx
@@ -0,0 +1,47 @@
+import { Button, useFormState } from "@babylonlabs-io/core-ui";
+import { Tooltip } from "react-tooltip";
+
+import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn";
+import { BBN_FEE_AMOUNT } from "@/ui/common/constants";
+import { useBbnQuery } from "@/ui/common/hooks/client/rpc/queries/useBbnQuery";
+
+const { coinSymbol } = getNetworkConfigBBN();
+
+export function SubmitButton() {
+ const { isValid, errors } = useFormState();
+ const {
+ balanceQuery: { data: bbnBalance = 0 },
+ } = useBbnQuery();
+
+ const [errorMessage] = Object.keys(errors).map(
+ (fieldName) => (errors[fieldName]?.message as string) ?? "",
+ );
+
+ const invalid = !isValid || bbnBalance === 0;
+ const tooltip =
+ errorMessage ??
+ (bbnBalance === 0
+ ? `Insufficient ${coinSymbol} Balance in Babylon Wallet${BBN_FEE_AMOUNT ? `.\n${BBN_FEE_AMOUNT} ${coinSymbol} required for network fees.` : ""}`
+ : "");
+
+ return (
+
+
+ Preview
+
+
+
+
+ );
+}
diff --git a/src/ui/common/components/Staking/DelegationForm/components/TermField.tsx b/src/ui/common/components/Staking/DelegationForm/components/TermField.tsx
new file mode 100644
index 000000000..b02a63b35
--- /dev/null
+++ b/src/ui/common/components/Staking/DelegationForm/components/TermField.tsx
@@ -0,0 +1,33 @@
+import { HiddenField, NumberField, Text } from "@babylonlabs-io/core-ui";
+
+interface TermFieldProps {
+ min?: number;
+ max?: number;
+ defaultValue?: number;
+}
+
+export function TermField({ min = 0, defaultValue }: TermFieldProps) {
+ if (defaultValue) {
+ return ;
+ }
+
+ const label = (
+
+
+ Term
+
+
+ min term is {min} blocks
+
+
+ );
+
+ return (
+
+ );
+}
diff --git a/src/ui/common/components/Staking/DelegationForm/components/Total.tsx b/src/ui/common/components/Staking/DelegationForm/components/Total.tsx
new file mode 100644
index 000000000..196ecf3ca
--- /dev/null
+++ b/src/ui/common/components/Staking/DelegationForm/components/Total.tsx
@@ -0,0 +1,42 @@
+import { Text, useWatch } from "@babylonlabs-io/core-ui";
+import { useMemo } from "react";
+
+import { getNetworkConfigBTC } from "@/ui/common/config/network/btc";
+import { usePrice } from "@/ui/common/hooks/client/api/usePrices";
+import { satoshiToBtc } from "@/ui/common/utils/btc";
+import { calculateTokenValueInCurrency } from "@/ui/common/utils/formatCurrency";
+import { maxDecimals } from "@/ui/common/utils/maxDecimals";
+
+const { coinSymbol, displayUSD } = getNetworkConfigBTC();
+
+export function Total() {
+ const [amount, feeAmount] = useWatch({ name: ["amount", "feeAmount"] });
+
+ const total = useMemo(
+ () =>
+ maxDecimals(parseFloat(amount || "0") + satoshiToBtc(feeAmount || 0), 8),
+ [amount, feeAmount],
+ );
+
+ const btcInUsd = usePrice(coinSymbol);
+ const totalInUsd = calculateTokenValueInCurrency(total, btcInUsd);
+
+ return (
+
+
+ Total
+
+
+
+
+ {total} {coinSymbol}
+
+ {displayUSD && (
+
+ {totalInUsd}
+
+ )}
+
+
+ );
+}
diff --git a/src/ui/common/components/Staking/DelegationForm/index.tsx b/src/ui/common/components/Staking/DelegationForm/index.tsx
new file mode 100644
index 000000000..839244b5f
--- /dev/null
+++ b/src/ui/common/components/Staking/DelegationForm/index.tsx
@@ -0,0 +1,170 @@
+import { Heading, HiddenField, Loader, Text } from "@babylonlabs-io/core-ui";
+
+import { AuthGuard } from "@/ui/common/components/Common/AuthGuard";
+import { StatusView } from "@/ui/common/components/Staking/FinalityProviders/FinalityProviderTableStatusView";
+import apiNotAvailable from "@/ui/common/components/Staking/Form/States/api-not-available.svg";
+import { Message } from "@/ui/common/components/Staking/Form/States/Message";
+import stakingUnavailableIcon from "@/ui/common/components/Staking/Form/States/staking-unavailable.svg";
+import walletIcon from "@/ui/common/components/Staking/Form/States/wallet-icon.svg";
+import { WalletNotConnected } from "@/ui/common/components/Staking/Form/States/WalletNotConnected";
+import { BBN_FEE_AMOUNT } from "@/ui/common/constants";
+import { useBalanceState } from "@/ui/common/state/BalanceState";
+
+import { AmountField } from "./components/AmountField";
+import { BBNFeeAmount } from "./components/BBNFeeAmount";
+import { BTCFeeAmount } from "./components/BTCFeeAmount";
+import { BTCFeeRate } from "./components/BTCFeeRate";
+import { FeeSection } from "./components/FeeSection";
+import { InfoAlert } from "./components/InfoAlert";
+import { FormOverlay } from "./components/Overlay";
+import { SubmitButton } from "./components/SubmitButton";
+import { TermField } from "./components/TermField";
+import { Total } from "./components/Total";
+
+interface DelegationFormProps {
+ loading?: boolean;
+ blocked?: boolean;
+ hasError?: boolean;
+ error?: string;
+ stakingInfo?: {
+ minFeeRate: number;
+ maxFeeRate: number;
+ defaultFeeRate: number;
+ minStakingTimeBlocks: number;
+ maxStakingTimeBlocks: number;
+ minStakingAmountSat: number;
+ maxStakingAmountSat: number;
+ defaultStakingTimeBlocks?: number;
+ };
+ disabled?: {
+ title: string;
+ message: string;
+ };
+}
+
+export function DelegationForm({
+ loading,
+ blocked,
+ disabled,
+ hasError,
+ error,
+ stakingInfo,
+}: DelegationFormProps) {
+ const { stakableBtcBalance } = useBalanceState();
+
+ if (loading) {
+ return (
+ }
+ title="Please wait..."
+ />
+ );
+ }
+
+ if (blocked) {
+ return (
+
+ }
+ />
+ );
+ }
+
+ if (disabled) {
+ return (
+
+ }
+ />
+ );
+ }
+
+ if (hasError) {
+ return (
+
+ }
+ title="Staking is not available"
+ message={error ?? ""}
+ />
+ );
+ }
+
+ const maxAmount = Math.min(
+ stakableBtcBalance,
+ stakingInfo?.maxStakingAmountSat || 0,
+ );
+
+ return (
+ }>
+
+
+ Step 2
+
+
+
+ Set Staking Amount
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {BBN_FEE_AMOUNT && }
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/ui/common/components/Staking/FeeModal/components/CustomLabel.tsx b/src/ui/common/components/Staking/FeeModal/components/CustomLabel.tsx
new file mode 100644
index 000000000..2ee688fa3
--- /dev/null
+++ b/src/ui/common/components/Staking/FeeModal/components/CustomLabel.tsx
@@ -0,0 +1,39 @@
+import { Input, Text } from "@babylonlabs-io/core-ui";
+import { ChangeEventHandler, forwardRef } from "react";
+
+import { WarningTooltip } from "./WarningTooltip";
+
+interface CustomLabelProps {
+ label: string;
+ amount: string;
+ disabled: boolean;
+ warning: boolean;
+ onChange: ChangeEventHandler;
+}
+
+export const CustomLabel = forwardRef(
+ ({ label, amount, disabled, warning, onChange }, ref) => (
+
+ {label} {" "}
+
+ {warning && }
+ sats vB
+
+ }
+ onChange={onChange}
+ />
+
+ ),
+);
+
+CustomLabel.displayName = "CustomLabel";
diff --git a/src/ui/common/components/Staking/FeeModal/components/Label.tsx b/src/ui/common/components/Staking/FeeModal/components/Label.tsx
new file mode 100644
index 000000000..941eafd62
--- /dev/null
+++ b/src/ui/common/components/Staking/FeeModal/components/Label.tsx
@@ -0,0 +1,28 @@
+import { Chip, Text } from "@babylonlabs-io/core-ui";
+
+import { WarningTooltip } from "./WarningTooltip";
+
+interface LabelProps {
+ label: string;
+ amount: string;
+ hint: string;
+ warning: boolean;
+}
+
+export const Label = ({ label, amount, warning, hint }: LabelProps) => (
+
+
+ {label} ({amount} sat/vB)
+
+
+
+ {warning && }
+
+ {hint}
+
+
+);
diff --git a/src/ui/common/components/Staking/FeeModal/components/WarningTooltip.tsx b/src/ui/common/components/Staking/FeeModal/components/WarningTooltip.tsx
new file mode 100644
index 000000000..e577d9253
--- /dev/null
+++ b/src/ui/common/components/Staking/FeeModal/components/WarningTooltip.tsx
@@ -0,0 +1,25 @@
+import { useId } from "react";
+import { IoWarningOutline } from "react-icons/io5";
+import { Tooltip } from "react-tooltip";
+
+interface WarningTooltipProps {
+ className?: string;
+}
+
+export const WarningTooltip = ({ className }: WarningTooltipProps) => {
+ const id = useId();
+
+ return (
+
+
+
+
+ );
+};
diff --git a/src/ui/common/components/Staking/FeeModal/index.tsx b/src/ui/common/components/Staking/FeeModal/index.tsx
new file mode 100644
index 000000000..dc92cf0dc
--- /dev/null
+++ b/src/ui/common/components/Staking/FeeModal/index.tsx
@@ -0,0 +1,166 @@
+import {
+ Button,
+ DialogBody,
+ DialogFooter,
+ DialogHeader,
+ Heading,
+ Loader,
+ Radio,
+ Text,
+} from "@babylonlabs-io/core-ui";
+import { useEffect, useRef, useState } from "react";
+
+import { ResponsiveDialog } from "@/ui/common/components/Modals/ResponsiveDialog";
+import { StatusView } from "@/ui/common/components/Staking/FinalityProviders/FinalityProviderTableStatusView";
+import { useNetworkFees } from "@/ui/common/hooks/client/api/useNetworkFees";
+import { useStakingState } from "@/ui/common/state/StakingState";
+
+import { CustomLabel } from "./components/CustomLabel";
+import { Label } from "./components/Label";
+
+interface FeeModalProps {
+ open?: boolean;
+ onSubmit?: (value: number) => void;
+ onClose?: () => void;
+}
+
+export function FeeModal({ open, onSubmit, onClose }: FeeModalProps) {
+ const [selectedValue, setSelectedValue] = useState("");
+ const [customFee, setCustomFee] = useState("");
+ const customFeeRef = useRef(null);
+
+ const {
+ data: {
+ fastestFee = 0,
+ halfHourFee: mediumFee = 0,
+ hourFee: lowestFee = 0,
+ } = {},
+ isLoading,
+ } = useNetworkFees();
+ const { stakingInfo: { defaultFeeRate = 0 } = {} } = useStakingState();
+
+ useEffect(() => {
+ if (selectedValue === "custom") {
+ customFeeRef.current?.focus();
+ }
+ }, [selectedValue]);
+
+ const feeOptions = [
+ {
+ label: (
+
+ ),
+ className: "border border-secondary-strokeLight rounded p-4",
+ key: "fast",
+ value: fastestFee.toString(),
+ },
+ {
+ label: (
+
+ ),
+ className: "border border-secondary-strokeLight rounded p-4",
+ key: "medium",
+ value: mediumFee.toString(),
+ },
+ {
+ label: (
+
+ ),
+ className: "border border-secondary-strokeLight rounded p-4",
+ key: "slow",
+ value: lowestFee.toString(),
+ },
+ {
+ label: (
+ void setCustomFee(e.currentTarget?.value)}
+ />
+ ),
+ className: "items-center border border-transparent px-4 py-2",
+ key: "custom",
+ value: customFee,
+ },
+ ];
+
+ function handleSubmit() {
+ const selectedOption = feeOptions.find(
+ (option) => option.key === selectedValue,
+ );
+
+ if (selectedOption && selectedOption.value) {
+ onSubmit?.(parseFloat(selectedOption.value));
+ onClose?.();
+ }
+ }
+
+ return (
+
+
+
+ Adjusting the fee rate lets you control how quickly your Bitcoin
+ transaction is confirmed, with higher fees resulting in faster
+ confirmations and lower fees potentially causing delays.
+
+
+
+
+ {isLoading ? (
+ }
+ title="Please wait..."
+ />
+ ) : (
+ <>
+ Fee Rate
+
+ {feeOptions.map((option) => (
+ void setSelectedValue(option.key)}
+ />
+ ))}
+ >
+ )}
+
+
+
+
+ Apply
+
+
+
+ );
+}
diff --git a/src/ui/common/components/Staking/FinalityProviders/FinalityProviderColumns.tsx b/src/ui/common/components/Staking/FinalityProviders/FinalityProviderColumns.tsx
new file mode 100644
index 000000000..d99b3d68c
--- /dev/null
+++ b/src/ui/common/components/Staking/FinalityProviders/FinalityProviderColumns.tsx
@@ -0,0 +1,91 @@
+import { Hash } from "@/ui/common/components/Hash/Hash";
+import { getNetworkConfigBTC } from "@/ui/common/config/network/btc";
+import {
+ FinalityProvider,
+ FinalityProviderState,
+ FinalityProviderStateLabels,
+} from "@/ui/common/types/finalityProviders";
+import { satoshiToBtc } from "@/ui/common/utils/btc";
+import { maxDecimals } from "@/ui/common/utils/maxDecimals";
+
+import { FinalityProviderLogo } from "./FinalityProviderLogo";
+
+const { coinSymbol } = getNetworkConfigBTC();
+
+const mapStatus = (value: FinalityProviderState): string => {
+ return FinalityProviderStateLabels[value] || "Unknown";
+};
+
+export const finalityProviderColumns = [
+ {
+ key: "moniker",
+ header: "Finality Provider",
+ cellClassName: "text-primary-dark",
+ render: (_: unknown, row?: FinalityProvider) => {
+ if (!row) return null;
+
+ return (
+
+
+ {row.description?.moniker || "No name provided"}
+
+ );
+ },
+ sorter: (a?: FinalityProvider, b?: FinalityProvider) => {
+ const monikerA = a?.description?.moniker || "";
+ const monikerB = b?.description?.moniker || "";
+ return monikerA.localeCompare(monikerB);
+ },
+ },
+ {
+ key: "state",
+ header: "Status",
+ render: (value: unknown) => {
+ if (value == null) return "Unknown";
+ return mapStatus(value as FinalityProviderState);
+ },
+ },
+ {
+ key: "btcPk",
+ header: `${coinSymbol} PK`,
+ render: (_: unknown, row?: FinalityProvider) => {
+ if (!row?.btcPk) return null;
+ return ;
+ },
+ },
+ {
+ key: "activeTVLSat",
+ header: "Total Delegation",
+ render: (value: unknown) => {
+ const amount = Number(value);
+ if (isNaN(amount)) return "-";
+ return `${maxDecimals(satoshiToBtc(amount), 8)} ${coinSymbol}`;
+ },
+ sorter: (a?: FinalityProvider, b?: FinalityProvider) => {
+ const valueA = a?.activeTVLSat ?? 0;
+ const valueB = b?.activeTVLSat ?? 0;
+ return valueA - valueB;
+ },
+ },
+ {
+ key: "commission",
+ header: "Commission",
+ render: (value: unknown) => {
+ const commission = Number(value);
+ if (isNaN(commission)) return "-";
+ return `${maxDecimals(commission * 100, 2)}%`;
+ },
+ sorter: (a?: FinalityProvider, b?: FinalityProvider) => {
+ const commissionA = Number(a?.commission) || 0;
+ const commissionB = Number(b?.commission) || 0;
+ return commissionA - commissionB;
+ },
+ },
+];
diff --git a/src/ui/common/components/Staking/FinalityProviders/FinalityProviderFilter.tsx b/src/ui/common/components/Staking/FinalityProviders/FinalityProviderFilter.tsx
new file mode 100644
index 000000000..ffa7ae0c6
--- /dev/null
+++ b/src/ui/common/components/Staking/FinalityProviders/FinalityProviderFilter.tsx
@@ -0,0 +1,23 @@
+import { Select } from "@babylonlabs-io/core-ui";
+
+import { useFinalityProviderState } from "@/ui/common/state/FinalityProviderState";
+
+const options = [
+ { value: "active", label: "Active" },
+ { value: "inactive", label: "Inactive" },
+];
+
+export const FinalityProviderFilter = () => {
+ const { filter, handleFilter } = useFinalityProviderState();
+
+ return (
+ handleFilter("status", value.toString())}
+ placeholder="Select Status"
+ value={filter.search ? "" : filter.status}
+ disabled={Boolean(filter.search)}
+ renderSelectedOption={(option) => `Showing ${option.label}`}
+ />
+ );
+};
diff --git a/src/ui/common/components/Staking/FinalityProviders/FinalityProviderLogo.tsx b/src/ui/common/components/Staking/FinalityProviders/FinalityProviderLogo.tsx
new file mode 100644
index 000000000..432e5a2d6
--- /dev/null
+++ b/src/ui/common/components/Staking/FinalityProviders/FinalityProviderLogo.tsx
@@ -0,0 +1,68 @@
+import { Text } from "@babylonlabs-io/core-ui";
+import { useState } from "react";
+import { twMerge } from "tailwind-merge";
+
+interface FinalityProviderLogoProps {
+ logoUrl?: string;
+ rank: number;
+ moniker?: string;
+ className?: string;
+ size?: "lg" | "md" | "sm";
+}
+
+const STYLES = {
+ lg: {
+ logo: "size-10",
+ subLogo: "text-[0.8rem]",
+ },
+ md: {
+ logo: "size-6",
+ subLogo: "text-[0.5rem]",
+ },
+ sm: {
+ logo: "size-5",
+ subLogo: "text-[0.4rem]",
+ },
+};
+
+export const FinalityProviderLogo = ({
+ logoUrl,
+ rank,
+ moniker,
+ size = "md",
+ className,
+}: FinalityProviderLogoProps) => {
+ const [imageError, setImageError] = useState(false);
+ const styles = STYLES[size];
+
+ const fallbackLabel = moniker?.charAt(0).toUpperCase() ?? String(rank);
+
+ return (
+
+ {logoUrl && !imageError ? (
+ setImageError(true)}
+ />
+ ) : (
+
+ {fallbackLabel}
+
+ )}
+
+ {/*
+ {rank}
+ */}
+
+ );
+};
diff --git a/src/ui/common/components/Staking/FinalityProviders/FinalityProviderSearch.tsx b/src/ui/common/components/Staking/FinalityProviders/FinalityProviderSearch.tsx
new file mode 100644
index 000000000..92c5aab18
--- /dev/null
+++ b/src/ui/common/components/Staking/FinalityProviders/FinalityProviderSearch.tsx
@@ -0,0 +1,43 @@
+import { Input } from "@babylonlabs-io/core-ui";
+import { useCallback } from "react";
+import { MdCancel } from "react-icons/md";
+import { RiSearchLine } from "react-icons/ri";
+
+import { useFinalityProviderState } from "@/ui/common/state/FinalityProviderState";
+
+export const FinalityProviderSearch = () => {
+ const { filter, handleFilter } = useFinalityProviderState();
+
+ const onSearchChange = useCallback(
+ (e: React.ChangeEvent) => {
+ handleFilter("search", e.target.value);
+ },
+ [handleFilter],
+ );
+
+ const onClearSearch = useCallback(() => {
+ handleFilter("search", "");
+ }, [handleFilter]);
+
+ const searchSuffix = filter.search ? (
+
+
+
+ ) : (
+
+
+
+ );
+
+ return (
+
+ );
+};
diff --git a/src/ui/common/components/Staking/FinalityProviders/FinalityProviderTable.tsx b/src/ui/common/components/Staking/FinalityProviders/FinalityProviderTable.tsx
new file mode 100644
index 000000000..248600e13
--- /dev/null
+++ b/src/ui/common/components/Staking/FinalityProviders/FinalityProviderTable.tsx
@@ -0,0 +1,88 @@
+/**
+ * Import polyfill for array.toSorted
+ */
+import "core-js/features/array/to-sorted";
+
+import { Loader, Table } from "@babylonlabs-io/core-ui";
+
+import warningOctagon from "@/ui/common/assets/warning-octagon.svg";
+import warningTriangle from "@/ui/common/assets/warning-triangle.svg";
+import { useFinalityProviderState } from "@/ui/common/state/FinalityProviderState";
+
+import { finalityProviderColumns } from "./FinalityProviderColumns";
+import { StatusView } from "./FinalityProviderTableStatusView";
+
+interface FinalityProviderTable {
+ selectedFP: string;
+ onSelectRow?: (fpPK: string) => void;
+}
+
+export const FinalityProviderTable = ({
+ selectedFP,
+ onSelectRow,
+}: FinalityProviderTable) => {
+ const {
+ isFetching,
+ finalityProviders,
+ hasNextPage,
+ hasError,
+ fetchNextPage,
+ isRowSelectable,
+ } = useFinalityProviderState();
+
+ 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;
+ }
+
+ return (
+ {
+ onSelectRow?.(row?.btcPk ?? "");
+ }}
+ isRowSelectable={isRowSelectable}
+ />
+ );
+};
diff --git a/src/ui/common/components/Staking/FinalityProviders/FinalityProviderTableStatusView.tsx b/src/ui/common/components/Staking/FinalityProviders/FinalityProviderTableStatusView.tsx
new file mode 100644
index 000000000..252d9d37b
--- /dev/null
+++ b/src/ui/common/components/Staking/FinalityProviders/FinalityProviderTableStatusView.tsx
@@ -0,0 +1,36 @@
+import { Heading, Text } from "@babylonlabs-io/core-ui";
+import { twMerge } from "tailwind-merge";
+
+interface StatusViewProps {
+ icon: React.ReactNode | string;
+ title: string;
+ description?: React.ReactNode;
+ className?: string;
+}
+
+export const StatusView = ({
+ icon,
+ title,
+ description,
+ className,
+}: StatusViewProps) => (
+
+
+
+
+ {title}
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+);
diff --git a/src/ui/common/components/Staking/FinalityProviders/FinalityProviders.tsx b/src/ui/common/components/Staking/FinalityProviders/FinalityProviders.tsx
new file mode 100644
index 000000000..865bb58f1
--- /dev/null
+++ b/src/ui/common/components/Staking/FinalityProviders/FinalityProviders.tsx
@@ -0,0 +1,46 @@
+import {
+ Heading,
+ Text,
+ useFormContext,
+ useWatch,
+} from "@babylonlabs-io/core-ui";
+
+import { FinalityProviderFilter } from "./FinalityProviderFilter";
+import { FinalityProviderSearch } from "./FinalityProviderSearch";
+import { FinalityProviderTable } from "./FinalityProviderTable";
+
+export const FinalityProviders = () => {
+ const { setValue } = useFormContext();
+ const selectedFP = useWatch({ name: "finalityProviders", defaultValue: [] });
+
+ return (
+
+
+ Step 1
+
+
+ Select a Finality Provider
+
+
+
+
+
0 ? selectedFP[0] : ""}
+ onSelectRow={(pk) =>
+ setValue("finalityProviders", [pk], {
+ shouldValidate: true,
+ shouldTouch: true,
+ shouldDirty: true,
+ })
+ }
+ />
+
+ );
+};
diff --git a/src/ui/common/components/Staking/FinalityProviders/components/FPInfo.tsx b/src/ui/common/components/Staking/FinalityProviders/components/FPInfo.tsx
new file mode 100644
index 000000000..b55befb81
--- /dev/null
+++ b/src/ui/common/components/Staking/FinalityProviders/components/FPInfo.tsx
@@ -0,0 +1,33 @@
+import { FiExternalLink } from "react-icons/fi";
+
+import blue from "@/ui/common/assets/blue-check.svg";
+
+interface FPInfoProps {
+ moniker?: string;
+ website?: string;
+}
+
+export const FPInfo = ({ moniker, website }: FPInfoProps) => {
+ if (!moniker) {
+ return No data provided ;
+ }
+
+ return (
+
+
+
+ {moniker}
+ {website && (
+
+
+
+ )}
+
+
+ );
+};
diff --git a/src/ui/common/components/Staking/Form/States/Message.tsx b/src/ui/common/components/Staking/Form/States/Message.tsx
new file mode 100644
index 000000000..a3f5d5d4d
--- /dev/null
+++ b/src/ui/common/components/Staking/Form/States/Message.tsx
@@ -0,0 +1,31 @@
+import { Heading, Text } from "@babylonlabs-io/core-ui";
+
+interface MessageProps {
+ title: string;
+ message: React.ReactNode;
+ icon: JSX.Element;
+}
+
+export const Message: React.FC = ({ title, message, icon }) => {
+ return (
+
+
+ {icon}
+
+
+ {title}
+
+
+ {message}
+
+
+
+
+ );
+};
diff --git a/src/ui/common/components/Staking/Form/States/WalletNotConnected.tsx b/src/ui/common/components/Staking/Form/States/WalletNotConnected.tsx
new file mode 100644
index 000000000..2a489f725
--- /dev/null
+++ b/src/ui/common/components/Staking/Form/States/WalletNotConnected.tsx
@@ -0,0 +1,36 @@
+import { Button, Heading, Text } from "@babylonlabs-io/core-ui";
+
+import { getNetworkConfigBTC } from "@/ui/common/config/network/btc";
+import { useBTCWallet } from "@/ui/common/context/wallet/BTCWalletProvider";
+
+import walletIcon from "./wallet-icon.svg";
+
+export const WalletNotConnected = () => {
+ const { open } = useBTCWallet();
+ const { coinName } = getNetworkConfigBTC();
+
+ return (
+
+
+
+
+
+
+
+ Connect wallets to start staking
+
+
+ To start staking your {coinName} first connect wallets then select a
+ Finality Provider
+
+
+
+
+ Connect Wallets
+
+
+ );
+};
diff --git a/src/ui/common/components/Staking/Form/States/api-not-available.svg b/src/ui/common/components/Staking/Form/States/api-not-available.svg
new file mode 100644
index 000000000..f964f25ce
--- /dev/null
+++ b/src/ui/common/components/Staking/Form/States/api-not-available.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/src/ui/common/components/Staking/Form/States/connect-icon.svg b/src/ui/common/components/Staking/Form/States/connect-icon.svg
new file mode 100644
index 000000000..dfb9813f9
--- /dev/null
+++ b/src/ui/common/components/Staking/Form/States/connect-icon.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/ui/common/components/Staking/Form/States/geo-restricted.svg b/src/ui/common/components/Staking/Form/States/geo-restricted.svg
new file mode 100644
index 000000000..23f87fbfb
--- /dev/null
+++ b/src/ui/common/components/Staking/Form/States/geo-restricted.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/src/ui/common/components/Staking/Form/States/staking-cap-reached.svg b/src/ui/common/components/Staking/Form/States/staking-cap-reached.svg
new file mode 100644
index 000000000..071b042a0
--- /dev/null
+++ b/src/ui/common/components/Staking/Form/States/staking-cap-reached.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/src/ui/common/components/Staking/Form/States/staking-unavailable.svg b/src/ui/common/components/Staking/Form/States/staking-unavailable.svg
new file mode 100644
index 000000000..efebe4664
--- /dev/null
+++ b/src/ui/common/components/Staking/Form/States/staking-unavailable.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/ui/common/components/Staking/Form/States/staking-upgrading.svg b/src/ui/common/components/Staking/Form/States/staking-upgrading.svg
new file mode 100644
index 000000000..1791d6f82
--- /dev/null
+++ b/src/ui/common/components/Staking/Form/States/staking-upgrading.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/src/ui/common/components/Staking/Form/States/wallet-icon.svg b/src/ui/common/components/Staking/Form/States/wallet-icon.svg
new file mode 100644
index 000000000..65ec9339d
--- /dev/null
+++ b/src/ui/common/components/Staking/Form/States/wallet-icon.svg
@@ -0,0 +1,105 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/ui/common/components/Staking/Form/validation/validation.ts b/src/ui/common/components/Staking/Form/validation/validation.ts
new file mode 100644
index 000000000..b7e49bc1e
--- /dev/null
+++ b/src/ui/common/components/Staking/Form/validation/validation.ts
@@ -0,0 +1,18 @@
+/**
+ * Validates if the value does not have any decimal points.
+ * @param value The value as a string to validate.
+ * @returns `true` if the value does not have any decimal points, otherwise `false`.
+ */
+export const validateNoDecimalPoints = (value: string): boolean => {
+ return !value.includes(".") && !value.includes(",");
+};
+
+/**
+ * Validates if the value has no more than 8 decimal points.
+ * @param value The value to validate.
+ * @returns `true` if the value has no more than 8 decimal points, otherwise `false`.
+ */
+export const validateDecimalPoints = (value: string): boolean => {
+ const decimalPoints = value.split(".")[1]?.length || 0;
+ return decimalPoints <= 8;
+};
diff --git a/src/ui/common/components/Staking/StakingForm.tsx b/src/ui/common/components/Staking/StakingForm.tsx
new file mode 100644
index 000000000..14e7555c8
--- /dev/null
+++ b/src/ui/common/components/Staking/StakingForm.tsx
@@ -0,0 +1,55 @@
+import { Card, Form } from "@babylonlabs-io/core-ui";
+
+import { Section } from "@/ui/common/components/Section/Section";
+import { DelegationForm } from "@/ui/common/components/Staking/DelegationForm";
+import { StakingModal } from "@/ui/common/components/Staking/StakingModal";
+import { getNetworkConfigBTC } from "@/ui/common/config/network/btc";
+import { useStakingService } from "@/ui/common/hooks/services/useStakingService";
+import { useStakingState } from "@/ui/common/state/StakingState";
+
+import { FinalityProviders } from "./FinalityProviders/FinalityProviders";
+
+const { networkName } = getNetworkConfigBTC();
+
+export function StakingForm() {
+ const {
+ loading,
+ validationSchema,
+ stakingInfo,
+ hasError,
+ blocked,
+ disabled,
+ errorMessage,
+ } = useStakingState();
+ const { displayPreview } = useStakingService();
+
+ return (
+
+ );
+}
diff --git a/src/ui/common/components/Staking/StakingModal/index.tsx b/src/ui/common/components/Staking/StakingModal/index.tsx
new file mode 100644
index 000000000..890a1abd4
--- /dev/null
+++ b/src/ui/common/components/Staking/StakingModal/index.tsx
@@ -0,0 +1,139 @@
+import { useFormContext } from "@babylonlabs-io/core-ui";
+import { useMemo } from "react";
+
+import { CancelFeedbackModal } from "@/ui/common/components/Modals/CancelFeedbackModal";
+import { PreviewModal } from "@/ui/common/components/Modals/PreviewModal";
+import { SignModal } from "@/ui/common/components/Modals/SignModal/SignModal";
+import { StakeModal } from "@/ui/common/components/Modals/StakeModal";
+import { SuccessFeedbackModal } from "@/ui/common/components/Modals/SuccessFeedbackModal";
+import { VerificationModal } from "@/ui/common/components/Modals/VerificationModal";
+import { useStakingService } from "@/ui/common/hooks/services/useStakingService";
+import { useDelegationV2State } from "@/ui/common/state/DelegationV2State";
+import { useFinalityProviderState } from "@/ui/common/state/FinalityProviderState";
+import { useStakingState } from "@/ui/common/state/StakingState";
+
+import { SignDetailsModal } from "../../Modals/SignDetailsModal";
+
+const EOI_INDEXES: Record = {
+ "eoi-staking-slashing": 1,
+ "eoi-unbonding-slashing": 2,
+ "eoi-proof-of-possession": 3,
+ "eoi-sign-bbn": 4,
+};
+
+const VERIFICATION_STEPS: Record = {
+ "eoi-send-bbn": 1,
+ verifying: 2,
+};
+
+export function StakingModal() {
+ const {
+ processing,
+ step,
+ formData,
+ stakingInfo,
+ verifiedDelegation,
+ reset: resetState,
+ stakingStepOptions,
+ } = useStakingState();
+ const { getRegisteredFinalityProvider } = useFinalityProviderState();
+ const { createEOI, stakeDelegation } = useStakingService();
+ const {
+ reset: resetForm,
+ trigger: revalidateForm,
+ setValue: setFieldValue,
+ } = useFormContext();
+
+ const { delegationV2StepOptions, setDelegationV2StepOptions } =
+ useDelegationV2State();
+ const detailsModalTitle =
+ (delegationV2StepOptions?.type as string) || "Transaction Details";
+
+ const fp = useMemo(() => {
+ if (!formData || !formData.finalityProviders?.length) return null;
+ return getRegisteredFinalityProvider(formData.finalityProviders[0]);
+ }, [formData, getRegisteredFinalityProvider]);
+
+ if (!step) {
+ return null;
+ }
+
+ const handleClose = () => {
+ resetState();
+ setDelegationV2StepOptions(undefined);
+ };
+
+ return (
+ <>
+ {step === "preview" && formData && fp && stakingInfo && (
+ {
+ await createEOI(formData);
+ resetForm({
+ finalityProviders: [],
+ term: "",
+ amount: "",
+ feeRate: stakingInfo?.defaultFeeRate?.toString() ?? "0",
+ feeAmount: "0",
+ });
+ if (stakingInfo?.defaultStakingTimeBlocks) {
+ setFieldValue("term", stakingInfo?.defaultStakingTimeBlocks, {
+ shouldDirty: true,
+ shouldTouch: true,
+ });
+ }
+ revalidateForm();
+ }}
+ />
+ )}
+ {Boolean(EOI_INDEXES[step]) && (
+
+ )}
+ {Boolean(VERIFICATION_STEPS[step]) && (
+
+ )}
+ {verifiedDelegation && (
+ stakeDelegation(verifiedDelegation)}
+ onClose={handleClose}
+ />
+ )}
+
+
+ setDelegationV2StepOptions(undefined)}
+ details={delegationV2StepOptions}
+ title={detailsModalTitle}
+ />
+ >
+ );
+}
diff --git a/src/ui/common/components/Stats/ActionComponent.tsx b/src/ui/common/components/Stats/ActionComponent.tsx
new file mode 100644
index 000000000..90b1e5f6f
--- /dev/null
+++ b/src/ui/common/components/Stats/ActionComponent.tsx
@@ -0,0 +1,29 @@
+import { Button, Loader } from "@babylonlabs-io/core-ui";
+
+interface ActionComponentProps {
+ title: string;
+ onAction: () => void;
+ awaitingResponse?: boolean;
+ isDisabled?: boolean;
+ className?: string;
+}
+
+export function ActionComponent({
+ title,
+ onAction,
+ awaitingResponse,
+ isDisabled,
+ className,
+}: ActionComponentProps) {
+ return (
+
+ {awaitingResponse ? : title}
+
+ );
+}
diff --git a/src/ui/common/components/Stats/StatItem.tsx b/src/ui/common/components/Stats/StatItem.tsx
new file mode 100644
index 000000000..f33764faf
--- /dev/null
+++ b/src/ui/common/components/Stats/StatItem.tsx
@@ -0,0 +1,115 @@
+import {
+ Button,
+ ListItem,
+ Loader,
+ MobileDialog,
+ type ListItemProps,
+} from "@babylonlabs-io/core-ui";
+import { useEffect, useId, useState, type JSX } from "react";
+import { AiOutlineInfoCircle } from "react-icons/ai";
+import { Tooltip } from "react-tooltip";
+
+import { useIsMobileView } from "@/ui/common/hooks/useBreakpoint";
+
+interface StatItemProps extends ListItemProps {
+ hidden?: boolean;
+ loading?: boolean;
+ tooltip?: string | JSX.Element;
+ loadingStyle?: LoadingStyle;
+}
+
+export enum LoadingStyle {
+ ShowSpinner = "show-spinner",
+ ShowSpinnerAndValue = "show-spinner-and-value",
+}
+
+const SPINNER_RENDERERS: Record<
+ LoadingStyle,
+ (value: string | JSX.Element) => JSX.Element
+> = {
+ [LoadingStyle.ShowSpinner]: () => ,
+ [LoadingStyle.ShowSpinnerAndValue]: (value) => (
+ <>
+ {value}
+
+ >
+ ),
+};
+
+export const StatItem = ({
+ hidden = false,
+ loading,
+ title,
+ value,
+ tooltip,
+ suffix,
+ loadingStyle = LoadingStyle.ShowSpinner,
+ ...props
+}: StatItemProps) => {
+ const tooltipId = useId();
+ const isMobileView = useIsMobileView();
+ const [dialogOpen, setDialogOpen] = useState(false);
+
+ useEffect(() => {
+ if (!isMobileView && dialogOpen) {
+ setDialogOpen(false);
+ }
+ }, [isMobileView, dialogOpen]);
+
+ if (hidden) return null;
+
+ const suffixEl =
+ !suffix && tooltip ? (
+ isMobileView ? (
+ <>
+ setDialogOpen(true)}
+ >
+
+
+ setDialogOpen(false)}>
+ {tooltip}
+
+ setDialogOpen(false)}
+ >
+ Done
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+
+ {tooltip}
+
+ >
+ )
+ ) : (
+ suffix
+ );
+
+ return (
+
+ );
+};
diff --git a/src/ui/common/components/Stats/Stats.tsx b/src/ui/common/components/Stats/Stats.tsx
new file mode 100644
index 000000000..6f4f7a3ea
--- /dev/null
+++ b/src/ui/common/components/Stats/Stats.tsx
@@ -0,0 +1,61 @@
+import { List } from "@babylonlabs-io/core-ui";
+import { memo } from "react";
+
+import { Section } from "@/ui/common/components/Section/Section";
+import { getNetworkConfigBTC } from "@/ui/common/config/network/btc";
+import { usePrice } from "@/ui/common/hooks/client/api/usePrices";
+import { useSystemStats } from "@/ui/common/hooks/client/api/useSystemStats";
+import { satoshiToBtc } from "@/ui/common/utils/btc";
+import { formatBTCTvl } from "@/ui/common/utils/formatBTCTvl";
+
+import { StatItem } from "./StatItem";
+
+const { coinSymbol } = getNetworkConfigBTC();
+
+const formatter = Intl.NumberFormat("en", {
+ notation: "compact",
+ maximumFractionDigits: 2,
+});
+
+export const Stats = memo(() => {
+ const {
+ data: {
+ total_active_tvl: totalActiveTVL = 0,
+ active_tvl: activeTVL = 0,
+ btc_staking_apr: stakingAPR,
+ } = {},
+ isLoading,
+ } = useSystemStats();
+ const usdRate = usePrice(coinSymbol);
+
+ return (
+
+ );
+});
+
+Stats.displayName = "Stats";
diff --git a/src/ui/common/components/Stats/icons/index.tsx b/src/ui/common/components/Stats/icons/index.tsx
new file mode 100644
index 000000000..1d8f8d758
--- /dev/null
+++ b/src/ui/common/components/Stats/icons/index.tsx
@@ -0,0 +1,77 @@
+import { RiCopperCoinLine, RiHandCoinLine } from "react-icons/ri";
+
+export const tvlIcon = (
+
+
+
+);
+
+export const delegationIcon = (
+
+
+
+);
+
+export const stakerIcon = (
+
+
+
+);
+
+export const finalityProviderIcon = (
+
+
+
+);
+
+export const rewardHistoryIcon = ;
+
+export const rewardRateIcon = ;
diff --git a/src/ui/common/components/Tabs/Tabs.tsx b/src/ui/common/components/Tabs/Tabs.tsx
new file mode 100644
index 000000000..b3ef89ad9
--- /dev/null
+++ b/src/ui/common/components/Tabs/Tabs.tsx
@@ -0,0 +1,82 @@
+import { ReactNode, useEffect, useState } from "react";
+import { twMerge } from "tailwind-merge";
+
+interface TabItem {
+ id: string;
+ label: string;
+ content: ReactNode;
+}
+
+interface TabsProps {
+ items: TabItem[];
+ defaultActiveTab?: string;
+ activeTab?: string;
+ onTabChange?: (tabId: string) => void;
+ className?: string;
+}
+
+export const Tabs = ({
+ items,
+ defaultActiveTab,
+ activeTab: controlledActiveTab,
+ onTabChange,
+ className,
+}: TabsProps) => {
+ const [internalActiveTab, setInternalActiveTab] = useState(
+ defaultActiveTab || items[0]?.id || "",
+ );
+
+ const activeTab = controlledActiveTab ?? internalActiveTab;
+
+ // Synchronizes the internal active tab state with the controlledActiveTab prop.
+ useEffect(() => {
+ if (controlledActiveTab !== undefined) {
+ setInternalActiveTab(controlledActiveTab);
+ }
+ }, [controlledActiveTab]);
+
+ const handleTabClick = (tabId: string) => {
+ if (onTabChange) {
+ onTabChange(tabId);
+ } else {
+ setInternalActiveTab(tabId);
+ }
+ };
+
+ const activeContent = items.find((item) => item.id === activeTab)?.content;
+
+ return (
+
+
+ {items.map((item) => (
+ handleTabClick(item.id)}
+ >
+ {item.label}
+
+ ))}
+
+
+
+ {activeContent}
+
+
+ );
+};
diff --git a/src/ui/common/components/Tabs/index.ts b/src/ui/common/components/Tabs/index.ts
new file mode 100644
index 000000000..471a0b2d6
--- /dev/null
+++ b/src/ui/common/components/Tabs/index.ts
@@ -0,0 +1 @@
+export { Tabs } from "./Tabs";
diff --git a/src/ui/common/components/ThemeToggle/ThemeToggle.tsx b/src/ui/common/components/ThemeToggle/ThemeToggle.tsx
new file mode 100644
index 000000000..f6323ae93
--- /dev/null
+++ b/src/ui/common/components/ThemeToggle/ThemeToggle.tsx
@@ -0,0 +1,28 @@
+import { Text, Toggle } from "@babylonlabs-io/core-ui";
+import { IoIosMoon, IoIosSunny } from "react-icons/io";
+
+import { useAppState } from "@/ui/common/state";
+
+export const ThemeToggle = () => {
+ const { theme, setTheme } = useAppState();
+
+ if (!theme) return null;
+
+ return (
+
+
+ {theme} Mode
+
+
+ {
+ setTheme(value ? "dark" : "light");
+ }}
+ inactiveIcon={ }
+ activeIcon={ }
+ />
+
+
+ );
+};
diff --git a/src/ui/common/components/Wallet/Connect.tsx b/src/ui/common/components/Wallet/Connect.tsx
new file mode 100644
index 000000000..02a47d9a7
--- /dev/null
+++ b/src/ui/common/components/Wallet/Connect.tsx
@@ -0,0 +1,190 @@
+import { Avatar, AvatarGroup, Button } from "@babylonlabs-io/core-ui";
+import { useWidgetState } from "@babylonlabs-io/wallet-connector";
+import { useMemo, useRef, useState } from "react";
+import { AiOutlineInfoCircle } from "react-icons/ai";
+import { PiWalletBold } from "react-icons/pi";
+import { Tooltip } from "react-tooltip";
+import { twMerge } from "tailwind-merge";
+
+import { useBTCWallet } from "@/ui/common/context/wallet/BTCWalletProvider";
+import { useCosmosWallet } from "@/ui/common/context/wallet/CosmosWalletProvider";
+import { useHealthCheck } from "@/ui/common/hooks/useHealthCheck";
+import { useAppState } from "@/ui/common/state";
+import { useDelegationV2State } from "@/ui/common/state/DelegationV2State";
+
+import {
+ SettingMenuButton,
+ SettingMenuContainer,
+ SettingMenuContent,
+} from "../Menu/SettingMenu";
+import { WalletMenuContainer } from "../Menu/WalletMenu";
+
+interface ConnectProps {
+ loading?: boolean;
+ onConnect: () => void;
+}
+
+export const Connect: React.FC = ({
+ loading = false,
+ onConnect,
+}) => {
+ const settingsButtonRef = useRef(null);
+ const [isSettingsMenuOpen, setIsSettingsMenuOpen] = useState(false);
+
+ const [isWalletMenuOpen, setIsWalletMenuOpen] = useState(false);
+ const handleOpenChange = (open: boolean) => {
+ setIsWalletMenuOpen(open);
+ };
+
+ // App state and wallet context
+ const { includeOrdinals, excludeOrdinals, ordinalsExcluded } = useAppState();
+ const { linkedDelegationsVisibility, displayLinkedDelegations } =
+ useDelegationV2State();
+
+ // Wallet states
+ const {
+ loading: btcLoading,
+ address: btcAddress,
+ connected: btcConnected,
+ publicKeyNoCoord,
+ } = useBTCWallet();
+ const {
+ loading: bbnLoading,
+ bech32Address,
+ connected: bbnConnected,
+ } = useCosmosWallet();
+
+ // Widget states
+ const { selectedWallets } = useWidgetState();
+
+ const {
+ isApiNormal,
+ isGeoBlocked,
+ apiMessage,
+ isLoading: isHealthcheckLoading,
+ } = useHealthCheck();
+ const isConnected = useMemo(
+ () =>
+ btcConnected && bbnConnected && !isGeoBlocked && !isHealthcheckLoading,
+ [btcConnected, bbnConnected, isGeoBlocked, isHealthcheckLoading],
+ );
+
+ const isLoading =
+ isConnected || !isApiNormal || loading || btcLoading || bbnLoading;
+
+ const transformedWallets = useMemo(() => {
+ const result: Record = {};
+ Object.entries(selectedWallets).forEach(([key, wallet]) => {
+ if (wallet) {
+ result[key] = { name: wallet.name, icon: wallet.icon };
+ }
+ });
+ return result;
+ }, [selectedWallets]);
+
+ const renderApiNotAvailableTooltip = useMemo(() => {
+ if (!isGeoBlocked && isApiNormal) return null;
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+ }, [isGeoBlocked, isApiNormal, apiMessage]);
+
+ // DISCONNECTED STATE: Show connect button + settings menu
+ if (!isConnected) {
+ return (
+
+
+
+ Connect Wallets
+
+
+
setIsSettingsMenuOpen(!isSettingsMenuOpen)}
+ />
+ setIsSettingsMenuOpen(false)}
+ >
+
+
+
+ {!isApiNormal && renderApiNotAvailableTooltip}
+
+ );
+ }
+
+ // CONNECTED STATE: Show wallet avatars + settings menu
+ return (
+
+ }
+ btcAddress={btcAddress}
+ bbnAddress={bech32Address}
+ selectedWallets={transformedWallets}
+ ordinalsExcluded={ordinalsExcluded}
+ linkedDelegationsVisibility={linkedDelegationsVisibility}
+ onIncludeOrdinals={includeOrdinals}
+ onExcludeOrdinals={excludeOrdinals}
+ onDisplayLinkedDelegations={displayLinkedDelegations}
+ publicKeyNoCoord={publicKeyNoCoord}
+ onOpenChange={handleOpenChange}
+ />
+
+ setIsSettingsMenuOpen(!isSettingsMenuOpen)}
+ />
+ setIsSettingsMenuOpen(false)}
+ >
+
+
+
+ );
+};
diff --git a/src/ui/common/config/index.ts b/src/ui/common/config/index.ts
new file mode 100644
index 000000000..51b17ccbb
--- /dev/null
+++ b/src/ui/common/config/index.ts
@@ -0,0 +1,62 @@
+// Default gas price for BABY
+const DEFAULT_BBN_GAS_PRICE = 0.002;
+
+// API URL configuration
+export const getApiBaseUrl = (): string => {
+ let apiUrl = process.env.NEXT_PUBLIC_API_URL;
+
+ if (!apiUrl) {
+ throw new Error("NEXT_PUBLIC_API_URL environment variable is not defined");
+ }
+
+ if (apiUrl === "/") {
+ apiUrl = "";
+ }
+
+ return apiUrl;
+};
+
+// shouldDisplayTestingMsg function is used to check if the application is running in testing mode or not.
+// Default to true if the environment variable is not set.
+export const shouldDisplayTestingMsg = (): boolean => {
+ return (
+ process.env.NEXT_PUBLIC_DISPLAY_TESTING_MESSAGES?.toString() !== "false"
+ );
+};
+
+// getNetworkAppUrl function is used to get the network app url based on the environment
+export const getNetworkAppUrl = (): string => {
+ return shouldDisplayTestingMsg()
+ ? "https://btcstaking.testnet.babylonchain.io"
+ : "https://btcstaking.babylonlabs.io";
+};
+
+export const IS_FIXED_TERM_FIELD =
+ process.env.NEXT_PUBLIC_FIXED_STAKING_TERM === "true";
+
+// BBN_GAS_PRICE is used to get the gas price for BABY
+export const BBN_GAS_PRICE = (() => {
+ const price = parseFloat(process.env.NEXT_PUBLIC_BBN_GAS_PRICE || "");
+ if (isNaN(price) || price <= 0 || price >= 1) {
+ return DEFAULT_BBN_GAS_PRICE; // fallback to default if invalid
+ }
+ return price;
+})();
+
+// PROD_ENVS defines production environments from CI matrix in GitHub workflows
+// These values match the environment names in the CI matrix configuration
+export const PROD_ENVS = ["phase-2-mainnet"];
+
+export const isProductionEnv = (): boolean => {
+ const env = process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? "";
+ return PROD_ENVS.includes(env);
+};
+
+// Disable the wallet by their name in the event of incident. split by comma.
+// You can find the wallet name from the wallet provider.
+export const getDisabledWallets = (): string[] => {
+ return (
+ process.env.NEXT_PUBLIC_DISABLED_WALLETS?.split(",").map((w) => w.trim()) ||
+ []
+ );
+};
diff --git a/src/ui/common/config/network/bbn.ts b/src/ui/common/config/network/bbn.ts
new file mode 100644
index 000000000..93e20dbd3
--- /dev/null
+++ b/src/ui/common/config/network/bbn.ts
@@ -0,0 +1,76 @@
+import type { BBNConfig } from "@babylonlabs-io/wallet-connector";
+
+import { bbnBsnDevnet } from "./bbn/bsn-devnet";
+import { bbnCanary } from "./bbn/canary";
+import { bbnDevnet } from "./bbn/devnet";
+import { bbnEdgeDevnet } from "./bbn/edge-devnet";
+import { bbnMainnet } from "./bbn/mainnet";
+import { bbnTestnet } from "./bbn/testnet";
+
+interface ExtendedBBNConfig extends BBNConfig {
+ displayUSD: boolean;
+}
+
+const defaultNetwork = "devnet";
+export const network = process.env.NEXT_PUBLIC_NETWORK ?? defaultNetwork;
+
+const config: Record = {
+ mainnet: {
+ chainId: bbnMainnet.chainId,
+ rpc: bbnMainnet.rpc,
+ chainData: bbnMainnet,
+ networkName: "BABY",
+ networkFullName: "Babylon Genesis",
+ coinSymbol: "BABY",
+ displayUSD: true,
+ },
+ canary: {
+ chainId: bbnCanary.chainId,
+ rpc: bbnCanary.rpc,
+ chainData: bbnCanary,
+ networkName: "BABY",
+ networkFullName: "Babylon Genesis",
+ coinSymbol: "BABY",
+ displayUSD: true,
+ },
+ devnet: {
+ chainId: bbnDevnet.chainId,
+ rpc: bbnDevnet.rpc,
+ chainData: bbnDevnet,
+ networkName: "Testnet BABY",
+ networkFullName: "Testnet Babylon Genesis",
+ coinSymbol: "tBABY",
+ displayUSD: false,
+ },
+ bsnDevnet: {
+ chainId: bbnBsnDevnet.chainId,
+ rpc: bbnBsnDevnet.rpc,
+ chainData: bbnBsnDevnet,
+ networkName: "Testnet BABY",
+ networkFullName: "Testnet Babylon Genesis",
+ coinSymbol: "tBABY",
+ displayUSD: false,
+ },
+ edgeDevnet: {
+ chainId: bbnEdgeDevnet.chainId,
+ rpc: bbnEdgeDevnet.rpc,
+ chainData: bbnEdgeDevnet,
+ networkName: "Testnet BABY",
+ networkFullName: "Testnet Babylon Genesis",
+ coinSymbol: "tBABY",
+ displayUSD: false,
+ },
+ testnet: {
+ chainId: bbnTestnet.chainId,
+ rpc: bbnTestnet.rpc,
+ chainData: bbnTestnet,
+ networkName: "Testnet BABY",
+ networkFullName: "Testnet Babylon Genesis",
+ coinSymbol: "tBABY",
+ displayUSD: false,
+ },
+};
+
+export function getNetworkConfigBBN(): ExtendedBBNConfig {
+ return config[network] ?? config[defaultNetwork];
+}
diff --git a/src/ui/common/config/network/bbn/bsn-devnet.ts b/src/ui/common/config/network/bbn/bsn-devnet.ts
new file mode 100644
index 000000000..b338b1007
--- /dev/null
+++ b/src/ui/common/config/network/bbn/bsn-devnet.ts
@@ -0,0 +1,69 @@
+import { getUrlFromEnv } from "./urlUtils";
+
+export const BSN_DEVNET_RPC_URL = getUrlFromEnv(
+ process.env.NEXT_PUBLIC_BABY_RPC_URL,
+ "http://localhost:3000",
+ "https://rpc.bsn-devnet.babylonlabs.io/",
+);
+
+export const BSN_DEVNET_LCD_URL = getUrlFromEnv(
+ process.env.NEXT_PUBLIC_BABY_LCD_URL,
+ "http://localhost:1317",
+ "https://lcd.bsn-devnet.babylonlabs.io/",
+);
+
+export const bbnBsnDevnet = {
+ chainId: "bsn-devnet-1",
+ chainName: "Babylon BSN Devnet 1",
+ chainSymbolImageUrl:
+ "https://raw.githubusercontent.com/babylonlabs-io/simple-staking/main/public/chain.png",
+ rpc: BSN_DEVNET_RPC_URL,
+ rest: BSN_DEVNET_LCD_URL,
+ nodeProvider: {
+ name: "Babylonlabs",
+ email: "contact@babylonlabs.io",
+ website: "https://babylonlabs.io/",
+ },
+ bip44: {
+ coinType: 118,
+ },
+ bech32Config: {
+ bech32PrefixAccAddr: "bbn",
+ bech32PrefixAccPub: "bbnpub",
+ bech32PrefixValAddr: "bbnvaloper",
+ bech32PrefixValPub: "bbnvaloperpub",
+ bech32PrefixConsAddr: "bbnvalcons",
+ bech32PrefixConsPub: "bbnvalconspub",
+ },
+ currencies: [
+ {
+ coinDenom: "BABY",
+ coinMinimalDenom: "ubbn",
+ coinDecimals: 6,
+ coinImageUrl:
+ "https://raw.githubusercontent.com/babylonlabs-io/simple-staking/main/public/chain.png",
+ },
+ ],
+ feeCurrencies: [
+ {
+ coinDenom: "BABY",
+ coinMinimalDenom: "ubbn",
+ coinDecimals: 6,
+ coinImageUrl:
+ "https://raw.githubusercontent.com/babylonlabs-io/simple-staking/main/public/chain.png",
+ gasPriceStep: {
+ low: 0.007,
+ average: 0.007,
+ high: 0.01,
+ },
+ },
+ ],
+ stakeCurrency: {
+ coinDenom: "BABY",
+ coinMinimalDenom: "ubbn",
+ coinDecimals: 6,
+ coinImageUrl:
+ "https://raw.githubusercontent.com/babylonlabs-io/simple-staking/main/public/chain.png",
+ },
+ features: ["cosmwasm"],
+};
diff --git a/src/ui/common/config/network/bbn/canary.ts b/src/ui/common/config/network/bbn/canary.ts
new file mode 100644
index 000000000..8f690c88f
--- /dev/null
+++ b/src/ui/common/config/network/bbn/canary.ts
@@ -0,0 +1,69 @@
+import { getUrlFromEnv } from "./urlUtils";
+
+export const BBN_CANARY_RPC_URL = getUrlFromEnv(
+ process.env.NEXT_PUBLIC_BABY_RPC_URL,
+ "http://localhost:3000",
+ "https://rpc.staging.babylonlabs.io/",
+);
+
+export const BBN_CANARY_LCD_URL = getUrlFromEnv(
+ process.env.NEXT_PUBLIC_BABY_LCD_URL,
+ "http://localhost:1317",
+ "https://lcd.staging.babylonlabs.io/",
+);
+
+export const bbnCanary = {
+ chainId: "bbn-staging-1",
+ chainName: "Babylon Staging",
+ chainSymbolImageUrl:
+ "https://raw.githubusercontent.com/babylonlabs-io/simple-staking/main/public/chain.png",
+ rpc: BBN_CANARY_RPC_URL,
+ rest: BBN_CANARY_LCD_URL,
+ nodeProvider: {
+ name: "Babylonlabs",
+ email: "contact@babylonlabs.io",
+ website: "https://babylonlabs.io/",
+ },
+ bip44: {
+ coinType: 118,
+ },
+ bech32Config: {
+ bech32PrefixAccAddr: "bbn",
+ bech32PrefixAccPub: "bbnpub",
+ bech32PrefixValAddr: "bbnvaloper",
+ bech32PrefixValPub: "bbnvaloperpub",
+ bech32PrefixConsAddr: "bbnvalcons",
+ bech32PrefixConsPub: "bbnvalconspub",
+ },
+ currencies: [
+ {
+ coinDenom: "BABY",
+ coinMinimalDenom: "ubbn",
+ coinDecimals: 6,
+ coinImageUrl:
+ "https://raw.githubusercontent.com/babylonlabs-io/simple-staking/main/public/chain.png",
+ },
+ ],
+ feeCurrencies: [
+ {
+ coinDenom: "BABY",
+ coinMinimalDenom: "ubbn",
+ coinDecimals: 6,
+ coinImageUrl:
+ "https://raw.githubusercontent.com/babylonlabs-io/simple-staking/main/public/chain.png",
+ gasPriceStep: {
+ low: 0.007,
+ average: 0.007,
+ high: 0.01,
+ },
+ },
+ ],
+ stakeCurrency: {
+ coinDenom: "BABY",
+ coinMinimalDenom: "ubbn",
+ coinDecimals: 6,
+ coinImageUrl:
+ "https://raw.githubusercontent.com/babylonlabs-io/simple-staking/main/public/chain.png",
+ },
+ features: ["cosmwasm"],
+};
diff --git a/src/ui/common/config/network/bbn/devnet.ts b/src/ui/common/config/network/bbn/devnet.ts
new file mode 100644
index 000000000..b49e4674d
--- /dev/null
+++ b/src/ui/common/config/network/bbn/devnet.ts
@@ -0,0 +1,69 @@
+import { getUrlFromEnv } from "./urlUtils";
+
+export const BBN_DEVNET_RPC_URL = getUrlFromEnv(
+ process.env.NEXT_PUBLIC_BABY_RPC_URL,
+ "http://localhost:3000",
+ "https://rpc-dapp.devnet.babylonlabs.io/",
+);
+
+export const BBN_DEVNET_LCD_URL = getUrlFromEnv(
+ process.env.NEXT_PUBLIC_BABY_LCD_URL,
+ "http://localhost:1317",
+ "https://lcd-dapp.devnet.babylonlabs.io/",
+);
+
+export const bbnDevnet = {
+ chainId: "devnet-12",
+ chainName: "Babylon Devnet 12",
+ chainSymbolImageUrl:
+ "https://raw.githubusercontent.com/babylonlabs-io/simple-staking/main/public/chain.png",
+ rpc: BBN_DEVNET_RPC_URL,
+ rest: BBN_DEVNET_LCD_URL,
+ nodeProvider: {
+ name: "Babylonlabs",
+ email: "contact@babylonlabs.io",
+ website: "https://babylonlabs.io/",
+ },
+ bip44: {
+ coinType: 118,
+ },
+ bech32Config: {
+ bech32PrefixAccAddr: "bbn",
+ bech32PrefixAccPub: "bbnpub",
+ bech32PrefixValAddr: "bbnvaloper",
+ bech32PrefixValPub: "bbnvaloperpub",
+ bech32PrefixConsAddr: "bbnvalcons",
+ bech32PrefixConsPub: "bbnvalconspub",
+ },
+ currencies: [
+ {
+ coinDenom: "BABY",
+ coinMinimalDenom: "ubbn",
+ coinDecimals: 6,
+ coinImageUrl:
+ "https://raw.githubusercontent.com/babylonlabs-io/simple-staking/main/public/chain.png",
+ },
+ ],
+ feeCurrencies: [
+ {
+ coinDenom: "BABY",
+ coinMinimalDenom: "ubbn",
+ coinDecimals: 6,
+ coinImageUrl:
+ "https://raw.githubusercontent.com/babylonlabs-io/simple-staking/main/public/chain.png",
+ gasPriceStep: {
+ low: 0.007,
+ average: 0.007,
+ high: 0.01,
+ },
+ },
+ ],
+ stakeCurrency: {
+ coinDenom: "BABY",
+ coinMinimalDenom: "ubbn",
+ coinDecimals: 6,
+ coinImageUrl:
+ "https://raw.githubusercontent.com/babylonlabs-io/simple-staking/main/public/chain.png",
+ },
+ features: ["cosmwasm"],
+};
diff --git a/src/ui/common/config/network/bbn/edge-devnet.ts b/src/ui/common/config/network/bbn/edge-devnet.ts
new file mode 100644
index 000000000..6bb292b7c
--- /dev/null
+++ b/src/ui/common/config/network/bbn/edge-devnet.ts
@@ -0,0 +1,69 @@
+import { getUrlFromEnv } from "./urlUtils";
+
+export const BBN_EDGE_DEVNET_RPC_URL = getUrlFromEnv(
+ process.env.NEXT_PUBLIC_BABY_RPC_URL,
+ "http://localhost:3000",
+ "https://rpc.edge-devnet.babylonlabs.io/",
+);
+
+export const BBN_EDGE_DEVNET_LCD_URL = getUrlFromEnv(
+ process.env.NEXT_PUBLIC_BABY_LCD_URL,
+ "http://localhost:1317",
+ "https://lcd.edge-devnet.babylonlabs.io/",
+);
+
+export const bbnEdgeDevnet = {
+ chainId: "edge-devnet-1",
+ chainName: "Babylon Edge Devnet 1",
+ chainSymbolImageUrl:
+ "https://raw.githubusercontent.com/babylonlabs-io/simple-staking/main/public/chain.png",
+ rpc: BBN_EDGE_DEVNET_RPC_URL,
+ rest: BBN_EDGE_DEVNET_LCD_URL,
+ nodeProvider: {
+ name: "Babylonlabs",
+ email: "contact@babylonlabs.io",
+ website: "https://babylonlabs.io/",
+ },
+ bip44: {
+ coinType: 118,
+ },
+ bech32Config: {
+ bech32PrefixAccAddr: "bbn",
+ bech32PrefixAccPub: "bbnpub",
+ bech32PrefixValAddr: "bbnvaloper",
+ bech32PrefixValPub: "bbnvaloperpub",
+ bech32PrefixConsAddr: "bbnvalcons",
+ bech32PrefixConsPub: "bbnvalconspub",
+ },
+ currencies: [
+ {
+ coinDenom: "BABY",
+ coinMinimalDenom: "ubbn",
+ coinDecimals: 6,
+ coinImageUrl:
+ "https://raw.githubusercontent.com/babylonlabs-io/simple-staking/main/public/chain.png",
+ },
+ ],
+ feeCurrencies: [
+ {
+ coinDenom: "BABY",
+ coinMinimalDenom: "ubbn",
+ coinDecimals: 6,
+ coinImageUrl:
+ "https://raw.githubusercontent.com/babylonlabs-io/simple-staking/main/public/chain.png",
+ gasPriceStep: {
+ low: 0.007,
+ average: 0.007,
+ high: 0.01,
+ },
+ },
+ ],
+ stakeCurrency: {
+ coinDenom: "BABY",
+ coinMinimalDenom: "ubbn",
+ coinDecimals: 6,
+ coinImageUrl:
+ "https://raw.githubusercontent.com/babylonlabs-io/simple-staking/main/public/chain.png",
+ },
+ features: ["cosmwasm"],
+};
diff --git a/src/ui/common/config/network/bbn/mainnet.ts b/src/ui/common/config/network/bbn/mainnet.ts
new file mode 100644
index 000000000..713309104
--- /dev/null
+++ b/src/ui/common/config/network/bbn/mainnet.ts
@@ -0,0 +1,69 @@
+import { getUrlFromEnv } from "./urlUtils";
+
+export const BBN_MAINNET_RPC_URL = getUrlFromEnv(
+ process.env.NEXT_PUBLIC_BABY_RPC_URL,
+ "http://localhost:3000",
+ "https://rpc-dapp.babylonlabs.io/",
+);
+
+export const BBN_MAINNET_LCD_URL = getUrlFromEnv(
+ process.env.NEXT_PUBLIC_BABY_LCD_URL,
+ "http://localhost:1317",
+ "https://lcd-dapp.babylonlabs.io/",
+);
+
+export const bbnMainnet = {
+ chainId: "bbn-1",
+ chainName: "Babylon Genesis",
+ chainSymbolImageUrl:
+ "https://raw.githubusercontent.com/babylonlabs-io/simple-staking/main/public/chain.png",
+ rpc: BBN_MAINNET_RPC_URL,
+ rest: BBN_MAINNET_LCD_URL,
+ nodeProvider: {
+ name: "Babylonlabs",
+ email: "contact@babylonlabs.io",
+ website: "https://babylonlabs.io/",
+ },
+ bip44: {
+ coinType: 118,
+ },
+ bech32Config: {
+ bech32PrefixAccAddr: "bbn",
+ bech32PrefixAccPub: "bbnpub",
+ bech32PrefixValAddr: "bbnvaloper",
+ bech32PrefixValPub: "bbnvaloperpub",
+ bech32PrefixConsAddr: "bbnvalcons",
+ bech32PrefixConsPub: "bbnvalconspub",
+ },
+ currencies: [
+ {
+ coinDenom: "BABY",
+ coinMinimalDenom: "ubbn",
+ coinDecimals: 6,
+ coinImageUrl:
+ "https://raw.githubusercontent.com/babylonlabs-io/simple-staking/main/public/chain.png",
+ },
+ ],
+ feeCurrencies: [
+ {
+ coinDenom: "BABY",
+ coinMinimalDenom: "ubbn",
+ coinDecimals: 6,
+ coinImageUrl:
+ "https://raw.githubusercontent.com/babylonlabs-io/simple-staking/main/public/chain.png",
+ gasPriceStep: {
+ low: 0.007,
+ average: 0.007,
+ high: 0.01,
+ },
+ },
+ ],
+ stakeCurrency: {
+ coinDenom: "BABY",
+ coinMinimalDenom: "ubbn",
+ coinDecimals: 6,
+ coinImageUrl:
+ "https://raw.githubusercontent.com/babylonlabs-io/simple-staking/main/public/chain.png",
+ },
+ features: ["cosmwasm"],
+};
diff --git a/src/ui/common/config/network/bbn/testnet.ts b/src/ui/common/config/network/bbn/testnet.ts
new file mode 100644
index 000000000..d47a1ab63
--- /dev/null
+++ b/src/ui/common/config/network/bbn/testnet.ts
@@ -0,0 +1,69 @@
+import { getUrlFromEnv } from "./urlUtils";
+
+export const BBN_TESTNET_RPC_URL = getUrlFromEnv(
+ process.env.NEXT_PUBLIC_BABY_RPC_URL,
+ "http://localhost:3000",
+ "https://rpc-dapp.testnet.babylonlabs.io/",
+);
+
+export const BBN_TESTNET_LCD_URL = getUrlFromEnv(
+ process.env.NEXT_PUBLIC_BABY_LCD_URL,
+ "http://localhost:1317",
+ "https://lcd-dapp.testnet.babylonlabs.io/",
+);
+
+export const bbnTestnet = {
+ chainId: "bbn-test-5",
+ chainName: "Babylon Phase-2 Testnet",
+ chainSymbolImageUrl:
+ "https://raw.githubusercontent.com/babylonlabs-io/simple-staking/main/public/chain.png",
+ rpc: BBN_TESTNET_RPC_URL,
+ rest: BBN_TESTNET_LCD_URL,
+ nodeProvider: {
+ name: "Babylonlabs",
+ email: "contact@babylonlabs.io",
+ website: "https://babylonlabs.io/",
+ },
+ bip44: {
+ coinType: 118,
+ },
+ bech32Config: {
+ bech32PrefixAccAddr: "bbn",
+ bech32PrefixAccPub: "bbnpub",
+ bech32PrefixValAddr: "bbnvaloper",
+ bech32PrefixValPub: "bbnvaloperpub",
+ bech32PrefixConsAddr: "bbnvalcons",
+ bech32PrefixConsPub: "bbnvalconspub",
+ },
+ currencies: [
+ {
+ coinDenom: "BABY",
+ coinMinimalDenom: "ubbn",
+ coinDecimals: 6,
+ coinImageUrl:
+ "https://raw.githubusercontent.com/babylonlabs-io/simple-staking/main/public/chain.png",
+ },
+ ],
+ feeCurrencies: [
+ {
+ coinDenom: "BABY",
+ coinMinimalDenom: "ubbn",
+ coinDecimals: 6,
+ coinImageUrl:
+ "https://raw.githubusercontent.com/babylonlabs-io/simple-staking/main/public/chain.png",
+ gasPriceStep: {
+ low: 0.007,
+ average: 0.007,
+ high: 0.01,
+ },
+ },
+ ],
+ stakeCurrency: {
+ coinDenom: "BABY",
+ coinMinimalDenom: "ubbn",
+ coinDecimals: 6,
+ coinImageUrl:
+ "https://raw.githubusercontent.com/babylonlabs-io/simple-staking/main/public/chain.png",
+ },
+ features: ["cosmwasm"],
+};
diff --git a/src/ui/common/config/network/bbn/urlUtils.ts b/src/ui/common/config/network/bbn/urlUtils.ts
new file mode 100644
index 000000000..6b13917ca
--- /dev/null
+++ b/src/ui/common/config/network/bbn/urlUtils.ts
@@ -0,0 +1,23 @@
+/**
+ * Helper function to determine URL based on environment variables
+ *
+ * @param envVar The environment variable containing a potential URL value
+ * @param localUrl The URL to use in CI/testing environments
+ * @param prodUrl The URL to use in production
+ * @returns The appropriate URL based on the environment
+ */
+export const getUrlFromEnv = (
+ envVar: string | undefined,
+ localUrl: string,
+ prodUrl: string,
+): string => {
+ if (envVar && envVar !== "/") {
+ return envVar;
+ }
+
+ if (process.env.CI || process.env.NEXT_BUILD_E2E) {
+ return localUrl;
+ }
+
+ return prodUrl;
+};
diff --git a/src/ui/common/config/network/btc.ts b/src/ui/common/config/network/btc.ts
new file mode 100644
index 000000000..6fd28e76d
--- /dev/null
+++ b/src/ui/common/config/network/btc.ts
@@ -0,0 +1,84 @@
+import type { BTCConfig } from "@babylonlabs-io/wallet-connector";
+
+import bitcoinIcon from "@/ui/common/assets/bitcoin.png";
+import signetBitcoinIcon from "@/ui/common/assets/signet_bitcoin.svg";
+import { MEMPOOL_API } from "@/ui/common/constants";
+import { ClientError, ERROR_CODES } from "@/ui/common/errors";
+import { Network } from "@/ui/common/types/network";
+
+const defaultNetwork = "devnet";
+export const network = process.env.NEXT_PUBLIC_NETWORK ?? defaultNetwork;
+
+type Config = BTCConfig & { icon: string; name: string; displayUSD: boolean };
+
+const config: Record = {
+ mainnet: {
+ name: "Bitcoin",
+ coinName: "BTC",
+ coinSymbol: "BTC",
+ networkName: "BTC",
+ mempoolApiUrl: `${MEMPOOL_API}`,
+ network: Network.MAINNET,
+ icon: bitcoinIcon,
+ displayUSD: true,
+ },
+ canary: {
+ name: "Bitcoin",
+ coinName: "BTC",
+ coinSymbol: "BTC",
+ networkName: "BTC",
+ mempoolApiUrl: `${MEMPOOL_API}`,
+ network: Network.MAINNET,
+ icon: bitcoinIcon,
+ displayUSD: true,
+ },
+ testnet: {
+ // We do not use BTC Testnet
+ name: "Signet Bitcoin",
+ coinName: "Signet BTC",
+ coinSymbol: "sBTC",
+ networkName: "BTC signet",
+ mempoolApiUrl: `${MEMPOOL_API}/signet`,
+ network: Network.SIGNET,
+ icon: signetBitcoinIcon,
+ displayUSD: false,
+ },
+ devnet: {
+ name: "Signet Bitcoin",
+ coinName: "Signet BTC",
+ coinSymbol: "sBTC",
+ networkName: "BTC signet",
+ mempoolApiUrl: `${MEMPOOL_API}/signet`,
+ network: Network.SIGNET,
+ icon: signetBitcoinIcon,
+ displayUSD: false,
+ },
+};
+
+export function getNetworkConfigBTC(): Config {
+ return config[network] ?? config[defaultNetwork];
+}
+
+export function validateAddress(network: Network, address: string): void {
+ if (network === Network.MAINNET && !address.startsWith("bc1")) {
+ throw new ClientError(
+ ERROR_CODES.VALIDATION_ERROR,
+ `Incorrect address prefix for ${network}. Expected address to start with 'bc1'.`,
+ );
+ } else if (
+ [Network.SIGNET, Network.TESTNET].includes(network) &&
+ !address.startsWith("tb1")
+ ) {
+ throw new ClientError(
+ ERROR_CODES.VALIDATION_ERROR,
+ "Incorrect address prefix for Testnet / Signet. Expected address to start with 'tb1'.",
+ );
+ } else if (
+ ![Network.MAINNET, Network.SIGNET, Network.TESTNET].includes(network)
+ ) {
+ throw new ClientError(
+ ERROR_CODES.VALIDATION_ERROR,
+ `Unsupported network: ${network}. Please provide a valid network.`,
+ );
+ }
+}
diff --git a/src/ui/common/config/network/index.ts b/src/ui/common/config/network/index.ts
new file mode 100644
index 000000000..21bde5b2a
--- /dev/null
+++ b/src/ui/common/config/network/index.ts
@@ -0,0 +1,17 @@
+import { BBNConfig, BTCConfig } from "@babylonlabs-io/wallet-connector";
+
+import { getNetworkConfigBBN } from "./bbn";
+import { getNetworkConfigBTC } from "./btc";
+
+export interface NetworkConfig {
+ bbn: BBNConfig;
+ btc: BTCConfig;
+}
+
+// Get all network configs
+export const getNetworkConfig = (): NetworkConfig => {
+ return {
+ bbn: getNetworkConfigBBN(),
+ btc: getNetworkConfigBTC(),
+ };
+};
diff --git a/src/ui/common/config/screen-breakpoints.ts b/src/ui/common/config/screen-breakpoints.ts
new file mode 100644
index 000000000..884a2aba6
--- /dev/null
+++ b/src/ui/common/config/screen-breakpoints.ts
@@ -0,0 +1,7 @@
+export const screenBreakPoints = {
+ sm: "600px",
+ md: "767px",
+ lg: "1000px",
+ xl: "1130px",
+ "2xl": "1350px",
+} as const;
diff --git a/src/ui/common/constants.ts b/src/ui/common/constants.ts
new file mode 100644
index 000000000..1e35af290
--- /dev/null
+++ b/src/ui/common/constants.ts
@@ -0,0 +1,59 @@
+import babylon from "@/ui/common/assets/chains/babylon-genesis.png";
+import cosmos from "@/ui/common/assets/chains/cosmos.png";
+import ethereum from "@/ui/common/assets/chains/ethereum.png";
+import placeholder from "@/ui/common/assets/chains/placeholder.svg";
+import sui from "@/ui/common/assets/chains/sui.png";
+import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn";
+
+const { chainId: BABYLON_BSN_ID } = getNetworkConfigBBN();
+
+export const chainLogos: Record = {
+ babylon,
+ [BABYLON_BSN_ID]: babylon,
+ cosmos,
+ ethereum,
+ sui,
+ placeholder,
+};
+
+export const chainNames = {
+ babylon: "Babylon Genesis",
+ cosmos: "Cosmos",
+ ethereum: "Ethereum",
+ sui: "Sui",
+ unknown: "Unknown Chain",
+} as const;
+
+export const ONE_SECOND = 1000;
+export const ONE_MINUTE = 60 * ONE_SECOND;
+
+export const API_DEFAULT_RETRY_COUNT = 3;
+export const API_DEFAULT_RETRY_DELAY = 3.5; // seconds
+
+export const DELEGATION_ACTIONS = {
+ STAKE: "STAKE",
+ UNBOND: "UNBOND",
+ WITHDRAW_ON_EARLY_UNBONDING: "WITHDRAW_ON_EARLY_UNBONDING",
+ WITHDRAW_ON_TIMELOCK: "WITHDRAW_ON_TIMELOCK",
+ WITHDRAW_ON_TIMELOCK_SLASHING: "WITHDRAW_ON_TIMELOCK_SLASHING",
+ WITHDRAW_ON_EARLY_UNBONDING_SLASHING: "WITHDRAW_ON_EARLY_UNBONDING_SLASHING",
+} as const;
+
+export const DOCUMENTATION_LINKS = {
+ TECHNICAL_PRELIMINARIES:
+ "https://babylonlabs.io/blog/technical-preliminaries-of-bitcoin-staking",
+} as const;
+
+export const BBN_FEE_AMOUNT = process.env.NEXT_PUBLIC_BBN_FEE_AMOUNT;
+
+export const MEMPOOL_API =
+ process.env.NEXT_PUBLIC_MEMPOOL_API || "https://mempool.space";
+
+export const STAKING_DISABLED =
+ process.env.NEXT_PUBLIC_STAKING_DISABLED === "true";
+
+export const BABYLON_EXPLORER = process.env.NEXT_PUBLIC_BABYLON_EXPLORER ?? "";
+
+export const REPLAYS_ON_ERROR_RATE = parseFloat(
+ process.env.NEXT_PUBLIC_REPLAYS_RATE ?? "0.05",
+);
diff --git a/src/ui/common/constants/endpoints.ts b/src/ui/common/constants/endpoints.ts
new file mode 100644
index 000000000..2bd256cc6
--- /dev/null
+++ b/src/ui/common/constants/endpoints.ts
@@ -0,0 +1,21 @@
+export const API_ENDPOINTS = {
+ NETWORK_INFO: "/v2/network-info",
+
+ DELEGATION_V2: "/v2/delegation",
+ DELEGATIONS_V2: "/v2/delegations",
+ HEALTHCHECK: "/healthcheck",
+ FINALITY_PROVIDERS: "/v1/finality-providers",
+ STAKER_DELEGATIONS: "/v1/staker/delegations",
+
+ // Mempool API paths
+ MEMPOOL: {
+ TX: "tx",
+ ADDRESS: "address",
+ BLOCKS_TIP_HEIGHT: "blocks/tip/height",
+ FEES_RECOMMENDED: "v1/fees/recommended",
+ VALIDATE_ADDRESS: "v1/validate-address",
+ MERKLE_PROOF: "merkle-proof",
+ HEX: "hex",
+ TX_INFO: "tx/info",
+ },
+} as const;
diff --git a/src/ui/common/constants/errorMessages.ts b/src/ui/common/constants/errorMessages.ts
new file mode 100644
index 000000000..e8c7b43f2
--- /dev/null
+++ b/src/ui/common/constants/errorMessages.ts
@@ -0,0 +1,33 @@
+export const ClientErrorCategory = {
+ CLIENT_VALIDATION: "CLIENT_VALIDATION",
+ CLIENT_TRANSACTION: "CLIENT_TRANSACTION",
+ CLIENT_UNKNOWN: "CLIENT_UNKNOWN",
+ ORDINALS_ERROR: "ORDINALS_ERROR",
+ RPC_NODE: "RPC_NODE_ERROR",
+ COMPLIANCE: "COMPLIANCE_ERROR",
+} as const;
+
+export type ClientErrorCategory =
+ (typeof ClientErrorCategory)[keyof typeof ClientErrorCategory];
+
+// Client error messages mapping
+const CLIENT_ERROR_MESSAGES: Record = {
+ CLIENT_VALIDATION:
+ "The provided data is invalid. Please check your input and try again.",
+ CLIENT_TRANSACTION: "Failed to process transaction. Please try again.",
+ CLIENT_UNKNOWN: "An unexpected client error occurred.",
+ ORDINALS_ERROR: "Operation failed due to the presence of ordinals.",
+ RPC_NODE_ERROR:
+ "Unable to connect to the RPC node. Network fee data couldn't be loaded.",
+ COMPLIANCE_ERROR:
+ "This operation cannot be completed due to compliance restrictions.",
+} as const;
+
+export function getClientErrorMessage(
+ category: ClientErrorCategory,
+ details?: string,
+): string {
+ const baseMessage =
+ CLIENT_ERROR_MESSAGES[category] ?? CLIENT_ERROR_MESSAGES.CLIENT_UNKNOWN;
+ return details ? `${baseMessage} Details: ${details}` : baseMessage;
+}
diff --git a/src/ui/common/constants/index.ts b/src/ui/common/constants/index.ts
new file mode 100644
index 000000000..bedaa8853
--- /dev/null
+++ b/src/ui/common/constants/index.ts
@@ -0,0 +1 @@
+export * from "./errorMessages";
diff --git a/src/ui/common/context/Error/ErrorProvider.tsx b/src/ui/common/context/Error/ErrorProvider.tsx
new file mode 100644
index 000000000..15f27fd37
--- /dev/null
+++ b/src/ui/common/context/Error/ErrorProvider.tsx
@@ -0,0 +1,100 @@
+import {
+ type FC,
+ ReactNode,
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useState,
+} from "react";
+
+import { ErrorModal } from "@/ui/common/components/Modals/ErrorModal";
+import { Error as AppError, ErrorHandlerParam } from "@/ui/common/types/errors";
+
+const ErrorContext = createContext({
+ isOpen: false,
+ error: {
+ message: "",
+ },
+ modalOptions: {},
+ dismissError: () => {},
+ handleError: () => {},
+});
+
+interface ErrorProviderProps {
+ children: ReactNode;
+}
+
+type ErrorState = {
+ isOpen: boolean;
+ error: AppError;
+ modalOptions: {
+ retryAction?: () => void;
+ noCancel?: boolean;
+ };
+};
+
+export type ErrorContextType = ErrorState & {
+ dismissError: () => void;
+ handleError: (param: ErrorHandlerParam) => void;
+};
+
+export const ErrorProvider: FC = ({ children }) => {
+ const [state, setState] = useState({
+ isOpen: false,
+ error: { message: "" },
+ modalOptions: {},
+ });
+
+ const dismissError = useCallback(() => {
+ setState((prev) => ({ ...prev, isOpen: false }));
+ setTimeout(() => {
+ setState({ isOpen: false, error: { message: "" }, modalOptions: {} });
+ }, 300);
+ }, []);
+
+ const handleError = useCallback(
+ ({ error, displayOptions, metadata }: ErrorHandlerParam) => {
+ if (!error) return;
+
+ // Extract stack trace if available
+ const stackTrace = error instanceof Error ? error.stack || "" : "";
+
+ const shouldShowModal = displayOptions?.showModal ?? true;
+
+ const errorData = {
+ message: error.message,
+ trace: stackTrace,
+ ...metadata,
+ };
+
+ if (shouldShowModal) {
+ setState({
+ isOpen: true,
+ error: errorData,
+ modalOptions: {
+ ...displayOptions,
+ },
+ });
+ }
+ },
+ [],
+ );
+
+ const contextValue = useMemo(
+ () => ({
+ ...state,
+ dismissError,
+ handleError,
+ }),
+ [state, dismissError, handleError],
+ );
+
+ return (
+
+ {children}
+
+
+ );
+};
+export const useError = () => useContext(ErrorContext);
diff --git a/src/ui/common/context/Error/errors/clientError.ts b/src/ui/common/context/Error/errors/clientError.ts
new file mode 100644
index 000000000..c2e9fc7d3
--- /dev/null
+++ b/src/ui/common/context/Error/errors/clientError.ts
@@ -0,0 +1,59 @@
+import {
+ ClientErrorCategory,
+ getClientErrorMessage,
+} from "../../../constants/errorMessages";
+import { ErrorType } from "../../../types/errors";
+
+/**
+ * @deprecated This class is deprecated and will be removed in a future version.
+ */
+export class ClientError extends Error {
+ readonly name = "ClientError";
+ readonly displayMessage: string;
+ readonly category?: ClientErrorCategory;
+ readonly type?: ErrorType;
+ readonly metadata?: Record;
+
+ /**
+ * @param message Error message
+ * @param category Error category
+ * @param type Error type
+ * @param metadata Additional metadata about the error
+ * @deprecated This constructor is deprecated along with the class.
+ */
+ constructor(
+ {
+ message,
+ category = ClientErrorCategory.CLIENT_UNKNOWN,
+ type = ErrorType.UNKNOWN,
+ metadata,
+ }: {
+ message: string;
+ category?: ClientErrorCategory;
+ type?: ErrorType;
+ metadata?: Record;
+ },
+ options?: ErrorOptions,
+ ) {
+ super(message, options);
+ this.category = category;
+ this.type = type;
+ this.metadata = metadata || {};
+ this.displayMessage = getClientErrorMessage(category, message);
+ Object.setPrototypeOf(this, ClientError.prototype);
+ }
+
+ /**
+ * @deprecated This method is deprecated along with the class.
+ */
+ public getDisplayMessage(): string {
+ return this.displayMessage;
+ }
+
+ /**
+ * @deprecated This method is deprecated along with the class.
+ */
+ public getErrorCode(): ClientErrorCategory | undefined {
+ return this.category;
+ }
+}
diff --git a/src/ui/common/context/Error/errors/index.ts b/src/ui/common/context/Error/errors/index.ts
new file mode 100644
index 000000000..bfbf6ee87
--- /dev/null
+++ b/src/ui/common/context/Error/errors/index.ts
@@ -0,0 +1,2 @@
+export * from "./clientError";
+export * from "./serverError";
diff --git a/src/ui/common/context/Error/errors/serverError.ts b/src/ui/common/context/Error/errors/serverError.ts
new file mode 100644
index 000000000..79186e9c6
--- /dev/null
+++ b/src/ui/common/context/Error/errors/serverError.ts
@@ -0,0 +1,60 @@
+import { HttpStatusCode } from "@/ui/common/api/httpStatusCodes";
+import { ErrorType } from "@/ui/common/types/errors";
+
+/**
+ * @deprecated This class is deprecated and will be removed in a future version.
+ */
+export class ServerError extends Error {
+ readonly name = "ServerError";
+ readonly displayMessage: string;
+ readonly status: HttpStatusCode;
+ readonly endpoint?: string;
+ readonly type: ErrorType;
+ readonly request?: Record;
+ readonly response?: Record;
+ metadata?: Record;
+
+ /**
+ * @param message Error message
+ * @param status HTTP status code
+ * @param endpoint Endpoint, resource name, or server location
+ * @param request Request data that caused the error
+ * @param response Response data from the server
+ */
+ constructor({
+ message,
+ status = HttpStatusCode.InternalServerError,
+ endpoint,
+ request,
+ response,
+ }: {
+ message: string;
+ status?: HttpStatusCode;
+ endpoint?: string;
+ request?: Record;
+ response?: Record;
+ }) {
+ super(message);
+ this.status = status;
+ this.endpoint = endpoint;
+ this.displayMessage = message;
+ this.type = ErrorType.SERVER;
+ this.request = request;
+ this.response = response;
+ Object.setPrototypeOf(this, ServerError.prototype);
+ }
+
+ /**
+ * @deprecated This method is deprecated along with the class.
+ */
+ public getDisplayMessage(): string {
+ return this.displayMessage;
+ }
+
+ /**
+ * @deprecated This method is deprecated along with the class.
+ */
+ public getStatusCode(): number {
+ return this.status;
+ }
+}
diff --git a/src/ui/common/context/api/StakingStatsProvider.tsx b/src/ui/common/context/api/StakingStatsProvider.tsx
new file mode 100644
index 000000000..8454cd2e0
--- /dev/null
+++ b/src/ui/common/context/api/StakingStatsProvider.tsx
@@ -0,0 +1,78 @@
+import { useQuery } from "@tanstack/react-query";
+import {
+ type FC,
+ ReactNode,
+ createContext,
+ useContext,
+ useEffect,
+} from "react";
+
+import { getStats } from "@/ui/common/api/getStats";
+import {
+ API_DEFAULT_RETRY_COUNT,
+ API_DEFAULT_RETRY_DELAY,
+ ONE_SECOND,
+} from "@/ui/common/constants";
+import { useLogger } from "@/ui/common/hooks/useLogger";
+
+import { useError } from "../Error/ErrorProvider";
+export interface StakingStats {
+ activeTVLSat: number;
+ totalTVLSat: number;
+ activeDelegations: number;
+ totalDelegations: number;
+ totalStakers: number;
+ unconfirmedTVLSat: number;
+}
+
+interface StakingStatsProviderProps {
+ children: ReactNode;
+}
+
+interface StakingStatsContextType {
+ data: StakingStats | undefined;
+ isLoading: boolean;
+}
+
+const defaultContextValue: StakingStatsContextType = {
+ data: undefined,
+ isLoading: true,
+};
+
+const StakingStatsContext =
+ createContext(defaultContextValue);
+
+export const StakingStatsProvider: FC = ({
+ children,
+}) => {
+ const { isOpen, handleError } = useError();
+ const { data, isLoading, isError, error, refetch } = useQuery({
+ queryKey: ["API_STATS"],
+ queryFn: async () => getStats(),
+ refetchInterval: 60000, // 1 minute
+ retry: (failureCount) => !isOpen && failureCount < API_DEFAULT_RETRY_COUNT,
+ retryDelay: (count) => API_DEFAULT_RETRY_DELAY ** (count + 1) * ONE_SECOND,
+ });
+ const logger = useLogger();
+
+ useEffect(() => {
+ if (isError && error) {
+ logger.error(error);
+ handleError({
+ error,
+ displayOptions: {
+ retryAction: refetch,
+ },
+ });
+ }
+ }, [isError, error, handleError, refetch, logger]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+// Custom hook to use the staking stats
+export const useStakingStats = () => useContext(StakingStatsContext);
diff --git a/src/ui/common/context/rpc/BbnRpcProvider.tsx b/src/ui/common/context/rpc/BbnRpcProvider.tsx
new file mode 100644
index 000000000..55a484f71
--- /dev/null
+++ b/src/ui/common/context/rpc/BbnRpcProvider.tsx
@@ -0,0 +1,83 @@
+import { QueryClient } from "@cosmjs/stargate";
+import { Tendermint34Client } from "@cosmjs/tendermint-rpc";
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+} from "react";
+
+import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn";
+import { ClientError, ERROR_CODES } from "@/ui/common/errors";
+
+interface BbnRpcContextType {
+ queryClient: QueryClient | undefined;
+ isLoading: boolean;
+ error: Error | null;
+ reconnect: () => Promise;
+}
+
+const BbnRpcContext = createContext({
+ queryClient: undefined,
+ isLoading: true,
+ error: null,
+ reconnect: async () => {},
+});
+
+export function BbnRpcProvider({ children }: { children: React.ReactNode }) {
+ const [queryClient, setQueryClient] = useState();
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const { rpc } = getNetworkConfigBBN();
+
+ const connect = useCallback(async () => {
+ try {
+ const tmClient = await Tendermint34Client.connect(rpc);
+ const client = QueryClient.withExtensions(tmClient);
+ setQueryClient(client);
+ setIsLoading(false);
+ setError(null);
+ } catch (err) {
+ const clientError = new ClientError(
+ ERROR_CODES.EXTERNAL_SERVICE_UNAVAILABLE,
+ "Failed to connect RPC Provider",
+ { cause: err as Error },
+ );
+ setError(clientError);
+ setIsLoading(false);
+ }
+ }, [rpc]);
+
+ useEffect(() => {
+ let mounted = true;
+
+ const init = async () => {
+ if (mounted) {
+ await connect();
+ }
+ };
+
+ init();
+
+ return () => {
+ mounted = false;
+ };
+ }, [connect]);
+
+ const reconnect = useCallback(async () => {
+ setIsLoading(true);
+ setError(null);
+ await connect();
+ }, [connect]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export const useBbnRpc = () => useContext(BbnRpcContext);
diff --git a/src/ui/common/context/wallet/BTCWalletProvider.tsx b/src/ui/common/context/wallet/BTCWalletProvider.tsx
new file mode 100644
index 000000000..6c3933020
--- /dev/null
+++ b/src/ui/common/context/wallet/BTCWalletProvider.tsx
@@ -0,0 +1,315 @@
+import {
+ IBTCProvider,
+ InscriptionIdentifier,
+ Network,
+ SignPsbtOptions,
+ useChainConnector,
+ useWalletConnect,
+} from "@babylonlabs-io/wallet-connector";
+import type { networks } from "bitcoinjs-lib";
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+ type PropsWithChildren,
+} from "react";
+
+import { getNetworkConfigBTC } from "@/ui/common/config/network/btc";
+import { useError } from "@/ui/common/context/Error/ErrorProvider";
+import { ClientError, ERROR_CODES } from "@/ui/common/errors";
+import { useLogger } from "@/ui/common/hooks/useLogger";
+import { useSentryUser } from "@/ui/common/hooks/useSentryUser";
+import { Fees } from "@/ui/common/types/fee";
+import {
+ getAddressBalance,
+ getNetworkFees,
+ getTipHeight,
+ pushTx,
+} from "@/ui/common/utils/mempool_api";
+import {
+ getPublicKeyNoCoord,
+ isSupportedAddressType,
+ toNetwork,
+} from "@/ui/common/utils/wallet";
+
+const btcConfig = getNetworkConfigBTC();
+
+interface BTCWalletContextProps {
+ loading: boolean;
+ network?: networks.Network;
+ publicKeyNoCoord: string;
+ address: string;
+ connected: boolean;
+ disconnect: () => void;
+ open: () => void;
+ getAddress: () => Promise;
+ getPublicKeyHex: () => Promise;
+ signPsbt: (psbtHex: string, options?: SignPsbtOptions) => Promise;
+ signPsbts: (
+ psbtsHexes: string[],
+ options?: SignPsbtOptions[],
+ ) => Promise;
+ getNetwork: () => Promise;
+ signMessage: (
+ message: string,
+ type: "ecdsa" | "bip322-simple",
+ ) => Promise;
+ getBalance: (address: string) => Promise;
+ getNetworkFees: () => Promise;
+ pushTx: (txHex: string) => Promise;
+ getBTCTipHeight: () => Promise;
+ getInscriptions: () => Promise;
+}
+
+const BTCWalletContext = createContext({
+ loading: true,
+ network: undefined,
+ connected: false,
+ publicKeyNoCoord: "",
+ address: "",
+ disconnect: () => {},
+ open: () => {},
+ getAddress: async () => "",
+ getPublicKeyHex: async () => "",
+ signPsbt: async () => "",
+ signPsbts: async () => [],
+ getNetwork: async () => ({}) as Network,
+ signMessage: async () => "",
+ getBalance: async () => 0,
+ getNetworkFees: async () => ({}) as Fees,
+ pushTx: async () => "",
+ getBTCTipHeight: async () => 0,
+ getInscriptions: async () => [],
+});
+
+export const BTCWalletProvider = ({ children }: PropsWithChildren) => {
+ const [loading, setLoading] = useState(true);
+ const [btcWalletProvider, setBTCWalletProvider] = useState();
+ const [network, setNetwork] = useState();
+ const [publicKeyNoCoord, setPublicKeyNoCoord] = useState("");
+ const [address, setAddress] = useState("");
+
+ const { handleError } = useError();
+ const btcConnector = useChainConnector("BTC");
+ const { open = () => {}, connected } = useWalletConnect();
+ const logger = useLogger();
+ const { updateUser } = useSentryUser();
+
+ const btcDisconnect = useCallback(() => {
+ setBTCWalletProvider(undefined);
+ setNetwork(undefined);
+ setPublicKeyNoCoord("");
+ setAddress("");
+
+ updateUser({ btcAddress: null });
+ }, [updateUser]);
+
+ const connectBTC = useCallback(
+ async (walletProvider: IBTCProvider | null) => {
+ if (!walletProvider) return;
+ setLoading(true);
+
+ const supportedNetworkMessage =
+ "Only Native SegWit and Taproot addresses are supported. Please switch the address type in your wallet and try again.";
+
+ try {
+ const network = await walletProvider.getNetwork();
+ if (network !== btcConfig.network) {
+ const networkMismatchError = new ClientError(
+ ERROR_CODES.WALLET_CONFIGURATION_ERROR,
+ `BTC wallet network (${network}) does not match configured network (${btcConfig.network}).`,
+ );
+ throw networkMismatchError;
+ }
+
+ const address = await walletProvider.getAddress();
+ if (!address) {
+ const noAddressError = new ClientError(
+ ERROR_CODES.WALLET_CONFIGURATION_ERROR,
+ "BTC wallet provider returned an empty address.",
+ );
+ throw noAddressError;
+ }
+
+ const supported = isSupportedAddressType(address);
+ if (!supported) {
+ const clientError = new ClientError(
+ ERROR_CODES.WALLET_CONFIGURATION_ERROR,
+ supportedNetworkMessage,
+ );
+ logger.warn(clientError.message);
+ throw clientError;
+ }
+
+ const publicKeyHex = await walletProvider.getPublicKeyHex();
+ if (!publicKeyHex) {
+ const noPubKeyError = new ClientError(
+ ERROR_CODES.WALLET_CONFIGURATION_ERROR,
+ "BTC wallet provider returned an empty public key.",
+ );
+ throw noPubKeyError;
+ }
+
+ const publicKeyBuffer = getPublicKeyNoCoord(publicKeyHex);
+ const publicKeyNoCoordHex = publicKeyBuffer.toString("hex");
+
+ if (!publicKeyNoCoordHex) {
+ const emptyProcessedPubKeyError = new ClientError(
+ ERROR_CODES.WALLET_CONFIGURATION_ERROR,
+ "Processed BTC public key (no coordinates) is empty.",
+ );
+ throw emptyProcessedPubKeyError;
+ }
+
+ setBTCWalletProvider(walletProvider);
+ setNetwork(toNetwork(network));
+ setAddress(address);
+ setPublicKeyNoCoord(publicKeyNoCoordHex);
+ setLoading(false);
+
+ updateUser({ btcAddress: address });
+
+ logger.info("BTC wallet connected", {
+ network,
+ userPublicKey: publicKeyNoCoordHex,
+ btcAddress: address,
+ walletName: await walletProvider.getWalletProviderName(),
+ });
+ } catch (error: any) {
+ logger.error(error);
+ handleError({
+ error,
+ displayOptions: {
+ retryAction: () => connectBTC(walletProvider),
+ },
+ metadata: {
+ userPublicKey: publicKeyNoCoord,
+ btcAddress: address,
+ },
+ });
+ }
+ },
+ [handleError, publicKeyNoCoord, address, logger, updateUser],
+ );
+
+ useEffect(() => {
+ if (!btcConnector) return;
+ setLoading(false);
+ if (btcConnector.connectedWallet) {
+ connectBTC(btcConnector?.connectedWallet.provider);
+ }
+
+ const unsubscribe = btcConnector?.on("connect", (wallet) => {
+ if (wallet.provider) {
+ connectBTC(wallet.provider);
+ }
+ });
+
+ return unsubscribe;
+ }, [btcConnector, connectBTC]);
+
+ useEffect(() => {
+ if (!btcConnector) return;
+
+ const unsubscribe = btcConnector.on("disconnect", () => {
+ btcDisconnect();
+ });
+
+ return unsubscribe;
+ }, [btcConnector, btcDisconnect]);
+
+ // Listen for BTC account changes
+ useEffect(() => {
+ if (!btcWalletProvider) return;
+
+ const cb = async () => {
+ await btcWalletProvider.connectWallet();
+ connectBTC(btcWalletProvider);
+ };
+
+ btcWalletProvider.on("accountChanged", cb);
+
+ return () => void btcWalletProvider.off("accountChanged", cb);
+ }, [btcWalletProvider, connectBTC]);
+
+ useEffect(() => {
+ if (!btcConnector) return;
+
+ const installedWallets = btcConnector.wallets
+ .filter((wallet) => wallet.installed)
+ .reduce(
+ (acc, wallet) => ({ ...acc, [wallet.id]: wallet.name }),
+ {} as Record,
+ );
+
+ logger.info("Installed BTC wallets", {
+ installedWallets: Object.values(installedWallets).join(", "),
+ });
+ }, [btcConnector, logger]);
+
+ const btcWalletMethods = useMemo(
+ () => ({
+ getAddress: async () => btcWalletProvider?.getAddress() ?? "",
+ getPublicKeyHex: async () => btcWalletProvider?.getPublicKeyHex() ?? "",
+ signPsbt: async (psbtHex: string, options?: SignPsbtOptions) =>
+ btcWalletProvider?.signPsbt(psbtHex, options) ?? "",
+ signPsbts: async (psbtsHexes: string[], options?: SignPsbtOptions[]) =>
+ btcWalletProvider?.signPsbts(psbtsHexes, options) ?? [],
+ getNetwork: async () =>
+ btcWalletProvider?.getNetwork() ?? ({} as Network),
+ signMessage: async (message: string, type: "ecdsa" | "bip322-simple") =>
+ btcWalletProvider?.signMessage(message, type) ?? "",
+ getBalance: async (address: string) => getAddressBalance(address),
+ getNetworkFees: async () => getNetworkFees(),
+ pushTx: async (txHex: string) => pushTx(txHex),
+ getBTCTipHeight: async () => getTipHeight(),
+ getInscriptions: async (): Promise => {
+ if (!btcWalletProvider?.getInscriptions) {
+ const clientError = new ClientError(
+ ERROR_CODES.WALLET_CONFIGURATION_ERROR,
+ "`getInscriptions` method is not provided by the wallet",
+ );
+ logger.warn(clientError.message);
+ throw clientError;
+ }
+
+ return btcWalletProvider.getInscriptions();
+ },
+ }),
+ [btcWalletProvider, logger],
+ );
+
+ const btcContextValue = useMemo(
+ () => ({
+ loading,
+ network,
+ publicKeyNoCoord,
+ address,
+ connected,
+ open,
+ disconnect: btcDisconnect,
+ ...btcWalletMethods,
+ }),
+ [
+ loading,
+ connected,
+ network,
+ publicKeyNoCoord,
+ address,
+ open,
+ btcDisconnect,
+ btcWalletMethods,
+ ],
+ );
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useBTCWallet = () => useContext(BTCWalletContext);
diff --git a/src/ui/common/context/wallet/CosmosWalletProvider.tsx b/src/ui/common/context/wallet/CosmosWalletProvider.tsx
new file mode 100644
index 000000000..298e71ae7
--- /dev/null
+++ b/src/ui/common/context/wallet/CosmosWalletProvider.tsx
@@ -0,0 +1,234 @@
+import {
+ IBBNProvider,
+ useChainConnector,
+ useWalletConnect,
+} from "@babylonlabs-io/wallet-connector";
+import { OfflineSigner } from "@cosmjs/proto-signing";
+import { SigningStargateClient } from "@cosmjs/stargate";
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+ type PropsWithChildren,
+} from "react";
+
+import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn";
+import { useError } from "@/ui/common/context/Error/ErrorProvider";
+import { ClientError, ERROR_CODES } from "@/ui/common/errors";
+import { useLogger } from "@/ui/common/hooks/useLogger";
+import { useSentryUser } from "@/ui/common/hooks/useSentryUser";
+import { createBbnAminoTypes } from "@/ui/common/utils/wallet/amino";
+import { createBbnRegistry } from "@/ui/common/utils/wallet/bbnRegistry";
+
+const { chainId, rpc } = getNetworkConfigBBN();
+
+interface CosmosWalletContextProps {
+ loading: boolean;
+ bech32Address: string;
+ connected: boolean;
+ disconnect: () => void;
+ open: () => void;
+ signingStargateClient: SigningStargateClient | undefined;
+ walletName: string;
+}
+
+const CosmosWalletContext = createContext({
+ loading: true,
+ bech32Address: "",
+ connected: false,
+ disconnect: () => {},
+ open: () => {},
+ signingStargateClient: undefined,
+ walletName: "",
+});
+
+export const CosmosWalletProvider = ({ children }: PropsWithChildren) => {
+ const [loading, setLoading] = useState(true);
+ const [BBNWalletProvider, setBBNWalletProvider] = useState<
+ IBBNProvider | undefined
+ >();
+ const [cosmosBech32Address, setCosmosBech32Address] = useState("");
+ const [signingStargateClient, setSigningStargateClient] = useState<
+ SigningStargateClient | undefined
+ >();
+ const [walletName, setWalletName] = useState("");
+
+ const { handleError } = useError();
+ const logger = useLogger();
+ const { open = () => {} } = useWalletConnect();
+ const bbnConnector = useChainConnector("BBN");
+ const { updateUser } = useSentryUser();
+
+ const cosmosDisconnect = useCallback(() => {
+ setBBNWalletProvider(undefined);
+ setCosmosBech32Address("");
+ setSigningStargateClient(undefined);
+
+ updateUser({ babylonAddress: null });
+ }, [updateUser]);
+
+ const connectCosmos = useCallback(
+ async (provider: IBBNProvider | null) => {
+ if (!provider) return;
+ setLoading(true);
+
+ try {
+ const offlineSigner = provider.getOfflineSignerAuto
+ ? // use `auto` (if it is provided) for direct and amino support
+ await provider.getOfflineSignerAuto()
+ : // otherwise, use `getOfflineSigner` for direct signer
+ await provider.getOfflineSigner();
+
+ // @ts-expect-error - chainId is missing in keplr types
+ if (offlineSigner.chainId && offlineSigner.chainId !== chainId) {
+ const networkMismatchError = new ClientError(
+ ERROR_CODES.WALLET_CONFIGURATION_ERROR,
+ `Cosmos wallet chain ID does not match configured chain ID (${chainId}).`,
+ );
+ throw networkMismatchError;
+ }
+
+ const bech32Address = await provider.getAddress();
+ if (!bech32Address) {
+ const noAddressError = new ClientError(
+ ERROR_CODES.WALLET_CONFIGURATION_ERROR,
+ "Cosmos wallet provider returned an empty address.",
+ );
+ throw noAddressError;
+ }
+
+ const walletNameStr = await provider.getWalletProviderName();
+ if (!walletNameStr) {
+ const noWalletNameError = new ClientError(
+ ERROR_CODES.WALLET_CONFIGURATION_ERROR,
+ "Cosmos wallet provider returned an empty wallet name.",
+ );
+ throw noWalletNameError;
+ }
+
+ const client = await SigningStargateClient.connectWithSigner(
+ rpc,
+ offlineSigner as OfflineSigner,
+ {
+ registry: createBbnRegistry(),
+ aminoTypes: createBbnAminoTypes(),
+ },
+ );
+ setSigningStargateClient(client);
+ setBBNWalletProvider(provider);
+ setCosmosBech32Address(bech32Address);
+ setLoading(false);
+ setWalletName(walletNameStr || "Unknown Wallet");
+
+ updateUser({ babylonAddress: bech32Address });
+
+ logger.info("Babylon wallet connected", {
+ babylonAddress: bech32Address,
+ walletName: walletNameStr || "Unknown Wallet",
+ chainId,
+ });
+ } catch (error: any) {
+ logger.error(error);
+ handleError({
+ error,
+ displayOptions: {
+ retryAction: () => connectCosmos(provider),
+ },
+ metadata: {
+ babylonAddress: cosmosBech32Address,
+ walletName,
+ },
+ });
+ }
+ },
+ [handleError, cosmosBech32Address, walletName, logger, updateUser],
+ );
+
+ // Listen for Babylon account changes
+ useEffect(() => {
+ if (!BBNWalletProvider || !BBNWalletProvider.off || !BBNWalletProvider.on)
+ return;
+
+ const cb = async () => {
+ await BBNWalletProvider.connectWallet();
+ connectCosmos(BBNWalletProvider);
+ };
+
+ BBNWalletProvider.on("accountChanged", cb);
+ return () => {
+ BBNWalletProvider.off("accountChanged", cb);
+ };
+ }, [BBNWalletProvider, connectCosmos]);
+
+ const cosmosContextValue = useMemo(
+ () => ({
+ loading,
+ bech32Address: cosmosBech32Address,
+ connected: Boolean(BBNWalletProvider) && Boolean(signingStargateClient),
+ disconnect: cosmosDisconnect,
+ open,
+ signingStargateClient,
+ walletName,
+ }),
+ [
+ loading,
+ cosmosBech32Address,
+ BBNWalletProvider,
+ cosmosDisconnect,
+ open,
+ signingStargateClient,
+ walletName,
+ ],
+ );
+
+ useEffect(() => {
+ if (!bbnConnector) return;
+
+ setLoading(false);
+
+ if (bbnConnector.connectedWallet) {
+ connectCosmos(bbnConnector?.connectedWallet.provider);
+ }
+
+ const unsubscribe = bbnConnector?.on("connect", (wallet) => {
+ connectCosmos(wallet.provider);
+ });
+
+ return unsubscribe;
+ }, [bbnConnector, connectCosmos]);
+
+ useEffect(() => {
+ if (!bbnConnector) return;
+
+ const unsubscribe = bbnConnector.on("disconnect", () => {
+ cosmosDisconnect();
+ });
+
+ return unsubscribe;
+ }, [bbnConnector, cosmosDisconnect]);
+
+ useEffect(() => {
+ if (!bbnConnector) return;
+
+ const installedWallets = bbnConnector.wallets
+ .filter((wallet) => wallet.installed)
+ .reduce(
+ (acc, wallet) => ({ ...acc, [wallet.id]: wallet.name }),
+ {} as Record,
+ );
+
+ logger.info("Installed Babylon wallets", {
+ installedWallets: Object.values(installedWallets).join(", ") || "",
+ });
+ }, [bbnConnector, logger]);
+
+ return (
+
+ {children}
+
+ );
+};
+export const useCosmosWallet = () => useContext(CosmosWalletContext);
diff --git a/src/ui/common/context/wallet/WalletConnectionProvider.tsx b/src/ui/common/context/wallet/WalletConnectionProvider.tsx
new file mode 100644
index 000000000..f4e5ba065
--- /dev/null
+++ b/src/ui/common/context/wallet/WalletConnectionProvider.tsx
@@ -0,0 +1,90 @@
+import {
+ ChainConfigArr,
+ ExternalWallets,
+ WalletProvider,
+} from "@babylonlabs-io/wallet-connector";
+import { useTheme } from "next-themes";
+import { useCallback, type PropsWithChildren } from "react";
+
+import { logTermsAcceptance } from "@/ui/common/api/logTermAcceptance";
+import { verifyBTCAddress } from "@/ui/common/api/verifyBTCAddress";
+import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn";
+import { getNetworkConfigBTC } from "@/ui/common/config/network/btc";
+import { ClientError, ERROR_CODES } from "@/ui/common/errors";
+import { useLogger } from "@/ui/common/hooks/useLogger";
+import FeatureFlagService from "@/ui/common/utils/FeatureFlagService";
+
+import { useError } from "../Error/ErrorProvider";
+
+const context = typeof window !== "undefined" ? window : {};
+
+const lifecycleHooks = {
+ acceptTermsOfService: logTermsAcceptance,
+ verifyBTCAddress: verifyBTCAddress,
+};
+
+const config: ChainConfigArr = [
+ {
+ chain: "BTC",
+ connectors: [
+ {
+ id: "tomo-btc-connector",
+ widget: ({ onError }) => (
+
+ ),
+ },
+ ],
+ config: getNetworkConfigBTC(),
+ },
+ {
+ chain: "BBN",
+ connectors: [
+ {
+ id: "tomo-bbn-connector",
+ widget: ({ onError }) => (
+
+ ),
+ },
+ ],
+ config: getNetworkConfigBBN(),
+ },
+];
+
+export const WalletConnectionProvider = ({ children }: PropsWithChildren) => {
+ const { handleError } = useError();
+ const { theme } = useTheme();
+ const logger = useLogger();
+
+ const onError = useCallback(
+ (error: Error) => {
+ if (error?.message?.includes("rejected")) {
+ return;
+ }
+
+ const clientError = new ClientError(
+ ERROR_CODES.WALLET_ACTION_FAILED,
+ "Error connecting to wallet",
+ { cause: error as Error },
+ );
+ logger.error(clientError);
+ handleError({
+ error: clientError,
+ });
+ },
+ [handleError, logger],
+ );
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/ui/common/errors/codes.ts b/src/ui/common/errors/codes.ts
new file mode 100644
index 000000000..3f3e79a7a
--- /dev/null
+++ b/src/ui/common/errors/codes.ts
@@ -0,0 +1,29 @@
+export const ERROR_CODES = {
+ // --- Connection & Availability Errors ---
+ CONNECTION_ERROR: "CONNECTION_ERROR", // General network problems
+ EXTERNAL_SERVICE_UNAVAILABLE: "EXTERNAL_SERVICE_UNAVAILABLE", // External service (RPC, Ordinals) is unavailable
+ GEO_BLOCK: "GEO_BLOCK", // Geolocation restriction
+
+ // --- Configuration & Initialization Errors ---
+ CONFIGURATION_ERROR: "CONFIGURATION_ERROR", // Problems with genesis params, BBN params, network fees config
+ INITIALIZATION_ERROR: "INITIALIZATION_ERROR", // Staking manager, staker info, params not loaded/initialized
+
+ // --- Data & Validation Errors ---
+ VALIDATION_ERROR: "VALIDATION_ERROR", // Generic invalid data (address, block height, amounts, timelocks, proofs, delegation state)
+ MISSING_DATA_ERROR: "MISSING_DATA_ERROR", // Required data not provided (e.g., PoP, signatures)
+
+ // --- Transaction Lifecycle Errors ---
+ TRANSACTION_PREPARATION_ERROR: "TRANSACTION_PREPARATION_ERROR", // Errors building the transaction (e.g., fee estimation issues)
+ TRANSACTION_SUBMISSION_ERROR: "TRANSACTION_SUBMISSION_ERROR", // Errors sending/broadcasting the transaction via wallet or directly
+ TRANSACTION_VERIFICATION_ERROR: "TRANSACTION_VERIFICATION_ERROR", // Errors confirming transaction status (not found, hash mismatch, not eligible)
+
+ // --- Staking Logic Errors ---
+ DELEGATION_LOGIC_ERROR: "DELEGATION_LOGIC_ERROR", // Errors in delegation selection, missing covenant signatures, finality provider issues
+
+ // --- Wallet Interaction Errors ---
+ WALLET_NOT_CONNECTED: "WALLET_NOT_CONNECTED", // Wallet connection is required but not established
+ WALLET_CONFIGURATION_ERROR: "WALLET_CONFIGURATION_ERROR", // Wallet is connected to an unsupported network or using an unsupported address type
+ WALLET_AUTHENTICATION_ERROR: "WALLET_AUTHENTICATION_ERROR", // Authentication or permission issue with the wallet
+ WALLET_ACTION_REJECTED: "WALLET_ACTION_REJECTED", // User explicitly rejected the action (e.g., signing, sending) in the wallet interface
+ WALLET_ACTION_FAILED: "WALLET_ACTION_FAILED", // An unspecified error occurred within the wallet during an action (sign, send, etc.)
+};
diff --git a/src/ui/common/errors/index.ts b/src/ui/common/errors/index.ts
new file mode 100644
index 000000000..d01caa31f
--- /dev/null
+++ b/src/ui/common/errors/index.ts
@@ -0,0 +1,11 @@
+export class ClientError extends Error {
+ constructor(
+ public readonly errorCode: string,
+ message: string,
+ options?: ErrorOptions,
+ ) {
+ super(message, options);
+ }
+}
+
+export * from "./codes";
diff --git a/src/ui/common/global-error.tsx b/src/ui/common/global-error.tsx
new file mode 100644
index 000000000..ae5f76a98
--- /dev/null
+++ b/src/ui/common/global-error.tsx
@@ -0,0 +1,19 @@
+import { useEffect } from "react";
+
+import GenericError from "@/ui/common/components/Error/GenericError";
+import { useError } from "@/ui/common/context/Error/ErrorProvider";
+import { useLogger } from "@/ui/common/hooks/useLogger";
+
+export default function GlobalError({ error }: { error: Error }) {
+ const { handleError } = useError();
+ const logger = useLogger();
+
+ useEffect(() => {
+ logger.error(error);
+ handleError({
+ error,
+ });
+ }, [error, handleError, logger]);
+
+ return ;
+}
diff --git a/src/ui/common/hooks/client/api/useBTCBalance.ts b/src/ui/common/hooks/client/api/useBTCBalance.ts
new file mode 100644
index 000000000..2fb157da6
--- /dev/null
+++ b/src/ui/common/hooks/client/api/useBTCBalance.ts
@@ -0,0 +1,19 @@
+import { useBTCWallet } from "@/ui/common/context/wallet/BTCWalletProvider";
+
+import { useClientQuery } from "../useClient";
+
+export const BTC_BALANCE_KEY = "BTC_BALANCE";
+
+export function useBTCBalance() {
+ const {
+ getBalance: getBTCBalance,
+ connected: btcConnected,
+ address,
+ } = useBTCWallet();
+
+ return useClientQuery({
+ queryKey: [BTC_BALANCE_KEY, address],
+ queryFn: () => getBTCBalance(address),
+ enabled: btcConnected,
+ });
+}
diff --git a/src/ui/common/hooks/client/api/useBsn.ts b/src/ui/common/hooks/client/api/useBsn.ts
new file mode 100644
index 000000000..78f0a8384
--- /dev/null
+++ b/src/ui/common/hooks/client/api/useBsn.ts
@@ -0,0 +1,15 @@
+import { getBSNs } from "@/ui/common/api/getBsn";
+import { ONE_MINUTE } from "@/ui/common/constants";
+import { useClientQuery } from "@/ui/common/hooks/client/useClient";
+import { Bsn } from "@/ui/common/types/bsn";
+
+export const BSN_KEY = "BSN";
+
+export function useBsn({ enabled = true }: { enabled?: boolean } = {}) {
+ return useClientQuery({
+ queryKey: [BSN_KEY],
+ queryFn: getBSNs,
+ enabled,
+ refetchInterval: ONE_MINUTE * 5,
+ });
+}
diff --git a/src/ui/common/hooks/client/api/useDelegations.ts b/src/ui/common/hooks/client/api/useDelegations.ts
new file mode 100644
index 000000000..fadafff78
--- /dev/null
+++ b/src/ui/common/hooks/client/api/useDelegations.ts
@@ -0,0 +1,106 @@
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { useEffect } from "react";
+
+import {
+ getDelegations,
+ type PaginatedDelegations,
+} from "@/ui/common/api/getDelegations";
+import {
+ API_DEFAULT_RETRY_COUNT,
+ API_DEFAULT_RETRY_DELAY,
+ ONE_MINUTE,
+ ONE_SECOND,
+} from "@/ui/common/constants";
+import { useError } from "@/ui/common/context/Error/ErrorProvider";
+import { useBTCWallet } from "@/ui/common/context/wallet/BTCWalletProvider";
+import { ClientError } from "@/ui/common/errors";
+import { ERROR_CODES } from "@/ui/common/errors/codes";
+import { useLogger } from "@/ui/common/hooks/useLogger";
+
+import { useHealthCheck } from "../../useHealthCheck";
+
+export const DELEGATIONS_KEY = "DELEGATIONS";
+
+export function useDelegations({ enabled = true }: { enabled?: boolean } = {}) {
+ const { isGeoBlocked, isLoading } = useHealthCheck();
+ const { publicKeyNoCoord } = useBTCWallet();
+ const { handleError, isOpen } = useError();
+ const logger = useLogger();
+
+ 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: (query) => {
+ const totalPages = query.state.data?.pages.length ?? 0;
+ if (
+ totalPages > 0 &&
+ query.state.data?.pages[totalPages - 1].delegations.length === 0
+ ) {
+ // Stop refetching is there is no data available
+ return false;
+ }
+ return ONE_MINUTE;
+ },
+ enabled:
+ Boolean(publicKeyNoCoord) && enabled && !isGeoBlocked && !isLoading,
+ 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) => !isOpen && failureCount < API_DEFAULT_RETRY_COUNT,
+ retryDelay: (count) => API_DEFAULT_RETRY_DELAY ** (count + 1) * ONE_SECOND,
+ });
+
+ useEffect(() => {
+ if (query.isError) {
+ const clientError = new ClientError(
+ ERROR_CODES.EXTERNAL_SERVICE_UNAVAILABLE,
+ "Error fetching delegations",
+ {
+ cause: query.error as Error,
+ },
+ );
+ logger.error(clientError, {
+ tags: {
+ isGeoblocked: isGeoBlocked ? "true" : "false",
+ },
+ data: {
+ userPublicKey: publicKeyNoCoord,
+ },
+ });
+ handleError({
+ error: query.error,
+ displayOptions: {
+ retryAction: query.refetch,
+ },
+ metadata: {
+ userPublicKey: publicKeyNoCoord,
+ },
+ });
+ }
+ }, [
+ query.isError,
+ query.error,
+ query.refetch,
+ handleError,
+ publicKeyNoCoord,
+ logger,
+ isGeoBlocked,
+ ]);
+
+ return query;
+}
diff --git a/src/ui/common/hooks/client/api/useDelegationsV2.ts b/src/ui/common/hooks/client/api/useDelegationsV2.ts
new file mode 100644
index 000000000..c56a499bd
--- /dev/null
+++ b/src/ui/common/hooks/client/api/useDelegationsV2.ts
@@ -0,0 +1,110 @@
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { useEffect } from "react";
+
+import {
+ getDelegationsV2,
+ type PaginatedDelegations,
+} from "@/ui/common/api/getDelegationsV2";
+import {
+ API_DEFAULT_RETRY_COUNT,
+ API_DEFAULT_RETRY_DELAY,
+ ONE_MINUTE,
+ ONE_SECOND,
+} from "@/ui/common/constants";
+import { useError } from "@/ui/common/context/Error/ErrorProvider";
+import { useBTCWallet } from "@/ui/common/context/wallet/BTCWalletProvider";
+import { ClientError } from "@/ui/common/errors";
+import { ERROR_CODES } from "@/ui/common/errors/codes";
+import { useLogger } from "@/ui/common/hooks/useLogger";
+
+import { useHealthCheck } from "../../useHealthCheck";
+
+export const DELEGATIONS_V2_KEY = "DELEGATIONS_V2";
+
+export function useDelegationsV2(
+ babylonAddress?: string,
+ {
+ enabled = true,
+ }: {
+ enabled?: boolean;
+ } = {},
+) {
+ const { isGeoBlocked, isLoading } = useHealthCheck();
+ const { publicKeyNoCoord } = useBTCWallet();
+ const { isOpen, handleError } = useError();
+ const logger = useLogger();
+
+ const query = useInfiniteQuery({
+ queryKey: [DELEGATIONS_V2_KEY, publicKeyNoCoord, babylonAddress],
+ queryFn: ({ pageParam = "" }) =>
+ getDelegationsV2({
+ stakerPublicKey: publicKeyNoCoord,
+ pageKey: pageParam,
+ babylonAddress,
+ }),
+ getNextPageParam: (lastPage) =>
+ lastPage?.pagination?.next_key !== ""
+ ? lastPage?.pagination?.next_key
+ : null,
+ initialPageParam: "",
+ refetchInterval: ONE_MINUTE,
+ enabled:
+ Boolean(publicKeyNoCoord) && enabled && !isGeoBlocked && !isLoading,
+ 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) => !isOpen && failureCount < API_DEFAULT_RETRY_COUNT,
+ retryDelay: (count) => API_DEFAULT_RETRY_DELAY ** (count + 1) * ONE_SECOND,
+ });
+
+ useEffect(() => {
+ if (query.isError) {
+ const clientError = new ClientError(
+ ERROR_CODES.EXTERNAL_SERVICE_UNAVAILABLE,
+ "Error fetching delegations",
+ {
+ cause: query.error as Error,
+ },
+ );
+ logger.error(clientError, {
+ tags: {
+ isGeoblocked: isGeoBlocked ? "true" : "false",
+ },
+ data: {
+ userPublicKey: publicKeyNoCoord,
+ babylonAddress: babylonAddress || "",
+ },
+ });
+ handleError({
+ error: query.error,
+ displayOptions: {
+ retryAction: query.refetch,
+ },
+ metadata: {
+ userPublicKey: publicKeyNoCoord,
+ babylonAddress,
+ },
+ });
+ }
+ }, [
+ query.isError,
+ query.error,
+ query.refetch,
+ handleError,
+ publicKeyNoCoord,
+ babylonAddress,
+ logger,
+ isGeoBlocked,
+ ]);
+
+ return query;
+}
diff --git a/src/ui/common/hooks/client/api/useFinalityProviders.ts b/src/ui/common/hooks/client/api/useFinalityProviders.ts
new file mode 100644
index 000000000..3213f06fc
--- /dev/null
+++ b/src/ui/common/hooks/client/api/useFinalityProviders.ts
@@ -0,0 +1,69 @@
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { useEffect } from "react";
+
+import {
+ getFinalityProviders,
+ type PaginatedFinalityProviders,
+} from "@/ui/common/api/getFinalityProviders";
+import {
+ API_DEFAULT_RETRY_COUNT,
+ API_DEFAULT_RETRY_DELAY,
+ ONE_MINUTE,
+ ONE_SECOND,
+} from "@/ui/common/constants";
+import { useError } from "@/ui/common/context/Error/ErrorProvider";
+import { useLogger } from "@/ui/common/hooks/useLogger";
+
+const FINALITY_PROVIDERS_KEY = "GET_FINALITY_PROVIDERS_V1_KEY";
+
+interface Params {
+ pk?: string;
+ name?: string;
+ sortBy?: string;
+ order?: "asc" | "desc";
+}
+
+export function useFinalityProviders({ pk, sortBy, order, name }: Params = {}) {
+ const { isOpen, handleError } = useError();
+ const logger = useLogger();
+
+ const query = useInfiniteQuery({
+ queryKey: [FINALITY_PROVIDERS_KEY, pk, sortBy, order, name],
+ queryFn: ({ pageParam = "" }) =>
+ getFinalityProviders({ key: pageParam, pk, sortBy, order, name }),
+ getNextPageParam: (lastPage) =>
+ lastPage?.pagination?.next_key !== ""
+ ? lastPage?.pagination?.next_key
+ : null,
+ initialPageParam: "",
+ refetchInterval: ONE_MINUTE,
+ placeholderData: (prev) => prev,
+ select: (data) => {
+ const flattenedData = data.pages.reduce(
+ (acc, page) => {
+ acc.finalityProviders.push(...page.finalityProviders);
+ acc.pagination = page.pagination;
+ return acc;
+ },
+ { finalityProviders: [], pagination: { next_key: "" } },
+ );
+ return flattenedData;
+ },
+ retry: (failureCount) => !isOpen && failureCount < API_DEFAULT_RETRY_COUNT,
+ retryDelay: (count) => API_DEFAULT_RETRY_DELAY ** (count + 1) * ONE_SECOND,
+ });
+
+ useEffect(() => {
+ if (query.isError) {
+ logger.error(query.error);
+ handleError({
+ error: query.error,
+ displayOptions: {
+ retryAction: query.refetch,
+ },
+ });
+ }
+ }, [query.isError, query.error, query.refetch, handleError, logger]);
+
+ return query;
+}
diff --git a/src/ui/common/hooks/client/api/useFinalityProvidersV2.ts b/src/ui/common/hooks/client/api/useFinalityProvidersV2.ts
new file mode 100644
index 000000000..65cfe1c18
--- /dev/null
+++ b/src/ui/common/hooks/client/api/useFinalityProvidersV2.ts
@@ -0,0 +1,86 @@
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { useEffect } from "react";
+
+import {
+ getFinalityProvidersV2,
+ type PaginatedFinalityProviders,
+} from "@/ui/common/api/getFinalityProvidersV2";
+import {
+ API_DEFAULT_RETRY_COUNT,
+ API_DEFAULT_RETRY_DELAY,
+ ONE_MINUTE,
+ ONE_SECOND,
+} from "@/ui/common/constants";
+import { useError } from "@/ui/common/context/Error/ErrorProvider";
+import { useLogger } from "@/ui/common/hooks/useLogger";
+
+const FINALITY_PROVIDERS_KEY = "GET_FINALITY_PROVIDERS_V2_KEY";
+
+interface Params {
+ pk?: string;
+ name?: string;
+ sortBy?: string;
+ order?: "asc" | "desc";
+ bsnId?: string;
+ enabled?: boolean;
+}
+
+export function useFinalityProvidersV2({
+ pk,
+ sortBy,
+ order,
+ name,
+ bsnId,
+ enabled,
+}: Params = {}) {
+ const { isOpen, handleError } = useError();
+ const logger = useLogger();
+
+ const query = useInfiniteQuery({
+ queryKey: [FINALITY_PROVIDERS_KEY, pk, sortBy, order, name, bsnId],
+ queryFn: ({ pageParam = "" }) =>
+ getFinalityProvidersV2({
+ key: pageParam,
+ pk,
+ sortBy,
+ order,
+ name,
+ bsnId,
+ }),
+ getNextPageParam: (lastPage) =>
+ lastPage?.pagination?.next_key !== ""
+ ? lastPage?.pagination?.next_key
+ : null,
+ initialPageParam: "",
+ refetchInterval: ONE_MINUTE,
+ placeholderData: (prev) => prev,
+ select: (data) => {
+ const flattenedData = data.pages.reduce(
+ (acc, page) => {
+ acc.finalityProviders.push(...page.finalityProviders);
+ acc.pagination = page.pagination;
+ return acc;
+ },
+ { finalityProviders: [], pagination: { next_key: "" } },
+ );
+ return flattenedData;
+ },
+ retry: (failureCount) => !isOpen && failureCount < API_DEFAULT_RETRY_COUNT,
+ retryDelay: (count) => API_DEFAULT_RETRY_DELAY ** (count + 1) * ONE_SECOND,
+ enabled,
+ });
+
+ useEffect(() => {
+ if (query.isError) {
+ logger.error(query.error);
+ handleError({
+ error: query.error,
+ displayOptions: {
+ retryAction: query.refetch,
+ },
+ });
+ }
+ }, [query.isError, query.error, query.refetch, handleError, logger]);
+
+ return query;
+}
diff --git a/src/ui/common/hooks/client/api/useNetworkFees.ts b/src/ui/common/hooks/client/api/useNetworkFees.ts
new file mode 100644
index 000000000..95b3d0c03
--- /dev/null
+++ b/src/ui/common/hooks/client/api/useNetworkFees.ts
@@ -0,0 +1,14 @@
+import { useClientQuery } from "@/ui/common/hooks/client/useClient";
+import { getNetworkFees } from "@/ui/common/utils/mempool_api";
+
+export const NETWORK_FEES_KEY = "NETWORK_FEES";
+
+export function useNetworkFees({ enabled = true }: { enabled?: boolean } = {}) {
+ const query = useClientQuery({
+ queryKey: [NETWORK_FEES_KEY],
+ queryFn: getNetworkFees,
+ enabled,
+ });
+
+ return query;
+}
diff --git a/src/ui/common/hooks/client/api/useNetworkInfo.ts b/src/ui/common/hooks/client/api/useNetworkInfo.ts
new file mode 100644
index 000000000..9e1ff68bf
--- /dev/null
+++ b/src/ui/common/hooks/client/api/useNetworkInfo.ts
@@ -0,0 +1,13 @@
+import { getNetworkInfo } from "@/ui/common/api/getNetworkInfo";
+import { useClientQuery } from "@/ui/common/hooks/client/useClient";
+import { NetworkInfo } from "@/ui/common/types/networkInfo";
+
+export const NETWORK_INFO_KEY = "NETWORK_INFO";
+
+export function useNetworkInfo({ enabled = true }: { enabled?: boolean } = {}) {
+ return useClientQuery({
+ queryKey: [NETWORK_INFO_KEY],
+ queryFn: getNetworkInfo,
+ enabled,
+ });
+}
diff --git a/src/ui/common/hooks/client/api/useOrdinals.ts b/src/ui/common/hooks/client/api/useOrdinals.ts
new file mode 100644
index 000000000..8aecaca8d
--- /dev/null
+++ b/src/ui/common/hooks/client/api/useOrdinals.ts
@@ -0,0 +1,77 @@
+import { UTXO } from "@babylonlabs-io/btc-staking-ts";
+import { InscriptionIdentifier } from "@babylonlabs-io/wallet-connector";
+
+import { postVerifyUtxoOrdinals } from "@/ui/common/api/postFilterOrdinals";
+import { ONE_MINUTE } from "@/ui/common/constants";
+import { useError } from "@/ui/common/context/Error/ErrorProvider";
+import { useBTCWallet } from "@/ui/common/context/wallet/BTCWalletProvider";
+import { ClientError, ERROR_CODES } from "@/ui/common/errors";
+import { useClientQuery } from "@/ui/common/hooks/client/useClient";
+import { useLogger } from "@/ui/common/hooks/useLogger";
+import { wait } from "@/ui/common/utils";
+import { filterDust } from "@/ui/common/utils/wallet";
+
+export const ORDINAL_KEY = "ORDINALS";
+export const WALLET_FETCH_INSRIPTIONS_TIMEOUT = 3_000;
+
+export function useOrdinals(
+ utxos: UTXO[],
+ { enabled = true }: { enabled?: boolean } = {},
+) {
+ const { getInscriptions, address, publicKeyNoCoord } = useBTCWallet();
+ const { handleError } = useError();
+ const logger = useLogger();
+
+ const fetchOrdinals = async (): Promise => {
+ if (address) {
+ logger.info("Fetching ordinals for address", { btcAddress: address });
+ }
+ try {
+ const inscriptions = await Promise.race([
+ getInscriptions().catch(() => null),
+ wait(WALLET_FETCH_INSRIPTIONS_TIMEOUT),
+ ]);
+
+ if (inscriptions) {
+ return inscriptions;
+ }
+
+ const verifiedUTXOs = await postVerifyUtxoOrdinals(
+ filterDust(utxos),
+ address,
+ );
+
+ return verifiedUTXOs.filter((utxo) => utxo.inscription);
+ } catch (error) {
+ const clientError = new ClientError(
+ ERROR_CODES.EXTERNAL_SERVICE_UNAVAILABLE,
+ "Error fetching ordinals information",
+ {
+ cause: error as Error,
+ },
+ );
+ handleError({
+ error: clientError,
+ displayOptions: {
+ retryAction: () => fetchOrdinals(),
+ },
+ metadata: {
+ userPublicKey: publicKeyNoCoord,
+ btcAddress: address,
+ },
+ });
+ // App should work without ordinals
+ // -> return an empty array instead of throwing an error
+ return [];
+ }
+ };
+
+ const data = useClientQuery({
+ queryKey: [ORDINAL_KEY, utxos, address],
+ queryFn: fetchOrdinals,
+ enabled: Boolean(address) || enabled,
+ refetchInterval: 5 * ONE_MINUTE,
+ });
+
+ return data;
+}
diff --git a/src/ui/common/hooks/client/api/usePrices.ts b/src/ui/common/hooks/client/api/usePrices.ts
new file mode 100644
index 000000000..6509183e8
--- /dev/null
+++ b/src/ui/common/hooks/client/api/usePrices.ts
@@ -0,0 +1,19 @@
+import { ONE_MINUTE } from "@/ui/common/constants";
+import { getPrices } from "@/ui/common/utils/getPrices";
+
+import { useClientQuery } from "../useClient";
+
+export const PRICES_KEY = "PRICES";
+
+export const usePrices = () => {
+ return useClientQuery({
+ queryKey: [PRICES_KEY],
+ queryFn: getPrices,
+ staleTime: ONE_MINUTE,
+ });
+};
+
+export const usePrice = (symbol: string) => {
+ const { data: prices } = usePrices();
+ return prices?.[symbol] ?? 0;
+};
diff --git a/src/ui/common/hooks/client/api/useSystemStats.ts b/src/ui/common/hooks/client/api/useSystemStats.ts
new file mode 100644
index 000000000..b73563af8
--- /dev/null
+++ b/src/ui/common/hooks/client/api/useSystemStats.ts
@@ -0,0 +1,14 @@
+import { getSystemStats } from "@/ui/common/api/getSystemStats";
+import { ONE_MINUTE } from "@/ui/common/constants";
+import { useClientQuery } from "@/ui/common/hooks/client/useClient";
+
+export const BTC_TIP_HEIGHT_KEY = "API_STATS";
+
+export function useSystemStats({ enabled = true }: { enabled?: boolean } = {}) {
+ return useClientQuery({
+ queryKey: ["API_STATS"],
+ queryFn: () => getSystemStats(),
+ refetchInterval: ONE_MINUTE,
+ enabled,
+ });
+}
diff --git a/src/ui/common/hooks/client/api/useUTXOs.ts b/src/ui/common/hooks/client/api/useUTXOs.ts
new file mode 100644
index 000000000..49e99f688
--- /dev/null
+++ b/src/ui/common/hooks/client/api/useUTXOs.ts
@@ -0,0 +1,28 @@
+import { ONE_MINUTE } from "@/ui/common/constants";
+import { useBTCWallet } from "@/ui/common/context/wallet/BTCWalletProvider";
+import { useClientQuery } from "@/ui/common/hooks/client/useClient";
+import { getUTXOs } from "@/ui/common/utils/mempool_api";
+
+export const UTXO_KEY = "UTXO";
+
+export function useUTXOs({ enabled = true }: { enabled?: boolean } = {}) {
+ const { address } = useBTCWallet();
+
+ const { data, isLoading, isError, error, refetch } = useClientQuery({
+ queryKey: [UTXO_KEY, address],
+ queryFn: () => getUTXOs(address),
+ enabled: Boolean(address) && enabled,
+ refetchInterval: 5 * ONE_MINUTE,
+ });
+
+ return {
+ isLoading,
+ isError,
+ error,
+ refetch,
+ // Get all UTXOs regardless of confirmation status
+ allUTXOs: data || [],
+ // Get UTXOs that are confirmed
+ confirmedUTXOs: data?.filter((utxo) => utxo.confirmed) || [],
+ };
+}
diff --git a/src/ui/common/hooks/client/rpc/mutation/useBbnTransaction.ts b/src/ui/common/hooks/client/rpc/mutation/useBbnTransaction.ts
new file mode 100644
index 000000000..351120f78
--- /dev/null
+++ b/src/ui/common/hooks/client/rpc/mutation/useBbnTransaction.ts
@@ -0,0 +1,91 @@
+import { useCallback } from "react";
+
+import { BBN_GAS_PRICE } from "@/ui/common/config";
+import { useLogger } from "@/ui/common/hooks/useLogger";
+
+import { useSigningStargateClient } from "./useSigningStargateClient";
+
+const GAS_MULTIPLIER = 1.5;
+const GAS_DENOM = "ubbn";
+
+export interface BbnGasFee {
+ amount: { denom: string; amount: string }[];
+ gas: string;
+}
+
+/**
+ * Transaction service for Babylon which contains all the transactions for
+ * interacting with Babylon RPC nodes
+ */
+export const useBbnTransaction = () => {
+ const { simulate, signTx, broadcastTx } = useSigningStargateClient();
+ const logger = useLogger();
+
+ /**
+ * Estimates the gas fee for a transaction.
+ * @param {Object} msg - The transaction message.
+ * @returns {Promise} - The gas fee.
+ */
+ const estimateBbnGasFee = useCallback(
+ async (msg: { typeUrl: string; value: T }): Promise => {
+ const gasEstimate = await simulate(msg);
+ const gasWanted = Math.ceil(gasEstimate * GAS_MULTIPLIER);
+ return {
+ amount: [
+ {
+ denom: GAS_DENOM,
+ amount: (gasWanted * BBN_GAS_PRICE).toFixed(0),
+ },
+ ],
+ gas: gasWanted.toString(),
+ };
+ },
+ [simulate],
+ );
+
+ /**
+ * Sign a transaction
+ * @param {Object} msg - The transaction message.
+ * @returns The signed transaction in bytes
+ */
+ const signBbnTx = useCallback(
+ async (msg: {
+ typeUrl: string;
+ value: T;
+ }): Promise => {
+ logger.info("Starting BBN transaction signing", {
+ msgType: msg.typeUrl,
+ category: "transaction",
+ });
+
+ // estimate gas
+ const fee = await estimateBbnGasFee(msg);
+ // sign it
+ return signTx(msg, fee);
+ },
+ [estimateBbnGasFee, signTx, logger],
+ );
+
+ /**
+ * Sends a transaction to the Babylon network.
+ * @param {Uint8Array} tx - The transaction in bytes.
+ * @returns {Promise<{txHash: string; gasUsed: string;}>} - The transaction hash and gas used.
+ */
+ const sendBbnTx = useCallback(
+ async (tx: Uint8Array) => {
+ logger.info("Broadcasting BBN transaction", {
+ txSize: tx.length,
+ category: "transaction",
+ });
+
+ return broadcastTx(tx);
+ },
+ [broadcastTx, logger],
+ );
+
+ return {
+ signBbnTx,
+ sendBbnTx,
+ estimateBbnGasFee,
+ };
+};
diff --git a/src/ui/common/hooks/client/rpc/mutation/useSigningStargateClient.ts b/src/ui/common/hooks/client/rpc/mutation/useSigningStargateClient.ts
new file mode 100644
index 000000000..827361ddc
--- /dev/null
+++ b/src/ui/common/hooks/client/rpc/mutation/useSigningStargateClient.ts
@@ -0,0 +1,174 @@
+import { DeliverTxResponse, StdFee } from "@cosmjs/stargate";
+import { TxRaw } from "cosmjs-types/cosmos/tx/v1beta1/tx";
+import { useCallback } from "react";
+
+import { useCosmosWallet } from "@/ui/common/context/wallet/CosmosWalletProvider";
+import { ClientError, ERROR_CODES } from "@/ui/common/errors";
+import { useLogger } from "@/ui/common/hooks/useLogger";
+
+/**
+ * Hook for signing and broadcasting transactions with the Cosmos wallet
+ */
+export const useSigningStargateClient = () => {
+ const { signingStargateClient, bech32Address } = useCosmosWallet();
+ const logger = useLogger();
+
+ const handleTransactionError = useCallback(
+ (res: DeliverTxResponse, txType: string) => {
+ const errorMessage = `Failed to send ${txType} transaction, code: ${res.code}, txHash: ${res.transactionHash}`;
+ const causeError = new Error(
+ res.rawLog ||
+ "Transaction failed with non-zero code and no raw log provided.",
+ );
+ const clientError = new ClientError(
+ ERROR_CODES.TRANSACTION_SUBMISSION_ERROR,
+ errorMessage,
+ { cause: causeError },
+ );
+ return clientError; // Return it to be thrown by the caller
+ },
+ [],
+ );
+
+ /**
+ * Simulates a transaction to estimate the gas fee
+ * @param msg - The transaction message
+ * @returns The gas fee
+ */
+ const simulate = useCallback(
+ (msg: { typeUrl: string; value: T }): Promise => {
+ if (!signingStargateClient || !bech32Address) {
+ const clientError = new ClientError(
+ ERROR_CODES.WALLET_NOT_CONNECTED,
+ "Wallet not connected for simulation",
+ );
+ throw clientError;
+ }
+ if (bech32Address) {
+ logger.info("Using Cosmos address for simulation", { bech32Address });
+ }
+ // estimate gas
+ return signingStargateClient.simulate(
+ bech32Address,
+ [msg],
+ `estimate transaction fee for ${msg.typeUrl}`,
+ );
+ },
+ [signingStargateClient, bech32Address, logger],
+ );
+
+ /**
+ * Signs and broadcasts a transaction
+ * @param msg - The transaction message
+ * @param fee - The gas fee
+ * @returns The transaction hash and gas used
+ */
+ const signAndBroadcast = useCallback(
+ async (
+ msg: {
+ typeUrl: string;
+ value: T;
+ },
+ fee: StdFee,
+ ): Promise<{
+ txHash: string;
+ gasUsed: string;
+ }> => {
+ if (!signingStargateClient || !bech32Address) {
+ const clientError = new ClientError(
+ ERROR_CODES.WALLET_NOT_CONNECTED,
+ "Wallet not connected for signAndBroadcast",
+ );
+ logger.error(clientError);
+ throw clientError;
+ }
+ if (bech32Address) {
+ logger.info("Using Cosmos address for signAndBroadcast", {
+ bech32Address,
+ });
+ }
+ const res = await signingStargateClient.signAndBroadcast(
+ bech32Address,
+ [msg],
+ fee,
+ );
+
+ if (res.code !== 0) {
+ throw handleTransactionError(res, msg.typeUrl);
+ }
+ return {
+ txHash: res.transactionHash,
+ gasUsed: res.gasUsed.toString(),
+ };
+ },
+ [signingStargateClient, bech32Address, logger, handleTransactionError],
+ );
+
+ /**
+ * Signs a transaction
+ * @param msg - The transaction message
+ * @param fee - The gas fee
+ * @returns The signed transaction in bytes
+ */
+ const signTx = useCallback(
+ async (
+ msg: {
+ typeUrl: string;
+ value: T;
+ },
+ fee: StdFee,
+ ): Promise => {
+ if (!signingStargateClient || !bech32Address) {
+ const clientError = new ClientError(
+ ERROR_CODES.WALLET_NOT_CONNECTED,
+ "Wallet not connected",
+ );
+ throw clientError;
+ }
+
+ const res = await signingStargateClient.sign(
+ bech32Address,
+ [msg],
+ fee,
+ "",
+ );
+ return TxRaw.encode(res).finish();
+ },
+
+ [signingStargateClient, bech32Address],
+ );
+
+ /**
+ * Broadcasts a transaction
+ * @param tx - The transaction in bytes
+ * @returns The transaction hash
+ */
+ const broadcastTx = useCallback(
+ async (
+ tx: Uint8Array,
+ ): Promise<{
+ txHash: string;
+ gasUsed: string;
+ }> => {
+ if (!signingStargateClient || !bech32Address) {
+ const clientError = new ClientError(
+ ERROR_CODES.WALLET_NOT_CONNECTED,
+ "Wallet not connected",
+ );
+ throw clientError;
+ }
+
+ const res = await signingStargateClient.broadcastTx(tx);
+ if (res.code !== 0) {
+ throw handleTransactionError(res, "broadcasted_tx_bytes");
+ }
+ return {
+ gasUsed: res.gasUsed.toString(),
+ txHash: res.transactionHash,
+ };
+ },
+ [signingStargateClient, bech32Address, handleTransactionError],
+ );
+
+ return { simulate, signAndBroadcast, signTx, broadcastTx };
+};
diff --git a/src/ui/common/hooks/client/rpc/queries/useBbnQuery.ts b/src/ui/common/hooks/client/rpc/queries/useBbnQuery.ts
new file mode 100644
index 000000000..c3c0d9ffb
--- /dev/null
+++ b/src/ui/common/hooks/client/rpc/queries/useBbnQuery.ts
@@ -0,0 +1,175 @@
+import {
+ btclightclientquery,
+ incentivequery,
+} from "@babylonlabs-io/babylon-proto-ts";
+import {
+ QueryClient,
+ createProtobufRpcClient,
+ setupBankExtension,
+} from "@cosmjs/stargate";
+
+import { ONE_MINUTE } from "@/ui/common/constants";
+import { useBbnRpc } from "@/ui/common/context/rpc/BbnRpcProvider";
+import { useCosmosWallet } from "@/ui/common/context/wallet/CosmosWalletProvider";
+import { ClientError } from "@/ui/common/errors";
+import { ERROR_CODES } from "@/ui/common/errors/codes";
+import { useHealthCheck } from "@/ui/common/hooks/useHealthCheck";
+
+import { useClientQuery } from "../../useClient";
+import { useRpcErrorHandler } from "../useRpcErrorHandler";
+
+const BBN_BTCLIGHTCLIENT_TIP_KEY = "BBN_BTCLIGHTCLIENT_TIP";
+const BBN_BALANCE_KEY = "BBN_BALANCE";
+const BBN_REWARDS_KEY = "BBN_REWARDS";
+const REWARD_GAUGE_KEY_BTC_DELEGATION = "BTC_STAKER";
+
+/**
+ * Query service for Babylon which contains all the queries for
+ * interacting with Babylon RPC nodes
+ */
+export const useBbnQuery = () => {
+ const { isGeoBlocked, isLoading: isHealthcheckLoading } = useHealthCheck();
+ const { bech32Address, connected } = useCosmosWallet();
+ const { queryClient } = useBbnRpc();
+ const { hasRpcError, reconnect } = useRpcErrorHandler();
+
+ /**
+ * Gets the rewards from the user's account.
+ * @returns {Promise} - The rewards from the user's account.
+ */
+ const rewardsQuery = useClientQuery({
+ queryKey: [BBN_REWARDS_KEY, bech32Address, connected],
+ queryFn: async () => {
+ if (!connected || !queryClient || !bech32Address) {
+ return undefined;
+ }
+ const { incentive } = setupIncentiveExtension(queryClient);
+ const req: incentivequery.QueryRewardGaugesRequest =
+ incentivequery.QueryRewardGaugesRequest.fromPartial({
+ address: bech32Address,
+ });
+
+ let rewards: incentivequery.QueryRewardGaugesResponse;
+ try {
+ rewards = await incentive.RewardGauges(req);
+ } catch (error) {
+ // If error message contains "reward gauge not found", silently return 0
+ // This is to handle the case where the user has no rewards, meaning
+ // they have not staked
+ if (
+ error instanceof Error &&
+ error.message.includes("reward gauge not found")
+ ) {
+ return 0;
+ }
+ throw new ClientError(
+ ERROR_CODES.EXTERNAL_SERVICE_UNAVAILABLE,
+ "Error getting rewards",
+ { cause: error as Error },
+ );
+ }
+ if (!rewards) {
+ return 0;
+ }
+
+ const coins =
+ rewards.rewardGauges[REWARD_GAUGE_KEY_BTC_DELEGATION]?.coins;
+ if (!coins) {
+ return 0;
+ }
+
+ const withdrawnCoins = rewards.rewardGauges[
+ REWARD_GAUGE_KEY_BTC_DELEGATION
+ ]?.withdrawnCoins.reduce((acc, coin) => acc + Number(coin.amount), 0);
+
+ return (
+ coins.reduce((acc, coin) => acc + Number(coin.amount), 0) -
+ (withdrawnCoins || 0)
+ );
+ },
+ enabled: Boolean(
+ queryClient &&
+ connected &&
+ bech32Address &&
+ !isGeoBlocked &&
+ !isHealthcheckLoading,
+ ),
+ staleTime: ONE_MINUTE,
+ refetchInterval: ONE_MINUTE,
+ });
+
+ /**
+ * Gets the balance of the user's account.
+ * @returns {Promise} - The balance of the user's account.
+ */
+ const balanceQuery = useClientQuery({
+ queryKey: [BBN_BALANCE_KEY, bech32Address, connected],
+ queryFn: async () => {
+ if (!connected || !queryClient || !bech32Address) {
+ return 0;
+ }
+ const { bank } = setupBankExtension(queryClient);
+ const balance = await bank.balance(bech32Address, "ubbn");
+ return Number(balance?.amount ?? 0);
+ },
+ enabled: Boolean(
+ queryClient &&
+ connected &&
+ bech32Address &&
+ !isGeoBlocked &&
+ !isHealthcheckLoading,
+ ),
+ staleTime: ONE_MINUTE,
+ refetchInterval: ONE_MINUTE,
+ });
+
+ /**
+ * Gets the tip of the Bitcoin blockchain.
+ * @returns {Promise} - The tip of the Bitcoin blockchain.
+ */
+ const btcTipQuery = useClientQuery({
+ queryKey: [BBN_BTCLIGHTCLIENT_TIP_KEY],
+ queryFn: async () => {
+ if (!queryClient) {
+ return undefined;
+ }
+ const { btclightQueryClient } = setupBtclightClientExtension(queryClient);
+ const req = btclightclientquery.QueryTipRequest.fromPartial({});
+ const { header } = await btclightQueryClient.Tip(req);
+ return header;
+ },
+ enabled: Boolean(queryClient && !isGeoBlocked && !isHealthcheckLoading),
+ staleTime: ONE_MINUTE,
+ refetchInterval: false, // Disable automatic periodic refetching
+ });
+
+ return {
+ rewardsQuery,
+ balanceQuery,
+ btcTipQuery,
+ hasRpcError,
+ reconnectRpc: reconnect,
+ queryClient,
+ };
+};
+
+// Extend the QueryClient with the Incentive module
+const setupIncentiveExtension = (
+ base: QueryClient,
+): {
+ incentive: incentivequery.QueryClientImpl;
+} => {
+ const rpc = createProtobufRpcClient(base);
+ const incentiveQueryClient = new incentivequery.QueryClientImpl(rpc);
+ return { incentive: incentiveQueryClient };
+};
+
+const setupBtclightClientExtension = (
+ base: QueryClient,
+): {
+ btclightQueryClient: btclightclientquery.QueryClientImpl;
+} => {
+ const rpc = createProtobufRpcClient(base);
+ const btclightQueryClient = new btclightclientquery.QueryClientImpl(rpc);
+ return { btclightQueryClient };
+};
diff --git a/src/ui/common/hooks/client/rpc/useRpcErrorHandler.ts b/src/ui/common/hooks/client/rpc/useRpcErrorHandler.ts
new file mode 100644
index 000000000..868c281c6
--- /dev/null
+++ b/src/ui/common/hooks/client/rpc/useRpcErrorHandler.ts
@@ -0,0 +1,35 @@
+import { useEffect } from "react";
+
+import { useError } from "@/ui/common/context/Error/ErrorProvider";
+import { useBbnRpc } from "@/ui/common/context/rpc/BbnRpcProvider";
+import { useLogger } from "@/ui/common/hooks/useLogger";
+
+/**
+ * Hook that handles RPC connection errors by showing an error modal
+ * when the connection to the RPC node fails.
+ *
+ * @returns Object containing a flag indicating if there's an RPC error
+ */
+export function useRpcErrorHandler() {
+ const { error, isLoading, reconnect } = useBbnRpc();
+ const { handleError } = useError();
+ const logger = useLogger();
+
+ useEffect(() => {
+ if (error && !isLoading) {
+ logger.error(error);
+ handleError({
+ error,
+ displayOptions: {
+ showModal: true,
+ retryAction: reconnect,
+ },
+ });
+ }
+ }, [error, isLoading, handleError, reconnect, logger]);
+
+ return {
+ hasRpcError: Boolean(error) && !isLoading,
+ reconnect,
+ };
+}
diff --git a/src/ui/common/hooks/client/useClient.ts b/src/ui/common/hooks/client/useClient.ts
new file mode 100644
index 000000000..008c7931a
--- /dev/null
+++ b/src/ui/common/hooks/client/useClient.ts
@@ -0,0 +1,90 @@
+import {
+ DefinedInitialDataOptions,
+ DefinedUseQueryResult,
+ UseQueryOptions,
+ UseQueryResult,
+ useQuery,
+ type DefaultError,
+ type QueryKey,
+ type UndefinedInitialDataOptions,
+} from "@tanstack/react-query";
+import { useEffect } from "react";
+
+import {
+ API_DEFAULT_RETRY_COUNT,
+ API_DEFAULT_RETRY_DELAY,
+ ONE_MINUTE,
+ ONE_SECOND,
+} from "@/ui/common/constants";
+import { useError } from "@/ui/common/context/Error/ErrorProvider";
+import { ClientError, ERROR_CODES } from "@/ui/common/errors";
+import { useLogger } from "@/ui/common/hooks/useLogger";
+
+export function useClientQuery<
+ TQueryFnData = unknown,
+ TError = DefaultError,
+ TData = TQueryFnData,
+ TQueryKey extends QueryKey = QueryKey,
+>(
+ options: DefinedInitialDataOptions,
+): DefinedUseQueryResult;
+export function useClientQuery<
+ TQueryFnData = unknown,
+ TError = DefaultError,
+ TData = TQueryFnData,
+ TQueryKey extends QueryKey = QueryKey,
+>(
+ options: UndefinedInitialDataOptions,
+): UseQueryResult;
+export function useClientQuery<
+ TQueryFnData = unknown,
+ TError = DefaultError,
+ TData = TQueryFnData,
+ TQueryKey extends QueryKey = QueryKey,
+>(
+ options: UseQueryOptions,
+): UseQueryResult {
+ const { isOpen, handleError } = useError();
+ const logger = useLogger();
+
+ const data = useQuery({
+ refetchInterval: ONE_MINUTE,
+ retry: (failureCount, error) => {
+ // Prevent retries for geoblocked errors
+ if ((error as ClientError).errorCode === ERROR_CODES.GEO_BLOCK) {
+ return false;
+ }
+ return !isOpen && failureCount < API_DEFAULT_RETRY_COUNT;
+ },
+ retryDelay: (count) => API_DEFAULT_RETRY_DELAY ** (count + 1) * ONE_SECOND,
+ ...options,
+ });
+
+ useEffect(() => {
+ if (data.isError) {
+ const error = data.error as Error;
+ const isGeoBlocked =
+ (error as ClientError).errorCode === ERROR_CODES.GEO_BLOCK;
+
+ if (isGeoBlocked) {
+ return;
+ }
+
+ const clientError = new ClientError(
+ ERROR_CODES.EXTERNAL_SERVICE_UNAVAILABLE,
+ "Error fetching data from the API",
+ { cause: error },
+ );
+ logger.error(clientError);
+
+ handleError({
+ error: clientError,
+ displayOptions: {
+ retryAction: data.refetch,
+ },
+ });
+ }
+ }, [handleError, data.error, data.isError, data.refetch, logger]);
+
+ return data;
+}
diff --git a/src/ui/common/hooks/services/useDelegationService.ts b/src/ui/common/hooks/services/useDelegationService.ts
new file mode 100644
index 000000000..55cd64fbd
--- /dev/null
+++ b/src/ui/common/hooks/services/useDelegationService.ts
@@ -0,0 +1,364 @@
+import { useCallback, useMemo, useState } from "react";
+
+import { DELEGATION_ACTIONS as ACTIONS } from "@/ui/common/constants";
+import { ClientError, ERROR_CODES } from "@/ui/common/errors";
+import { useLogger } from "@/ui/common/hooks/useLogger";
+import { useAppState } from "@/ui/common/state";
+import { useDelegationV2State } from "@/ui/common/state/DelegationV2State";
+import { useFinalityProviderState } from "@/ui/common/state/FinalityProviderState";
+import {
+ DelegationV2,
+ DelegationWithFP,
+ DelegationV2StakingState as State,
+} from "@/ui/common/types/delegationsV2";
+import { FinalityProvider } from "@/ui/common/types/finalityProviders";
+import { BbnStakingParamsVersion } from "@/ui/common/types/networkInfo";
+import { validateDelegation } from "@/ui/common/utils/delegations";
+import { getBbnParamByVersion } from "@/ui/common/utils/params";
+
+import { useTransactionService } from "./useTransactionService";
+
+export type ActionType = keyof typeof ACTIONS;
+
+interface TxProps {
+ stakingTxHashHex: string;
+ stakingTxHex: string;
+ paramsVersion: number;
+ unbondingTxHex: string;
+ covenantUnbondingSignatures?: {
+ covenantBtcPkHex: string;
+ signatureHex: string;
+ }[];
+ state: State;
+ stakingInput: {
+ finalityProviderPksNoCoordHex: string[];
+ stakingAmountSat: number;
+ stakingTimelock: number;
+ };
+ slashing: {
+ stakingSlashingTxHex: string;
+ unbondingSlashingTxHex: string;
+ spendingHeight: number;
+ };
+}
+
+type DelegationCommand = (props: TxProps) => Promise;
+
+interface ConfirmationModalState {
+ action: ActionType;
+ delegation: DelegationWithFP;
+ param: BbnStakingParamsVersion;
+}
+
+export function useDelegationService() {
+ const [confirmationModal, setConfirmationModal] =
+ useState(null);
+ const [processingDelegations, setProcessingDelegations] = useState<
+ Record
+ >({});
+ const logger = useLogger();
+
+ const { availableUTXOs = [], networkInfo } = useAppState();
+ const {
+ delegations = [],
+ fetchMoreDelegations,
+ hasMoreDelegations,
+ isLoading: isDelegationLoading,
+ isFetchingNextPage,
+ updateDelegationStatus,
+ setDelegationV2StepOptions,
+ } = useDelegationV2State();
+
+ const {
+ submitStakingTx,
+ submitUnbondingTx,
+ submitEarlyUnbondedWithdrawalTx,
+ submitTimelockUnbondedWithdrawalTx,
+ submitSlashingWithdrawalTx,
+ } = useTransactionService();
+
+ const { isFetching: isFPLoading, finalityProviderMap } =
+ useFinalityProviderState();
+
+ const isLoading = isDelegationLoading || isFPLoading;
+
+ const delegationsWithFP = useMemo(
+ () =>
+ delegations.map((d) => ({
+ ...d,
+ fp: finalityProviderMap.get(
+ d.finalityProviderBtcPksHex[0],
+ ) as FinalityProvider,
+ })),
+ [delegations, finalityProviderMap],
+ );
+
+ const validations = useMemo(
+ () =>
+ delegations.reduce(
+ (acc, delegation) => ({
+ ...acc,
+ [delegation.stakingTxHashHex]: validateDelegation(
+ delegation,
+ availableUTXOs,
+ ),
+ }),
+ {} as Record,
+ ),
+ [delegations, availableUTXOs],
+ );
+
+ const processing = useMemo(
+ () =>
+ confirmationModal?.delegation
+ ? processingDelegations[confirmationModal.delegation.stakingTxHashHex]
+ : false,
+ [confirmationModal, processingDelegations],
+ );
+
+ const COMMANDS: Record = useMemo(
+ () => ({
+ [ACTIONS.STAKE]: async ({
+ stakingInput,
+ paramsVersion,
+ stakingTxHashHex,
+ stakingTxHex,
+ }: TxProps) => {
+ await submitStakingTx(
+ stakingInput,
+ paramsVersion,
+ stakingTxHashHex,
+ stakingTxHex,
+ );
+ updateDelegationStatus(
+ stakingTxHashHex,
+ State.INTERMEDIATE_PENDING_BTC_CONFIRMATION,
+ );
+ },
+
+ [ACTIONS.UNBOND]: async ({
+ stakingInput,
+ paramsVersion,
+ stakingTxHashHex,
+ stakingTxHex,
+ unbondingTxHex,
+ covenantUnbondingSignatures,
+ }: TxProps) => {
+ if (!covenantUnbondingSignatures) {
+ const clientError = new ClientError(
+ ERROR_CODES.VALIDATION_ERROR,
+ "Covenant unbonding signatures not found",
+ );
+ throw clientError;
+ }
+
+ await submitUnbondingTx(
+ stakingInput,
+ paramsVersion,
+ stakingTxHex,
+ unbondingTxHex,
+ covenantUnbondingSignatures.map((sig) => ({
+ btcPkHex: sig.covenantBtcPkHex,
+ sigHex: sig.signatureHex,
+ })),
+ );
+
+ updateDelegationStatus(
+ stakingTxHashHex,
+ State.INTERMEDIATE_UNBONDING_SUBMITTED,
+ );
+ },
+
+ [ACTIONS.WITHDRAW_ON_EARLY_UNBONDING]: async ({
+ stakingTxHashHex,
+ stakingInput,
+ paramsVersion,
+ unbondingTxHex,
+ }: TxProps) => {
+ await submitEarlyUnbondedWithdrawalTx(
+ stakingInput,
+ paramsVersion,
+ unbondingTxHex,
+ );
+
+ updateDelegationStatus(
+ stakingTxHashHex,
+ State.INTERMEDIATE_EARLY_UNBONDING_WITHDRAWAL_SUBMITTED,
+ );
+ },
+
+ [ACTIONS.WITHDRAW_ON_EARLY_UNBONDING_SLASHING]: async ({
+ stakingTxHashHex,
+ stakingInput,
+ paramsVersion,
+ slashing,
+ }) => {
+ if (!slashing.unbondingSlashingTxHex) {
+ const clientError = new ClientError(
+ ERROR_CODES.VALIDATION_ERROR,
+ "Unbonding slashing tx not found, can't submit withdrawal",
+ );
+ throw clientError;
+ }
+
+ await submitSlashingWithdrawalTx(
+ stakingInput,
+ paramsVersion,
+ slashing.unbondingSlashingTxHex,
+ );
+
+ updateDelegationStatus(
+ stakingTxHashHex,
+ State.INTERMEDIATE_EARLY_UNBONDING_SLASHING_WITHDRAWAL_SUBMITTED,
+ );
+ },
+
+ [ACTIONS.WITHDRAW_ON_TIMELOCK]: async ({
+ stakingInput,
+ paramsVersion,
+ stakingTxHashHex,
+ stakingTxHex,
+ }: TxProps) => {
+ await submitTimelockUnbondedWithdrawalTx(
+ stakingInput,
+ paramsVersion,
+ stakingTxHex,
+ );
+
+ updateDelegationStatus(
+ stakingTxHashHex,
+ State.INTERMEDIATE_TIMELOCK_WITHDRAWAL_SUBMITTED,
+ );
+ },
+
+ [ACTIONS.WITHDRAW_ON_TIMELOCK_SLASHING]: async ({
+ stakingInput,
+ paramsVersion,
+ stakingTxHashHex,
+ slashing,
+ }) => {
+ if (!slashing.stakingSlashingTxHex) {
+ const clientError = new ClientError(
+ ERROR_CODES.VALIDATION_ERROR,
+ "Slashing tx not found, can't submit withdrawal",
+ );
+ throw clientError;
+ }
+
+ await submitSlashingWithdrawalTx(
+ stakingInput,
+ paramsVersion,
+ slashing.stakingSlashingTxHex,
+ );
+
+ updateDelegationStatus(
+ stakingTxHashHex,
+ State.INTERMEDIATE_TIMELOCK_SLASHING_WITHDRAWAL_SUBMITTED,
+ );
+ },
+ }),
+ [
+ submitStakingTx,
+ updateDelegationStatus,
+ submitUnbondingTx,
+ submitEarlyUnbondedWithdrawalTx,
+ submitTimelockUnbondedWithdrawalTx,
+ submitSlashingWithdrawalTx,
+ ],
+ );
+
+ const openConfirmationModal = useCallback(
+ (action: ActionType, delegation: DelegationWithFP) => {
+ const param = getBbnParamByVersion(
+ delegation.paramsVersion,
+ networkInfo?.params.bbnStakingParams.versions || [],
+ );
+
+ setConfirmationModal({
+ action,
+ delegation,
+ param,
+ });
+ },
+ [networkInfo],
+ );
+
+ const closeConfirmationModal = useCallback(() => {
+ setConfirmationModal(null);
+ setDelegationV2StepOptions(undefined);
+ }, [setDelegationV2StepOptions]);
+
+ const toggleProcessingDelegation = useCallback(
+ (id: string, processing: boolean) => {
+ setProcessingDelegations((state) => ({ ...state, [id]: processing }));
+ },
+ [],
+ );
+
+ const executeDelegationAction = useCallback(
+ async (action: string, delegation: DelegationV2) => {
+ const {
+ stakingTxHashHex,
+ stakingTxHex,
+ finalityProviderBtcPksHex,
+ stakingAmount,
+ paramsVersion,
+ stakingTimelock,
+ unbondingTxHex,
+ covenantUnbondingSignatures,
+ state,
+ slashing,
+ } = delegation;
+
+ const stakingInput = {
+ finalityProviderPksNoCoordHex: finalityProviderBtcPksHex,
+ stakingAmountSat: stakingAmount,
+ stakingTimelock,
+ };
+
+ logger.info("Executing delegation action", {
+ action,
+ delegationState: state,
+ stakingTxHashHex,
+ paramsVersion,
+ slashingSpendingHeight: slashing?.spendingHeight,
+ });
+
+ const execute = COMMANDS[action as ActionType];
+
+ try {
+ toggleProcessingDelegation(stakingTxHashHex, true);
+
+ await execute?.({
+ stakingTxHashHex,
+ stakingTxHex,
+ paramsVersion,
+ unbondingTxHex,
+ covenantUnbondingSignatures,
+ state,
+ stakingInput,
+ slashing,
+ });
+
+ closeConfirmationModal();
+ } finally {
+ toggleProcessingDelegation(stakingTxHashHex, false);
+ }
+ },
+ [COMMANDS, closeConfirmationModal, toggleProcessingDelegation, logger],
+ );
+
+ return {
+ processing,
+ isLoading,
+ delegations: delegationsWithFP,
+ validations,
+ hasMoreDelegations,
+ confirmationModal,
+ isFetchingNextPage,
+ openConfirmationModal,
+ closeConfirmationModal,
+ fetchMoreDelegations,
+ executeDelegationAction,
+ };
+}
diff --git a/src/ui/common/hooks/services/useRegistrationService.ts b/src/ui/common/hooks/services/useRegistrationService.ts
new file mode 100644
index 000000000..ab0d3a6e6
--- /dev/null
+++ b/src/ui/common/hooks/services/useRegistrationService.ts
@@ -0,0 +1,131 @@
+import { useCallback } from "react";
+
+import { getDelegationV2 } from "@/ui/common/api/getDelegationsV2";
+import { ONE_SECOND } from "@/ui/common/constants";
+import { useError } from "@/ui/common/context/Error/ErrorProvider";
+import { ClientError, ERROR_CODES } from "@/ui/common/errors";
+import { useLogger } from "@/ui/common/hooks/useLogger";
+import { useDelegationState } from "@/ui/common/state/DelegationState";
+import { useDelegationV2State } from "@/ui/common/state/DelegationV2State";
+import { DelegationV2StakingState as DelegationState } from "@/ui/common/types/delegationsV2";
+import { retry } from "@/ui/common/utils";
+
+import { useBbnTransaction } from "../client/rpc/mutation/useBbnTransaction";
+
+import { useTransactionService } from "./useTransactionService";
+
+interface RegistrationData {
+ stakingTxHex: string;
+ startHeight: number;
+ stakingInput: {
+ finalityProviderPksNoCoordHex: string[];
+ stakingAmountSat: number;
+ stakingTimelock: number;
+ };
+}
+
+export function useRegistrationService() {
+ const {
+ setRegistrationStep: setStep,
+ setProcessing,
+ selectedDelegation,
+ resetRegistration: reset,
+ refetch: refetchV1Delegations,
+ } = useDelegationState();
+ const { transitionPhase1Delegation } = useTransactionService();
+ const { addDelegation, refetch: refetchV2Delegations } =
+ useDelegationV2State();
+ const { sendBbnTx } = useBbnTransaction();
+ const { handleError } = useError();
+ const logger = useLogger();
+
+ const registerPhase1Delegation = useCallback(async () => {
+ // set the step to staking-slashing
+ setStep("registration-staking-slashing");
+
+ if (!selectedDelegation) {
+ const clientError = new ClientError(
+ ERROR_CODES.VALIDATION_ERROR,
+ "No delegation selected for registration",
+ );
+ logger.warn(clientError.message);
+ handleError({
+ error: clientError,
+ });
+ return;
+ }
+
+ try {
+ setProcessing(true);
+
+ const registrationData: RegistrationData = {
+ stakingTxHex: selectedDelegation.stakingTx.txHex,
+ startHeight: selectedDelegation.stakingTx.startHeight,
+ stakingInput: {
+ finalityProviderPksNoCoordHex: [
+ // Phase-1 delegation only contains a single FP
+ selectedDelegation.finalityProviderPkHex,
+ ],
+ stakingAmountSat: selectedDelegation.stakingValueSat,
+ stakingTimelock: selectedDelegation.stakingTx.timelock,
+ },
+ };
+
+ logger.info("Executing registration action", {
+ selectedDelegationId: selectedDelegation?.stakingTxHashHex,
+ stakingTxHex: registrationData.stakingTxHex,
+ stakingHeight: registrationData.startHeight,
+ });
+
+ const { signedBabylonTx } = await transitionPhase1Delegation(
+ registrationData.stakingTxHex,
+ registrationData.startHeight,
+ registrationData.stakingInput,
+ );
+ // Send the transaction
+ setStep("registration-send-bbn");
+ await sendBbnTx(signedBabylonTx);
+
+ addDelegation({
+ stakingAmount: selectedDelegation.stakingValueSat,
+ stakingTxHashHex: selectedDelegation.stakingTxHashHex,
+ startHeight: selectedDelegation.stakingTx.startHeight,
+ state: DelegationState.INTERMEDIATE_PENDING_VERIFICATION,
+ });
+
+ setStep("registration-verifying");
+
+ const delegation = await retry(
+ () => getDelegationV2(selectedDelegation.stakingTxHashHex),
+ (delegation) => delegation?.state === DelegationState.ACTIVE,
+ 5 * ONE_SECOND,
+ );
+ if (delegation) {
+ setStep("registration-verified");
+ // Refetch both v1 and v2 delegations to reflect the latest state
+ refetchV1Delegations();
+ refetchV2Delegations();
+ }
+ setProcessing(false);
+ } catch (error: any) {
+ handleError({
+ error,
+ });
+ reset();
+ }
+ }, [
+ setStep,
+ selectedDelegation,
+ handleError,
+ setProcessing,
+ transitionPhase1Delegation,
+ sendBbnTx,
+ addDelegation,
+ refetchV1Delegations,
+ refetchV2Delegations,
+ reset,
+ logger,
+ ]);
+
+ return { registerPhase1Delegation };
+}
diff --git a/src/ui/common/hooks/services/useRewardsService.ts b/src/ui/common/hooks/services/useRewardsService.ts
new file mode 100644
index 000000000..01cb8ea9d
--- /dev/null
+++ b/src/ui/common/hooks/services/useRewardsService.ts
@@ -0,0 +1,134 @@
+import { incentivetx } from "@babylonlabs-io/babylon-proto-ts";
+import { useCallback } from "react";
+
+import { ONE_SECOND } from "@/ui/common/constants";
+import { useError } from "@/ui/common/context/Error/ErrorProvider";
+import { useLogger } from "@/ui/common/hooks/useLogger";
+import { useRewardsState } from "@/ui/common/state/RewardState";
+import { retry } from "@/ui/common/utils";
+import { BBN_REGISTRY_TYPE_URLS } from "@/ui/common/utils/wallet/bbnRegistry";
+
+import { useBbnTransaction } from "../client/rpc/mutation/useBbnTransaction";
+import { useBbnQuery } from "../client/rpc/queries/useBbnQuery";
+
+const MAX_RETRY_ATTEMPTS = 3;
+
+export const useRewardsService = () => {
+ const {
+ bbnAddress,
+ openRewardModal,
+ closeRewardModal,
+ openProcessingModal,
+ closeProcessingModal,
+ setTransactionHash,
+ refetchRewardBalance,
+ setProcessing,
+ setTransactionFee,
+ } = useRewardsState();
+ const { balanceQuery } = useBbnQuery();
+ const { handleError } = useError();
+ const logger = useLogger();
+ const { estimateBbnGasFee, sendBbnTx, signBbnTx } = useBbnTransaction();
+
+ /**
+ * Estimates the gas fee for claiming rewards.
+ * @returns {Promise} The gas fee for claiming rewards.
+ */
+ const estimateClaimRewardsGas = useCallback(async (): Promise => {
+ const withdrawRewardMsg = createWithdrawRewardMsg(bbnAddress);
+ const gasFee = await estimateBbnGasFee(withdrawRewardMsg);
+ return gasFee.amount.reduce((acc, coin) => acc + Number(coin.amount), 0);
+ }, [bbnAddress, estimateBbnGasFee]);
+
+ const showPreview = useCallback(async () => {
+ setTransactionFee(0);
+ setProcessing(true);
+ openRewardModal();
+ try {
+ const fee = await estimateClaimRewardsGas();
+ setTransactionFee(fee);
+ } catch (error: any) {
+ logger.error(error, {
+ tags: { bbnAddress },
+ });
+ handleError({ error });
+ } finally {
+ setProcessing(false);
+ }
+ }, [
+ estimateClaimRewardsGas,
+ setProcessing,
+ openRewardModal,
+ setTransactionFee,
+ logger,
+ handleError,
+ bbnAddress,
+ ]);
+
+ /**
+ * Claims the rewards from the user's account.
+ */
+ const claimRewards = useCallback(async () => {
+ closeRewardModal();
+ setProcessing(true);
+ openProcessingModal();
+
+ try {
+ const msg = createWithdrawRewardMsg(bbnAddress);
+ const signedTx = await signBbnTx(msg);
+ const result = await sendBbnTx(signedTx);
+
+ if (result?.txHash) {
+ setTransactionHash(result.txHash);
+ }
+
+ await refetchRewardBalance();
+ const initialBalance = balanceQuery.data || 0;
+ await retry(
+ () => balanceQuery.refetch().then((res) => res.data),
+ (value) => value !== initialBalance,
+ ONE_SECOND,
+ MAX_RETRY_ATTEMPTS,
+ );
+ } catch (error: any) {
+ closeProcessingModal();
+ setTransactionHash("");
+ logger.error(error, {
+ tags: { bbnAddress },
+ });
+ handleError({ error });
+ } finally {
+ setProcessing(false);
+ }
+ }, [
+ closeRewardModal,
+ setProcessing,
+ openProcessingModal,
+ bbnAddress,
+ signBbnTx,
+ sendBbnTx,
+ refetchRewardBalance,
+ balanceQuery,
+ setTransactionHash,
+ closeProcessingModal,
+ handleError,
+ logger,
+ ]);
+
+ return {
+ claimRewards,
+ showPreview,
+ };
+};
+
+const createWithdrawRewardMsg = (bech32Address: string) => {
+ const withdrawRewardMsg = incentivetx.MsgWithdrawReward.fromPartial({
+ type: "btc_staker",
+ address: bech32Address,
+ });
+
+ return {
+ typeUrl: BBN_REGISTRY_TYPE_URLS.MsgWithdrawReward,
+ value: withdrawRewardMsg,
+ };
+};
diff --git a/src/ui/common/hooks/services/useStakingManagerService.ts b/src/ui/common/hooks/services/useStakingManagerService.ts
new file mode 100644
index 000000000..0e3c9ecb9
--- /dev/null
+++ b/src/ui/common/hooks/services/useStakingManagerService.ts
@@ -0,0 +1,85 @@
+import { BabylonBtcStakingManager } from "@babylonlabs-io/btc-staking-ts";
+import { useCallback } from "react";
+
+import { useBTCWallet } from "@/ui/common/context/wallet/BTCWalletProvider";
+import { useCosmosWallet } from "@/ui/common/context/wallet/CosmosWalletProvider";
+import { useBbnTransaction } from "@/ui/common/hooks/client/rpc/mutation/useBbnTransaction";
+import { useEventBus } from "@/ui/common/hooks/useEventBus";
+import { useLogger } from "@/ui/common/hooks/useLogger";
+import { useAppState } from "@/ui/common/state";
+
+export const useStakingManagerService = () => {
+ const { networkInfo } = useAppState();
+ const { signBbnTx } = useBbnTransaction();
+ const logger = useLogger();
+ const eventBus = useEventBus();
+
+ const { connected: cosmosConnected } = useCosmosWallet();
+ const {
+ network: btcNetwork,
+ connected: btcConnected,
+ signPsbt,
+ signMessage,
+ } = useBTCWallet();
+
+ const versionedParams = networkInfo?.params.bbnStakingParams?.versions;
+
+ const isLoading =
+ !btcNetwork ||
+ !cosmosConnected ||
+ !btcConnected ||
+ !signPsbt ||
+ !signMessage ||
+ !signBbnTx ||
+ !versionedParams ||
+ versionedParams.length === 0;
+
+ const createBtcStakingManager = useCallback(() => {
+ if (isLoading) {
+ logger.info("createBtcStakingManager", {
+ cosmosConnected,
+ btcConnected,
+ btcNetwork: Boolean(btcNetwork),
+ signPsbt: Boolean(signPsbt),
+ signMessage: Boolean(signMessage),
+ signBbnTx: Boolean(signBbnTx),
+ versionedParams: Boolean(versionedParams),
+ });
+
+ return null;
+ }
+
+ const btcProvider = {
+ signPsbt,
+ signMessage,
+ };
+
+ const bbnProvider = {
+ signTransaction: signBbnTx,
+ };
+
+ return new BabylonBtcStakingManager(
+ btcNetwork,
+ versionedParams,
+ btcProvider,
+ bbnProvider,
+ eventBus,
+ );
+ }, [
+ isLoading,
+ btcNetwork,
+ versionedParams,
+ logger,
+ cosmosConnected,
+ btcConnected,
+ eventBus,
+ signPsbt,
+ signMessage,
+ signBbnTx,
+ ]);
+
+ return {
+ isLoading,
+ createBtcStakingManager,
+ };
+};
diff --git a/src/ui/common/hooks/services/useStakingService.ts b/src/ui/common/hooks/services/useStakingService.ts
new file mode 100644
index 000000000..5a4b12c5e
--- /dev/null
+++ b/src/ui/common/hooks/services/useStakingService.ts
@@ -0,0 +1,210 @@
+import { useCallback } from "react";
+
+import { getDelegationV2 } from "@/ui/common/api/getDelegationsV2";
+import { ONE_SECOND } from "@/ui/common/constants";
+import { useError } from "@/ui/common/context/Error/ErrorProvider";
+import { useBTCWallet } from "@/ui/common/context/wallet/BTCWalletProvider";
+import { useCosmosWallet } from "@/ui/common/context/wallet/CosmosWalletProvider";
+import { ClientError } from "@/ui/common/errors";
+import { ERROR_CODES } from "@/ui/common/errors/codes";
+import { useLogger } from "@/ui/common/hooks/useLogger";
+import { useDelegationV2State } from "@/ui/common/state/DelegationV2State";
+import {
+ StakingStep,
+ useStakingState,
+ type FormFields,
+} from "@/ui/common/state/StakingState";
+import {
+ DelegationV2StakingState as DelegationState,
+ DelegationV2,
+} from "@/ui/common/types/delegationsV2";
+import { retry } from "@/ui/common/utils";
+import { btcToSatoshi } from "@/ui/common/utils/btc";
+
+import { useBbnTransaction } from "../client/rpc/mutation/useBbnTransaction";
+
+import { useTransactionService } from "./useTransactionService";
+
+export function useStakingService() {
+ const { setFormData, goToStep, setProcessing, setVerifiedDelegation, reset } =
+ useStakingState();
+ const { sendBbnTx } = useBbnTransaction();
+ const { refetch: refetchDelegations } = useDelegationV2State();
+ const { addDelegation, updateDelegationStatus } = useDelegationV2State();
+ const { estimateStakingFee, createDelegationEoi, submitStakingTx } =
+ useTransactionService();
+ const { handleError } = useError();
+ const { publicKeyNoCoord, address: btcAddress } = useBTCWallet();
+ const { bech32Address } = useCosmosWallet();
+ const logger = useLogger();
+
+ const calculateFeeAmount = useCallback(
+ ({
+ finalityProviders,
+ amount,
+ term,
+ feeRate,
+ }: Omit) => {
+ const eoiInput = {
+ finalityProviderPksNoCoordHex: finalityProviders || [],
+ stakingAmountSat: btcToSatoshi(amount),
+ stakingTimelock: term,
+ feeRate: feeRate,
+ };
+ return estimateStakingFee(eoiInput, feeRate);
+ },
+ [estimateStakingFee],
+ );
+
+ const displayPreview = useCallback(
+ (formFields: FormFields) => {
+ setFormData(formFields);
+ goToStep(StakingStep.PREVIEW);
+ },
+ [setFormData, goToStep],
+ );
+
+ const createEOI = useCallback(
+ async ({ finalityProviders, amount, term, feeRate }: FormFields) => {
+ try {
+ const eoiInput = {
+ finalityProviderPksNoCoordHex: finalityProviders || [],
+ stakingAmountSat: amount,
+ stakingTimelock: term,
+ feeRate: feeRate,
+ };
+ setProcessing(true);
+ const { stakingTxHash, signedBabylonTx } = await createDelegationEoi(
+ eoiInput,
+ feeRate,
+ );
+
+ // Send the transaction
+ goToStep(StakingStep.EOI_SEND_BBN);
+ await sendBbnTx(signedBabylonTx);
+
+ addDelegation({
+ stakingAmount: amount,
+ stakingTxHashHex: stakingTxHash,
+ startHeight: 0,
+ state: DelegationState.INTERMEDIATE_PENDING_VERIFICATION,
+ });
+
+ goToStep(StakingStep.VERIFYING);
+
+ const delegation = await retry(
+ () => getDelegationV2(stakingTxHash),
+ (delegation) => delegation?.state === DelegationState.VERIFIED,
+ 5 * ONE_SECOND,
+ );
+
+ setVerifiedDelegation(delegation as DelegationV2);
+ refetchDelegations();
+ goToStep(StakingStep.VERIFIED);
+ setProcessing(false);
+ } catch (error: any) {
+ const metadata = {
+ userPublicKey: publicKeyNoCoord,
+ btcAddress: btcAddress,
+ babylonAddress: bech32Address,
+ };
+ const clientError = new ClientError(
+ ERROR_CODES.TRANSACTION_PREPARATION_ERROR,
+ "Error creating EOI",
+ { cause: error as Error },
+ );
+ logger.error(clientError, {
+ data: metadata,
+ });
+ handleError({
+ error,
+ metadata,
+ });
+ reset();
+ }
+ },
+ [
+ setProcessing,
+ createDelegationEoi,
+ goToStep,
+ sendBbnTx,
+ addDelegation,
+ setVerifiedDelegation,
+ handleError,
+ reset,
+ refetchDelegations,
+ publicKeyNoCoord,
+ btcAddress,
+ bech32Address,
+ logger,
+ ],
+ );
+
+ const stakeDelegation = useCallback(
+ async (delegation: DelegationV2) => {
+ try {
+ setProcessing(true);
+
+ const {
+ finalityProviderBtcPksHex,
+ stakingAmount,
+ stakingTimelock,
+ paramsVersion,
+ stakingTxHashHex,
+ stakingTxHex,
+ } = delegation;
+
+ await submitStakingTx(
+ {
+ finalityProviderPksNoCoordHex: finalityProviderBtcPksHex,
+ stakingAmountSat: stakingAmount,
+ stakingTimelock,
+ },
+ paramsVersion,
+ stakingTxHashHex,
+ stakingTxHex,
+ );
+ updateDelegationStatus(
+ stakingTxHashHex,
+ DelegationState.INTERMEDIATE_PENDING_BTC_CONFIRMATION,
+ );
+ reset();
+ goToStep(StakingStep.FEEDBACK_SUCCESS);
+ } catch (error: any) {
+ const clientError = new ClientError(
+ ERROR_CODES.TRANSACTION_SUBMISSION_ERROR,
+ "Error submitting staking transaction",
+ { cause: error as Error },
+ );
+ logger.error(clientError);
+ reset();
+ handleError({
+ error,
+ displayOptions: {
+ retryAction: () => stakeDelegation(delegation),
+ },
+ metadata: {
+ stakingTxHash: delegation.stakingTxHashHex,
+ userPublicKey: publicKeyNoCoord,
+ btcAddress: btcAddress,
+ babylonAddress: bech32Address,
+ },
+ });
+ }
+ },
+ [
+ updateDelegationStatus,
+ submitStakingTx,
+ goToStep,
+ setProcessing,
+ reset,
+ handleError,
+ publicKeyNoCoord,
+ btcAddress,
+ bech32Address,
+ logger,
+ ],
+ );
+
+ return { calculateFeeAmount, displayPreview, createEOI, stakeDelegation };
+}
diff --git a/src/ui/common/hooks/services/useTransactionService.ts b/src/ui/common/hooks/services/useTransactionService.ts
new file mode 100644
index 000000000..4020a02b3
--- /dev/null
+++ b/src/ui/common/hooks/services/useTransactionService.ts
@@ -0,0 +1,490 @@
+import { BabylonBtcStakingManager } from "@babylonlabs-io/btc-staking-ts";
+import { Transaction } from "bitcoinjs-lib";
+import { useCallback, useMemo } from "react";
+
+import { useBTCWallet } from "@/ui/common/context/wallet/BTCWalletProvider";
+import { useCosmosWallet } from "@/ui/common/context/wallet/CosmosWalletProvider";
+import { ClientError, ERROR_CODES } from "@/ui/common/errors";
+import { useLogger } from "@/ui/common/hooks/useLogger";
+import { useAppState } from "@/ui/common/state";
+import { validateStakingInput } from "@/ui/common/utils/delegations";
+import { getFeeRateFromMempool } from "@/ui/common/utils/getFeeRateFromMempool";
+import { getTxInfo, getTxMerkleProof } from "@/ui/common/utils/mempool_api";
+
+import { useNetworkFees } from "../client/api/useNetworkFees";
+import { useBbnQuery } from "../client/rpc/queries/useBbnQuery";
+
+import { useStakingManagerService } from "./useStakingManagerService";
+
+export interface BtcStakingInputs {
+ finalityProviderPksNoCoordHex: string[];
+ stakingAmountSat: number;
+ stakingTimelock: number;
+}
+
+export const useTransactionService = () => {
+ const { availableUTXOs, refetchUTXOs } = useAppState();
+
+ const { data: networkFees } = useNetworkFees();
+ const { defaultFeeRate } = getFeeRateFromMempool(networkFees);
+ const {
+ btcTipQuery: { data: tipHeader },
+ } = useBbnQuery();
+
+ const { bech32Address } = useCosmosWallet();
+ const { publicKeyNoCoord, address: btcAddress, pushTx } = useBTCWallet();
+ const logger = useLogger();
+
+ const stakerInfo = useMemo(
+ () => ({
+ address: btcAddress,
+ publicKeyNoCoordHex: publicKeyNoCoord,
+ }),
+ [btcAddress, publicKeyNoCoord],
+ );
+
+ const tipHeight = useMemo(() => tipHeader?.height ?? 0, [tipHeader]);
+
+ const { createBtcStakingManager } = useStakingManagerService();
+
+ /**
+ * Create the delegation EOI
+ *
+ * @param stakingInput - The staking inputs
+ * @param feeRate - The fee rate
+ * @returns The staking transaction hash
+ */
+ const createDelegationEoi = useCallback(
+ async (stakingInput: BtcStakingInputs, feeRate: number) => {
+ const btcStakingManager = createBtcStakingManager();
+
+ validateCommonInputs(
+ btcStakingManager,
+ stakingInput,
+ tipHeight,
+ stakerInfo,
+ );
+
+ if (!availableUTXOs) {
+ const clientError = new ClientError(
+ ERROR_CODES.INITIALIZATION_ERROR,
+ "Available UTXOs not initialized",
+ );
+ logger.error(clientError);
+ throw clientError;
+ }
+
+ const { stakingTx, signedBabylonTx } =
+ await btcStakingManager!.preStakeRegistrationBabylonTransaction(
+ stakerInfo,
+ stakingInput,
+ tipHeight,
+ availableUTXOs,
+ feeRate,
+ bech32Address,
+ );
+ return {
+ stakingTxHash: stakingTx.getId(),
+ signedBabylonTx,
+ };
+ },
+ [
+ availableUTXOs,
+ bech32Address,
+ createBtcStakingManager,
+ stakerInfo,
+ tipHeight,
+ logger,
+ ],
+ );
+
+ /**
+ * Estimate the staking fee
+ *
+ * @param stakingInput - The staking inputs
+ * @param feeRate - The fee rate
+ * @returns The staking fee
+ */
+ const estimateStakingFee = useCallback(
+ (stakingInput: BtcStakingInputs, feeRate: number): number => {
+ logger.info("Estimating staking fee", {
+ feeRate,
+ });
+ const btcStakingManager = createBtcStakingManager();
+ validateCommonInputs(
+ btcStakingManager,
+ stakingInput,
+ tipHeight,
+ stakerInfo,
+ );
+ if (!availableUTXOs) {
+ const clientError = new ClientError(
+ ERROR_CODES.INITIALIZATION_ERROR,
+ "Available UTXOs not initialized",
+ );
+ logger.error(clientError);
+ throw clientError;
+ }
+ const fee = btcStakingManager!.estimateBtcStakingFee(
+ stakerInfo,
+ tipHeight,
+ stakingInput,
+ availableUTXOs,
+ feeRate,
+ );
+ return fee;
+ },
+ [createBtcStakingManager, tipHeight, stakerInfo, availableUTXOs, logger],
+ );
+
+ /**
+ * Transition the delegation to phase 1
+ *
+ * @param stakingTxHex - The staking transaction hex
+ * @param stakingHeight - The staking height of the phase-1 delegation
+ * @param stakingInput - The staking inputs
+ */
+ const transitionPhase1Delegation = useCallback(
+ async (
+ stakingTxHex: string,
+ stakingHeight: number,
+ stakingInput: BtcStakingInputs,
+ ) => {
+ const btcStakingManager = createBtcStakingManager();
+ validateCommonInputs(
+ btcStakingManager,
+ stakingInput,
+ tipHeight,
+ stakerInfo,
+ );
+
+ const stakingTx = Transaction.fromHex(stakingTxHex);
+ const inclusionProof = await getInclusionProof(stakingTx);
+
+ logger.info("Transitioning delegation", {
+ stakingHeight,
+ stakingTxId: stakingTx.getId(),
+ });
+
+ const { signedBabylonTx } =
+ await btcStakingManager!.postStakeRegistrationBabylonTransaction(
+ stakerInfo,
+ stakingTx,
+ stakingHeight,
+ stakingInput,
+ inclusionProof,
+ bech32Address,
+ );
+
+ return {
+ stakingTxHash: stakingTx.getId(),
+ signedBabylonTx,
+ };
+ },
+ [bech32Address, createBtcStakingManager, stakerInfo, tipHeight, logger],
+ );
+
+ /**
+ * Submit the staking transaction
+ *
+ * @param stakingInput - The staking inputs
+ * @param paramVersion - The param version
+ * @param expectedTxHashHex - The expected transaction hash hex
+ * @param stakingTxHex - The staking transaction hex
+ */
+ const submitStakingTx = useCallback(
+ async (
+ stakingInput: BtcStakingInputs,
+ paramVersion: number,
+ expectedTxHashHex: string,
+ unsignedStakingTxHex: string,
+ ) => {
+ const btcStakingManager = createBtcStakingManager();
+ validateCommonInputs(
+ btcStakingManager,
+ stakingInput,
+ tipHeight,
+ stakerInfo,
+ );
+ if (!availableUTXOs) {
+ const clientError = new ClientError(
+ ERROR_CODES.INITIALIZATION_ERROR,
+ "Available UTXOs not initialized",
+ );
+ logger.error(clientError);
+ throw clientError;
+ }
+
+ const unsignedStakingTx = Transaction.fromHex(unsignedStakingTxHex);
+
+ const signedStakingTx =
+ await btcStakingManager!.createSignedBtcStakingTransaction(
+ stakerInfo,
+ stakingInput,
+ unsignedStakingTx,
+ availableUTXOs,
+ paramVersion,
+ );
+
+ if (signedStakingTx.getId() !== expectedTxHashHex) {
+ const clientError = new ClientError(
+ ERROR_CODES.VALIDATION_ERROR,
+ `Staking transaction hash mismatch, expected ${expectedTxHashHex} but got ${signedStakingTx.getId()}`,
+ );
+ logger.error(clientError, {
+ data: {
+ expectedTxHashHex,
+ unsignedStakingTxHex,
+ },
+ });
+ throw clientError;
+ }
+ await pushTx(signedStakingTx.toHex());
+ refetchUTXOs();
+ },
+ [
+ availableUTXOs,
+ createBtcStakingManager,
+ pushTx,
+ refetchUTXOs,
+ stakerInfo,
+ tipHeight,
+ logger,
+ ],
+ );
+
+ /**
+ * Submit the unbonding transaction
+ *
+ * @param stakingInput - The staking inputs
+ * @param paramVersion - The param version of the EOI
+ * @param stakingTxHex - The staking transaction hex
+ * @param unbondingTxHex - The unbonding transaction hex
+ * @param covenantUnbondingSignatures - The covenant unbonding signatures
+ */
+ const submitUnbondingTx = useCallback(
+ async (
+ stakingInput: BtcStakingInputs,
+ paramVersion: number,
+ stakingTxHex: string,
+ unbondingTxHex: string,
+ covenantUnbondingSignatures: {
+ btcPkHex: string;
+ sigHex: string;
+ }[],
+ ) => {
+ const btcStakingManager = createBtcStakingManager();
+ validateCommonInputs(
+ btcStakingManager,
+ stakingInput,
+ tipHeight,
+ stakerInfo,
+ );
+
+ const unsignedUnbondingTx = Transaction.fromHex(unbondingTxHex);
+
+ const { transaction: signedUnbondingTx } =
+ await btcStakingManager!.createSignedBtcUnbondingTransaction(
+ stakerInfo,
+ stakingInput,
+ paramVersion,
+ Transaction.fromHex(stakingTxHex),
+ unsignedUnbondingTx,
+ covenantUnbondingSignatures,
+ );
+
+ await pushTx(signedUnbondingTx.toHex());
+ },
+ [createBtcStakingManager, pushTx, stakerInfo, tipHeight],
+ );
+
+ /**
+ * Withdraw from the early unbonding transaction which is now unbonded
+ *
+ * @param stakingInput - The staking inputs
+ * @param paramVersion - The param version of the EOI
+ * @param earlyUnbondingTxHex - The early unbonding transaction hex
+ */
+ const submitEarlyUnbondedWithdrawalTx = useCallback(
+ async (
+ stakingInput: BtcStakingInputs,
+ paramVersion: number,
+ earlyUnbondingTxHex: string,
+ ) => {
+ logger.info("Executing submitEarlyUnbondedWithdrawalTx", {
+ paramVersion,
+ earlyUnbondingTxHex,
+ });
+ const btcStakingManager = createBtcStakingManager();
+ validateCommonInputs(
+ btcStakingManager,
+ stakingInput,
+ tipHeight,
+ stakerInfo,
+ );
+
+ const { transaction: signedWithdrawalTx } =
+ await btcStakingManager!.createSignedBtcWithdrawEarlyUnbondedTransaction(
+ stakerInfo,
+ stakingInput,
+ paramVersion,
+ Transaction.fromHex(earlyUnbondingTxHex),
+ defaultFeeRate,
+ );
+ await pushTx(signedWithdrawalTx.toHex());
+ },
+ [
+ createBtcStakingManager,
+ defaultFeeRate,
+ pushTx,
+ stakerInfo,
+ tipHeight,
+ logger,
+ ],
+ );
+
+ /**
+ * Submit the timelock unbonded withdrawal transaction
+ *
+ * @param stakingInput - The staking inputs
+ * @param paramVersion - The param version of the EOI
+ * @param stakingTxHex - The staking transaction hex
+ */
+ const submitTimelockUnbondedWithdrawalTx = useCallback(
+ async (
+ stakingInput: BtcStakingInputs,
+ paramVersion: number,
+ stakingTxHex: string,
+ ) => {
+ logger.info("Executing submitTimelockUnbondedWithdrawalTx", {
+ paramVersion,
+ stakingTxHash: Transaction.fromHex(stakingTxHex).getId(),
+ });
+ const btcStakingManager = createBtcStakingManager();
+ validateCommonInputs(
+ btcStakingManager,
+ stakingInput,
+ tipHeight,
+ stakerInfo,
+ );
+
+ const { transaction: signedWithdrawalTx } =
+ await btcStakingManager!.createSignedBtcWithdrawStakingExpiredTransaction(
+ stakerInfo,
+ stakingInput,
+ paramVersion,
+ Transaction.fromHex(stakingTxHex),
+ defaultFeeRate,
+ );
+ await pushTx(signedWithdrawalTx.toHex());
+ },
+ [
+ createBtcStakingManager,
+ defaultFeeRate,
+ pushTx,
+ stakerInfo,
+ tipHeight,
+ logger,
+ ],
+ );
+
+ /**
+ * Submit the withdrawal transaction for a slashed staking
+ *
+ * @param stakingInput - The staking inputs
+ * @param paramVersion - The param version of the EOI
+ * @param slashingTxHex - The slashing transaction hex that to be withdrawn
+ */
+ const submitSlashingWithdrawalTx = useCallback(
+ async (
+ stakingInput: BtcStakingInputs,
+ paramVersion: number,
+ slashingTxHex: string,
+ ) => {
+ const btcStakingManager = createBtcStakingManager();
+ validateCommonInputs(
+ btcStakingManager,
+ stakingInput,
+ tipHeight,
+ stakerInfo,
+ );
+
+ const { transaction: signedWithdrawalTx } =
+ await btcStakingManager!.createSignedBtcWithdrawSlashingTransaction(
+ stakerInfo,
+ stakingInput,
+ paramVersion,
+ Transaction.fromHex(slashingTxHex),
+ defaultFeeRate,
+ );
+ await pushTx(signedWithdrawalTx.toHex());
+ },
+ [createBtcStakingManager, defaultFeeRate, pushTx, stakerInfo, tipHeight],
+ );
+
+ return {
+ createDelegationEoi,
+ estimateStakingFee,
+ transitionPhase1Delegation,
+ submitStakingTx,
+ submitUnbondingTx,
+ submitEarlyUnbondedWithdrawalTx,
+ submitTimelockUnbondedWithdrawalTx,
+ submitSlashingWithdrawalTx,
+ tipHeight,
+ };
+};
+
+/**
+ * Get the inclusion proof for a staking transaction
+ * @param stakingTx - The staking transaction
+ * @returns The inclusion proof
+ */
+const getInclusionProof = async (stakingTx: Transaction) => {
+ // Get the merkle proof
+ const { pos, merkle } = await getTxMerkleProof(stakingTx.getId());
+
+ const {
+ status: { blockHash: blockHashHex },
+ } = await getTxInfo(stakingTx.getId());
+
+ return {
+ pos,
+ merkle,
+ blockHashHex,
+ };
+};
+
+/**
+ * Validate the common inputs
+ * @param btcStakingManager - The BTC Staking Manager
+ * @param stakingInput - The staking inputs (e.g. amount, timelock, etc.)
+ * @param tipHeight - The BTC tip height from the Babylon Genesis
+ * @param stakerInfo - The staker info (e.g. address, public key, etc.)
+ */
+const validateCommonInputs = (
+ btcStakingManager: BabylonBtcStakingManager | null,
+ stakingInput: BtcStakingInputs,
+ tipHeight: number,
+ stakerInfo: { address: string; publicKeyNoCoordHex: string },
+) => {
+ validateStakingInput(stakingInput);
+ if (!btcStakingManager) {
+ throw new ClientError(
+ ERROR_CODES.INITIALIZATION_ERROR,
+ "BTC Staking Manager not initialized",
+ );
+ }
+ if (!tipHeight) {
+ throw new ClientError(
+ ERROR_CODES.INITIALIZATION_ERROR,
+ "Tip height not initialized",
+ );
+ }
+ if (!stakerInfo.address || !stakerInfo.publicKeyNoCoordHex) {
+ throw new ClientError(
+ ERROR_CODES.INITIALIZATION_ERROR,
+ "Staker info not initialized",
+ );
+ }
+};
diff --git a/src/ui/common/hooks/services/useV1TransactionService.ts b/src/ui/common/hooks/services/useV1TransactionService.ts
new file mode 100644
index 000000000..205d5088f
--- /dev/null
+++ b/src/ui/common/hooks/services/useV1TransactionService.ts
@@ -0,0 +1,255 @@
+import {
+ BabylonBtcStakingManager,
+ getUnbondingTxStakerSignature,
+ TransactionResult,
+ VersionedStakingParams,
+} from "@babylonlabs-io/btc-staking-ts";
+import { Transaction } from "bitcoinjs-lib";
+import { useCallback, useMemo } from "react";
+
+import { getUnbondingEligibility } from "@/ui/common/api/getUnbondingEligibility";
+import { postUnbonding } from "@/ui/common/api/postUnbonding";
+import { useBTCWallet } from "@/ui/common/context/wallet/BTCWalletProvider";
+import { ClientError, ERROR_CODES } from "@/ui/common/errors";
+import { useLogger } from "@/ui/common/hooks/useLogger";
+import { useAppState } from "@/ui/common/state";
+import { validateStakingInput } from "@/ui/common/utils/delegations";
+import { txFeeSafetyCheck } from "@/ui/common/utils/delegations/fee";
+import { getFeeRateFromMempool } from "@/ui/common/utils/getFeeRateFromMempool";
+import { getBbnParamByBtcHeight } from "@/ui/common/utils/params";
+
+import { useNetworkFees } from "../client/api/useNetworkFees";
+
+import { useStakingManagerService } from "./useStakingManagerService";
+import { BtcStakingInputs } from "./useTransactionService";
+
+export function useV1TransactionService() {
+ const { publicKeyNoCoord, address: btcAddress, pushTx } = useBTCWallet();
+ const { data: networkFees } = useNetworkFees();
+ const { defaultFeeRate } = getFeeRateFromMempool(networkFees);
+ const { networkInfo } = useAppState();
+ const logger = useLogger();
+
+ const stakerBtcInfo = useMemo(
+ () => ({
+ address: btcAddress,
+ publicKeyNoCoordHex: publicKeyNoCoord,
+ }),
+ [btcAddress, publicKeyNoCoord],
+ );
+
+ // We use phase-2 parameters instead of legacy global parameters.
+ // Phase-2 BBN parameters include all phase-1 global parameters,
+ // except for the "tag" field which is only used for staking transactions.
+ // The "tag" is not needed for withdrawal or unbonding transactions.
+ const versionedParams = networkInfo?.params.bbnStakingParams?.versions;
+
+ const { createBtcStakingManager } = useStakingManagerService();
+
+ /**
+ * Submit the unbonding transaction to babylon API for further processing
+ * The system will gather covenant signatures and submit the unbonding
+ * transaction to the Bitcoin network
+ *
+ * @param stakingInput - The staking inputs
+ * @param stakingHeight - The height of the staking transaction
+ * @param stakingTxHex - The staking transaction hex
+ */
+ const submitUnbondingTx = useCallback(
+ async (
+ stakingInput: BtcStakingInputs,
+ stakingHeight: number,
+ stakingTxHex: string,
+ ) => {
+ const btcStakingManager = createBtcStakingManager();
+ validateCommonInputs(
+ btcStakingManager,
+ stakingInput,
+ stakerBtcInfo,
+ versionedParams,
+ logger,
+ );
+
+ const stakingTx = Transaction.fromHex(stakingTxHex);
+
+ // Check if this staking transaction is eligible for unbonding
+ const eligibility = await getUnbondingEligibility(stakingTx.getId());
+
+ if (!eligibility) {
+ const clientError = new ClientError(
+ ERROR_CODES.VALIDATION_ERROR,
+ "Transaction not eligible for unbonding",
+ );
+ logger.warn(clientError.message);
+ throw clientError;
+ }
+
+ // Get the param version based on height
+ const { version: paramsVersion } = getBbnParamByBtcHeight(
+ stakingHeight,
+ versionedParams!,
+ );
+
+ logger.info("Creating unbonding transaction", {
+ paramsVersion,
+ stakingHeight,
+ });
+
+ const { transaction: signedUnbondingTx } =
+ await btcStakingManager!.createPartialSignedBtcUnbondingTransaction(
+ stakerBtcInfo,
+ stakingInput,
+ paramsVersion,
+ stakingTx,
+ );
+
+ const stakerSignatureHex =
+ getUnbondingTxStakerSignature(signedUnbondingTx);
+
+ try {
+ logger.info("Submitting unbonding transaction to API", {
+ stakingTxId: stakingTx.getId(),
+ unbondingTxId: signedUnbondingTx.getId(),
+ });
+
+ await postUnbonding(
+ stakerSignatureHex,
+ stakingTx.getId(),
+ signedUnbondingTx.getId(),
+ signedUnbondingTx.toHex(),
+ );
+ } catch (error) {
+ const clientError = new ClientError(
+ ERROR_CODES.EXTERNAL_SERVICE_UNAVAILABLE,
+ `Error submitting unbonding transaction: ${error instanceof Error ? error.message : String(error)}`,
+ { cause: error as Error },
+ );
+ logger.error(clientError);
+ throw clientError;
+ }
+ },
+ [createBtcStakingManager, stakerBtcInfo, versionedParams, logger],
+ );
+
+ /**
+ * Submit the withdrawal transaction
+ * For withdrawal from a staking transaction that has expired, or from an early
+ * unbonding transaction
+ * If earlyUnbondingTxHex is provided, the early unbonding transaction will be used,
+ * otherwise the staking transaction will be used
+ *
+ * @param stakingInput - The staking inputs (e.g. amount, timelock, etc.)
+ * @param stakingHeight - The height of the staking transaction
+ * @param stakingTxHex - The staking transaction hex
+ * @param earlyUnbondingTxHex - The early unbonding transaction hex
+ */
+ const submitWithdrawalTx = useCallback(
+ async (
+ stakingInput: BtcStakingInputs,
+ stakingHeight: number,
+ stakingTxHex: string,
+ earlyUnbondingTxHex?: string,
+ ) => {
+ logger.info("Starting withdrawal transaction submission", {
+ stakingHeight,
+ hasEarlyUnbonding: Boolean(earlyUnbondingTxHex),
+ });
+
+ const btcStakingManager = createBtcStakingManager();
+ validateCommonInputs(
+ btcStakingManager,
+ stakingInput,
+ stakerBtcInfo,
+ versionedParams,
+ logger,
+ );
+
+ // Get the param version based on height
+ const { version: paramVersion } = getBbnParamByBtcHeight(
+ stakingHeight,
+ versionedParams!,
+ );
+
+ let result: TransactionResult;
+
+ if (earlyUnbondingTxHex) {
+ const earlyUnbondingTx = Transaction.fromHex(earlyUnbondingTxHex);
+ result =
+ await btcStakingManager!.createSignedBtcWithdrawEarlyUnbondedTransaction(
+ stakerBtcInfo,
+ stakingInput,
+ paramVersion,
+ earlyUnbondingTx,
+ defaultFeeRate,
+ );
+ } else {
+ result =
+ await btcStakingManager!.createSignedBtcWithdrawStakingExpiredTransaction(
+ stakerBtcInfo,
+ stakingInput,
+ paramVersion,
+ Transaction.fromHex(stakingTxHex),
+ defaultFeeRate,
+ );
+ }
+
+ // Perform a safety check on the estimated transaction fee
+ txFeeSafetyCheck(result.transaction, defaultFeeRate, result.fee);
+
+ await pushTx(result.transaction.toHex());
+ },
+ [
+ createBtcStakingManager,
+ defaultFeeRate,
+ pushTx,
+ stakerBtcInfo,
+ versionedParams,
+ logger,
+ ],
+ );
+
+ return {
+ submitUnbondingTx,
+ submitWithdrawalTx,
+ };
+}
+
+/**
+ * Validate the common inputs
+ * @param btcStakingManager - The BTC Staking Manager
+ * @param stakingInput - The staking inputs (e.g. amount, timelock, etc.)
+ * @param stakerInfo - The staker info (e.g. address, public key, etc.)
+ */
+const validateCommonInputs = (
+ btcStakingManager: BabylonBtcStakingManager | null,
+ stakingInput: BtcStakingInputs,
+ stakerBtcInfo: { address: string; publicKeyNoCoordHex: string },
+ versionedParams?: VersionedStakingParams[],
+ logger?: ReturnType,
+) => {
+ validateStakingInput(stakingInput);
+ if (!btcStakingManager) {
+ const clientError = new ClientError(
+ ERROR_CODES.INITIALIZATION_ERROR,
+ "BTC Staking Manager not initialized",
+ );
+ logger?.warn(clientError.message);
+ throw clientError;
+ }
+ if (!stakerBtcInfo.address || !stakerBtcInfo.publicKeyNoCoordHex) {
+ const clientError = new ClientError(
+ ERROR_CODES.INITIALIZATION_ERROR,
+ "Staker info not initialized",
+ );
+ logger?.warn(clientError.message);
+ throw clientError;
+ }
+ if (!versionedParams?.length) {
+ const clientError = new ClientError(
+ ERROR_CODES.INITIALIZATION_ERROR,
+ "Staking params not loaded",
+ );
+ logger?.warn(clientError.message);
+ throw clientError;
+ }
+};
diff --git a/src/ui/common/hooks/storage/useDelegationStorage.ts b/src/ui/common/hooks/storage/useDelegationStorage.ts
new file mode 100644
index 000000000..f3f29e9b7
--- /dev/null
+++ b/src/ui/common/hooks/storage/useDelegationStorage.ts
@@ -0,0 +1,134 @@
+import { useCallback, useEffect, useMemo } from "react";
+import { useLocalStorage } from "usehooks-ts";
+
+import {
+ DELEGATION_STATUSES,
+ DelegationLike,
+ DelegationV2,
+ DelegationV2StakingState as State,
+} from "@/ui/common/types/delegationsV2";
+
+export function useDelegationStorage(
+ key: string,
+ delegations?: DelegationV2[],
+) {
+ const [pendingDelegations = {}, setPendingDelegations] = useLocalStorage<
+ Record
+ >(`${key}_pending`, {});
+ const [delegationStatuses = {}, setDelegationStatuses] = useLocalStorage<
+ Record
+ >(`${key}_statuses`, {});
+
+ const delegationMap = useMemo(() => {
+ return (delegations ?? []).reduce(
+ (acc, delegation) => ({
+ ...acc,
+ [delegation.stakingTxHashHex]: delegation,
+ }),
+ {} as Record,
+ );
+ }, [delegations]);
+
+ const formattedDelegations = useMemo(() => {
+ const pendingDelegationArr = Object.values(pendingDelegations).map(
+ (d) =>
+ ({
+ ...d,
+ stakingTxHex: "",
+ paramsVersion: 0,
+ finalityProviderBtcPksHex: [],
+ stakerBtcPkHex: "",
+ stakingTimelock: 0,
+ endHeight: 0,
+ unbondingTimelock: 0,
+ unbondingTxHex: "",
+ stakingSlashingTxHex: "",
+ bbnInceptionHeight: 0,
+ bbnInceptionTime: new Date().toISOString(),
+ slashing: {
+ stakingSlashingTxHex: "",
+ unbondingSlashingTxHex: "",
+ spendingHeight: 0,
+ },
+ }) as DelegationV2,
+ );
+
+ return pendingDelegationArr.concat(
+ (delegations ?? [])
+ .filter((d) => !pendingDelegations[d.stakingTxHashHex])
+ .map((d) => ({
+ ...d,
+ state: delegationStatuses[d.stakingTxHashHex] ?? d.state,
+ })),
+ );
+ }, [delegations, pendingDelegations, delegationStatuses]);
+
+ useEffect(
+ function syncPendingDelegations() {
+ if (!key) return;
+
+ setPendingDelegations((delegations) =>
+ Object.values(delegations)
+ .filter((d) => !delegationMap[d.stakingTxHashHex])
+ .reduce(
+ (acc, d) => ({ ...acc, [d.stakingTxHashHex]: d }),
+ {} as Record,
+ ),
+ );
+ },
+ [key, delegationMap, setPendingDelegations],
+ );
+
+ useEffect(
+ function syncDelegationStatuses() {
+ if (!key) return;
+
+ setDelegationStatuses((statuses) =>
+ Object.entries(statuses)
+ .filter(([hash, status]) => {
+ if (!delegationMap[hash]?.state) return true;
+
+ return (
+ DELEGATION_STATUSES[delegationMap[hash].state] <
+ DELEGATION_STATUSES[status]
+ );
+ })
+ .reduce(
+ (acc, [hash, status]) => ({ ...acc, [hash]: status }),
+ {} as Record,
+ ),
+ );
+ },
+ [key, delegationMap, setDelegationStatuses],
+ );
+
+ const addPendingDelegation = useCallback(
+ (delegation: DelegationLike) => {
+ if (!key) return;
+
+ setPendingDelegations((delegations) => ({
+ ...delegations,
+ [delegation.stakingTxHashHex]: {
+ ...delegation,
+ state: State.INTERMEDIATE_PENDING_VERIFICATION,
+ },
+ }));
+ },
+ [key, setPendingDelegations],
+ );
+
+ const updateDelegationStatus = useCallback(
+ (id: string, status: State) => {
+ if (!key) return;
+
+ setDelegationStatuses((statuses) => ({ ...statuses, [id]: status }));
+ },
+ [key, setDelegationStatuses],
+ );
+
+ return {
+ delegations: formattedDelegations,
+ addPendingDelegation,
+ updateDelegationStatus,
+ };
+}
diff --git a/src/ui/common/hooks/useBreakpoint.ts b/src/ui/common/hooks/useBreakpoint.ts
new file mode 100644
index 000000000..eab889199
--- /dev/null
+++ b/src/ui/common/hooks/useBreakpoint.ts
@@ -0,0 +1,37 @@
+import { useMediaQuery } from "usehooks-ts";
+
+import { screenBreakPoints } from "@/ui/common/config/screen-breakpoints";
+
+type BreakpointKey = keyof typeof screenBreakPoints;
+
+/**
+ * Custom hook to check if the current viewport matches a specific breakpoint
+ * @param breakpoint - The breakpoint to check against ("sm" | "md" | "lg" | "xl" | "2xl")
+ * @returns boolean indicating if the viewport width is less than or equal to the specified breakpoint
+ */
+export const useBreakpoint = (breakpoint: BreakpointKey): boolean => {
+ const matches = useMediaQuery(
+ `(max-width: ${screenBreakPoints[breakpoint]})`,
+ );
+ return matches;
+};
+
+// Returns true if the viewport is mobile
+export const useIsMobileView = () => {
+ return useBreakpoint("md");
+};
+
+// Returns true if the viewport is desktop
+export const useIsDesktopView = () => {
+ return useBreakpoint("lg");
+};
+
+// Returns true if the viewport is large desktop
+export const useIsLargeDesktopView = () => {
+ return useBreakpoint("xl");
+};
+
+// Returns true if the viewport is extra large desktop
+export const useIsExtraLargeDesktopView = () => {
+ return useBreakpoint("2xl");
+};
diff --git a/src/ui/common/hooks/useCurrentTime.tsx b/src/ui/common/hooks/useCurrentTime.tsx
new file mode 100644
index 000000000..62def87c4
--- /dev/null
+++ b/src/ui/common/hooks/useCurrentTime.tsx
@@ -0,0 +1,17 @@
+import { useEffect, useState } from "react";
+
+import { ONE_MINUTE } from "@/ui/common/constants";
+
+export function useCurrentTime(refreshInterval: number = 60 * ONE_MINUTE) {
+ const [currentTime, setCurrentTime] = useState(() => Date.now());
+
+ useEffect(() => {
+ const timerId = setInterval(() => {
+ setCurrentTime(Date.now());
+ }, refreshInterval);
+
+ return () => clearInterval(timerId);
+ }, [refreshInterval]);
+
+ return currentTime;
+}
diff --git a/src/ui/common/hooks/useDI.ts b/src/ui/common/hooks/useDI.ts
new file mode 100644
index 000000000..536e725d6
--- /dev/null
+++ b/src/ui/common/hooks/useDI.ts
@@ -0,0 +1,7 @@
+import { useMemo } from "react";
+
+import diContainer from "@/containers";
+
+export function useDI(name: N): DI.Container[N] {
+ return useMemo(() => diContainer.resolve(name), [name]);
+}
diff --git a/src/ui/common/hooks/useEventBus.ts b/src/ui/common/hooks/useEventBus.ts
new file mode 100644
index 000000000..f6582cd5a
--- /dev/null
+++ b/src/ui/common/hooks/useEventBus.ts
@@ -0,0 +1,10 @@
+import type { ManagerEvents } from "@babylonlabs-io/btc-staking-ts";
+import { createNanoEvents } from "nanoevents";
+
+export type EventBusEvents = ManagerEvents;
+
+const eventBus = createNanoEvents();
+
+export function useEventBus() {
+ return eventBus;
+}
diff --git a/src/ui/common/hooks/useFormError.ts b/src/ui/common/hooks/useFormError.ts
new file mode 100644
index 000000000..8717f3784
--- /dev/null
+++ b/src/ui/common/hooks/useFormError.ts
@@ -0,0 +1,32 @@
+// Removed unused React import to avoid triggering the TypeScript `noUnusedLocals` rule.
+
+import { useFormState } from "@babylonlabs-io/core-ui";
+
+import { useMultistakingState } from "../state/MultistakingState";
+
+interface FieldError {
+ field: string;
+ message: string;
+ level: "error" | "warning" | "default";
+}
+
+export function useFormError(): FieldError | undefined {
+ const { errors } = useFormState();
+ const { formFields } = useMultistakingState();
+
+ const fieldErrors = formFields
+ .map(({ field, errors: errorOptions }) => {
+ const error = errors[field];
+
+ return error
+ ? {
+ field,
+ message: error.message as string,
+ level: errorOptions?.[error.type as string]?.level ?? "default",
+ }
+ : null;
+ })
+ .filter(Boolean) as FieldError[];
+
+ return fieldErrors[0];
+}
diff --git a/src/ui/common/hooks/useHealthCheck.ts b/src/ui/common/hooks/useHealthCheck.ts
new file mode 100644
index 000000000..b23dfbae1
--- /dev/null
+++ b/src/ui/common/hooks/useHealthCheck.ts
@@ -0,0 +1,60 @@
+import { useQuery } from "@tanstack/react-query";
+import { useEffect } from "react";
+
+import { ClientError, ERROR_CODES } from "@/ui/common/errors";
+import { useLogger } from "@/ui/common/hooks/useLogger";
+import { getHealthCheck } from "@/ui/common/services/healthCheckService";
+import { HealthCheckStatus } from "@/ui/common/types/services/healthCheck";
+
+import { useError } from "../context/Error/ErrorProvider";
+
+export const HEALTH_CHECK_KEY = "HEALTH_CHECK";
+
+export const useHealthCheck = () => {
+ const { handleError } = useError();
+ const logger = useLogger();
+
+ const { data, error, isError, isLoading, refetch } = useQuery({
+ queryKey: [HEALTH_CHECK_KEY],
+ queryFn: getHealthCheck,
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ retry: (_, error) => {
+ // Prevent retries for geoblocked errors
+ return (error as ClientError).errorCode !== ERROR_CODES.GEO_BLOCK;
+ },
+ });
+
+ const isApiNormal = data?.status === HealthCheckStatus.Normal;
+ const isGeoBlocked = error
+ ? (error as ClientError).errorCode === ERROR_CODES.GEO_BLOCK
+ : false;
+ const apiMessage = data?.message;
+
+ useEffect(() => {
+ if (isError) {
+ if (isGeoBlocked) {
+ return;
+ }
+
+ logger.error(error);
+
+ handleError({
+ error,
+ displayOptions: {
+ retryAction: refetch,
+ },
+ });
+ }
+ }, [isError, error, refetch, handleError, logger, isGeoBlocked]);
+
+ return {
+ isApiNormal,
+ isGeoBlocked,
+ apiMessage,
+ isError,
+ error,
+ isLoading,
+ refetch,
+ };
+};
diff --git a/src/ui/common/hooks/useLogger.ts b/src/ui/common/hooks/useLogger.ts
new file mode 100644
index 000000000..f986308aa
--- /dev/null
+++ b/src/ui/common/hooks/useLogger.ts
@@ -0,0 +1,49 @@
+import { SeverityLevel, addBreadcrumb, captureException } from "@sentry/react";
+import { useMemo } from "react";
+
+import { ClientError } from "@/ui/common/errors";
+
+type Context = Record & {
+ category?: string;
+};
+
+type ErrorContext = {
+ level?: SeverityLevel;
+ tags?: Record;
+ data?: Record;
+};
+
+interface Logger {
+ info(message: string, context?: Context): void;
+ warn(message: string, context?: Context): void;
+ error(error: Error, context?: ErrorContext): string;
+}
+
+const logger: Logger = {
+ info: (message, { category, ...data } = {}) =>
+ addBreadcrumb({
+ level: "info",
+ message,
+ category,
+ data,
+ }),
+ warn: (message, { category, ...data } = {}) =>
+ addBreadcrumb({
+ level: "warning",
+ message,
+ category,
+ data,
+ }),
+ error: (error, { level = "error", tags, data: extra } = {}) =>
+ captureException(error, {
+ level,
+ tags: Reflect.has(error, "errorCode")
+ ? { ...tags, errorCode: (error as ClientError).errorCode }
+ : tags,
+ extra,
+ }),
+};
+
+export function useLogger(): Logger {
+ return useMemo(() => logger, []);
+}
diff --git a/src/ui/common/hooks/useNotification.tsx b/src/ui/common/hooks/useNotification.tsx
new file mode 100644
index 000000000..b63817eb6
--- /dev/null
+++ b/src/ui/common/hooks/useNotification.tsx
@@ -0,0 +1,28 @@
+import { FaCheck } from "react-icons/fa6";
+import { LuBadgeCheck } from "react-icons/lu";
+import { PiWarningCircleBold } from "react-icons/pi";
+import { toast } from "react-toastify";
+
+import { Notification } from "@/ui/common/components/Notification/Notification";
+
+export const notifySuccess = (title: string, text: string) => {
+ toast.success( );
+};
+
+export const notifyWraning = (title: string, text: string) => {
+ toast.warning(
+ ,
+ );
+};
+
+export const notifyError = (title: string, text: string) => {
+ toast.error(
+ ,
+ );
+};
+
+export const notifyInfo = (title: string, text: string) => {
+ toast.info(
+ ,
+ );
+};
diff --git a/src/ui/common/hooks/useSentryUser.ts b/src/ui/common/hooks/useSentryUser.ts
new file mode 100644
index 000000000..9394496f3
--- /dev/null
+++ b/src/ui/common/hooks/useSentryUser.ts
@@ -0,0 +1,15 @@
+import { getIsolationScope, setUser } from "@sentry/react";
+import { useCallback } from "react";
+
+export const useSentryUser = () => {
+ const updateUser = useCallback((updates: Record) => {
+ const currentScope = getIsolationScope();
+ const currentUser = currentScope.getUser();
+ setUser({
+ ...currentUser,
+ ...updates,
+ });
+ }, []);
+
+ return { updateUser };
+};
diff --git a/src/ui/common/layout.tsx b/src/ui/common/layout.tsx
new file mode 100644
index 000000000..96ecfa787
--- /dev/null
+++ b/src/ui/common/layout.tsx
@@ -0,0 +1,35 @@
+import { Outlet } from "react-router";
+import { twJoin } from "tailwind-merge";
+
+import { network } from "@/ui/common/config/network/btc";
+import { Network } from "@/ui/common/types/network";
+import "@/ui/globals.css";
+
+import { Banner } from "./components/Banner/Banner";
+import { Footer } from "./components/Footer/Footer";
+import { Header } from "./components/Header/Header";
+import Providers from "./providers";
+
+export default function RootLayout() {
+ return (
+
+
+
+ );
+}
diff --git a/src/ui/common/not-found.tsx b/src/ui/common/not-found.tsx
new file mode 100644
index 000000000..fdf05949b
--- /dev/null
+++ b/src/ui/common/not-found.tsx
@@ -0,0 +1,13 @@
+import FourOFourErrorCharacter from "@/ui/common/assets/404-error-character.svg";
+
+import GenericError from "./components/Error/GenericError";
+
+export default function Error() {
+ return (
+
+ );
+}
diff --git a/src/ui/common/page.tsx b/src/ui/common/page.tsx
new file mode 100644
index 000000000..3f31219d8
--- /dev/null
+++ b/src/ui/common/page.tsx
@@ -0,0 +1,76 @@
+import { initBTCCurve } from "@babylonlabs-io/btc-staking-ts";
+import { useWalletConnect } from "@babylonlabs-io/wallet-connector";
+import { useEffect, useState } from "react";
+
+import { useHealthCheck } from "@/ui/common/hooks/useHealthCheck";
+
+import { Activity } from "./components/Activity/Activity";
+import { Container } from "./components/Container/Container";
+import { FAQ } from "./components/FAQ/FAQ";
+import { MultistakingFormWrapper } from "./components/Multistaking/MultistakingForm/MultistakingFormWrapper";
+import { PersonalBalance } from "./components/PersonalBalance/PersonalBalance";
+import { Stats } from "./components/Stats/Stats";
+import { Tabs } from "./components/Tabs";
+
+const Home = () => {
+ const [activeTab, setActiveTab] = useState("stake");
+
+ useEffect(() => {
+ initBTCCurve();
+ }, []);
+
+ const { connected } = useWalletConnect();
+ const { isGeoBlocked, isLoading } = useHealthCheck();
+ const isConnected = connected && !isGeoBlocked && !isLoading;
+
+ // Reset tab to "stake" when wallet disconnects
+ useEffect(() => {
+ if (!connected) {
+ setActiveTab("stake");
+ }
+ }, [connected]);
+
+ const tabItems = [
+ {
+ id: "stake",
+ label: "Stake",
+ content: ,
+ },
+ ...(isConnected
+ ? [
+ {
+ id: "balances",
+ label: "Balances",
+ content: ,
+ },
+ {
+ id: "activity",
+ label: "Activity",
+ content: ,
+ },
+ ]
+ : []),
+ {
+ id: "faqs",
+ label: "FAQs",
+ content: ,
+ },
+ ];
+
+ return (
+
+
+
+
+ );
+};
+
+export default Home;
diff --git a/src/ui/common/providers.tsx b/src/ui/common/providers.tsx
new file mode 100644
index 000000000..01d9a3a2c
--- /dev/null
+++ b/src/ui/common/providers.tsx
@@ -0,0 +1,49 @@
+import { ScrollLocker } from "@babylonlabs-io/core-ui";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
+import { ThemeProvider } from "next-themes";
+import { Suspense, useState } from "react";
+
+import { NotificationContainer } from "./components/Notification/NotificationContainer";
+import { ErrorProvider } from "./context/Error/ErrorProvider";
+import { StakingStatsProvider } from "./context/api/StakingStatsProvider";
+import { BbnRpcProvider } from "./context/rpc/BbnRpcProvider";
+import { BTCWalletProvider } from "./context/wallet/BTCWalletProvider";
+import { CosmosWalletProvider } from "./context/wallet/CosmosWalletProvider";
+import { WalletConnectionProvider } from "./context/wallet/WalletConnectionProvider";
+import { AppState } from "./state";
+
+function Providers({ children }: React.PropsWithChildren) {
+ const [client] = useState(new QueryClient());
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default Providers;
diff --git a/src/ui/common/services/healthCheckService.ts b/src/ui/common/services/healthCheckService.ts
new file mode 100644
index 000000000..7e1e113ce
--- /dev/null
+++ b/src/ui/common/services/healthCheckService.ts
@@ -0,0 +1,27 @@
+import { ClientError, ERROR_CODES } from "@/ui/common/errors";
+
+import { isError451 } from "../api/error";
+import { fetchHealthCheck } from "../api/healthCheckClient";
+import {
+ GEO_BLOCK_MESSAGE,
+ HealthCheckResult,
+ HealthCheckStatus,
+} from "../types/services/healthCheck";
+
+export const getHealthCheck = async (): Promise => {
+ try {
+ const healthCheckAPIResponse = await fetchHealthCheck();
+
+ return {
+ status: HealthCheckStatus.Normal,
+ message: healthCheckAPIResponse.data,
+ };
+ } catch (error: any) {
+ if (isError451(error.cause)) {
+ throw new ClientError(ERROR_CODES.GEO_BLOCK, GEO_BLOCK_MESSAGE, {
+ cause: error.cause,
+ });
+ }
+ throw error;
+ }
+};
diff --git a/src/ui/common/state/BalanceState.tsx b/src/ui/common/state/BalanceState.tsx
new file mode 100644
index 000000000..b7541811d
--- /dev/null
+++ b/src/ui/common/state/BalanceState.tsx
@@ -0,0 +1,142 @@
+import { useMemo, type PropsWithChildren } from "react";
+
+import { useBbnQuery } from "@/ui/common/hooks/client/rpc/queries/useBbnQuery";
+import { createStateUtils } from "@/ui/common/utils/createStateUtils";
+
+import { useAppState } from ".";
+import { DelegationV2StakingState } from "../types/delegationsV2";
+
+import { useDelegationV2State } from "./DelegationV2State";
+
+interface BalanceStateProps {
+ loading: boolean;
+ stakableBtcBalance: number;
+ totalBtcBalance: number;
+ stakedBtcBalance: number;
+ bbnBalance: number;
+ inscriptionsBtcBalance: number;
+ hasRpcError: boolean;
+ reconnectRpc: () => void;
+}
+
+const STAKED_BALANCE_STATUSES = [
+ DelegationV2StakingState.ACTIVE,
+ DelegationV2StakingState.TIMELOCK_UNBONDING,
+ DelegationV2StakingState.EARLY_UNBONDING,
+ DelegationV2StakingState.TIMELOCK_WITHDRAWABLE,
+ DelegationV2StakingState.EARLY_UNBONDING_WITHDRAWABLE,
+ DelegationV2StakingState.TIMELOCK_SLASHING_WITHDRAWABLE,
+ DelegationV2StakingState.EARLY_UNBONDING_SLASHING_WITHDRAWABLE,
+ DelegationV2StakingState.INTERMEDIATE_UNBONDING_SUBMITTED,
+];
+
+const defaultState: BalanceStateProps = {
+ loading: false,
+ stakableBtcBalance: 0,
+ totalBtcBalance: 0,
+ stakedBtcBalance: 0,
+ bbnBalance: 0,
+ inscriptionsBtcBalance: 0,
+ hasRpcError: false,
+ reconnectRpc: () => {},
+};
+
+const { StateProvider, useState: useBalanceState } =
+ createStateUtils(defaultState);
+
+export function BalanceState({ children }: PropsWithChildren) {
+ const {
+ availableUTXOs,
+ allUTXOs,
+ inscriptionsUTXOs,
+ isLoading: isBTCBalanceLoading,
+ } = useAppState();
+
+ const {
+ balanceQuery: { data: bbnBalance = 0, isLoading: isCosmosBalanceLoading },
+ hasRpcError,
+ reconnectRpc,
+ } = useBbnQuery();
+
+ const { delegations } = useDelegationV2State();
+
+ const loading = isBTCBalanceLoading || isCosmosBalanceLoading;
+
+ // Stakable BTC Balance which is the sum of all UTXOs that are not Ordinals
+ const stakableBtcBalance = useMemo(
+ () =>
+ availableUTXOs?.reduce(
+ (accumulator, item) => accumulator + item.value,
+ 0,
+ ) ?? 0,
+ [availableUTXOs],
+ );
+
+ // Total BTC Balance which is the sum of all UTXOs
+ const totalBtcBalance = useMemo(() => {
+ return (
+ allUTXOs?.reduce((accumulator, item) => accumulator + item.value, 0) ?? 0
+ );
+ }, [allUTXOs]);
+
+ const inscriptionsBtcBalance = useMemo(() => {
+ return (
+ inscriptionsUTXOs?.reduce(
+ (accumulator, item) => accumulator + item.value,
+ 0,
+ ) ?? 0
+ );
+ }, [inscriptionsUTXOs]);
+
+ // The amount of balance that is staked in the babylon system.
+ // Temporary solution to calculate total staked balance while waiting for API support.
+ // Once the API is complete, it will directly provide the staker's total balance
+ // (active + unbonding + withdrawing). For now, we manually sum up all delegation
+ // amounts that are in relevant states, including intermediate states.
+ const stakedBtcBalance = useMemo(() => {
+ const statusAmountMap = delegations.reduce(
+ (acc, delegation) => {
+ if (!STAKED_BALANCE_STATUSES.includes(delegation.state)) {
+ return acc;
+ }
+ if (!acc[delegation.state]) {
+ acc[delegation.state] = [];
+ }
+ acc[delegation.state].push(delegation.stakingAmount);
+ return acc;
+ },
+ {} as Record,
+ );
+ // Then sum up all amounts across all statuses into a single number
+ return Object.values(statusAmountMap)
+ .flat()
+ .reduce((total, amount) => total + amount, 0);
+ }, [delegations]);
+
+ const context = useMemo(
+ () => ({
+ loading,
+ stakableBtcBalance,
+ totalBtcBalance,
+ bbnBalance,
+ stakedBtcBalance,
+ inscriptionsBtcBalance,
+ hasRpcError,
+ reconnectRpc,
+ }),
+ [
+ loading,
+ stakableBtcBalance,
+ totalBtcBalance,
+ bbnBalance,
+ stakedBtcBalance,
+ inscriptionsBtcBalance,
+ hasRpcError,
+ reconnectRpc,
+ ],
+ );
+
+ return {children} ;
+}
+
+export { useBalanceState };
diff --git a/src/ui/common/state/DelegationState.tsx b/src/ui/common/state/DelegationState.tsx
new file mode 100644
index 000000000..998ec3bbd
--- /dev/null
+++ b/src/ui/common/state/DelegationState.tsx
@@ -0,0 +1,198 @@
+import type {
+ RegistrationStep,
+ SignPsbtOptions,
+} from "@babylonlabs-io/btc-staking-ts";
+import { EventData } from "@babylonlabs-io/btc-staking-ts";
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+ type PropsWithChildren,
+} from "react";
+import { useLocalStorage } from "usehooks-ts";
+
+import { useBTCWallet } from "@/ui/common/context/wallet/BTCWalletProvider";
+import { useDelegations } from "@/ui/common/hooks/client/api/useDelegations";
+import { useEventBus } from "@/ui/common/hooks/useEventBus";
+import type { Delegation } from "@/ui/common/types/delegations";
+import { createStateUtils } from "@/ui/common/utils/createStateUtils";
+import { calculateDelegationsDiff } from "@/ui/common/utils/local_storage/calculateDelegationsDiff";
+import { getDelegationsLocalStorageKey as getDelegationsKey } from "@/ui/common/utils/local_storage/getDelegationsLocalStorageKey";
+
+export type SigningStep =
+ | undefined
+ | "registration-start"
+ | "registration-staking-slashing"
+ | "registration-unbonding-slashing"
+ | "registration-proof-of-possession"
+ | "registration-sign-bbn"
+ | "registration-send-bbn"
+ | "registration-verifying"
+ | "registration-verified";
+
+interface DelegationState {
+ isLoading: boolean;
+ hasMoreDelegations: boolean;
+ delegations: Delegation[];
+ // Registration state
+ processing: boolean;
+ registrationStep?: SigningStep;
+ selectedDelegation?: Delegation;
+ // Methods
+ addDelegation: (delegation: Delegation) => void;
+ fetchMoreDelegations: () => void;
+ setRegistrationStep: (step: SigningStep, options?: SignPsbtOptions) => void;
+ setProcessing: (value: boolean) => void;
+ setSelectedDelegation: (delegation?: Delegation) => void;
+ resetRegistration: () => void;
+ refetch: () => void;
+ delegationStepOptions: EventData | undefined;
+ setDelegationStepOptions: (options?: EventData) => void;
+}
+
+export const REGISTRATION_STEP_MAP: Record = {
+ "staking-slashing": "registration-staking-slashing",
+ "unbonding-slashing": "registration-unbonding-slashing",
+ "proof-of-possession": "registration-proof-of-possession",
+ "create-btc-delegation-msg": "registration-sign-bbn",
+};
+
+const { StateProvider, useState: useDelegationState } =
+ createStateUtils({
+ isLoading: false,
+ delegations: [],
+ hasMoreDelegations: false,
+ processing: false,
+ registrationStep: undefined,
+ selectedDelegation: undefined,
+ addDelegation: () => null,
+ fetchMoreDelegations: () => null,
+ setRegistrationStep: () => null,
+ setProcessing: () => null,
+ setSelectedDelegation: () => null,
+ resetRegistration: () => null,
+ refetch: () => null,
+ delegationStepOptions: undefined,
+ setDelegationStepOptions: () => null,
+ });
+
+export function DelegationState({ children }: PropsWithChildren) {
+ const { publicKeyNoCoord } = useBTCWallet();
+ const { data, fetchNextPage, isFetchingNextPage, hasNextPage, refetch } =
+ useDelegations();
+ const eventBus = useEventBus();
+
+ // States
+ const [delegations, setDelegations] = useLocalStorage(
+ getDelegationsKey(publicKeyNoCoord),
+ [],
+ );
+ const [processing, setProcessing] = useState(false);
+ const [registrationStep, setRegistrationStep] = useState<
+ SigningStep | undefined
+ >();
+ const [selectedDelegation, setSelectedDelegation] = useState();
+ const [delegationStepOptions, setDelegationStepOptions] =
+ useState();
+
+ // Methods
+ const addDelegation = useCallback(
+ (newDelegation: Delegation) => {
+ setDelegations((delegations) => {
+ const exists = delegations.some(
+ (delegation) =>
+ delegation.stakingTxHashHex === newDelegation.stakingTxHashHex,
+ );
+
+ if (!exists) {
+ return [newDelegation, ...delegations];
+ }
+
+ return delegations;
+ });
+ },
+ [setDelegations],
+ );
+
+ const resetRegistration = useCallback(() => {
+ setSelectedDelegation(undefined);
+ setRegistrationStep(undefined);
+ setDelegationStepOptions(undefined);
+ setProcessing(false);
+ }, []);
+
+ // Sync delegations with API
+ 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],
+ );
+
+ useEffect(() => {
+ const unsubscribe = eventBus.on("delegation:register", (options) => {
+ const type = options?.type as RegistrationStep | undefined;
+
+ if (type) {
+ const stepName = REGISTRATION_STEP_MAP[type];
+ setRegistrationStep(stepName);
+ setDelegationStepOptions(options);
+ }
+ });
+
+ return unsubscribe;
+ }, [setRegistrationStep, setDelegationStepOptions, eventBus]);
+
+ // Context
+ const state = useMemo(
+ () => ({
+ delegations,
+ isLoading: isFetchingNextPage,
+ hasMoreDelegations: hasNextPage,
+ processing,
+ registrationStep,
+ selectedDelegation,
+ addDelegation,
+ fetchMoreDelegations: fetchNextPage,
+ setRegistrationStep,
+ setProcessing,
+ setSelectedDelegation,
+ resetRegistration,
+ refetch,
+ delegationStepOptions,
+ setDelegationStepOptions,
+ }),
+ [
+ delegations,
+ isFetchingNextPage,
+ hasNextPage,
+ processing,
+ registrationStep,
+ selectedDelegation,
+ addDelegation,
+ fetchNextPage,
+ setRegistrationStep,
+ setProcessing,
+ setSelectedDelegation,
+ resetRegistration,
+ refetch,
+ delegationStepOptions,
+ setDelegationStepOptions,
+ ],
+ );
+
+ return {children} ;
+}
+
+export { useDelegationState };
diff --git a/src/ui/common/state/DelegationV2State.tsx b/src/ui/common/state/DelegationV2State.tsx
new file mode 100644
index 000000000..59a4c5095
--- /dev/null
+++ b/src/ui/common/state/DelegationV2State.tsx
@@ -0,0 +1,146 @@
+import { EventData } from "@babylonlabs-io/btc-staking-ts";
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+ type PropsWithChildren,
+} from "react";
+import { useLocalStorage } from "usehooks-ts";
+
+import { useBTCWallet } from "@/ui/common/context/wallet/BTCWalletProvider";
+import { useEventBus } from "@/ui/common/hooks/useEventBus";
+import {
+ type DelegationLike,
+ type DelegationV2,
+} from "@/ui/common/types/delegationsV2";
+import { createStateUtils } from "@/ui/common/utils/createStateUtils";
+import { getDelegationsV2LocalStorageKey } from "@/ui/common/utils/local_storage/getDelegationsLocalStorageKey";
+
+import { useCosmosWallet } from "../context/wallet/CosmosWalletProvider";
+import { useDelegationsV2 } from "../hooks/client/api/useDelegationsV2";
+import { useDelegationStorage } from "../hooks/storage/useDelegationStorage";
+
+const DELEGATION_V2_CHANNELS = [
+ "delegation:stake",
+ "delegation:unbond",
+ // can be used later on if needed
+ // "delegation:withdraw",
+] as const;
+
+interface DelegationV2State {
+ isLoading: boolean;
+ isFetchingNextPage: boolean;
+ linkedDelegationsVisibility: boolean;
+ hasMoreDelegations: boolean;
+ delegations: DelegationV2[];
+ addDelegation: (delegation: DelegationLike) => void;
+ updateDelegationStatus: (is: string, status: DelegationV2["state"]) => void;
+ fetchMoreDelegations: () => void;
+ findDelegationByTxHash: (txHash: string) => DelegationV2 | undefined;
+ refetch: () => void;
+ displayLinkedDelegations: (value: boolean) => void;
+ delegationV2StepOptions: EventData | undefined;
+ setDelegationV2StepOptions: (options?: EventData) => void;
+}
+
+const { StateProvider, useState: useDelegationV2State } =
+ createStateUtils({
+ linkedDelegationsVisibility: false,
+ isLoading: false,
+ isFetchingNextPage: false,
+ delegations: [],
+ hasMoreDelegations: false,
+ addDelegation: () => {},
+ updateDelegationStatus: () => {},
+ fetchMoreDelegations: () => {},
+ findDelegationByTxHash: () => undefined,
+ refetch: () => Promise.resolve(),
+ displayLinkedDelegations: () => {},
+ delegationV2StepOptions: undefined,
+ setDelegationV2StepOptions: () => {},
+ });
+
+export function DelegationV2State({ children }: PropsWithChildren) {
+ const [showLinkedDelegations, setLinkedDelegations] = useLocalStorage(
+ "baby-linked-wallet-stakes-visibility",
+ false,
+ );
+ const { publicKeyNoCoord } = useBTCWallet();
+ const { bech32Address } = useCosmosWallet();
+ const [delegationV2StepOptions, setDelegationV2StepOptions] =
+ useState();
+ const eventBus = useEventBus();
+
+ const {
+ data,
+ fetchNextPage,
+ isFetchingNextPage,
+ isLoading,
+ hasNextPage,
+ refetch,
+ } = useDelegationsV2(!showLinkedDelegations ? bech32Address : undefined);
+ // States
+ const { delegations, addPendingDelegation, updateDelegationStatus } =
+ useDelegationStorage(
+ getDelegationsV2LocalStorageKey(publicKeyNoCoord),
+ data?.delegations,
+ );
+
+ // Get a delegation by its txHash
+ const findDelegationByTxHash = useCallback(
+ (txHash: string) => delegations.find((d) => d.stakingTxHashHex === txHash),
+ [delegations],
+ );
+
+ useEffect(() => {
+ const unsubscribeFns = DELEGATION_V2_CHANNELS.map((channel) =>
+ eventBus.on(channel, (options) => {
+ setDelegationV2StepOptions(options);
+ }),
+ );
+
+ return () =>
+ void unsubscribeFns.forEach((unsubscribe) => void unsubscribe());
+ }, [eventBus, setDelegationV2StepOptions]);
+
+ // Context
+ const state = useMemo(
+ () => ({
+ delegations,
+ isLoading,
+ isFetchingNextPage,
+ hasMoreDelegations: hasNextPage,
+ linkedDelegationsVisibility: showLinkedDelegations,
+ displayLinkedDelegations: setLinkedDelegations,
+ addDelegation: addPendingDelegation,
+ updateDelegationStatus,
+ findDelegationByTxHash,
+ fetchMoreDelegations: fetchNextPage,
+ refetch: async () => {
+ await refetch();
+ },
+ delegationV2StepOptions,
+ setDelegationV2StepOptions,
+ }),
+ [
+ delegations,
+ isFetchingNextPage,
+ isLoading,
+ hasNextPage,
+ showLinkedDelegations,
+ addPendingDelegation,
+ updateDelegationStatus,
+ findDelegationByTxHash,
+ fetchNextPage,
+ setLinkedDelegations,
+ refetch,
+ delegationV2StepOptions,
+ setDelegationV2StepOptions,
+ ],
+ );
+
+ return {children} ;
+}
+
+export { useDelegationV2State };
diff --git a/src/ui/common/state/FinalityProviderBsnState.tsx b/src/ui/common/state/FinalityProviderBsnState.tsx
new file mode 100644
index 000000000..1ca750056
--- /dev/null
+++ b/src/ui/common/state/FinalityProviderBsnState.tsx
@@ -0,0 +1,247 @@
+import { useDebounce } from "@uidotdev/usehooks";
+import { useCallback, useMemo, useState, type PropsWithChildren } from "react";
+import { useSearchParams } from "react-router";
+
+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 { Bsn } from "@/ui/common/types/bsn";
+import {
+ FinalityProviderState as FinalityProviderStateEnum,
+ type FinalityProvider,
+} from "@/ui/common/types/finalityProviders";
+import { createStateUtils } from "@/ui/common/utils/createStateUtils";
+
+interface SortState {
+ field?: string;
+ direction?: "asc" | "desc";
+}
+
+interface FilterState {
+ search: string;
+ status: "active" | "inactive" | "";
+}
+
+interface FinalityProviderBsnState {
+ filter: FilterState;
+ finalityProviders: FinalityProvider[];
+ isFetching: boolean;
+ hasError: boolean;
+ hasNextPage: boolean;
+ fetchNextPage: () => void;
+ // BSN
+ bsnList: Bsn[];
+ bsnLoading: boolean;
+ bsnError: boolean;
+ selectedBsnId: string | undefined;
+ setSelectedBsnId: (id: string | undefined) => void;
+ // Modal
+ stakingModalPage: StakingModalPage;
+ setStakingModalPage: (page: StakingModalPage) => void;
+ handleSort: (sortField: string) => void;
+ handleFilter: (key: keyof FilterState, value: string) => void;
+ isRowSelectable: (row: FinalityProvider) => boolean;
+}
+
+export enum StakingModalPage {
+ DEFAULT,
+ BSN,
+ FINALITY_PROVIDER,
+}
+
+const FP_STATUSES = {
+ [FinalityProviderStateEnum.ACTIVE]: 1,
+ [FinalityProviderStateEnum.INACTIVE]: 0,
+ [FinalityProviderStateEnum.SLASHED]: 0,
+ [FinalityProviderStateEnum.JAILED]: 0,
+} as const;
+
+const SORT_DIRECTIONS = {
+ undefined: "desc",
+ desc: "asc",
+ asc: undefined,
+} as const;
+
+const STATUS_FILTERS = {
+ active: (fp: FinalityProvider) =>
+ fp.state === FinalityProviderStateEnum.ACTIVE,
+ inactive: (fp: FinalityProvider) =>
+ fp.state !== FinalityProviderStateEnum.ACTIVE,
+};
+
+const FILTERS = {
+ search: (fp: FinalityProvider, filter: FilterState) => {
+ const searchTerm = filter.search.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 { chainId: BBN_CHAIN_ID } = getNetworkConfigBBN();
+
+const defaultState: FinalityProviderBsnState = {
+ filter: {
+ search: "",
+ status: "active",
+ },
+ finalityProviders: [],
+ isFetching: false,
+ hasError: false,
+ hasNextPage: false,
+ fetchNextPage: () => {},
+ bsnList: [],
+ bsnLoading: false,
+ bsnError: false,
+ selectedBsnId: undefined,
+ setSelectedBsnId: () => {},
+ isRowSelectable: () => false,
+ handleSort: () => {},
+ handleFilter: () => {},
+ stakingModalPage: StakingModalPage.DEFAULT,
+ setStakingModalPage: () => {},
+};
+
+const { StateProvider, useState: useFpBsnState } =
+ createStateUtils(defaultState);
+
+export function FinalityProviderBsnState({ children }: PropsWithChildren) {
+ const [params] = useSearchParams();
+ const fpParam = params.get("fp");
+
+ const [stakingModalPage, setStakingModalPage] = useState(
+ StakingModalPage.DEFAULT,
+ );
+
+ const [filter, setFilter] = useState({
+ search: fpParam || "",
+ status: "active",
+ });
+ const [sortState, setSortState] = useState({});
+ const debouncedSearch = useDebounce(filter.search, 300);
+
+ const [selectedBsnId, setSelectedBsnId] = useState(
+ BBN_CHAIN_ID,
+ );
+ const [selectedProviderIds, setSelectedProviderIds] = useState([]);
+
+ const { data, isFetching, isError, hasNextPage, fetchNextPage } =
+ useFinalityProvidersV2({
+ sortBy: sortState.field,
+ order: sortState.direction,
+ name: debouncedSearch,
+ bsnId: selectedBsnId,
+ enabled: stakingModalPage === StakingModalPage.FINALITY_PROVIDER,
+ });
+
+ const {
+ data: bsnList = [],
+ isLoading: bsnLoading,
+ isError: bsnError,
+ } = useBsn({
+ enabled: stakingModalPage === StakingModalPage.BSN,
+ });
+
+ const finalityProviders = useMemo(() => {
+ if (!data?.finalityProviders) {
+ return [];
+ }
+
+ return data.finalityProviders
+ .sort((a, b) => {
+ const condition = FP_STATUSES[b.state] - FP_STATUSES[a.state];
+
+ if (condition !== 0) {
+ return condition;
+ }
+
+ return (b.activeTVLSat ?? 0) - (a.activeTVLSat ?? 0);
+ })
+ .map((fp, i) => ({
+ ...fp,
+ rank: i + 1,
+ id: fp.btcPk,
+ }));
+ }, [data?.finalityProviders]);
+
+ const handleFilter = useCallback((key: keyof FilterState, value: string) => {
+ setFilter((state) => ({ ...state, [key]: value }));
+ }, []);
+
+ const handleSort = useCallback((sortField: string) => {
+ setSortState(({ field, direction }) =>
+ field === sortField
+ ? {
+ field: SORT_DIRECTIONS[`${direction}`] ? field : undefined,
+ direction: SORT_DIRECTIONS[`${direction}`],
+ }
+ : {
+ field: sortField,
+ direction: "desc",
+ },
+ );
+ }, []);
+
+ const isRowSelectable = useCallback((row: FinalityProvider) => {
+ return (
+ row.state === FinalityProviderStateEnum.ACTIVE ||
+ row.state === FinalityProviderStateEnum.INACTIVE
+ );
+ }, []);
+
+ const filteredFinalityProviders = useMemo(() => {
+ return finalityProviders.filter((fp: FinalityProvider) =>
+ Object.values(FILTERS).every((filterFn) => filterFn(fp, filter)),
+ );
+ }, [finalityProviders, filter]);
+
+ const state = useMemo(
+ () => ({
+ filter,
+ finalityProviders: filteredFinalityProviders,
+ bsnList,
+ bsnLoading,
+ bsnError,
+ hasNextPage,
+ fetchNextPage,
+ selectedBsnId,
+ setSelectedBsnId,
+ selectedProviderIds,
+ setSelectedProviderIds,
+ isFetching,
+ hasError: isError,
+ handleSort,
+ handleFilter,
+ isRowSelectable,
+ stakingModalPage,
+ setStakingModalPage,
+ }),
+ [
+ filter,
+ filteredFinalityProviders,
+ bsnList,
+ bsnLoading,
+ bsnError,
+ hasNextPage,
+ fetchNextPage,
+ selectedBsnId,
+ setSelectedBsnId,
+ selectedProviderIds,
+ setSelectedProviderIds,
+ isFetching,
+ isError,
+ handleSort,
+ handleFilter,
+ isRowSelectable,
+ stakingModalPage,
+ setStakingModalPage,
+ ],
+ );
+
+ return {children} ;
+}
+
+export { useFpBsnState as useFinalityProviderBsnState };
diff --git a/src/ui/common/state/FinalityProviderState.tsx b/src/ui/common/state/FinalityProviderState.tsx
new file mode 100644
index 000000000..ed6443b2d
--- /dev/null
+++ b/src/ui/common/state/FinalityProviderState.tsx
@@ -0,0 +1,239 @@
+import { useDebounce } from "@uidotdev/usehooks";
+import { useCallback, useMemo, useState, type PropsWithChildren } from "react";
+import { useSearchParams } from "react-router";
+
+import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn";
+import { useFinalityProviders } from "@/ui/common/hooks/client/api/useFinalityProviders";
+import { useFinalityProvidersV2 } from "@/ui/common/hooks/client/api/useFinalityProvidersV2";
+import {
+ FinalityProviderState as FinalityProviderStateEnum,
+ FinalityProviderV1,
+ type FinalityProvider,
+} from "@/ui/common/types/finalityProviders";
+import { createStateUtils } from "@/ui/common/utils/createStateUtils";
+import FeatureFlagService from "@/ui/common/utils/FeatureFlagService";
+
+interface SortState {
+ field?: string;
+ direction?: "asc" | "desc";
+}
+
+interface FilterState {
+ search: string;
+ status: "active" | "inactive" | "";
+}
+
+interface FinalityProviderState {
+ filter: FilterState;
+ finalityProviders: FinalityProvider[];
+ finalityProviderMap: Map;
+ hasNextPage: boolean;
+ isFetching: boolean;
+ hasError: boolean;
+ handleSort: (sortField: string) => void;
+ handleFilter: (key: keyof FilterState, value: string) => void;
+ isRowSelectable: (row: FinalityProvider) => boolean;
+ getRegisteredFinalityProvider: (btcPkHex: string) => FinalityProvider | null;
+ fetchNextPage: () => void;
+ getFinalityProviderName: (btcPkHex: string) => string | undefined;
+}
+
+const { chainId: BBN_CHAIN_ID } = getNetworkConfigBBN();
+
+const FP_STATUSES = {
+ [FinalityProviderStateEnum.ACTIVE]: 1,
+ [FinalityProviderStateEnum.INACTIVE]: 0,
+ [FinalityProviderStateEnum.SLASHED]: 0,
+ [FinalityProviderStateEnum.JAILED]: 0,
+} as const;
+
+const SORT_DIRECTIONS = {
+ undefined: "desc",
+ desc: "asc",
+ asc: undefined,
+} as const;
+
+const STATUS_FILTERS = {
+ active: (fp: FinalityProvider) =>
+ fp.state === FinalityProviderStateEnum.ACTIVE,
+ inactive: (fp: FinalityProvider) =>
+ fp.state !== FinalityProviderStateEnum.ACTIVE,
+};
+
+const FILTERS = {
+ search: (fp: FinalityProvider, filter: FilterState) => {
+ const searchTerm = filter.search.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: FinalityProviderState = {
+ filter: {
+ search: "",
+ status: "active",
+ },
+ finalityProviders: [],
+ hasNextPage: false,
+ isFetching: false,
+ hasError: false,
+ isRowSelectable: () => false,
+ handleSort: () => {},
+ handleFilter: () => {},
+ getRegisteredFinalityProvider: () => null,
+ fetchNextPage: () => {},
+ getFinalityProviderName: () => undefined,
+ finalityProviderMap: new Map(),
+};
+
+const { StateProvider, useState: useFpState } =
+ createStateUtils(defaultState);
+
+export function FinalityProviderState({ children }: PropsWithChildren) {
+ const [params] = useSearchParams();
+ const fpParam = params.get("fp");
+
+ const [filter, setFilter] = useState({
+ search: fpParam || "",
+ status: "active",
+ });
+ const [sortState, setSortState] = useState({});
+ const debouncedSearch = useDebounce(filter.search, 300);
+
+ const { data, hasNextPage, fetchNextPage, isFetching, isError } =
+ useFinalityProvidersV2({
+ sortBy: sortState.field,
+ order: sortState.direction,
+ name: debouncedSearch,
+ bsnId: FeatureFlagService.IsPhase3Enabled ? "all" : BBN_CHAIN_ID,
+ });
+
+ const { data: dataV1 } = useFinalityProviders();
+
+ const finalityProviders = useMemo(() => {
+ if (!data?.finalityProviders) return [];
+
+ return data.finalityProviders
+ .sort((a, b) => {
+ const condition = FP_STATUSES[b.state] - FP_STATUSES[a.state];
+
+ if (condition !== 0) {
+ return condition;
+ }
+
+ return (b.activeTVLSat ?? 0) - (a.activeTVLSat ?? 0);
+ })
+ .map((fp, i) => ({
+ ...fp,
+ rank: i + 1,
+ id: fp.btcPk,
+ }));
+ }, [data?.finalityProviders]);
+
+ const finalityProviderMap = useMemo(
+ () =>
+ finalityProviders.reduce((acc, fp) => {
+ if (fp.btcPk) {
+ acc.set(fp.btcPk, fp);
+ }
+
+ return acc;
+ }, new Map()),
+ [finalityProviders],
+ );
+
+ const providersV1Map = useMemo(
+ () =>
+ (dataV1?.finalityProviders ?? []).reduce((acc, fp) => {
+ if (fp.btcPk) {
+ acc.set(fp.btcPk, fp);
+ }
+
+ return acc;
+ }, new Map()),
+ [dataV1?.finalityProviders],
+ );
+
+ const getFinalityProviderName = useCallback(
+ (btcPkHex: string) =>
+ finalityProviderMap.get(btcPkHex)?.description?.moniker ??
+ providersV1Map.get(btcPkHex)?.description?.moniker,
+ [finalityProviderMap, providersV1Map],
+ );
+
+ const handleFilter = useCallback((key: keyof FilterState, value: string) => {
+ setFilter((state) => ({ ...state, [key]: value }));
+ }, []);
+
+ const handleSort = useCallback((sortField: string) => {
+ setSortState(({ field, direction }) =>
+ field === sortField
+ ? {
+ field: SORT_DIRECTIONS[`${direction}`] ? field : undefined,
+ direction: SORT_DIRECTIONS[`${direction}`],
+ }
+ : {
+ field: sortField,
+ direction: "desc",
+ },
+ );
+ }, []);
+
+ const isRowSelectable = useCallback((row: FinalityProvider) => {
+ return (
+ row.state === FinalityProviderStateEnum.ACTIVE ||
+ row.state === FinalityProviderStateEnum.INACTIVE
+ );
+ }, []);
+
+ const filteredFinalityProviders = useMemo(() => {
+ return finalityProviders.filter((fp: FinalityProvider) =>
+ Object.values(FILTERS).every((filterFn) => filterFn(fp, filter)),
+ );
+ }, [finalityProviders, filter]);
+
+ const getRegisteredFinalityProvider = useCallback(
+ (btcPkHex: string) =>
+ data?.finalityProviders.find((fp) => fp.btcPk === btcPkHex) || null,
+ [data?.finalityProviders],
+ );
+
+ const state = useMemo(
+ () => ({
+ filter,
+ finalityProviders: filteredFinalityProviders,
+ isFetching,
+ hasError: isError,
+ hasNextPage,
+ finalityProviderMap,
+ handleSort,
+ handleFilter,
+ isRowSelectable,
+ getRegisteredFinalityProvider,
+ fetchNextPage,
+ getFinalityProviderName,
+ }),
+ [
+ filter,
+ filteredFinalityProviders,
+ isFetching,
+ hasNextPage,
+ isError,
+ finalityProviderMap,
+ handleSort,
+ handleFilter,
+ isRowSelectable,
+ getRegisteredFinalityProvider,
+ fetchNextPage,
+ getFinalityProviderName,
+ ],
+ );
+
+ return {children} ;
+}
+
+export { useFpState as useFinalityProviderState };
diff --git a/src/ui/common/state/MultistakingState.tsx b/src/ui/common/state/MultistakingState.tsx
new file mode 100644
index 000000000..46b71fc39
--- /dev/null
+++ b/src/ui/common/state/MultistakingState.tsx
@@ -0,0 +1,191 @@
+import { useMemo, useState, type PropsWithChildren } from "react";
+import {
+ array,
+ number,
+ object,
+ ObjectSchema,
+ ObjectShape,
+ Schema,
+ string,
+} from "yup";
+
+import { validateDecimalPoints } from "@/ui/common/components/Staking/Form/validation/validation";
+import { getNetworkConfigBTC } from "@/ui/common/config/network/btc";
+import { useNetworkInfo } from "@/ui/common/hooks/client/api/useNetworkInfo";
+import { satoshiToBtc } from "@/ui/common/utils/btc";
+import { createStateUtils } from "@/ui/common/utils/createStateUtils";
+import {
+ formatNumber,
+ formatStakingAmount,
+} from "@/ui/common/utils/formTransforms";
+
+import { useBalanceState } from "./BalanceState";
+import { StakingModalPage, useStakingState } from "./StakingState";
+
+const { coinName } = getNetworkConfigBTC();
+
+export interface MultistakingFormFields {
+ finalityProviders: Record;
+ amount: number;
+ term: number;
+ feeRate: number;
+ feeAmount: number;
+}
+
+interface FieldOptions {
+ field: string;
+ schema: Schema;
+ errors?: Record;
+}
+
+export interface MultistakingState {
+ stakingModalPage: StakingModalPage;
+ setStakingModalPage: (page: StakingModalPage) => void;
+ maxFinalityProviders: number;
+ validationSchema?: ObjectSchema;
+ formFields: FieldOptions[];
+}
+
+const { StateProvider, useState: useMultistakingState } =
+ createStateUtils({
+ stakingModalPage: StakingModalPage.DEFAULT,
+ setStakingModalPage: () => {},
+ maxFinalityProviders: 3,
+ validationSchema: undefined,
+ formFields: [],
+ });
+
+export function MultistakingState({ children }: PropsWithChildren) {
+ const [stakingModalPage, setStakingModalPage] = useState(
+ StakingModalPage.DEFAULT,
+ );
+ const { data: networkInfo } = useNetworkInfo();
+ const maxFinalityProviders = networkInfo?.params.maxBsnFpProviders ?? 3;
+ const { stakableBtcBalance } = useBalanceState();
+ const { stakingInfo } = useStakingState();
+
+ const formFields: FieldOptions[] = useMemo(
+ () =>
+ [
+ {
+ field: "finalityProviders",
+ schema: array()
+ .of(string())
+ .transform((value) => Object.values(value))
+ .required("Add Finality Provider")
+ .min(1, "Add Finality Provider")
+ .max(
+ maxFinalityProviders,
+ `Maximum ${maxFinalityProviders} finality providers allowed.`,
+ ),
+ },
+ {
+ field: "term",
+ schema: number()
+ .transform(formatNumber)
+ .typeError("Staking term must be a valid number.")
+ .required("Staking term is the required field.")
+ .integer("Staking term must not have decimal points.")
+ .moreThan(0, "Staking term must be greater than 0.")
+ .min(
+ stakingInfo?.minStakingTimeBlocks ?? 0,
+ `Staking term must be at least ${stakingInfo?.minStakingTimeBlocks ?? 0} blocks.`,
+ )
+ .max(
+ stakingInfo?.maxStakingTimeBlocks ?? 0,
+ `Staking term must be no more than ${stakingInfo?.maxStakingTimeBlocks ?? 0} blocks.`,
+ ),
+ },
+ {
+ field: "amount",
+ schema: number()
+ .transform(formatStakingAmount)
+ .typeError("Staking amount must be a valid number.")
+ .required("Enter BTC Amount to Stake")
+ .moreThan(0, "Staking amount must be greater than 0.")
+ .min(
+ stakingInfo?.minStakingAmountSat ?? 0,
+ `Minimum Staking ${satoshiToBtc(
+ stakingInfo?.minStakingAmountSat ?? 0,
+ )} ${coinName}`,
+ )
+ .max(
+ stakingInfo?.maxStakingAmountSat ?? 0,
+ `Maximum Staking ${satoshiToBtc(stakingInfo?.maxStakingAmountSat ?? 0)} ${coinName}`,
+ )
+ .test(
+ "invalidBalance",
+ "Staking Amount Exceeds Balance",
+ (value = 0) => value <= stakableBtcBalance,
+ )
+ .test(
+ "invalidFormat",
+ "Staking amount must have no more than 8 decimal points.",
+ (_, context) => validateDecimalPoints(context.originalValue),
+ )
+ // ???
+ .test("insufficientFunds", "Insufficient BTC", () => true),
+ errors: {
+ invalidFormat: { level: "error" },
+ },
+ },
+ {
+ field: "feeRate",
+ schema: number()
+ .transform(formatNumber)
+ .typeError("Staking fee rate must be a valid number.")
+ .required("Staking fee rate is the required field.")
+ .moreThan(0, "Staking fee rate must be greater than 0.")
+ .min(
+ stakingInfo?.minFeeRate ?? 0,
+ "Selected fee rate is lower than the hour fee",
+ )
+ .max(
+ stakingInfo?.maxFeeRate ?? 0,
+ "Selected fee rate is higher than the hour fee",
+ ),
+ },
+ {
+ field: "feeAmount",
+ schema: number()
+ .transform(formatNumber)
+ .typeError("Staking fee amount must be a valid number.")
+ .required("Staking fee amount is the required field.")
+ .moreThan(0, "Staking fee amount must be greater than 0."),
+ },
+ ] as const,
+ [stakingInfo, stakableBtcBalance, maxFinalityProviders],
+ );
+
+ const validationSchema = useMemo(() => {
+ const shape = formFields.reduce(
+ (map, formItem) => ({ ...map, [formItem.field]: formItem.schema }),
+ {} as ObjectShape,
+ );
+
+ return object()
+ .shape(shape)
+ .required() as ObjectSchema;
+ }, [formFields]);
+
+ const context = useMemo(
+ () => ({
+ stakingModalPage,
+ setStakingModalPage,
+ maxFinalityProviders,
+ validationSchema,
+ formFields,
+ }),
+ [
+ stakingModalPage,
+ setStakingModalPage,
+ maxFinalityProviders,
+ validationSchema,
+ formFields,
+ ],
+ );
+
+ return {children} ;
+}
+
+export { useMultistakingState };
diff --git a/src/ui/common/state/RewardState.tsx b/src/ui/common/state/RewardState.tsx
new file mode 100644
index 000000000..85e24bd7b
--- /dev/null
+++ b/src/ui/common/state/RewardState.tsx
@@ -0,0 +1,122 @@
+import { useCallback, useMemo, useState, type PropsWithChildren } from "react";
+
+import { useCosmosWallet } from "@/ui/common/context/wallet/CosmosWalletProvider";
+import { useBbnQuery } from "@/ui/common/hooks/client/rpc/queries/useBbnQuery";
+import { createStateUtils } from "@/ui/common/utils/createStateUtils";
+
+interface RewardsStateProps {
+ loading: boolean;
+ showRewardModal: boolean;
+ showProcessingModal: boolean;
+ processing: boolean;
+ bbnAddress: string;
+ rewardBalance: number;
+ transactionFee: number;
+ transactionHash: string;
+ setTransactionHash: (hash: string) => void;
+ setTransactionFee: (value: number) => void;
+ openRewardModal: () => void;
+ closeRewardModal: () => void;
+ openProcessingModal: () => void;
+ closeProcessingModal: () => void;
+ setProcessing: (value: boolean) => void;
+ refetchRewardBalance: () => Promise;
+}
+
+const defaultState: RewardsStateProps = {
+ loading: false,
+ showRewardModal: false,
+ showProcessingModal: false,
+ processing: false,
+ bbnAddress: "",
+ rewardBalance: 0,
+ transactionFee: 0,
+ transactionHash: "",
+ setTransactionHash: () => {},
+ openRewardModal: () => {},
+ closeRewardModal: () => {},
+ openProcessingModal: () => {},
+ closeProcessingModal: () => {},
+ setProcessing: () => {},
+ setTransactionFee: () => {},
+ refetchRewardBalance: () => Promise.resolve(),
+};
+
+const { StateProvider, useState: useRewardsState } =
+ createStateUtils(defaultState);
+
+export function RewardsState({ children }: PropsWithChildren) {
+ const [showRewardModal, setRewardModal] = useState(false);
+ const [showProcessingModal, setProcessingModal] = useState(false);
+ const [processing, setProcessing] = useState(false);
+ const [transactionFee, setTransactionFee] = useState(0);
+ const [transactionHash, setTransactionHash] = useState("");
+
+ const { bech32Address: bbnAddress } = useCosmosWallet();
+
+ const {
+ rewardsQuery: {
+ data: rewardBalance = 0,
+ isLoading: isRewardBalanceLoading,
+ refetch: refetchRewardBalance,
+ },
+ } = useBbnQuery();
+
+ const openRewardModal = useCallback(() => {
+ setRewardModal(true);
+ }, []);
+
+ const closeRewardModal = useCallback(() => {
+ setRewardModal(false);
+ }, []);
+
+ const openProcessingModal = useCallback(() => {
+ setProcessingModal(true);
+ }, []);
+
+ const closeProcessingModal = useCallback(() => {
+ setProcessingModal(false);
+ }, []);
+
+ const context = useMemo(
+ () => ({
+ loading: isRewardBalanceLoading,
+ showRewardModal,
+ showProcessingModal,
+ processing,
+ bbnAddress,
+ rewardBalance,
+ transactionFee,
+ transactionHash,
+ setTransactionHash,
+ setTransactionFee,
+ setProcessing,
+ openRewardModal,
+ closeRewardModal,
+ openProcessingModal,
+ closeProcessingModal,
+ refetchRewardBalance: async () => {
+ await refetchRewardBalance();
+ },
+ }),
+ [
+ isRewardBalanceLoading,
+ showRewardModal,
+ showProcessingModal,
+ openProcessingModal,
+ closeProcessingModal,
+ processing,
+ bbnAddress,
+ rewardBalance,
+ transactionFee,
+ transactionHash,
+ openRewardModal,
+ closeRewardModal,
+ refetchRewardBalance,
+ ],
+ );
+
+ return {children} ;
+}
+
+export { useRewardsState };
diff --git a/src/ui/common/state/StakingState.tsx b/src/ui/common/state/StakingState.tsx
new file mode 100644
index 000000000..14ab843af
--- /dev/null
+++ b/src/ui/common/state/StakingState.tsx
@@ -0,0 +1,433 @@
+import type {
+ RegistrationStep,
+ SignPsbtOptions,
+} from "@babylonlabs-io/btc-staking-ts";
+import { EventData } from "@babylonlabs-io/btc-staking-ts";
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+ type PropsWithChildren,
+} from "react";
+import { useLocalStorage } from "usehooks-ts";
+import { array, number, object, ObjectSchema, string } from "yup";
+
+import { validateDecimalPoints } from "@/ui/common/components/Staking/Form/validation/validation";
+import { getDisabledWallets, IS_FIXED_TERM_FIELD } from "@/ui/common/config";
+import { getNetworkConfigBTC } from "@/ui/common/config/network/btc";
+import { useBTCWallet } from "@/ui/common/context/wallet/BTCWalletProvider";
+import { useNetworkFees } from "@/ui/common/hooks/client/api/useNetworkFees";
+import { useEventBus } from "@/ui/common/hooks/useEventBus";
+import { useHealthCheck } from "@/ui/common/hooks/useHealthCheck";
+import { useAppState } from "@/ui/common/state";
+import type { DelegationV2 } from "@/ui/common/types/delegationsV2";
+import { satoshiToBtc } from "@/ui/common/utils/btc";
+import { createStateUtils } from "@/ui/common/utils/createStateUtils";
+import {
+ formatNumber,
+ formatStakingAmount,
+} from "@/ui/common/utils/formTransforms";
+import { getFeeRateFromMempool } from "@/ui/common/utils/getFeeRateFromMempool";
+
+import { STAKING_DISABLED } from "../constants";
+import { useCosmosWallet } from "../context/wallet/CosmosWalletProvider";
+
+import { useBalanceState } from "./BalanceState";
+
+export enum StakingModalPage {
+ DEFAULT,
+ BSN,
+ FINALITY_PROVIDER,
+}
+
+const { coinName } = getNetworkConfigBTC();
+
+export interface FormFields {
+ finalityProviders: string[];
+ amount: number;
+ term: number;
+ feeRate: number;
+ feeAmount: number;
+}
+
+export enum StakingStep {
+ PREVIEW = "preview",
+ EOI_STAKING_SLASHING = "eoi-staking-slashing",
+ EOI_UNBONDING_SLASHING = "eoi-unbonding-slashing",
+ EOI_PROOF_OF_POSSESSION = "eoi-proof-of-possession",
+ EOI_SIGN_BBN = "eoi-sign-bbn",
+ EOI_SEND_BBN = "eoi-send-bbn",
+ VERIFYING = "verifying",
+ VERIFIED = "verified",
+ FEEDBACK_SUCCESS = "feedback-success",
+ FEEDBACK_CANCEL = "feedback-cancel",
+}
+
+export interface StakingState {
+ hasError: boolean;
+ blocked: boolean;
+ available: boolean;
+ loading: boolean;
+ processing: boolean;
+ errorMessage?: string;
+ validationSchema?: ObjectSchema;
+ stakingInfo?: {
+ minFeeRate: number;
+ maxFeeRate: number;
+ defaultFeeRate: number;
+ minStakingTimeBlocks: number;
+ maxStakingTimeBlocks: number;
+ defaultStakingTimeBlocks?: number;
+ minStakingAmountSat: number;
+ maxStakingAmountSat: number;
+ unbondingFeeSat: number;
+ unbondingTime: number;
+ };
+ formData?: FormFields;
+ step?: StakingStep;
+ verifiedDelegation?: DelegationV2;
+ goToStep: (name: StakingStep, options?: SignPsbtOptions) => void;
+ setProcessing: (value: boolean) => void;
+ setFormData: (formData?: FormFields) => void;
+ setVerifiedDelegation: (value?: DelegationV2) => void;
+ reset: () => void;
+ disabled?: {
+ title: string;
+ message: string;
+ };
+ stakingStepOptions: EventData | undefined;
+ setStakingStepOptions?: (options?: EventData) => void;
+}
+
+export const STAKING_SIGNING_STEP_MAP: Record = {
+ "staking-slashing": StakingStep.EOI_STAKING_SLASHING,
+ "unbonding-slashing": StakingStep.EOI_UNBONDING_SLASHING,
+ "proof-of-possession": StakingStep.EOI_PROOF_OF_POSSESSION,
+ "create-btc-delegation-msg": StakingStep.EOI_SIGN_BBN,
+};
+
+const { StateProvider, useState: useStakingState } =
+ createStateUtils({
+ hasError: false,
+ blocked: false,
+ available: false,
+ disabled: undefined,
+ loading: false,
+ processing: false,
+ errorMessage: "",
+ stakingInfo: {
+ minFeeRate: 0,
+ maxFeeRate: 0,
+ defaultFeeRate: 0,
+ minStakingAmountSat: 0,
+ maxStakingAmountSat: 0,
+ minStakingTimeBlocks: 0,
+ maxStakingTimeBlocks: 0,
+ unbondingFeeSat: 0,
+ unbondingTime: 0,
+ },
+ formData: {
+ finalityProviders: [],
+ term: 0,
+ amount: 0,
+ feeRate: 0,
+ feeAmount: 0,
+ },
+ step: undefined,
+ verifiedDelegation: undefined,
+ setVerifiedDelegation: () => {},
+ goToStep: () => {},
+ setFormData: () => {},
+ setProcessing: () => {},
+ reset: () => {},
+ stakingStepOptions: undefined,
+ setStakingStepOptions: () => {},
+ });
+
+export function StakingState({ children }: PropsWithChildren) {
+ const [currentStep, setCurrentStep] = useState();
+ const [stakingStepOptions, setStakingStepOptions] = useState();
+
+ const [formData, setFormData] = useState();
+ const [processing, setProcessing] = useState(false);
+ const [verifiedDelegation, setVerifiedDelegation] = useState();
+ const [successModalShown, setSuccessModalShown] = useLocalStorage(
+ "bbn-staking-successFeedbackModalOpened",
+ false,
+ );
+ const [cancelModalShown, setCancelModalShown] = useLocalStorage(
+ "bbn-staking-cancelFeedbackModalOpened ",
+ false,
+ );
+ const eventBus = useEventBus();
+
+ const {
+ networkInfo,
+ isError: isStateError,
+ isLoading: isStateLoading,
+ } = useAppState();
+ const {
+ isApiNormal,
+ isGeoBlocked,
+ isLoading: isCheckLoading,
+ error: healthCheckError,
+ } = useHealthCheck();
+ const {
+ data: mempoolFeeRates,
+ isError: isNetworkFeeError,
+ isLoading: isFeeLoading,
+ } = useNetworkFees();
+ const { stakableBtcBalance, loading: isBalanceLoading } = useBalanceState();
+
+ const { publicKeyNoCoord } = useBTCWallet();
+ const { walletName: cosmosWalletName } = useCosmosWallet();
+
+ const loading =
+ isStateLoading || isCheckLoading || isFeeLoading || isBalanceLoading;
+ const hasError = isStateError || isNetworkFeeError || !isApiNormal;
+ const blocked = isGeoBlocked;
+ const available = Boolean(networkInfo?.stakingStatus.isStakingOpen);
+ const errorMessage = healthCheckError?.message;
+ const latestParam = networkInfo?.params.bbnStakingParams?.latestParam;
+
+ const stakingInfo = useMemo(() => {
+ if (!latestParam || !mempoolFeeRates) {
+ return;
+ }
+
+ const {
+ minStakingAmountSat = 0,
+ maxStakingAmountSat = 0,
+ minStakingTimeBlocks = 0,
+ maxStakingTimeBlocks = 0,
+ unbondingFeeSat,
+ unbondingTime,
+ } = latestParam || {};
+
+ const { minFeeRate, defaultFeeRate, maxFeeRate } =
+ getFeeRateFromMempool(mempoolFeeRates);
+ const defaultStakingTimeBlocks =
+ IS_FIXED_TERM_FIELD || minStakingTimeBlocks === maxStakingTimeBlocks
+ ? maxStakingTimeBlocks
+ : undefined;
+
+ return {
+ defaultFeeRate,
+ minFeeRate,
+ maxFeeRate,
+ minStakingAmountSat,
+ maxStakingAmountSat,
+ minStakingTimeBlocks,
+ maxStakingTimeBlocks,
+ defaultStakingTimeBlocks,
+ unbondingFeeSat,
+ unbondingTime,
+ };
+ }, [latestParam, mempoolFeeRates]);
+
+ const isDisabled = useMemo(() => {
+ // System wide staking disabled
+ if (STAKING_DISABLED) {
+ return {
+ title: "Staking Currently Unavailable",
+ message:
+ "Staking is temporarily disabled due to network downtime. New stakes are paused until the network resumes.",
+ };
+ }
+ // Disable wallet by their name in the event of incident
+ // TODO: Add support for BTC wallet in the future
+ if (
+ cosmosWalletName != "" &&
+ getDisabledWallets().includes(cosmosWalletName)
+ ) {
+ return {
+ title: `Staking registration is temporarily disabled for ${cosmosWalletName} wallet.`,
+ message: "Please try again later.",
+ };
+ }
+
+ // If the staking is not disabled, return undefined
+ return undefined;
+ }, [cosmosWalletName]);
+
+ const validationSchema = useMemo(
+ () =>
+ object()
+ .shape({
+ finalityProviders: array()
+ .of(string().required())
+ .required("Please select a finality provider")
+ .min(1, "Please select at least one finality provider")
+ .test(
+ "no-duplicate-public-keys",
+ "Cannot select a finality provider with the same public key as the wallet",
+ (value) => !value?.includes(publicKeyNoCoord),
+ ),
+
+ term: number()
+ .transform(formatNumber)
+ .typeError("Staking term must be a valid number.")
+ .required("Staking term is the required field.")
+ .integer("Staking term must not have decimal points.")
+ .moreThan(0, "Staking term must be greater than 0.")
+ .min(
+ stakingInfo?.minStakingTimeBlocks ?? 0,
+ `Staking term must be at least ${stakingInfo?.minStakingTimeBlocks ?? 0} blocks.`,
+ )
+ .max(
+ stakingInfo?.maxStakingTimeBlocks ?? 0,
+ `Staking term must be no more than ${stakingInfo?.maxStakingTimeBlocks ?? 0} blocks.`,
+ ),
+
+ amount: number()
+ .transform(formatStakingAmount)
+ .typeError("Staking amount must be a valid number.")
+ .required("Staking amount is the required field.")
+ .moreThan(0, "Staking amount must be greater than 0.")
+ .min(
+ stakingInfo?.minStakingAmountSat ?? 0,
+ `Staking amount must be at least ${satoshiToBtc(stakingInfo?.minStakingAmountSat ?? 0)} ${coinName}.`,
+ )
+ .max(
+ stakingInfo?.maxStakingAmountSat ?? 0,
+ `Staking amount must be no more than ${satoshiToBtc(stakingInfo?.maxStakingAmountSat ?? 0)} ${coinName}.`,
+ )
+ .max(
+ stakableBtcBalance,
+ `Staking amount exceeds your balance (${satoshiToBtc(stakableBtcBalance)} ${coinName})!`,
+ )
+ .test(
+ "decimal-points",
+ "Staking amount must have no more than 8 decimal points.",
+ (_, context) => validateDecimalPoints(context.originalValue),
+ ),
+
+ feeRate: number()
+ .transform(formatNumber)
+ .typeError("Staking fee rate must be a valid number.")
+ .required("Staking fee rate is the required field.")
+ .moreThan(0, "Staking fee rate must be greater than 0.")
+ .min(
+ stakingInfo?.minFeeRate ?? 0,
+ "Selected fee rate is lower than the hour fee",
+ )
+ .max(
+ stakingInfo?.maxFeeRate ?? 0,
+ "Selected fee rate is higher than the hour fee",
+ ),
+
+ feeAmount: number()
+ .transform(formatNumber)
+ .typeError("Staking fee amount must be a valid number.")
+ .required("Staking fee amount is the required field.")
+ .moreThan(0, "Staking fee amount must be greater than 0."),
+ })
+ .required(),
+ [publicKeyNoCoord, stakingInfo, stakableBtcBalance],
+ );
+
+ const goToStep = useCallback(
+ (stepName: StakingStep) => {
+ if (stepName === StakingStep.FEEDBACK_SUCCESS) {
+ if (successModalShown) {
+ return;
+ } else {
+ setSuccessModalShown(true);
+ }
+ }
+
+ if (stepName === StakingStep.FEEDBACK_CANCEL) {
+ if (cancelModalShown) {
+ return;
+ } else {
+ setCancelModalShown(true);
+ }
+ }
+
+ setCurrentStep(stepName);
+ },
+ [
+ successModalShown,
+ cancelModalShown,
+ setCancelModalShown,
+ setSuccessModalShown,
+ setCurrentStep,
+ ],
+ );
+
+ const reset = useCallback(() => {
+ setVerifiedDelegation(undefined);
+ setFormData(undefined);
+ setCurrentStep(undefined);
+ setStakingStepOptions(undefined);
+ setProcessing(false);
+ }, [
+ setVerifiedDelegation,
+ setFormData,
+ setCurrentStep,
+ setProcessing,
+ setStakingStepOptions,
+ ]);
+
+ useEffect(() => {
+ const unsubscribe = eventBus.on("delegation:create", (options) => {
+ const type = options?.type as RegistrationStep | undefined;
+
+ if (type) {
+ const stepName = STAKING_SIGNING_STEP_MAP[type];
+ setCurrentStep(stepName);
+ setStakingStepOptions(options);
+ }
+ });
+
+ return unsubscribe;
+ }, [eventBus, setCurrentStep, setStakingStepOptions]);
+
+ const context = useMemo(
+ () => ({
+ hasError,
+ blocked,
+ available,
+ disabled: isDisabled,
+ loading,
+ processing,
+ errorMessage,
+ validationSchema,
+ stakingInfo,
+ formData,
+ step: currentStep,
+ verifiedDelegation,
+ setVerifiedDelegation,
+ setFormData,
+ goToStep,
+ setProcessing,
+ reset,
+ stakingStepOptions,
+ setStakingStepOptions,
+ }),
+ [
+ hasError,
+ blocked,
+ available,
+ isDisabled,
+ loading,
+ processing,
+ errorMessage,
+ validationSchema,
+ stakingInfo,
+ formData,
+ currentStep,
+ verifiedDelegation,
+ goToStep,
+ setProcessing,
+ reset,
+ stakingStepOptions,
+ setStakingStepOptions,
+ ],
+ );
+
+ return {children} ;
+}
+
+export { useStakingState };
diff --git a/src/ui/common/state/index.tsx b/src/ui/common/state/index.tsx
new file mode 100644
index 000000000..cc6b26155
--- /dev/null
+++ b/src/ui/common/state/index.tsx
@@ -0,0 +1,167 @@
+import { UTXO } from "@babylonlabs-io/btc-staking-ts";
+import {
+ InscriptionIdentifier,
+ useInscriptionProvider,
+} from "@babylonlabs-io/wallet-connector";
+import { useTheme } from "next-themes";
+import { useCallback, useMemo, type PropsWithChildren } from "react";
+
+import { useOrdinals } from "@/ui/common/hooks/client/api/useOrdinals";
+import { useUTXOs } from "@/ui/common/hooks/client/api/useUTXOs";
+import { createStateUtils } from "@/ui/common/utils/createStateUtils";
+import { filterDust } from "@/ui/common/utils/wallet";
+
+import { useNetworkInfo } from "../hooks/client/api/useNetworkInfo";
+import { NetworkInfo } from "../types/networkInfo";
+
+import { BalanceState } from "./BalanceState";
+import { DelegationState } from "./DelegationState";
+import { DelegationV2State } from "./DelegationV2State";
+import { FinalityProviderState } from "./FinalityProviderState";
+import { RewardsState } from "./RewardState";
+import { StakingState } from "./StakingState";
+
+// The order of the states is important for the state provider
+const STATE_LIST = [
+ DelegationState,
+ DelegationV2State,
+ FinalityProviderState,
+ BalanceState,
+ StakingState,
+ RewardsState,
+];
+
+export interface AppState {
+ theme?: string;
+ availableUTXOs?: UTXO[];
+ allUTXOs?: UTXO[];
+ inscriptionsUTXOs?: UTXO[];
+ networkInfo?: NetworkInfo;
+ isError: boolean;
+ isLoading: boolean;
+ ordinalsExcluded: boolean;
+ includeOrdinals: () => void;
+ excludeOrdinals: () => void;
+ refetchUTXOs: () => void;
+ setTheme: (theme: "dark" | "light") => void;
+}
+
+const { StateProvider, useState: useApplicationState } =
+ createStateUtils({
+ theme: undefined,
+ isLoading: false,
+ isError: false,
+ ordinalsExcluded: true,
+ includeOrdinals: () => {},
+ excludeOrdinals: () => {},
+ refetchUTXOs: () => {},
+ setTheme: () => {},
+ });
+
+export function AppState({ children }: PropsWithChildren) {
+ const { theme, setTheme } = useTheme();
+
+ const { lockInscriptions: ordinalsExcluded, toggleLockInscriptions } =
+ useInscriptionProvider();
+
+ // States
+ const {
+ allUTXOs = [],
+ confirmedUTXOs = [],
+ isLoading: isUTXOLoading,
+ isError: isUTXOError,
+ refetch: refetchUTXOs,
+ } = useUTXOs();
+ const {
+ data: ordinals = [],
+ isLoading: isOrdinalLoading,
+ isError: isOrdinalError,
+ } = useOrdinals(confirmedUTXOs, {
+ enabled: !isUTXOLoading,
+ });
+ const {
+ data: networkInfo,
+ isLoading: isNetworkInfoLoading,
+ isError: isNetworkInfoError,
+ } = useNetworkInfo();
+
+ // Computed
+ const isLoading = isUTXOLoading || isOrdinalLoading || isNetworkInfoLoading;
+ const isError = isUTXOError || isOrdinalError || isNetworkInfoError;
+
+ const ordinalMap: Record = useMemo(
+ () =>
+ ordinals.reduce(
+ (acc, ordinal) => ({ ...acc, [ordinal.txid]: ordinal }),
+ {},
+ ),
+ [ordinals],
+ );
+
+ const inscriptionsUTXOs = useMemo(() => {
+ return confirmedUTXOs.filter((utxo) => ordinalMap[utxo.txid]);
+ }, [confirmedUTXOs, ordinalMap]);
+
+ const availableUTXOs = useMemo(() => {
+ if (isLoading) return [];
+
+ return ordinalsExcluded
+ ? filterDust(confirmedUTXOs).filter((utxo) => !ordinalMap[utxo.txid])
+ : confirmedUTXOs;
+ }, [isLoading, ordinalsExcluded, confirmedUTXOs, ordinalMap]);
+
+ // Handlers
+ const includeOrdinals = useCallback(
+ () => toggleLockInscriptions?.(false),
+ [toggleLockInscriptions],
+ );
+ const excludeOrdinals = useCallback(
+ () => toggleLockInscriptions?.(true),
+ [toggleLockInscriptions],
+ );
+
+ // Context
+ const context = useMemo(
+ () => ({
+ theme,
+ allUTXOs,
+ availableUTXOs,
+ inscriptionsUTXOs,
+ networkInfo,
+ isError,
+ isLoading,
+ ordinalsExcluded,
+ includeOrdinals,
+ excludeOrdinals,
+ refetchUTXOs,
+ setTheme,
+ }),
+ [
+ theme,
+ allUTXOs,
+ availableUTXOs,
+ inscriptionsUTXOs,
+ networkInfo,
+ isError,
+ isLoading,
+ ordinalsExcluded,
+ includeOrdinals,
+ excludeOrdinals,
+ refetchUTXOs,
+ setTheme,
+ ],
+ );
+
+ const states = useMemo(
+ () =>
+ STATE_LIST.reduceRight(
+ (children, State, index) => {children} ,
+ children,
+ ),
+ [children],
+ );
+
+ return {states} ;
+}
+
+export const useAppState = useApplicationState;
diff --git a/src/ui/common/types/api.ts b/src/ui/common/types/api.ts
new file mode 100644
index 000000000..cc7998964
--- /dev/null
+++ b/src/ui/common/types/api.ts
@@ -0,0 +1,9 @@
+export interface Pagination {
+ next_key: string;
+}
+
+export interface QueryMeta {
+ next: () => void;
+ hasMore: boolean;
+ isFetchingMore: boolean;
+}
diff --git a/src/ui/common/types/api/healthCheck.ts b/src/ui/common/types/api/healthCheck.ts
new file mode 100644
index 000000000..76d101771
--- /dev/null
+++ b/src/ui/common/types/api/healthCheck.ts
@@ -0,0 +1,3 @@
+export interface HealthCheckResponse {
+ data: string;
+}
diff --git a/src/ui/common/types/bsn.ts b/src/ui/common/types/bsn.ts
new file mode 100644
index 000000000..3b7106aeb
--- /dev/null
+++ b/src/ui/common/types/bsn.ts
@@ -0,0 +1,6 @@
+export interface Bsn {
+ id: string;
+ name: string;
+ description: string;
+ activeTvl: number;
+}
diff --git a/src/ui/common/types/delegations.ts b/src/ui/common/types/delegations.ts
new file mode 100644
index 000000000..0c99e0b33
--- /dev/null
+++ b/src/ui/common/types/delegations.ts
@@ -0,0 +1,54 @@
+// The phase-1 delegation
+export interface Delegation {
+ stakingTxHashHex: string;
+ stakerPkHex: string;
+ finalityProviderPkHex: string;
+ state: string;
+ stakingValueSat: number;
+ stakingTx: StakingTx;
+ unbondingTx: UnbondingTx | undefined;
+ isOverflow: boolean;
+ isEligibleForTransition: boolean;
+}
+
+export interface StakingTx {
+ txHex: string;
+ outputIndex: number;
+ startTimestamp: string;
+ startHeight: number;
+ timelock: number;
+}
+
+export interface UnbondingTx {
+ txHex: string;
+ outputIndex: number;
+}
+
+export const ACTIVE = "active";
+export const UNBONDING_REQUESTED = "unbonding_requested";
+export const UNBONDING = "unbonding";
+export const UNBONDED = "unbonded";
+export const WITHDRAWN = "withdrawn";
+export const PENDING = "pending";
+export const OVERFLOW = "overflow";
+export const EXPIRED = "expired";
+export const INTERMEDIATE_UNBONDING = "intermediate_unbonding";
+export const INTERMEDIATE_WITHDRAWAL = "intermediate_withdrawal";
+export const TRANSITIONED = "transitioned";
+export const INTERMEDIATE_TRANSITIONING = "intermediate_transitioning";
+
+// Define the state of a delegation as per API
+export const DelegationState = {
+ ACTIVE,
+ UNBONDING_REQUESTED,
+ UNBONDING,
+ UNBONDED,
+ WITHDRAWN,
+ PENDING,
+ OVERFLOW,
+ EXPIRED,
+ INTERMEDIATE_UNBONDING,
+ INTERMEDIATE_WITHDRAWAL,
+ TRANSITIONED,
+ INTERMEDIATE_TRANSITIONING,
+};
diff --git a/src/ui/common/types/delegationsV2.ts b/src/ui/common/types/delegationsV2.ts
new file mode 100644
index 000000000..363d70764
--- /dev/null
+++ b/src/ui/common/types/delegationsV2.ts
@@ -0,0 +1,121 @@
+import { ClientError, ERROR_CODES } from "@/ui/common/errors";
+
+import { FinalityProvider } from "./finalityProviders";
+
+export interface DelegationLike {
+ stakingAmount: number;
+ stakingTxHashHex: string;
+ startHeight: number;
+ state: DelegationV2StakingState;
+}
+
+export interface DelegationV2 extends DelegationLike {
+ stakingTxHex: string;
+ paramsVersion: number;
+ finalityProviderBtcPksHex: string[];
+ stakerBtcPkHex: string;
+ stakingTimelock: number;
+ bbnInceptionHeight: number;
+ bbnInceptionTime: string;
+ startHeight: number;
+ endHeight: number;
+ unbondingTimelock: number;
+ unbondingTxHex: string;
+ covenantUnbondingSignatures?: {
+ covenantBtcPkHex: string;
+ signatureHex: string;
+ }[];
+ slashing: {
+ stakingSlashingTxHex: string;
+ unbondingSlashingTxHex: string;
+ spendingHeight: number;
+ };
+}
+
+export interface DelegationWithFP extends DelegationV2 {
+ fp: FinalityProvider;
+}
+
+export enum DelegationV2StakingState {
+ // Basic states
+ PENDING = "PENDING",
+ VERIFIED = "VERIFIED",
+ ACTIVE = "ACTIVE",
+
+ // Unbonding states
+ TIMELOCK_UNBONDING = "TIMELOCK_UNBONDING",
+ EARLY_UNBONDING = "EARLY_UNBONDING",
+
+ // Withdrawable states
+ TIMELOCK_WITHDRAWABLE = "TIMELOCK_WITHDRAWABLE",
+ EARLY_UNBONDING_WITHDRAWABLE = "EARLY_UNBONDING_WITHDRAWABLE",
+ TIMELOCK_SLASHING_WITHDRAWABLE = "TIMELOCK_SLASHING_WITHDRAWABLE",
+ EARLY_UNBONDING_SLASHING_WITHDRAWABLE = "EARLY_UNBONDING_SLASHING_WITHDRAWABLE",
+
+ // Withdrawn states
+ TIMELOCK_WITHDRAWN = "TIMELOCK_WITHDRAWN",
+ EARLY_UNBONDING_WITHDRAWN = "EARLY_UNBONDING_WITHDRAWN",
+ TIMELOCK_SLASHING_WITHDRAWN = "TIMELOCK_SLASHING_WITHDRAWN",
+ EARLY_UNBONDING_SLASHING_WITHDRAWN = "EARLY_UNBONDING_SLASHING_WITHDRAWN",
+
+ // Slashed states
+ SLASHED = "SLASHED",
+
+ // Intermediate states
+ INTERMEDIATE_PENDING_VERIFICATION = "INTERMEDIATE_PENDING_VERIFICATION",
+ INTERMEDIATE_PENDING_BTC_CONFIRMATION = "INTERMEDIATE_PENDING_BTC_CONFIRMATION",
+ INTERMEDIATE_UNBONDING_SUBMITTED = "INTERMEDIATE_UNBONDING_SUBMITTED",
+ INTERMEDIATE_EARLY_UNBONDING_WITHDRAWAL_SUBMITTED = "INTERMEDIATE_EARLY_UNBONDING_WITHDRAWAL_SUBMITTED",
+ INTERMEDIATE_EARLY_UNBONDING_SLASHING_WITHDRAWAL_SUBMITTED = "INTERMEDIATE_EARLY_UNBONDING_SLASHING_WITHDRAWAL_SUBMITTED",
+ INTERMEDIATE_TIMELOCK_WITHDRAWAL_SUBMITTED = "INTERMEDIATE_TIMELOCK_WITHDRAWAL_SUBMITTED",
+ INTERMEDIATE_TIMELOCK_SLASHING_WITHDRAWAL_SUBMITTED = "INTERMEDIATE_TIMELOCK_SLASHING_WITHDRAWAL_SUBMITTED",
+}
+
+export const DELEGATION_STATUSES = {
+ [DelegationV2StakingState.PENDING]: 0,
+ [DelegationV2StakingState.INTERMEDIATE_PENDING_VERIFICATION]: 0.5,
+ [DelegationV2StakingState.VERIFIED]: 1,
+ [DelegationV2StakingState.INTERMEDIATE_PENDING_BTC_CONFIRMATION]: 1.5,
+ [DelegationV2StakingState.ACTIVE]: 2,
+
+ [DelegationV2StakingState.INTERMEDIATE_UNBONDING_SUBMITTED]: 2.5,
+ [DelegationV2StakingState.EARLY_UNBONDING]: 3,
+ [DelegationV2StakingState.EARLY_UNBONDING_WITHDRAWABLE]: 4,
+ [DelegationV2StakingState.INTERMEDIATE_EARLY_UNBONDING_WITHDRAWAL_SUBMITTED]: 4.5,
+ [DelegationV2StakingState.EARLY_UNBONDING_WITHDRAWN]: 5,
+
+ [DelegationV2StakingState.SLASHED]: 4,
+ [DelegationV2StakingState.EARLY_UNBONDING_SLASHING_WITHDRAWABLE]: 5,
+ [DelegationV2StakingState.INTERMEDIATE_EARLY_UNBONDING_SLASHING_WITHDRAWAL_SUBMITTED]: 5.5,
+ [DelegationV2StakingState.EARLY_UNBONDING_SLASHING_WITHDRAWN]: 6,
+
+ [DelegationV2StakingState.TIMELOCK_UNBONDING]: 3,
+ [DelegationV2StakingState.TIMELOCK_WITHDRAWABLE]: 4,
+ [DelegationV2StakingState.INTERMEDIATE_TIMELOCK_WITHDRAWAL_SUBMITTED]: 4.5,
+ [DelegationV2StakingState.TIMELOCK_WITHDRAWN]: 5,
+
+ [DelegationV2StakingState.TIMELOCK_SLASHING_WITHDRAWABLE]: 5,
+ [DelegationV2StakingState.INTERMEDIATE_TIMELOCK_SLASHING_WITHDRAWAL_SUBMITTED]: 5.5,
+ [DelegationV2StakingState.TIMELOCK_SLASHING_WITHDRAWN]: 6,
+} as const;
+
+export const getDelegationV2StakingState = (
+ state: string,
+): DelegationV2StakingState => {
+ const validState = Object.values(DelegationV2StakingState).find(
+ (enumState) => enumState === state,
+ );
+
+ if (!validState) {
+ throw new ClientError(
+ ERROR_CODES.VALIDATION_ERROR,
+ `Invalid delegation state: ${state}`,
+ );
+ }
+
+ return validState;
+};
+
+export interface DelegationV2Params {
+ currentTime: number;
+}
diff --git a/src/ui/common/types/errors.ts b/src/ui/common/types/errors.ts
new file mode 100644
index 000000000..21fb0c1a1
--- /dev/null
+++ b/src/ui/common/types/errors.ts
@@ -0,0 +1,63 @@
+/**
+ * @deprecated This enum is deprecated and will be removed in a future version.
+ */
+export enum ErrorType {
+ // Server-related errors
+ SERVER = "SERVER",
+
+ // Staking lifecycle errors
+ STAKING = "STAKING",
+ UNBONDING = "UNBONDING",
+ WITHDRAW = "WITHDRAW",
+ REGISTRATION = "REGISTRATION",
+
+ // Wallet errors
+ WALLET = "WALLET",
+
+ // Data errors
+ DELEGATIONS = "DELEGATIONS",
+
+ // Fallback
+ UNKNOWN = "UNKNOWN",
+}
+
+/**
+ * @deprecated This interface is deprecated and will be removed in a future version.
+ */
+export interface Error {
+ message: string;
+ type?: ErrorType;
+ displayMessage?: string;
+ sentryEventId?: string;
+ trace?: string;
+ userPublicKey?: string;
+ babylonAddress?: string;
+ stakingTxHash?: string;
+ btcAddress?: string;
+ category?: string;
+ endpoint?: string;
+ request?: Record;
+ response?: Record;
+ errorSource?: string;
+ metadata?: Record;
+}
+
+/**
+ * @deprecated This interface is deprecated and will be removed in a future version.
+ */
+export interface ErrorHandlerParam {
+ error: Error | null;
+ metadata?: Record;
+ displayOptions?: {
+ retryAction?: () => void;
+ noCancel?: boolean;
+ showModal?: boolean;
+ };
+}
+
+export interface ShowErrorParams {
+ error: Error;
+ retryAction?: () => void;
+ noCancel?: boolean;
+ showModal?: boolean;
+}
diff --git a/src/ui/common/types/fee.ts b/src/ui/common/types/fee.ts
new file mode 100644
index 000000000..5048b5a47
--- /dev/null
+++ b/src/ui/common/types/fee.ts
@@ -0,0 +1,12 @@
+export type Fees = {
+ // fee for inclusion in the next block
+ fastestFee: number;
+ // fee for inclusion in a block in 30 mins
+ halfHourFee: number;
+ // fee for inclusion in a block in 1 hour
+ hourFee: number;
+ // economy fee: inclusion not guaranteed
+ economyFee: number;
+ // minimum fee: the minimum fee of the network
+ minimumFee: number;
+};
diff --git a/src/ui/common/types/finalityProviders.ts b/src/ui/common/types/finalityProviders.ts
new file mode 100644
index 000000000..fba3d5b57
--- /dev/null
+++ b/src/ui/common/types/finalityProviders.ts
@@ -0,0 +1,51 @@
+export interface FinalityProvider {
+ id: string;
+ rank: number;
+ description: Description;
+ state: FinalityProviderState;
+ commission: string;
+ btcPk: string;
+ activeTVLSat: number;
+ totalTVLSat: number;
+ activeDelegations: number;
+ totalDelegations: number;
+ logo_url?: string;
+ bsnId?: string;
+ chain: string;
+}
+
+export interface FinalityProviderV1 {
+ description: Description;
+ state: "active" | "standby";
+ commission: string;
+ btcPk: string;
+ activeTVLSat: number;
+ totalTVLSat: number;
+ activeDelegations: number;
+ totalDelegations: number;
+}
+
+export interface Description {
+ moniker: string;
+ identity: string;
+ website: string;
+ securityContact: string;
+ details: string;
+}
+
+export enum FinalityProviderState {
+ ACTIVE = "FINALITY_PROVIDER_STATUS_ACTIVE",
+ INACTIVE = "FINALITY_PROVIDER_STATUS_INACTIVE",
+ JAILED = "FINALITY_PROVIDER_STATUS_JAILED",
+ SLASHED = "FINALITY_PROVIDER_STATUS_SLASHED",
+}
+
+export const FinalityProviderStateLabels: Record<
+ FinalityProviderState,
+ string
+> = {
+ [FinalityProviderState.ACTIVE]: "Active",
+ [FinalityProviderState.INACTIVE]: "Inactive",
+ [FinalityProviderState.JAILED]: "Jailed",
+ [FinalityProviderState.SLASHED]: "Slashed",
+};
diff --git a/src/ui/common/types/network.ts b/src/ui/common/types/network.ts
new file mode 100644
index 000000000..cdade211c
--- /dev/null
+++ b/src/ui/common/types/network.ts
@@ -0,0 +1,6 @@
+// supported networks
+export enum Network {
+ MAINNET = "mainnet",
+ TESTNET = "testnet",
+ SIGNET = "signet",
+}
diff --git a/src/ui/common/types/networkInfo.ts b/src/ui/common/types/networkInfo.ts
new file mode 100644
index 000000000..496665cfc
--- /dev/null
+++ b/src/ui/common/types/networkInfo.ts
@@ -0,0 +1,45 @@
+import { StakingParams } from "@babylonlabs-io/btc-staking-ts";
+
+export interface BbnStakingParamsVersion extends StakingParams {
+ version: number;
+ minCommissionRate: string;
+ maxActiveFinalityProviders: number;
+ delegationCreationBaseGasFee: number;
+ btcActivationHeight: number;
+ allowListExpirationHeight: number;
+}
+
+export interface BtcEpochCheckParamsVersion {
+ version: number;
+ btcConfirmationDepth: number;
+}
+
+export interface BbnStakingParams {
+ // The genesis params is the version 0 of the staking params which is
+ // compatible with the phase-1 global params
+ genesisParam: BbnStakingParamsVersion;
+ // The latest params is the param with the highest version number
+ latestParam: BbnStakingParamsVersion;
+ versions: BbnStakingParamsVersion[];
+}
+
+export interface BtcEpochCheckParams {
+ genesisParam: BtcEpochCheckParamsVersion;
+ latestParam: BtcEpochCheckParamsVersion;
+ versions: BtcEpochCheckParamsVersion[];
+}
+
+export interface StakingStatus {
+ isStakingOpen: boolean;
+}
+
+export interface Params {
+ bbnStakingParams: BbnStakingParams;
+ btcEpochCheckParams: BtcEpochCheckParams;
+ maxBsnFpProviders?: number;
+}
+
+export interface NetworkInfo {
+ stakingStatus: StakingStatus;
+ params: Params;
+}
diff --git a/src/ui/common/types/services/healthCheck.ts b/src/ui/common/types/services/healthCheck.ts
new file mode 100644
index 000000000..af1b71c0e
--- /dev/null
+++ b/src/ui/common/types/services/healthCheck.ts
@@ -0,0 +1,15 @@
+export type HealthCheckResult =
+ | { status: HealthCheckStatus.Normal; message: string }
+ | { status: HealthCheckStatus.GeoBlocked; message: string }
+ | { status: HealthCheckStatus.Error; message: string };
+
+export enum HealthCheckStatus {
+ Normal = "normal",
+ GeoBlocked = "geoblocked",
+ Error = "error",
+}
+
+export const API_ERROR_MESSAGE =
+ "Error occurred while fetching API. Please try again later";
+export const GEO_BLOCK_MESSAGE =
+ "We're sorry, but this page isn't accessible in your location at the moment due to the regional restrictions";
diff --git a/src/ui/common/types/stakingParams.ts b/src/ui/common/types/stakingParams.ts
new file mode 100644
index 000000000..7338ee130
--- /dev/null
+++ b/src/ui/common/types/stakingParams.ts
@@ -0,0 +1,7 @@
+export interface StakingParams {
+ minStakingAmountSat: number;
+ maxStakingAmountSat: number;
+ minStakingTimeBlocks: number;
+ maxStakingTimeBlocks: number;
+ stakingCap: number;
+}
diff --git a/src/ui/common/types/stakingStats.ts b/src/ui/common/types/stakingStats.ts
new file mode 100644
index 000000000..910c51054
--- /dev/null
+++ b/src/ui/common/types/stakingStats.ts
@@ -0,0 +1,8 @@
+export interface StakingStats {
+ activeTVLSat: number;
+ totalTVLSat: number;
+ activeDelegations: number;
+ totalDelegations: number;
+ totalStakers: number;
+ unconfirmedTVLSat: number;
+}
diff --git a/src/ui/common/utils/FeatureFlagService.ts b/src/ui/common/utils/FeatureFlagService.ts
new file mode 100644
index 000000000..b1932efd6
--- /dev/null
+++ b/src/ui/common/utils/FeatureFlagService.ts
@@ -0,0 +1,59 @@
+/**
+ * Feature flags service module
+ *
+ * This module provides methods for checking feature flags
+ * defined in the environment variables. All feature flag environment
+ * variables should be prefixed with NEXT_PUBLIC_FF_
+ *
+ * Rules:
+ * 1. All feature flags must be defined in this file for easy maintenance
+ * 2. All feature flags must start with NEXT_PUBLIC_FF_ prefix
+ * 3. Default value for all feature flags is false
+ * 4. Feature flags are only configurable by DevOps in mainnet environments
+ */
+
+export default {
+ /**
+ * MULTISTAKING feature flag
+ *
+ * Purpose: Enables multi-staking functionality
+ * Why needed: To gradually roll out multi-staking capabilities
+ * ETA for removal: TBD - Will be removed once multi-staking is fully released
+ */
+ get IsMultiStakingEnabled() {
+ return process.env.NEXT_PUBLIC_FF_MULTISTAKING === "true";
+ },
+
+ /**
+ * ENABLE_LEDGER feature flag
+ *
+ * Purpose: Enables ledger support
+ * Why needed: To gradually roll out ledger support
+ * ETA for removal: TBD - Will be removed once ledger support is fully released
+ */
+ get IsLedgerEnabled() {
+ return process.env.NEXT_PUBLIC_FF_ENABLE_LEDGER === "true";
+ },
+
+ /**
+ * PHASE_3 feature flag
+ *
+ * Purpose: Enables phase 3 functionality
+ * Why needed: To gradually roll out phase 3
+ * ETA for removal: TBD - Will be removed once phase 3 is fully released
+ */
+ get IsPhase3Enabled() {
+ return process.env.NEXT_PUBLIC_FF_PHASE_3 === "true";
+ },
+
+ /**
+ * Baby Staking feature flag
+ *
+ * Purpose: Enables Baby Staking Page
+ * Why needed: To gradually roll out Baby Staking
+ * ETA for removal: TBD - Will be removed once Baby Staking is fully released
+ */
+ get IsBabyStakingEnabled() {
+ return process.env.NEXT_PUBLIC_FF_BABYSTAKING === "true";
+ },
+};
diff --git a/src/ui/common/utils/bbn.ts b/src/ui/common/utils/bbn.ts
new file mode 100644
index 000000000..735de9c8f
--- /dev/null
+++ b/src/ui/common/utils/bbn.ts
@@ -0,0 +1,19 @@
+/**
+ * Converts BABY to uBBN (micro BABY).
+ * should be used internally in the app
+ * @param bbn The amount in BABY.
+ * @returns The equivalent amount in uBBN.
+ */
+export function babyToUbbn(bbn: number): number {
+ return Math.round(bbn * 1e6);
+}
+
+/**
+ * Converts uBBN (micro BABY) to BABY.
+ * should be used only in the UI
+ * @param ubbn The amount in uBBN.
+ * @returns The equivalent amount in BABY.
+ */
+export function ubbnToBaby(ubbn: number): number {
+ return ubbn / 1e6;
+}
diff --git a/src/ui/common/utils/btc.ts b/src/ui/common/utils/btc.ts
new file mode 100644
index 000000000..4cb34798e
--- /dev/null
+++ b/src/ui/common/utils/btc.ts
@@ -0,0 +1,19 @@
+/**
+ * Converts satoshis to BTC.
+ * should be used internally in the app
+ * @param satoshi The amount in satoshis.
+ * @returns The equivalent amount in BTC.
+ */
+export function satoshiToBtc(satoshi: number): number {
+ return satoshi / 1e8;
+}
+
+/**
+ * Converts BTC to satoshis.
+ * should be used only in the UI
+ * @param btc The amount in BTC.
+ * @returns The equivalent amount in satoshis.
+ */
+export function btcToSatoshi(btc: number): number {
+ return Math.round(btc * 1e8);
+}
diff --git a/src/ui/common/utils/buffer.ts b/src/ui/common/utils/buffer.ts
new file mode 100644
index 000000000..2c053d0c9
--- /dev/null
+++ b/src/ui/common/utils/buffer.ts
@@ -0,0 +1,17 @@
+/**
+ * Reverses the order of bytes in a buffer.
+ * @param buffer - The buffer to reverse.
+ * @returns A new buffer with the bytes reversed.
+ */
+export const reverseBuffer = (buffer: Uint8Array): Uint8Array => {
+ if (buffer.length < 1) return buffer;
+ let j = buffer.length - 1;
+ let tmp = 0;
+ for (let i = 0; i < buffer.length / 2; i++) {
+ tmp = buffer[i];
+ buffer[i] = buffer[j];
+ buffer[j] = tmp;
+ j--;
+ }
+ return buffer;
+};
diff --git a/src/ui/common/utils/chunkArray.ts b/src/ui/common/utils/chunkArray.ts
new file mode 100644
index 000000000..b5e76bf75
--- /dev/null
+++ b/src/ui/common/utils/chunkArray.ts
@@ -0,0 +1,14 @@
+import { ClientError, ERROR_CODES } from "@/ui/common/errors";
+
+// Helper function to split the array into chunks of specified size
+export const chunkArray = (array: any[], size: number) => {
+ // system error
+ if (size <= 0)
+ throw new ClientError(ERROR_CODES.VALIDATION_ERROR, "Invalid chunk size");
+
+ const result = [];
+ for (let i = 0; i < array.length; i += size) {
+ result.push(array.slice(i, i + size));
+ }
+ return result;
+};
diff --git a/src/ui/common/utils/createStateUtils.ts b/src/ui/common/utils/createStateUtils.ts
new file mode 100644
index 000000000..c834d9d10
--- /dev/null
+++ b/src/ui/common/utils/createStateUtils.ts
@@ -0,0 +1,12 @@
+import { createContext, useContext } from "react";
+
+export function createStateUtils