Skip to content

Commit 7774958

Browse files
feat: add HardwarewalletProvider and HardwareWalletBottomSheet for HW lifecycle and error management (#26520)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** Part 4 of the hardware wallet connection & error management overhaul. This does not introduce user facing changes. Will close: - https://consensyssoftware.atlassian.net/browse/MUL-1303 - https://consensyssoftware.atlassian.net/browse/MUL-1494 Final implementation will look like this ([Figma designs](https://www.figma.com/design/1F3yNWYLOVPFpTPeJugH20/SWAP?node-id=11110-19571&t=tPMZNNiwCgbDfegd-0)): <img width="1404" height="631" alt="image" src="https://github.com/user-attachments/assets/68850711-f53b-4060-8b47-6faceb67f82f" /> Reference feature branch: #25519 ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** no manual testing steps ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Moderate risk because it adds new state-management and error/transport handling paths in hardware-wallet connection flows; behavior changes are largely encapsulated but could affect connection/retry UX and edge cases. > > **Overview** > Introduces a new unified hardware wallet connection flow via `HardwareWalletProvider` and a state-driven `HardwareWalletBottomSheet`, covering scanning/device selection, connecting, awaiting app, awaiting confirmation, error, and success states. > > The provider now owns adapter lifecycle, device discovery, retry/last-operation tracking, and transport availability monitoring (surfacing transport-disabled errors when availability drops mid-flow), and wires internal actions into the bottom sheet. > > Adds the bottom sheet content components (`ConnectingContent`, `DeviceSelectionContent`, `AwaitingAppContent`, `AwaitingConfirmationContent`, `ErrorContent`, `SuccessContent`), comprehensive unit tests for the provider, sheet, and content behaviors, and new `en.json` i18n strings under `hardware_wallet`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 69a47be. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com>
1 parent 9ee92ba commit 7774958

22 files changed

Lines changed: 4208 additions & 0 deletions

app/core/HardwareWallet/HardwareWalletProvider.test.tsx

Lines changed: 658 additions & 0 deletions
Large diffs are not rendered by default.

app/core/HardwareWallet/HardwareWalletProvider.tsx

Lines changed: 710 additions & 0 deletions
Large diffs are not rendered by default.

app/core/HardwareWallet/components/HardwareWalletBottomSheet/HardwareWalletBottomSheet.test.tsx

Lines changed: 591 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import React, { useMemo, useRef, useCallback, useEffect } from 'react';
2+
import { StyleSheet } from 'react-native';
3+
4+
import BottomSheet, {
5+
BottomSheetRef,
6+
} from '../../../../component-library/components/BottomSheets/BottomSheet';
7+
8+
import { useTheme } from '../../../../util/theme';
9+
10+
import {
11+
HardwareWalletType,
12+
HardwareWalletConnectionState,
13+
ConnectionStatus,
14+
} from '@metamask/hw-wallet-sdk';
15+
16+
import {
17+
ConnectingContent,
18+
DeviceSelectionContent,
19+
AwaitingAppContent,
20+
AwaitingConfirmationContent,
21+
ErrorContent,
22+
SuccessContent,
23+
} from './contents';
24+
import { DiscoveredDevice, type DeviceSelectionState } from '../../types';
25+
import DevLogger from '../../../SDKConnect/utils/DevLogger';
26+
27+
export const HARDWARE_WALLET_BOTTOM_SHEET_TEST_ID =
28+
'hardware-wallet-bottom-sheet';
29+
30+
const createStyles = (colors: { background: { default: string } }) =>
31+
StyleSheet.create({
32+
bottomSheet: {
33+
backgroundColor: colors.background.default,
34+
},
35+
});
36+
37+
export interface HardwareWalletBottomSheetProps {
38+
connectionState: HardwareWalletConnectionState;
39+
deviceSelection: DeviceSelectionState;
40+
walletType: HardwareWalletType | null;
41+
connectionTips: string[];
42+
43+
retryLastOperation: () => Promise<void>;
44+
selectDevice: (device: DiscoveredDevice) => void;
45+
rescan: () => void;
46+
connect: (deviceId: string) => Promise<void>;
47+
48+
/** Callback when sheet is dismissed (handles all cleanup) */
49+
onClose?: () => void;
50+
/** Show success state briefly before hiding (ms, 0 to disable) */
51+
successAutoDismissMs?: number;
52+
/** Callback when connection succeeds (e.g., to navigate to account selection) */
53+
onConnectionSuccess?: () => void;
54+
/** Callback when user cancels during awaiting confirmation state */
55+
onAwaitingConfirmationCancel?: () => void;
56+
}
57+
58+
/**
59+
* Unified Hardware Wallet Bottom Sheet
60+
*
61+
* Automatically displays the appropriate content based on connection state:
62+
* - Scanning: Device selection list
63+
* - Connecting: Shows tips and loading spinner
64+
* - AwaitingApp: Prompts user to open correct app
65+
* - AwaitingConfirmation: Prompts user to confirm on device
66+
* - Error: Shows error with recovery actions
67+
* - Ready: Shows success feedback
68+
*/
69+
export const HardwareWalletBottomSheet: React.FC<
70+
HardwareWalletBottomSheetProps
71+
> = ({
72+
connectionState,
73+
deviceSelection,
74+
walletType,
75+
connectionTips,
76+
retryLastOperation,
77+
selectDevice,
78+
rescan,
79+
connect,
80+
onClose,
81+
successAutoDismissMs = 1000,
82+
onConnectionSuccess,
83+
onAwaitingConfirmationCancel,
84+
}) => {
85+
const { colors } = useTheme();
86+
const styles = useMemo(() => createStyles(colors), [colors]);
87+
88+
const bottomSheetRef = useRef<BottomSheetRef>(null);
89+
90+
const { devices, selectedDevice, isScanning } = deviceSelection;
91+
92+
const shouldShow = useMemo(() => {
93+
switch (connectionState.status) {
94+
case ConnectionStatus.Scanning:
95+
case ConnectionStatus.Connecting:
96+
case ConnectionStatus.Connected:
97+
case ConnectionStatus.AwaitingApp:
98+
case ConnectionStatus.AwaitingConfirmation:
99+
case ConnectionStatus.ErrorState:
100+
case ConnectionStatus.Ready:
101+
return true;
102+
default:
103+
return false;
104+
}
105+
}, [connectionState.status]);
106+
107+
useEffect(() => {
108+
DevLogger.log(
109+
'[HardwareWalletBottomSheet] shouldShow:',
110+
shouldShow,
111+
'status:',
112+
connectionState.status,
113+
);
114+
}, [shouldShow, connectionState.status]);
115+
116+
const handleClose = useCallback(() => {
117+
if (connectionState.status === ConnectionStatus.AwaitingConfirmation) {
118+
onAwaitingConfirmationCancel?.();
119+
}
120+
onClose?.();
121+
}, [connectionState.status, onAwaitingConfirmationCancel, onClose]);
122+
123+
const handleAwaitingConfirmationCancel = useCallback(() => {
124+
onAwaitingConfirmationCancel?.();
125+
}, [onAwaitingConfirmationCancel]);
126+
127+
const handleErrorContinue = useCallback(async () => {
128+
await retryLastOperation();
129+
}, [retryLastOperation]);
130+
131+
const handleErrorDismiss = useCallback(() => {
132+
onClose?.();
133+
}, [onClose]);
134+
135+
const handleSuccessDismiss = useCallback(() => {
136+
onConnectionSuccess?.();
137+
}, [onConnectionSuccess]);
138+
139+
const handleSelectDevice = useCallback(
140+
(selectedDev: DiscoveredDevice) => {
141+
selectDevice(selectedDev);
142+
},
143+
[selectDevice],
144+
);
145+
146+
const handleConfirmDeviceSelection = useCallback(async () => {
147+
if (selectedDevice) {
148+
DevLogger.log(
149+
'[HardwareWalletBottomSheet] Connecting to device:',
150+
selectedDevice.id,
151+
);
152+
await connect(selectedDevice.id);
153+
}
154+
}, [selectedDevice, connect]);
155+
156+
const handleRescan = useCallback(() => {
157+
rescan();
158+
}, [rescan]);
159+
160+
const handleCancelDeviceSelection = useCallback(() => {
161+
onClose?.();
162+
}, [onClose]);
163+
164+
// The effective device type — only used when the sheet is visible,
165+
// so walletType should always be set by then.
166+
const deviceType = walletType ?? HardwareWalletType.Ledger;
167+
168+
const renderContent = () => {
169+
switch (connectionState.status) {
170+
case ConnectionStatus.Ready:
171+
return (
172+
<SuccessContent
173+
deviceType={deviceType}
174+
onDismiss={handleSuccessDismiss}
175+
autoDismissMs={successAutoDismissMs}
176+
/>
177+
);
178+
179+
case ConnectionStatus.Scanning:
180+
return (
181+
<DeviceSelectionContent
182+
devices={devices}
183+
selectedDevice={selectedDevice ?? undefined}
184+
isScanning={isScanning}
185+
deviceType={deviceType}
186+
onSelectDevice={handleSelectDevice}
187+
onConfirmSelection={handleConfirmDeviceSelection}
188+
onRescan={handleRescan}
189+
onCancel={handleCancelDeviceSelection}
190+
/>
191+
);
192+
193+
case ConnectionStatus.Connecting:
194+
case ConnectionStatus.Connected:
195+
return (
196+
<ConnectingContent
197+
deviceType={deviceType}
198+
connectionTips={connectionTips}
199+
/>
200+
);
201+
202+
case ConnectionStatus.AwaitingApp:
203+
return (
204+
<AwaitingAppContent
205+
deviceType={deviceType}
206+
requiredApp={connectionState.appName}
207+
onContinue={handleErrorContinue}
208+
/>
209+
);
210+
211+
case ConnectionStatus.AwaitingConfirmation:
212+
return (
213+
<AwaitingConfirmationContent
214+
deviceType={deviceType}
215+
operationType={connectionState.operationType}
216+
onCancel={handleAwaitingConfirmationCancel}
217+
/>
218+
);
219+
220+
case ConnectionStatus.ErrorState:
221+
return (
222+
<ErrorContent
223+
error={connectionState.error}
224+
deviceType={deviceType}
225+
onContinue={handleErrorContinue}
226+
onDismiss={handleErrorDismiss}
227+
/>
228+
);
229+
230+
default:
231+
return null;
232+
}
233+
};
234+
235+
if (!shouldShow) {
236+
return null;
237+
}
238+
239+
return (
240+
<BottomSheet
241+
ref={bottomSheetRef}
242+
testID={HARDWARE_WALLET_BOTTOM_SHEET_TEST_ID}
243+
isFullscreen={false}
244+
onClose={handleClose}
245+
shouldNavigateBack={false}
246+
style={styles.bottomSheet}
247+
>
248+
{renderContent()}
249+
</BottomSheet>
250+
);
251+
};
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import React from 'react';
2+
import { fireEvent } from '@testing-library/react-native';
3+
import { HardwareWalletType } from '@metamask/hw-wallet-sdk';
4+
import renderWithProvider from '../../../../../util/test/renderWithProvider';
5+
import { AppThemeKey } from '../../../../../util/theme/models';
6+
7+
import {
8+
AwaitingAppContent,
9+
AWAITING_APP_CONTENT_TEST_ID,
10+
} from './AwaitingAppContent';
11+
12+
// Mock locales
13+
jest.mock('../../../../../../locales/i18n', () => ({
14+
strings: (key: string, params?: Record<string, string>) => {
15+
if (params) {
16+
return `${key} ${JSON.stringify(params)}`;
17+
}
18+
return key;
19+
},
20+
}));
21+
22+
describe('AwaitingAppContent', () => {
23+
const mockInitialState = {
24+
user: {
25+
appTheme: AppThemeKey.light,
26+
},
27+
settings: {
28+
useBlockieIcon: false,
29+
},
30+
engine: {
31+
backgroundState: {
32+
PreferencesController: {},
33+
},
34+
},
35+
};
36+
37+
const defaultProps = {
38+
deviceType: HardwareWalletType.Ledger,
39+
};
40+
41+
const renderComponent = (props = {}) =>
42+
renderWithProvider(
43+
<AwaitingAppContent {...defaultProps} {...props} />,
44+
{ state: mockInitialState },
45+
false,
46+
false,
47+
);
48+
49+
it('renders with test ID', () => {
50+
const { getByTestId } = renderComponent();
51+
52+
expect(getByTestId(AWAITING_APP_CONTENT_TEST_ID)).toBeOnTheScreen();
53+
});
54+
55+
it('renders open app message', () => {
56+
const { getByText } = renderComponent();
57+
58+
expect(getByText(/hardware_wallet\.awaiting_app\.title/)).toBeOnTheScreen();
59+
expect(
60+
getByText(/hardware_wallet\.awaiting_app\.message/),
61+
).toBeOnTheScreen();
62+
});
63+
64+
it('shows current app when different from required', () => {
65+
const { getByText } = renderComponent({
66+
currentApp: 'Bitcoin',
67+
requiredApp: 'Ethereum',
68+
});
69+
70+
expect(
71+
getByText('hardware_wallet.awaiting_app.current_app {"app":"Bitcoin"}'),
72+
).toBeOnTheScreen();
73+
});
74+
75+
it('does not show current app when same as required', () => {
76+
const { queryByText } = renderComponent({
77+
currentApp: 'Ethereum',
78+
requiredApp: 'Ethereum',
79+
});
80+
81+
expect(queryByText(/current_app/)).toBeNull();
82+
});
83+
84+
it('does not show current app for BOLOS', () => {
85+
const { queryByText } = renderComponent({
86+
currentApp: 'BOLOS',
87+
requiredApp: 'Ethereum',
88+
});
89+
90+
expect(queryByText(/current_app/)).toBeNull();
91+
});
92+
93+
it('renders continue button when onContinue provided', () => {
94+
const onContinue = jest.fn();
95+
const { getByText } = renderComponent({ onContinue });
96+
97+
const continueButton = getByText('hardware_wallet.common.continue');
98+
fireEvent.press(continueButton);
99+
expect(onContinue).toHaveBeenCalled();
100+
});
101+
102+
it('does not render continue button when onContinue not provided', () => {
103+
const { queryByText } = renderComponent();
104+
105+
expect(queryByText('hardware_wallet.common.continue')).toBeNull();
106+
});
107+
});

0 commit comments

Comments
 (0)