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

WalletConnect #15803

Open
wants to merge 4 commits into
base: develop
Choose a base branch
from
Open
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
15 changes: 11 additions & 4 deletions packages/components/src/components/Note/Note.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,28 @@ import { spacings } from '@trezor/theme';

import { FrameProps, FramePropsKeys } from '../../utils/frameProps';
import { Row } from '../Flex/Flex';
import { Icon, IconName } from '../Icon/Icon';
import { Icon, IconName, IconVariant } from '../Icon/Icon';
import { Paragraph } from '../typography/Paragraph/Paragraph';

export const allowedNoteFrameProps = ['margin', 'gap'] as const satisfies FramePropsKeys[];
type AllowedFrameProps = Pick<FrameProps, (typeof allowedNoteFrameProps)[number]>;

export type NoteProps = AllowedFrameProps & {
iconName?: IconName;
variant?: IconVariant;
children: ReactNode;
};

export const Note = ({ children, iconName = 'info', margin, gap = spacings.xxs }: NoteProps) => (
export const Note = ({
children,
iconName = 'info',
margin,
gap = spacings.xxs,
variant = 'tertiary',
}: NoteProps) => (
<Row gap={gap} margin={margin}>
<Icon name={iconName} size={16} variant="tertiary" />
<Paragraph typographyStyle="hint" variant="tertiary">
<Icon name={iconName} size={16} variant={variant} />
<Paragraph typographyStyle="hint" variant={variant}>
{children}
</Paragraph>
</Row>
Expand Down
1 change: 1 addition & 0 deletions packages/connect/src/events/call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ type CallApi = {

export type CallMethodUnion = CallApi[keyof CallApi];
export type CallMethodPayload = Parameters<CallMethodUnion>[0];
export type CallMethodParams<M extends keyof CallApi> = Parameters<CallApi[M]>[0];
export type CallMethodResponse<M extends keyof CallApi> = UnwrappedResponse<ReturnType<CallApi[M]>>;
export type CallMethodAnyResponse = ReturnType<CallMethodUnion>;

Expand Down
68 changes: 43 additions & 25 deletions packages/suite-desktop-connect-popup/src/connectPopupThunks.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,34 @@
import { createThunk } from '@suite-common/redux-utils';
import { AsyncThunkAction } from '@reduxjs/toolkit';

import { CustomThunkAPI, createThunk } from '@suite-common/redux-utils';
import { selectSelectedDevice } from '@suite-common/wallet-core';
import TrezorConnect, { ERRORS } from '@trezor/connect';
import TrezorConnect, { CallMethodParams, CallMethodResponse, ERRORS } from '@trezor/connect';
import { serializeError } from '@trezor/connect/src/constants/errors';
import { desktopApi } from '@trezor/suite-desktop-api';
import { createDeferred } from '@trezor/utils';

const CONNECT_POPUP_MODULE = '@common/connect-popup';

export const connectPopupCallThunk = createThunk(
type ConnectPopupCallThunkResponse<M extends keyof typeof TrezorConnect> = Promise<{
id: number;
success: boolean;
payload: CallMethodResponse<M>;
}>;

type ConnectPopupCallThunkParams<M extends keyof typeof TrezorConnect> = {
id: number;
processName?: string;
origin?: string;
method: M;
payload: Omit<CallMethodParams<M>, 'method'>;
};

export const connectPopupCallThunkInner = createThunk<
ConnectPopupCallThunkResponse<keyof typeof TrezorConnect>,
ConnectPopupCallThunkParams<keyof typeof TrezorConnect>
>(
`${CONNECT_POPUP_MODULE}/callThunk`,
async (
{
id,
method,
payload,
processName,
origin,
}: {
id: number;
method: string;
payload: any;
processName?: string;
origin?: string;
},
{ dispatch, getState, extra },
) => {
async ({ id, method, payload, processName, origin }, { dispatch, getState, extra }) => {
try {
const device = selectSelectedDevice(getState());

Expand Down Expand Up @@ -71,27 +75,41 @@ export const connectPopupCallThunk = createThunk(

dispatch(extra.actions.onModalCancel());

desktopApi.connectPopupResponse({
return {
...response,
id,
});
};
} catch (error) {
console.error('connectPopupCallThunk', error);
desktopApi.connectPopupResponse({
dispatch(extra.actions.onModalCancel());

return {
success: false,
payload: serializeError(error),
id,
});
};
}
},
);

// Typed thunk that takes the method as a generic parameter
// Original thunk is exposed as well for using .fulfilled, .rejected, etc.
export const connectPopupCallThunk = <M extends keyof typeof TrezorConnect>(
params: ConnectPopupCallThunkParams<M>,
): AsyncThunkAction<
ConnectPopupCallThunkResponse<M>,
ConnectPopupCallThunkParams<M>,
CustomThunkAPI
> => connectPopupCallThunkInner(params) as any;

export const connectPopupInitThunk = createThunk(
`${CONNECT_POPUP_MODULE}/initPopupThunk`,
async (_, { dispatch }) => {
if (desktopApi.available && (await desktopApi.connectPopupEnabled())) {
desktopApi.on('connect-popup/call', params => {
dispatch(connectPopupCallThunk(params));
desktopApi.on('connect-popup/call', async params => {
// @ts-expect-error: params in desktopApi are not fully typed
const response = await dispatch(connectPopupCallThunk(params)).unwrap();
desktopApi.connectPopupResponse(response);
});
desktopApi.connectPopupReady();
}
Expand Down
1 change: 1 addition & 0 deletions packages/suite-desktop-core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const allowedDomains = [
'eth-api-b2c.everstake.one', // staking endpoint for Ethereum mainnet
'dashboard-api.everstake.one', // staking enpoint for Solana
'stake-sync-api.everstake.one', // staking rewards enpoint for Solana
'verify.walletconnect.org', // WalletConnect
];

export const cspRules = [
Expand Down
4 changes: 2 additions & 2 deletions packages/suite/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,10 @@ module.exports = {
'/public/',
],

transformIgnorePatterns: ['/node_modules/(?!d3-(.*)|internmap)/'],
transformIgnorePatterns: ['/node_modules/(?!d3-(.*)|internmap|@walletconnect|uint8arrays)/'],
testMatch: ['**/*.test.(ts|tsx|js)'],
transform: {
'(d3-|internmap).*\\.js$': ['babel-jest', babelConfig],
'(d3-|internmap|esm).*\\.js$': ['babel-jest', babelConfig],
'\\.(ts|tsx)$': ['babel-jest', babelConfig],
},
verbose: false,
Expand Down
1 change: 1 addition & 0 deletions packages/suite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"@suite-common/wallet-core": "workspace:*",
"@suite-common/wallet-types": "workspace:*",
"@suite-common/wallet-utils": "workspace:*",
"@suite-common/walletconnect": "workspace:*",
"@trezor/address-validator": "workspace:*",
"@trezor/analytics": "workspace:*",
"@trezor/blockchain-link": "workspace:*",
Expand Down
4 changes: 4 additions & 0 deletions packages/suite/src/actions/suite/__tests__/initAction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
prepareDeviceReducer,
updateMissingTxFiatRatesThunk,
} from '@suite-common/wallet-core';
import { walletConnectInitThunk } from '@suite-common/walletconnect';
import TrezorConnect from '@trezor/connect';

import { ROUTER, SUITE } from 'src/actions/suite/constants';
Expand Down Expand Up @@ -121,6 +122,7 @@ const fixtures: Fixture[] = [
updateMissingTxFiatRatesThunk.fulfilled.type,
periodicCheckStakeDataThunk.pending.type,
initStakeDataThunk.pending.type,
walletConnectInitThunk.pending.type,
SUITE.READY,
],
},
Expand Down Expand Up @@ -167,6 +169,7 @@ const fixtures: Fixture[] = [
ROUTER.LOCATION_CHANGE,
periodicCheckStakeDataThunk.pending.type,
initStakeDataThunk.pending.type,
walletConnectInitThunk.pending.type,
SUITE.READY,
],
},
Expand Down Expand Up @@ -211,6 +214,7 @@ const fixtures: Fixture[] = [
ROUTER.LOCATION_CHANGE,
periodicCheckStakeDataThunk.pending.type,
initStakeDataThunk.pending.type,
walletConnectInitThunk.pending.type,
SUITE.READY,
],
},
Expand Down
2 changes: 2 additions & 0 deletions packages/suite/src/actions/suite/initAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
periodicFetchFiatRatesThunk,
updateMissingTxFiatRatesThunk,
} from '@suite-common/wallet-core';
import * as walletConnectActions from '@suite-common/walletconnect';
import { isDesktop } from '@trezor/env-utils';
import { desktopApi } from '@trezor/suite-desktop-api';
import * as trezorConnectPopupActions from '@trezor/suite-desktop-connect-popup';
Expand Down Expand Up @@ -118,6 +119,7 @@ export const init = () => async (dispatch: Dispatch, getState: GetState) => {
if (isDesktop()) {
dispatch(trezorConnectPopupActions.connectPopupInitThunk());
}
dispatch(walletConnectActions.walletConnectInitThunk());

// 15. backend connected, suite is ready to use
dispatch(onSuiteReady());
Expand Down
19 changes: 16 additions & 3 deletions packages/suite/src/actions/suite/protocolActions.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { Protocol } from '@suite-common/suite-constants';
import { getNetworkSymbolForProtocol } from '@suite-common/suite-utils';
import { notificationsActions } from '@suite-common/toast-notifications';
import { SUITE_BRIDGE_DEEPLINK } from '@trezor/urls';
import * as walletConnectActions from '@suite-common/walletconnect';
import { SUITE_BRIDGE_DEEPLINK, SUITE_WALLETCONNECT_DEEPLINK } from '@trezor/urls';

import * as routerActions from 'src/actions/suite/routerActions';
import type { SendFormState } from 'src/reducers/suite/protocolReducer';
import type { Dispatch } from 'src/types/suite';
import { selectIsDebugModeActive } from 'src/reducers/suite/suiteReducer';
import type { Dispatch, GetState } from 'src/types/suite';
import { parseUri } from 'src/utils/suite/parseUri';
import { CoinProtocolInfo, getProtocolInfo } from 'src/utils/suite/protocol';

import { PROTOCOL } from './constants';
Expand All @@ -31,7 +34,7 @@ const saveCoinProtocol = (scheme: Protocol, address: string, amount?: number): P
payload: { scheme, address, amount },
});

export const handleProtocolRequest = (uri: string) => (dispatch: Dispatch) => {
export const handleProtocolRequest = (uri: string) => (dispatch: Dispatch, getState: GetState) => {
const protocol = getProtocolInfo(uri);

if (protocol && !('error' in protocol) && getNetworkSymbolForProtocol(protocol.scheme)) {
Expand All @@ -49,6 +52,16 @@ export const handleProtocolRequest = (uri: string) => (dispatch: Dispatch) => {
);
} else if (uri?.startsWith(SUITE_BRIDGE_DEEPLINK)) {
dispatch(routerActions.goto('suite-bridge-requested', { params: { cancelable: true } }));
} else if (uri?.startsWith(SUITE_WALLETCONNECT_DEEPLINK)) {
// This feature is currently only available in debug mode
const isDebug = selectIsDebugModeActive(getState());
if (!isDebug) return;

const parsedUri = parseUri(uri);
const wcUri = parsedUri?.searchParams?.get('uri');
if (wcUri) {
dispatch(walletConnectActions.walletConnectPairThunk({ uri: wcUri }));
}
Comment on lines +55 to +64
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Enhance error handling for WalletConnect URIs.

The WalletConnect URI handling could be more robust with proper error handling and user feedback.

 } else if (uri?.startsWith(SUITE_WALLETCONNECT_DEEPLINK)) {
     // This feature is currently only available in debug mode
     const isDebug = selectIsDebugModeActive(getState());
-    if (!isDebug) return;
+    if (!isDebug) {
+        dispatch(notificationsActions.addToast({
+            type: 'error',
+            message: 'WalletConnect is only available in debug mode',
+        }));
+        return;
+    }

     const parsedUri = parseUri(uri);
+    if (!parsedUri) {
+        dispatch(notificationsActions.addToast({
+            type: 'error',
+            message: 'Invalid WalletConnect URI format',
+        }));
+        return;
+    }
+
     const wcUri = parsedUri?.searchParams?.get('uri');
     if (wcUri) {
         dispatch(walletConnectActions.walletConnectPairThunk({ uri: wcUri }));
+    } else {
+        dispatch(notificationsActions.addToast({
+            type: 'error',
+            message: 'Missing WalletConnect URI parameter',
+        }));
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} else if (uri?.startsWith(SUITE_WALLETCONNECT_DEEPLINK)) {
// This feature is currently only available in debug mode
const isDebug = selectIsDebugModeActive(getState());
if (!isDebug) return;
const parsedUri = parseUri(uri);
const wcUri = parsedUri?.searchParams?.get('uri');
if (wcUri) {
dispatch(walletConnectActions.walletConnectPairThunk({ uri: wcUri }));
}
} else if (uri?.startsWith(SUITE_WALLETCONNECT_DEEPLINK)) {
// This feature is currently only available in debug mode
const isDebug = selectIsDebugModeActive(getState());
if (!isDebug) {
dispatch(notificationsActions.addToast({
type: 'error',
message: 'WalletConnect is only available in debug mode',
}));
return;
}
const parsedUri = parseUri(uri);
if (!parsedUri) {
dispatch(notificationsActions.addToast({
type: 'error',
message: 'Invalid WalletConnect URI format',
}));
return;
}
const wcUri = parsedUri?.searchParams?.get('uri');
if (wcUri) {
dispatch(walletConnectActions.walletConnectPairThunk({ uri: wcUri }));
} else {
dispatch(notificationsActions.addToast({
type: 'error',
message: 'Missing WalletConnect URI parameter',
}));
}

}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,17 @@ export const ConnectPopupModal = ({

{processName && (
<Paragraph margin={{ top: spacings.xs }}>
Process: <strong>{processName}</strong>
<Translation id="TR_CONNECT_MODAL_PROCESS" /> <strong>{processName}</strong>
</Paragraph>
)}
{origin && (
<Paragraph>
Web Origin: <strong>{origin}</strong>
<Translation id="TR_CONNECT_MODAL_WEB_ORIGIN" /> <strong>{origin}</strong>
</Paragraph>
)}
Comment on lines 39 to 48
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add origin validation and display warning for untrusted origins.

Enhance security by validating and highlighting potentially unsafe origins.

+const isUntrustedOrigin = (origin: string) => {
+    // Add origin validation logic
+    return !origin.startsWith('https://');
+};

 {origin && (
     <Paragraph>
         <Translation id="TR_CONNECT_MODAL_WEB_ORIGIN" /> <strong>{origin}</strong>
+        {isUntrustedOrigin(origin) && (
+            <Paragraph color="warning">
+                <Translation id="TR_CONNECT_MODAL_UNTRUSTED_ORIGIN_WARNING" />
+            </Paragraph>
+        )}
     </Paragraph>
 )}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{processName && (
<Paragraph margin={{ top: spacings.xs }}>
Process: <strong>{processName}</strong>
<Translation id="TR_CONNECT_MODAL_PROCESS" /> <strong>{processName}</strong>
</Paragraph>
)}
{origin && (
<Paragraph>
Web Origin: <strong>{origin}</strong>
<Translation id="TR_CONNECT_MODAL_WEB_ORIGIN" /> <strong>{origin}</strong>
</Paragraph>
)}
{processName && (
<Paragraph margin={{ top: spacings.xs }}>
<Translation id="TR_CONNECT_MODAL_PROCESS" /> <strong>{processName}</strong>
</Paragraph>
)}
+const isUntrustedOrigin = (origin: string) => {
+ // Add origin validation logic
+ return !origin.startsWith('https://');
+};
{origin && (
<Paragraph>
<Translation id="TR_CONNECT_MODAL_WEB_ORIGIN" /> <strong>{origin}</strong>
{isUntrustedOrigin(origin) && (
<Paragraph color="warning">
<Translation id="TR_CONNECT_MODAL_UNTRUSTED_ORIGIN_WARNING" />
</Paragraph>
)}
</Paragraph>
)}


<Paragraph variant="tertiary" margin={{ top: spacings.xs }}>
A 3rd party application is trying to connect to your device. Do you want to allow this
action?
<Translation id="TR_CONNECT_MODAL_REQUEST_DESCRIPTION" />
</Paragraph>
</NewModal>
);
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { FirmwareRevisionOptOutModal } from './FirmwareRevisionOptOutModal';
import { PassphraseMismatchModal } from './PassphraseMismatchModal';
import { CardanoWithdrawModal } from '../CardanoWithdrawModal';
import { EverstakeModal } from './UnstakeModal/EverstakeModal';
import { WalletConnectProposalModal } from './WalletConnectProposalModal';

/** Modals opened as a result of user action */
export const UserContextModal = ({
Expand Down Expand Up @@ -222,6 +223,8 @@ export const UserContextModal = ({
processName={payload.processName}
/>
);
case 'walletconnect-proposal':
return <WalletConnectProposalModal eventId={payload.eventId} />;
default:
return null;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import {
selectPendingProposal,
sessionProposalApproveThunk,
sessionProposalRejectThunk,
} from '@suite-common/walletconnect';
import { Banner, H2, NewModal, Note, Paragraph } from '@trezor/components';
import { spacings } from '@trezor/theme';

import { onCancel } from 'src/actions/suite/modalActions';
import { Translation } from 'src/components/suite';
import { useDispatch, useSelector } from 'src/hooks/suite';

interface WalletConnectProposalModalProps {
eventId: number;
}

export const WalletConnectProposalModal = ({ eventId }: WalletConnectProposalModalProps) => {
const dispatch = useDispatch();
const pendingProposal = useSelector(selectPendingProposal);

const handleAccept = () => {
dispatch(sessionProposalApproveThunk({ eventId }));
dispatch(onCancel());
};
const handleReject = () => {
dispatch(sessionProposalRejectThunk({ eventId }));
dispatch(onCancel());
};
martykan marked this conversation as resolved.
Show resolved Hide resolved

return (
<NewModal
onCancel={handleReject}
iconName="plugs"
variant="primary"
bottomContent={
<>
<NewModal.Button variant="tertiary" onClick={handleReject}>
<Translation id="TR_CANCEL" />
</NewModal.Button>
<NewModal.Button
variant="primary"
onClick={handleAccept}
isDisabled={
!pendingProposal || pendingProposal.expired || pendingProposal.isScam
}
>
<Translation id="TR_CONFIRM" />
</NewModal.Button>
</>
}
heading={<Translation id="TR_TREZOR_CONNECT" />}
>
<H2>{pendingProposal?.params.proposer.metadata.name}</H2>

<Paragraph>{pendingProposal?.params.proposer.metadata.url}</Paragraph>

Comment on lines +53 to +56
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add null checks for pendingProposal metadata.

The metadata access could throw if pendingProposal is null. Use optional chaining consistently.

Apply this diff:

-            <H2>{pendingProposal?.params.proposer.metadata.name}</H2>
+            <H2>{pendingProposal?.params?.proposer?.metadata?.name}</H2>

-            <Paragraph>{pendingProposal?.params.proposer.metadata.url}</Paragraph>
+            <Paragraph>{pendingProposal?.params?.proposer?.metadata?.url}</Paragraph>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<H2>{pendingProposal?.params.proposer.metadata.name}</H2>
<Paragraph>{pendingProposal?.params.proposer.metadata.url}</Paragraph>
<H2>{pendingProposal?.params?.proposer?.metadata?.name}</H2>
<Paragraph>{pendingProposal?.params?.proposer?.metadata?.url}</Paragraph>

{!pendingProposal?.isScam && pendingProposal?.validation === 'VALID' && (
<Note variant="info" iconName="shieldCheckFilled">
<Translation id="TR_WALLETCONNECT_SERVICE_VERIFIED" />
</Note>
)}
{!pendingProposal?.isScam && pendingProposal?.validation === 'UNKNOWN' && (
<Note variant="warning" iconName="shieldWarningFilled">
<Translation id="TR_WALLETCONNECT_SERVICE_UNKNOWN" />
</Note>
)}
{(pendingProposal?.isScam || pendingProposal?.validation === 'INVALID') && (
<Note variant="destructive" iconName="shieldWarningFilled">
<Translation id="TR_WALLETCONNECT_SERVICE_DANGEROUS" />
</Note>
)}

<Paragraph variant="tertiary" margin={{ top: spacings.xs }}>
<Translation id="TR_WALLETCONNECT_REQUEST" />
</Paragraph>

{pendingProposal?.isScam && (
<Banner variant="destructive" margin={{ top: spacings.xs }}>
<Translation id="TR_WALLETCONNECT_IS_SCAM" />
</Banner>
)}
{pendingProposal?.validation === 'INVALID' && (
<Banner variant="destructive" margin={{ top: spacings.xs }}>
<Translation id="TR_WALLETCONNECT_UNABLE_TO_VERIFY" />
</Banner>
)}

{pendingProposal?.expired && (
<Banner variant="warning" margin={{ top: spacings.xs }}>
<Translation id="TR_WALLETCONNECT_REQUEST_EXPIRED" />
</Banner>
)}
</NewModal>
);
};
Loading
Loading