Skip to content

Commit

Permalink
[wallet-ext] pull real Ledger addresses and display them in the "Impo…
Browse files Browse the repository at this point in the history
…rt Accounts" UI (MystenLabs#9117)

## Description 

This PR updates the "Import Accounts" UI to pull and display real
addresses from a user's Ledger device.

With all accounts already imported:
<video
src="https://user-images.githubusercontent.com/7453188/224426690-693f5ee5-bfa4-4f85-90e5-86159db77c4c.mov">

With many accounts to import:
<video
src="https://user-images.githubusercontent.com/7453188/224426794-5b87f263-692e-4c2c-8854-489df30619ef.mov">

With few accounts to import:
<img width="411" alt="image"
src="https://user-images.githubusercontent.com/7453188/224427040-0239af4a-76ac-49bf-9a98-16e1dab4f290.png">

## Test Plan 
- Manual testing 
  - Verified addresses are valid Sui addresses that can receive money
- Tested a variety of error cases where the Ledger doesn't have the Sui
application open

---
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
  • Loading branch information
williamrobertson13 authored Mar 11, 2023
1 parent 70f56d9 commit c79a5b4
Show file tree
Hide file tree
Showing 5 changed files with 257 additions and 79 deletions.
10 changes: 0 additions & 10 deletions apps/wallet/src/ui/app/components/ledger/ConnectLedgerModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,7 @@ export function ConnectLedgerModal({
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);
Expand Down
186 changes: 125 additions & 61 deletions apps/wallet/src/ui/app/components/ledger/ImportLedgerAccounts.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,111 @@
// Copyright (c) Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

import { LockUnlocked16 as UnlockedLockIcon } from '@mysten/icons';
import { Navigate, useNavigate } from 'react-router-dom';
import {
LockUnlocked16 as UnlockedLockIcon,
Spinner16 as SpinnerIcon,
ThumbUpStroke32 as ThumbUpIcon,
} from '@mysten/icons';
import { useCallback } from 'react';
import toast from 'react-hot-toast';
import { useNavigate } from 'react-router-dom';

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 { type LedgerAccount } from './LedgerAccountItem';
import { LedgerAccountList } from './LedgerAccountList';
import { useDeriveLedgerAccounts } from './useDeriveLedgerAccounts';
import { Button } from '_src/ui/app/shared/ButtonUI';
import { Link } from '_src/ui/app/shared/Link';
import { Text } from '_src/ui/app/shared/text';

const mockAccounts = [
{
isSelected: false,
address:
'0x7a286c8455a801f6d81faaa0f87543fa4a0de64dcc48b9c9308ee18f0f6ccdd3',
balance: 30,
},
{
isSelected: true,
address:
'0x7a286c8455a401f6d81faaa0f87543fa4a0de64dcc48b9c9308ee18f0f6ccdd3',
balance: 30000,
},
];
const numLedgerAccountsToDeriveByDefault = 10;

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 />;
const onDeriveError = useCallback(() => {
navigate(accountsUrl, { replace: true });
toast.error('Make sure you have the Sui application open.');
}, [accountsUrl, navigate]);

const [ledgerAccounts, setLedgerAccounts, areLedgerAccountsLoading] =
useDeriveLedgerAccounts({
numAccountsToDerive: numLedgerAccountsToDeriveByDefault,
onError: onDeriveError,
});

const onAccountClick = useCallback(
(targetAccount: LedgerAccount) => {
setLedgerAccounts((prevState) =>
prevState.map((account) => {
if (account.address === targetAccount.address) {
return {
isSelected: !targetAccount.isSelected,
address: targetAccount.address,
};
}
return account;
})
);
},
[setLedgerAccounts]
);

const onSelectAllAccountsClick = useCallback(() => {
setLedgerAccounts((prevState) =>
prevState.map((account) => ({
isSelected: true,
address: account.address,
}))
);
}, [setLedgerAccounts]);

// TODO: Add logic to filter out already imported Ledger accounts
// so we don't allow users to import the same account twice
const filteredLedgerAccounts = ledgerAccounts.filter(() => true);
const selectedLedgerAccounts = filteredLedgerAccounts.filter(
(account) => account.isSelected
);

const numAccounts = ledgerAccounts.length;
const numFilteredAccounts = filteredLedgerAccounts.length;
const numSelectedAccounts = selectedLedgerAccounts.length;
const areAllAccountsImported = numAccounts > 0 && numFilteredAccounts === 0;
const areNoAccountsSelected = numSelectedAccounts === 0;
const areAllAccountsSelected = numSelectedAccounts === numAccounts;
const isSelectAllButtonDisabled =
areAllAccountsSelected || areAllAccountsImported;

let summaryCardBody: JSX.Element | null = null;
if (areLedgerAccountsLoading) {
summaryCardBody = (
<div className="w-full h-full flex flex-col justify-center items-center gap-3">
<SpinnerIcon className="animate-spin text-steel w-4 h-4" />
<Text variant="p2" color="steel-darker">
Looking for accounts
</Text>
</div>
);
} else if (areAllAccountsImported) {
summaryCardBody = (
<div className="w-full h-full flex flex-col justify-center items-center gap-2">
<ThumbUpIcon className="text-steel w-8 h-8" />
<Text variant="p2" color="steel-darker">
All Ledger accounts have been imported.
</Text>
</div>
);
} else {
summaryCardBody = (
<div className="max-h-[272px] -mr-2 mt-1 pr-2 overflow-auto custom-scrollbar">
<LedgerAccountList
accounts={filteredLedgerAccounts}
onAccountClick={onAccountClick}
/>
</div>
);
}

return (
Expand All @@ -46,50 +116,44 @@ export function ImportLedgerAccounts() {
navigate(accountsUrl);
}}
>
<div className="w-full flex flex-col">
<SummaryCard
minimalPadding
header="Connect Ledger Accounts"
body={
<ul className="list-none h-[272px] m-0 p-0 -mr-2 mt-1 py-0 pr-2 overflow-auto custom-scrollbar">
{mockAccounts.map((account) => {
return (
<li
className="pt-2 pb-2 first:pt-1"
key={account.address}
>
<LedgerAccount
isSelected={account.isSelected}
address={account.address}
balance={account.balance}
/>
</li>
);
})}
</ul>
}
footer={
<div className="rounded-b-2xl text-center">
<div className="w-full flex flex-col gap-5">
<div className="h-full bg-white flex flex-col border border-solid border-gray-45 rounded-2xl">
<div className="text-center bg-gray-40 py-2.5 rounded-t-2xl">
<Text
variant="captionSmall"
weight="bold"
color="steel-darker"
truncate
>
{areAllAccountsImported
? 'Ledger Accounts '
: 'Connect Ledger Accounts'}
</Text>
</div>
<div className="grow px-4 py-2">{summaryCardBody}</div>
<div className="w-full rounded-b-2xl border-x-0 border-b-0 border-t border-solid border-gray-40 text-center pt-3 pb-4">
<div className="w-fit ml-auto mr-auto">
<Link
text="Select All Accounts"
color="heroDark"
weight="medium"
onClick={onSelectAllAccountsClick}
disabled={isSelectAllButtonDisabled}
/>
</div>
}
/>
<div className="mt-5">
<Button
variant="primary"
before={<UnlockedLockIcon />}
text="Unlock"
onClick={() => {
// TODO: Do work to actually import the selected accounts once we have
// the account infrastructure setup to support Ledger accounts
navigate(accountsUrl);
}}
/>
</div>
</div>
<Button
variant="primary"
before={<UnlockedLockIcon />}
text="Unlock"
onClick={() => {
// TODO: Do work to actually import the selected accounts once we have
// the account infrastructure setup to support Ledger accounts
navigate(accountsUrl);
}}
disabled={areNoAccountsSelected}
/>
</div>
</Overlay>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,23 @@ import { CheckFill16 } from '@mysten/icons';
import { formatAddress, type SuiAddress, SUI_TYPE_ARG } from '@mysten/sui.js';
import cl from 'classnames';

import { useGetCoinBalance } from '../../hooks';
import { Text } from '_src/ui/app/shared/text';

type LedgerAccountProps = {
export type LedgerAccount = {
isSelected: boolean;
address: SuiAddress;
balance: number;
};

export function LedgerAccount({
type LedgerAccountItemProps = LedgerAccount;

export function LedgerAccountItem({
isSelected,
address,
balance,
}: LedgerAccountProps) {
}: LedgerAccountItemProps) {
const { data: coinBalance } = useGetCoinBalance(SUI_TYPE_ARG, address);
const [totalAmount, totalAmountSymbol] = useFormatCoin(
balance,
coinBalance?.totalBalance ?? 0,
SUI_TYPE_ARG
);

Expand All @@ -35,13 +37,13 @@ export function LedgerAccount({
<Text
mono
variant="bodySmall"
weight="bold"
weight="semibold"
color={isSelected ? 'steel-darker' : 'steel-dark'}
>
{formatAddress(address)}
</Text>
<div className="ml-auto">
<Text variant="bodySmall" color="steel" weight="bold" mono>
<Text variant="bodySmall" color="steel" weight="semibold" mono>
{totalAmount} {totalAmountSymbol}
</Text>
</div>
Expand Down
34 changes: 34 additions & 0 deletions apps/wallet/src/ui/app/components/ledger/LedgerAccountList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright (c) Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

import { LedgerAccountItem, type LedgerAccount } from './LedgerAccountItem';

type LedgerAccountListProps = {
accounts: LedgerAccount[];
onAccountClick: (account: LedgerAccount) => void;
};

export function LedgerAccountList({
accounts,
onAccountClick,
}: LedgerAccountListProps) {
return (
<ul className="list-none m-0 p-0">
{accounts.map((account) => (
<li className="pt-2 pb-2 first:pt-1" key={account.address}>
<button
className="w-full appearance-none border-0 p-0 bg-transparent cursor-pointer"
onClick={() => {
onAccountClick(account);
}}
>
<LedgerAccountItem
isSelected={account.isSelected}
address={account.address}
/>
</button>
</li>
))}
</ul>
);
}
Loading

0 comments on commit c79a5b4

Please sign in to comment.