From 4754347f8b1c71477f9bcce34df5457cf26ece8b Mon Sep 17 00:00:00 2001 From: Govard Barkhatov Date: Fri, 10 Oct 2025 19:18:19 +0300 Subject: [PATCH 1/3] feat(packages): co-staking API APR data --- .../hooks/services/useDelegationService.ts | 12 +- .../services/usePendingOperationsService.tsx | 3 + .../src/ui/common/api/getAPR.ts | 24 +- .../components/Modals/CoStakingBoostModal.tsx | 6 +- .../hooks/services/useCoStakingService.ts | 309 +++++------------- .../hooks/services/useTransactionService.ts | 10 + .../src/ui/common/rewards/index.tsx | 26 +- .../src/ui/common/state/CoStakingState.tsx | 163 ++++++--- .../src/ui/common/types/api/coStaking.ts | 38 ++- .../ui/common/utils/coStakingCalculations.ts | 57 ---- 10 files changed, 275 insertions(+), 373 deletions(-) diff --git a/services/simple-staking/src/ui/baby/hooks/services/useDelegationService.ts b/services/simple-staking/src/ui/baby/hooks/services/useDelegationService.ts index 91fc6bf2..b2be018f 100644 --- a/services/simple-staking/src/ui/baby/hooks/services/useDelegationService.ts +++ b/services/simple-staking/src/ui/baby/hooks/services/useDelegationService.ts @@ -1,7 +1,11 @@ import { useCallback, useMemo } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import babylon from "@/infrastructure/babylon"; -import { useDelegations } from "@/ui/baby/hooks/api/useDelegations"; +import { + useDelegations, + BABY_DELEGATIONS_KEY, +} from "@/ui/baby/hooks/api/useDelegations"; import { useUnbondingDelegations } from "@/ui/baby/hooks/api/useUnbondingDelegations"; import { usePendingOperationsService } from "@/ui/baby/hooks/services/usePendingOperationsService"; import { @@ -33,6 +37,7 @@ export interface Delegation { } export function useDelegationService() { + const queryClient = useQueryClient(); const { bech32Address } = useCosmosWallet(); const { pendingOperations, @@ -241,9 +246,12 @@ export function useDelegationService() { // Only add pending operation after successful transaction submission addPendingOperation(validatorAddress, amount, operationType); + // Invalidate queries to trigger APR refetch with updated totals + queryClient.invalidateQueries({ queryKey: [BABY_DELEGATIONS_KEY] }); + return result; }, - [sendBbnTx, addPendingOperation], + [sendBbnTx, addPendingOperation, queryClient], ); const estimateStakingFee = useCallback( diff --git a/services/simple-staking/src/ui/baby/hooks/services/usePendingOperationsService.tsx b/services/simple-staking/src/ui/baby/hooks/services/usePendingOperationsService.tsx index 602f348f..2caa16f1 100644 --- a/services/simple-staking/src/ui/baby/hooks/services/usePendingOperationsService.tsx +++ b/services/simple-staking/src/ui/baby/hooks/services/usePendingOperationsService.tsx @@ -108,6 +108,9 @@ function usePendingOperationsServiceInternal() { ); localStorage.setItem(storageKey, JSON.stringify(storageFormat)); + + // Emit custom event for same-tab updates (storage event only fires for other tabs) + window.dispatchEvent(new Event("baby-pending-operations-updated")); }, [pendingOperations, bech32Address]); const addPendingOperation = useCallback( diff --git a/services/simple-staking/src/ui/common/api/getAPR.ts b/services/simple-staking/src/ui/common/api/getAPR.ts index 0ed4dcd9..bfd4d841 100644 --- a/services/simple-staking/src/ui/common/api/getAPR.ts +++ b/services/simple-staking/src/ui/common/api/getAPR.ts @@ -1,17 +1,27 @@ import { API_ENDPOINTS } from "../constants/endpoints"; -import { GlobalUnitAPRData } from "../types/api/coStaking"; +import { PersonalizedAPRResponse } from "../types/api/coStaking"; import { apiWrapper } from "./apiWrapper"; /** - * Fetch APR data from the backend API - * Returns APR values for BTC staking, BABY staking, Co-staking, and maximum APR + * Fetch personalized APR data from backend based on user's total staked amounts + * @param btcStakedSat - Total BTC in satoshis (confirmed + pending) + * @param babyStakedUbbn - Total BABY in ubbn (confirmed + pending) + * @returns Personalized APR data including current, boost, and additional BABY needed */ -export const getAPR = async (): Promise => { - const { data } = await apiWrapper<{ data: GlobalUnitAPRData }>( +export const getPersonalizedAPR = async ( + btcStakedSat: number, + babyStakedUbbn: number, +): Promise => { + const params = new URLSearchParams({ + btc_staked: btcStakedSat.toString(), + baby_staked: babyStakedUbbn.toString(), + }); + + const { data } = await apiWrapper( "GET", - API_ENDPOINTS.APR, - "Error fetching APR data", + `${API_ENDPOINTS.APR}?${params.toString()}`, + "Error fetching personalized APR data", ); return data.data; diff --git a/services/simple-staking/src/ui/common/components/Modals/CoStakingBoostModal.tsx b/services/simple-staking/src/ui/common/components/Modals/CoStakingBoostModal.tsx index 0f5880e6..fd7ca252 100644 --- a/services/simple-staking/src/ui/common/components/Modals/CoStakingBoostModal.tsx +++ b/services/simple-staking/src/ui/common/components/Modals/CoStakingBoostModal.tsx @@ -2,7 +2,7 @@ import { useMemo } from "react"; import { getNetworkConfigBTC } from "../../config/network/btc"; import { getNetworkConfigBBN } from "../../config/network/bbn"; -import { useCoStakingService } from "../../hooks/services/useCoStakingService"; +import { useCoStakingState } from "../../state/CoStakingState"; import { formatAPRPercentage } from "../../utils/formatAPR"; import { SubmitModal } from "./SubmitModal"; @@ -18,9 +18,9 @@ export const CoStakingBoostModal: React.FC = ({ }) => { const { coinSymbol: btcCoinSymbol } = getNetworkConfigBTC(); const { coinSymbol: babyCoinSymbol } = getNetworkConfigBBN(); - const { getCoStakingAPR } = useCoStakingService(); + const { aprData } = useCoStakingState(); - const { currentApr, boostApr, additionalBabyNeeded } = getCoStakingAPR(); + const { currentApr, boostApr, additionalBabyNeeded } = aprData; const submitButtonText = useMemo( () => diff --git a/services/simple-staking/src/ui/common/hooks/services/useCoStakingService.ts b/services/simple-staking/src/ui/common/hooks/services/useCoStakingService.ts index 2a137391..2ce85dcc 100644 --- a/services/simple-staking/src/ui/common/hooks/services/useCoStakingService.ts +++ b/services/simple-staking/src/ui/common/hooks/services/useCoStakingService.ts @@ -1,36 +1,34 @@ -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import babylon from "@/infrastructure/babylon"; import { useClientQuery } from "@/ui/common/hooks/client/useClient"; import { ONE_MINUTE } from "@/ui/common/constants"; -import { useCosmosWallet } from "@/ui/common/context/wallet/CosmosWalletProvider"; -import { useError } from "@/ui/common/context/Error/ErrorProvider"; import { useLogger } from "@/ui/common/hooks/useLogger"; import type { CoStakingAPRData } from "@/ui/common/types/api/coStaking"; import { calculateAdditionalBabyNeeded, - calculateBTCEligibilityPercentage, - calculateUserCoStakingAPR, + calculateRequiredBabyTokens, } from "@/ui/common/utils/coStakingCalculations"; import FeatureFlagService from "@/ui/common/utils/FeatureFlagService"; import { ubbnToBaby } from "@/ui/common/utils/bbn"; -import { getAPR } from "@/ui/common/api/getAPR"; +import { getPersonalizedAPR } from "@/ui/common/api/getAPR"; const CO_STAKING_PARAMS_KEY = "CO_STAKING_PARAMS"; -const CO_STAKING_REWARDS_TRACKER_KEY = "CO_STAKING_REWARDS_TRACKER"; -const CO_STAKING_CURRENT_REWARDS_KEY = "CO_STAKING_CURRENT_REWARDS"; const CO_STAKING_APR_KEY = "CO_STAKING_APR"; -const CO_STAKING_REWARD_SUPPLY_KEY = "CO_STAKING_REWARD_SUPPLY"; export const DEFAULT_COSTAKING_SCORE_RATIO = 50; /** * Hook for managing co-staking functionality * Provides methods to fetch co-staking data, calculate requirements, and manage rewards + * + * @param totalBtcStakedSat - Total BTC staked in satoshis (confirmed + pending from localStorage) + * @param totalBabyStakedUbbn - Total BABY staked in ubbn (confirmed + pending from localStorage) */ -export const useCoStakingService = () => { - const { bech32Address, connected } = useCosmosWallet(); - const { handleError } = useError(); +export const useCoStakingService = ( + totalBtcStakedSat: number, + totalBabyStakedUbbn: number, +) => { const logger = useLogger(); // Check if co-staking is enabled @@ -47,7 +45,7 @@ export const useCoStakingService = () => { return { params: { costaking_portion: params.costakingPortion.toString(), - score_ratio_btc_by_baby: params.scoreRatioBtcByBaby, + score_ratio_btc_by_baby: String(params.scoreRatioBtcByBaby), validators_portion: params.validatorsPortion.toString(), }, }; @@ -63,103 +61,37 @@ export const useCoStakingService = () => { retry: 3, }); - // Query for user's co-staking rewards tracker - const rewardsTrackerQuery = useClientQuery({ - queryKey: [CO_STAKING_REWARDS_TRACKER_KEY, bech32Address], - queryFn: async () => { - if (!bech32Address) return null; - - const client = await babylon.client(); - try { - const tracker = - await client.baby.getCoStakerRewardsTracker(bech32Address); - if (!tracker) return null; - - // Convert to match the existing interface - return { - start_period_cumulative_reward: tracker.startPeriodCumulativeReward, - active_satoshis: tracker.activeSatoshis, - active_baby: tracker.activeBaby, - total_score: tracker.totalScore, - }; - } catch (error) { - logger.error(error as Error, { - tags: { action: "getCoStakerRewardsTracker", bech32Address }, - }); - return null; - } - }, - enabled: Boolean(isCoStakingEnabled && connected && bech32Address), - staleTime: ONE_MINUTE, - retry: 3, - }); - - // Query for current co-staking rewards - const currentRewardsQuery = useClientQuery({ - queryKey: [CO_STAKING_CURRENT_REWARDS_KEY], - queryFn: async () => { - const client = await babylon.client(); - try { - const rewards = await client.baby.getCurrentCoStakingRewards(); - // Convert to match the existing interface - return { - rewards: rewards.rewards, - period: rewards.period, - total_score: rewards.totalScore, - }; - } catch (error) { - logger.error(error as Error, { - tags: { action: "getCurrentCoStakingRewards" }, - }); - throw error; - } - }, - enabled: isCoStakingEnabled, - staleTime: ONE_MINUTE, - retry: 3, - }); - - // Query for APR data + // Query for personalized APR data from backend const aprQuery = useClientQuery({ - queryKey: [CO_STAKING_APR_KEY], + queryKey: [CO_STAKING_APR_KEY, totalBtcStakedSat, totalBabyStakedUbbn], queryFn: async () => { try { - return await getAPR(); - } catch (error) { - logger.error(error as Error, { - tags: { action: "getAPR" }, - }); - return null; - } - }, - enabled: isCoStakingEnabled, - staleTime: Infinity, // Fetch once per page load - retry: 3, - }); + const result = await getPersonalizedAPR( + totalBtcStakedSat, + totalBabyStakedUbbn, + ); - // Query for total co-staking reward supply - const rewardSupplyQuery = useClientQuery({ - queryKey: [CO_STAKING_REWARD_SUPPLY_KEY], - queryFn: async () => { - const client = await babylon.client(); - try { - return await client.baby.getAnnualCoStakingRewardSupply(); + return result; } catch (error) { + console.error("[APR Query] Error:", error); logger.error(error as Error, { - tags: { action: "getAnnualCoStakingRewardSupply" }, + tags: { + action: "getPersonalizedAPR", + btcStaked: String(totalBtcStakedSat), + babyStaked: String(totalBabyStakedUbbn), + }, }); return null; } }, enabled: isCoStakingEnabled, - staleTime: ONE_MINUTE * 5, // Cache for 5 minutes + staleTime: ONE_MINUTE, // Cache for 1 minute retry: 3, }); // Destructure refetch functions for stable references const { refetch: refetchCoStakingParams } = coStakingParamsQuery; - const { refetch: refetchRewardsTracker } = rewardsTrackerQuery; - const { refetch: refetchCurrentRewards } = currentRewardsQuery; + const { refetch: refetchAPR } = aprQuery; /** * Get the co-staking score ratio (BABY per BTC) @@ -176,184 +108,114 @@ export const useCoStakingService = () => { /** * Calculate additional BABY tokens needed for full co-staking rewards + * Uses backend-provided value or falls back to frontend calculation */ const getAdditionalBabyNeeded = useCallback((): number => { - const scoreRatio = getScoreRatio(); - const rewardsTracker = rewardsTrackerQuery.data; - - if (!rewardsTracker) return 0; + const aprData = aprQuery.data; - const activeSatoshis = Number(rewardsTracker.active_satoshis); - const currentUbbn = Number(rewardsTracker.active_baby); + // Use backend-provided value if available + if (aprData && aprData.additional_baby_needed_for_boost !== undefined) { + return aprData.additional_baby_needed_for_boost; + } - // Calculate additional ubbn needed + // Fallback to frontend calculation if backend data not available + const scoreRatio = getScoreRatio(); const additionalUbbnNeeded = calculateAdditionalBabyNeeded( - activeSatoshis, - currentUbbn, + totalBtcStakedSat, + totalBabyStakedUbbn, scoreRatio, ); - // Convert to BABY for display return ubbnToBaby(additionalUbbnNeeded); - }, [getScoreRatio, rewardsTrackerQuery.data]); + }, [aprQuery.data, getScoreRatio, totalBtcStakedSat, totalBabyStakedUbbn]); /** * Get co-staking APR with current and boost values * - * Uses the formula based on user's share of the global co-staking pool: - * - * co_staking_apr = (user_total_score / global_total_score_sum) - * × total_co_staking_reward_supply - * / user_active_baby - * - * A% (Current APR) = BTC staking APR + BABY staking APR + user's co-staking APR - * B% (Boost APR) = What user earns at 100% co-staking eligibility - * X (Additional BABY needed) = BABY tokens to reach 100% eligibility - * - * Example UI Message: "Your current APR is A%. Stake X BABY to boost it up to B%." + * Returns personalized APR data from backend: + * - A% (Current APR) = current.total_apr (BTC + BABY + current co-staking) + * - B% (Boost APR) = boost.total_apr (BTC + BABY + maximum co-staking) + * - X (Additional BABY needed) = additional_baby_needed_for_boost */ const getCoStakingAPR = useCallback((): CoStakingAPRData => { - const rewardsTracker = rewardsTrackerQuery.data; - const currentRewards = currentRewardsQuery.data; const aprData = aprQuery.data; - const rewardSupply = rewardSupplyQuery.data; - const scoreRatio = getScoreRatio(); - const additionalBabyNeeded = getAdditionalBabyNeeded(); // Check if we have all required data - const isLoading = - rewardsTrackerQuery.isLoading || - currentRewardsQuery.isLoading || - aprQuery.isLoading || - rewardSupplyQuery.isLoading || - coStakingParamsQuery.isLoading; + const isLoading = aprQuery.isLoading || coStakingParamsQuery.isLoading; - if (!aprData || !rewardsTracker || !currentRewards || !rewardSupply) { + if (!aprData) { return { currentApr: null, boostApr: null, additionalBabyNeeded: 0, - eligibilityPercentage: 0, isLoading, error: isLoading ? undefined : "APR data not available", }; } - // Prepare numeric values once - const activeSatoshis = Number(rewardsTracker.active_satoshis); - const activeBabyUbbn = Number(rewardsTracker.active_baby); - const userScore = Number(rewardsTracker.total_score); - const globalScore = Number(currentRewards.total_score); - - // Calculate eligibility percentage (what % of BTC qualifies for co-staking bonus) - const eligibilityPercentage = calculateBTCEligibilityPercentage( - activeSatoshis, - activeBabyUbbn, - scoreRatio, - ); - - // Calculate user's personalized co-staking APR based on pool share - const userCoStakingApr = calculateUserCoStakingAPR( - userScore, - globalScore, - rewardSupply, - activeBabyUbbn, - ); - - // Current APR = BTC staking APR + user's co-staking APR - const currentApr = aprData.btc_staking + userCoStakingApr; - - // Calculate boost APR: what user earns at 100% eligibility - // Need to calculate what user_total_score would be with full eligibility - const requiredBaby = activeSatoshis * scoreRatio; - const maxTotalScore = activeSatoshis; - - // Calculate co-staking APR at 100% eligibility - const boostCoStakingApr = calculateUserCoStakingAPR( - maxTotalScore, - globalScore, - rewardSupply, - requiredBaby, - ); - - const boostApr = aprData.btc_staking + boostCoStakingApr; - return { - currentApr, - boostApr, - additionalBabyNeeded, - eligibilityPercentage, + currentApr: aprData.current.total_apr, + boostApr: aprData.boost.total_apr, + additionalBabyNeeded: aprData.additional_baby_needed_for_boost, isLoading: false, error: undefined, }; - }, [ - rewardsTrackerQuery.data, - rewardsTrackerQuery.isLoading, - currentRewardsQuery.data, - currentRewardsQuery.isLoading, - aprQuery.data, - aprQuery.isLoading, - rewardSupplyQuery.data, - rewardSupplyQuery.isLoading, - coStakingParamsQuery.isLoading, - getScoreRatio, - getAdditionalBabyNeeded, - ]); + }, [aprQuery.data, aprQuery.isLoading, coStakingParamsQuery.isLoading]); /** - * Get user's co-staking status + * Get user's co-staking status based on provided stake amounts */ const getUserCoStakingStatus = useCallback(() => { - const rewardsTracker = rewardsTrackerQuery.data; const additionalBabyNeeded = getAdditionalBabyNeeded(); - const activeSatoshis = rewardsTracker - ? Number(rewardsTracker.active_satoshis) - : 0; - const activeBaby = rewardsTracker ? Number(rewardsTracker.active_baby) : 0; - const totalScore = rewardsTracker ? Number(rewardsTracker.total_score) : 0; - return { - isCoStaking: activeBaby > 0, - activeSatoshis, - activeBaby, - totalScore, + isCoStaking: totalBabyStakedUbbn > 0, + activeSatoshis: totalBtcStakedSat, + activeBaby: totalBabyStakedUbbn, + totalScore: 0, // No longer needed with backend API additionalBabyNeeded, }; - }, [rewardsTrackerQuery.data, getAdditionalBabyNeeded]); + }, [totalBtcStakedSat, totalBabyStakedUbbn, getAdditionalBabyNeeded]); /** * Refresh all co-staking data */ const refreshCoStakingData = useCallback(async () => { try { - await Promise.all([ - refetchCoStakingParams(), - refetchRewardsTracker(), - refetchCurrentRewards(), - ]); + await Promise.all([refetchCoStakingParams(), refetchAPR()]); } catch (error) { logger.error(error as Error, { - tags: { bech32Address }, + tags: { action: "refreshCoStakingData" }, }); - handleError({ error: error as Error }); } - }, [ - refetchCoStakingParams, - refetchRewardsTracker, - refetchCurrentRewards, - logger, - bech32Address, - handleError, - ]); + }, [refetchCoStakingParams, refetchAPR, logger]); + + /** + * Calculate required BABY tokens for a given amount of BTC satoshis + */ + const getRequiredBabyForSatoshis = useCallback( + (satoshis: number): number => { + const scoreRatio = getScoreRatio(); + const requiredUbbn = calculateRequiredBabyTokens(satoshis, scoreRatio); + return ubbnToBaby(requiredUbbn); + }, + [getScoreRatio], + ); + + const isLoading = useMemo( + () => coStakingParamsQuery.isLoading || aprQuery.isLoading, + [coStakingParamsQuery.isLoading, aprQuery.isLoading], + ); + + const error = useMemo( + () => coStakingParamsQuery.error || aprQuery.error, + [coStakingParamsQuery.error, aprQuery.error], + ); return { // Data coStakingParams: coStakingParamsQuery.data, - rewardsTracker: rewardsTrackerQuery.data, - currentRewards: currentRewardsQuery.data, aprData: aprQuery.data, - rewardSupply: rewardSupplyQuery.data, // Methods getScoreRatio, @@ -361,22 +223,13 @@ export const useCoStakingService = () => { getCoStakingAPR, getUserCoStakingStatus, refreshCoStakingData, + getRequiredBabyForSatoshis, // Loading states - isLoading: - coStakingParamsQuery.isLoading || - rewardsTrackerQuery.isLoading || - currentRewardsQuery.isLoading || - aprQuery.isLoading || - rewardSupplyQuery.isLoading, + isLoading, // Error states - error: - coStakingParamsQuery.error || - rewardsTrackerQuery.error || - currentRewardsQuery.error || - aprQuery.error || - rewardSupplyQuery.error, + error, // Feature flag isCoStakingEnabled, diff --git a/services/simple-staking/src/ui/common/hooks/services/useTransactionService.ts b/services/simple-staking/src/ui/common/hooks/services/useTransactionService.ts index 6dd0af7e..12704760 100644 --- a/services/simple-staking/src/ui/common/hooks/services/useTransactionService.ts +++ b/services/simple-staking/src/ui/common/hooks/services/useTransactionService.ts @@ -1,6 +1,7 @@ import { BabylonBtcStakingManager } from "@babylonlabs-io/btc-staking-ts"; import { Transaction } from "bitcoinjs-lib"; import { useCallback, useMemo } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { useBTCWallet } from "@/ui/common/context/wallet/BTCWalletProvider"; import { useCosmosWallet } from "@/ui/common/context/wallet/CosmosWalletProvider"; @@ -12,6 +13,7 @@ import { getFeeRateFromMempool } from "@/ui/common/utils/getFeeRateFromMempool"; import { getTxInfo, getTxMerkleProof } from "@/ui/common/utils/mempool_api"; import { useNetworkFees } from "../client/api/useNetworkFees"; +import { DELEGATIONS_V2_KEY } from "../client/api/useDelegationsV2"; import { useBbnQuery } from "../client/rpc/queries/useBbnQuery"; import { useStakingManagerService } from "./useStakingManagerService"; @@ -32,6 +34,7 @@ export interface BtcStakingExpansionInputs { } export const useTransactionService = () => { + const queryClient = useQueryClient(); const { availableUTXOs, refetchUTXOs, @@ -252,8 +255,14 @@ export const useTransactionService = () => { }); throw clientError; } + await pushTx(signedStakingTx.toHex()); + refetchUTXOs(); + + // Invalidate delegations query to trigger APR refetch with updated BTC totals + // This ensures co-staking APR updates immediately after staking + queryClient.invalidateQueries({ queryKey: [DELEGATIONS_V2_KEY] }); }, [ availableUTXOs, @@ -263,6 +272,7 @@ export const useTransactionService = () => { stakerInfo, tipHeight, logger, + queryClient, ], ); diff --git a/services/simple-staking/src/ui/common/rewards/index.tsx b/services/simple-staking/src/ui/common/rewards/index.tsx index cdca583f..496cc955 100644 --- a/services/simple-staking/src/ui/common/rewards/index.tsx +++ b/services/simple-staking/src/ui/common/rewards/index.tsx @@ -33,7 +33,7 @@ import { ClaimStatusModal, ClaimResult, } from "@/ui/common/components/Modals/ClaimStatusModal/ClaimStatusModal"; -import { useCoStakingService } from "@/ui/common/hooks/services/useCoStakingService"; +import { useCoStakingState } from "@/ui/common/state/CoStakingState"; import { calculateCoStakingAmount } from "@/ui/common/utils/calculateCoStakingAmount"; import { NAVIGATION_STATE_KEYS, @@ -71,15 +71,9 @@ function RewardsPageContent() { const { claimRewards: btcClaimRewards } = useRewardsService(); - const { - getAdditionalBabyNeeded, - rewardsTracker, - currentRewards, - rewardSupply, - aprData, - } = useCoStakingService(); + const { eligibility, aprData } = useCoStakingState(); - const additionalBabyNeeded = getAdditionalBabyNeeded(); + const additionalBabyNeeded = eligibility.additionalBabyNeeded; const btcRewardBaby = maxDecimals( ubbnToBaby(Number(btcRewardUbbn || 0)), @@ -98,13 +92,17 @@ function RewardsPageContent() { ); // Calculate co-staking amount split from BTC rewards + // NOTE: calculateCoStakingAmount may need updating to use new APR data structure + // For now, we use fallback values to maintain compatibility const coStakingSplit = calculateCoStakingAmount( btcRewardBaby, - rewardsTracker?.total_score, - currentRewards?.total_score, - rewardsTracker?.active_baby, - rewardSupply, - aprData?.btc_staking, + undefined, // rewardsTracker?.total_score (no longer available) + undefined, // currentRewards?.total_score (no longer available) + undefined, // rewardsTracker?.active_baby (no longer available) + undefined, // rewardSupply (no longer available) + aprData?.currentApr + ? aprData.currentApr - aprData.currentApr * 0.1 + : undefined, // Approximate BTC staking APR ); const coStakingAmountBaby = coStakingSplit?.coStakingAmount; diff --git a/services/simple-staking/src/ui/common/state/CoStakingState.tsx b/services/simple-staking/src/ui/common/state/CoStakingState.tsx index 6d427d63..2ca43ffe 100644 --- a/services/simple-staking/src/ui/common/state/CoStakingState.tsx +++ b/services/simple-staking/src/ui/common/state/CoStakingState.tsx @@ -1,25 +1,51 @@ -import { useCallback, useEffect, useMemo, type PropsWithChildren } from "react"; +import { useEffect, useMemo, useState, type PropsWithChildren } from "react"; import { useEventBus } from "@/ui/common/hooks/useEventBus"; import { useCoStakingService, DEFAULT_COSTAKING_SCORE_RATIO, } from "@/ui/common/hooks/services/useCoStakingService"; +import { useDelegations } from "@/ui/baby/hooks/api/useDelegations"; +import { useCosmosWallet } from "@/ui/common/context/wallet/CosmosWalletProvider"; import { createStateUtils } from "@/ui/common/utils/createStateUtils"; -import { - calculateBTCEligibilityPercentage, - calculateRequiredBabyTokens, -} from "@/ui/common/utils/coStakingCalculations"; +import { calculateRequiredBabyTokens } from "@/ui/common/utils/coStakingCalculations"; import { ubbnToBaby } from "@/ui/common/utils/bbn"; +import { network } from "@/ui/common/config/network/bbn"; +import { + DelegationV2StakingState, + type DelegationV2, +} from "@/ui/common/types/delegationsV2"; import type { CoStakingParams, - CoStakerRewardsTracker, - CoStakingCurrentRewards, CoStakingAPRData, } from "@/ui/common/types/api/coStaking"; import { useDelegationV2State } from "./DelegationV2State"; +/** + * Helper to read pending BABY operations from localStorage + * This avoids needing the PendingOperationsProvider context + */ +const getPendingBabyOperations = (bech32Address: string | undefined) => { + if (!bech32Address) return []; + + try { + const storageKey = `baby-pending-operations-${network}-${bech32Address}`; + const stored = localStorage.getItem(storageKey); + if (!stored) return []; + + const parsed = JSON.parse(stored); + return parsed.map((item: any) => ({ + validatorAddress: item.validatorAddress, + amount: BigInt(item.amount), + operationType: item.operationType as "stake" | "unstake", + timestamp: item.timestamp, + })); + } catch { + return []; + } +}; + // Event channels that should trigger co-staking data refresh const CO_STAKING_REFRESH_CHANNELS = [ "delegation:stake", @@ -28,7 +54,6 @@ const CO_STAKING_REFRESH_CHANNELS = [ export interface CoStakingEligibility { isEligible: boolean; - eligibilityPercentage: number; activeSatoshis: number; activeBabyTokens: number; requiredBabyTokens: number; @@ -37,8 +62,6 @@ export interface CoStakingEligibility { interface CoStakingStateValue { params: CoStakingParams | null; - rewardsTracker: CoStakerRewardsTracker | null; - currentRewards: CoStakingCurrentRewards | null; // Computed values eligibility: CoStakingEligibility; scoreRatio: number; @@ -52,7 +75,6 @@ interface CoStakingStateValue { const defaultEligibility: CoStakingEligibility = { isEligible: false, - eligibilityPercentage: 0, activeSatoshis: 0, activeBabyTokens: 0, requiredBabyTokens: 0, @@ -61,15 +83,12 @@ const defaultEligibility: CoStakingEligibility = { const defaultState: CoStakingStateValue = { params: null, - rewardsTracker: null, - currentRewards: null, eligibility: defaultEligibility, scoreRatio: DEFAULT_COSTAKING_SCORE_RATIO, aprData: { currentApr: null, boostApr: null, additionalBabyNeeded: 0, - eligibilityPercentage: 0, isLoading: false, error: undefined, }, @@ -85,20 +104,101 @@ const { StateProvider, useState: useCoStakingState } = export function CoStakingState({ children }: PropsWithChildren) { const eventBus = useEventBus(); - const { delegations } = useDelegationV2State(); + const { delegations: btcDelegations } = useDelegationV2State(); + const { bech32Address } = useCosmosWallet(); + const { data: babyDelegationsRaw = [] } = useDelegations(bech32Address); + + // Track localStorage version to force re-computation of pending operations + const [, setStorageVersion] = useState(0); + + // Listen for localStorage changes (both from other tabs and same tab) + useEffect(() => { + const handleStorageChange = (e: StorageEvent) => { + if (e.key?.includes("baby-pending-operations")) { + setStorageVersion((v) => v + 1); + } + }; + + const handleCustomStorage = () => { + setStorageVersion((v) => v + 1); + }; + + window.addEventListener("storage", handleStorageChange); + window.addEventListener( + "baby-pending-operations-updated", + handleCustomStorage, + ); + + return () => { + window.removeEventListener("storage", handleStorageChange); + window.removeEventListener( + "baby-pending-operations-updated", + handleCustomStorage, + ); + }; + }, []); + + // Get pending BABY operations from localStorage - now reactive to storage changes + const pendingBabyOps = useMemo(() => { + const ops = getPendingBabyOperations(bech32Address); + return ops; + }, [bech32Address]); + + /** + * Calculate total BTC staked (only broadcasted delegations) + * Includes: PENDING, ACTIVE, INTERMEDIATE_PENDING_BTC_CONFIRMATION + * Excludes: VERIFIED (Babylon verified but not yet broadcasted to BTC) + * Excludes: INTERMEDIATE_PENDING_VERIFICATION (waiting for Babylon verification) + */ + const totalBtcStakedSat = useMemo(() => { + const activeDelegations = btcDelegations.filter( + (d: DelegationV2) => + d.state === DelegationV2StakingState.PENDING || + d.state === DelegationV2StakingState.ACTIVE || + d.state === + DelegationV2StakingState.INTERMEDIATE_PENDING_BTC_CONFIRMATION, + ); + + const total = activeDelegations.reduce( + (sum: number, d: DelegationV2) => sum + d.stakingAmount, + 0, + ); + + return total; + }, [btcDelegations]); + + /** + * Calculate total BABY staked (confirmed + pending from localStorage) + * This matches the BTC calculation behavior for consistency + */ + const totalBabyStakedUbbn = useMemo(() => { + // Confirmed delegations from API + const confirmedUbbn = babyDelegationsRaw.reduce( + (sum: number, d: any) => sum + Number(d.balance?.amount || 0), + 0, + ); + + // Pending stake operations from localStorage + const pendingStakeUbbn = pendingBabyOps + .filter((op: any) => op.operationType === "stake") + .reduce((sum: number, op: any) => sum + Number(op.amount), 0); + + const total = confirmedUbbn + pendingStakeUbbn; + + return total; + }, [babyDelegationsRaw, pendingBabyOps]); const { coStakingParams, - rewardsTracker, - currentRewards, getScoreRatio, getCoStakingAPR, getUserCoStakingStatus, refreshCoStakingData, + getRequiredBabyForSatoshis, isLoading, error, isCoStakingEnabled, - } = useCoStakingService(); + } = useCoStakingService(totalBtcStakedSat, totalBabyStakedUbbn); const scoreRatio = getScoreRatio(); const aprData = getCoStakingAPR(); @@ -110,23 +210,16 @@ export function CoStakingState({ children }: PropsWithChildren) { const activeBabyUbbn = status.activeBaby; const activeBabyTokens = ubbnToBaby(activeBabyUbbn); - const eligibilityPercentage = calculateBTCEligibilityPercentage( - activeSatoshis, - activeBabyUbbn, - scoreRatio, - ); - const requiredBabyUbbn = calculateRequiredBabyTokens( activeSatoshis, scoreRatio, ); const requiredBabyTokens = ubbnToBaby(requiredBabyUbbn); - const isEligible = eligibilityPercentage > 0; + const isEligible = activeBabyUbbn > 0; return { isEligible, - eligibilityPercentage, activeSatoshis, activeBabyTokens, requiredBabyTokens, @@ -134,14 +227,6 @@ export function CoStakingState({ children }: PropsWithChildren) { }; }, [getUserCoStakingStatus, scoreRatio]); - const getRequiredBabyForSatoshis = useCallback( - (satoshis: number): number => { - const requiredUbbn = calculateRequiredBabyTokens(satoshis, scoreRatio); - return ubbnToBaby(requiredUbbn); - }, - [scoreRatio], - ); - useEffect(() => { const unsubscribeFns = CO_STAKING_REFRESH_CHANNELS.map((channel) => eventBus.on(channel, refreshCoStakingData), @@ -151,17 +236,9 @@ export function CoStakingState({ children }: PropsWithChildren) { void unsubscribeFns.forEach((unsubscribe) => void unsubscribe()); }, [eventBus, refreshCoStakingData]); - useEffect(() => { - if (isCoStakingEnabled && delegations.length > 0) { - refreshCoStakingData(); - } - }, [delegations.length, isCoStakingEnabled, refreshCoStakingData]); - const state = useMemo( () => ({ params: coStakingParams?.params || null, - rewardsTracker: rewardsTracker || null, - currentRewards: currentRewards || null, eligibility, scoreRatio, aprData, @@ -173,8 +250,6 @@ export function CoStakingState({ children }: PropsWithChildren) { }), [ coStakingParams, - rewardsTracker, - currentRewards, eligibility, scoreRatio, aprData, diff --git a/services/simple-staking/src/ui/common/types/api/coStaking.ts b/services/simple-staking/src/ui/common/types/api/coStaking.ts index 72326d43..24defee9 100644 --- a/services/simple-staking/src/ui/common/types/api/coStaking.ts +++ b/services/simple-staking/src/ui/common/types/api/coStaking.ts @@ -25,21 +25,25 @@ export interface CoStakingCurrentRewards { } /** - * Global unit APR data from backend /v2/apr endpoint - * - * These are unit APR values based on 1 BTC staked. - * They represent the global APR rates available to all users, - * not personalized values for a specific user. + * Personalized APR response from backend /v2/apr endpoint + * Backend calculates APR based on user's total BTC and BABY staked amounts */ -export interface GlobalUnitAPRData { - /** Base APR for BTC staking (per 1 BTC) */ - btc_staking: number; - /** APR for BABY-only staking */ - baby_staking: number; - /** Bonus APR for co-staking (per 1 BTC when fully eligible) */ - co_staking: number; - /** Maximum APR (btc_staking + baby_staking + co_staking) */ - max_apr: number; +export interface PersonalizedAPRResponse { + data: { + current: { + btc_staking_apr: number; + baby_staking_apr: number; + co_staking_apr: number; + total_apr: number; + }; + additional_baby_needed_for_boost: number; + boost: { + btc_staking_apr: number; + baby_staking_apr: number; + co_staking_apr: number; + total_apr: number; + }; + }; } /** @@ -47,14 +51,12 @@ export interface GlobalUnitAPRData { * These values are personalized based on the user's staking positions */ export interface CoStakingAPRData { - /** A% - User's current total APR (BTC APR + partial co-staking bonus) */ + /** A% - User's current total APR (BTC APR + BABY APR + current co-staking bonus) */ currentApr: number | null; - /** B% - Maximum APR user can earn at 100% eligibility (BTC APR + full co-staking bonus) */ + /** B% - Maximum APR user can earn at 100% eligibility (BTC APR + BABY APR + full co-staking bonus) */ boostApr: number | null; /** X - Additional BABY tokens needed to reach 100% eligibility and boost APR */ additionalBabyNeeded: number; - /** Percentage of user's BTC stake that's eligible for co-staking rewards */ - eligibilityPercentage: number; isLoading: boolean; error?: string; } diff --git a/services/simple-staking/src/ui/common/utils/coStakingCalculations.ts b/services/simple-staking/src/ui/common/utils/coStakingCalculations.ts index f5d44e27..e5eb180f 100644 --- a/services/simple-staking/src/ui/common/utils/coStakingCalculations.ts +++ b/services/simple-staking/src/ui/common/utils/coStakingCalculations.ts @@ -1,23 +1,3 @@ -/** - * Calculates the BTC eligibility percentage for co-staking rewards - * Formula: min(active_satoshis, active_baby/score_ratio) / active_satoshis * 100 - * - * @param activeSatoshis - * @param activeBaby - * @param scoreRatio - */ -export const calculateBTCEligibilityPercentage = ( - activeSatoshis: number, - activeBaby: number, - scoreRatio: number, -): number => { - if (activeSatoshis === 0) return 0; - if (scoreRatio === 0) return 0; - - const eligibleSats = Math.min(activeSatoshis, activeBaby / scoreRatio); - return (eligibleSats / activeSatoshis) * 100; -}; - /** * Calculates the required ubbn for full BTC co-staking rewards * Based on satoshis * scoreRatio formula @@ -63,40 +43,3 @@ export const formatBabyTokens = (value: number): string => { } return formatNumber(value, 2); }; - -/** - * Calculates the user's personalized co-staking APR based on their share of the global pool - * - * Formula from protocol design: - * co_staking_apr = (user_total_score / global_total_score_sum) - * × total_co_staking_reward_supply - * / user_active_baby - * - * @param userTotalScore - User's total score from rewards tracker - * @param globalTotalScore - Total score of all co-stakers from current_rewards - * @param totalCoStakingRewardSupply - Annual BABY tokens allocated to co-staking, calculated dynamically using cascade formula: annual_provisions × (1 - btc_portion - fp_portion) × costaking_portion - * @param userActiveBaby - User's active BABY stake in ubbn - * @returns User's personalized co-staking APR as a percentage - */ -export const calculateUserCoStakingAPR = ( - userTotalScore: number, - globalTotalScore: number, - totalCoStakingRewardSupply: number, - userActiveBaby: number, -): number => { - // Edge cases - if (userTotalScore === 0 || globalTotalScore === 0 || userActiveBaby === 0) { - return 0; - } - - // Calculate user's share of the co-staking pool - const poolShare = userTotalScore / globalTotalScore; - - // Calculate user's portion of annual rewards - const userAnnualRewards = poolShare * totalCoStakingRewardSupply; - - // Calculate APR as percentage: (annual_rewards / staked_amount) × 100 - const apr = (userAnnualRewards / userActiveBaby) * 100; - - return apr; -}; From 79c222f3e31d38067b8bff1c46f8479e0bb57a78 Mon Sep 17 00:00:00 2001 From: Govard Barkhatov Date: Mon, 13 Oct 2025 11:30:26 +0300 Subject: [PATCH 2/3] chore(packages): calc split --- .../hooks/services/useCoStakingService.ts | 2 +- .../src/ui/common/rewards/index.tsx | 55 +++++++++++++------ .../src/ui/common/state/CoStakingState.tsx | 6 ++ 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/services/simple-staking/src/ui/common/hooks/services/useCoStakingService.ts b/services/simple-staking/src/ui/common/hooks/services/useCoStakingService.ts index 2ce85dcc..600a0808 100644 --- a/services/simple-staking/src/ui/common/hooks/services/useCoStakingService.ts +++ b/services/simple-staking/src/ui/common/hooks/services/useCoStakingService.ts @@ -215,7 +215,7 @@ export const useCoStakingService = ( return { // Data coStakingParams: coStakingParamsQuery.data, - aprData: aprQuery.data, + rawAprData: aprQuery.data, // Methods getScoreRatio, diff --git a/services/simple-staking/src/ui/common/rewards/index.tsx b/services/simple-staking/src/ui/common/rewards/index.tsx index 496cc955..c2f24eac 100644 --- a/services/simple-staking/src/ui/common/rewards/index.tsx +++ b/services/simple-staking/src/ui/common/rewards/index.tsx @@ -34,7 +34,6 @@ import { ClaimResult, } from "@/ui/common/components/Modals/ClaimStatusModal/ClaimStatusModal"; import { useCoStakingState } from "@/ui/common/state/CoStakingState"; -import { calculateCoStakingAmount } from "@/ui/common/utils/calculateCoStakingAmount"; import { NAVIGATION_STATE_KEYS, type NavigationState, @@ -71,7 +70,7 @@ function RewardsPageContent() { const { claimRewards: btcClaimRewards } = useRewardsService(); - const { eligibility, aprData } = useCoStakingState(); + const { eligibility, rawAprData } = useCoStakingState(); const additionalBabyNeeded = eligibility.additionalBabyNeeded; @@ -91,22 +90,44 @@ function RewardsPageContent() { MAX_DECIMALS, ); - // Calculate co-staking amount split from BTC rewards - // NOTE: calculateCoStakingAmount may need updating to use new APR data structure - // For now, we use fallback values to maintain compatibility - const coStakingSplit = calculateCoStakingAmount( - btcRewardBaby, - undefined, // rewardsTracker?.total_score (no longer available) - undefined, // currentRewards?.total_score (no longer available) - undefined, // rewardsTracker?.active_baby (no longer available) - undefined, // rewardSupply (no longer available) - aprData?.currentApr - ? aprData.currentApr - aprData.currentApr * 0.1 - : undefined, // Approximate BTC staking APR - ); + // Calculate co-staking amount split from BTC rewards using API APR ratios + const { coStakingAmountBaby, baseBtcRewardBaby } = useMemo(() => { + // If co-staking APR data not available, return base values + if (!rawAprData || !rawAprData.current) { + return { + coStakingAmountBaby: 0, + baseBtcRewardBaby: btcRewardBaby, + }; + } + + const { co_staking_apr, btc_staking_apr, total_apr } = rawAprData.current; + + // If no co-staking APR, all BTC rewards are base BTC rewards + if (co_staking_apr === 0 || total_apr === 0) { + return { + coStakingAmountBaby: 0, + baseBtcRewardBaby: btcRewardBaby, + }; + } + + // Calculate split based on APR ratios from API + const coStakingRatio = co_staking_apr / total_apr; + const btcStakingRatio = btc_staking_apr / total_apr; + + const coStakingAmount = maxDecimals( + btcRewardBaby * coStakingRatio, + MAX_DECIMALS, + ); + const baseBtcAmount = maxDecimals( + btcRewardBaby * btcStakingRatio, + MAX_DECIMALS, + ); - const coStakingAmountBaby = coStakingSplit?.coStakingAmount; - const baseBtcRewardBaby = coStakingSplit?.baseBtcAmount ?? btcRewardBaby; + return { + coStakingAmountBaby: coStakingAmount, + baseBtcRewardBaby: baseBtcAmount, + }; + }, [btcRewardBaby, rawAprData]); const [previewOpen, setPreviewOpen] = useState(false); const [claimingBtc, setClaimingBtc] = useState(false); diff --git a/services/simple-staking/src/ui/common/state/CoStakingState.tsx b/services/simple-staking/src/ui/common/state/CoStakingState.tsx index 2ca43ffe..3c5fef22 100644 --- a/services/simple-staking/src/ui/common/state/CoStakingState.tsx +++ b/services/simple-staking/src/ui/common/state/CoStakingState.tsx @@ -18,6 +18,7 @@ import { import type { CoStakingParams, CoStakingAPRData, + PersonalizedAPRResponse, } from "@/ui/common/types/api/coStaking"; import { useDelegationV2State } from "./DelegationV2State"; @@ -66,6 +67,7 @@ interface CoStakingStateValue { eligibility: CoStakingEligibility; scoreRatio: number; aprData: CoStakingAPRData; + rawAprData: PersonalizedAPRResponse["data"] | null; isLoading: boolean; isEnabled: boolean; hasError: boolean; @@ -92,6 +94,7 @@ const defaultState: CoStakingStateValue = { isLoading: false, error: undefined, }, + rawAprData: null, isLoading: false, isEnabled: false, hasError: false, @@ -190,6 +193,7 @@ export function CoStakingState({ children }: PropsWithChildren) { const { coStakingParams, + rawAprData, getScoreRatio, getCoStakingAPR, getUserCoStakingStatus, @@ -242,6 +246,7 @@ export function CoStakingState({ children }: PropsWithChildren) { eligibility, scoreRatio, aprData, + rawAprData: rawAprData ?? null, isLoading, isEnabled: isCoStakingEnabled, hasError: Boolean(error), @@ -253,6 +258,7 @@ export function CoStakingState({ children }: PropsWithChildren) { eligibility, scoreRatio, aprData, + rawAprData, isLoading, isCoStakingEnabled, error, From d92554e851dcf6c861e1e5d0d7e2ce751d14e7e2 Mon Sep 17 00:00:00 2001 From: Govard Barkhatov Date: Mon, 13 Oct 2025 12:05:59 +0300 Subject: [PATCH 3/3] chore(packages): code improvement --- .../services/usePendingOperationsService.tsx | 13 ++++- .../src/ui/common/api/getAPR.ts | 14 +++++ .../hooks/services/useCoStakingService.ts | 1 - .../src/ui/common/rewards/index.tsx | 8 ++- .../src/ui/common/state/CoStakingState.tsx | 54 +++++++++++++++---- 5 files changed, 76 insertions(+), 14 deletions(-) diff --git a/services/simple-staking/src/ui/baby/hooks/services/usePendingOperationsService.tsx b/services/simple-staking/src/ui/baby/hooks/services/usePendingOperationsService.tsx index 2caa16f1..5f5ff48f 100644 --- a/services/simple-staking/src/ui/baby/hooks/services/usePendingOperationsService.tsx +++ b/services/simple-staking/src/ui/baby/hooks/services/usePendingOperationsService.tsx @@ -12,6 +12,10 @@ import { useCosmosWallet } from "@/ui/common/context/wallet/CosmosWalletProvider import { useLogger } from "@/ui/common/hooks/useLogger"; import { getCurrentEpoch } from "@/ui/common/utils/local_storage/epochStorage"; +/** + * Runtime representation of a pending BABY staking operation. + * Uses bigint for amount to support arbitrary precision arithmetic. + */ export interface PendingOperation { validatorAddress: string; amount: bigint; @@ -21,9 +25,14 @@ export interface PendingOperation { epoch?: number; } -interface PendingOperationStorage { +/** + * localStorage-serializable format of PendingOperation. + * Converts bigint → string because JSON doesn't support BigInt. + * Use this type when reading/writing to localStorage. + */ +export interface PendingOperationStorage { validatorAddress: string; - amount: string; + amount: string; // Serialized bigint operationType: "stake" | "unstake"; timestamp: number; walletAddress: string; diff --git a/services/simple-staking/src/ui/common/api/getAPR.ts b/services/simple-staking/src/ui/common/api/getAPR.ts index bfd4d841..8717bb5f 100644 --- a/services/simple-staking/src/ui/common/api/getAPR.ts +++ b/services/simple-staking/src/ui/common/api/getAPR.ts @@ -8,11 +8,25 @@ import { apiWrapper } from "./apiWrapper"; * @param btcStakedSat - Total BTC in satoshis (confirmed + pending) * @param babyStakedUbbn - Total BABY in ubbn (confirmed + pending) * @returns Personalized APR data including current, boost, and additional BABY needed + * @throws Error if stake amounts are invalid (negative or not finite) */ export const getPersonalizedAPR = async ( btcStakedSat: number, babyStakedUbbn: number, ): Promise => { + // Validate input parameters + if (btcStakedSat < 0 || !isFinite(btcStakedSat)) { + throw new Error( + `Invalid BTC stake amount: ${btcStakedSat}. Must be non-negative and finite.`, + ); + } + + if (babyStakedUbbn < 0 || !isFinite(babyStakedUbbn)) { + throw new Error( + `Invalid BABY stake amount: ${babyStakedUbbn}. Must be non-negative and finite.`, + ); + } + const params = new URLSearchParams({ btc_staked: btcStakedSat.toString(), baby_staked: babyStakedUbbn.toString(), diff --git a/services/simple-staking/src/ui/common/hooks/services/useCoStakingService.ts b/services/simple-staking/src/ui/common/hooks/services/useCoStakingService.ts index 600a0808..712c161d 100644 --- a/services/simple-staking/src/ui/common/hooks/services/useCoStakingService.ts +++ b/services/simple-staking/src/ui/common/hooks/services/useCoStakingService.ts @@ -73,7 +73,6 @@ export const useCoStakingService = ( return result; } catch (error) { - console.error("[APR Query] Error:", error); logger.error(error as Error, { tags: { action: "getPersonalizedAPR", diff --git a/services/simple-staking/src/ui/common/rewards/index.tsx b/services/simple-staking/src/ui/common/rewards/index.tsx index c2f24eac..f9c9624e 100644 --- a/services/simple-staking/src/ui/common/rewards/index.tsx +++ b/services/simple-staking/src/ui/common/rewards/index.tsx @@ -103,7 +103,13 @@ function RewardsPageContent() { const { co_staking_apr, btc_staking_apr, total_apr } = rawAprData.current; // If no co-staking APR, all BTC rewards are base BTC rewards - if (co_staking_apr === 0 || total_apr === 0) { + // Guard against division by zero and invalid numbers + if ( + co_staking_apr === 0 || + total_apr === 0 || + !isFinite(total_apr) || + total_apr < 0 + ) { return { coStakingAmountBaby: 0, baseBtcRewardBaby: btcRewardBaby, diff --git a/services/simple-staking/src/ui/common/state/CoStakingState.tsx b/services/simple-staking/src/ui/common/state/CoStakingState.tsx index 3c5fef22..32fee26c 100644 --- a/services/simple-staking/src/ui/common/state/CoStakingState.tsx +++ b/services/simple-staking/src/ui/common/state/CoStakingState.tsx @@ -20,14 +20,37 @@ import type { CoStakingAPRData, PersonalizedAPRResponse, } from "@/ui/common/types/api/coStaking"; +import type { + PendingOperation, + PendingOperationStorage, +} from "@/ui/baby/hooks/services/usePendingOperationsService"; import { useDelegationV2State } from "./DelegationV2State"; +interface BabyDelegationBalance { + amount: string; + denom: string; +} + +interface BabyDelegationData { + balance: BabyDelegationBalance | undefined; + delegation: { + delegator_address: string; + validator_address: string; + shares: string; + }; +} + /** - * Helper to read pending BABY operations from localStorage - * This avoids needing the PendingOperationsProvider context + * Helper to read pending BABY operations from localStorage. + * This avoids needing the PendingOperationsProvider context. + * + * Note: Deserializes PendingOperationStorage (string amounts) → PendingOperation (bigint amounts) + * because JSON.parse doesn't support BigInt natively. */ -const getPendingBabyOperations = (bech32Address: string | undefined) => { +const getPendingBabyOperations = ( + bech32Address: string | undefined, +): PendingOperation[] => { if (!bech32Address) return []; try { @@ -35,14 +58,22 @@ const getPendingBabyOperations = (bech32Address: string | undefined) => { const stored = localStorage.getItem(storageKey); if (!stored) return []; - const parsed = JSON.parse(stored); - return parsed.map((item: any) => ({ + const parsed = JSON.parse(stored) as PendingOperationStorage[]; + return parsed.map((item) => ({ validatorAddress: item.validatorAddress, amount: BigInt(item.amount), - operationType: item.operationType as "stake" | "unstake", + operationType: item.operationType, timestamp: item.timestamp, + walletAddress: item.walletAddress, + epoch: item.epoch, })); - } catch { + } catch (error) { + // Log parse failures for debugging but don't throw + // This ensures the app continues to function even with corrupted localStorage + console.warn( + `Failed to parse pending BABY operations from localStorage for ${bech32Address}:`, + error, + ); return []; } }; @@ -177,14 +208,17 @@ export function CoStakingState({ children }: PropsWithChildren) { const totalBabyStakedUbbn = useMemo(() => { // Confirmed delegations from API const confirmedUbbn = babyDelegationsRaw.reduce( - (sum: number, d: any) => sum + Number(d.balance?.amount || 0), + (sum: number, d: unknown) => { + const delegation = d as BabyDelegationData; + return sum + Number(delegation.balance?.amount || 0); + }, 0, ); // Pending stake operations from localStorage const pendingStakeUbbn = pendingBabyOps - .filter((op: any) => op.operationType === "stake") - .reduce((sum: number, op: any) => sum + Number(op.amount), 0); + .filter((op) => op.operationType === "stake") + .reduce((sum: number, op) => sum + Number(op.amount), 0); const total = confirmedUbbn + pendingStakeUbbn;