diff --git a/src/components/KeyManager.tsx b/src/components/KeyManager.tsx
index d87bd67..ec31c2e 100644
--- a/src/components/KeyManager.tsx
+++ b/src/components/KeyManager.tsx
@@ -31,6 +31,7 @@ import {
IconKey,
IconPlus,
IconTrash,
+ IconWritingSign,
} from '@tabler/icons-react';
import useConfirmation from '../hooks/useConfirmation';
@@ -64,6 +65,7 @@ import ImportKeyFromLedgerModal from './ImportKeyFromLedgerModal';
import ImportKeyPairModal from './ImportKeyPairModal';
import RenameKeyModal from './RenameKeyModal';
import RevealSecretKeyModal from './RevealSecretKeyModal';
+import SignMessageModal from './SignMessageModal';
type KeyManagerProps = {
isOpen: boolean;
@@ -117,9 +119,11 @@ function KeyManager({ isOpen, onClose }: KeyManagerProps): JSX.Element {
const createAccountModal = useDisclosure();
const importAccountModal = useDisclosure();
const editAccountModal = useDisclosure();
+ const signMessageModal = useDisclosure();
const [renameKeyIdx, setRenameKeyIdx] = useState(0);
const [editAccountIdx, setEditAccountIdx] = useState(0);
+ const [signMessageByKeyIndex, setSignMessageByKeyIndex] = useState(0);
const closeHandler = () => {
onClose();
@@ -241,59 +245,91 @@ function KeyManager({ isOpen, onClose }: KeyManagerProps): JSX.Element {
backgroundColor="blackAlpha.300"
borderRadius="md"
>
- {key.type === KeyPairType.Software ? (
-
- ) : (
-
-
- Hardware
-
- )}
-
+
+ {key.type === KeyPairType.Software ? (
+ <>
+
+
+ >
+ ) : (
+
+
+ Hardware
+
+ )}
+
+
{key.displayName}
- onRenameKey(idx)}
- icon={}
- variant="whiteOutline"
- borderWidth={1}
- size="xs"
- />
- onDeleteKey(idx)}
- icon={}
- variant="dangerOutline"
- borderWidth={1}
- size="xs"
- />
+
+ onRenameKey(idx)}
+ icon={}
+ variant="whiteOutline"
+ borderWidth={1}
+ size="xs"
+ mr={1}
+ />
+ onDeleteKey(idx)}
+ icon={}
+ variant="dangerOutline"
+ borderWidth={1}
+ size="xs"
+ mr={1}
+ />
+
Public Key
@@ -483,6 +519,11 @@ function KeyManager({ isOpen, onClose }: KeyManagerProps): JSX.Element {
isOpen={editAccountModal.isOpen}
onClose={editAccountModal.onClose}
/>
+
diff --git a/src/components/MainMenu.tsx b/src/components/MainMenu.tsx
index c718078..7f998db 100644
--- a/src/components/MainMenu.tsx
+++ b/src/components/MainMenu.tsx
@@ -16,6 +16,7 @@ import { MAIN_MENU_BUTTONS_SIZE } from '../utils/constants';
import KeyManager from './KeyManager';
import MnemonicsModal from './MnemonicsModal';
+import VerifyMessageModal from './VerifyMessageModal';
import WipeOutAlert from './WipeOutAlert';
function MainMenu(): JSX.Element {
@@ -24,6 +25,7 @@ function MainMenu(): JSX.Element {
const wipeAlert = useDisclosure();
const keyManagerDrawer = useDisclosure();
+ const verifyMessageModal = useDisclosure();
const { revealMnemonics } = useMnemonics();
const iconSize = useBreakpointValue(MAIN_MENU_BUTTONS_SIZE, { ssr: false });
@@ -46,6 +48,10 @@ function MainMenu(): JSX.Element {
+
+
@@ -57,6 +63,10 @@ function MainMenu(): JSX.Element {
isOpen={keyManagerDrawer.isOpen}
onClose={keyManagerDrawer.onClose}
/>
+
>
);
}
diff --git a/src/components/SignMessage.tsx b/src/components/SignMessage.tsx
deleted file mode 100644
index e69de29..0000000
diff --git a/src/components/SignMessageModal.tsx b/src/components/SignMessageModal.tsx
new file mode 100644
index 0000000..7c269eb
--- /dev/null
+++ b/src/components/SignMessageModal.tsx
@@ -0,0 +1,175 @@
+import fileDownload from 'js-file-download';
+import { useState } from 'react';
+import { useForm } from 'react-hook-form';
+
+import {
+ Button,
+ Modal,
+ ModalBody,
+ ModalCloseButton,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+ ModalOverlay,
+ Spacer,
+ Text,
+ Textarea,
+} from '@chakra-ui/react';
+
+import useCopy from '../hooks/useCopy';
+import { useSignMessage } from '../hooks/useSigning';
+import usePassword from '../store/usePassword';
+import useWallet from '../store/useWallet';
+import { SignedMessage } from '../types/message';
+import { KeyPairType } from '../types/wallet';
+import { SIGNED_MESSAGE_PREFIX } from '../utils/constants';
+import { toHexString } from '../utils/hexString';
+
+type SignMessageModalProps = {
+ keyIndex: number;
+ isOpen: boolean;
+ onClose: () => void;
+};
+
+function SignMessageModal({
+ keyIndex,
+ isOpen,
+ onClose,
+}: SignMessageModalProps): JSX.Element | null {
+ const { wallet } = useWallet();
+ const signMessage = useSignMessage();
+ const { withPassword } = usePassword();
+ const { register, reset, handleSubmit } = useForm<{ message: string }>();
+ const [signResult, setSignResult] = useState('');
+ const { isCopied, onCopy } = useCopy();
+
+ const keys = wallet?.keychain ?? [];
+ const key = keys[keyIndex];
+ if (!key) return null;
+
+ const close = () => {
+ setSignResult('');
+ reset();
+ onClose();
+ };
+
+ const download = () =>
+ fileDownload(signResult, `signed-message.json`, 'plain/text');
+
+ const submit = handleSubmit(async ({ message }) => {
+ const result = await withPassword(
+ async (password) => {
+ if (key.type === KeyPairType.Hardware) {
+ // Sign using Ledger device
+ throw new Error('Hardware wallet is not supported yet');
+ }
+ const text = `${SIGNED_MESSAGE_PREFIX}${message}`;
+ // Sign using local key
+ return JSON.stringify(
+ {
+ publicKey: key.publicKey,
+ text,
+ signature: toHexString(
+ await signMessage(text, key.publicKey, password)
+ ),
+ } satisfies SignedMessage,
+ null,
+ 2
+ );
+ },
+ 'Sign message',
+ <>
+ Please enter your password to sign the message using key "
+ {key.displayName}"{' '}
+
+ ({key.publicKey})
+
+ >
+ );
+ if (result) {
+ setSignResult(result);
+ } else {
+ setSignResult('');
+ }
+ });
+
+ return (
+
+
+
+
+ Sign message
+ {signResult ? (
+ <>
+
+
+ Here is your message and the signature. You can copy them to the
+ clipboard and share with another party.
+
+
+
+
+
+
+
+
+
+ >
+ ) : (
+ <>
+
+
+ You are going to sign some text message using key "
+ {key.displayName}"{' '}
+
+ ({key.publicKey})
+
+ :
+
+
+
+ The signature will be generated using the private key.
+
+ Another party may verify the message using your public key.
+
+
+
+
+
+ >
+ )}
+
+
+ );
+}
+
+export default SignMessageModal;
diff --git a/src/components/VerifyMessageModal.tsx b/src/components/VerifyMessageModal.tsx
new file mode 100644
index 0000000..79e34f1
--- /dev/null
+++ b/src/components/VerifyMessageModal.tsx
@@ -0,0 +1,208 @@
+import React, { useRef, useState } from 'react';
+import { Form, useForm } from 'react-hook-form';
+
+import { CheckCircleIcon, WarningIcon } from '@chakra-ui/icons';
+import {
+ Box,
+ Button,
+ Input,
+ Modal,
+ ModalBody,
+ ModalCloseButton,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+ ModalOverlay,
+ Text,
+ Textarea,
+} from '@chakra-ui/react';
+
+import { useVerifyMessage } from '../hooks/useSigning';
+import { isSignedMessage } from '../types/message';
+import { SIGNED_MESSAGE_PREFIX } from '../utils/constants';
+
+type VerifyMessageModalProps = {
+ isOpen: boolean;
+ onClose: () => void;
+};
+
+enum VerifyStatus {
+ None = 0,
+ Valid = 1,
+ Invalid = 2,
+}
+
+function VerifyMessageModal({
+ isOpen,
+ onClose,
+}: VerifyMessageModalProps): JSX.Element {
+ const inputRef = useRef(null);
+ const {
+ setValue,
+ register,
+ reset,
+ setError: setFormError,
+ clearErrors,
+ handleSubmit,
+ formState: { errors },
+ control,
+ } = useForm<{
+ signedMessage: string;
+ }>();
+ const [verifyStatus, setVerifyStatus] = useState(VerifyStatus.None);
+ const verifyMessage = useVerifyMessage();
+
+ const close = () => {
+ setVerifyStatus(VerifyStatus.None);
+ reset();
+ onClose();
+ };
+
+ const setError = (...args: Parameters) => {
+ setVerifyStatus(VerifyStatus.None);
+ setFormError(...args);
+ };
+
+ const submit = handleSubmit(({ signedMessage }) => {
+ const data = (() => {
+ try {
+ return JSON.parse(signedMessage);
+ } catch (err) {
+ setError('root', {
+ type: 'manual',
+ message: `Failed to parse the message:\n${err}`,
+ });
+ return null;
+ }
+ })();
+
+ if (!data) return;
+ if (!isSignedMessage(data)) {
+ setError('root', {
+ type: 'manual',
+ message: 'Invalid signed message format',
+ });
+ return;
+ }
+ verifyMessage(data.signature, data.text, data.publicKey)
+ .then((result) => {
+ setVerifyStatus(result ? VerifyStatus.Valid : VerifyStatus.Invalid);
+ })
+ .catch((err) => {
+ setError('root', {
+ type: 'manual',
+ message: `Failed to verify the signature:\n${err}`,
+ });
+ });
+ });
+
+ const readFile = (event: React.ChangeEvent) => {
+ const file = event.target.files?.[0];
+ if (!file) {
+ return;
+ }
+ setValue('signedMessage', '');
+ clearErrors('root');
+
+ const reader = new FileReader();
+ reader.onload = () => {
+ if (typeof reader.result !== 'string') {
+ setError('root', { type: 'manual', message: 'Failed to read file' });
+ return;
+ }
+ try {
+ const msg = JSON.parse(reader.result as string);
+ if (!isSignedMessage(msg)) {
+ setError('root', {
+ type: 'manual',
+ message: 'Invalid signed message file',
+ });
+ return;
+ }
+ setValue('signedMessage', reader.result);
+ } catch (err) {
+ setError('root', {
+ type: 'manual',
+ message: `Failed to open signed message file:\n${err}`,
+ });
+ }
+ };
+ reader.readAsText(file);
+ if (inputRef.current) {
+ // Make it possible to load the same file again
+ inputRef.current.value = '';
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ );
+}
+
+export default VerifyMessageModal;
diff --git a/src/hooks/useSigning.ts b/src/hooks/useSigning.ts
new file mode 100644
index 0000000..b5c6f6c
--- /dev/null
+++ b/src/hooks/useSigning.ts
@@ -0,0 +1,31 @@
+import { signAsync, verifyAsync } from '@noble/ed25519';
+
+import useWallet from '../store/useWallet';
+import { HexString } from '../types/common';
+
+export const useSignMessage = () => {
+ const { revealSecretKey } = useWallet();
+
+ return async (
+ message: Uint8Array | string,
+ publicKey: HexString,
+ password: string
+ ) => {
+ const data =
+ typeof message === 'string' ? new TextEncoder().encode(message) : message;
+ const secret = await revealSecretKey(publicKey, password);
+ return signAsync(data, secret.slice(0, 64));
+ };
+};
+
+export const useVerifyMessage =
+ () =>
+ async (
+ signature: HexString,
+ message: Uint8Array | string,
+ publicKey: HexString
+ ) => {
+ const data =
+ typeof message === 'string' ? new TextEncoder().encode(message) : message;
+ return verifyAsync(signature, data, publicKey);
+ };
diff --git a/src/hooks/useTxMethods.ts b/src/hooks/useTxMethods.ts
index 69e2f44..d6931ea 100644
--- a/src/hooks/useTxMethods.ts
+++ b/src/hooks/useTxMethods.ts
@@ -1,12 +1,11 @@
import { O } from '@mobily/ts-belt';
-import { signAsync } from '@noble/ed25519';
import { fetchEstimatedGas, fetchPublishTx } from '../api/requests/tx';
-import useWallet from '../store/useWallet';
import { HexString } from '../types/common';
import { prepareTxForSign } from '../utils/tx';
import { useCurrentGenesisID, useCurrentRPC } from './useNetworkSelectors';
+import { useSignMessage } from './useSigning';
export const useEstimateGas = () => {
const rpc = useCurrentRPC();
@@ -19,8 +18,8 @@ export const useEstimateGas = () => {
};
export const useSignTx = () => {
- const { revealSecretKey } = useWallet();
const genesisID = useCurrentGenesisID();
+ const sign = useSignMessage();
return async (
encodedTx: Uint8Array,
publicKey: HexString,
@@ -31,11 +30,7 @@ export const useSignTx = () => {
'Please select the network first and then sign a transaction.'
);
}
- const secret = await revealSecretKey(publicKey, password);
- return signAsync(
- prepareTxForSign(genesisID, encodedTx),
- secret.slice(0, 64)
- );
+ return sign(prepareTxForSign(genesisID, encodedTx), publicKey, password);
};
};
diff --git a/src/types/message.ts b/src/types/message.ts
new file mode 100644
index 0000000..9fc96b8
--- /dev/null
+++ b/src/types/message.ts
@@ -0,0 +1,14 @@
+import { HexString } from './common';
+
+export type SignedMessage = {
+ publicKey: HexString;
+ text: string;
+ signature: HexString;
+};
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const isSignedMessage = (data: any): data is SignedMessage =>
+ data &&
+ typeof data.publicKey === 'string' &&
+ typeof data.text === 'string' &&
+ typeof data.signature === 'string';
diff --git a/src/utils/constants.ts b/src/utils/constants.ts
index a2e2dca..4e71088 100644
--- a/src/utils/constants.ts
+++ b/src/utils/constants.ts
@@ -59,3 +59,5 @@ export const GENESIS_VESTING_ACCOUNTS = {
};
export const GENESIS_VESTING_START = 105120;
export const GENESIS_VESTING_END = 420480;
+
+export const SIGNED_MESSAGE_PREFIX = 'Message:';