diff --git a/src/app/api/getDelegationPoints.ts b/src/app/api/getDelegationPoints.ts new file mode 100644 index 00000000..34a69072 --- /dev/null +++ b/src/app/api/getDelegationPoints.ts @@ -0,0 +1,85 @@ +import { encode } from "url-safe-base64"; + +import { Pagination } from "../types/api"; + +import { apiWrapper } from "./apiWrapper"; + +export interface DelegationPoints { + staking_tx_hash_hex: string; + staker: { + pk: string; + points: number; + }; + finality_provider: { + pk: string; + points: number; + }; + staking_height: number; + unbonding_height: number | null; + expiry_height: number; +} + +export interface PaginatedDelegationsPoints { + data: DelegationPoints[]; + pagination: Pagination; +} + +export const getDelegationPoints = async ( + paginationKey?: string, + stakerBtcPk?: string, + stakingTxHashHexes?: string[], +): Promise => { + const params: Record = {}; + + if (stakerBtcPk && stakingTxHashHexes && stakingTxHashHexes.length > 0) { + throw new Error( + "Only one of stakerBtcPk or stakingTxHashHexes should be provided", + ); + } + + if ( + !stakerBtcPk && + (!stakingTxHashHexes || stakingTxHashHexes.length === 0) + ) { + throw new Error( + "Either stakerBtcPk or stakingTxHashHexes must be provided", + ); + } + + if (stakerBtcPk) { + params.staker_btc_pk = encode(stakerBtcPk); + } + + let allDelegationPoints: DelegationPoints[] = []; + let nextPaginationKey = paginationKey; + + do { + const currentParams = { ...params }; + if (nextPaginationKey && nextPaginationKey !== "") { + currentParams.pagination_key = encode(nextPaginationKey); + } + + if (stakingTxHashHexes && stakingTxHashHexes.length > 0) { + currentParams.staking_tx_hash_hex = stakingTxHashHexes.slice(0, 10); + stakingTxHashHexes = stakingTxHashHexes.slice(10); + } + + const response = await apiWrapper( + "GET", + "/v1/points/staker/delegations", + "Error getting delegation points", + currentParams, + ); + + allDelegationPoints = allDelegationPoints.concat(response.data.data); + nextPaginationKey = response.data.pagination.next_key; + } while ( + nextPaginationKey || + (stakingTxHashHexes && stakingTxHashHexes.length > 0) + ); + + return { + data: allDelegationPoints, + pagination: { next_key: nextPaginationKey || "" }, + }; +}; diff --git a/src/app/api/getStakersPoints.ts b/src/app/api/getStakersPoints.ts new file mode 100644 index 00000000..cd86bf8e --- /dev/null +++ b/src/app/api/getStakersPoints.ts @@ -0,0 +1,28 @@ +import { encode } from "url-safe-base64"; + +import { apiWrapper } from "./apiWrapper"; + +export interface StakerPoints { + staker_btc_pk: string; + points: number; +} + +export const getStakersPoints = async ( + stakerBtcPk: string[], +): Promise => { + const params: Record = {}; + + params.staker_btc_pk = + stakerBtcPk.length > 1 + ? stakerBtcPk.map(encode).join(",") + : encode(stakerBtcPk[0]); + + const response = await apiWrapper( + "GET", + "/v1/points/stakers", + "Error getting staker points", + params, + ); + + return response.data; +}; diff --git a/src/app/components/Delegations/Delegation.tsx b/src/app/components/Delegations/Delegation.tsx index eb9c5080..0ecd4265 100644 --- a/src/app/components/Delegations/Delegation.tsx +++ b/src/app/components/Delegations/Delegation.tsx @@ -13,6 +13,8 @@ import { getState, getStateTooltip } from "@/utils/getState"; import { maxDecimals } from "@/utils/maxDecimals"; import { trim } from "@/utils/trim"; +import { DelegationPoints } from "../Points/DelegationPoints"; + interface DelegationProps { stakingTx: StakingTx; stakingValueSat: number; @@ -122,7 +124,7 @@ export const Delegation: React.FC = ({

overflow

)} -
+

@@ -142,13 +144,11 @@ export const Delegation: React.FC = ({ {trim(stakingTxHash)}

- {/* Future data placeholder */} -
{/* we need to center the text without the tooltip add its size 12px and gap 4px, 16/2 = 8px */} -
+

{renderState()}

= ({
+
+ +
{generateActionButton()}
diff --git a/src/app/components/Delegations/Delegations.tsx b/src/app/components/Delegations/Delegations.tsx index bf2f7e96..d58da715 100644 --- a/src/app/components/Delegations/Delegations.tsx +++ b/src/app/components/Delegations/Delegations.tsx @@ -5,6 +5,7 @@ import { useLocalStorage } from "usehooks-ts"; import { SignPsbtTransaction } from "@/app/common/utils/psbt"; import { LoadingTableList } from "@/app/components/Loading/Loading"; +import { DelegationsPointsProvider } from "@/app/context/api/DelegationsPointsProvider"; import { useError } from "@/app/context/Error/ErrorContext"; import { QueryMeta } from "@/app/types/api"; import { @@ -39,19 +40,56 @@ interface DelegationsProps { pushTx: WalletProvider["pushTx"]; queryMeta: QueryMeta; getNetworkFees: WalletProvider["getNetworkFees"]; + isWalletConnected: boolean; } export const Delegations: React.FC = ({ delegationsAPI, delegationsLocalStorage, globalParamsVersion, - publicKeyNoCoord, - btcWalletNetwork, + signPsbtTx, + pushTx, + queryMeta, + getNetworkFees, address, + btcWalletNetwork, + publicKeyNoCoord, + isWalletConnected, +}) => { + return ( + + + + ); +}; + +const DelegationsContent: React.FC = ({ + delegationsAPI, + delegationsLocalStorage, + globalParamsVersion, signPsbtTx, pushTx, queryMeta, getNetworkFees, + address, + btcWalletNetwork, + publicKeyNoCoord, }) => { const [modalOpen, setModalOpen] = useState(false); const [txID, setTxID] = useState(""); @@ -113,7 +151,7 @@ export const Delegations: React.FC = ({ id, delegationsAPI, publicKeyNoCoord, - btcWalletNetwork, + btcWalletNetwork!, signPsbtTx, ); // Update the local state with the new intermediate delegation @@ -143,7 +181,7 @@ export const Delegations: React.FC = ({ id, delegationsAPI, publicKeyNoCoord, - btcWalletNetwork, + btcWalletNetwork!, signPsbtTx, address, getNetworkFees, @@ -233,11 +271,12 @@ export const Delegations: React.FC = ({
) : ( <> -
+

Amount

Inception

Transaction hash

Status

+

Points

Action

= ({ + stakingTxHash, +}) => { + const { delegationPoints, isLoading } = useDelegationsPoints(); + + const points = delegationPoints.get(stakingTxHash); + + if (isLoading) { + return ( +
+
+
+ ); + } + + return ( +
+

+ {points !== undefined ? points : 0} +

+
+ ); +}; diff --git a/src/app/components/Points/StakerPoints.tsx b/src/app/components/Points/StakerPoints.tsx new file mode 100644 index 00000000..7ccae028 --- /dev/null +++ b/src/app/components/Points/StakerPoints.tsx @@ -0,0 +1,39 @@ +import { useQuery } from "@tanstack/react-query"; +import React from "react"; + +import { getStakersPoints } from "@/app/api/getStakersPoints"; +import { satoshiToBtc } from "@/utils/btcConversions"; +import { maxDecimals } from "@/utils/maxDecimals"; + +interface StakerPointsProps { + publicKeyNoCoord: string; +} + +export const StakerPoints: React.FC = ({ + publicKeyNoCoord, +}) => { + const { data: stakerPoints, isLoading } = useQuery({ + queryKey: ["stakerPoints", publicKeyNoCoord], + queryFn: () => getStakersPoints([publicKeyNoCoord]), + enabled: !!publicKeyNoCoord, + refetchInterval: 60000, // Refresh every minute + }); + + if (isLoading) { + return ( +
+
+
+ ); + } + + const points = stakerPoints?.[0]?.points; + + return ( +
+

+ {points !== undefined ? maxDecimals(satoshiToBtc(points), 8) : 0} +

+
+ ); +}; diff --git a/src/app/components/Staking/Staking.tsx b/src/app/components/Staking/Staking.tsx index 12f9cb65..0950be55 100644 --- a/src/app/components/Staking/Staking.tsx +++ b/src/app/components/Staking/Staking.tsx @@ -1,5 +1,5 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { Transaction, networks } from "bitcoinjs-lib"; +import { networks, Transaction } from "bitcoinjs-lib"; import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react"; import { Tooltip } from "react-tooltip"; import { useLocalStorage } from "usehooks-ts"; @@ -27,8 +27,8 @@ import { } from "@/utils/delegations/signStakingTx"; import { getFeeRateFromMempool } from "@/utils/getFeeRateFromMempool"; import { - ParamsWithContext, getCurrentGlobalParamsVersion, + ParamsWithContext, } from "@/utils/globalParams"; import { isStakingSignReady } from "@/utils/isStakingSignReady"; import { toLocalStorageDelegation } from "@/utils/local_storage/toLocalStorageDelegation"; @@ -74,13 +74,13 @@ export const Staking: React.FC = ({ isWalletConnected, onConnect, isLoading, - btcWallet, - btcWalletNetwork, - address, - publicKeyNoCoord, setDelegationsLocalStorage, btcWalletBalanceSat, availableUTXOs, + publicKeyNoCoord, + address, + btcWallet, + btcWalletNetwork, }) => { // Staking form state const [stakingAmountSat, setStakingAmountSat] = useState(0); diff --git a/src/app/components/Summary/Summary.tsx b/src/app/components/Summary/Summary.tsx index 344220d4..d0a6b44a 100644 --- a/src/app/components/Summary/Summary.tsx +++ b/src/app/components/Summary/Summary.tsx @@ -15,15 +15,18 @@ import { maxDecimals } from "@/utils/maxDecimals"; import { Network } from "@/utils/wallet/wallet_provider"; import { LoadingSmall } from "../Loading/Loading"; +import { StakerPoints } from "../Points/StakerPoints"; interface SummaryProps { totalStakedSat: number; btcWalletBalanceSat?: number; + publicKeyNoCoord: string; } export const Summary: React.FC = ({ totalStakedSat, btcWalletBalanceSat, + publicKeyNoCoord, }) => { const { coinName } = getNetworkConfig(); const onMainnet = getNetworkConfig().network === Network.MAINNET; @@ -46,70 +49,89 @@ export const Summary: React.FC = ({ }, [btcHeight, globalParams]); return ( -
+

Your staking summary

-
-
-
-

Total staked

- - - - -
-
- -

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

-
-
-
-
-
-

Stakable Balance

- - - - -
-
- - {typeof btcWalletBalanceSat === "number" ? ( +
+
+
+
+

Total staked

+ + + + +
+
+

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

- ) : ( - - )} +
+
+
+
+
+

Total points

+ + + + +
+
+ +
+
+
+
+
+

Stakable Balance

+ + + + +
+
+ + {typeof btcWalletBalanceSat === "number" ? ( +

+ {maxDecimals(satoshiToBtc(btcWalletBalanceSat), 8)} {coinName} +

+ ) : ( + + )} +
- {!onMainnet && ( - - Get Test Tokens - - )}
+ {!onMainnet && ( + + Get Test Tokens + + )}
); diff --git a/src/app/context/api/DelegationsPointsProvider.tsx b/src/app/context/api/DelegationsPointsProvider.tsx new file mode 100644 index 00000000..733c5eb8 --- /dev/null +++ b/src/app/context/api/DelegationsPointsProvider.tsx @@ -0,0 +1,92 @@ +import { useQuery } from "@tanstack/react-query"; +import React, { createContext, useContext, useEffect, useState } from "react"; + +import { + getDelegationPoints, + PaginatedDelegationsPoints, +} from "@/app/api/getDelegationPoints"; +import { Delegation } from "@/app/types/delegations"; + +interface PointsContextType { + delegationPoints: Map; + isLoading: boolean; + error: Error | null; +} + +const DelegationsPointsContext = createContext( + undefined, +); + +export const useDelegationsPoints = () => { + const context = useContext(DelegationsPointsContext); + if (context === undefined) { + throw new Error( + "useDelegationsPoints must be used within a DelegationsPointsProvider", + ); + } + return context; +}; + +interface DelegationsPointsProviderProps { + children: React.ReactNode; + publicKeyNoCoord: string; + delegationsAPI: Delegation[]; + isWalletConnected: boolean; +} + +export const DelegationsPointsProvider: React.FC< + DelegationsPointsProviderProps +> = ({ children, publicKeyNoCoord, delegationsAPI, isWalletConnected }) => { + const [delegationPoints, setDelegationPoints] = useState>( + new Map(), + ); + + const fetchAllPoints = async () => { + let allPoints: PaginatedDelegationsPoints["data"] = []; + let paginationKey = ""; + + const stakingTxHashHexes = delegationsAPI.map( + (delegation) => delegation.stakingTxHashHex, + ); + + do { + const result = await getDelegationPoints( + paginationKey, + undefined, + stakingTxHashHexes, + ); + allPoints = [...allPoints, ...result.data]; + paginationKey = result.pagination.next_key; + } while (paginationKey !== ""); + + return allPoints; + }; + + const { data, isLoading, error } = useQuery({ + queryKey: ["delegationPoints", publicKeyNoCoord, delegationsAPI], + queryFn: fetchAllPoints, + enabled: isWalletConnected && delegationsAPI.length > 0, + }); + + useEffect(() => { + if (data) { + const newDelegationPoints = new Map(); + data.forEach((point) => { + newDelegationPoints.set(point.staking_tx_hash_hex, point.staker.points); + }); + setDelegationPoints(newDelegationPoints); + } + }, [data]); + + const value = { + delegationPoints, + isLoading, + error, + }; + + return ( + + {children} + + ); +}; diff --git a/src/app/page.tsx b/src/app/page.tsx index e99f2b9e..422a934c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -333,6 +333,7 @@ const Home: React.FC = () => { )} = () => { isFetchingMore: isFetchingNextDelegationsPage, }} getNetworkFees={btcWallet.getNetworkFees} + isWalletConnected={!!btcWallet} /> )} {/* At this point of time is not used */}