Skip to content

Commit

Permalink
feat(suite-native): send flow handles device disconect during a review
Browse files Browse the repository at this point in the history
  • Loading branch information
PeKne committed Oct 2, 2024
1 parent eb8a8be commit 17bd988
Show file tree
Hide file tree
Showing 20 changed files with 230 additions and 58 deletions.
2 changes: 1 addition & 1 deletion suite-common/wallet-core/src/send/sendFormBitcoinThunks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ export const signBitcoinSendFormTransactionThunk = createThunk<
if (!response.success) {
return rejectWithValue({
error: 'sign-transaction-failed',
connectErrorCode: response.payload.code,
errorCode: response.payload.code,
message: response.payload.error,
});
}
Expand Down
2 changes: 1 addition & 1 deletion suite-common/wallet-core/src/send/sendFormCardanoThunks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ export const signCardanoSendFormTransactionThunk = createThunk<
if (!response.success) {
return rejectWithValue({
error: 'sign-transaction-failed',
connectErrorCode: response.payload.code,
errorCode: response.payload.code,
message: response.payload.error,
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ export const signEthereumSendFormTransactionThunk = createThunk<
// catch manual error from TransactionReviewModal
return rejectWithValue({
error: 'sign-transaction-failed',
connectErrorCode: response.payload.code,
errorCode: response.payload.code,
message: response.payload.error,
});
}
Expand Down
2 changes: 1 addition & 1 deletion suite-common/wallet-core/src/send/sendFormRippleThunks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ export const signRippleSendFormTransactionThunk = createThunk<
// catch manual error from TransactionReviewModal
return rejectWithValue({
error: 'sign-transaction-failed',
connectErrorCode: response.payload.code,
errorCode: response.payload.code,
message: response.payload.error,
});
}
Expand Down
2 changes: 1 addition & 1 deletion suite-common/wallet-core/src/send/sendFormSolanaThunks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ export const signSolanaSendFormTransactionThunk = createThunk<
// catch manual error from TransactionReviewModal
return rejectWithValue({
error: 'sign-transaction-failed',
connectErrorCode: response.payload.code,
errorCode: response.payload.code,
message: response.payload.error,
});
}
Expand Down
3 changes: 2 additions & 1 deletion suite-common/wallet-core/src/send/sendFormTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { TokenInfo, Unsuccessful } from '@trezor/connect';
import { Network, NetworkSymbol } from '@suite-common/wallet-config';
import { TrezorDevice } from '@suite-common/suite-types';
import { ERRORS as CONNECT_ERRORS } from '@trezor/connect';

export type SerializedTx = { tx: string; coin: NetworkSymbol };

Expand Down Expand Up @@ -53,7 +54,7 @@ export type ComposeFeeLevelsError = {

export type SignTransactionError = {
error: 'sign-transaction-failed';
connectErrorCode?: string;
errorCode?: CONNECT_ERRORS.ErrorCode;
message?: string;
};

Expand Down
23 changes: 18 additions & 5 deletions suite-native/device/src/hooks/useHandleDeviceConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
selectIsNoPhysicalDeviceConnected,
selectIsDeviceUsingPassphrase,
authorizeDeviceThunk,
selectIsDeviceRemembered,
} from '@suite-common/wallet-core';
import { selectDeviceRequestedPin } from '@suite-native/device-authorization';
import { selectIsOnboardingFinished } from '@suite-native/settings';
Expand All @@ -35,6 +36,7 @@ export const useHandleDeviceConnection = () => {
const isNoPhysicalDeviceConnected = useSelector(selectIsNoPhysicalDeviceConnected);
const isPortfolioTrackerDevice = useSelector(selectIsPortfolioTrackerDevice);
const isOnboardingFinished = useSelector(selectIsOnboardingFinished);
const isDeviceRemembered = useSelector(selectIsDeviceRemembered);
const isDeviceConnectedAndAuthorized = useSelector(selectIsDeviceConnectedAndAuthorized);
const hasDeviceRequestedPin = useSelector(selectDeviceRequestedPin);
const isDeviceConnected = useSelector(selectIsDeviceConnected);
Expand All @@ -43,6 +45,10 @@ export const useHandleDeviceConnection = () => {
const navigation = useNavigation<NavigationProp>();
const dispatch = useDispatch();

const isSendStackFocused =
navigation.getState()?.routes.at(-1)?.name === RootStackRoutes.SendStack;
const shouldBlockSendReviewRedirect = isDeviceRemembered && isSendStackFocused;

// At the moment when unauthorized physical device is selected,
// redirect to the Connecting screen where is handled the connection logic.
useEffect(() => {
Expand All @@ -59,7 +65,7 @@ export const useHandleDeviceConnection = () => {

// Note: Passphrase protected device (excluding empty passphrase, e. g. standard wallet with passphrase protection on device),
// post auth navigation is handled in @suite-native/module-passphrase for custom UX flow.
if (!isDeviceUsingPassphrase) {
if (!isDeviceUsingPassphrase && !shouldBlockSendReviewRedirect) {
navigation.navigate(RootStackRoutes.AuthorizeDeviceStack, {
screen: AuthorizeDeviceStackRoutes.ConnectingDevice,
});
Expand All @@ -75,17 +81,19 @@ export const useHandleDeviceConnection = () => {
isBiometricsOverlayVisible,
navigation,
isDeviceUsingPassphrase,
shouldBlockSendReviewRedirect,
]);

// In case that the physical device is disconnected, redirect to the home screen and
// set connecting screen to be displayed again on the next device connection.
useEffect(() => {
if (isNoPhysicalDeviceConnected && isOnboardingFinished) {
const previousRoute = navigation.getState()?.routes.at(-1)?.name;

// This accidentally gets triggered by finishing onboarding with no device connected,
// so this prevents from redirect being duplicated.
const isPreviousRouteOnboarding =
navigation.getState()?.routes.at(-1)?.name === RootStackRoutes.Onboarding;
if (isPreviousRouteOnboarding) {
const isPreviousRouteOnboarding = previousRoute === RootStackRoutes.Onboarding;
if (isPreviousRouteOnboarding || shouldBlockSendReviewRedirect) {
return;
}
navigation.navigate(RootStackRoutes.AppTabs, {
Expand All @@ -95,7 +103,12 @@ export const useHandleDeviceConnection = () => {
},
});
}
}, [isNoPhysicalDeviceConnected, isOnboardingFinished, navigation]);
}, [
isNoPhysicalDeviceConnected,
isOnboardingFinished,
navigation,
shouldBlockSendReviewRedirect,
]);

// When trezor gets locked, it is necessary to display a PIN matrix for T1 so that it can be unlocked
// and then continue with the interaction. For T2, PIN is entered on device, but the screen is still displayed.
Expand Down
5 changes: 5 additions & 0 deletions suite-native/intl/src/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -903,6 +903,11 @@ export const en = {
title: 'Are you sure you’d like to cancel sending the transaction?',
continueButton: 'Continue editing',
},
deviceDisconnectedAlert: {
title: 'Your Trezor has been disconnected.',
description: 'Reconnect your Trezor to continue.',
primaryButton: 'Reconnect Trezor',
},
lockedToast: 'Device is locked.',
address: {
title: 'Check the address on your Trezor against the original to make sure it’s correct.',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export const ConnectDeviceScreenHeader = ({
}

if (onCancelNavigationTarget) {
navigation.navigate(...onCancelNavigationTarget);
navigation.navigate(onCancelNavigationTarget);

return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,11 @@ export const ConnectAndUnlockDeviceScreen = ({

return (
<Screen
screenHeader={<ConnectDeviceScreenHeader onCancelNavigationTarget={params?.onCancelNavigationTarget} />}
screenHeader={
<ConnectDeviceScreenHeader
onCancelNavigationTarget={params?.onCancelNavigationTarget}
/>
}
customHorizontalPadding={0}
customVerticalPadding={0}
hasBottomInset={false}
Expand Down
3 changes: 2 additions & 1 deletion suite-native/module-send/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,12 @@
"@suite-native/navigation": "workspace:*",
"@suite-native/qr-code": "workspace:*",
"@suite-native/settings": "workspace:*",
"@suite-native/toasts": "workspace:*",
"@trezor/blockchain-link-types": "workspace:*",
"@trezor/react-utils": "workspace:*",
"@trezor/styles": "workspace:*",
"@trezor/theme": "workspace:*",
"@trezor/transport": "workspace:*",
"@trezor/utils": "workspace:*",
"expo-linear-gradient": "13.0.2",
"react": "18.2.0",
"react-hook-form": "^7.53.0",
Expand Down
41 changes: 10 additions & 31 deletions suite-native/module-send/src/components/AddressReviewStepList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { useNavigation, useRoute } from '@react-navigation/native';

import {
RootStackParamList,
RootStackRoutes,
SendStackParamList,
SendStackRoutes,
StackProps,
Expand All @@ -17,7 +16,6 @@ import { Button, VStack } from '@suite-native/atoms';
import { Translation } from '@suite-native/intl';
import { AccountsRootState, DeviceRootState, SendRootState } from '@suite-common/wallet-core';
import { nativeSpacings } from '@trezor/theme';
import { useToast } from '@suite-native/toasts';

import {
cleanupSendFormThunk,
Expand All @@ -28,6 +26,7 @@ import { SlidingFooterOverlay } from '../components/SlidingFooterOverlay';
import { AddressReviewStep } from '../components/AddressReviewStep';
import { CompareAddressHelpButton } from '../components/CompareAddressHelpButton';
import { AddressOriginHelpButton } from '../components/AddressOriginHelpButton';
import { useHandleSendReviewFailure } from '../hooks/useHandleSendReviewFailure';

const NUMBER_OF_STEPS = 3;
const OVERLAY_INITIAL_POSITION = 75;
Expand All @@ -42,16 +41,16 @@ type NavigationProps = StackToStackCompositeNavigationProps<

export const AddressReviewStepList = () => {
const route = useRoute<RouteProps>();
const { accountKey, transaction } = route.params;
const navigation = useNavigation<NavigationProps>();
const dispatch = useDispatch();

const [childHeights, setChildHeights] = useState<number[]>([]);
const [stepIndex, setStepIndex] = useState(0);
const { showToast } = useToast();
const handleSendReviewFailure = useHandleSendReviewFailure({ accountKey, transaction });

const areAllStepsDone = stepIndex === NUMBER_OF_STEPS - 1;
const isLayoutReady = childHeights.length === NUMBER_OF_STEPS;
const { accountKey, transaction } = route.params;

const isAddressConfirmed = useSelector(
(state: AccountsRootState & DeviceRootState & SendRootState) =>
Expand All @@ -74,6 +73,11 @@ export const AddressReviewStepList = () => {
});
};

const restartAddressReview = () => {
setStepIndex(0);
dispatch(cleanupSendFormThunk({ accountKey, shouldDeleteDraft: false }));
};

const handleNextStep = async () => {
setStepIndex(prevStepIndex => prevStepIndex + 1);

Expand All @@ -86,33 +90,8 @@ export const AddressReviewStepList = () => {
);

if (isRejected(response)) {
const connectErrorCode = response.payload?.connectErrorCode;
// In case that the signing review is interrupted, restart the flow so user can try again.
if (
connectErrorCode === 'Failure_PinCancelled' || // User cancelled the pin entry on device
connectErrorCode === 'Method_Cancel' || // User canceled the pin entry in the app UI.
connectErrorCode === 'Failure_ActionCancelled' // Device got locked before the review was finished.
) {
showToast({
message: <Translation id="moduleSend.review.lockedToast" />,
variant: 'error',
icon: 'closeCircle',
});
navigation.navigate(SendStackRoutes.SendAddressReview, {
accountKey,
transaction,
});
setStepIndex(0);
dispatch(cleanupSendFormThunk({ accountKey, shouldDeleteDraft: false }));

return;
}

// Review was exited or cancelled on purpose.
navigation.navigate(RootStackRoutes.AccountDetail, {
accountKey,
closeActionType: 'back',
});
restartAddressReview();
handleSendReviewFailure(response);
}
}
};
Expand Down
14 changes: 6 additions & 8 deletions suite-native/module-send/src/components/SendFeesForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,13 @@ export const SendFeesForm = ({ accountKey, feeLevels }: SendFormProps) => {
screen: AuthorizeDeviceStackRoutes.ConnectAndUnlockDevice,
params: {
// If user cancels, navigate back to the send fees screen.
onCancelNavigationTarget: [
{
name: RootStackRoutes.SendStack,
params: {
screen: SendStackRoutes.SendFees,
params: { accountKey, feeLevels },
},
onCancelNavigationTarget: {
name: RootStackRoutes.SendStack,
params: {
screen: SendStackRoutes.SendFees,
params: { accountKey, feeLevels },
},
],
},
},
});
});
Expand Down
95 changes: 95 additions & 0 deletions suite-native/module-send/src/hooks/useHandleSendReviewFailure.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { useSelector } from 'react-redux';

import { useNavigation } from '@react-navigation/native';
import { PayloadAction } from '@reduxjs/toolkit';

import { Translation } from '@suite-native/intl';
import { useAlert } from '@suite-native/alerts';
import { selectIsDeviceRemembered, SignTransactionError } from '@suite-common/wallet-core';
import {
RootStackParamList,
RootStackRoutes,
SendStackParamList,
SendStackRoutes,
StackToStackCompositeNavigationProps,
} from '@suite-native/navigation';
import { GeneralPrecomposedTransactionFinal } from '@suite-common/wallet-types';
import { TRANSPORT_ERROR } from '@trezor/transport';

import { useShowDeviceDisconnectedAlert } from './useShowDeviceDisconnectedAlert';

type NavigationProps = StackToStackCompositeNavigationProps<
SendStackParamList,
SendStackRoutes.SendOutputsReview,
RootStackParamList
>;

type UseHandleSendReviewFailureArguments = {
accountKey: string;
transaction: GeneralPrecomposedTransactionFinal;
};

export const useHandleSendReviewFailure = ({
accountKey,
transaction,
}: UseHandleSendReviewFailureArguments) => {
const navigation = useNavigation<NavigationProps>();
const { showAlert } = useAlert();
const isViewOnlyDevice = useSelector(selectIsDeviceRemembered);
const showDeviceDisconnectedAlert = useShowDeviceDisconnectedAlert();

const handleSendReviewFailure = (response: PayloadAction<SignTransactionError | undefined>) => {
const errorCode = response.payload?.errorCode;
const message = response.payload?.message;

if (
errorCode === 'Failure_PinCancelled' || // User cancelled the pin entry on device
errorCode === 'Method_Cancel' || // User canceled the pin entry in the app UI.
errorCode === 'Failure_ActionCancelled' // User canceled the review on device OR device got locked before the review was finished.
) {
navigation.navigate(SendStackRoutes.SendAddressReview, {
accountKey,
transaction,
});

return;
}

if (
errorCode === 'Device_InvalidState' || // Incorrect Passphrase submitted.
errorCode === 'Method_Interrupted' // Passphrase modal closed.
) {
showAlert({
title: <Translation id="modulePassphrase.featureAuthorizationError" />,
pictogramVariant: 'red',
primaryButtonTitle: <Translation id="generic.buttons.close" />,
primaryButtonVariant: 'redBold',
});

return;
}

// Device disconnected during the review.
if (
message === TRANSPORT_ERROR.DEVICE_DISCONNECTED_DURING_ACTION ||
message === TRANSPORT_ERROR.UNEXPECTED_ERROR
) {
if (isViewOnlyDevice) {
navigation.navigate(SendStackRoutes.SendAddressReview, {
accountKey,
transaction,
});
}
showDeviceDisconnectedAlert();

return;
}

navigation.navigate(RootStackRoutes.AccountDetail, {
accountKey,
closeActionType: 'back',
});
};

return handleSendReviewFailure;
};
Loading

0 comments on commit 17bd988

Please sign in to comment.