diff --git a/apps/extension/src/ui/domains/Account/AccountAdd/AccountAddLedger/AddLedgerSelectNetwork.tsx b/apps/extension/src/ui/domains/Account/AccountAdd/AccountAddLedger/AddLedgerSelectNetwork.tsx index c162cafc6e..82bf45b0de 100644 --- a/apps/extension/src/ui/domains/Account/AccountAdd/AccountAddLedger/AddLedgerSelectNetwork.tsx +++ b/apps/extension/src/ui/domains/Account/AccountAdd/AccountAddLedger/AddLedgerSelectNetwork.tsx @@ -271,7 +271,7 @@ export const AddLedgerSelectNetwork = () => { )} diff --git a/apps/extension/src/ui/domains/Account/AccountAdd/AccountAddLedger/Shared/ConnectLedgerBase.tsx b/apps/extension/src/ui/domains/Account/AccountAdd/AccountAddLedger/Shared/ConnectLedgerBase.tsx new file mode 100644 index 0000000000..b8653064f3 --- /dev/null +++ b/apps/extension/src/ui/domains/Account/AccountAdd/AccountAddLedger/Shared/ConnectLedgerBase.tsx @@ -0,0 +1,81 @@ +import { log } from "extension-shared" +import { FC, useCallback, useEffect, useRef, useState } from "react" +import { useTranslation } from "react-i18next" + +import { Spacer } from "@talisman/components/Spacer" +import { + LedgerConnectionStatus, + LedgerConnectionStatusProps, +} from "@ui/domains/Account/LedgerConnectionStatus" +import { getCustomTalismanLedgerError } from "@ui/hooks/ledger/errors" + +type ConnectLedgerBaseProps = { + appName: string + isReadyCheck: () => Promise + onReadyChanged: (ready: boolean) => void + className?: string +} + +export const ConnectLedgerBase: FC = ({ + appName, + isReadyCheck, + onReadyChanged, + className, +}) => { + const { t } = useTranslation("admin") + + // flag to prevents double connect attempt in dev mode + const refIsBusy = useRef(false) + + const [connectionStatus, setConnectionStatus] = useState({ + status: "connecting", + message: t("Connecting to Ledger..."), + }) + + const connect = useCallback(async () => { + if (refIsBusy.current) return + refIsBusy.current = true + + try { + onReadyChanged?.(false) + setConnectionStatus({ + status: "connecting", + message: t("Connecting to Ledger..."), + }) + + await isReadyCheck() + + setConnectionStatus({ + status: "ready", + message: t("Successfully connected to Ledger."), + }) + onReadyChanged?.(true) + } catch (err) { + const error = getCustomTalismanLedgerError(err) + log.error("ConnectLedgerSubstrateGeneric", { error }) + setConnectionStatus({ + status: "error", + message: error.message, + onRetryClick: connect, + }) + } finally { + refIsBusy.current = false + } + }, [isReadyCheck, onReadyChanged, t]) + + useEffect(() => { + connect() + }, [connect, isReadyCheck, onReadyChanged]) + + return ( +
+
+ {t("Connect and unlock your Ledger, then open the {{appName}} app on your Ledger.", { + appName, + })} +
+ + {!!connectionStatus && } +
+ ) +} diff --git a/apps/extension/src/ui/domains/Account/AccountAdd/AccountAddLedger/Shared/ConnectLedgerEthereum.tsx b/apps/extension/src/ui/domains/Account/AccountAdd/AccountAddLedger/Shared/ConnectLedgerEthereum.tsx index 882df81978..f7ab854dee 100644 --- a/apps/extension/src/ui/domains/Account/AccountAdd/AccountAddLedger/Shared/ConnectLedgerEthereum.tsx +++ b/apps/extension/src/ui/domains/Account/AccountAdd/AccountAddLedger/Shared/ConnectLedgerEthereum.tsx @@ -1,38 +1,27 @@ -import { useEffect } from "react" -import { Trans, useTranslation } from "react-i18next" +import { getEthLedgerDerivationPath } from "extension-core" +import { FC, useCallback } from "react" -import { Spacer } from "@talisman/components/Spacer" -import { LedgerConnectionStatus } from "@ui/domains/Account/LedgerConnectionStatus" import { useLedgerEthereum } from "@ui/hooks/ledger/useLedgerEthereum" -export const ConnectLedgerEthereum = ({ - onReadyChanged, - className, -}: { - onReadyChanged?: (ready: boolean) => void - className?: string -}) => { - const { t } = useTranslation("admin") - const ledger = useLedgerEthereum(true) +import { ConnectLedgerBase } from "./ConnectLedgerBase" - useEffect(() => { - onReadyChanged?.(ledger.isReady) +export const ConnectLedgerEthereum: FC<{ + onReadyChanged: (ready: boolean) => void + className?: string +}> = ({ onReadyChanged, className }) => { + const { getAddress } = useLedgerEthereum() - return () => { - onReadyChanged?.(false) - } - }, [ledger.isReady, onReadyChanged]) + const isReadyCheck = useCallback(() => { + const derivationPath = getEthLedgerDerivationPath("LedgerLive") + return getAddress(derivationPath) + }, [getAddress]) return ( -
-
- - Connect and unlock your Ledger, then open the Ethereum{" "} - app on your Ledger. - -
- - -
+ ) } diff --git a/apps/extension/src/ui/domains/Account/AccountAdd/AccountAddLedger/Shared/ConnectLedgerSubstrateGeneric.tsx b/apps/extension/src/ui/domains/Account/AccountAdd/AccountAddLedger/Shared/ConnectLedgerSubstrateGeneric.tsx index 35d4753146..2361deca43 100644 --- a/apps/extension/src/ui/domains/Account/AccountAdd/AccountAddLedger/Shared/ConnectLedgerSubstrateGeneric.tsx +++ b/apps/extension/src/ui/domains/Account/AccountAdd/AccountAddLedger/Shared/ConnectLedgerSubstrateGeneric.tsx @@ -1,43 +1,30 @@ -import { FC, useEffect } from "react" -import { useTranslation } from "react-i18next" +import { FC, useCallback } from "react" -import { Spacer } from "@talisman/components/Spacer" -import { LedgerConnectionStatus } from "@ui/domains/Account/LedgerConnectionStatus" +import { getPolkadotLedgerDerivationPath } from "@ui/hooks/ledger/common" import { useLedgerSubstrateAppByName } from "@ui/hooks/ledger/useLedgerSubstrateApp" import { useLedgerSubstrateGeneric } from "@ui/hooks/ledger/useLedgerSubstrateGeneric" -type ConnectLedgerSubstrateGenericProps = { - onReadyChanged?: (ready: boolean) => void - className?: string - appName?: string | null -} +import { ConnectLedgerBase } from "./ConnectLedgerBase" -export const ConnectLedgerSubstrateGeneric: FC = ({ - onReadyChanged, - className, - appName, -}) => { - const app = useLedgerSubstrateAppByName(appName) - const ledger = useLedgerSubstrateGeneric({ persist: true, app }) - const { t } = useTranslation("admin") - - useEffect(() => { - onReadyChanged?.(ledger.isReady) +export const ConnectLedgerSubstrateGeneric: FC<{ + onReadyChanged: (ready: boolean) => void + className?: string + legacyAppName?: string | null +}> = ({ onReadyChanged, className, legacyAppName }) => { + const legacyApp = useLedgerSubstrateAppByName(legacyAppName) + const { getAddress } = useLedgerSubstrateGeneric({ legacyApp }) - return () => { - onReadyChanged?.(false) - } - }, [ledger.isReady, onReadyChanged]) + const isReadyCheck = useCallback(() => { + const derivationPath = getPolkadotLedgerDerivationPath({ legacyApp }) + return getAddress(derivationPath) + }, [getAddress, legacyApp]) return ( -
-
- {t("Connect and unlock your Ledger, then open the {{appName}} app on your Ledger.", { - appName: app ? "Polkadot Migration" : "Polkadot", - })} -
- - -
+ ) } diff --git a/apps/extension/src/ui/domains/Account/AccountAdd/AccountAddLedger/Shared/ConnectLedgerSubstrateLegacy.tsx b/apps/extension/src/ui/domains/Account/AccountAdd/AccountAddLedger/Shared/ConnectLedgerSubstrateLegacy.tsx index e677f9e8a2..0e0747acd9 100644 --- a/apps/extension/src/ui/domains/Account/AccountAdd/AccountAddLedger/Shared/ConnectLedgerSubstrateLegacy.tsx +++ b/apps/extension/src/ui/domains/Account/AccountAdd/AccountAddLedger/Shared/ConnectLedgerSubstrateLegacy.tsx @@ -1,56 +1,26 @@ -import { FC, useEffect } from "react" -import { Trans, useTranslation } from "react-i18next" +import { FC, useCallback } from "react" -import { Spacer } from "@talisman/components/Spacer" -import { LedgerConnectionStatus } from "@ui/domains/Account/LedgerConnectionStatus" -import { useLedgerSubstrateAppByChain } from "@ui/hooks/ledger/useLedgerSubstrateApp" import { useLedgerSubstrateLegacy } from "@ui/hooks/ledger/useLedgerSubstrateLegacy" -import { useChain, useToken } from "@ui/state" +import { useChain } from "@ui/state" -type ConnectLedgerSubstrateLegacyProps = { +import { ConnectLedgerBase } from "./ConnectLedgerBase" + +export const ConnectLedgerSubstrateLegacy: FC<{ chainId: string - onReadyChanged?: (ready: boolean) => void + onReadyChanged: (ready: boolean) => void className?: string -} - -export const ConnectLedgerSubstrateLegacy: FC = ({ - chainId, - onReadyChanged, - className, -}) => { +}> = ({ chainId, onReadyChanged, className }) => { const chain = useChain(chainId) - const token = useToken(chain?.nativeToken?.id) - const ledger = useLedgerSubstrateLegacy(chain?.genesisHash, true) - const app = useLedgerSubstrateAppByChain(chain) - const { t } = useTranslation("admin") - - useEffect(() => { - onReadyChanged?.(ledger.isReady) - - return () => { - onReadyChanged?.(false) - } - }, [ledger.isReady, onReadyChanged]) + const { app, getAddress } = useLedgerSubstrateLegacy(chain?.genesisHash) - if (!app) return null + const isReadyCheck = useCallback(() => getAddress(0, 0), [getAddress]) return ( -
-
- - {app.name + (token?.symbol ? ` (${token.symbol})` : "")} - - ), - }} - defaults="Connect and unlock your Ledger, then open the app on your Ledger." - /> -
- - -
+ ) } diff --git a/apps/extension/src/ui/domains/Account/LedgerConnectionStatus.tsx b/apps/extension/src/ui/domains/Account/LedgerConnectionStatus.tsx index 60fe38cae0..befb8bbc7b 100644 --- a/apps/extension/src/ui/domains/Account/LedgerConnectionStatus.tsx +++ b/apps/extension/src/ui/domains/Account/LedgerConnectionStatus.tsx @@ -1,16 +1,14 @@ import { CheckCircleIcon, LoaderIcon, XCircleIcon } from "@talismn/icons" import { classNames } from "@talismn/util" -import { FC, ReactNode, useEffect, useState } from "react" +import { useTranslation } from "react-i18next" import { LedgerStatus } from "@ui/hooks/ledger/common" export type LedgerConnectionStatusProps = { status: LedgerStatus message: string - requiresManualRetry?: boolean - hideOnSuccess?: boolean className?: string - refresh: () => void + onRetryClick?: () => void } const wrapStrong = (text: string) => { @@ -31,45 +29,22 @@ const wrapStrong = (text: string) => { }) } -const Container: FC<{ className?: string; onClick?: () => void; children?: ReactNode }> = ({ - className, - onClick, - children, -}) => { - if (onClick) - return ( - - ) - else return
{children}
-} - export const LedgerConnectionStatus = ({ status, message, - requiresManualRetry, - hideOnSuccess = false, className, - refresh, + onRetryClick, }: LedgerConnectionStatusProps) => { - const [hide, setHide] = useState(false) - - useEffect(() => { - if (status === "ready" && hideOnSuccess) setTimeout(() => setHide(true), 1000) - }, [status, hideOnSuccess]) + const { t } = useTranslation() if (!status || status === "unknown") return null return ( - {status === "ready" && ( @@ -83,7 +58,16 @@ export const LedgerConnectionStatus = ({ {status === "connecting" && ( )} -
{wrapStrong(message)}
-
+
{wrapStrong(message)}
+ {!!onRetryClick && ( + + )} + ) } diff --git a/apps/extension/src/ui/domains/Account/LedgerEthereumAccountPicker.tsx b/apps/extension/src/ui/domains/Account/LedgerEthereumAccountPicker.tsx index cd548d7ab5..74c179bd94 100644 --- a/apps/extension/src/ui/domains/Account/LedgerEthereumAccountPicker.tsx +++ b/apps/extension/src/ui/domains/Account/LedgerEthereumAccountPicker.tsx @@ -1,15 +1,17 @@ -import { FC, useCallback, useEffect, useMemo, useState } from "react" +import { FC, useCallback, useEffect, useMemo, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { getEthLedgerDerivationPath, LedgerEthDerivationPathType } from "@extension/core" -import { DEBUG } from "@extension/shared" +import { log } from "@extension/shared" import { convertAddress } from "@talisman/util/convertAddress" import { LedgerAccountDefEthereum } from "@ui/domains/Account/AccountAdd/AccountAddLedger/context" +import { getCustomTalismanLedgerError } from "@ui/hooks/ledger/errors" import { useLedgerEthereum } from "@ui/hooks/ledger/useLedgerEthereum" import { AccountImportDef, useAccountImportBalances } from "@ui/hooks/useAccountImportBalances" import { useAccounts, useEvmNetworks } from "@ui/state" import { DerivedAccountBase, DerivedAccountPickerBase } from "./DerivedAccountPickerBase" +import { LedgerConnectionStatus, LedgerConnectionStatusProps } from "./LedgerConnectionStatus" const useLedgerEthereumAccounts = ( name: string, @@ -23,50 +25,87 @@ const useLedgerEthereumAccounts = ( const [derivedAccounts, setDerivedAccounts] = useState<(LedgerEthereumAccount | undefined)[]>([ ...Array(itemsPerPage), ]) - const [isBusy, setIsBusy] = useState() - const [error, setError] = useState() + + const refIsBusy = useRef(false) + + const { getAddress } = useLedgerEthereum() + + const [connectionStatus, setConnectionStatus] = useState({ + status: "connecting", + message: t("Fetching account addresses..."), + }) + + // derivation path => address cache, used when going back to previous page + const refAddressCache = useRef>({}) + useEffect(() => { + refAddressCache.current = {} // reset if app changes + }, []) + const evmNetworks = useEvmNetworks({ activeOnly: true, includeTestnets: false }) const withBalances = useMemo(() => !!evmNetworks.length, [evmNetworks]) - const { isReady, ledger, ...connectionStatus } = useLedgerEthereum() + // keep page index as ref to allow for cancelling current page load when changing page + const refPageIndex = useRef(pageIndex) + useEffect(() => { + refPageIndex.current = pageIndex + }, [pageIndex]) - const loadPage = useCallback(async () => { - if (!ledger || !isReady) return + const loadPage = useCallback( + async (pageIndex: number, force = false) => { + if (!force && refIsBusy.current) return + refIsBusy.current = true - setError(undefined) - setIsBusy(true) - const skip = pageIndex * itemsPerPage + // setError(undefined) + setConnectionStatus({ + status: "connecting", + message: t("Fetching account addresses..."), + }) - try { - const newAccounts: (LedgerEthereumAccount | undefined)[] = [...Array(itemsPerPage)] + const skip = pageIndex * itemsPerPage - for (let i = 0; i < itemsPerPage; i++) { - const accountIndex = skip + i - const path = getEthLedgerDerivationPath(derivationPathType, accountIndex) + try { + const newAccounts: (LedgerEthereumAccount | undefined)[] = [...Array(itemsPerPage)] + setDerivedAccounts([...newAccounts]) - const { address } = await ledger.getAddress(path) + for (let i = 0; i < itemsPerPage; i++) { + if (refPageIndex.current !== pageIndex) return loadPage(refPageIndex.current, true) - newAccounts[i] = { - accountIndex, - name: `${name.trim()} ${accountIndex + 1}`, - path, - address, - } as LedgerEthereumAccount + const accountIndex = skip + i + const path = getEthLedgerDerivationPath(derivationPathType, accountIndex) - setDerivedAccounts([...newAccounts]) + const { address } = refAddressCache.current[path] ?? (await getAddress(path)) + if (refPageIndex.current !== pageIndex) return loadPage(refPageIndex.current, true) + if (!address) throw new Error("Unable to get address") + refAddressCache.current[path] = { address } + + newAccounts[i] = { + accountIndex, + name: `${name.trim()} ${accountIndex + 1}`, + path, + address, + } as LedgerEthereumAccount + + setDerivedAccounts([...newAccounts]) + } + + setConnectionStatus({ + status: "ready", + message: t("Ledger is ready."), + }) + } catch (err) { + const error = getCustomTalismanLedgerError(err) + log.error("Failed to load page", { err }) + setConnectionStatus({ + status: "error", + message: error.message, + onRetryClick: () => loadPage(pageIndex), + }) + } finally { + refIsBusy.current = false } - } catch (err) { - const error = err as Error & { statusCode?: number } - // eslint-disable-next-line no-console - DEBUG && console.error(error.message, err) - if (error.message?.toLowerCase().includes("busy")) setError(t("Ledger is busy")) - else if (error.message?.toLowerCase().includes("disconnected")) - setError(t("Ledger is disconnected")) - else if (error.statusCode === 27404) setError(t("Ledger is locked")) - else setError(t("Failed to connect to Ledger")) - } - setIsBusy(false) - }, [derivationPathType, isReady, itemsPerPage, ledger, name, pageIndex, t]) + }, + [derivationPathType, getAddress, itemsPerPage, name, t], + ) // start fetching balances only once all accounts are loaded to prevent recreating subscription 5 times const balanceDefs = useMemo( @@ -111,14 +150,12 @@ const useLedgerEthereumAccounts = ( useEffect(() => { // refresh on every page change - loadPage() - }, [loadPage]) + loadPage(pageIndex) + }, [loadPage, pageIndex]) return { accounts, withBalances, - isBusy, - error, connectionStatus, } } @@ -139,7 +176,7 @@ export const LedgerEthereumAccountPicker: FC = const itemsPerPage = 5 const [pageIndex, setPageIndex] = useState(0) const [selectedAccounts, setSelectedAccounts] = useState([]) - const { accounts, error, isBusy, withBalances } = useLedgerEthereumAccounts( + const { accounts, withBalances, connectionStatus } = useLedgerEthereumAccounts( name, derivationPathType, selectedAccounts, @@ -166,17 +203,18 @@ export const LedgerEthereumAccountPicker: FC = return ( <> +
+ +
0} - disablePaging={isBusy} onAccountClick={handleToggleAccount} onPagerFirstClick={handlePageFirst} onPagerPrevClick={handlePagePrev} onPagerNextClick={handlePageNext} /> -

{error}

) } diff --git a/apps/extension/src/ui/domains/Account/LedgerSubstrateGenericAccountPicker.tsx b/apps/extension/src/ui/domains/Account/LedgerSubstrateGenericAccountPicker.tsx index efe2f5d077..2c8109f87b 100644 --- a/apps/extension/src/ui/domains/Account/LedgerSubstrateGenericAccountPicker.tsx +++ b/apps/extension/src/ui/domains/Account/LedgerSubstrateGenericAccountPicker.tsx @@ -1,6 +1,6 @@ import { InfoIcon } from "@talismn/icons" -import { classNames } from "@talismn/util" -import { SubstrateAppParams } from "@zondax/ledger-substrate/dist/common" +import { classNames, encodeAnyAddress } from "@talismn/util" +import { GenericeResponseAddress, SubstrateAppParams } from "@zondax/ledger-substrate/dist/common" import { AccountJsonAny, ChainId, SubstrateLedgerAppType } from "extension-core" import { log } from "extension-shared" import { @@ -19,6 +19,7 @@ import { FormFieldContainer, FormFieldInputText, Tooltip, TooltipTrigger } from import { convertAddress } from "@talisman/util/convertAddress" import { LedgerAccountDefSubstrateGeneric } from "@ui/domains/Account/AccountAdd/AccountAddLedger/context" import { getPolkadotLedgerDerivationPath } from "@ui/hooks/ledger/common" +import { getCustomTalismanLedgerError, TalismanLedgerError } from "@ui/hooks/ledger/errors" import { useLedgerSubstrateGeneric } from "@ui/hooks/ledger/useLedgerSubstrateGeneric" import { AccountImportDef, useAccountImportBalances } from "@ui/hooks/useAccountImportBalances" import { useAccounts, useChain, useChains } from "@ui/state" @@ -28,13 +29,13 @@ import { AccountIcon } from "./AccountIcon" import { Address } from "./Address" import { BalancesSummaryTooltipContent } from "./BalancesSummaryTooltipContent" import { DerivedAccountBase, DerivedAccountPickerBase } from "./DerivedAccountPickerBase" -import { LedgerConnectionStatus } from "./LedgerConnectionStatus" +import { LedgerConnectionStatus, LedgerConnectionStatusProps } from "./LedgerConnectionStatus" const useLedgerSubstrateGenericAccounts = ( selectedAccounts: LedgerAccountDefSubstrateGeneric[], pageIndex: number, itemsPerPage: number, - app?: SubstrateAppParams | null, + legacyApp?: SubstrateAppParams | null, ) => { const walletAccounts = useAccounts() const { t } = useTranslation() @@ -42,53 +43,98 @@ const useLedgerSubstrateGenericAccounts = ( const [ledgerAccounts, setLedgerAccounts] = useState< (LedgerSubstrateGenericAccount | undefined)[] >([...Array(itemsPerPage)]) - const [isBusy, setIsBusy] = useState(false) - const [error, setError] = useState() + const refIsBusy = useRef(false) + + // derivation path => address cache, used when going back to previous page + const refAddressCache = useRef>({}) + useEffect(() => { + refAddressCache.current = {} // reset if app changes + }, [legacyApp]) + const chains = useChains({ activeOnly: true, includeTestnets: false }) const withBalances = useMemo(() => chains.some((chain) => chain.hasCheckMetadataHash), [chains]) - const { isReady, ledger, getAddress, ...connectionStatus } = useLedgerSubstrateGeneric({ app }) + const { getAddress } = useLedgerSubstrateGeneric({ legacyApp }) - const loadPage = useCallback(async () => { - if (!ledger || !isReady) return + const [connectionStatus, setConnectionStatus] = useState({ + status: "connecting", + message: t("Fetching account addresses..."), + }) - setIsBusy(true) - setError(undefined) + // keep page index as ref to allow for cancelling current page load when changing page + const refPageIndex = useRef(pageIndex) + useEffect(() => { + refPageIndex.current = pageIndex + }, [pageIndex]) - const skip = pageIndex * itemsPerPage + const loadPage = useCallback( + async (pageIndex: number, force = false) => { + if (!force && refIsBusy.current) return + refIsBusy.current = true - try { - const newAccounts: (LedgerSubstrateGenericAccount | undefined)[] = [...Array(itemsPerPage)] + // setError(undefined) + setConnectionStatus({ + status: "connecting", + message: t("Fetching account addresses..."), + }) - for (let i = 0; i < itemsPerPage; i++) { - const accountIndex = skip + i - const addressOffset = 0 + const skip = pageIndex * itemsPerPage - const path = getPolkadotLedgerDerivationPath({ accountIndex, addressOffset, app }) + try { + const newAccounts: (LedgerSubstrateGenericAccount | undefined)[] = [...Array(itemsPerPage)] + setLedgerAccounts([...newAccounts]) - const genericAddress = await getAddress(path, app?.ss58_addr_type ?? 42) - if (!genericAddress) throw new Error("Unable to get address") + for (let i = 0; i < itemsPerPage; i++) { + if (refPageIndex.current !== pageIndex) return loadPage(refPageIndex.current, true) + + const accountIndex = skip + i + const addressOffset = 0 + + const path = getPolkadotLedgerDerivationPath({ + accountIndex, + addressOffset, + legacyApp: legacyApp, + }) + + const genericAddress = + refAddressCache.current[path] ?? + (await getAddress(path, legacyApp?.ss58_addr_type ?? 42)) + if (refPageIndex.current !== pageIndex) return loadPage(refPageIndex.current, true) + if (!genericAddress) throw new Error("Unable to get address") + refAddressCache.current[path] = genericAddress + + newAccounts[i] = { + accountIndex, + addressOffset, + address: genericAddress.address, + name: t("Ledger {{appName}} {{accountIndex}}", { + appName: legacyApp?.name ?? "Polkadot", + accountIndex: accountIndex + 1, + }), + migrationAppName: legacyApp?.name, + } as LedgerSubstrateGenericAccount - newAccounts[i] = { - accountIndex, - addressOffset, - address: genericAddress.address, - name: t("Ledger {{appName}} {{accountIndex}}", { - appName: app?.name ?? "Polkadot", - accountIndex: accountIndex + 1, - }), - migrationAppName: app?.name, - } as LedgerSubstrateGenericAccount + setLedgerAccounts([...newAccounts]) + } - setLedgerAccounts([...newAccounts]) + setConnectionStatus({ + status: "ready", + message: t("Ledger is ready."), + }) + } catch (err) { + const error = getCustomTalismanLedgerError(err) + log.error("Failed to load page", { err }) + setConnectionStatus({ + status: "error", + message: error.message, + onRetryClick: () => loadPage(pageIndex), + }) + } finally { + refIsBusy.current = false } - } catch (err) { - log.error("Failed to load page", { err }) - setError((err as Error).message) - } - - setIsBusy(false) - }, [app, isReady, itemsPerPage, ledger, getAddress, pageIndex, t]) + }, + [t, itemsPerPage, legacyApp, getAddress], + ) // start fetching balances only once all accounts are loaded to prevent recreating subscription 5 times const balanceDefs = useMemo( @@ -131,14 +177,11 @@ const useLedgerSubstrateGenericAccounts = ( useEffect(() => { // refresh on every page change - loadPage() - }, [loadPage]) + loadPage(pageIndex) + }, [loadPage, pageIndex]) return { - ledger, accounts, - isBusy, - error, connectionStatus, withBalances, } @@ -157,24 +200,17 @@ const LedgerSubstrateGenericAccountPickerDefault: FC { - const { t } = useTranslation() const itemsPerPage = 5 const [pageIndex, setPageIndex] = useState(0) const [selectedAccounts, setSelectedAccounts] = useState([]) - const { accounts, error, isBusy, connectionStatus, withBalances } = - useLedgerSubstrateGenericAccounts(selectedAccounts, pageIndex, itemsPerPage, app) + const { accounts, connectionStatus, withBalances } = useLedgerSubstrateGenericAccounts( + selectedAccounts, + pageIndex, + itemsPerPage, + app, + ) const chain = useChain(chainId) - // if ledger was busy when changing tabs, connection needs to be refreshed once on mount - const refInitialized = useRef(false) - useEffect(() => { - if (!refInitialized.current && connectionStatus.status === "error") { - refInitialized.current = true - connectionStatus.refresh() - return - } - }, [connectionStatus]) - const handleToggleAccount = useCallback( (acc: DerivedAccountBase) => { const { address, name, accountIndex, addressOffset } = acc as LedgerSubstrateGenericAccount @@ -211,16 +247,12 @@ const LedgerSubstrateGenericAccountPickerDefault: FC 0} onAccountClick={handleToggleAccount} onPagerFirstClick={handlePageFirst} onPagerPrevClick={handlePagePrev} onPagerNextClick={handlePageNext} /> -

- {error ? t("An error occured, Ledger might be locked.") : null} -

) } @@ -258,51 +290,68 @@ const getNextAccountDetails = ( const useLedgerAccountAddress = ( account: CustomAccountDetails | undefined, - app: SubstrateAppParams | null | undefined, + legacyApp: SubstrateAppParams | null | undefined, ) => { - const { isReady, ledger, ...connectionStatus } = useLedgerSubstrateGeneric({ app }) + const { t } = useTranslation() + const { getAddress } = useLedgerSubstrateGeneric({ legacyApp }) - // if ledger was busy when changing tabs, connection needs to be refreshed once on mount - const refInitialized = useRef(false) - useEffect(() => { - if (!refInitialized.current && connectionStatus.status === "error") { - refInitialized.current = true - connectionStatus.refresh() - return - } - }, [connectionStatus]) + const refIsBusy = useRef(false) + + const [connectionStatus, setConnectionStatus] = useState({ + status: "connecting", + message: t("Fetching account address..."), + }) const [state, setState] = useState<{ - isBusy: boolean - error: string | undefined account: CustomAccountDetails | undefined address: string | undefined }>({ - isBusy: false, - error: undefined, account: account, address: undefined, }) // this system makes sure that if input changes, we don't fetch the address until ledger has returned previous result const loadAccountInfo = useCallback(async () => { - if (!ledger || !isReady || !account || state.isBusy) return + if (!account) return if (state.account === account && state.address) return // result is up to date + if (refIsBusy.current) throw new TalismanLedgerError("Busy", t("Ledger is busy")) + refIsBusy.current = true - setState({ account, isBusy: true, error: undefined, address: undefined }) + setState({ account, address: undefined }) + setConnectionStatus({ + status: "connecting", + message: t("Fetching account address..."), + }) try { const { accountIndex, addressOffset } = account - const path = getPolkadotLedgerDerivationPath({ accountIndex, addressOffset, app }) - - const res = await ledger.getAddress(path, app?.ss58_addr_type ?? 42, false) - - setState((prev) => ({ ...prev, address: res.address, isBusy: false })) + const path = getPolkadotLedgerDerivationPath({ + accountIndex, + addressOffset, + legacyApp: legacyApp, + }) + + const res = await getAddress(path, legacyApp?.ss58_addr_type ?? 42) + + setState((prev) => ({ ...prev, address: res.address })) + setConnectionStatus({ + status: "ready", + message: t("Ledger is ready."), + }) } catch (err) { + const error = getCustomTalismanLedgerError(err) + log.error("Failed to load page", { err }) + setConnectionStatus({ + status: "error", + message: error.message, + onRetryClick: loadAccountInfo, + }) log.error("Failed to load account info", { err }) - setState((prev) => ({ ...prev, error: (err as Error).message, isBusy: false })) + setState((prev) => ({ ...prev, error: error.message })) + } finally { + refIsBusy.current = false } - }, [ledger, isReady, account, state.isBusy, state.account, state.address, app]) + }, [account, state.account, state.address, t, legacyApp, getAddress]) useEffect(() => { loadAccountInfo() @@ -310,9 +359,7 @@ const useLedgerAccountAddress = ( return useMemo(() => { return { - isBusy: state.isBusy, address: state.account === account ? state.address : undefined, - error: state.account === account ? state.error : undefined, connectionStatus, } }, [state, account, connectionStatus]) @@ -321,9 +368,10 @@ const useLedgerAccountAddress = ( const LedgerSubstrateGenericAccountPickerCustom: FC = ({ onChange, app, + chainId, }) => { const { t } = useTranslation() - + const chain = useChain(chainId) const walletAccounts = useAccounts() const [accountDetails, setAccountDetails] = useState(() => getNextAccountDetails(walletAccounts, app), @@ -341,7 +389,7 @@ const LedgerSubstrateGenericAccountPickerCustom: FC ({ ...prev, name: e.target.value })) }, []) - const { address, error, connectionStatus } = useLedgerAccountAddress(accountDetails, app) + const { address, connectionStatus } = useLedgerAccountAddress(accountDetails, app) const accountImportDefs = useMemo( () => @@ -434,7 +482,11 @@ const LedgerSubstrateGenericAccountPickerCustom: FC
-
+
@@ -458,7 +510,7 @@ const LedgerSubstrateGenericAccountPickerCustom: FC - ) : !error ? ( + ) : connectionStatus.status === "connecting" ? ( <>
@@ -479,9 +531,7 @@ const LedgerSubstrateGenericAccountPickerCustom: FC
- ) : ( -
{error}
- )} + ) : null}
diff --git a/apps/extension/src/ui/domains/Account/LedgerSubstrateLegacyAccountPicker.tsx b/apps/extension/src/ui/domains/Account/LedgerSubstrateLegacyAccountPicker.tsx index 5a7f0d86fc..fe0a098fef 100644 --- a/apps/extension/src/ui/domains/Account/LedgerSubstrateLegacyAccountPicker.tsx +++ b/apps/extension/src/ui/domains/Account/LedgerSubstrateLegacyAccountPicker.tsx @@ -1,12 +1,11 @@ import { validateHexString } from "@talismn/util" -import { FC, useCallback, useEffect, useMemo, useState } from "react" +import { FC, useCallback, useEffect, useMemo, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { isChainActive, SubstrateLedgerAppType } from "@extension/core" import { log } from "@extension/shared" import { convertAddress } from "@talisman/util/convertAddress" -import { LEDGER_HARDENED_OFFSET } from "@ui/hooks/ledger/common" -import { useLedgerSubstrateAppByChain } from "@ui/hooks/ledger/useLedgerSubstrateApp" +import { getCustomTalismanLedgerError } from "@ui/hooks/ledger/errors" import { useLedgerSubstrateLegacy } from "@ui/hooks/ledger/useLedgerSubstrateLegacy" import { AccountImportDef, useAccountImportBalances } from "@ui/hooks/useAccountImportBalances" import { useAccounts, useActiveChainsState, useChain } from "@ui/state" @@ -16,6 +15,7 @@ import { LedgerAccountDefSubstrateLegacy, } from "./AccountAdd/AccountAddLedger/context" import { DerivedAccountBase, DerivedAccountPickerBase } from "./DerivedAccountPickerBase" +import { LedgerConnectionStatus, LedgerConnectionStatusProps } from "./LedgerConnectionStatus" const useLedgerChainAccounts = ( chainId: string, @@ -26,64 +26,98 @@ const useLedgerChainAccounts = ( const walletAccounts = useAccounts() const { t } = useTranslation() const chain = useChain(chainId) - const app = useLedgerSubstrateAppByChain(chain) const activeChains = useActiveChainsState() const withBalances = useMemo( () => !chain?.isTestnet && !!chain && isChainActive(chain, activeChains), [chain, activeChains], ) + const { getAddress, app } = useLedgerSubstrateLegacy(chain?.genesisHash) + + const [connectionStatus, setConnectionStatus] = useState({ + status: "connecting", + message: t("Fetching account addresses..."), + }) + const [ledgerAccounts, setLedgerAccounts] = useState<(LedgerSubstrateAccount | undefined)[]>([ ...Array(itemsPerPage), ]) - const [isBusy, setIsBusy] = useState(false) - const [error, setError] = useState() - const { isReady, ledger, ...connectionStatus } = useLedgerSubstrateLegacy(chain?.genesisHash) + const refIsBusy = useRef(false) - const loadPage = useCallback(async () => { - if (!app || !ledger || !isReady || !chain) return + // derivation path => address cache, used when going back to previous page + const refAddressCache = useRef>({}) + useEffect(() => { + refAddressCache.current = {} // reset if app changes + }, [app]) - setIsBusy(true) - setError(undefined) + // keep page index as ref to allow for cancelling current page load when changing page + const refPageIndex = useRef(pageIndex) + useEffect(() => { + refPageIndex.current = pageIndex + }, [pageIndex]) - const skip = pageIndex * itemsPerPage + const loadPage = useCallback( + async (pageIndex: number, force = false) => { + if (!app || !chain) return + if (!force && refIsBusy.current) return + refIsBusy.current = true - try { - const newAccounts: (LedgerSubstrateAccount | undefined)[] = [...Array(itemsPerPage)] + setConnectionStatus({ + status: "connecting", + message: t("Fetching account addresses..."), + }) - for (let i = 0; i < itemsPerPage; i++) { - const accountIndex = skip + i - const change = 0 - const addressOffset = 0 + const skip = pageIndex * itemsPerPage - const { address } = await ledger.getAddress( - LEDGER_HARDENED_OFFSET + accountIndex, - LEDGER_HARDENED_OFFSET + change, - LEDGER_HARDENED_OFFSET + addressOffset, - false, - ) + try { + const newAccounts: (LedgerSubstrateAccount | undefined)[] = [...Array(itemsPerPage)] + setLedgerAccounts([...newAccounts]) - newAccounts[i] = { - genesisHash: chain.genesisHash as string, - accountIndex, - addressOffset, - address, - name: t("Ledger {{appLabel}} {{accountIndex}}", { - appLabel: app.name, - accountIndex: accountIndex + 1, - }), - } as LedgerSubstrateAccount + for (let i = 0; i < itemsPerPage; i++) { + if (refPageIndex.current !== pageIndex) return loadPage(refPageIndex.current, true) + const accountIndex = skip + i + const addressOffset = 0 - setLedgerAccounts([...newAccounts]) - } - } catch (err) { - log.error("Failed to load page", { err }) - setError((err as Error).message) - } + const cacheKey = `${accountIndex}-${addressOffset}` + const { address } = + refAddressCache.current[cacheKey] ?? (await getAddress(accountIndex, addressOffset)) + if (refPageIndex.current !== pageIndex) return loadPage(refPageIndex.current, true) + if (!address) throw new Error("Unable to get address") + refAddressCache.current[cacheKey] = { address } + + newAccounts[i] = { + genesisHash: chain.genesisHash as string, + accountIndex, + addressOffset, + address, + name: t("Ledger {{appLabel}} {{accountIndex}}", { + appLabel: app.name, + accountIndex: accountIndex + 1, + }), + } as LedgerSubstrateAccount + + setLedgerAccounts([...newAccounts]) + } - setIsBusy(false) - }, [app, chain, isReady, itemsPerPage, ledger, pageIndex, t]) + setConnectionStatus({ + status: "ready", + message: t("Ledger is ready."), + }) + } catch (err) { + const error = getCustomTalismanLedgerError(err) + log.error("Failed to load page", { err }) + setConnectionStatus({ + status: "error", + message: error.message, + onRetryClick: () => loadPage(pageIndex), + }) + } finally { + refIsBusy.current = false + } + }, + [app, chain, getAddress, itemsPerPage, t], + ) // start fetching balances only once all accounts are loaded to prevent recreating subscription 5 times const balanceDefs = useMemo( @@ -141,17 +175,14 @@ const useLedgerChainAccounts = ( useEffect(() => { // refresh on every page change - loadPage() - }, [loadPage]) + loadPage(pageIndex) + }, [loadPage, pageIndex]) return { - chain, - ledger, accounts, - isBusy, - error, connectionStatus, withBalances, + chain, } } @@ -166,11 +197,10 @@ export const LedgerSubstrateLegacyAccountPicker: FC { - const { t } = useTranslation() const itemsPerPage = 5 const [pageIndex, setPageIndex] = useState(0) const [selectedAccounts, setSelectedAccounts] = useState([]) - const { accounts, withBalances, error, isBusy, chain } = useLedgerChainAccounts( + const { accounts, withBalances, chain, connectionStatus } = useLedgerChainAccounts( chainId, selectedAccounts, pageIndex, @@ -204,20 +234,19 @@ export const LedgerSubstrateLegacyAccountPicker: FC +
+ +
0} onAccountClick={handleToggleAccount} onPagerFirstClick={handlePageFirst} onPagerPrevClick={handlePagePrev} onPagerNextClick={handlePageNext} /> -

- {error ? t("An error occured, Ledger might be locked.") : null} -

) } diff --git a/apps/extension/src/ui/domains/Sign/ErrorMessageDrawer.tsx b/apps/extension/src/ui/domains/Sign/ErrorMessageDrawer.tsx index cf0fd078b0..79558a697f 100644 --- a/apps/extension/src/ui/domains/Sign/ErrorMessageDrawer.tsx +++ b/apps/extension/src/ui/domains/Sign/ErrorMessageDrawer.tsx @@ -1,20 +1,22 @@ -import { AlertCircleIcon } from "@talismn/icons" +import { XCircleIcon } from "@talismn/icons" +import { CONNECT_LEDGER_DOCS_URL } from "extension-shared" import { FC, useEffect, useState } from "react" -import { useTranslation } from "react-i18next" +import { Trans, useTranslation } from "react-i18next" import { Button, Drawer } from "talisman-ui" export const ErrorMessageDrawer: FC<{ message: string | undefined + name?: string // identifies specific errors, some require specific UI containerId: string | undefined onDismiss: () => void -}> = ({ message, containerId, onDismiss }) => { +}> = ({ message, name, containerId, onDismiss }) => { const { t } = useTranslation() // keep message in memory to prevent flickering on slide out const [content, setContent] = useState() useEffect(() => { - setContent(message) + if (message) setContent(message) }, [message]) return ( @@ -24,13 +26,54 @@ export const ErrorMessageDrawer: FC<{ containerId={containerId} onDismiss={onDismiss} > -
- -

{content}

+
+ +

+ {name === "GenericAppRequired" ? : wrapStrong(content)} +

) } + +const LedgerGenericRequired = () => { + const { t } = useTranslation() + return ( + + ), + }} + /> + ) +} + +const wrapStrong = (text?: string) => { + if (!text) return text + + const splitter = new RegExp("([^<]*?)", "g") + const extractor = new RegExp("^([^<]*?)$", "g") + + return text.split(splitter).map((str, i) => { + const match = extractor.exec(str) + return match ? ( + + {match[1]} + + ) : ( + {str} + ) + }) +} diff --git a/apps/extension/src/ui/domains/Sign/LedgerSigningStatus.tsx b/apps/extension/src/ui/domains/Sign/LedgerSigningStatus.tsx deleted file mode 100644 index 3572558c0f..0000000000 --- a/apps/extension/src/ui/domains/Sign/LedgerSigningStatus.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { AlertCircleIcon, LoaderIcon } from "@talismn/icons" -import { classNames } from "@talismn/util" -import { CONNECT_LEDGER_DOCS_URL } from "extension-shared" -import { Trans, useTranslation } from "react-i18next" -import { Button } from "talisman-ui" - -interface LedgerSigningStatusProps { - message: string - requiresConfirmation?: boolean - status?: "error" | "signing" - confirm?: () => void -} - -const NO_OP = () => {} - -export const LedgerSigningStatus = ({ - status, - message, - requiresConfirmation = true, - confirm = NO_OP, -}: LedgerSigningStatusProps) => { - const { t } = useTranslation("request") - - return ( -
- {status === "error" && ( - <> - -

- {message === "GENERIC_APP_REQUIRED" ? ( - - ), - }} - /> - ) : ( - message - )} -

- - )} - {status === "signing" && ( - <> - - {t("Sign with Ledger...")} - - )} - {status === "error" && requiresConfirmation && confirm && ( - - )} -
- ) -} diff --git a/apps/extension/src/ui/domains/Sign/SignHardwareEthereum.tsx b/apps/extension/src/ui/domains/Sign/SignHardwareEthereum.tsx index 004dd5e0ca..42398f6236 100644 --- a/apps/extension/src/ui/domains/Sign/SignHardwareEthereum.tsx +++ b/apps/extension/src/ui/domains/Sign/SignHardwareEthereum.tsx @@ -1,13 +1,12 @@ import { HexString } from "@polkadot/util/types" import { EvmNetworkId } from "@talismn/chaindata-provider" -import { FC, lazy, Suspense } from "react" +import { FC, Suspense } from "react" import { AccountJsonAny, AccountType, EthSignMessageMethod } from "@extension/core" import { SuspenseTracker } from "@talisman/components/SuspenseTracker" import { SignDcentUnsupportedMessage } from "./SignDcentUnsupportedMessage" - -const SignLedgerEthereum = lazy(() => import("./SignLedgerEthereum")) +import { SignLedgerEthereum } from "./SignLedgerEthereum" export type SignHardwareEthereumProps = { evmNetworkId?: EvmNetworkId diff --git a/apps/extension/src/ui/domains/Sign/SignHardwareSubstrate.tsx b/apps/extension/src/ui/domains/Sign/SignHardwareSubstrate.tsx index 911effcef0..5ba521dd0c 100644 --- a/apps/extension/src/ui/domains/Sign/SignHardwareSubstrate.tsx +++ b/apps/extension/src/ui/domains/Sign/SignHardwareSubstrate.tsx @@ -1,16 +1,14 @@ import { TypeRegistry } from "@polkadot/types" import { SignerPayloadJSON, SignerPayloadRaw } from "@polkadot/types/types" import { HexString } from "@polkadot/util/types" -import { FC, lazy, Suspense } from "react" +import { FC } from "react" import { AccountJsonAny, AccountType, SubstrateLedgerAppType } from "@extension/core" -import { SuspenseTracker } from "@talisman/components/SuspenseTracker" import { useAccountByAddress } from "@ui/state" import { SignDcentUnsupportedMessage } from "./SignDcentUnsupportedMessage" - -const SignLedgerSubstrateGeneric = lazy(() => import("./SignLedgerSubstrateGeneric")) -const SignLedgerSubstrateLegacy = lazy(() => import("./SignLedgerSubstrateLegacy")) +import { SignLedgerSubstrateGeneric } from "./SignLedgerSubstrateGeneric" +import { SignLedgerSubstrateLegacy } from "./SignLedgerSubstrateLegacy" export type SignHardwareSubstrateProps = { payload: SignerPayloadRaw | SignerPayloadJSON | undefined @@ -51,9 +49,5 @@ export const SignHardwareSubstrate: FC = (props) => if (!SignHardwareComponent) return null - return ( - }> - - - ) + return } diff --git a/apps/extension/src/ui/domains/Sign/SignLedgerBase.tsx b/apps/extension/src/ui/domains/Sign/SignLedgerBase.tsx new file mode 100644 index 0000000000..de0ffbc75d --- /dev/null +++ b/apps/extension/src/ui/domains/Sign/SignLedgerBase.tsx @@ -0,0 +1,49 @@ +import { classNames } from "@talismn/util" +import { FC } from "react" +import { useTranslation } from "react-i18next" +import { Button } from "talisman-ui" + +import { TalismanLedgerError } from "@ui/hooks/ledger/errors" + +import { ErrorMessageDrawer } from "./ErrorMessageDrawer" + +export const SignLedgerBase: FC<{ + isProcessing: boolean + error: TalismanLedgerError | null + containerId?: string + className?: string + onSignClick: () => void + onDismissErrorClick: () => void + onCancel?: () => void +}> = ({ + isProcessing, + error, + containerId, + className, + onSignClick, + onDismissErrorClick, + onCancel, +}) => { + const { t } = useTranslation() + + return ( +
+ {!!onCancel && } + + +
+ ) +} diff --git a/apps/extension/src/ui/domains/Sign/SignLedgerEthereum.tsx b/apps/extension/src/ui/domains/Sign/SignLedgerEthereum.tsx index 3fd7542d4e..3293444cc8 100644 --- a/apps/extension/src/ui/domains/Sign/SignLedgerEthereum.tsx +++ b/apps/extension/src/ui/domains/Sign/SignLedgerEthereum.tsx @@ -1,143 +1,16 @@ -import { stripHexPrefix } from "@ethereumjs/util" -import LedgerEthereumApp from "@ledgerhq/hw-app-eth" -import { SignTypedDataVersion, TypedDataUtils } from "@metamask/eth-sig-util" -import { classNames } from "@talismn/util" -import { FC, useCallback, useEffect, useMemo, useState } from "react" +import { FC, useCallback } from "react" import { useTranslation } from "react-i18next" -import { Button, Drawer } from "talisman-ui" -import { - hexToBigInt, - isHex, - serializeTransaction, - Signature, - signatureToHex, - TransactionRequest, -} from "viem" -import i18next from "@common/i18nConfig" -import { - AccountJsonHardwareEthereum, - EthSignMessageMethod, - getTransactionSerializable, -} from "@extension/core" +import { AccountJsonHardwareEthereum } from "@extension/core" import { log } from "@extension/shared" +import { getCustomTalismanLedgerError } from "@ui/hooks/ledger/errors" import { useLedgerEthereum } from "@ui/hooks/ledger/useLedgerEthereum" -import { - LedgerConnectionStatus, - LedgerConnectionStatusProps, -} from "../Account/LedgerConnectionStatus" -import { LedgerSigningStatus } from "./LedgerSigningStatus" -import { SignApproveButton } from "./SignApproveButton" import { SignHardwareEthereumProps } from "./SignHardwareEthereum" +import { SignLedgerBase } from "./SignLedgerBase" +import { useSignLedgerBase } from "./useSignLedgerBase" -const toSignature = ({ v, r, s }: { v: string | number; r: string; s: string }): Signature => { - const parseV = (v: string | number) => { - const parsed = typeof v === "string" ? hexToBigInt(`0x${v}`) : BigInt(v) - - // ideally this should be done in viem - if (parsed === 0n) return 27n - if (parsed === 1n) return 28n - - return parsed - } - - return { - v: parseV(v), - r: `0x${r}`, - s: `0x${s}`, - } -} - -const signWithLedger = async ( - ledger: LedgerEthereumApp, - chainId: number, - method: EthSignMessageMethod | "eth_sendTransaction", - payload: unknown, - accountPath: string, -): Promise<`0x${string}`> => { - switch (method) { - case "eth_signTypedData_v3": - case "eth_signTypedData_v4": { - const jsonMessage = typeof payload === "string" ? JSON.parse(payload) : payload - - try { - // Nano S doesn't support signEIP712Message, fallback to signEIP712HashedMessage in case of error - // see https://github.com/LedgerHQ/ledger-live/tree/develop/libs/ledgerjs/packages/hw-app-eth#signeip712message - - // eslint-disable-next-line no-var - var sig = await ledger.signEIP712Message(accountPath, jsonMessage) - } catch { - // fallback for ledger Nano S - const { domain, types, primaryType, message } = TypedDataUtils.sanitizeData(jsonMessage) - const domainSeparatorHex = TypedDataUtils.hashStruct( - "EIP712Domain", - domain, - types, - SignTypedDataVersion.V4, - ).toString("hex") - const hashStructMessageHex = TypedDataUtils.hashStruct( - primaryType as string, - message, - types, - SignTypedDataVersion.V4, - ).toString("hex") - - sig = await ledger.signEIP712HashedMessage( - accountPath, - domainSeparatorHex, - hashStructMessageHex, - ) - } - - return signatureToHex(toSignature(sig)) - } - - case "personal_sign": { - // ensure that it is hex encoded - const messageHex = isHex(payload) ? payload : Buffer.from(payload as string).toString("hex") - - const sig = await ledger.signPersonalMessage(accountPath, stripHexPrefix(messageHex)) - - return signatureToHex(toSignature(sig)) - } - - case "eth_sendTransaction": { - const txRequest = payload as TransactionRequest - const baseTx = getTransactionSerializable(txRequest, chainId) - const serialized = serializeTransaction(baseTx) - - const sig = await ledger.signTransaction(accountPath, stripHexPrefix(serialized), null) - - return serializeTransaction(baseTx, toSignature(sig)) - } - - default: { - throw new Error(i18next.t("This type of message cannot be signed with ledger.")) - } - } -} - -const ErrorDrawer: FC<{ error: string | null; containerId?: string; onClose: () => void }> = ({ - error, - containerId, - onClose, -}) => { - // save error so the content doesn't disappear before the drawer closing animation - const [savedError, setSavedError] = useState() - - useEffect(() => { - if (error) setSavedError(error) - }, [error]) - - return ( - - - - ) -} - -const SignLedgerEthereum: FC = ({ +export const SignLedgerEthereum: FC = ({ evmNetworkId, account, className = "", @@ -148,115 +21,66 @@ const SignLedgerEthereum: FC = ({ onSigned, onCancel, }) => { - const { t } = useTranslation("request") - const [isSigning, setIsSigning] = useState(false) - const [isSigned, setIsSigned] = useState(false) - const [error, setError] = useState(null) - const { ledger, refresh, status, message, isReady, requiresManualRetry } = useLedgerEthereum() + const { t } = useTranslation() - const inputsReady = useMemo( - () => !!payload && (method !== "eth_sendTransaction" || !!evmNetworkId), - [evmNetworkId, method, payload], - ) - - // reset - useEffect(() => { - setIsSigned(false) - }, [method, payload]) + const { isSigning, error, setIsSigning, setError } = useSignLedgerBase({ payload }) - const connectionStatus: LedgerConnectionStatusProps = useMemo( - () => ({ - status: status === "ready" ? "connecting" : status, - message: status === "ready" ? t("Please approve from your Ledger.") : message, - refresh, - requiresManualRetry, - }), - [refresh, status, message, requiresManualRetry, t], - ) + const { sign } = useLedgerEthereum() - const _onRefresh = useCallback(() => { - refresh() - setError(null) - }, [refresh, setError]) + const signWithLedger = useCallback(async () => { + if (!payload || !onSigned || !account) return - const signLedger = useCallback(async () => { - if (!ledger || !onSigned || !inputsReady) return + onSentToDevice?.(true) + setIsSigning(true) - setError(null) try { - const signature = await signWithLedger( - ledger, + const signature = await sign( Number(evmNetworkId), method, payload, (account as AccountJsonHardwareEthereum).path, ) - setIsSigned(true) // await so we can keep the spinning loader until popup closes await onSigned({ signature }) } catch (err) { - const error = err as Error & { statusCode?: number; reason?: string } - // if user rejects from device - if (error.statusCode === 27013) { - onSentToDevice?.(false) - return - } - - log.error("ledger sign Ethereum", { error }) - - // ETH ledger app requires EIP-1559 type 2 transactions - if (error.reason === "invalid object key - maxPriorityFeePerGas") + const errCheck = err as Error & { statusCode?: number; reason?: string } + if (errCheck.reason === "invalid object key - maxPriorityFeePerGas") { setError( - t("Sorry, Talisman doesn't support signing transactions with Ledger on this network."), + getCustomTalismanLedgerError( + t("Sorry, Talisman doesn't support signing transactions with Ledger on this network."), + ), ) - else setError(error.reason ?? error.message) + } else { + const error = getCustomTalismanLedgerError(err) + log.error("signLedger", { error }) + setError(error) + } + } finally { + onSentToDevice?.(false) } - }, [ledger, onSigned, inputsReady, evmNetworkId, method, payload, account, t, onSentToDevice]) - - const handleSendClick = useCallback(() => { - setIsSigning(true) - onSentToDevice?.(true) - signLedger() - .catch(() => onSentToDevice?.(false)) - .finally(() => setIsSigning(false)) - }, [onSentToDevice, signLedger]) - - const handleClearErrorClick = useCallback(() => { - onSentToDevice?.(false) - setError(null) - }, [onSentToDevice]) + }, [ + account, + evmNetworkId, + method, + onSentToDevice, + onSigned, + payload, + setError, + setIsSigning, + sign, + t, + ]) return ( -
- {!error && ( - <> - {isReady ? ( - - {t("Approve on Ledger")} - - ) : ( - !isSigned && ( - - ) - )} - - )} - {onCancel && ( - - )} - -
+ setError(null)} + onCancel={onCancel} + /> ) } - -// default export to allow for lazy loading -export default SignLedgerEthereum diff --git a/apps/extension/src/ui/domains/Sign/SignLedgerSubstrateGeneric.tsx b/apps/extension/src/ui/domains/Sign/SignLedgerSubstrateGeneric.tsx index bc62d16d5e..226401595e 100644 --- a/apps/extension/src/ui/domains/Sign/SignLedgerSubstrateGeneric.tsx +++ b/apps/extension/src/ui/domains/Sign/SignLedgerSubstrateGeneric.tsx @@ -1,71 +1,17 @@ -import { TypeRegistry } from "@polkadot/types" -import { hexToU8a, u8aToHex, u8aWrapBytes } from "@polkadot/util" -import { classNames } from "@talismn/util" -import { PolkadotGenericApp } from "@zondax/ledger-substrate" -import { SubstrateAppParams } from "@zondax/ledger-substrate/dist/common" -import { FC, useCallback, useEffect, useMemo, useState } from "react" -import { useTranslation } from "react-i18next" -import { Button, Drawer } from "talisman-ui" +import { FC, useCallback } from "react" -import { - AccountJsonHardwareSubstrate, - isJsonPayload, - SignerPayloadJSON, - SignerPayloadRaw, -} from "@extension/core" +import { AccountJsonHardwareSubstrate } from "@extension/core" import { log } from "@extension/shared" -import { getPolkadotLedgerDerivationPath } from "@ui/hooks/ledger/common" +import { getCustomTalismanLedgerError } from "@ui/hooks/ledger/errors" import { useLedgerSubstrateAppByName } from "@ui/hooks/ledger/useLedgerSubstrateApp" import { useLedgerSubstrateGeneric } from "@ui/hooks/ledger/useLedgerSubstrateGeneric" import { useAccountByAddress } from "@ui/state" -import { - LedgerConnectionStatus, - LedgerConnectionStatusProps, -} from "../Account/LedgerConnectionStatus" -import { LedgerSigningStatus } from "./LedgerSigningStatus" import { SignHardwareSubstrateProps } from "./SignHardwareSubstrate" +import { SignLedgerBase } from "./SignLedgerBase" +import { useSignLedgerBase } from "./useSignLedgerBase" -type RawLedgerError = { - errorMessage: string - name?: string - returnCode: number -} - -const sign = async ( - ledger: PolkadotGenericApp, - payload: SignerPayloadJSON | SignerPayloadRaw, - account: AccountJsonHardwareSubstrate, - app?: SubstrateAppParams | null, - registry?: TypeRegistry | null, - txMetadata?: string | null, -) => { - const path = getPolkadotLedgerDerivationPath({ ...account, app }) - - if (isJsonPayload(payload)) { - if (!txMetadata) throw new Error("Missing metadata") - if (!registry) throw new Error("Missing registry") - - const unsigned = registry.createType("ExtrinsicPayload", payload) - - const blob = Buffer.from(unsigned.toU8a(true)) - const metadata = Buffer.from(hexToU8a(txMetadata)) - - const { signature } = await ledger.signWithMetadata(path, blob, metadata) - - return u8aToHex(new Uint8Array(signature)) - } else { - // raw payload - const unsigned = u8aWrapBytes(payload.data) - - const { signature } = await ledger.signRaw(path, Buffer.from(unsigned)) - - // skip first byte (sig type) or signatureVerify fails, this seems specific to ed25519 signatures - return u8aToHex(new Uint8Array(signature.slice(1))) - } -} - -const SignLedgerSubstrateGeneric: FC = ({ +export const SignLedgerSubstrateGeneric: FC = ({ className = "", onSigned, onSentToDevice, @@ -76,127 +22,55 @@ const SignLedgerSubstrateGeneric: FC = ({ registry, }) => { const account = useAccountByAddress(payload?.address) - const app = useLedgerSubstrateAppByName(account?.migrationAppName as string) - - const { t } = useTranslation("request") - const [isSigning, setIsSigning] = useState(false) - const [isSigned, setIsSigned] = useState(false) - const [error, setError] = useState(null) - const { ledger, refresh, status, message, isReady, requiresManualRetry } = - useLedgerSubstrateGeneric({ app }) - - // reset - useEffect(() => { - setIsSigned(false) - }, [payload]) - - const connectionStatus: LedgerConnectionStatusProps = useMemo( - () => ({ - status: status === "ready" ? "connecting" : status, - message: status === "ready" ? t("Please approve from your Ledger.") : message, - refresh, - requiresManualRetry, - }), - [refresh, status, message, requiresManualRetry, t], - ) + const legacyApp = useLedgerSubstrateAppByName(account?.migrationAppName as string) + const { sign } = useLedgerSubstrateGeneric({ legacyApp }) - const signLedger = useCallback(async () => { - if (!ledger || !payload || !onSigned || !account) return + const { isSigning, error, setIsSigning, setError } = useSignLedgerBase({ payload }) - if (isJsonPayload(payload)) { - if (!payload.withSignedTransaction) - return setError(t("This dapp needs to be updated in order to support Ledger signing.")) - if (!registry) return setError(t("Missing registry")) + const signWithLedger = useCallback(async () => { + if (!payload || !onSigned || !account) return - const hasCheckMetadataHash = registry.metadata.extrinsic.signedExtensions.some( - (ext) => ext.identifier.toString() === "CheckMetadataHash", - ) - if (!hasCheckMetadataHash) - return setError(t("This network doesn't support Ledger Polkadot Generic App.")) - - if (!shortMetadata) return setError(t("Missing short metadata")) - } - - setError(null) + onSentToDevice?.(true) + setIsSigning(true) try { const signature = await sign( - ledger, payload, account as AccountJsonHardwareSubstrate, - app, registry, shortMetadata, ) // await to keep loader spinning until popup closes await onSigned({ signature }) - } catch (error) { + } catch (err) { + const error = getCustomTalismanLedgerError(err) log.error("signLedger", { error }) - const message = (error as Error)?.message ?? (error as RawLedgerError)?.errorMessage - switch (message) { - case "Transaction rejected": - return - - case "Instruction not supported": - return setError( - t( - "This instruction is not supported on your ledger. You should check for firmware and app updates in Ledger Live before trying again.", - ), - ) - - default: - log.error("ledger sign Substrate : " + message, { error }) - setError(message) - } + setError(error) + } finally { + onSentToDevice?.(false) } - }, [ledger, payload, onSigned, account, shortMetadata, registry, t, app]) - - const onRefresh = useCallback(() => { - refresh() - setError(null) - }, [refresh, setError]) - - const handleSendClick = useCallback(() => { - setIsSigning(true) - onSentToDevice?.(true) - signLedger() - .catch(() => onSentToDevice?.(false)) - .finally(() => setIsSigning(false)) - }, [onSentToDevice, signLedger]) - - const handleCloseDrawer = useCallback(() => setError(null), [setError]) + }, [ + payload, + onSigned, + account, + onSentToDevice, + setIsSigning, + setError, + registry, + shortMetadata, + sign, + ]) return ( -
- {!error && ( - <> - {isReady ? ( - - ) : ( - !isSigned && - )} - - )} - {onCancel && ( - - )} - {error && ( - - - - )} -
+ setError(null)} + onCancel={onCancel} + /> ) } - -// default export to allow for lazy loading -export default SignLedgerSubstrateGeneric diff --git a/apps/extension/src/ui/domains/Sign/SignLedgerSubstrateLegacy.tsx b/apps/extension/src/ui/domains/Sign/SignLedgerSubstrateLegacy.tsx index 943c4126a2..e018b84660 100644 --- a/apps/extension/src/ui/domains/Sign/SignLedgerSubstrateLegacy.tsx +++ b/apps/extension/src/ui/domains/Sign/SignLedgerSubstrateLegacy.tsx @@ -1,27 +1,17 @@ -import { u8aToHex, u8aWrapBytes } from "@polkadot/util" -import { classNames } from "@talismn/util" -import { FC, useCallback, useEffect, useMemo, useState } from "react" +import { FC, useCallback } from "react" import { useTranslation } from "react-i18next" -import { Button, Drawer } from "talisman-ui" -import { SignerPayloadJSON, SignerPayloadRaw } from "@extension/core" +import { AccountJsonHardwareSubstrate } from "@extension/core" import { log } from "@extension/shared" -import { LEDGER_HARDENED_OFFSET, LEDGER_SUCCESS_CODE, LedgerError } from "@ui/hooks/ledger/common" +import { getCustomTalismanLedgerError } from "@ui/hooks/ledger/errors" import { useLedgerSubstrateLegacy } from "@ui/hooks/ledger/useLedgerSubstrateLegacy" import { useAccountByAddress } from "@ui/state" -import { - LedgerConnectionStatus, - LedgerConnectionStatusProps, -} from "../Account/LedgerConnectionStatus" -import { LedgerSigningStatus } from "./LedgerSigningStatus" import { SignHardwareSubstrateProps } from "./SignHardwareSubstrate" +import { SignLedgerBase } from "./SignLedgerBase" +import { useSignLedgerBase } from "./useSignLedgerBase" -function isRawPayload(payload: SignerPayloadJSON | SignerPayloadRaw): payload is SignerPayloadRaw { - return !!(payload as SignerPayloadRaw).data -} - -const SignLedgerSubstrateLegacy: FC = ({ +export const SignLedgerSubstrateLegacy: FC = ({ className = "", onSigned, onSentToDevice, @@ -30,155 +20,42 @@ const SignLedgerSubstrateLegacy: FC = ({ containerId, registry, }) => { + const { t } = useTranslation() const account = useAccountByAddress(payload?.address) - const { t } = useTranslation("request") - const [isSigning, setIsSigning] = useState(false) - const [error, setError] = useState(null) - const [unsigned, setUnsigned] = useState() - const [isRaw, setIsRaw] = useState() - const { ledger, refresh, status, message, isReady, requiresManualRetry } = - useLedgerSubstrateLegacy(account?.genesisHash) - - const connectionStatus: LedgerConnectionStatusProps = useMemo( - () => ({ - status: status === "ready" ? "connecting" : status, - message: status === "ready" ? t("Please approve from your Ledger.") : message, - refresh, - requiresManualRetry, - }), - [refresh, status, message, requiresManualRetry, t], - ) - - useEffect(() => { - if (!payload) return - - if (isRawPayload(payload)) { - const tmpUnsigned = u8aWrapBytes(payload.data) - if (tmpUnsigned.length > 256) setError(t("The message is too long to be signed with Ledger.")) - - setUnsigned(tmpUnsigned) - setIsRaw(true) - } else if (registry) { - // Legacy dapps don't support the CheckMetadataHash signed extension - if (payload.signedExtensions.includes("CheckMetadataHash")) - return setError("GENERIC_APP_REQUIRED") // this error message is handled in the rendering component because of a link to docs - - const extrinsicPayload = registry.createType("ExtrinsicPayload", payload, { - version: payload.version, - }) - setUnsigned(extrinsicPayload.toU8a(true)) - setIsRaw(false) - } - }, [payload, registry, t]) - - const onRefresh = useCallback(() => { - refresh() - setError(null) - }, [refresh, setError]) + const { sign } = useLedgerSubstrateLegacy(account?.genesisHash) - const signLedger = useCallback(async () => { - if (!ledger || !unsigned || !onSigned || !account) return + const { isSigning, error, setIsSigning, setError } = useSignLedgerBase({ payload }) - if (isRaw && unsigned.length > 256) - return setError(t("The message is too long to be signed with Ledger.")) + const signWithLedger = useCallback(async () => { + if (!payload || !onSigned || !account) return + if (!registry) return setError(getCustomTalismanLedgerError(t("Missing registry."))) - setError(null) + onSentToDevice?.(true) + setIsSigning(true) try { - const { - signature: signatureBuffer, - error_message, - return_code, - } = await (isRaw - ? ledger.signRaw( - LEDGER_HARDENED_OFFSET + (account.accountIndex ?? 0), - LEDGER_HARDENED_OFFSET + 0, - LEDGER_HARDENED_OFFSET + (account.addressOffset ?? 0), - Buffer.from(unsigned), - ) - : ledger.sign( - LEDGER_HARDENED_OFFSET + (account.accountIndex ?? 0), - LEDGER_HARDENED_OFFSET + 0, - LEDGER_HARDENED_OFFSET + (account.addressOffset ?? 0), - Buffer.from(unsigned), - )) - - if (return_code !== LEDGER_SUCCESS_CODE) - throw new LedgerError(error_message, "SignError", return_code) - - // remove first byte which stores the signature type (0 here, as 0 = ed25519) - const signature = isRaw - ? u8aToHex(new Uint8Array(signatureBuffer.slice(1))) - : u8aToHex(new Uint8Array(signatureBuffer)) + const signature = await sign(payload, account as AccountJsonHardwareSubstrate, registry) // await to keep loader spinning until popup closes await onSigned({ signature }) - } catch (error) { - const message = (error as Error)?.message - switch (message) { - case "Transaction rejected": - return - - case "Txn version not supported": - return setError( - t( - "This type of transaction is not supported on your ledger. You should check for firmware and app updates in Ledger Live before trying again.", - ), - ) - - case "Instruction not supported": - return setError( - t( - "This instruction is not supported on your ledger. You should check for firmware and app updates in Ledger Live before trying again.", - ), - ) - - default: - log.error("ledger sign Substrate : " + message, { error }) - setError(message) - } + } catch (err) { + const error = getCustomTalismanLedgerError(err) + log.error("signLedger", { error }) + setError(error) + } finally { + onSentToDevice?.(false) } - }, [ledger, unsigned, onSigned, account, isRaw, t]) - - useEffect(() => { - if (isReady && !error && unsigned && !isSigning) { - setIsSigning(true) - onSentToDevice?.(true) - signLedger().finally(() => { - setIsSigning(false) - onSentToDevice?.(false) - }) - } - }, [signLedger, isSigning, error, isReady, onSentToDevice, unsigned]) - - const handleCloseDrawer = useCallback(() => setError(null), [setError]) + }, [payload, onSigned, account, registry, setError, t, onSentToDevice, setIsSigning, sign]) return ( -
- {!error && ( - - )} - {onCancel && ( - - )} - {error && ( - - - - )} -
+ setError(null)} + onCancel={onCancel} + /> ) } - -// default export to allow for lazy loading -export default SignLedgerSubstrateLegacy diff --git a/apps/extension/src/ui/domains/Sign/useSignLedgerBase.ts b/apps/extension/src/ui/domains/Sign/useSignLedgerBase.ts new file mode 100644 index 0000000000..e7667acd7d --- /dev/null +++ b/apps/extension/src/ui/domains/Sign/useSignLedgerBase.ts @@ -0,0 +1,25 @@ +import { useCallback, useEffect, useState } from "react" + +import { TalismanLedgerError } from "@ui/hooks/ledger/errors" + +export const useSignLedgerBase = ({ payload }: { payload: unknown }) => { + const [{ isSigning, error }, setState] = useState<{ + isSigning: boolean + error: TalismanLedgerError | null + }>({ isSigning: false, error: null }) + + // reset + useEffect(() => { + setState({ isSigning: false, error: null }) + }, [payload]) + + const setError = useCallback((error: TalismanLedgerError | null) => { + setState({ isSigning: false, error }) + }, []) + + const setIsSigning = useCallback((isSigning: boolean) => { + setState({ isSigning, error: null }) + }, []) + + return { setError, setIsSigning, isSigning, error } +} diff --git a/apps/extension/src/ui/hooks/ledger/common.ts b/apps/extension/src/ui/hooks/ledger/common.ts index 1f38bd2895..797b60029c 100644 --- a/apps/extension/src/ui/hooks/ledger/common.ts +++ b/apps/extension/src/ui/hooks/ledger/common.ts @@ -1,9 +1,6 @@ import { supportedApps } from "@zondax/ledger-substrate" import { SubstrateAppParams } from "@zondax/ledger-substrate/dist/common" import { ChainId } from "extension-core" -import { t } from "i18next" - -import { DEBUG } from "@extension/shared" export const CHAIN_ID_TO_LEDGER_APP_NAME: Partial> = { "kusama": "Kusama", @@ -50,169 +47,20 @@ export const LEDGER_SUCCESS_CODE = 0x9000 export const LEDGER_HARDENED_OFFSET = 0x80000000 -export class LedgerError extends Error { - statusCode?: number - - constructor(message?: string, name?: string, statusCode?: number) { - super(message) - this.name = name || "Error" - this.statusCode = statusCode - } -} - -export const ERROR_LEDGER_EVM_CANNOT_SIGN_SUBSTRATE = - "This transaction cannot be signed via an Ethereum Ledger account." -export const ERROR_LEDGER_NO_APP = "There is no Ledger app available for this network." - export type LedgerStatus = "ready" | "warning" | "error" | "connecting" | "unknown" -export type LedgerErrorProps = { - status: LedgerStatus - message: string - requiresManualRetry: boolean -} - -const capitalize = (str: string) => (str.length > 1 ? str[0].toUpperCase() + str.slice(1) : str) - -export const getLedgerErrorProps = (err: LedgerError, appName: string): LedgerErrorProps => { - // Generic errors - switch (err.name) { - case "SecurityError": - // happens on some browser when ledger is plugged after browser is launched - // when this happens, the only way to connect is to restart all instances of the browser - return { - status: "error", - requiresManualRetry: false, - message: t("Failed to connect USB. Restart your browser and retry."), - } - - case "NotFoundError": - case "NetworkError": // while connecting - case "InvalidStateError": // while connecting - return { - status: "connecting", - message: t(`Connecting to Ledger...`), - requiresManualRetry: false, - } - - case "UnsupportedVersion": // For ethereum only - return { - status: "error", - message: t("Please update your Ledger Ethereum app."), - requiresManualRetry: false, - } - - case "TransportStatusError": { - switch (err.statusCode) { - case 27404: // locked - case 27010: - return { - status: "warning", - message: t("Please unlock your Ledger."), - requiresManualRetry: false, - } - case 28160: // non-compatible app - case 25831: // home screen - case 25873: - case 27906: - case 57346: - default: - return { - status: "warning", - message: t(`Please open {{appName}} app on your Ledger.`, { - appName: capitalize(appName), - }), - requiresManualRetry: false, - } - } - } - - case "TransportOpenUserCancelled": // occurs when user doesn't select a device in the browser popup - case "TransportWebUSBGestureRequired": - case "TransportInterfaceNotAvailable": // occurs after unlock, or if browser requires a click to connect usb (only on MacOS w/chrome) - return { - status: "error", - message: t("Failed to connect to your Ledger. Click here to retry."), - requiresManualRetry: true, - } - } - - // Polkadot specific errors, wrapped in simple Error object - // only message is available - switch (err.message) { - case "Timeout": // this one is throw by Talisman in case of timeout when calling ledger.getAddress - case "Failed to execute 'requestDevice' on 'USB': Must be handling a user gesture to show a permission request.": - return { - status: "error", - message: t("Failed to connect to your Ledger. Click here to retry."), - requiresManualRetry: true, - } - - case "App does not seem to be open": // locked but underlying app is eth - case "Unknown Status Code: 28161": // just unlocked, didn't open kusama yet - case "Unknown Status Code: 38913": // just unlocked, didn't open kusama yet - return { - status: "warning", - message: t(`Please open {{appName}} app on your Ledger.`, { appName }), - requiresManualRetry: false, - } - case "Unknown Status Code: 26628": - case "Transaction rejected": // unplugged then retry while on lock screen - return { - status: "warning", - message: t("Please unlock your Ledger."), - requiresManualRetry: false, - } - - case "Device is busy": - case "NetworkError: Failed to execute 'transferOut' on 'USBDevice': A transfer error has occurred.": - case "NetworkError: Failed to execute 'transferIn' on 'USBDevice': A transfer error has occurred.": - return { - status: "connecting", - message: t(`Connecting to Ledger...`), - requiresManualRetry: false, - } - - case ERROR_LEDGER_EVM_CANNOT_SIGN_SUBSTRATE: - return { - status: "error", - // can't reuse the const here because the i18n plugin wouldn't lookup the content - message: t("This transaction cannot be signed via an Ethereum Ledger account."), - requiresManualRetry: false, - } - - case ERROR_LEDGER_NO_APP: - return { - status: "error", - // can't reuse the const here because we need i18n plugin to parse the text - message: t("There is no Ledger app available for this network."), - requiresManualRetry: false, - } - } - - // eslint-disable-next-line no-console - DEBUG && console.warn("unmanaged ledger error", { err }) - - // Fallback error message - return { - status: "error", - message: t("Failed to connect to your Ledger. Click here to retry."), - requiresManualRetry: true, - } -} - export const getPolkadotLedgerDerivationPath = ({ accountIndex = 0, addressOffset = 0, - app, + legacyApp, }: { accountIndex?: number addressOffset?: number - app?: SubstrateAppParams | null + legacyApp?: SubstrateAppParams | null }) => { - if (!app) app = supportedApps.find((a) => a.name === "Polkadot")! + if (!legacyApp) legacyApp = supportedApps.find((a) => a.name === "Polkadot")! - const slip = app.slip0044 - LEDGER_HARDENED_OFFSET + const slip = legacyApp.slip0044 - LEDGER_HARDENED_OFFSET //354 for polkadot return `m/44'/${slip}'/${accountIndex}'/0'/${addressOffset}'` diff --git a/apps/extension/src/ui/hooks/ledger/errors.ts b/apps/extension/src/ui/hooks/ledger/errors.ts new file mode 100644 index 0000000000..afbac245cf --- /dev/null +++ b/apps/extension/src/ui/hooks/ledger/errors.ts @@ -0,0 +1,220 @@ +import { DEBUG, log } from "extension-shared" +import { t } from "i18next" +import { capitalize } from "lodash" + +export const ERROR_LEDGER_EVM_CANNOT_SIGN_SUBSTRATE = + "This transaction cannot be signed via an Ethereum Ledger account." +export const ERROR_LEDGER_NO_APP = "There is no Ledger app available for this network." + +type NativeLedgerError = { + message: string + name?: string // if error raised by @zondax/* + statusCode?: number // if error raised by @zondax/* + returnCode?: number // if error raised by @ledgerhq/hw-transport +} + +// used to generate an error-like object when using an api (substrate legacy app) that returns error message as part of the response without throwing it +export const getCustomNativeLedgerError = ( + message: string, + statusCode?: number, +): NativeLedgerError => { + const error = new Error(message) as NativeLedgerError + error.name = "Unknown" + error.statusCode = statusCode + error.returnCode = statusCode + return error +} + +type TalismanLedgerErrorName = + | "Unknown" + | "UnsupportedVersion" + | "InvalidApp" + | "NotFound" + | "Timeout" + | "Locked" + | "BrowserSecurity" + | "Busy" + | "Network" + | "InvalidRequest" + | "UserRejected" + | "GenericAppRequired" + +export class TalismanLedgerError extends Error { + constructor(name: TalismanLedgerErrorName, message: string, options?: ErrorOptions) { + super(message, options) + this.name = name || "Unknown" + } +} + +export const getCustomTalismanLedgerError = (errorOrMessage: unknown): TalismanLedgerError => { + if (errorOrMessage instanceof TalismanLedgerError) return errorOrMessage + + if (typeof errorOrMessage === "string") return new TalismanLedgerError("Unknown", errorOrMessage) + + return getTalismanLedgerError(errorOrMessage as NativeLedgerError, "substrate") +} + +export const getTalismanLedgerError = (error: unknown, appName: string): TalismanLedgerError => { + log.log("getTalismanLedgerError", { error }) + + const cause = error as NativeLedgerError + + // Generic errors + if (cause.name) { + switch (cause.name) { + case "SecurityError": + // happens on some browser when ledger is plugged after browser is launched + // when this happens, the only way to connect is to restart all instances of the browser + return new TalismanLedgerError( + "BrowserSecurity", + t("Failed to connect USB. Restart your browser and retry."), + { cause }, + ) + + case "NotFoundError": + return new TalismanLedgerError("NotFound", t("Device not found"), { cause }) + + case "NetworkError": + return new TalismanLedgerError( + "Network", + t("Failed to connect to Ledger (network error)"), + { cause }, + ) + + case "InvalidStateError": + return new TalismanLedgerError( + "Unknown", + t("Failed to connect to Ledger (invalid state)"), + { cause }, + ) + + case "UnsupportedVersion": // For ethereum only + return new TalismanLedgerError( + "UnsupportedVersion", + t("Please update your Ledger Ethereum app"), + { cause }, + ) + + case "TransportStatusError": + return getErrorFromCode(cause.statusCode, appName, cause) + + case "TransportOpenUserCancelled": // occurs when user doesn't select a device in the browser popup (also noticed it when device is turned off or sleeping) + return new TalismanLedgerError("Unknown", t("Failed to connect to your Ledger"), { cause }) + + case "TransportWebUSBGestureRequired": + case "TransportInterfaceNotAvailable": // occurs after unlock, or if browser requires a click to connect usb (only on MacOS w/chrome) + return new TalismanLedgerError( + "BrowserSecurity", + t("Failed to connect to your Ledger (browser security)"), + { cause }, + ) + } + } + + if (cause.returnCode) return getErrorFromCode(cause.returnCode, appName, cause) + + // Polkadot specific errors, wrapped in simple Error object + // only message is available + // TODO Check if still a thing since ledger generic app + switch (cause.message) { + case "Timeout": // this one is throw by Talisman in case of timeout when calling ledger.getAddress + return new TalismanLedgerError("Timeout", t("Failed to connect to your Ledger (timeout)"), { + cause, + }) + + case "Failed to execute 'requestDevice' on 'USB': Must be handling a user gesture to show a permission request.": + return new TalismanLedgerError( + "BrowserSecurity", + t("Failed to connect to your Ledger (browser security)"), + { cause }, + ) + + case "App does not seem to be open": // locked but underlying app is eth + case "Unknown Status Code: 28161": // just unlocked, didn't open kusama yet + case "Unknown Status Code: 38913": // just unlocked, didn't open kusama yet + return new TalismanLedgerError( + "InvalidApp", + t(`Please open {{appName}} app on your Ledger.`, { + appName: capitalize(appName), + }), + { cause }, + ) + + case "Unknown Status Code: 26628": + case "Transaction rejected": // unplugged then retry while on lock screen + return new TalismanLedgerError("Locked", t("Please unlock your Ledger"), { cause }) + + case "Device is busy": + return new TalismanLedgerError("Busy", t("Failed to connect to Ledger (device is busy)"), { + cause, + }) + + case "NetworkError: Failed to execute 'transferOut' on 'USBDevice': A transfer error has occurred.": + case "NetworkError: Failed to execute 'transferIn' on 'USBDevice': A transfer error has occurred.": + return new TalismanLedgerError("Network", t("Failed to connect to Ledger (network error)"), { + cause, + }) + + case "Instruction not supported": + return new TalismanLedgerError( + "InvalidRequest", + t( + "This instruction is not supported on your ledger. You should check for firmware and app updates in Ledger Live before trying again.", + ), + { cause }, + ) + + case ERROR_LEDGER_EVM_CANNOT_SIGN_SUBSTRATE: + return new TalismanLedgerError( + "InvalidRequest", + t("This transaction cannot be signed via an Ethereum Ledger account."), + { cause }, + ) + + case ERROR_LEDGER_NO_APP: + return new TalismanLedgerError( + "InvalidRequest", + t("There is no Ledger app available for this network."), + { cause }, + ) + } + + // eslint-disable-next-line no-console + DEBUG && console.warn("unmanaged ledger error", { error }) + + return new TalismanLedgerError( + "Unknown", + t("Failed to connect to your Ledger. Click here to retry."), + { cause }, + ) +} + +const getErrorFromCode = (code: number | undefined, appName: string, cause: unknown) => { + switch (code) { + case 27014: + return new TalismanLedgerError( + "UserRejected", + t("Transaction was rejected by the Ledger device."), + { cause }, + ) + + case 27404: // locked + case 27010: + return new TalismanLedgerError("Locked", t("Please unlock your Ledger"), { cause }) + + case 28160: // non-compatible app + case 28161: // home screen on Flex + case 25831: // home screen + case 25873: + case 27906: + case 57346: + default: + return new TalismanLedgerError( + "InvalidApp", + t(`Please open {{appName}} app on your Ledger.`, { + appName: capitalize(appName), + }), + { cause }, + ) + } +} diff --git a/apps/extension/src/ui/hooks/ledger/useLedgerEthereum.ts b/apps/extension/src/ui/hooks/ledger/useLedgerEthereum.ts index 889d978fab..fff3efc2ec 100644 --- a/apps/extension/src/ui/hooks/ledger/useLedgerEthereum.ts +++ b/apps/extension/src/ui/hooks/ledger/useLedgerEthereum.ts @@ -1,135 +1,161 @@ +import { stripHexPrefix } from "@ethereumjs/util" import LedgerEthereumApp from "@ledgerhq/hw-app-eth" -import Transport from "@ledgerhq/hw-transport" -import TransportWebUSB from "@ledgerhq/hw-transport-webusb" -import { throwAfter } from "@talismn/util" -import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { SignTypedDataVersion, TypedDataUtils } from "@metamask/eth-sig-util" +import { t } from "i18next" +import { useCallback, useRef } from "react" import { useTranslation } from "react-i18next" -import { gte } from "semver" +import { + hexToBigInt, + isHex, + serializeTransaction, + Signature, + signatureToHex, + TransactionRequest, +} from "viem" -import { getEthLedgerDerivationPath } from "@extension/core" -import { LEDGER_ETHEREUM_MIN_VERSION, log } from "@extension/shared" +import { EthSignMessageMethod, getTransactionSerializable } from "@extension/core" -import { useSetInterval } from "../useSetInterval" -import { getLedgerErrorProps, LedgerError, LedgerStatus } from "./common" +import { getTalismanLedgerError, TalismanLedgerError } from "./errors" +import { useLedgerTransport } from "./useLedgerTransport" -export const useLedgerEthereum = (persist = false) => { +type LedgerRequest = (ledger: LedgerEthereumApp) => Promise + +export const useLedgerEthereum = () => { const { t } = useTranslation() - const [isLoading, setIsLoading] = useState(false) - const [refreshCounter, setRefreshCounter] = useState(0) - const [ledgerError, setLedgerError] = useState() - const [isReady, setIsReady] = useState(false) - const [ledger, setLedger] = useState(null) - const refConnecting = useRef(false) - const refTransport = useRef(null) - - useEffect(() => { - return () => { - // ensures the transport is closed on unmount, allowing other tabs to access the ledger - // the persist argument can be used to prevent this behaviour, when the hook is used - // in two components that need to share the ledger connection - if (!persist && ledger?.transport) { - ledger.transport.close() + const refIsBusy = useRef(false) + const { ensureTransport, closeTransport } = useLedgerTransport() + + const withLedger = useCallback( + async (request: LedgerRequest): Promise => { + if (refIsBusy.current) throw new TalismanLedgerError("Busy", t("Ledger is busy")) + + refIsBusy.current = true + + try { + const transport = await ensureTransport() + const ledger = new LedgerEthereumApp(transport) + + return await request(ledger) + } catch (err) { + await closeTransport() + throw getTalismanLedgerError(err, "Ethereum") + } finally { + refIsBusy.current = false + } + }, + [closeTransport, ensureTransport, t], + ) + + const sign = useCallback( + ( + chainId: number, + method: EthSignMessageMethod | "eth_sendTransaction", + payload: unknown, + derivationPath: string, + ) => { + return withLedger((ledger) => + signWithLedger(ledger, chainId, method, payload, derivationPath), + ) + }, + [withLedger], + ) + + const getAddress = useCallback( + (derivationPath: string) => { + return withLedger((ledger) => ledger.getAddress(derivationPath, false)) + }, + [withLedger], + ) + + return { + getAddress, + sign, + } +} + +const signWithLedger = async ( + ledger: LedgerEthereumApp, + chainId: number, + method: EthSignMessageMethod | "eth_sendTransaction", + payload: unknown, + accountPath: string, +): Promise<`0x${string}`> => { + switch (method) { + case "eth_signTypedData_v3": + case "eth_signTypedData_v4": { + const jsonMessage = typeof payload === "string" ? JSON.parse(payload) : payload + + try { + // Nano S doesn't support signEIP712Message, fallback to signEIP712HashedMessage in case of error + // see https://github.com/LedgerHQ/ledger-live/tree/develop/libs/ledgerjs/packages/hw-app-eth#signeip712message + + // eslint-disable-next-line no-var + var sig = await ledger.signEIP712Message(accountPath, jsonMessage) + } catch { + // fallback for ledger Nano S + const { domain, types, primaryType, message } = TypedDataUtils.sanitizeData(jsonMessage) + const domainSeparatorHex = TypedDataUtils.hashStruct( + "EIP712Domain", + domain, + types, + SignTypedDataVersion.V4, + ).toString("hex") + const hashStructMessageHex = TypedDataUtils.hashStruct( + primaryType as string, + message, + types, + SignTypedDataVersion.V4, + ).toString("hex") + + sig = await ledger.signEIP712HashedMessage( + accountPath, + domainSeparatorHex, + hashStructMessageHex, + ) } - } - }, [ledger?.transport, persist]) - const connectLedger = useCallback(async (resetError?: boolean) => { - if (refConnecting.current) return - refConnecting.current = true + return signatureToHex(toSignature(sig)) + } - setIsLoading(true) - setIsReady(false) + case "personal_sign": { + // ensure that it is hex encoded + const messageHex = isHex(payload) ? payload : Buffer.from(payload as string).toString("hex") - // when displaying an error and polling silently, on the UI we don't want the error to disappear - // so error should be cleared explicitly - if (resetError) setLedgerError(undefined) + const sig = await ledger.signPersonalMessage(accountPath, stripHexPrefix(messageHex)) - try { - refTransport.current = await TransportWebUSB.create() + return signatureToHex(toSignature(sig)) + } - const ledger = new LedgerEthereumApp(refTransport.current) - // this may hang at this point just after plugging the ledger - await Promise.race([ - ledger.getAddress(getEthLedgerDerivationPath("LedgerLive")), - throwAfter(5_000, "Timeout on Ledger Ethereum connection"), - ]) + case "eth_sendTransaction": { + const txRequest = payload as TransactionRequest + const baseTx = getTransactionSerializable(txRequest, chainId) + const serialized = serializeTransaction(baseTx) - const { version } = await ledger.getAppConfiguration() - if (!gte(version, LEDGER_ETHEREUM_MIN_VERSION)) - throw new LedgerError("Unsupported version", "UnsupportedVersion") + const sig = await ledger.signTransaction(accountPath, stripHexPrefix(serialized), null) - setLedgerError(undefined) - setLedger(ledger) - setIsReady(true) - } catch (err) { - log.error("connectLedger Ethereum : " + (err as LedgerError).message, { err }) + return serializeTransaction(baseTx, toSignature(sig)) + } - try { - await refTransport.current?.close() - refTransport.current = null - } catch (err2) { - log.error("Can't close ledger transport", err2) - // ignore - } - setLedger(null) - setLedgerError(err as LedgerError) + default: { + throw new Error(t("This type of message cannot be signed with ledger.")) } + } +} - refConnecting.current = false - setIsLoading(false) - }, []) - - const { status, message, requiresManualRetry } = useMemo<{ - status: LedgerStatus - message: string - requiresManualRetry: boolean - }>(() => { - if (ledgerError) return getLedgerErrorProps(ledgerError, "Ethereum") - - if (isLoading) - return { - status: "connecting", - message: t(`Connecting to Ledger...`), - requiresManualRetry: false, - } +const toSignature = ({ v, r, s }: { v: string | number; r: string; s: string }): Signature => { + const parseV = (v: string | number) => { + const parsed = typeof v === "string" ? hexToBigInt(`0x${v}`) : BigInt(v) - if (isReady) - return { - status: "ready", - message: t("Successfully connected to Ledger."), - requiresManualRetry: false, - } + // ideally this should be done in viem + if (parsed === 0n) return 27n + if (parsed === 1n) return 28n - return { status: "unknown", message: "", requiresManualRetry: false } - }, [ledgerError, isLoading, isReady, t]) - - // if not connected, poll every 2 seconds - // this will recreate the ledger instance which triggers automatic connection - useSetInterval(() => { - if ( - !isLoading && - !requiresManualRetry && - ["warning", "error", "unknown", "connecting"].includes(status) - ) - setRefreshCounter((idx) => idx + 1) - }, 2000) - - // manual connection - const refresh = useCallback(() => { - connectLedger(true) - }, [connectLedger]) - - useEffect(() => { - connectLedger() - }, [connectLedger, refreshCounter]) + return parsed + } return { - isLoading, - isReady, - requiresManualRetry, - status, - message, - ledger, - refresh, + v: parseV(v), + r: `0x${r}`, + s: `0x${s}`, } } diff --git a/apps/extension/src/ui/hooks/ledger/useLedgerSubstrateGeneric.ts b/apps/extension/src/ui/hooks/ledger/useLedgerSubstrateGeneric.ts index b2eff4f8f2..439ec122b0 100644 --- a/apps/extension/src/ui/hooks/ledger/useLedgerSubstrateGeneric.ts +++ b/apps/extension/src/ui/hooks/ledger/useLedgerSubstrateGeneric.ts @@ -1,220 +1,127 @@ -import Transport from "@ledgerhq/hw-transport" -import TransportWebUSB from "@ledgerhq/hw-transport-webusb" -import { throwAfter } from "@talismn/util" +import { TypeRegistry } from "@polkadot/types" +import { hexToU8a, u8aToHex, u8aWrapBytes } from "@polkadot/util" import { PolkadotGenericApp } from "@zondax/ledger-substrate" import { SubstrateAppParams } from "@zondax/ledger-substrate/dist/common" -import { log } from "extension-shared" -import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { + AccountJsonHardwareSubstrate, + isJsonPayload, + SignerPayloadJSON, + SignerPayloadRaw, +} from "extension-core" +import { t } from "i18next" +import { useCallback, useRef } from "react" import { useTranslation } from "react-i18next" -import { useSetInterval } from "../useSetInterval" -import { - getLedgerErrorProps, - getPolkadotLedgerDerivationPath, - LedgerError, - LedgerStatus, -} from "./common" +import { getPolkadotLedgerDerivationPath } from "./common" +import { getCustomTalismanLedgerError, getTalismanLedgerError, TalismanLedgerError } from "./errors" +import { useLedgerTransport } from "./useLedgerTransport" + +type LedgerRequest = (ledger: PolkadotGenericApp) => Promise type UseLedgerSubstrateGenericProps = { - persist?: boolean - app?: SubstrateAppParams | null + legacyApp?: SubstrateAppParams | null } const DEFAULT_PROPS: UseLedgerSubstrateGenericProps = {} -const LEDGER_IN_PROGRESS_ERROR = "An operation that changes interface state is in progress." - -const safelyCreateTransport = async (attempt = 1): Promise => { - if (attempt > 5) throw new Error("Unable to connect to Ledger") - try { - return await TransportWebUSB.create() - } catch (e) { - if ((e as Error).message.includes(LEDGER_IN_PROGRESS_ERROR)) { - await new Promise((resolve) => setTimeout(resolve, 200 * attempt)) - return await safelyCreateTransport(attempt + 1) - } else throw e - } -} +export const useLedgerSubstrateGeneric = ({ legacyApp } = DEFAULT_PROPS) => { + const { t } = useTranslation() + const refIsBusy = useRef(false) + const { ensureTransport, closeTransport } = useLedgerTransport() -const safelyCloseTransport = async (transport: Transport | null, attempt = 1): Promise => { - if (attempt > 5) throw new Error("Unable to disconnect Ledger") - try { - await transport?.close() - } catch (e) { - if ((e as Error).message.includes(LEDGER_IN_PROGRESS_ERROR)) { - await new Promise((resolve) => setTimeout(resolve, 100 * attempt)) - return await safelyCloseTransport(transport, attempt + 1) - } else throw e - } -} + const withLedger = useCallback( + async (request: LedgerRequest): Promise => { + if (refIsBusy.current) throw new TalismanLedgerError("Busy", t("Ledger is busy")) -const safelyGetAddress = async ( - ledger: PolkadotGenericApp, - bip44path: string, - ss58prefix = 42, - attempt = 1, -): Promise<{ address: string }> => { - if (!ledger) throw new Error("Ledger not connected") + refIsBusy.current = true - if (attempt > 5) throw new Error("Unable to connect to Ledger") - try { - return await ledger.getAddress(bip44path, ss58prefix, false) - } catch (err) { - if ( - (err as Error).message.includes(LEDGER_IN_PROGRESS_ERROR) || - (err as Error).message === "Unknown transport error" - ) { - await new Promise((resolve) => setTimeout(resolve, 200 * attempt)) - return await safelyGetAddress(ledger, bip44path, undefined, attempt + 1) - } else throw err - } -} + try { + const transport = await ensureTransport() + const ledger = new PolkadotGenericApp(transport) -export const useLedgerSubstrateGeneric = ({ persist, app } = DEFAULT_PROPS) => { - const { t } = useTranslation() - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState() - const [isReady, setIsReady] = useState(false) - const [ledger, setLedger] = useState(null) - - const refConnecting = useRef(false) - const refTransport = useRef(null) - - useEffect(() => { - return () => { - // ensures the transport is closed on unmount, allowing other tabs to access the ledger - // the persist argument can be used to prevent this behaviour, when the hook is used - // in two components that need to share the ledger connection - if (!persist && ledger?.transport) { - safelyCloseTransport(ledger.transport as Transport).then(() => { - refTransport.current = null - setLedger(null) - }) + return await request(ledger) + } catch (err) { + await closeTransport() + throw getTalismanLedgerError(err, legacyApp ? "Polkadot Migration" : "Polkadot") + } finally { + refIsBusy.current = false } - } - }, [ledger, persist]) - - const getAddress = useCallback( - async (bip44path: string, ss58prefix = 42) => { - if (!ledger) return - - return await Promise.race([ - safelyGetAddress(ledger, bip44path, ss58prefix), - throwAfter(5_000, "Timeout on Ledger Substrate Generic getAddress"), - ]) }, - [ledger], + [closeTransport, ensureTransport, legacyApp, t], ) - const connectLedger = useCallback( - async (resetError?: boolean) => { - if (refConnecting.current) return - refConnecting.current = true - - setIsReady(false) - setIsLoading(true) - - // when displaying an error and polling silently, on the UI we don't want the error to disappear - // so error should be cleared explicitly - if (resetError) setError(undefined) - - try { - refTransport.current = await safelyCreateTransport() - - const ledger = new PolkadotGenericApp(refTransport.current) + const sign = useCallback( + ( + payload: SignerPayloadJSON | SignerPayloadRaw, + account: AccountJsonHardwareSubstrate, + registry?: TypeRegistry | null, + txMetadata?: string | null, + ) => { + return withLedger((ledger) => { + return signPayload(ledger, payload, account, legacyApp, registry, txMetadata) + }) + }, + [withLedger, legacyApp], + ) - const bip44path = getPolkadotLedgerDerivationPath({ app }) + const getAddress = useCallback( + (bip44path: string, ss58prefix = 42) => { + return withLedger((ledger) => { + return ledger.getAddress(bip44path, ss58prefix, false) + }) + }, + [withLedger], + ) - // verify that Ledger connection is ready by querying first address - await Promise.race([ - safelyGetAddress(ledger, bip44path), - throwAfter(5_000, "Timeout on Ledger Substrate Generic connection"), - ]) + return { + getAddress, + sign, + } +} - setLedger(ledger) - setError(undefined) - setIsReady(true) - } catch (err) { - log.error("connectLedger Substrate Generic : " + (err as LedgerError).message, { err }) - - try { - if ( - refTransport.current && - "device" in refTransport.current && - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (refTransport.current.device as any).opened // TODO look into this - ) - await refTransport.current?.close() - refTransport.current = null - } catch (err2) { - log.error("Can't close ledger transport", err2) - // ignore - } - - setLedger(null) - setError(err as Error) - } +const signPayload = async ( + ledger: PolkadotGenericApp, + payload: SignerPayloadJSON | SignerPayloadRaw, + account: AccountJsonHardwareSubstrate, + legacyApp?: SubstrateAppParams | null, + registry?: TypeRegistry | null, + txMetadata?: string | null, +) => { + if (!ledger) throw new Error("Ledger not connected") - refConnecting.current = false - setIsLoading(false) - }, - [app], - ) + const path = getPolkadotLedgerDerivationPath({ ...account, legacyApp }) - const { status, message, requiresManualRetry } = useMemo<{ - status: LedgerStatus - message: string - requiresManualRetry: boolean - }>(() => { - if (error) return getLedgerErrorProps(error, app ? "Polkadot Migration" : "Polkadot") - - if (isLoading) - return { - status: "connecting", - message: t(`Connecting to Ledger...`), - requiresManualRetry: false, - } + if (isJsonPayload(payload)) { + if (!payload.withSignedTransaction) + throw getCustomTalismanLedgerError( + t("This dapp needs to be updated in order to support Ledger signing."), + ) + if (!registry) throw getCustomTalismanLedgerError(t("Missing registry.")) - if (isReady) - return { - status: "ready", - message: t("Successfully connected to Ledger."), - requiresManualRetry: false, - } + const hasCheckMetadataHash = registry.metadata.extrinsic.signedExtensions.some( + (ext) => ext.identifier.toString() === "CheckMetadataHash", + ) + if (!hasCheckMetadataHash) + throw getCustomTalismanLedgerError( + t("This network doesn't support Ledger Polkadot Generic App."), + ) + if (!txMetadata) throw getCustomTalismanLedgerError(t("Missing short metadata")) - return { status: "unknown", message: "", requiresManualRetry: false } - }, [isReady, isLoading, error, app, t]) + const unsigned = registry.createType("ExtrinsicPayload", payload) - // automatic connection (startup + polling) - // use a ref to avoid re-renders when refreshCounter changes - const refreshCounterRef = useRef(0) + const blob = Buffer.from(unsigned.toU8a(true)) + const metadata = Buffer.from(hexToU8a(txMetadata)) - useEffect(() => { - connectLedger() - }, [connectLedger]) + const { signature } = await ledger.signWithMetadata(path, blob, metadata) - // if not connected, poll every 2 seconds - // this will recreate the ledger instance which triggers automatic connection - useSetInterval(() => { - if (!isLoading && !requiresManualRetry && ["warning", "error", "unknown"].includes(status)) { - refreshCounterRef.current += 1 - connectLedger() - } - }, 2000) + return u8aToHex(new Uint8Array(signature)) + } else { + // raw payload + const unsigned = u8aWrapBytes(payload.data) - // manual connection - const refresh = useCallback(() => { - connectLedger(true) - }, [connectLedger]) + const { signature } = await ledger.signRaw(path, Buffer.from(unsigned)) - return { - isLoading, - isReady, - requiresManualRetry, - status, - message, - ledger, - getAddress, - refresh, + // skip first byte (sig type) or signatureVerify fails, this seems specific to ed25519 signatures + return u8aToHex(new Uint8Array(signature.slice(1))) } } diff --git a/apps/extension/src/ui/hooks/ledger/useLedgerSubstrateLegacy.ts b/apps/extension/src/ui/hooks/ledger/useLedgerSubstrateLegacy.ts index cfa91d00f8..f8bb379c53 100644 --- a/apps/extension/src/ui/hooks/ledger/useLedgerSubstrateLegacy.ts +++ b/apps/extension/src/ui/hooks/ledger/useLedgerSubstrateLegacy.ts @@ -1,234 +1,177 @@ -import Transport from "@ledgerhq/hw-transport" -import TransportWebUSB from "@ledgerhq/hw-transport-webusb" -import { assert } from "@polkadot/util" -import { throwAfter } from "@talismn/util" +import { TypeRegistry } from "@polkadot/types" +import { u8aToHex, u8aWrapBytes } from "@polkadot/util" import { SubstrateApp } from "@zondax/ledger-substrate" -import { log } from "extension-shared" -import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { + AccountJsonHardwareSubstrate, + isJsonPayload, + SignerPayloadJSON, + SignerPayloadRaw, +} from "extension-core" +import { t } from "i18next" +import { useCallback, useRef } from "react" import { useTranslation } from "react-i18next" import { useChainByGenesisHash } from "@ui/state" -import { getIsLedgerCapable } from "@ui/util/getIsLedgerCapable" -import { useSetInterval } from "../useSetInterval" +import { LEDGER_HARDENED_OFFSET, LEDGER_SUCCESS_CODE } from "./common" import { ERROR_LEDGER_EVM_CANNOT_SIGN_SUBSTRATE, ERROR_LEDGER_NO_APP, - getLedgerErrorProps, - LEDGER_HARDENED_OFFSET, - LedgerError, - LedgerStatus, -} from "./common" + getCustomNativeLedgerError, + getTalismanLedgerError, + TalismanLedgerError, +} from "./errors" import { useLedgerSubstrateAppByChain } from "./useLedgerSubstrateApp" +import { useLedgerTransport } from "./useLedgerTransport" -const LEDGER_IN_PROGRESS_ERROR = "An operation that changes interface state is in progress." - -const safelyCreateTransport = async (attempt = 1): Promise => { - if (attempt > 5) throw new Error("Unable to connect to Ledger") - try { - return await TransportWebUSB.create() - } catch (e) { - if ((e as Error).message.includes(LEDGER_IN_PROGRESS_ERROR)) { - await new Promise((resolve) => setTimeout(resolve, 200 * attempt)) - return await safelyCreateTransport(attempt + 1) - } else throw e - } -} - -const safelyCloseTransport = async (transport: Transport | null, attempt = 1): Promise => { - if (attempt > 5) throw new Error("Unable to disconnect Ledger") - try { - await transport?.close() - } catch (e) { - if ((e as Error).message.includes(LEDGER_IN_PROGRESS_ERROR)) { - await new Promise((resolve) => setTimeout(resolve, 100 * attempt)) - return await safelyCloseTransport(transport, attempt + 1) - } else throw e - } -} - -const safelyGetAddress = async ( - ledger: SubstrateApp, - accountIndex: number, - addressIndex: number, - attempt = 1, -): Promise<{ address: string }> => { - if (!ledger) throw new Error("Ledger not connected") - - if (attempt > 5) throw new Error("Unable to connect to Ledger") - try { - const change = 0 - const addressOffset = 0 - - const { address, error_message, return_code } = await ledger.getAddress( - LEDGER_HARDENED_OFFSET + accountIndex, - LEDGER_HARDENED_OFFSET + change, - LEDGER_HARDENED_OFFSET + addressOffset, - false, - ) - if (!address) throw new LedgerError(error_message, "GetAddressError", return_code) - return { address } - } catch (err) { - if ( - (err as Error).message.includes(LEDGER_IN_PROGRESS_ERROR) || - (err as Error).message === "Unknown transport error" - ) { - await new Promise((resolve) => setTimeout(resolve, 200 * attempt)) - return await safelyGetAddress(ledger, accountIndex, addressIndex, attempt + 1) - } else throw err - } -} +type LedgerRequest = (ledger: SubstrateApp) => Promise -export const useLedgerSubstrateLegacy = (genesis?: string | null, persist = false) => { +export const useLedgerSubstrateLegacy = (genesis?: string | null) => { const { t } = useTranslation() const chain = useChainByGenesisHash(genesis) const app = useLedgerSubstrateAppByChain(chain) - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState() - const [isReady, setIsReady] = useState(false) - const [ledger, setLedger] = useState(null) - - const refConnecting = useRef(false) - const refTransport = useRef(null) - - useEffect(() => { - return () => { - // ensures the transport is closed on unmount, allowing other tabs to access the ledger - // the persist argument can be used to prevent this behaviour, when the hook is used - // in two components that need to share the ledger connection - if (!persist && ledger?.transport) { - safelyCloseTransport(ledger.transport as Transport).then(() => { - refTransport.current = null - setLedger(null) - }) + const { ensureTransport, closeTransport } = useLedgerTransport() + const refIsBusy = useRef(false) + + const withLedger = useCallback( + async (request: LedgerRequest): Promise => { + if (refIsBusy.current) throw new TalismanLedgerError("Busy", t("Ledger is busy")) + + refIsBusy.current = true + + try { + if (chain?.account === "secp256k1") + throw new TalismanLedgerError("Unknown", ERROR_LEDGER_EVM_CANNOT_SIGN_SUBSTRATE) + if (!app?.cla) throw new TalismanLedgerError("Unknown", ERROR_LEDGER_NO_APP) + + const transport = await ensureTransport() + const ledger = new SubstrateApp(transport, app.cla, app.slip0044) + + return await request(ledger) + } catch (err) { + await closeTransport() + throw getTalismanLedgerError(err, app?.name ?? "Unknown app") + } finally { + refIsBusy.current = false } - } - }, [ledger, persist]) + }, + [app, chain?.account, closeTransport, ensureTransport, t], + ) const getAddress = useCallback( - async (accountIndex = 0, addressIndex = 0) => { - if (!ledger) return - - return await Promise.race([ - safelyGetAddress(ledger, accountIndex, addressIndex), - throwAfter(5_000, "Timeout on Ledger Substrate Legacy getAddress"), - ]) + (accountIndex = 0, addressIndex = 0) => { + return withLedger((ledger) => getAccountAddress(ledger, accountIndex, addressIndex)) }, - [ledger], + [withLedger], ) - const connectLedger = useCallback( - async (resetError?: boolean) => { - if (refConnecting.current) return - refConnecting.current = true + const sign = useCallback( + ( + payload: SignerPayloadJSON | SignerPayloadRaw, + account: AccountJsonHardwareSubstrate, + registry: TypeRegistry, + ) => { + return withLedger((ledger) => + isJsonPayload(payload) + ? signJsonPayload(ledger, payload, account, registry) + : signRawPayload(ledger, payload, account), + ) + }, + [withLedger], + ) - setIsReady(false) - setIsLoading(true) - // when displaying an error and polling silently, on the UI we don't want the error to disappear - // so error should be cleared explicitly - if (resetError) setError(undefined) + return { + sign, + getAddress, + app, + } +} - try { - assert(getIsLedgerCapable(), t("Sorry, Ledger is not supported on your browser.")) - assert(!chain || chain.account !== "secp256k1", ERROR_LEDGER_EVM_CANNOT_SIGN_SUBSTRATE) - assert(app?.cla, ERROR_LEDGER_NO_APP) +const getAccountAddress = async ( + ledger: SubstrateApp, + accountIndex: number, + addressIndex: number, +): Promise<{ address: string }> => { + const change = 0 - refTransport.current = await safelyCreateTransport() + const { address, error_message, return_code } = await ledger.getAddress( + LEDGER_HARDENED_OFFSET + accountIndex, + LEDGER_HARDENED_OFFSET + change, + LEDGER_HARDENED_OFFSET + addressIndex, + false, + ) - const ledger = new SubstrateApp(refTransport.current, app.cla, app.slip0044) + if (!address) + throw getCustomNativeLedgerError( + error_message || "Ledger provided an empty address", + return_code, + ) - // verify that Ledger connection is ready by querying first address - await Promise.race([ - safelyGetAddress(ledger, 0, 0), - throwAfter(5_000, "Timeout on Ledger Substrate connection"), - ]) + return { address } +} - setLedger(ledger) - setError(undefined) - setIsReady(true) - } catch (err) { - log.error("connectLedger Substrate Legacy " + (err as Error).message, { err }) - - try { - if ( - refTransport.current && - "device" in refTransport.current && - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (refTransport.current.device as any).opened // TODO look into this - ) - await refTransport.current?.close() - refTransport.current = null - } catch (err2) { - log.error("Can't close ledger transport", err2) - // ignore - } - - setLedger(null) - setError(err as Error) - } +const signJsonPayload = async ( + ledger: SubstrateApp, + payload: SignerPayloadJSON, + account: AccountJsonHardwareSubstrate, + registry: TypeRegistry, +) => { + // Legacy dapps don't support the CheckMetadataHash signed extension + if (payload.signedExtensions.includes("CheckMetadataHash")) + throw new TalismanLedgerError( + "GenericAppRequired", + "This network requires the Polkadot Generic app", + ) - refConnecting.current = false - setIsLoading(false) - }, - [app, chain, t], + const extrinsicPayload = registry.createType("ExtrinsicPayload", payload, { + version: payload.version, + }) + + const unsigned = extrinsicPayload.toU8a(true) + + const { + signature: signatureBuffer, + error_message, + return_code, + } = await ledger.sign( + LEDGER_HARDENED_OFFSET + (account.accountIndex ?? 0), + LEDGER_HARDENED_OFFSET + 0, + LEDGER_HARDENED_OFFSET + (account.addressOffset ?? 0), + Buffer.from(unsigned), ) - const { status, message, requiresManualRetry } = useMemo<{ - status: LedgerStatus - message: string - requiresManualRetry: boolean - }>(() => { - if (error) return getLedgerErrorProps(error, app?.name ?? t("Unknown app")) - - if (isLoading) - return { - status: "connecting", - message: t(`Connecting to Ledger...`), - requiresManualRetry: false, - } + if (return_code !== LEDGER_SUCCESS_CODE) + throw getCustomNativeLedgerError(error_message, return_code) - if (isReady) - return { - status: "ready", - message: t("Successfully connected to Ledger."), - requiresManualRetry: false, - } - - return { status: "unknown", message: "", requiresManualRetry: false } - }, [isReady, isLoading, error, app, t]) - - // automatic connection (startup + polling) - // use a ref to avoid re-renders when refreshCounter changes - const refreshCounterRef = useRef(0) + return u8aToHex(new Uint8Array(signatureBuffer)) +} - // automatic connection (startup + polling) - useEffect(() => { - connectLedger() - }, [connectLedger]) +const signRawPayload = async ( + ledger: SubstrateApp, + payload: SignerPayloadRaw, + account: AccountJsonHardwareSubstrate, +) => { + const unsigned = u8aWrapBytes(payload.data) + if (unsigned.length > 256) + throw new TalismanLedgerError( + "InvalidRequest", + t("The message is too long to be signed with Ledger."), + ) - // if not connected, poll every 2 seconds - // this will recreate the ledger instance which triggers automatic connection - useSetInterval(() => { - if (!isLoading && !requiresManualRetry && ["warning", "error", "unknown"].includes(status)) { - refreshCounterRef.current += 1 - connectLedger() - } - }, 2000) + const { + signature: signatureBuffer, + error_message, + return_code, + } = await ledger.signRaw( + LEDGER_HARDENED_OFFSET + (account.accountIndex ?? 0), + LEDGER_HARDENED_OFFSET + 0, + LEDGER_HARDENED_OFFSET + (account.addressOffset ?? 0), + Buffer.from(unsigned), + ) - // manual connection - const refresh = useCallback(() => { - connectLedger(true) - }, [connectLedger]) + if (return_code !== LEDGER_SUCCESS_CODE) + throw getCustomNativeLedgerError(error_message, return_code) - return { - isLoading, - isReady, - requiresManualRetry, - status, - message, - network: app, - ledger, - refresh, - getAddress, - } + // skip first byte (sig type) or signatureVerify fails, this seems specific to ed25519 signatures + return u8aToHex(new Uint8Array(signatureBuffer.slice(1))) } diff --git a/apps/extension/src/ui/hooks/ledger/useLedgerTransport.ts b/apps/extension/src/ui/hooks/ledger/useLedgerTransport.ts new file mode 100644 index 0000000000..44aeab24fa --- /dev/null +++ b/apps/extension/src/ui/hooks/ledger/useLedgerTransport.ts @@ -0,0 +1,64 @@ +import Transport from "@ledgerhq/hw-transport" +import TransportWebUSB from "@ledgerhq/hw-transport-webusb" +import { log } from "extension-shared" +import { useCallback, useEffect, useRef } from "react" + +import { getIsLedgerCapable } from "@ui/util/getIsLedgerCapable" + +const LEDGER_IN_PROGRESS_ERROR = "An operation that changes interface state is in progress." + +const safelyCreateTransport = async (attempt = 1): Promise => { + if (!getIsLedgerCapable()) throw new Error("Ledger is not supported on your browser.") + + if (attempt > 5) throw new Error("Unable to connect to Ledger") + try { + return await TransportWebUSB.create() + } catch (e) { + if ((e as Error).message.includes(LEDGER_IN_PROGRESS_ERROR)) { + await new Promise((resolve) => setTimeout(resolve, 200 * attempt)) + return await safelyCreateTransport(attempt + 1) + } else throw e + } +} + +const safelyCloseTransport = async (transport: Transport | null, attempt = 1): Promise => { + if (attempt > 5) throw new Error("Unable to disconnect Ledger") + try { + await transport?.close() + } catch (e) { + if ((e as Error).message.includes(LEDGER_IN_PROGRESS_ERROR)) { + await new Promise((resolve) => setTimeout(resolve, 100 * attempt)) + return await safelyCloseTransport(transport, attempt + 1) + } else throw e + } +} + +export const useLedgerTransport = () => { + const refTransport = useRef(null) + + const ensureTransport = useCallback(async () => { + if (!refTransport.current) { + refTransport.current = await safelyCreateTransport() + refTransport.current.on("disconnect", () => { + refTransport.current = null + }) + } + + return refTransport.current! + }, []) + + const closeTransport = useCallback(async () => { + if (!refTransport.current) return + + await safelyCloseTransport(refTransport.current) + refTransport.current = null + }, []) + + useEffect(() => { + return () => { + if (refTransport.current) safelyCloseTransport(refTransport.current).catch(log.error) + } + }, []) + + return { ensureTransport, closeTransport } +}