Skip to content

Commit

Permalink
[wallet-ext] hook up the "Connect to Ledger" modal to actually connec…
Browse files Browse the repository at this point in the history
…t to an external Ledger device (#9061)

## Description 

This PR enables users to be able to connect to their external Ledger
device by hitting "Continue" in the "Connect Ledger" modal. I added some
error handling and loading states as well. One important thing to call
out here is how we're managing the state of the ledger client - for now,
we'll maintain a context instead of using global variables/adding the
ledger client as a Redux thunk extra so that the client instance is
reactive and has generally more expected behavior.

Failed connection attempts:
<video
src="https://user-images.githubusercontent.com/7453188/224116008-f6b5d644-5c55-4eea-9997-88e3d4662550.mov">

Successful connection attempt:
<video
src="https://user-images.githubusercontent.com/7453188/224116020-d82bdfb2-95e9-4724-9889-5f14a58ddd33.mov">

## Test Plan 
- Manual testing

---
If your changes are not user-facing and not a breaking change, you can
skip the following section. Otherwise, please indicate what changed, and
then add to the Release Notes section as highlighted during the release
process.

### Type of Change (Check all that apply)

- [ ] user-visible impact
- [ ] breaking change for a client SDKs
- [ ] breaking change for FNs (FN binary must upgrade)
- [ ] breaking change for validators or node operators (must upgrade
binaries)
- [ ] breaking change for on-chain data layout
- [ ] necessitate either a data wipe or data migration

### Release notes
N/A
  • Loading branch information
williamrobertson13 authored Mar 9, 2023
1 parent 3a4393e commit 08beb3a
Show file tree
Hide file tree
Showing 11 changed files with 235 additions and 10 deletions.
1 change: 1 addition & 0 deletions apps/wallet/configs/ts/tsconfig.common.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"_font-icons/*": ["./font-icons/*"],
"@mysten/sui.js": ["../../sdk/typescript/src/"],
"@mysten/bcs": ["../../sdk/bcs/src/"],
"@mysten/ledgerjs-hw-app-sui": ["../../sdk/ledgerjs-hw-app-sui"],
"@mysten/wallet-standard": [
"../../sdk/wallet-adapter/wallet-standard/src/"
]
Expand Down
5 changes: 5 additions & 0 deletions apps/wallet/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,14 @@
"@growthbook/growthbook": "^0.20.1",
"@growthbook/growthbook-react": "^0.10.1",
"@headlessui/react": "^1.7.7",
"@ledgerhq/errors": "^6.12.3",
"@ledgerhq/hw-transport": "^6.27.11",
"@ledgerhq/hw-transport-webhid": "^6.27.11",
"@ledgerhq/hw-transport-webusb": "^6.27.11",
"@metamask/browser-passworder": "^4.0.2",
"@mysten/core": "workspace:*",
"@mysten/icons": "workspace:*",
"@mysten/ledgerjs-hw-app-sui": "workspace:*",
"@mysten/sui.js": "workspace:*",
"@mysten/wallet-standard": "workspace:*",
"@noble/hashes": "^1.1.5",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
// Copyright (c) Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

import { useState } from 'react';

import ExternalLink from '_components/external-link';
import { useSuiLedgerClient } from '_src/ui/app/components/ledger/SuiLedgerClientProvider';
import { Button } from '_src/ui/app/shared/ButtonUI';
import { ModalDialog } from '_src/ui/app/shared/ModalDialog';
import { Text } from '_src/ui/app/shared/text';
Expand All @@ -10,13 +13,40 @@ type ConnectLedgerModalProps = {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
onError: (error: unknown) => void;
};

export function ConnectLedgerModal({
isOpen,
onClose,
onConfirm,
onError,
}: ConnectLedgerModalProps) {
const [isConnectingToLedger, setConnectingToLedger] = useState(false);
const [, connectToLedger] = useSuiLedgerClient();

const onContinueClick = async () => {
try {
setConnectingToLedger(true);

await connectToLedger();

// Let's make sure that the user has the Sui application open
// by making a call to getVersion. We can probably abstract this
// away at the SDK level in some follow-up work
// (See https://github.com/LedgerHQ/ledgerjs/issues/122)
// TODO: I'll un-comment this out when I can actually load the
// Sui application onto my Ledger device.
// await suiLedgerClient.getVersion();

onConfirm();
} catch (error) {
onError(error);
} finally {
setConnectingToLedger(false);
}
};

return (
<ModalDialog
isOpen={isOpen}
Expand Down Expand Up @@ -58,7 +88,8 @@ export function ConnectLedgerModal({
<Button
variant="outline"
text="Continue"
onClick={onConfirm}
onClick={onContinueClick}
loading={isConnectingToLedger}
/>
</div>
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@
// SPDX-License-Identifier: Apache-2.0

import { LockUnlocked16 as UnlockedLockIcon } from '@mysten/icons';
import { useNavigate } from 'react-router-dom';
import { Navigate, useNavigate } from 'react-router-dom';

import { SummaryCard } from '../../../SummaryCard';
import Overlay from '../../../overlay';
import { useNextMenuUrl } from '../../hooks';
import { SummaryCard } from '../SummaryCard';
import { useNextMenuUrl } from '../menu/hooks';
import Overlay from '../overlay';
import { LedgerAccount } from './LedgerAccount';
import { useSuiLedgerClient } from '_src/ui/app/components/ledger/SuiLedgerClientProvider';
import { Button } from '_src/ui/app/shared/ButtonUI';
import { Link } from '_src/ui/app/shared/Link';

Expand All @@ -29,6 +30,13 @@ const mockAccounts = [
export function ImportLedgerAccounts() {
const accountsUrl = useNextMenuUrl(true, `/accounts`);
const navigate = useNavigate();
const [suiLedgerClient] = useSuiLedgerClient();

if (!suiLedgerClient) {
// TODO (future improvement): We should detect when a user's Ledger device has disconnected so that
// we can redirect them away from this route if they were to pull out their Ledger device mid-flow
return <Navigate to={accountsUrl} replace />;
}

return (
<Overlay
Expand Down
16 changes: 16 additions & 0 deletions apps/wallet/src/ui/app/components/ledger/LedgerExceptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright (c) Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

export class LedgerConnectionFailedError extends Error {
constructor(message: string) {
super(message);
Object.setPrototypeOf(this, LedgerConnectionFailedError.prototype);
}
}

export class LedgerNoTransportMechanismError extends Error {
constructor(message: string) {
super(message);
Object.setPrototypeOf(this, LedgerNoTransportMechanismError.prototype);
}
}
112 changes: 112 additions & 0 deletions apps/wallet/src/ui/app/components/ledger/SuiLedgerClientProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Copyright (c) Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

import TransportWebHID from '@ledgerhq/hw-transport-webhid';
import TransportWebUSB from '@ledgerhq/hw-transport-webusb';
import SuiLedgerClient from '@mysten/ledgerjs-hw-app-sui';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';

import {
LedgerConnectionFailedError,
LedgerNoTransportMechanismError,
} from './LedgerExceptions';

import type Transport from '@ledgerhq/hw-transport';

type SuiLedgerClientProviderProps = {
children: React.ReactNode;
};

type SuiLedgerClientContextValue = [
SuiLedgerClient | undefined,
() => Promise<SuiLedgerClient>
];

const SuiLedgerClientContext = createContext<
SuiLedgerClientContextValue | undefined
>(undefined);

export function SuiLedgerClientProvider({
children,
}: SuiLedgerClientProviderProps) {
const [suiLedgerClient, setSuiLedgerClient] = useState<SuiLedgerClient>();

useEffect(() => {
const onDisconnect = () => {
setSuiLedgerClient(undefined);
};

suiLedgerClient?.transport.on('disconnect', onDisconnect);
return () => suiLedgerClient?.transport.off('disconnect', onDisconnect);
}, [suiLedgerClient?.transport]);

const connectToLedger = useCallback(async () => {
if (suiLedgerClient?.transport) {
// If we've already connected to a Ledger device, we need
// to close the connection before we try to re-connect
await suiLedgerClient.transport.close();
}

const ledgerTransport = await getLedgerTransport();
const ledgerClient = new SuiLedgerClient(ledgerTransport);
setSuiLedgerClient(ledgerClient);
return ledgerClient;
}, [suiLedgerClient]);

const contextValue: SuiLedgerClientContextValue = useMemo(
() => [suiLedgerClient, connectToLedger],
[connectToLedger, suiLedgerClient]
);

return (
<SuiLedgerClientContext.Provider value={contextValue}>
{children}
</SuiLedgerClientContext.Provider>
);
}

export function useSuiLedgerClient() {
const suiLedgerClientContext = useContext(SuiLedgerClientContext);
if (!suiLedgerClientContext) {
throw new Error(
'useSuiLedgerClient use must be within SuiLedgerClientContext'
);
}
return suiLedgerClientContext;
}

async function getLedgerTransport() {
let ledgerTransport: Transport | null | undefined;

try {
ledgerTransport = await initiateLedgerConnection();
} catch (error) {
throw new LedgerConnectionFailedError(
"Unable to connect to the user's Ledger device"
);
}

if (!ledgerTransport) {
throw new LedgerNoTransportMechanismError(
"There are no supported transport mechanisms to connect to the user's Ledger device"
);
}

return ledgerTransport;
}

async function initiateLedgerConnection(): Promise<Transport | null> {
if (await TransportWebHID.isSupported()) {
return await TransportWebHID.request();
} else if (await TransportWebUSB.isSupported()) {
return await TransportWebUSB.request();
}
return null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,19 @@
// SPDX-License-Identifier: Apache-2.0

import { useFeature } from '@growthbook/growthbook-react';
import { LockedDeviceError } from '@ledgerhq/errors';
import { LockLocked16 as LockedLockIcon } from '@mysten/icons';
import { useState } from 'react';
import toast from 'react-hot-toast';
import { useNavigate } from 'react-router-dom';

import { ConnectLedgerModal } from '../../ledger/ConnectLedgerModal';
import {
LedgerConnectionFailedError,
LedgerNoTransportMechanismError,
} from '../../ledger/LedgerExceptions';
import { Account } from './Account';
import { MenuLayout } from './MenuLayout';
import { ConnectLedgerModal } from './ledger/ConnectLedgerModal';
import { useNextMenuUrl } from '_components/menu/hooks';
import { FEATURES } from '_src/shared/experimentation/features';
import { useAccounts } from '_src/ui/app/hooks/useAccounts';
Expand Down Expand Up @@ -72,6 +78,10 @@ export function AccountsSettings() {
<ConnectLedgerModal
isOpen={isConnectLedgerModalOpen}
onClose={() => setConnectLedgerModalOpen(false)}
onError={(error) => {
setConnectLedgerModalOpen(false);
toast.error(getLedgerErrorMessage(error));
}}
onConfirm={() => {
setConnectLedgerModalOpen(false);
navigate(importLedgerAccountsUrl);
Expand All @@ -83,3 +93,14 @@ export function AccountsSettings() {
</MenuLayout>
);
}

function getLedgerErrorMessage(error: unknown) {
if (error instanceof LockedDeviceError) {
return 'Your device is locked. Un-lock it and try again.';
} else if (error instanceof LedgerConnectionFailedError) {
return 'Ledger connection failed.';
} else if (error instanceof LedgerNoTransportMechanismError) {
return "Your machine doesn't support USB or HID.";
}
return 'Something went wrong. Try again.';
}
2 changes: 1 addition & 1 deletion apps/wallet/src/ui/app/components/menu/content/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ import {
useNavigate,
} from 'react-router-dom';

import { ImportLedgerAccounts } from '../../ledger/ImportLedgerAccounts';
import { AccountsSettings } from './AccountsSettings';
import { AutoLockSettings } from './AutoLockSettings';
import { ExportAccount } from './ExportAccount';
import { ImportPrivateKey } from './ImportPrivateKey';
import MenuList from './MenuList';
import { NetworkSettings } from './NetworkSettings';
import { ImportLedgerAccounts } from './ledger/ImportLedgerAccounts';
import { ErrorBoundary } from '_components/error-boundary';
import {
MainLocationContext,
Expand Down
9 changes: 6 additions & 3 deletions apps/wallet/src/ui/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Provider } from 'react-redux';
import { HashRouter } from 'react-router-dom';

import App from './app';
import { SuiLedgerClientProvider } from './app/components/ledger/SuiLedgerClientProvider';
import { growthbook } from './app/experimentation/feature-gating';
import { queryClient } from './app/helpers/queryClient';
import { useAppSelector } from './app/hooks';
Expand Down Expand Up @@ -68,9 +69,11 @@ function AppWrapper() {
<RpcClientContext.Provider
value={api.instance.fullNode}
>
<ErrorBoundary>
<App />
</ErrorBoundary>
<SuiLedgerClientProvider>
<ErrorBoundary>
<App />
</ErrorBoundary>
</SuiLedgerClientProvider>
</RpcClientContext.Provider>
</QueryClientProvider>
</IntlProvider>
Expand Down
28 changes: 28 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 08beb3a

Please sign in to comment.