From 6dc163e5f802ffdcf54a800395a0df3230045383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Mon, 4 Nov 2024 11:57:15 +0100 Subject: [PATCH 01/11] Transaction flow and stake configuration page --- .../src/popup/popupX/constants/routes.ts | 17 +- .../Delegator/Result/DelegationResult.tsx | 67 +----- .../Validator/Register/RegisterValidator.scss | 87 -------- .../Validator/Register/RegisterValidator.tsx | 45 ---- .../Validator/Register/index.ts | 1 - .../Validator/Result/ValidationResult.scss | 12 ++ .../Validator/Result/ValidationResult.tsx | 125 ++++++++++++ .../Validator/Result/ValidatorResult.tsx | 193 ++++++++++++++++++ .../EarningRewards/Validator/Result/index.ts | 1 + .../Validator/Stake/ValidatorStake.scss | 30 +++ .../Validator/Stake/ValidatorStake.tsx | 183 +++++++++++++++++ .../EarningRewards/Validator/Stake/index.ts | 1 + .../pages/EarningRewards/Validator/Status.tsx | 15 +- .../Validator/TransactionFlow.tsx | 108 ++++++++++ .../pages/EarningRewards/Validator/util.ts | 97 ++++++++- .../popupX/pages/EarningRewards/i18n/en.ts | 57 ++++++ .../src/popup/popupX/shell/Routes.tsx | 9 +- .../src/popup/popupX/styles/_elements.scss | 3 +- .../popup/shared/utils/transaction-helpers.ts | 69 ++++++- 19 files changed, 903 insertions(+), 217 deletions(-) delete mode 100644 packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Register/RegisterValidator.scss delete mode 100644 packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Register/RegisterValidator.tsx delete mode 100644 packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Register/index.ts create mode 100644 packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Result/ValidationResult.scss create mode 100644 packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Result/ValidationResult.tsx create mode 100644 packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Result/ValidatorResult.tsx create mode 100644 packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Result/index.ts create mode 100644 packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Stake/ValidatorStake.scss create mode 100644 packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Stake/ValidatorStake.tsx create mode 100644 packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Stake/index.ts create mode 100644 packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/TransactionFlow.tsx diff --git a/packages/browser-wallet/src/popup/popupX/constants/routes.ts b/packages/browser-wallet/src/popup/popupX/constants/routes.ts index 2e3258e94..a303218ce 100644 --- a/packages/browser-wallet/src/popup/popupX/constants/routes.ts +++ b/packages/browser-wallet/src/popup/popupX/constants/routes.ts @@ -198,12 +198,19 @@ export const relativeRoutes = { /** Configure existing delegator */ update: { path: 'update', + /** Update validator stake */ + stake: { path: 'stake' }, + /** Update validator pool settings */ + settings: { path: 'settings' }, + /** Update validator keys */ + keys: { path: 'keys' }, }, - openPool: { - path: 'openPool', - }, - keys: { - path: 'keys', + /** Submit configure validator transaction */ + submit: { + path: 'submit', + config: { + backTitle: i18n.t('x:earn.validator.submit.backTitle'), + }, }, }, /** Delegation section */ 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 733816676..a758db491 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,18 +1,13 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { useMemo } from 'react'; import { - AccountAddress, AccountInfoDelegator, - 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 Button from '@popup/popupX/shared/Button'; import Page from '@popup/popupX/shared/Page'; @@ -21,70 +16,12 @@ 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 { useGetTransactionFee, useTransactionSubmit } from '@popup/shared/utils/transaction-helpers'; import { cpStakingCooldown } from '@shared/utils/chain-parameters-helpers'; import { submittedTransactionRoute } from '@popup/popupX/constants/routes'; import Text from '@popup/popupX/shared/Text'; import { useSelectedAccountInfo } from '@popup/shared/AccountInfoListenerContext/AccountInfoListenerContext'; -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'; diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Register/RegisterValidator.scss b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Register/RegisterValidator.scss deleted file mode 100644 index 8b337a1d9..000000000 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Register/RegisterValidator.scss +++ /dev/null @@ -1,87 +0,0 @@ -.register-validator-container { - display: flex; - flex-direction: column; - height: 100%; - padding-bottom: rem(32px); - - .register-validator { - &__title { - display: flex; - align-items: baseline; - justify-content: space-between; - margin-bottom: rem(16px); - } - - &__token-card { - background: $gradient-card-bg; - border-radius: rem(16px); - padding: rem(20px) rem(16px); - margin-bottom: rem(8px); - - .token { - margin-bottom: rem(12px); - - .token-available { - display: flex; - margin-top: rem(12px); - justify-content: space-between; - } - } - - .amount { - padding: rem(24px) 0 rem(8px) 0; - border-top: 1px solid rgba($color-black, 0.1); - border-bottom: 1px solid rgba($color-black, 0.1); - - .amount-selected { - display: flex; - margin-top: rem(8px); - justify-content: space-between; - align-items: center; - - .capture__additional_small { - border-radius: rem(4px); - padding: rem(4px); - background-color: $secondary-button-bg; - } - } - } - - .estimated-fee { - display: flex; - flex-direction: column; - margin-top: rem(4px); - } - - .text__main_regular, - .text__main_small, - .capture__additional_small { - color: $color-black; - } - - .heading_big { - color: $color-grey-1; - } - - .capture__main_small { - color: $color-grey-3; - } - } - - &__reward { - border-radius: rem(12px); - padding: rem(16px); - background-color: rgba($color-grey-3, 0.3); - - &_auto-add { - display: flex; - justify-content: space-between; - margin-bottom: rem(12px); - - .text__main { - color: $color-white; - } - } - } - } -} diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Register/RegisterValidator.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Register/RegisterValidator.tsx deleted file mode 100644 index 14d2dd0b5..000000000 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Register/RegisterValidator.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import Button from '@popup/popupX/shared/Button'; -import { ToggleCheckbox } from '@popup/popupX/shared/Form/ToggleCheckbox'; - -export default function RegisterValidator() { - return ( -
-
- Register validator - on Accout 1 / 6gk...k7o -
-
-
- Token -
- CCD - 17,800 CCD available -
-
-
- Amount -
- 12,600.00 - Stake max. -
-
-
- Estimated transaction fee: - 12.200,29 CCD – 18.500,04 CCD -
-
-
-
- Auto add rewards - -
- - Set to automatically add baking rewards to validator stake. amounts will be at disposal on your - account balance at each pay day. - -
- -
- ); -} diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Register/index.ts b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Register/index.ts deleted file mode 100644 index 2b3bd0963..000000000 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Register/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './RegisterValidator'; diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Result/ValidationResult.scss b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Result/ValidationResult.scss new file mode 100644 index 000000000..35eedb862 --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Result/ValidationResult.scss @@ -0,0 +1,12 @@ +.validation-result-container { + .capture__main_small { + color: $color-white; + word-wrap: break-word; + } + + .validation-result { + &__card { + margin-top: rem(16px); + } + } +} diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Result/ValidationResult.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Result/ValidationResult.tsx new file mode 100644 index 000000000..38b150dcc --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Result/ValidationResult.tsx @@ -0,0 +1,125 @@ +import React, { useMemo } from 'react'; +import { AccountInfoBaker, AccountTransactionType, ConfigureBakerPayload, TransactionHash } from '@concordium/web-sdk'; +import { Navigate, useLocation, Location, useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; + +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 { useGetTransactionFee, useTransactionSubmit } from '@popup/shared/utils/transaction-helpers'; +import { cpStakingCooldown } from '@shared/utils/chain-parameters-helpers'; +import { submittedTransactionRoute } from '@popup/popupX/constants/routes'; +import Text from '@popup/popupX/shared/Text'; +import { useSelectedAccountInfo } from '@popup/shared/AccountInfoListenerContext/AccountInfoListenerContext'; +import { showValidatorAmount, showValidatorOpenStatus, showValidatorRestake } from '../util'; + +export type ValidationResultLocationState = { + payload: ConfigureBakerPayload; + type: 'register' | 'change' | 'remove'; +}; + +export default function ValidationResult() { + const { state } = useLocation() as Location & { + state: ValidationResultLocationState | undefined; + }; + const nav = useNavigate(); + const { t } = useTranslation('x', { keyPrefix: 'earn.validator' }); + const getCost = useGetTransactionFee(AccountTransactionType.ConfigureBaker); + const accountInfo = ensureDefined(useSelectedAccountInfo(), 'No account selected'); + + const parametersV1 = useBlockChainParametersAboveV0(); + const submitTransaction = useTransactionSubmit(accountInfo.accountAddress, AccountTransactionType.ConfigureBaker); + + const cooldown = useMemo(() => { + let cooldownParam = 0n; + if (parametersV1 !== undefined) { + cooldownParam = cpStakingCooldown(parametersV1); + } + return secondsToDaysRoundedDown(cooldownParam); + }, [parametersV1]); + + const [title, notice] = useMemo(() => { + switch (state?.type) { + case 'register': + return [t('register.title'), t('register.notice', { cooldown })]; + case 'change': + if ( + state.payload.stake === undefined || + state.payload.stake.microCcdAmount >= + (accountInfo as AccountInfoBaker).accountBaker.stakedAmount.microCcdAmount + ) { + // Staked amount is not lowered + return [t('update.title')]; + } + return [t('update.title'), t('update.lowerStakeNotice', { cooldown })]; + case 'remove': + return [t('remove.title'), t('remove.notice', { cooldown })]; + default: + throw new Error("'type' must be defined on route state"); + } + }, [state, t, cooldown]); + + 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))); + }; + + // TODO: + // [ ] Add the rest of the transaction fields + return ( + + + {notice !== undefined && {notice}} + + + + + {state.payload.stake !== undefined && ( + + + + )} + {state.payload.restakeEarnings !== undefined && ( + + + + )} + {state.payload.openForDelegation !== undefined && ( + + + + )} + + + + + + + + + ); +} diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Result/ValidatorResult.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Result/ValidatorResult.tsx new file mode 100644 index 000000000..05b61009c --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Result/ValidatorResult.tsx @@ -0,0 +1,193 @@ +import React, { useCallback, useMemo } from 'react'; +import { + AccountAddress, + AccountInfoBaker, + AccountTransactionPayload, + AccountTransactionType, + CcdAmount, + ConfigureBakerPayload, + 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 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'; +import { useSelectedAccountInfo } from '@popup/shared/AccountInfoListenerContext/AccountInfoListenerContext'; +import { showValidatorAmount, showValidatorOpenStatus, showValidatorRestake } from '../util'; + +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 ValidationResultLocationState = { + payload: ConfigureBakerPayload; + type: 'register' | 'change' | 'remove'; +}; + +export default function DelegationResult() { + const { state } = useLocation() as Location & { + state: ValidationResultLocationState | undefined; + }; + const nav = useNavigate(); + const { t } = useTranslation('x', { keyPrefix: 'earn.validator' }); + const getCost = useGetTransactionFee(AccountTransactionType.ConfigureBaker); + const accountInfo = ensureDefined(useSelectedAccountInfo(), 'No account selected'); + + const parametersV1 = useBlockChainParametersAboveV0(); + const submitTransaction = useTransactionSubmit(accountInfo.accountAddress, AccountTransactionType.ConfigureBaker); + + const cooldown = useMemo(() => { + let cooldownParam = 0n; + if (parametersV1 !== undefined) { + cooldownParam = cpStakingCooldown(parametersV1); + } + return secondsToDaysRoundedDown(cooldownParam); + }, [parametersV1]); + + const [title, notice] = useMemo(() => { + switch (state?.type) { + case 'register': + return [t('register.title'), t('register.notice', { cooldown })]; + case 'change': + if ( + state.payload.stake === undefined || + state.payload.stake.microCcdAmount >= + (accountInfo as AccountInfoBaker).accountBaker.stakedAmount.microCcdAmount + ) { + // Staked amount is not lowered + return [t('update.title')]; + } + return [t('update.title'), t('update.lowerStakeNotice', { cooldown })]; + case 'remove': + return [t('remove.title'), t('remove.notice', { cooldown })]; + default: + throw new Error("'type' must be defined on route state"); + } + }, [state, t, cooldown]); + + 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))); + }; + + // TODO: + // - Add the rest of the transaction fields + return ( + + + {notice !== undefined && {notice}} + + + + + {state.payload.stake !== undefined && ( + + + + )} + {state.payload.restakeEarnings !== undefined && ( + + + + )} + {state.payload.openForDelegation !== undefined && ( + + + + )} + + + + + + + + + ); +} diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Result/index.ts b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Result/index.ts new file mode 100644 index 000000000..3dfa1efe8 --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Result/index.ts @@ -0,0 +1 @@ +export { default, type ValidationResultLocationState } from './ValidationResult'; diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Stake/ValidatorStake.scss b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Stake/ValidatorStake.scss new file mode 100644 index 000000000..263dc7f2c --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Stake/ValidatorStake.scss @@ -0,0 +1,30 @@ +.register-validator-container { + display: flex; + flex-direction: column; + min-height: 100%; + padding-bottom: rem(32px); + + .register-validator { + &__token-card { + margin-top: rem(16px); + margin-bottom: rem(8px); + } + + &__reward { + border-radius: rem(12px); + padding: rem(16px); + background-color: rgba($color-grey-3, 0.3); + margin-bottom: rem(8px); + + &_auto-add { + display: flex; + justify-content: space-between; + margin-bottom: rem(12px); + + .text__main { + color: $color-white; + } + } + } + } +} diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Stake/ValidatorStake.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Stake/ValidatorStake.tsx new file mode 100644 index 000000000..8b33f0a01 --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Stake/ValidatorStake.tsx @@ -0,0 +1,183 @@ +/* eslint-disable react/destructuring-assignment */ +import React, { useEffect, useMemo, useState } from 'react'; +import { UseFormReturn } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { AccountTransactionType, CcdAmount, ConfigureBakerPayload, OpenStatus } 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 { formatCcdAmount, parseCcdAmount } from '@popup/popupX/shared/utils/helpers'; +import Text from '@popup/popupX/shared/Text'; +import { useGetTransactionFee } from '@popup/shared/utils/transaction-helpers'; +import FullscreenNotice, { FullscreenNoticeProps } from '@popup/popupX/shared/FullscreenNotice'; + +import { METADATAURL_MAX_LENGTH } from '@shared/constants/baking'; +import { STAKE_WARNING_THRESHOLD, isAboveStakeWarningThreshold } from '../../util'; +import { ValidatorStakeForm } from '../util'; + +type HighStakeNoticeProps = FullscreenNoticeProps & { + onContinue(): void; +}; + +function HighStakeWarning({ onContinue, ...props }: HighStakeNoticeProps) { + const { t } = useTranslation('x', { keyPrefix: 'earn.validator.stake.overStakeThresholdWarning' }); + return ( + + + + {t('description', { threshold: STAKE_WARNING_THRESHOLD.toString() })} + + + + + + + ); +} + +const PAYLOAD_MAX: ConfigureBakerPayload = { + keys: { + electionVerifyKey: '0000000000000000000000000000000000000000000000000000000000000000', + signatureVerifyKey: '0000000000000000000000000000000000000000000000000000000000000000', + aggregationVerifyKey: + '000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', + proofSig: + '00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', + proofElection: + '00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', + proofAggregation: + '00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', + }, + finalizationRewardCommission: 0, + transactionFeeCommission: 0, + bakingRewardCommission: 0, + stake: CcdAmount.zero(), + openForDelegation: OpenStatus.OpenForAll, + restakeEarnings: true, + metadataUrl: 'a'.repeat(METADATAURL_MAX_LENGTH), +}; + +type Props = { + /** The title for the configuriation step */ + title: string; + /** The initial values of the step, if any */ + initialValues?: ValidatorStakeForm; + /** The existing validation stake values registered on the account */ + existingValues?: ValidatorStakeForm; + /** The submit handler triggered when submitting the form in the step */ + onSubmit(values: ValidatorStakeForm): void; +}; + +export default function ValidatorStake({ title, initialValues, existingValues, onSubmit }: Props) { + const { t } = useTranslation('x', { keyPrefix: 'earn.validator.stake' }); + const form = useForm({ + defaultValues: initialValues ?? { amount: '0.00', restake: true }, + }); + const submit = form.handleSubmit(onSubmit); + const selectedCred = useSelectedCredential(); + const selectedAccountInfo = useAccountInfo(selectedCred); + const [highStakeWarning, setHighStakeWarning] = useState(false); + + const values = form.watch(); + const getCost = useGetTransactionFee(AccountTransactionType.ConfigureBaker); + const fee = useMemo(() => { + if (existingValues === undefined) { + return getCost(PAYLOAD_MAX); + } + + try { + let stake: CcdAmount.Type | undefined; + if (values.amount !== existingValues?.amount) { + stake = parseCcdAmount(values.amount); + } + let restake: boolean | undefined; + if (values.restake !== existingValues?.restake) { + restake = values.restake; + } + + const payload: ConfigureBakerPayload = { stake, restakeEarnings: restake }; + return getCost(payload); + } catch { + // We failed to parse the amount + return undefined; + } + }, [getCost, existingValues, values]); + + useEffect(() => { + if (selectedAccountInfo === undefined || fee === undefined) { + return; + } + + try { + const parsed = parseCcdAmount(values.amount); + const newMax = CcdAmount.fromMicroCcd( + selectedAccountInfo.accountAmount.microCcdAmount - fee.microCcdAmount + ); + if (parsed.microCcdAmount > newMax.microCcdAmount) { + form.setValue('amount', formatCcdAmount(newMax), { shouldValidate: true }); + } + } catch { + // Do nothing.. + } + }, [selectedAccountInfo?.accountAmount, fee]); + + if (selectedAccountInfo === undefined || selectedCred === undefined || fee === undefined) { + return null; + } + + const handleSubmit = () => { + if (!form.formState.errors.amount === undefined) { + submit(); // To set the form to submitted. + return; + } + + const amount = parseCcdAmount(form.getValues().amount); + if (isAboveStakeWarningThreshold(amount.microCcdAmount, selectedAccountInfo)) { + setHighStakeWarning(true); + } else { + submit(); + } + }; + + return ( + <> + setHighStakeWarning(false)} onContinue={submit} /> + + + + {t('selectedAccount', { account: displayNameAndSplitAddress(selectedCred) })} + +
+ {(f) => ( + <> + } + ccdBalance="total" + /> +
+
+ {t('restake.label')} + +
+ {t('restake.description')} +
+ + )} + + + + +
+ + ); +} diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Stake/index.ts b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Stake/index.ts new file mode 100644 index 000000000..9dee13211 --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Stake/index.ts @@ -0,0 +1 @@ +export { default } from './ValidatorStake'; diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Status.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Status.tsx index adbcf1901..638342635 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Status.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Status.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { AccountInfoType } from '@concordium/web-sdk'; +import { AccountInfoType, CcdAmount } from '@concordium/web-sdk'; import { absoluteRoutes } from '@popup/popupX/constants/routes'; import Button from '@popup/popupX/shared/Button'; import Card from '@popup/popupX/shared/Card'; @@ -8,7 +8,13 @@ import { useTranslation } from 'react-i18next'; import { useSelectedAccountInfo } from '@popup/shared/AccountInfoListenerContext/AccountInfoListenerContext'; import { Navigate, useNavigate } from 'react-router-dom'; import AccountCooldowns from '../AccountCooldowns'; -import { showRestake, showValidatorAmount, showValidatorOpenStatus } from './util'; +import { showValidatorRestake, showValidatorAmount, showValidatorOpenStatus } from './util'; +import { ValidationResultLocationState } from './Result/ValidatorResult'; + +const REMOVE_STATE: ValidationResultLocationState = { + type: 'remove', + payload: { stake: CcdAmount.zero() }, +}; export default function ValidatorStatus() { const { t } = useTranslation('x', { keyPrefix: 'earn.validator' }); @@ -22,6 +28,7 @@ export default function ValidatorStatus() { const { accountBaker, accountCooldowns } = accountInfo; if (accountBaker.version === 0) { + // assume protocol version >= 4 return null; } @@ -41,7 +48,7 @@ export default function ValidatorStatus() { @@ -66,7 +73,7 @@ export default function ValidatorStatus() { /> nav(absoluteRoutes.settings.earn.delegator.stop.path)} + onClick={() => nav(absoluteRoutes.settings.earn.validator.submit.path, { state: REMOVE_STATE })} /> diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/TransactionFlow.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/TransactionFlow.tsx new file mode 100644 index 000000000..48825b58e --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/TransactionFlow.tsx @@ -0,0 +1,108 @@ +/* eslint-disable react/destructuring-assignment */ +import React, { useCallback, useState } from 'react'; +import { Location, useLocation, useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { AccountInfoType } from '@concordium/web-sdk'; + +import { absoluteRoutes } from '@popup/popupX/constants/routes'; +import MultiStepForm from '@popup/shared/MultiStepForm'; +import { useSelectedAccountInfo } from '@popup/shared/AccountInfoListenerContext/AccountInfoListenerContext'; +import { formatCcdAmount } from '@popup/popupX/shared/utils/helpers'; +import FullscreenNotice, { FullscreenNoticeProps } from '@popup/popupX/shared/FullscreenNotice'; +import Page from '@popup/popupX/shared/Page'; +import Button from '@popup/popupX/shared/Button'; +import { ValidatorForm, ValidatorFormExisting, configureValidatorFromForm } from './util'; +import ValidatorStake from './Stake'; +import { type ValidationResultLocationState } from './Result'; + +// TODO: +// - Form steps + +// TODO: use this when implementing update flows +function NoChangesNotice(props: FullscreenNoticeProps) { + const { t } = useTranslation('x', { keyPrefix: 'earn.validator.update.noChangesNotice' }); + return ( + + + + {t('description')} + + + + + + ); +} + +type Props = { + title: string; + existingValues?: ValidatorFormExisting | undefined; +}; + +function ValidatorTransactionFlow({ existingValues, title }: Props) { + const { state, pathname } = useLocation() as Location & { state: ValidatorForm | undefined }; + const nav = useNavigate(); + + const initialValues = state ?? existingValues; + const store = useState>(initialValues ?? {}); + + const handleDone = useCallback( + (form: ValidatorForm) => { + const payload = configureValidatorFromForm(form); + + nav(pathname, { replace: true, state: form }); // Override current router entry with stateful version + + const submitDelegatorState: ValidationResultLocationState = { + payload, + type: 'register', + }; + nav(absoluteRoutes.settings.earn.validator.submit.path, { state: submitDelegatorState }); + }, + [pathname, existingValues] + ); + + return ( + onDone={handleDone} valueStore={store}> + {{ + stake: { + render: (initial, onNext) => { + return ; + }, + }, + }} + + ); +} + +export function RegisterValidatorTransactionFlow() { + const { t } = useTranslation('x', { keyPrefix: 'earn.validator.register' }); + return ; +} + +export function UpdateValidatorTransactionFlow() { + const { t } = useTranslation('x', { keyPrefix: 'earn.validator.update' }); + const accountInfo = useSelectedAccountInfo(); + + if ( + accountInfo === undefined || + accountInfo.type !== AccountInfoType.Baker || + accountInfo.accountBaker.version === 0 + ) { + return null; + } + const { + accountBaker: { stakedAmount, restakeEarnings, bakerPoolInfo }, + } = accountInfo; + + const values: ValidatorFormExisting = { + stake: { + amount: formatCcdAmount(stakedAmount), + restake: restakeEarnings, + }, + status: { status: bakerPoolInfo.openStatus }, + metadata: { url: bakerPoolInfo.metadataUrl }, + commissions: bakerPoolInfo.commissionRates, + }; + + return ; +} diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/util.ts b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/util.ts index b29ae2e61..15bc9800e 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/util.ts +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/util.ts @@ -1,17 +1,27 @@ -import { CcdAmount, OpenStatusText } from '@concordium/web-sdk'; -import { formatCcdAmount } from '@popup/popupX/shared/utils/helpers'; +import { + BakerKeysWithProofs, + CcdAmount, + CommissionRates, + ConfigureBakerPayload, + OpenStatus, + OpenStatusText, +} from '@concordium/web-sdk'; +import { formatCcdAmount, parseCcdAmount } from '@popup/popupX/shared/utils/helpers'; import i18n from '@popup/shell/i18n'; export function showValidatorAmount(amount: CcdAmount.Type): string { return `${formatCcdAmount(amount)} CCD`; } -export function showValidatorOpenStatus(status: OpenStatusText): string { +export function showValidatorOpenStatus(status: OpenStatusText | OpenStatus): string { switch (status) { + case OpenStatus.OpenForAll: case OpenStatusText.OpenForAll: return i18n.t('x:earn.validator.values.openStatus.open'); + case OpenStatus.ClosedForAll: case OpenStatusText.ClosedForAll: return i18n.t('x:earn.validator.values.openStatus.closed'); + case OpenStatus.ClosedForNew: case OpenStatusText.ClosedForNew: return i18n.t('x:earn.validator.values.openStatus.closedNew'); default: @@ -19,8 +29,87 @@ export function showValidatorOpenStatus(status: OpenStatusText): string { } } -export function showRestake(value: boolean): string { +export function showValidatorRestake(value: boolean): string { return value ? i18n.t('x:earn.validator.values.restake.validation') : i18n.t('x:earn.validator.values.restake.public'); } + +/** The form data for specifying validator stake */ +export type ValidatorStakeForm = { amount: string; restake: boolean }; +/** The form data for specifying validator metadata url */ +export type ValidatorMetadataForm = { url: string }; +/** The form data for specifying validator pool status for delegators */ +export type ValidatorStatusForm = { status: OpenStatusText }; + +export type ValidatorFormUpdateStake = { stake: ValidatorStakeForm }; +export type ValidatorFormUpdateKeys = { keys: BakerKeysWithProofs }; +export type ValidatorFormUpdateSettings = { + status: ValidatorStatusForm; + commissions: CommissionRates; + metadata: ValidatorMetadataForm; +}; + +/** The cummulative validator form for declaring the data for the transaction for configuring validators */ +export type ValidatorForm = ValidatorFormUpdateStake & ValidatorFormUpdateSettings & ValidatorFormUpdateKeys; + +/** The existing values needed to compare with updates */ +export type ValidatorFormExisting = Omit; + +export function configureValidatorFromForm( + values: Partial, + existingValues?: ValidatorFormExisting +): ConfigureBakerPayload { + let restakeEarnings: boolean | undefined; + if (values.stake?.restake !== existingValues?.stake.restake) { + restakeEarnings = values.stake?.restake; + } + let stake: CcdAmount.Type | undefined; + if (values.stake?.amount !== existingValues?.stake.amount && values.stake !== undefined) { + stake = parseCcdAmount(values.stake.amount); + } + let openForDelegation: OpenStatus | undefined; + if (values.status?.status !== existingValues?.status.status) { + switch (values.status?.status) { + case OpenStatusText.OpenForAll: + openForDelegation = OpenStatus.OpenForAll; + break; + case OpenStatusText.ClosedForAll: + openForDelegation = OpenStatus.ClosedForAll; + break; + case OpenStatusText.ClosedForNew: + openForDelegation = OpenStatus.ClosedForNew; + break; + default: + throw new Error('Unsupported status'); + } + } + let metadataUrl: string | undefined; + if (values.metadata?.url !== existingValues?.metadata.url) { + metadataUrl = values.metadata?.url; + } + + let bakingRewardCommission: number | undefined; + if (values.commissions?.bakingCommission !== existingValues?.commissions.bakingCommission) { + bakingRewardCommission = values.commissions?.bakingCommission; + } + let finalizationRewardCommission: number | undefined; + if (values.commissions?.finalizationCommission !== existingValues?.commissions.finalizationCommission) { + finalizationRewardCommission = values.commissions?.finalizationCommission; + } + let transactionFeeCommission: number | undefined; + if (values.commissions?.transactionCommission !== existingValues?.commissions.transactionCommission) { + transactionFeeCommission = values.commissions?.transactionCommission; + } + + return { + stake, + restakeEarnings, + openForDelegation, + metadataUrl, + bakingRewardCommission, + transactionFeeCommission, + finalizationRewardCommission, + keys: values.keys, + }; +} 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 d0caf9854..f479decbd 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 @@ -183,6 +183,63 @@ const t = { register: { title: 'Register validator', backTitle: 'Earning rewards', + notice: 'This will lock your validation amount. Amount is released after {{cooldown}} days from the time you remove or decrease your validation stake.', + }, + update: { + title: 'Update validator', + noChangesNotice: { + title: 'No changes', + description: 'The proposed transaction contains no changes compared to the current validation.', + buttonBack: 'Go back', + }, + lowerStakeNotice: + 'Reducing your stake is subject to a cooldown period of {{cooldown}} days, in which the stake cannot be spent or transferred.', + }, + remove: { + title: 'Remove validator', + notice: 'The validator stake is released after {{cooldown}} days', + }, + 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', + }, + restake: { + label: 'Restake rewards', + description: 'I want to automatically add my validation rewards to my validation amount.', + }, + buttonContinue: 'Continue', + overStakeThresholdWarning: { + title: 'Important', + description: + 'You are about to lock more than {{ threshold }}% of your total balance in a validation stake.\n\nIf you don’t have enough unlocked CCD at your disposal, you might not be able to pay future transaction fees.', + buttonContinue: 'Continue', + buttonBack: 'Enter new stake', + }, + }, + submit: { + backTitle: 'Validation settings', + sender: { label: 'Sender' }, + fee: { label: 'Estimated transaction fee' }, + button: 'Submit validation', }, }, }; diff --git a/packages/browser-wallet/src/popup/popupX/shell/Routes.tsx b/packages/browser-wallet/src/popup/popupX/shell/Routes.tsx index 0569b5efb..c305633d7 100644 --- a/packages/browser-wallet/src/popup/popupX/shell/Routes.tsx +++ b/packages/browser-wallet/src/popup/popupX/shell/Routes.tsx @@ -20,7 +20,6 @@ import { IdSubmitted, IdCardsInfo, RequestIdentity, SetupPassword, Welcome } fro import ConnectedSites from '@popup/popupX/pages/ConnectedSites'; import EarningRewards from '@popup/popupX/pages/EarningRewards'; import ValidatorIntro from '@popup/popupX/pages/EarningRewards/Validator/Intro'; -import RegisterValidator from '@popup/popupX/pages/EarningRewards/Validator/Register'; import PrivateKey from '@popup/popupX/pages/PrivateKey'; import { RestoreIntro, RestoreResult } from '@popup/popupX/pages/Restore'; import { MessagePromptHandlersType } from '@popup/shared/utils/message-prompt-handlers'; @@ -37,6 +36,8 @@ import { } from '../pages/EarningRewards/Delegator/TransactionFlow'; import DelegatorStatus from '../pages/EarningRewards/Delegator/Status'; import ValidatorStatus from '../pages/EarningRewards/Validator/Status'; +import { RegisterValidatorTransactionFlow } from '../pages/EarningRewards/Validator/TransactionFlow'; +import ValidationResult from '../pages/EarningRewards/Validator/Result/ValidationResult'; export default function Routes({ messagePromptHandlers }: { messagePromptHandlers: MessagePromptHandlersType }) { const { handleConnectionResponse } = messagePromptHandlers; @@ -122,9 +123,13 @@ export default function Routes({ messagePromptHandlers }: { messagePromptHandler } /> } + element={} /> + } + 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 3c5997b2b..e30c528a2 100644 --- a/packages/browser-wallet/src/popup/popupX/styles/_elements.scss +++ b/packages/browser-wallet/src/popup/popupX/styles/_elements.scss @@ -21,7 +21,8 @@ @import '../pages/EarningRewards/EarningRewards'; @import '../pages/EarningRewards/AccountCooldowns/AccountCooldowns'; @import '../pages/EarningRewards/Validator/Intro/ValidatorIntro'; -@import '../pages/EarningRewards/Validator/Register/RegisterValidator'; +@import '../pages/EarningRewards/Validator/Stake/ValidatorStake'; +@import '../pages/EarningRewards/Validator/Result/ValidationResult'; @import '../pages/EarningRewards/Validator/OpenPool/OpenPool'; @import '../pages/EarningRewards/Validator/Keys/ValidatorKeys'; @import '../pages/EarningRewards/Delegator/Intro/DelegatorIntro'; 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 ea8258e92..78bdbfdd2 100644 --- a/packages/browser-wallet/src/popup/shared/utils/transaction-helpers.ts +++ b/packages/browser-wallet/src/popup/shared/utils/transaction-helpers.ts @@ -18,7 +18,6 @@ import { UpdateContractPayload, SimpleTransferWithMemoPayload, AccountInfoType, - ConfigureDelegationPayload, getEnergyCost, convertEnergyToMicroCcd, } from '@concordium/web-sdk'; @@ -33,11 +32,14 @@ import { import i18n from '@popup/shell/i18n'; import { useAtomValue } from 'jotai'; -import { selectedPendingTransactionsAtom } from '@popup/store/transactions'; +import { addPendingTransactionAtom, selectedPendingTransactionsAtom } from '@popup/store/transactions'; import { DEFAULT_TRANSACTION_EXPIRY } from '@shared/constants/time'; import { useCallback } from 'react'; +import { grpcClientAtom } from '@popup/store/settings'; +import { useUpdateAtom } from 'jotai/utils'; import { BrowserWalletAccountTransaction, TransactionStatus } from './transaction-history-types'; import { useBlockChainParameters } from '../BlockChainParametersProvider'; +import { usePrivateKey } from './account-helpers'; export function buildSimpleTransferPayload(recipient: string, amount: bigint): SimpleTransferPayload { return { @@ -257,7 +259,7 @@ export function useGetTransactionFee(type: AccountTransactionType) { const cp = useBlockChainParameters(); return useCallback( - (payload: ConfigureDelegationPayload) => { + (payload: AccountTransactionPayload) => { if (cp === undefined) { return undefined; } @@ -267,3 +269,64 @@ export function useGetTransactionFee(type: AccountTransactionType) { [cp, type] ); } + +/** Types of errors returned when attempting transaction submission */ +export enum TransactionSubmitErrorType { + InsufficientFunds = 'InsufficientFunds', +} + +/** Error returned when attempting to submit a transaction using {@linkcode useTransactionSubmit} */ +export class TransactionSubmitError extends Error { + private constructor(public type: TransactionSubmitErrorType) { + super(); + super.name = `TransactionSubmitError.${type}`; + } + + public static insufficientFunds(): TransactionSubmitError { + return new TransactionSubmitError(TransactionSubmitErrorType.InsufficientFunds); + } +} + +/** + * Hook returning a function to submit a transaction of the specified type from the specified sender. + * If successful, a pending transaction is added to the local store which will then await finalization status from the node. + * + * @param sender - The account address of the sender. + * @param type - The type of the account transaction. + * + * @returns A function to submit a transaction. + * @throws {@linkcode TransactionSubmitError} + */ +export 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] + ); +} From d6bfc6e4eb5a56f13a9051946f04a49896eb76df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Tue, 5 Nov 2024 10:16:29 +0100 Subject: [PATCH 02/11] Open status page --- .../Validator/OpenPool/OpenPool.scss | 12 +--- .../Validator/OpenPool/OpenPool.tsx | 66 ++++++++++++------- .../Validator/TransactionFlow.tsx | 12 +++- .../pages/EarningRewards/Validator/util.ts | 16 ++--- .../popupX/pages/EarningRewards/i18n/en.ts | 9 +++ 5 files changed, 69 insertions(+), 46 deletions(-) diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/OpenPool/OpenPool.scss b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/OpenPool/OpenPool.scss index 155b297df..34769b2e2 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/OpenPool/OpenPool.scss +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/OpenPool/OpenPool.scss @@ -1,9 +1,4 @@ .open-pool-container { - display: flex; - flex-direction: column; - height: 100%; - padding-bottom: rem(32px); - .open-pool { &__title { display: flex; @@ -12,13 +7,8 @@ } &__card { - display: flex; - flex-direction: column; - gap: rem(16px); - border-radius: rem(12px); - padding: rem(16px); margin-top: rem(16px); - background-color: rgba($color-grey-3, 0.3); + gap: rem(16px); &_delegation { display: flex; diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/OpenPool/OpenPool.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/OpenPool/OpenPool.tsx index 095cf52da..42c7447dc 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/OpenPool/OpenPool.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/OpenPool/OpenPool.tsx @@ -1,30 +1,52 @@ -import React from 'react'; +import React, { useCallback, useState } from 'react'; import { ToggleCheckbox } from '@popup/popupX/shared/Form/ToggleCheckbox'; import Button from '@popup/popupX/shared/Button'; +import Card from '@popup/popupX/shared/Card'; +import Page from '@popup/popupX/shared/Page'; +import { OpenStatusText } from '@concordium/web-sdk'; +import { useTranslation } from 'react-i18next'; +import Text from '@popup/popupX/shared/Text'; + +type Props = { + initial?: OpenStatusText; + onSubmit(value: OpenStatusText): void; +}; + +function toBoolean(status: OpenStatusText): boolean { + switch (status) { + case OpenStatusText.OpenForAll: + return true; + case OpenStatusText.ClosedForAll: + return false; + default: + throw new Error('Not supported'); + } +} + +function toStatus(value: boolean): OpenStatusText { + return value ? OpenStatusText.OpenForAll : OpenStatusText.ClosedForAll; +} + +export default function OpenPool({ initial = OpenStatusText.OpenForAll, onSubmit }: Props) { + const { t } = useTranslation('x', { keyPrefix: 'earn.validator.openStatus' }); + const [value, setValue] = useState(toBoolean(initial)); + const submit = useCallback(() => { + onSubmit(toStatus(value)); + }, [onSubmit, value]); -export default function OpenPool() { return ( -
-
- Opening a pool - on Accout 1 / 6gk...k7o -
-
+ + +
- Open for delegation - + {t('switch.label')} + setValue(e.currentTarget.checked)} />
- - You have the option to open your validator as a pool for others to delegate their CCD to. - - - If you choose to open your pool, other people will be able to delegate CCDs to your baking pool. - - - You can also keep the pool closed, if you want only your own CCDs to be stalked. - -
- -
+ {t('description')} + + + + + ); } diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/TransactionFlow.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/TransactionFlow.tsx index 48825b58e..c502963b8 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/TransactionFlow.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/TransactionFlow.tsx @@ -14,6 +14,7 @@ import Button from '@popup/popupX/shared/Button'; import { ValidatorForm, ValidatorFormExisting, configureValidatorFromForm } from './util'; import ValidatorStake from './Stake'; import { type ValidationResultLocationState } from './Result'; +import OpenPool from './OpenPool'; // TODO: // - Form steps @@ -65,10 +66,15 @@ function ValidatorTransactionFlow({ existingValues, title }: Props) { onDone={handleDone} valueStore={store}> {{ stake: { - render: (initial, onNext) => { + render(initial, onNext) { return ; }, }, + status: { + render(initial, onNext) { + return ; + }, + }, }} ); @@ -99,8 +105,8 @@ export function UpdateValidatorTransactionFlow() { amount: formatCcdAmount(stakedAmount), restake: restakeEarnings, }, - status: { status: bakerPoolInfo.openStatus }, - metadata: { url: bakerPoolInfo.metadataUrl }, + status: bakerPoolInfo.openStatus, + metadataUrl: bakerPoolInfo.metadataUrl, commissions: bakerPoolInfo.commissionRates, }; diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/util.ts b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/util.ts index 15bc9800e..994ea55f1 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/util.ts +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/util.ts @@ -37,17 +37,13 @@ export function showValidatorRestake(value: boolean): string { /** The form data for specifying validator stake */ export type ValidatorStakeForm = { amount: string; restake: boolean }; -/** The form data for specifying validator metadata url */ -export type ValidatorMetadataForm = { url: string }; -/** The form data for specifying validator pool status for delegators */ -export type ValidatorStatusForm = { status: OpenStatusText }; export type ValidatorFormUpdateStake = { stake: ValidatorStakeForm }; export type ValidatorFormUpdateKeys = { keys: BakerKeysWithProofs }; export type ValidatorFormUpdateSettings = { - status: ValidatorStatusForm; + status: OpenStatusText; commissions: CommissionRates; - metadata: ValidatorMetadataForm; + metadataUrl: string; }; /** The cummulative validator form for declaring the data for the transaction for configuring validators */ @@ -69,8 +65,8 @@ export function configureValidatorFromForm( stake = parseCcdAmount(values.stake.amount); } let openForDelegation: OpenStatus | undefined; - if (values.status?.status !== existingValues?.status.status) { - switch (values.status?.status) { + if (values.status !== existingValues?.status) { + switch (values.status) { case OpenStatusText.OpenForAll: openForDelegation = OpenStatus.OpenForAll; break; @@ -85,8 +81,8 @@ export function configureValidatorFromForm( } } let metadataUrl: string | undefined; - if (values.metadata?.url !== existingValues?.metadata.url) { - metadataUrl = values.metadata?.url; + if (values.metadataUrl !== existingValues?.metadataUrl) { + metadataUrl = values.metadataUrl; } let bakingRewardCommission: number | undefined; 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 f479decbd..594823876 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 @@ -235,6 +235,15 @@ const t = { buttonBack: 'Enter new stake', }, }, + openStatus: { + title: 'Opening a pool', + switch: { + label: 'Open for delegation', + }, + description: + 'Opening a pool\nYou have the option when adding a validator to open a staking pool or not. A staking pool allows others who want to earn rewards to do so without the need to run a node or become a validator themselves.\n\nTo do this they delegate an amount to your staking pool which then increases your total stake and your chances of winning the lottery to bake a block. At each pay day the rewards will be distributed to you and your delegators.\n\nYou can also choose not to open a pool, in which case only your own stake applies toward the lottery. You can always open or close a pool later.', + buttonContinue: 'Continue', + }, submit: { backTitle: 'Validation settings', sender: { label: 'Sender' }, From 6110b062a35fb73e73006aa5a19dc49b457671ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Tue, 5 Nov 2024 12:57:10 +0100 Subject: [PATCH 03/11] Keys page --- .../Validator/Keys/ValidatorKeys.scss | 58 ++++------ .../Validator/Keys/ValidatorKeys.tsx | 104 ++++++++++++------ .../Validator/TransactionFlow.tsx | 6 + .../src/popup/popupX/shared/Card/Card.scss | 1 + 4 files changed, 96 insertions(+), 73 deletions(-) diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Keys/ValidatorKeys.scss b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Keys/ValidatorKeys.scss index 31a0c3b09..429cd551e 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Keys/ValidatorKeys.scss +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Keys/ValidatorKeys.scss @@ -1,47 +1,29 @@ -.validator-keys-container { - .capture__main_small { - color: $color-white; - word-wrap: break-word; +.validator-keys { + gap: rem(16px); + + .page__top { + margin-bottom: 0 !important; } - .validator-keys { - &__title { - display: flex; - justify-content: space-between; - align-items: flex-end; - margin-bottom: rem(12px); + &--expanded { + .validator-keys__expand svg { + transform: rotate(-90deg); } + } - &__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); - } - } + &:not(.validator-keys--expanded) { + .card-x .details .capture__main_small { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } + } + + &__expand { + margin-bottom: rem(8px); - &__export { - display: flex; - align-items: center; - gap: rem(8px); - margin-top: rem(16px); + svg { + transform: rotate(90deg); } } } diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Keys/ValidatorKeys.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Keys/ValidatorKeys.tsx index 91d87d8cb..d23154c8f 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Keys/ValidatorKeys.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Keys/ValidatorKeys.tsx @@ -1,42 +1,76 @@ -import React from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; +import { BakerKeysWithProofs, generateBakerKeys } from '@concordium/web-sdk'; + import ExportIcon from '@assets/svgX/sign-out.svg'; +import Caret from '@assets/svgX/caret-right.svg'; +import Page from '@popup/popupX/shared/Page'; +import Text from '@popup/popupX/shared/Text'; +import Button from '@popup/popupX/shared/Button'; +import Card from '@popup/popupX/shared/Card'; +import { ensureDefined } from '@shared/utils/basic-helpers'; +import { saveData } from '@popup/shared/utils/file-helpers'; +import { useSelectedAccountInfo } from '@popup/shared/AccountInfoListenerContext/AccountInfoListenerContext'; +import { getBakerKeyExport } from '@popup/shared/utils/baking-helpers'; +import clsx from 'clsx'; + +const KEYS_FILENAME = 'validator-credentials.json'; + +type Props = { onSubmit(keys: BakerKeysWithProofs): void }; + +export default function ValidatorKeys({ onSubmit }: Props) { + const [expand, setExpand] = useState(false); + const [exported, setExported] = useState(false); + const accountInfo = ensureDefined(useSelectedAccountInfo(), 'Expected seleted account'); + const keysFull = useMemo(() => generateBakerKeys(accountInfo.accountAddress), [accountInfo]); + const keysPublic: BakerKeysWithProofs = useMemo( + () => ({ + proofSig: keysFull.proofSig, + proofElection: keysFull.proofElection, + proofAggregation: keysFull.proofAggregation, + electionVerifyKey: keysFull.electionVerifyKey, + signatureVerifyKey: keysFull.signatureVerifyKey, + aggregationVerifyKey: keysFull.aggregationVerifyKey, + }), + [keysFull] + ); + + const exportKeys = useCallback(() => { + saveData(getBakerKeyExport(keysFull, accountInfo), KEYS_FILENAME); + setExported(true); + }, [keysFull]); -export default function ValidatorKeys() { return ( -
-
- Validator keys - on Accout 1 / 6gk...k7o -
- + + + Your new validator keys have been generated. Before you can continue, you must export and save them. The - keys will have to be added to the validator node. Besides exporting the keys, you will have to finish - and submit the transaction afterwards for the validator to be registered. - -
-
- Election verify key - - 474564hhfjdjde5f8f9g7fnsnsjs9e7g8f7fs64d3s3f6vb90f9d8d8dd66d - -
-
- Signature verify key - - 9f6g5e6g8gh9g9r7d4fghgfdx76gv5b4hg4fd5sxs9cvbn9m9nhgf77dfgh - -
-
- Aggregation verify key - - 4f84fg3gb6d9s9s3s1d46gg9grf7jmf9xc5c7s5x3vn80b8c6x5x4f84fg3gb6d9s9s3s1d46gg9grf7jmf9xc5c7s5x3vn80b8c6x5x4f84fg3gb6d9s9s3s1d46gg9grf7jmf9xc5c7s5x3vn80b8c6x5x4f84fg3gb6d9s9s3s1d46gg9grf7jmf9xc5c7s5x3vn80b8c6x5xdjd9f7g66673 - -
-
-
- - Export validator keys + keys will have to be added to the validator node. + + + + + + + + + + + + +
+ } + label={expand ? 'Show less' : 'Show full'} + onClick={() => setExpand((v) => !v)} + /> + } label="Export keys as .json" onClick={exportKeys} />
-
+ {exported && ( + + onSubmit(keysPublic)} /> + + )} + ); } diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/TransactionFlow.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/TransactionFlow.tsx index c502963b8..1e107ca41 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/TransactionFlow.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/TransactionFlow.tsx @@ -15,6 +15,7 @@ import { ValidatorForm, ValidatorFormExisting, configureValidatorFromForm } from import ValidatorStake from './Stake'; import { type ValidationResultLocationState } from './Result'; import OpenPool from './OpenPool'; +import Keys from './Keys'; // TODO: // - Form steps @@ -75,6 +76,11 @@ function ValidatorTransactionFlow({ existingValues, title }: Props) { return ; }, }, + keys: { + render(_, onNext) { + return ; + }, + }, }} ); diff --git a/packages/browser-wallet/src/popup/popupX/shared/Card/Card.scss b/packages/browser-wallet/src/popup/popupX/shared/Card/Card.scss index 4a0d9419e..4fd24c999 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Card/Card.scss +++ b/packages/browser-wallet/src/popup/popupX/shared/Card/Card.scss @@ -28,6 +28,7 @@ &.details { flex-direction: column; + max-width: 100%; .capture__main_small { color: $color-white; From d9e62fd16b22a35c8d12b61581c32e5aebc62f76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Tue, 5 Nov 2024 13:22:32 +0100 Subject: [PATCH 04/11] Add keys to result page --- .../Validator/Result/ValidationResult.tsx | 27 +++ .../Validator/Result/ValidatorResult.tsx | 193 ------------------ .../Validator/Stake/ValidatorStake.tsx | 27 ++- .../popupX/pages/EarningRewards/i18n/en.ts | 3 + .../popupX/shared/Form/TokenAmount/View.tsx | 16 +- .../src/popup/popupX/shared/i18n/en.ts | 2 +- 6 files changed, 71 insertions(+), 197 deletions(-) delete mode 100644 packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Result/ValidatorResult.tsx diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Result/ValidationResult.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Result/ValidationResult.tsx index 38b150dcc..10f1c2fd6 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Result/ValidationResult.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Result/ValidationResult.tsx @@ -110,6 +110,33 @@ export default function ValidationResult() { /> )} + {state.payload.metadataUrl !== undefined && ( + + + + )} + {state.payload.keys !== undefined && ( + <> + + + + + + + + + + + )} { - 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 ValidationResultLocationState = { - payload: ConfigureBakerPayload; - type: 'register' | 'change' | 'remove'; -}; - -export default function DelegationResult() { - const { state } = useLocation() as Location & { - state: ValidationResultLocationState | undefined; - }; - const nav = useNavigate(); - const { t } = useTranslation('x', { keyPrefix: 'earn.validator' }); - const getCost = useGetTransactionFee(AccountTransactionType.ConfigureBaker); - const accountInfo = ensureDefined(useSelectedAccountInfo(), 'No account selected'); - - const parametersV1 = useBlockChainParametersAboveV0(); - const submitTransaction = useTransactionSubmit(accountInfo.accountAddress, AccountTransactionType.ConfigureBaker); - - const cooldown = useMemo(() => { - let cooldownParam = 0n; - if (parametersV1 !== undefined) { - cooldownParam = cpStakingCooldown(parametersV1); - } - return secondsToDaysRoundedDown(cooldownParam); - }, [parametersV1]); - - const [title, notice] = useMemo(() => { - switch (state?.type) { - case 'register': - return [t('register.title'), t('register.notice', { cooldown })]; - case 'change': - if ( - state.payload.stake === undefined || - state.payload.stake.microCcdAmount >= - (accountInfo as AccountInfoBaker).accountBaker.stakedAmount.microCcdAmount - ) { - // Staked amount is not lowered - return [t('update.title')]; - } - return [t('update.title'), t('update.lowerStakeNotice', { cooldown })]; - case 'remove': - return [t('remove.title'), t('remove.notice', { cooldown })]; - default: - throw new Error("'type' must be defined on route state"); - } - }, [state, t, cooldown]); - - 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))); - }; - - // TODO: - // - Add the rest of the transaction fields - return ( - - - {notice !== undefined && {notice}} - - - - - {state.payload.stake !== undefined && ( - - - - )} - {state.payload.restakeEarnings !== undefined && ( - - - - )} - {state.payload.openForDelegation !== undefined && ( - - - - )} - - - - - - - - - ); -} diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Stake/ValidatorStake.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Stake/ValidatorStake.tsx index 8b33f0a01..bed986fd8 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Stake/ValidatorStake.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Stake/ValidatorStake.tsx @@ -1,5 +1,5 @@ /* eslint-disable react/destructuring-assignment */ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { ReactNode, useEffect, useMemo, useState } from 'react'; import { UseFormReturn } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { AccountTransactionType, CcdAmount, ConfigureBakerPayload, OpenStatus } from '@concordium/web-sdk'; @@ -19,6 +19,7 @@ import FullscreenNotice, { FullscreenNoticeProps } from '@popup/popupX/shared/Fu import { METADATAURL_MAX_LENGTH } from '@shared/constants/baking'; import { STAKE_WARNING_THRESHOLD, isAboveStakeWarningThreshold } from '../../util'; import { ValidatorStakeForm } from '../util'; +import { displayAsCcd } from 'wallet-common-helpers'; type HighStakeNoticeProps = FullscreenNoticeProps & { onContinue(): void; @@ -62,6 +63,8 @@ const PAYLOAD_MAX: ConfigureBakerPayload = { metadataUrl: 'a'.repeat(METADATAURL_MAX_LENGTH), }; +const PAYLOAD_MIN: ConfigureBakerPayload = { ...PAYLOAD_MAX, metadataUrl: '' }; + type Props = { /** The title for the configuriation step */ title: string; @@ -108,6 +111,14 @@ export default function ValidatorStake({ title, initialValues, existingValues, o } }, [getCost, existingValues, values]); + const minFee = useMemo(() => { + if (existingValues !== undefined) { + return undefined; // We know the cost as we don't depend on values set later in the flow + } + + return getCost(PAYLOAD_MIN); + }, [getCost, existingValues]); + useEffect(() => { if (selectedAccountInfo === undefined || fee === undefined) { return; @@ -126,6 +137,19 @@ export default function ValidatorStake({ title, initialValues, existingValues, o } }, [selectedAccountInfo?.accountAmount, fee]); + const formatFee: undefined | ((v: CcdAmount.Type) => ReactNode) = useMemo(() => { + if (minFee === undefined) { + return undefined; + } + + // eslint-disable-next-line react/function-component-definition + return (v) => ( +
+ {displayAsCcd(minFee, false, true)} - {displayAsCcd(v, false, true)} +
+ ); + }, [minFee]); + if (selectedAccountInfo === undefined || selectedCred === undefined || fee === undefined) { return null; } @@ -159,6 +183,7 @@ export default function ValidatorStake({ title, initialValues, existingValues, o className="register-validator__token-card" accountInfo={selectedAccountInfo} fee={fee} + formatFee={formatFee} tokenType="ccd" buttonMaxLabel={t('inputAmount.buttonMax')} form={f as unknown as UseFormReturn} 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 594823876..86f400abe 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 @@ -159,6 +159,9 @@ const t = { public: 'Added to public balance', }, metadataUrl: { label: 'Metadata URL' }, + electionKey: { label: 'Election verify key' }, + signatureKey: { label: 'Signature verify key' }, + aggregationKey: { label: 'Aggregation verify key' }, }, status: { title: 'Your validation is registered', 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 6d13944a9..a8e526463 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 @@ -227,6 +227,8 @@ export type TokenAmountViewProps = { buttonMaxLabel: string; /** The fee associated with the transaction */ fee: CcdAmount.Type; + /** A custom function for formatting the fee. Defaults to `displayAsCcd(fee, false, true)` */ + formatFee?(fee: CcdAmount.Type): ReactNode; /** The set of tokens available for the account specified by `accountInfo` */ tokens: TokenInfo[]; /** The token balance. `undefined` should be used to indicate that the balance is not yet available. */ @@ -247,7 +249,15 @@ export type TokenAmountViewProps = { */ export default function TokenAmountView(props: TokenAmountViewProps) { const { t } = useTranslation('x', { keyPrefix: 'sharedX' }); - const { buttonMaxLabel, fee, tokens, balance, onSelectToken, className } = props; + const { + buttonMaxLabel, + fee, + tokens, + balance, + onSelectToken, + className, + formatFee = (f) => displayAsCcd(f, false, true), + } = props; const [selectedToken, setSelectedToken] = useState(() => { switch (props.tokenType) { case 'cis2': { @@ -396,7 +406,9 @@ 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')} {formatFee(fee)} +
{props.receiver === true && (
diff --git a/packages/browser-wallet/src/popup/popupX/shared/i18n/en.ts b/packages/browser-wallet/src/popup/popupX/shared/i18n/en.ts index 49b2586af..a104419a4 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/i18n/en.ts +++ b/packages/browser-wallet/src/popup/popupX/shared/i18n/en.ts @@ -16,7 +16,7 @@ const t = { }, amount: { label: 'Amount', - fee: 'Estimated transaction fee: {{fee}}', + fee: 'Estimated transaction fee:', }, address: { label: 'Receiver address', From 33d072557daed277bce90b4666643c102b8eefd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Tue, 5 Nov 2024 15:21:34 +0100 Subject: [PATCH 05/11] Metadata page --- .../Validator/Metadata/Metadata.scss | 3 ++ .../Validator/Metadata/Metadata.tsx | 52 +++++++++++++++++++ .../Validator/Metadata/index.ts | 1 + .../Validator/TransactionFlow.tsx | 7 +++ .../popupX/pages/EarningRewards/i18n/en.ts | 12 +++++ .../src/popup/popupX/styles/_elements.scss | 1 + 6 files changed, 76 insertions(+) create mode 100644 packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Metadata/Metadata.scss create mode 100644 packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Metadata/Metadata.tsx create mode 100644 packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Metadata/index.ts diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Metadata/Metadata.scss b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Metadata/Metadata.scss new file mode 100644 index 000000000..f691c9e93 --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Metadata/Metadata.scss @@ -0,0 +1,3 @@ +.validator-metadata { + gap: rem(16px); +} diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Metadata/Metadata.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Metadata/Metadata.tsx new file mode 100644 index 000000000..323db1e75 --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Metadata/Metadata.tsx @@ -0,0 +1,52 @@ +import React, { useCallback } from 'react'; +import Page from '@popup/popupX/shared/Page'; +import { useTranslation } from 'react-i18next'; +import Text from '@popup/popupX/shared/Text'; +import Form, { useForm } from '@popup/popupX/shared/Form'; +import FormInput from '@popup/popupX/shared/Form/Input/Input'; +import { METADATAURL_MAX_LENGTH } from '@shared/constants/baking'; +import Button from '@popup/popupX/shared/Button'; + +type MetadataForm = { value: string }; + +type Props = { + initial?: string; + onSubmit(value: string): void; +}; + +export default function Metadata({ initial, onSubmit }: Props) { + const { t } = useTranslation('x', { keyPrefix: 'earn.validator.metadata' }); + const form = useForm({ defaultValues: { value: initial ?? '' } }); + + const submit = useCallback( + (v: MetadataForm) => { + onSubmit(v.value); + }, + [onSubmit] + ); + + return ( + + + {t('description')} + formMethods={form} onSubmit={submit}> + {(f) => ( + + )} + + + + + + ); +} diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Metadata/index.ts b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Metadata/index.ts new file mode 100644 index 000000000..05c0943f3 --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Metadata/index.ts @@ -0,0 +1 @@ +export { default } from './Metadata'; diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/TransactionFlow.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/TransactionFlow.tsx index 1e107ca41..6fb93bf4b 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/TransactionFlow.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/TransactionFlow.tsx @@ -16,6 +16,7 @@ import ValidatorStake from './Stake'; import { type ValidationResultLocationState } from './Result'; import OpenPool from './OpenPool'; import Keys from './Keys'; +import Metadata from './Metadata'; // TODO: // - Form steps @@ -76,6 +77,12 @@ function ValidatorTransactionFlow({ existingValues, title }: Props) { return ; }, }, + // commissions: {}, // TODO: ... + metadataUrl: { + render(initial, onNext) { + return ; + }, + }, keys: { render(_, onNext) { return ; 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 86f400abe..15eb0c03a 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 @@ -247,6 +247,18 @@ const t = { 'Opening a pool\nYou have the option when adding a validator to open a staking pool or not. A staking pool allows others who want to earn rewards to do so without the need to run a node or become a validator themselves.\n\nTo do this they delegate an amount to your staking pool which then increases your total stake and your chances of winning the lottery to bake a block. At each pay day the rewards will be distributed to you and your delegators.\n\nYou can also choose not to open a pool, in which case only your own stake applies toward the lottery. You can always open or close a pool later.', buttonContinue: 'Continue', }, + metadata: { + title: 'Metadata', + description: + 'You can choose to add a URL with metadata about your validator. Leave it blank if you don’t have any.', + field: { + label: 'Enter metadata URL', + error: { + maxLength: 'Cannot exceed {{max}} characters', + }, + }, + buttonContinue: 'Continue', + }, submit: { backTitle: 'Validation settings', sender: { label: 'Sender' }, diff --git a/packages/browser-wallet/src/popup/popupX/styles/_elements.scss b/packages/browser-wallet/src/popup/popupX/styles/_elements.scss index e30c528a2..9168789e3 100644 --- a/packages/browser-wallet/src/popup/popupX/styles/_elements.scss +++ b/packages/browser-wallet/src/popup/popupX/styles/_elements.scss @@ -25,6 +25,7 @@ @import '../pages/EarningRewards/Validator/Result/ValidationResult'; @import '../pages/EarningRewards/Validator/OpenPool/OpenPool'; @import '../pages/EarningRewards/Validator/Keys/ValidatorKeys'; +@import '../pages/EarningRewards/Validator/Metadata/Metadata'; @import '../pages/EarningRewards/Delegator/Intro/DelegatorIntro'; @import '../pages/EarningRewards/Delegator/Type/DelegationType'; @import '../pages/EarningRewards/Delegator/Stake/DelegatorStake'; From 523f5b968e8f786482682a2ca1d01d2f8e62c107 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Tue, 5 Nov 2024 15:32:23 +0100 Subject: [PATCH 06/11] Make keys page recoverable --- .../Validator/Keys/ValidatorKeys.tsx | 36 ++++++++----------- .../Validator/Stake/ValidatorStake.tsx | 2 +- .../Validator/TransactionFlow.tsx | 4 +-- .../pages/EarningRewards/Validator/util.ts | 4 +-- 4 files changed, 19 insertions(+), 27 deletions(-) diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Keys/ValidatorKeys.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Keys/ValidatorKeys.tsx index d23154c8f..daebdd85a 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Keys/ValidatorKeys.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Keys/ValidatorKeys.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useMemo, useState } from 'react'; -import { BakerKeysWithProofs, generateBakerKeys } from '@concordium/web-sdk'; +import { GenerateBakerKeysOutput, generateBakerKeys } from '@concordium/web-sdk'; import ExportIcon from '@assets/svgX/sign-out.svg'; import Caret from '@assets/svgX/caret-right.svg'; @@ -15,29 +15,21 @@ import clsx from 'clsx'; const KEYS_FILENAME = 'validator-credentials.json'; -type Props = { onSubmit(keys: BakerKeysWithProofs): void }; +type Props = { + initial: GenerateBakerKeysOutput | undefined; + onSubmit(keys: GenerateBakerKeysOutput): void; +}; -export default function ValidatorKeys({ onSubmit }: Props) { +export default function ValidatorKeys({ onSubmit, initial }: Props) { const [expand, setExpand] = useState(false); const [exported, setExported] = useState(false); const accountInfo = ensureDefined(useSelectedAccountInfo(), 'Expected seleted account'); - const keysFull = useMemo(() => generateBakerKeys(accountInfo.accountAddress), [accountInfo]); - const keysPublic: BakerKeysWithProofs = useMemo( - () => ({ - proofSig: keysFull.proofSig, - proofElection: keysFull.proofElection, - proofAggregation: keysFull.proofAggregation, - electionVerifyKey: keysFull.electionVerifyKey, - signatureVerifyKey: keysFull.signatureVerifyKey, - aggregationVerifyKey: keysFull.aggregationVerifyKey, - }), - [keysFull] - ); + const keys = useMemo(() => initial ?? generateBakerKeys(accountInfo.accountAddress), [initial, accountInfo]); const exportKeys = useCallback(() => { - saveData(getBakerKeyExport(keysFull, accountInfo), KEYS_FILENAME); + saveData(getBakerKeyExport(keys, accountInfo), KEYS_FILENAME); setExported(true); - }, [keysFull]); + }, [keys]); return ( @@ -48,13 +40,13 @@ export default function ValidatorKeys({ onSubmit }: Props) { - + - + - +
@@ -66,9 +58,9 @@ export default function ValidatorKeys({ onSubmit }: Props) { /> } label="Export keys as .json" onClick={exportKeys} />
- {exported && ( + {(exported || initial !== undefined) && ( - onSubmit(keysPublic)} /> + onSubmit(keys)} /> )}
diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Stake/ValidatorStake.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Stake/ValidatorStake.tsx index bed986fd8..6948ee69c 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Stake/ValidatorStake.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Stake/ValidatorStake.tsx @@ -17,9 +17,9 @@ import { useGetTransactionFee } from '@popup/shared/utils/transaction-helpers'; import FullscreenNotice, { FullscreenNoticeProps } from '@popup/popupX/shared/FullscreenNotice'; import { METADATAURL_MAX_LENGTH } from '@shared/constants/baking'; +import { displayAsCcd } from 'wallet-common-helpers'; import { STAKE_WARNING_THRESHOLD, isAboveStakeWarningThreshold } from '../../util'; import { ValidatorStakeForm } from '../util'; -import { displayAsCcd } from 'wallet-common-helpers'; type HighStakeNoticeProps = FullscreenNoticeProps & { onContinue(): void; diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/TransactionFlow.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/TransactionFlow.tsx index 6fb93bf4b..55b8d0d15 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/TransactionFlow.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/TransactionFlow.tsx @@ -84,8 +84,8 @@ function ValidatorTransactionFlow({ existingValues, title }: Props) { }, }, keys: { - render(_, onNext) { - return ; + render(initial, onNext) { + return ; }, }, }} diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/util.ts b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/util.ts index 994ea55f1..681212ac7 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/util.ts +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/util.ts @@ -1,8 +1,8 @@ import { - BakerKeysWithProofs, CcdAmount, CommissionRates, ConfigureBakerPayload, + GenerateBakerKeysOutput, OpenStatus, OpenStatusText, } from '@concordium/web-sdk'; @@ -39,7 +39,7 @@ export function showValidatorRestake(value: boolean): string { export type ValidatorStakeForm = { amount: string; restake: boolean }; export type ValidatorFormUpdateStake = { stake: ValidatorStakeForm }; -export type ValidatorFormUpdateKeys = { keys: BakerKeysWithProofs }; +export type ValidatorFormUpdateKeys = { keys: GenerateBakerKeysOutput }; export type ValidatorFormUpdateSettings = { status: OpenStatusText; commissions: CommissionRates; From c4f3d89d751150c3d27c8bd3c4268f7694b29c8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Wed, 6 Nov 2024 08:44:03 +0100 Subject: [PATCH 07/11] Add slider form component --- .../src/popup/popupX/shared/Form/Form.scss | 43 +++++- .../Form/InlineInput/InlineInput.stories.tsx | 47 +++++++ .../shared/Form/InlineInput/InlineInput.tsx | 75 ++++++++++ .../popupX/shared/Form/InlineInput/index.ts | 1 + .../popup/popupX/shared/Form/Input/Input.tsx | 12 +- .../popupX/shared/Form/Slider/Slider.scss | 43 ++++++ .../shared/Form/Slider/Slider.stories.tsx | 54 ++++++++ .../popupX/shared/Form/Slider/Slider.tsx | 128 ++++++++++++++++++ .../popup/popupX/shared/Form/Slider/index.ts | 1 + .../src/popup/popupX/styles/_elements.scss | 1 + 10 files changed, 400 insertions(+), 5 deletions(-) create mode 100644 packages/browser-wallet/src/popup/popupX/shared/Form/InlineInput/InlineInput.stories.tsx create mode 100644 packages/browser-wallet/src/popup/popupX/shared/Form/InlineInput/InlineInput.tsx create mode 100644 packages/browser-wallet/src/popup/popupX/shared/Form/InlineInput/index.ts create mode 100644 packages/browser-wallet/src/popup/popupX/shared/Form/Slider/Slider.scss create mode 100644 packages/browser-wallet/src/popup/popupX/shared/Form/Slider/Slider.stories.tsx create mode 100644 packages/browser-wallet/src/popup/popupX/shared/Form/Slider/Slider.tsx create mode 100644 packages/browser-wallet/src/popup/popupX/shared/Form/Slider/index.ts 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 2fd8b3dd3..0e1f1d0e5 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/Form.scss +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/Form.scss @@ -115,6 +115,40 @@ } } +.form-inline-input { + appearance: textfield; + border: none; + padding: 0; + color: inherit; + font-size: inherit; + font-family: inherit; + font-weight: inherit; + line-height: inherit; + background: none; + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + appearance: none; + margin: 0; + } + + &:focus { + outline: none; + } + + &:disabled { + color: $color-grey-3; + } + + &:read-only { + color: inherit; + } + + @include when-invalid { + color: $color-red-attention !important; + } +} + .form-password { position: relative; @@ -131,6 +165,7 @@ transform: unset; svg { + g, path { fill: $color-mineral-3; @@ -160,11 +195,11 @@ $handle-size: rem(20px); .form-toggle-x { &__root { - input:checked + .form-toggle-x__slider { + input:checked+.form-toggle-x__slider { background-color: $color-green-toggle; } - input:checked + .form-toggle-x__slider::before { + input:checked+.form-toggle-x__slider::before { transform: translateX(rem(24px)); } } @@ -244,11 +279,11 @@ $handle-size: rem(20px); } } - &:hover input ~ .checkmark { + &:hover input~.checkmark { background-color: $color-grey-3; } - input:checked ~ .checkmark::after { + input:checked~.checkmark::after { display: block; } diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/InlineInput/InlineInput.stories.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/InlineInput/InlineInput.stories.tsx new file mode 100644 index 000000000..cfb667417 --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/InlineInput/InlineInput.stories.tsx @@ -0,0 +1,47 @@ +/* eslint-disable react/function-component-definition */ +import React, { useState } from 'react'; +import { Meta, StoryObj } from '@storybook/react'; +import { InlineInput } from './InlineInput'; + +export default { + title: 'X/Shared/Form/InlineInput', + component: InlineInput, +} as Meta; + +type Story = StoryObj; + +const render: Story['render'] = (args) => { + const [value, setValue] = useState(); + + return ( + <> + I want to pay: setValue(v)} /> CCD + + ); +}; + +export const Text: Story = { + render, + args: { + label: 'Label', + type: 'text', + fallbackValue: '0', + }, +}; +export const Number: Story = { + render, + args: { + label: 'Label', + type: 'number', + fallbackValue: '0', + }, +}; +export const Invalid: Story = { + render, + args: { + label: 'Label', + type: 'text', + error: 'This is an error', + fallbackValue: '0', + }, +}; diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/InlineInput/InlineInput.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/InlineInput/InlineInput.tsx new file mode 100644 index 000000000..79e8e566e --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/InlineInput/InlineInput.tsx @@ -0,0 +1,75 @@ +import clsx from 'clsx'; +import React, { InputHTMLAttributes, useCallback, useLayoutEffect, useRef, useState } from 'react'; +import { noOp, useUpdateEffect } from 'wallet-common-helpers'; +import { scaleFieldWidth } from '@popup/shared/utils/html-helpers'; +import { CommonFieldProps, RequiredControlledFieldProps } from '../common/types'; +import { makeControlled } from '../common/utils'; + +type Props = Pick< + InputHTMLAttributes, + 'type' | 'className' | 'autoFocus' | 'onKeyUp' | 'onMouseUp' | 'maxLength' | 'min' | 'max' +> & + RequiredControlledFieldProps & + CommonFieldProps & { + fallbackValue?: string; + fallbackOnError?: boolean; + fixedWidth?: number; + }; + +export function InlineInput({ + className, + type = 'text', + value, + fallbackValue, + fallbackOnError = false, + onChange = noOp, + onBlur = noOp, + fixedWidth, + error, + ...props +}: Props) { + const ref = useRef(null); + const [innerValue, setInnerValue] = useState(value ?? fallbackValue); + + useLayoutEffect(() => { + if (!fixedWidth) { + scaleFieldWidth(ref.current); + } + }, [innerValue]); + + useUpdateEffect(() => { + setInnerValue(value); + }, [value]); + + const handleBlur = useCallback(() => { + if (fallbackValue === undefined) { + onBlur(); + return; + } + + if (!value || (fallbackOnError && error)) { + onChange(fallbackValue); + } + + onBlur(); + }, [onBlur, value, fallbackValue, onChange]); + + return ( + onChange(e.currentTarget.value)} + onBlur={handleBlur} + ref={ref} + autoComplete="off" + spellCheck="false" + {...props} + style={{ width: fixedWidth || 6 }} // To prevent initial UI jitter. + /> + ); +} + +const FormInlineInput = makeControlled(InlineInput); +export default FormInlineInput; diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/InlineInput/index.ts b/packages/browser-wallet/src/popup/popupX/shared/Form/InlineInput/index.ts new file mode 100644 index 000000000..dc3b457b6 --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/InlineInput/index.ts @@ -0,0 +1 @@ +export { default, InlineInput } from './InlineInput'; diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/Input/Input.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/Input/Input.tsx index fbecd693f..b5afa01ef 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/Input/Input.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/Input/Input.tsx @@ -7,7 +7,17 @@ import ErrorMessage from '../ErrorMessage'; type Props = Pick< InputHTMLAttributes, - 'className' | 'type' | 'value' | 'onChange' | 'onBlur' | 'autoFocus' | 'readOnly' | 'placeholder' + | 'className' + | 'type' + | 'value' + | 'onChange' + | 'onBlur' + | 'autoFocus' + | 'readOnly' + | 'placeholder' + | 'step' + | 'min' + | 'max' > & RequiredUncontrolledFieldProps & CommonFieldProps; diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/Slider/Slider.scss b/packages/browser-wallet/src/popup/popupX/shared/Form/Slider/Slider.scss new file mode 100644 index 000000000..acef0ba4a --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/Slider/Slider.scss @@ -0,0 +1,43 @@ +.form-slider-x { + position: relative; + cursor: pointer; + height: rem(63px); + padding: rem(28px) rem(14px) rem(19px); + + @include when-valid { + &:where(:focus) { + border-color: $color-green-success; + } + } + + @include when-invalid { + border-color: $color-red-attention; + } + + &__slider { + position: absolute; + bottom: rem(-6px); + left: rem(4px); + width: calc(100% - rem(8px)); + + &, + & .rc-slider-rail, + & .rc-slider-track { + border-bottom-left-radius: rem(16px); + border-bottom-right-radius: rem(16px); + border-top-left-radius: 0; + border-top-right-radius: 0; + } + + .rc-slider-track { + background-color: $color-green-success; + } + + .rc-slider-handle { + border: none; + height: rem(20px); + width: rem(20px); + margin-top: rem(-8px); + } + } +} diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/Slider/Slider.stories.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/Slider/Slider.stories.tsx new file mode 100644 index 000000000..6073e0652 --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/Slider/Slider.stories.tsx @@ -0,0 +1,54 @@ +/* eslint-disable react/function-component-definition */ +import React, { useState } from 'react'; +import { Meta, StoryObj } from '@storybook/react'; +import { Slider } from './Slider'; + +const render: Story['render'] = (args) => { + const [value, setValue] = useState(); + + return ; +}; + +export default { + title: 'X/Shared/Form/Slider', + component: Slider, + render, + beforeEach: () => { + const body = document.getElementsByTagName('body').item(0); + body?.classList.add('popup-x'); + + return () => { + body?.classList.remove('popup-x'); + }; + }, +} as Meta; + +type Story = StoryObj; +export const Integer: Story = { + render, + args: { + label: 'Label', + min: 50, + max: 75, + }, +}; +export const Percent: Story = { + render, + args: { + label: 'Label', + unit: '%', + step: 0.01, + min: 0, + max: 100, + }, +}; +export const Invalid: Story = { + render, + args: { + label: 'Label', + min: 0, + max: 100, + error: 'This is an error', + isInvalid: true, + }, +}; diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/Slider/Slider.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/Slider/Slider.tsx new file mode 100644 index 000000000..fc74c4151 --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/Slider/Slider.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import RcSlider from 'rc-slider'; +import clsx from 'clsx'; +import { noOp } from 'wallet-common-helpers'; +import { CommonFieldProps, RequiredControlledFieldProps } from '../common/types'; +import { InlineInput } from '../InlineInput'; +import { makeControlled } from '../common/utils'; +import ErrorMessage from '../ErrorMessage'; +import Text from '../../Text'; + +interface Props extends CommonFieldProps, RequiredControlledFieldProps { + /** The minimum value of the slider. */ + min: number; + /** The maximum value of the slider. */ + max: number; + /** The step value of the slider. */ + step: number; + /** The unit to display next to the value. */ + unit?: string; + /** The current value of the slider. */ + value: number | undefined; + /** Change handler callback */ + onChange?(value: number | undefined): void; + /** Additional class names for the slider. */ + className?: string; + /** The name for the input field */ + name: string; + /** Flag to indicate if the slider is in an invalid state. */ + isInvalid?: boolean; +} + +/** + * Slider component to select a value within a range. + * + * @example + * const [value, setValue] = useState(); + * + */ +export function Slider({ + min, + max, + step, + label, + unit = '', + onChange = noOp, + onBlur = noOp, + value, + className, + name, + isInvalid, + error, +}: Props) { + const handleChange = (v: string) => { + if (v !== '') { + onChange(Number(v)); + } else { + onChange(undefined); + } + }; + const parsed = value ?? ''; + + if (min > max) { + throw new Error('Prop "min" must be lower that prop "max"'); + } + + return ( + // eslint-disable-next-line jsx-a11y/label-has-associated-control + <> + + {error} + + ); +} + +/** + * Slider component to select a value within a range in the context of a `
` + * + * @example + * ...> + * {form => + * + * } + * + */ +const FormSlider = makeControlled(Slider); +export default FormSlider; diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/Slider/index.ts b/packages/browser-wallet/src/popup/popupX/shared/Form/Slider/index.ts new file mode 100644 index 000000000..9898d6a85 --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/Slider/index.ts @@ -0,0 +1 @@ +export { default } from './Slider'; diff --git a/packages/browser-wallet/src/popup/popupX/styles/_elements.scss b/packages/browser-wallet/src/popup/popupX/styles/_elements.scss index 9168789e3..40ac2add0 100644 --- a/packages/browser-wallet/src/popup/popupX/styles/_elements.scss +++ b/packages/browser-wallet/src/popup/popupX/styles/_elements.scss @@ -47,5 +47,6 @@ @import '../shared/Carousel/Carousel'; @import '../shared/Form/Form'; @import '../shared/Form/TokenAmount/TokenAmount'; +@import '../shared/Form/Slider/Slider'; @import '../shared/FullscreenNotice/FullscreenNotice'; @import '../shared/EditableValue/EditableValue'; From 4e149a8948851204943e968054d9dd715b9d91b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Wed, 6 Nov 2024 15:55:57 +0100 Subject: [PATCH 08/11] Add commissions page to validator flow --- .../Validator/Commissions/Commissions.scss | 15 +++ .../Validator/Commissions/Commissions.tsx | 110 ++++++++++++++++++ .../Validator/Commissions/index.ts | 1 + .../Validator/Metadata/Metadata.scss | 4 + .../Validator/OpenPool/OpenPool.scss | 4 + .../Validator/Result/ValidationResult.tsx | 44 ++++++- .../pages/EarningRewards/Validator/Status.tsx | 10 +- .../Validator/TransactionFlow.tsx | 17 ++- .../pages/EarningRewards/Validator/util.ts | 18 ++- .../popupX/pages/EarningRewards/i18n/en.ts | 23 ++++ .../src/popup/popupX/styles/_elements.scss | 1 + 11 files changed, 234 insertions(+), 13 deletions(-) create mode 100644 packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Commissions/Commissions.scss create mode 100644 packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Commissions/Commissions.tsx create mode 100644 packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Commissions/index.ts diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Commissions/Commissions.scss b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Commissions/Commissions.scss new file mode 100644 index 000000000..4f4f57277 --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Commissions/Commissions.scss @@ -0,0 +1,15 @@ +.validator-commissions { + .page__top { + margin-bottom: 0 !important; + } + + &, + &__form { + gap: rem(16px); + } + + &__form { + display: flex; + flex-direction: column; + } +} diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Commissions/Commissions.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Commissions/Commissions.tsx new file mode 100644 index 000000000..3f8886fba --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Commissions/Commissions.tsx @@ -0,0 +1,110 @@ +import React, { useCallback, useMemo } from 'react'; +import Page from '@popup/popupX/shared/Page'; +import { useTranslation } from 'react-i18next'; +import Text from '@popup/popupX/shared/Text'; +import Button from '@popup/popupX/shared/Button'; +import { ChainParameters, ChainParametersV0, CommissionRange, CommissionRates } from '@concordium/web-sdk'; +import Form, { useForm } from '@popup/popupX/shared/Form'; +import FormSlider from '@popup/popupX/shared/Form/Slider/Slider'; +import { PropsOf } from 'wallet-common-helpers'; +import { isRange } from '../util'; + +const COMMISSION_STEP = 0.001; + +type Props = { + initial?: CommissionRates; + onSubmit(values: CommissionRates): void; + chainParams: Exclude; +}; + +export default function Commissions({ initial, onSubmit, chainParams }: Props) { + const { t } = useTranslation('x', { keyPrefix: 'earn.validator.commissions' }); + const { bakingCommissionRange, finalizationCommissionRange, transactionCommissionRange } = chainParams; + const defaultValues: CommissionRates = useMemo( + () => ({ + bakingCommission: (initial?.bakingCommission ?? bakingCommissionRange.min) * 100, + transactionCommission: (initial?.transactionCommission ?? transactionCommissionRange.min) * 100, + finalizationCommission: (initial?.finalizationCommission ?? finalizationCommissionRange.min) * 100, + }), + [initial, chainParams] + ); + const form = useForm({ defaultValues }); + + const commissionRules = useCallback( + (range: CommissionRange): PropsOf['rules'] => { + const min = range.min * 100; + const max = range.max * 100; + return { + min: { value: min, message: t('error.min', { min }) }, + max: { value: max, message: t('error.max', { max }) }, + required: t('error.required'), + }; + }, + [t] + ); + + const handleSubmit = useCallback( + (values: CommissionRates) => { + const fractions: CommissionRates = { + transactionCommission: values.transactionCommission / 100, + bakingCommission: values.bakingCommission / 100, + finalizationCommission: values.finalizationCommission / 100, + }; + + onSubmit(fractions); + }, + [onSubmit] + ); + + return ( + + + {t('desciption')} +
+ {(f) => ( + <> + {isRange(transactionCommissionRange) && ( + + )} + {isRange(bakingCommissionRange) && ( + + )} + {isRange(finalizationCommissionRange) && ( + + )} + + )} + + + + +
+ ); +} diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Commissions/index.ts b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Commissions/index.ts new file mode 100644 index 000000000..906900b6f --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Commissions/index.ts @@ -0,0 +1 @@ +export { default } from './Commissions'; diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Metadata/Metadata.scss b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Metadata/Metadata.scss index f691c9e93..77844f744 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Metadata/Metadata.scss +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Metadata/Metadata.scss @@ -1,3 +1,7 @@ .validator-metadata { gap: rem(16px); + + .page__top { + margin-bottom: 0 !important; + } } diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/OpenPool/OpenPool.scss b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/OpenPool/OpenPool.scss index 34769b2e2..33312eb35 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/OpenPool/OpenPool.scss +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/OpenPool/OpenPool.scss @@ -1,4 +1,8 @@ .open-pool-container { + .page__top { + margin-bottom: 0 !important; + } + .open-pool { &__title { display: flex; diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Result/ValidationResult.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Result/ValidationResult.tsx index 10f1c2fd6..e00cdee6b 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Result/ValidationResult.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Result/ValidationResult.tsx @@ -15,7 +15,13 @@ import { cpStakingCooldown } from '@shared/utils/chain-parameters-helpers'; import { submittedTransactionRoute } from '@popup/popupX/constants/routes'; import Text from '@popup/popupX/shared/Text'; import { useSelectedAccountInfo } from '@popup/shared/AccountInfoListenerContext/AccountInfoListenerContext'; -import { showValidatorAmount, showValidatorOpenStatus, showValidatorRestake } from '../util'; +import { + isRange, + showCommissionRate, + showValidatorAmount, + showValidatorOpenStatus, + showValidatorRestake, +} from '../util'; export type ValidationResultLocationState = { payload: ConfigureBakerPayload; @@ -67,6 +73,10 @@ export default function ValidationResult() { return ; } + if (parametersV1 === undefined) { + return null; + } + const fee = getCost(state.payload); const submit = async () => { if (fee === undefined) { @@ -110,9 +120,39 @@ export default function ValidationResult() { /> )} + {isRange(parametersV1.transactionCommissionRange) && + state.payload.transactionFeeCommission !== undefined && ( + + {' '} + + + )} + {isRange(parametersV1.bakingCommissionRange) && state.payload.bakingRewardCommission !== undefined && ( + + + + )} + {isRange(parametersV1.finalizationCommissionRange) && + state.payload.finalizationRewardCommission !== undefined && ( + + + + )} {state.payload.metadataUrl !== undefined && ( - + )} {state.payload.keys !== undefined && ( diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Status.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Status.tsx index 638342635..911f05e03 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Status.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Status.tsx @@ -1,15 +1,17 @@ import React from 'react'; import { AccountInfoType, CcdAmount } from '@concordium/web-sdk'; +import { useTranslation } from 'react-i18next'; +import { Navigate, useNavigate } from 'react-router-dom'; + import { absoluteRoutes } from '@popup/popupX/constants/routes'; import Button from '@popup/popupX/shared/Button'; import Card from '@popup/popupX/shared/Card'; import Page from '@popup/popupX/shared/Page'; -import { useTranslation } from 'react-i18next'; import { useSelectedAccountInfo } from '@popup/shared/AccountInfoListenerContext/AccountInfoListenerContext'; -import { Navigate, useNavigate } from 'react-router-dom'; + import AccountCooldowns from '../AccountCooldowns'; import { showValidatorRestake, showValidatorAmount, showValidatorOpenStatus } from './util'; -import { ValidationResultLocationState } from './Result/ValidatorResult'; +import { ValidationResultLocationState } from './Result'; const REMOVE_STATE: ValidationResultLocationState = { type: 'remove', @@ -60,7 +62,7 @@ export default function ValidatorStatus() { diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/TransactionFlow.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/TransactionFlow.tsx index 55b8d0d15..8035d86fe 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/TransactionFlow.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/TransactionFlow.tsx @@ -11,17 +11,18 @@ import { formatCcdAmount } from '@popup/popupX/shared/utils/helpers'; import FullscreenNotice, { FullscreenNoticeProps } from '@popup/popupX/shared/FullscreenNotice'; import Page from '@popup/popupX/shared/Page'; import Button from '@popup/popupX/shared/Button'; +import { useBlockChainParametersAboveV0 } from '@popup/shared/BlockChainParametersProvider'; + import { ValidatorForm, ValidatorFormExisting, configureValidatorFromForm } from './util'; import ValidatorStake from './Stake'; import { type ValidationResultLocationState } from './Result'; import OpenPool from './OpenPool'; import Keys from './Keys'; import Metadata from './Metadata'; - -// TODO: -// - Form steps +import Commissions from './Commissions'; // TODO: use this when implementing update flows +// eslint-disable-next-line @typescript-eslint/no-unused-vars function NoChangesNotice(props: FullscreenNoticeProps) { const { t } = useTranslation('x', { keyPrefix: 'earn.validator.update.noChangesNotice' }); return ( @@ -44,6 +45,7 @@ type Props = { function ValidatorTransactionFlow({ existingValues, title }: Props) { const { state, pathname } = useLocation() as Location & { state: ValidatorForm | undefined }; + const chainParams = useBlockChainParametersAboveV0(); const nav = useNavigate(); const initialValues = state ?? existingValues; @@ -77,7 +79,14 @@ function ValidatorTransactionFlow({ existingValues, title }: Props) { return ; }, }, - // commissions: {}, // TODO: ... + commissions: { + render(initial, onNext) { + if (chainParams === undefined) { + return null; + } + return ; + }, + }, metadataUrl: { render(initial, onNext) { return ; diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/util.ts b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/util.ts index 681212ac7..ad97ecc6a 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/util.ts +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/util.ts @@ -1,5 +1,6 @@ import { CcdAmount, + CommissionRange, CommissionRates, ConfigureBakerPayload, GenerateBakerKeysOutput, @@ -9,6 +10,8 @@ import { import { formatCcdAmount, parseCcdAmount } from '@popup/popupX/shared/utils/helpers'; import i18n from '@popup/shell/i18n'; +const FRACTION_RES = 100000; + export function showValidatorAmount(amount: CcdAmount.Type): string { return `${formatCcdAmount(amount)} CCD`; } @@ -35,6 +38,12 @@ export function showValidatorRestake(value: boolean): string { : i18n.t('x:earn.validator.values.restake.public'); } +export function showCommissionRate(fraction: number): string { + return `${(fraction * 100) / FRACTION_RES}%`; +} + +export const isRange = (range: CommissionRange) => range.min !== range.max; + /** The form data for specifying validator stake */ export type ValidatorStakeForm = { amount: string; restake: boolean }; @@ -52,6 +61,9 @@ export type ValidatorForm = ValidatorFormUpdateStake & ValidatorFormUpdateSettin /** The existing values needed to compare with updates */ export type ValidatorFormExisting = Omit; +const numToFraction = (value: number | undefined) => + value === undefined ? undefined : Math.floor(value * FRACTION_RES); + export function configureValidatorFromForm( values: Partial, existingValues?: ValidatorFormExisting @@ -87,15 +99,15 @@ export function configureValidatorFromForm( let bakingRewardCommission: number | undefined; if (values.commissions?.bakingCommission !== existingValues?.commissions.bakingCommission) { - bakingRewardCommission = values.commissions?.bakingCommission; + bakingRewardCommission = numToFraction(values.commissions?.bakingCommission); } let finalizationRewardCommission: number | undefined; if (values.commissions?.finalizationCommission !== existingValues?.commissions.finalizationCommission) { - finalizationRewardCommission = values.commissions?.finalizationCommission; + finalizationRewardCommission = numToFraction(values.commissions?.finalizationCommission); } let transactionFeeCommission: number | undefined; if (values.commissions?.transactionCommission !== existingValues?.commissions.transactionCommission) { - transactionFeeCommission = values.commissions?.transactionCommission; + transactionFeeCommission = numToFraction(values.commissions?.transactionCommission); } return { 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 15eb0c03a..d26bc705e 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 @@ -162,6 +162,9 @@ const t = { electionKey: { label: 'Election verify key' }, signatureKey: { label: 'Signature verify key' }, aggregationKey: { label: 'Aggregation verify key' }, + transactionFeeCommission: { label: 'Transaction fee commission' }, + bakingRewardCommission: { label: 'Baking reward commission' }, + finalizationRewardCommission: { label: 'Finalization reward commission' }, }, status: { title: 'Your validation is registered', @@ -259,6 +262,26 @@ const t = { }, buttonContinue: 'Continue', }, + commissions: { + title: 'Commissions', + desciption: + 'When you open your validator as a pool, you earn commissions of stake delegated to your pool from other accounts:', + fieldTransactionFee: { + label: 'Transaction fee commission', + }, + fieldBlockReward: { + label: 'Block reward commission', + }, + fieldFinalizationReward: { + label: 'Finalization reward commission', + }, + error: { + required: 'Please specify a value', + min: 'Value exceeds lower bound of {{min}}', + max: 'Value exceeds upper bound of {{max}}', + }, + buttonContinue: 'Continue', + }, submit: { backTitle: 'Validation settings', sender: { label: 'Sender' }, diff --git a/packages/browser-wallet/src/popup/popupX/styles/_elements.scss b/packages/browser-wallet/src/popup/popupX/styles/_elements.scss index 40ac2add0..33d2d425c 100644 --- a/packages/browser-wallet/src/popup/popupX/styles/_elements.scss +++ b/packages/browser-wallet/src/popup/popupX/styles/_elements.scss @@ -26,6 +26,7 @@ @import '../pages/EarningRewards/Validator/OpenPool/OpenPool'; @import '../pages/EarningRewards/Validator/Keys/ValidatorKeys'; @import '../pages/EarningRewards/Validator/Metadata/Metadata'; +@import '../pages/EarningRewards/Validator/Commissions/Commissions'; @import '../pages/EarningRewards/Delegator/Intro/DelegatorIntro'; @import '../pages/EarningRewards/Delegator/Type/DelegationType'; @import '../pages/EarningRewards/Delegator/Stake/DelegatorStake'; From 3b20b4b45304a306c2628b58e6984bc9231e71eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Thu, 7 Nov 2024 12:15:43 +0100 Subject: [PATCH 09/11] Section validator status by stake and pool info --- .../Validator/Status/Status.scss | 5 +++ .../Validator/{ => Status}/Status.tsx | 44 +++++++++++++++---- .../EarningRewards/Validator/Status/index.ts | 1 + .../src/popup/popupX/styles/_elements.scss | 1 + 4 files changed, 43 insertions(+), 8 deletions(-) create mode 100644 packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Status/Status.scss rename packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/{ => Status}/Status.tsx (63%) create mode 100644 packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Status/index.ts diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Status/Status.scss b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Status/Status.scss new file mode 100644 index 000000000..fa4ca70a3 --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Status/Status.scss @@ -0,0 +1,5 @@ +.validator-status { + &__info { + margin-top: rem(8px); + } +} diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Status.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Status/Status.tsx similarity index 63% rename from packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Status.tsx rename to packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Status/Status.tsx index 911f05e03..f2a815925 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Status.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Status/Status.tsx @@ -8,10 +8,11 @@ import Button from '@popup/popupX/shared/Button'; import Card from '@popup/popupX/shared/Card'; import Page from '@popup/popupX/shared/Page'; import { useSelectedAccountInfo } from '@popup/shared/AccountInfoListenerContext/AccountInfoListenerContext'; +import { useBlockChainParametersAboveV0 } from '@popup/shared/BlockChainParametersProvider'; -import AccountCooldowns from '../AccountCooldowns'; -import { showValidatorRestake, showValidatorAmount, showValidatorOpenStatus } from './util'; -import { ValidationResultLocationState } from './Result'; +import AccountCooldowns from '../../AccountCooldowns'; +import { showValidatorRestake, showValidatorAmount, showValidatorOpenStatus, isRange } from '../util'; +import { ValidationResultLocationState } from '../Result'; const REMOVE_STATE: ValidationResultLocationState = { type: 'remove', @@ -22,6 +23,7 @@ export default function ValidatorStatus() { const { t } = useTranslation('x', { keyPrefix: 'earn.validator' }); const accountInfo = useSelectedAccountInfo(); const nav = useNavigate(); + const chainParams = useBlockChainParametersAboveV0(); if (accountInfo?.type !== AccountInfoType.Baker) { return ; @@ -29,13 +31,13 @@ export default function ValidatorStatus() { const { accountBaker, accountCooldowns } = accountInfo; - if (accountBaker.version === 0) { + if (accountBaker.version === 0 || chainParams === undefined) { // assume protocol version >= 4 return null; } return ( - + @@ -44,15 +46,17 @@ export default function ValidatorStatus() { value={showValidatorAmount(accountBaker.stakedAmount)} /> - - - + + + + + + {isRange(chainParams.transactionCommissionRange) && ( + + + + )} + {isRange(chainParams.bakingCommissionRange) && ( + + + + )} + {isRange(chainParams.finalizationCommissionRange) && ( + + + + )} diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Status/index.ts b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Status/index.ts new file mode 100644 index 000000000..1c888ce41 --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Status/index.ts @@ -0,0 +1 @@ +export { default } from './Status'; diff --git a/packages/browser-wallet/src/popup/popupX/styles/_elements.scss b/packages/browser-wallet/src/popup/popupX/styles/_elements.scss index 33d2d425c..a179996a2 100644 --- a/packages/browser-wallet/src/popup/popupX/styles/_elements.scss +++ b/packages/browser-wallet/src/popup/popupX/styles/_elements.scss @@ -20,6 +20,7 @@ @import '../pages/Nft/Nft'; @import '../pages/EarningRewards/EarningRewards'; @import '../pages/EarningRewards/AccountCooldowns/AccountCooldowns'; +@import '../pages/EarningRewards/Validator/Status/Status'; @import '../pages/EarningRewards/Validator/Intro/ValidatorIntro'; @import '../pages/EarningRewards/Validator/Stake/ValidatorStake'; @import '../pages/EarningRewards/Validator/Result/ValidationResult'; From 54ae44eb4c120cec07bd275349e0e0b30f13330c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Thu, 7 Nov 2024 12:53:11 +0100 Subject: [PATCH 10/11] Validate minimum stake --- .../Validator/Stake/ValidatorStake.tsx | 16 ++++- .../Validator/TransactionFlow.tsx | 62 +++++-------------- .../popupX/pages/EarningRewards/i18n/en.ts | 3 + .../popupX/shared/Form/TokenAmount/View.tsx | 5 +- 4 files changed, 37 insertions(+), 49 deletions(-) diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Stake/ValidatorStake.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Stake/ValidatorStake.tsx index 6948ee69c..ca4a27b2a 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Stake/ValidatorStake.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Stake/ValidatorStake.tsx @@ -1,6 +1,6 @@ /* eslint-disable react/destructuring-assignment */ import React, { ReactNode, useEffect, useMemo, useState } from 'react'; -import { UseFormReturn } from 'react-hook-form'; +import { UseFormReturn, Validate } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { AccountTransactionType, CcdAmount, ConfigureBakerPayload, OpenStatus } from '@concordium/web-sdk'; @@ -68,6 +68,8 @@ const PAYLOAD_MIN: ConfigureBakerPayload = { ...PAYLOAD_MAX, metadataUrl: '' }; type Props = { /** The title for the configuriation step */ title: string; + /** The minimum stake required to be a validator */ + minStake: CcdAmount.Type; /** The initial values of the step, if any */ initialValues?: ValidatorStakeForm; /** The existing validation stake values registered on the account */ @@ -76,7 +78,7 @@ type Props = { onSubmit(values: ValidatorStakeForm): void; }; -export default function ValidatorStake({ title, initialValues, existingValues, onSubmit }: Props) { +export default function ValidatorStake({ title, initialValues, existingValues, onSubmit, minStake }: Props) { const { t } = useTranslation('x', { keyPrefix: 'earn.validator.stake' }); const form = useForm({ defaultValues: initialValues ?? { amount: '0.00', restake: true }, @@ -168,6 +170,15 @@ export default function ValidatorStake({ title, initialValues, existingValues, o } }; + const validateAmount: Validate = (v) => { + const amount = parseCcdAmount(v); + if (amount.microCcdAmount < minStake.microCcdAmount) { + return t('inputAmount.errors.min', { min: formatCcdAmount(minStake) }); + } + + return undefined; + }; + return ( <> setHighStakeWarning(false)} onContinue={submit} /> @@ -188,6 +199,7 @@ export default function ValidatorStake({ title, initialValues, existingValues, o buttonMaxLabel={t('inputAmount.buttonMax')} form={f as unknown as UseFormReturn} ccdBalance="total" + validateAmount={validateAmount} />
diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/TransactionFlow.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/TransactionFlow.tsx index 8035d86fe..041b1e1ca 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/TransactionFlow.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/TransactionFlow.tsx @@ -2,18 +2,15 @@ import React, { useCallback, useState } from 'react'; import { Location, useLocation, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { AccountInfoType } from '@concordium/web-sdk'; import { absoluteRoutes } from '@popup/popupX/constants/routes'; import MultiStepForm from '@popup/shared/MultiStepForm'; -import { useSelectedAccountInfo } from '@popup/shared/AccountInfoListenerContext/AccountInfoListenerContext'; -import { formatCcdAmount } from '@popup/popupX/shared/utils/helpers'; import FullscreenNotice, { FullscreenNoticeProps } from '@popup/popupX/shared/FullscreenNotice'; import Page from '@popup/popupX/shared/Page'; import Button from '@popup/popupX/shared/Button'; import { useBlockChainParametersAboveV0 } from '@popup/shared/BlockChainParametersProvider'; -import { ValidatorForm, ValidatorFormExisting, configureValidatorFromForm } from './util'; +import { ValidatorForm, configureValidatorFromForm } from './util'; import ValidatorStake from './Stake'; import { type ValidationResultLocationState } from './Result'; import OpenPool from './OpenPool'; @@ -38,17 +35,13 @@ function NoChangesNotice(props: FullscreenNoticeProps) { ); } -type Props = { - title: string; - existingValues?: ValidatorFormExisting | undefined; -}; - -function ValidatorTransactionFlow({ existingValues, title }: Props) { +export function RegisterValidatorTransactionFlow() { + const { t } = useTranslation('x', { keyPrefix: 'earn.validator.register' }); const { state, pathname } = useLocation() as Location & { state: ValidatorForm | undefined }; const chainParams = useBlockChainParametersAboveV0(); const nav = useNavigate(); - const initialValues = state ?? existingValues; + const initialValues = state; const store = useState>(initialValues ?? {}); const handleDone = useCallback( @@ -63,7 +56,7 @@ function ValidatorTransactionFlow({ existingValues, title }: Props) { }; nav(absoluteRoutes.settings.earn.validator.submit.path, { state: submitDelegatorState }); }, - [pathname, existingValues] + [pathname] ); return ( @@ -71,7 +64,17 @@ function ValidatorTransactionFlow({ existingValues, title }: Props) { {{ stake: { render(initial, onNext) { - return ; + if (chainParams === undefined) { + return null; + } + return ( + + ); }, }, status: { @@ -101,36 +104,3 @@ function ValidatorTransactionFlow({ existingValues, title }: Props) { ); } - -export function RegisterValidatorTransactionFlow() { - const { t } = useTranslation('x', { keyPrefix: 'earn.validator.register' }); - return ; -} - -export function UpdateValidatorTransactionFlow() { - const { t } = useTranslation('x', { keyPrefix: 'earn.validator.update' }); - const accountInfo = useSelectedAccountInfo(); - - if ( - accountInfo === undefined || - accountInfo.type !== AccountInfoType.Baker || - accountInfo.accountBaker.version === 0 - ) { - return null; - } - const { - accountBaker: { stakedAmount, restakeEarnings, bakerPoolInfo }, - } = accountInfo; - - const values: ValidatorFormExisting = { - stake: { - amount: formatCcdAmount(stakedAmount), - restake: restakeEarnings, - }, - status: bakerPoolInfo.openStatus, - metadataUrl: bakerPoolInfo.metadataUrl, - commissions: bakerPoolInfo.commissionRates, - }; - - return ; -} 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 d26bc705e..3510ada18 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 @@ -214,6 +214,9 @@ const t = { }, inputAmount: { label: 'Amount', + errors: { + min: 'A minimum stake of {{min}} CCD is required', + }, buttonMax: 'Stake max.', }, fee: { 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 a8e526463..cf1e15c88 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 @@ -238,6 +238,8 @@ export type TokenAmountViewProps = { * `null` is used to communicate the native token (CCD) is selected. */ onSelectToken(event: TokenSelectEvent): void; + /** Custom validation for the amount */ + validateAmount?: Validate; } & ValueVariant & TokenVariant & ClassName; @@ -257,6 +259,7 @@ export default function TokenAmountView(props: TokenAmountViewProps) { onSelectToken, className, formatFee = (f) => displayAsCcd(f, false, true), + validateAmount: customValidateAmount, } = props; const [selectedToken, setSelectedToken] = useState(() => { switch (props.tokenType) { @@ -396,7 +399,7 @@ export default function TokenAmountView(props: TokenAmountViewProps) { rules={{ required: t('utils.amount.required'), min: { value: 0, message: t('utils.amount.zero') }, - validate: validateAmount, + validate: (v) => validateAmount(v) ?? customValidateAmount?.(v), }} /> setMax()}> From 4ea799e084db4bad01be702b95308ac2fc0decd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Fri, 8 Nov 2024 10:20:46 +0100 Subject: [PATCH 11/11] Add translations to keys page --- .../Validator/Keys/ValidatorKeys.tsx | 23 +++++++++---------- .../popupX/pages/EarningRewards/i18n/en.ts | 8 +++++++ 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Keys/ValidatorKeys.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Keys/ValidatorKeys.tsx index daebdd85a..608e94d69 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Keys/ValidatorKeys.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Keys/ValidatorKeys.tsx @@ -1,5 +1,7 @@ import React, { useCallback, useMemo, useState } from 'react'; import { GenerateBakerKeysOutput, generateBakerKeys } from '@concordium/web-sdk'; +import clsx from 'clsx'; +import { useTranslation } from 'react-i18next'; import ExportIcon from '@assets/svgX/sign-out.svg'; import Caret from '@assets/svgX/caret-right.svg'; @@ -11,7 +13,6 @@ import { ensureDefined } from '@shared/utils/basic-helpers'; import { saveData } from '@popup/shared/utils/file-helpers'; import { useSelectedAccountInfo } from '@popup/shared/AccountInfoListenerContext/AccountInfoListenerContext'; import { getBakerKeyExport } from '@popup/shared/utils/baking-helpers'; -import clsx from 'clsx'; const KEYS_FILENAME = 'validator-credentials.json'; @@ -21,6 +22,7 @@ type Props = { }; export default function ValidatorKeys({ onSubmit, initial }: Props) { + const { t } = useTranslation('x', { keyPrefix: 'earn.validator' }); const [expand, setExpand] = useState(false); const [exported, setExported] = useState(false); const accountInfo = ensureDefined(useSelectedAccountInfo(), 'Expected seleted account'); @@ -33,34 +35,31 @@ export default function ValidatorKeys({ onSubmit, initial }: Props) { return ( - - - Your new validator keys have been generated. Before you can continue, you must export and save them. The - keys will have to be added to the validator node. - + + {t('keys.description')} - + - + - +
} - label={expand ? 'Show less' : 'Show full'} + label={expand ? t('keys.buttonToggle.less') : t('keys.buttonToggle.full')} onClick={() => setExpand((v) => !v)} /> - } label="Export keys as .json" onClick={exportKeys} /> + } label={t('keys.buttonExport')} onClick={exportKeys} />
{(exported || initial !== undefined) && ( - onSubmit(keys)} /> + onSubmit(keys)} /> )}
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 3510ada18..35905311c 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 @@ -244,6 +244,14 @@ const t = { buttonBack: 'Enter new stake', }, }, + keys: { + title: 'Validator keys', + description: + 'Your new validator keys have been generated. Before you can continue, you must export and save them. The keys will have to be added to the validator node.', + buttonToggle: { less: 'Show less', full: 'Show full' }, + buttonExport: 'Export as .json', + buttonContinue: 'Continue', + }, openStatus: { title: 'Opening a pool', switch: {