diff --git a/e2e/fixtures/wallet_balance.selectors.ts b/e2e/fixtures/wallet_balance.selectors.ts index 602bef901..da17f4ec6 100644 --- a/e2e/fixtures/wallet_balance.selectors.ts +++ b/e2e/fixtures/wallet_balance.selectors.ts @@ -3,12 +3,12 @@ export const SPINNER_SELECTOR = '[data-testid="staked-balance"] .bbn-loader, [data-testid="stakable-balance"] .bbn-loader, [data-testid="baby-balance"] .bbn-loader, [data-testid="baby-rewards"] .bbn-loader'; export const STAKED_BALANCE_ITEM_SELECTOR = - '.bbn-list-item:has-text("Staked Balance")'; + '.bbn-list-item-legacy:has-text("Staked Balance")'; export const STAKED_BALANCE_VALUE_SELECTOR = - '.bbn-list-item:has-text("Staked Balance") .bbn-list-value'; + '.bbn-list-item-legacy:has-text("Staked Balance") .bbn-list-value-legacy'; export const STAKABLE_BALANCE_VALUE_SELECTOR = - '.bbn-list-item:has-text("Stakable Balance") .bbn-list-value'; + '.bbn-list-item-legacy:has-text("Stakable Balance") .bbn-list-value-legacy'; export const BABY_BALANCE_VALUE_SELECTOR = - '.bbn-list-item:has-text("BABY Balance") .bbn-list-value'; + '.bbn-list-item-legacy:has-text("BABY Balance") .bbn-list-value-legacy'; export const BABY_REWARDS_VALUE_SELECTOR = - '.bbn-list-item:has-text("BABY Rewards") .bbn-list-value'; + '.bbn-list-item-legacy:has-text("BABY Rewards") .bbn-list-value-legacy'; diff --git a/package-lock.json b/package-lock.json index 352e867ef..f1553311a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,8 @@ "dependencies": { "@babylonlabs-io/babylon-proto-ts": "1.1.0", "@babylonlabs-io/btc-staking-ts": "2.3.4", - "@babylonlabs-io/core-ui": "1.1.1", - "@babylonlabs-io/wallet-connector": "1.7.9", + "@babylonlabs-io/core-ui": "1.4.1", + "@babylonlabs-io/wallet-connector": "1.8.1", "@bitcoin-js/tiny-secp256k1-asmjs": "2.2.3", "@cosmjs/proto-signing": "^0.32.4", "@cosmjs/stargate": "^0.32.4", @@ -2217,9 +2217,9 @@ } }, "node_modules/@babylonlabs-io/core-ui": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@babylonlabs-io/core-ui/-/core-ui-1.1.1.tgz", - "integrity": "sha512-a7ZdVkfr/zlF2LNJgbUGjFgxEhLBA5psokTiOijNOA12pUmtWRQnis08pdmimJ7s/jNNewnJuOY2GzALyZEvVA==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@babylonlabs-io/core-ui/-/core-ui-1.4.1.tgz", + "integrity": "sha512-z1Y5c45JB7P1y1SZEZGH9T3TG2lfYHeJPMuD9qoI4hb6fNRtX6R0JQ/wcXCidP98ngKQcULQgc/P3rEil/5nPQ==", "dependencies": { "@hookform/resolvers": "^3.9.1", "@popperjs/core": "^2.11.8", @@ -2235,9 +2235,9 @@ } }, "node_modules/@babylonlabs-io/wallet-connector": { - "version": "1.7.9", - "resolved": "https://registry.npmjs.org/@babylonlabs-io/wallet-connector/-/wallet-connector-1.7.9.tgz", - "integrity": "sha512-dyuLegL8wd66GSNaMGo0oQqFxqW2/RCRRwRYtDvjTmslIaX1Zt+iEEvUGMNJonjmQJpEL0ghHfgX48ZwYCM2IA==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@babylonlabs-io/wallet-connector/-/wallet-connector-1.8.1.tgz", + "integrity": "sha512-9xYy2Ba4p9YDfX73e7ETQy3nOAURczSxgLI4IeyOzphOtzjZHgv84VoeHVsR8bA811kpUlVhOiqOJSOW/D6IrA==", "dependencies": { "@bitcoin-js/tiny-secp256k1-asmjs": "^2.2.3", "@cosmjs/stargate": "^0.32.4", @@ -2257,7 +2257,7 @@ "uuid": "^11.1.0" }, "peerDependencies": { - "@babylonlabs-io/core-ui": "1.1.1", + "@babylonlabs-io/core-ui": "1.4.1", "react": "^18.3.1", "react-dom": "^18.3.1", "tailwind-merge": "^2.5.4" diff --git a/package.json b/package.json index e25b28d96..4e3875ca8 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,8 @@ "dependencies": { "@babylonlabs-io/babylon-proto-ts": "1.1.0", "@babylonlabs-io/btc-staking-ts": "2.3.4", - "@babylonlabs-io/core-ui": "1.1.1", - "@babylonlabs-io/wallet-connector": "1.7.9", + "@babylonlabs-io/core-ui": "1.4.1", + "@babylonlabs-io/wallet-connector": "1.8.1", "@bitcoin-js/tiny-secp256k1-asmjs": "2.2.3", "@cosmjs/proto-signing": "^0.32.4", "@cosmjs/stargate": "^0.32.4", diff --git a/src/main.tsx b/src/main.tsx index 4aa0c2133..b9de02b34 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,8 +3,7 @@ import { createRoot } from "react-dom/client"; import { ErrorBoundary } from "react-error-boundary"; import { BrowserRouter } from "react-router"; -import GlobalError from "@/ui/legacy/global-error"; -import Providers from "@/ui/legacy/providers"; +import GlobalError from "@/ui/common/global-error"; import { Router } from "@/ui/router"; import "@/ui/globals.css"; @@ -13,11 +12,9 @@ import "../sentry.client.config"; createRoot(document.getElementById("root")!).render( - - - - - + + + , ); diff --git a/src/ui/baby/components/AmountInput.tsx b/src/ui/baby/components/AmountInput.tsx new file mode 100644 index 000000000..91f393e8c --- /dev/null +++ b/src/ui/baby/components/AmountInput.tsx @@ -0,0 +1,28 @@ +interface AmountInputProps { + value: string; + onChange: (value: string) => void; + label: string; + placeholder: string; +} + +export function AmountInput({ + value, + onChange, + label, + placeholder, +}: AmountInputProps) { + return ( +
+ + onChange(e.target.value)} + className="w-full bg-secondary-highlight py-[14px] px-6 rounded border border-transparent text-accent-primary outline-none" + placeholder={placeholder} + /> +
+ ); +} diff --git a/src/ui/baby/components/DelegationsList.tsx b/src/ui/baby/components/DelegationsList.tsx new file mode 100644 index 000000000..8096b8edc --- /dev/null +++ b/src/ui/baby/components/DelegationsList.tsx @@ -0,0 +1,102 @@ +import { Button } from "@babylonlabs-io/core-ui"; + +import { ubbnToBaby } from "@/ui/legacy/utils/bbn"; + +interface Delegation { + delegation?: { + validatorAddress: string; + }; + balance?: { + amount: string; + denom: string; + }; +} + +interface Validator { + operatorAddress: string; + description?: { + moniker: string; + }; +} + +interface DelegationsListProps { + delegations: Delegation[]; + validators: Validator[]; + getRewardsForValidator: (validatorAddress: string) => number; + onUnstakeAll: (validatorAddress: string, amount: string) => void; + onClaimRewards: (validatorAddress: string) => Promise; + loading: boolean; +} + +export function DelegationsList({ + delegations, + validators, + getRewardsForValidator, + onUnstakeAll, + onClaimRewards, + loading, +}: DelegationsListProps) { + if (delegations.length === 0) { + return null; + } + + return ( +
+

My Delegations

+
+ {delegations.map((delegation, index) => { + const validatorAddress = + delegation.delegation?.validatorAddress || ""; + const stakedAmount = ubbnToBaby( + parseFloat(delegation.balance?.amount || "0"), + ); + const rewardsAmount = getRewardsForValidator(validatorAddress); + + return ( +
+
+

+ {validators.find( + (v) => v.operatorAddress === validatorAddress, + )?.description?.moniker || validatorAddress} +

+

+ Staked: {stakedAmount.toLocaleString()} tBABY +

+ {rewardsAmount > 0 && ( +

+ Rewards: {rewardsAmount.toLocaleString()} tBABY +

+ )} +
+
+ + {rewardsAmount > 0 && ( + + )} +
+
+ ); + })} +
+
+ ); +} diff --git a/src/ui/baby/components/RewardsForm.tsx b/src/ui/baby/components/RewardsForm.tsx new file mode 100644 index 000000000..0132f87b1 --- /dev/null +++ b/src/ui/baby/components/RewardsForm.tsx @@ -0,0 +1,47 @@ +import { Button } from "@babylonlabs-io/core-ui"; + +import { ValidatorSelector } from "./ValidatorSelector"; + +interface Validator { + operatorAddress: string; + description?: { + moniker: string; + }; +} + +interface RewardsFormProps { + validators: Validator[]; + validatorAddress: string; + setValidatorAddress: (address: string) => void; + onClaimRewards: () => void; + loading: boolean; +} + +export function RewardsForm({ + validators, + validatorAddress, + setValidatorAddress, + onClaimRewards, + loading, +}: RewardsFormProps) { + return ( +
+

Rewards

+
+ + +
+
+ ); +} diff --git a/src/ui/baby/components/StakeForm.tsx b/src/ui/baby/components/StakeForm.tsx new file mode 100644 index 000000000..f31dfbe4c --- /dev/null +++ b/src/ui/baby/components/StakeForm.tsx @@ -0,0 +1,58 @@ +import { Button } from "@babylonlabs-io/core-ui"; + +import { AmountInput } from "./AmountInput"; +import { ValidatorSelector } from "./ValidatorSelector"; + +interface Validator { + operatorAddress: string; + description?: { + moniker: string; + }; +} + +interface StakeFormProps { + validators: Validator[]; + validatorAddress: string; + setValidatorAddress: (address: string) => void; + stakeAmount: string; + setStakeAmount: (amount: string) => void; + onStake: () => void; + loading: boolean; +} + +export function StakeForm({ + validators, + validatorAddress, + setValidatorAddress, + stakeAmount, + setStakeAmount, + onStake, + loading, +}: StakeFormProps) { + return ( +
+

Stake

+
+ + + +
+
+ ); +} diff --git a/src/ui/baby/components/UnstakeForm.tsx b/src/ui/baby/components/UnstakeForm.tsx new file mode 100644 index 000000000..c24b94614 --- /dev/null +++ b/src/ui/baby/components/UnstakeForm.tsx @@ -0,0 +1,58 @@ +import { Button } from "@babylonlabs-io/core-ui"; + +import { AmountInput } from "./AmountInput"; +import { ValidatorSelector } from "./ValidatorSelector"; + +interface Validator { + operatorAddress: string; + description?: { + moniker: string; + }; +} + +interface UnstakeFormProps { + validators: Validator[]; + validatorAddress: string; + setValidatorAddress: (address: string) => void; + unstakeAmount: string; + setUnstakeAmount: (amount: string) => void; + onUnstake: () => void; + loading: boolean; +} + +export function UnstakeForm({ + validators, + validatorAddress, + setValidatorAddress, + unstakeAmount, + setUnstakeAmount, + onUnstake, + loading, +}: UnstakeFormProps) { + return ( +
+

Unstake

+
+ + + +
+
+ ); +} diff --git a/src/ui/baby/components/ValidatorSelector.tsx b/src/ui/baby/components/ValidatorSelector.tsx new file mode 100644 index 000000000..70a688b59 --- /dev/null +++ b/src/ui/baby/components/ValidatorSelector.tsx @@ -0,0 +1,43 @@ +interface Validator { + operatorAddress: string; + description?: { + moniker: string; + }; +} + +interface ValidatorSelectorProps { + validators: Validator[]; + value: string; + onChange: (value: string) => void; + label: string; +} + +export function ValidatorSelector({ + validators, + value, + onChange, + label, +}: ValidatorSelectorProps) { + return ( +
+ + +
+ ); +} diff --git a/src/ui/baby/components/WalletInfo.tsx b/src/ui/baby/components/WalletInfo.tsx new file mode 100644 index 000000000..e493891a8 --- /dev/null +++ b/src/ui/baby/components/WalletInfo.tsx @@ -0,0 +1,44 @@ +interface WalletInfoProps { + bech32Address: string; + delegationsCount: number; + totalStaked: number; + totalRewards: number; + validatorsCount: number; +} + +export function WalletInfo({ + bech32Address, + delegationsCount, + totalStaked, + totalRewards, + validatorsCount, +}: WalletInfoProps) { + return ( +
+

Wallet Info

+

Address: {bech32Address}

+
+
+

Active Delegations

+

{delegationsCount}

+
+
+

Total Staked

+

+ {totalStaked.toLocaleString()} tBABY +

+
+
+

Total Rewards

+

+ {totalRewards.toLocaleString()} tBABY +

+
+
+

Available Validators

+

{validatorsCount}

+
+
+
+ ); +} diff --git a/src/ui/baby/components/WalletNotConnected.tsx b/src/ui/baby/components/WalletNotConnected.tsx new file mode 100644 index 000000000..4e214dd73 --- /dev/null +++ b/src/ui/baby/components/WalletNotConnected.tsx @@ -0,0 +1,12 @@ +export function WalletNotConnected() { + return ( +
+
+

Connect Your Wallet

+

+ Please connect your Babylon wallet to use staking features. +

+
+
+ ); +} diff --git a/src/ui/baby/index.tsx b/src/ui/baby/index.tsx index 682f762bc..10fe5cf06 100644 --- a/src/ui/baby/index.tsx +++ b/src/ui/baby/index.tsx @@ -1,13 +1,20 @@ import { useState } from "react"; -import { useCosmosWallet } from "@/ui/legacy/context/wallet/CosmosWalletProvider"; +import { Container } from "@/ui/common/components/Container/Container"; +import { useCosmosWallet } from "@/ui/common/context/wallet/CosmosWalletProvider"; import { useBbnQuery } from "@/ui/legacy/hooks/client/rpc/queries/useBbnQuery"; import { useEpochingService } from "@/ui/legacy/hooks/services/useEpochingService"; -import Layout from "@/ui/legacy/layout"; import { babyToUbbn, ubbnToBaby } from "@/ui/legacy/utils/bbn"; +import { DelegationsList } from "./components/DelegationsList"; +import { RewardsForm } from "./components/RewardsForm"; +import { StakeForm } from "./components/StakeForm"; +import { UnstakeForm } from "./components/UnstakeForm"; +import { WalletInfo } from "./components/WalletInfo"; + export default function BabyStaking() { - const { bech32Address, connected } = useCosmosWallet(); + const cosmosWallet = useCosmosWallet(); + const { bech32Address } = cosmosWallet; const { stake, unstake, claimRewards } = useEpochingService(); const { delegationsQuery, delegationRewardsQuery, validatorsQuery } = useBbnQuery(); @@ -124,246 +131,67 @@ export default function BabyStaking() { } }; - if (!connected) { - return ( - -
-
-

Connect Your Wallet

-

- Please connect your Babylon wallet to use staking features. -

-
-
-
- ); - } - - return ( - -
-

Baby Staking

- -
-

Wallet Info

-

Address: {bech32Address}

-
-
-

Active Delegations

-

{delegations.length}

-
-
-

Total Staked

-

- {totalStaked.toLocaleString()} tBABY -

-
-
-

Total Rewards

-

- {totalRewards.toLocaleString()} tBABY -

-
-
-

Available Validators

-

{validators.length}

-
-
-
- - {delegations.length > 0 && ( -
-

My Delegations

-
- {delegations.map((delegation, index) => { - const validatorAddress = - delegation.delegation?.validatorAddress || ""; - const stakedAmount = ubbnToBaby( - parseFloat(delegation.balance?.amount || "0"), - ); - const rewardsAmount = getRewardsForValidator(validatorAddress); - - return ( -
-
-

- {validators.find( - (v) => v.operatorAddress === validatorAddress, - )?.description?.moniker || validatorAddress} -

-

- Staked: {stakedAmount.toLocaleString()} tBABY -

- {rewardsAmount > 0 && ( -

- Rewards: {rewardsAmount.toLocaleString()} tBABY -

- )} -
-
- - {rewardsAmount > 0 && ( - - )} -
-
- ); - })} -
-
- )} - -
- {/* Stake Section */} -
-

Stake

-
-
- - -
-
- - setStakeAmount(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" - placeholder="Enter tBABY amount to stake (e.g., 10)" - /> -
- -
-
+ const handleUnstakeAll = (validatorAddr: string, amount: string) => { + setValidatorAddress(validatorAddr); + setUnstakeAmount(amount); + }; - {/* Unstake Section */} -
-

Unstake

-
-
- - -
-
- - setUnstakeAmount(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-red-500" - placeholder="Enter tBABY amount to unstake (e.g., 5)" - /> -
- -
-
+ const handleClaimRewardsForValidator = async (validatorAddr: string) => { + setValidatorAddress(validatorAddr); + await handleClaimRewards(); + }; - {/* Rewards Section */} -
-

Rewards

-
-
- - -
- -
-
-
+ return ( + + + + + +
+ + + + +
- +
); } diff --git a/src/ui/common/api/apiWrapper.ts b/src/ui/common/api/apiWrapper.ts new file mode 100644 index 000000000..a1712c93e --- /dev/null +++ b/src/ui/common/api/apiWrapper.ts @@ -0,0 +1,103 @@ +import { stringify } from "qs"; + +import { ClientError, ERROR_CODES } from "@/ui/common/errors"; + +import { getApiBaseUrl } from "../config"; + +type QueryParamValue = + | string + | number + | boolean + | Array; + +interface QueryParams { + [key: string]: QueryParamValue | undefined; +} + +interface RequestParams { + query?: QueryParams; + body?: any; +} + +export const apiWrapper = async ( + method: "GET" | "POST", + path: string, + generalErrorMessage: string, + params?: RequestParams, + timeout?: number, +): Promise<{ + data: TResponseData; + status: number; +}> => { + const queryString = params?.query + ? `?${stringify(params.query, { arrayFormat: "repeat" })}` + : ""; + + const baseUrl = getApiBaseUrl(); + const url = `${baseUrl}${path}${queryString}`; + + const options: RequestInit = { + method, + headers: + method === "POST" + ? { + "Content-Type": "application/json", + } + : {}, + }; + + if (params?.body) { + options.body = JSON.stringify(params.body); + } + + if (timeout) { + options.signal = AbortSignal.timeout(timeout); + } + + try { + const response = await fetch(url, options); + + if (!response.ok) { + const responseText = await response + .text() + .catch(() => generalErrorMessage); + const cause = new Error(responseText); + (cause as any).status = response.status; + const clientError = new ClientError( + ERROR_CODES.EXTERNAL_SERVICE_UNAVAILABLE, + "Error fetching data from the API", + { + cause, + }, + ); + throw clientError; + } + + const data = await response.json(); + return { data, status: response.status }; + } catch (error) { + if (error instanceof ClientError) { + throw error; + } + + if (error instanceof TypeError && error.message.includes("NetworkError")) { + const clientError = new ClientError( + ERROR_CODES.CONNECTION_ERROR, + "Network error occurred", + { + cause: error, + }, + ); + throw clientError; + } + + const clientError = new ClientError( + ERROR_CODES.EXTERNAL_SERVICE_UNAVAILABLE, + error instanceof Error ? error.message : generalErrorMessage, + { + cause: error as Error, + }, + ); + throw clientError; + } +}; diff --git a/src/ui/common/api/error/index.ts b/src/ui/common/api/error/index.ts new file mode 100644 index 000000000..db7938020 --- /dev/null +++ b/src/ui/common/api/error/index.ts @@ -0,0 +1,13 @@ +/** + * Checks if an error is a 451 HTTP status code error (Unavailable For Legal Reasons) + * @param error - The error to check + * @returns boolean indicating if the error has a 451 status code + */ +export const isError451 = (error: any): boolean => { + return ( + error && + (error.status === 451 || + (error.response && error.response.status === 451) || + (error.request && error.request.status === 451)) + ); +}; diff --git a/src/ui/common/api/getBsn.ts b/src/ui/common/api/getBsn.ts new file mode 100644 index 000000000..c85741778 --- /dev/null +++ b/src/ui/common/api/getBsn.ts @@ -0,0 +1,32 @@ +import { Bsn } from "../types/bsn"; + +import { apiWrapper } from "./apiWrapper"; + +interface BsnAPI { + id: string; + name: string; + description: string; + active_tvl: number; +} + +interface BsnDataResponse { + data: BsnAPI[]; +} + +const createBSN = (bsn: BsnAPI): Bsn => ({ + id: bsn.id, + name: bsn.name, + description: bsn.description, + activeTvl: bsn.active_tvl, +}); + +export const getBSNs = async (): Promise => { + const response = await apiWrapper( + "GET", + "/v2/bsn", + "Error getting BSN list", + ); + + const { data } = response.data; + return data.map(createBSN); +}; diff --git a/src/ui/common/api/getDelegations.ts b/src/ui/common/api/getDelegations.ts new file mode 100644 index 000000000..31040ad33 --- /dev/null +++ b/src/ui/common/api/getDelegations.ts @@ -0,0 +1,91 @@ +import { Pagination } from "../types/api"; +import { Delegation } from "../types/delegations"; + +import { apiWrapper } from "./apiWrapper"; + +export interface PaginatedDelegations { + delegations: Delegation[]; + pagination: Pagination; +} + +interface DelegationsAPIResponse { + data: DelegationAPI[]; + pagination: Pagination; +} + +interface DelegationAPI { + staking_tx_hash_hex: string; + staker_pk_hex: string; + finality_provider_pk_hex: string; + state: string; + staking_value: number; + staking_tx: StakingTxAPI; + unbonding_tx?: UnbondingTxAPI; + is_overflow: boolean; + is_eligible_for_transition: boolean; +} + +interface StakingTxAPI { + tx_hex: string; + output_index: number; + start_timestamp: string; + start_height: number; + timelock: number; +} + +interface UnbondingTxAPI { + tx_hex: string; + output_index: number; +} + +export const getDelegations = async ( + key: string, + publicKeyNoCoord: string, +): Promise => { + const params = { + pagination_key: key, + staker_btc_pk: publicKeyNoCoord, + // We only fetch for states that can have pending actions. + // We don't care terminal states such as "withdrawn" or "transitioned". + pending_action: true, + }; + + const response = await apiWrapper( + "GET", + "/v1/staker/delegations", + "Error getting delegations", + { query: params }, + ); + + const delegationsAPIResponse: DelegationsAPIResponse = response.data; + + const delegations: Delegation[] = delegationsAPIResponse.data.map( + (apiDelegation: DelegationAPI): Delegation => ({ + stakingTxHashHex: apiDelegation.staking_tx_hash_hex, + stakerPkHex: apiDelegation.staker_pk_hex, + finalityProviderPkHex: apiDelegation.finality_provider_pk_hex, + state: apiDelegation.state, + stakingValueSat: apiDelegation.staking_value, + stakingTx: { + txHex: apiDelegation.staking_tx.tx_hex, + outputIndex: apiDelegation.staking_tx.output_index, + startTimestamp: apiDelegation.staking_tx.start_timestamp, + startHeight: apiDelegation.staking_tx.start_height, + timelock: apiDelegation.staking_tx.timelock, + }, + isOverflow: apiDelegation.is_overflow, + unbondingTx: apiDelegation.unbonding_tx + ? { + txHex: apiDelegation.unbonding_tx.tx_hex, + outputIndex: apiDelegation.unbonding_tx.output_index, + } + : undefined, + isEligibleForTransition: apiDelegation.is_eligible_for_transition, + }), + ); + + const pagination: Pagination = { + next_key: delegationsAPIResponse.pagination.next_key, + }; + return { delegations: delegations, pagination }; +}; diff --git a/src/ui/common/api/getDelegationsV2.ts b/src/ui/common/api/getDelegationsV2.ts new file mode 100644 index 000000000..5b92b0c38 --- /dev/null +++ b/src/ui/common/api/getDelegationsV2.ts @@ -0,0 +1,134 @@ +import { Pagination } from "../types/api"; +import { + DelegationV2, + getDelegationV2StakingState, +} from "../types/delegationsV2"; + +import { apiWrapper } from "./apiWrapper"; + +export interface PaginatedDelegations { + delegations: DelegationV2[]; + pagination: Pagination; +} + +interface DelegationV2API { + finality_provider_btc_pks_hex: string[]; + params_version: number; + staker_btc_pk_hex: string; + delegation_staking: { + staking_tx_hex: string; + staking_tx_hash_hex: string; + staking_timelock: number; + staking_amount: number; + start_height: number; + end_height: number; + bbn_inception_height: number; + bbn_inception_time: string; + slashing: { + slashing_tx_hex: string; + spending_height: number; + }; + }; + delegation_unbonding: { + unbonding_timelock: number; + unbonding_tx: string; + covenant_unbonding_signatures?: { + covenant_btc_pk_hex: string; + signature_hex: string; + }[]; + slashing: { + unbonding_slashing_tx_hex: string; + spending_height: number; + }; + }; + state: string; +} + +export const getDelegationV2 = async ( + stakingTxHashHex: string, +): Promise => { + try { + const params = { + staking_tx_hash_hex: stakingTxHashHex, + }; + + const { data: delegationAPIResponse } = await apiWrapper<{ + data: DelegationV2API; + }>("GET", "/v2/delegation", "Error getting delegation v2", { + query: params, + }); + + return apiToDelegationV2(delegationAPIResponse.data); + } catch { + return null; + } +}; + +interface DelegationProps { + stakerPublicKey: string; + babylonAddress?: string; + pageKey?: string; +} + +export const getDelegationsV2 = async ({ + stakerPublicKey, + babylonAddress, + pageKey, +}: DelegationProps): Promise => { + const params = { + staker_pk_hex: stakerPublicKey, + babylon_address: babylonAddress, + pagination_key: pageKey ? pageKey : "", + }; + + const { data: delegationsAPIResponse } = await apiWrapper<{ + data: DelegationV2API[]; + pagination: Pagination; + }>("GET", "/v2/delegations", "Error getting delegations v2", { + query: params, + }); + + const pagination: Pagination = { + next_key: delegationsAPIResponse.pagination.next_key, + }; + return { + delegations: delegationsAPIResponse.data.map(apiToDelegationV2), + pagination, + }; +}; + +const apiToDelegationV2 = (apiDelegation: DelegationV2API): DelegationV2 => { + const state = getDelegationV2StakingState(apiDelegation.state); + + return { + finalityProviderBtcPksHex: apiDelegation.finality_provider_btc_pks_hex, + stakingTxHex: apiDelegation.delegation_staking.staking_tx_hex, + paramsVersion: apiDelegation.params_version, + stakerBtcPkHex: apiDelegation.staker_btc_pk_hex, + stakingAmount: apiDelegation.delegation_staking.staking_amount, + stakingTimelock: apiDelegation.delegation_staking.staking_timelock, + stakingTxHashHex: apiDelegation.delegation_staking.staking_tx_hash_hex, + startHeight: apiDelegation.delegation_staking.start_height, + endHeight: apiDelegation.delegation_staking.end_height, + bbnInceptionHeight: apiDelegation.delegation_staking.bbn_inception_height, + bbnInceptionTime: apiDelegation.delegation_staking.bbn_inception_time, + state, + unbondingTimelock: apiDelegation.delegation_unbonding.unbonding_timelock, + unbondingTxHex: apiDelegation.delegation_unbonding.unbonding_tx, + slashing: { + stakingSlashingTxHex: + apiDelegation.delegation_staking.slashing.slashing_tx_hex, + unbondingSlashingTxHex: + apiDelegation.delegation_unbonding.slashing.unbonding_slashing_tx_hex, + spendingHeight: + apiDelegation.delegation_unbonding.slashing.spending_height, + }, + covenantUnbondingSignatures: + apiDelegation.delegation_unbonding.covenant_unbonding_signatures?.map( + (signature) => ({ + covenantBtcPkHex: signature.covenant_btc_pk_hex, + signatureHex: signature.signature_hex, + }), + ), + }; +}; diff --git a/src/ui/common/api/getFinalityProviders.ts b/src/ui/common/api/getFinalityProviders.ts new file mode 100644 index 000000000..9fad8221e --- /dev/null +++ b/src/ui/common/api/getFinalityProviders.ts @@ -0,0 +1,97 @@ +import { API_ENDPOINTS } from "@/ui/common/constants/endpoints"; +import { isValidUrl } from "@/ui/common/utils/url"; + +import { Pagination } from "../types/api"; +import { FinalityProviderV1 } from "../types/finalityProviders"; + +import { apiWrapper } from "./apiWrapper"; + +export interface PaginatedFinalityProviders { + finalityProviders: FinalityProviderV1[]; + pagination: Pagination; +} + +interface FinalityProvidersAPIResponse { + data: FinalityProviderAPI[]; + pagination: Pagination; +} + +interface FinalityProviderAPI { + description: DescriptionAPI; + state: "active" | "standby"; + commission: string; + btc_pk: string; + active_tvl: number; + total_tvl: number; + active_delegations: number; + total_delegations: number; +} + +interface DescriptionAPI { + moniker: string; + identity: string; + website: string; + security_contact: string; + details: string; +} + +export const getFinalityProviders = async ({ + key, + pk, + sortBy, + order, + name, +}: { + key: string; + name?: string; + sortBy?: string; + order?: "asc" | "desc"; + pk?: string; +}): Promise => { + const params = { + pagination_key: key, + finality_provider_pk: pk, + sort_by: sortBy, + order, + name, + }; + + const response = await apiWrapper( + "GET", + API_ENDPOINTS.FINALITY_PROVIDERS, + "Error getting finality providers", + { query: params }, + ); + + const finalityProvidersAPIResponse: FinalityProvidersAPIResponse = + response.data; + const finalityProvidersAPI: FinalityProviderAPI[] = + finalityProvidersAPIResponse.data; + + const finalityProviders = finalityProvidersAPI.map( + (fp: FinalityProviderAPI): FinalityProviderV1 => ({ + description: { + moniker: fp.description.moniker, + identity: fp.description.identity, + website: isValidUrl(fp.description.website) + ? fp.description.website + : "", + securityContact: fp.description.security_contact, + details: fp.description.details, + }, + state: fp.state, + commission: fp.commission, + btcPk: fp.btc_pk, + activeTVLSat: fp.active_tvl, + totalTVLSat: fp.total_tvl, + activeDelegations: fp.active_delegations, + totalDelegations: fp.total_delegations, + }), + ); + + const pagination: Pagination = { + next_key: finalityProvidersAPIResponse.pagination.next_key, + }; + + return { finalityProviders, pagination }; +}; diff --git a/src/ui/common/api/getFinalityProvidersV2.ts b/src/ui/common/api/getFinalityProvidersV2.ts new file mode 100644 index 000000000..3788e6a44 --- /dev/null +++ b/src/ui/common/api/getFinalityProvidersV2.ts @@ -0,0 +1,110 @@ +import { isValidUrl } from "@/ui/common/utils/url"; + +import { Pagination } from "../types/api"; +import { + FinalityProvider, + FinalityProviderState, +} from "../types/finalityProviders"; + +import { apiWrapper } from "./apiWrapper"; + +export interface PaginatedFinalityProviders { + finalityProviders: FinalityProvider[]; + pagination: Pagination; +} + +interface FinalityProvidersAPIResponse { + data: FinalityProviderAPI[]; + pagination: Pagination; +} + +interface FinalityProviderAPI { + description: DescriptionAPI; + state: FinalityProviderState; + commission: string; + btc_pk: string; + active_tvl: number; + total_tvl: number; + active_delegations: number; + total_delegations: number; + logo_url?: string; + bsn_id?: string; + type: string; +} + +interface DescriptionAPI { + moniker: string; + identity: string; + website: string; + security_contact: string; + details: string; +} + +export const getFinalityProvidersV2 = async ({ + key, + pk, + sortBy, + order, + name, + bsnId, +}: { + key: string; + name?: string; + sortBy?: string; + order?: "asc" | "desc"; + pk?: string; + bsnId?: string; +}): Promise => { + const params = { + pagination_key: key, + finality_provider_pk: pk, + sort_by: sortBy, + order, + name, + bsn_id: bsnId, + }; + + const response = await apiWrapper( + "GET", + "/v2/finality-providers", + "Error getting finality providers", + { query: params }, + ); + + const finalityProvidersAPIResponse: FinalityProvidersAPIResponse = + response.data; + const finalityProvidersAPI: FinalityProviderAPI[] = + finalityProvidersAPIResponse.data; + + const finalityProviders = finalityProvidersAPI.map( + (fp: FinalityProviderAPI, i): FinalityProvider => ({ + description: { + moniker: fp.description.moniker, + identity: fp.description.identity, + website: isValidUrl(fp.description.website) + ? fp.description.website + : "", + securityContact: fp.description.security_contact, + details: fp.description.details, + }, + id: fp.btc_pk, + rank: i + 1, + state: fp.state, + commission: fp.commission, + btcPk: fp.btc_pk, + activeTVLSat: fp.active_tvl, + totalTVLSat: fp.total_tvl, + activeDelegations: fp.active_delegations, + totalDelegations: fp.total_delegations, + logo_url: fp.logo_url, + bsnId: fp.bsn_id, + chain: fp.type, + }), + ); + + const pagination: Pagination = { + next_key: finalityProvidersAPIResponse.pagination.next_key, + }; + + return { finalityProviders, pagination }; +}; diff --git a/src/ui/common/api/getNetworkInfo.ts b/src/ui/common/api/getNetworkInfo.ts new file mode 100644 index 000000000..cc3c895bd --- /dev/null +++ b/src/ui/common/api/getNetworkInfo.ts @@ -0,0 +1,165 @@ +import { getPublicKeyNoCoord } from "@babylonlabs-io/btc-staking-ts"; + +import { ClientError, ERROR_CODES } from "@/ui/common/errors"; + +import { NetworkInfo } from "../types/networkInfo"; + +import { apiWrapper } from "./apiWrapper"; + +interface NetworkInfoDataResponse { + data: NetworkInfoAPI; +} + +export interface BtcCheckpointParams { + version: number; + btc_confirmation_depth: number; +} + +interface StakingStatus { + is_staking_open: boolean; +} + +interface NetworkInfoAPI { + staking_status: StakingStatus; + params: { + bbn: BbnParams[]; + btc: BtcCheckpointParams[]; + max_bsn_fp_providers?: number; + }; +} + +export interface BbnParams { + version: number; + covenant_pks: string[]; + covenant_quorum: number; + min_staking_value_sat: number; + max_staking_value_sat: number; + min_staking_time_blocks: number; + max_staking_time_blocks: number; + slashing_pk_script: string; + min_slashing_tx_fee_sat: number; + slashing_rate: string; + unbonding_time_blocks: number; + unbonding_fee_sat: number; + min_commission_rate: string; + max_active_finality_providers: number; + delegation_creation_base_gas_fee: number; + btc_activation_height: number; + allow_list_expiration_height: number; + btcActivationHeight?: number; +} + +export const getNetworkInfo = async (): Promise => { + const response = await apiWrapper( + "GET", + "/v2/network-info", + "Error getting network info", + ); + const { data } = response; + const { params, staking_status } = data.data; + + const stakingVersions = (params.bbn || []) + .sort((a, b) => a.version - b.version) + .map((v) => ({ + version: v.version, + covenantNoCoordPks: v.covenant_pks.map((pk) => + String(getPublicKeyNoCoord(pk)), + ), + covenantQuorum: v.covenant_quorum, + minStakingValueSat: v.min_staking_value_sat, + maxStakingValueSat: v.max_staking_value_sat, + minStakingTimeBlocks: v.min_staking_time_blocks, + maxStakingTimeBlocks: v.max_staking_time_blocks, + unbondingTime: v.unbonding_time_blocks, + unbondingFeeSat: v.unbonding_fee_sat, + minCommissionRate: v.min_commission_rate, + maxActiveFinalityProviders: v.max_active_finality_providers, + delegationCreationBaseGasFee: v.delegation_creation_base_gas_fee, + slashing: { + slashingPkScriptHex: v.slashing_pk_script, + slashingRate: parseFloat(v.slashing_rate), + minSlashingTxFeeSat: v.min_slashing_tx_fee_sat, + }, + maxStakingAmountSat: v.max_staking_value_sat, + minStakingAmountSat: v.min_staking_value_sat, + btcActivationHeight: v.btc_activation_height, + allowListExpirationHeight: v.allow_list_expiration_height, + })); + + // Verify that version numbers and BTC activation heights are consistently ordered + const sortedByVersion = stakingVersions.toSorted( + (a, b) => b.version - a.version, + ); + const sortedByHeight = stakingVersions.toSorted( + (a, b) => b.btcActivationHeight - a.btcActivationHeight, + ); + + // Verify both sorts produce same order + const areEqual = sortedByVersion.every( + (param, index) => param === sortedByHeight[index], + ); + if (!areEqual) { + const clientError = new ClientError( + ERROR_CODES.EXTERNAL_SERVICE_UNAVAILABLE, + "Version numbers and BTC activation heights are not consistently ordered", + ); + throw clientError; + } + + const latestStakingParam = stakingVersions.reduce((prev, current) => + current.version > prev.version ? current : prev, + ); + + // Map the BTC checkpoint params to the expected format + const epochCheckVersions = (params.btc || []) + .sort((a, b) => a.version - b.version) + .map((v) => ({ + version: v.version, + btcConfirmationDepth: v.btc_confirmation_depth, + })); + + // Get the latest epoch check param + const latestEpochCheckParam = epochCheckVersions.reduce((prev, current) => + current.version > prev.version ? current : prev, + ); + + const genesisStakingParam = stakingVersions.find((v) => v.version === 0); + if (!genesisStakingParam) { + const clientError = new ClientError( + ERROR_CODES.EXTERNAL_SERVICE_UNAVAILABLE, + "Genesis staking params not found", + ); + throw clientError; + } + + // Find the genesis epoch check param (version 0) + const genesisEpochCheckParam = epochCheckVersions.find( + (v) => v.version === 0, + ); + if (!genesisEpochCheckParam) { + const clientError = new ClientError( + ERROR_CODES.EXTERNAL_SERVICE_UNAVAILABLE, + "Genesis epoch check params not found", + ); + throw clientError; + } + + return { + stakingStatus: { + isStakingOpen: staking_status.is_staking_open, + }, + params: { + bbnStakingParams: { + latestParam: latestStakingParam, + versions: stakingVersions, + genesisParam: genesisStakingParam, + }, + btcEpochCheckParams: { + latestParam: latestEpochCheckParam, + versions: epochCheckVersions, + genesisParam: genesisEpochCheckParam, + }, + maxBsnFpProviders: params.max_bsn_fp_providers, + }, + }; +}; diff --git a/src/ui/common/api/getStats.ts b/src/ui/common/api/getStats.ts new file mode 100644 index 000000000..feeb947df --- /dev/null +++ b/src/ui/common/api/getStats.ts @@ -0,0 +1,35 @@ +import { StakingStats } from "../types/stakingStats"; + +import { apiWrapper } from "./apiWrapper"; + +interface StatsAPIResponse { + data: StatsAPI; +} + +interface StatsAPI { + active_tvl: number; + total_tvl: number; + active_delegations: number; + total_delegations: number; + total_stakers: number; + unconfirmed_tvl: number; +} + +export const getStats = async (): Promise => { + const response = await apiWrapper( + "GET", + "/v1/stats", + "Error getting stats", + ); + const statsAPIResponse: StatsAPIResponse = response.data; + const statsAPI: StatsAPI = statsAPIResponse.data; + + return { + activeTVLSat: statsAPI.active_tvl, + totalTVLSat: statsAPI.total_tvl, + activeDelegations: statsAPI.active_delegations, + totalDelegations: statsAPI.total_delegations, + totalStakers: statsAPI.total_stakers, + unconfirmedTVLSat: statsAPI.unconfirmed_tvl, + }; +}; diff --git a/src/ui/common/api/getSystemStats.ts b/src/ui/common/api/getSystemStats.ts new file mode 100644 index 000000000..0dfbcfe96 --- /dev/null +++ b/src/ui/common/api/getSystemStats.ts @@ -0,0 +1,43 @@ +import { ClientError } from "@/ui/common/errors"; +import { ERROR_CODES } from "@/ui/common/errors/codes"; + +import { apiWrapper } from "./apiWrapper"; + +export interface SystemStats { + active_tvl: number; + total_tvl: number; + active_delegations: number; + total_delegations: number; + active_stakers: number; + total_stakers: number; + active_finality_providers: number; + total_finality_providers: number; + total_active_tvl: number; + btc_staking_apr: number; +} + +interface SystemStatsAPIResponse { + data: SystemStats; +} + +export const getSystemStats = async (): Promise => { + try { + const response = await apiWrapper( + "GET", + "/v2/stats", + "Error getting system stats", + ); + + const systemStatsAPIResponse: SystemStatsAPIResponse = response.data; + + return systemStatsAPIResponse.data; + } catch (error) { + throw new ClientError( + ERROR_CODES.EXTERNAL_SERVICE_UNAVAILABLE, + "Error getting system stats", + { + cause: error, + }, + ); + } +}; diff --git a/src/ui/common/api/getUnbondingEligibility.ts b/src/ui/common/api/getUnbondingEligibility.ts new file mode 100644 index 000000000..a4e55aa4b --- /dev/null +++ b/src/ui/common/api/getUnbondingEligibility.ts @@ -0,0 +1,17 @@ +import { apiWrapper } from "./apiWrapper"; + +export const getUnbondingEligibility = async (txID: string) => { + const params = { + staking_tx_hash_hex: txID, + }; + + const response = await apiWrapper( + "GET", + "/v1/unbonding/eligibility", + "Error checking unbonding eligibility", + { query: params }, + ); + + // If the response status is 200, the unbonding is eligible + return response.status === 200; +}; diff --git a/src/ui/common/api/healthCheckClient.ts b/src/ui/common/api/healthCheckClient.ts new file mode 100644 index 000000000..95bd9b156 --- /dev/null +++ b/src/ui/common/api/healthCheckClient.ts @@ -0,0 +1,15 @@ +import { apiWrapper } from "./apiWrapper"; + +type HealthCheckResponse = { + data: string; +}; + +export const fetchHealthCheck = async (): Promise => { + const response = await apiWrapper( + "GET", + "/healthcheck", + "Error getting healthcheck status", + ); + + return response.data; +}; diff --git a/src/ui/common/api/httpStatusCodes.ts b/src/ui/common/api/httpStatusCodes.ts new file mode 100644 index 000000000..5a60f395a --- /dev/null +++ b/src/ui/common/api/httpStatusCodes.ts @@ -0,0 +1,69 @@ +/** + * Standard HTTP status codes used throughout the application + * Replaces the Axios HttpStatusCode enum + */ +export enum HttpStatusCode { + Continue = 100, + SwitchingProtocols = 101, + Processing = 102, + EarlyHints = 103, + Ok = 200, + Created = 201, + Accepted = 202, + NonAuthoritativeInformation = 203, + NoContent = 204, + ResetContent = 205, + PartialContent = 206, + MultiStatus = 207, + AlreadyReported = 208, + ImUsed = 226, + MultipleChoices = 300, + MovedPermanently = 301, + Found = 302, + SeeOther = 303, + NotModified = 304, + UseProxy = 305, + Unused = 306, + TemporaryRedirect = 307, + PermanentRedirect = 308, + BadRequest = 400, + Unauthorized = 401, + PaymentRequired = 402, + Forbidden = 403, + NotFound = 404, + MethodNotAllowed = 405, + NotAcceptable = 406, + ProxyAuthenticationRequired = 407, + RequestTimeout = 408, + Conflict = 409, + Gone = 410, + LengthRequired = 411, + PreconditionFailed = 412, + PayloadTooLarge = 413, + UriTooLong = 414, + UnsupportedMediaType = 415, + RangeNotSatisfiable = 416, + ExpectationFailed = 417, + ImATeapot = 418, + MisdirectedRequest = 421, + UnprocessableEntity = 422, + Locked = 423, + FailedDependency = 424, + TooEarly = 425, + UpgradeRequired = 426, + PreconditionRequired = 428, + TooManyRequests = 429, + RequestHeaderFieldsTooLarge = 431, + UnavailableForLegalReasons = 451, + InternalServerError = 500, + NotImplemented = 501, + BadGateway = 502, + ServiceUnavailable = 503, + GatewayTimeout = 504, + HttpVersionNotSupported = 505, + VariantAlsoNegotiates = 506, + InsufficientStorage = 507, + LoopDetected = 508, + NotExtended = 510, + NetworkAuthenticationRequired = 511, +} diff --git a/src/ui/common/api/logTermAcceptance.ts b/src/ui/common/api/logTermAcceptance.ts new file mode 100644 index 000000000..06ee58fc6 --- /dev/null +++ b/src/ui/common/api/logTermAcceptance.ts @@ -0,0 +1,32 @@ +import { encode } from "url-safe-base64"; + +import { getPublicKeyNoCoord } from "@/ui/common/utils/wallet"; + +import { apiWrapper } from "./apiWrapper"; + +interface TermsPayload { + address: string; + public_key: string; +} + +// postLogTermsAcceptance is used to log the terms acceptance for a given address and public key +export const logTermsAcceptance = async ({ + address, + public_key, +}: TermsPayload) => { + try { + const payload: TermsPayload = { + address: address, + public_key: encode(getPublicKeyNoCoord(public_key).toString("hex")), + }; + + await apiWrapper( + "POST", + "/log-terms-acceptance", + "Error submitting terms acceptance request", + { body: payload }, + ); + } catch { + return; + } +}; diff --git a/src/ui/common/api/postFilterOrdinals.ts b/src/ui/common/api/postFilterOrdinals.ts new file mode 100644 index 000000000..ee15f6ea1 --- /dev/null +++ b/src/ui/common/api/postFilterOrdinals.ts @@ -0,0 +1,46 @@ +import { UTXO } from "@babylonlabs-io/btc-staking-ts"; + +import { chunkArray } from "@/ui/common/utils/chunkArray"; + +import { apiWrapper } from "./apiWrapper"; + +export interface UtxoInfo { + txid: string; + vout: number; + inscription: boolean; +} + +interface VerifyUtxosResponse { + data: UtxoInfo[]; +} + +const TIMEOUT_DURATION = 2000; // 2 seconds +const BATCH_SIZE = 30; + +export const postVerifyUtxoOrdinals = async ( + utxos: UTXO[], + address: string, +): Promise => { + const utxoChunks = chunkArray(utxos, BATCH_SIZE); + const responses = await Promise.all( + utxoChunks.map((chunk) => + apiWrapper( + "POST", + "/v1/ordinals/verify-utxos", + "Error verifying utxos ordinals", + { + body: { + address, + utxos: chunk.map((utxo) => ({ + txid: utxo.txid, + vout: utxo.vout, + })), + }, + }, + TIMEOUT_DURATION, + ), + ), + ); + + return responses.flatMap((response) => response.data.data); +}; diff --git a/src/ui/common/api/postUnbonding.ts b/src/ui/common/api/postUnbonding.ts new file mode 100644 index 000000000..a1a6d7cfa --- /dev/null +++ b/src/ui/common/api/postUnbonding.ts @@ -0,0 +1,32 @@ +import { apiWrapper } from "./apiWrapper"; + +interface UnbondingPayload { + staker_signed_signature_hex: string; + staking_tx_hash_hex: string; + unbonding_tx_hash_hex: string; + unbonding_tx_hex: string; +} + +export const postUnbonding = async ( + stakerSignedSignatureHex: string, + stakingTxHashHex: string, + unbondingTxHashHex: string, + unbondingTxHex: string, +) => { + const payload: UnbondingPayload = { + staker_signed_signature_hex: stakerSignedSignatureHex, + staking_tx_hash_hex: stakingTxHashHex, + unbonding_tx_hash_hex: unbondingTxHashHex, + unbonding_tx_hex: unbondingTxHex, + }; + + const response = await apiWrapper( + "POST", + "/v1/unbonding", + "Error submitting unbonding request", + { body: payload }, + ); + + // If the response status is 202, the request was accepted + return response.status === 202; +}; diff --git a/src/ui/common/api/verifyBTCAddress.ts b/src/ui/common/api/verifyBTCAddress.ts new file mode 100644 index 000000000..00ba7c2d7 --- /dev/null +++ b/src/ui/common/api/verifyBTCAddress.ts @@ -0,0 +1,27 @@ +import { apiWrapper } from "./apiWrapper"; + +const ALLOWED_STATUSES = ["medium", "low"]; + +export const verifyBTCAddress = async (address: string) => { + interface AddressScreeningResponse { + data: { + btc_address?: { + risk: string; + }; + }; + } + + try { + const { data: response } = await apiWrapper( + "GET", + "/address/screening", + "Error performing BTC address screening", + { query: { btc_address: address } }, + ); + + const risk = response.data?.btc_address?.risk; + return risk ? ALLOWED_STATUSES.includes(risk.toLowerCase()) : false; + } catch { + return false; + } +}; diff --git a/src/ui/common/assets/404-error-character.svg b/src/ui/common/assets/404-error-character.svg new file mode 100644 index 000000000..ce8eb154e --- /dev/null +++ b/src/ui/common/assets/404-error-character.svg @@ -0,0 +1,438 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ui/common/assets/babylon.svg b/src/ui/common/assets/babylon.svg new file mode 100644 index 000000000..bbfb55c9e --- /dev/null +++ b/src/ui/common/assets/babylon.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/ui/common/assets/bbn.svg b/src/ui/common/assets/bbn.svg new file mode 100644 index 000000000..cbd7e0e87 --- /dev/null +++ b/src/ui/common/assets/bbn.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/ui/common/assets/bg-pattern.svg b/src/ui/common/assets/bg-pattern.svg new file mode 100644 index 000000000..0ed801711 --- /dev/null +++ b/src/ui/common/assets/bg-pattern.svg @@ -0,0 +1,12 @@ + + + mainnet-bg-pattern + + + + + + diff --git a/src/ui/common/assets/bitcoin-block.svg b/src/ui/common/assets/bitcoin-block.svg new file mode 100644 index 000000000..0c0e3991b --- /dev/null +++ b/src/ui/common/assets/bitcoin-block.svg @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ui/common/assets/bitcoin.png b/src/ui/common/assets/bitcoin.png new file mode 100644 index 000000000..865ffda19 Binary files /dev/null and b/src/ui/common/assets/bitcoin.png differ diff --git a/src/ui/common/assets/blue-check.svg b/src/ui/common/assets/blue-check.svg new file mode 100644 index 000000000..0b3e283f5 --- /dev/null +++ b/src/ui/common/assets/blue-check.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/ui/common/assets/cancel-circle.svg b/src/ui/common/assets/cancel-circle.svg new file mode 100644 index 000000000..43431f631 --- /dev/null +++ b/src/ui/common/assets/cancel-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/common/assets/chains/babylon-genesis.png b/src/ui/common/assets/chains/babylon-genesis.png new file mode 100644 index 000000000..9417c41cc Binary files /dev/null and b/src/ui/common/assets/chains/babylon-genesis.png differ diff --git a/src/ui/common/assets/chains/cosmos.png b/src/ui/common/assets/chains/cosmos.png new file mode 100644 index 000000000..e42d23571 Binary files /dev/null and b/src/ui/common/assets/chains/cosmos.png differ diff --git a/src/ui/common/assets/chains/ethereum.png b/src/ui/common/assets/chains/ethereum.png new file mode 100644 index 000000000..272fdb7de Binary files /dev/null and b/src/ui/common/assets/chains/ethereum.png differ diff --git a/src/ui/common/assets/chains/placeholder.svg b/src/ui/common/assets/chains/placeholder.svg new file mode 100644 index 000000000..9481665aa --- /dev/null +++ b/src/ui/common/assets/chains/placeholder.svg @@ -0,0 +1,6 @@ + + + + ? + \ No newline at end of file diff --git a/src/ui/common/assets/chains/sui.png b/src/ui/common/assets/chains/sui.png new file mode 100644 index 000000000..ede4202f4 Binary files /dev/null and b/src/ui/common/assets/chains/sui.png differ diff --git a/src/ui/common/assets/check.svg b/src/ui/common/assets/check.svg new file mode 100644 index 000000000..88cf8e477 --- /dev/null +++ b/src/ui/common/assets/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/common/assets/cog.svg b/src/ui/common/assets/cog.svg new file mode 100644 index 000000000..81c9f764a --- /dev/null +++ b/src/ui/common/assets/cog.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/src/ui/common/assets/edit.svg b/src/ui/common/assets/edit.svg new file mode 100644 index 000000000..700a47613 --- /dev/null +++ b/src/ui/common/assets/edit.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/common/assets/generic-error-character.svg b/src/ui/common/assets/generic-error-character.svg new file mode 100644 index 000000000..302612289 --- /dev/null +++ b/src/ui/common/assets/generic-error-character.svg @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ui/common/assets/gold-check.svg b/src/ui/common/assets/gold-check.svg new file mode 100644 index 000000000..eac74b041 --- /dev/null +++ b/src/ui/common/assets/gold-check.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/ui/common/assets/icon-black.svg b/src/ui/common/assets/icon-black.svg new file mode 100644 index 000000000..e6e7889b4 --- /dev/null +++ b/src/ui/common/assets/icon-black.svg @@ -0,0 +1,10 @@ + + + logo-full-black + + + + + + + \ No newline at end of file diff --git a/src/ui/common/assets/icon-white.svg b/src/ui/common/assets/icon-white.svg new file mode 100644 index 000000000..6682bebe4 --- /dev/null +++ b/src/ui/common/assets/icon-white.svg @@ -0,0 +1,10 @@ + + + logo-full-black + + + + + + + \ No newline at end of file diff --git a/src/ui/common/assets/signet_bitcoin.svg b/src/ui/common/assets/signet_bitcoin.svg new file mode 100644 index 000000000..816ad9a05 --- /dev/null +++ b/src/ui/common/assets/signet_bitcoin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/common/assets/testnet-bg-pattern.svg b/src/ui/common/assets/testnet-bg-pattern.svg new file mode 100644 index 000000000..f6ec6b949 --- /dev/null +++ b/src/ui/common/assets/testnet-bg-pattern.svg @@ -0,0 +1,12 @@ + + + testnet-bg-pattern + + + + + + diff --git a/src/ui/common/assets/warning-octagon.svg b/src/ui/common/assets/warning-octagon.svg new file mode 100644 index 000000000..bed1691a0 --- /dev/null +++ b/src/ui/common/assets/warning-octagon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/common/assets/warning-triangle.svg b/src/ui/common/assets/warning-triangle.svg new file mode 100644 index 000000000..787b60c9f --- /dev/null +++ b/src/ui/common/assets/warning-triangle.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/common/components/Activity/Activity.tsx b/src/ui/common/components/Activity/Activity.tsx new file mode 100644 index 000000000..cea784217 --- /dev/null +++ b/src/ui/common/components/Activity/Activity.tsx @@ -0,0 +1,16 @@ +import { ActivityList } from "@/ui/common/components/Activity/components/ActivityList"; +import { AuthGuard } from "@/ui/common/components/Common/AuthGuard"; +import { Section } from "@/ui/common/components/Section/Section"; + +import { Delegations } from "../Delegations/Delegations"; + +export function Activity() { + return ( + +
+ + +
+
+ ); +} diff --git a/src/ui/common/components/Activity/components/ActivityList.tsx b/src/ui/common/components/Activity/components/ActivityList.tsx new file mode 100644 index 000000000..fd4ecd40f --- /dev/null +++ b/src/ui/common/components/Activity/components/ActivityList.tsx @@ -0,0 +1,344 @@ +import { useMemo } from "react"; + +import bitcoin from "@/ui/common/assets/bitcoin.png"; +import { Status } from "@/ui/common/components/Delegations/DelegationList/components/Status"; +import { Hash } from "@/ui/common/components/Hash/Hash"; +import { FinalityProviderLogo } from "@/ui/common/components/Staking/FinalityProviders/FinalityProviderLogo"; +import { getNetworkConfig } from "@/ui/common/config/network"; +import { getNetworkConfigBTC } from "@/ui/common/config/network/btc"; +import { + DELEGATION_ACTIONS as ACTIONS, + chainLogos, +} from "@/ui/common/constants"; +import { + ActionType, + useDelegationService, +} from "@/ui/common/hooks/services/useDelegationService"; +import { useStakingManagerService } from "@/ui/common/hooks/services/useStakingManagerService"; +import { + DelegationV2StakingState, + DelegationWithFP, +} from "@/ui/common/types/delegationsV2"; +import { FinalityProviderState } from "@/ui/common/types/finalityProviders"; +import { satoshiToBtc } from "@/ui/common/utils/btc"; +import { maxDecimals } from "@/ui/common/utils/maxDecimals"; + +import { + ActivityCard, + ActivityCardActionButton, + ActivityCardData, + ActivityCardDetailItem, + ActivityListItemData, +} from "../../ActivityCard/ActivityCard"; +import { DelegationModal } from "../../Delegations/DelegationList/components/DelegationModal"; + +const networkConfig = getNetworkConfig(); +const { coinName } = getNetworkConfigBTC(); + +const getActionButton = ( + delegation: DelegationWithFP, + onAction: (action: ActionType, delegation: DelegationWithFP) => void, + isStakingManagerReady: boolean, +): ActivityCardActionButton | undefined => { + const { state, fp } = delegation; + + // Define action mapping + const actionMap: Record< + string, + Record + > = { + [FinalityProviderState.ACTIVE]: { + [DelegationV2StakingState.VERIFIED]: { + action: ACTIONS.STAKE, + title: "Stake", + }, + [DelegationV2StakingState.ACTIVE]: { + action: ACTIONS.UNBOND, + title: "Unbond", + }, + [DelegationV2StakingState.EARLY_UNBONDING_WITHDRAWABLE]: { + action: ACTIONS.WITHDRAW_ON_EARLY_UNBONDING, + title: "Withdraw", + }, + [DelegationV2StakingState.TIMELOCK_WITHDRAWABLE]: { + action: ACTIONS.WITHDRAW_ON_TIMELOCK, + title: "Withdraw", + }, + [DelegationV2StakingState.TIMELOCK_SLASHING_WITHDRAWABLE]: { + action: ACTIONS.WITHDRAW_ON_TIMELOCK_SLASHING, + title: "Withdraw", + }, + [DelegationV2StakingState.EARLY_UNBONDING_SLASHING_WITHDRAWABLE]: { + action: ACTIONS.WITHDRAW_ON_EARLY_UNBONDING_SLASHING, + title: "Withdraw", + }, + }, + [FinalityProviderState.INACTIVE]: { + [DelegationV2StakingState.VERIFIED]: { + action: ACTIONS.STAKE, + title: "Stake", + }, + [DelegationV2StakingState.ACTIVE]: { + action: ACTIONS.UNBOND, + title: "Unbond", + }, + [DelegationV2StakingState.EARLY_UNBONDING_WITHDRAWABLE]: { + action: ACTIONS.WITHDRAW_ON_EARLY_UNBONDING, + title: "Withdraw", + }, + [DelegationV2StakingState.TIMELOCK_WITHDRAWABLE]: { + action: ACTIONS.WITHDRAW_ON_TIMELOCK, + title: "Withdraw", + }, + [DelegationV2StakingState.TIMELOCK_SLASHING_WITHDRAWABLE]: { + action: ACTIONS.WITHDRAW_ON_TIMELOCK_SLASHING, + title: "Withdraw", + }, + [DelegationV2StakingState.EARLY_UNBONDING_SLASHING_WITHDRAWABLE]: { + action: ACTIONS.WITHDRAW_ON_EARLY_UNBONDING_SLASHING, + title: "Withdraw", + }, + }, + [FinalityProviderState.JAILED]: { + [DelegationV2StakingState.VERIFIED]: { + action: ACTIONS.STAKE, + title: "Stake", + }, + [DelegationV2StakingState.ACTIVE]: { + action: ACTIONS.UNBOND, + title: "Unbond", + }, + [DelegationV2StakingState.EARLY_UNBONDING_WITHDRAWABLE]: { + action: ACTIONS.WITHDRAW_ON_EARLY_UNBONDING, + title: "Withdraw", + }, + [DelegationV2StakingState.TIMELOCK_WITHDRAWABLE]: { + action: ACTIONS.WITHDRAW_ON_TIMELOCK, + title: "Withdraw", + }, + [DelegationV2StakingState.TIMELOCK_SLASHING_WITHDRAWABLE]: { + action: ACTIONS.WITHDRAW_ON_TIMELOCK_SLASHING, + title: "Withdraw", + }, + [DelegationV2StakingState.EARLY_UNBONDING_SLASHING_WITHDRAWABLE]: { + action: ACTIONS.WITHDRAW_ON_EARLY_UNBONDING_SLASHING, + title: "Withdraw", + }, + }, + [FinalityProviderState.SLASHED]: { + [DelegationV2StakingState.ACTIVE]: { + action: ACTIONS.UNBOND, + title: "Unbond", + }, + [DelegationV2StakingState.EARLY_UNBONDING_WITHDRAWABLE]: { + action: ACTIONS.WITHDRAW_ON_EARLY_UNBONDING, + title: "Withdraw", + }, + [DelegationV2StakingState.TIMELOCK_WITHDRAWABLE]: { + action: ACTIONS.WITHDRAW_ON_TIMELOCK, + title: "Withdraw", + }, + [DelegationV2StakingState.TIMELOCK_SLASHING_WITHDRAWABLE]: { + action: ACTIONS.WITHDRAW_ON_TIMELOCK_SLASHING, + title: "Withdraw", + }, + [DelegationV2StakingState.EARLY_UNBONDING_SLASHING_WITHDRAWABLE]: { + action: ACTIONS.WITHDRAW_ON_EARLY_UNBONDING_SLASHING, + title: "Withdraw", + }, + }, + }; + + const actionConfig = actionMap[fp?.state]?.[state]; + if (!actionConfig) return undefined; + + const isUnbondDisabled = + state === DelegationV2StakingState.ACTIVE && !isStakingManagerReady; + + return { + label: actionConfig.title, + onClick: () => onAction(actionConfig.action, delegation), + variant: "contained", + size: "medium", + className: isUnbondDisabled ? "opacity-50" : "", + }; +}; + +const transformToActivityCard = ( + delegation: DelegationWithFP, + onAction: (action: ActionType, delegation: DelegationWithFP) => void, + isStakingManagerReady: boolean, +): ActivityCardData => { + const details: ActivityCardDetailItem[] = [ + { + label: "Status", + value: , + }, + { + label: "Inception", + value: delegation.bbnInceptionTime + ? new Date(delegation.bbnInceptionTime).toLocaleDateString("en-GB", { + year: "2-digit", + month: "2-digit", + day: "2-digit", + }) + : "N/A", + }, + { + label: "Tx Hash", + value: ( + + ), + }, + ]; + + const listItems: { + label: string; + items: ActivityListItemData[]; + }[] = []; + + if (delegation.fp?.bsnId) { + const bsnLogo = chainLogos[delegation.fp.bsnId] || chainLogos.placeholder; + listItems.push({ + label: "BSN", + items: [ + { + icon: bsnLogo, + iconAlt: delegation.fp.bsnId, + name: delegation.fp.bsnId, + id: delegation.fp.bsnId, + }, + ], + }); + } + + // Finality Provider Section + if (delegation.fp) { + listItems.push({ + label: "Finality Provider", + items: [ + { + icon: ( + + ), + iconAlt: delegation.fp.description?.moniker || "Finality Provider", + name: + delegation.fp.description?.moniker || + `Provider ${delegation.fp.rank}`, + id: delegation.fp.btcPk, + }, + ], + }); + } + + // Reward Section (placeholder for future implementation) + // listItems.push({ + // label: "Reward", + // items: [{ + // }] + // }); + + const primaryAction = getActionButton( + delegation, + onAction, + isStakingManagerReady, + ); + + return { + formattedAmount: `${maxDecimals(satoshiToBtc(delegation.stakingAmount), 8)} ${coinName}`, + icon: bitcoin, + iconAlt: "bitcoin", + details, + listItems: listItems.length > 0 ? listItems : undefined, + primaryAction, + }; +}; + +export function ActivityList() { + const { + processing, + confirmationModal, + delegations, + isLoading, + validations, + executeDelegationAction, + openConfirmationModal, + closeConfirmationModal, + } = useDelegationService(); + + const { isLoading: isStakingManagerLoading } = useStakingManagerService(); + const isStakingManagerReady = !isStakingManagerLoading; + + const activityList = useMemo(() => { + return delegations + .filter((delegation) => { + const { valid } = validations[delegation.stakingTxHashHex]; + return valid; + }) + .map((delegation) => + transformToActivityCard( + delegation, + openConfirmationModal, + isStakingManagerReady, + ), + ); + }, [delegations, validations, openConfirmationModal, isStakingManagerReady]); + + if (isLoading) { + return ( +
+
Loading delegations...
+
+ ); + } + + if (activityList.length === 0) { + return ( +
+
+ 👁️ +
+

+ No {networkConfig.bbn.networkFullName} Stakes +

+

No activity found.

+
+ ); + } + + return ( + <> +
+
+ {activityList.map((data, index) => ( + + ))} +
+
+ + + + ); +} diff --git a/src/ui/common/components/ActivityCard/ActivityCard.tsx b/src/ui/common/components/ActivityCard/ActivityCard.tsx new file mode 100644 index 000000000..49b8003fe --- /dev/null +++ b/src/ui/common/components/ActivityCard/ActivityCard.tsx @@ -0,0 +1,68 @@ +import { ActivityCardActionSection } from "./components/ActivityCardActionSection"; +import { ActivityCardAmountSection } from "./components/ActivityCardAmountSection"; +import { ActivityCardDetailsSection } from "./components/ActivityCardDetailsSection"; + +export interface ActivityCardDetailItem { + label: string; + value: string | React.ReactNode; +} + +export interface ActivityListItemData { + icon?: string | React.ReactNode; + iconAlt?: string; + name: string; + id?: string; +} + +export interface ActivityCardActionButton { + label: string; + onClick: () => void; + variant?: "contained" | "outlined"; + size?: "small" | "medium" | "large"; + className?: string; + fullWidth?: boolean; +} + +export interface ActivityCardData { + formattedAmount: string; + icon?: string | React.ReactNode; + iconAlt?: string; + details: ActivityCardDetailItem[]; + optionalDetails?: ActivityCardDetailItem[]; + listItems?: { + label: string; + items: ActivityListItemData[]; + }[]; + primaryAction?: ActivityCardActionButton; + secondaryActions?: ActivityCardActionButton[]; +} + +interface ActivityCardProps { + data: ActivityCardData; + className?: string; +} + +export function ActivityCard({ data, className }: ActivityCardProps) { + return ( +
+ + + + + {data.secondaryActions && data.secondaryActions.length > 0 && ( + + )} +
+ ); +} diff --git a/src/ui/common/components/ActivityCard/components/ActivityCardActionSection.tsx b/src/ui/common/components/ActivityCard/components/ActivityCardActionSection.tsx new file mode 100644 index 000000000..96421ae72 --- /dev/null +++ b/src/ui/common/components/ActivityCard/components/ActivityCardActionSection.tsx @@ -0,0 +1,27 @@ +import { Button } from "@babylonlabs-io/core-ui"; + +import { ActivityCardActionButton } from "../ActivityCard"; + +interface ActivityCardActionSectionProps { + actions: ActivityCardActionButton[]; +} + +export function ActivityCardActionSection({ + actions, +}: ActivityCardActionSectionProps) { + return ( +
+ {actions.map((action, index) => ( + + ))} +
+ ); +} diff --git a/src/ui/common/components/ActivityCard/components/ActivityCardAmountSection.tsx b/src/ui/common/components/ActivityCard/components/ActivityCardAmountSection.tsx new file mode 100644 index 000000000..dfea6a7ca --- /dev/null +++ b/src/ui/common/components/ActivityCard/components/ActivityCardAmountSection.tsx @@ -0,0 +1,48 @@ +import { Button } from "@babylonlabs-io/core-ui"; + +import { ActivityCardActionButton } from "../ActivityCard"; + +interface ActivityCardAmountSectionProps { + formattedAmount: string; + icon?: string | React.ReactNode; + iconAlt?: string; + primaryAction?: ActivityCardActionButton; +} + +export function ActivityCardAmountSection({ + formattedAmount, + icon, + iconAlt, + primaryAction, +}: ActivityCardAmountSectionProps) { + return ( +
+
+ {icon && + (typeof icon === "string" ? ( + {iconAlt + ) : ( + icon + ))} + + {formattedAmount} + +
+ + {primaryAction && ( + + )} +
+ ); +} diff --git a/src/ui/common/components/ActivityCard/components/ActivityCardDetailsSection.tsx b/src/ui/common/components/ActivityCard/components/ActivityCardDetailsSection.tsx new file mode 100644 index 000000000..9589fe60e --- /dev/null +++ b/src/ui/common/components/ActivityCard/components/ActivityCardDetailsSection.tsx @@ -0,0 +1,79 @@ +import { ActivityCardDetailItem, ActivityListItemData } from "../ActivityCard"; + +import { ActivityListItem } from "./ActivityListItem"; + +interface ActivityCardDetailsSectionProps { + details: ActivityCardDetailItem[]; + optionalDetails?: ActivityCardDetailItem[]; + listItems?: { + label: string; + items: ActivityListItemData[]; + }[]; +} + +interface DetailRowProps { + label: string; + value: string | React.ReactNode; +} + +function DetailRow({ label, value }: DetailRowProps) { + return ( +
+ + {label} + + + {value} + +
+ ); +} + +export function ActivityCardDetailsSection({ + details, + optionalDetails, + listItems, +}: ActivityCardDetailsSectionProps) { + const hasOptionalDetails = optionalDetails && optionalDetails.length > 0; + const hasListItems = listItems && listItems.length > 0; + + return ( +
+
+ {details.map((detail, index) => ( + + ))} +
+ + {hasListItems && ( +
+ {listItems.map((listSection, sectionIndex) => ( +
+
+ + {listSection.label} + +
+ {listSection.items.map((item, itemIndex) => ( + + ))} +
+
+
+ ))} +
+ )} + + {hasOptionalDetails && ( +
+ {optionalDetails.map((detail, index) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/ui/common/components/ActivityCard/components/ActivityListItem.tsx b/src/ui/common/components/ActivityCard/components/ActivityListItem.tsx new file mode 100644 index 000000000..e49f20f56 --- /dev/null +++ b/src/ui/common/components/ActivityCard/components/ActivityListItem.tsx @@ -0,0 +1,33 @@ +export interface ActivityListItemData { + icon?: string | React.ReactNode; + iconAlt?: string; + name: string; + id?: string; +} + +interface ActivityListItemProps { + item: ActivityListItemData; + className?: string; +} + +export function ActivityListItem({ item, className }: ActivityListItemProps) { + return ( +
+ + {item.name} + + {item.icon && + (typeof item.icon === "string" ? ( + {item.iconAlt + ) : ( +
+ {item.icon} +
+ ))} +
+ ); +} diff --git a/src/ui/common/components/ActivityCard/index.tsx b/src/ui/common/components/ActivityCard/index.tsx new file mode 100644 index 000000000..bc248d5e1 --- /dev/null +++ b/src/ui/common/components/ActivityCard/index.tsx @@ -0,0 +1,14 @@ +export { + ActivityCard, + type ActivityCardActionButton, + type ActivityCardData, + type ActivityCardDetailItem, + type ActivityListItemData, +} from "./ActivityCard"; + +export { ActivityList } from "../Activity/components/ActivityList"; + +export { ActivityCardActionSection } from "./components/ActivityCardActionSection"; +export { ActivityCardAmountSection } from "./components/ActivityCardAmountSection"; +export { ActivityCardDetailsSection } from "./components/ActivityCardDetailsSection"; +export { ActivityListItem } from "./components/ActivityListItem"; diff --git a/src/ui/common/components/Alerts/Alert.tsx b/src/ui/common/components/Alerts/Alert.tsx new file mode 100644 index 000000000..13a23b132 --- /dev/null +++ b/src/ui/common/components/Alerts/Alert.tsx @@ -0,0 +1,65 @@ +import { Text } from "@babylonlabs-io/core-ui"; +import { cloneElement, isValidElement, type ReactElement } from "react"; +import { twMerge } from "tailwind-merge"; + +interface AlertProps { + children: React.ReactNode; + title: React.ReactNode; + icon: React.ReactNode; + className?: string; + variant?: "info" | "warning" | "error"; +} + +const variantStyles = { + info: { + bg: "bg-secondary-contrast", + text: "text-primary-light", + }, + warning: { + bg: "bg-orange-100 dark:bg-orange-900/50", + text: "text-orange-700 dark:text-orange-300", + }, + error: { + bg: "bg-red-100 dark:bg-red-900/50", + text: "text-red-700 dark:text-red-300", + }, +}; + +export const Alert = ({ + children, + icon, + title, + className, + variant = "info", +}: AlertProps) => { + const baseClasses = "flex flex-row items-start py-2 px-4 rounded-md"; + const styles = variantStyles[variant]; + + let styledIcon = icon; + if (icon && isValidElement(icon)) { + styledIcon = cloneElement(icon as ReactElement, { + className: twMerge( + (icon as ReactElement).props.className, + "w-5 h-5", + styles.text, + ), + }); + } + + return ( +
+
{styledIcon}
+
+
+ {title && {title}} + {children} +
+
+
+ ); +}; diff --git a/src/ui/common/components/Banner/Banner.tsx b/src/ui/common/components/Banner/Banner.tsx new file mode 100644 index 000000000..f2a1c14f6 --- /dev/null +++ b/src/ui/common/components/Banner/Banner.tsx @@ -0,0 +1,25 @@ +import { Text } from "@babylonlabs-io/core-ui"; +import { PiWarningOctagonFill } from "react-icons/pi"; + +import { shouldDisplayTestingMsg } from "@/ui/common/config"; + +export const Banner = () => { + if (!shouldDisplayTestingMsg()) { + return null; + } + + return ( +
+
+ + + This is a testing app +
+ The app may contain bugs. Use it after conducting your own research + and making an informed decision. Tokens are for testing only and do + not carry any monetary value and the testnet is not incentivized. +
+
+
+ ); +}; diff --git a/src/ui/common/components/Common/AuthGuard/index.tsx b/src/ui/common/components/Common/AuthGuard/index.tsx new file mode 100644 index 000000000..9099501a8 --- /dev/null +++ b/src/ui/common/components/Common/AuthGuard/index.tsx @@ -0,0 +1,30 @@ +import { useWalletConnect } from "@babylonlabs-io/wallet-connector"; +import { + useEffect, + useState, + type PropsWithChildren, + type ReactNode, +} from "react"; + +import { useHealthCheck } from "@/ui/common/hooks/useHealthCheck"; + +interface AuthGuardProps { + fallback?: ReactNode; +} + +export function AuthGuard({ + children, + fallback, +}: PropsWithChildren) { + const [displayComponent, setDisplayComponent] = useState(false); + + const { connected } = useWalletConnect(); + const { isGeoBlocked, isLoading } = useHealthCheck(); + const isConnected = connected && !isGeoBlocked && !isLoading; + + useEffect(() => { + setDisplayComponent(isConnected); + }, [isConnected]); + + return <>{displayComponent ? children : fallback}; +} diff --git a/src/ui/common/components/Common/Body/index.tsx b/src/ui/common/components/Common/Body/index.tsx new file mode 100644 index 000000000..f53b32b27 --- /dev/null +++ b/src/ui/common/components/Common/Body/index.tsx @@ -0,0 +1,13 @@ +import type { DetailedHTMLProps, HTMLAttributes } from "react"; +import { twJoin } from "tailwind-merge"; + +import { useAppState } from "@/ui/common/state"; + +export const Body = ({ + className, + ...props +}: DetailedHTMLProps, HTMLBodyElement>) => { + const { theme } = useAppState(); + + return ; +}; diff --git a/src/ui/common/components/Common/GridTable/components/TCell.tsx b/src/ui/common/components/Common/GridTable/components/TCell.tsx new file mode 100644 index 000000000..6e558b3a2 --- /dev/null +++ b/src/ui/common/components/Common/GridTable/components/TCell.tsx @@ -0,0 +1,26 @@ +import { MouseEventHandler, type ReactNode } from "react"; +import { twMerge } from "tailwind-merge"; + +interface TCellProps { + className?: string; + align?: "left" | "right" | "center"; + children?: ReactNode; + onClick?: MouseEventHandler; +} + +const ALIGN = { + left: "justify-start", + right: "justify-end", + center: "justify-center", +} as const; + +export function GridCell({ className, align, children, onClick }: TCellProps) { + return ( +
+ {children} +
+ ); +} diff --git a/src/ui/common/components/Common/GridTable/components/THead.tsx b/src/ui/common/components/Common/GridTable/components/THead.tsx new file mode 100644 index 000000000..31ea6e4a8 --- /dev/null +++ b/src/ui/common/components/Common/GridTable/components/THead.tsx @@ -0,0 +1,48 @@ +import { useCallback, type ReactNode } from "react"; +import { twMerge } from "tailwind-merge"; + +import type { SortColumn } from "../types"; + +interface THeadProps { + field: string; + title: ReactNode; + className?: string; + sortable?: boolean; + align?: "left" | "right" | "center"; + sortColumn?: SortColumn; + onSortChange: (sortColumn: SortColumn) => void; +} + +const SORT_DIRECTIONS = { + "": "ASC", + ASC: "DESC", + DESC: "", +} as const; + +export function GridHead({ + field, + title, + className, + align, + sortable = false, + sortColumn, + onSortChange, +}: THeadProps) { + const direction = SORT_DIRECTIONS[sortColumn?.direction || ""]; + + const handleColumnClick = useCallback(() => { + if (sortable) { + onSortChange({ field, direction }); + } + }, [field, direction, sortable, onSortChange]); + + return ( +

+ {title} +

+ ); +} diff --git a/src/ui/common/components/Common/GridTable/index.tsx b/src/ui/common/components/Common/GridTable/index.tsx new file mode 100644 index 000000000..b7454557c --- /dev/null +++ b/src/ui/common/components/Common/GridTable/index.tsx @@ -0,0 +1,129 @@ +import { Loader } from "@babylonlabs-io/core-ui"; +import { type ReactElement, useId } from "react"; +import InfiniteScroll from "react-infinite-scroll-component"; +import { twJoin, twMerge } from "tailwind-merge"; + +import { LoadingTableList } from "@/ui/common/components/Loading/Loading"; +import { StatusView } from "@/ui/common/components/Staking/FinalityProviders/FinalityProviderTableStatusView"; + +import { GridCell } from "./components/TCell"; +import { GridHead } from "./components/THead"; +import type { TableColumn, TableProps } from "./types"; +import { createColumnTemplate } from "./utils"; + +export function GridTable({ + loading = false, + isFetchingNextPage = false, + infiniteScroll = false, + classNames, + columns, + sortColumn, + fallback, + params = {} as P, + data = [], + getRowId, + onRowClick, + onCellClick, + onInfiniteScroll = () => null, + onSortColumn = () => null, +}: TableProps): ReactElement | null { + const id = useId(); + + function handleCellClick(row: R, col: TableColumn) { + return () => { + onRowClick?.(row); + onCellClick?.(row, col); + }; + } + + if (loading) { + return ( + } + title="Please wait..." + /> + ); + } + + if (!data?.length) { + return fallback as ReactElement | null; + } + + return ( +
+ : null} + scrollableTarget={id} + > +
+ {columns.map((col) => ( + + ))} +
+ + {data.map((row, i) => { + const rowId = getRowId(row); + + return ( +
+ {columns.map((col) => ( + handleCellClick(row, col)} + > + {col.renderCell?.(row, i, params) ?? + (row[col.field as keyof R] as string)} + + ))} +
+ ); + })} +
+
+ ); +} + +export * from "./types"; diff --git a/src/ui/common/components/Common/GridTable/types.ts b/src/ui/common/components/Common/GridTable/types.ts new file mode 100644 index 000000000..5c21a6b64 --- /dev/null +++ b/src/ui/common/components/Common/GridTable/types.ts @@ -0,0 +1,52 @@ +import type { ReactNode } from "react"; + +export interface TableColumn { + field: string; + headerName: ReactNode; + cellClassName?: string; + headerCellClassName?: string; + width?: string; + align?: "left" | "right" | "center"; + sortable?: boolean; + renderCell?: (row: R, index: number, params: P) => ReactNode; +} + +export interface SortColumn { + field: string; + direction: "ASC" | "DESC" | ""; +} + +type RowClassNameCreator = ( + row: R, + param: P, +) => string; +type CellClassNameCreator = ( + row: R, + col: TableColumn, + param: P, +) => string; + +export interface TableProps { + loading?: boolean; + isFetchingNextPage?: boolean; + classNames?: { + wrapperClassName?: string; + headerCellClassName?: string; + headerRowClassName?: string; + rowClassName?: string | RowClassNameCreator; + cellClassName?: string | CellClassNameCreator; + bodyClassName?: string; + contentClassName?: string; + }; + sortColumn?: SortColumn; + infiniteScroll?: boolean; + columns: TableColumn[]; + data: R[]; + params?: P; + fallback?: ReactNode; + getRowId: (row: R) => string; + onRowClick?: (row: R) => void; + onCellClick?: (row: R, column: TableColumn) => void; + onSortColumn?: (sortColumn: SortColumn) => void; + onInfiniteScroll?: () => void; +} diff --git a/src/ui/common/components/Common/GridTable/utils.ts b/src/ui/common/components/Common/GridTable/utils.ts new file mode 100644 index 000000000..ca2224ea6 --- /dev/null +++ b/src/ui/common/components/Common/GridTable/utils.ts @@ -0,0 +1,17 @@ +import type { TableColumn } from "./types"; + +export function createColumnTemplate( + columns: TableColumn[], +) { + const hasWidthParam = columns.some((col) => col.width); + + if (hasWidthParam) { + return columns.reduce((template, col) => { + const colWidth = col.width ?? "minmax(0, 1fr)"; + + return template ? `${template} ${colWidth}` : colWidth; + }, ""); + } + + return `repeat(${columns.length}, minmax(0, 1fr))`; +} diff --git a/src/ui/common/components/Common/Hint/index.tsx b/src/ui/common/components/Common/Hint/index.tsx new file mode 100644 index 000000000..54c1f851e --- /dev/null +++ b/src/ui/common/components/Common/Hint/index.tsx @@ -0,0 +1,83 @@ +import { useId, type PropsWithChildren, type ReactNode } from "react"; +import { AiOutlineInfoCircle } from "react-icons/ai"; +import { Tooltip } from "react-tooltip"; +import { twJoin } from "tailwind-merge"; + +type HintStatus = "default" | "warning" | "error"; + +interface HintProps { + tooltip?: ReactNode; + status?: HintStatus; + /** Attach tooltip to children instead of showing separate icon */ + attachToChildren?: boolean; +} + +const STATUS_COLORS = { + default: "text-accent-primary", + warning: "text-warning-main", + error: "text-error-main", +} as const; + +const ICON_COLOR = { + default: "text-secondary-strokeDark", + warning: "text-warning-main", + error: "text-error-main", +} as const; + +export function Hint({ + children, + tooltip, + status = "default", + attachToChildren = false, +}: PropsWithChildren) { + const id = useId(); + const statusColor = STATUS_COLORS[status]; + + if (!tooltip) { + return ( +
+ {children} +
+ ); + } + + return ( +
+ {attachToChildren ? ( + + {children} + + ) : ( + <> + {children} + + + + + )} + + + {typeof tooltip !== "string" && tooltip} + +
+ ); +} diff --git a/src/ui/common/components/Container/Container.tsx b/src/ui/common/components/Container/Container.tsx new file mode 100644 index 000000000..f2a5724f2 --- /dev/null +++ b/src/ui/common/components/Container/Container.tsx @@ -0,0 +1,18 @@ +import { createElement, type PropsWithChildren } from "react"; +import { twMerge } from "tailwind-merge"; + +interface ContainerProps { + className?: string; + as?: string; +} + +export const Container = ({ + as = "div", + className, + children, +}: PropsWithChildren) => + createElement( + as, + { className: twMerge("container px-4 mx-auto sm:px-0", className) }, + children, + ); diff --git a/src/ui/common/components/Delegations/Delegation.tsx b/src/ui/common/components/Delegations/Delegation.tsx new file mode 100644 index 000000000..821a3684c --- /dev/null +++ b/src/ui/common/components/Delegations/Delegation.tsx @@ -0,0 +1,240 @@ +import { FaBitcoin } from "react-icons/fa"; + +import { Hint } from "@/ui/common/components/Common/Hint"; +import { DelegationActions } from "@/ui/common/components/Delegations/DelegationActions"; +import { getNetworkConfigBTC } from "@/ui/common/config/network/btc"; +import { DOCUMENTATION_LINKS } from "@/ui/common/constants"; +import { useFinalityProviderState } from "@/ui/common/state/FinalityProviderState"; +import { + DelegationState as DelegationStateEnum, + type Delegation as DelegationInterface, +} from "@/ui/common/types/delegations"; +import { FinalityProviderState } from "@/ui/common/types/finalityProviders"; +import { satoshiToBtc } from "@/ui/common/utils/btc"; +import { getState, getStateTooltip } from "@/ui/common/utils/getState"; +import { maxDecimals } from "@/ui/common/utils/maxDecimals"; +import { durationTillNow } from "@/ui/common/utils/time"; +import { trim } from "@/ui/common/utils/trim"; + +import { DelegationCell } from "./components/DelegationCell"; + +interface DelegationProps { + currentTime: number; + delegation: DelegationInterface; + onWithdraw: (id: string) => void; + onUnbond: (id: string) => void; + onRegistration: (delegation: DelegationInterface) => Promise; + // This attribute is set when an action has been taken by the user + // that should change the status but the back-end + // has not had time to reflect this change yet + intermediateState?: string; +} + +const { coinName, mempoolApiUrl } = getNetworkConfigBTC(); + +interface FinalityProviderDisplayProps { + fpName: string; + isSlashed: boolean; + isJailed: boolean; +} + +const FinalityProviderDisplay: React.FC = ({ + fpName, + isSlashed, + isJailed, +}) => { + if (isSlashed) { + return ( + + This finality provider has been slashed.{" "} + + Learn more + + + } + status="error" + > + {fpName} + + ); + } + + if (isJailed) { + return ( + + This finality provider has been jailed.{" "} + + Learn more + + + } + status="error" + > + {fpName} + + ); + } + + return <>{fpName}; +}; + +const DelegationState: React.FC<{ + displayState: string; + isSlashed: boolean; + isFPResgistered: boolean; +}> = ({ displayState, isSlashed, isFPResgistered }) => { + const renderStateTooltip = () => { + if (!isFPResgistered) { + return "Your Finality Provider is not registered on Babylon Genesis. You need to wait for their registration to become eligible to register your stake to Babylon Genesis"; + } + + if (isSlashed) { + return ( + + This finality provider has been slashed.{" "} + + Learn more + + + ); + } + + if (displayState === DelegationStateEnum.OVERFLOW) { + return "Stake was over the Phase-1 staking cap it was created in"; + } + + return getStateTooltip(displayState); + }; + + const renderState = () => { + if (isSlashed) { + return Slashed; + } + return getState(displayState); + }; + + return ( + + {renderState()} + + ); +}; + +export const Delegation: React.FC = ({ + currentTime, + delegation, + onWithdraw, + onUnbond, + onRegistration, + intermediateState, +}) => { + const { + stakingTx, + stakingTxHashHex, + state, + stakingValueSat, + isOverflow, + finalityProviderPkHex, + isEligibleForTransition, + } = delegation; + + const { startTimestamp } = stakingTx; + + const { getRegisteredFinalityProvider, getFinalityProviderName } = + useFinalityProviderState(); + + const finalityProvider = getRegisteredFinalityProvider(finalityProviderPkHex); + const fpState = finalityProvider?.state; + const fpName = getFinalityProviderName(finalityProviderPkHex) ?? "-"; + const isActive = state === DelegationStateEnum.ACTIVE; + const isFpRegistered = finalityProvider !== null; + const isSlashed = fpState === FinalityProviderState.SLASHED; + const isJailed = fpState === FinalityProviderState.JAILED; + + const displayState = + isOverflow && isActive + ? DelegationStateEnum.OVERFLOW + : intermediateState || state; + + return ( + <> + + + {durationTillNow(startTimestamp, currentTime)} + + + + + + + +
+ +

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

+
+
+ + + + {trim(stakingTxHashHex)} + + + {/* + we need to center the text without the tooltip + add its size 12px and gap 4px, 16/2 = 8px + */} + + + + + + onRegistration(delegation)} + onUnbond={onUnbond} + onWithdraw={onWithdraw} + /> + + + + ); +}; diff --git a/src/ui/common/components/Delegations/DelegationActions.tsx b/src/ui/common/components/Delegations/DelegationActions.tsx new file mode 100644 index 000000000..c1c03329f --- /dev/null +++ b/src/ui/common/components/Delegations/DelegationActions.tsx @@ -0,0 +1,204 @@ +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 { getNetworkConfigBBN } from "@/ui/common/config/network/bbn"; +import { useBbnQuery } from "@/ui/common/hooks/client/rpc/queries/useBbnQuery"; +import { useStakingManagerService } from "@/ui/common/hooks/services/useStakingManagerService"; +import { useFinalityProviderState } from "@/ui/common/state/FinalityProviderState"; +import { useStakingState } from "@/ui/common/state/StakingState"; +import { DelegationState } from "@/ui/common/types/delegations"; +import { FinalityProviderState } from "@/ui/common/types/finalityProviders"; + +interface DelegationActionsProps { + state: string; + intermediateState?: string; + isEligibleForRegistration: boolean; + isFpRegistered: boolean; + stakingTxHashHex: string; + finalityProviderPkHex: string; + onRegistration: () => Promise; + onUnbond: (id: string) => void; + onWithdraw: (id: string) => void; +} + +export const DelegationActions: React.FC = ({ + state, + intermediateState, + isEligibleForRegistration, + isFpRegistered, + stakingTxHashHex, + finalityProviderPkHex, + onRegistration, + onUnbond, + onWithdraw, +}) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [anchorEl, setAnchorEl] = useState(null); + const { getRegisteredFinalityProvider } = useFinalityProviderState(); + const { disabled } = useStakingState(); + const { isLoading } = useStakingManagerService(); + const isStakingManagerReady = !isLoading; + + const { + balanceQuery: { data: bbnBalance = 0 }, + } = useBbnQuery(); + + const { networkFullName, coinSymbol } = getNetworkConfigBBN(); + + const finalityProvider = getRegisteredFinalityProvider(finalityProviderPkHex); + const fpState = finalityProvider?.state; + const isSlashed = fpState === FinalityProviderState.SLASHED; + + const hasInsufficientBalance = bbnBalance === 0; + const insufficientBalanceMessage = hasInsufficientBalance + ? `Insufficient ${coinSymbol} Balance in ${networkFullName} Wallet` + : ""; + + const getDelegationTooltip = () => { + if (!isFpRegistered) { + return "Registration is not available as your finality provider has not yet registered to Babylon Genesis."; + } + + if (disabled) { + return disabled.title; + } + + if (state === DelegationState.ACTIVE && !isEligibleForRegistration) { + return "Your Finality Provider has registered, however, only Phase-1 Cap-1 stakes are allowed to register at the moment. Please check the live countdown on Mintscan."; + } + + return insufficientBalanceMessage; + }; + + // We no longer show the registration button when the unbonding transaction is pending + if (intermediateState === DelegationState.INTERMEDIATE_UNBONDING) { + return null; + } + + // Unbonded + if ( + state === DelegationState.UNBONDED && + intermediateState !== DelegationState.INTERMEDIATE_WITHDRAWAL + ) { + return ( +
+ +
+ ); + } + + // If FP is slashed, only show unbond button + if ( + isSlashed && + intermediateState !== DelegationState.INTERMEDIATE_WITHDRAWAL + ) { + return ( +
+ +
+ ); + } + + // Active, eligible for registration + if (state === DelegationState.ACTIVE || isEligibleForRegistration) { + const tooltipId = `tooltip-registration-${stakingTxHashHex}`; + + return ( +
+
+ + +
+ + + + setIsPopoverOpen(false)} + className="bg-surface p-4 rounded border border-secondary-strokeLight w-48 shadow-md" + > + { + if (isStakingManagerReady) { + onUnbond(stakingTxHashHex); + setIsPopoverOpen(false); + } + }} + className={twJoin( + "flex items-center gap-1 text-accent-primary transition-all", + isStakingManagerReady + ? "hover:brightness-125" + : "opacity-50 cursor-not-allowed", + )} + > + Unbond + {!isStakingManagerReady && ( + + )} + + +
+ ); + } + + return null; +}; diff --git a/src/ui/common/components/Delegations/DelegationList/NoDelegations.tsx b/src/ui/common/components/Delegations/DelegationList/NoDelegations.tsx new file mode 100644 index 000000000..5fece964d --- /dev/null +++ b/src/ui/common/components/Delegations/DelegationList/NoDelegations.tsx @@ -0,0 +1,18 @@ +import { Heading } from "@babylonlabs-io/core-ui"; +import { IoEye } from "react-icons/io5"; + +import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn"; + +const { networkFullName: bbnNetworkFullName } = getNetworkConfigBBN(); + +export const NoDelegations = () => ( +
+
+ +
+ No {bbnNetworkFullName} Stakes +

+ This is where your registered stakes will be displayed. +

+
+); diff --git a/src/ui/common/components/Delegations/DelegationList/components/ActionButton.tsx b/src/ui/common/components/Delegations/DelegationList/components/ActionButton.tsx new file mode 100644 index 000000000..9b9edbd02 --- /dev/null +++ b/src/ui/common/components/Delegations/DelegationList/components/ActionButton.tsx @@ -0,0 +1,154 @@ +import { Button, Loader } from "@babylonlabs-io/core-ui"; + +import { Hint } from "@/ui/common/components/Common/Hint"; +import { DELEGATION_ACTIONS as ACTIONS } from "@/ui/common/constants"; +import { ActionType } from "@/ui/common/hooks/services/useDelegationService"; +import { + DelegationV2StakingState as DelegationState, + DelegationWithFP, +} from "@/ui/common/types/delegationsV2"; +import { FinalityProviderState } from "@/ui/common/types/finalityProviders"; + +interface ActionButtonProps { + tooltip?: string | JSX.Element; + delegation: DelegationWithFP; + onClick?: (action: ActionType, delegation: DelegationWithFP) => void; + disabled?: boolean; + showLoader?: boolean; +} + +type Actions = Record< + string, + Record +>; + +const ACTION_BUTTON_PROPS: Actions = { + [FinalityProviderState.ACTIVE]: { + [DelegationState.VERIFIED]: { + action: ACTIONS.STAKE, + title: "Stake", + }, + [DelegationState.ACTIVE]: { + action: ACTIONS.UNBOND, + title: "Unbond", + }, + [DelegationState.EARLY_UNBONDING_WITHDRAWABLE]: { + action: ACTIONS.WITHDRAW_ON_EARLY_UNBONDING, + title: "Withdraw", + }, + [DelegationState.TIMELOCK_WITHDRAWABLE]: { + action: ACTIONS.WITHDRAW_ON_TIMELOCK, + title: "Withdraw", + }, + [DelegationState.TIMELOCK_SLASHING_WITHDRAWABLE]: { + action: ACTIONS.WITHDRAW_ON_TIMELOCK_SLASHING, + title: "Withdraw", + }, + [DelegationState.EARLY_UNBONDING_SLASHING_WITHDRAWABLE]: { + action: ACTIONS.WITHDRAW_ON_EARLY_UNBONDING_SLASHING, + title: "Withdraw", + }, + }, + [FinalityProviderState.INACTIVE]: { + [DelegationState.VERIFIED]: { + action: ACTIONS.STAKE, + title: "Stake", + }, + [DelegationState.ACTIVE]: { + action: ACTIONS.UNBOND, + title: "Unbond", + }, + [DelegationState.EARLY_UNBONDING_WITHDRAWABLE]: { + action: ACTIONS.WITHDRAW_ON_EARLY_UNBONDING, + title: "Withdraw", + }, + [DelegationState.TIMELOCK_WITHDRAWABLE]: { + action: ACTIONS.WITHDRAW_ON_TIMELOCK, + title: "Withdraw", + }, + [DelegationState.TIMELOCK_SLASHING_WITHDRAWABLE]: { + action: ACTIONS.WITHDRAW_ON_TIMELOCK_SLASHING, + title: "Withdraw", + }, + [DelegationState.EARLY_UNBONDING_SLASHING_WITHDRAWABLE]: { + action: ACTIONS.WITHDRAW_ON_EARLY_UNBONDING_SLASHING, + title: "Withdraw", + }, + }, + [FinalityProviderState.JAILED]: { + [DelegationState.VERIFIED]: { + action: ACTIONS.STAKE, + title: "Stake", + }, + [DelegationState.ACTIVE]: { + action: ACTIONS.UNBOND, + title: "Unbond", + }, + [DelegationState.EARLY_UNBONDING_WITHDRAWABLE]: { + action: ACTIONS.WITHDRAW_ON_EARLY_UNBONDING, + title: "Withdraw", + }, + [DelegationState.TIMELOCK_WITHDRAWABLE]: { + action: ACTIONS.WITHDRAW_ON_TIMELOCK, + title: "Withdraw", + }, + [DelegationState.TIMELOCK_SLASHING_WITHDRAWABLE]: { + action: ACTIONS.WITHDRAW_ON_TIMELOCK_SLASHING, + title: "Withdraw", + }, + [DelegationState.EARLY_UNBONDING_SLASHING_WITHDRAWABLE]: { + action: ACTIONS.WITHDRAW_ON_EARLY_UNBONDING_SLASHING, + title: "Withdraw", + }, + }, + [FinalityProviderState.SLASHED]: { + [DelegationState.ACTIVE]: { + action: ACTIONS.UNBOND, + title: "Unbond", + }, + [DelegationState.EARLY_UNBONDING_WITHDRAWABLE]: { + action: ACTIONS.WITHDRAW_ON_EARLY_UNBONDING, + title: "Withdraw", + }, + [DelegationState.TIMELOCK_WITHDRAWABLE]: { + action: ACTIONS.WITHDRAW_ON_TIMELOCK, + title: "Withdraw", + }, + [DelegationState.TIMELOCK_SLASHING_WITHDRAWABLE]: { + action: ACTIONS.WITHDRAW_ON_TIMELOCK_SLASHING, + title: "Withdraw", + }, + [DelegationState.EARLY_UNBONDING_SLASHING_WITHDRAWABLE]: { + action: ACTIONS.WITHDRAW_ON_EARLY_UNBONDING_SLASHING, + title: "Withdraw", + }, + }, +}; + +export function ActionButton({ + delegation, + tooltip, + onClick, + disabled = false, + showLoader = false, +}: ActionButtonProps) { + const buttonProps = + ACTION_BUTTON_PROPS[delegation.fp?.state]?.[delegation.state]; + + if (!buttonProps) return null; + + return ( + + + + ); +} diff --git a/src/ui/common/components/Delegations/DelegationList/components/Amount.tsx b/src/ui/common/components/Delegations/DelegationList/components/Amount.tsx new file mode 100644 index 000000000..bcdca8d00 --- /dev/null +++ b/src/ui/common/components/Delegations/DelegationList/components/Amount.tsx @@ -0,0 +1,22 @@ +import { FaBitcoin } from "react-icons/fa"; + +import { getNetworkConfigBTC } from "@/ui/common/config/network/btc"; +import { satoshiToBtc } from "@/ui/common/utils/btc"; +import { maxDecimals } from "@/ui/common/utils/maxDecimals"; + +interface Amount { + value: number; +} + +const { coinName } = getNetworkConfigBTC(); + +export function Amount({ value }: Amount) { + return ( +
+ +

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

+
+ ); +} diff --git a/src/ui/common/components/Delegations/DelegationList/components/DelegationModal.tsx b/src/ui/common/components/Delegations/DelegationList/components/DelegationModal.tsx new file mode 100644 index 000000000..add65f6c7 --- /dev/null +++ b/src/ui/common/components/Delegations/DelegationList/components/DelegationModal.tsx @@ -0,0 +1,123 @@ +import { useCallback } from "react"; +import { IoMdWarning } from "react-icons/io"; + +import { SignDetailsModal } from "@/ui/common/components/Modals/SignDetailsModal"; +import { SlashingModal } from "@/ui/common/components/Modals/SlashingModal"; +import { StakeModal } from "@/ui/common/components/Modals/StakeModal"; +import { SubmitModal } from "@/ui/common/components/Modals/SubmitModal"; +import { UnbondModal } from "@/ui/common/components/Modals/UnbondModal"; +import { WithdrawModal } from "@/ui/common/components/Modals/WithdrawModal"; +import { NetworkConfig } from "@/ui/common/config/network"; +import { DELEGATION_ACTIONS as ACTIONS } from "@/ui/common/constants"; +import { ActionType } from "@/ui/common/hooks/services/useDelegationService"; +import { useDelegationV2State } from "@/ui/common/state/DelegationV2State"; +import { DelegationWithFP } from "@/ui/common/types/delegationsV2"; +import { FinalityProviderState } from "@/ui/common/types/finalityProviders"; +import { BbnStakingParamsVersion } from "@/ui/common/types/networkInfo"; + +interface ConfirmationModalProps { + processing: boolean; + action: ActionType | undefined; + delegation: DelegationWithFP | null; + onSubmit: (action: ActionType, delegation: DelegationWithFP) => void; + onClose: () => void; + networkConfig: NetworkConfig; + param: BbnStakingParamsVersion | null; +} + +export function DelegationModal({ + action, + delegation, + onSubmit, + ...restProps +}: ConfirmationModalProps) { + const handleSubmit = useCallback(() => { + if (!action || !delegation) return; + + onSubmit(action, delegation); + }, [action, delegation, onSubmit]); + + const { delegationV2StepOptions, setDelegationV2StepOptions } = + useDelegationV2State(); + + const detailsModalTitle = + (delegationV2StepOptions?.type as string) || "Transaction Details"; + + return ( + <> + + } + title="Finality Provider is Inactive" + submitButton="Continue" + cancelButton="Cancel" + onSubmit={handleSubmit} + {...restProps} + > + The finality provider you selected is not currently active, so you won’t + earn rewards for your stake unless it becomes active. You can proceed + with this choice or select a different, active finality provider. + + } + title="Finality Provider is Jailed" + submitButton="Continue" + cancelButton="Cancel" + onSubmit={handleSubmit} + {...restProps} + > + The finality provider you selected is currently jailed, so you won’t + earn rewards for your stake unless it becomes active. You can proceed + with this choice or select a different, active finality provider. + + + + + + + setDelegationV2StepOptions(undefined)} + details={delegationV2StepOptions} + title={detailsModalTitle} + /> + + ); +} diff --git a/src/ui/common/components/Delegations/DelegationList/components/FinalityProviderMoniker.tsx b/src/ui/common/components/Delegations/DelegationList/components/FinalityProviderMoniker.tsx new file mode 100644 index 000000000..796b3e129 --- /dev/null +++ b/src/ui/common/components/Delegations/DelegationList/components/FinalityProviderMoniker.tsx @@ -0,0 +1,65 @@ +import { Hint } from "@/ui/common/components/Common/Hint"; +import { DOCUMENTATION_LINKS } from "@/ui/common/constants"; +import { + FinalityProvider, + FinalityProviderState, +} from "@/ui/common/types/finalityProviders"; + +interface FinalityProviderMonikerProps { + value: FinalityProvider; +} + +const STATUSES: Record< + string, + { + tooltip?: React.ReactNode; + status?: "warning" | "error"; + } +> = { + [FinalityProviderState.INACTIVE]: { + tooltip: "This finality provider is inactive.", + }, + [FinalityProviderState.JAILED]: { + tooltip: ( + + This finality provider has been jailed.{" "} + + Learn more + + + ), + }, + [FinalityProviderState.SLASHED]: { + tooltip: ( + + This finality provider has been slashed.{" "} + + Learn more + + + ), + status: "error", + }, +}; + +export function FinalityProviderMoniker({ + value: finalProvider, +}: FinalityProviderMonikerProps) { + const moniker = finalProvider?.description?.moniker ?? "-"; + const state = finalProvider?.state ?? FinalityProviderState.ACTIVE; + const { tooltip, status } = STATUSES[state] ?? {}; + + return ( + + {moniker} + + ); +} diff --git a/src/ui/common/components/Delegations/DelegationList/components/Inception.tsx b/src/ui/common/components/Delegations/DelegationList/components/Inception.tsx new file mode 100644 index 000000000..d54a0493c --- /dev/null +++ b/src/ui/common/components/Delegations/DelegationList/components/Inception.tsx @@ -0,0 +1,15 @@ +import { durationTillNow } from "@/ui/common/utils/time"; + +interface Inception { + value: string; +} + +export function Inception({ value }: Inception) { + const currentTime = Date.now(); + + return ( +
+ {durationTillNow(value, currentTime)} +
+ ); +} diff --git a/src/ui/common/components/Delegations/DelegationList/components/Overflow.tsx b/src/ui/common/components/Delegations/DelegationList/components/Overflow.tsx new file mode 100644 index 000000000..018e2b917 --- /dev/null +++ b/src/ui/common/components/Delegations/DelegationList/components/Overflow.tsx @@ -0,0 +1,10 @@ +import { IoIosWarning } from "react-icons/io"; + +export function Overflow() { + return ( +
+ +

overflow

+
+ ); +} diff --git a/src/ui/common/components/Delegations/DelegationList/components/SlashingContent.tsx b/src/ui/common/components/Delegations/DelegationList/components/SlashingContent.tsx new file mode 100644 index 000000000..5f06ea8d6 --- /dev/null +++ b/src/ui/common/components/Delegations/DelegationList/components/SlashingContent.tsx @@ -0,0 +1,100 @@ +import { useMemo } from "react"; + +import { getNetworkConfigBTC } from "@/ui/common/config/network/btc"; +import { DOCUMENTATION_LINKS } from "@/ui/common/constants"; +import { + DelegationV2, + DelegationV2StakingState, +} from "@/ui/common/types/delegationsV2"; +import { NetworkInfo } from "@/ui/common/types/networkInfo"; +import { satoshiToBtc } from "@/ui/common/utils/btc"; +import { getSlashingAmount } from "@/ui/common/utils/delegations/slashing"; +import { maxDecimals } from "@/ui/common/utils/maxDecimals"; +import { getBbnParamByVersion } from "@/ui/common/utils/params"; +import { blocksToDisplayTime } from "@/ui/common/utils/time"; + +interface SlashingContentProps { + delegation: DelegationV2; + networkInfo?: NetworkInfo; +} + +const { coinName } = getNetworkConfigBTC(); + +export const SlashingContent = ({ + delegation, + networkInfo, +}: SlashingContentProps) => { + const slashingAmount = useMemo(() => { + const params = getBbnParamByVersion( + delegation.paramsVersion, + networkInfo?.params.bbnStakingParams.versions || [], + ); + const amount = getSlashingAmount(delegation.stakingAmount, params); + + return amount; + }, [delegation, networkInfo]); + + const unbondingTime = useMemo(() => { + const unbondingTimeBlocks = + networkInfo?.params?.bbnStakingParams.latestParam.unbondingTime ?? 1008; + return { + blocks: unbondingTimeBlocks, + time: blocksToDisplayTime(unbondingTimeBlocks), + }; + }, [networkInfo]); + + if (delegation.startHeight === undefined) { + return ( + <> + This Finality Provider has been slashed due to double voting.{" "} + + Learn more + + + ); + } + + // For the SLASHED state specifically, show the information with waiting period + if (delegation.state === DelegationV2StakingState.SLASHED) { + return ( + <> + The Finality Provider you selected has been slashed, resulting in{" "} + + {maxDecimals(satoshiToBtc(slashingAmount ?? 0), 8)} {coinName} + {" "} + being deducted from your delegation. It will take {unbondingTime.blocks}{" "} + blocks (~ {unbondingTime.time}) before it becomes withdrawable.{" "} + + Learn more + + + ); + } + + // For other slashing-related states + return ( + <> + The Finality Provider you selected has been slashed, resulting in{" "} + + {maxDecimals(satoshiToBtc(slashingAmount ?? 0), 8)} {coinName} + {" "} + being deducted from your delegation due to the finality provider double + voting.{" "} + + Learn more + + + ); +}; diff --git a/src/ui/common/components/Delegations/DelegationList/components/Status.tsx b/src/ui/common/components/Delegations/DelegationList/components/Status.tsx new file mode 100644 index 000000000..a1a6e5e34 --- /dev/null +++ b/src/ui/common/components/Delegations/DelegationList/components/Status.tsx @@ -0,0 +1,193 @@ +import { useMemo, type JSX } from "react"; + +import { Hint } from "@/ui/common/components/Common/Hint"; +import { getNetworkConfigBTC } from "@/ui/common/config/network/btc"; +import { DOCUMENTATION_LINKS } from "@/ui/common/constants"; +import { useAppState } from "@/ui/common/state"; +import { + DelegationWithFP, + DelegationV2StakingState as State, +} from "@/ui/common/types/delegationsV2"; +import { FinalityProviderState } from "@/ui/common/types/finalityProviders"; +import { NetworkInfo } from "@/ui/common/types/networkInfo"; +import { blocksToDisplayTime } from "@/ui/common/utils/time"; + +import { SlashingContent } from "./SlashingContent"; + +interface StatusProps { + delegation: DelegationWithFP; + showTooltip?: boolean; +} + +interface StatusParams { + delegation: DelegationWithFP; + networkInfo?: NetworkInfo; +} + +type StatusAdapter = (props: StatusParams) => { + label: string; + tooltip: string | JSX.Element; + status?: "warning" | "error"; +}; + +const { coinName } = getNetworkConfigBTC(); + +const STATUSES: Record = { + [State.PENDING]: () => ({ + label: "Pending", + tooltip: "Stake is pending verification", + }), + [State.VERIFIED]: () => ({ + label: "Verified", + tooltip: "Stake is verified, you can start staking", + }), + [State.ACTIVE]: () => ({ + label: "Active", + tooltip: "Stake is active", + }), + [State.TIMELOCK_UNBONDING]: () => ({ + label: "Unbonding", + tooltip: + "Stake is about to be unbonded as it's reaching the timelock period", + }), + [State.EARLY_UNBONDING]: ({ networkInfo }) => ({ + label: "Unbonding", + tooltip: `Stake unbonding is in progress. The unbonding time is set to ${blocksToDisplayTime(networkInfo?.params?.bbnStakingParams.latestParam.unbondingTime ?? 0)}.`, + }), + [State.TIMELOCK_WITHDRAWABLE]: () => ({ + label: "Withdrawable", + tooltip: "Stake is withdrawable as it's reached the timelock period", + }), + [State.EARLY_UNBONDING_WITHDRAWABLE]: () => ({ + label: "Withdrawable", + tooltip: "Stake is withdrawable after the unbonding period", + }), + [State.TIMELOCK_SLASHING_WITHDRAWABLE]: ({ delegation, networkInfo }) => ({ + label: "Withdrawable", + tooltip: ( + + ), + status: "error", + }), + [State.EARLY_UNBONDING_SLASHING_WITHDRAWABLE]: ({ + delegation, + networkInfo, + }) => ({ + label: "Withdrawable", + tooltip: ( + + ), + status: "error", + }), + [State.SLASHED]: ({ delegation, networkInfo }) => ({ + label: delegation.startHeight === undefined ? "Invalid" : "Slashed", + tooltip: ( + + ), + status: "error", + }), + [State.TIMELOCK_WITHDRAWN]: () => ({ + label: "Withdrawn", + tooltip: "Stake has been withdrawn", + }), + [State.EARLY_UNBONDING_WITHDRAWN]: () => ({ + label: "Withdrawn", + tooltip: "Stake has been withdrawn", + }), + [State.EARLY_UNBONDING_SLASHING_WITHDRAWN]: () => ({ + label: "Withdrawn", + tooltip: "Slashed Stake has been withdrawn", + status: "error", + }), + [State.TIMELOCK_SLASHING_WITHDRAWN]: () => ({ + label: "Withdrawn", + tooltip: "Slashed Stake has been withdrawn", + status: "error", + }), + // Intermediate States + [State.INTERMEDIATE_PENDING_VERIFICATION]: () => ({ + label: "Pending", + tooltip: "Stake is pending verification", + }), + [State.INTERMEDIATE_PENDING_BTC_CONFIRMATION]: ({ networkInfo }) => ({ + label: `Pending ${coinName} Confirmation`, + tooltip: `Stake is pending ${networkInfo?.params?.btcEpochCheckParams.latestParam.btcConfirmationDepth ?? 0} ${coinName} confirmations`, + }), + [State.INTERMEDIATE_UNBONDING_SUBMITTED]: ({ networkInfo }) => ({ + label: "Unbonding", + tooltip: `Stake unbonding is in progress. The unbonding time is set to ${blocksToDisplayTime(networkInfo?.params?.bbnStakingParams.latestParam.unbondingTime ?? 0)}.`, + }), + [State.INTERMEDIATE_EARLY_UNBONDING_WITHDRAWAL_SUBMITTED]: () => ({ + label: "Withdrawing", + tooltip: "Withdrawal transaction pending confirmation on Bitcoin", + }), + [State.INTERMEDIATE_EARLY_UNBONDING_SLASHING_WITHDRAWAL_SUBMITTED]: () => ({ + label: "Withdrawing", + tooltip: "Withdrawal transaction pending confirmation on Bitcoin", + }), + [State.INTERMEDIATE_TIMELOCK_WITHDRAWAL_SUBMITTED]: () => ({ + label: "Withdrawing", + tooltip: "Withdrawal transaction pending confirmation on Bitcoin", + }), + [State.INTERMEDIATE_TIMELOCK_SLASHING_WITHDRAWAL_SUBMITTED]: () => ({ + label: "Withdrawing", + tooltip: "Withdrawal transaction pending confirmation on Bitcoin", + }), +}; + +const FP_STATUSES: Record> = { + [FinalityProviderState.ACTIVE]: {}, + [FinalityProviderState.SLASHED]: { + [State.VERIFIED]: () => ({ + label: "Verified", + tooltip: ( + <> + This Finality Provider has been slashed and is no longer available for + staking.{" "} + + Learn more + + + ), + }), + [State.ACTIVE]: () => ({ + label: "Active", + tooltip: "", + }), + }, + [FinalityProviderState.INACTIVE]: {}, + [FinalityProviderState.JAILED]: {}, +}; + +export function Status({ delegation, showTooltip = true }: StatusProps) { + const { networkInfo } = useAppState(); + + const delegationStatus = useMemo( + () => + FP_STATUSES[delegation.fp?.state]?.[delegation.state]?.({ + delegation, + networkInfo, + }) ?? + STATUSES[delegation.state]?.({ + delegation, + networkInfo, + }), + [delegation, networkInfo], + ); + + const { + label = "unknown", + tooltip = "unknown", + status, + } = delegationStatus ?? {}; + + return ( + + {label} + + ); +} diff --git a/src/ui/common/components/Delegations/DelegationList/components/TxHash.tsx b/src/ui/common/components/Delegations/DelegationList/components/TxHash.tsx new file mode 100644 index 000000000..ef942b591 --- /dev/null +++ b/src/ui/common/components/Delegations/DelegationList/components/TxHash.tsx @@ -0,0 +1,23 @@ +import { getNetworkConfigBTC } from "@/ui/common/config/network/btc"; +import { trim } from "@/ui/common/utils/trim"; + +const { mempoolApiUrl } = getNetworkConfigBTC(); + +interface TxHashProps { + value: string; +} + +export function TxHash({ value }: TxHashProps) { + if (!value) return -; + + return ( + + {trim(value) ?? value} + + ); +} diff --git a/src/ui/common/components/Delegations/DelegationList/index.tsx b/src/ui/common/components/Delegations/DelegationList/index.tsx new file mode 100644 index 000000000..d41dbd572 --- /dev/null +++ b/src/ui/common/components/Delegations/DelegationList/index.tsx @@ -0,0 +1,161 @@ +import { Card, Heading } from "@babylonlabs-io/core-ui"; + +import { + GridTable, + type TableColumn, +} from "@/ui/common/components/Common/GridTable"; +import { Hint } from "@/ui/common/components/Common/Hint"; +import { FinalityProviderMoniker } from "@/ui/common/components/Delegations/DelegationList/components/FinalityProviderMoniker"; +import { getNetworkConfig } from "@/ui/common/config/network"; +import { + ActionType, + useDelegationService, +} from "@/ui/common/hooks/services/useDelegationService"; +import { useStakingManagerService } from "@/ui/common/hooks/services/useStakingManagerService"; +import { + DelegationV2StakingState, + DelegationWithFP, +} from "@/ui/common/types/delegationsV2"; + +import { ActionButton } from "./components/ActionButton"; +import { Amount } from "./components/Amount"; +import { DelegationModal } from "./components/DelegationModal"; +import { Inception } from "./components/Inception"; +import { Status } from "./components/Status"; +import { TxHash } from "./components/TxHash"; +import { NoDelegations } from "./NoDelegations"; + +type TableParams = { + validations: Record; + handleActionClick: (action: ActionType, delegation: DelegationWithFP) => void; + isStakingManagerReady: boolean; +}; + +const networkConfig = getNetworkConfig(); + +const columns: TableColumn[] = [ + { + field: "Inception", + headerName: "Inception", + width: "minmax(max-content, 1fr)", + renderCell: (row) => , + }, + { + field: "finalityProvider", + headerName: "Finality Provider", + width: "minmax(max-content, 1fr)", + renderCell: (row) => , + }, + { + field: "stakingAmount", + headerName: "Amount", + width: "minmax(max-content, 1fr)", + renderCell: (row) => , + }, + { + field: "stakingTxHashHex", + headerName: "Transaction ID", + width: "minmax(max-content, 1fr)", + renderCell: (row) => , + }, + { + field: "state", + headerName: "Status", + width: "minmax(max-content, 1fr)", + renderCell: (row, _, { validations }) => { + const { valid, error } = validations[row.stakingTxHashHex]; + if (!valid) return Unavailable; + + return ; + }, + }, + { + field: "actions", + headerName: "Action", + width: "minmax(max-content, 0.5fr)", + renderCell: ( + row, + _, + { handleActionClick, validations, isStakingManagerReady }, + ) => { + const { valid, error } = validations[row.stakingTxHashHex]; + + if (!valid) return null; + + const isUnbondDisabled = + row.state === DelegationV2StakingState.ACTIVE && !isStakingManagerReady; + + return ( + + ); + }, + }, +]; + +export function DelegationList() { + const { + processing, + confirmationModal, + delegations, + isLoading, + isFetchingNextPage, + hasMoreDelegations, + validations, + fetchMoreDelegations, + executeDelegationAction, + openConfirmationModal, + closeConfirmationModal, + } = useDelegationService(); + + const { isLoading: isStakingManagerLoading } = useStakingManagerService(); + const isStakingManagerReady = !isStakingManagerLoading; + + return ( + + + {networkConfig.bbn.networkFullName} Stakes + + + `${row.stakingTxHashHex}-${row.startHeight}`} + columns={columns} + data={delegations} + loading={isLoading} + isFetchingNextPage={isFetchingNextPage} + infiniteScroll={hasMoreDelegations} + onInfiniteScroll={fetchMoreDelegations} + classNames={{ + headerRowClassName: "text-accent-primary text-xs", + headerCellClassName: "p-4 text-align-left text-accent-secondary", + rowClassName: "group", + wrapperClassName: "max-h-[25rem] overflow-x-auto", + bodyClassName: "min-w-[1000px]", + cellClassName: + "p-4 first:pl-4 first:rounded-l last:pr-4 last:rounded-r bg-surface flex items-center text-sm justify-start group-even:bg-secondary-highlight text-accent-primary", + }} + params={{ + handleActionClick: openConfirmationModal, + validations, + isStakingManagerReady, + }} + fallback={} + /> + + + + ); +} diff --git a/src/ui/common/components/Delegations/DelegationList/utils.ts b/src/ui/common/components/Delegations/DelegationList/utils.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/ui/common/components/Delegations/Delegations.tsx b/src/ui/common/components/Delegations/Delegations.tsx new file mode 100644 index 000000000..398ebd7ef --- /dev/null +++ b/src/ui/common/components/Delegations/Delegations.tsx @@ -0,0 +1,487 @@ +import { getBabylonParamByBtcHeight } from "@babylonlabs-io/btc-staking-ts"; +import { Card, Heading } from "@babylonlabs-io/core-ui"; +import { useEffect, useMemo, useState } from "react"; +import InfiniteScroll from "react-infinite-scroll-component"; +import { useLocalStorage } from "usehooks-ts"; + +import { LoadingTableList } from "@/ui/common/components/Loading/Loading"; +import { RegistrationEndModal } from "@/ui/common/components/Modals/RegistrationModal/RegistrationEndModal"; +import { RegistrationStartModal } from "@/ui/common/components/Modals/RegistrationModal/RegistrationStartModal"; +import { SignModal } from "@/ui/common/components/Modals/SignModal/SignModal"; +import { WithdrawModal } from "@/ui/common/components/Modals/WithdrawModal"; +import { ONE_MINUTE } from "@/ui/common/constants"; +import { useError } from "@/ui/common/context/Error/ErrorProvider"; +import { useBTCWallet } from "@/ui/common/context/wallet/BTCWalletProvider"; +import { ClientError, ERROR_CODES } from "@/ui/common/errors"; +import { useDelegations } from "@/ui/common/hooks/client/api/useDelegations"; +import { useNetworkFees } from "@/ui/common/hooks/client/api/useNetworkFees"; +import { useNetworkInfo } from "@/ui/common/hooks/client/api/useNetworkInfo"; +import { useRegistrationService } from "@/ui/common/hooks/services/useRegistrationService"; +import { useV1TransactionService } from "@/ui/common/hooks/services/useV1TransactionService"; +import { useLogger } from "@/ui/common/hooks/useLogger"; +import { useDelegationState } from "@/ui/common/state/DelegationState"; +import { + Delegation as DelegationInterface, + DelegationState, +} from "@/ui/common/types/delegations"; +import { getIntermediateDelegationsLocalStorageKey } from "@/ui/common/utils/local_storage/getIntermediateDelegationsLocalStorageKey"; +import { toLocalStorageIntermediateDelegation } from "@/ui/common/utils/local_storage/toLocalStorageIntermediateDelegation"; + +import { UnbondModal } from "../Modals/UnbondModal"; +import { VerificationModal } from "../Modals/VerificationModal"; + +import { Delegation } from "./Delegation"; + +const MODE_WITHDRAW = "withdraw"; +const MODE_UNBOND = "unbond"; +type MODE = "transition" | "withdraw" | "unbond"; +// step index +const REGISTRATION_INDEXES: Record = { + "registration-staking-slashing": 1, + "registration-unbonding-slashing": 2, + "registration-proof-of-possession": 3, + "registration-sign-bbn": 4, +}; + +const VERIFICATION_STEPS: Record = { + "registration-send-bbn": 1, + "registration-verifying": 2, +}; + +export const Delegations = () => { + const { data: networkInfo } = useNetworkInfo(); + const { publicKeyNoCoord, connected, network } = useBTCWallet(); + const [modalOpen, setModalOpen] = useState(false); + const [txID, setTxID] = useState(""); + const [modalMode, setModalMode] = useState(); + const { handleError } = useError(); + const logger = useLogger(); + const [awaitingWalletResponse, setAwaitingWalletResponse] = useState(false); + const { data: delegationsAPI } = useDelegations(); + const [currentTime, setCurrentTime] = useState(Date.now()); + const { + processing, + registrationStep: step, + setRegistrationStep: setStep, + setSelectedDelegation, + resetRegistration: handleCloseRegistration, + delegationStepOptions, + } = useDelegationState(); + const { registerPhase1Delegation } = useRegistrationService(); + const { + delegations = [], + fetchMoreDelegations, + hasMoreDelegations, + isLoading, + } = useDelegationState(); + + const { setDelegationStepOptions } = useDelegationState(); + + const { submitWithdrawalTx, submitUnbondingTx } = useV1TransactionService(); + const { data: networkFees } = useNetworkFees(); + + const selectedDelegation = delegationsAPI?.delegations.find( + (delegation) => delegation.stakingTxHashHex === txID, + ); + + const selectedDelegationUnbondingTime = useMemo(() => { + if ( + !selectedDelegation?.stakingTx.startHeight || + !networkInfo?.params.bbnStakingParams.versions + ) { + return 0; + } + return getBabylonParamByBtcHeight( + selectedDelegation.stakingTx.startHeight, + networkInfo.params.bbnStakingParams.versions, + ).unbondingTime; + }, [ + selectedDelegation?.stakingTx.startHeight, + networkInfo?.params.bbnStakingParams.versions, + ]); + + const selectedDelegationUnbondingFeeSat = useMemo(() => { + if ( + !selectedDelegation?.stakingTx.startHeight || + !networkInfo?.params.bbnStakingParams.versions + ) { + return 0; + } + return getBabylonParamByBtcHeight( + selectedDelegation.stakingTx.startHeight, + networkInfo.params.bbnStakingParams.versions, + ).unbondingFeeSat; + }, [ + selectedDelegation?.stakingTx.startHeight, + networkInfo?.params.bbnStakingParams.versions, + ]); + + // Local storage state for intermediate delegations (transitioning, withdrawing) + const intermediateDelegationsLocalStorageKey = + getIntermediateDelegationsLocalStorageKey(publicKeyNoCoord); + + const [ + intermediateDelegationsLocalStorage, + setIntermediateDelegationsLocalStorage, + ] = useLocalStorage( + intermediateDelegationsLocalStorageKey, + [], + ); + + // Update the local storage with the new intermediate delegation state + const updateLocalStorage = ( + delegation: DelegationInterface, + newState: string, + ) => { + const newTxId = delegation.stakingTxHashHex; + + setIntermediateDelegationsLocalStorage((delegations) => { + // Check if an intermediate delegation with the same transaction ID already exists + const exists = delegations.some( + (existingDelegation) => existingDelegation.stakingTxHashHex === newTxId, + ); + + // If it doesn't exist, add the new intermediate delegation + if (!exists) { + return [ + toLocalStorageIntermediateDelegation( + newTxId, + publicKeyNoCoord, + delegation.finalityProviderPkHex, + delegation.stakingValueSat, + delegation.stakingTx.txHex, + delegation.stakingTx.timelock, + newState, + ), + ...delegations, + ]; + } + + // If it exists, return the existing delegations unchanged + return delegations; + }); + }; + + const handleUnbond = async (id: string) => { + try { + if (selectedDelegation?.stakingTxHashHex != id) { + if (selectedDelegation?.stakingTxHashHex) { + logger.info("Unbond attempt for delegation", { + delegationId: selectedDelegation.stakingTxHashHex, + }); + } + const clientError = new ClientError( + ERROR_CODES.VALIDATION_ERROR, + "Wrong delegation selected for unbonding", + ); + throw clientError; + } + // Sign the withdrawal transaction + const { stakingTx, finalityProviderPkHex, stakingValueSat } = + selectedDelegation; + setAwaitingWalletResponse(true); + await submitUnbondingTx( + { + stakingTimelock: stakingTx.timelock, + finalityProviderPksNoCoordHex: [finalityProviderPkHex], + stakingAmountSat: stakingValueSat, + }, + stakingTx.startHeight, + stakingTx.txHex, + ); + // Update the local state with the new intermediate delegation + updateLocalStorage( + selectedDelegation, + DelegationState.INTERMEDIATE_UNBONDING, + ); + } catch (error: any) { + logger.error(error); + handleError({ + error, + }); + } finally { + handleModalClose(); + } + }; + + // Handles withdrawing requests for delegations that have expired timelocks + // It constructs a withdrawal transaction, creates a signature for it, + // and submits it to the Bitcoin network + const handleWithdraw = async (id: string) => { + try { + if (!networkFees) { + const clientError = new ClientError( + ERROR_CODES.MISSING_DATA_ERROR, + "Network fees not found", + ); + throw clientError; + } + // Prevent the modal from closing + setAwaitingWalletResponse(true); + + if (selectedDelegation?.stakingTxHashHex != id) { + const clientError = new ClientError( + ERROR_CODES.VALIDATION_ERROR, + "Wrong delegation selected for withdrawal", + ); + throw clientError; + } + // Sign the withdrawal transaction for phase-1 delegation + const { stakingTx, finalityProviderPkHex, stakingValueSat, unbondingTx } = + selectedDelegation; + await submitWithdrawalTx( + { + stakingTimelock: stakingTx.timelock, + finalityProviderPksNoCoordHex: [finalityProviderPkHex], + stakingAmountSat: stakingValueSat, + }, + stakingTx.startHeight, + stakingTx.txHex, + unbondingTx?.txHex, + ); + // Update the local state with the new intermediate delegation + updateLocalStorage( + selectedDelegation, + DelegationState.INTERMEDIATE_WITHDRAWAL, + ); + } catch (error: any) { + logger.error(error); + handleError({ + error, + displayOptions: { + retryAction: () => handleModal(id, MODE_WITHDRAW), + }, + }); + } finally { + handleModalClose(); + } + }; + + const handleModal = (txID: string, mode: MODE) => { + setModalOpen(true); + setTxID(txID); + setModalMode(mode); + }; + + const handleModalClose = () => { + setModalOpen(false); + setTxID(""); + setModalMode(undefined); + setAwaitingWalletResponse(false); + setDelegationStepOptions(undefined); + setSelectedDelegation(undefined); + setStep(undefined); + }; + + useEffect(() => { + if (!delegationsAPI) { + return; + } + + setIntermediateDelegationsLocalStorage((intermediateDelegations) => { + if (!intermediateDelegations) { + return []; + } + + return intermediateDelegations.filter((intermediateDelegation) => { + const matchingDelegation = delegationsAPI.delegations.find( + (delegation) => + delegation?.stakingTxHashHex === + intermediateDelegation?.stakingTxHashHex, + ); + + if (!matchingDelegation) { + return true; // keep intermediate state if no matching state is found in the API + } + + // conditions based on intermediate states + if ( + intermediateDelegation.state === + DelegationState.INTERMEDIATE_TRANSITIONING + ) { + return !(matchingDelegation.state === DelegationState.TRANSITIONED); + } + + if ( + intermediateDelegation.state === + DelegationState.INTERMEDIATE_UNBONDING + ) { + return matchingDelegation.state !== DelegationState.UNBONDED; + } + + if ( + intermediateDelegation.state === + DelegationState.INTERMEDIATE_WITHDRAWAL + ) { + return matchingDelegation.state !== DelegationState.WITHDRAWN; + } + + return true; + }); + }); + }, [delegationsAPI, setIntermediateDelegationsLocalStorage]); + + useEffect(() => { + if (modalOpen && !selectedDelegation) { + const clientError = new ClientError( + ERROR_CODES.MISSING_DATA_ERROR, + "Delegation not found when modal is open", + ); + handleError({ + error: clientError, + displayOptions: { + noCancel: false, + }, + }); + setModalOpen(false); + setTxID(""); + setModalMode(undefined); + } + }, [modalOpen, selectedDelegation, handleError]); + + useEffect(() => { + const timerId = setInterval(() => setCurrentTime(Date.now()), ONE_MINUTE); + return () => clearInterval(timerId); + }, []); + + const onRegistration = async (delegation: DelegationInterface) => { + setSelectedDelegation(delegation); + setStep("registration-start"); + }; + + const handleProceed = async () => { + await registerPhase1Delegation(); + }; + + if (!connected || !delegationsAPI || !network) { + return null; + } + + // combine delegations from the API and local storage, prioritizing API data + const combinedDelegationsData = delegationsAPI + ? [...delegations, ...delegationsAPI.delegations] + : // if no API data, fallback to using only local storage delegations + delegations; + + return ( + <> + {combinedDelegationsData.length !== 0 && ( + + + Pending Registration + + + : null} + > + + + + + + + + + + + + + + {combinedDelegationsData?.map((delegation) => { + if (!delegation) return null; + const { stakingTx, stakingTxHashHex } = delegation; + const intermediateDelegation = + intermediateDelegationsLocalStorage.find( + (item) => item.stakingTxHashHex === stakingTxHashHex, + ); + + return ( + + handleModal(stakingTxHashHex, MODE_WITHDRAW) + } + onRegistration={onRegistration} + onUnbond={() => + handleModal(stakingTxHashHex, MODE_UNBOND) + } + intermediateState={intermediateDelegation?.state} + /> + ); + })} + +
+ Inception + + Finality Provider + + Amount + + Transaction ID + + Status + + Action +
+
+
+ )} + {modalMode === MODE_WITHDRAW && txID && selectedDelegation && ( + { + handleWithdraw(txID); + }} + processing={awaitingWalletResponse} + /> + )} + {modalMode === MODE_UNBOND && ( + { + handleUnbond(txID); + }} + processing={awaitingWalletResponse} + unbondingFeeSat={selectedDelegationUnbondingFeeSat} + unbondingTimeInBlocks={selectedDelegationUnbondingTime} + /> + )} + + + {step && Boolean(REGISTRATION_INDEXES[step]) && ( + + )} + + {step && Boolean(VERIFICATION_STEPS[step]) && ( + + )} + + + + ); +}; diff --git a/src/ui/common/components/Delegations/components/DelegationCell.tsx b/src/ui/common/components/Delegations/components/DelegationCell.tsx new file mode 100644 index 000000000..19a7cfa54 --- /dev/null +++ b/src/ui/common/components/Delegations/components/DelegationCell.tsx @@ -0,0 +1,20 @@ +import { twMerge } from "tailwind-merge"; + +interface DelegationCellProps { + children: React.ReactNode; + className?: string; +} + +export const DelegationCell: React.FC = ({ + children, + className = "", +}) => ( + + {children} + +); diff --git a/src/ui/common/components/Error/GenericError.tsx b/src/ui/common/components/Error/GenericError.tsx new file mode 100644 index 000000000..fa9e12452 --- /dev/null +++ b/src/ui/common/components/Error/GenericError.tsx @@ -0,0 +1,54 @@ +import { Button, Heading, Text } from "@babylonlabs-io/core-ui"; + +import BitcoinBlock from "@/ui/common/assets/bitcoin-block.svg"; +import { Container } from "@/ui/common/components/Container/Container"; + +import { Footer } from "../Footer/Footer"; +import { SimplifiedHeader } from "../Header/SimplifiedHeader"; + +interface Props { + title?: string; + message?: string; + image?: any; +} + +export default function GenericError({ + title = "Oops! Something Went Wrong", + message = `It looks like we’re experiencing a temporary issue on our end. + Our team is already on it, and we’ll have things back to normal as soon as possible. + Please check back shortly, and thank you for your patience!`, + image = BitcoinBlock, +}: Props) { + return ( +
+ + + +
+ Generic Error + + {title} + +
+ + {message} + +
+ +
+
+ +
+
+ ); +} diff --git a/src/ui/common/components/FAQ/FAQ.tsx b/src/ui/common/components/FAQ/FAQ.tsx new file mode 100644 index 000000000..b74ecafce --- /dev/null +++ b/src/ui/common/components/FAQ/FAQ.tsx @@ -0,0 +1,25 @@ +import { Card } from "@babylonlabs-io/core-ui"; + +import { Section as SectionContainer } from "@/ui/common/components/Section/Section"; +import { getNetworkConfigBTC } from "@/ui/common/config/network/btc"; + +import { questions } from "./data/questions"; +import { Section } from "./Section"; + +export const FAQ = () => { + const { coinName } = getNetworkConfigBTC(); + + return ( + + + {questions(coinName).map((question) => ( +
+ ))} + + + ); +}; diff --git a/src/ui/common/components/FAQ/Section.tsx b/src/ui/common/components/FAQ/Section.tsx new file mode 100644 index 000000000..2c2762f51 --- /dev/null +++ b/src/ui/common/components/FAQ/Section.tsx @@ -0,0 +1,39 @@ +import { + Accordion, + AccordionDetails, + AccordionSummary, + Heading, + Text, +} from "@babylonlabs-io/core-ui"; +import { ReactNode } from "react"; +import { AiOutlineMinus, AiOutlinePlus } from "react-icons/ai"; + +interface SectionProps { + title: string; + content: ReactNode; +} + +export const Section: React.FC = ({ title, content }) => { + return ( +
+ + + expanded ? ( + + ) : ( + + ) + } + > + + {title} + + + + {content} + + +
+ ); +}; diff --git a/src/ui/common/components/FAQ/data/questions.tsx b/src/ui/common/components/FAQ/data/questions.tsx new file mode 100644 index 000000000..564f03e91 --- /dev/null +++ b/src/ui/common/components/FAQ/data/questions.tsx @@ -0,0 +1,220 @@ +import { ReactNode } from "react"; + +export interface Question { + title: string; + content: ReactNode; +} + +export const questions = (coinName: string): Question[] => { + const questionList = [ + { + title: "What is Babylon?", + content: ( +

+ Babylon is a suite of security-sharing protocols that bring + Bitcoin's unparalleled security to the decentralized world. The + latest protocol, Bitcoin Staking, enables Bitcoin holders to stake + their Bitcoin to provide crypto-economic security to PoS + (proof-of-stake) systems in a trustless and self-custodial way. +

+ ), + }, + { + title: "How does Bitcoin Staking work?", + content: ( + <> +

+ {coinName} holders lock their {coinName} using the trustless and + self-custodial Bitcoin Staking script for a predetermined time + (timelock) in exchange for voting power in an underlying PoS + protocol. In return, Bitcoin holders will earn PoS staking rewards. +

+
+

+ Finality Providers perform the voting. A Finality Provider is an + entity responsible for casting votes on behalf of {coinName}{" "} + stakers, helping secure the PoS protocol. +

+
+

+ If a Finality Provider attacks the PoS system, the {coinName}s + behind the voting powers delegated to it will be subject to protocol + slashing. This deters {coinName} stakers and Finality Providers from + attacking the PoS system. +

+ + ), + }, + { + title: "What does this staking dApp allow me to do?", + content: ( +

+ The staking dApp is an interface to the Babylon Bitcoin Staking + protocol. It interacts with both the Bitcoin and Babylon Genesis + blockchains to create Bitcoin staking transactions, and to register + the stake and delegation of voting power to a selected Finality + Provider on the Babylon Genesis chain. The staked Bitcoin provides + slashable proof-of-stake security to Babylon Genesis and earns BABY as + staking reward. +

+ ), + }, + { + title: `Does my ${coinName} leave my wallet once staked?`, + content: ( +

+ Your {coinName} does not leave your custody. It is important to note + that once your {coinName} is staked, your wallet will not display your + locked {coinName} balance. This is because the current wallet software + has not been updated to display staked {coinName} balances. When + staking, you do not send the {coinName} to a third party. It is locked + in a self-custodial Bitcoin Staking script that you control. This + means that any subsequent movement of the {coinName} will need your + approval. You are the only one who can unbond the stake and withdraw. +

+ ), + }, + { + title: "Are there any other ways to stake?", + content: ( +

+ Users with a technical background can use the{" "} + + btc-staker CLI program + {" "} + to create {coinName} staking transactions from the CLI. +

+ ), + }, + { + title: + "Is it ok to use a wallet holding fungible tokens built on Bitcoin (e.g. BRC-20/ARC-20/Runes)?", + content: ( +

+ No, this should be avoided. Please do not connect or use a Bitcoin + wallet holding BRC-20, ARC-20, Runes, or other NFTs or Bitcoin-native + assets (other than {coinName}). They are still in their infancy and in + an experimental phase. Software built for the detection of such tokens + to avoid their misspending may not work, and you could lose all such + tokens. +

+ ), + }, + { + title: + "If I have multiple stakes funded by the same BTC address but using different BABY addresses, is there a way for me to view all?", + content: ( +

+ Yes. Click the dropdown next to the 'Connect Wallets' button + or the 'Wallets Connected' area, and toggle on "Linked + Wallet Stakes". This will display all delegations associated with + the connected {coinName} key, regardless of which Babylon account the + stakes are associated with. +

+ ), + }, + { + title: "Is there a staking cap?", + content: ( +

+ The Babylon Genesis launch included a two-week period (April 10 – 24, + 2025) during which registration was capped at Phase-1 Cap-1 stakes + (1,000 bitcoins). The system is now permissionless, with no cap. +

+ ), + }, + { + title: "Why do I need to connect two wallets?", + content: ( +

+ Because Bitcoin and Babylon Genesis are different networks with + different address formats, you'll need to connect two wallets. + However, If your wallet supports both networks, you can use it for + both connections. +

+ ), + }, + { + title: "Are there any geo-restrictions for accessing Babylon Genesis?", + content: ( +

+ Due to applicable laws and regulations, Babylon Genesis may not be + available in all jurisdictions. Users are advised to consult the{" "} + + Terms of Use + {" "} + to determine access eligibility based on their location. +

+ ), + }, + { + title: "How should I choose my finality provider?", + content: ( +

+ If you'd like to learn more about a specific Finality Provider, + we recommend doing your own due diligence by starting with their + website or social channels. +

+ ), + }, + { + title: "How long will it take for my stake to become active?", + content: ( +

+ Your stake becomes active after it receives at least 10 Bitcoin block + confirmations and is registered and verified by the Babylon Genesis + chain. This process typically takes around 100 minutes, depending on + Bitcoin network conditions. +

+ ), + }, + { + title: "What is slashing and can it happen to me?", + content: ( +

+ When you stake {coinName} in Babylon Genesis, your {coinName} remains + locked in a self-custodial Bitcoin script — you do not transfer + custody to Babylon or any third party. However, staking carries + slashing risk. When you stake, you pre-authorize a slashing condition + within the Bitcoin script. If a cryptographic offense occurs — such as + a Finality Provider (FP) you staked against double-signing — a + predefined percentage of your staked {coinName} can be slashed + (burned) without needing further authorization from you. +

+ ), + }, + { + title: "Will I pay any fees for staking?", + content: ( + <> +

Yes. There are two types of fees:

+
+ Babylon Genesis Network Fees +

+ You'll pay a gas fee when registering your stake and when + claiming rewards. +

+
+ Bitcoin Network Fees +

+ {coinName} is required to cover fees for staking, unbonding, and + withdrawing. +

+
+

Fees vary depending on network conditions.

+ + ), + }, + ]; + return questionList; +}; diff --git a/src/ui/common/components/FAQ/index.ts b/src/ui/common/components/FAQ/index.ts new file mode 100644 index 000000000..5054f901d --- /dev/null +++ b/src/ui/common/components/FAQ/index.ts @@ -0,0 +1 @@ +export { FAQ } from "./FAQ"; diff --git a/src/ui/common/components/Footer/Footer.tsx b/src/ui/common/components/Footer/Footer.tsx new file mode 100644 index 000000000..1d69ed363 --- /dev/null +++ b/src/ui/common/components/Footer/Footer.tsx @@ -0,0 +1,116 @@ +import { Text } from "@babylonlabs-io/core-ui"; +import { + BsDiscord, + BsGithub, + BsLinkedin, + BsMedium, + BsTelegram, +} from "react-icons/bs"; +import { FaXTwitter } from "react-icons/fa6"; +import { GoHome } from "react-icons/go"; +import { IoMdBook } from "react-icons/io"; +import { MdAlternateEmail, MdForum } from "react-icons/md"; + +import { Container } from "@/ui/common/components/Container/Container"; + +import { Logo } from "../Logo/Logo"; + +const iconLinks = [ + { + name: "Website", + url: "https://babylonlabs.io", + Icon: GoHome, + }, + { + name: "X", + url: "https://x.com/babylonlabs_io", + Icon: FaXTwitter, + }, + { + name: "GitHub", + url: "https://github.com/babylonlabs-io", + Icon: BsGithub, + }, + { + name: "Telegram", + url: "https://t.me/babyloncommunity", + Icon: BsTelegram, + }, + { + name: "LinkedIn", + url: "https://www.linkedin.com/company/babylon-labs-official", + Icon: BsLinkedin, + }, + { + name: "Medium", + url: "https://medium.com/babylonlabs-io", + Icon: BsMedium, + }, + { + name: "Docs", + url: "https://docs.babylonlabs.io/", + Icon: IoMdBook, + }, + { + name: "Forum", + url: "https://forum.babylonlabs.io/", + Icon: MdForum, + }, + { + name: "Email", + url: "mailto:contact@babylonlabs.io", + Icon: MdAlternateEmail, + }, + { + name: "Discord", + url: "https://discord.com/invite/babylonglobal", + Icon: BsDiscord, + }, +]; + +export const Footer: React.FC = () => { + return ( + + ); +}; diff --git a/src/ui/common/components/Hash/Hash.tsx b/src/ui/common/components/Hash/Hash.tsx new file mode 100644 index 000000000..07e1647a8 --- /dev/null +++ b/src/ui/common/components/Hash/Hash.tsx @@ -0,0 +1,88 @@ +import { Text } from "@babylonlabs-io/core-ui"; +import { useEffect, useState } from "react"; +import { FiCopy } from "react-icons/fi"; +import { IoIosCheckmarkCircle } from "react-icons/io"; +import { twMerge } from "tailwind-merge"; +import { useCopyToClipboard } from "usehooks-ts"; + +import { trim } from "@/ui/common/utils/trim"; + +interface HashProps { + value: string; + noFade?: boolean; + address?: boolean; + small?: boolean; + fullWidth?: boolean; + symbols?: number; + size?: React.ComponentProps["variant"]; + className?: string; + noCopy?: boolean; +} + +export const Hash: React.FC = ({ + value, + noFade, + address, + small, + fullWidth, + className, + symbols = 8, + noCopy = false, + size = "body2", +}) => { + const [, copy] = useCopyToClipboard(); + const [copiedText, setCopiedText] = useState(""); + + const handleCopy = () => { + if (!value || noCopy) return; + setCopiedText("Copied!"); + copy(value); + }; + + useEffect(() => { + const timer = setTimeout(() => { + setCopiedText(""); + }, 2000); + return () => clearTimeout(timer); + }, [copiedText]); + + if (!value) { + return -; + } + + return ( +
+ + {copiedText ? ( + copiedText + ) : ( + <> + {!address && 0x} + {trim(value, symbols) ?? value} + + )} + + {!noCopy && + (copiedText ? ( + + ) : ( + + ))} +
+ ); +}; diff --git a/src/ui/common/components/Header/Header.tsx b/src/ui/common/components/Header/Header.tsx new file mode 100644 index 000000000..8e51bc3f9 --- /dev/null +++ b/src/ui/common/components/Header/Header.tsx @@ -0,0 +1,24 @@ +import { useWalletConnect } from "@babylonlabs-io/wallet-connector"; + +import { Container } from "@/ui/common/components/Container/Container"; +import { useAppState } from "@/ui/common/state"; + +import { SmallLogo } from "../Logo/SmallLogo"; +import { Connect } from "../Wallet/Connect"; + +export const Header = () => { + const { open } = useWalletConnect(); + const { isLoading: loading } = useAppState(); + + return ( +
+ + + +
+ +
+
+
+ ); +}; diff --git a/src/ui/common/components/Header/SimplifiedHeader.tsx b/src/ui/common/components/Header/SimplifiedHeader.tsx new file mode 100644 index 000000000..75da9b221 --- /dev/null +++ b/src/ui/common/components/Header/SimplifiedHeader.tsx @@ -0,0 +1,25 @@ +import { twJoin } from "tailwind-merge"; + +import { Container } from "../Container/Container"; +import { Logo } from "../Logo/Logo"; + +export const SimplifiedHeader = ({ + isMinimal = false, +}: { + isMinimal?: boolean; +}) => { + return ( + + ); +}; diff --git a/src/ui/common/components/Icons/ThemedIcon.tsx b/src/ui/common/components/Icons/ThemedIcon.tsx new file mode 100644 index 000000000..87fa92ed1 --- /dev/null +++ b/src/ui/common/components/Icons/ThemedIcon.tsx @@ -0,0 +1,55 @@ +import { twJoin } from "tailwind-merge"; + +interface ThemedIconProps { + children: React.ReactNode; + className?: string; + size?: number; + variant?: "default" | "primary" | "secondary" | "error" | "success"; + background?: boolean; + rounded?: boolean; +} + +export const ThemedIcon = ({ + children, + className = "", + size = 40, + variant = "default", + background = false, + rounded = false, +}: ThemedIconProps) => { + const variants = { + default: "text-accent-secondary", + primary: "text-primary-light", + secondary: "text-accent-secondary", + error: "text-error-main", + success: "text-success-main", + }; + + const backgroundStyles = { + default: "bg-accent-secondary/10", + primary: "bg-primary-light/10 dark:bg-[#47484A]", + secondary: "bg-accent-secondary/10", + error: "bg-error-main/10", + success: "bg-success-main/10", + }; + + return ( +
+ {children} +
+ ); +}; diff --git a/src/ui/common/components/Icons/common/CopyIcon.tsx b/src/ui/common/components/Icons/common/CopyIcon.tsx new file mode 100644 index 000000000..3dcdfdadf --- /dev/null +++ b/src/ui/common/components/Icons/common/CopyIcon.tsx @@ -0,0 +1,19 @@ +import { BaseIconProps } from "../index"; + +export const CopyIcon = ({ className = "", size = 16 }: BaseIconProps) => { + return ( + + + + ); +}; diff --git a/src/ui/common/components/Icons/index.ts b/src/ui/common/components/Icons/index.ts new file mode 100644 index 000000000..488747ddd --- /dev/null +++ b/src/ui/common/components/Icons/index.ts @@ -0,0 +1,20 @@ +// Base icon props interface +export interface BaseIconProps { + className?: string; + size?: number; +} + +// Extended icon props with variant support +export interface IconProps extends BaseIconProps { + variant?: "default" | "primary" | "secondary" | "error" | "success"; +} + +export { ThemedIcon } from "./ThemedIcon"; + +// Wallet icons +export { BitcoinPublicKeyIcon } from "./wallet/BitcoinPublicKeyIcon"; +export { LinkWalletIcon } from "./wallet/LinkWalletIcon"; +export { UsingInscriptionIcon } from "./wallet/UsingInscriptionIcon"; + +// Common icons +export { CopyIcon } from "./common/CopyIcon"; diff --git a/src/ui/common/components/Icons/wallet/BitcoinPublicKeyIcon.tsx b/src/ui/common/components/Icons/wallet/BitcoinPublicKeyIcon.tsx new file mode 100644 index 000000000..ccd569735 --- /dev/null +++ b/src/ui/common/components/Icons/wallet/BitcoinPublicKeyIcon.tsx @@ -0,0 +1,20 @@ +import { BaseIconProps } from "../index"; +import { ThemedIcon } from "../ThemedIcon"; + +export const BitcoinPublicKeyIcon = ({ + className = "", + size = 24, +}: BaseIconProps) => { + return ( + + + + + + ); +}; diff --git a/src/ui/common/components/Icons/wallet/LinkWalletIcon.tsx b/src/ui/common/components/Icons/wallet/LinkWalletIcon.tsx new file mode 100644 index 000000000..012c0ddb5 --- /dev/null +++ b/src/ui/common/components/Icons/wallet/LinkWalletIcon.tsx @@ -0,0 +1,20 @@ +import { BaseIconProps } from "../index"; +import { ThemedIcon } from "../ThemedIcon"; + +export const LinkWalletIcon = ({ + className = "", + size = 24, +}: BaseIconProps) => { + return ( + + + + + + ); +}; diff --git a/src/ui/common/components/Icons/wallet/UsingInscriptionIcon.tsx b/src/ui/common/components/Icons/wallet/UsingInscriptionIcon.tsx new file mode 100644 index 000000000..9f03d5fac --- /dev/null +++ b/src/ui/common/components/Icons/wallet/UsingInscriptionIcon.tsx @@ -0,0 +1,21 @@ +import { BaseIconProps } from "../index"; +import { ThemedIcon } from "../ThemedIcon"; + +export const UsingInscriptionIcon = ({ + className = "", + size = 24, +}: BaseIconProps) => { + return ( + + + + + + + ); +}; diff --git a/src/ui/common/components/Loading/Loading.tsx b/src/ui/common/components/Loading/Loading.tsx new file mode 100644 index 000000000..1653397fe --- /dev/null +++ b/src/ui/common/components/Loading/Loading.tsx @@ -0,0 +1,38 @@ +import { twJoin } from "tailwind-merge"; + +interface LoadingProps { + text?: string; + noBorder?: boolean; +} + +export const LoadingView: React.FC = ({ text, noBorder }) => { + return ( +
+ +

{text || "Please wait..."}

+
+ ); +}; + +export const LoadingTableList: React.FC = () => { + return ( +
+ +

Please wait...

+
+ ); +}; + +export const LoadingSmall: React.FC = ({ text }) => { + return ( +
+

{text || "Please wait..."}

+ +
+ ); +}; diff --git a/src/ui/common/components/Logo/Icon.tsx b/src/ui/common/components/Logo/Icon.tsx new file mode 100644 index 000000000..7073e41fb --- /dev/null +++ b/src/ui/common/components/Logo/Icon.tsx @@ -0,0 +1,33 @@ +import { useTheme } from "next-themes"; +import { useEffect, useState } from "react"; + +import darkIcon from "@/ui/common/assets/icon-black.svg"; +import lightIcon from "@/ui/common/assets/icon-white.svg"; + +export const Icon = () => { + const [mounted, setMounted] = useState(false); + const { resolvedTheme } = useTheme(); + const lightSelected = resolvedTheme === "light"; + + // useEffect only runs on the client, so now we can safely show the UI + useEffect(() => { + setMounted(true); + }, []); + + // uses placeholder of babylon logo with primary color + // since before theme is resolved, we don't know which logo to show + if (!mounted) { + return ; + } + + return ( + + Babylon Genesis + + ); +}; diff --git a/src/ui/common/components/Logo/Logo.tsx b/src/ui/common/components/Logo/Logo.tsx new file mode 100644 index 000000000..7bb26e7cd --- /dev/null +++ b/src/ui/common/components/Logo/Logo.tsx @@ -0,0 +1,52 @@ +import { twMerge } from "tailwind-merge"; + +interface LogoProps { + className?: string; +} + +export const Logo: React.FC = ({ className }) => ( + + + + + + + + + + + + + +); diff --git a/src/ui/common/components/Logo/SmallLogo.tsx b/src/ui/common/components/Logo/SmallLogo.tsx new file mode 100644 index 000000000..36fbeadc7 --- /dev/null +++ b/src/ui/common/components/Logo/SmallLogo.tsx @@ -0,0 +1,48 @@ +export const SmallLogo: React.FC = () => ( + + + + + + + + + + + + + +); diff --git a/src/ui/common/components/Menu/SettingMenu/components/SettingMenuButton.tsx b/src/ui/common/components/Menu/SettingMenu/components/SettingMenuButton.tsx new file mode 100644 index 000000000..4a2de16e3 --- /dev/null +++ b/src/ui/common/components/Menu/SettingMenu/components/SettingMenuButton.tsx @@ -0,0 +1,32 @@ +import type { HTMLAttributes } from "react"; +import { forwardRef } from "react"; +import { twJoin } from "tailwind-merge"; + +import cogIcon from "@/ui/common/assets/cog.svg"; + +interface SettingMenuButtonProps extends HTMLAttributes { + toggleMenu: () => void; +} +export const SettingMenuButton = forwardRef< + HTMLButtonElement, + SettingMenuButtonProps +>(({ className, toggleMenu, ...props }, ref) => { + return ( + + ); +}); + +SettingMenuButton.displayName = "SettingMenuButton"; diff --git a/src/ui/common/components/Menu/SettingMenu/components/SettingMenuContainer.tsx b/src/ui/common/components/Menu/SettingMenu/components/SettingMenuContainer.tsx new file mode 100644 index 000000000..5ad49c747 --- /dev/null +++ b/src/ui/common/components/Menu/SettingMenu/components/SettingMenuContainer.tsx @@ -0,0 +1,42 @@ +import { MobileDialog, Popover } from "@babylonlabs-io/core-ui"; + +import { useIsMobileView } from "@/ui/common/hooks/useBreakpoint"; + +interface SettingMenuContainerProps { + anchorEl: HTMLElement | null; + children: React.ReactNode; + className?: string; + isOpen: boolean; + onClose: () => void; +} + +export const SettingMenuContainer = ({ + anchorEl, + children, + className, + isOpen, + onClose, +}: SettingMenuContainerProps) => { + const isMobileView = useIsMobileView(); + + if (isMobileView) { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); +}; diff --git a/src/ui/common/components/Menu/SettingMenu/components/SettingMenuContent.tsx b/src/ui/common/components/Menu/SettingMenu/components/SettingMenuContent.tsx new file mode 100644 index 000000000..4ffa39f75 --- /dev/null +++ b/src/ui/common/components/Menu/SettingMenu/components/SettingMenuContent.tsx @@ -0,0 +1,14 @@ +import { ThemeToggle } from "@/ui/common/components/ThemeToggle/ThemeToggle"; +interface SettingMenuContentProps { + className?: string; +} + +export const SettingMenuContent = ({ className }: SettingMenuContentProps) => { + return ( +
+
+ +
+
+ ); +}; diff --git a/src/ui/common/components/Menu/SettingMenu/index.ts b/src/ui/common/components/Menu/SettingMenu/index.ts new file mode 100644 index 000000000..a87f4b7e9 --- /dev/null +++ b/src/ui/common/components/Menu/SettingMenu/index.ts @@ -0,0 +1,3 @@ +export { SettingMenuButton } from "./components/SettingMenuButton"; +export { SettingMenuContainer } from "./components/SettingMenuContainer"; +export { SettingMenuContent } from "./components/SettingMenuContent"; diff --git a/src/ui/common/components/Menu/WalletMenu/components/Toggle.tsx b/src/ui/common/components/Menu/WalletMenu/components/Toggle.tsx new file mode 100644 index 000000000..f5eaab401 --- /dev/null +++ b/src/ui/common/components/Menu/WalletMenu/components/Toggle.tsx @@ -0,0 +1,49 @@ +import { twJoin } from "tailwind-merge"; + +interface NewToggleProps { + value: boolean; + onChange: (value: boolean) => void; + className?: string; + disabled?: boolean; +} + +export const NewToggle = ({ + value, + onChange, + className = "", + disabled = false, +}: NewToggleProps) => { + const handleClick = () => { + if (!disabled) { + onChange(!value); + } + }; + + return ( + + ); +}; diff --git a/src/ui/common/components/Menu/WalletMenu/components/WalletInfoSection.tsx b/src/ui/common/components/Menu/WalletMenu/components/WalletInfoSection.tsx new file mode 100644 index 000000000..f40e342bb --- /dev/null +++ b/src/ui/common/components/Menu/WalletMenu/components/WalletInfoSection.tsx @@ -0,0 +1,106 @@ +import { Avatar, Text } from "@babylonlabs-io/core-ui"; + +import bbnIcon from "@/ui/common/assets/bbn.svg"; +import bitcoin from "@/ui/common/assets/bitcoin.png"; +import { Hash } from "@/ui/common/components/Hash/Hash"; +import { CopyIcon } from "@/ui/common/components/Icons"; + +interface WalletInfoProps { + btcAddress: string; + bbnAddress: string; + selectedWallets: Record; +} + +export const WalletInfoSection = ({ + btcAddress, + bbnAddress, + selectedWallets, +}: WalletInfoProps) => { + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + }; + + return ( +
+ {/* Bitcoin Wallet */} +
+
+
+ +
+ +
+ + Bitcoin Wallet + +
+ +
+ + +
+
+
+ + {/* Babylon Wallet */} +
+
+
+ +
+ +
+ + Babylon Wallet + +
+ +
+ + +
+
+
+
+ ); +}; diff --git a/src/ui/common/components/Menu/WalletMenu/components/WalletMenuContainer.tsx b/src/ui/common/components/Menu/WalletMenu/components/WalletMenuContainer.tsx new file mode 100644 index 000000000..adafc034a --- /dev/null +++ b/src/ui/common/components/Menu/WalletMenu/components/WalletMenuContainer.tsx @@ -0,0 +1,157 @@ +import { MobileDialog, Popover } from "@babylonlabs-io/core-ui"; +import { useWalletConnect } from "@babylonlabs-io/wallet-connector"; +import { useCallback, useEffect, useState } from "react"; +import { twJoin } from "tailwind-merge"; + +import { WalletDisconnectModal } from "@/ui/common/components/Modals/WalletDisconnectModal"; +import { useIsMobileView } from "@/ui/common/hooks/useBreakpoint"; + +import { WalletInfoSection } from "./WalletInfoSection"; +import { WalletSettingsSection } from "./WalletSettingsSection"; + +interface WalletMenuProps { + trigger: React.ReactNode; + btcAddress: string; + bbnAddress: string; + selectedWallets: Record; + ordinalsExcluded: boolean; + linkedDelegationsVisibility: boolean; + onIncludeOrdinals: () => void; + onExcludeOrdinals: () => void; + onDisplayLinkedDelegations: (value: boolean) => void; + publicKeyNoCoord: string; + forceOpen?: boolean; + onOpenChange?: (isOpen: boolean) => void; +} + +export const WalletMenuContainer = ({ + trigger, + btcAddress, + bbnAddress, + selectedWallets, + ordinalsExcluded, + linkedDelegationsVisibility, + onIncludeOrdinals, + onExcludeOrdinals, + onDisplayLinkedDelegations, + publicKeyNoCoord, + forceOpen = false, + onOpenChange, +}: WalletMenuProps) => { + const isMobile = useIsMobileView(); + const [isPopoverOpen, setIsPopoverOpen] = useState(forceOpen); + const [mobileDialogOpen, setMobileDialogOpen] = useState(forceOpen); + const [anchorEl, setAnchorEl] = useState(null); + const [showDisconnectModal, setShowDisconnectModal] = useState(false); + + const { disconnect } = useWalletConnect(); + + // Notify parent when menu open state changes + useEffect(() => { + const isOpen = isMobile ? mobileDialogOpen : isPopoverOpen; + onOpenChange?.(isOpen); + }, [isPopoverOpen, mobileDialogOpen, isMobile, onOpenChange]); + + const handleDisconnectClick = useCallback(() => { + setShowDisconnectModal(true); + }, []); + + const handleDisconnectCancel = useCallback(() => { + setShowDisconnectModal(false); + }, []); + + const handleDisconnectConfirm = useCallback(() => { + setShowDisconnectModal(false); + setIsPopoverOpen(false); + setMobileDialogOpen(false); + disconnect(); + }, [disconnect]); + + const menuContent = ( +
+ + + + +
+ +
+
+ ); + + const handleTriggerClick = (event: React.MouseEvent) => { + event.stopPropagation(); + if (isMobile) { + setMobileDialogOpen(true); + } else { + setAnchorEl(event.currentTarget); + setIsPopoverOpen(!isPopoverOpen); + } + }; + + const handleCloseMenu = useCallback(() => { + setIsPopoverOpen(false); + setMobileDialogOpen(false); + }, []); + + if (isMobile) { + return ( + <> +
{trigger}
+ + {menuContent} + + + + ); + } + + return ( + <> +
{trigger}
+ + {menuContent} + + + + ); +}; diff --git a/src/ui/common/components/Menu/WalletMenu/components/WalletSettingsSection.tsx b/src/ui/common/components/Menu/WalletMenu/components/WalletSettingsSection.tsx new file mode 100644 index 000000000..4011b4a6c --- /dev/null +++ b/src/ui/common/components/Menu/WalletMenu/components/WalletSettingsSection.tsx @@ -0,0 +1,112 @@ +import { Text } from "@babylonlabs-io/core-ui"; + +import { Hash } from "@/ui/common/components/Hash/Hash"; +import { + BitcoinPublicKeyIcon, + CopyIcon, + LinkWalletIcon, + UsingInscriptionIcon, +} from "@/ui/common/components/Icons"; + +import { NewToggle } from "./Toggle"; + +interface WalletSettingsProps { + ordinalsExcluded: boolean; + linkedDelegationsVisibility: boolean; + onIncludeOrdinals: () => void; + onExcludeOrdinals: () => void; + onDisplayLinkedDelegations: (value: boolean) => void; + publicKeyNoCoord: string; +} + +export const WalletSettingsSection = ({ + ordinalsExcluded, + linkedDelegationsVisibility, + onIncludeOrdinals, + onExcludeOrdinals, + onDisplayLinkedDelegations, + publicKeyNoCoord, +}: WalletSettingsProps) => { + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + }; + + return ( +
+ {/* Using Inscriptions Toggle */} +
+
+ +
+ + Using Inscriptions + + + {ordinalsExcluded ? "Off" : "On"} + +
+
+ + value ? onIncludeOrdinals() : onExcludeOrdinals() + } + /> +
+ + {/* Linked Wallet Stakes Toggle */} +
+
+ +
+ + Linked Wallet Stakes + + + {linkedDelegationsVisibility ? "On" : "Off"} + +
+
+ +
+ + {/* Bitcoin Public Key */} +
+
+ +
+ + Bitcoin Public Key + + +
+
+ +
+
+ ); +}; diff --git a/src/ui/common/components/Menu/WalletMenu/index.ts b/src/ui/common/components/Menu/WalletMenu/index.ts new file mode 100644 index 000000000..6dbd786ee --- /dev/null +++ b/src/ui/common/components/Menu/WalletMenu/index.ts @@ -0,0 +1,4 @@ +export { NewToggle } from "./components/Toggle"; +export { WalletInfoSection } from "./components/WalletInfoSection"; +export { WalletMenuContainer } from "./components/WalletMenuContainer"; +export { WalletSettingsSection } from "./components/WalletSettingsSection"; diff --git a/src/ui/common/components/Menu/index.ts b/src/ui/common/components/Menu/index.ts new file mode 100644 index 000000000..df1ca8cd2 --- /dev/null +++ b/src/ui/common/components/Menu/index.ts @@ -0,0 +1,3 @@ +export * from "./SettingMenu"; + +export * from "./WalletMenu"; diff --git a/src/ui/common/components/Modals/CancelFeedbackModal.tsx b/src/ui/common/components/Modals/CancelFeedbackModal.tsx new file mode 100644 index 000000000..9dafb066e --- /dev/null +++ b/src/ui/common/components/Modals/CancelFeedbackModal.tsx @@ -0,0 +1,68 @@ +import { LuPartyPopper } from "react-icons/lu"; + +import { SubmitModal } from "./SubmitModal"; + +interface FeedbackModalProps { + open: boolean; + onClose: () => void; +} + +export const CancelFeedbackModal: React.FC = ({ + open, + onClose, +}) => ( + } + title="We Value Your Feedback" + open={open} + submitButton="Done" + cancelButton="" + onSubmit={onClose} + > +

+ It looks like you didn’t complete your staking journey. We'd love to + help you get back on track or hear about any issues you faced. +

+ + + +

+ Your feedback is crucial for us to improve and provide a seamless + experience. Thank you for being a part of our Bitcoin Staking Protocol! +

+
+); diff --git a/src/ui/common/components/Modals/ClaimRewardModal.tsx b/src/ui/common/components/Modals/ClaimRewardModal.tsx new file mode 100644 index 000000000..de30a974d --- /dev/null +++ b/src/ui/common/components/Modals/ClaimRewardModal.tsx @@ -0,0 +1,85 @@ +import { Heading, Text } from "@babylonlabs-io/core-ui"; +import { PropsWithChildren } from "react"; + +import { shouldDisplayTestingMsg } from "@/ui/common/config"; +import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn"; +import { ubbnToBaby } from "@/ui/common/utils/bbn"; +import { trim } from "@/ui/common/utils/trim"; + +import { LoadingSmall } from "../Loading/Loading"; + +import { ConfirmationModal } from "./ConfirmationModal"; + +interface ConfirmationModalProps { + processing: boolean; + open: boolean; + address: string; + receivingValue: string; + transactionFee: number; + onClose: () => void; + onSubmit: () => void; +} + +const { coinSymbol } = getNetworkConfigBBN(); + +export const ClaimRewardModal = ({ + open, + processing, + receivingValue, + address, + transactionFee, + onClose, + onSubmit, +}: PropsWithChildren) => { + return ( + +
+
+
+ + Receiving + + + {receivingValue} {coinSymbol} + +
+ +
+ + Babylon {shouldDisplayTestingMsg() ? "Test" : ""} Chain Address + + {trim(address, 14)} +
+
+ Transaction Fees + {transactionFee === 0 ? ( + + ) : ( + + {ubbnToBaby(transactionFee)} {coinSymbol} + + )} +
+
+
+ Attention! + + Processing your claim will take approximately 2 blocks to complete. + + {shouldDisplayTestingMsg() && ( + + {coinSymbol} is a test token without any real world value. + + )} +
+
+
+ ); +}; diff --git a/src/ui/common/components/Modals/ClaimStatusModal/ClaimStatusModal.tsx b/src/ui/common/components/Modals/ClaimStatusModal/ClaimStatusModal.tsx new file mode 100644 index 000000000..d763c3bd9 --- /dev/null +++ b/src/ui/common/components/Modals/ClaimStatusModal/ClaimStatusModal.tsx @@ -0,0 +1,57 @@ +import { Loader } from "@babylonlabs-io/core-ui"; +import { BiSolidBadgeCheck } from "react-icons/bi"; + +import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn"; + +import { SubmitModal } from "../SubmitModal"; + +import { SuccessContent } from "./SuccessContent"; + +interface ClaimStatusModalProps { + open: boolean; + onClose?: () => void; + loading: boolean; + transactionHash?: string; +} + +const { coinSymbol } = getNetworkConfigBBN(); + +const MODAL_STEP = { + processing: { + icon: , + title: "Processing Claim", + submitButton: "", + cancelButton: "", + content: null, + }, + success: { + icon: , + title: `Successfully Claimed ${coinSymbol}`, + submitButton: "Done", + cancelButton: "", + content: (txHash?: string) => , + }, +}; + +export const ClaimStatusModal = ({ + open, + onClose, + loading, + transactionHash, +}: ClaimStatusModalProps) => { + const config = loading ? MODAL_STEP.processing : MODAL_STEP.success; + + return ( + + {config.content?.(transactionHash)} + + ); +}; diff --git a/src/ui/common/components/Modals/ClaimStatusModal/SuccessContent.tsx b/src/ui/common/components/Modals/ClaimStatusModal/SuccessContent.tsx new file mode 100644 index 000000000..9b96181c1 --- /dev/null +++ b/src/ui/common/components/Modals/ClaimStatusModal/SuccessContent.tsx @@ -0,0 +1,38 @@ +import { Text } from "@babylonlabs-io/core-ui"; + +import { Hash } from "@/ui/common/components/Hash/Hash"; +import { BABYLON_EXPLORER } from "@/ui/common/constants"; +import { trim } from "@/ui/common/utils/trim"; + +export const SuccessContent = ({ + transactionHash, +}: { + transactionHash?: string; +}) => ( +
+ + Your claim has been submitted and will be processed in 2 blocks. + + {transactionHash && ( +
+
+ + Transaction Hash: + + {BABYLON_EXPLORER ? ( + + {trim(transactionHash, 8)} + + ) : ( + + )} +
+
+ )} +
+); diff --git a/src/ui/common/components/Modals/ConfirmationModal.tsx b/src/ui/common/components/Modals/ConfirmationModal.tsx new file mode 100644 index 000000000..f7f625ffb --- /dev/null +++ b/src/ui/common/components/Modals/ConfirmationModal.tsx @@ -0,0 +1,63 @@ +import { + Button, + DialogBody, + DialogFooter, + DialogHeader, + Loader, +} from "@babylonlabs-io/core-ui"; +import { PropsWithChildren } from "react"; + +import { ResponsiveDialog } from "./ResponsiveDialog"; + +interface ConfirmationModalProps { + className?: string; + processing: boolean; + open: boolean; + title: string; + onClose: () => void; + onSubmit: () => void; +} + +export const ConfirmationModal = ({ + className, + processing, + open, + title, + children, + onClose, + onSubmit, +}: PropsWithChildren) => ( + + + + {children} + + + + + + + +); diff --git a/src/ui/common/components/Modals/ErrorModal.tsx b/src/ui/common/components/Modals/ErrorModal.tsx new file mode 100644 index 000000000..1e1de359a --- /dev/null +++ b/src/ui/common/components/Modals/ErrorModal.tsx @@ -0,0 +1,154 @@ +import { + Button, + DialogBody, + DialogFooter, + Heading, + Text, +} from "@babylonlabs-io/core-ui"; +import { useEffect, useState } from "react"; +import { FiCheck, FiCopy } from "react-icons/fi"; + +import WarningTriangle from "@/ui/common/assets/warning-triangle.svg"; +import { useError } from "@/ui/common/context/Error/ErrorProvider"; +import { ErrorType, ShowErrorParams } from "@/ui/common/types/errors"; +import { getCommitHash } from "@/ui/common/utils/version"; + +import { ResponsiveDialog } from "./ResponsiveDialog"; + +export const ErrorModal: React.FC = () => { + const { error, modalOptions, dismissError, isOpen } = useError(); + const { retryAction, noCancel } = modalOptions; + const [copied, setCopied] = useState(false); + const version = getCommitHash(); + + const handleRetry = () => { + const retryErrorParam: ShowErrorParams = { + error: { + message: error.message, + type: error.type, + }, + retryAction: retryAction, + }; + + dismissError(); + + setTimeout(() => { + if (retryErrorParam.retryAction) { + retryErrorParam.retryAction(); + } + }, 300); + }; + + const ERROR_TITLES = { + [ErrorType.SERVER]: "Server Error", + [ErrorType.WITHDRAW]: "Withdraw Error", + [ErrorType.STAKING]: "Stake Error", + [ErrorType.UNBONDING]: "Unbonding Error", + [ErrorType.REGISTRATION]: "Transition Error", + [ErrorType.DELEGATIONS]: "Delegations Error", + [ErrorType.WALLET]: "Wallet Error", + [ErrorType.UNKNOWN]: "System Error", + }; + + const ERROR_MESSAGES = { + [ErrorType.SERVER]: "Error fetching data due to:", + [ErrorType.UNBONDING]: "Your request to unbond failed due to:", + [ErrorType.WITHDRAW]: "Failed to withdraw due to:", + [ErrorType.STAKING]: "Failed to stake due to:", + [ErrorType.DELEGATIONS]: "Failed to fetch delegations due to:", + [ErrorType.REGISTRATION]: "Failed to transition due to:", + [ErrorType.WALLET]: "Failed to perform wallet action due to:", + [ErrorType.UNKNOWN]: "A system error occurred:", + }; + + const getErrorTitle = () => { + return ERROR_TITLES[error.type ?? ErrorType.UNKNOWN]; + }; + + const getErrorMessage = () => { + const prefix = ERROR_MESSAGES[error.type ?? ErrorType.UNKNOWN]; + return `${prefix} ${error.displayMessage || error.message}`; + }; + + const copyErrorDetails = () => { + const errorDetails = JSON.stringify( + { + date: new Date().toISOString(), + device: navigator.userAgent, + version, + release: version, + environment: process.env.NODE_ENV, + ...error, + }, + null, + 2, + ); + + navigator.clipboard.writeText(errorDetails); + setCopied(true); + }; + + useEffect(() => { + if (copied) { + const timer = setTimeout(() => setCopied(false), 2000); + return () => clearTimeout(timer); + } + }, [copied]); + + return ( + + +
+ Warning +
+ + + {getErrorTitle()} + + +
+ + {getErrorMessage()} + + +
+ +
+
+
+ + + {!noCancel && ( // Only show the cancel button if noCancel is false or undefined + + )} + {retryAction && ( + + )} + +
+ ); +}; diff --git a/src/ui/common/components/Modals/InfoModal.tsx b/src/ui/common/components/Modals/InfoModal.tsx new file mode 100644 index 000000000..bbd3d187b --- /dev/null +++ b/src/ui/common/components/Modals/InfoModal.tsx @@ -0,0 +1,65 @@ +import { + Button, + DialogBody, + DialogFooter, + DialogHeader, + Text, +} from "@babylonlabs-io/core-ui"; + +import { getNetworkConfigBTC } from "@/ui/common/config/network/btc"; +import { useNetworkInfo } from "@/ui/common/hooks/client/api/useNetworkInfo"; +import { blocksToDisplayTime } from "@/ui/common/utils/time"; + +import { ResponsiveDialog } from "./ResponsiveDialog"; + +interface InfoModalProps { + open: boolean; + onClose: () => void; +} + +const { coinName } = getNetworkConfigBTC(); + +export function InfoModal({ open, onClose }: InfoModalProps) { + const { data: networkInfo } = useNetworkInfo(); + + const unbondingTime = blocksToDisplayTime( + networkInfo?.params.bbnStakingParams?.latestParam?.unbondingTime, + ); + const maxStakingPeriod = blocksToDisplayTime( + networkInfo?.params.bbnStakingParams?.latestParam?.maxStakingTimeBlocks, + ); + + return ( + + + +
+ + Stakes made through this dashboard are locked for up to{" "} + {maxStakingPeriod}. You can on-demand unbond at any time, with + withdrawal available after a {unbondingTime} unbonding period. If + the maximum staking period expires, your stake becomes withdrawable + automatically, with no need for prior unbonding. + + + Note: Timeframes are approximate, based on an average {coinName}{" "} + block time of 10 minutes. + +
+
+ + + +
+ ); +} diff --git a/src/ui/common/components/Modals/MultistakingModal/MultistakingStartModal.tsx b/src/ui/common/components/Modals/MultistakingModal/MultistakingStartModal.tsx new file mode 100644 index 000000000..daa3e468e --- /dev/null +++ b/src/ui/common/components/Modals/MultistakingModal/MultistakingStartModal.tsx @@ -0,0 +1,180 @@ +import { + Button, + Card, + DialogBody, + DialogFooter, + DialogHeader, + Heading, + Text, +} from "@babylonlabs-io/core-ui"; +import { PropsWithChildren } from "react"; + +import { ResponsiveDialog } from "../ResponsiveDialog"; + +interface Info { + icon: React.ReactNode; + name: string; +} + +interface StakingTerm { + blocks: string; + duration: string; +} + +interface StakingDetails { + stakeAmount: string; + feeRate: string; + transactionFees: string; + term: StakingTerm; + unbonding: string; + unbondingFee: string; +} + +interface MultistakingPreviewModalProps { + open: boolean; + processing?: boolean; + onClose: () => void; + onProceed: () => void; + bsns: Info[]; + finalityProviders: Info[]; + details: StakingDetails; +} + +export const MultistakingPreviewModal = ({ + open, + processing = false, + onClose, + onProceed, + bsns, + finalityProviders, + details, +}: PropsWithChildren) => { + const fields = [ + { label: "Stake Amount", value: details.stakeAmount }, + { label: "Fee Rate", value: details.feeRate }, + { label: "Transaction Fees", value: details.transactionFees }, + { + label: "Term", + value: ( + <> + {details.term.blocks} +
+ + {details.term.duration} + + + ), + }, + { label: "Unbonding", value: details.unbonding }, + { label: "Unbonding Fee", value: details.unbondingFee }, + ]; + + return ( + + + + +
+ {bsns.length > 1 ? ( +
+ + BSNs + + + Finality Provider + +
+ ) : null} +
+
+ {bsns.map((bsnItem, index) => ( +
+ {bsnItem.icon} + + {bsnItem.name} + +
+ ))} +
+
+ {finalityProviders.map((fpItem, index) => ( +
+ {fpItem.icon} + + {fpItem.name} + +
+ ))} +
+
+
+
+ +
+ {fields.map((field, index) => ( +
+ + {field.label} + + + {field.value} + +
+ ))} +
+ +
+ +
+ + Attention! + + + 1. No third party possesses your staked BTC. You are the only one + who can unbond and withdraw your stake. +
+
+
+ + 2. Your stake will first be sent to Babylon Genesis for verification + (~20 seconds), then you will be prompted to submit it to the Bitcoin + ledger. It will be marked as 'Pending' until it receives + 10 Bitcoin confirmations. + +
+ + + + + + + ); +}; diff --git a/src/ui/common/components/Modals/PreviewModal.tsx b/src/ui/common/components/Modals/PreviewModal.tsx new file mode 100644 index 000000000..810c51b4f --- /dev/null +++ b/src/ui/common/components/Modals/PreviewModal.tsx @@ -0,0 +1,243 @@ +import { + Avatar, + Button, + DialogBody, + DialogFooter, + DialogHeader, + Heading, + Loader, + Text, +} from "@babylonlabs-io/core-ui"; +import { Fragment } from "react"; + +import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn"; +import { getNetworkConfigBTC } from "@/ui/common/config/network/btc"; +import { useNetworkInfo } from "@/ui/common/hooks/client/api/useNetworkInfo"; +import { usePrice } from "@/ui/common/hooks/client/api/usePrices"; +import { useIsMobileView } from "@/ui/common/hooks/useBreakpoint"; +import { satoshiToBtc } from "@/ui/common/utils/btc"; +import { calculateTokenValueInCurrency } from "@/ui/common/utils/formatCurrency"; +import { maxDecimals } from "@/ui/common/utils/maxDecimals"; +import { blocksToDisplayTime } from "@/ui/common/utils/time"; + +import { ResponsiveDialog } from "./ResponsiveDialog"; + +interface PreviewModalProps { + open: boolean; + onClose: () => void; + onSign: () => void; + finalityProvider: string | undefined; + finalityProviderAvatar: string | undefined; + stakingAmountSat: number; + stakingTimelock: number; + stakingFeeSat: number; + feeRate: number; + unbondingFeeSat: number; + processing: boolean; +} + +const { networkFullName: bbnNetworkFullName } = getNetworkConfigBBN(); +const { coinSymbol, networkName, displayUSD } = getNetworkConfigBTC(); + +export const PreviewModal = ({ + open, + onClose, + finalityProvider, + finalityProviderAvatar, + stakingAmountSat, + stakingTimelock, + onSign, + stakingFeeSat, + feeRate, + unbondingFeeSat, + processing, +}: PreviewModalProps) => { + const isMobileView = useIsMobileView(); + + const { data: networkInfo } = useNetworkInfo(); + const confirmationDepth = + networkInfo?.params.btcEpochCheckParams?.latestParam + ?.btcConfirmationDepth || 30; + const unbondingTime = + blocksToDisplayTime( + networkInfo?.params.bbnStakingParams?.latestParam?.unbondingTime, + ) || "7 days"; + + const btcInUsd = usePrice(coinSymbol); + + const FinalityProviderValue = isMobileView ? ( + + {finalityProviderAvatar && ( + + )} + {finalityProvider || "-"} + + ) : ( + {finalityProvider || "-"} + ); + + const previewFields = [ + { + key: "Finality Provider", + value: FinalityProviderValue, + }, + { + key: "Stake Amount", + value: ( + <> + + {maxDecimals(satoshiToBtc(stakingAmountSat), 8)} {coinSymbol} + + {displayUSD && ( + + {calculateTokenValueInCurrency( + satoshiToBtc(stakingAmountSat), + btcInUsd, + )} + + )} + + ), + }, + { + key: "Fee rate", + value: {feeRate} sat/vB, + }, + { + key: "Transaction fee", + value: ( + <> + + {maxDecimals(satoshiToBtc(stakingFeeSat), 8)} {coinSymbol} + + {displayUSD && ( + + {calculateTokenValueInCurrency( + satoshiToBtc(stakingFeeSat), + btcInUsd, + )} + + )} + + ), + }, + { + key: "Term", + value: ( + <> + {stakingTimelock} blocks + + ~ {blocksToDisplayTime(stakingTimelock)} + + + ), + }, + { + key: "On Demand Unbonding", + value: ( + Enabled (~ {unbondingTime} unbonding time) + ), + }, + { + key: "Unbonding fee", + value: ( + <> + + {maxDecimals(satoshiToBtc(unbondingFeeSat), 8)} {coinSymbol} + + {displayUSD && ( + + {calculateTokenValueInCurrency( + satoshiToBtc(unbondingFeeSat), + btcInUsd, + )} + + )} + + ), + }, + ]; + + return ( + + + + +
+ {previewFields.map((field, index) => ( + +
+ {field.key} +
{field.value}
+
+ {index < previewFields.length - 1 && ( +
+ )} + + ))} +
+
+
+ Attention! + + 1. No third party possesses your staked {coinSymbol}. You are the + only one who can unbond and withdraw your stake. + + + 2. Your stake will first be sent to {bbnNetworkFullName} for + verification (~20 seconds), then you will be prompted to submit it + to the {networkName} ledger. It will be marked as + "Pending" until it receives {confirmationDepth} Bitcoin + confirmations. + +
+ + + + + + + + ); +}; diff --git a/src/ui/common/components/Modals/PreviewMultistakingModal.tsx b/src/ui/common/components/Modals/PreviewMultistakingModal.tsx new file mode 100644 index 000000000..61c8c0624 --- /dev/null +++ b/src/ui/common/components/Modals/PreviewMultistakingModal.tsx @@ -0,0 +1,252 @@ +import { + Avatar, + Button, + DialogBody, + DialogFooter, + DialogHeader, + Heading, + Loader, + Text, +} from "@babylonlabs-io/core-ui"; +import { Fragment } from "react"; + +import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn"; +import { getNetworkConfigBTC } from "@/ui/common/config/network/btc"; +import { useNetworkInfo } from "@/ui/common/hooks/client/api/useNetworkInfo"; +import { usePrice } from "@/ui/common/hooks/client/api/usePrices"; +import { useIsMobileView } from "@/ui/common/hooks/useBreakpoint"; +import { satoshiToBtc } from "@/ui/common/utils/btc"; +import { calculateTokenValueInCurrency } from "@/ui/common/utils/formatCurrency"; +import { maxDecimals } from "@/ui/common/utils/maxDecimals"; +import { blocksToDisplayTime } from "@/ui/common/utils/time"; + +import { ResponsiveDialog } from "./ResponsiveDialog"; + +interface ProviderInfo { + name: string; + avatar?: string; +} + +interface PreviewMultistakingModalProps { + open: boolean; + onClose: () => void; + onSign: () => void; + providers: ProviderInfo[]; + stakingAmountSat: number; + stakingTimelock: number; + stakingFeeSat: number; + feeRate: number; + unbondingFeeSat: number; + processing: boolean; +} + +const { networkFullName: bbnNetworkFullName } = getNetworkConfigBBN(); +const { coinSymbol, networkName, displayUSD } = getNetworkConfigBTC(); + +export const PreviewMultistakingModal = ({ + open, + onClose, + onSign, + providers, + stakingAmountSat, + stakingTimelock, + stakingFeeSat, + feeRate, + unbondingFeeSat, + processing, +}: PreviewMultistakingModalProps) => { + const isMobileView = useIsMobileView(); + + const { data: networkInfo } = useNetworkInfo(); + const confirmationDepth = + networkInfo?.params.btcEpochCheckParams?.latestParam + ?.btcConfirmationDepth || 30; + const unbondingTime = + blocksToDisplayTime( + networkInfo?.params.bbnStakingParams?.latestParam?.unbondingTime, + ) || "7 days"; + + const btcInUsd = usePrice(coinSymbol); + + const FinalityProvidersValue = isMobileView ? ( +
+ {providers.map((p) => ( + + {p.avatar && ( + + )} + {p.name} + + ))} +
+ ) : ( + + {providers.map((p) => p.name).join(", ") || "-"} + + ); + + const previewFields = [ + { + key: "Finality Providers", + value: FinalityProvidersValue, + }, + { + key: "Stake Amount", + value: ( + <> + + {maxDecimals(satoshiToBtc(stakingAmountSat), 8)} {coinSymbol} + + {displayUSD && ( + + {calculateTokenValueInCurrency( + satoshiToBtc(stakingAmountSat), + btcInUsd, + )} + + )} + + ), + }, + { + key: "Fee rate", + value: {feeRate} sat/vB, + }, + { + key: "Transaction fee", + value: ( + <> + + {maxDecimals(satoshiToBtc(stakingFeeSat), 8)} {coinSymbol} + + {displayUSD && ( + + {calculateTokenValueInCurrency( + satoshiToBtc(stakingFeeSat), + btcInUsd, + )} + + )} + + ), + }, + { + key: "Term", + value: ( + <> + {stakingTimelock} blocks + + ~ {blocksToDisplayTime(stakingTimelock)} + + + ), + }, + { + key: "On Demand Unbonding", + value: ( + Enabled (~ {unbondingTime} unbonding time) + ), + }, + { + key: "Unbonding fee", + value: ( + <> + + {maxDecimals(satoshiToBtc(unbondingFeeSat), 8)} {coinSymbol} + + {displayUSD && ( + + {calculateTokenValueInCurrency( + satoshiToBtc(unbondingFeeSat), + btcInUsd, + )} + + )} + + ), + }, + ]; + + return ( + + + + +
+ {previewFields.map((field, index) => ( + +
+ {field.key} +
{field.value}
+
+ {index < previewFields.length - 1 && ( +
+ )} + + ))} +
+
+
+ Attention! + + 1. No third party possesses your staked {coinSymbol}. You are the + only one who can unbond and withdraw your stake. + + + 2. Your stake will first be sent to {bbnNetworkFullName} for + verification (~20 seconds), then you will be prompted to submit it + to the {networkName} ledger. It will be marked as + "Pending" until it receives {confirmationDepth} Bitcoin + confirmations. + +
+ + + + + + + + ); +}; diff --git a/src/ui/common/components/Modals/RegistrationModal/RegistrationEndModal.tsx b/src/ui/common/components/Modals/RegistrationModal/RegistrationEndModal.tsx new file mode 100644 index 000000000..41d892094 --- /dev/null +++ b/src/ui/common/components/Modals/RegistrationModal/RegistrationEndModal.tsx @@ -0,0 +1,49 @@ +import { Text } from "@babylonlabs-io/core-ui"; + +import { getNetworkConfig } from "@/ui/common/config/network"; + +import { SubmitModal } from "../SubmitModal"; + +interface RegistrationEndModalProps { + open: boolean; + onClose: () => void; +} + +const SuccessIcon = () => ( + + + +); + +const { bbn } = getNetworkConfig(); + +export function RegistrationEndModal({ + open, + onClose, +}: RegistrationEndModalProps) { + return ( + } + title="Registration Submitted" + submitButton="Done" + cancelButton="" + > + + Your staking transaction has been successfully registered to the{" "} + {bbn.networkFullName}. + + + ); +} diff --git a/src/ui/common/components/Modals/RegistrationModal/RegistrationStartModal.tsx b/src/ui/common/components/Modals/RegistrationModal/RegistrationStartModal.tsx new file mode 100644 index 000000000..65e9f337c --- /dev/null +++ b/src/ui/common/components/Modals/RegistrationModal/RegistrationStartModal.tsx @@ -0,0 +1,40 @@ +import { Text } from "@babylonlabs-io/core-ui"; +import { BiSolidEditAlt } from "react-icons/bi"; + +import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn"; +import { getNetworkConfigBTC } from "@/ui/common/config/network/btc"; + +import { SubmitModal } from "../SubmitModal"; + +interface RegistrationStartModalProps { + open: boolean; + onClose: () => void; + onProceed?: () => void; +} + +const { networkName } = getNetworkConfigBTC(); +const { networkFullName } = getNetworkConfigBBN(); + +export function RegistrationStartModal({ + open, + onClose, + onProceed, +}: RegistrationStartModalProps) { + return ( + } + title={`Register to ${networkFullName}`} + submitButton="Proceed" + > + + You are about to register your {networkName} stake to the{" "} + {networkFullName}. The registration requires consenting to slashing and + the association of your {networkFullName} account with your{" "} + {networkName} address. + + + ); +} diff --git a/src/ui/common/components/Modals/ResponsiveDialog.tsx b/src/ui/common/components/Modals/ResponsiveDialog.tsx new file mode 100644 index 000000000..aa74d033d --- /dev/null +++ b/src/ui/common/components/Modals/ResponsiveDialog.tsx @@ -0,0 +1,16 @@ +import { Dialog, DialogProps, MobileDialog } from "@babylonlabs-io/core-ui"; +import { twMerge } from "tailwind-merge"; + +import { useIsMobileView } from "@/ui/common/hooks/useBreakpoint"; + +export function ResponsiveDialog(props: DialogProps) { + const isMobileView = useIsMobileView(); + const DialogComponent = isMobileView ? MobileDialog : Dialog; + + return ( + + ); +} diff --git a/src/ui/common/components/Modals/SignDetailsModal.tsx b/src/ui/common/components/Modals/SignDetailsModal.tsx new file mode 100644 index 000000000..42b24acbc --- /dev/null +++ b/src/ui/common/components/Modals/SignDetailsModal.tsx @@ -0,0 +1,57 @@ +import { EventData } from "@babylonlabs-io/btc-staking-ts"; +import { + Button, + DialogBody, + DialogFooter, + DialogHeader, +} from "@babylonlabs-io/core-ui"; + +import { SignDetails } from "@/ui/common/components/SignDetails/SignDetails"; + +import { ResponsiveDialog } from "./ResponsiveDialog"; + +interface SignDetailsModalProps { + open: boolean; + onClose: () => void; + details?: EventData; + title?: string; +} + +export const SignDetailsModal: React.FC = ({ + open, + onClose, + details, + title = "Sign Details", +}) => { + if (!details) { + return null; + } + + const capitalizedTitle = title + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); + + return ( + + + + + + + + + + ); +}; diff --git a/src/ui/common/components/Modals/SignModal/SignModal.tsx b/src/ui/common/components/Modals/SignModal/SignModal.tsx new file mode 100644 index 000000000..ccf61282c --- /dev/null +++ b/src/ui/common/components/Modals/SignModal/SignModal.tsx @@ -0,0 +1,97 @@ +import { EventData } from "@babylonlabs-io/btc-staking-ts"; +import { + Button, + DialogBody, + DialogFooter, + DialogHeader, + Loader, + Text, +} from "@babylonlabs-io/core-ui"; + +import { ResponsiveDialog } from "@/ui/common/components/Modals/ResponsiveDialog"; +import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn"; +import { getNetworkConfigBTC } from "@/ui/common/config/network/btc"; + +import { Step } from "./Step"; + +interface SignModalProps { + processing?: boolean; + open: boolean; + title: string; + step: number; + onClose?: () => void; + onSubmit?: () => void; + options?: EventData; +} + +const { coinSymbol } = getNetworkConfigBTC(); +const { coinSymbol: bbnCoinSymbol } = getNetworkConfigBBN(); + +export const SignModal = ({ + processing = false, + open, + title, + step, + onClose, + onSubmit, + options, +}: SignModalProps) => ( + + + + + + Please sign the following messages + + +
+ + Consent to slashing + + + Consent to slashing during unbonding + + + {coinSymbol}-{bbnCoinSymbol} address binding for receiving staking + rewards + + {/* There are no details to show on staking transaction registration */} + + Staking transaction registration + +
+
+ + + {onClose && ( + + )} + + {onSubmit && ( + + )} + +
+); diff --git a/src/ui/common/components/Modals/SignModal/Step.tsx b/src/ui/common/components/Modals/SignModal/Step.tsx new file mode 100644 index 000000000..f32339796 --- /dev/null +++ b/src/ui/common/components/Modals/SignModal/Step.tsx @@ -0,0 +1,99 @@ +import { EventData } from "@babylonlabs-io/btc-staking-ts"; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Loader, + Text, +} from "@babylonlabs-io/core-ui"; +import { type PropsWithChildren, type ReactNode } from "react"; +import { AiOutlineMinus, AiOutlinePlus } from "react-icons/ai"; +import { IoCheckmarkSharp } from "react-icons/io5"; +import { twMerge } from "tailwind-merge"; + +import { SignDetails } from "../../SignDetails/SignDetails"; + +interface StepProps { + step: number; + currentStep: number; + children: ReactNode; + shouldShowDetails?: boolean; + options?: EventData; +} + +const renderIcon = (step: number, currentStep: number) => { + if (currentStep > step) { + return ( +
+ +
+ ); + } + + if (currentStep === step) { + return ( +
+ +
+ ); + } + + return ( +
+ + {step} + +
+ ); +}; + +export const Step = ({ + step, + currentStep, + children, + shouldShowDetails, + options, +}: PropsWithChildren) => { + return ( +
+ {shouldShowDetails && options ? ( + + + expanded ? ( + + ) : ( + + ) + } + > +
+ {renderIcon(step, currentStep)} + + Step {step}: {children} + +
+
+ + + +
+ ) : ( +
+
+ {renderIcon(step, currentStep)} + + Step {step}: {children} + +
+
+ )} +
+ ); +}; diff --git a/src/ui/common/components/Modals/SlashingModal.tsx b/src/ui/common/components/Modals/SlashingModal.tsx new file mode 100644 index 000000000..626cc688e --- /dev/null +++ b/src/ui/common/components/Modals/SlashingModal.tsx @@ -0,0 +1,31 @@ +import { Text } from "@babylonlabs-io/core-ui"; + +import { NetworkConfig } from "@/ui/common/config/network"; +import { BbnStakingParamsVersion } from "@/ui/common/types/networkInfo"; + +import { ConfirmationModal } from "./ConfirmationModal"; + +interface UnbondModalProps { + processing: boolean; + open: boolean; + onClose: () => void; + onSubmit: () => void; + networkConfig: NetworkConfig; + param: BbnStakingParamsVersion | null; +} + +export const SlashingModal = (props: UnbondModalProps) => { + const { networkConfig, param } = props; + const slashingRate = (param?.slashing?.slashingRate ?? 0) * 100; + + return ( + + + Your finality provider equivocated (double-voted) leading to{" "} + {slashingRate}% of your stake getting slashed. You are about to withdraw + the remaining balance. A transaction fee will be deducted from your + stake by the {networkConfig.btc.networkName} network. + + + ); +}; diff --git a/src/ui/common/components/Modals/StakeModal.tsx b/src/ui/common/components/Modals/StakeModal.tsx new file mode 100644 index 000000000..043376a0d --- /dev/null +++ b/src/ui/common/components/Modals/StakeModal.tsx @@ -0,0 +1,39 @@ +import { BiSolidBadgeCheck } from "react-icons/bi"; + +import { getNetworkConfig } from "@/ui/common/config/network"; + +import { SubmitModal } from "./SubmitModal"; + +interface StakeModalProps { + processing?: boolean; + open: boolean; + onSubmit?: () => void; + onClose?: () => void; +} + +const { btc, bbn } = getNetworkConfig(); + +export const StakeModal = ({ + processing, + open, + onSubmit, + onClose, +}: StakeModalProps) => ( + } + title="Verified" + submitButton={ + <> + Stake {btc.coinName} + + } + cancelButton="Later" + onSubmit={onSubmit} + onClose={onClose} + > + Your request has been verified by the {bbn.networkFullName}. You can now + stake! + +); diff --git a/src/ui/common/components/Modals/SubmitModal.tsx b/src/ui/common/components/Modals/SubmitModal.tsx new file mode 100644 index 000000000..4750a5f22 --- /dev/null +++ b/src/ui/common/components/Modals/SubmitModal.tsx @@ -0,0 +1,75 @@ +import { + Button, + DialogBody, + DialogFooter, + Heading, + Loader, + Text, +} from "@babylonlabs-io/core-ui"; +import type { JSX, PropsWithChildren } from "react"; + +import { ResponsiveDialog } from "./ResponsiveDialog"; + +interface SubmitModalProps { + className?: string; + processing?: boolean; + disabled?: boolean; + open: boolean; + icon: JSX.Element; + title: string | JSX.Element; + cancelButton?: string | JSX.Element; + submitButton?: string | JSX.Element; + onClose?: () => void; + onSubmit?: () => void; +} + +export const SubmitModal = ({ + className, + processing = false, + disabled = false, + icon, + title, + children, + open, + cancelButton = "Cancel", + submitButton = "Submit", + onClose, + onSubmit, +}: PropsWithChildren) => ( + + +
+ {icon} +
+ + + {title} + + + {children} +
+ + + {cancelButton && ( + + )} + + {submitButton && ( + + )} + +
+); diff --git a/src/ui/common/components/Modals/SuccessFeedbackModal.tsx b/src/ui/common/components/Modals/SuccessFeedbackModal.tsx new file mode 100644 index 000000000..bb399cefb --- /dev/null +++ b/src/ui/common/components/Modals/SuccessFeedbackModal.tsx @@ -0,0 +1,45 @@ +import { LuPartyPopper } from "react-icons/lu"; + +import { SubmitModal } from "./SubmitModal"; + +interface FeedbackModalProps { + open: boolean; + onClose: () => void; +} + +export const SuccessFeedbackModal: React.FC = ({ + open, + onClose, +}) => ( + } + title="Congratulations" + open={open} + submitButton="Done" + cancelButton="" + onSubmit={onClose} + > +

+ Share feedback or report issues on our{" "} + + Forums + {" "} + or{" "} + + Discord + {" "} + (#feedback and #support) – thank you for being part of the Babylon + community! +

+
+); diff --git a/src/ui/common/components/Modals/UnbondModal.tsx b/src/ui/common/components/Modals/UnbondModal.tsx new file mode 100644 index 000000000..2d2b2af6b --- /dev/null +++ b/src/ui/common/components/Modals/UnbondModal.tsx @@ -0,0 +1,54 @@ +import { Text } from "@babylonlabs-io/core-ui"; + +import { getNetworkConfigBTC } from "@/ui/common/config/network/btc"; +import { satoshiToBtc } from "@/ui/common/utils/btc"; +import { maxDecimals } from "@/ui/common/utils/maxDecimals"; +import { blocksToDisplayTime } from "@/ui/common/utils/time"; + +import { ConfirmationModal } from "./ConfirmationModal"; + +interface UnbondModalProps { + processing: boolean; + open: boolean; + onClose: () => void; + onSubmit: () => void; + unbondingFeeSat: number | undefined; + unbondingTimeInBlocks: number | undefined; +} +const { networkName, coinSymbol } = getNetworkConfigBTC(); + +export const UnbondModal = ({ + open, + onClose, + onSubmit, + processing, + unbondingFeeSat, + unbondingTimeInBlocks, +}: UnbondModalProps) => { + if (!unbondingTimeInBlocks || !unbondingFeeSat) { + return null; + } + + const formattedUnbondingTime = blocksToDisplayTime(unbondingTimeInBlocks); + const unbondingFeeBtc = maxDecimals(satoshiToBtc(unbondingFeeSat), 8); + + return ( + + + You are about to unbond your stake before its expiration. A transaction + fee of {unbondingFeeBtc} {coinSymbol} will be deduced from your stake by + the {networkName} network. +
+ The expected unbonding time will be about {formattedUnbondingTime}. + After unbonded, you will need to use this dashboard to withdraw your + stake for it to appear in your wallet. +
+
+ ); +}; diff --git a/src/ui/common/components/Modals/VerificationModal.tsx b/src/ui/common/components/Modals/VerificationModal.tsx new file mode 100644 index 000000000..9393c8e5b --- /dev/null +++ b/src/ui/common/components/Modals/VerificationModal.tsx @@ -0,0 +1,49 @@ +import { Loader } from "@babylonlabs-io/core-ui"; + +import { getNetworkConfig } from "@/ui/common/config/network"; + +import { SubmitModal } from "./SubmitModal"; + +interface VerificationModalProps { + processing: boolean; + open: boolean; + step: 1 | 2; +} + +const { btc, bbn } = getNetworkConfig(); + +const VERIFICATION_STEPS = { + 1: { + title: ( + <> + 1/2
Processing Confirmation + + ), + description: `Waiting for the staking registration to be confirmed on ${bbn.networkFullName}.`, + }, + 2: { + title: ( + <> + 2/2
Pending Verification + + ), + description: `The ${bbn.networkFullName} is verifying your staking transaction.`, + }, +} as const; + +export const VerificationModal = ({ + processing, + open, + step, +}: VerificationModalProps) => ( + } + title={VERIFICATION_STEPS[step].title} + submitButton={`Stake ${btc.coinName}`} + cancelButton="" + > + {VERIFICATION_STEPS[step].description} + +); diff --git a/src/ui/common/components/Modals/WalletDisconnectModal.tsx b/src/ui/common/components/Modals/WalletDisconnectModal.tsx new file mode 100644 index 000000000..21c490002 --- /dev/null +++ b/src/ui/common/components/Modals/WalletDisconnectModal.tsx @@ -0,0 +1,75 @@ +import { + Button, + DialogBody, + DialogFooter, + Heading, + Text, +} from "@babylonlabs-io/core-ui"; +import { useCallback } from "react"; +import { MdCancel } from "react-icons/md"; + +import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn"; +import { getNetworkConfigBTC } from "@/ui/common/config/network/btc"; + +import { ResponsiveDialog } from "./ResponsiveDialog"; + +interface WalletDisconnectModalProps { + isOpen: boolean; + closeMenu: () => void; + onClose: () => void; + onDisconnect: () => void; +} + +const { networkName } = getNetworkConfigBTC(); +const { networkFullName: bbnNetworkFullName } = getNetworkConfigBBN(); + +export const WalletDisconnectModal = ({ + isOpen, + closeMenu, + onClose, + onDisconnect, +}: WalletDisconnectModalProps) => { + const handleDisconnect = useCallback(() => { + onDisconnect(); + closeMenu(); + }, [onDisconnect, closeMenu]); + + return ( + + +
+
+ +
+
+ + Disconnect Wallets + + + Disconnecting will log you out of both your {bbnNetworkFullName} Chain + and {networkName} wallets. You'll need to reconnect them to + access your staking account. + +
+ + + + + +
+ ); +}; diff --git a/src/ui/common/components/Modals/WithdrawModal.tsx b/src/ui/common/components/Modals/WithdrawModal.tsx new file mode 100644 index 000000000..40b305c4a --- /dev/null +++ b/src/ui/common/components/Modals/WithdrawModal.tsx @@ -0,0 +1,25 @@ +import { Text } from "@babylonlabs-io/core-ui"; + +import { getNetworkConfigBTC } from "@/ui/common/config/network/btc"; + +import { ConfirmationModal } from "./ConfirmationModal"; + +interface WithdrawModalProps { + processing: boolean; + open: boolean; + onClose: () => void; + onSubmit: () => void; +} + +const { networkName } = getNetworkConfigBTC(); + +export const WithdrawModal = (props: WithdrawModalProps) => { + return ( + + + You are about to withdraw your stake.
A transaction fee will be + deduced from your stake by the {networkName} network. +
+
+ ); +}; diff --git a/src/ui/common/components/Multistaking/BsnFinalityProviderField/BsnFinalityProviderField.tsx b/src/ui/common/components/Multistaking/BsnFinalityProviderField/BsnFinalityProviderField.tsx new file mode 100644 index 000000000..a279fa372 --- /dev/null +++ b/src/ui/common/components/Multistaking/BsnFinalityProviderField/BsnFinalityProviderField.tsx @@ -0,0 +1,131 @@ +import { useField } from "@babylonlabs-io/core-ui"; +import { useMemo } from "react"; + +import { CounterButton } from "@/ui/common/components/Multistaking/CounterButton"; +import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn"; +import { + StakingModalPage, + useFinalityProviderBsnState, +} from "@/ui/common/state/FinalityProviderBsnState"; + +import { ChainSelectionModal } from "../ChainSelectionModal/ChainSelectionModal"; +import { FinalityProviderModal } from "../FinalityProviderField/FinalityProviderModal"; +import { SubSection } from "../MultistakingForm/SubSection"; + +import { SelectedProvidersList } from "./SelectedProvidersList"; + +interface Props { + max: number; +} + +const { chainId: BBN_CHAIN_ID } = getNetworkConfigBBN(); + +export function BsnFinalityProviderField({ max }: Props) { + const { value: selectedProviderMap = {}, onChange } = useField< + Record + >({ + name: "finalityProviders", + defaultValue: {}, + }); + + const count = useMemo( + () => Object.keys(selectedProviderMap).length, + [selectedProviderMap], + ); + + const { + bsnList, + bsnLoading, + stakingModalPage, + selectedBsnId, + setStakingModalPage, + setSelectedBsnId, + } = useFinalityProviderBsnState(); + + const allowsMultipleBsns = max > 1; + + const handleAdd = (selectedBsnId: string, providerPk: string) => { + onChange({ ...selectedProviderMap, [selectedBsnId]: providerPk }); + setStakingModalPage(StakingModalPage.DEFAULT); + }; + + const handleRemove = (selectedBsnId?: string) => { + if (selectedBsnId !== undefined) { + const map = { ...selectedProviderMap }; + Reflect.deleteProperty(map, selectedBsnId); + onChange(map); + } + }; + + const handleOpen = () => { + if (allowsMultipleBsns) { + setStakingModalPage(StakingModalPage.BSN); + } else { + setSelectedBsnId(BBN_CHAIN_ID); + setStakingModalPage(StakingModalPage.FINALITY_PROVIDER); + } + }; + + const handleClose = () => { + setStakingModalPage(StakingModalPage.DEFAULT); + setSelectedBsnId(undefined); + }; + + const handleNext = () => { + setStakingModalPage(StakingModalPage.FINALITY_PROVIDER); + }; + + const handleSelectBsn = (chainId: string) => { + setSelectedBsnId(chainId); + }; + + const handleBack = () => { + if (allowsMultipleBsns) { + setStakingModalPage(StakingModalPage.BSN); + } else { + setStakingModalPage(StakingModalPage.DEFAULT); + } + }; + + const actionText = allowsMultipleBsns + ? "Add BSN and Finality Provider" + : "Add Finality Provider"; + + return ( + +
+
+
+ {actionText} + +
+
+ {count > 0 && ( + + )} +
+ + + + +
+ ); +} diff --git a/src/ui/common/components/Multistaking/BsnFinalityProviderField/BsnFinalityProviderItem.tsx b/src/ui/common/components/Multistaking/BsnFinalityProviderField/BsnFinalityProviderItem.tsx new file mode 100644 index 000000000..fed15f797 --- /dev/null +++ b/src/ui/common/components/Multistaking/BsnFinalityProviderField/BsnFinalityProviderItem.tsx @@ -0,0 +1,79 @@ +import { Avatar } from "@babylonlabs-io/core-ui"; +import { useMemo } from "react"; + +import { FinalityProviderLogo } from "@/ui/common/components/Staking/FinalityProviders/FinalityProviderLogo"; +import { chainLogos } from "@/ui/common/constants"; +import { useFinalityProviderBsnState } from "@/ui/common/state/FinalityProviderBsnState"; +import { useFinalityProviderState } from "@/ui/common/state/FinalityProviderState"; + +export const BsnFinalityProviderItem = ({ + bsnId, + providerId, + onRemove, +}: { + bsnId: string; + providerId: string; + onRemove: (bsnId?: string) => void; +}) => { + const { bsnList } = useFinalityProviderBsnState(); + const { finalityProviderMap } = useFinalityProviderState(); + const provider = finalityProviderMap.get(providerId); + + const bsn = useMemo( + () => bsnList.find((bsn) => bsn.id === bsnId), + [bsnList, bsnId], + ); + + const renderBsnLogo = () => { + if (!bsn || !provider) { + return null; + } + + const logoUrl = chainLogos[provider.chain || "babylon"]; + + return ( + + ); + }; + + if (!provider) { + return null; + } + + return ( +
+
+ +
+
+ {renderBsnLogo()} + {bsn?.name} +
+
+ {provider.description?.moniker} +
+
+
+ + +
+ ); +}; diff --git a/src/ui/common/components/Multistaking/BsnFinalityProviderField/SelectedProvidersList.tsx b/src/ui/common/components/Multistaking/BsnFinalityProviderField/SelectedProvidersList.tsx new file mode 100644 index 000000000..ac79da2ea --- /dev/null +++ b/src/ui/common/components/Multistaking/BsnFinalityProviderField/SelectedProvidersList.tsx @@ -0,0 +1,30 @@ +import { useMemo } from "react"; + +import { BsnFinalityProviderItem } from "@/ui/common/components/Multistaking/BsnFinalityProviderField/BsnFinalityProviderItem"; + +interface SelectedProvidersListProps { + selectedFPs: Record; + onRemove: (bsnId?: string) => void; +} + +export function SelectedProvidersList({ + selectedFPs, + onRemove, +}: SelectedProvidersListProps) { + const values = useMemo(() => Object.entries(selectedFPs), [selectedFPs]); + + if (values.length === 0) return null; + + return ( +
+ {values.map(([bsnId, providerId]) => ( + + ))} +
+ ); +} diff --git a/src/ui/common/components/Multistaking/ChainSelectionModal/ChainSelectionModal.tsx b/src/ui/common/components/Multistaking/ChainSelectionModal/ChainSelectionModal.tsx new file mode 100644 index 000000000..9eb3b5af8 --- /dev/null +++ b/src/ui/common/components/Multistaking/ChainSelectionModal/ChainSelectionModal.tsx @@ -0,0 +1,178 @@ +import { + Button, + DialogBody, + DialogFooter, + DialogHeader, + Text, +} from "@babylonlabs-io/core-ui"; +import { PropsWithChildren, useMemo } from "react"; +import { MdOutlineInfo } from "react-icons/md"; +import { twMerge } from "tailwind-merge"; + +import { ResponsiveDialog } from "@/ui/common/components/Modals/ResponsiveDialog"; +import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn"; +import { chainLogos } from "@/ui/common/constants"; +import { Bsn } from "@/ui/common/types/bsn"; + +const BSN_ID = getNetworkConfigBBN().chainId; + +const SubSection = ({ + children, + style, + className, +}: { + children: React.ReactNode; + style?: React.CSSProperties; + className?: string; +}) => ( +
+ {children} +
+); + +interface ChainButtonProps extends PropsWithChildren { + className?: string; + disabled?: boolean; + logo?: string; + title?: string | JSX.Element; + alt?: string; + selected?: boolean; + onClick?: () => void; +} + +const ChainButton = ({ + className, + disabled, + title, + logo, + selected, + onClick, +}: ChainButtonProps) => ( + +
+
+ {logo && ( + bitcoin + )} + {title} +
+
+
+); + +interface ChainSelectionModalProps { + open: boolean; + loading?: boolean; + activeBsnId?: string; + selectedBsns?: Record; + bsns?: Bsn[]; + onNext: () => void; + onClose: () => void; + onSelect: (bsnId: string) => void; +} + +export const ChainSelectionModal = ({ + bsns = [], + open, + loading, + activeBsnId, + selectedBsns = {}, + onSelect, + onNext, + onClose, +}: ChainSelectionModalProps) => { + const babylonBsn = useMemo( + () => bsns.find((bsn) => bsn.id === BSN_ID), + [bsns], + ); + const externalBsns = useMemo( + () => bsns.filter((bsn) => bsn.id !== BSN_ID), + [bsns], + ); + const isBabylonSelected = babylonBsn + ? Boolean(selectedBsns[babylonBsn.id]) + : false; + + return ( + + + + +
+ Bitcoin Supercharged Networks (BSNs) are Proof-of-Stake systems + secured by Bitcoin staking. Select a network to delegate your stake + and earn rewards. +
+
+ {loading &&
Loading...
} + {babylonBsn && ( + onSelect(babylonBsn.id)} + /> + )} + {externalBsns.map((bsn) => ( + onSelect(bsn.id)} + /> + ))} +
+ {!isBabylonSelected && ( + +
+ +
+
+ Babylon Genesis must be the first BSN you add before selecting + others. Once added, you can choose additional BSNs to multi-stake. +
+
+ )} +
+ + + + +
+ ); +}; diff --git a/src/ui/common/components/Multistaking/CounterButton.tsx b/src/ui/common/components/Multistaking/CounterButton.tsx new file mode 100644 index 000000000..9e081db31 --- /dev/null +++ b/src/ui/common/components/Multistaking/CounterButton.tsx @@ -0,0 +1,42 @@ +import { AiOutlinePlus } from "react-icons/ai"; +import { twJoin } from "tailwind-merge"; + +interface CounterButtonProps { + counter: number; + max: number; + onAdd: () => void; + alwaysShowCounter?: boolean; +} + +export function CounterButton({ + counter, + max, + onAdd, + alwaysShowCounter = false, +}: CounterButtonProps) { + const isClickable = counter < max; + const showsCounter = + (0 < counter && 1 < max) || (alwaysShowCounter && counter === 0); + + return ( +
+ {isClickable && ( +
+ +
+ )} + {showsCounter && ( +
+ {counter}/{max} +
+ )} +
+ ); +} diff --git a/src/ui/common/components/Multistaking/FinalityProviderField/FinalityProviderModal.tsx b/src/ui/common/components/Multistaking/FinalityProviderField/FinalityProviderModal.tsx new file mode 100644 index 000000000..7030eaad0 --- /dev/null +++ b/src/ui/common/components/Multistaking/FinalityProviderField/FinalityProviderModal.tsx @@ -0,0 +1,78 @@ +import { + Button, + DialogBody, + DialogFooter, + DialogHeader, +} from "@babylonlabs-io/core-ui"; +import { useState } from "react"; + +import { ResponsiveDialog } from "@/ui/common/components/Modals/ResponsiveDialog"; +import { FinalityProviders } from "@/ui/common/components/Multistaking/FinalityProviderField/FinalityProviders"; + +interface Props { + open: boolean; + defaultFinalityProvider?: string; + selectedBsnId?: string; + onClose: () => void; + onAdd: (selectedBsnId: string, selectedProviderKey: string) => void; + onBack?: () => void; +} + +export const FinalityProviderModal = ({ + defaultFinalityProvider = "", + open, + selectedBsnId, + onClose, + onAdd, + onBack, +}: Props) => { + const [selectedFP, setSelectedFp] = useState(defaultFinalityProvider); + + const handleClose = () => { + onClose(); + setSelectedFp(""); + }; + + return ( + + + + +
+ Finality Providers play a key role in securing Proof-of-Stake networks + by validating and finalising transactions. Select one to delegate your + stake and earn rewards. +
+
+ +
+
+ + + {onBack ? ( + + ) : ( +
+ )} + + + + ); +}; diff --git a/src/ui/common/components/Multistaking/FinalityProviderField/FinalityProviders.tsx b/src/ui/common/components/Multistaking/FinalityProviderField/FinalityProviders.tsx new file mode 100644 index 000000000..a5d7b0cc0 --- /dev/null +++ b/src/ui/common/components/Multistaking/FinalityProviderField/FinalityProviders.tsx @@ -0,0 +1,25 @@ +import { FinalityProviderFilter } from "@/ui/common/components/Multistaking/FinalityProviders/FinalityProviderFilter"; +import { FinalityProviderSearch } from "@/ui/common/components/Multistaking/FinalityProviders/FinalityProviderSearch"; +import { FinalityProviderTable } from "@/ui/common/components/Multistaking/FinalityProviders/FinalityProviderTable"; + +interface Props { + selectedFP: string; + onChange: (value: string) => void; +} + +export const FinalityProviders = ({ selectedFP, onChange }: Props) => { + return ( +
+
+
+ +
+
+ +
+
+ + +
+ ); +}; diff --git a/src/ui/common/components/Multistaking/FinalityProviders/FinalityProviderFilter.tsx b/src/ui/common/components/Multistaking/FinalityProviders/FinalityProviderFilter.tsx new file mode 100644 index 000000000..92332d7e4 --- /dev/null +++ b/src/ui/common/components/Multistaking/FinalityProviders/FinalityProviderFilter.tsx @@ -0,0 +1,23 @@ +import { Select } from "@babylonlabs-io/core-ui"; + +import { useFinalityProviderBsnState } from "@/ui/common/state/FinalityProviderBsnState"; + +const options = [ + { value: "active", label: "Active" }, + { value: "inactive", label: "Inactive" }, +]; + +export const FinalityProviderFilter = () => { + const { filter, handleFilter } = useFinalityProviderBsnState(); + + return ( + + ); +}; diff --git a/src/ui/common/components/Multistaking/FinalityProviders/FinalityProviderTable.tsx b/src/ui/common/components/Multistaking/FinalityProviders/FinalityProviderTable.tsx new file mode 100644 index 000000000..e0abe6452 --- /dev/null +++ b/src/ui/common/components/Multistaking/FinalityProviders/FinalityProviderTable.tsx @@ -0,0 +1,147 @@ +/** + * Import polyfill for array.toSorted + */ +import "core-js/features/array/to-sorted"; + +import { Button, Loader } from "@babylonlabs-io/core-ui"; + +import warningOctagon from "@/ui/common/assets/warning-octagon.svg"; +import warningTriangle from "@/ui/common/assets/warning-triangle.svg"; +import { FinalityProviderLogo } from "@/ui/common/components/Staking/FinalityProviders/FinalityProviderLogo"; +import { StatusView } from "@/ui/common/components/Staking/FinalityProviders/FinalityProviderTableStatusView"; +import { getNetworkConfigBTC } from "@/ui/common/config/network/btc"; +import { useFinalityProviderBsnState } from "@/ui/common/state/FinalityProviderBsnState"; +import { FinalityProviderStateLabels } from "@/ui/common/types/finalityProviders"; +import { satoshiToBtc } from "@/ui/common/utils/btc"; +import { maxDecimals } from "@/ui/common/utils/maxDecimals"; + +interface Props { + selectedFP?: string; + onSelectRow?: (btcPk: string) => void; +} + +export const FinalityProviderTable = ({ selectedFP, onSelectRow }: Props) => { + const { isFetching, finalityProviders, hasError, isRowSelectable } = + useFinalityProviderBsnState(); + + const { coinSymbol } = getNetworkConfigBTC(); + + const errorView = ( + } + title="Failed to Load" + description={ + <> + The finality provider list failed to load. Please check
+ your internet connection or try again later. + + } + /> + ); + + const loadingView = ( + } + title="Loading Finality Providers" + /> + ); + + const noMatchesView = ( + } + title="No Matches Found" + /> + ); + + if (hasError) { + return errorView; + } + + if (isFetching && (!finalityProviders || finalityProviders.length === 0)) { + return loadingView; + } + + if (!isFetching && (!finalityProviders || finalityProviders.length === 0)) { + return noMatchesView; + } + + const handleSelect = (btcPk: string) => { + if (onSelectRow) { + onSelectRow(btcPk); + } + }; + + return ( +
+ {finalityProviders.map((fp) => { + const isSelected = selectedFP === fp.btcPk; + const isSelectable = isRowSelectable(fp); + const totalDelegation = maxDecimals( + satoshiToBtc(fp.activeTVLSat || 0), + 8, + ); + const commission = maxDecimals((Number(fp.commission) || 0) * 100, 2); + const status = FinalityProviderStateLabels[fp.state] || "Unknown"; + + return ( +
+
+ +
+
+ {fp.btcPk + ? `${fp.btcPk.slice(0, 6)}...${fp.btcPk.slice(-6)}` + : ""} +
+
+ {fp.description?.moniker || "Unnamed Provider"} +
+
+
+
+
+
Status
+
{status}
+
+
+
{coinSymbol} PK
+
+ {fp.btcPk + ? `${fp.btcPk.slice(0, 5)}...${fp.btcPk.slice(-5)}` + : ""} +
+
+
+
Total Delegation
+
+ {totalDelegation} {coinSymbol} +
+
+
+
Commission
+
+ {commission}% +
+
+ +
+ ); + })} +
+ ); +}; diff --git a/src/ui/common/components/Multistaking/MultistakingForm/AmountBalanceInfo.tsx b/src/ui/common/components/Multistaking/MultistakingForm/AmountBalanceInfo.tsx new file mode 100644 index 000000000..4d8ce8dc5 --- /dev/null +++ b/src/ui/common/components/Multistaking/MultistakingForm/AmountBalanceInfo.tsx @@ -0,0 +1,46 @@ +import { useWatch } from "@babylonlabs-io/core-ui"; + +import { getNetworkConfigBTC } from "@/ui/common/config/network/btc"; +import { usePrice } from "@/ui/common/hooks/client/api/usePrices"; +import { useBalanceState } from "@/ui/common/state/BalanceState"; +import { satoshiToBtc } from "@/ui/common/utils/btc"; +import { calculateTokenValueInCurrency } from "@/ui/common/utils/formatCurrency"; +import { maxDecimals } from "@/ui/common/utils/maxDecimals"; + +const { coinSymbol, displayUSD } = getNetworkConfigBTC(); + +//TODO: Temporary disable max button until we implement https://github.com/babylonlabs-io/simple-staking/issues/1119 +export const AmountBalanceInfo = () => { + const { stakableBtcBalance } = useBalanceState(); + + const btcAmount = useWatch({ name: "amount", defaultValue: "" }); + // const { setValue } = useFormContext(); + const btcInUsd = usePrice(coinSymbol); + + const btcAmountValue = parseFloat(btcAmount || "0"); + const btcAmountUsd = calculateTokenValueInCurrency(btcAmountValue, btcInUsd, { + zeroDisplay: "$0.00", + }); + const formattedBalance = satoshiToBtc(stakableBtcBalance); + + // const handleSetMaxBalance = () => { + // setValue("amount", formattedBalance.toString(), { + // shouldValidate: true, + // shouldDirty: true, + // shouldTouch: true, + // }); + // }; + + return ( +
+
+ Stakable:{" "} + + {maxDecimals(formattedBalance, 8)} + {" "} + {coinSymbol} +
+ {displayUSD &&
{btcAmountUsd} USD
} +
+ ); +}; diff --git a/src/ui/common/components/Multistaking/MultistakingForm/AmountSubsection.tsx b/src/ui/common/components/Multistaking/MultistakingForm/AmountSubsection.tsx new file mode 100644 index 000000000..828abc4c6 --- /dev/null +++ b/src/ui/common/components/Multistaking/MultistakingForm/AmountSubsection.tsx @@ -0,0 +1,59 @@ +import { HiddenField, useFormContext, useWatch } from "@babylonlabs-io/core-ui"; + +import { AuthGuard } from "@/ui/common/components/Common/AuthGuard"; +import { getNetworkConfigBTC } from "@/ui/common/config/network/btc"; + +import { AmountBalanceInfo } from "./AmountBalanceInfo"; +import { SubSection } from "./SubSection"; + +const { icon, name } = getNetworkConfigBTC(); + +export const AmountSubsection = () => { + const btcAmount = useWatch({ name: "amount", defaultValue: "" }); + const { setValue } = useFormContext(); + + const handleInputChange = (e: React.ChangeEvent) => { + setValue("amount", e.target.value, { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "ArrowUp" || e.key === "ArrowDown") { + e.preventDefault(); + } + }; + + return ( + +
+
+ {name} +
{name}
+
+ +
+ + + + + +
+ ); +}; diff --git a/src/ui/common/components/Multistaking/MultistakingForm/BTCFeeRate.tsx b/src/ui/common/components/Multistaking/MultistakingForm/BTCFeeRate.tsx new file mode 100644 index 000000000..751bf41be --- /dev/null +++ b/src/ui/common/components/Multistaking/MultistakingForm/BTCFeeRate.tsx @@ -0,0 +1,125 @@ +import { Button, useFormContext, useWatch } from "@babylonlabs-io/core-ui"; +import { useEffect, useMemo, useState } from "react"; +import { FaPen } from "react-icons/fa6"; + +import { FeeItem } from "@/ui/common/components/Staking/DelegationForm/components/FeeItem"; +import { FeeModal } from "@/ui/common/components/Staking/FeeModal"; +import { useStakingService } from "@/ui/common/hooks/services/useStakingService"; + +interface FeeFiledProps { + defaultRate?: number; +} + +export function BTCFeeRate({ defaultRate = 0 }: FeeFiledProps) { + const [visible, setVisibility] = useState(false); + const feeRate = useWatch({ name: "feeRate" }); + const { setValue, setError, clearErrors } = useFormContext(); + const { calculateFeeAmount } = useStakingService(); + + const amount = useWatch({ name: "amount" }); + const term = useWatch({ name: "term" }); + const finalityProviders = useWatch({ name: "finalityProviders" }); + + const validFinalityProviders = useMemo( + () => Object.values(finalityProviders ?? {}) as string[], + [finalityProviders], + ); + + useEffect(() => { + setValue("feeRate", defaultRate.toString(), { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }); + }, [defaultRate, setValue]); + + // TODO: useFieldState instead of multiple useWatch + useEffect(() => { + let cancelled = false; + + const run = () => { + try { + if (!validFinalityProviders.length || !amount || !term || !feeRate) { + if (cancelled) return; + setValue("feeAmount", "0", { + shouldValidate: false, + shouldDirty: false, + shouldTouch: false, + }); + return; + } + + const feeAmount = calculateFeeAmount({ + finalityProviders: validFinalityProviders, + amount: Number(amount), + term: Number(term), + feeRate: Number(feeRate), + }); + + if (cancelled) return; + + clearErrors("feeAmount"); + setValue("feeAmount", feeAmount.toString(), { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }); + } catch (e: any) { + if (cancelled) return; + setValue("feeAmount", "0", { + shouldValidate: false, + shouldDirty: false, + shouldTouch: false, + }); + setError("feeAmount", { + type: "custom", + message: e.message, + }); + } + }; + + Promise.resolve().then(run); + + return () => { + cancelled = true; + }; + }, [ + feeRate, + amount, + term, + validFinalityProviders, + setValue, + setError, + clearErrors, + calculateFeeAmount, + ]); + + return ( + + {feeRate} sats/vB + + + + void setVisibility(false)} + onSubmit={(value) => + setValue("feeRate", value.toString(), { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }) + } + /> + + ); +} diff --git a/src/ui/common/components/Multistaking/MultistakingForm/ConnectButton.tsx b/src/ui/common/components/Multistaking/MultistakingForm/ConnectButton.tsx new file mode 100644 index 000000000..481eb9404 --- /dev/null +++ b/src/ui/common/components/Multistaking/MultistakingForm/ConnectButton.tsx @@ -0,0 +1,15 @@ +import { Button } from "@babylonlabs-io/core-ui"; + +import { useBTCWallet } from "@/ui/common/context/wallet/BTCWalletProvider"; +import { useStakingState } from "@/ui/common/state/StakingState"; + +export function ConnectButton() { + const { open } = useBTCWallet(); + const { blocked: isGeoBlocked } = useStakingState(); + + return ( + + ); +} diff --git a/src/ui/common/components/Multistaking/MultistakingForm/FeesSection.tsx b/src/ui/common/components/Multistaking/MultistakingForm/FeesSection.tsx new file mode 100644 index 000000000..60ce8e1c8 --- /dev/null +++ b/src/ui/common/components/Multistaking/MultistakingForm/FeesSection.tsx @@ -0,0 +1,24 @@ +import { BBNFeeAmount } from "@/ui/common/components/Staking/DelegationForm/components/BBNFeeAmount"; +import { BTCFeeAmount } from "@/ui/common/components/Staking/DelegationForm/components/BTCFeeAmount"; +import { Total } from "@/ui/common/components/Staking/DelegationForm/components/Total"; +import { BBN_FEE_AMOUNT } from "@/ui/common/constants"; +import { useStakingState } from "@/ui/common/state/StakingState"; + +import { BTCFeeRate } from "./BTCFeeRate"; +import { SubSection } from "./SubSection"; + +export const FeesSection = () => { + const { stakingInfo } = useStakingState(); + + return ( + +
+ + + {BBN_FEE_AMOUNT && } +
+ +
+ + ); +}; diff --git a/src/ui/common/components/Multistaking/MultistakingForm/FormAlert.tsx b/src/ui/common/components/Multistaking/MultistakingForm/FormAlert.tsx new file mode 100644 index 000000000..f462bc0d2 --- /dev/null +++ b/src/ui/common/components/Multistaking/MultistakingForm/FormAlert.tsx @@ -0,0 +1,46 @@ +import { MdErrorOutline } from "react-icons/md"; + +import { Alert } from "@/ui/common/components/Alerts/Alert"; +import { STAKING_DISABLED } from "@/ui/common/constants"; + +interface FormAlertProps { + address: string | undefined; + isGeoBlocked: boolean; + geoBlockMessage: string | undefined; +} + +export const FormAlert = ({ + address, + isGeoBlocked, + geoBlockMessage, +}: FormAlertProps) => { + const stakingDisabledMessage = ( + <> + The Babylon network is temporarily halted. New stakes are paused until the + network resumes. + + ); + + const shouldShowAlert = (address && STAKING_DISABLED) || isGeoBlocked; + + if (!shouldShowAlert) { + return null; + } + + return ( +
+ } + title={ + isGeoBlocked ? ( + Unavailable In Your Region. + ) : ( + Staking Currently Unavailable. + ) + } + > + {isGeoBlocked ? geoBlockMessage : stakingDisabledMessage} + +
+ ); +}; diff --git a/src/ui/common/components/Multistaking/MultistakingForm/MultistakingForm.tsx b/src/ui/common/components/Multistaking/MultistakingForm/MultistakingForm.tsx new file mode 100644 index 000000000..407cabbc1 --- /dev/null +++ b/src/ui/common/components/Multistaking/MultistakingForm/MultistakingForm.tsx @@ -0,0 +1,89 @@ +import { Form, HiddenField } from "@babylonlabs-io/core-ui"; +import { useCallback } from "react"; + +import { AuthGuard } from "@/ui/common/components/Common/AuthGuard"; +import { BsnFinalityProviderField } from "@/ui/common/components/Multistaking/BsnFinalityProviderField/BsnFinalityProviderField"; +import { AmountSubsection } from "@/ui/common/components/Multistaking/MultistakingForm/AmountSubsection"; +import { FeesSection } from "@/ui/common/components/Multistaking/MultistakingForm/FeesSection"; +import { MultistakingModal } from "@/ui/common/components/Multistaking/MultistakingModal/MultistakingModal"; +import { useBTCWallet } from "@/ui/common/context/wallet/BTCWalletProvider"; +import { + useMultistakingState, + type MultistakingFormFields, +} from "@/ui/common/state/MultistakingState"; +import { StakingStep, useStakingState } from "@/ui/common/state/StakingState"; +import FeatureFlagService from "@/ui/common/utils/FeatureFlagService"; + +import { ConnectButton } from "./ConnectButton"; +import { FormAlert } from "./FormAlert"; +import { SubmitButton } from "./SubmitButton"; + +export function MultistakingForm() { + const { address } = useBTCWallet(); + const { + stakingInfo, + setFormData, + goToStep, + blocked: isGeoBlocked, + errorMessage: geoBlockMessage, + } = useStakingState(); + const { validationSchema, maxFinalityProviders } = useMultistakingState(); + + const handlePreview = useCallback( + (formValues: MultistakingFormFields) => { + // Persist form values into global staking state + // For multistaking, pass all selected finality providers + setFormData({ + finalityProviders: Object.values(formValues.finalityProviders), + term: Number(formValues.term), + amount: Number(formValues.amount), + feeRate: Number(formValues.feeRate), + feeAmount: Number(formValues.feeAmount), + }); + + goToStep(StakingStep.PREVIEW); + }, + [setFormData, goToStep], + ); + + if (!stakingInfo) { + return null; + } + + return ( +
+ {stakingInfo && ( + + )} + + +
+ + + + + }> + + + + +
+ + + + ); +} diff --git a/src/ui/common/components/Multistaking/MultistakingForm/MultistakingFormWrapper.tsx b/src/ui/common/components/Multistaking/MultistakingForm/MultistakingFormWrapper.tsx new file mode 100644 index 000000000..87c017e61 --- /dev/null +++ b/src/ui/common/components/Multistaking/MultistakingForm/MultistakingFormWrapper.tsx @@ -0,0 +1,17 @@ +import { FinalityProviderBsnState } from "@/ui/common/state/FinalityProviderBsnState"; +import { MultistakingState } from "@/ui/common/state/MultistakingState"; +import { StakingState } from "@/ui/common/state/StakingState"; + +import { MultistakingForm } from "./MultistakingForm"; + +export function MultistakingFormWrapper() { + return ( + + + + + + + + ); +} diff --git a/src/ui/common/components/Multistaking/MultistakingForm/SubSection.tsx b/src/ui/common/components/Multistaking/MultistakingForm/SubSection.tsx new file mode 100644 index 000000000..fd988958c --- /dev/null +++ b/src/ui/common/components/Multistaking/MultistakingForm/SubSection.tsx @@ -0,0 +1,22 @@ +import type { CSSProperties, ReactNode } from "react"; +import { twJoin } from "tailwind-merge"; + +export const SubSection = ({ + children, + style, + className, +}: { + children: ReactNode; + style?: CSSProperties; + className?: string; +}) => ( +
+ {children} +
+); diff --git a/src/ui/common/components/Multistaking/MultistakingForm/SubmitButton.tsx b/src/ui/common/components/Multistaking/MultistakingForm/SubmitButton.tsx new file mode 100644 index 000000000..d970e942b --- /dev/null +++ b/src/ui/common/components/Multistaking/MultistakingForm/SubmitButton.tsx @@ -0,0 +1,53 @@ +import { Button, useFormState } from "@babylonlabs-io/core-ui"; +import { twMerge } from "tailwind-merge"; + +import { STAKING_DISABLED } from "@/ui/common/constants"; +import { useFormError } from "@/ui/common/hooks/useFormError"; +import { useStakingState } from "@/ui/common/state/StakingState"; + +const BUTTON_STYLES: Record = { + error: "disabled:!text-error-main disabled:!bg-error-main/10", + default: "", +}; + +export function SubmitButton() { + const { isValid, isValidating, isLoading } = useFormState(); + const { blocked: isGeoBlocked } = useStakingState(); + const error = useFormError(); + + const renderText = () => { + if (isValidating) { + return "Calculating..."; + } + + if (isLoading) { + return "Loading..."; + } + + if (error) { + return error.message; + } + + return "Preview"; + }; + + return ( + + ); +} diff --git a/src/ui/common/components/Multistaking/MultistakingModal/MultistakingModal.tsx b/src/ui/common/components/Multistaking/MultistakingModal/MultistakingModal.tsx new file mode 100644 index 000000000..f0c1dbc8f --- /dev/null +++ b/src/ui/common/components/Multistaking/MultistakingModal/MultistakingModal.tsx @@ -0,0 +1,263 @@ +import { Avatar, useFormContext, useWatch } from "@babylonlabs-io/core-ui"; +import { useMemo } from "react"; + +import { CancelFeedbackModal } from "@/ui/common/components/Modals/CancelFeedbackModal"; +import { MultistakingPreviewModal } from "@/ui/common/components/Modals/MultistakingModal/MultistakingStartModal"; +import { SignModal } from "@/ui/common/components/Modals/SignModal/SignModal"; +import { StakeModal } from "@/ui/common/components/Modals/StakeModal"; +import { SuccessFeedbackModal } from "@/ui/common/components/Modals/SuccessFeedbackModal"; +import { VerificationModal } from "@/ui/common/components/Modals/VerificationModal"; +import { FinalityProviderLogo } from "@/ui/common/components/Staking/FinalityProviders/FinalityProviderLogo"; +import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn"; +import { getNetworkConfigBTC } from "@/ui/common/config/network/btc"; +import { chainLogos } from "@/ui/common/constants"; +import { useNetworkInfo } from "@/ui/common/hooks/client/api/useNetworkInfo"; +import { usePrice } from "@/ui/common/hooks/client/api/usePrices"; +import { useStakingService } from "@/ui/common/hooks/services/useStakingService"; +import { useFinalityProviderBsnState } from "@/ui/common/state/FinalityProviderBsnState"; +import { useFinalityProviderState } from "@/ui/common/state/FinalityProviderState"; +import { useStakingState } from "@/ui/common/state/StakingState"; +import { satoshiToBtc } from "@/ui/common/utils/btc"; +import { calculateTokenValueInCurrency } from "@/ui/common/utils/formatCurrency"; +import { maxDecimals } from "@/ui/common/utils/maxDecimals"; +import { blocksToDisplayTime } from "@/ui/common/utils/time"; +import { trim } from "@/ui/common/utils/trim"; + +const EOI_INDEXES: Record = { + "eoi-staking-slashing": 1, + "eoi-unbonding-slashing": 2, + "eoi-proof-of-possession": 3, + "eoi-sign-bbn": 4, +}; + +const VERIFICATION_STEPS: Record = { + "eoi-send-bbn": 1, + verifying: 2, +}; + +const { chainId: BBN_CHAIN_ID } = getNetworkConfigBBN(); +const { displayUSD } = getNetworkConfigBTC(); + +export function MultistakingModal() { + const { + processing, + step, + formData, + stakingInfo, + verifiedDelegation, + reset: resetState, + stakingStepOptions, + } = useStakingState(); + const { getRegisteredFinalityProvider } = useFinalityProviderState(); + const { bsnList } = useFinalityProviderBsnState(); + const { createEOI, stakeDelegation } = useStakingService(); + + const { + reset: resetForm, + trigger: revalidateForm, + setValue: setFieldValue, + } = useFormContext(); + + const { coinSymbol } = getNetworkConfigBTC(); + const { data: networkInfo } = useNetworkInfo(); + const btcInUsd = usePrice(coinSymbol); + + const currentFinalityProviders = useWatch({ name: "finalityProviders" }); + + const { bsnInfos, finalityProviderInfos } = useMemo(() => { + const bsns: Array<{ icon: React.ReactNode; name: string }> = []; + const fps: Array<{ icon: React.ReactNode; name: string }> = []; + + if (currentFinalityProviders) { + if ( + typeof currentFinalityProviders === "object" && + !Array.isArray(currentFinalityProviders) + ) { + const providerMap = currentFinalityProviders as Record; + + Object.entries(providerMap).forEach(([bsnId, fpPublicKey]) => { + const bsn = bsnList.find((bsn) => bsn.id === bsnId); + if (bsn || bsnId === BBN_CHAIN_ID) { + const logoUrl = + chainLogos[bsn?.id || "babylon"] || chainLogos.placeholder; + bsns.push({ + icon: ( + + ), + name: bsn?.name || "Babylon Genesis", + }); + } + + const provider = getRegisteredFinalityProvider(fpPublicKey); + if (provider) { + fps.push({ + icon: ( + + ), + name: provider.description?.moniker || trim(fpPublicKey, 8), + }); + } + }); + } else { + const fpArray = Array.isArray(currentFinalityProviders) + ? currentFinalityProviders + : []; + + fpArray.forEach((fpPublicKey) => { + const logoUrl = chainLogos["babylon"]; + bsns.push({ + icon: ( + + ), + name: "Babylon Genesis", + }); + + const provider = getRegisteredFinalityProvider(fpPublicKey); + if (provider) { + fps.push({ + icon: ( + + ), + name: provider.description?.moniker || trim(fpPublicKey, 8), + }); + } + }); + } + } + + return { bsnInfos: bsns, finalityProviderInfos: fps }; + }, [currentFinalityProviders, bsnList, getRegisteredFinalityProvider]); + + const details = useMemo(() => { + if (!formData || !stakingInfo) return null; + + const unbondingTime = + blocksToDisplayTime( + networkInfo?.params.bbnStakingParams?.latestParam?.unbondingTime, + ) || "7 days"; + + const stakeAmountBtc = maxDecimals(satoshiToBtc(formData.amount), 8); + const stakeAmountUsd = calculateTokenValueInCurrency( + satoshiToBtc(formData.amount), + btcInUsd, + ); + + const feeAmountBtc = maxDecimals(satoshiToBtc(formData.feeAmount), 8); + const feeAmountUsd = calculateTokenValueInCurrency( + satoshiToBtc(formData.feeAmount), + btcInUsd, + ); + + const unbondingFeeBtc = maxDecimals( + satoshiToBtc(stakingInfo.unbondingFeeSat), + 8, + ); + const unbondingFeeUsd = calculateTokenValueInCurrency( + satoshiToBtc(stakingInfo.unbondingFeeSat), + btcInUsd, + ); + + return { + stakeAmount: `${stakeAmountBtc} ${coinSymbol}${displayUSD ? ` (${stakeAmountUsd})` : ""}`, + feeRate: `${formData.feeRate} sat/vB`, + transactionFees: `${feeAmountBtc} ${coinSymbol}${displayUSD ? ` (${feeAmountUsd})` : ""}`, + term: { + blocks: `${formData.term} blocks`, + duration: `~ ${blocksToDisplayTime(formData.term)}`, + }, + unbonding: `~ ${unbondingTime}`, + unbondingFee: `${unbondingFeeBtc} ${coinSymbol}${displayUSD ? ` (${unbondingFeeUsd})` : ""}`, + }; + }, [formData, stakingInfo, networkInfo, btcInUsd, coinSymbol]); + + if (!step) return null; + + return ( + <> + {step === "preview" && stakingInfo && details && ( + { + if (!formData) return; + await createEOI(formData); + resetForm({ + finalityProviders: undefined, + term: "", + amount: "", + feeRate: stakingInfo?.defaultFeeRate?.toString() ?? "0", + feeAmount: "0", + }); + if (stakingInfo?.defaultStakingTimeBlocks) { + setFieldValue("term", stakingInfo?.defaultStakingTimeBlocks, { + shouldDirty: true, + shouldTouch: true, + }); + } + revalidateForm(); + }} + /> + )} + + {Boolean(EOI_INDEXES[step]) && ( + + )} + + {Boolean(VERIFICATION_STEPS[step]) && ( + + )} + + {verifiedDelegation && ( + stakeDelegation(verifiedDelegation)} + onClose={resetState} + /> + )} + + + + + ); +} diff --git a/src/ui/common/components/NetworkBadge/NetworkBadge.tsx b/src/ui/common/components/NetworkBadge/NetworkBadge.tsx new file mode 100644 index 000000000..25f608ed7 --- /dev/null +++ b/src/ui/common/components/NetworkBadge/NetworkBadge.tsx @@ -0,0 +1,31 @@ +import { twJoin } from "tailwind-merge"; + +import { getNetworkConfigBTC } from "@/ui/common/config/network/btc"; +import { Network } from "@/ui/common/types/network"; + +import testnetIcon from "./testnet-icon.png"; + +const { network } = getNetworkConfigBTC(); + +export const NetworkBadge = () => { + return ( +
+ {[Network.SIGNET, Network.TESTNET].includes(network) && ( + <> + Testnet + {/* + currently the text is absolutely positioned + since the image has a shadow + */} +

+ Testnet +

+ + )} +
+ ); +}; diff --git a/src/ui/common/components/NetworkBadge/testnet-icon.png b/src/ui/common/components/NetworkBadge/testnet-icon.png new file mode 100644 index 000000000..ffdef3fea Binary files /dev/null and b/src/ui/common/components/NetworkBadge/testnet-icon.png differ diff --git a/src/ui/common/components/Notification/DetailsButton.tsx b/src/ui/common/components/Notification/DetailsButton.tsx new file mode 100644 index 000000000..45121990b --- /dev/null +++ b/src/ui/common/components/Notification/DetailsButton.tsx @@ -0,0 +1,10 @@ +export const DetailsButton = () => { + return ( + + ); +}; diff --git a/src/ui/common/components/Notification/FloatingTopBar.tsx b/src/ui/common/components/Notification/FloatingTopBar.tsx new file mode 100644 index 000000000..13447db38 --- /dev/null +++ b/src/ui/common/components/Notification/FloatingTopBar.tsx @@ -0,0 +1,22 @@ +import { TypeOptions } from "react-toastify"; +import { twJoin } from "tailwind-merge"; + +interface Props { + type: TypeOptions; +} + +const BG_COLOR = { + success: "bg-[#49B149]", + warning: "bg-[#C5882D]", + error: "bg-[#DD6464]", + info: "bg-[#919191]", + default: "bg-[#919191]", +} as const; + +export const FloatingTopBar = ({ type }: Props) => { + return ( +
+ ); +}; diff --git a/src/ui/common/components/Notification/IconWrapper.tsx b/src/ui/common/components/Notification/IconWrapper.tsx new file mode 100644 index 000000000..daccb9b08 --- /dev/null +++ b/src/ui/common/components/Notification/IconWrapper.tsx @@ -0,0 +1,24 @@ +import { IconType } from "react-icons"; +import { TypeOptions } from "react-toastify"; +import { twJoin } from "tailwind-merge"; + +interface Props { + ReactIcon: IconType; + type: TypeOptions; +} + +const BG_TEXT_COLOR = { + success: "bg-[#49B149]/15 text-[#49B149]", + warning: "bg-[#C5882D]/15 text-[#C5882D]", + error: "bg-[#DD6464]/15 text-[#DD6464]", + info: "bg-[#919191]/15 text-[#919191]", + default: "bg-[#919191]/15 text-[#919191]", +} as const; + +export const IconWrapper = ({ ReactIcon, type }: Props) => { + return ( +
+ +
+ ); +}; diff --git a/src/ui/common/components/Notification/Notification.tsx b/src/ui/common/components/Notification/Notification.tsx new file mode 100644 index 000000000..030fb3765 --- /dev/null +++ b/src/ui/common/components/Notification/Notification.tsx @@ -0,0 +1,52 @@ +import { ReactNode } from "react"; +import { IconType } from "react-icons"; +import { IoClose } from "react-icons/io5"; +import { ToastContentProps } from "react-toastify"; + +import { DetailsButton } from "./DetailsButton"; +import { FloatingTopBar } from "./FloatingTopBar"; +import { IconWrapper } from "./IconWrapper"; +import { NotificationText } from "./NotificationText"; +import { NotificationTitle } from "./NotificationTitle"; + +interface Props extends Partial { + title: string; + text: string; + actionComponent?: ReactNode; + reactIcon: IconType; +} + +export const Notification = ({ + closeToast, + toastProps, + title, + text, + actionComponent = , + reactIcon, +}: Props) => { + if (closeToast === undefined || toastProps === undefined) { + throw new SyntaxError( + "Notification should only be used with toast from react-toastify", + ); + } + return ( +
+ +
+ +
+ {title} + {text} +
{actionComponent}
+
+
{actionComponent}
+ +
+
+ ); +}; diff --git a/src/ui/common/components/Notification/NotificationContainer.tsx b/src/ui/common/components/Notification/NotificationContainer.tsx new file mode 100644 index 000000000..2cae3efae --- /dev/null +++ b/src/ui/common/components/Notification/NotificationContainer.tsx @@ -0,0 +1,34 @@ +import { ToastContainer } from "react-toastify"; +import { twJoin } from "tailwind-merge"; + +import { useIsMobileView } from "@/ui/common/hooks/useBreakpoint"; + +const commonClassName = "relative overflow-hidden rounded-lg px-4 py-3 md:p-3"; + +const BG_COLOR = { + success: + "dark:bg-[linear-gradient(0deg,rgba(73,177,73,0.1),rgba(73,177,73,0.1))] dark:bg-[#191919] bg-[linear-gradient(0deg,rgba(73,177,73,0.05),rgba(73,177,73,0.05))] bg-[#FFFFFF]", + warning: + "bg-[linear-gradient(0deg,rgba(197,136,45,0.05),rgba(197,136,45,0.05))] bg-[#FFFFFF] dark:bg-[linear-gradient(0deg,rgba(197,136,45,0.1),rgba(197,136,45,0.1))] dark:bg-[#191919]", + error: + "bg-[linear-gradient(0deg,rgba(221,100,100,0.05),rgba(221,100,100,0.05))] bg-[#FFFFFF] dark:bg-[linear-gradient(0deg,rgba(221,100,100,0.1),rgba(221,100,100,0.1))] dark:bg-[#191919]", + info: "bg-[#FFFFFF] dark:bg-[#191919]", + default: "bg-[#FFFFFF] dark:bg-[#191919]", +} as const; + +export const NotificationContainer = () => { + const isMobileView = useIsMobileView(); + + return ( + { + return twJoin(commonClassName, BG_COLOR[context?.type ?? "default"]); + }} + autoClose={false} + closeButton={false} + icon={false} + hideProgressBar={true} + position={isMobileView ? "top-center" : "bottom-center"} + /> + ); +}; diff --git a/src/ui/common/components/Notification/NotificationText.tsx b/src/ui/common/components/Notification/NotificationText.tsx new file mode 100644 index 000000000..a97bffdb4 --- /dev/null +++ b/src/ui/common/components/Notification/NotificationText.tsx @@ -0,0 +1,9 @@ +interface Props { + children: string; +} + +export const NotificationText = ({ children }: Props) => { + return ( + {children} + ); +}; diff --git a/src/ui/common/components/Notification/NotificationTitle.tsx b/src/ui/common/components/Notification/NotificationTitle.tsx new file mode 100644 index 000000000..c9fce9157 --- /dev/null +++ b/src/ui/common/components/Notification/NotificationTitle.tsx @@ -0,0 +1,9 @@ +interface Props { + children: string; +} + +export const NotificationTitle = ({ children }: Props) => { + return ( + {children} + ); +}; diff --git a/src/ui/common/components/PersonalBalance/PersonalBalance.tsx b/src/ui/common/components/PersonalBalance/PersonalBalance.tsx new file mode 100644 index 000000000..93670a904 --- /dev/null +++ b/src/ui/common/components/PersonalBalance/PersonalBalance.tsx @@ -0,0 +1,135 @@ +import { AuthGuard } from "@/ui/common/components/Common/AuthGuard"; +import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn"; +import { getNetworkConfigBTC } from "@/ui/common/config/network/btc"; +import { useUTXOs } from "@/ui/common/hooks/client/api/useUTXOs"; +import { useRewardsService } from "@/ui/common/hooks/services/useRewardsService"; +import { useIsMobileView } from "@/ui/common/hooks/useBreakpoint"; +import { useBalanceState } from "@/ui/common/state/BalanceState"; +import { useRewardsState } from "@/ui/common/state/RewardState"; +import { ubbnToBaby } from "@/ui/common/utils/bbn"; +import { satoshiToBtc } from "@/ui/common/utils/btc"; + +import { ClaimRewardModal } from "../Modals/ClaimRewardModal"; +import { ClaimStatusModal } from "../Modals/ClaimStatusModal/ClaimStatusModal"; +import { Section } from "../Section/Section"; +import { ActionComponent } from "../Stats/ActionComponent"; +import { LoadingStyle, StatItem } from "../Stats/StatItem"; + +const { networkName: bbnNetworkName, coinSymbol: bbnCoinSymbol } = + getNetworkConfigBBN(); +const { coinSymbol, networkName } = getNetworkConfigBTC(); + +export function PersonalBalance() { + // Load reward state + const { + loading: rewardLoading, + processing, + showRewardModal, + showProcessingModal, + closeProcessingModal, + closeRewardModal, + bbnAddress, + rewardBalance, + transactionFee, + transactionHash, + setTransactionHash, + } = useRewardsState(); + + // Load balance state + const { + bbnBalance, + stakableBtcBalance, + stakedBtcBalance, + inscriptionsBtcBalance, + loading: isBalanceLoading, + } = useBalanceState(); + + const { allUTXOs = [], confirmedUTXOs = [] } = useUTXOs(); + const hasUnconfirmedUTXOs = allUTXOs.length > confirmedUTXOs.length; + + const { claimRewards, showPreview } = useRewardsService(); + + const isMobile = useIsMobileView(); + const formattedRewardBalance = ubbnToBaby(rewardBalance); + + return ( + +
+ + + +
+ +
+ + + + } + /> +
+ + + + { + closeProcessingModal(); + setTransactionHash(""); + }} + loading={processing} + transactionHash={transactionHash} + /> +
+ ); +} diff --git a/src/ui/common/components/Section/Section.tsx b/src/ui/common/components/Section/Section.tsx new file mode 100644 index 000000000..3521e4f9d --- /dev/null +++ b/src/ui/common/components/Section/Section.tsx @@ -0,0 +1,33 @@ +import { Heading } from "@babylonlabs-io/core-ui"; +import { PropsWithChildren } from "react"; +import { twMerge } from "tailwind-merge"; + +interface SectionProps { + className?: string; + titleClassName?: string; + title: string; +} + +export function Section({ + className, + titleClassName, + title, + children, +}: PropsWithChildren) { + return ( +
+ + {title} + + + {children} +
+ ); +} diff --git a/src/ui/common/components/SignDetails/SignDetails.tsx b/src/ui/common/components/SignDetails/SignDetails.tsx new file mode 100644 index 000000000..6239efcfb --- /dev/null +++ b/src/ui/common/components/SignDetails/SignDetails.tsx @@ -0,0 +1,149 @@ +import { EventData } from "@babylonlabs-io/btc-staking-ts"; +import { Text } from "@babylonlabs-io/core-ui"; +import { ReactNode } from "react"; +import { twMerge } from "tailwind-merge"; + +import { Hash } from "@/ui/common/components/Hash/Hash"; +import { getNetworkConfigBTC } from "@/ui/common/config/network/btc"; +import { satoshiToBtc } from "@/ui/common/utils/btc"; +import { maxDecimals } from "@/ui/common/utils/maxDecimals"; +import { blocksToDisplayTime } from "@/ui/common/utils/time"; + +interface SignDetailsProps { + details?: EventData; + shouldHaveMargin?: boolean; +} + +const keyDisplayMappings: Record = { + stakerPk: "Staker Public Key", + finalityProviders: "Finality Providers", + covenantPks: "Covenant Public Keys", + covenantThreshold: "Covenant Threshold", + minUnbondingTime: "Unbonding Time", + stakingDuration: "Staking Duration", + unbondingTimeBlocks: "Unbonding Time", + address: "Address", + type: "Type", + timelockBlocks: "Timelock", + bech32Address: "BABY Address", + unbondingFeeSat: "Unbonding Fee", + slashingFeeSat: "Slashing Fee", + slashingPkScriptHex: "Slashing Script Hex", +}; + +const formatDisplayKey = (key: string): string => { + return keyDisplayMappings[key] || key; +}; + +// Format the display value based on the key and value type +const formatDisplayValue = (key: string, value: any): ReactNode => { + const { coinName } = getNetworkConfigBTC(); + + // Staking duration, unbonding time + if ( + typeof value === "number" && + (key.toLowerCase().includes("time") || + key.toLowerCase().includes("duration")) + ) { + return ( + + {blocksToDisplayTime(value)} + + ); + } + // Finality providers, covenant public keys + if (Array.isArray(value)) { + return ( +
+ {value.map((item, index) => ( + + ))} +
+ ); + } + // Public keys and addresses + if ( + key.toLowerCase().includes("pk") || + key.toLowerCase().includes("address") + ) { + return ; + } + // Title + if (key.toLowerCase() === "type") { + const capitalizedTitle = (value as string) + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); + return ( + + {capitalizedTitle} + + ); + } + // Fees, convert from satoshis to BTC + if (key.toLowerCase().includes("fee") && typeof value === "number") { + return ( + + {maxDecimals(satoshiToBtc(value), 8)} {coinName} + + ); + } + // Default case for other values + return ( + + {String(value)} + + ); +}; + +const getOrderedKeys = (details: EventData): string[] => { + // Provide an order for specific keys + const orderedKeys = ["type", "stakerPk"]; + // Then add any remaining keys from details that aren't already included + const allKeys = orderedKeys.filter((key) => key in details); + Object.keys(details).forEach((key) => { + if (!allKeys.includes(key)) { + allKeys.push(key); + } + }); + return allKeys; +}; + +export const SignDetails: React.FC = ({ + details, + shouldHaveMargin, +}) => { + if (!details || Object.keys(details).length === 0) { + return null; + } + + return ( +
+ {getOrderedKeys(details).map((key) => ( +
+ + {formatDisplayKey(key)}: + + {formatDisplayValue(key, details[key])} +
+ ))} +
+ ); +}; diff --git a/src/ui/common/components/Stakers/Staker.tsx b/src/ui/common/components/Stakers/Staker.tsx new file mode 100644 index 000000000..a8dfb5cc3 --- /dev/null +++ b/src/ui/common/components/Stakers/Staker.tsx @@ -0,0 +1,46 @@ +import { getNetworkConfigBTC } from "@/ui/common/config/network/btc"; +import { satoshiToBtc } from "@/ui/common/utils/btc"; +import { maxDecimals } from "@/ui/common/utils/maxDecimals"; + +import { Hash } from "../Hash/Hash"; + +interface StakerProps { + pkHex: string; + delegations: number; + activeTVLSat: number; +} + +export const Staker: React.FC = ({ + pkHex, + delegations, + activeTVLSat, +}) => { + const { coinName } = getNetworkConfigBTC(); + + return ( +
+
+ +
+
+
+ +
+
+

+ Delegations +

+

{delegations || 0}

+
+
+

Stake

+

+ {activeTVLSat + ? `${maxDecimals(satoshiToBtc(activeTVLSat), 8)} ${coinName}` + : 0} +

+
+
+
+ ); +}; diff --git a/src/ui/common/components/Staking/DelegationForm/components/AmountField.tsx b/src/ui/common/components/Staking/DelegationForm/components/AmountField.tsx new file mode 100644 index 000000000..cce1c7953 --- /dev/null +++ b/src/ui/common/components/Staking/DelegationForm/components/AmountField.tsx @@ -0,0 +1,33 @@ +import { NumberField, Text } from "@babylonlabs-io/core-ui"; + +import { getNetworkConfigBTC } from "@/ui/common/config/network/btc"; +import { satoshiToBtc } from "@/ui/common/utils/btc"; + +interface AmountFieldProps { + min?: number; + max?: number; +} + +const { coinSymbol } = getNetworkConfigBTC(); + +export function AmountField({ min = 0, max = 0 }: AmountFieldProps) { + const label = ( +
+ + Amount + + + min/max: {satoshiToBtc(min)}/{satoshiToBtc(max)} {coinSymbol} + +
+ ); + + return ( + + ); +} diff --git a/src/ui/common/components/Staking/DelegationForm/components/BBNFeeAmount.tsx b/src/ui/common/components/Staking/DelegationForm/components/BBNFeeAmount.tsx new file mode 100644 index 000000000..1127936c3 --- /dev/null +++ b/src/ui/common/components/Staking/DelegationForm/components/BBNFeeAmount.tsx @@ -0,0 +1,24 @@ +import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn"; +import { usePrice } from "@/ui/common/hooks/client/api/usePrices"; +import { calculateTokenValueInCurrency } from "@/ui/common/utils/formatCurrency"; + +import { FeeItem } from "./FeeItem"; +interface FeeStatsProps { + amount?: string; +} + +const { coinSymbol, displayUSD } = getNetworkConfigBBN(); + +export function BBNFeeAmount({ amount = "0" }: FeeStatsProps) { + const bbnInUsd = usePrice(coinSymbol); + const feeInUsd = calculateTokenValueInCurrency(parseFloat(amount), bbnInUsd); + + return ( + + {amount} {coinSymbol} + + ); +} diff --git a/src/ui/common/components/Staking/DelegationForm/components/BTCFeeAmount.tsx b/src/ui/common/components/Staking/DelegationForm/components/BTCFeeAmount.tsx new file mode 100644 index 000000000..32c5c81e0 --- /dev/null +++ b/src/ui/common/components/Staking/DelegationForm/components/BTCFeeAmount.tsx @@ -0,0 +1,30 @@ +import { useWatch } from "@babylonlabs-io/core-ui"; + +import { getNetworkConfigBTC } from "@/ui/common/config/network/btc"; +import { usePrice } from "@/ui/common/hooks/client/api/usePrices"; +import { satoshiToBtc } from "@/ui/common/utils/btc"; +import { calculateTokenValueInCurrency } from "@/ui/common/utils/formatCurrency"; + +import { FeeItem } from "./FeeItem"; + +const { coinSymbol, displayUSD } = getNetworkConfigBTC(); + +export function BTCFeeAmount() { + const feeAmount = useWatch({ name: "feeAmount" }); + + const btcInUsd = usePrice(coinSymbol); + const formattedFeeAmount = parseFloat(feeAmount || "0"); + const feeInUsd = calculateTokenValueInCurrency( + satoshiToBtc(formattedFeeAmount), + btcInUsd, + ); + + return ( + + {satoshiToBtc(formattedFeeAmount)} {coinSymbol} + + ); +} diff --git a/src/ui/common/components/Staking/DelegationForm/components/BTCFeeRate.tsx b/src/ui/common/components/Staking/DelegationForm/components/BTCFeeRate.tsx new file mode 100644 index 000000000..5f0b01800 --- /dev/null +++ b/src/ui/common/components/Staking/DelegationForm/components/BTCFeeRate.tsx @@ -0,0 +1,112 @@ +import { Button, useFormContext, useWatch } from "@babylonlabs-io/core-ui"; +import { useEffect, useState } from "react"; +import { FaPen } from "react-icons/fa6"; + +import { FeeModal } from "@/ui/common/components/Staking/FeeModal"; +import { useStakingService } from "@/ui/common/hooks/services/useStakingService"; + +import { FeeItem } from "./FeeItem"; + +interface FeeFiledProps { + defaultRate?: number; +} + +export function BTCFeeRate({ defaultRate = 0 }: FeeFiledProps) { + const [visible, setVisibility] = useState(false); + const feeRate = useWatch({ name: "feeRate" }); + const finalityProviders = useWatch({ name: "finalityProviders" }); + const amount = useWatch({ name: "amount" }); + const term = useWatch({ name: "term" }); + const { setValue, setError, clearErrors } = useFormContext(); + const { calculateFeeAmount } = useStakingService(); + + useEffect(() => { + setValue("feeRate", defaultRate.toString(), { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }); + }, [defaultRate, setValue]); + + useEffect(() => { + try { + if ( + !finalityProviders || + !Array.isArray(finalityProviders) || + finalityProviders.length === 0 || + !amount || + !term || + !feeRate + ) { + setValue("feeAmount", "0", { + shouldValidate: false, + shouldDirty: false, + shouldTouch: false, + }); + return; + } + + const feeAmount = calculateFeeAmount({ + finalityProviders, + amount, + term, + feeRate, + }); + + clearErrors("feeAmount"); + setValue("feeAmount", feeAmount.toString(), { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }); + } catch (e: any) { + setValue("feeAmount", "0", { + shouldValidate: false, + shouldDirty: false, + shouldTouch: false, + }); + setError("feeAmount", { + type: "custom", + message: e.message, + }); + } + }, [ + finalityProviders, + amount, + term, + feeRate, + calculateFeeAmount, + setValue, + setError, + clearErrors, + ]); + + return ( + + {feeRate} sats/vB + + + + void setVisibility(false)} + onSubmit={(value) => + setValue("feeRate", value.toString(), { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }) + } + /> + + ); +} diff --git a/src/ui/common/components/Staking/DelegationForm/components/FeeItem.tsx b/src/ui/common/components/Staking/DelegationForm/components/FeeItem.tsx new file mode 100644 index 000000000..2bd5090cd --- /dev/null +++ b/src/ui/common/components/Staking/DelegationForm/components/FeeItem.tsx @@ -0,0 +1,40 @@ +import { Text } from "@babylonlabs-io/core-ui"; +import { PropsWithChildren } from "react"; +import { twMerge } from "tailwind-merge"; + +interface FeeItemProps extends PropsWithChildren { + title: string; + className?: string; + hint?: string; +} + +export function FeeItem({ title, children, className, hint }: FeeItemProps) { + return ( +
+ + {title} + + + {!hint ? ( + + {children} + + ) : ( +
+ + {children} + + + {hint} + +
+ )} +
+ ); +} diff --git a/src/ui/common/components/Staking/DelegationForm/components/FeeSection.tsx b/src/ui/common/components/Staking/DelegationForm/components/FeeSection.tsx new file mode 100644 index 000000000..11f813137 --- /dev/null +++ b/src/ui/common/components/Staking/DelegationForm/components/FeeSection.tsx @@ -0,0 +1,14 @@ +import { useFieldState } from "@babylonlabs-io/core-ui"; +import { PropsWithChildren } from "react"; + +const FIELDS = ["finalityProviders", "term", "amount"]; + +export const FeeSection = ({ children }: PropsWithChildren) => { + const fieldStates = useFieldState(FIELDS); + + if (fieldStates.some((field) => field.invalid || !field.isDirty)) { + return null; + } + + return <>{children}; +}; diff --git a/src/ui/common/components/Staking/DelegationForm/components/InfoAlert.tsx b/src/ui/common/components/Staking/DelegationForm/components/InfoAlert.tsx new file mode 100644 index 000000000..d0fa6f3b0 --- /dev/null +++ b/src/ui/common/components/Staking/DelegationForm/components/InfoAlert.tsx @@ -0,0 +1,39 @@ +import { Text } from "@babylonlabs-io/core-ui"; +import { useState } from "react"; +import { MdErrorOutline } from "react-icons/md"; + +import { InfoModal } from "@/ui/common/components/Modals/InfoModal"; +import { useStakingState } from "@/ui/common/state/StakingState"; +import { blocksToDisplayTime } from "@/ui/common/utils/time"; + +export function InfoAlert() { + const [showMore, setShowMore] = useState(false); + const { stakingInfo } = useStakingState(); + + return ( +
+
+ +
+ +
+ + Info + + + You can unbond and withdraw your stake anytime with an unbonding time + of {blocksToDisplayTime(stakingInfo?.unbondingTime)}. + {" "} + setShowMore(true)} + > + Learn More + +
+ + setShowMore(false)} /> +
+ ); +} diff --git a/src/ui/common/components/Staking/DelegationForm/components/Overlay.tsx b/src/ui/common/components/Staking/DelegationForm/components/Overlay.tsx new file mode 100644 index 000000000..0561fed0a --- /dev/null +++ b/src/ui/common/components/Staking/DelegationForm/components/Overlay.tsx @@ -0,0 +1,33 @@ +import { useFieldState } from "@babylonlabs-io/core-ui"; +import { type PropsWithChildren } from "react"; +import { twJoin, twMerge } from "tailwind-merge"; + +interface OverlayProps { + className?: string; +} + +export function FormOverlay({ + className, + children, +}: PropsWithChildren) { + const fpState = useFieldState("finalityProviders"); + const available = !fpState.invalid && fpState.isTouched; + + return ( +
+
+ {children} +
+ ); +} diff --git a/src/ui/common/components/Staking/DelegationForm/components/SubmitButton.tsx b/src/ui/common/components/Staking/DelegationForm/components/SubmitButton.tsx new file mode 100644 index 000000000..6f9c25e27 --- /dev/null +++ b/src/ui/common/components/Staking/DelegationForm/components/SubmitButton.tsx @@ -0,0 +1,47 @@ +import { Button, useFormState } from "@babylonlabs-io/core-ui"; +import { Tooltip } from "react-tooltip"; + +import { getNetworkConfigBBN } from "@/ui/common/config/network/bbn"; +import { BBN_FEE_AMOUNT } from "@/ui/common/constants"; +import { useBbnQuery } from "@/ui/common/hooks/client/rpc/queries/useBbnQuery"; + +const { coinSymbol } = getNetworkConfigBBN(); + +export function SubmitButton() { + const { isValid, errors } = useFormState(); + const { + balanceQuery: { data: bbnBalance = 0 }, + } = useBbnQuery(); + + const [errorMessage] = Object.keys(errors).map( + (fieldName) => (errors[fieldName]?.message as string) ?? "", + ); + + const invalid = !isValid || bbnBalance === 0; + const tooltip = + errorMessage ?? + (bbnBalance === 0 + ? `Insufficient ${coinSymbol} Balance in Babylon Wallet${BBN_FEE_AMOUNT ? `.\n${BBN_FEE_AMOUNT} ${coinSymbol} required for network fees.` : ""}` + : ""); + + return ( + + + + + + ); +} diff --git a/src/ui/common/components/Staking/DelegationForm/components/TermField.tsx b/src/ui/common/components/Staking/DelegationForm/components/TermField.tsx new file mode 100644 index 000000000..b02a63b35 --- /dev/null +++ b/src/ui/common/components/Staking/DelegationForm/components/TermField.tsx @@ -0,0 +1,33 @@ +import { HiddenField, NumberField, Text } from "@babylonlabs-io/core-ui"; + +interface TermFieldProps { + min?: number; + max?: number; + defaultValue?: number; +} + +export function TermField({ min = 0, defaultValue }: TermFieldProps) { + if (defaultValue) { + return ; + } + + const label = ( +
+ + Term + + + min term is {min} blocks + +
+ ); + + return ( + + ); +} diff --git a/src/ui/common/components/Staking/DelegationForm/components/Total.tsx b/src/ui/common/components/Staking/DelegationForm/components/Total.tsx new file mode 100644 index 000000000..196ecf3ca --- /dev/null +++ b/src/ui/common/components/Staking/DelegationForm/components/Total.tsx @@ -0,0 +1,42 @@ +import { Text, useWatch } from "@babylonlabs-io/core-ui"; +import { useMemo } from "react"; + +import { getNetworkConfigBTC } from "@/ui/common/config/network/btc"; +import { usePrice } from "@/ui/common/hooks/client/api/usePrices"; +import { satoshiToBtc } from "@/ui/common/utils/btc"; +import { calculateTokenValueInCurrency } from "@/ui/common/utils/formatCurrency"; +import { maxDecimals } from "@/ui/common/utils/maxDecimals"; + +const { coinSymbol, displayUSD } = getNetworkConfigBTC(); + +export function Total() { + const [amount, feeAmount] = useWatch({ name: ["amount", "feeAmount"] }); + + const total = useMemo( + () => + maxDecimals(parseFloat(amount || "0") + satoshiToBtc(feeAmount || 0), 8), + [amount, feeAmount], + ); + + const btcInUsd = usePrice(coinSymbol); + const totalInUsd = calculateTokenValueInCurrency(total, btcInUsd); + + return ( +
+ + Total + + +
+ + {total} {coinSymbol} + + {displayUSD && ( + + {totalInUsd} + + )} +
+
+ ); +} diff --git a/src/ui/common/components/Staking/DelegationForm/index.tsx b/src/ui/common/components/Staking/DelegationForm/index.tsx new file mode 100644 index 000000000..839244b5f --- /dev/null +++ b/src/ui/common/components/Staking/DelegationForm/index.tsx @@ -0,0 +1,170 @@ +import { Heading, HiddenField, Loader, Text } from "@babylonlabs-io/core-ui"; + +import { AuthGuard } from "@/ui/common/components/Common/AuthGuard"; +import { StatusView } from "@/ui/common/components/Staking/FinalityProviders/FinalityProviderTableStatusView"; +import apiNotAvailable from "@/ui/common/components/Staking/Form/States/api-not-available.svg"; +import { Message } from "@/ui/common/components/Staking/Form/States/Message"; +import stakingUnavailableIcon from "@/ui/common/components/Staking/Form/States/staking-unavailable.svg"; +import walletIcon from "@/ui/common/components/Staking/Form/States/wallet-icon.svg"; +import { WalletNotConnected } from "@/ui/common/components/Staking/Form/States/WalletNotConnected"; +import { BBN_FEE_AMOUNT } from "@/ui/common/constants"; +import { useBalanceState } from "@/ui/common/state/BalanceState"; + +import { AmountField } from "./components/AmountField"; +import { BBNFeeAmount } from "./components/BBNFeeAmount"; +import { BTCFeeAmount } from "./components/BTCFeeAmount"; +import { BTCFeeRate } from "./components/BTCFeeRate"; +import { FeeSection } from "./components/FeeSection"; +import { InfoAlert } from "./components/InfoAlert"; +import { FormOverlay } from "./components/Overlay"; +import { SubmitButton } from "./components/SubmitButton"; +import { TermField } from "./components/TermField"; +import { Total } from "./components/Total"; + +interface DelegationFormProps { + loading?: boolean; + blocked?: boolean; + hasError?: boolean; + error?: string; + stakingInfo?: { + minFeeRate: number; + maxFeeRate: number; + defaultFeeRate: number; + minStakingTimeBlocks: number; + maxStakingTimeBlocks: number; + minStakingAmountSat: number; + maxStakingAmountSat: number; + defaultStakingTimeBlocks?: number; + }; + disabled?: { + title: string; + message: string; + }; +} + +export function DelegationForm({ + loading, + blocked, + disabled, + hasError, + error, + stakingInfo, +}: DelegationFormProps) { + const { stakableBtcBalance } = useBalanceState(); + + if (loading) { + return ( + } + title="Please wait..." + /> + ); + } + + if (blocked) { + return ( + + } + /> + ); + } + + if (disabled) { + return ( + + } + /> + ); + } + + if (hasError) { + return ( + + } + title="Staking is not available" + message={error ?? ""} + /> + ); + } + + const maxAmount = Math.min( + stakableBtcBalance, + stakingInfo?.maxStakingAmountSat || 0, + ); + + return ( + }> +
+ + Step 2 + + + + Set Staking Amount + + + + +
+ + + + + + + + + + +
+ + + {BBN_FEE_AMOUNT && } +
+ +
+ + + + + + +
+
+ + ); +} diff --git a/src/ui/common/components/Staking/FeeModal/components/CustomLabel.tsx b/src/ui/common/components/Staking/FeeModal/components/CustomLabel.tsx new file mode 100644 index 000000000..2ee688fa3 --- /dev/null +++ b/src/ui/common/components/Staking/FeeModal/components/CustomLabel.tsx @@ -0,0 +1,39 @@ +import { Input, Text } from "@babylonlabs-io/core-ui"; +import { ChangeEventHandler, forwardRef } from "react"; + +import { WarningTooltip } from "./WarningTooltip"; + +interface CustomLabelProps { + label: string; + amount: string; + disabled: boolean; + warning: boolean; + onChange: ChangeEventHandler; +} + +export const CustomLabel = forwardRef( + ({ label, amount, disabled, warning, onChange }, ref) => ( + + {label}{" "} + + {warning && } + sats vB + + } + onChange={onChange} + /> + + ), +); + +CustomLabel.displayName = "CustomLabel"; diff --git a/src/ui/common/components/Staking/FeeModal/components/Label.tsx b/src/ui/common/components/Staking/FeeModal/components/Label.tsx new file mode 100644 index 000000000..941eafd62 --- /dev/null +++ b/src/ui/common/components/Staking/FeeModal/components/Label.tsx @@ -0,0 +1,28 @@ +import { Chip, Text } from "@babylonlabs-io/core-ui"; + +import { WarningTooltip } from "./WarningTooltip"; + +interface LabelProps { + label: string; + amount: string; + hint: string; + warning: boolean; +} + +export const Label = ({ label, amount, warning, hint }: LabelProps) => ( + + + {label} ({amount} sat/vB) + + + + {warning && } + + {hint} + + +); diff --git a/src/ui/common/components/Staking/FeeModal/components/WarningTooltip.tsx b/src/ui/common/components/Staking/FeeModal/components/WarningTooltip.tsx new file mode 100644 index 000000000..e577d9253 --- /dev/null +++ b/src/ui/common/components/Staking/FeeModal/components/WarningTooltip.tsx @@ -0,0 +1,25 @@ +import { useId } from "react"; +import { IoWarningOutline } from "react-icons/io5"; +import { Tooltip } from "react-tooltip"; + +interface WarningTooltipProps { + className?: string; +} + +export const WarningTooltip = ({ className }: WarningTooltipProps) => { + const id = useId(); + + return ( + + + + + ); +}; diff --git a/src/ui/common/components/Staking/FeeModal/index.tsx b/src/ui/common/components/Staking/FeeModal/index.tsx new file mode 100644 index 000000000..dc92cf0dc --- /dev/null +++ b/src/ui/common/components/Staking/FeeModal/index.tsx @@ -0,0 +1,166 @@ +import { + Button, + DialogBody, + DialogFooter, + DialogHeader, + Heading, + Loader, + Radio, + Text, +} from "@babylonlabs-io/core-ui"; +import { useEffect, useRef, useState } from "react"; + +import { ResponsiveDialog } from "@/ui/common/components/Modals/ResponsiveDialog"; +import { StatusView } from "@/ui/common/components/Staking/FinalityProviders/FinalityProviderTableStatusView"; +import { useNetworkFees } from "@/ui/common/hooks/client/api/useNetworkFees"; +import { useStakingState } from "@/ui/common/state/StakingState"; + +import { CustomLabel } from "./components/CustomLabel"; +import { Label } from "./components/Label"; + +interface FeeModalProps { + open?: boolean; + onSubmit?: (value: number) => void; + onClose?: () => void; +} + +export function FeeModal({ open, onSubmit, onClose }: FeeModalProps) { + const [selectedValue, setSelectedValue] = useState(""); + const [customFee, setCustomFee] = useState(""); + const customFeeRef = useRef(null); + + const { + data: { + fastestFee = 0, + halfHourFee: mediumFee = 0, + hourFee: lowestFee = 0, + } = {}, + isLoading, + } = useNetworkFees(); + const { stakingInfo: { defaultFeeRate = 0 } = {} } = useStakingState(); + + useEffect(() => { + if (selectedValue === "custom") { + customFeeRef.current?.focus(); + } + }, [selectedValue]); + + const feeOptions = [ + { + label: ( +