diff --git a/nym-wallet/src/components/Bonding/BondUpdateCard.tsx b/nym-wallet/src/components/Bonding/BondUpdateCard.tsx index ea25216bc5..9523b194c0 100644 --- a/nym-wallet/src/components/Bonding/BondUpdateCard.tsx +++ b/nym-wallet/src/components/Bonding/BondUpdateCard.tsx @@ -1,12 +1,10 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { Box, Button, Stack, Tooltip, Typography } from '@mui/material'; -import { isMixnode, Network } from 'src/types'; +import { Network } from 'src/types'; import { NymCard } from 'src/components'; import { TBondedMixnode } from 'src/requests/mixnodeDetails'; export const BondUpdateCard = ({ - mixnode, - network, setSuccesfullUpdate, }: { mixnode: TBondedMixnode; diff --git a/nym-wallet/src/components/Bonding/BondedNymNode.tsx b/nym-wallet/src/components/Bonding/BondedNymNode.tsx index d4a9662a5d..77d2ef3004 100644 --- a/nym-wallet/src/components/Bonding/BondedNymNode.tsx +++ b/nym-wallet/src/components/Bonding/BondedNymNode.tsx @@ -7,11 +7,10 @@ import { urls } from 'src/context'; import { NymCard } from 'src/components'; import { IdentityKey } from 'src/components/IdentityKey'; import { getIntervalAsDate } from 'src/utils'; +import { TBondedNymNode } from 'src/requests/nymNodeDetails'; import { Node as NodeIcon } from '../../svg-icons/node'; import { Cell, Header, NodeTable } from './NodeTable'; -import { BondedNymNodeActions } from './BondedNymNodeActions'; -import { TBondedNymNode } from 'src/requests/nymNodeDetails'; -import { TBondedNymNodeActions } from './BondedNymNodeActions'; +import { BondedNymNodeActions, TBondedNymNodeActions } from './BondedNymNodeActions'; const textWhenNotName = 'This node has not yet set a name'; diff --git a/nym-wallet/src/components/Bonding/forms/nym-node/FormContext.tsx b/nym-wallet/src/components/Bonding/forms/nym-node/FormContext.tsx new file mode 100644 index 0000000000..77c6252257 --- /dev/null +++ b/nym-wallet/src/components/Bonding/forms/nym-node/FormContext.tsx @@ -0,0 +1,87 @@ +import { createContext, useContext, useState } from 'react'; +import { CurrencyDenom } from '@nymproject/types'; +import { TBondNymNodeArgs, TBondMixNodeArgs } from 'src/types'; + +const defaultNymNodeValues: TBondNymNodeArgs['nymNode'] = { + identity_key: 'H6rXWgsW89QsVyaNSS3qBe9zZFLhBS6Gn3YRkGFSoFW9', + custom_http_port: 1, + host: '1.1.1.1', +}; + +const defaultCostParams = (denom: CurrencyDenom): TBondNymNodeArgs['costParams'] => ({ + interval_operating_cost: { amount: '40', denom }, + profit_margin_percent: '10', +}); + +const defaultAmount = (denom: CurrencyDenom): TBondMixNodeArgs['pledge'] => ({ + amount: '100', + denom, +}); + +interface FormContextType { + step: 1 | 2 | 3 | 4; + setStep: React.Dispatch>; + nymNodeData: TBondNymNodeArgs['nymNode']; + setNymNodeData: React.Dispatch>; + costParams: TBondNymNodeArgs['costParams']; + setCostParams: React.Dispatch>; + amountData: TBondMixNodeArgs['pledge']; + setAmountData: React.Dispatch>; + signature?: string; + setSignature: React.Dispatch>; + onError: (e: string) => void; +} + +const FormContext = createContext({ + step: 1, + setStep: () => {}, + nymNodeData: defaultNymNodeValues, + setNymNodeData: () => {}, + costParams: defaultCostParams('nym'), + setCostParams: () => {}, + amountData: defaultAmount('nym'), + setAmountData: () => {}, + signature: undefined, + setSignature: () => {}, + + onError: (e: string) => {}, +}); + +const FormContextProvider = ({ children }: { children: React.ReactNode }) => { + // TODO - Make denom dynamic + const denom = 'nym'; + + const [step, setStep] = useState<1 | 2 | 3 | 4>(1); + const [nymNodeData, setNymNodeData] = useState(defaultNymNodeValues); + const [costParams, setCostParams] = useState(defaultCostParams(denom)); + const [amountData, setAmountData] = useState(defaultAmount(denom)); + const [signature, setSignature] = useState(); + + const onError = (e: string) => { + console.error(e); + }; + + return ( + + {children} + + ); +}; + +export const useFormContext = () => useContext(FormContext); + +export default FormContextProvider; diff --git a/nym-wallet/src/components/Bonding/forms/nym-node/NymNodeAmount.tsx b/nym-wallet/src/components/Bonding/forms/nym-node/NymNodeAmount.tsx new file mode 100644 index 0000000000..bb31485cc2 --- /dev/null +++ b/nym-wallet/src/components/Bonding/forms/nym-node/NymNodeAmount.tsx @@ -0,0 +1,119 @@ +import { Stack, TextField, Box, FormHelperText } from '@mui/material'; +import { useForm } from 'react-hook-form'; +import { TBondNymNodeArgs } from 'src/types'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { SimpleModal } from 'src/components/Modals/SimpleModal'; +import { nymNodeAmountSchema } from './amountValidationSchema'; +import { CurrencyFormField } from '@nymproject/react/currency/CurrencyFormField'; +import { checkHasEnoughFunds } from 'src/utils'; + +const defaultNymNodeCostParamValues: TBondNymNodeArgs['costParams'] = { + profit_margin_percent: '10', + interval_operating_cost: { amount: '40', denom: 'nym' }, +}; + +const defaultNymNodePledgeValue: TBondNymNodeArgs['pledge'] = { + amount: '100', + denom: 'nym', +}; + +type NymNodeDataProps = { + onClose: () => void; + onBack: () => void; + onNext: () => Promise; + step: number; +}; + +const NymNodeAmount = ({ onClose, onBack, onNext, step }: NymNodeDataProps) => { + const { + formState: { errors }, + register, + getValues, + setValue, + setError, + handleSubmit, + } = useForm({ + mode: 'all', + defaultValues: { + pledge: defaultNymNodePledgeValue, + ...defaultNymNodeCostParamValues, + }, + resolver: yupResolver(nymNodeAmountSchema()), + }); + + console.log(errors, 'errors'); + + const handleRequestValidation = async () => { + const values = getValues(); + + const hasSufficientTokens = await checkHasEnoughFunds(values.pledge.amount); + + if (hasSufficientTokens) { + handleSubmit(onNext)(); + } else { + setError('pledge.amount', { message: 'Not enough tokens' }); + } + }; + + return ( + 0} + > + + { + setValue('pledge.amount', newValue.amount, { shouldValidate: true }); + }} + validationError={errors.pledge?.amount?.message} + denom={defaultNymNodePledgeValue.denom} + initialValue={defaultNymNodePledgeValue.amount} + /> + + + { + setValue('interval_operating_cost', newValue, { shouldValidate: true }); + }} + validationError={errors.interval_operating_cost?.amount?.message} + denom={defaultNymNodeCostParamValues.interval_operating_cost.denom} + initialValue={defaultNymNodeCostParamValues.interval_operating_cost.amount} + /> + + Monthly operational costs of running your node. If your node is in the active set the amount will be paid + back to you from the rewards. + + + + + + The percentage of node rewards that you as the node operator take before rewards are distributed to operator + and delegators. + + + + + ); +}; + +export default NymNodeAmount; diff --git a/nym-wallet/src/components/Bonding/forms/nym-node/NymNodeData.tsx b/nym-wallet/src/components/Bonding/forms/nym-node/NymNodeData.tsx new file mode 100644 index 0000000000..c1c35b521a --- /dev/null +++ b/nym-wallet/src/components/Bonding/forms/nym-node/NymNodeData.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { Stack, TextField, FormControlLabel, Checkbox } from '@mui/material'; +import { useForm } from 'react-hook-form'; +import { IdentityKeyFormField } from '@nymproject/react/mixnodes/IdentityKeyFormField'; +import { TBondNymNodeArgs } from 'src/types'; +import { useFormContext } from './FormContext'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; +import { isValidHostname, validateRawPort } from 'src/utils'; +import { SimpleModal } from 'src/components/Modals/SimpleModal'; + +const defaultNymNodeValues: TBondNymNodeArgs['nymNode'] = { + identity_key: 'H6rXWgsW89QsVyaNSS3qBe9zZFLhBS6Gn3YRkGFSoFW9', + custom_http_port: 1, + host: '1.1.1.1', +}; + +const yupValidationSchema = yup.object().shape({ + identity_key: yup.string().required('Identity key is required'), + host: yup + .string() + .required('A host is required') + .test('no-whitespace', 'Host cannot contain whitespace', (value) => !/\s/.test(value || '')) + .test('valid-host', 'A valid host is required', (value) => { + return value ? isValidHostname(value) : false; + }), + + custom_http_port: yup + .number() + .required('A custom http port is required') + .test('valid-http', 'A valid http port is required', (value) => (value ? validateRawPort(value) : false)), +}); + +type NymNodeDataProps = { + onClose: () => void; + onBack: () => void; + onNext: () => Promise; + step: number; +}; + +const NymNodeData = ({ onClose, onNext, step }: NymNodeDataProps) => { + const { + formState: { errors }, + register, + setValue, + handleSubmit, + } = useForm({ + mode: 'all', + defaultValues: defaultNymNodeValues, + resolver: yupResolver(yupValidationSchema), + }); + + const [showAdvancedOptions, setShowAdvancedOptions] = React.useState(false); + + const handleNext = async () => { + handleSubmit(onNext)(); + }; + + return ( + 0} + > + + setValue('identity_key', value, { shouldValidate: true })} + showTickOnValid={false} + /> + + + + setShowAdvancedOptions((show) => !show)} checked={showAdvancedOptions} />} + label="Show advanced options" + /> + {showAdvancedOptions && ( + + + + )} + + + ); +}; + +export default NymNodeData; diff --git a/nym-wallet/src/components/Bonding/forms/nym-node/NymNodeSignature.tsx b/nym-wallet/src/components/Bonding/forms/nym-node/NymNodeSignature.tsx new file mode 100644 index 0000000000..fe8054129c --- /dev/null +++ b/nym-wallet/src/components/Bonding/forms/nym-node/NymNodeSignature.tsx @@ -0,0 +1,112 @@ +import React, { useEffect, useState } from 'react'; +import { Stack, TextField, Typography } from '@mui/material'; +import { useForm } from 'react-hook-form'; +import { CopyToClipboard } from 'src/components/CopyToClipboard'; +import { ErrorModal } from 'src/components/Modals/ErrorModal'; +import { SimpleModal } from 'src/components/Modals/SimpleModal'; +import { useBondingContext } from 'src/context'; +import { TBondNymNodeArgs } from 'src/types'; +import { Signature } from 'src/pages/bonding/types'; + +const NymNodeSignature = ({ + nymNode, + pledge, + costParams, + step, + onNext, + onClose, + onBack, +}: { + nymNode: TBondNymNodeArgs['nymNode']; + pledge: TBondNymNodeArgs['pledge']; + costParams: TBondNymNodeArgs['costParams']; + step: number; + onNext: (data: Signature) => void; + onClose: () => void; + onBack: () => void; +}) => { + const [message, setMessage] = useState(''); + const [error, setError] = useState(); + const { generateNymNodeMsgPayload } = useBondingContext(); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm(); + + const generateMessage = async () => { + try { + const message = await generateNymNodeMsgPayload({ + nymNode, + pledge, + costParams, + }); + + if (message) { + setMessage(message); + } + } catch (e) { + console.error(e); + setError('Something went wrong while generating the payload signature'); + } + }; + + useEffect(() => { + generateMessage(); + }, [nymNode, pledge, costParams]); + + const onSubmit = async (data: Signature) => { + onNext(data); + }; + + if (error) { + return {}} />; + } + + return ( + + onSubmit({ + signature: 'signature', + }) + } + onClose={onClose} + header="Bond Nym Node" + subHeader={`Step ${step}/3`} + okLabel="Next" + onBack={onBack} + okDisabled={Object.keys(errors).length > 0} + > + + + Copy the message below and sign it: +
+ If you are using a nym-node: +
+ nym-node sign --id <your-node-id> --contract-msg <payload-generated-by-the-wallet> +
+ Then paste the signature in the next field. +
+ + + Copy Message + {message && } + + +
+
+ ); +}; + +export default NymNodeSignature; diff --git a/nym-wallet/src/components/Bonding/forms/nym-node/amountValidationSchema.ts b/nym-wallet/src/components/Bonding/forms/nym-node/amountValidationSchema.ts new file mode 100644 index 0000000000..1868a4f2e2 --- /dev/null +++ b/nym-wallet/src/components/Bonding/forms/nym-node/amountValidationSchema.ts @@ -0,0 +1,58 @@ +import * as Yup from 'yup'; +import { TauriContractStateParams } from 'src/types'; +import { isLessThan, isGreaterThan, validateAmount } from 'src/utils'; + +const operatingCostAndPmValidation = (params?: TauriContractStateParams) => { + const defaultParams = { + profit_margin_percent: { + minimum: parseFloat(params?.profit_margin.minimum || '0%'), + maximum: parseFloat(params?.profit_margin.maximum || '100%'), + }, + + interval_operating_cost: { + minimum: parseFloat(params?.operating_cost.minimum.amount || '0'), + maximum: parseFloat(params?.operating_cost.maximum.amount || '1000000000'), + }, + }; + + return { + profit_margin_percent: Yup.number() + .required('Profit Percentage is required') + .min(defaultParams.profit_margin_percent.minimum) + .max(defaultParams.profit_margin_percent.maximum), + interval_operating_cost: Yup.object().shape({ + amount: Yup.string() + .required('An operating cost is required') + // eslint-disable-next-line prefer-arrow-callback + .test('valid-operating-cost', 'A valid amount is required', async function isValidAmount(this, value) { + if ( + value && + (!Number(value) || + isLessThan(+value, defaultParams.interval_operating_cost.minimum) || + isGreaterThan(+value, Number(defaultParams.interval_operating_cost.maximum))) + ) { + return this.createError({ + message: `A valid amount is required (min ${defaultParams?.interval_operating_cost.minimum} - max ${defaultParams?.interval_operating_cost.maximum})`, + }); + } + return true; + }), + }), + }; +}; + +export const nymNodeAmountSchema = (params?: TauriContractStateParams) => + Yup.object().shape({ + pledge: Yup.object().shape({ + amount: Yup.string() + .required('An amount is required') + .test('valid-amount', 'Pledge error', async function isValidAmount(this, value) { + const isValid = await validateAmount(value || '', '100'); + if (!isValid) { + return this.createError({ message: 'A valid amount is required (min 100)' }); + } + return true; + }), + }), + ...operatingCostAndPmValidation(params), + }); diff --git a/nym-wallet/src/components/Bonding/modals/BondGatewayModal.tsx b/nym-wallet/src/components/Bonding/modals/BondGatewayModal.tsx index defdac3f95..e4ced6b256 100644 --- a/nym-wallet/src/components/Bonding/modals/BondGatewayModal.tsx +++ b/nym-wallet/src/components/Bonding/modals/BondGatewayModal.tsx @@ -11,8 +11,8 @@ import { simulateBondGateway, simulateVestingBondGateway } from 'src/requests'; import { TBondGatewayArgs } from 'src/types'; import { BalanceWarning } from 'src/components/FeeWarning'; import { AppContext } from 'src/context'; -import { BondGatewayForm } from '../forms/BondGatewayForm'; import { gatewayToTauri } from '../utils'; +import { BondGatewayForm } from '../forms/legacyForms/BondGatewayForm'; const defaultGatewayValues: GatewayData = { identityKey: '', diff --git a/nym-wallet/src/components/Bonding/modals/BondNymNodeModal.tsx b/nym-wallet/src/components/Bonding/modals/BondNymNodeModal.tsx new file mode 100644 index 0000000000..2e09aa503f --- /dev/null +++ b/nym-wallet/src/components/Bonding/modals/BondNymNodeModal.tsx @@ -0,0 +1,96 @@ +import { useContext, useEffect } from 'react'; +import { ConfirmTx } from 'src/components/ConfirmTX'; +import { ModalListItem } from 'src/components/Modals/ModalListItem'; +import { useGetFee } from 'src/hooks/useGetFee'; +import { MixnodeAmount, Signature } from 'src/pages/bonding/types'; +import { BalanceWarning } from 'src/components/FeeWarning'; +import { AppContext } from 'src/context'; +import FormContextProvider, { useFormContext } from '../forms/nym-node/FormContext'; +import NymNodeData from '../forms/nym-node/NymNodeData'; +import NymNodeAmount from '../forms/nym-node/NymNodeAmount'; +import NymNodeSignature from '../forms/nym-node/NymNodeSignature'; + +export const BondNymNodeModal = ({ onClose }: { onClose: () => void }) => { + const { fee, getFee, resetFeeState, feeError } = useGetFee(); + const { userBalance } = useContext(AppContext); + const { setStep, step, onError, setSignature, amountData, costParams, nymNodeData } = useFormContext(); + + useEffect(() => { + if (feeError) { + onError(feeError); + } + }, [feeError]); + + const handleBack = () => { + setStep(step); + }; + + const handleUpdateMixnodeData = async () => { + setStep(2); + }; + + const handleUpdateAmountData = async (data: MixnodeAmount) => { + setStep(3); + }; + + const handleUpdateSignature = async (data: Signature) => { + setSignature(data.signature); + }; + + const handleConfirm = async () => {}; + + if (fee) { + return ( + + + + {fee.amount?.amount && userBalance.balance && ( + + )} + + ); + } + + if (step === 1) { + return ; + } + + if (step === 2) { + return setStep(1)} onNext={async () => setStep(3)} step={step} />; + } + + if (step === 3) { + return ( + setStep(2)} + step={step} + /> + ); + } + + return null; +}; + +export const BondNymNodeModalWithState = ({ open, onClose }: { open: boolean; onClose: () => void }) => { + if (!open) { + return null; + } + + return ( + + + + ); +};