diff --git a/apps/tangle-dapp/app/restake/ActionButtonBase.tsx b/apps/tangle-dapp/app/restake/ActionButtonBase.tsx new file mode 100644 index 000000000..d54d30b63 --- /dev/null +++ b/apps/tangle-dapp/app/restake/ActionButtonBase.tsx @@ -0,0 +1,74 @@ +import { useConnectWallet } from '@webb-tools/api-provider-environment/ConnectWallet'; +import { useWebContext } from '@webb-tools/api-provider-environment/webb-context'; +import Button from '@webb-tools/webb-ui-components/components/buttons/Button'; +import { ConnectWalletMobileButton } from '@webb-tools/webb-ui-components/components/ConnectWalletMobileButton'; +import { useCheckMobile } from '@webb-tools/webb-ui-components/hooks/useCheckMobile'; +import { Typography } from '@webb-tools/webb-ui-components/typography/Typography'; +import Link from 'next/link'; +import { type ReactNode, useMemo } from 'react'; + +type Props = { + targetTypedChainId?: number; + children: (isLoading: boolean, loadingText?: string) => ReactNode; +}; + +export default function ActionButtonBase({ + targetTypedChainId, + children, +}: Props) { + const { isMobile } = useCheckMobile(); + const { loading, isConnecting, activeWallet } = useWebContext(); + const { toggleModal } = useConnectWallet(); + + const { isLoading, loadingText } = useMemo(() => { + if (loading) + return { + isLoading: true, + loadingText: 'Loading...', + }; + + if (isConnecting) + return { + isLoading: true, + loadingText: 'Connecting...', + }; + + return { + isLoading: false, + }; + }, [isConnecting, loading]); + + if (isMobile) { + return ( + + + A complete mobile experience for Hubble Bridge is in the works. For + now, enjoy all features on a desktop device. + + + Visit the link on desktop below to start transacting privately! + + + + ); + } + + // If the user is not connected to a wallet, show the connect wallet button + if (activeWallet === undefined) { + return ( + + ); + } + + return children(isLoading, loadingText); +} diff --git a/apps/tangle-dapp/app/restake/AvatarWithText.tsx b/apps/tangle-dapp/app/restake/AvatarWithText.tsx new file mode 100644 index 000000000..b3559ef2e --- /dev/null +++ b/apps/tangle-dapp/app/restake/AvatarWithText.tsx @@ -0,0 +1,59 @@ +import { isEthereumAddress } from '@polkadot/util-crypto'; +import { getFlexBasic } from '@webb-tools/icons/utils'; +import { Avatar } from '@webb-tools/webb-ui-components/components/Avatar'; +import { Typography } from '@webb-tools/webb-ui-components/typography/Typography'; +import { shortenHex } from '@webb-tools/webb-ui-components/utils/shortenHex'; +import { shortenString } from '@webb-tools/webb-ui-components/utils/shortenString'; +import isEqual from 'lodash/isEqual'; +import { type ComponentProps, memo } from 'react'; +import { twMerge } from 'tailwind-merge'; +import { isHex } from 'viem'; + +type Props = ComponentProps<'div'> & { + accountAddress: string; + overrideAvatarProps?: Partial>; + overrideTypographyProps?: Partial>; +}; + +const AvatarWithText = ({ + accountAddress, + overrideAvatarProps, + overrideTypographyProps, + className, + ...props +}: Props) => { + return ( +
+ + + + {isHex(accountAddress) + ? shortenHex(accountAddress) + : shortenString(accountAddress)} + +
+ ); +}; + +export default memo(AvatarWithText, (prevProps, nextProps) => + isEqual(prevProps, nextProps), +); diff --git a/apps/tangle-dapp/app/restake/ChainList.tsx b/apps/tangle-dapp/app/restake/ChainList.tsx new file mode 100644 index 000000000..281d63e9e --- /dev/null +++ b/apps/tangle-dapp/app/restake/ChainList.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { useWebContext } from '@webb-tools/api-provider-environment/webb-context'; +import { calculateTypedChainId } from '@webb-tools/sdk-core/typed-chain-id'; +import ChainListCard from '@webb-tools/webb-ui-components/components/ListCard/ChainListCard'; +import { type ComponentProps, useMemo } from 'react'; +import { twMerge } from 'tailwind-merge'; + +import { SUPPORTED_RESTAKE_DEPOSIT_TYPED_CHAIN_IDS } from '../../constants/restake'; + +type Props = Partial> & { + selectedTypedChainId?: number | null; +}; + +const ChainList = ({ + className, + onClose, + selectedTypedChainId, + ...props +}: Props) => { + const { activeChain, loading, apiConfig } = useWebContext(); + + const selectedChain = useMemo( + () => + typeof selectedTypedChainId === 'number' + ? apiConfig.chains[selectedTypedChainId] + : null, + [apiConfig.chains, selectedTypedChainId], + ); + + const chains = useMemo( + () => + SUPPORTED_RESTAKE_DEPOSIT_TYPED_CHAIN_IDS.map( + (typedChainId) => + [typedChainId, apiConfig.chains[typedChainId]] as const, + ) + .filter(([, chain]) => Boolean(chain)) + .map(([typedChainId, chain]) => ({ + typedChainId, + name: chain.name, + tag: chain.tag, + needSwitchWallet: + activeChain?.id !== chain.id && + activeChain?.chainType !== chain.chainType, + })), + [activeChain?.chainType, activeChain?.id, apiConfig.chains], + ); + + const defaultCategory = useMemo< + ComponentProps['defaultCategory'] + >(() => { + return selectedChain?.tag ?? activeChain?.tag ?? 'test'; + }, [activeChain?.tag, selectedChain?.tag]); + + return ( + + ); +}; + +ChainList.displayName = 'ChainList'; + +export default ChainList; diff --git a/apps/tangle-dapp/app/restake/ErrorMessage.tsx b/apps/tangle-dapp/app/restake/ErrorMessage.tsx new file mode 100644 index 000000000..b6df8b66a --- /dev/null +++ b/apps/tangle-dapp/app/restake/ErrorMessage.tsx @@ -0,0 +1,45 @@ +import isDefined from '@webb-tools/dapp-types/utils/isDefined'; +import { InformationLine } from '@webb-tools/icons'; +import type { PropsOf } from '@webb-tools/webb-ui-components/types'; +import { Typography } from '@webb-tools/webb-ui-components/typography/Typography'; +import { ComponentProps } from 'react'; +import { twMerge } from 'tailwind-merge'; + +type Props = PropsOf<'p'> & { + typographyProps?: Partial>; +}; + +export default function ErrorMessage({ + children, + className, + typographyProps: { + variant = 'body4', + className: typoClassName, + ...typographyProps + } = {}, + ...props +}: Props) { + return ( +

+ {isDefined(children) ? ( + + ) : null} + + + {children} + +

+ ); +} diff --git a/apps/tangle-dapp/app/restake/SlideAnimation.tsx b/apps/tangle-dapp/app/restake/SlideAnimation.tsx new file mode 100644 index 000000000..c6e72a4cb --- /dev/null +++ b/apps/tangle-dapp/app/restake/SlideAnimation.tsx @@ -0,0 +1,37 @@ +import { Transition, TransitionRootProps } from '@headlessui/react'; +import type { PropsWithChildren } from 'react'; +import { twMerge } from 'tailwind-merge'; + +type Props = Partial> & + Pick, 'show'> & + Omit, 'as'> & { + className?: string; + }; + +export default function SlideAnimation({ + children, + className, + enter, + enterFrom, + enterTo, + leave, + leaveFrom, + leaveTo, + ...restProps +}: PropsWithChildren) { + return ( + + {children} + + ); +} diff --git a/apps/tangle-dapp/app/restake/delegate/ActionButton.tsx b/apps/tangle-dapp/app/restake/delegate/ActionButton.tsx new file mode 100644 index 000000000..70f07d3f1 --- /dev/null +++ b/apps/tangle-dapp/app/restake/delegate/ActionButton.tsx @@ -0,0 +1,85 @@ +import isDefined from '@webb-tools/dapp-types/utils/isDefined'; +import type { Noop } from '@webb-tools/dapp-types/utils/types'; +import Button from '@webb-tools/webb-ui-components/components/buttons/Button'; +import { useMemo } from 'react'; +import type { FieldErrors, UseFormWatch } from 'react-hook-form'; + +import { SUPPORTED_RESTAKE_DEPOSIT_TYPED_CHAIN_IDS } from '../../../constants/restake'; +import useActiveTypedChainId from '../../../hooks/useActiveTypedChainId'; +import { DelegationFormFields } from '../../../types/restake'; +import ActionButtonBase from '../ActionButtonBase'; + +type Props = { + openChainModal: Noop; + isValid: boolean; + isSubmitting: boolean; + errors: FieldErrors; + watch: UseFormWatch; +}; + +export default function ActionButton({ + openChainModal, + isValid, + isSubmitting, + errors, + watch, +}: Props) { + const activeTypedChainId = useActiveTypedChainId(); + const operatorAccountId = watch('operatorAccountId'); + const assetId = watch('assetId'); + const amount = watch('amount'); + + const displayError = useMemo( + () => { + return errors.operatorAccountId !== undefined || !operatorAccountId + ? 'Select an operator' + : errors.assetId !== undefined || !assetId + ? 'Select an asset' + : !amount + ? 'Enter an amount' + : errors.amount !== undefined + ? 'Invalid amount' + : undefined; + }, + // prettier-ignore + [errors.operatorAccountId, errors.assetId, errors.amount, operatorAccountId, assetId, amount], + ); + + return ( + + {(isLoading, loadingText) => { + const activeChainSupported = + isDefined(activeTypedChainId) && + SUPPORTED_RESTAKE_DEPOSIT_TYPED_CHAIN_IDS.includes( + activeTypedChainId, + ); + + if (!activeChainSupported) { + return ( + + ); + } + + return ( + + ); + }} + + ); +} diff --git a/apps/tangle-dapp/app/restake/delegate/AssetList.tsx b/apps/tangle-dapp/app/restake/delegate/AssetList.tsx new file mode 100644 index 000000000..a664d79a3 --- /dev/null +++ b/apps/tangle-dapp/app/restake/delegate/AssetList.tsx @@ -0,0 +1,77 @@ +import isDefined from '@webb-tools/dapp-types/utils/isDefined'; +import { TokenListCard } from '@webb-tools/webb-ui-components/components/ListCard/TokenListCard'; +import type { TokenListCardProps } from '@webb-tools/webb-ui-components/components/ListCard/types'; +import entries from 'lodash/entries'; +import { useCallback, useMemo } from 'react'; +import type { UseFormSetValue } from 'react-hook-form'; +import { twMerge } from 'tailwind-merge'; +import { formatUnits } from 'viem'; + +import { useRestakeContext } from '../../../context/RestakeContext'; +import type { + DelegationFormFields, + DelegatorInfo, +} from '../../../types/restake'; + +type Props = Partial & { + setValue: UseFormSetValue; + delegatorInfo: DelegatorInfo | null; +}; + +export default function AssetList({ + className, + onClose, + setValue, + delegatorInfo, + ...props +}: Props) { + const { assetMap } = useRestakeContext(); + + const selectableTokens = useMemo(() => { + if (!isDefined(delegatorInfo)) { + return []; + } + + return entries(delegatorInfo.deposits) + .filter(([assetId]) => Boolean(assetMap[assetId])) + .map(([assetId, { amount }]) => { + const asset = assetMap[assetId]; + + return { + id: asset.id, + name: asset.name, + symbol: asset.symbol, + assetBalanceProps: { + balance: +formatUnits(amount, asset.decimals), + }, + } satisfies TokenListCardProps['selectTokens'][number]; + }); + }, [assetMap, delegatorInfo]); + + const handleAssetChange = useCallback( + (asset: TokenListCardProps['selectTokens'][number]) => { + setValue('assetId', asset.id); + onClose?.(); + }, + [onClose, setValue], + ); + + return ( + + ); +} diff --git a/apps/tangle-dapp/app/restake/delegate/DelegationInput.tsx b/apps/tangle-dapp/app/restake/delegate/DelegationInput.tsx new file mode 100644 index 000000000..42840f3d1 --- /dev/null +++ b/apps/tangle-dapp/app/restake/delegate/DelegationInput.tsx @@ -0,0 +1,150 @@ +import { ZERO_BIG_INT } from '@webb-tools/dapp-config/constants'; +import isDefined from '@webb-tools/dapp-types/utils/isDefined'; +import type { Noop } from '@webb-tools/dapp-types/utils/types'; +import type { TextFieldInputProps } from '@webb-tools/webb-ui-components/components/TextField/types'; +import { TransactionInputCard } from '@webb-tools/webb-ui-components/components/TransactionInputCard'; +import { useCallback, useMemo } from 'react'; +import type { + UseFormRegister, + UseFormSetValue, + UseFormWatch, +} from 'react-hook-form'; +import { formatUnits } from 'viem'; + +import { useRestakeContext } from '../../../context/RestakeContext'; +import useRestakeConsts from '../../../data/restake/useRestakeConsts'; +import type { + DelegationFormFields, + DelegatorInfo, +} from '../../../types/restake'; +import decimalsToStep from '../../../utils/decimalsToStep'; +import { getAmountValidation } from '../../../utils/getAmountValidation'; +import AvatarWithText from '../AvatarWithText'; +import ErrorMessage from '../ErrorMessage'; + +type Props = { + amountError: string | undefined; + delegatorInfo: DelegatorInfo | null; + openAssetModal: Noop; + openOperatorModal: Noop; + register: UseFormRegister; + setValue: UseFormSetValue; + watch: UseFormWatch; +}; + +export default function DelegationInput({ + amountError, + delegatorInfo, + openAssetModal, + openOperatorModal, + register, + setValue, + watch, +}: Props) { + const selectedAssetId = watch('assetId'); + const selectedOperatorAccountId = watch('operatorAccountId'); + + const { assetMap } = useRestakeContext(); + const { minDelegateAmount } = useRestakeConsts(); + + const selectedAsset = useMemo( + () => (selectedAssetId !== null ? assetMap[selectedAssetId] : null), + [assetMap, selectedAssetId], + ); + + const { max, maxFormatted } = useMemo(() => { + if (!isDefined(selectedAsset) || !isDefined(delegatorInfo)) { + return {}; + } + + const amountRaw = + delegatorInfo.deposits[selectedAsset.id]?.amount ?? ZERO_BIG_INT; + const maxFormatted = +formatUnits(amountRaw, selectedAsset.decimals); + + return { + max: amountRaw, + maxFormatted, + }; + }, [delegatorInfo, selectedAsset]); + + const { min, minFormatted } = useMemo(() => { + if (!isDefined(minDelegateAmount) || !isDefined(selectedAsset)) { + return {}; + } + + return { + min: minDelegateAmount, + minFormatted: formatUnits(minDelegateAmount, selectedAsset.decimals), + }; + }, [minDelegateAmount, selectedAsset]); + + const handleAmountChange = useCallback( + (amount: string) => { + setValue('amount', amount); + }, + [setValue], + ); + + const customAmountProps = useMemo( + () => { + const step = decimalsToStep(selectedAsset?.decimals); + + return { + type: 'number', + step, + ...register('amount', { + required: 'Amount is required', + validate: getAmountValidation( + step, + minFormatted, + min, + max, + selectedAsset?.decimals, + selectedAsset?.symbol, + ), + }), + }; + }, + // prettier-ignore + [max, min, minFormatted, register, selectedAsset?.decimals, selectedAsset?.symbol], + ); + + return ( + + + ( + + ), + } + : {})} + /> + + + + + + {amountError} + + ); +} diff --git a/apps/tangle-dapp/app/restake/delegate/Info.tsx b/apps/tangle-dapp/app/restake/delegate/Info.tsx new file mode 100644 index 000000000..362589a3c --- /dev/null +++ b/apps/tangle-dapp/app/restake/delegate/Info.tsx @@ -0,0 +1,36 @@ +import isDefined from '@webb-tools/dapp-types/utils/isDefined'; +import FeeDetails from '@webb-tools/webb-ui-components/components/FeeDetails'; +import { memo } from 'react'; + +import useRestakeConsts from '../../../data/restake/useRestakeConsts'; + +const Info = memo(() => { + const { leaveDelegatorsDelay, delegationBondLessDelay } = useRestakeConsts(); + + return ( + + ); +}); + +Info.displayName = 'Info'; + +export default Info; diff --git a/apps/tangle-dapp/app/restake/delegate/OperatorList.tsx b/apps/tangle-dapp/app/restake/delegate/OperatorList.tsx new file mode 100644 index 000000000..bb72dd6f3 --- /dev/null +++ b/apps/tangle-dapp/app/restake/delegate/OperatorList.tsx @@ -0,0 +1,373 @@ +import { + type Column, + type ColumnDef, + getCoreRowModel, + getSortedRowModel, + type Row, + type SortingState, + type Updater, + useReactTable, +} from '@tanstack/react-table'; +import { useActiveChain } from '@webb-tools/api-provider-environment/WebbProvider/subjects'; +import { DEFAULT_DECIMALS } from '@webb-tools/dapp-config'; +import isDefined from '@webb-tools/dapp-types/utils/isDefined'; +import { ArrowDropDownFill } from '@webb-tools/icons/ArrowDropDownFill'; +import { ArrowDropUpFill } from '@webb-tools/icons/ArrowDropUpFill'; +import { Chip } from '@webb-tools/webb-ui-components/components/Chip'; +import { fuzzyFilter } from '@webb-tools/webb-ui-components/components/Filter/utils'; +import { ListCardWrapper } from '@webb-tools/webb-ui-components/components/ListCard/ListCardWrapper'; +import { + RadioGroup, + RadioItem, +} from '@webb-tools/webb-ui-components/components/Radio'; +import { Table } from '@webb-tools/webb-ui-components/components/Table'; +import { + Tooltip, + TooltipBody, + TooltipTrigger, +} from '@webb-tools/webb-ui-components/components/Tooltip'; +import { Typography } from '@webb-tools/webb-ui-components/typography/Typography'; +import cx from 'classnames'; +import { + type ComponentProps, + forwardRef, + PropsWithChildren, + useCallback, + useMemo, + useState, +} from 'react'; +import { twMerge } from 'tailwind-merge'; +import { formatUnits } from 'viem'; + +import useNetworkStore from '../../../context/useNetworkStore'; +import type { + OperatorMap, + OperatorMetadata, + OperatorStatus, +} from '../../../types/restake'; +import AvatarWithText from '../AvatarWithText'; + +type TableData = OperatorMetadata & { accountId: string }; + +type Props = Partial> & { + operatorMap: OperatorMap; + selectedOperatorAccountId: string; + onOperatorAccountIdChange?: (accountId: string) => void; +}; + +const defaultSorting: SortingState = [{ id: 'status', desc: false }]; + +function getStatusIndex(status: OperatorStatus) { + if (status === 'Active') { + return 0; + } + + if (typeof status === 'object') { + return 1; + } + + return 2; +} + +function sortStatus(rowA: Row, rowB: Row) { + const statusA = rowA.original.status; + const statusB = rowB.original.status; + return getStatusIndex(statusA) - getStatusIndex(statusB); +} + +const OperatorList = forwardRef( + ( + { + onClose, + overrideTitleProps, + operatorMap, + selectedOperatorAccountId, + onOperatorAccountIdChange, + ...props + }, + ref, + ) => { + const isEmpty = Object.keys(operatorMap).length === 0; + + const { nativeTokenSymbol } = useNetworkStore(); + const [activeChain] = useActiveChain(); + + const nativeDecimals = useMemo( + () => + isDefined(activeChain) + ? activeChain.nativeCurrency.decimals + : DEFAULT_DECIMALS, + [activeChain], + ); + + const data = useMemo( + () => + Object.entries(operatorMap).map(([accountId, metadata]) => ({ + ...metadata, + accountId, + })), + [operatorMap], + ); + + const columns = useMemo[]>( + () => [ + { + id: 'select-row', + enableSorting: false, + header: () =>
Operator
, + cell: ({ + row: { + original: { accountId, status }, + }, + }) => ( + + + + ), + }, + { + accessorKey: 'bond', + enableSorting: true, + enableMultiSort: true, + header: ({ column }) => ( +
+ Total Staked +
+ ), + cell: ({ + getValue, + row: { + original: { status }, + }, + }) => ( + + {formatUnits(getValue(), nativeDecimals)}{' '} + {nativeTokenSymbol} + + ), + }, + { + accessorKey: 'delegationCount', + enableSorting: true, + enableMultiSort: true, + header: ({ column }) => ( +
+ Delegations +
+ ), + cell: ({ + getValue, + row: { + original: { status }, + }, + }) => ( + + {getValue()} + + ), + }, + { + accessorKey: 'status', + enableSorting: true, + enableMultiSort: true, + header: () =>
Status
, + cell: (info) => ( + ()} /> + ), + sortingFn: sortStatus, + }, + ], + [nativeDecimals, nativeTokenSymbol], + ); + + const [sorting, setSorting] = useState(defaultSorting); + + const handleSortingChange = useCallback( + (updaterOrValue: Updater) => { + if (typeof updaterOrValue === 'function') { + setSorting((prev) => { + console.log('prev', prev); + const next = updaterOrValue(prev); + console.log('next', next); + return next.length === 0 + ? defaultSorting + : defaultSorting.concat(next); + }); + } else { + if (updaterOrValue.length === 0) { + setSorting(defaultSorting); + } else { + setSorting(defaultSorting.concat(updaterOrValue)); + } + } + }, + [], + ); + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + filterFns: { + fuzzy: fuzzyFilter, + }, + state: { + sorting, + }, + onSortingChange: handleSortingChange, + }); + + return ( + + {!isEmpty && ( + + + + )} + + {isEmpty && ( +
+ + No Operator Found. + + + + You can comeback later or add apply to become a operator. + +
+ )} + + ); + }, +); + +OperatorList.displayName = 'OperatorList'; + +export default OperatorList; + +const Header = ({ + children, + isCenter, + column, +}: PropsWithChildren<{ isCenter?: boolean; column?: Column }>) => + isDefined(column) ? ( +
+ + {children} + + + {{ + asc: , + desc: , + }[column.getIsSorted() as string] ?? null} +
+ ) : ( + + {children} + + ); + +type CellProps = { + isCenter?: boolean; + isDisabled?: boolean; +}; + +const ChipCell = ({ + children, + isCenter, + isDisabled, +}: PropsWithChildren) => ( +
+ {children} +
+); + +const StatusCell = ({ + status, + isDisabled, + isCenter, +}: CellProps & { status: OperatorStatus }) => ( +
+ {status === 'Active' ? ( + Active + ) : status === 'Inactive' ? ( + Inactive + ) : ( + + + Leaving + + + Operator will leaving at {status.Leaving} round. + + + )} +
+); diff --git a/apps/tangle-dapp/app/restake/delegate/page.tsx b/apps/tangle-dapp/app/restake/delegate/page.tsx index b49a08c7b..64486418a 100644 --- a/apps/tangle-dapp/app/restake/delegate/page.tsx +++ b/apps/tangle-dapp/app/restake/delegate/page.tsx @@ -1,24 +1,275 @@ +'use client'; + +import chainsPopulated from '@webb-tools/dapp-config/chains/chainsPopulated'; +import isDefined from '@webb-tools/dapp-types/utils/isDefined'; +import Button from '@webb-tools/webb-ui-components/components/buttons/Button'; +import type { ChainType } from '@webb-tools/webb-ui-components/components/ListCard/types'; +import { useModal } from '@webb-tools/webb-ui-components/hooks/useModal'; import { Typography } from '@webb-tools/webb-ui-components/typography/Typography'; -import { Fragment } from 'react'; +import keys from 'lodash/keys'; +import Link from 'next/link'; +import { useCallback, useEffect, useMemo } from 'react'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import { formatUnits, parseUnits } from 'viem'; +import { SUPPORTED_RESTAKE_DEPOSIT_TYPED_CHAIN_IDS } from '../../../constants/restake'; +import { useRestakeContext } from '../../../context/RestakeContext'; +import { DelegateContext, TxEvent } from '../../../data/restake/RestakeTx/base'; +import useRestakeDelegatorInfo from '../../../data/restake/useRestakeDelegatorInfo'; +import useRestakeOperatorMap from '../../../data/restake/useRestakeOperatorMap'; +import useRestakeTx from '../../../data/restake/useRestakeTx'; +import useRestakeTxEventHandlersWithNoti, { + type Props, +} from '../../../data/restake/useRestakeTxEventHandlersWithNoti'; +import useActiveTypedChainId from '../../../hooks/useActiveTypedChainId'; +import { useRpcSubscription } from '../../../hooks/usePolkadotApi'; +import { PagePath } from '../../../types'; +import type { DelegationFormFields } from '../../../types/restake'; +import AvatarWithText from '../AvatarWithText'; +import ChainList from '../ChainList'; import RestakeTabs from '../RestakeTabs'; -import DepositButton from './DepositButton'; +import SlideAnimation from '../SlideAnimation'; +import useSwitchChain from '../useSwitchChain'; +import ActionButton from './ActionButton'; +import AssetList from './AssetList'; +import DelegationInput from './DelegationInput'; +import Info from './Info'; +import OperatorList from './OperatorList'; export default function DelegatePage() { + const { + register, + setValue: setFormValue, + handleSubmit, + watch, + reset, + formState: { errors, isValid, isSubmitting }, + } = useForm({ + mode: 'onBlur', + }); + + const setValue = useCallback( + (...params: Parameters) => { + setFormValue(params[0], params[1], { + shouldDirty: true, + shouldValidate: true, + ...params[2], + }); + }, + [setFormValue], + ); + + // Register select fields on mount + useEffect(() => { + register('assetId', { required: 'Asset is required' }); + register('operatorAccountId', { required: 'Operator is required' }); + }, [register]); + + const { assetMap } = useRestakeContext(); + const { delegate } = useRestakeTx(); + const { delegatorInfo } = useRestakeDelegatorInfo(); + const { operatorMap } = useRestakeOperatorMap(); + + const switchChain = useSwitchChain(); + const activeTypedChainId = useActiveTypedChainId(); + + useRpcSubscription(activeTypedChainId); + + // Set the default assetId to the first assetId in the depositedAssets + const defaultAssetId = useMemo(() => { + if (!isDefined(delegatorInfo)) { + return null; + } + + const assetIds = keys(delegatorInfo.deposits); + if (assetIds.length === 0) { + return null; + } + + return assetIds[0]; + }, [delegatorInfo]); + + // Set the default assetId to the first deposited assets + useEffect(() => { + if (defaultAssetId !== null) { + setValue('assetId', defaultAssetId); + } + }, [defaultAssetId, setValue]); + + const { + status: isChainModalOpen, + open: openChainModal, + close: closeChainModal, + } = useModal(false); + + const { + status: isAssetModalOpen, + open: openAssetModal, + close: closeAssetModal, + } = useModal(false); + + const { + status: isOperatorModalOpen, + open: openOperatorModal, + close: closeOperatorModal, + } = useModal(false); + + const handleChainChange = useCallback( + async ({ typedChainId }: ChainType) => { + await switchChain(typedChainId); + closeChainModal(); + }, + [closeChainModal, switchChain], + ); + + const handleOperatorAccountIdChange = useCallback( + (operatorAccountId: string) => { + setValue('operatorAccountId', operatorAccountId); + closeOperatorModal(); + }, + [closeOperatorModal, setValue], + ); + + const options = useMemo>(() => { + return { + options: { + [TxEvent.SUCCESS]: { + secondaryMessage: ( + { amount, assetId, operatorAccount }, + explorerUrl, + ) => ( +
+ + Successfully delegated{' '} + {formatUnits(amount, assetMap[assetId].decimals)}{' '} + {assetMap[assetId].symbol} to{' '} + + + + {explorerUrl && ( + + View the transaction{' '} + + + )} +
+ ), + }, + }, + onTxSuccess: () => reset(), + }; + }, [assetMap, reset]); + + const txEventHandlers = useRestakeTxEventHandlersWithNoti(options); + + const onSubmit = useCallback>( + async (data) => { + const { amount, assetId, operatorAccountId } = data; + if (!assetId || !isDefined(assetMap[assetId])) { + return; + } + + const asset = assetMap[assetId]; + + await delegate( + operatorAccountId, + assetId, + parseUnits(amount, asset.decimals), + txEventHandlers, + ); + }, + [assetMap, delegate, txEventHandlers], + ); + return ( - - - -
- - 🚧 The delegation feature is under development 🚧 - - - Now, you can try the deposit feature. Stay tuned for updates! - - - +
+
+ + + + +
+ + + +
- + + + ( +
+ + No assets available + + + +
+ )} + /> +
+ + + + + + + + + ); } diff --git a/apps/tangle-dapp/app/restake/deposit/ActionButton.tsx b/apps/tangle-dapp/app/restake/deposit/ActionButton.tsx index 582c6ada1..b082bc0eb 100644 --- a/apps/tangle-dapp/app/restake/deposit/ActionButton.tsx +++ b/apps/tangle-dapp/app/restake/deposit/ActionButton.tsx @@ -1,20 +1,13 @@ 'use client'; -import { useConnectWallet } from '@webb-tools/api-provider-environment/ConnectWallet'; -import { useWebContext } from '@webb-tools/api-provider-environment/webb-context'; -import chainsPopulated from '@webb-tools/dapp-config/chains/chainsPopulated'; import Button from '@webb-tools/webb-ui-components/components/buttons/Button'; -import { ConnectWalletMobileButton } from '@webb-tools/webb-ui-components/components/ConnectWalletMobileButton'; -import { useCheckMobile } from '@webb-tools/webb-ui-components/hooks/useCheckMobile'; -import { Typography } from '@webb-tools/webb-ui-components/typography/Typography'; -import Link from 'next/link'; -import { type RefObject, useCallback, useMemo } from 'react'; +import { type RefObject } from 'react'; import type { FieldErrors, UseFormWatch } from 'react-hook-form'; -import useNetworkStore from '../../../context/useNetworkStore'; import useActiveTypedChainId from '../../../hooks/useActiveTypedChainId'; import { DepositFormFields } from '../../../types/restake'; -import chainToNetwork from '../../../utils/chainToNetwork'; +import ActionButtonBase from '../ActionButtonBase'; +import useSwitchChain from '../useSwitchChain'; type Props = { errors: FieldErrors; @@ -33,144 +26,60 @@ export default function ActionButton({ }: Props) { const sourceTypedChainId = watch('sourceTypedChainId'); - const { isMobile } = useCheckMobile(); - const { loading, isConnecting, activeWallet } = useWebContext(); const activeTypedChainId = useActiveTypedChainId(); - const { toggleModal } = useConnectWallet(); const switchChain = useSwitchChain(); - const { isLoading, loadingText } = useMemo(() => { - if (isConnecting) - return { - isLoading: true, - loadingText: 'Connecting...', - }; - - if (loading) - return { - isLoading: true, - loadingText: 'Loading...', - }; - - if (isSubmitting) { - return { - isLoading: true, - loadingText: 'Depositing...', - }; - } - - return {}; - }, [isConnecting, isSubmitting, loading]); - - if (isMobile) { - return ( - - - A complete mobile experience for Hubble Bridge is in the works. For - now, enjoy all features on a desktop device. - - - Visit the link on desktop below to start transacting privately! - - - - ); - } - - // If the user is not connected to a wallet, show the connect wallet button - if (activeWallet === undefined) { - return ( - - ); - } - - const displayError = - errors.sourceTypedChainId !== undefined - ? `Select a source chain` - : errors.depositAssetId !== undefined - ? `Select an asset` - : errors.amount !== undefined - ? `Enter an amount` - : undefined; - - if (activeTypedChainId !== sourceTypedChainId) { - const handleClick = async () => { - const result = await switchChain(sourceTypedChainId); - const isSuccessful = result !== null && result !== undefined; - - // Dispatch submit event to trigger form submission - // when the chain switch is successful and the form is valid - if (isSuccessful && isValid && displayError === undefined) { - formRef.current?.dispatchEvent( - new Event('submit', { cancelable: true }), - ); - } - }; - - return ( - - ); - } - return ( - - ); -} - -function useSwitchChain() { - const { activeWallet, activeApi, switchChain } = useWebContext(); - const { toggleModal } = useConnectWallet(); - const { setNetwork } = useNetworkStore(); - - return useCallback( - async (typedChainId: number) => { - if (activeWallet === undefined || activeApi === undefined) { - return toggleModal(true, typedChainId); - } - - const nextChain = chainsPopulated[typedChainId]; - if (nextChain === undefined) return; - - const isWalletSupported = nextChain.wallets.includes(activeWallet.id); - - if (!isWalletSupported) { - return toggleModal(true, typedChainId); - } else { - const switchResult = await switchChain(nextChain, activeWallet); - if (switchResult !== null) { - const nextNetwork = chainToNetwork(typedChainId); - setNetwork(nextNetwork); + + {(isLoading, loadingText) => { + const displayError = + errors.sourceTypedChainId !== undefined + ? `Select a source chain` + : errors.depositAssetId !== undefined + ? `Select an asset` + : errors.amount !== undefined + ? `Enter an amount` + : undefined; + + if (activeTypedChainId !== sourceTypedChainId) { + const handleClick = async () => { + const result = await switchChain(sourceTypedChainId); + const isSuccessful = result !== null && result !== undefined; + + // Dispatch submit event to trigger form submission + // when the chain switch is successful and the form is valid + if (isSuccessful && isValid && displayError === undefined) { + formRef.current?.dispatchEvent( + new Event('submit', { cancelable: true }), + ); + } + }; + + return ( + + ); } - return switchResult; - } - }, - [activeApi, activeWallet, setNetwork, switchChain, toggleModal], + return ( + + ); + }} + ); } diff --git a/apps/tangle-dapp/app/restake/deposit/SourceChainInput.tsx b/apps/tangle-dapp/app/restake/deposit/SourceChainInput.tsx index f51ec0efd..52a1f1595 100644 --- a/apps/tangle-dapp/app/restake/deposit/SourceChainInput.tsx +++ b/apps/tangle-dapp/app/restake/deposit/SourceChainInput.tsx @@ -1,15 +1,10 @@ 'use client'; -import { - DEFAULT_DECIMALS, - ZERO_BIG_INT, -} from '@webb-tools/dapp-config/constants'; +import { ZERO_BIG_INT } from '@webb-tools/dapp-config/constants'; import type { Noop } from '@webb-tools/dapp-types/utils/types'; import type { TextFieldInputProps } from '@webb-tools/webb-ui-components/components/TextField/types'; import type { TokenSelectorProps } from '@webb-tools/webb-ui-components/components/TokenSelector/types'; import { TransactionInputCard } from '@webb-tools/webb-ui-components/components/TransactionInputCard'; -import { Typography } from '@webb-tools/webb-ui-components/typography/Typography'; -import Decimal from 'decimal.js'; import { useCallback, useMemo } from 'react'; import type { UseFormRegister, @@ -21,7 +16,9 @@ import { formatUnits } from 'viem'; import { useRestakeContext } from '../../../context/RestakeContext'; import useRestakeConsts from '../../../data/restake/useRestakeConsts'; import { DepositFormFields } from '../../../types/restake'; -import safeParseUnits from '../../../utils/safeParseUnits'; +import decimalsToStep from '../../../utils/decimalsToStep'; +import { getAmountValidation } from '../../../utils/getAmountValidation'; +import ErrorMessage from '../ErrorMessage'; type Props = { amountError?: string; @@ -94,7 +91,7 @@ const SourceChainInput = ({ [openChainModal], ); - const customAmountProsp = useMemo(() => { + const customAmountProps = useMemo(() => { const step = decimalsToStep(asset?.decimals); return { @@ -102,43 +99,14 @@ const SourceChainInput = ({ step, ...register('amount', { required: 'Amount is required', - validate: { - // Check amount with asset denomination - shouldDivisibleWithDecimals: (value) => { - return ( - Decimal.mod(value, step).isZero() || - `Amount must be divisible by ${step} ${asset !== null ? `, as ${asset?.symbol} has ${asset?.decimals} decimals` : ''}`.trim() - ); - }, - shouldNotBeZero: (value) => { - const parsed = safeParseUnits(value, asset?.decimals); - if (!parsed.sucess) return true; - - return ( - parsed.value !== ZERO_BIG_INT || - 'Amount must be greater than zero' - ); - }, - shouldNotLessThanMin: (value) => { - if (typeof min !== 'bigint') return true; - - const parsed = safeParseUnits(value, asset?.decimals); - if (!parsed.sucess) return true; - - return ( - parsed.value >= min || - `Amount must be at least ${minFormatted} ${asset?.symbol ?? ''}`.trim() - ); - }, - shouldNotExceedMax: (value) => { - if (typeof max !== 'bigint') return true; - - const parsed = safeParseUnits(value, asset?.decimals); - if (!parsed.sucess) return true; - - return parsed.value <= max || 'Amount exceeds balance'; - }, - }, + validate: getAmountValidation( + step, + minFormatted, + min, + max, + asset?.decimals, + asset?.symbol, + ), }), }; }, [asset, max, min, minFormatted, register]); @@ -171,30 +139,12 @@ const SourceChainInput = ({ - - {amountError} - + {amountError} ); }; export default SourceChainInput; - -/** - * @internal - * Convert decimals to input step - * 18 decimals -> 0.000000000000000001 - */ -function decimalsToStep(decimals = DEFAULT_DECIMALS) { - if (decimals === 0) return '1'; - - return `0.${'0'.repeat(decimals - 1)}1`; -} diff --git a/apps/tangle-dapp/app/restake/deposit/TokenList.tsx b/apps/tangle-dapp/app/restake/deposit/TokenList.tsx index a02d4db6c..2db4cd72c 100644 --- a/apps/tangle-dapp/app/restake/deposit/TokenList.tsx +++ b/apps/tangle-dapp/app/restake/deposit/TokenList.tsx @@ -60,10 +60,10 @@ export default function TokenList({ overrideTitleProps={{ variant: 'h4', }} - title={`Select a token`} + title={`Select an asset`} popularTokens={[]} selectTokens={selectableTokens} - unavailableTokens={[]} // TODO: Add unavailable tokens + unavailableTokens={[]} onChange={handleTokenChange} {...props} onClose={onClose} diff --git a/apps/tangle-dapp/app/restake/deposit/TxDetails.tsx b/apps/tangle-dapp/app/restake/deposit/TxDetails.tsx index 5a76025a3..b306de49e 100644 --- a/apps/tangle-dapp/app/restake/deposit/TxDetails.tsx +++ b/apps/tangle-dapp/app/restake/deposit/TxDetails.tsx @@ -43,6 +43,7 @@ export default function TxDetails({ watch }: Props) { return ( diff --git a/apps/tangle-dapp/app/restake/deposit/page.tsx b/apps/tangle-dapp/app/restake/deposit/page.tsx index ea767ba2b..ead57b498 100644 --- a/apps/tangle-dapp/app/restake/deposit/page.tsx +++ b/apps/tangle-dapp/app/restake/deposit/page.tsx @@ -1,31 +1,48 @@ 'use client'; -import chainsPopulated from '@webb-tools/dapp-config/chains/chainsPopulated'; +import isDefined from '@webb-tools/dapp-types/utils/isDefined'; import { ArrowRight } from '@webb-tools/icons/ArrowRight'; +import { ChainType } from '@webb-tools/webb-ui-components/components/ListCard/types'; import { useSubscription } from 'observable-hooks'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { type SubmitHandler, useForm } from 'react-hook-form'; -import { parseUnits } from 'viem'; +import { formatUnits, parseUnits } from 'viem'; -import useRestakeTxEventHandlersWithNoti from '../../..//data/restake/useRestakeTxEventHandlersWithNoti'; +import useRestakeTxEventHandlersWithNoti, { + type Props, +} from '../../..//data/restake/useRestakeTxEventHandlersWithNoti'; import { SUPPORTED_RESTAKE_DEPOSIT_TYPED_CHAIN_IDS } from '../../../constants/restake'; import { useRestakeContext } from '../../../context/RestakeContext'; -import type { TxEventHandlers } from '../../../data/restake/RestakeTx/base'; +import { + type DepositContext, + TxEvent, +} from '../../../data/restake/RestakeTx/base'; import useRestakeTx from '../../../data/restake/useRestakeTx'; -import usePolkadotApi from '../../../hooks/usePolkadotApi'; +import ViewTxOnExplorer from '../../../data/restake/ViewTxOnExplorer'; +import useActiveTypedChainId from '../../../hooks/useActiveTypedChainId'; +import { useRpcSubscription } from '../../../hooks/usePolkadotApi'; import { DepositFormFields } from '../../../types/restake'; +import ChainList from '../ChainList'; import RestakeTabs from '../RestakeTabs'; +import SlideAnimation from '../SlideAnimation'; import ActionButton from './ActionButton'; -import ChainList from './ChainList'; import DestChainInput from './DestChainInput'; -import SlideAnimation from './SlideAnimation'; import SourceChainInput from './SourceChainInput'; import TokenList from './TokenList'; import TxDetails from './TxDetails'; +function getDefaultTypedChainId(activeTypedChainId: number | null) { + return isDefined(activeTypedChainId) && + SUPPORTED_RESTAKE_DEPOSIT_TYPED_CHAIN_IDS.includes(activeTypedChainId) + ? activeTypedChainId + : SUPPORTED_RESTAKE_DEPOSIT_TYPED_CHAIN_IDS[0]; +} + export default function DepositPage() { const formRef = useRef(null); + const activeTypedChainId = useActiveTypedChainId(); + const { register, setValue, @@ -36,11 +53,10 @@ export default function DepositPage() { } = useForm({ mode: 'onBlur', defaultValues: { - sourceTypedChainId: SUPPORTED_RESTAKE_DEPOSIT_TYPED_CHAIN_IDS[0], + sourceTypedChainId: getDefaultTypedChainId(activeTypedChainId), }, }); - const { setCustomRpc } = usePolkadotApi(); const { assetMap, assetWithBalances$ } = useRestakeContext(); const { deposit } = useRestakeTx(); @@ -63,13 +79,14 @@ export default function DepositPage() { register('sourceTypedChainId', { required: 'Chain is required' }); }, [register]); + useEffect(() => { + setValue('sourceTypedChainId', getDefaultTypedChainId(activeTypedChainId)); + }, [activeTypedChainId, setValue]); + const sourceTypedChainId = watch('sourceTypedChainId'); // Subscribe to sourceTypedChainId and update customRpc - useEffect(() => { - const chain = chainsPopulated[sourceTypedChainId]; - setCustomRpc(chain?.rpcUrls.default.webSocket?.[0]); - }, [setCustomRpc, sourceTypedChainId]); + useRpcSubscription(sourceTypedChainId); // Modal states const [chainModalOpen, setChainModalOpen] = useState(false); @@ -83,10 +100,36 @@ export default function DepositPage() { const openTokenModal = useCallback(() => setTokenModalOpen(true), []); const closeTokenModal = useCallback(() => setTokenModalOpen(false), []); - const txEventHandlers = useRestakeTxEventHandlersWithNoti( - useRef({ + const options = useMemo>(() => { + return { + options: { + [TxEvent.SUCCESS]: { + secondaryMessage: ({ amount, assetId }, explorerUrl) => { + return ( + + {assetMap[assetId] + ? `Successfully deposit ${formatUnits(amount, assetMap[assetId].decimals)} ${assetMap[assetId].symbol}`.trim() + : undefined} + + ); + }, + }, + }, onTxSuccess: () => resetField('amount'), - }).current, + }; + }, [assetMap, resetField]); + + const txEventHandlers = useRestakeTxEventHandlersWithNoti(options); + + const handleChainChange = useCallback( + ({ typedChainId }: ChainType) => { + setValue('sourceTypedChainId', typedChainId, { + shouldDirty: true, + shouldValidate: true, + }); + setChainModalOpen(false); + }, + [setValue], ); const onSubmit = useCallback>( @@ -146,10 +189,10 @@ export default function DepositPage() { diff --git a/apps/tangle-dapp/app/restake/useSwitchChain.ts b/apps/tangle-dapp/app/restake/useSwitchChain.ts new file mode 100644 index 000000000..92c76578a --- /dev/null +++ b/apps/tangle-dapp/app/restake/useSwitchChain.ts @@ -0,0 +1,39 @@ +import { useConnectWallet } from '@webb-tools/api-provider-environment/ConnectWallet'; +import { useWebContext } from '@webb-tools/api-provider-environment/webb-context'; +import chainsPopulated from '@webb-tools/dapp-config/chains/chainsPopulated'; +import { useCallback } from 'react'; + +import useNetworkStore from '../../context/useNetworkStore'; +import chainToNetwork from '../../utils/chainToNetwork'; + +export default function useSwitchChain() { + const { activeWallet, activeApi, switchChain } = useWebContext(); + const { toggleModal } = useConnectWallet(); + const { setNetwork } = useNetworkStore(); + + return useCallback( + async (typedChainId: number) => { + if (activeWallet === undefined || activeApi === undefined) { + return toggleModal(true, typedChainId); + } + + const nextChain = chainsPopulated[typedChainId]; + if (nextChain === undefined) return; + + const isWalletSupported = nextChain.wallets.includes(activeWallet.id); + + if (!isWalletSupported) { + return toggleModal(true, typedChainId); + } else { + const switchResult = await switchChain(nextChain, activeWallet); + if (switchResult !== null) { + const nextNetwork = chainToNetwork(typedChainId); + setNetwork(nextNetwork); + } + + return switchResult; + } + }, + [activeApi, activeWallet, setNetwork, switchChain, toggleModal], + ); +} diff --git a/apps/tangle-dapp/context/RestakeContext.tsx b/apps/tangle-dapp/context/RestakeContext.tsx index dd38ffb97..7ce05ef03 100644 --- a/apps/tangle-dapp/context/RestakeContext.tsx +++ b/apps/tangle-dapp/context/RestakeContext.tsx @@ -1,7 +1,7 @@ 'use client'; import { ZERO_BIG_INT } from '@webb-tools/dapp-config/constants'; -import orderBy from 'lodash/orderBy'; +import isDefined from '@webb-tools/dapp-types/utils/isDefined'; import toPairs from 'lodash/toPairs'; import { useObservableState } from 'observable-hooks'; import { @@ -70,22 +70,32 @@ const RestakeContextProvider = (props: PropsWithChildren) => { () => combineLatest([assetMap$, balances$]).pipe( map(([assetMap, balances]) => { - return orderBy( - toPairs(assetMap).reduce( - (assetWithBalances, [assetId, assetMetadata]) => { - const balance = balances[assetId] ?? null; - - return assetWithBalances.concat({ - assetId, - metadata: assetMetadata, - balance, - }); - }, - [] as Array, - ), - ({ balance }) => balance?.balance ?? ZERO_BIG_INT, - 'desc', + const combined = toPairs(assetMap).reduce( + (assetWithBalances, [assetId, assetMetadata]) => { + const balance = balances[assetId] ?? null; + + return assetWithBalances.concat({ + assetId, + metadata: assetMetadata, + balance, + }); + }, + [] as Array, ); + + // Order assets with balances first + return [ + ...combined.filter( + (asset) => + isDefined(asset.balance) && + asset.balance.balance > ZERO_BIG_INT, + ), + ...combined.filter( + (asset) => + !isDefined(asset.balance) || + asset.balance.balance === ZERO_BIG_INT, + ), + ]; }), ), [assetMap$, balances$], diff --git a/apps/tangle-dapp/data/restake/RestakeTx/base.ts b/apps/tangle-dapp/data/restake/RestakeTx/base.ts index d9d30c76a..d47757036 100644 --- a/apps/tangle-dapp/data/restake/RestakeTx/base.ts +++ b/apps/tangle-dapp/data/restake/RestakeTx/base.ts @@ -8,12 +8,35 @@ export enum TxEvent { FAILED = 'FAILED', } -export type TxEventHandlers = { - onTxSending?: () => void | Promise; - onTxInBlock?: (txHash: Hash, blockHash: Hash) => void | Promise; - onTxFinalized?: (txHash: Hash, blockHash: Hash) => void | Promise; - onTxSuccess?: (txHash: Hash, blockHash: Hash) => void | Promise; - onTxFailed?: (error: string) => void | Promise; +export type TxEventHandlers> = { + onTxSending?: (context: Context) => void | Promise; + onTxInBlock?: ( + txHash: Hash, + blockHash: Hash, + context: Context, + ) => void | Promise; + onTxFinalized?: ( + txHash: Hash, + blockHash: Hash, + context: Context, + ) => void | Promise; + onTxSuccess?: ( + txHash: Hash, + blockHash: Hash, + context: Context, + ) => void | Promise; + onTxFailed?: (error: string, context: Context) => void | Promise; +}; + +export type DepositContext = { + assetId: string; + amount: bigint; +}; + +export type DelegateContext = { + operatorAccount: string; + assetId: string; + amount: bigint; }; export abstract class RestakeTxBase { @@ -29,6 +52,13 @@ export abstract class RestakeTxBase { abstract deposit( assetId: string, amount: bigint, - eventHandlers?: TxEventHandlers, + eventHandlers?: TxEventHandlers, + ): Promise; + + abstract delegate( + operatorAccount: string, + assetId: string, + amount: bigint, + eventHandlers?: TxEventHandlers, ): Promise; } diff --git a/apps/tangle-dapp/data/restake/RestakeTx/evm.ts b/apps/tangle-dapp/data/restake/RestakeTx/evm.ts index 42679f0f0..d3afc8354 100644 --- a/apps/tangle-dapp/data/restake/RestakeTx/evm.ts +++ b/apps/tangle-dapp/data/restake/RestakeTx/evm.ts @@ -1,7 +1,12 @@ import type { Account, Address } from 'viem'; import { Config } from 'wagmi'; -import { RestakeTxBase, type TxEventHandlers } from './base'; +import { + type DelegateContext, + type DepositContext, + RestakeTxBase, + type TxEventHandlers, +} from './base'; export default class EVMRestakeTx extends RestakeTxBase { constructor( @@ -10,17 +15,26 @@ export default class EVMRestakeTx extends RestakeTxBase { readonly provider: Config, ) { super(); - - this.deposit = this.deposit.bind(this); } - public async deposit( + deposit = async ( _assetId: string, _amount: bigint, - _eventHandlers?: TxEventHandlers, - ) { + _eventHandlers?: TxEventHandlers, + ) => { console.warn('EVM deposit not implemented yet'); // Deposit the asset into the EVM. return null; - } + }; + + delegate = async ( + _operatorAccount: string, + _assetId: string, + _amount: bigint, + _eventHandlers?: TxEventHandlers, + ) => { + console.warn('EVM delegate not implemented yet'); + // Delegate the asset to the operator. + return null; + }; } diff --git a/apps/tangle-dapp/data/restake/RestakeTx/substrate.ts b/apps/tangle-dapp/data/restake/RestakeTx/substrate.ts index f10a0d678..9573d237e 100644 --- a/apps/tangle-dapp/data/restake/RestakeTx/substrate.ts +++ b/apps/tangle-dapp/data/restake/RestakeTx/substrate.ts @@ -1,9 +1,15 @@ import type { ApiPromise } from '@polkadot/api'; -import { Signer } from '@polkadot/types/types'; +import type { SubmittableExtrinsic } from '@polkadot/api/types'; +import type { ISubmittableResult, Signer } from '@polkadot/types/types'; import noop from 'lodash/noop'; import type { Hash } from 'viem'; -import { RestakeTxBase, type TxEventHandlers } from './base'; +import { + type DelegateContext, + type DepositContext, + RestakeTxBase, + type TxEventHandlers, +} from './base'; export default class SubstrateRestakeTx extends RestakeTxBase { constructor( @@ -14,22 +20,13 @@ export default class SubstrateRestakeTx extends RestakeTxBase { super(); this.provider.setSigner(this.signer); - this.deposit = this.deposit.bind(this); } - public async deposit( - assetId: string, - amount: bigint, - eventHandlers?: TxEventHandlers, + private signAndSendExtrinsic>( + extrinsic: SubmittableExtrinsic<'promise', ISubmittableResult>, + context: Context, + eventHandlers?: TxEventHandlers, ) { - // Deposit the asset into the Substrate chain. - const extrinsic = this.provider.tx.multiAssetDelegation.deposit( - assetId, - amount, - ); - - eventHandlers?.onTxSending?.(); - return new Promise((resolve) => { let unsub = noop; @@ -40,12 +37,20 @@ export default class SubstrateRestakeTx extends RestakeTxBase { ({ dispatchError, events, status, txHash }) => { if (status.isInBlock) { const blockHash = status.asInBlock; - eventHandlers?.onTxInBlock?.(txHash.toHex(), blockHash.toHex()); + eventHandlers?.onTxInBlock?.( + txHash.toHex(), + blockHash.toHex(), + context, + ); } if (status.isFinalized) { const blockHash = status.asFinalized; - eventHandlers?.onTxFinalized?.(txHash.toHex(), blockHash.toHex()); + eventHandlers?.onTxFinalized?.( + txHash.toHex(), + blockHash.toHex(), + context, + ); } if (!status.isFinalized) { @@ -71,7 +76,7 @@ export default class SubstrateRestakeTx extends RestakeTxBase { message = `${error.section}.${error.name}`; } catch { - eventHandlers?.onTxFailed?.(message); + eventHandlers?.onTxFailed?.(message, context); resolve(null); unsub(); } @@ -79,7 +84,7 @@ export default class SubstrateRestakeTx extends RestakeTxBase { message = `${dispatchError.type}.${dispatchError.asToken.type}`; } - eventHandlers?.onTxFailed?.(message); + eventHandlers?.onTxFailed?.(message, context); resolve(null); unsub(); } else if (method === 'ExtrinsicSuccess' && status.isFinalized) { @@ -87,6 +92,7 @@ export default class SubstrateRestakeTx extends RestakeTxBase { eventHandlers?.onTxSuccess?.( txHashHex, status.asFinalized.toHex(), + context, ); // Resolve with the block hash resolve(txHashHex); @@ -98,9 +104,54 @@ export default class SubstrateRestakeTx extends RestakeTxBase { .then((unsubFn) => (unsub = unsubFn)) .catch((error) => { resolve(null); - eventHandlers?.onTxFailed?.(error.message); + eventHandlers?.onTxFailed?.(error.message, context); unsub(); }); }); } + + deposit = async ( + assetId: string, + amount: bigint, + eventHandlers?: TxEventHandlers, + ) => { + const context = { + amount, + assetId, + } satisfies DepositContext; + + // Deposit the asset into the Substrate chain. + const extrinsic = this.provider.tx.multiAssetDelegation.deposit( + assetId, + amount, + ); + + eventHandlers?.onTxSending?.(context); + + return this.signAndSendExtrinsic(extrinsic, context, eventHandlers); + }; + + delegate = async ( + operatorAccount: string, + assetId: string, + amount: bigint, + eventHandlers?: TxEventHandlers, + ) => { + const context = { + amount, + assetId, + operatorAccount, + } satisfies DelegateContext; + + // Deposit the asset into the Substrate chain. + const extrinsic = this.provider.tx.multiAssetDelegation.delegate( + operatorAccount, + assetId, + amount, + ); + + eventHandlers?.onTxSending?.(context); + + return this.signAndSendExtrinsic(extrinsic, context, eventHandlers); + }; } diff --git a/apps/tangle-dapp/data/restake/ViewTxOnExplorer.tsx b/apps/tangle-dapp/data/restake/ViewTxOnExplorer.tsx new file mode 100644 index 000000000..5840d593c --- /dev/null +++ b/apps/tangle-dapp/data/restake/ViewTxOnExplorer.tsx @@ -0,0 +1,23 @@ +import Button from '@webb-tools/webb-ui-components/components/buttons/Button'; +import { Typography } from '@webb-tools/webb-ui-components/typography/Typography'; + +export default function ViewTxOnExplorer({ + url, + children, +}: { url?: string; children?: string } = {}) { + if (url === undefined) return null; + + return ( + + {children !== undefined ? `${children}. ` : ``}View the transaction{' '} + + + ); +} diff --git a/apps/tangle-dapp/data/restake/useRestakeDelegatorInfo.ts b/apps/tangle-dapp/data/restake/useRestakeDelegatorInfo.ts index 52a720175..1e96fb5e9 100644 --- a/apps/tangle-dapp/data/restake/useRestakeDelegatorInfo.ts +++ b/apps/tangle-dapp/data/restake/useRestakeDelegatorInfo.ts @@ -1,9 +1,14 @@ -import { Option } from '@polkadot/types'; -import { PalletMultiAssetDelegationDelegatorDelegatorMetadata } from '@polkadot/types/lookup'; +import type { Option } from '@polkadot/types'; +import type { + PalletMultiAssetDelegationDelegatorBondLessRequest, + PalletMultiAssetDelegationDelegatorDelegatorMetadata, + PalletMultiAssetDelegationDelegatorDelegatorStatus, + PalletMultiAssetDelegationDelegatorUnstakeRequest, +} from '@polkadot/types/lookup'; import { WebbError, WebbErrorCodes } from '@webb-tools/dapp-types/WebbError'; import { useObservable, useObservableState } from 'observable-hooks'; import { useMemo } from 'react'; -import { EMPTY, map, Observable, switchMap } from 'rxjs'; +import { EMPTY, map, type Observable, switchMap } from 'rxjs'; import usePolkadotApi from '../../hooks/usePolkadotApi'; import useSubstrateAddress from '../../hooks/useSubstrateAddress'; @@ -35,9 +40,6 @@ export default function useRestakeDelegatorInfo() { const delegatorInfo$ = useObservable( (input$) => input$.pipe( - map((args) => { - return args; - }), switchMap(([activeAddress, delegatorQuery]) => delegatorQuery(activeAddress ?? '').pipe( map((delegatorInfo) => { @@ -74,58 +76,6 @@ export default function useRestakeDelegatorInfo() { }; }); - function getUnstakeRequest( - request: typeof info.unstakeRequest, - ): DelegatorInfo['unstakeRequest'] { - if (request.isNone) { - return null; - } - - const unstakeRequest = request.unwrap(); - const amountBigInt = unstakeRequest.amount.toBigInt(); - const assetIdStr = unstakeRequest.assetId.toString(); - - return { - assetId: assetIdStr, - amount: amountBigInt, - requestedRound: unstakeRequest.requestedRound.toNumber(), - }; - } - - function getBondLessRequest( - request: typeof info.delegatorBondLessRequest, - ): DelegatorInfo['delegatorBondLessRequest'] { - if (request.isNone) { - return null; - } - - const bondLessRequest = request.unwrap(); - const amountBigInt = bondLessRequest.amount.toBigInt(); - const assetIdStr = bondLessRequest.assetId.toString(); - - return { - assetId: assetIdStr, - bondLessAmount: amountBigInt, - requestedRound: bondLessRequest.requestedRound.toNumber(), - }; - } - - function getStatus( - status: typeof info.status, - ): DelegatorInfo['status'] { - if (status.isActive) { - return 'Active'; - } - - if (status.isLeavingScheduled) { - return { - LeavingScheduled: status.asLeavingScheduled.toNumber(), - }; - } - - throw WebbError.from(WebbErrorCodes.InvalidEnumValue); - } - return { deposits, delegations, @@ -149,3 +99,64 @@ export default function useRestakeDelegatorInfo() { delegatorInfo$, }; } + +/** + * @internal + */ +function getUnstakeRequest( + request: Option, +): DelegatorInfo['unstakeRequest'] { + if (request.isNone) { + return null; + } + + const unstakeRequest = request.unwrap(); + const amountBigInt = unstakeRequest.amount.toBigInt(); + const assetIdStr = unstakeRequest.assetId.toString(); + + return { + assetId: assetIdStr, + amount: amountBigInt, + requestedRound: unstakeRequest.requestedRound.toNumber(), + }; +} + +/** + * @internal + */ +function getBondLessRequest( + request: Option, +): DelegatorInfo['delegatorBondLessRequest'] { + if (request.isNone) { + return null; + } + + const bondLessRequest = request.unwrap(); + const amountBigInt = bondLessRequest.amount.toBigInt(); + const assetIdStr = bondLessRequest.assetId.toString(); + + return { + assetId: assetIdStr, + bondLessAmount: amountBigInt, + requestedRound: bondLessRequest.requestedRound.toNumber(), + }; +} + +/** + * @internal + */ +function getStatus( + status: PalletMultiAssetDelegationDelegatorDelegatorStatus, +): DelegatorInfo['status'] { + if (status.isActive) { + return 'Active'; + } + + if (status.isLeavingScheduled) { + return { + LeavingScheduled: status.asLeavingScheduled.toNumber(), + }; + } + + throw WebbError.from(WebbErrorCodes.InvalidEnumValue); +} diff --git a/apps/tangle-dapp/data/restake/useRestakeOperatorMap.ts b/apps/tangle-dapp/data/restake/useRestakeOperatorMap.ts index 2e4e5fc21..83badfc53 100644 --- a/apps/tangle-dapp/data/restake/useRestakeOperatorMap.ts +++ b/apps/tangle-dapp/data/restake/useRestakeOperatorMap.ts @@ -42,9 +42,10 @@ export default function useRestakeOperatorMap(): UseRestakeOperatorMapReturnType return entries$.pipe( map((entries) => entries.reduce( - (operatorsMap, [accountId, operatorMetadata]) => { + (operatorsMap, [accountStorage, operatorMetadata]) => { if (operatorMetadata.isNone) return operatorsMap; + const accountId = accountStorage.args[0]; const operator = operatorMetadata.unwrap(); const operatorMetadataPrimitive = { diff --git a/apps/tangle-dapp/data/restake/useRestakeTx.ts b/apps/tangle-dapp/data/restake/useRestakeTx.ts index d0c429736..9b94b3917 100644 --- a/apps/tangle-dapp/data/restake/useRestakeTx.ts +++ b/apps/tangle-dapp/data/restake/useRestakeTx.ts @@ -8,10 +8,11 @@ import { WebbPolkadot } from '@webb-tools/polkadot-api-provider'; import assert from 'assert'; import { useMemo } from 'react'; +import type { RestakeTxBase } from './RestakeTx/base'; import EVMRestakeTx from './RestakeTx/evm'; import SubstrateRestakeTx from './RestakeTx/substrate'; -export default function useRestakeTx() { +export default function useRestakeTx(): RestakeTxBase { const { activeAccount, activeWallet, activeApi } = useWebContext(); return useMemo(() => { @@ -20,11 +21,7 @@ export default function useRestakeTx() { activeAccount === null || activeApi === undefined ) { - return { - deposit: () => { - throw WebbError.from(WebbErrorCodes.ApiNotReady); - }, - }; + return createDummyApi(WebbError.from(WebbErrorCodes.ApiNotReady).message); } switch (activeWallet.platform) { @@ -43,12 +40,23 @@ export default function useRestakeTx() { } default: { - return { - deposit: () => { - throw WebbError.from(WebbErrorCodes.UnsupportedWallet); - }, - }; + return createDummyApi( + WebbError.from(WebbErrorCodes.UnsupportedWallet).message, + ); } } }, [activeAccount, activeApi, activeWallet]); } + +function createDummyApi(error: string): RestakeTxBase { + return { + delegate(operatorAccount, assetId, amount, eventHandlers) { + eventHandlers?.onTxFailed?.(error, { amount, assetId, operatorAccount }); + return Promise.resolve(null); + }, + deposit(assetId, amount, eventHandlers) { + eventHandlers?.onTxFailed?.(error, { amount, assetId }); + return Promise.resolve(null); + }, + }; +} diff --git a/apps/tangle-dapp/data/restake/useRestakeTxEventHandlersWithNoti.tsx b/apps/tangle-dapp/data/restake/useRestakeTxEventHandlersWithNoti.tsx index cad6c649b..5efc8cb32 100644 --- a/apps/tangle-dapp/data/restake/useRestakeTxEventHandlersWithNoti.tsx +++ b/apps/tangle-dapp/data/restake/useRestakeTxEventHandlersWithNoti.tsx @@ -1,18 +1,56 @@ import { useWebContext } from '@webb-tools/api-provider-environment'; import { isPolkadotPortal } from '@webb-tools/api-provider-environment/transaction/utils'; +import type { Evaluate } from '@webb-tools/dapp-types/utils/types'; import Spinner from '@webb-tools/icons/Spinner'; -import { useWebbUI } from '@webb-tools/webb-ui-components'; -import Button from '@webb-tools/webb-ui-components/components/buttons/Button'; +import { type SnackBarOpts } from '@webb-tools/webb-ui-components/components/Notification/NotificationContext'; +import { useWebbUI } from '@webb-tools/webb-ui-components/hooks/useWebbUI'; import { Typography } from '@webb-tools/webb-ui-components/typography/Typography'; +import type React from 'react'; import { useCallback, useMemo } from 'react'; import type { Hash } from 'viem'; import useExplorerUrl from '../../hooks/useExplorerUrl'; import { TxEvent, type TxEventHandlers } from './RestakeTx/base'; - -export default function useRestakeTxEventHandlersWithNoti( - props?: TxEventHandlers, -) { +import ViewTxOnExplorer from './ViewTxOnExplorer'; + +type NotiOpts> = Evaluate< + Partial< + Omit & { + secondaryMessage?: + | SnackBarOpts['secondaryMessage'] + | ((context: Context, explorerUrl?: string) => React.JSX.Element); + } + > +>; + +export type Options> = Partial< + Record> +>; + +export type Props> = + TxEventHandlers & { + options?: Options; + }; + +const extractNotiOptions = >( + context: Context, + options: NotiOpts = {}, + explorerUrl?: string, +) => { + const { secondaryMessage, ...restProps } = options; + return { + ...restProps, + ...(typeof secondaryMessage === 'function' + ? { secondaryMessage: secondaryMessage(context, explorerUrl) } + : secondaryMessage !== undefined + ? { secondaryMessage } + : {}), + }; +}; + +export default function useRestakeTxEventHandlersWithNoti< + Context extends Record, +>({ options = {}, ...props }: Props = {}) { const { activeChain } = useWebContext(); const { notificationApi } = useWebbUI(); const getExplorerUrl = useExplorerUrl(); @@ -43,34 +81,40 @@ export default function useRestakeTxEventHandlersWithNoti( [getExplorerUrl], ); - return useMemo( + return useMemo>( () => ({ - onTxSending: () => { + onTxSending: (context) => { + const key = TxEvent.SENDING; + notificationApi.addToQueue({ - key: TxEvent.SENDING, + key, Icon: , message: 'Sending transaction...', variant: 'info', persist: true, + ...extractNotiOptions(context, options[key]), }); - props?.onTxSending?.(); + props?.onTxSending?.(context); }, - onTxInBlock: (txHash, blockHash) => { + onTxInBlock: (txHash, blockHash, context) => { + const key = TxEvent.IN_BLOCK; const url = getTxUrl(txHash, blockHash, blockExplorer); notificationApi.remove(TxEvent.SENDING); notificationApi.addToQueue({ - key: TxEvent.IN_BLOCK, + key, Icon: , message: 'Transaction is included in a block', secondaryMessage: , variant: 'info', persist: true, + ...extractNotiOptions(context, options[key], url?.toString()), }); - props?.onTxInBlock?.(txHash, blockHash); + props?.onTxInBlock?.(txHash, blockHash, context); }, - onTxSuccess: (txHash, blockHash) => { + onTxSuccess: (txHash, blockHash, context) => { + const key = TxEvent.SUCCESS; const url = getTxUrl(txHash, blockHash, blockExplorer); notificationApi.remove(TxEvent.SENDING); @@ -78,21 +122,24 @@ export default function useRestakeTxEventHandlersWithNoti( notificationApi.remove(TxEvent.FINALIZED); notificationApi.addToQueue({ - key: TxEvent.SUCCESS, + key, message: 'Transaction finalized successfully!', secondaryMessage: , variant: 'success', + ...extractNotiOptions(context, options[key], url?.toString()), }); - props?.onTxSuccess?.(txHash, blockHash); + props?.onTxSuccess?.(txHash, blockHash, context); }, - onTxFailed: (error) => { + onTxFailed: (error, context) => { + const key = TxEvent.FAILED; + notificationApi.remove(TxEvent.SENDING); notificationApi.remove(TxEvent.IN_BLOCK); notificationApi.remove(TxEvent.FINALIZED); notificationApi.addToQueue({ - key: TxEvent.FAILED, + key, message: 'Transaction failed!', secondaryMessage: ( ), variant: 'error', + ...extractNotiOptions(context, options[key]), }); - props?.onTxFailed?.(error); + props?.onTxFailed?.(error, context); }, }), - [blockExplorer, getTxUrl, notificationApi, props], + [blockExplorer, getTxUrl, notificationApi, options, props], ); } - -/** - * @internal - */ -function ViewTxOnExplorer({ url }: { url?: string } = {}) { - if (url === undefined) return null; - - return ( - - View the transaction{' '} - - - ); -} - -/** - * @internal - */ diff --git a/apps/tangle-dapp/hooks/usePolkadotApi.ts b/apps/tangle-dapp/hooks/usePolkadotApi.ts index 7bbe5ce2b..a90275dfe 100644 --- a/apps/tangle-dapp/hooks/usePolkadotApi.ts +++ b/apps/tangle-dapp/hooks/usePolkadotApi.ts @@ -1,4 +1,6 @@ -import { useContext } from 'react'; +import chainsPopulated from '@webb-tools/dapp-config/chains/chainsPopulated'; +import isDefined from '@webb-tools/dapp-types/utils/isDefined'; +import { useContext, useEffect } from 'react'; import { PolkadotApiContext } from '../context/PolkadotApiContext'; @@ -9,3 +11,15 @@ export default function usePolkadotApi() { } return ctx; } + +export function useRpcSubscription(typedChainId?: number | null) { + const { setCustomRpc } = usePolkadotApi(); + + // Subscribe to sourceTypedChainId and update customRpc + useEffect(() => { + if (!isDefined(typedChainId)) return; + + const chain = chainsPopulated[typedChainId]; + setCustomRpc(chain?.rpcUrls.default.webSocket?.[0]); + }, [setCustomRpc, typedChainId]); +} diff --git a/apps/tangle-dapp/stores/deposit.ts b/apps/tangle-dapp/stores/deposit.ts deleted file mode 100644 index 1b0aeb996..000000000 --- a/apps/tangle-dapp/stores/deposit.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { ZERO_BIG_INT } from '@webb-tools/dapp-config/constants'; -import { create } from 'zustand'; -import { subscribeWithSelector } from 'zustand/middleware'; - -import { SUPPORTED_RESTAKE_DEPOSIT_TYPED_CHAIN_IDS } from '../constants/restake'; - -type Actions = { - updateSourceTypedChainId: (chainId: number) => void; - updateDepositAssetId: (assetId: string) => void; - updateAmount: (amount: bigint) => void; -}; - -type State = { - sourceTypedChainId: number; - depositAssetId: string | null; - amount: bigint; - actions: Actions; -}; - -const useRestakeDepositStore = create()( - subscribeWithSelector((set) => ({ - sourceTypedChainId: SUPPORTED_RESTAKE_DEPOSIT_TYPED_CHAIN_IDS[0], - depositAssetId: null, - amount: ZERO_BIG_INT, - actions: { - updateSourceTypedChainId: (sourceTypedChainId: number) => - set({ sourceTypedChainId }), - updateDepositAssetId: (depositAssetId: string) => set({ depositAssetId }), - updateAmount: (amount: bigint) => set({ amount }), - }, - })), -); - -const useDepositAssetId = () => - useRestakeDepositStore((state) => state.depositAssetId); - -const useSourceTypedChainId = () => - useRestakeDepositStore((state) => state.sourceTypedChainId); - -const useActions = () => useRestakeDepositStore((state) => state.actions); - -const useAmount = () => useRestakeDepositStore((state) => state.amount); - -export type { Actions, State }; - -export { useActions, useAmount, useDepositAssetId, useSourceTypedChainId }; diff --git a/apps/tangle-dapp/types/restake.ts b/apps/tangle-dapp/types/restake.ts index 963ab2fe2..2b0daf203 100644 --- a/apps/tangle-dapp/types/restake.ts +++ b/apps/tangle-dapp/types/restake.ts @@ -240,3 +240,9 @@ export type DepositFormFields = { sourceTypedChainId: number; depositAssetId: string | null; }; + +export type DelegationFormFields = { + amount: string; + operatorAccountId: string; + assetId: string; +}; diff --git a/apps/tangle-dapp/utils/decimalsToStep.ts b/apps/tangle-dapp/utils/decimalsToStep.ts new file mode 100644 index 000000000..21b94d479 --- /dev/null +++ b/apps/tangle-dapp/utils/decimalsToStep.ts @@ -0,0 +1,14 @@ +import { DEFAULT_DECIMALS } from '@webb-tools/dapp-config/constants'; + +/** + * Convert decimals to input step + * 18 decimals -> 0.000000000000000001 + * + * @param decimals the number of decimals to convert to step + * @returns the step value + */ +export default function decimalsToStep(decimals = DEFAULT_DECIMALS) { + if (decimals === 0) return '1'; + + return `0.${'0'.repeat(decimals - 1)}1`; +} diff --git a/apps/tangle-dapp/utils/getAmountValidation.ts b/apps/tangle-dapp/utils/getAmountValidation.ts new file mode 100644 index 000000000..e6e884166 --- /dev/null +++ b/apps/tangle-dapp/utils/getAmountValidation.ts @@ -0,0 +1,63 @@ +import { ZERO_BIG_INT } from '@webb-tools/dapp-config/constants'; +import Decimal from 'decimal.js'; + +import safeParseUnits from './safeParseUnits'; + +/** + * Get the shared amount validation for react-hook-form + * amount input. + * + * @param step the input step value + * @param minFormatted the minimum amount formatted + * @param min the minimum amount raw in bigint + * @param max the maximum amount raw in bigint + * @param assetDecimals the asset decimals + * @param assetSymbol the asset symbol + * @returns the amount validation object for react-hook-form + * amount input + */ +export function getAmountValidation( + step: string, + minFormatted?: string, + min?: bigint, + max?: bigint, + assetDecimals?: number, + assetSymbol?: string, +) { + return { + // Check amount with asset denomination + shouldDivisibleWithDecimals: (value: string) => { + return ( + Decimal.mod(value, step).isZero() || + `Amount must be divisible by ${step} ${assetDecimals !== null && assetSymbol !== null ? `, as ${assetSymbol} has ${assetDecimals} decimals` : ''}`.trim() + ); + }, + shouldNotBeZero: (value: string) => { + const parsed = safeParseUnits(value, assetDecimals); + if (!parsed.sucess) return true; + + return ( + parsed.value !== ZERO_BIG_INT || 'Amount must be greater than zero' + ); + }, + shouldNotLessThanMin: (value: string) => { + if (typeof min !== 'bigint') return true; + + const parsed = safeParseUnits(value, assetDecimals); + if (!parsed.sucess) return true; + + return ( + parsed.value >= min || + `Amount must be at least ${minFormatted} ${assetSymbol ?? ''}`.trim() + ); + }, + shouldNotExceedMax: (value: string) => { + if (typeof max !== 'bigint') return true; + + const parsed = safeParseUnits(value, assetDecimals); + if (!parsed.sucess) return true; + + return parsed.value <= max || 'Amount exceeds balance'; + }, + }; +} diff --git a/libs/dapp-types/src/utils/index.ts b/libs/dapp-types/src/utils/index.ts index b73a628aa..ef4189d47 100644 --- a/libs/dapp-types/src/utils/index.ts +++ b/libs/dapp-types/src/utils/index.ts @@ -1,3 +1,5 @@ +export { default as isNotNullOrUndefined } from './isDefined'; +export { default as isPrimitive } from './isPrimitive'; export { default as isSubstrateAddress } from './isSubstrateAddress'; export { default as isValidAddress } from './isValidAddress'; export { default as isValidPublicKey } from './isValidPublicKey'; diff --git a/libs/dapp-types/src/utils/isDefined.ts b/libs/dapp-types/src/utils/isDefined.ts new file mode 100644 index 000000000..8df0e269e --- /dev/null +++ b/libs/dapp-types/src/utils/isDefined.ts @@ -0,0 +1,3 @@ +export default function isDefined(value: T | null | undefined): value is T { + return value !== null && value !== undefined; +} diff --git a/libs/webb-ui-components/src/components/ListCard/TokenListCard.tsx b/libs/webb-ui-components/src/components/ListCard/TokenListCard.tsx index ca22d0eb2..abd91b5a4 100644 --- a/libs/webb-ui-components/src/components/ListCard/TokenListCard.tsx +++ b/libs/webb-ui-components/src/components/ListCard/TokenListCard.tsx @@ -22,6 +22,8 @@ export const TokenListCard = forwardRef( type = 'token', unavailableTokens, value: selectedAsset, + overrideInputProps, + renderEmpty, alertTitle, ...props }, @@ -62,6 +64,7 @@ export const TokenListCard = forwardRef( placeholder="Search pool or enter token address" value={searchText} onChange={(val) => setSearchText(val.toString())} + {...overrideInputProps} />
@@ -88,29 +91,31 @@ export const TokenListCard = forwardRef( {/** Select tokens */}
-
- - Select {type} - + {filteredSelect.length > 0 && ( +
+ + Select {type} + - {/** Token list */} - -
    - {filteredSelect.map((current, idx) => ( - onChange?.(current)} - /> - ))} -
-
-
+ {/** Token list */} + +
    + {filteredSelect.map((current, idx) => ( + onChange?.(current)} + /> + ))} +
+
+
+ )} {unavailableTokens.length ? (
@@ -137,6 +142,18 @@ export const TokenListCard = forwardRef(
) : null} + + {filteredSelect.length === 0 && ( +
+ {typeof renderEmpty === 'function' ? ( + renderEmpty() + ) : ( + + No {type} Found. + + )} +
+ )}
{/* Alert Component */} diff --git a/libs/webb-ui-components/src/components/ListCard/types.ts b/libs/webb-ui-components/src/components/ListCard/types.ts index baf80e896..1ea93496e 100644 --- a/libs/webb-ui-components/src/components/ListCard/types.ts +++ b/libs/webb-ui-components/src/components/ListCard/types.ts @@ -259,7 +259,8 @@ export interface ContractListCardProps isLoading?: boolean; } -export interface RelayerListCardProps extends Omit, 'onChange'> { +export interface RelayerListCardProps + extends Partial> { /** * If `true`, the component will display in connected view */ @@ -310,7 +311,7 @@ export interface TokenListCardProps * The type of token list card * @default "token" */ - type?: 'token' | 'pool'; + type?: 'token' | 'pool' | 'asset'; /** * The popular token list @@ -341,4 +342,14 @@ export interface TokenListCardProps * The text for the alert component at the bottom */ alertTitle?: string; + + /** + * Override the input props + */ + overrideInputProps?: Partial; + + /** + * Function to render body when the list is empty + */ + renderEmpty?: () => React.ReactNode; } diff --git a/libs/webb-ui-components/src/components/Table/TData.tsx b/libs/webb-ui-components/src/components/Table/TData.tsx index 76c6fcc53..6ffc2c0dc 100644 --- a/libs/webb-ui-components/src/components/Table/TData.tsx +++ b/libs/webb-ui-components/src/components/Table/TData.tsx @@ -8,7 +8,7 @@ import { TDataProps } from './types'; * The styler wrapper of `
` tag, use inside `` tab for table */ export const TData = forwardRef( - ({ children, className, ...props }, ref) => { + ({ children, className, isDisabledHoverStyle, ...props }, ref) => { return (
( 'border-mono-40 dark:border-mono-140', 'text-mono-140 dark:text-mono-60', 'bg-mono-0 dark:bg-mono-180', - 'group-hover/tr:bg-blue-0 dark:group-hover/tr:bg-mono-160', + !isDisabledHoverStyle && + 'group-hover/tr:bg-blue-0 dark:group-hover/tr:bg-mono-160', ), className, )} diff --git a/libs/webb-ui-components/src/components/Table/Table.tsx b/libs/webb-ui-components/src/components/Table/Table.tsx index 31051a37b..9f05025a7 100644 --- a/libs/webb-ui-components/src/components/Table/Table.tsx +++ b/libs/webb-ui-components/src/components/Table/Table.tsx @@ -8,18 +8,19 @@ import { THeaderMemo as THeader } from './THeader'; import { TableProps } from './types'; export const Table = ({ + isDisabledRowHoverStyle, isDisplayFooter, isPaginated, - tableProps: table, - totalRecords = 0, + onRowClick, + paginationClassName, tableClassName, + tableProps: table, tableWrapperClassName, - thClassName, - trClassName, tdClassName, - paginationClassName, + thClassName, title, - onRowClick, + totalRecords = 0, + trClassName, ref, ...props }: TableProps) => { @@ -70,7 +71,11 @@ export const Table = ({ onClick={getRowClickHandler(row)} > {row.getVisibleCells().map((cell) => ( - + {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} diff --git a/libs/webb-ui-components/src/components/Table/types.ts b/libs/webb-ui-components/src/components/Table/types.ts index 1b5e255e1..6d74ca324 100644 --- a/libs/webb-ui-components/src/components/Table/types.ts +++ b/libs/webb-ui-components/src/components/Table/types.ts @@ -76,6 +76,8 @@ export interface TableProps * The optional ref to forward to the table component */ ref?: React.Ref; + + isDisabledRowHoverStyle?: boolean; } /** @@ -86,4 +88,6 @@ export interface THeaderProps extends PropsOf<'th'>, IWebbComponentBase {} /** * The `TData` props */ -export interface TDataProps extends PropsOf<'td'>, IWebbComponentBase {} +export interface TDataProps extends PropsOf<'td'>, IWebbComponentBase { + isDisabledHoverStyle?: boolean; +} diff --git a/libs/webb-ui-components/src/components/TransactionInputCard/TransactionInputCard.tsx b/libs/webb-ui-components/src/components/TransactionInputCard/TransactionInputCard.tsx index 0a7cedec6..292284a9b 100644 --- a/libs/webb-ui-components/src/components/TransactionInputCard/TransactionInputCard.tsx +++ b/libs/webb-ui-components/src/components/TransactionInputCard/TransactionInputCard.tsx @@ -98,42 +98,58 @@ TransactionInputCardRoot.displayName = 'TransactionInputCardRoot'; const TransactionChainSelector = forwardRef< React.ElementRef<'button'>, TransactionChainSelectorProps ->(({ typedChainId: typedChainIdProps, className, disabled, ...props }, ref) => { - const context = useContext(TransactionInputCardContext); +>( + ( + { + typedChainId: typedChainIdProps, + className, + disabled, + placeholder = 'Select Chain', + renderBody, + ...props + }, + ref, + ) => { + const context = useContext(TransactionInputCardContext); - const typedChainId = typedChainIdProps ?? context.typedChainId; - const chain = typedChainId ? chainsConfig[typedChainId] : undefined; + const typedChainId = typedChainIdProps ?? context.typedChainId; + const chain = typedChainId ? chainsConfig[typedChainId] : undefined; - return ( - - ); -}); + {!disabled && ( + + )} + + ); + }, +); TransactionChainSelector.displayName = 'TransactionChainSelector'; const TransactionButton = forwardRef< diff --git a/libs/webb-ui-components/src/components/TransactionInputCard/types.ts b/libs/webb-ui-components/src/components/TransactionInputCard/types.ts index 4f46f4c61..b6ca78965 100644 --- a/libs/webb-ui-components/src/components/TransactionInputCard/types.ts +++ b/libs/webb-ui-components/src/components/TransactionInputCard/types.ts @@ -60,7 +60,14 @@ export interface TransactionInputCardRootProps export interface TransactionChainSelectorProps extends PropsOf<'button'>, - Pick {} + Pick { + /** + * @default 'Select Chain' + */ + placeholder?: string; + + renderBody?: () => ReactNode; +} export interface TransactionButtonProps extends PropsOf<'button'> { /** diff --git a/libs/webb-ui-components/src/hooks/useModal.ts b/libs/webb-ui-components/src/hooks/useModal.ts index 75038674a..b8e273d54 100644 --- a/libs/webb-ui-components/src/hooks/useModal.ts +++ b/libs/webb-ui-components/src/hooks/useModal.ts @@ -1,7 +1,6 @@ 'use client'; -import { noop } from 'lodash'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useState } from 'react'; interface ReturnData { close: () => void; @@ -11,20 +10,14 @@ interface ReturnData { update: (status: boolean) => void; } -export const useModal = ( - defaultStatus = false, - callback?: () => void, -): ReturnData => { +export const useModal = (defaultStatus = false): ReturnData => { const [status, setStatus] = useState(defaultStatus); + const open = useCallback((): void => setStatus(true), []); const close = useCallback((): void => setStatus(false), []); - const toggle = useCallback((): void => setStatus(!status), [status]); - const _callback = useRef<() => void>(callback || noop); - const update = useCallback((status: boolean): void => setStatus(status), []); - useEffect(() => { - _callback.current(); - }, [status, _callback]); + const toggle = useCallback((): void => setStatus((prev) => !prev), []); + const update = useCallback((status: boolean): void => setStatus(status), []); return { close, open, status, toggle, update }; }; diff --git a/tools/scripts/setupMultiAssetDelegationPallet.ts b/tools/scripts/setupMultiAssetDelegationPallet.ts index fa751fa85..3244637f3 100644 --- a/tools/scripts/setupMultiAssetDelegationPallet.ts +++ b/tools/scripts/setupMultiAssetDelegationPallet.ts @@ -1,6 +1,9 @@ import chalk from 'chalk'; import { ApiPromise, Keyring, WsProvider } from '@polkadot/api'; -import { TANGLE_LOCAL_WS_RPC_ENDPOINT } from '@webb-tools/dapp-config/constants/tangle'; +import { + TANGLE_LOCAL_WS_RPC_ENDPOINT, + TANGLE_TOKEN_DECIMALS, +} from '@webb-tools/dapp-config/constants/tangle'; import { parseUnits } from 'viem'; import { formatBalance, stringToU8a } from '@polkadot/util'; import { encodeAddress } from '@polkadot/util-crypto'; @@ -55,6 +58,7 @@ success( const keyring = new Keyring({ type: 'sr25519' }); const ALICE_SUDO = keyring.addFromUri('//Alice'); const BOB = keyring.addFromUri('//Bob'); +const DAVE = keyring.addFromUri('//Dave'); type Asset = { id: number; @@ -151,6 +155,13 @@ await api.tx.sudo .signAndSend(ALICE_SUDO, { nonce }); success('Assets whitelisted for the multi-asset-delegation pallet!'); +info('Join operators for DAVE'); +nonce = await api.rpc.system.accountNextIndex(DAVE.address); +await api.tx.multiAssetDelegation + .joinOperators(parseUnits(MINIMUM_BALANCE_UINT, TANGLE_TOKEN_DECIMALS)) + .signAndSend(DAVE, { nonce }); +success('DAVE joined the operators successfully!'); + console.log( chalk.bold.green( '✨ Pallet `multi-asset-delegation` setup completed successfully! ✨',