Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Unable to Sign multisignature transaction - Closes #3975, Closes #3978 #3987

Merged
merged 20 commits into from
Dec 3, 2021
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"All": "All",
"All accounts": "All accounts",
"All blocks": "All blocks",
"All members can not be optional. Consider changing them to mandatory.": "All members can not be optional. Consider changing them to mandatory.",
"All transactions": "All transactions",
"All types": "All types",
"All you need to do:": "All you need to do:",
Expand Down Expand Up @@ -154,6 +155,8 @@
"Edit bookmark": "Edit bookmark",
"Edit transaction": "Edit transaction",
"Edit vote": "Edit vote",
"Either change the optional member to mandatory or define more optional members.": "Either change the optional member to mandatory or define more optional members.",
"Either change the optional member to mandatory or reduce the number of signatures.": "Either change the optional member to mandatory or reduce the number of signatures.",
"Empty": "Empty",
"Empty/Not empty": "Empty/Not empty",
"Enable custom derivation path": "Enable custom derivation path",
Expand Down Expand Up @@ -308,7 +311,9 @@
"Nothing has been found. Make sure to double check the ID you typed.": "Nothing has been found. Make sure to double check the ID you typed.",
"Now you can send it to the blockchain. You may also copy or download it, if you wish to send the transaction using another device later.": "Now you can send it to the blockchain. You may also copy or download it, if you wish to send the transaction using another device later.",
"Number": "Number",
"Number of signatures must be lower than or equal to the number of members.": "Number of signatures must be lower than or equal to the number of members.",
"Number of signatures must be above {{num}}.": "Number of signatures must be above {{num}}.",
"Number of signatures must be equal to the number of members.": "Number of signatures must be equal to the number of members.",
"Number of signatures must be lower than {{num}}.": "Number of signatures must be lower than {{num}}.",
"Official guidelines": "Official guidelines",
"Ok": "Ok",
"Old account": "Old account",
Expand Down Expand Up @@ -602,6 +607,7 @@
"Your multisignatures groups": "Your multisignatures groups",
"Your session will be timed out in {{time}} if no network activity occurs.": "Your session will be timed out in {{time}} if no network activity occurs.",
"Your signature was successful": "Your signature was successful",
"Your signature will replace one optional signature.": "Your signature will replace one optional signature.",
"Your tokens and passphrase are safe.": "Your tokens and passphrase are safe.",
"Your transaction has been submitted and will appear in sender account's wallet after confirmation.": "Your transaction has been submitted and will appear in sender account's wallet after confirmation.",
"Your transaction has been submitted and will be confirmed in a few moments.": "Your transaction has been submitted and will be confirmed in a few moments.",
Expand Down
67 changes: 53 additions & 14 deletions src/components/screens/multiSignature/form/form.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,61 @@ const placeholderMember = {
const getInitialMembersState = (prevState) => prevState.members ?? [placeholderMember];
const getInitialSignaturesState = (prevState) => prevState.numberOfSignatures ?? 2;

const validateState = ({
const validators = [
{
pattern: (_, optional) => optional.length === 1,
message: t => t('Either change the optional member to mandatory or define more optional members.'),
},
{
pattern: (mandatory, optional) => mandatory.length === 0 && optional.length > 0,
message: t => t('All members can not be optional. Consider changing them to mandatory.'),
},
{
pattern: (mandatory, optional, signatures) =>
mandatory.length > 0 && optional.length === 0 && signatures !== mandatory.length,
message: t => t('Number of signatures must be equal to the number of members.'),
},
{
pattern: (mandatory, optional, signatures) =>
mandatory.length > 0 && optional.length > 0 && signatures <= mandatory.length,
message: (t, mandatory) => t(t('Number of signatures must be above {{num}}.', { num: mandatory.length })),
},
{
pattern: (mandatory, optional, signatures) =>
mandatory.length > 0 && optional.length > 0
&& signatures === mandatory.length + optional.length,
message: t => t('Either change the optional member to mandatory or reduce the number of signatures.'),
},
{
pattern: (mandatory, optional, signatures) =>
mandatory.length > 0 && optional.length > 0
&& signatures > mandatory.length + optional.length,
message: (t, mandatory, optional) => t('Number of signatures must be lower than {{num}}.', { num: mandatory.length + optional.length }),
},
{
pattern: (mandatory, optional) => mandatory.length + optional.length > MAX_MULTI_SIG_MEMBERS,
message: t => t('Maximum number of members is {{MAX_MULTI_SIG_MEMBERS}}.', { MAX_MULTI_SIG_MEMBERS }),
},
{
pattern: (mandatory, optional) =>
mandatory.some(item => !regex.publicKey.test(item))
|| optional.some(item => !regex.publicKey.test(item)),
message: t => t('Please enter a valid public key for each member.'),
},
];

export const validateState = ({
mandatoryKeys, optionalKeys, requiredSignatures, t,
}) => {
const messages = [];
if (requiredSignatures > MAX_MULTI_SIG_MEMBERS) {
messages.push(t('Maximum number of members is {{MAX_MULTI_SIG_MEMBERS}}.', { MAX_MULTI_SIG_MEMBERS }));
}
if (requiredSignatures > mandatoryKeys.length + optionalKeys.length) {
messages.push(t('Number of signatures must be lower than or equal to the number of members.'));
}
if (
mandatoryKeys.some(item => !regex.publicKey.test(item))
|| optionalKeys.some(item => !regex.publicKey.test(item))
) {
messages.push(t('Please enter a valid public key for each member.'));
}
const messages = validators
.map((scenario) => {
if (scenario.pattern(mandatoryKeys, optionalKeys, requiredSignatures)) {
return scenario.message(t, mandatoryKeys, optionalKeys);
}
return null;
})
.filter(item => !!item);

return {
error: (mandatoryKeys.length + optionalKeys.length) ? messages.length : -1,
messages,
Expand Down
88 changes: 87 additions & 1 deletion src/components/screens/multiSignature/form/form.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import useTransactionFeeCalculation from '@shared/transactionPriority/useTransac
import { fromRawLsk } from '@utils/lsk';
import accounts from '../../../../../test/constants/accounts';

import Form from './form';
import Form, { validateState } from './form';

jest.mock('@shared/transactionPriority/useTransactionFeeCalculation');
jest.mock('@api/transaction');
Expand Down Expand Up @@ -98,3 +98,89 @@ describe('Multisignature editor component', () => {
expect(props.nextStep).toHaveBeenCalledTimes(1);
});
});

describe('validateState', () => {
it('should return error if signature are less than mandatory members', () => {
const pbk = accounts.genesis.summary.publicKey;
const params = {
mandatoryKeys: [pbk, pbk, pbk],
optionalKeys: [],
requiredSignatures: 2,
t: str => str,
};
const error = 'Number of signatures must be equal to the number of members.';
expect(validateState(params).messages).toContain(error);
});

it('should return error if signatures are more than all members', () => {
const pbk = accounts.genesis.summary.publicKey;
const params = {
mandatoryKeys: [pbk, pbk, pbk],
optionalKeys: [],
requiredSignatures: 5,
t: str => str,
};
const error = 'Number of signatures must be equal to the number of members.';
expect(validateState(params).messages).toContain(error);
});

it('should return error if optional members are practically mandatory', () => {
const pbk = accounts.genesis.summary.publicKey;
const params = {
mandatoryKeys: [pbk, pbk, pbk],
optionalKeys: [pbk],
requiredSignatures: 4,
t: str => str,
};
const error = 'Either change the optional member to mandatory or define more optional members.';
expect(validateState(params).messages).toContain(error);
});

it('should return error if optional members never get to sign', () => {
const pbk = accounts.genesis.summary.publicKey;
const params = {
mandatoryKeys: [pbk, pbk, pbk],
optionalKeys: [pbk],
requiredSignatures: 3,
t: str => str,
};
const error = 'Either change the optional member to mandatory or define more optional members.';
expect(validateState(params).messages).toContain(error);
});

it('should return error if there are only optional members', () => {
const pbk = accounts.genesis.summary.publicKey;
const params = {
mandatoryKeys: [],
optionalKeys: [pbk, pbk, pbk],
requiredSignatures: 3,
t: str => str,
};
const error = 'All members can not be optional. Consider changing them to mandatory.';
expect(validateState(params).messages).toContain(error);
});

it('should return error if the number of signature is equal to optional and mandatory members', () => {
const pbk = accounts.genesis.summary.publicKey;
const params = {
mandatoryKeys: [pbk, pbk, pbk],
optionalKeys: [pbk, pbk, pbk],
requiredSignatures: 6,
t: str => str,
};
const error = 'Either change the optional member to mandatory or reduce the number of signatures.';
expect(validateState(params).messages).toContain(error);
});

it('should return error if there are more than 64 members', () => {
const pbk = accounts.genesis.summary.publicKey;
const params = {
mandatoryKeys: new Array(65).fill(pbk),
optionalKeys: [],
requiredSignatures: 65,
t: str => str,
};
const error = 'Maximum number of members is {{MAX_MULTI_SIG_MEMBERS}}.';
expect(validateState(params).messages).toContain(error);
});
});
3 changes: 2 additions & 1 deletion src/components/screens/registerDelegate/status/status.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import TransactionResult from '@shared/transactionResult';
import { getTransactionStatus, txStatusTypes } from '@shared/transactionResult/statusConfig';
import { txStatusTypes } from '@constants';
import { getTransactionStatus } from '@shared/transactionResult/statusConfig';
import DelegateAnimation from '../animations/delegateAnimation';
import statusMessages from './statusMessages';
import styles from './status.css';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* istanbul ignore file */
import { statusMessages, txStatusTypes } from '@shared/transactionResult/statusConfig';
import { statusMessages } from '@shared/transactionResult/statusConfig';
import { txStatusTypes } from '@constants';

const registerDelegatesMessages = t => ({
...statusMessages(t),
Expand Down
3 changes: 2 additions & 1 deletion src/components/screens/send/status/status.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import React, { useEffect } from 'react';
import { isEmpty } from '@utils/helpers';
import { txStatusTypes } from '@constants';
import { PrimaryButton } from '@toolbox/buttons';
import TransactionResult from '@shared/transactionResult';
import { getTransactionStatus, txStatusTypes, statusMessages } from '@shared/transactionResult/statusConfig';
import { getTransactionStatus, statusMessages } from '@shared/transactionResult/statusConfig';
import DialogLink from '@toolbox/dialog/link';
import styles from './status.css';

Expand Down
20 changes: 17 additions & 3 deletions src/components/screens/signMultiSignTransaction/helpers.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { MODULE_ASSETS_NAME_ID_MAP } from '@constants';
import { MODULE_ASSETS_NAME_ID_MAP, signatureCollectionStatus } from '@constants';
import { joinModuleAndAssetIds } from '@utils/moduleAssets';
import { getKeys } from '@utils/account';

Expand Down Expand Up @@ -26,7 +26,7 @@ export const findNonEmptySignatureIndices = (signatures) => {
};

// eslint-disable-next-line max-statements
export const isTransactionFullySigned = (senderAccount, transaction) => {
export const getTransactionSignatureStatus = (senderAccount, transaction) => {
const moduleAssetId = transaction.moduleAssetId || joinModuleAndAssetIds(transaction);
const isGroupRegistration = moduleAssetId
=== MODULE_ASSETS_NAME_ID_MAP.registerMultisignatureGroup;
Expand All @@ -39,8 +39,22 @@ export const isTransactionFullySigned = (senderAccount, transaction) => {
});

const alreadySigned = getNonEmptySignatures(transaction.signatures).length;
const registrationExtra = isGroupRegistration ? 1 : 0;
const mandatorySigs = keys.mandatoryKeys.length + registrationExtra;
const nonEmptyMandatorySigs = getNonEmptySignatures(
transaction.signatures.slice(0, mandatorySigs),
).length;

return (required === alreadySigned);
if (required > alreadySigned) {
return signatureCollectionStatus.partiallySigned;
}
if (required === alreadySigned && nonEmptyMandatorySigs === mandatorySigs) {
return signatureCollectionStatus.fullySigned;
}
if (required === alreadySigned && nonEmptyMandatorySigs < mandatorySigs) {
return signatureCollectionStatus.occupiedByOptionals;
}
return signatureCollectionStatus.overSigned;
};

// eslint-disable-next-line max-statements
Expand Down
13 changes: 12 additions & 1 deletion src/components/screens/signMultiSignTransaction/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@

& > .footer {
padding-top: 20px;

& + footer {
padding-top: 0;
}
}

& .feedback {
Expand All @@ -29,13 +33,20 @@

& > span {
width: 100%;
background: var(--error-background-color);
color: var(--color-strong-white);
border-radius: var(--border-radius-standard);
display: block;
padding: var(--vertical-padding-s) var(--horizontal-padding-m);
box-sizing: border-box;
}

&.error > span {
background: var(--error-background-color);
}

&.warning > span {
background: var(--color-danger);
}
}
}

Expand Down
16 changes: 10 additions & 6 deletions src/components/screens/signMultiSignTransaction/summary/footer.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import { signatureCollectionStatus } from '@constants';
import { useTheme } from '@utils/theme';
import { removeSearchParamsFromUrl } from '@utils/searchParams';
import { PrimaryButton, SecondaryButton } from '@toolbox/buttons';
Expand Down Expand Up @@ -28,22 +29,25 @@ export const ActionBar = ({
);

export const Feedback = ({
t, isMember, isFullySigned,
t, isMember, signatureStatus,
}) => {
let feedback = 'Unknown error';
let feedback;
const statusMessages = {
fullySigned: t('Transaction is already fully signed.'),
occupiedByOptionals: t('Your signature will replace one optional signature.'),
};
if (!isMember) {
feedback = t('Only members of the group can sign the transaction.');
}
if (isFullySigned) {
feedback = t('Transaction is already fully signed.');
} else {
feedback = statusMessages[signatureStatus];
}

return (
<BoxFooter
direction="horizontal"
className={styles.footer}
>
<div className={`${styles.feedback} feedback`}>
<div className={`${styles.feedback} ${signatureStatus === signatureCollectionStatus.occupiedByOptionals ? styles.warning : styles.error} feedback`}>
<span>{feedback}</span>
</div>
</BoxFooter>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import React, { useMemo } from 'react';
import { isEmpty } from '@utils/helpers';
import { signatureCollectionStatus } from '@constants';
import BoxContent from '@toolbox/box/content';
import Box from '@toolbox/box';
import TransactionDetails from '@screens/transactionDetails/transactionDetails';

import ProgressBar from '../progressBar';
import { showSignButton, isTransactionFullySigned } from '../helpers';
import { showSignButton, getTransactionSignatureStatus } from '../helpers';
import { ActionBar, Feedback } from './footer';
import styles from '../styles.css';

Expand All @@ -25,9 +26,9 @@ const Summary = ({
return null;
}, [senderAccount.data]);

const isFullySigned = useMemo(() => {
const signatureStatus = useMemo(() => {
if (senderAccount.data.keys) {
return isTransactionFullySigned(senderAccount.data, transaction);
return getTransactionSignatureStatus(senderAccount.data, transaction);
}
return null;
}, [senderAccount.data]);
Expand All @@ -40,11 +41,13 @@ const Summary = ({
};

const nextButton = {
title: isFullySigned ? t('Continue') : t('Sign'),
title: signatureStatus === signatureCollectionStatus.fullySigned ? t('Continue') : t('Sign'),
onClick,
};

const showFeedback = !isMember || isFullySigned;
const showFeedback = !isMember
|| signatureStatus === signatureCollectionStatus.fullySigned
|| signatureStatus === signatureCollectionStatus.occupiedByOptionals;

if (isEmpty(senderAccount.data)) {
return <div />;
Expand Down Expand Up @@ -84,7 +87,7 @@ const Summary = ({
<Feedback
t={t}
isMember={isMember}
isFullySigned={isFullySigned}
signatureStatus={signatureStatus}
/>
) : null
}
Expand Down
Loading