diff --git a/.vscode/settings.json b/.vscode/settings.json index 06337fda..014ffa92 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,5 +10,6 @@ "[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "eslint.validate": ["typescript", "typescriptreact"] + "eslint.validate": ["typescript", "typescriptreact"], + "typescript.tsdk": "node_modules/typescript/lib" } diff --git a/components/admins/components/validatorList.tsx b/components/admins/components/validatorList.tsx index 4ec262b5..753d31f4 100644 --- a/components/admins/components/validatorList.tsx +++ b/components/admins/components/validatorList.tsx @@ -27,6 +27,19 @@ export default function ValidatorList({ const [modalId, setModalId] = useState(null); const [warningVisible, setWarningVisible] = useState(false); const [validatorToRemove, setValidatorToRemove] = useState(null); + const totalvp = Array.isArray(activeValidators) + ? activeValidators.reduce( + (acc, validator) => acc + BigInt(validator?.consensus_power ?? 0), + BigInt(0) + ) + : BigInt(0); + + const validatorVPArray = Array.isArray(activeValidators) + ? activeValidators.map(validator => ({ + moniker: validator.description.moniker, + vp: BigInt(validator?.consensus_power ?? 0), + })) + : []; useEffect(() => { if (modalId) { @@ -174,6 +187,8 @@ export default function ValidatorList({ validator={selectedValidator} modalId={modalId || ''} admin={admin} + totalvp={totalvp.toString()} + validatorVPArray={validatorVPArray} /> { ).toBeInTheDocument(); }); - test('updates input field correctly', () => { + test('updates input field correctly', async () => { renderWithProps(); - const input = screen.getByPlaceholderText('manifest123...'); - fireEvent.change(input, { target: { value: 'manifest1newadminaddress' } }); - expect(input).toHaveValue('manifest1newadminaddress'); + const input = screen.getByLabelText('Admin Address'); + fireEvent.change(input, { target: { value: validAddress } }); + await waitFor(() => { + expect(input).toHaveValue(validAddress); + }); }); - test('disables update button when input is invalid', () => { + test('disables update button when input is invalid', async () => { renderWithProps(); - const input = screen.getByPlaceholderText('manifest123...'); + const input = screen.getByLabelText('Admin Address'); const updateButton = screen.getByText('Update'); expect(updateButton).toBeDisabled(); fireEvent.change(input, { target: { value: 'invalidaddress' } }); - expect(updateButton).toBeDisabled(); + await waitFor(() => { + expect(updateButton).toBeDisabled(); + }); }); - test('enables update button when input is valid', () => { + test('enables update button when input is valid', async () => { renderWithProps(); const updateButton = screen.getByText('Update'); expect(updateButton).toBeDisabled(); - const input = screen.getByPlaceholderText('manifest123...'); + const input = screen.getByLabelText('Admin Address'); fireEvent.change(input, { target: { value: validAddress } }); - expect(updateButton).toBeEnabled(); + await waitFor(() => { + expect(updateButton).toBeEnabled(); + }); + }); + + test('accepts valid manifest1 address', async () => { + renderWithProps(); + const input = screen.getByLabelText('Admin Address'); + const longValidAddress = 'manifest1' + 'a'.repeat(38); + fireEvent.change(input, { target: { value: longValidAddress } }); + await waitFor(() => { + expect( + screen.queryByText('Please enter a valid manifest1 address (at least 38 characters long)') + ).not.toBeInTheDocument(); + }); }); // // TODO: Why is this test failing? diff --git a/components/admins/modals/__tests__/validatorModal.test.tsx b/components/admins/modals/__tests__/validatorModal.test.tsx index 023d5277..c7b97e7c 100644 --- a/components/admins/modals/__tests__/validatorModal.test.tsx +++ b/components/admins/modals/__tests__/validatorModal.test.tsx @@ -1,6 +1,6 @@ import { describe, test, afterEach, expect } from 'bun:test'; import React from 'react'; -import { screen, fireEvent, cleanup, within } from '@testing-library/react'; +import { screen, fireEvent, cleanup, within, waitFor } from '@testing-library/react'; import { ValidatorDetailsModal } from '@/components/admins/modals/validatorModal'; import matchers from '@testing-library/jest-dom/matchers'; import { mockActiveValidators } from '@/tests/mock'; @@ -11,10 +11,19 @@ expect.extend(matchers); const validator = mockActiveValidators[0]; const modalId = 'test-modal'; const admin = 'manifest1adminaddress'; +const totalvp = '10000'; +const validatorVPArray = [{ vp: BigInt(1000), moniker: 'Validator One' }]; function renderWithProps(props = {}) { return renderWithChainProvider( - + ); } @@ -30,27 +39,55 @@ describe('ValidatorDetailsModal Component', () => { expect(within(detailsContainer).getByText('details1')).toBeInTheDocument(); }); - test('updates input field correctly', () => { + test('updates input field correctly', async () => { renderWithProps(); const input = screen.getByPlaceholderText('1000'); - fireEvent.change(input, { target: { value: 2000 } }); - expect(input).toHaveValue(2000); + fireEvent.change(input, { target: { value: '2000' } }); + await waitFor(() => { + expect(input).toHaveValue(2000); + }); }); - test('enables update button when input is valid', () => { + test('enables update button when input is valid', async () => { renderWithProps(); const input = screen.getByPlaceholderText('1000'); - fireEvent.change(input, { target: { value: 2000 } }); - const updateButton = screen.getByText('update'); - expect(updateButton).toBeEnabled(); + fireEvent.change(input, { target: { value: '2000' } }); + await waitFor(() => { + const updateButton = screen.getByText('Update'); + expect(updateButton).not.toBeDisabled(); + }); }); - // // TODO: Why is this test failing? - // // https://github.com/capricorn86/haVyppy-dom/issues/1184 - // test('closes modal when close button is clicked', async () => { - // renderWithProps(); - // const closeButton = screen.getByText('✕'); - // fireEvent.click(closeButton); - // await waitFor(() => expect(screen.queryByText('Validator Details')).not.toBeInTheDocument()); - // }); + test('disables update button when input is invalid', async () => { + renderWithProps(); + const input = screen.getByPlaceholderText('1000'); + fireEvent.change(input, { target: { value: '-1' } }); + fireEvent.blur(input); + await waitFor(() => { + const updateButton = screen.getByText('Update'); + expect(updateButton).toBeDisabled(); + }); + }); + + test('shows error message for invalid power input', async () => { + renderWithProps(); + const input = screen.getByPlaceholderText('1000'); + fireEvent.change(input, { target: { value: '-1' } }); + fireEvent.blur(input); + await waitFor(() => { + const errorMessage = screen.getByText(/power must be a non-negative number/i); + expect(errorMessage).toBeInTheDocument(); + }); + }); + + test('shows warning message for unsafe power update', async () => { + renderWithProps(); + const input = screen.getByPlaceholderText('1000'); + fireEvent.change(input, { target: { value: '9000' } }); + fireEvent.blur(input); + await waitFor(() => { + const warningMessage = screen.getByText(/Warning: This power update may be unsafe/i); + expect(warningMessage).toBeInTheDocument(); + }); + }); }); diff --git a/components/admins/modals/updateAdminModal.tsx b/components/admins/modals/updateAdminModal.tsx index 2a1b19ab..e9be177c 100644 --- a/components/admins/modals/updateAdminModal.tsx +++ b/components/admins/modals/updateAdminModal.tsx @@ -3,8 +3,11 @@ import { useFeeEstimation, useTx } from '@/hooks'; import { cosmos, strangelove_ventures } from '@chalabi/manifestjs'; import { Any } from '@chalabi/manifestjs/dist/codegen/google/protobuf/any'; import { MsgUpdateParams } from '@chalabi/manifestjs/dist/codegen/strangelove_ventures/poa/v1/tx'; -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import { PiWarning } from 'react-icons/pi'; +import { Formik, Form } from 'formik'; +import Yup from '@/utils/yupExtensions'; +import { TextInput } from '@/components/react/inputs'; interface UpdateModalProps { modalId: string; @@ -13,13 +16,16 @@ interface UpdateModalProps { allowExit: boolean | undefined; } +const UpdateAdminSchema = Yup.object().shape({ + newAdmin: Yup.string().required('Admin address is required').manifestAddress(), +}); + export function UpdateAdminModal({ modalId, admin, userAddress, allowExit, }: Readonly) { - const [newAdmin, setNewAdmin] = useState(''); const [isValidAddress, setIsValidAddress] = useState(false); const { estimateFee } = useFeeEstimation(chainName); @@ -27,18 +33,13 @@ export function UpdateAdminModal({ const { updateParams } = strangelove_ventures.poa.v1.MessageComposer.withTypeUrl; const { submitProposal } = cosmos.group.v1.MessageComposer.withTypeUrl; - useEffect(() => { - const isValid = /^manifest1[a-zA-Z0-9]{38}$/.test(newAdmin); - setIsValidAddress(isValid); - }, [newAdmin]); - - const handleUpdate = async () => { + const handleUpdate = async (values: { newAdmin: string }) => { if (!isValidAddress) return; const msgUpdateAdmin = updateParams({ sender: admin ?? '', params: { - admins: [newAdmin], + admins: [values.newAdmin], allowValidatorSelfExit: allowExit ?? false, }, }); @@ -54,7 +55,7 @@ export function UpdateAdminModal({ metadata: '', proposers: [userAddress ?? ''], title: `Update PoA Admin`, - summary: `This proposal will update the administrator of the PoA module to ${newAdmin}`, + summary: `This proposal will update the administrator of the PoA module to ${values.newAdmin}`, exec: 0, }); @@ -67,49 +68,55 @@ export function UpdateAdminModal({ return ( -
- -

Update Admin

-
-
-
-
- - Warning + + {({ isValid, dirty, values, setFieldValue }) => ( + + +

Update Admin

+
+
+
+
+ + Warning +
+

+ Currently, the admin is set to a group policy address. While the admin can be any + manifest1 address, it is recommended to set the new admin to another group policy + address. +

+
+
+ ) => { + const newValue = e.target.value; + setFieldValue('newAdmin', newValue); + setIsValidAddress(/^manifest1[a-zA-Z0-9]{38}$/.test(newValue)); + }} + /> +
-

- Currently, the admin is set to a group policy address. While the admin can be any - manifest1 address, it is recommended to set the new admin to another group policy - address. -

-
-
- - setNewAdmin(e.target.value)} - /> - {newAdmin && !isValidAddress && ( -

Please enter a valid manifest1 address

- )} -
-
-
- -
- +
+ +
+ + )} +
diff --git a/components/admins/modals/validatorModal.tsx b/components/admins/modals/validatorModal.tsx index 4bec5887..f008c37c 100644 --- a/components/admins/modals/validatorModal.tsx +++ b/components/admins/modals/validatorModal.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { TruncatedAddressWithCopy } from '@/components/react/addressCopy'; import { ExtendedValidatorSDKType } from '../components'; import ProfileAvatar from '@/utils/identicon'; @@ -11,21 +11,33 @@ import { useChain } from '@cosmos-kit/react'; import { cosmos } from '@chalabi/manifestjs'; import { Any } from '@chalabi/manifestjs/dist/codegen/google/protobuf/any'; import { MsgSetPower } from '@chalabi/manifestjs/dist/codegen/strangelove_ventures/poa/v1/tx'; -import { - Cosmos_basev1beta1Msg_ToAmino, - Cosmos_basev1beta1Msg_InterfaceDecoder, -} from '@chalabi/manifestjs/dist/codegen/cosmos/group/v1/tx'; +import { Formik, Form, Field, ErrorMessage } from 'formik'; +import * as Yup from 'yup'; +import { calculateIsUnsafe } from '@/utils/maths'; + +const PowerUpdateSchema = Yup.object().shape({ + power: Yup.number() + .typeError('Power must be a number') + .required('Power is required') + .min(0, 'Power must be a non-negative number') + .integer('Power must be an integer'), +}); export function ValidatorDetailsModal({ validator, modalId, admin, + totalvp, + validatorVPArray, }: Readonly<{ validator: ExtendedValidatorSDKType | null; modalId: string; admin: string; + totalvp: string; + validatorVPArray: { vp: bigint; moniker: string }[]; }>) { const [power, setPowerInput] = useState(validator?.consensus_power?.toString() || ''); + const [isSigning, setIsSigning] = useState(false); const { tx } = useTx(chainName); const { estimateFee } = useFeeEstimation(chainName); const { address: userAddress } = useChain(chainName); @@ -33,6 +45,10 @@ export function ValidatorDetailsModal({ const { setPower } = strangelove_ventures.poa.v1.MessageComposer.withTypeUrl; const { submitProposal } = cosmos.group.v1.MessageComposer.withTypeUrl; + const isUnsafe = React.useMemo(() => { + return calculateIsUnsafe(power, validator?.consensus_power?.toString() || '', totalvp); + }, [power, validator?.consensus_power, totalvp]); + if (!validator) return null; const isEmail = (contact: string | undefined): boolean => { @@ -48,12 +64,13 @@ export function ValidatorDetailsModal({ modal?.showModal(); }; - const handleUpdate = async () => { + const handleUpdate = async (values: { power: string }) => { + setIsSigning(true); const msgSetPower = setPower({ sender: admin ?? '', validatorAddress: validator.operator_address, - power: BigInt(power), - unsafe: false, + power: BigInt(values.power), + unsafe: isUnsafe, }); const anyMessage = Any.fromPartial({ @@ -67,88 +84,130 @@ export function ValidatorDetailsModal({ metadata: '', proposers: [userAddress ?? ''], title: `Update the Voting Power of ${validator.description.moniker}`, - summary: `This proposal will update the voting power of ${validator.description.moniker} to ${power}`, + summary: `This proposal will update the voting power of ${validator.description.moniker} to ${values.power}`, exec: 0, }); const fee = await estimateFee(userAddress ?? '', [groupProposalMsg]); await tx([groupProposalMsg], { fee, - onSuccess: () => {}, + onSuccess: () => { + setIsSigning(false); + }, }); + setIsSigning(false); + }; + + const handleClose = () => { + const modal = document.getElementById(modalId) as HTMLDialogElement; + modal?.close(); }; return ( -
- -

Validator Details

-
-
- VALIDATOR -
- {validator.logo_url !== '' && ( - - )} - {validator.logo_url === '' && ( - - )} - {validator.description.moniker} -
-
- -
-
- SECURITY CONTACT - - {isEmail(validator.description.security_contact) - ? validator.description.security_contact - : 'No Security Contact'} - -
-
- POWER -
- setPowerInput(e.target.value)} - placeholder={validator.consensus_power?.toString() ?? 'Inactive'} - className="input input-bordered input-xs w-2/3" - type="number" - /> - -
-
-
- OPERATOR ADDRESS - - - -
-
-
- DETAILS - {validator.description.details.length > 50 && ( - - )} -
+

Validator Details

+
+
+ VALIDATOR +
+ {validator.logo_url !== '' && ( + + )} + {validator.logo_url === '' && ( + + )} + {validator.description.moniker} +
+
- - {validator.description.details - ? validator.description.details.substring(0, 50) + - (validator.description.details.length > 50 ? '...' : '') - : 'No Details'} - -
-
-
+
+
+ SECURITY CONTACT + + {isEmail(validator.description.security_contact) + ? validator.description.security_contact + : 'No Security Contact'} + +
+
+ POWER +
+
+ ) => { + setPowerInput(e.target.value); + }} + /> + +
+ + {isUnsafe && Number(power) > 0 && ( +
+ Warning: This power update may be unsafe +
+ )} +
+
+
+ OPERATOR ADDRESS + + + +
+
+
+ DETAILS + {validator.description.details.length > 50 && ( + + )} +
+ + + {validator.description.details + ? validator.description.details.substring(0, 50) + + (validator.description.details.length > 50 ? '...' : '') + : 'No Details'} + +
+
+
+ ); + }} + 0 ? balances[0] : null; const validationSchema = Yup.object().shape({ - recipient: Yup.string() - .required('Recipient is required') - .test('valid-address', 'Invalid address', value => { - // Use the prefix from the destination chain for validation - return value?.startsWith(destinationChain) && value.length >= 44; - }), + recipient: Yup.string().required('Recipient is required').manifestAddress(), amount: Yup.string() .required('Amount is required') .test('valid-amount', 'Invalid amount', value => { - return /^\d*\.?\d*$/.test(value); // Allows integers and decimals + return /^\d*\.?\d*$/.test(value); }) .test('positive', 'Amount must be positive', value => { return parseFloat(value) > 0; diff --git a/components/bank/forms/sendForm.tsx b/components/bank/forms/sendForm.tsx index 70632cef..6d575ad8 100644 --- a/components/bank/forms/sendForm.tsx +++ b/components/bank/forms/sendForm.tsx @@ -37,11 +37,7 @@ export default function SendForm({ const initialSelectedToken = balances && balances.length > 0 ? balances[0] : null; const validationSchema = Yup.object().shape({ - recipient: Yup.string() - .required('Recipient is required') - .test('valid-address', 'Invalid address', value => { - return value?.startsWith('manifest') && value.length >= 44; - }), + recipient: Yup.string().required('Recipient is required').manifestAddress(), amount: Yup.string() .required('Amount is required') .test('valid-amount', 'Invalid amount', value => { diff --git a/components/factory/forms/CreateDenom.tsx b/components/factory/forms/CreateDenom.tsx index 26600806..698c1800 100644 --- a/components/factory/forms/CreateDenom.tsx +++ b/components/factory/forms/CreateDenom.tsx @@ -2,7 +2,21 @@ import { TokenAction, TokenFormData } from '@/helpers/formReducer'; import { useTx, useFeeEstimation } from '@/hooks'; import { osmosis } from '@chalabi/manifestjs'; import Link from 'next/link'; -import { useState, useEffect } from 'react'; +import { useState } from 'react'; +import { Formik, Form } from 'formik'; +import Yup from '@/utils/yupExtensions'; +import { TextInput } from '@/components/react/inputs'; + +const DenomSchema = Yup.object().shape({ + subdenom: Yup.string() + .required('Subdenom is required') + .matches( + /^[uaqg][a-zA-Z0-9]+$/, + 'Subdenom must start with u, a, q, or g, followed by letters and numbers' + ) + .min(4, 'Subdenom must be at least 4 characters') + .max(44, 'Subdenom must not exceed 44 characters'), +}); export default function CreateDenom({ nextStep, @@ -15,52 +29,17 @@ export default function CreateDenom({ dispatch: React.Dispatch; address: string; }>) { - const [error, setError] = useState(null); - const [touched, setTouched] = useState(false); - - const updateField = (field: keyof TokenFormData, value: any) => { - dispatch({ type: 'UPDATE_FIELD', field, value }); - setTouched(true); - }; - const { createDenom } = osmosis.tokenfactory.v1beta1.MessageComposer.withTypeUrl; const [isSigning, setIsSigning] = useState(false); const { tx } = useTx('manifest'); const { estimateFee } = useFeeEstimation('manifest'); - const validateSubdenom = (value: string) => { - if (value.length === 0) { - return 'Subdenom is required'; - } - if (!/^[a-zA-Z]/.test(value)) { - return 'Subdenom must start with a letter'; - } - if (!/^[a-zA-Z0-9]+$/.test(value)) { - return 'Subdenom can only contain letters and numbers'; - } - return null; - }; - - useEffect(() => { - if (touched) { - const validationError = validateSubdenom(formData.subdenom); - setError(validationError); - } - }, [formData.subdenom, touched]); - - const handleConfirm = async () => { - setTouched(true); - const validationError = validateSubdenom(formData.subdenom); - if (validationError) { - setError(validationError); - return; - } - + const handleConfirm = async (values: TokenFormData) => { setIsSigning(true); try { const msg = createDenom({ sender: address ?? '', - subdenom: formData.subdenom, + subdenom: values.subdenom, }); const fee = await estimateFee(address ?? '', [msg]); @@ -71,7 +50,6 @@ export default function CreateDenom({ }, }); } catch (error) { - setIsSigning(false); console.error('Error during transaction setup:', error); } finally { setIsSigning(false); @@ -86,45 +64,46 @@ export default function CreateDenom({

Create Denom

-
-
-
- - updateField('subdenom', e.target.value)} - onBlur={() => setTouched(true)} - /> - {touched && error && ( -

- {error} -

- )} + + {({ isValid, dirty, setFieldValue }) => ( + +
+ ) => { + dispatch({ + type: 'UPDATE_FIELD', + field: 'subdenom', + value: e.target.value, + }); + setFieldValue('subdenom', e.target.value); + }} + /> +

We recommend starting with 'u' (e.g., 'utoken')

-
-
-
- + )} - +