diff --git a/packages/browser-wallet/package.json b/packages/browser-wallet/package.json index 34a1ac9b9..c4724a27d 100644 --- a/packages/browser-wallet/package.json +++ b/packages/browser-wallet/package.json @@ -10,6 +10,7 @@ "build:dev": "cross-env NODE_ENV=development yarn build-base", "build:prod": "cross-env NODE_ENV=production yarn build-base", "build": "yarn build:prod", + "check": "tsc --noEmit", "test": "jest", "watch": "cross-env WATCH=1 yarn build:dev", "storybook": "storybook dev -p 6006", diff --git a/packages/browser-wallet/src/popup/popupX/constants/routes.ts b/packages/browser-wallet/src/popup/popupX/constants/routes.ts index 6f9efc832..7e7777e56 100644 --- a/packages/browser-wallet/src/popup/popupX/constants/routes.ts +++ b/packages/browser-wallet/src/popup/popupX/constants/routes.ts @@ -1,3 +1,6 @@ +import { AccountAddress, TransactionHash } from '@concordium/web-sdk'; +import { generatePath } from 'react-router-dom'; + export type RouteConfig = { hideBackArrow?: boolean; backTitle?: string; @@ -69,12 +72,6 @@ export const relativeRoutes = { config: { backTitle: 'to Send Funds form', }, - confirmed: { - path: 'confirmed', - config: { - hideBackArrow: true, - }, - }, }, }, receive: { @@ -92,6 +89,12 @@ export const relativeRoutes = { token: { path: 'token', }, + submittedTransaction: { + path: 'submitted/:transactionHash', + config: { + hideBackArrow: true, + }, + }, }, settings: { path: 'settings', @@ -146,9 +149,11 @@ export const relativeRoutes = { }, }, }, + /** Routes related to staking for the currently selected account */ earn: { path: 'earn', - baker: { + /** Validation related routes */ + validator: { path: 'baker', intro: { path: 'intro', @@ -159,23 +164,27 @@ export const relativeRoutes = { openPool: { path: 'openPool', }, - bakerKeys: { - path: 'bakerKeys', + keys: { + path: 'keys', }, }, + /** Delegation related routes */ delegator: { path: 'delegator', - intro: { - path: 'intro', - }, - type: { - path: 'type', - }, + /** Configure new delegator */ register: { path: 'register', + configure: { + path: 'configure', + }, }, - result: { - path: 'result', + /** Configure existing delegator */ + update: { + path: 'update', + }, + /** Submit configure delegator transaction */ + submit: { + path: 'submit', }, }, }, @@ -219,6 +228,15 @@ const buildAbsoluteRoutes = ( export const absoluteRoutes = buildAbsoluteRoutes(relativeRoutes); +export const transactionDetailsRoute = (account: AccountAddress.Type, tx: TransactionHash.Type) => + generatePath(absoluteRoutes.home.transactionLog.details.path, { + account: account.address, + transactionHash: TransactionHash.toHexString(tx), + }); + +export const submittedTransactionRoute = (tx: TransactionHash.Type) => + generatePath(absoluteRoutes.home.submittedTransaction.path, { transactionHash: TransactionHash.toHexString(tx) }); + /** * Given two absolute routes, returns the relative route between them. * Note: fromPath should be a prefix of toPath. diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Baker/Intro/BakerIntro.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Baker/Intro/BakerIntro.tsx index a1f81d24e..edd653ed9 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Baker/Intro/BakerIntro.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Baker/Intro/BakerIntro.tsx @@ -3,6 +3,7 @@ import Carousel from '@popup/popupX/shared/Carousel'; import { absoluteRoutes } from '@popup/popupX/constants/routes'; import { useNavigate } from 'react-router-dom'; import Page from '@popup/popupX/shared/Page'; +import Text from '@popup/popupX/shared/Text'; import { Trans, useTranslation } from 'react-i18next'; export default function BakerIntro() { @@ -10,19 +11,19 @@ export default function BakerIntro() { const { t } = useTranslation('x', { keyPrefix: 'earn.validator.intro' }); return ( - nav(absoluteRoutes.settings.earn.baker.register.path)}> - + nav(absoluteRoutes.settings.earn.validator.register.path)}> + - - + + - - + + - + ); diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Intro/DelegatorIntro.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Intro/DelegatorIntro.tsx index 511222195..f26ea098d 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Intro/DelegatorIntro.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Intro/DelegatorIntro.tsx @@ -1,17 +1,18 @@ import React from 'react'; import Carousel from '@popup/popupX/shared/Carousel'; import { useNavigate } from 'react-router-dom'; -import { absoluteRoutes } from '@popup/popupX/constants/routes'; import Page from '@popup/popupX/shared/Page'; import { Trans, useTranslation } from 'react-i18next'; import ExternalLink from '@popup/popupX/shared/ExternalLink'; -export default function DelegatorIntro() { +type Props = { onDoneRoute: string }; + +export default function DelegatorIntro({ onDoneRoute }: Props) { const nav = useNavigate(); const { t } = useTranslation('x', { keyPrefix: 'earn.delegator.intro' }); return ( - nav(absoluteRoutes.settings.earn.delegator.type.path)}> + nav(onDoneRoute)}> -
- Register Delegation - on Accout 1 / 6gk...k7o -
-
-
- Token -
- CCD - 17,800 CCD available -
-
-
- Amount -
- 12,600.00 - Stake max. -
-
-
- Estimated transaction fee: - 1,000.00 CCD -
-
-
-
- Current pool - 300,000.00 CCD -
-
- Pool limit - 56,400.66 CCD -
-
-
-
- Auto add rewards - -
- - I want to automatically add my baking rewards to my baker stake - -
- - - ); -} diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Register/index.ts b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Register/index.ts deleted file mode 100644 index 7221fac0a..000000000 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Register/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as RegisterDelegator } from './RegisterDelegator'; diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Result/DelegationResult.scss b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Result/DelegationResult.scss index 36ab1cc5d..1b6380508 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Result/DelegationResult.scss +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Result/DelegationResult.scss @@ -1,56 +1,11 @@ .delegation-result-container { - display: flex; - flex-direction: column; - height: 100%; - padding-bottom: rem(32px); - .capture__main_small { color: $color-white; word-wrap: break-word; } .delegation-result { - &__title { - display: flex; - flex-direction: column; - justify-content: space-between; - margin-bottom: rem(12px); - - .capture__main_small { - color: $color-mineral-2; - padding-left: rem(6px); - } - } - &__card { - display: flex; - flex-direction: column; - border-radius: rem(12px); - padding: rem(16px); - margin-top: rem(16px); - background-color: rgba($color-grey-3, 0.3); - - &_row:not(:last-child) { - padding-bottom: rem(8px); - margin-bottom: rem(8px); - border-bottom: 1px solid $color-grey-3; - } - - &_row { - display: flex; - flex-direction: column; - - .capture__main_small:first-child { - color: rgba($color-mineral-3, 0.5); - margin-bottom: rem(6px); - } - } - } - - &__export { - display: flex; - align-items: center; - gap: rem(8px); margin-top: rem(16px); } } diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Result/DelegationResult.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Result/DelegationResult.tsx index e6819601b..da55ab797 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Result/DelegationResult.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Result/DelegationResult.tsx @@ -1,34 +1,196 @@ -import React from 'react'; +import React, { useCallback, useMemo } from 'react'; +import { + AccountAddress, + AccountTransactionPayload, + AccountTransactionType, + CcdAmount, + ConfigureDelegationPayload, + DelegationTargetType, + TransactionHash, +} from '@concordium/web-sdk'; +import { Navigate, useLocation, Location, useNavigate } from 'react-router-dom'; +import { useAtomValue } from 'jotai'; +import { useTranslation } from 'react-i18next'; +import { useUpdateAtom } from 'jotai/utils'; + +import { selectedAccountAtom } from '@popup/store/account'; import Button from '@popup/popupX/shared/Button'; +import Page from '@popup/popupX/shared/Page'; +import { formatCcdAmount } from '@popup/popupX/shared/utils/helpers'; +import Card from '@popup/popupX/shared/Card'; +import { ensureDefined } from '@shared/utils/basic-helpers'; +import { useBlockChainParametersAboveV0 } from '@popup/shared/BlockChainParametersProvider'; +import { secondsToDaysRoundedDown } from '@shared/utils/time-helpers'; +import { grpcClientAtom } from '@popup/store/settings'; +import { usePrivateKey } from '@popup/shared/utils/account-helpers'; +import { + createPendingTransactionFromAccountTransaction, + getDefaultExpiry, + getTransactionAmount, + sendTransaction, + useGetTransactionFee, +} from '@popup/shared/utils/transaction-helpers'; +import { addPendingTransactionAtom } from '@popup/store/transactions'; +import { cpStakingCooldown } from '@shared/utils/chain-parameters-helpers'; +import { submittedTransactionRoute } from '@popup/popupX/constants/routes'; +import Text from '@popup/popupX/shared/Text'; + +enum TransactionSubmitErrorType { + InsufficientFunds = 'InsufficientFunds', +} + +class TransactionSubmitError extends Error { + private constructor(public type: TransactionSubmitErrorType) { + super(); + super.name = `TransactionSubmitError.${type}`; + } + + public static insufficientFunds(): TransactionSubmitError { + return new TransactionSubmitError(TransactionSubmitErrorType.InsufficientFunds); + } +} + +function useTransactionSubmit(sender: AccountAddress.Type, type: AccountTransactionType) { + const grpc = useAtomValue(grpcClientAtom); + const key = usePrivateKey(sender.address); + const addPendingTransaction = useUpdateAtom(addPendingTransactionAtom); + + return useCallback( + async (payload: AccountTransactionPayload, cost: CcdAmount.Type) => { + const accountInfo = await grpc.getAccountInfo(sender); + if ( + accountInfo.accountAvailableBalance.microCcdAmount < + getTransactionAmount(type, payload) + (cost.microCcdAmount || 0n) + ) { + throw TransactionSubmitError.insufficientFunds(); + } + + const nonce = await grpc.getNextAccountNonce(sender); + + const header = { + expiry: getDefaultExpiry(), + sender, + nonce: nonce.nonce, + }; + const transaction = { payload, header, type }; + + const hash = await sendTransaction(grpc, transaction, key!); + const pending = createPendingTransactionFromAccountTransaction(transaction, hash, cost.microCcdAmount); + await addPendingTransaction(pending); + + return hash; + }, + [key] + ); +} + +export type DelegationResultLocationState = { + payload: ConfigureDelegationPayload; + type: 'register' | 'change' | 'remove'; +}; export default function DelegationResult() { + const { state } = useLocation() as Location & { + state: DelegationResultLocationState | undefined; + }; + const nav = useNavigate(); + const { t } = useTranslation('x', { keyPrefix: 'earn.delegator' }); + const getCost = useGetTransactionFee(AccountTransactionType.ConfigureDelegation); + const account = ensureDefined(useAtomValue(selectedAccountAtom), 'No account selected'); + + const parametersV1 = useBlockChainParametersAboveV0(); + const submitTransaction = useTransactionSubmit( + AccountAddress.fromBase58(account), + AccountTransactionType.ConfigureDelegation + ); + + const cooldown = useMemo(() => { + let cooldownParam = 0n; + if (parametersV1 !== undefined) { + cooldownParam = cpStakingCooldown(parametersV1); + } + return secondsToDaysRoundedDown(cooldownParam); + }, [parametersV1]); + + const title = useMemo(() => { + switch (state?.type) { + case 'register': + return t('register.title'); + case 'change': + return t('update.title'); + // case 'remove': + // return t('remove.title'); + default: + return undefined; + } + }, [state, t]); + const notice = t('register.notice', { cooldown }); // TODO: add more cases when supporting change/remove + + if (state === undefined) { + return ; + } + + const fee = getCost(state.payload); + const submit = async () => { + if (fee === undefined) { + throw Error('Fee could not be calculated'); + } + const tx = await submitTransaction(state.payload, fee); + nav(submittedTransactionRoute(TransactionHash.fromHexString(tx))); + }; + return ( -
-
- Register Delegation - on Accout 1 / 6gk...k7o -
- - This will lock your delegation amount. Amount is released after 14 days from the time you remove or - decrease your delegation. - -
-
- Transaction - Register delegation -
-
- Estimated transaction fee - 1,000.00 CCD -
-
- Transaction hash - - 4f84fg3gb6d9s9s3s1d46gg9grf7jmf9xc5c7s5x3vn80b8c6x5x4f84fg3gb6d9s9s3s1d4 - -
-
- -
+ + + {notice} + + + + + {state.payload.delegationTarget !== undefined && ( + + + + )} + {state.payload.stake !== undefined && ( + + + + )} + {state.payload.restakeEarnings !== undefined && ( + + + + )} + + + + + + + + ); } diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Register/RegisterDelegator.scss b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Stake/DelegatorStake.scss similarity index 89% rename from packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Register/RegisterDelegator.scss rename to packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Stake/DelegatorStake.scss index c6a9b0c1b..94d8debdb 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Register/RegisterDelegator.scss +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Stake/DelegatorStake.scss @@ -1,23 +1,12 @@ .register-delegator-container { display: flex; flex-direction: column; - height: 100%; + min-height: 100%; padding-bottom: rem(32px); .register-delegator { - &__title { - display: flex; - flex-direction: column; - align-items: baseline; - justify-content: space-between; - margin-bottom: rem(16px); - - .capture__main_small { - padding-left: rem(6px); - } - } - &__token-card { + margin-top: rem(16px); background: $gradient-card-bg; border-radius: rem(16px); padding: rem(20px) rem(16px); diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Stake/DelegatorStake.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Stake/DelegatorStake.tsx new file mode 100644 index 000000000..ebbd4b8ab --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Stake/DelegatorStake.tsx @@ -0,0 +1,126 @@ +import React, { useMemo } from 'react'; +import { UseFormReturn } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { useAtomValue } from 'jotai'; +import { useAsyncMemo } from 'wallet-common-helpers'; +import { AccountTransactionType, DelegationTargetType } from '@concordium/web-sdk'; + +import Button from '@popup/popupX/shared/Button'; +import FormToggleCheckbox from '@popup/popupX/shared/Form/ToggleCheckbox'; +import Page from '@popup/popupX/shared/Page'; +import Form, { useForm } from '@popup/popupX/shared/Form'; +import TokenAmount, { AmountForm } from '@popup/popupX/shared/Form/TokenAmount'; +import { useAccountInfo } from '@popup/shared/AccountInfoListenerContext/AccountInfoListenerContext'; +import { displayNameAndSplitAddress, useSelectedCredential } from '@popup/shared/utils/account-helpers'; +import { formatTokenAmount } from '@popup/popupX/shared/utils/helpers'; +import { CCD_METADATA } from '@shared/constants/token-metadata'; +import { grpcClientAtom } from '@popup/store/settings'; +import Text from '@popup/popupX/shared/Text'; +import { useGetTransactionFee } from '@popup/shared/utils/transaction-helpers'; + +import { DelegationTypeForm, DelegatorStakeForm, configureDelegatorPayloadFromForm } from '../util'; + +type PoolInfoProps = { + /** The validator pool ID to show information for */ + validatorId: bigint; +}; + +function PoolInfo({ validatorId }: PoolInfoProps) { + const { t } = useTranslation('x', { keyPrefix: 'earn.delegator.stake' }); + const client = useAtomValue(grpcClientAtom); + const poolStatus = useAsyncMemo(async () => client.getPoolInfo(validatorId), undefined, []); + + if (poolStatus?.bakerEquityCapital === undefined) { + return null; + } + + const poolStake = poolStatus.bakerEquityCapital.microCcdAmount + poolStatus.delegatedCapital!.microCcdAmount; + + return ( +
+
+ {t('poolStake.label')} + + {t('poolStake.value', { amount: formatTokenAmount(poolStake, CCD_METADATA.decimals, 2) })} + +
+
+ {t('poolCap.label')} + + {t('poolCap.value', { + amount: formatTokenAmount( + poolStatus.delegatedCapitalCap!.microCcdAmount, + CCD_METADATA.decimals, + 2 + ), + })} + +
+
+ ); +} + +type Props = { + /** The title for the configuriation step */ + title: string; + /** The initial values of the step, if any */ + initialValues?: DelegatorStakeForm; + target: DelegationTypeForm; + /** The submit handler triggered when submitting the form in the step */ + onSubmit(values: DelegatorStakeForm): void; +}; + +export default function DelegatorStake({ title, target, initialValues, onSubmit }: Props) { + const { t } = useTranslation('x', { keyPrefix: 'earn.delegator.stake' }); + const form = useForm({ + defaultValues: initialValues ?? { amount: '0.00', redelegate: true }, + }); + const submit = form.handleSubmit(onSubmit); + const selectedCred = useSelectedCredential(); + const selectedAccountInfo = useAccountInfo(selectedCred); + const getCost = useGetTransactionFee(AccountTransactionType.ConfigureDelegation); + const fee = useMemo( + () => getCost(configureDelegatorPayloadFromForm({ target, stake: { amount: '0', redelegate: true } })), // Use dummy values, as it does not matter when calculating transaction cost + [target, getCost] + ); + + if (selectedAccountInfo === undefined || selectedCred === undefined || fee === undefined) { + return null; + } + + return ( + + + + {t('selectedAccount', { account: displayNameAndSplitAddress(selectedCred) })} + +
+ {(f) => ( + <> + } + ccdBalance="total" + /> + {target.type === DelegationTargetType.Baker && ( + + )} +
+
+ {t('redelegate.label')} + +
+ {t('redelegate.description')} +
+ + )} + + + + +
+ ); +} diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Stake/index.ts b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Stake/index.ts new file mode 100644 index 000000000..e39a2bee3 --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Stake/index.ts @@ -0,0 +1 @@ +export { default } from './DelegatorStake'; diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/TransactionFlow/TransactionFlow.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/TransactionFlow/TransactionFlow.tsx new file mode 100644 index 000000000..a055d8d9c --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/TransactionFlow/TransactionFlow.tsx @@ -0,0 +1,56 @@ +import React, { useCallback } from 'react'; +import { Location, Navigate, useLocation, useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; + +import { absoluteRoutes } from '@popup/popupX/constants/routes'; +import MultiStepForm from '@popup/shared/MultiStepForm'; +import DelegatorStake from '../Stake'; +import DelegatorType from '../Type'; +import { configureDelegatorPayloadFromForm, type DelegatorForm } from '../util'; +import { DelegationResultLocationState } from '../Result/DelegationResult'; + +export default function DelegatorTransactionFlow() { + const { state: initialValues, pathname } = useLocation() as Location & { state: DelegatorForm | undefined }; + const nav = useNavigate(); + const { t } = useTranslation('x', { keyPrefix: 'earn.delegator.register' }); + + const handleDone = useCallback( + (values: DelegatorForm) => { + const payload = configureDelegatorPayloadFromForm(values); + + nav(pathname, { replace: true, state: values }); // Override current router entry with stateful version + + const submitDelegatorState: DelegationResultLocationState = { payload, type: 'register' }; + nav(absoluteRoutes.settings.earn.delegator.submit.path, { state: submitDelegatorState }); + }, + [pathname] + ); + + return ( + onDone={handleDone} initialValues={initialValues}> + {{ + target: { + render: (initial, onNext) => ( + + ), + }, + stake: { + render: (initial, onNext, form) => { + if (form.target === undefined) { + return ; + } + + return ( + + ); + }, + }, + }} + + ); +} diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/TransactionFlow/index.ts b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/TransactionFlow/index.ts new file mode 100644 index 000000000..63d943bbe --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/TransactionFlow/index.ts @@ -0,0 +1 @@ +export { default } from './TransactionFlow'; diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Type/DelegationType.scss b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Type/DelegationType.scss index d716fe95f..f687044ae 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Type/DelegationType.scss +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Type/DelegationType.scss @@ -1,7 +1,7 @@ .delegation-type-container { display: flex; flex-direction: column; - height: 100%; + min-height: 100%; padding-bottom: rem(32px); .delegation-type { diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Type/DelegationType.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Type/DelegationType.tsx index 76d92be93..03a3f8cef 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Type/DelegationType.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Type/DelegationType.tsx @@ -1,26 +1,110 @@ import React from 'react'; -import Radio from '@popup/popupX/shared/Form/Radios'; +import { FormRadio } from '@popup/popupX/shared/Form/Radios'; +import { Trans, useTranslation } from 'react-i18next'; +import { Validate } from 'react-hook-form'; +import Page from '@popup/popupX/shared/Page'; +import ExternalLink from '@popup/popupX/shared/ExternalLink'; +import { DelegationTargetType, OpenStatusText } from '@concordium/web-sdk'; +import Form, { useForm } from '@popup/popupX/shared/Form'; import Button from '@popup/popupX/shared/Button'; +import FormInput from '@popup/popupX/shared/Form/Input/Input'; +import { useAtomValue } from 'jotai'; +import { grpcClientAtom, networkConfigurationAtom } from '@popup/store/settings'; +import { DelegationTypeForm } from '../util'; + +type Props = { + title: string; + /** The initial values delegation configuration target step */ + initialValues?: DelegationTypeForm; + /** The submit handler triggered when submitting the form in the step */ + onSubmit(values: DelegationTypeForm): void; +}; + +export default function DelegationType({ initialValues, onSubmit, title }: Props) { + const network = useAtomValue(networkConfigurationAtom); + const client = useAtomValue(grpcClientAtom); + const { t } = useTranslation('x', { keyPrefix: 'earn.delegator.target' }); + + const form = useForm({ + defaultValues: initialValues ?? { type: DelegationTargetType.PassiveDelegation }, + }); + const submit = form.handleSubmit(onSubmit); + const target = form.watch('type'); + + const validateBakerId: Validate = async (value) => { + try { + const bakerId = BigInt(value!); // Unwrap is safe here, as it will always be defined. + const poolStatus = await client.getPoolInfo(bakerId); + + if (poolStatus.poolInfo?.openStatus !== OpenStatusText.OpenForAll) { + return t('inputValidatorId.errorClosed'); + } + return true; + } catch { + return t('inputValidatorId.errorNotValidator'); + } + }; -export default function DelegationType() { return ( -
-
- Register Delegation -
- - You can delegate to an open pool of your choice, or you can stake using passive delegation. - -
- - -
+ + + {t('description')} + className="delegation-type__select-form" formMethods={form} onSubmit={onSubmit}> + {(f) => ( + <> + + + {target === DelegationTargetType.Baker && ( + + )} + + )} + - Passive delegation is an alternative to delegation to a specific baker pool that has lower rewards. With - passive delegation you do not have to worry about the uptime or quality of a baker node. + {target === DelegationTargetType.PassiveDelegation ? ( + + ), + }} + /> + ) : ( + , + }} + /> + )} - For more info you can visit developer.concordium.software - -
+ + + +
); } diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Type/index.ts b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Type/index.ts index 9ae73aa32..49f1466c9 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Type/index.ts +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Type/index.ts @@ -1 +1 @@ -export { default as DelegationType } from './DelegationType'; +export { default } from './DelegationType'; diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/util.ts b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/util.ts new file mode 100644 index 000000000..ae6f84043 --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/util.ts @@ -0,0 +1,43 @@ +import { ConfigureDelegationPayload, DelegationTarget, DelegationTargetType } from '@concordium/web-sdk'; +import { AmountForm } from '@popup/popupX/shared/Form/TokenAmount'; +import { parseCcdAmount } from '@popup/popupX/shared/utils/helpers'; + +/** Describes the form values for configuring the delegation target of a delegation transaction */ +export type DelegationTypeForm = { + /** The target for the delegation */ + type: DelegationTargetType; + /** The target baker ID - only relevant for target = {@linkcode DelegationTargetType.Baker} */ + bakerId?: string; +}; + +/** The form values for delegator stake configuration step */ +export type DelegatorStakeForm = AmountForm & { + /** Whether to add rewards to the stake or not */ + redelegate: boolean; +}; + +/** Represents the form data for a configure delegator transaction. */ +export type DelegatorForm = { + /** The delegation target configuration */ + target: DelegationTypeForm; + /** The delegation stake configuration */ + stake: DelegatorStakeForm; +}; + +/** Constructs a {@linkcode ConfigureDelegationPayload} from the corresponding {@linkcode DelegatorForm} */ +export function configureDelegatorPayloadFromForm(values: DelegatorForm): ConfigureDelegationPayload { + let delegationTarget: DelegationTarget; + if (values.target.type === DelegationTargetType.PassiveDelegation) { + delegationTarget = { delegateType: DelegationTargetType.PassiveDelegation }; + } else if (values.target.bakerId === undefined) { + throw new Error('Expected bakerId to be defined'); + } else { + delegationTarget = { delegateType: DelegationTargetType.Baker, bakerId: BigInt(values.target.bakerId) }; + } + + return { + restakeEarnings: values.stake.redelegate, + stake: parseCcdAmount(values.stake.amount), + delegationTarget, + }; +} diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/EarningRewards.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/EarningRewards.tsx index 55c348d4c..c6cf741bb 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/EarningRewards.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/EarningRewards.tsx @@ -8,6 +8,7 @@ import { displayAsCcd } from 'wallet-common-helpers'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { absoluteRoutes } from '@popup/popupX/constants/routes'; +import Text from '@popup/popupX/shared/Text'; export default function EarningRewards() { const { t } = useTranslation('x', { keyPrefix: 'earn.root' }); @@ -24,11 +25,11 @@ export default function EarningRewards() {
- {t('validatorTitle')} - + {t('validatorTitle')} + {t('validatorDescription', { amount: displayAsCcd(bakingThreshold, false) })} - - + +
{t('validatorAction')} @@ -36,9 +37,9 @@ export default function EarningRewards() {
- {t('delegationTitle')} - {t('delegationDescription')} - + {t('delegationTitle')} + {t('delegationDescription')} +
{t('delegationAction')} @@ -49,7 +50,7 @@ export default function EarningRewards() {
- {t('note')} + {t('note')}
diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/i18n/en.ts b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/i18n/en.ts index 3f991dffe..d54eec226 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/i18n/en.ts +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/i18n/en.ts @@ -37,6 +37,69 @@ const t = { body: 'When you make a delegation to either type of pool, your delegation amount will be locked on your account.\n\nThis means that you cannot use the amount for anything while it is still locked in for delegation.\n\nIf you decrease your delegation amount or stop the delegation altogether, the amount will still be locked for a cool-down period.\n\nAs transactions cost a fee, it is important to take into consideration that you will need some unlocked funds on your public balance to pay the fee for unlocking your delegation amount again.', }, }, + register: { + title: 'Register delegation', + notice: 'This will lock your delegation amount. Amount is released after {{cooldown}} days from the time you remove or decrease your delegation.', + }, + update: { title: 'Update delegation' }, + target: { + description: 'You can delegate to an open pool of your choice, or you can stake using passive delegation.', + radioValidatorLabel: 'Validator', + radioPassiveLabel: 'Passive', + inputValidatorId: { + label: 'Enter validator pool ID', + errorRequired: 'Please specify a validator ID', + errorMin: 'Validator ID cannot be negative', + errorClosed: 'The validator pool is not open for delegation', + errorNotValidator: 'The specified ID is not a validator pool', + }, + validatorDelegationDescription: + 'If you don’t already know which validator pool you want to delegate an amount to, you can look for on <1>here.', + passiveDelegationDescription: + 'Passive delegation is an alternative to delegation to a specific validator pool that has lower rewards. With passive delegation you do not have to worry about the uptime or quality of a baker node.\nFor more info you can visit <1>developer.concordium.software', + buttonContinue: 'Continue', + }, + stake: { + selectedAccount: 'on {{account}}', + token: { + label: 'Token', + value: 'CCD', + balance: '{{balance}} CCD available', + }, + inputAmount: { + label: 'Amount', + buttonMax: 'Stake max.', + }, + fee: { + label: 'Estimated transaction fee:', + value: '{{amount}} CCD', + }, + poolStake: { + label: 'Current pool', + value: '{{amount}} CCD', + }, + poolCap: { + label: 'Pool limit', + value: '{{amount}} CCD', + }, + redelegate: { + label: 'Restake rewards', + description: 'I want to automatically add my delegation rewards to my delegation amount.', + }, + buttonContinue: 'Continue', + }, + submit: { + sender: { label: 'Sender' }, + target: { label: 'Target', passive: 'Passive delegation', validator: 'Validator {{id}}' }, + amount: { label: 'Delegation amount' }, + redelegate: { + label: 'Rewards will be', + delegation: 'Added to delegation amount', + public: 'Added to public balance', + }, + fee: { label: 'Estimated transaction fee' }, + button: 'Submit delegation', + }, }, validator: { intro: { diff --git a/packages/browser-wallet/src/popup/popupX/pages/SendFunds/SendConfirm.tsx b/packages/browser-wallet/src/popup/popupX/pages/SendFunds/SendConfirm.tsx index d4fd76fa3..a930e37dd 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/SendFunds/SendConfirm.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/SendFunds/SendConfirm.tsx @@ -2,11 +2,15 @@ import React from 'react'; import Arrow from '@assets/svgX/arrow-right.svg'; import Button from '@popup/popupX/shared/Button'; import { useNavigate } from 'react-router-dom'; -import { relativeRoutes } from '@popup/popupX/constants/routes'; +import { submittedTransactionRoute } from '@popup/popupX/constants/routes'; +import { TransactionHash } from '@concordium/web-sdk'; export default function SendConfirm() { const nav = useNavigate(); - const navToConfirmed = () => nav(relativeRoutes.home.send.confirmation.confirmed.path); + // TODO: + // 1. Submit transaction (see `Delegator/TransactionFlow`) + // 2. Pass the transaction hash to the route function below + const navToConfirmed = () => nav(submittedTransactionRoute(TransactionHash.fromHexString('..'))); return (
diff --git a/packages/browser-wallet/src/popup/popupX/pages/SendFunds/SendSuccess.tsx b/packages/browser-wallet/src/popup/popupX/pages/SendFunds/SendSuccess.tsx deleted file mode 100644 index 90ca7ca17..000000000 --- a/packages/browser-wallet/src/popup/popupX/pages/SendFunds/SendSuccess.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; -import CheckCircle from '@assets/svgX/check-circle.svg'; -import Arrow from '@assets/svgX/arrow-right.svg'; -import Button from '@popup/popupX/shared/Button'; -import { useNavigate } from 'react-router-dom'; - -export default function SendSuccess() { - const nav = useNavigate(); - return ( -
-
- - You’ve sent - 12,600.00 - CCD -
-
- Transaction details - -
- nav('../../../../home')} label="Return to Account" /> -
- ); -} diff --git a/packages/browser-wallet/src/popup/popupX/pages/SendFunds/index.ts b/packages/browser-wallet/src/popup/popupX/pages/SendFunds/index.ts index e45486115..eb40e6ac0 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/SendFunds/index.ts +++ b/packages/browser-wallet/src/popup/popupX/pages/SendFunds/index.ts @@ -1,3 +1,2 @@ export { default as SendFunds } from './SendFunds'; export { default as SendConfirm } from './SendConfirm'; -export { default as SendSuccess } from './SendSuccess'; diff --git a/packages/browser-wallet/src/popup/popupX/pages/SubmittedTransaction/SubmittedTransaction.scss b/packages/browser-wallet/src/popup/popupX/pages/SubmittedTransaction/SubmittedTransaction.scss new file mode 100644 index 000000000..1406b5890 --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/pages/SubmittedTransaction/SubmittedTransaction.scss @@ -0,0 +1,33 @@ +.submitted-tx { + padding-top: rem(50px); + &__card { + align-items: center; + padding: rem(24px) !important; + + .capture__main_small { + color: $color-white; + } + + .heading_large { + margin: rem(8px) 0; + } + + svg, .loader-x { + margin-bottom: rem(30px); + } + } + + &__details-btn { + margin-top: rem(24px); + } + + &__failed-icon { + width: rem(48px); + height: rem(48px); + margin-bottom: rem(18px) !important; + + & path { + fill: $color-red-attention !important; + } + } +} diff --git a/packages/browser-wallet/src/popup/popupX/pages/SubmittedTransaction/SubmittedTransaction.tsx b/packages/browser-wallet/src/popup/popupX/pages/SubmittedTransaction/SubmittedTransaction.tsx new file mode 100644 index 000000000..1e125d87d --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/pages/SubmittedTransaction/SubmittedTransaction.tsx @@ -0,0 +1,191 @@ +import React from 'react'; +import { Navigate, useNavigate, useParams } from 'react-router-dom'; + +import CheckCircle from '@assets/svgX/check-circle.svg'; +import Cross from '@assets/svgX/close.svg'; +import Arrow from '@assets/svgX/arrow-right.svg'; +import Button from '@popup/popupX/shared/Button'; +import Page from '@popup/popupX/shared/Page'; +import { absoluteRoutes, transactionDetailsRoute } from '@popup/popupX/constants/routes'; +import Card from '@popup/popupX/shared/Card'; +import { useAsyncMemo } from 'wallet-common-helpers'; +import { + AccountTransactionSummary, + HexString, + TransactionHash, + TransactionSummaryType, + isRejectTransaction, + isSuccessTransaction, + TransactionKindString, + FailedTransactionSummary, + BaseAccountTransactionSummary, + TransactionEventTag, + DelegationStakeChangedEvent, + DelegatorEvent, + ConfigureDelegationSummary, +} from '@concordium/web-sdk'; +import { useAtomValue } from 'jotai'; +import { grpcClientAtom } from '@popup/store/settings'; +import { formatCcdAmount } from '@popup/popupX/shared/utils/helpers'; +import { LoaderInline } from '@popup/popupX/shared/Loader'; +import { useTranslation } from 'react-i18next'; +import Text from '@popup/popupX/shared/Text'; + +const TX_TIMEOUT = 60 * 1000; // 1 minute + +type DelegationBodyProps = BaseAccountTransactionSummary & ConfigureDelegationSummary; + +function DelegationBody({ events }: DelegationBodyProps) { + const { t } = useTranslation('x', { keyPrefix: 'submittedTransaction.success.configureDelegation' }); + const stakeChange = events.find((e) => + [TransactionEventTag.DelegationStakeIncreased, TransactionEventTag.DelegationStakeDecreased].includes(e.tag) + ) as DelegationStakeChangedEvent | undefined; + + if (stakeChange !== undefined) { + return ( + <> + {t('changeStake')} + {formatCcdAmount(stakeChange.newStake)} + CCD + + ); + } + + const removal = events.find((e) => [TransactionEventTag.DelegationRemoved].includes(e.tag)) as + | DelegatorEvent + | undefined; + + if (removal !== undefined) { + return {t('removed')}; + } + + return {t('updated')}; +} + +type SuccessSummary = Exclude; +type FailureSummary = BaseAccountTransactionSummary & FailedTransactionSummary; + +type Status = + | { type: 'success'; summary: SuccessSummary } + | { type: 'failure'; summary: FailureSummary } + | { type: 'error'; message: string }; + +type SuccessProps = { + tx: SuccessSummary; +}; + +function Success({ tx }: SuccessProps) { + const { t } = useTranslation('x', { keyPrefix: 'submittedTransaction' }); + return ( + <> + + {tx.transactionType === TransactionKindString.Transfer && ( + <> + {t('success.transfer.label')} + {formatCcdAmount(tx.transfer.amount)} + CCD + + )} + {tx.transactionType === TransactionKindString.ConfigureDelegation && } + + ); +} +type FailureProps = { + message: string; +}; + +function Failure({ message }: FailureProps) { + return ( + <> + + {message} + + ); +} + +function Finalizing() { + const { t } = useTranslation('x', { keyPrefix: 'submittedTransaction' }); + return ( + <> + + {t('pending.label')} + + ); +} + +export type SubmittedTransactionParams = { + /** The transaction to show the status for */ + transactionHash: HexString; +}; + +/** Component displaying the status of a submitted transaction. Must be given a transaction hash as a route parameter */ +export default function SubmittedTransaction() { + const { t } = useTranslation('x', { keyPrefix: 'submittedTransaction' }); + const { transactionHash } = useParams(); + const nav = useNavigate(); + const grpc = useAtomValue(grpcClientAtom); + + const status = useAsyncMemo( + async (): Promise => { + if (transactionHash === undefined) { + throw new Error('Transaction not specified in url'); + } + + try { + const outcome = await grpc.waitForTransactionFinalization( + TransactionHash.fromHexString(transactionHash), + TX_TIMEOUT + ); + + if (isRejectTransaction(outcome.summary)) { + return { type: 'failure', summary: outcome.summary }; + } + if ( + isSuccessTransaction(outcome.summary) && + outcome.summary.type === TransactionSummaryType.AccountTransaction + ) { + return { type: 'success', summary: outcome.summary }; + } + + throw Error('Unexpected transaction type'); + } catch (e) { + return { type: 'error', message: (e as Error).message ?? `${e}` }; + } + }, + undefined, + [transactionHash, grpc] + ); + + if (transactionHash === undefined) { + return ; + } + + return ( + + + {status === undefined && } + {status?.type === 'success' && } + {status?.type === 'failure' && ( + + )} + {status?.type === 'error' && } + + {status?.type !== undefined && status.type !== 'error' && ( + } + label={t('detailsButton')} + className="submitted-tx__details-btn" + leftLabel + onClick={() => nav(transactionDetailsRoute(status.summary.sender, status.summary.hash))} + /> + )} + + nav(absoluteRoutes.home.path)} + label={t('continue')} + /> + + + ); +} diff --git a/packages/browser-wallet/src/popup/popupX/pages/SubmittedTransaction/i18n/en.ts b/packages/browser-wallet/src/popup/popupX/pages/SubmittedTransaction/i18n/en.ts new file mode 100644 index 000000000..8d1b514a9 --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/pages/SubmittedTransaction/i18n/en.ts @@ -0,0 +1,22 @@ +const t = { + success: { + configureDelegation: { + changeStake: "You've delegated", + removed: "You've removed your delegated stake", + updated: "You've updated your delegation settings", + }, + transfer: { + label: "You've sent", + }, + }, + pending: { + label: 'Transaction in progress', + }, + failure: { + label: 'The transaction failed: {{reason}}', + }, + detailsButton: 'Transaction details', + continue: 'Return to account', +}; + +export default t; diff --git a/packages/browser-wallet/src/popup/popupX/pages/SubmittedTransaction/index.ts b/packages/browser-wallet/src/popup/popupX/pages/SubmittedTransaction/index.ts new file mode 100644 index 000000000..d21a93a70 --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/pages/SubmittedTransaction/index.ts @@ -0,0 +1 @@ +export { default } from './SubmittedTransaction'; diff --git a/packages/browser-wallet/src/popup/popupX/shared/Button/Button.scss b/packages/browser-wallet/src/popup/popupX/shared/Button/Button.scss index 935484ac3..f402b68e8 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Button/Button.scss +++ b/packages/browser-wallet/src/popup/popupX/shared/Button/Button.scss @@ -14,7 +14,6 @@ cursor: pointer; width: 100%; padding: rem(16px) 0; - margin-top: auto; border-radius: rem(32px); background: $color-white; diff --git a/packages/browser-wallet/src/popup/popupX/shared/Card/Card.tsx b/packages/browser-wallet/src/popup/popupX/shared/Card/Card.tsx index 2936b5880..ad01b33ff 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Card/Card.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Card/Card.tsx @@ -4,23 +4,34 @@ import Text from '@popup/popupX/shared/Text'; type CardType = 'gradient' | 'transparent' | 'grey'; -function CardRoot({ - type = 'grey', - className, - children, -}: { +type CardRootProps = { type?: CardType; className?: string; children: ReactNode; -}) { +}; + +function CardRoot({ type = 'grey', className, children }: CardRootProps) { return
{children}
; } -function CardRow({ className, children }: { className?: string; children: ReactNode }) { +type CardRowProps = { + className?: string; + children: ReactNode; +}; + +function CardRow({ className, children }: CardRowProps) { return
{children}
; } -function CardRowDetails({ title, value, className }: { title?: string; value?: string; className?: string }) { +type CardRowDetailsProps = { + /** Title of the card row detail */ + title?: string; + /** Value of the card row detail */ + value?: string; + className?: string; +}; + +function CardRowDetails({ title, value, className }: CardRowDetailsProps) { return (
{title} diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/Form.scss b/packages/browser-wallet/src/popup/popupX/shared/Form/Form.scss index 39ba6da5d..fc6bb9914 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/Form.scss +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/Form.scss @@ -14,6 +14,10 @@ position: relative; display: block; + &:not(:last-of-type) { + margin-bottom: rem(8px); + } + &__field { background-color: $color-input-bg; color: $color-white; @@ -21,7 +25,6 @@ border: rem(1px) solid $color-grey-4; border-radius: rem(12px); padding: rem(28px) rem(14px) rem(12px); - margin-bottom: rem(8px); width: 100%; outline: none; font-size: rem(14px); @@ -33,6 +36,13 @@ &:focus { border-color: $color-mineral-2; } + + // Hide the buttons to increment/decrement + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + appearance: none; + margin: 0; + } } &__label { @@ -40,6 +50,7 @@ left: rem(14px); top: rem(8px); color: $color-mineral-3; + opacity: 0.5; } @include when-valid { @@ -162,20 +173,23 @@ $handle-size: rem(20px); .form-radios-x { &__radio { + cursor: pointer; display: flex; align-items: center; gap: rem(16px); background-color: rgba($color-grey-3, 0.3); padding: rem(22px); border-radius: rem(8px); - margin-bottom: rem(4px); + + &:not(:last-of-type) { + margin-bottom: rem(4px); + } input { display: none; } .checkmark { - cursor: pointer; height: rem(20px); width: rem(20px); background-color: $color-grey-2; diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/Form.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/Form.tsx index 065793425..5ec20f5ad 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/Form.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/Form.tsx @@ -9,7 +9,7 @@ import { useForm as useFormLib, FormProvider, } from 'react-hook-form'; -import { ClassName, Id } from 'wallet-common-helpers'; +import { ClassName, Id, noOp } from 'wallet-common-helpers'; const useFormDefaults: Pick = { mode: 'onTouched', @@ -26,9 +26,9 @@ export const useForm = ( type FormProps = Id & ClassName & { /** - * Submit handler, receiving the values of the form as arg. + * Submit handler, receiving the values of the form as arg. If left undefined, form submission is expected to be handled explicitly elsewhere. */ - onSubmit: SubmitHandler; + onSubmit?: SubmitHandler; /** * Optional form methods from 'react-hook-form' useForm, if form methods need to be accessed outside of the form. */ @@ -75,9 +75,11 @@ const Form = forwardRef( const internal = useForm({ defaultValues }); const methods = external ?? internal; + const submit = () => (onSubmit === undefined ? noOp : methods.handleSubmit(onSubmit)); + return ( -
+ {children(methods)}
diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/Radios/Radios.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/Radios/Radios.tsx index da2fbfb89..accf7ab6d 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/Radios/Radios.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/Radios/Radios.tsx @@ -1,5 +1,6 @@ import { RequiredControlledFieldProps } from '@popup/shared/Form/common/types'; -import React, { InputHTMLAttributes } from 'react'; +import React, { InputHTMLAttributes, forwardRef } from 'react'; +import { makeUncontrolled } from '../common/utils'; type RadioProps = Omit & Pick, 'onBlur' | 'onChange' | 'checked'> & { @@ -7,12 +8,15 @@ type RadioProps = Omit & id: string; }; -export default function Radio({ label, id, ...inputProps }: RadioProps) { +const Radio = forwardRef(({ id, label, ...inputProps }, ref) => { return ( ); -} +}); + +export default Radio; +export const FormRadio = makeUncontrolled(Radio); diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/Radios/index.ts b/packages/browser-wallet/src/popup/popupX/shared/Form/Radios/index.ts index 441fd6190..35a6fd975 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/Radios/index.ts +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/Radios/index.ts @@ -1 +1 @@ -export { default } from './Radios'; +export { default, FormRadio } from './Radios'; diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx index 79ed84ea9..59bef513e 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx @@ -15,20 +15,29 @@ const tokenAddressEq = (a: CIS2.TokenAddress | null, b: CIS2.TokenAddress | null return a === b; }; +type CcdBalanceType = 'total' | 'available'; + const balanceAtomFamily = atomFamily( - ([account, tokenAddress]: [AccountInfo, CIS2.TokenAddress | null]) => { + ([account, ccdBalance, tokenAddress]: [AccountInfo, CcdBalanceType, CIS2.TokenAddress | null]) => { if (tokenAddress === null) { - return atom(account.accountAvailableBalance.microCcdAmount); + return atom( + ccdBalance === 'available' + ? account.accountAvailableBalance.microCcdAmount + : account.accountAmount.microCcdAmount + ); } const tokens = contractBalancesFamily(account.accountAddress.address, tokenAddress.contract.index.toString()); return selectAtom(tokens, (ts) => ts[tokenAddress.id]); }, - ([aa, ta], [ab, tb]) => AccountAddress.equals(aa.accountAddress, ab.accountAddress) && tokenAddressEq(ta, tb) + ([aa, ba, ta], [ab, bb, tb]) => + AccountAddress.equals(aa.accountAddress, ab.accountAddress) && ba === bb && tokenAddressEq(ta, tb) ); type Props = Omit & { /** The account info of the account to take the amount from */ accountInfo: AccountInfo; + /** The ccd balance to use. Defaults to 'available' */ + ccdBalance?: CcdBalanceType; }; /** @@ -70,10 +79,10 @@ type Props = Omit * address={{ id: '', contract: ContractAddress.create(1) }} * /> */ -export default function TokenAmount({ accountInfo, ...props }: Props) { +export default function TokenAmount({ accountInfo, ccdBalance = 'available', ...props }: Props) { const tokenInfo = useTokenInfo(accountInfo.accountAddress); const [tokenAddress, setTokenAddress] = useState(null); - const tokenBalance = useAtomValue(balanceAtomFamily([accountInfo, tokenAddress])); + const tokenBalance = useAtomValue(balanceAtomFamily([accountInfo, ccdBalance, tokenAddress])); if (tokenInfo.loading) { return null; diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/View.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/View.tsx index 20618d4fe..16a06991f 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/View.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/View.tsx @@ -12,6 +12,7 @@ import ConcordiumLogo from '@assets/svgX/concordium-logo.svg'; import { validateAccountAddress, validateTransferAmount } from '@popup/shared/utils/transaction-helpers'; import Img, { DEFAULT_FAILED } from '@popup/shared/Img'; import { displayAsCcd } from 'wallet-common-helpers'; +import Text from '@popup/popupX/shared/Text'; import { RequiredUncontrolledFieldProps } from '../common/types'; import { makeUncontrolled } from '../common/utils'; import Button from '../../Button'; @@ -160,7 +161,7 @@ function TokenPicker({ )}
{token.icon}
- {token.name} + {token.name} {canSelect && } {selectedTokenBalance !== undefined && ( @@ -394,9 +395,7 @@ export default function TokenAmountView(props: TokenAmountViewProps) { {props.form.formState.errors.amount?.message} - - {t('form.tokenAmount.amount.fee', { fee: displayAsCcd(fee, false, true) })} - + {t('form.tokenAmount.amount.fee', { fee: displayAsCcd(fee, false, true) })}
{props.receiver === true && (
diff --git a/packages/browser-wallet/src/popup/popupX/shared/Loader/Loader.tsx b/packages/browser-wallet/src/popup/popupX/shared/Loader/Loader.tsx index 35a4c7341..607cdb93c 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Loader/Loader.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Loader/Loader.tsx @@ -1,9 +1,15 @@ +import clsx from 'clsx'; import React from 'react'; +import { ClassName } from 'wallet-common-helpers'; + +export function LoaderInline({ className }: ClassName) { + return ; +} export default function Loader() { return (
- +
); } diff --git a/packages/browser-wallet/src/popup/popupX/shared/Loader/index.ts b/packages/browser-wallet/src/popup/popupX/shared/Loader/index.ts index 348c02a98..70ad52a7b 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Loader/index.ts +++ b/packages/browser-wallet/src/popup/popupX/shared/Loader/index.ts @@ -1 +1 @@ -export { default } from './Loader'; +export { default, LoaderInline } from './Loader'; diff --git a/packages/browser-wallet/src/popup/popupX/shared/Page/Page.tsx b/packages/browser-wallet/src/popup/popupX/shared/Page/Page.tsx index 3c716c58d..104dd29fd 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Page/Page.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Page/Page.tsx @@ -1,8 +1,9 @@ import React, { ReactNode } from 'react'; import Text from '@popup/popupX/shared/Text'; +import clsx from 'clsx'; -function PageRoot({ className, children }: { className: string; children: ReactNode }) { - return
{children}
; +function PageRoot({ className, children }: { className?: string; children: ReactNode }) { + return
{children}
; } function PageTop({ heading, children }: { heading?: ReactNode; children?: ReactNode }) { diff --git a/packages/browser-wallet/src/popup/popupX/shared/utils/helpers.ts b/packages/browser-wallet/src/popup/popupX/shared/utils/helpers.ts index 43906130e..08658ef4c 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/utils/helpers.ts +++ b/packages/browser-wallet/src/popup/popupX/shared/utils/helpers.ts @@ -1,3 +1,5 @@ +import { CcdAmount } from '@concordium/web-sdk'; +import { CCD_METADATA } from '@shared/constants/token-metadata'; import { useLocation } from 'react-router-dom'; import { displayUrl } from '@popup/shared/utils/string-helpers'; @@ -12,7 +14,7 @@ export async function copyToClipboard(text: string): Promise { export const removeNumberGrouping = (amount: string) => amount.replace(/,/g, ''); /** Display a token amount with a number of decimals + number groupings (thousand separators) */ -export function formatTokenAmount(amount: bigint, decimals = 0, minDecimals = decimals) { +export function formatTokenAmount(amount: bigint, decimals = 0, minDecimals = 2) { const padded = amount.toString().padStart(decimals + 1, '0'); // Ensure the string length is minimum decimals + 1 characters. For CCD, this would mean minimum 7 characters long const integer = padded.slice(0, -decimals); const fraction = padded.slice(-decimals); @@ -37,6 +39,13 @@ export function parseTokenAmount(amount: string, decimals = 0): bigint { return BigInt(combined); } +/** {@linkcode formatTokenAmount} for CCD + 2 minimum decimals */ +export const formatCcdAmount = (amount: CcdAmount.Type) => + formatTokenAmount(amount.microCcdAmount, CCD_METADATA.decimals); +/** {@linkcode parseTokenAmount} for CCD */ +export const parseCcdAmount = (amount: string): CcdAmount.Type => + CcdAmount.fromMicroCcd(parseTokenAmount(amount, CCD_METADATA.decimals)); + export function useUrlDisplay() { const { state } = useLocation(); diff --git a/packages/browser-wallet/src/popup/popupX/shell/Routes.tsx b/packages/browser-wallet/src/popup/popupX/shell/Routes.tsx index 63d724046..406cbff90 100644 --- a/packages/browser-wallet/src/popup/popupX/shell/Routes.tsx +++ b/packages/browser-wallet/src/popup/popupX/shell/Routes.tsx @@ -3,7 +3,7 @@ import { Route, Routes as ReactRoutes } from 'react-router-dom'; import { relativeRoutes, routePrefix } from '@popup/popupX/constants/routes'; import MainLayout from '@popup/popupX/page-layouts/MainLayout'; import MainPage from '@popup/popupX/pages/MainPage'; -import { SendConfirm, SendFunds, SendSuccess } from '@popup/popupX/pages/SendFunds'; +import { SendConfirm, SendFunds } from '@popup/popupX/pages/SendFunds'; import ReceiveFunds from '@popup/popupX/pages/ReceiveFunds'; import TransactionLog from '@popup/popupX/pages/TransactionLog'; import TransactionDetails from '@popup/popupX/pages/TransactionDetails'; @@ -20,18 +20,18 @@ import { IdSubmitted, IdCardsInfo, RequestIdentity, SetupPassword, Welcome } fro import ConnectedSites from '@popup/popupX/pages/ConnectedSites'; import EarningRewards from '@popup/popupX/pages/EarningRewards'; import { BakerIntro } from '@popup/popupX/pages/EarningRewards/Baker/Intro'; -import { DelegatorIntro } from '@popup/popupX/pages/EarningRewards/Delegator/Intro'; import { RegisterBaker } from '@popup/popupX/pages/EarningRewards/Baker/Register'; import { OpenPool } from '@popup/popupX/pages/EarningRewards/Baker/OpenPool'; import { BakerKeys } from '@popup/popupX/pages/EarningRewards/Baker/BakerKeys'; -import DelegationType from '@popup/popupX/pages/EarningRewards/Delegator/Type/DelegationType'; import PrivateKey from '@popup/popupX/pages/PrivateKey'; import { RestoreIntro, RestoreResult } from '@popup/popupX/pages/Restore'; import { MessagePromptHandlersType } from '@popup/shared/utils/message-prompt-handlers'; import ConnectionRequest from '@popup/popupX/pages/prompts/ConnectionRequest'; import ExternalRequestLayout from '@popup/popupX/page-layouts/ExternalRequestLayout'; -import RegisterDelegator from '../pages/EarningRewards/Delegator/Register/RegisterDelegator'; -import DelegationResult from '../pages/EarningRewards/Delegator/Result/DelegationResult'; +import { DelegationResult } from '../pages/EarningRewards/Delegator/Result'; +import SubmittedTransaction from '../pages/SubmittedTransaction'; +import { DelegatorIntro } from '../pages/EarningRewards/Delegator/Intro'; +import DelegatorTransactionFlow from '../pages/EarningRewards/Delegator/TransactionFlow'; export default function Routes({ messagePromptHandlers }: { messagePromptHandlers: MessagePromptHandlersType }) { const { handleConnectionResponse } = messagePromptHandlers; @@ -51,10 +51,6 @@ export default function Routes({ messagePromptHandlers }: { messagePromptHandler } /> } /> - } - path={relativeRoutes.home.send.confirmation.confirmed.path} - /> } path={relativeRoutes.home.receive.path} /> @@ -67,6 +63,10 @@ export default function Routes({ messagePromptHandlers }: { messagePromptHandler } path={relativeRoutes.home.token.path} /> } path={`${relativeRoutes.home.token.path}/ccd`} /> + } + path={`${relativeRoutes.home.submittedTransaction.path}`} + /> } path={relativeRoutes.settings.path}> } path={relativeRoutes.settings.idCards.path} /> @@ -95,31 +95,37 @@ export default function Routes({ messagePromptHandlers }: { messagePromptHandler } path={relativeRoutes.settings.about.path} /> } /> - - } path={relativeRoutes.settings.earn.baker.intro.path} /> + + } path={relativeRoutes.settings.earn.validator.intro.path} /> } - path={relativeRoutes.settings.earn.baker.register.path} + path={relativeRoutes.settings.earn.validator.register.path} /> - } path={relativeRoutes.settings.earn.baker.openPool.path} /> - } path={relativeRoutes.settings.earn.baker.bakerKeys.path} /> + } path={relativeRoutes.settings.earn.validator.openPool.path} /> + } path={relativeRoutes.settings.earn.validator.keys.path} /> + + + } + /> + } + /> + } - path={relativeRoutes.settings.earn.delegator.intro.path} - /> - } - path={relativeRoutes.settings.earn.delegator.type.path} - /> - } - path={relativeRoutes.settings.earn.delegator.register.path} + element={} + path={`${relativeRoutes.settings.earn.delegator.update.path}/*`} /> } - path={relativeRoutes.settings.earn.delegator.result.path} + path={relativeRoutes.settings.earn.delegator.submit.path} /> diff --git a/packages/browser-wallet/src/popup/popupX/styles/_elements.scss b/packages/browser-wallet/src/popup/popupX/styles/_elements.scss index f952427ce..8307c0cd5 100644 --- a/packages/browser-wallet/src/popup/popupX/styles/_elements.scss +++ b/packages/browser-wallet/src/popup/popupX/styles/_elements.scss @@ -23,9 +23,10 @@ @import '../pages/EarningRewards/Baker/BakerKeys/BakerKeys'; @import '../pages/EarningRewards/Delegator/Intro/DelegatorIntro'; @import '../pages/EarningRewards/Delegator/Type/DelegationType'; -@import '../pages/EarningRewards/Delegator/Register/RegisterDelegator'; +@import '../pages/EarningRewards/Delegator/Stake/DelegatorStake'; @import '../pages/EarningRewards/Delegator/Result/DelegationResult'; @import '../pages/prompts/ConnectionRequest/ConnectionRequest'; +@import '../pages/SubmittedTransaction/SubmittedTransaction'; @import '../page-layouts/MainLayout'; @import '../page-layouts/ExternalRequestLayout/ExternalRequestLayout'; @import '../shared/Page/Page'; diff --git a/packages/browser-wallet/src/popup/shared/AccountInfoListenerContext/AccountInfoListenerContext.tsx b/packages/browser-wallet/src/popup/shared/AccountInfoListenerContext/AccountInfoListenerContext.tsx index 4cf76951a..5511c5168 100644 --- a/packages/browser-wallet/src/popup/shared/AccountInfoListenerContext/AccountInfoListenerContext.tsx +++ b/packages/browser-wallet/src/popup/shared/AccountInfoListenerContext/AccountInfoListenerContext.tsx @@ -1,5 +1,5 @@ import { useAtomValue, useSetAtom, useAtom, atom, WritableAtom } from 'jotai'; -import React, { createContext, ReactNode, useContext, useEffect, useMemo, useState } from 'react'; +import React, { createContext, ReactNode, useContext, useEffect, useState } from 'react'; import { AccountAddress, AccountInfo } from '@concordium/web-sdk'; import { useTranslation } from 'react-i18next'; import i18next from 'i18next'; @@ -98,23 +98,22 @@ export default function AccountInfoListenerContextProvider({ children }: Props) * * N.B. has to be used inside an AccountInfoEmitterContext. */ -export function useAccountInfo(account: WalletCredential): AccountInfo | undefined { +export function useAccountInfo(account: WalletCredential | undefined): AccountInfo | undefined { const accountInfoEmitter = useContext(AccountInfoListenerContext); - const [accountInfo, refreshAccountInfo] = useAtom(accountInfoFamily(account.address)); + const [accountInfo, refreshAccountInfo] = useAtom(accountInfoFamily(account?.address ?? '')); const { genesisHash } = useAtomValue(networkConfigurationAtom); - const address = useMemo(() => account.address, [account]); useEffect(() => { - if (!accountInfo && account.status === CreationStatus.Confirmed) { + if (!accountInfo && account?.status === CreationStatus.Confirmed) { refreshAccountInfo(); } - }, [genesisHash, accountInfo, account.status]); + }, [genesisHash, accountInfo, account?.status]); useEffect(() => { - if (account.status === CreationStatus.Confirmed && accountInfoEmitter) { - accountInfoEmitter.subscribe(address); + if (account?.status === CreationStatus.Confirmed && accountInfoEmitter) { + accountInfoEmitter.subscribe(account.address); return () => { - accountInfoEmitter.unsubscribe(address); + accountInfoEmitter.unsubscribe(account.address); }; } return noOp; @@ -125,10 +124,5 @@ export function useAccountInfo(account: WalletCredential): AccountInfo | undefin export function useSelectedAccountInfo() { const cred = useSelectedCredential(); - - if (cred === undefined) { - return undefined; - } - return useAccountInfo(cred); } diff --git a/packages/browser-wallet/src/popup/shared/MultiStepForm.tsx b/packages/browser-wallet/src/popup/shared/MultiStepForm.tsx index 42873c28b..d3668e7f6 100644 --- a/packages/browser-wallet/src/popup/shared/MultiStepForm.tsx +++ b/packages/browser-wallet/src/popup/shared/MultiStepForm.tsx @@ -92,8 +92,26 @@ export type MultiStepFormProps> = /** * A component for spanning forms over multiple pages. This component doesn't render any UI, but merely handles collecting data from the different steps and routing between the steps. + * The component uses the application router to go through a number of pages. As such it needs to be used in combination with a catch-all route, as seen in the example. * * @template F Type of the form as a whole. Each step in the form flow should correspond to a member on the type. + * @component + * @example + * type Values = { + * first: { a: string; b: number; }; + * second: { c: boolean; }; + * }; + * + * const Flow = () => > + * {{ + * first: { render: (initialValues, onNext) => }, + * second: { render: (initialValues, onNext) => }, + * }} + * + * + * + * } /> + * */ export default function MultiStepForm< // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/browser-wallet/src/popup/shared/utils/transaction-helpers.ts b/packages/browser-wallet/src/popup/shared/utils/transaction-helpers.ts index 352379c0d..ea8258e92 100644 --- a/packages/browser-wallet/src/popup/shared/utils/transaction-helpers.ts +++ b/packages/browser-wallet/src/popup/shared/utils/transaction-helpers.ts @@ -18,6 +18,9 @@ import { UpdateContractPayload, SimpleTransferWithMemoPayload, AccountInfoType, + ConfigureDelegationPayload, + getEnergyCost, + convertEnergyToMicroCcd, } from '@concordium/web-sdk'; import { isValidResolutionString, @@ -32,7 +35,9 @@ import i18n from '@popup/shell/i18n'; import { useAtomValue } from 'jotai'; import { selectedPendingTransactionsAtom } from '@popup/store/transactions'; import { DEFAULT_TRANSACTION_EXPIRY } from '@shared/constants/time'; +import { useCallback } from 'react'; import { BrowserWalletAccountTransaction, TransactionStatus } from './transaction-history-types'; +import { useBlockChainParameters } from '../BlockChainParametersProvider'; export function buildSimpleTransferPayload(recipient: string, amount: bigint): SimpleTransferPayload { return { @@ -247,3 +252,18 @@ export function getTransactionAmount(type: AccountTransactionType, payload: Acco return 0n; } } +/** Hook which exposes a function for getting the transaction fee for a given transaction type */ +export function useGetTransactionFee(type: AccountTransactionType) { + const cp = useBlockChainParameters(); + + return useCallback( + (payload: ConfigureDelegationPayload) => { + if (cp === undefined) { + return undefined; + } + const energy = getEnergyCost(type, payload); + return convertEnergyToMicroCcd(energy, cp); + }, + [cp, type] + ); +} diff --git a/packages/browser-wallet/src/popup/shell/Root.tsx b/packages/browser-wallet/src/popup/shell/Root.tsx index 36e175402..676844ac3 100644 --- a/packages/browser-wallet/src/popup/shell/Root.tsx +++ b/packages/browser-wallet/src/popup/shell/Root.tsx @@ -15,7 +15,7 @@ import AccountInfoListenerContext from '@popup/shared/AccountInfoListenerContext import './i18n'; import { mainnet } from '@shared/constants/networkConfiguration'; -import { routePrefix } from '@popup/popupX/constants/routes'; +import { routePrefix, absoluteRoutes } from '@popup/popupX/constants/routes'; import { MessagePromptHandlersType, useMessagePromptHandlers } from '@popup/shared/utils/message-prompt-handlers'; import Routes from './Routes'; import RoutesX from '../popupX/shell/Routes'; @@ -136,7 +136,11 @@ export default function Root() { return ( - + diff --git a/packages/browser-wallet/src/popup/shell/i18n/locales/en.ts b/packages/browser-wallet/src/popup/shell/i18n/locales/en.ts index 8b6dd2cef..8f906902c 100644 --- a/packages/browser-wallet/src/popup/shell/i18n/locales/en.ts +++ b/packages/browser-wallet/src/popup/shell/i18n/locales/en.ts @@ -48,6 +48,7 @@ import header from '@popup/popupX/page-layouts/MainLayout/Header/i18n/en'; import web3Id from '@popup/popupX/pages/Web3Id/i18n/en'; import earn from '@popup/popupX/pages/EarningRewards/i18n/en'; import connectionRequestX from '@popup/popupX/pages/prompts/ConnectionRequest/i18n/en'; +import submittedTransaction from '@popup/popupX/pages/SubmittedTransaction/i18n/en'; const t = { shared, @@ -99,6 +100,7 @@ const t = { web3Id, earn, prompts: { connectionRequestX }, + submittedTransaction, }, }; diff --git a/packages/browser-wallet/src/popup/styles/util/_spacing.scss b/packages/browser-wallet/src/popup/styles/util/_spacing.scss index 41e4ccce9..d7c569cd5 100644 --- a/packages/browser-wallet/src/popup/styles/util/_spacing.scss +++ b/packages/browser-wallet/src/popup/styles/util/_spacing.scss @@ -48,6 +48,11 @@ p-h-50 = padding-left: 5rem; padding-right: 5rem; margin-#{$side}: #{rem($space)}; } } + .m-#{str-slice($side, 0, 1)}-neg-#{$space} { + @include with-first-last-mods { + margin-#{$side}: -#{rem($space)}; + } + } .p-#{str-slice($side, 0, 1)}-#{$space} { @include with-first-last-mods { padding-#{$side}: #{rem($space)}; diff --git a/packages/browser-wallet/src/shared/utils/chain-parameters-helpers.ts b/packages/browser-wallet/src/shared/utils/chain-parameters-helpers.ts index c56a09206..b7551859a 100644 --- a/packages/browser-wallet/src/shared/utils/chain-parameters-helpers.ts +++ b/packages/browser-wallet/src/shared/utils/chain-parameters-helpers.ts @@ -1,4 +1,4 @@ -import { CcdAmount, ChainParameters } from '@concordium/web-sdk/types'; +import { CcdAmount, ChainParameters, ChainParametersV0 } from '@concordium/web-sdk/types'; export function cpBakingThreshold(cp: ChainParameters): CcdAmount.Type { switch (cp.version) { @@ -13,3 +13,9 @@ export function cpBakingThreshold(cp: ChainParameters): CcdAmount.Type { throw new Error('Non-supported chain parameters version'); } } + +/** Get the staking cooldown from the chain parameters. */ +export function cpStakingCooldown(cp: Exclude): bigint { + // From protocol version 7, the lower of the two values is the value that counts. + return cp.poolOwnerCooldown < cp.delegatorCooldown ? cp.poolOwnerCooldown : cp.delegatorCooldown; +}