diff --git a/.env.example b/.env.example index 71b4231d..d08db61f 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ NEXT_PUBLIC_MEMPOOL_API=https://mempool.space NEXT_PUBLIC_API_URL=https://staking-api.testnet.babylonchain.io +NEXT_PUBLIC_POINTS_API_URL=https://points.testnet.babylonchain.io NEXT_PUBLIC_NETWORK=signet NEXT_PUBLIC_DISPLAY_TESTING_MESSAGES=true \ No newline at end of file diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 2c9f4030..1c946ac5 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -37,6 +37,7 @@ jobs: build-args: | NEXT_PUBLIC_MEMPOOL_API=${{ vars.NEXT_PUBLIC_MEMPOOL_API }} NEXT_PUBLIC_API_URL=${{ vars.NEXT_PUBLIC_API_URL }} + NEXT_PUBLIC_POINTS_API_URL=${{ vars.NEXT_PUBLIC_POINTS_API_URL }} NEXT_PUBLIC_NETWORK=${{ vars.NEXT_PUBLIC_NETWORK }} NEXT_PUBLIC_DISPLAY_TESTING_MESSAGES=${{ vars.NEXT_PUBLIC_DISPLAY_TESTING_MESSAGES }} diff --git a/Dockerfile b/Dockerfile index 08a69e30..e9b5ed96 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,9 @@ ENV NEXT_PUBLIC_MEMPOOL_API=${NEXT_PUBLIC_MEMPOOL_API} ARG NEXT_PUBLIC_API_URL ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} +ARG NEXT_PUBLIC_POINTS_API_URL +ENV NEXT_PUBLIC_POINTS_API_URL=${NEXT_PUBLIC_POINTS_API_URL} + ARG NEXT_PUBLIC_NETWORK ENV NEXT_PUBLIC_NETWORK=${NEXT_PUBLIC_NETWORK} diff --git a/README.md b/README.md index f370b0ef..03db1c9d 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ where, node queries - `NEXT_PUBLIC_API_URL` specifies the back-end API to use for the staking system queries +- `NEXT_PUBLIC_POINTS_API_URL` specifies the Points API to use for the points + system - `NEXT_PUBLIC_NETWORK` specifies the BTC network environment - `NEXT_PUBLIC_DISPLAY_TESTING_MESSAGES` boolean value to indicate whether display testing network related message. Default to true diff --git a/src/app/api/apiWrapper.ts b/src/app/api/apiWrapper.ts index 85742e18..ded5ed9f 100644 --- a/src/app/api/apiWrapper.ts +++ b/src/app/api/apiWrapper.ts @@ -2,7 +2,7 @@ import axios from "axios"; export const apiWrapper = async ( method: "GET" | "POST", - url: string, + path: string, generalErrorMessage: string, params?: any, timeout?: number, @@ -23,7 +23,7 @@ export const apiWrapper = async ( try { // destructure params in case of post request response = await handler( - `${process.env.NEXT_PUBLIC_API_URL}${url}`, + `${process.env.NEXT_PUBLIC_API_URL}${path}`, method === "POST" ? { ...params } : { diff --git a/src/app/api/getPoints.ts b/src/app/api/getPoints.ts new file mode 100644 index 00000000..6619885e --- /dev/null +++ b/src/app/api/getPoints.ts @@ -0,0 +1,67 @@ +import { encode } from "url-safe-base64"; + +import { Pagination } from "../types/api"; + +import { pointsApiWrapper } from "./pointsApiWrapper"; + +export interface StakerPoints { + staker_btc_pk: string; + points: number; +} + +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 interface DelegationsPoints { + data: DelegationPoints[]; +} + +export const getStakersPoints = async ( + stakerBtcPks: string[], +): Promise => { + const params = new URLSearchParams(); + + stakerBtcPks.forEach((pk) => { + params.append("staker_btc_pk", encode(pk)); + }); + + const response = await pointsApiWrapper( + "GET", + "/v1/points/stakers", + "Error getting staker points", + params, + ); + + return response.data; +}; + +// Get delegation points by staking transaction hash hex +export const getDelegationPointsByStakingTxHashHexes = async ( + stakingTxHashHexes: string[], +): Promise => { + const response = await pointsApiWrapper( + "POST", + "/v1/points/delegations", + "Error getting delegation points by staking transaction hashes", + { staking_tx_hash_hex: stakingTxHashHexes }, + ); + + return response.data; +}; diff --git a/src/app/api/pointsApiWrapper.ts b/src/app/api/pointsApiWrapper.ts new file mode 100644 index 00000000..c48b9566 --- /dev/null +++ b/src/app/api/pointsApiWrapper.ts @@ -0,0 +1,45 @@ +import axios from "axios"; + +export const pointsApiWrapper = async ( + method: "GET" | "POST", + path: string, + generalErrorMessage: string, + params?: any, + timeout?: number, +) => { + let response; + let handler; + switch (method) { + case "GET": + handler = axios.get; + break; + case "POST": + handler = axios.post; + break; + default: + throw new Error("Invalid method"); + } + + try { + // destructure params in case of post request + response = await handler( + `${process.env.NEXT_PUBLIC_POINTS_API_URL}${path}`, + method === "POST" + ? { ...params } + : { + params, + }, + { + timeout: timeout || 0, // 0 is no timeout + }, + ); + } catch (error) { + if (axios.isAxiosError(error)) { + const message = error?.response?.data?.message; + throw new Error(message || generalErrorMessage); + } else { + throw new Error(generalErrorMessage); + } + } + return response; +}; diff --git a/src/app/components/Delegations/Delegation.tsx b/src/app/components/Delegations/Delegation.tsx index eb9c5080..310a8724 100644 --- a/src/app/components/Delegations/Delegation.tsx +++ b/src/app/components/Delegations/Delegation.tsx @@ -4,6 +4,7 @@ import { FaBitcoin } from "react-icons/fa"; import { IoIosWarning } from "react-icons/io"; import { Tooltip } from "react-tooltip"; +import { useHealthCheck } from "@/app/hooks/useHealthCheck"; import { DelegationState, StakingTx } from "@/app/types/delegations"; import { GlobalParamsVersion } from "@/app/types/globalParams"; import { getNetworkConfig } from "@/config/network.config"; @@ -13,6 +14,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; @@ -41,6 +44,7 @@ export const Delegation: React.FC = ({ }) => { const { startTimestamp } = stakingTx; const [currentTime, setCurrentTime] = useState(Date.now()); + const { isApiNormal, isGeoBlocked } = useHealthCheck(); useEffect(() => { const timerId = setInterval(() => { @@ -122,7 +126,7 @@ export const Delegation: React.FC = ({

overflow

)} -
+

@@ -142,13 +146,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()}

= ({
+ {isApiNormal && !isGeoBlocked && ( +
+ +
+ )}
{generateActionButton()}
diff --git a/src/app/components/Delegations/Delegations.tsx b/src/app/components/Delegations/Delegations.tsx index bf2f7e96..2a47465e 100644 --- a/src/app/components/Delegations/Delegations.tsx +++ b/src/app/components/Delegations/Delegations.tsx @@ -5,7 +5,9 @@ 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 { useHealthCheck } from "@/app/hooks/useHealthCheck"; import { QueryMeta } from "@/app/types/api"; import { Delegation as DelegationInterface, @@ -39,24 +41,63 @@ 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(""); const [modalMode, setModalMode] = useState(); const { showError } = useError(); + const { isApiNormal, isGeoBlocked } = useHealthCheck(); // Local storage state for intermediate delegations (withdrawing, unbonding) const intermediateDelegationsLocalStorageKey = @@ -233,11 +274,14 @@ export const Delegations: React.FC = ({
) : ( <> -
+

Amount

Inception

Transaction hash

Status

+ {isApiNormal && !isGeoBlocked && ( +

Points

+ )}

Action

Keystone via QR code is the only hardware wallet supporting Bitcoin Staking. Using any other hardware wallet through any means (such as connection to a software/extension/mobile wallet) can lead to permanent inability to withdraw the stake.

`, }, + { + title: "What are the points?", + content: `

We use points to track staking activity. Points are not blockchain tokens. Points do not, and may never, convert to, accrue to, be used as a basis to calculate, or become tokens, other digital assets, or distributions thereof. Points are virtual calculations with no monetary value. Points do not constitute any currency or property of any type and are not redeemable, refundable, or transferable.

`, + }, ]; if (shouldDisplayTestingMsg()) { questionList.push({ diff --git a/src/app/components/Points/DelegationPoints.tsx b/src/app/components/Points/DelegationPoints.tsx new file mode 100644 index 00000000..387cef12 --- /dev/null +++ b/src/app/components/Points/DelegationPoints.tsx @@ -0,0 +1,32 @@ +import React from "react"; + +import { useDelegationsPoints } from "@/app/context/api/DelegationsPointsProvider"; + +interface DelegationPointsProps { + stakingTxHash: string; +} + +export const DelegationPoints: React.FC = ({ + stakingTxHash, +}) => { + const { delegationPoints, isLoading } = useDelegationsPoints(); + + const points = delegationPoints.get(stakingTxHash); + + if (isLoading) { + return ( +
+
+
+ ); + } + + return ( +
+

+ Points: + {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..d41a3cf9 --- /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/getPoints"; + +interface StakerPointsProps { + publicKeyNoCoord: string; +} + +export const StakerPoints: React.FC = ({ + publicKeyNoCoord, +}) => { + const { data: stakerPoints, isLoading } = useQuery({ + queryKey: ["stakerPoints", publicKeyNoCoord], + queryFn: () => getStakersPoints([publicKeyNoCoord]), + enabled: !!publicKeyNoCoord, + refetchInterval: 300000, // Refresh every 5 minutes + refetchOnWindowFocus: false, + retry: 1, + }); + + if (isLoading) { + return ( +
+
+
+ ); + } + + const points = stakerPoints?.[0]?.points; + + return ( +
+

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

+
+ ); +}; diff --git a/src/app/components/Staking/Staking.tsx b/src/app/components/Staking/Staking.tsx index 12f9cb65..edddf005 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"; diff --git a/src/app/components/Summary/Summary.tsx b/src/app/components/Summary/Summary.tsx index 083189ee..ce878803 100644 --- a/src/app/components/Summary/Summary.tsx +++ b/src/app/components/Summary/Summary.tsx @@ -5,6 +5,7 @@ import { Tooltip } from "react-tooltip"; import { useGlobalParams } from "@/app/context/api/GlobalParamsProvider"; import { useBtcHeight } from "@/app/context/mempool/BtcHeightProvider"; +import { useHealthCheck } from "@/app/hooks/useHealthCheck"; import { getNetworkConfig } from "@/config/network.config"; import { satoshiToBtc } from "@/utils/btcConversions"; import { @@ -15,15 +16,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; @@ -33,6 +37,7 @@ export const Summary: React.FC = ({ const btcHeight = useBtcHeight(); const globalParams = useGlobalParams(); + const { isApiNormal, isGeoBlocked } = useHealthCheck(); useMemo(() => { if (!btcHeight || !globalParams.data) { @@ -46,73 +51,102 @@ 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" ? ( -

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

- ) : ( - +
+
+
+
+
+

Total staked

+ + + + +
+
+ +

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

+
+
+ {isApiNormal && !isGeoBlocked && ( + <> +
+
+
+

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..6b058d04 --- /dev/null +++ b/src/app/context/api/DelegationsPointsProvider.tsx @@ -0,0 +1,113 @@ +import { useQuery } from "@tanstack/react-query"; +import React, { createContext, useContext, useEffect, useState } from "react"; + +import { getDelegationPointsByStakingTxHashHexes } from "@/app/api/getPoints"; +import { useHealthCheck } from "@/app/hooks/useHealthCheck"; +import { Delegation } from "@/app/types/delegations"; + +interface PointsContextType { + delegationPoints: Map; + isLoading: boolean; + error: Error | null; +} + +interface DelegationsPointsProviderProps { + children: React.ReactNode; + publicKeyNoCoord: string; + delegationsAPI: Delegation[]; + isWalletConnected: boolean; + address: string; +} + +const MAX_DELEGATION_POINTS_BATCH_SIZE = 60; +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; +}; + +export const DelegationsPointsProvider: React.FC< + DelegationsPointsProviderProps +> = ({ + children, + publicKeyNoCoord, + delegationsAPI, + isWalletConnected, + address, +}) => { + const [delegationPoints, setDelegationPoints] = useState>( + new Map(), + ); + const { isApiNormal, isGeoBlocked } = useHealthCheck(); + + const fetchAllPoints = async () => { + const stakingTxHashHexes = delegationsAPI + .filter((delegation) => !delegation.isOverflow) + .map((delegation) => delegation.stakingTxHashHex); + + const chunks = []; + for ( + let i = 0; + i < stakingTxHashHexes.length; + i += MAX_DELEGATION_POINTS_BATCH_SIZE + ) { + chunks.push( + stakingTxHashHexes.slice(i, i + MAX_DELEGATION_POINTS_BATCH_SIZE), + ); + } + + const results = await Promise.all( + chunks.map((chunk) => getDelegationPointsByStakingTxHashHexes(chunk)), + ); + + return results.flatMap((result) => result.data); + }; + + const { data, isLoading, error } = useQuery({ + queryKey: ["delegationPoints", address, publicKeyNoCoord], + queryFn: fetchAllPoints, + enabled: + isWalletConnected && + delegationsAPI.length > 0 && + isApiNormal && + !isGeoBlocked, + refetchInterval: 300000, // Refetch every 5 minutes + refetchOnWindowFocus: false, + retry: 1, + }); + + useEffect(() => { + if (data) { + const newDelegationPoints = new Map(); + data.forEach((point) => { + if (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 */}