Repository: babylonlabs-io/simple-staking
Pull request #985: fix: disable unbound when isStakingManagerReady is false
Author: login: jonybur
Created: 2025-05-13T15:26:49Z | Merged: 2025-05-21T09:29:59Z

- Added loading indicators to Unbond buttons when temporarily disabled
- Improved user experience by showing clear status during connection attempts
- Prevented UI from appearing frozen during staking operations

https://github.com/user-attachments/assets/6990acd3-d1d6-4d13-ba63-a9a1288a189e

https://github.com/user-attachments/assets/b1041d63-13af-46b9-8235-646de7260ef1


https://github.com/babylonlabs-io/simple-staking/issues/874


Diff:
diff --git a/src/app/components/Delegations/DelegationActions.tsx b/src/app/components/Delegations/DelegationActions.tsx
index f7c52e69e..4ca9271fb 100644
--- a/src/app/components/Delegations/DelegationActions.tsx
+++ b/src/app/components/Delegations/DelegationActions.tsx
@@ -1,9 +1,11 @@
-import { Button, Popover, Text } from "@babylonlabs-io/core-ui";
+import { Button, Loader, Popover, Text } from "@babylonlabs-io/core-ui";
 import { useState } from "react";
 import { IoMdMore } from "react-icons/io";
 import { Tooltip } from "react-tooltip";
+import { twJoin } from "tailwind-merge";
 
 import { useBbnQuery } from "@/app/hooks/client/rpc/queries/useBbnQuery";
+import { useStakingManagerService } from "@/app/hooks/services/useStakingManagerService";
 import { useFinalityProviderState } from "@/app/state/FinalityProviderState";
 import { useStakingState } from "@/app/state/StakingState";
 import { DelegationState } from "@/app/types/delegations";
@@ -37,6 +39,8 @@ export const DelegationActions: React.FC<DelegationActionsProps> = ({
   const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
   const { getRegisteredFinalityProvider } = useFinalityProviderState();
   const { disabled } = useStakingState();
+  const { isLoading } = useStakingManagerService();
+  const isStakingManagerReady = !isLoading;
 
   const {
     balanceQuery: { data: bbnBalance = 0 },
@@ -108,8 +112,13 @@ export const DelegationActions: React.FC<DelegationActionsProps> = ({
           size="small"
           color="primary"
           onClick={() => onUnbond(stakingTxHashHex)}
+          disabled={!isStakingManagerReady}
+          className="flex items-center gap-1"
         >
           Unbond
+          {!isStakingManagerReady && (
+            <Loader size={12} className="text-accent-primary" />
+          )}
         </Button>
       </div>
     );
@@ -167,12 +176,22 @@ export const DelegationActions: React.FC<DelegationActionsProps> = ({
             variant="body2"
             as="button"
             onClick={() => {
-              onUnbond(stakingTxHashHex);
-              setIsPopoverOpen(false);
+              if (isStakingManagerReady) {
+                onUnbond(stakingTxHashHex);
+                setIsPopoverOpen(false);
+              }
             }}
-            className="text-accent-primary transition-all hover:brightness-125"
+            className={twJoin(
+              "flex items-center gap-1 text-accent-primary transition-all",
+              isStakingManagerReady
+                ? "hover:brightness-125"
+                : "opacity-50 cursor-not-allowed",
+            )}
           >
             Unbond
+            {!isStakingManagerReady && (
+              <Loader size={12} className="text-accent-primary ml-1" />
+            )}
           </Text>
         </Popover>
       </div>
diff --git a/src/app/hooks/services/useStakingManagerService.ts b/src/app/hooks/services/useStakingManagerService.ts
index 9731d76f6..7003631a5 100644
--- a/src/app/hooks/services/useStakingManagerService.ts
+++ b/src/app/hooks/services/useStakingManagerService.ts
@@ -32,17 +32,18 @@ export const useStakingManagerService = () => {
 
   const { current: eventEmitter } = useRef<EventEmitter>(new EventEmitter());
 
+  const isLoading =
+    !btcNetwork ||
+    !cosmosConnected ||
+    !btcConnected ||
+    !signPsbt ||
+    !signMessage ||
+    !signBbnTx ||
+    !versionedParams ||
+    versionedParams.length === 0;
+
   const createBtcStakingManager = useCallback(() => {
-    if (
-      !btcNetwork ||
-      !cosmosConnected ||
-      !btcConnected ||
-      !signPsbt ||
-      !signMessage ||
-      !signBbnTx ||
-      !versionedParams ||
-      versionedParams.length === 0
-    ) {
+    if (isLoading) {
       logger.info("createBtcStakingManager", {
         cosmosConnected,
         btcConnected,
@@ -90,13 +91,15 @@ export const useStakingManagerService = () => {
       bbnProvider,
     );
   }, [
+    isLoading,
     btcNetwork,
+    versionedParams,
+    logger,
     cosmosConnected,
     btcConnected,
     signPsbt,
     signMessage,
     signBbnTx,
-    versionedParams,
     eventEmitter,
   ]);
 
@@ -115,6 +118,7 @@ export const useStakingManagerService = () => {
   );
 
   return {
+    isLoading,
     createBtcStakingManager,
     on,
     off,
diff --git a/src/components/delegations/DelegationList/components/ActionButton.tsx b/src/components/delegations/DelegationList/components/ActionButton.tsx
index e3f91dbd6..e276059dc 100644
--- a/src/components/delegations/DelegationList/components/ActionButton.tsx
+++ b/src/components/delegations/DelegationList/components/ActionButton.tsx
@@ -1,4 +1,4 @@
-import { Button } from "@babylonlabs-io/core-ui";
+import { Button, Loader } from "@babylonlabs-io/core-ui";
 
 import { DELEGATION_ACTIONS as ACTIONS } from "@/app/constants";
 import { ActionType } from "@/app/hooks/services/useDelegationService";
@@ -13,6 +13,8 @@ interface ActionButtonProps {
   tooltip?: string | JSX.Element;
   delegation: DelegationWithFP;
   onClick?: (action: ActionType, delegation: DelegationWithFP) => void;
+  disabled?: boolean;
+  showLoader?: boolean;
 }
 
 type Actions = Record<
@@ -127,6 +129,8 @@ export function ActionButton({
   delegation,
   tooltip,
   onClick,
+  disabled = false,
+  showLoader = false,
 }: ActionButtonProps) {
   const buttonProps =
     ACTION_BUTTON_PROPS[delegation.fp?.state]?.[delegation.state];
@@ -139,8 +143,11 @@ export function ActionButton({
         variant="outlined"
         size="small"
         onClick={() => onClick?.(buttonProps.action, delegation)}
+        disabled={disabled}
+        className="flex items-center gap-1"
       >
         {buttonProps.title}
+        {showLoader && <Loader size={12} className="text-accent-primary" />}
       </Button>
     </Hint>
   );
diff --git a/src/components/delegations/DelegationList/index.tsx b/src/components/delegations/DelegationList/index.tsx
index 26512aee3..5edba623d 100644
--- a/src/components/delegations/DelegationList/index.tsx
+++ b/src/components/delegations/DelegationList/index.tsx
@@ -4,7 +4,11 @@ import {
   ActionType,
   useDelegationService,
 } from "@/app/hooks/services/useDelegationService";
-import { DelegationWithFP } from "@/app/types/delegationsV2";
+import { useStakingManagerService } from "@/app/hooks/services/useStakingManagerService";
+import {
+  DelegationV2StakingState,
+  DelegationWithFP,
+} from "@/app/types/delegationsV2";
 import { GridTable, type TableColumn } from "@/components/common/GridTable";
 import { Hint } from "@/components/common/Hint";
 import { FinalityProviderMoniker } from "@/components/delegations/DelegationList/components/FinalityProviderMoniker";
@@ -21,6 +25,7 @@ import { NoDelegations } from "./NoDelegations";
 type TableParams = {
   validations: Record<string, { valid: boolean; error?: string }>;
   handleActionClick: (action: ActionType, delegation: DelegationWithFP) => void;
+  isStakingManagerReady: boolean;
 };
 
 const networkConfig = getNetworkConfig();
@@ -65,17 +70,25 @@ const columns: TableColumn<DelegationWithFP, TableParams>[] = [
     field: "actions",
     headerName: "Action",
     width: "minmax(max-content, 0.5fr)",
-    renderCell: (row, _, { handleActionClick, validations }) => {
+    renderCell: (
+      row,
+      _,
+      { handleActionClick, validations, isStakingManagerReady },
+    ) => {
       const { valid, error } = validations[row.stakingTxHashHex];
 
-      // Hide the action button if the delegation is invalid
       if (!valid) return null;
 
+      const isUnbondDisabled =
+        row.state === DelegationV2StakingState.ACTIVE && !isStakingManagerReady;
+
       return (
         <ActionButton
           tooltip={error}
           delegation={row}
           onClick={handleActionClick}
+          disabled={isUnbondDisabled}
+          showLoader={isUnbondDisabled}
         />
       );
     },
@@ -97,6 +110,9 @@ export function DelegationList() {
     closeConfirmationModal,
   } = useDelegationService();
 
+  const { isLoading: isStakingManagerLoading } = useStakingManagerService();
+  const isStakingManagerReady = !isStakingManagerLoading;
+
   return (
     <Card>
       <Heading variant="h6" className="text-accent-primary py-2 mb-6">
@@ -123,6 +139,7 @@ export function DelegationList() {
         params={{
           handleActionClick: openConfirmationModal,
           validations,
+          isStakingManagerReady,
         }}
         fallback={<NoDelegations />}
       />


Reviews:
user: login: jeremy-babylonlabs
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T20:16:39Z
user: login: jrwbabylonlab
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T01:21:05Z
user: login: jrwbabylonlab
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T01:21:39Z
user: login: jonybur
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-15T23:53:11Z
user: login: 0xDazzer
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T09:37:35Z
user: login: jrwbabylonlab
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T07:44:30Z
user: login: 0xDazzer
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T17:48:09Z
user: login: jonybur
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-20T02:26:50Z
user: login: jonybur
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-20T02:32:44Z
user: login: 0xDazzer
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T09:41:11Z
user: login: 0xDazzer
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T02:29:16Z
user: login: jrwbabylonlab
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T04:39:12Z

Review comments:
diff_hunk: @@ -167,12 +174,21 @@ export const DelegationActions: React.FC<DelegationActionsProps> = ({
             variant="body2"
             as="button"
             onClick={() => {
-              onUnbond(stakingTxHashHex);
-              setIsPopoverOpen(false);
+              if (isStakingManagerReady) {
+                onUnbond(stakingTxHashHex);
+                setIsPopoverOpen(false);
+              }
path: src/app/components/Delegations/DelegationActions.tsx
user: login: jrwbabylonlab
body: Do we need an else here to catch unhappy path where `isStakingManagerReady` is false?
created_at: 2025-05-15T01:21:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -167,12 +174,21 @@ export const DelegationActions: React.FC<DelegationActionsProps> = ({
             variant="body2"
             as="button"
             onClick={() => {
-              onUnbond(stakingTxHashHex);
-              setIsPopoverOpen(false);
+              if (isStakingManagerReady) {
+                onUnbond(stakingTxHashHex);
+                setIsPopoverOpen(false);
+              }
path: src/app/components/Delegations/DelegationActions.tsx
user: login: jonybur
body: I don't think so, we don't want the button to do anything if the button is disabled
created_at: 2025-05-15T23:53:10Z
author_association: NONE
path: src/app/hooks/services/useStakingManagerReady.ts
user: login: 0xDazzer
body: we need better way to do that, without setInterval
created_at: 2025-05-16T09:34:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -97,6 +45,75 @@ export function DelegationList() {
     closeConfirmationModal,
   } = useDelegationService();
 
+  const isStakingManagerReady = useStakingManagerReady();
+
+  const columns: TableColumn<DelegationWithFP, TableParams>[] = [
path: src/components/delegations/DelegationList/index.tsx
user: login: 0xDazzer
body: Any reason why did you move config inside component?
created_at: 2025-05-16T09:37:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -167,12 +174,21 @@ export const DelegationActions: React.FC<DelegationActionsProps> = ({
             variant="body2"
             as="button"
             onClick={() => {
-              onUnbond(stakingTxHashHex);
-              setIsPopoverOpen(false);
+              if (isStakingManagerReady) {
+                onUnbond(stakingTxHashHex);
+                setIsPopoverOpen(false);
+              }
             }}
-            className="text-accent-primary transition-all hover:brightness-125"
+            className={`flex items-center gap-1 text-accent-primary transition-all ${
+              isStakingManagerReady
+                ? "hover:brightness-125"
+                : "opacity-50 cursor-not-allowed"
+            }`}
path: src/app/components/Delegations/DelegationActions.tsx
user: login: 0xDazzer
body: nit: use twJoin() for classNames
created_at: 2025-05-19T17:31:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,18 @@
+import { useEffect, useState } from "react";
+
+import { useStakingManagerService } from "./useStakingManagerService";
+
+export function useStakingManagerReady() {
+  const { createBtcStakingManager } = useStakingManagerService();
+  const [ready, setReady] = useState<boolean>(() =>
+    Boolean(createBtcStakingManager()),
+  );
+
+  useEffect(() => {
+    if (!ready && createBtcStakingManager()) {
+      setReady(true);
+    }
+  }, [ready, createBtcStakingManager]);
path: src/app/hooks/services/useStakingManagerReady.ts
user: login: 0xDazzer
body: It still looks not good. I'll have another review tmr
created_at: 2025-05-19T17:47:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,18 @@
+import { useEffect, useState } from "react";
+
+import { useStakingManagerService } from "./useStakingManagerService";
+
+export function useStakingManagerReady() {
+  const { createBtcStakingManager } = useStakingManagerService();
+  const [ready, setReady] = useState<boolean>(() =>
+    Boolean(createBtcStakingManager()),
+  );
+
+  useEffect(() => {
+    if (!ready && createBtcStakingManager()) {
+      setReady(true);
+    }
+  }, [ready, createBtcStakingManager]);
path: src/app/hooks/services/useStakingManagerReady.ts
user: login: jonybur
body: I have removed this hook and instad of this I have set up a retrying logic in the staking manager creation step. Please recheck
created_at: 2025-05-20T02:26:50Z
author_association: NONE
diff_hunk: @@ -0,0 +1,18 @@
+import { useEffect, useState } from "react";
+
+import { useStakingManagerService } from "./useStakingManagerService";
+
+export function useStakingManagerReady() {
+  const { createBtcStakingManager } = useStakingManagerService();
+  const [ready, setReady] = useState<boolean>(() =>
+    Boolean(createBtcStakingManager()),
+  );
+
+  useEffect(() => {
+    if (!ready && createBtcStakingManager()) {
+      setReady(true);
+    }
+  }, [ready, createBtcStakingManager]);
path: src/app/hooks/services/useStakingManagerReady.ts
user: login: jonybur
body: https://github.com/user-attachments/assets/ac2ccd3c-7250-40df-a76e-accf394ab01b

This is how it looks like when the staking manager fails a couple of times (until it works)
created_at: 2025-05-20T02:32:44Z
author_association: NONE
diff_hunk: @@ -0,0 +1,18 @@
+import { useEffect, useState } from "react";
+
+import { useStakingManagerService } from "./useStakingManagerService";
+
+export function useStakingManagerReady() {
+  const { createBtcStakingManager } = useStakingManagerService();
+  const [ready, setReady] = useState<boolean>(() =>
+    Boolean(createBtcStakingManager()),
+  );
+
+  useEffect(() => {
+    if (!ready && createBtcStakingManager()) {
+      setReady(true);
+    }
+  }, [ready, createBtcStakingManager]);
path: src/app/hooks/services/useStakingManagerReady.ts
user: login: 0xDazzer
body: I think you can still simplify this logic https://github.com/babylonlabs-io/simple-staking/pull/1003
created_at: 2025-05-20T09:41:10Z
author_association: CONTRIBUTOR

PR comments:
user: login: jrwbabylonlab
created_at: 2025-05-15T04:59:48Z
body: @jonybur regarding this "Added default API request timeout (15s) to prevent hanging requests" could u point out the code that implement this? i couldn't locate the timeout policy on the client calls.
author_association: CONTRIBUTOR
user: login: jonybur
created_at: 2025-05-15T23:33:37Z
body: @jrwbabylonlab I actually took it out before commiting as it would affect all calls, is this something we want?
author_association: NONE
user: login: jrwbabylonlab
created_at: 2025-05-15T23:34:29Z
body: > @jrwbabylonlab I actually took it out before commiting as it would affect all calls, is this something we want?

that's fine. Maybe update the PR description as it mentioned about timeout
author_association: CONTRIBUTOR
user: login: jonybur
created_at: 2025-05-15T23:53:40Z
body: @jrwbabylonlab Updated and comments addressed, if it's OK I can merge
author_association: NONE