diff --git a/wallet-web/components/bond/BondNodeForm.tsx b/wallet-web/components/bond/BondNodeForm.tsx index d5cd0c2e17..18d208abc7 100644 --- a/wallet-web/components/bond/BondNodeForm.tsx +++ b/wallet-web/components/bond/BondNodeForm.tsx @@ -1,336 +1,51 @@ -import React, { useEffect, useState, ChangeEvent } from 'react' -import { printableBalanceToNative } from '@nymproject/nym-validator-client/dist/currency' -import { Coin, nativeToPrintable } from '@nymproject/nym-validator-client' -import { Alert } from '@material-ui/lab' +import React from 'react' import { Button, Checkbox, FormControlLabel, - InputAdornment, Grid, + InputAdornment, TextField, useMediaQuery, } from '@material-ui/core' -import bs58 from 'bs58' -import semver from 'semver' +import { Alert } from '@material-ui/lab' +import { Coin, nativeToPrintable } from '@nymproject/nym-validator-client' import { NodeType } from '../../common/node' -import { theme } from '../../lib/theme' -import { - basicRawCoinValueValidation, - checkAllocationSize, - makeBasicStyle, - validateRawPort, -} from '../../common/helpers' import { DENOM } from '../../pages/_app' +import { theme } from '../../lib/theme' import { BondingInformation } from './NodeBond' -import { useGetBalance } from '../../hooks/useGetBalance' - -const DEFAULT_MIX_PORT = 1789 -const DEFAULT_VERLOC_PORT = 1790 -const DEFAULT_HTTP_API_PORT = 8000 -const DEFAULT_CLIENTS_PORT = 9000 +import { useBondForm } from './useBondForm' type TBondNodeFormProps = { type: NodeType - minimumMixnodeBond: Coin - minimumGatewayBond: Coin - onSubmit: (event: any) => void + minimumBond: Coin + onSubmit: (values: BondingInformation) => void } -type TFormStringValue = { value: string } - -type TFormInput = { - amount: TFormStringValue - identityKey: TFormStringValue - sphinxKey: TFormStringValue - host: TFormStringValue - version: TFormStringValue - mixPort?: TFormStringValue - verlocPort?: TFormStringValue - location?: TFormStringValue - clientsPort?: TFormStringValue - httpApiPort?: TFormStringValue -} - -type TFormData = { - amount: string - identityKey: string - sphinxKey: string - host: string - version: string - mixPort?: string - verlocPort?: string - location?: string - clientsPort?: string - httpApiPort?: string -} - -export default function BondNodeForm(props: TBondNodeFormProps) { - const classes = makeBasicStyle(theme) - - const [validity, setValidity] = React.useState({ - validAmount: true, - validSphinxKey: true, - validIdentityKey: true, - validHost: true, - validVersion: true, - validLocation: true, - validMixPort: true, - - // this should have probably be somehow split to be subclasses of the validity matrix - // the above is more true now as more fields are added. This looks kinda disgusting... - // mixnode-specific: - validVerlocPort: true, - validHttpApiPort: true, - - // gateway-specific: - validClientsPort: true, - }) - +export const BondNodeForm = ({ + type, + minimumBond, + onSubmit, +}: TBondNodeFormProps) => { const [advancedShown, setAdvancedShown] = React.useState(false) - const [allocationWarning, setAllocationWarning] = useState() - const [isValidAmount, setIsValidAmount] = useState(true) - const { getBalance, accountBalance } = useGetBalance() - useEffect(() => { - getBalance() - }, [getBalance]) + const manageForm = useBondForm({ type, minimumBond }) const matches = useMediaQuery('(min-width:768px)') - const handleCheckboxToggle = () => { - setAdvancedShown((prevSet) => !prevSet) - } - - const handleAmountChange = (e: ChangeEvent) => { - const parsed = +e.target.value - const balance = +accountBalance.amount - - if (isNaN(parsed)) { - setIsValidAmount(false) - } else { - try { - const allocationCheck = checkAllocationSize( - +printableBalanceToNative(e.target.value), - balance, - 'bond' - ) - if (allocationCheck.error) { - setAllocationWarning(allocationCheck.message) - setIsValidAmount(false) - } else { - setAllocationWarning(allocationCheck.message) - setIsValidAmount(true) - } - } catch { - setIsValidAmount(false) - } - } - } - - // if arguments are undefined we provided a default value for - // verlocPort, mixPort, httpApiPort and clientsPort - const validateForm = ({ - amount, - sphinxKey, - identityKey, - host, - version, - location, - verlocPort = DEFAULT_VERLOC_PORT.toString(), - mixPort = DEFAULT_MIX_PORT.toString(), - httpApiPort = DEFAULT_HTTP_API_PORT.toString(), - clientsPort = DEFAULT_CLIENTS_PORT.toString(), - }: TFormData): boolean => { - let newValidity = { - validAmount: validateAmount(amount), - validSphinxKey: validateKey(sphinxKey), - validIdentityKey: validateKey(identityKey), - validHost: validateHost(host), - validVersion: validateVersion(version), - validMixPort: validateRawPort(mixPort), - validLocation: - props.type == NodeType.Gateway ? validateLocation(location) : true, - validVerlocPort: - props.type === NodeType.Mixnode ? validateRawPort(verlocPort) : true, - validHttpApiPort: - props.type === NodeType.Mixnode ? validateRawPort(httpApiPort) : true, - validClientsPort: - props.type === NodeType.Gateway ? validateRawPort(clientsPort) : true, - } - - setValidity((previousState) => { - return { ...previousState, ...newValidity } - }) - - // check if all values are true and return result - return Object.values({ ...validity, ...newValidity }).every( - (isValid) => isValid === true - ) - } - - const validateAmount = (rawValue: string): boolean => { - // tests basic coin value requirements, like no more than 6 decimal places, value lower than total supply, etc - if (!basicRawCoinValueValidation(rawValue)) { - return false - } - - // this conversion seems really iffy but I'm not sure how to better approach it - let nativeValueString = printableBalanceToNative(rawValue) - let nativeValue = parseInt(nativeValueString) - if (props.type == NodeType.Mixnode) { - return nativeValue >= parseInt(props.minimumMixnodeBond.amount) - } else { - return nativeValue >= parseInt(props.minimumGatewayBond.amount) - } - } - - const validateKey = (key: string): boolean => { - // it must be a valid base58 key - try { - const bytes = bs58.decode(key) - // of length 32 - return bytes.length === 32 - } catch { - return false - } - } - - const validateHost = (host: string): boolean => { - // I don't think that proper checks are in scope of the change here - // what would need to be checked is whether one of the following is true: - // - host is an ipv4 address - // - host is an ipv6 address - // - host is a valid hostname - - // so at least perform the dumbest possible checks - // ipv4 needs 4 dot-separated octets - // ipv6 can have multiple possible representations, but it needs to contain at least two colons - // a hostname (in this case) needs to have a top level domain present - - const dot_occurrences = host.trim().split('.').length - 1 - const colon_occurrences = host.trim().split(':').length - 1 - - if (dot_occurrences === 3) { - // possible ipv4 - // make sure it has no ports attached! - return colon_occurrences == 0 - } else if (colon_occurrences >= 2) { - // possible ipv6 - return true - } else if (dot_occurrences >= 1) { - // possible hostname - // make sure it has no ports attached! - return colon_occurrences == 0 - } - return false - } - - const validateVersion = (version: string): boolean => { - // check if its a valid semver - return semver.valid(version) && semver.minor(version) >= 11 - } - - const validateLocation = (location: string): boolean => { - // right now only perform the stupid check of whether the user copy-pasted the tooltip... (with or without brackets) - return !location.trim().includes('physical location of your node') - } - - const constructMixnodeBondingInfo = ({ - amount, - host, - httpApiPort, - mixPort, - verlocPort, - sphinxKey, - identityKey, - version, - }: TFormData): BondingInformation => { - return { - amount, - nodeDetails: { - host, - http_api_port: httpApiPort - ? parseInt(httpApiPort) - : DEFAULT_HTTP_API_PORT, - mix_port: mixPort ? parseInt(mixPort) : DEFAULT_MIX_PORT, - verloc_port: verlocPort ? parseInt(verlocPort) : DEFAULT_VERLOC_PORT, - sphinx_key: sphinxKey, - identity_key: identityKey, - version, - }, - } - } - - const constructGatewayBondingInfo = ({ - amount, - host, - mixPort, - clientsPort, - sphinxKey, - identityKey, - version, - location, - }: TFormData): BondingInformation => { - return { - amount, - nodeDetails: { - host, - mix_port: mixPort ? parseInt(mixPort) : DEFAULT_MIX_PORT, - clients_port: clientsPort - ? parseInt(clientsPort) - : DEFAULT_CLIENTS_PORT, - sphinx_key: sphinxKey, - identity_key: identityKey, - version, - location, - }, - } - } - - const submitForm = (event: React.FormEvent) => { - event.preventDefault() - - const target = event.target as typeof event.target & TFormInput - - const data: TFormData = { - amount: target.amount.value, - identityKey: target.identityKey.value, - sphinxKey: target.sphinxKey.value, - host: target.host.value, - version: target.version.value, - mixPort: target.mixPort?.value, - verlocPort: target.verlocPort?.value, - location: target.location?.value, - clientsPort: target.clientsPort?.value, - httpApiPort: target.httpApiPort?.value, - } - - if (validateForm(data)) { - let dataToSubmit - if (props.type == NodeType.Mixnode) { - dataToSubmit = constructMixnodeBondingInfo(data) - return props.onSubmit(dataToSubmit) - } else { - dataToSubmit = constructGatewayBondingInfo(data) - return props.onSubmit(dataToSubmit) - } - } - } - - let minimumBond = props.minimumMixnodeBond - if (props.type == NodeType.Gateway) { - minimumBond = props.minimumGatewayBond - } - - // if this whole interface wasn't to be completely redone in a month time, I would have definitely redone the form - // but I guess it's fine for time being return ( -
+ ) => { + e.preventDefault() + manageForm.handleSubmit(onSubmit) + }} + > {DENOM} ), }} - onChange={handleAmountChange} /> - {allocationWarning && ( + {manageForm.allocationWarning.message && ( - - {allocationWarning} + + {manageForm.allocationWarning.message} )} @@ -401,15 +126,17 @@ export default function BondNodeForm(props: TBondNodeFormProps) { {/* if it's a gateway - get location */} - {props.type === NodeType.Gateway && ( + {type === NodeType.Gateway && ( @@ -418,13 +145,15 @@ export default function BondNodeForm(props: TBondNodeFormProps) { { + setAdvancedShown((shown) => { + if (shown) manageForm.initialisePorts() + return !shown + }) + }} /> } - label="Show advanced options" + label="Use advanced options" /> @@ -449,53 +183,63 @@ export default function BondNodeForm(props: TBondNodeFormProps) { <> ) => + manageForm.handlePortChange('mixPort', e.target.value) + } + error={manageForm.formData.mixPort.isValid === false} variant="outlined" id="mixPort" name="mixPort" label="Mix Port" fullWidth - defaultValue={DEFAULT_MIX_PORT} /> - - {/*yes, I also hate so many layers of indentation here*/} - {props.type === NodeType.Mixnode ? ( + {type === NodeType.Mixnode ? ( <> ) => + manageForm.handlePortChange('verlocPort', e.target.value) + } + error={manageForm.formData.verlocPort.isValid === false} variant="outlined" id="verlocPort" name="verlocPort" label="Verloc Port" fullWidth - defaultValue={DEFAULT_VERLOC_PORT} /> ) => + manageForm.handlePortChange('httpApiPort', e.target.value) + } + error={manageForm.formData.httpApiPort.isValid === false} variant="outlined" id="httpApiPort" name="httpApiPort" label="HTTP API Port" fullWidth - defaultValue={DEFAULT_HTTP_API_PORT} /> ) : ( ) => + manageForm.handlePortChange('clientsPort', e.target.value) + } + error={manageForm.formData.clientsPort.isValid === false} variant="outlined" id="clientsPort" name="clientsPort" label="client WS API Port" fullWidth - defaultValue={DEFAULT_CLIENTS_PORT} /> )} @@ -503,13 +247,20 @@ export default function BondNodeForm(props: TBondNodeFormProps) { )} -
+
diff --git a/wallet-web/components/bond/NodeBond.tsx b/wallet-web/components/bond/NodeBond.tsx index d11e53f12b..ca946ec7b5 100644 --- a/wallet-web/components/bond/NodeBond.tsx +++ b/wallet-web/components/bond/NodeBond.tsx @@ -1,4 +1,5 @@ import React, { useContext, useEffect } from 'react' +import { useRouter } from 'next/router' import Typography from '@material-ui/core/Typography' import { Grid, LinearProgress, Paper } from '@material-ui/core' import { Gateway, MixNode } from '@nymproject/nym-validator-client/dist/types' @@ -7,12 +8,11 @@ import { printableBalanceToNative } from '@nymproject/nym-validator-client/dist/ import Confirmation from '../Confirmation' import { ValidatorClientContext } from '../../contexts/ValidatorClient' import NoClientError from '../NoClientError' -import { useRouter } from 'next/router' -import BondNodeForm from './BondNodeForm' +import { BondNodeForm } from './BondNodeForm' import { NodeType } from '../../common/node' import Link from '../Link' import { theme } from '../../lib/theme' -import { checkNodesOwnership, makeBasicStyle } from '../../common/helpers' +import { checkNodesOwnership } from '../../common/helpers' import NodeTypeChooser from '../NodeTypeChooser' import ExecFeeNotice from '../ExecFeeNotice' import { UDENOM } from '../../pages/_app' @@ -23,7 +23,6 @@ export type BondingInformation = { } const BondNode = () => { - const classes = makeBasicStyle(theme) const router = useRouter() const { client } = useContext(ValidatorClientContext) @@ -62,15 +61,11 @@ const BondNode = () => { const bondNode = async (bondingInformation: BondingInformation) => { setIsLoading(true) console.log(`BOND button pressed`) - - console.log(bondingInformation) let amountValue = parseInt( printableBalanceToNative(bondingInformation.amount) ) let amount = coin(amountValue, UDENOM) - console.log(bondingInformation.nodeDetails) - if (nodeType == NodeType.Mixnode) { let mixnode = bondingInformation.nodeDetails as MixNode client @@ -141,8 +136,11 @@ const BondNode = () => { ) diff --git a/wallet-web/components/bond/useBondForm.ts b/wallet-web/components/bond/useBondForm.ts new file mode 100644 index 0000000000..2875347de4 --- /dev/null +++ b/wallet-web/components/bond/useBondForm.ts @@ -0,0 +1,205 @@ +import { useEffect, useState } from 'react' +import { Coin } from '@cosmjs/amino' +import { printableBalanceToNative } from '@nymproject/nym-validator-client/dist/currency' +import { BondingInformation } from './NodeBond' +import { + formatDataForSubmission, + isValidHostname, + validateAmount, + validateKey, + validateLocation, + validateVersion, +} from './utils' +import { checkAllocationSize, validateRawPort } from '../../common/helpers' +import { NodeType } from '../../common/node' +import { useGetBalance } from '../../hooks/useGetBalance' + +const DEFAULT_PORTS = { + MIX_PORT: 1789, + VERLOC_PORT: 1790, + HTTP_API_PORT: 8000, + CLIENTS_PORT: 9000, +} + +const initialPorts = { + mixPort: { value: DEFAULT_PORTS.MIX_PORT.toString(), isValid: true }, + verlocPort: { + value: DEFAULT_PORTS.VERLOC_PORT.toString(), + isValid: true, + }, + clientsPort: { + value: DEFAULT_PORTS.CLIENTS_PORT.toString(), + isValid: true, + }, + httpApiPort: { + value: DEFAULT_PORTS.HTTP_API_PORT.toString(), + isValid: true, + }, +} + +const initialData = { + amount: { value: '', isValid: undefined }, + identityKey: { value: '', isValid: undefined }, + sphinxKey: { value: '', isValid: undefined }, + host: { value: '', isValid: undefined }, + version: { value: '', isValid: undefined }, + location: { value: '', isValid: true }, + ...initialPorts, +} + +type TDataField = { value: string; isValid?: boolean } + +type TData = { + amount: TDataField + identityKey: TDataField + sphinxKey: TDataField + host: TDataField + version: TDataField + location: TDataField + mixPort: TDataField + verlocPort: TDataField + clientsPort: TDataField + httpApiPort: TDataField +} + +export const useBondForm = ({ + type, + minimumBond, +}: { + type: NodeType + minimumBond: Coin +}) => { + const [formData, setFormData] = useState(initialData) + const [isValidForm, setIsValidForm] = useState(false) + const [allocationWarning, setAllocationWarning] = useState({ + error: false, + message: undefined, + }) + const { getBalance, accountBalance } = useGetBalance() + + useEffect(() => { + getBalance() + }, [getBalance]) + + useEffect(() => { + if (type === NodeType.Mixnode) + setFormData((data) => ({ ...data, location: initialData.location })) + }, [type]) + + useEffect(() => { + const keys = Object.keys(formData) + const isValid = keys + .map((key) => formData[key].isValid) + .every((value) => value === true) + setIsValidForm(isValid) + }, [formData]) + + const handleSubmit = (cb: (data: BondingInformation) => void) => { + const keys = Object.keys(formData) + const values = keys.reduce((a, c: keyof TData) => { + return { + ...a, + [c]: formData[c].value, + } + }, {}) + const formatted = formatDataForSubmission(values, type) + cb(formatted) + } + + const handleAmountChange = (e: React.ChangeEvent) => { + let isValid = false + + try { + if ( + !isNaN(+e.target.value) && + validateAmount(e.target.value, minimumBond.amount) + ) + isValid = true + + const allocationCheck = checkAllocationSize( + +printableBalanceToNative(e.target.value), + +accountBalance.amount, + 'bond' + ) + setAllocationWarning(allocationCheck) + } catch (e) { + console.log(e) + } + + setFormData((data) => ({ + ...data, + amount: { value: e.target.value, isValid }, + })) + } + + const handleIdentityKeyChange = (e: React.ChangeEvent) => { + const isValid = validateKey(e.target.value) + + setFormData((data) => ({ + ...data, + identityKey: { value: e.target.value, isValid }, + })) + } + + const handleShinxKeyChange = (e: React.ChangeEvent) => { + const isValid = validateKey(e.target.value) + + setFormData((data) => ({ + ...data, + sphinxKey: { value: e.target.value, isValid }, + })) + } + + const handleHostChange = (e: React.ChangeEvent) => { + const isValid = isValidHostname(e.target.value) + setFormData((data) => ({ + ...data, + host: { value: e.target.value, isValid }, + })) + } + + const handleVersionChange = (e: React.ChangeEvent) => { + const isValid = validateVersion(e.target.value) + setFormData((data) => ({ + ...data, + version: { value: e.target.value, isValid }, + })) + } + + const handleLocationChange = (e: React.ChangeEvent) => { + const isValid = validateLocation(e.target.value) + setFormData((data) => ({ + ...data, + location: { value: e.target.value, isValid }, + })) + } + + const handlePortChange = ( + port: 'mixPort' | 'verlocPort' | 'clientsPort' | 'httpApiPort', + value: string + ) => { + const isValid = validateRawPort(value) + setFormData((data) => ({ + ...data, + [port]: { value, isValid }, + })) + } + + const initialisePorts = () => + setFormData((data) => ({ ...data, ...initialPorts })) + + return { + formData, + isValidForm, + allocationWarning, + handleAmountChange, + handleIdentityKeyChange, + handleShinxKeyChange, + handleHostChange, + handleVersionChange, + handleLocationChange, + handlePortChange, + handleSubmit, + initialisePorts, + } +} diff --git a/wallet-web/components/bond/utils.ts b/wallet-web/components/bond/utils.ts new file mode 100644 index 0000000000..8ae2cbb45a --- /dev/null +++ b/wallet-web/components/bond/utils.ts @@ -0,0 +1,121 @@ +import { printableBalanceToNative } from '@nymproject/nym-validator-client/dist/currency' +import { Gateway, MixNode } from '@nymproject/nym-validator-client/dist/types' +import bs58 from 'bs58' +import semver from 'semver' +import { basicRawCoinValueValidation } from '../../common/helpers' +import { NodeType } from '../../common/node' +import { BondingInformation } from './NodeBond' + +export const validateAmount = (rawValue: string, minimum: string): boolean => { + // tests basic coin value requirements, like no more than 6 decimal places, value lower than total supply, etc + if (!basicRawCoinValueValidation(rawValue)) { + return false + } + + // this conversion seems really iffy but I'm not sure how to better approach it + let nativeValueString = printableBalanceToNative(rawValue) + let nativeValue = parseInt(nativeValueString) + console.log(nativeValue, minimum) + return nativeValue >= parseInt(minimum) +} + +export const validateKey = (key: string): boolean => { + // it must be a valid base58 key + try { + const bytes = bs58.decode(key) + // of length 32 + return bytes.length === 32 + } catch { + return false + } +} + +export const isValidHostname = (value) => { + if (typeof value !== 'string') return false + + const validHostnameChars = /^[a-zA-Z0-9-.]{1,253}\.?$/g + if (!validHostnameChars.test(value)) { + return false + } + + if (value.endsWith('.')) { + value = value.slice(0, value.length - 1) + } + + if (value.length > 253) { + return false + } + + const labels = value.split('.') + + const isValid = labels.every(function (label) { + const validLabelChars = /^([a-zA-Z0-9-]+)$/g + + const validLabel = + validLabelChars.test(label) && + label.length < 64 && + !label.startsWith('-') && + !label.endsWith('-') + + return validLabel + }) + + return isValid +} + +// check if its a valid semver +export const validateVersion = (version: string): boolean => + semver.valid(version) && semver.minor(version) >= 11 + +export const validateLocation = (location: string): boolean => { + // right now only perform the stupid check of whether the user copy-pasted the tooltip... (with or without brackets) + return !location.trim().includes('physical location of your node') +} + +export const formatDataForSubmission = ( + { + amount, + host, + identityKey, + sphinxKey, + clientsPort, + httpApiPort, + mixPort, + verlocPort, + location, + version, + }: { [key: string]: string | undefined }, + nodeType: NodeType +) => { + let data = { + amount, + nodeDetails: { + identity_key: identityKey, + sphinx_key: sphinxKey, + host, + version, + mix_port: parseInt(mixPort), + }, + } + + if (nodeType === NodeType.Mixnode) { + data = { + ...data, + nodeDetails: { + ...data.nodeDetails, + verloc_port: parseInt(verlocPort), + http_api_port: parseInt(httpApiPort), + } as MixNode, + } + } else { + data = { + ...data, + nodeDetails: { + ...data.nodeDetails, + location, + clients_port: parseInt(clientsPort), + } as Gateway, + } + } + return data as BondingInformation +}