Skip to content

Commit e6fa04b

Browse files
authored
feat(keystone-usb):CP-11865 keystone usb approval overlay (#485)
1 parent 3e70b89 commit e6fa04b

File tree

16 files changed

+471
-18
lines changed

16 files changed

+471
-18
lines changed

apps/next/src/hooks/useIsUsingHardwareWallet.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,12 @@ const getSignerType = ({
4848
if (isUsingLedgerWallet) {
4949
return 'ledger';
5050
}
51-
if (isUsingKeystoneWallet) {
52-
return 'keystone-qr';
53-
}
5451
if (isUsingKeystone3Wallet) {
5552
return 'keystone-usb';
5653
}
54+
if (isUsingKeystoneWallet) {
55+
return 'keystone-qr';
56+
}
5757

5858
throw new Error('Unknown signer type');
5959
};

apps/next/src/localization/locales/en/translation.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@
170170
"Core functionality may be unstable with custom RPC URLs": "Core functionality may be unstable with custom RPC URLs",
171171
"Core has entered an unexpected state. Please restart the browser if the issue persists.": "Core has entered an unexpected state. Please restart the browser if the issue persists.",
172172
"Core is committed to protecting your privacy. We will never sell or share your data. If you wish, you can disable this at any time in the settings menu.": "Core is committed to protecting your privacy. We will never sell or share your data. If you wish, you can disable this at any time in the settings menu.",
173+
"Core is no longer connected to your Keystone device. Reconnect to continue.": "Core is no longer connected to your Keystone device. Reconnect to continue.",
173174
"Core.app": "Core.app",
174175
"Could not connect.": "Could not connect.",
175176
"Could not swap {{srcAmount}} {{srcToken}} to {{destAmount}} {{destToken}}": "Could not swap {{srcAmount}} {{srcToken}} to {{destAmount}} {{destToken}}",
@@ -341,9 +342,11 @@
341342
"Invalid transaction data": "Invalid transaction data",
342343
"Invalid transfer parameters": "Invalid transfer parameters",
343344
"Investment": "Investment",
345+
"It seems that Core no longer has access to your Keystone device. Please click the button below to reconnect it.": "It seems that Core no longer has access to your Keystone device. Please click the button below to reconnect it.",
344346
"It seems that Core no longer has access to your Ledger device. Please click the button below to reconnect it.": "It seems that Core no longer has access to your Ledger device. Please click the button below to reconnect it.",
345347
"It will take 2 days to retrieve your recovery phrase. You will only have 48 hours to copy your recovery phrase once the 2 day waiting period is over": "It will take 2 days to retrieve your recovery phrase. You will only have 48 hours to copy your recovery phrase once the 2 day waiting period is over",
346348
"Japanese": "Japanese",
349+
"Keystone disconnected": "Keystone disconnected",
347350
"Keystone request was cancelled.": "Keystone request was cancelled.",
348351
"Keystone support": "Keystone support",
349352
"Keystone {{number}}": "Keystone {{number}}",
@@ -423,6 +426,7 @@
423426
"Oops! \n Something went wrong": "Oops! \n Something went wrong",
424427
"Oops! It seems like you have no internet connection. Please try again later.": "Oops! It seems like you have no internet connection. Please try again later.",
425428
"Open any authenticator app and scan the QR code below or enter the code manually": "Open any authenticator app and scan the QR code below or enter the code manually",
429+
"Open the Avalanche app on your Keystone device in order to continue with this transaction": "Open the Avalanche app on your Keystone device in order to continue with this transaction",
426430
"Open the Solana app on your Ledger device": "Open the Solana app on your Ledger device",
427431
"Open the {{appName}} app on your Ledger device in order to continue with this transaction": "Open the {{appName}} app on your Ledger device in order to continue with this transaction",
428432
"Options": "Options",
@@ -459,6 +463,7 @@
459463
"Please provide a valid X-Chain address as the recipient.": "Please provide a valid X-Chain address as the recipient.",
460464
"Please provide all the required information to send.": "Please provide all the required information to send.",
461465
"Please refer to Active Transfers list in your Fireblocks Console for a detailed explanation.": "Please refer to Active Transfers list in your Fireblocks Console for a detailed explanation.",
466+
"Please review the transaction on your Keystone": "Please review the transaction on your Keystone",
462467
"Please review the transaction on your Ledger": "Please review the transaction on your Ledger",
463468
"Please switch to the {{appName}} app on your Ledger device to continue": "Please switch to the {{appName}} app on your Ledger device to continue",
464469
"Please try again": "Please try again",
@@ -495,6 +500,7 @@
495500
"Recents": "Recents",
496501
"Recipient": "Recipient",
497502
"Reconnect": "Reconnect",
503+
"Reconnect your Keystone": "Reconnect your Keystone",
498504
"Reconnect your Ledger": "Reconnect your Ledger",
499505
"Reconnect your Ledger device to continue": "Reconnect your Ledger device to continue",
500506
"Recovery Phrase": "Recovery Phrase",

apps/next/src/pages/Approve/components/hardware/HardwareApprovalOverlay.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ export const HardwareApprovalOverlay = ({
4141
/>
4242
);
4343
} else if (deviceType === 'keystone-usb') {
44-
return <KeystoneUSBApprovalOverlay />;
44+
return (
45+
<KeystoneUSBApprovalOverlay
46+
action={action}
47+
reject={reject}
48+
approve={approve}
49+
/>
50+
);
4551
}
4652
};

apps/next/src/pages/Approve/components/hardware/ledger/components/Styled.tsx renamed to apps/next/src/pages/Approve/components/hardware/common/PendingCircles.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,26 @@
1-
import { getHexAlpha, Box, styled, LedgerIcon } from '@avalabs/k2-alpine';
1+
import { Box, styled } from '@avalabs/k2-alpine';
2+
import { getHexAlpha } from '@avalabs/k2-alpine';
3+
import { FC, ComponentType } from 'react';
24

3-
export const PendingLedgerCircles = () => (
5+
export type IconProps = {
6+
size?: string | number;
7+
};
8+
9+
export type PendingCirclesProps = {
10+
Icon: ComponentType<IconProps>;
11+
size?: number;
12+
};
13+
14+
export const PendingCircles: FC<PendingCirclesProps> = ({
15+
Icon,
16+
size = 24,
17+
}) => (
418
<ConcentricCirclesBox>
519
<Circle delay="6s" />
620
<Circle delay="4s" />
721
<Circle delay="2s" />
822
<Circle delay="0s" />
9-
<LedgerIcon size={24} />
23+
<Icon size={size} />
1024
</ConcentricCirclesBox>
1125
);
1226

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,39 @@
1-
import { FC } from 'react';
1+
import { FC, useEffect } from 'react';
2+
import { DisplayData } from '@avalabs/vm-module-types';
3+
import { Action } from '@core/types';
4+
import { HardwareApprovalDrawer } from '../common/ApprovalDrawer';
5+
import { useKeystoneUsbApprovalState } from './useKeystoneUsbApprovalState';
6+
import { StateComponentMapper } from './types';
7+
import { useKeystoneUsbContext } from '@core/ui';
8+
import { Loading } from './components/Loading';
29

3-
export const KeystoneUSBApprovalOverlay: FC = () => {
4-
return <div>KeystoneUSBApprovalOverlay</div>;
10+
type KeystoneUSBApprovalOverlayProps = {
11+
action: Action<DisplayData>;
12+
reject: () => void;
13+
approve: () => Promise<unknown>;
14+
};
15+
16+
export const KeystoneUSBApprovalOverlay: FC<
17+
KeystoneUSBApprovalOverlayProps
18+
> = ({ action, reject, approve }) => {
19+
const state = useKeystoneUsbApprovalState();
20+
const { initKeystoneTransport } = useKeystoneUsbContext();
21+
22+
useEffect(() => {
23+
// Initialize transport to check availability (required for state detection)
24+
initKeystoneTransport();
25+
}, [initKeystoneTransport]);
26+
27+
const Component = StateComponentMapper[state] || Loading;
28+
29+
return (
30+
<HardwareApprovalDrawer reject={reject}>
31+
<Component
32+
state={state}
33+
approve={approve}
34+
reject={reject}
35+
action={action}
36+
/>
37+
</HardwareApprovalDrawer>
38+
);
539
};
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { FC, useCallback, useEffect } from 'react';
2+
import { StateComponentProps } from '../types';
3+
import { Box, Stack, Typography, Button } from '@avalabs/k2-alpine';
4+
import { useTranslation } from 'react-i18next';
5+
import { useKeystoneUsbContext, isSpecificContextContainer } from '@core/ui';
6+
import { tabs } from 'webextension-polyfill';
7+
import { ContextContainer } from '@core/types';
8+
import { FiAlertCircle } from 'react-icons/fi';
9+
10+
export const Disconnected: FC<StateComponentProps> = ({ state }) => {
11+
const { t } = useTranslation();
12+
const {
13+
popDeviceSelection,
14+
initKeystoneTransport,
15+
retryConnection,
16+
wasTransportAttempted,
17+
} = useKeystoneUsbContext();
18+
19+
// Auto-retry connection when in disconnected state (device might be locked)
20+
useEffect(() => {
21+
if (state === 'disconnected' && wasTransportAttempted) {
22+
const interval = setInterval(() => {
23+
retryConnection();
24+
}, 2000);
25+
26+
return () => {
27+
clearInterval(interval);
28+
};
29+
}
30+
}, [state, wasTransportAttempted, retryConnection]);
31+
32+
const onReconnect = useCallback(async () => {
33+
if (isSpecificContextContainer(ContextContainer.CONFIRM)) {
34+
await popDeviceSelection();
35+
} else {
36+
// Open in a full screen tab, the extension window does not support the device selection popup.
37+
const tab = await tabs.create({
38+
url: '/fullscreen.html#/keystone-usb/reconnect',
39+
});
40+
41+
const initTransport = (tabId) => {
42+
if (tabId === tab.id) {
43+
initKeystoneTransport();
44+
}
45+
46+
tabs.onRemoved.removeListener(initTransport);
47+
};
48+
49+
tabs.onRemoved.addListener(initTransport);
50+
}
51+
}, [popDeviceSelection, initKeystoneTransport]);
52+
53+
if (state !== 'disconnected') {
54+
return null;
55+
}
56+
57+
return (
58+
<Stack width="100%" height="100%" gap={2}>
59+
<Stack
60+
gap={1}
61+
flexGrow={1}
62+
alignItems="center"
63+
justifyContent="center"
64+
textAlign="center"
65+
color="error.main"
66+
px={5}
67+
>
68+
<Box flexShrink={0}>
69+
<FiAlertCircle size={24} color="red" />
70+
</Box>
71+
<Stack gap={0.5}>
72+
<Typography variant="body3" fontWeight={500}>
73+
{t('Keystone disconnected')}
74+
</Typography>
75+
<Stack gap={1.5}>
76+
<Typography variant="caption">
77+
{t(
78+
'Core is no longer connected to your Keystone device. Reconnect to continue.',
79+
)}
80+
</Typography>
81+
<Button onClick={onReconnect} fullWidth>
82+
{t('Unable to connect?')}
83+
</Button>
84+
</Stack>
85+
</Stack>
86+
</Stack>
87+
</Stack>
88+
);
89+
};
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { LoadingScreen } from '@/components/LoadingScreen';
2+
import { FC } from 'react';
3+
import { StateComponentProps } from '../types';
4+
5+
export const Loading: FC<StateComponentProps> = ({ state }) => {
6+
if (state !== 'loading') {
7+
return null;
8+
}
9+
10+
return <LoadingScreen />;
11+
};
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { FC, useEffect, useRef } from 'react';
2+
import { Stack, Typography } from '@avalabs/k2-alpine';
3+
import { Action, ActionStatus } from '@core/types';
4+
import { DisplayData } from '@avalabs/vm-module-types';
5+
import { StateComponentProps } from '../types';
6+
import { PendingKeystoneCircles } from './PendingKeystoneCircles';
7+
import { useTranslation } from 'react-i18next';
8+
import { useKeystoneUsbContext } from '@core/ui';
9+
import { createKeystoneTransport } from '@keystonehq/hw-transport-webusb';
10+
import { resolve } from '@core/common';
11+
12+
type PendingProps = StateComponentProps & {
13+
action: Action<DisplayData>;
14+
};
15+
16+
export const Pending: FC<PendingProps> = ({ state, approve, action }) => {
17+
const { t } = useTranslation();
18+
const { hasKeystoneTransport } = useKeystoneUsbContext();
19+
const hasApprovedRef = useRef(false);
20+
21+
useEffect(() => {
22+
// Reset flag when state is not pending or action is not pending
23+
if (state !== 'pending' || action.status !== ActionStatus.PENDING) {
24+
hasApprovedRef.current = false;
25+
return;
26+
}
27+
28+
// Only call approve when device is ready and we haven't called it yet
29+
if (hasKeystoneTransport && !hasApprovedRef.current) {
30+
// Verify device is actually ready by testing transport creation
31+
// This ensures the device can handle signing requests
32+
const verifyAndApprove = async () => {
33+
try {
34+
// Make sure that the transport is available and the device is ready
35+
const [usbTransport] = await resolve(createKeystoneTransport());
36+
if (!usbTransport) {
37+
throw new Error('Transport not available');
38+
}
39+
40+
// Device is ready, now call approve
41+
hasApprovedRef.current = true;
42+
await approve();
43+
} catch (error) {
44+
console.error('Approval error:', error);
45+
hasApprovedRef.current = false;
46+
}
47+
};
48+
49+
if (
50+
state === 'pending' &&
51+
action.status === ActionStatus.PENDING &&
52+
hasKeystoneTransport &&
53+
!hasApprovedRef.current
54+
) {
55+
verifyAndApprove();
56+
} else {
57+
hasApprovedRef.current = false;
58+
}
59+
}
60+
}, [state, approve, action.status, hasKeystoneTransport]);
61+
62+
if (state !== 'pending') {
63+
return null;
64+
}
65+
66+
return (
67+
<Stack width="100%" height="100%" gap={2}>
68+
<Stack px={6} flexGrow={1} alignItems="center" justifyContent="center">
69+
<PendingKeystoneCircles />
70+
<Stack gap={1} textAlign="center">
71+
<Typography variant="body3" fontWeight={500}>
72+
{t('Please review the transaction on your Keystone')}
73+
</Typography>
74+
<Typography variant="caption" color="text.secondary">
75+
{t(
76+
'Open the Avalanche app on your Keystone device in order to continue with this transaction',
77+
)}
78+
</Typography>
79+
</Stack>
80+
</Stack>
81+
</Stack>
82+
);
83+
};
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { KeystoneIcon } from '@avalabs/k2-alpine';
2+
import { PendingCircles } from '../../common/PendingCircles';
3+
4+
export const PendingKeystoneCircles = () => (
5+
<PendingCircles Icon={KeystoneIcon} />
6+
);
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import React from 'react';
2+
import { Action } from '@core/types';
3+
import { Disconnected } from './components/Disconnected';
4+
import { Loading } from './components/Loading';
5+
import { Pending } from './components/Pending';
6+
import { DisplayData } from '@avalabs/vm-module-types';
7+
8+
export type StateComponentProps = {
9+
state: KeystoneUsbApprovalState;
10+
approve: () => Promise<unknown>;
11+
reject: () => void;
12+
action: Action<DisplayData>;
13+
};
14+
15+
export type KeystoneUsbApprovalState = 'loading' | 'disconnected' | 'pending';
16+
17+
export const StateComponentMapper: Record<
18+
KeystoneUsbApprovalState,
19+
React.ComponentType<StateComponentProps>
20+
> = {
21+
loading: Loading,
22+
disconnected: Disconnected,
23+
pending: Pending,
24+
};

0 commit comments

Comments
 (0)