diff --git a/.changeset/dirty-drinks-roll.md b/.changeset/dirty-drinks-roll.md new file mode 100644 index 000000000000..05facbb142bc --- /dev/null +++ b/.changeset/dirty-drinks-roll.md @@ -0,0 +1,8 @@ +--- +"ledger-live-desktop": minor +"live-mobile": minor +"@ledgerhq/trustchain": minor +"@ledgerhq/web-tools": minor +--- + +Ledger Sync - Added the synchronization of a trustchain from mobile to desktop by scanning the QR code diff --git a/apps/ledger-live-desktop/src/newArch/features/WalletSync/__tests__/manageSynchronizedInstances.test.tsx b/apps/ledger-live-desktop/src/newArch/features/WalletSync/__tests__/manageSynchronizedInstances.test.tsx index d00d6457455e..eb49135b4b45 100644 --- a/apps/ledger-live-desktop/src/newArch/features/WalletSync/__tests__/manageSynchronizedInstances.test.tsx +++ b/apps/ledger-live-desktop/src/newArch/features/WalletSync/__tests__/manageSynchronizedInstances.test.tsx @@ -7,6 +7,7 @@ jest.mock("../hooks/useTrustchainSdk", () => ({ useTrustchainSdk: () => ({ getMembers: (mockedSdk.getMembers = jest.fn()), removeMember: (mockedSdk.removeMember = jest.fn()), + initMemberCredentials: (mockedSdk.initMemberCredentials = jest.fn()), }), })); diff --git a/apps/ledger-live-desktop/src/newArch/features/WalletSync/__tests__/manageYourBackup.test.tsx b/apps/ledger-live-desktop/src/newArch/features/WalletSync/__tests__/manageYourBackup.test.tsx index cac8684a51ab..aaa2342cf87f 100644 --- a/apps/ledger-live-desktop/src/newArch/features/WalletSync/__tests__/manageYourBackup.test.tsx +++ b/apps/ledger-live-desktop/src/newArch/features/WalletSync/__tests__/manageYourBackup.test.tsx @@ -8,6 +8,7 @@ jest.mock("../hooks/useTrustchainSdk", () => ({ useTrustchainSdk: () => ({ destroyTrustchain: (mockedSdk.destroyTrustchain = jest.fn()), getMembers: (mockedSdk.getMembers = jest.fn()), + initMemberCredentials: (mockedSdk.initMemberCredentials = jest.fn()), }), })); diff --git a/apps/ledger-live-desktop/src/newArch/features/WalletSync/__tests__/useWatchWalletSync.test.ts b/apps/ledger-live-desktop/src/newArch/features/WalletSync/__tests__/useWatchWalletSync.test.ts index f1f804126d93..6999cd88b901 100644 --- a/apps/ledger-live-desktop/src/newArch/features/WalletSync/__tests__/useWatchWalletSync.test.ts +++ b/apps/ledger-live-desktop/src/newArch/features/WalletSync/__tests__/useWatchWalletSync.test.ts @@ -31,6 +31,7 @@ jest.mock("../hooks/useTrustchainSdk", () => ({ useTrustchainSdk: () => ({ getMembers: (mockedSdk.getMembers = jest.fn()), removeMember: (mockedSdk.removeMember = jest.fn()), + initMemberCredentials: (mockedSdk.initMemberCredentials = jest.fn()), }), })); diff --git a/apps/ledger-live-desktop/src/newArch/features/WalletSync/__tests__/walletSync.test.tsx b/apps/ledger-live-desktop/src/newArch/features/WalletSync/__tests__/walletSync.test.tsx index db3c6386149c..2ab81b31cc00 100644 --- a/apps/ledger-live-desktop/src/newArch/features/WalletSync/__tests__/walletSync.test.tsx +++ b/apps/ledger-live-desktop/src/newArch/features/WalletSync/__tests__/walletSync.test.tsx @@ -4,7 +4,15 @@ import React from "react"; import { render, screen, waitFor } from "tests/testUtils"; import { initialStateWalletSync } from "~/renderer/reducers/walletSync"; -import { WalletSyncTestApp } from "./shared"; +import { WalletSyncTestApp, mockedSdk } from "./shared"; + +jest.mock("../hooks/useTrustchainSdk", () => ({ + useTrustchainSdk: () => ({ + getMembers: (mockedSdk.getMembers = jest.fn()), + removeMember: (mockedSdk.removeMember = jest.fn()), + initMemberCredentials: (mockedSdk.initMemberCredentials = jest.fn()), + }), +})); describe("Rendering", () => { it("should loads and displays WalletSync Row", async () => { diff --git a/apps/ledger-live-desktop/src/newArch/features/WalletSync/components/Error.tsx b/apps/ledger-live-desktop/src/newArch/features/WalletSync/components/Error.tsx index 4480ddc37f57..75a40be61168 100644 --- a/apps/ledger-live-desktop/src/newArch/features/WalletSync/components/Error.tsx +++ b/apps/ledger-live-desktop/src/newArch/features/WalletSync/components/Error.tsx @@ -11,6 +11,7 @@ type Props = { cta?: string; onClick?: () => void; analyticsPage?: AnalyticsPage; + ctaVariant?: "shade" | "main"; }; const Container = styled(Box)` @@ -23,7 +24,14 @@ const Container = styled(Box)` justify-content: center; `; -export const Error = ({ title, description, cta, onClick, analyticsPage }: Props) => { +export const Error = ({ + title, + description, + cta, + onClick, + analyticsPage, + ctaVariant = "shade", +}: Props) => { const { colors } = useTheme(); return ( @@ -38,7 +46,7 @@ export const Error = ({ title, description, cta, onClick, analyticsPage }: Props {description} {cta && onClick && ( - + {cta} )} diff --git a/apps/ledger-live-desktop/src/newArch/features/WalletSync/hooks/useInstanceName.ts b/apps/ledger-live-desktop/src/newArch/features/WalletSync/hooks/useInstanceName.ts new file mode 100644 index 000000000000..e0bf04771251 --- /dev/null +++ b/apps/ledger-live-desktop/src/newArch/features/WalletSync/hooks/useInstanceName.ts @@ -0,0 +1,14 @@ +import os from "os"; + +const platformMap: Record = { + darwin: "Mac", + win32: "Windows", + linux: "Linux", +}; + +export function useInstanceName(): string { + const platform = os.platform(); + const hostname = os.hostname(); + const name = `${platformMap[platform] || platform}${hostname ? " " + hostname : ""}`; + return name; +} diff --git a/apps/ledger-live-desktop/src/newArch/features/WalletSync/hooks/useQRCode.ts b/apps/ledger-live-desktop/src/newArch/features/WalletSync/hooks/useQRCode.ts index 447d34f8daa3..255190e7ed8f 100644 --- a/apps/ledger-live-desktop/src/newArch/features/WalletSync/hooks/useQRCode.ts +++ b/apps/ledger-live-desktop/src/newArch/features/WalletSync/hooks/useQRCode.ts @@ -1,15 +1,20 @@ import { useCallback, useState } from "react"; import { createQRCodeHostInstance } from "@ledgerhq/trustchain/qrcode/index"; -import { InvalidDigitsError } from "@ledgerhq/trustchain/errors"; +import { InvalidDigitsError, NoTrustchainInitialized } from "@ledgerhq/trustchain/errors"; import { useDispatch, useSelector } from "react-redux"; import { setFlow, setQrCodePinCode } from "~/renderer/actions/walletSync"; import { Flow, Step } from "~/renderer/reducers/walletSync"; -import { trustchainSelector, memberCredentialsSelector } from "@ledgerhq/trustchain/store"; +import { + trustchainSelector, + memberCredentialsSelector, + setTrustchain, +} from "@ledgerhq/trustchain/store"; import { useTrustchainSdk } from "./useTrustchainSdk"; import { useFeature } from "@ledgerhq/live-common/featureFlags/index"; import getWalletSyncEnvironmentParams from "@ledgerhq/live-common/walletSync/getEnvironmentParams"; import { useQueryClient } from "@tanstack/react-query"; import { QueryKey } from "./type.hooks"; +import { useInstanceName } from "./useInstanceName"; export function useQRCode() { const queryClient = useQueryClient(); @@ -24,13 +29,14 @@ export function useQRCode() { const [isLoading, setIsLoading] = useState(false); const [url, setUrl] = useState(null); const [error, setError] = useState(null); + const memberName = useInstanceName(); const goToActivation = useCallback(() => { dispatch(setFlow({ flow: Flow.Activation, step: Step.DeviceAction })); }, [dispatch]); const startQRCodeProcessing = useCallback(() => { - if (!trustchain || !memberCredentials) return; + if (!memberCredentials) return; setError(null); setIsLoading(true); @@ -44,18 +50,29 @@ export function useQRCode() { dispatch(setFlow({ flow: Flow.Synchronize, step: Step.PinCode })); }, addMember: async member => { - await sdk.addMember(trustchain, memberCredentials, member); - return trustchain; + if (trustchain) { + await sdk.addMember(trustchain, memberCredentials, member); + return trustchain; + } + throw new NoTrustchainInitialized(); }, + memberCredentials, + memberName, + alreadyHasATrustchain: !!trustchain, }) .catch(e => { if (e instanceof InvalidDigitsError) { dispatch(setFlow({ flow: Flow.Synchronize, step: Step.PinCodeError })); + } else if (e instanceof NoTrustchainInitialized) { + dispatch(setFlow({ flow: Flow.Synchronize, step: Step.UnbackedError })); } setError(e); throw e; }) - .then(() => { + .then(newTrustchain => { + if (newTrustchain) { + dispatch(setTrustchain(newTrustchain)); + } dispatch(setFlow({ flow: Flow.Synchronize, step: Step.Synchronized })); queryClient.invalidateQueries({ queryKey: [QueryKey.getMembers] }); setUrl(null); @@ -63,7 +80,7 @@ export function useQRCode() { setIsLoading(false); setError(null); }); - }, [trustchain, memberCredentials, trustchainApiBaseUrl, dispatch, sdk, queryClient]); + }, [memberCredentials, trustchainApiBaseUrl, memberName, dispatch, trustchain, sdk, queryClient]); return { url, diff --git a/apps/ledger-live-desktop/src/newArch/features/WalletSync/hooks/useTrustchainSdk.ts b/apps/ledger-live-desktop/src/newArch/features/WalletSync/hooks/useTrustchainSdk.ts index 25f806aeefe1..005b22b69803 100644 --- a/apps/ledger-live-desktop/src/newArch/features/WalletSync/hooks/useTrustchainSdk.ts +++ b/apps/ledger-live-desktop/src/newArch/features/WalletSync/hooks/useTrustchainSdk.ts @@ -1,4 +1,3 @@ -import os from "os"; import { useMemo } from "react"; import { getEnv } from "@ledgerhq/live-env"; import { getSdk } from "@ledgerhq/trustchain/index"; @@ -10,12 +9,7 @@ import { walletSyncStateSelector } from "@ledgerhq/live-wallet/store"; import { TrustchainSDK } from "@ledgerhq/trustchain/types"; import { useFeature } from "@ledgerhq/live-common/featureFlags/index"; import getWalletSyncEnvironmentParams from "@ledgerhq/live-common/walletSync/getEnvironmentParams"; - -const platformMap: Record = { - darwin: "Mac", - win32: "Windows", - linux: "Linux", -}; +import { useInstanceName } from "./useInstanceName"; let sdkInstance: TrustchainSDK | null = null; @@ -24,15 +18,13 @@ export function useTrustchainSdk() { const { trustchainApiBaseUrl, cloudSyncApiBaseUrl } = getWalletSyncEnvironmentParams( featureWalletSync?.params?.environment, ); + const name = useInstanceName(); const isMockEnv = !!getEnv("MOCK"); const defaultContext = useMemo(() => { const applicationId = 16; - const platform = os.platform(); - const hostname = os.hostname(); - const name = `${platformMap[platform] || platform}${hostname ? " " + hostname : ""}`; return { applicationId, name, apiBaseUrl: trustchainApiBaseUrl }; - }, [trustchainApiBaseUrl]); + }, [trustchainApiBaseUrl, name]); const store = useStore(); const lifecycle = useMemo( diff --git a/apps/ledger-live-desktop/src/newArch/features/WalletSync/hooks/useWalletSyncAnalytics.ts b/apps/ledger-live-desktop/src/newArch/features/WalletSync/hooks/useWalletSyncAnalytics.ts index d4264c94af85..7316d49e27bd 100644 --- a/apps/ledger-live-desktop/src/newArch/features/WalletSync/hooks/useWalletSyncAnalytics.ts +++ b/apps/ledger-live-desktop/src/newArch/features/WalletSync/hooks/useWalletSyncAnalytics.ts @@ -27,6 +27,7 @@ export enum AnalyticsPage { SyncWithQR = "Sync with QR code", PinCode = "Pin code", PinCodeError = "Pin code error", + UnbackedError = "No trustchain initialized error", SettingsGeneral = "Settings General", WalletSyncSettings = "Wallet Sync Settings", @@ -66,6 +67,7 @@ export const StepMappedToAnalytics: Record = { [Step.PinCode]: AnalyticsPage.PinCode, [Step.PinCodeError]: AnalyticsPage.PinCodeError, [Step.Synchronized]: AnalyticsPage.KeyUpdated, + [Step.UnbackedError]: AnalyticsPage.UnbackedError, //ManageInstances [Step.SynchronizedInstances]: AnalyticsPage.ManageInstances, diff --git a/apps/ledger-live-desktop/src/newArch/features/WalletSync/screens/Activation/02-DeviceActionStep.tsx b/apps/ledger-live-desktop/src/newArch/features/WalletSync/screens/Activation/02-DeviceActionStep.tsx index c86afe2cb7a4..82b66a720e40 100644 --- a/apps/ledger-live-desktop/src/newArch/features/WalletSync/screens/Activation/02-DeviceActionStep.tsx +++ b/apps/ledger-live-desktop/src/newArch/features/WalletSync/screens/Activation/02-DeviceActionStep.tsx @@ -1,14 +1,11 @@ import React from "react"; import { Device } from "@ledgerhq/live-common/hw/actions/types"; import OpenOrInstallTrustChainApp from "../DeviceActions/openOrInstall"; -import { useInitMemberCredentials } from "../../hooks/useInitMemberCredentials"; type Props = { goNext: (device: Device) => void; }; export default function DeviceActionStep({ goNext }: Props) { - useInitMemberCredentials(); - return ; } diff --git a/apps/ledger-live-desktop/src/newArch/features/WalletSync/screens/Synchronize/05-UnbackedError.tsx b/apps/ledger-live-desktop/src/newArch/features/WalletSync/screens/Synchronize/05-UnbackedError.tsx new file mode 100644 index 000000000000..a78f658f2300 --- /dev/null +++ b/apps/ledger-live-desktop/src/newArch/features/WalletSync/screens/Synchronize/05-UnbackedError.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { Error } from "../../components/Error"; +import { useTranslation } from "react-i18next"; +import { AnalyticsPage } from "../../hooks/useWalletSyncAnalytics"; +import { useDispatch } from "react-redux"; +import { setFlow } from "~/renderer/actions/walletSync"; +import { Flow, Step } from "~/renderer/reducers/walletSync"; + +export default function UnbackedError() { + const { t } = useTranslation(); + + const dispatch = useDispatch(); + + return ( + dispatch(setFlow({ flow: Flow.Activation, step: Step.DeviceAction }))} + ctaVariant="main" + /> + ); +} diff --git a/apps/ledger-live-desktop/src/newArch/features/WalletSync/screens/Synchronize/index.tsx b/apps/ledger-live-desktop/src/newArch/features/WalletSync/screens/Synchronize/index.tsx index 9b7659005c4e..ecfdb8319442 100644 --- a/apps/ledger-live-desktop/src/newArch/features/WalletSync/screens/Synchronize/index.tsx +++ b/apps/ledger-live-desktop/src/newArch/features/WalletSync/screens/Synchronize/index.tsx @@ -10,6 +10,7 @@ import PinCodeStep from "./03-PinCodeStep"; import SyncFinalStep from "./04-SyncFinalStep"; import { AnalyticsPage, useWalletSyncAnalytics } from "../../hooks/useWalletSyncAnalytics"; import PinCodeErrorStep from "./05-PinCodeError"; +import UnbackedErrorStep from "./05-UnbackedError"; import { BackProps, BackRef } from "../router"; const SynchronizeWallet = forwardRef((_props, ref) => { @@ -66,23 +67,24 @@ const SynchronizeWallet = forwardRef((_props, ref) => { case Step.PinCodeError: return ; + case Step.UnbackedError: + return ; + case Step.Synchronized: return ; } }; + const centeredItems = [Step.Synchronized, Step.PinCodeError, Step.UnbackedError]; + return ( {getStep()} diff --git a/apps/ledger-live-desktop/src/newArch/features/WalletSync/screens/router.tsx b/apps/ledger-live-desktop/src/newArch/features/WalletSync/screens/router.tsx index 418bc4715a9f..8f969bdd4aae 100644 --- a/apps/ledger-live-desktop/src/newArch/features/WalletSync/screens/router.tsx +++ b/apps/ledger-live-desktop/src/newArch/features/WalletSync/screens/router.tsx @@ -6,6 +6,7 @@ import SynchronizeWallet from "./Synchronize"; import WalletSyncManageInstances from "./ManageInstances"; import WalletSyncActivation from "./Activation"; import WalletSyncManage from "./Manage"; +import { useInitMemberCredentials } from "../hooks/useInitMemberCredentials"; export interface BackRef { goBack: () => void; @@ -14,6 +15,7 @@ export interface BackRef { export interface BackProps {} export const WalletSyncRouter = forwardRef((_props, ref) => { + useInitMemberCredentials(); const walletSyncFlow = useSelector(walletSyncFlowSelector); switch (walletSyncFlow) { diff --git a/apps/ledger-live-desktop/src/renderer/reducers/walletSync.ts b/apps/ledger-live-desktop/src/renderer/reducers/walletSync.ts index 372e67813ed0..1c2143e2d47b 100644 --- a/apps/ledger-live-desktop/src/renderer/reducers/walletSync.ts +++ b/apps/ledger-live-desktop/src/renderer/reducers/walletSync.ts @@ -30,6 +30,7 @@ export enum Step { SynchronizeWithQRCode = "SynchronizeWithQRCode", PinCode = "PinCode", PinCodeError = "PinCodeError", + UnbackedError = "UnbackedError", Synchronized = "Synchronized", //ManageInstances diff --git a/apps/ledger-live-desktop/static/i18n/en/app.json b/apps/ledger-live-desktop/static/i18n/en/app.json index 2423d073c931..7807c0941e7f 100644 --- a/apps/ledger-live-desktop/static/i18n/en/app.json +++ b/apps/ledger-live-desktop/static/i18n/en/app.json @@ -6508,6 +6508,11 @@ } } }, + "unbackedError": { + "title": "You need to create your encryption key first", + "description": "Please make sure you’ve created an encryption key on one of your Ledger Live apps before continuing your synchronization.", + "cta": "Create your encryption key" + }, "pinCode": { "title": "Enter the code", "description": "Type the code displayed on the other Ledger Live you want to sync with.", diff --git a/apps/ledger-live-mobile/src/locales/en/common.json b/apps/ledger-live-mobile/src/locales/en/common.json index 4810eb06f02c..32735d8113f6 100644 --- a/apps/ledger-live-mobile/src/locales/en/common.json +++ b/apps/ledger-live-mobile/src/locales/en/common.json @@ -6843,6 +6843,11 @@ "desc": "Make sure the code you type is the one displayed on the other Ledger Live instance.", "tryAgain": "Try again" } + }, + "unbacked": { + "title": "You need to create your encryption key first", + "desc": "Please make sure you’ve created an encryption key on one of your Ledger Live apps before continuing your synchronization.", + "cta": "Create your encryption key" } } } diff --git a/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/AddAccount/components/StepFlow.tsx b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/AddAccount/components/StepFlow.tsx index 9568b6ee27a1..5113dff3da12 100644 --- a/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/AddAccount/components/StepFlow.tsx +++ b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/AddAccount/components/StepFlow.tsx @@ -11,6 +11,7 @@ import PinCodeDisplay from "LLM/features/WalletSync/screens/Synchronize/PinCodeD import PinCodeInput from "LLM/features/WalletSync/screens/Synchronize/PinCodeInput"; import { useInitMemberCredentials } from "LLM/features/WalletSync/hooks/useInitMemberCredentials"; import { useSyncWithQrCode } from "LLM/features/WalletSync/hooks/useSyncWithQrCode"; +import UnbackedError from "~/newArch/features/WalletSync/screens/Synchronize/UnbackedError"; type Props = { currentStep: Steps; @@ -21,6 +22,7 @@ type Props = { currentOption: Options; navigateToChooseSyncMethod: () => void; navigateToQrCodeMethod: () => void; + onCreateKey: () => void; onQrCodeScanned: () => void; qrProcess: { url: string | null; @@ -41,6 +43,7 @@ const StepFlow = ({ onQrCodeScanned, qrProcess, setCurrentStep, + onCreateKey, }: Props) => { const { memberCredentials } = useInitMemberCredentials(); @@ -95,6 +98,9 @@ const StepFlow = ({ case Steps.SyncError: return ; + case Steps.UnbackedError: + return ; + default: return null; } diff --git a/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/AddAccount/index.tsx b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/AddAccount/index.tsx index b6eaa4cbe440..6e282ba63400 100644 --- a/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/AddAccount/index.tsx +++ b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/AddAccount/index.tsx @@ -30,6 +30,7 @@ function View({ navigateToQrCodeMethod, qrProcess, onQrCodeScanned, + onCreateKey, }: ViewProps) { const CustomDrawerHeader = () => ; @@ -53,6 +54,7 @@ function View({ navigateToQrCodeMethod={navigateToQrCodeMethod} qrProcess={qrProcess} onQrCodeScanned={onQrCodeScanned} + onCreateKey={onCreateKey} /> diff --git a/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/AddAccount/useAddAccountViewModel.ts b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/AddAccount/useAddAccountViewModel.ts index a143ebe03c91..0afdbb6c0dec 100644 --- a/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/AddAccount/useAddAccountViewModel.ts +++ b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/AddAccount/useAddAccountViewModel.ts @@ -2,21 +2,34 @@ import { useCallback, useState } from "react"; import { track } from "~/analytics"; import { useQRCodeHost } from "LLM/features/WalletSync/hooks/useQRCodeHost"; import { Options, Steps } from "LLM/features/WalletSync/types/Activation"; +import { NavigatorName, ScreenName } from "~/const"; +import { + AnalyticsButton, + AnalyticsPage, + useLedgerSyncAnalytics, +} from "~/newArch/features/WalletSync/hooks/useLedgerSyncAnalytics"; +import { useNavigation } from "@react-navigation/native"; +import { BaseComposite, StackNavigatorProps } from "~/components/RootNavigator/types/helpers"; +import { WalletSyncNavigatorStackParamList } from "~/components/RootNavigator/types/WalletSyncNavigator"; type AddAccountDrawerProps = { isOpened: boolean; onClose: () => void; }; +type NavigationProps = BaseComposite< + StackNavigatorProps +>; + const startingStep = Steps.AddAccountMethod; const useAddAccountViewModel = ({ isOpened, onClose }: AddAccountDrawerProps) => { const [currentStep, setCurrentStep] = useState(startingStep); const [currentOption, setCurrentOption] = useState(Options.SCAN); - + const { onClickTrack } = useLedgerSyncAnalytics(); const navigateToChooseSyncMethod = () => setCurrentStep(Steps.ChooseSyncMethod); const navigateToQrCodeMethod = () => setCurrentStep(Steps.QrCodeMethod); - + const navigation = useNavigation(); const onGoBack = () => setCurrentStep(prevStep => getPreviousStep(prevStep)); const reset = () => { @@ -54,6 +67,13 @@ const useAddAccountViewModel = ({ isOpened, onClose }: AddAccountDrawerProps) => currentOption, }); + const onCreateKey = () => { + onClickTrack({ button: AnalyticsButton.CreateYourKey, page: AnalyticsPage.Unbacked }); + navigation.navigate(NavigatorName.WalletSync, { + screen: ScreenName.WalletSyncActivationProcess, + }); + }; + const onQrCodeScanned = () => setCurrentStep(Steps.PinInput); return { @@ -73,6 +93,7 @@ const useAddAccountViewModel = ({ isOpened, onClose }: AddAccountDrawerProps) => isLoading, pinCode, }, + onCreateKey, }; }; diff --git a/apps/ledger-live-mobile/src/newArch/features/WalletSync/__integrations__/synchronizeWithQrCode.integration.test.tsx b/apps/ledger-live-mobile/src/newArch/features/WalletSync/__integrations__/synchronizeWithQrCode.integration.test.tsx index c0a7f6e92eaf..499c64c42212 100644 --- a/apps/ledger-live-mobile/src/newArch/features/WalletSync/__integrations__/synchronizeWithQrCode.integration.test.tsx +++ b/apps/ledger-live-mobile/src/newArch/features/WalletSync/__integrations__/synchronizeWithQrCode.integration.test.tsx @@ -4,15 +4,25 @@ import { render, screen, waitFor } from "@tests/test-renderer"; import { INITIAL_TEST, WalletSyncSettingsNavigator } from "./shared"; import getWalletSyncEnvironmentParams from "@ledgerhq/live-common/walletSync/getEnvironmentParams"; -jest.mock("@ledgerhq/trustchain/qrcode/index", () => ({ - createQRCodeHostInstance: () => ({ - trustchainApiBaseUrl: getWalletSyncEnvironmentParams("STAGING").trustchainApiBaseUrl, - onDisplayQRCode: jest.fn().mockImplementation(url => url), - onDisplayDigits: jest.fn().mockImplementation(digits => digits), - addMember: jest.fn(), +jest.mock("../hooks/useQRCodeHost", () => ({ + useQRCodeHost: () => ({ + setCurrentStep: jest.fn(), + currentStep: jest.fn(), + currentOption: jest.fn(), + url: "ledger.com", }), })); +jest.mock("@ledgerhq/trustchain/qrcode/index", () => ({ + createQRCodeHostInstance: () => + Promise.resolve({ + trustchainApiBaseUrl: getWalletSyncEnvironmentParams("STAGING").trustchainApiBaseUrl, + onDisplayQRCode: jest.fn().mockImplementation(url => url), + onDisplayDigits: jest.fn().mockImplementation(digits => digits), + addMember: jest.fn(), + }), +})); + describe("SynchronizeWithQrCode", () => { it("Should display the QR code when 'show qr' toggle is pressed and add a new member through the flow", async () => { const { user } = render(, { diff --git a/apps/ledger-live-mobile/src/newArch/features/WalletSync/__integrations__/useWatchWalletSync.test.ts b/apps/ledger-live-mobile/src/newArch/features/WalletSync/__integrations__/useWatchWalletSync.test.ts index ac89b636896e..9a1d1ce9c74d 100644 --- a/apps/ledger-live-mobile/src/newArch/features/WalletSync/__integrations__/useWatchWalletSync.test.ts +++ b/apps/ledger-live-mobile/src/newArch/features/WalletSync/__integrations__/useWatchWalletSync.test.ts @@ -31,6 +31,7 @@ jest.mock("../hooks/useTrustchainSdk", () => ({ useTrustchainSdk: () => ({ getMembers: (mockedSdk.getMembers = jest.fn()), removeMember: (mockedSdk.removeMember = jest.fn()), + initMemberCredentials: (mockedSdk.initMemberCredentials = jest.fn()), }), })); diff --git a/apps/ledger-live-mobile/src/newArch/features/WalletSync/components/Activation/ActivationFlow.tsx b/apps/ledger-live-mobile/src/newArch/features/WalletSync/components/Activation/ActivationFlow.tsx index dff0e95ad6bf..59cf1eaa9ece 100644 --- a/apps/ledger-live-mobile/src/newArch/features/WalletSync/components/Activation/ActivationFlow.tsx +++ b/apps/ledger-live-mobile/src/newArch/features/WalletSync/components/Activation/ActivationFlow.tsx @@ -10,6 +10,7 @@ import PinCodeInput from "../../screens/Synchronize/PinCodeInput"; import SyncError from "../../screens/Synchronize/SyncError"; import { useInitMemberCredentials } from "../../hooks/useInitMemberCredentials"; import { useSyncWithQrCode } from "../../hooks/useSyncWithQrCode"; +import UnbackedError from "../../screens/Synchronize/UnbackedError"; type Props = { currentStep: Steps; @@ -25,6 +26,7 @@ type Props = { currentOption: Options; setOption: (option: Options) => void; setCurrentStep: (step: Steps) => void; + onCreateKey: () => void; }; const ActivationFlow = ({ @@ -36,6 +38,7 @@ const ActivationFlow = ({ setOption, onQrCodeScanned, setCurrentStep, + onCreateKey, }: Props) => { const { memberCredentials } = useInitMemberCredentials(); @@ -72,6 +75,7 @@ const ActivationFlow = ({ onQrCodeScanned={handleQrCodeScanned} currentOption={currentOption} setSelectedOption={setOption} + qrCodeValue={qrProcess.url} /> ); @@ -86,6 +90,8 @@ const ActivationFlow = ({ case Steps.SyncError: return ; + case Steps.UnbackedError: + return ; default: return null; } diff --git a/apps/ledger-live-mobile/src/newArch/features/WalletSync/components/Error/index.tsx b/apps/ledger-live-mobile/src/newArch/features/WalletSync/components/Error/index.tsx index 57d6e188870c..0e0820a280fb 100644 --- a/apps/ledger-live-mobile/src/newArch/features/WalletSync/components/Error/index.tsx +++ b/apps/ledger-live-mobile/src/newArch/features/WalletSync/components/Error/index.tsx @@ -7,6 +7,7 @@ type Props = { mainButton: { label: string; onPress: () => void; + outline: boolean; }; }; @@ -26,7 +27,7 @@ export function ErrorComponent({ title, desc, mainButton }: Props) { - diff --git a/apps/ledger-live-mobile/src/newArch/features/WalletSync/components/Synchronize/QrCode.tsx b/apps/ledger-live-mobile/src/newArch/features/WalletSync/components/Synchronize/QrCode.tsx index 86a46ace77d3..2c8fcc7bd687 100644 --- a/apps/ledger-live-mobile/src/newArch/features/WalletSync/components/Synchronize/QrCode.tsx +++ b/apps/ledger-live-mobile/src/newArch/features/WalletSync/components/Synchronize/QrCode.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Flex, Text } from "@ledgerhq/native-ui"; +import { Flex, InfiniteLoader, Text } from "@ledgerhq/native-ui"; import styled, { useTheme } from "styled-components/native"; import QRCode from "react-native-qrcode-svg"; import getWindowDimensions from "~/logic/getWindowDimensions"; @@ -12,7 +12,7 @@ const Italic = styled(Text)` // Won't work since we don't have inter italic font type Props = { - qrCodeValue: string; + qrCodeValue?: string | null; }; const QrCode = ({ qrCodeValue }: Props) => { @@ -79,12 +79,16 @@ const QrCode = ({ qrCodeValue }: Props) => { justifyContent={"center"} testID="ws-qr-code-displayed" > - + {qrCodeValue ? ( + + ) : ( + + )} diff --git a/apps/ledger-live-mobile/src/newArch/features/WalletSync/hooks/useLedgerSyncAnalytics.ts b/apps/ledger-live-mobile/src/newArch/features/WalletSync/hooks/useLedgerSyncAnalytics.ts index 5f84a9deeea8..1a1538e9b6cb 100644 --- a/apps/ledger-live-mobile/src/newArch/features/WalletSync/hooks/useLedgerSyncAnalytics.ts +++ b/apps/ledger-live-mobile/src/newArch/features/WalletSync/hooks/useLedgerSyncAnalytics.ts @@ -18,6 +18,7 @@ export enum AnalyticsPage { SyncWithNoKey = "Sync with no key", LedgerSyncActivated = "Ledger Sync activated", AutoRemove = "Remove current instance", + Unbacked = "Unbacked", } export enum AnalyticsFlow { diff --git a/apps/ledger-live-mobile/src/newArch/features/WalletSync/hooks/useQRCodeHost.ts b/apps/ledger-live-mobile/src/newArch/features/WalletSync/hooks/useQRCodeHost.ts index 4d39827ad4eb..fa314a197dad 100644 --- a/apps/ledger-live-mobile/src/newArch/features/WalletSync/hooks/useQRCodeHost.ts +++ b/apps/ledger-live-mobile/src/newArch/features/WalletSync/hooks/useQRCodeHost.ts @@ -1,8 +1,12 @@ import { useCallback, useEffect, useState } from "react"; import { createQRCodeHostInstance } from "@ledgerhq/trustchain/qrcode/index"; -import { InvalidDigitsError } from "@ledgerhq/trustchain/errors"; -import { useSelector } from "react-redux"; -import { trustchainSelector, memberCredentialsSelector } from "@ledgerhq/trustchain/store"; +import { InvalidDigitsError, NoTrustchainInitialized } from "@ledgerhq/trustchain/errors"; +import { useDispatch, useSelector } from "react-redux"; +import { + trustchainSelector, + memberCredentialsSelector, + setTrustchain, +} from "@ledgerhq/trustchain/store"; import { useTrustchainSdk } from "./useTrustchainSdk"; import { Options, Steps } from "../types/Activation"; import { useNavigation } from "@react-navigation/native"; @@ -11,6 +15,7 @@ import { useFeature } from "@ledgerhq/live-common/featureFlags/index"; import getWalletSyncEnvironmentParams from "@ledgerhq/live-common/walletSync/getEnvironmentParams"; import { useQueryClient } from "@tanstack/react-query"; import { QueryKey } from "./type.hooks"; +import { useInstanceName } from "./useInstanceName"; interface Props { setCurrentStep: (step: Steps) => void; @@ -23,11 +28,13 @@ export function useQRCodeHost({ setCurrentStep, currentStep, currentOption }: Pr const trustchain = useSelector(trustchainSelector); const memberCredentials = useSelector(memberCredentialsSelector); const sdk = useTrustchainSdk(); + const dispatch = useDispatch(); const featureWalletSync = useFeature("llmWalletSync"); const { trustchainApiBaseUrl } = getWalletSyncEnvironmentParams( featureWalletSync?.params?.environment, ); + const memberName = useInstanceName(); const [isLoading, setIsLoading] = useState(false); const [url, setUrl] = useState(null); @@ -37,7 +44,7 @@ export function useQRCodeHost({ setCurrentStep, currentStep, currentOption }: Pr const navigation = useNavigation(); const startQRCodeProcessing = useCallback(() => { - if (!trustchain || !memberCredentials || isLoading) return; + if (!memberCredentials || isLoading) return; setError(null); setIsLoading(true); @@ -45,29 +52,26 @@ export function useQRCodeHost({ setCurrentStep, currentStep, currentOption }: Pr trustchainApiBaseUrl, onDisplayQRCode: url => { setUrl(url); - - //TODO-remove when clearing code, used to test behavior with webTool - // eslint-disable-next-line no-console - console.log("onDisplayQRCode", url); }, onDisplayDigits: digits => { setPinCode(digits); setCurrentStep(Steps.PinDisplay); }, addMember: async member => { - await sdk.addMember(trustchain, memberCredentials, member); - return trustchain; + if (trustchain) { + await sdk.addMember(trustchain, memberCredentials, member); + return trustchain; + } + throw new NoTrustchainInitialized(); }, + memberCredentials, + memberName, + alreadyHasATrustchain: !!trustchain, }) - .catch(e => { - if (e instanceof InvalidDigitsError) { - setCurrentStep(Steps.SyncError); - return; + .then(newTrustchain => { + if (newTrustchain) { + dispatch(setTrustchain(newTrustchain)); } - setError(e); - throw e; - }) - .then(() => { queryClient.invalidateQueries({ queryKey: [QueryKey.getMembers] }); navigation.navigate(NavigatorName.WalletSync, { screen: ScreenName.WalletSyncSuccess, @@ -79,16 +83,29 @@ export function useQRCodeHost({ setCurrentStep, currentStep, currentOption }: Pr setUrl(null); setPinCode(null); setIsLoading(false); + }) + .catch(e => { + if (e instanceof InvalidDigitsError) { + setCurrentStep(Steps.SyncError); + return; + } else if (e instanceof NoTrustchainInitialized) { + setCurrentStep(Steps.UnbackedError); + return; + } + setError(e); + throw e; }); }, [ trustchain, memberCredentials, isLoading, trustchainApiBaseUrl, + memberName, setCurrentStep, sdk, queryClient, navigation, + dispatch, ]); useEffect(() => { diff --git a/apps/ledger-live-mobile/src/newArch/features/WalletSync/hooks/useSyncWithQrCode.ts b/apps/ledger-live-mobile/src/newArch/features/WalletSync/hooks/useSyncWithQrCode.ts index b3fa8aeed2dd..66a613cde89e 100644 --- a/apps/ledger-live-mobile/src/newArch/features/WalletSync/hooks/useSyncWithQrCode.ts +++ b/apps/ledger-live-mobile/src/newArch/features/WalletSync/hooks/useSyncWithQrCode.ts @@ -1,18 +1,21 @@ import { useCallback, useState } from "react"; -import { MemberCredentials } from "@ledgerhq/trustchain/types"; +import { MemberCredentials, TrustchainMember } from "@ledgerhq/trustchain/types"; import { createQRCodeCandidateInstance } from "@ledgerhq/trustchain/qrcode/index"; -import { InvalidDigitsError } from "@ledgerhq/trustchain/errors"; -import { setTrustchain } from "@ledgerhq/trustchain/store"; -import { useDispatch } from "react-redux"; +import { InvalidDigitsError, NoTrustchainInitialized } from "@ledgerhq/trustchain/errors"; +import { setTrustchain, trustchainSelector } from "@ledgerhq/trustchain/store"; +import { useDispatch, useSelector } from "react-redux"; import { useNavigation } from "@react-navigation/native"; import { Steps } from "../types/Activation"; import { NavigatorName, ScreenName } from "~/const"; import { useInstanceName } from "./useInstanceName"; +import { useTrustchainSdk } from "./useTrustchainSdk"; export const useSyncWithQrCode = () => { const [nbDigits, setDigits] = useState(null); const [input, setInput] = useState(null); const instanceName = useInstanceName(); + const trustchain = useSelector(trustchainSelector); + const sdk = useTrustchainSdk(); const navigation = useNavigation(); @@ -46,24 +49,37 @@ export const useSyncWithQrCode = () => { setCurrentStep: (step: Steps) => void, ) => { try { - const trustchain = await createQRCodeCandidateInstance({ + const newTrustchain = await createQRCodeCandidateInstance({ memberCredentials, scannedUrl: url, memberName: instanceName, onRequestQRCodeInput, + addMember: async (member: TrustchainMember) => { + if (trustchain) { + await sdk.addMember(trustchain, memberCredentials, member); + return trustchain; + } + throw new NoTrustchainInitialized(); + }, + alreadyHasATrustchain: !!trustchain, }); - dispatch(setTrustchain(trustchain)); + if (newTrustchain) { + dispatch(setTrustchain(newTrustchain)); + } onSyncFinished(); return true; } catch (e) { if (e instanceof InvalidDigitsError) { setCurrentStep(Steps.SyncError); return; + } else if (e instanceof NoTrustchainInitialized) { + setCurrentStep(Steps.UnbackedError); + return; } throw e; } }, - [instanceName, onRequestQRCodeInput, onSyncFinished, dispatch], + [instanceName, onRequestQRCodeInput, trustchain, dispatch, onSyncFinished, sdk], ); const handleSendDigits = useCallback( diff --git a/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/Activation/ActivationDrawer.tsx b/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/Activation/ActivationDrawer.tsx index 545e26869dbd..91f405e4b8a4 100644 --- a/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/Activation/ActivationDrawer.tsx +++ b/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/Activation/ActivationDrawer.tsx @@ -22,6 +22,7 @@ function View({ canGoBack, navigateToChooseSyncMethod, navigateToQrCodeMethod, + onCreateKey, onQrCodeScanned, goBackToPreviousStep, handleClose, @@ -53,6 +54,7 @@ function View({ setOption={setCurrentOption} onQrCodeScanned={onQrCodeScanned} setCurrentStep={setCurrentStep} + onCreateKey={onCreateKey} /> diff --git a/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/Activation/useActivationDrawerModel.ts b/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/Activation/useActivationDrawerModel.ts index f5763a62b96e..a11d1842401c 100644 --- a/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/Activation/useActivationDrawerModel.ts +++ b/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/Activation/useActivationDrawerModel.ts @@ -7,6 +7,10 @@ import { } from "../../hooks/useLedgerSyncAnalytics"; import { useQRCodeHost } from "../../hooks/useQRCodeHost"; import { Options } from "LLM/features/WalletSync/types/Activation"; +import { NavigatorName, ScreenName } from "~/const"; +import { useNavigation } from "@react-navigation/native"; +import { BaseComposite, StackNavigatorProps } from "~/components/RootNavigator/types/helpers"; +import { WalletSyncNavigatorStackParamList } from "~/components/RootNavigator/types/WalletSyncNavigator"; type Props = { isOpen: boolean; @@ -14,11 +18,15 @@ type Props = { handleClose: () => void; }; +type NavigationProps = BaseComposite< + StackNavigatorProps +>; + const useActivationDrawerModel = ({ isOpen, startingStep, handleClose }: Props) => { const { onClickTrack } = useLedgerSyncAnalytics(); const [currentStep, setCurrentStep] = useState(startingStep); const [currentOption, setCurrentOption] = useState(Options.SCAN); - + const navigation = useNavigation(); const hasCustomHeader = useMemo(() => currentStep === Steps.QrCodeMethod, [currentStep]); const canGoBack = useMemo( () => currentStep === Steps.ChooseSyncMethod && startingStep === Steps.Activation, @@ -61,6 +69,13 @@ const useActivationDrawerModel = ({ isOpen, startingStep, handleClose }: Props) handleClose(); }; + const onCreateKey = () => { + onClickTrack({ button: AnalyticsButton.CreateYourKey, page: AnalyticsPage.Unbacked }); + navigation.navigate(NavigatorName.WalletSync, { + screen: ScreenName.WalletSyncActivationProcess, + }); + }; + const { url, error, isLoading, pinCode } = useQRCodeHost({ setCurrentStep, currentStep, @@ -82,6 +97,7 @@ const useActivationDrawerModel = ({ isOpen, startingStep, handleClose }: Props) currentOption, setCurrentOption, setCurrentStep, + onCreateKey, }; }; diff --git a/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/Synchronize/QrCodeMethod.tsx b/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/Synchronize/QrCodeMethod.tsx index 4c76f7bf62fd..03b083db11b3 100644 --- a/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/Synchronize/QrCodeMethod.tsx +++ b/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/Synchronize/QrCodeMethod.tsx @@ -15,9 +15,15 @@ interface Props { setSelectedOption: (option: OptionsType) => void; onQrCodeScanned: (data: string) => void; currentOption: Options; + qrCodeValue?: string | null; } -const QrCodeMethod = ({ setSelectedOption, onQrCodeScanned, currentOption }: Props) => { +const QrCodeMethod = ({ + setSelectedOption, + onQrCodeScanned, + currentOption, + qrCodeValue, +}: Props) => { const { onClickTrack } = useLedgerSyncAnalytics(); const { t } = useTranslation(); @@ -44,7 +50,7 @@ const QrCodeMethod = ({ setSelectedOption, onQrCodeScanned, currentOption }: Pro return ( <> - + ); } diff --git a/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/Synchronize/SyncError.tsx b/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/Synchronize/SyncError.tsx index 9470385f1526..fb5c44da0ebd 100644 --- a/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/Synchronize/SyncError.tsx +++ b/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/Synchronize/SyncError.tsx @@ -15,6 +15,7 @@ export default function SyncError({ tryAgain }: Props) { mainButton={{ label: t("walletSync.synchronize.qrCode.pinCode.error.tryAgain"), onPress: tryAgain, + outline: true, }} /> ); diff --git a/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/Synchronize/UnbackedError.tsx b/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/Synchronize/UnbackedError.tsx new file mode 100644 index 000000000000..9554492262de --- /dev/null +++ b/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/Synchronize/UnbackedError.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { ErrorComponent } from "../../components/Error"; + +interface Props { + create: () => void; +} + +export default function UnbackedError({ create }: Props) { + const { t } = useTranslation(); + return ( + + ); +} diff --git a/apps/ledger-live-mobile/src/newArch/features/WalletSync/types/Activation.ts b/apps/ledger-live-mobile/src/newArch/features/WalletSync/types/Activation.ts index 64867abb2b7e..563db584c53b 100644 --- a/apps/ledger-live-mobile/src/newArch/features/WalletSync/types/Activation.ts +++ b/apps/ledger-live-mobile/src/newArch/features/WalletSync/types/Activation.ts @@ -14,4 +14,5 @@ export enum Steps { PinDisplay = "PinDisplay", PinInput = "PinInput", SyncError = "SyncError", + UnbackedError = "UnbackedError", } diff --git a/apps/web-tools/trustchain/components/App.tsx b/apps/web-tools/trustchain/components/App.tsx index e9a93eb2c6c3..fc601875203c 100644 --- a/apps/web-tools/trustchain/components/App.tsx +++ b/apps/web-tools/trustchain/components/App.tsx @@ -267,6 +267,7 @@ const App = () => { diff --git a/apps/web-tools/trustchain/components/AppQRCodeCandidate.tsx b/apps/web-tools/trustchain/components/AppQRCodeCandidate.tsx index ff1b68a382c4..2e749fed5dce 100644 --- a/apps/web-tools/trustchain/components/AppQRCodeCandidate.tsx +++ b/apps/web-tools/trustchain/components/AppQRCodeCandidate.tsx @@ -1,18 +1,22 @@ import React, { useCallback, useState } from "react"; import { createQRCodeCandidateInstance } from "@ledgerhq/trustchain/qrcode/index"; -import { InvalidDigitsError } from "@ledgerhq/trustchain/errors"; +import { InvalidDigitsError, NoTrustchainInitialized } from "@ledgerhq/trustchain/errors"; import { MemberCredentials, Trustchain } from "@ledgerhq/trustchain/types"; import { Actionable } from "./Actionable"; import { memberNameForPubKey } from "./IdentityManager"; import { Input } from "./Input"; +import { useTrustchainSDK } from "../context"; export function AppQRCodeCandidate({ memberCredentials, setTrustchain, + trustchain, }: { memberCredentials: MemberCredentials | null; setTrustchain: (trustchain: Trustchain | null) => void; + trustchain: Trustchain | null; }) { + const sdk = useTrustchainSDK(); const [scannedUrl, setScannedUrl] = useState(null); const [input, setInput] = useState(null); const [digits, setDigits] = useState(null); @@ -33,9 +37,19 @@ export function AppQRCodeCandidate({ scannedUrl, memberName: memberNameForPubKey(memberCredentials.pubkey), onRequestQRCodeInput, + addMember: async member => { + if (trustchain) { + await sdk.addMember(trustchain, memberCredentials, member); + return trustchain; + } + throw new NoTrustchainInitialized(); + }, + alreadyHasATrustchain: !!trustchain, }) .then(trustchain => { - setTrustchain(trustchain); + if (trustchain) { + setTrustchain(trustchain); + } return true; }) .catch(e => { @@ -54,7 +68,7 @@ export function AppQRCodeCandidate({ setInputCallback(null); }); }, - [onRequestQRCodeInput, setTrustchain], + [onRequestQRCodeInput, sdk, setTrustchain, trustchain], ); const handleSendDigits = useCallback( diff --git a/apps/web-tools/trustchain/components/AppQRCodeHost.tsx b/apps/web-tools/trustchain/components/AppQRCodeHost.tsx index 55cad8bb0855..29fa9747c355 100644 --- a/apps/web-tools/trustchain/components/AppQRCodeHost.tsx +++ b/apps/web-tools/trustchain/components/AppQRCodeHost.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useState } from "react"; import { createQRCodeHostInstance } from "@ledgerhq/trustchain/qrcode/index"; -import { InvalidDigitsError } from "@ledgerhq/trustchain/errors"; +import { InvalidDigitsError, NoTrustchainInitialized } from "@ledgerhq/trustchain/errors"; import { MemberCredentials, Trustchain } from "@ledgerhq/trustchain/types"; import { RenderActionable } from "./Actionable"; import QRCode from "./QRCode"; @@ -30,9 +30,15 @@ export function AppQRCodeHost({ setDigits(digits); }, addMember: async member => { - await sdk.addMember(trustchain, memberCredentials, member); - return trustchain; + if (trustchain) { + await sdk.addMember(trustchain, memberCredentials, member); + return trustchain; + } + throw new NoTrustchainInitialized(); }, + memberCredentials, + memberName: "WebTools", + alreadyHasATrustchain: !!trustchain, }) .catch(e => { if (e instanceof InvalidDigitsError) { diff --git a/libs/trustchain/src/errors.ts b/libs/trustchain/src/errors.ts index 9bb8eac49168..fe9757d53aa1 100644 --- a/libs/trustchain/src/errors.ts +++ b/libs/trustchain/src/errors.ts @@ -6,3 +6,7 @@ export const TrustchainEjected = createCustomErrorClass("TrustchainEjected"); export const TrustchainNotAllowed = createCustomErrorClass("TrustchainNotAllowed"); export const TrustchainOutdated = createCustomErrorClass("TrustchainOutdated"); export const TrustchainNotFound = createCustomErrorClass("TrustchainNotFound"); +export const NoTrustchainInitialized = createCustomErrorClass("NoTrustchainInitialized"); +export const TrustchainsAlreadyInitialized = createCustomErrorClass( + "TrustchainsAlreadyInitialized", +); diff --git a/libs/trustchain/src/qrcode/index.test.ts b/libs/trustchain/src/qrcode/index.test.ts index 21798a907581..2861b0e3bcf9 100644 --- a/libs/trustchain/src/qrcode/index.test.ts +++ b/libs/trustchain/src/qrcode/index.test.ts @@ -39,6 +39,7 @@ describe("Trustchain QR Code", () => { }; const addMember = jest.fn(() => Promise.resolve(trustchain)); const memberCredentials = convertKeyPairToLiveCredentials(await crypto.randomKeypair()); + const memberName = "foo"; let scannedUrlResolve: (url: string) => void; const scannedUrlPromise = new Promise(resolve => { @@ -56,14 +57,18 @@ describe("Trustchain QR Code", () => { onDisplayQRCode, onDisplayDigits, addMember, + memberCredentials, + memberName, + alreadyHasATrustchain: !!trustchain, }); const scannedUrl = await scannedUrlPromise; - const memberName = "foo"; const candidateP = createQRCodeCandidateInstance({ memberCredentials, memberName, + alreadyHasATrustchain: false, + addMember, scannedUrl, onRequestQRCodeInput, }); diff --git a/libs/trustchain/src/qrcode/index.ts b/libs/trustchain/src/qrcode/index.ts index d866ac4f46cb..7b4f041ae272 100644 --- a/libs/trustchain/src/qrcode/index.ts +++ b/libs/trustchain/src/qrcode/index.ts @@ -3,13 +3,94 @@ import WebSocket from "isomorphic-ws"; import { MemberCredentials, Trustchain, TrustchainMember } from "../types"; import { makeCipher, makeMessageCipher } from "./cipher"; import { Message } from "./types"; -import { InvalidDigitsError } from "../errors"; +import { + InvalidDigitsError, + NoTrustchainInitialized, + TrustchainsAlreadyInitialized, +} from "../errors"; import { log } from "@ledgerhq/logs"; const version = 1; const CLOSE_TIMEOUT = 100; // just enough time for the onerror to appear before onclose +const commonSwitch = async ({ + data, + cipher, + addMember, + send, + publisher, + resolve, + memberCredentials, + memberName, + reject, + ws, + setFinished, + alreadyHasATrustchain, +}) => { + switch (data.message) { + case "TrustchainShareCredential": { + if (!alreadyHasATrustchain) { + const payload = { + type: "UNEXPECTED_SHARE_CREDENTIAL", + message: "unexpected share credential", + }; + send({ version, publisher, message: "Failure", payload }); + throw new NoTrustchainInitialized("unexpected share credential"); + } + setFinished(true); + if (!cipher) { + throw new Error("sessionEncryptionKey not set"); + } + const { id, name } = await cipher.decryptMessage(data); + const trustchain = await addMember({ id, name, permissions: Permissions.OWNER }); + const payload = await cipher.encryptMessagePayload({ trustchain }); + send({ version, publisher, message: "TrustchainAddedMember", payload }); + resolve(); + break; + } + + case "TrustchainRequestCredential": { + if (alreadyHasATrustchain) { + const payload = { + type: "UNEXPECTED_REQUEST_CREDENTIAL", + message: "unexpected request credential", + }; + send({ version, publisher, message: "Failure", payload }); + throw new TrustchainsAlreadyInitialized("unexpected request credential"); + } + const payload = await cipher.encryptMessagePayload({ + id: memberCredentials.pubkey, + name: memberName, + }); + send({ version, publisher, message: "TrustchainShareCredential", payload }); + break; + } + case "TrustchainAddedMember": { + setFinished(true); + const { trustchain } = await cipher.decryptMessage(data); + resolve(trustchain); + ws.close(); + break; + } + case "Failure": { + setFinished(true); + log("trustchain/qrcode", "Failure", { data }); + const error = fromErrorMessage(data.payload); + reject(error); + ws.close(); + break; + } + case "HandshakeChallenge": + case "HandshakeCompletionSucceeded": + case "InitiateHandshake": + case "CompleteHandshakeChallenge": + break; + default: + throw new Error("unexpected message"); + } +}; + /** * establish a channel to be able to add a member to the trustchain after displaying the QR Code * @returns a promise that resolves when this is done @@ -19,6 +100,9 @@ export async function createQRCodeHostInstance({ onDisplayQRCode, onDisplayDigits, addMember, + memberCredentials, + memberName, + alreadyHasATrustchain, }: { /** * the base URL of the trustchain API @@ -36,7 +120,19 @@ export async function createQRCodeHostInstance({ * this function will need to using the TrustchainSDK (and use sdk.addMember) */ addMember: (member: TrustchainMember) => Promise; -}): Promise { + /** + * the client credentials of the instance (given by TrustchainSDK) + */ + memberCredentials: MemberCredentials; + /** + * the name of the member + */ + memberName: string; + /** + * if the member already has a trustchain, this will be true + */ + alreadyHasATrustchain: boolean; +}): Promise { const ephemeralKey = await crypto.randomKeypair(); const publisher = crypto.to_hex(ephemeralKey.publicKey); const url = `${trustchainApiBaseUrl.replace("http", "ws")}/v1/qr?host=${publisher}`; @@ -49,6 +145,7 @@ export async function createQRCodeHostInstance({ let cipher: ReturnType | undefined; let expectedDigits: string | undefined; let finished = false; + const setFinished = newValue => (finished = newValue); onDisplayQRCode(url); return new Promise((resolve, reject) => { @@ -96,30 +193,23 @@ export async function createQRCodeHostInstance({ send({ version, publisher, message: "HandshakeCompletionSucceeded", payload }); break; } - case "TrustchainShareCredential": { - finished = true; - if (!cipher) { - throw new Error("sessionEncryptionKey not set"); - } - const { id, name } = await cipher.decryptMessage(data); - const trustchain = await addMember({ id, name, permissions: Permissions.OWNER }); - const payload = await cipher.encryptMessagePayload({ trustchain }); - send({ version, publisher, message: "TrustchainAddedMember", payload }); - resolve(); - break; - } - case "Failure": { - finished = true; - const error = fromErrorMessage(data.payload); - reject(error); - ws.close(); - break; - } - default: { - throw new Error("unexpected message"); - } } + await commonSwitch({ + data, + cipher, + addMember, + send, + publisher, + resolve, + memberCredentials, + memberName, + reject, + ws, + setFinished, + alreadyHasATrustchain, + }); } catch (e) { + console.error("socket error", e); ws.close(); reject(e); } @@ -134,6 +224,8 @@ export async function createQRCodeHostInstance({ export async function createQRCodeCandidateInstance({ memberCredentials, memberName, + addMember, + alreadyHasATrustchain, scannedUrl, onRequestQRCodeInput, }: { @@ -145,6 +237,14 @@ export async function createQRCodeCandidateInstance({ * the name of the member */ memberName: string; + /** + * if the member already has a trustchain, this will be true + */ + alreadyHasATrustchain: boolean; + /** + * this function will need to using the TrustchainSDK (and use sdk.addMember) + */ + addMember: (member: TrustchainMember) => Promise; /** * the scanned URL that contains the host public key */ @@ -160,7 +260,7 @@ export async function createQRCodeCandidateInstance({ }, callback: (digits: string) => void, ) => void; -}): Promise { +}): Promise { const m = scannedUrl.match(/host=([0-9A-Fa-f]+)/); if (!m) { throw new Error("invalid scannedUrl"); @@ -175,6 +275,7 @@ export async function createQRCodeCandidateInstance({ ws.send(JSON.stringify(message)); } let finished = false; + const setFinished = newValue => (finished = newValue); return new Promise((resolve, reject) => { ws.addEventListener("close", () => { @@ -197,32 +298,35 @@ export async function createQRCodeCandidateInstance({ break; } case "HandshakeCompletionSucceeded": { - const payload = await cipher.encryptMessagePayload({ - id: memberCredentials.pubkey, - name: memberName, - }); - send({ version, publisher, message: "TrustchainShareCredential", payload }); - break; - } - case "TrustchainAddedMember": { - finished = true; - const { trustchain } = await cipher.decryptMessage(data); - resolve(trustchain); - ws.close(); - break; - } - case "Failure": { - finished = true; - log("trustchain/qrcode", "Failure", { data }); - const error = fromErrorMessage(data.payload); - reject(error); - ws.close(); + if (alreadyHasATrustchain) { + const payload = await cipher.encryptMessagePayload({}); + send({ version, publisher, message: "TrustchainRequestCredential", payload }); + } else { + const payload = await cipher.encryptMessagePayload({ + id: memberCredentials.pubkey, + name: memberName, + }); + send({ version, publisher, message: "TrustchainShareCredential", payload }); + } break; } - default: - throw new Error("unexpected message"); } + await commonSwitch({ + data, + cipher, + addMember, + send, + publisher, + resolve, + memberCredentials, + memberName, + reject, + ws, + setFinished, + alreadyHasATrustchain, + }); } catch (e) { + console.error("socket error", e); ws.close(); reject(e); } @@ -268,6 +372,12 @@ function fromErrorMessage(payload: { message: string; type: string }): Error { if (payload.type === "HANDSHAKE_COMPLETION_FAILED") { throw new InvalidDigitsError(payload.message); } + if (payload.type === "UNEXPECTED_SHARE_CREDENTIAL") { + throw new NoTrustchainInitialized(payload.message); + } + if (payload.type === "UNEXPECTED_REQUEST_CREDENTIAL") { + throw new TrustchainsAlreadyInitialized(payload.message); + } const error = new Error(payload.message); error.name = "TrustchainQRCode-" + payload.type; return error; diff --git a/libs/trustchain/src/qrcode/types.ts b/libs/trustchain/src/qrcode/types.ts index 1e633c1c4311..84a37e573f1e 100644 --- a/libs/trustchain/src/qrcode/types.ts +++ b/libs/trustchain/src/qrcode/types.ts @@ -35,6 +35,12 @@ export type Message = message: "HandshakeCompletionSucceeded"; payload: Encrypted>; } + | { + version: number; + publisher: string; + message: "TrustchainRequestCredential"; + payload: Encrypted>; + } | { version: number; publisher: string;