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

Implement WalletConnect claim message signing and feedback #5570

Merged
merged 8 commits into from
Mar 4, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions libs/wcm/constants/permissions.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const SIGNING_METHODS = {
SIGN_TRANSACTION: { key: 'sign_transaction', title: 'Signature request' },
SIGN_MESSAGE: { key: 'sign_message', title: 'Sign message' },
SIGN_RAW_MESSAGE: { key: 'sign_raw_message', title: 'Sign raw message' },
};
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@
"socket.io-client": "4.7.0",
"stream-browserify": "3.0.0",
"swiper": "8.4.2",
"tweetnacl": "1.0.3",
clemente-xyz marked this conversation as resolved.
Show resolved Hide resolved
"usb": "2.9.0",
"yup": "0.32.11"
},
Expand Down
3 changes: 2 additions & 1 deletion setup/react/app/MainRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Route, Switch } from 'react-router-dom';
import { addSearchParamsToUrl, removeSearchParamsFromUrl } from 'src/utils/searchParams';
import { useEvents } from '@libs/wcm/hooks/useEvents';
import { EVENTS } from '@libs/wcm/constants/lifeCycle';
import { SIGNING_METHODS } from '@libs/wcm/constants/permissions';
import routesMap from 'src/routes/routesMap';
import NotFound from '@common/components/NotFound';
import CustomRoute from '@common/components/customRoute';
Expand All @@ -28,7 +29,7 @@ const MainRouter = ({ history }) => {
if (event.name === EVENTS.SESSION_REQUEST) {
const method = event.meta?.params?.request?.method;

if (method === 'sign_message') {
if (method === SIGNING_METHODS.SIGN_MESSAGE || method === SIGNING_METHODS.SIGN_RAW_MESSAGE) {
showRequestModal('requestSignMessageDialog', event);
} else {
showRequestModal('requestView', event);
Expand Down
2 changes: 1 addition & 1 deletion src/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@
"Invalid amount": "Invalid amount",
"Invalid dates": "Invalid dates",
"Invalid network name": "Invalid network name",
"Invalid transaction initiated from another application/network.": "Invalid transaction initiated from another application/network.",
"Invalid signature request initiated from another application/network.": "Invalid signature request initiated from another application/network.",
"Invalid websocket URL": "Invalid websocket URL",
"Is the problem persisting?": "Is the problem persisting?",
"Keep it safe as it is the only way to access your wallet.": "Keep it safe as it is the only way to access your wallet.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,31 @@ import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { useCurrentAccount } from '@account/hooks';
import { useDispatch } from 'react-redux';
import { signMessage } from '@message/store/action';
import { signMessage, signClaimMessage } from '@message/store/action';
import CopyToClipboard from '@common/components/copyToClipboard';
import { PrimaryButton } from '@theme/buttons';
import styles from './RequestSignMessageConfirmation.css';

export function RequestSignMessageConfirmation({ nextStep, address, message }) {
export function RequestSignMessageConfirmation({ nextStep, address, message, portalMessage }) {
const { t } = useTranslation();
const [currentAccount] = useCurrentAccount();
const dispatch = useDispatch();

/* istanbul ignore next */
const onClick = () => {
nextStep({
message,
actionFunction: (formProps, _, privateKey) =>
dispatch(signMessage({ message, nextStep, privateKey, currentAccount })),
});
if (message) {
nextStep({
message,
actionFunction: (formProps, _, privateKey) =>
dispatch(signMessage({ message, nextStep, privateKey, currentAccount })),
});
} else {
nextStep({
message: portalMessage,
actionFunction: (formProps, _, privateKey) =>
dispatch(signClaimMessage({ portalMessage, nextStep, privateKey, currentAccount })),
});
}
};

return (
Expand All @@ -39,7 +47,7 @@ export function RequestSignMessageConfirmation({ nextStep, address, message }) {
}}
/>
<p className={styles.label}>{t('Message')}</p>
<div className={styles.messageBox}>{message}</div>
<div className={styles.messageBox}>{message ?? portalMessage}</div>
<PrimaryButton className={classNames(styles.btnContinue, 'continue-btn')} onClick={onClick}>
{t('Continue')}
</PrimaryButton>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,43 @@ const props = {
address: 'address',
message: 'message',
};

beforeEach(() => {
render(<RequestSignMessageConfirmation {...props} />);
});
const portalProps = {
...props,
message: undefined,
portalMessage:
'0xe4dbb94d0f19e47b0cff8206bebc1fcf8d892325ab851e1a5bdab954711d926e000000000000000000',
};

describe('RequestSignMessageConfirmation', () => {
it('should render properly', async () => {
it('should render properly when message is passed', async () => {
render(<RequestSignMessageConfirmation {...props} />);
expect(screen.getByText('This request was initiated from another application.')).toBeTruthy();
expect(screen.getByText(props.address)).toBeTruthy();
expect(screen.getByText(props.message)).toBeTruthy();
});

it('should call nextStep when button is clicked', async () => {
it('should render properly when portal message is passed', async () => {
render(<RequestSignMessageConfirmation {...portalProps} />);
expect(screen.getByText('This request was initiated from another application.')).toBeTruthy();
expect(screen.getByText(portalProps.address)).toBeTruthy();
expect(screen.getByText(portalProps.portalMessage)).toBeTruthy();
});

it('should call nextStep with message when button is clicked', async () => {
render(<RequestSignMessageConfirmation {...props} />);
fireEvent.click(screen.getByRole('button'));
expect(props.nextStep).toHaveBeenCalledWith({
message: props.message,
actionFunction: expect.any(Function),
});
});

it('should call nextStep when button is clicked', async () => {
render(<RequestSignMessageConfirmation {...portalProps} />);
fireEvent.click(screen.getByRole('button'));
expect(portalProps.nextStep).toHaveBeenCalledWith({
message: portalProps.portalMessage,
actionFunction: expect.any(Function),
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import SignedMessage from '@message/components/signedMessage';
import { RequestSignMessageConfirmation } from '@blockchainApplication/connection/components/RequestSignMessageDialog/RequestSignMessageConfirmation';
import { USER_REJECT_ERROR } from '@libs/wcm/utils/jsonRPCFormat';
import styles from './RequestSignMessageDialog.css';
import RequestSummary from '../RequestSummary';
import RequestSummary, { getTitle } from '../RequestSummary';

// eslint-disable-next-line max-statements
const RequestSignMessageDialog = () => {
Expand All @@ -31,7 +31,8 @@ const RequestSignMessageDialog = () => {

const { peer, requiredNamespaces } = sessionRequest || {};
const event = events?.find((e) => e.name === EVENTS.SESSION_REQUEST);
const { message, address } = event?.meta?.params?.request?.params || {};
const { method, params: { message, address, portalMessage } = {} } =
event?.meta?.params?.request || {};
const { icons, name, url } = peer?.metadata || {};

/* istanbul ignore next */
Expand Down Expand Up @@ -73,7 +74,7 @@ const RequestSignMessageDialog = () => {
{!isPasswordStep && !isErrorView && (
<BlockchainAppDetailsHeader
className={styles.blockchainAppDetailsHeaderProp}
headerText={multiStepPosition === 2 ? t('Signed message') : t('Sign message')}
headerText={method ? getTitle(method, t) : 'Signed message'}
application={{
data: {
name,
Expand All @@ -93,7 +94,7 @@ const RequestSignMessageDialog = () => {
})}
onChange={onMultiStepChange}
>
<RequestSummary history={history} message={message} />
<RequestSummary history={history} message={message} portalMessage={portalMessage} />
<RequestSignMessageConfirmation message={message} address={address} />
<TxSignatureCollector
type="message"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ import { toTransactionJSON } from '@transaction/utils/encoding';
import { useBlockchainApplicationMeta } from '@blockchainApplication/manage/hooks/queries/useBlockchainApplicationMeta';
import { convertFromBaseDenom } from '@token/fungible/utils/helpers';
import { joinModuleAndCommand } from '@transaction/utils/moduleCommand';
import { signMessage } from '@message/store/action';
import { signMessage, signClaimMessage } from '@message/store/action';
import { addSearchParamsToUrl, removeSearchParamsFromUrl } from 'src/utils/searchParams';
import { sizeOfString } from 'src/utils/helpers';
import { validator } from '@liskhq/lisk-client';
import { useSession } from '@libs/wcm/hooks/useSession';
import { useEvents } from '@libs/wcm/hooks/useEvents';
Expand All @@ -36,12 +37,12 @@ import { ReactComponent as SwitchIcon } from '../../../../../../setup/react/asse
import EmptyState from './EmptyState';
import styles from './requestSummary.css';

const getTitle = (key, t) =>
export const getTitle = (key, t) =>
Object.values(SIGNING_METHODS).find((item) => item.key === key)?.title ?? t('Method not found.');
const defaultToken = { symbol: 'LSK' };

// eslint-disable-next-line max-statements
const RequestSummary = ({ nextStep, history, message }) => {
const RequestSummary = ({ nextStep, history, message, portalMessage }) => {
const { t } = useTranslation();
const { getAccountByAddress, accounts } = useAccounts();
const [currentAccount, setCurrentAccount] = useCurrentAccount();
Expand Down Expand Up @@ -86,6 +87,12 @@ const RequestSummary = ({ nextStep, history, message }) => {
actionFunction: (formProps, _, privateKey) =>
reduxDispatch(signMessage({ message, nextStep, privateKey, currentAccount })),
});
} else if (portalMessage) {
nextStep({
portalMessage,
actionFunction: (formProps, _, privateKey) =>
reduxDispatch(signClaimMessage({ portalMessage, nextStep, privateKey, currentAccount })),
});
} else {
const moduleCommand = joinModuleAndCommand(transaction);
const transactionJSON = toTransactionJSON(transaction, request?.request?.params.schema);
Expand All @@ -110,6 +117,7 @@ const RequestSummary = ({ nextStep, history, message }) => {
});
}
};

const rejectHandler = async () => {
await respond({ payload: USER_REJECT_ERROR });
removeSearchParamsFromUrl(history, ['modal', 'status', 'name', 'action']);
Expand Down Expand Up @@ -144,14 +152,19 @@ const RequestSummary = ({ nextStep, history, message }) => {
const { payload, schema, address: publicKey } = request.request.params;
let transactionObj;

if (!message) {
// Validate portal message
if (portalMessage && sizeOfString(portalMessage) === 84) {
setErrorMessage('');
} else if (portalMessage && sizeOfString(portalMessage) !== 84) {
setErrorMessage('Claim message of invalid size received.');
} else if (!message) {
validator.validator.validateSchema(schema);
transactionObj = decodeTransaction(Buffer.from(payload, 'hex'), schema);
validator.validator.validate(schema, transactionObj.params);
setTransaction(transactionObj);
}

const senderPublicKey = !message ? transactionObj.senderPublicKey : publicKey;
const senderPublicKey =
!message && !portalMessage ? transactionObj.senderPublicKey : publicKey;
const address = extractAddressFromPublicKey(senderPublicKey);
const account = getAccountByAddress(address);
setSenderAccount({
Expand Down Expand Up @@ -203,7 +216,7 @@ const RequestSummary = ({ nextStep, history, message }) => {

return (
<div className={`${styles.wrapper}`}>
{!message && (
{!message && !portalMessage && (
<BlockchainAppDetailsHeader
headerText={getTitle(request?.request?.method, t)}
application={application}
Expand All @@ -221,7 +234,9 @@ const RequestSummary = ({ nextStep, history, message }) => {
</span>
) : (
<div className={styles.invalidTransactionTextContainer}>
<span>{t('Invalid transaction initiated from another application/network.')}</span>
<span>
{t('Invalid signature request initiated from another application/network.')}
</span>
<span className={styles.errorMessage}>{errorMessage}</span>
</div>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ describe('RequestSummary', () => {
});

expect(
screen.getByText('Invalid transaction initiated from another application/network.')
screen.getByText('Invalid signature request initiated from another application/network.')
).toBeTruthy();
expect(
screen.getByText(
Expand Down
2 changes: 1 addition & 1 deletion src/modules/message/components/signedMessage/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ const SignedMessage = ({ signature, error, onPrev, reset }) => {
return (
<Success
t={t}
signature={signature}
signature={typeof signature === 'string' ? signature : JSON.stringify(signature, null, '\t')}
copied={copied}
copy={copy}
history={history}
Expand Down
23 changes: 22 additions & 1 deletion src/modules/message/store/action.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { to } from 'await-to-js';
import { cryptography } from '@liskhq/lisk-client';
import { signMessageUsingHW } from '@wallet/utils/signMessage';
import { signMessageWithPrivateKey } from '../utils/signMessageWithPrivateKey';
import {
signMessageWithPrivateKey,
signClaimMessageWithPrivateKey,
} from '../utils/signMessageWithPrivateKey';

export const getUnsignedNonProtocolMessage = (message) =>
Buffer.concat([
Expand Down Expand Up @@ -39,3 +42,21 @@ export const signMessage =

return nextStep({ signature, message });
};

export const signClaimMessage =
({ nextStep, portalMessage, privateKey, currentAccount }) =>
async () => {
const signature = signClaimMessageWithPrivateKey({
message: portalMessage,
privateKey,
});
const portalSignature = {
data: {
pubKey: currentAccount.metadata.pubkey,
r: `0x${signature.substring(0, 64)}`,
s: `0x${signature.substring(64)}`,
},
};

return nextStep({ signature: portalSignature, portalMessage });
};
43 changes: 39 additions & 4 deletions src/modules/message/store/action.test.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import { cryptography } from '@liskhq/lisk-client';
import { mockHWAccounts } from '@hardwareWallet/__fixtures__';
import * as signMessageUtil from '@wallet/utils/signMessage';
import { signMessage } from './action';
import * as signMessageWithPrivateKeyUtils from '../utils/signMessageWithPrivateKey';
import { signMessage, signClaimMessage } from './action';

jest.spyOn(cryptography.ed, 'signAndPrintMessage');
jest.spyOn(cryptography.ed, 'printSignedMessage');
jest.spyOn(signMessageUtil, 'signMessageUsingHW');
jest.spyOn(signMessageWithPrivateKeyUtils, 'signClaimMessageWithPrivateKey');

const privateKey =
'314852d7afb0d4c283692fef8a2cb40e30c7a5df2ed79994178c10ac168d6d977ef45cd525e95b7a86244bbd4eb4550914ad06301013958f4dd64d32ef7bc588';
const nextStep = jest.fn();

describe('balanceReclaimed', () => {
const mockCurrentAccount = mockHWAccounts[0];
const message = 'test-message';
const nextStep = jest.fn();
const privateKey =
'314852d7afb0d4c283692fef8a2cb40e30c7a5df2ed79994178c10ac168d6d977ef45cd525e95b7a86244bbd4eb4550914ad06301013958f4dd64d32ef7bc588';
const signature =
'68937004b6720d7e1902ef05a577e6d9f9ab2756286b1f2ae918f8a0e5153c15e4f410916076f750b708f8979be2430e4cfc7ebb523ae1905d2ea1f5d24ce700';
const defaultPrintedMessage = `
Expand Down Expand Up @@ -68,3 +71,35 @@ describe('balanceReclaimed', () => {
});
});
});

describe('signClaimMessage', () => {
const mockCurrentAccount = {
metadata: {
pubkey: '5bb1138c01b7762318f5e8a8799573077caadb1c7333a5c631773a2ade4bbdb5',
},
};
const portalMessage =
'0xe4dbb94d0f19e47b0cff8206bebc1fcf8d892325ab851e1a5bdab954711d926e000000000000000000';
afterEach(() => jest.clearAllMocks());

it('should call next step with signature', async () => {
const claimResult =
'15e546e6df7a17960c00c80cb42a3968ca004f2d8efd044cb2bb14e83ba173b02fc4c40ad47b0eca722f3022d5d82874fad25a7c0264d8a31e20f17741a4e602';
signMessageWithPrivateKeyUtils.signClaimMessageWithPrivateKey.mockReturnValue(claimResult);

const signedClaim = {
data: {
pubKey: '5bb1138c01b7762318f5e8a8799573077caadb1c7333a5c631773a2ade4bbdb5',
r: '0x15e546e6df7a17960c00c80cb42a3968ca004f2d8efd044cb2bb14e83ba173b0',
s: '0x2fc4c40ad47b0eca722f3022d5d82874fad25a7c0264d8a31e20f17741a4e602',
},
};
await signClaimMessage({
nextStep,
portalMessage,
privateKey,
currentAccount: mockCurrentAccount,
})();
expect(nextStep).toHaveBeenCalledWith({ signature: signedClaim, portalMessage });
});
});
9 changes: 9 additions & 0 deletions src/modules/message/utils/signMessageWithPrivateKey.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import { cryptography } from '@liskhq/lisk-client';
import { sign } from 'tweetnacl';

export const signMessageWithPrivateKey = ({ message, privateKey }) => {
const result = cryptography.ed.signAndPrintMessage(message, Buffer.from(privateKey, 'hex'));

return result;
};

export const signClaimMessageWithPrivateKey = ({ message, privateKey }) => {
const result = Buffer.from(
sign.detached(Buffer.from(message.substring(2), 'hex'), Buffer.from(privateKey, 'hex'))
).toString('hex');

return result;
};
Loading
Loading