From ff97500cbf4139bcb57ed7d1c91fd68f71770fe6 Mon Sep 17 00:00:00 2001 From: David Totrashvili <8580261+totraev@users.noreply.github.com> Date: Tue, 3 Dec 2024 21:58:23 +0500 Subject: [PATCH] feat: handle intermediate states (#412) * feat: add delegation service * feat: add selegation storage * feat: remove ordinal modal * feat: update delegation statuses --- src/app/api/getNetworkInfo.ts | 5 +- .../components/Modals/FilterOrdinalsModal.tsx | 105 -------- src/app/components/Staking/Staking.tsx | 10 + src/app/constants.ts | 10 + .../hooks/services/useDelegationService.ts | 247 ++++++++++++++++++ src/app/hooks/storage/useDelegationStorage.ts | 128 +++++++++ src/app/page.tsx | 2 - src/app/state/DelegationV2State.tsx | 48 ++-- src/app/types/delegationsV2.ts | 43 ++- .../components/ActionButton.tsx | 77 +++--- .../DelegationList/components/Status.tsx | 50 ++-- .../delegations/DelegationList/index.tsx | 120 +-------- 12 files changed, 529 insertions(+), 316 deletions(-) delete mode 100644 src/app/components/Modals/FilterOrdinalsModal.tsx create mode 100644 src/app/hooks/services/useDelegationService.ts create mode 100644 src/app/hooks/storage/useDelegationStorage.ts diff --git a/src/app/api/getNetworkInfo.ts b/src/app/api/getNetworkInfo.ts index e8b4a509..3f8df94d 100644 --- a/src/app/api/getNetworkInfo.ts +++ b/src/app/api/getNetworkInfo.ts @@ -50,10 +50,9 @@ export const getNetworkInfo = async (): Promise => { "/v2/network-info", "Error getting network info", )) as AxiosResponse; - const { params, staking_status } = data.data; - const stakingVersions = params.bbn + const stakingVersions = (params.bbn || []) .sort((a, b) => a.version - b.version) // Sort by version ascending .map((v) => ({ version: v.version, @@ -85,7 +84,7 @@ export const getNetworkInfo = async (): Promise => { ); // Map the BTC checkpoint params to the expected format - const epochCheckVersions = params.btc + const epochCheckVersions = (params.btc || []) .sort((a, b) => a.version - b.version) // Sort by version ascending .map((v) => ({ version: v.version, diff --git a/src/app/components/Modals/FilterOrdinalsModal.tsx b/src/app/components/Modals/FilterOrdinalsModal.tsx deleted file mode 100644 index a3e7a001..00000000 --- a/src/app/components/Modals/FilterOrdinalsModal.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { useWalletConnect } from "@babylonlabs-io/bbn-wallet-connect"; -import { useEffect, useState } from "react"; -import { IoMdClose } from "react-icons/io"; -import { useLocalStorage } from "usehooks-ts"; - -import { FILTER_ORDINALS_MODAL_KEY } from "@/app/common/constants"; -import { useBTCWallet } from "@/app/context/wallet/BTCWalletProvider"; -import { useAppState } from "@/app/state"; - -import { GeneralModal } from "./GeneralModal"; - -interface FilterOrdinalsModalProps {} - -export const FilterOrdinalsModal: React.FC = ({}) => { - const [modalOpen, setModalOpen] = useState(false); - const { address } = useBTCWallet(); - - const handleClose = () => { - setModalOpen(false); - setHasSeenFilterOrdinalsModal((prev) => ({ - ...prev, - [address]: true, - })); - }; - - const { connected: isConnected } = useWalletConnect(); - const { ordinalsExcluded, includeOrdinals, excludeOrdinals } = useAppState(); - - const [hasSeenFilterOrdinalsModal, setHasSeenFilterOrdinalsModal] = - useLocalStorage>(FILTER_ORDINALS_MODAL_KEY, {}); - - useEffect(() => { - if (isConnected && address && !hasSeenFilterOrdinalsModal[address]) { - setModalOpen(true); - } - }, [isConnected, hasSeenFilterOrdinalsModal, address]); - - return ( - -
-

- Ordinals, Bitcoin NFTs, Runes, Bitcoin inscriptions -

- -
-
-
-

- Your wallet may contain Bitcoin Ordinals, which are unique digital - assets. If you proceed without filtering, these Ordinals could be - included in future actions involving your balance. -

-

Please select:

-
-
-
- -
-
- -
-
-

- * You can change this setting later if needed -

- -
-
- ); -}; diff --git a/src/app/components/Staking/Staking.tsx b/src/app/components/Staking/Staking.tsx index 834cdc24..c8a30383 100644 --- a/src/app/components/Staking/Staking.tsx +++ b/src/app/components/Staking/Staking.tsx @@ -14,6 +14,8 @@ import { } from "@/app/hooks/services/useTransactionService"; import { useHealthCheck } from "@/app/hooks/useHealthCheck"; import { useAppState } from "@/app/state"; +import { useDelegationV2State } from "@/app/state/DelegationV2State"; +import { DelegationV2StakingState } from "@/app/types/delegationsV2"; import { ErrorHandlerParam, ErrorState } from "@/app/types/errors"; import { FinalityProvider, @@ -74,6 +76,7 @@ export const Staking = () => { useLocalStorage("bbn-staking-cancelFeedbackModalOpened ", false); const { createDelegationEoi, estimateStakingFee } = useTransactionService(); + const { addDelegation } = useDelegationV2State(); const { networkInfo } = useAppState(); const latestParam = networkInfo?.params.bbnStakingParams?.latestParam; const stakingStatus = networkInfo?.stakingStatus; @@ -226,6 +229,13 @@ export const Staking = () => { signingCallback, ); + addDelegation({ + stakingAmount: stakingAmountSat, + stakingTxHashHex, + startHeight: 0, + state: DelegationV2StakingState.INTERMEDIATE_PENDING_VERIFICATION, + }); + setStakingTxHashHex(stakingTxHashHex); setPendingVerificationOpen(true); } catch (error: Error | any) { diff --git a/src/app/constants.ts b/src/app/constants.ts index be40975f..29842c6e 100644 --- a/src/app/constants.ts +++ b/src/app/constants.ts @@ -1,2 +1,12 @@ export const ONE_SECOND = 1000; export const ONE_MINUTE = 60 * ONE_SECOND; + +export const DELEGATION_ACTIONS = { + STAKE: "STAKE", + UNBOUND: "UNBOUND", + WITHDRAW_ON_EARLY_UNBOUNDING: "WITHDRAW_ON_EARLY_UNBOUNDING", + WITHDRAW_ON_TIMELOCK: "WITHDRAW_ON_TIMELOCK", + WITHDRAW_ON_TIMELOCK_SLASHING: "WITHDRAW_ON_TIMELOCK_SLASHING", + WITHDRAW_ON_EARLY_UNBOUNDING_SLASHING: + "WITHDRAW_ON_EARLY_UNBOUNDING_SLASHING", +} as const; diff --git a/src/app/hooks/services/useDelegationService.ts b/src/app/hooks/services/useDelegationService.ts new file mode 100644 index 00000000..e41e1f37 --- /dev/null +++ b/src/app/hooks/services/useDelegationService.ts @@ -0,0 +1,247 @@ +import { useCallback, useMemo } from "react"; + +import { DELEGATION_ACTIONS as ACTIONS } from "@/app/constants"; +import { useDelegationV2State } from "@/app/state/DelegationV2State"; +import { DelegationV2StakingState as State } from "@/app/types/delegationsV2"; + +import { useTransactionService } from "./useTransactionService"; + +export type ActionType = keyof typeof ACTIONS; + +interface TxProps { + stakingTxHashHex: string; + stakingTxHex: string; + finalityProviderPk: string; + stakingAmount: number; + paramsVersion: number; + stakingTime: number; + unbondingTxHex: string; + covenantUnbondingSignatures?: { + covenantBtcPkHex: string; + signatureHex: string; + }[]; + state: State; + stakingInput: { + finalityProviderPkNoCoordHex: string; + stakingAmountSat: number; + stakingTimeBlocks: number; + }; + slashingTxHex: string; + unbondingSlashingTxHex: string; +} + +type DelegationCommand = (props: TxProps) => Promise; + +export function useDelegationService() { + const { + delegations = [], + fetchMoreDelegations, + hasMoreDelegations, + isLoading, + findDelegationByTxHash, + updateDelegationStatus, + } = useDelegationV2State(); + + const { + submitStakingTx, + submitUnbondingTx, + submitEarlyUnbondedWithdrawalTx, + submitTimelockUnbondedWithdrawalTx, + } = useTransactionService(); + + const COMMANDS: Record = useMemo( + () => ({ + [ACTIONS.STAKE]: async ({ + stakingInput, + paramsVersion, + stakingTxHashHex, + stakingTxHex, + }: TxProps) => { + await submitStakingTx( + stakingInput, + paramsVersion, + stakingTxHashHex, + stakingTxHex, + ); + + updateDelegationStatus( + stakingTxHashHex, + State.INTERMEDIATE_PENDING_BTC_CONFIRMATION, + ); + }, + + [ACTIONS.UNBOUND]: async ({ + stakingInput, + paramsVersion, + stakingTxHashHex, + stakingTxHex, + unbondingTxHex, + covenantUnbondingSignatures, + }: TxProps) => { + if (!covenantUnbondingSignatures) { + throw new Error("Covenant unbonding signatures not found"); + } + + await submitUnbondingTx( + stakingInput, + paramsVersion, + stakingTxHex, + unbondingTxHex, + covenantUnbondingSignatures.map((sig) => ({ + btcPkHex: sig.covenantBtcPkHex, + sigHex: sig.signatureHex, + })), + ); + + updateDelegationStatus( + stakingTxHashHex, + State.INTERMEDIATE_UNBONDING_SUBMITTED, + ); + }, + + [ACTIONS.WITHDRAW_ON_EARLY_UNBOUNDING]: async ({ + stakingTxHashHex, + stakingInput, + paramsVersion, + unbondingTxHex, + }: TxProps) => { + await submitEarlyUnbondedWithdrawalTx( + stakingInput, + paramsVersion, + unbondingTxHex, + ); + + updateDelegationStatus( + stakingTxHashHex, + State.INTERMEDIATE_EARLY_UNBONDING_WITHDRAWAL_SUBMITTED, + ); + }, + + [ACTIONS.WITHDRAW_ON_TIMELOCK]: async ({ + stakingInput, + paramsVersion, + stakingTxHex, + stakingTxHashHex, + }: TxProps) => { + await submitTimelockUnbondedWithdrawalTx( + stakingInput, + paramsVersion, + stakingTxHex, + ); + + updateDelegationStatus( + stakingTxHashHex, + State.INTERMEDIATE_TIMELOCK_WITHDRAWAL_SUBMITTED, + ); + }, + + [ACTIONS.WITHDRAW_ON_EARLY_UNBOUNDING_SLASHING]: async ({ + stakingInput, + paramsVersion, + unbondingSlashingTxHex, + stakingTxHashHex, + }) => { + if (!unbondingSlashingTxHex) { + throw new Error( + "Unbonding slashing tx not found, can't submit withdrawal", + ); + } + await submitEarlyUnbondedWithdrawalTx( + stakingInput, + paramsVersion, + unbondingSlashingTxHex, + ); + + updateDelegationStatus( + stakingTxHashHex, + State.INTERMEDIATE_EARLY_UNBONDING_SLASHING_WITHDRAWAL_SUBMITTED, + ); + }, + + [ACTIONS.WITHDRAW_ON_TIMELOCK_SLASHING]: async ({ + stakingInput, + paramsVersion, + slashingTxHex, + stakingTxHashHex, + }) => { + if (!slashingTxHex) { + throw new Error("Slashing tx not found, can't submit withdrawal"); + } + await submitEarlyUnbondedWithdrawalTx( + stakingInput, + paramsVersion, + slashingTxHex, + ); + + updateDelegationStatus( + stakingTxHashHex, + State.INTERMEDIATE_TIMELOCK_WITHDRAWAL_SUBMITTED, + ); + }, + }), + [ + updateDelegationStatus, + submitStakingTx, + submitUnbondingTx, + submitEarlyUnbondedWithdrawalTx, + submitTimelockUnbondedWithdrawalTx, + ], + ); + + const executeDelegationAction = useCallback( + async (action: string, txHash: string) => { + const d = findDelegationByTxHash(txHash); + + if (!d) { + throw new Error("Delegation not found: " + txHash); + } + + const { + stakingTxHashHex, + stakingTxHex, + finalityProviderBtcPksHex, + stakingAmount, + paramsVersion, + stakingTime, + unbondingTxHex, + covenantUnbondingSignatures, + state, + slashingTxHex, + unbondingSlashingTxHex, + } = d; + + const finalityProviderPk = finalityProviderBtcPksHex[0]; + const stakingInput = { + finalityProviderPkNoCoordHex: finalityProviderPk, + stakingAmountSat: stakingAmount, + stakingTimeBlocks: stakingTime, + }; + + const execute = COMMANDS[action as ActionType]; + + await execute?.({ + stakingTxHashHex, + stakingTxHex, + stakingAmount, + paramsVersion, + stakingTime, + unbondingTxHex, + covenantUnbondingSignatures, + finalityProviderPk, + state, + stakingInput, + slashingTxHex, + unbondingSlashingTxHex, + }); + }, + [COMMANDS, findDelegationByTxHash], + ); + + return { + isLoading, + delegations, + hasMoreDelegations, + fetchMoreDelegations, + executeDelegationAction, + }; +} diff --git a/src/app/hooks/storage/useDelegationStorage.ts b/src/app/hooks/storage/useDelegationStorage.ts new file mode 100644 index 00000000..65d8891a --- /dev/null +++ b/src/app/hooks/storage/useDelegationStorage.ts @@ -0,0 +1,128 @@ +import { useCallback, useEffect, useMemo } from "react"; +import { useLocalStorage } from "usehooks-ts"; + +import { + DELEGATION_STATUSES, + DelegationLike, + DelegationV2, + DelegationV2StakingState as State, +} from "@/app/types/delegationsV2"; + +export function useDelegationStorage( + key: string, + delegations?: DelegationV2[], +) { + const [pendingDelegations = {}, setPendingDelegations] = useLocalStorage< + Record + >(`${key}_pending`, {}); + const [delegationStatuses = {}, setDelegationStatuses] = useLocalStorage< + Record + >(`${key}_statuses`, {}); + + const delegationMap = useMemo(() => { + return (delegations ?? []).reduce( + (acc, delegation) => ({ + ...acc, + [delegation.stakingTxHashHex]: delegation, + }), + {} as Record, + ); + }, [delegations]); + + const formattedDelegations = useMemo(() => { + const pendingDelegationArr = Object.values(pendingDelegations).map( + (d) => + ({ + ...d, + stakingTxHex: "", + paramsVersion: 0, + finalityProviderBtcPksHex: [], + stakerBtcPkHex: "", + stakingTime: 0, + endHeight: 0, + unbondingTime: 0, + unbondingTxHex: "", + stakingSlashingTxHex: "", + bbnInceptionHeight: 0, + bbnInceptionTime: 0, + slashingTxHex: "", + unbondingSlashingTxHex: "", + }) as DelegationV2, + ); + + return pendingDelegationArr.concat( + (delegations ?? []) + .filter((d) => !pendingDelegations[d.stakingTxHashHex]) + .map((d) => ({ + ...d, + state: delegationStatuses[d.stakingTxHashHex] ?? d.state, + })), + ); + }, [delegations, pendingDelegations, delegationStatuses]); + + useEffect( + function syncPendingDelegations() { + if (!key) return; + + setPendingDelegations((delegations) => + Object.values(delegations) + .filter((d) => !delegationMap[d.stakingTxHashHex]) + .reduce( + (acc, d) => ({ ...acc, [d.stakingTxHashHex]: d }), + {} as Record, + ), + ); + }, + [key, delegationMap, setPendingDelegations], + ); + + useEffect( + function syncDelegationStatuses() { + if (!key) return; + + setDelegationStatuses((statuses) => + Object.entries(statuses) + .filter( + ([hash, status]) => + DELEGATION_STATUSES[delegationMap[hash].state] < + DELEGATION_STATUSES[status], + ) + .reduce( + (acc, [hash, status]) => ({ ...acc, [hash]: status }), + {} as Record, + ), + ); + }, + [key, delegationMap, setDelegationStatuses], + ); + + const addPendingDelegation = useCallback( + (delegation: DelegationLike) => { + if (!key) return; + + setPendingDelegations((delegations) => ({ + ...delegations, + [delegation.stakingTxHashHex]: { + ...delegation, + state: State.INTERMEDIATE_PENDING_VERIFICATION, + }, + })); + }, + [key, setPendingDelegations], + ); + + const updateDelegationStatus = useCallback( + (id: string, status: State) => { + if (!key) return; + + setDelegationStatuses((statuses) => ({ ...statuses, [id]: status })); + }, + [key, setDelegationStatuses], + ); + + return { + delegations: formattedDelegations, + addPendingDelegation, + updateDelegationStatus, + }; +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 94ea72ce..acffc3b3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -7,7 +7,6 @@ import { Activity } from "./components/Delegations/Activity"; import { FAQ } from "./components/FAQ/FAQ"; import { Footer } from "./components/Footer/Footer"; import { Header } from "./components/Header/Header"; -import { FilterOrdinalsModal } from "./components/Modals/FilterOrdinalsModal"; import { NetworkBadge } from "./components/NetworkBadge/NetworkBadge"; import { PersonalBalance } from "./components/PersonalBalance/PersonalBalance"; import { Staking } from "./components/Staking/Staking"; @@ -32,7 +31,6 @@ const Home = () => {