UNPKG

@ledgerhq/live-common

Version:
1,528 lines (1,366 loc) • 52.4 kB
import { useMemo, useState, useEffect, useRef, useCallback, RefObject } from "react"; import { useDispatch } from "react-redux"; import semver from "semver"; import { intervalToDuration } from "date-fns"; import { Account, AccountLike, AnyMessage, Operation, SignedOperation } from "@ledgerhq/types-live"; import { WalletHandlers, ServerConfig, WalletAPIServer } from "@ledgerhq/wallet-api-server"; import { Transport, Permission } from "@ledgerhq/wallet-api-core"; import { first } from "rxjs/operators"; import { getEnv } from "@ledgerhq/live-env"; import { UserRefusedOnDevice } from "@ledgerhq/errors"; import { WalletState } from "@ledgerhq/live-wallet/store"; import { endpoints as calEndpoints } from "@ledgerhq/cryptoassets/cal-client/state-manager/api"; import { ThunkDispatch, UnknownAction } from "@reduxjs/toolkit"; import { InfiniteData } from "@reduxjs/toolkit/query/react"; import type { TokensDataWithPagination, PageParam, } from "@ledgerhq/cryptoassets/cal-client/state-manager/types"; import { Subject } from "rxjs"; import { StateDB } from "../hooks/useDBRaw"; import { useFeatureFlags } from "../featureFlags/FeatureFlagsContext"; import { accountToWalletAPIAccount, currencyToWalletAPICurrency, setWalletApiIdForAccountId, } from "./converters"; import { isWalletAPISupportedCurrency } from "./helpers"; import { WalletAPICurrency, AppManifest, WalletAPIAccount, WalletAPICustomHandlers, DiscoverDB, } from "./types"; import { getMainAccount, getParentAccount } from "../account"; import { listSupportedCurrencies } from "../currencies"; import { getCryptoAssetsStore } from "@ledgerhq/cryptoassets/state"; import { TrackingAPI } from "./tracking"; import { bitcoinFamilyAccountGetXPubLogic, broadcastTransactionLogic, startExchangeLogic, completeExchangeLogic, CompleteExchangeRequest, CompleteExchangeUiRequest, receiveOnAccountLogic, signMessageLogic, signTransactionLogic, bitcoinFamilyAccountGetAddressLogic, bitcoinFamilyAccountGetAddressesLogic, bitcoinFamilyAccountGetPublicKeyLogic, signRawTransactionLogic, protectStorageLogic, } from "./logic"; import { handlers as featureFlagsHandlers } from "./FeatureFlags"; import { getAccountBridge } from "../bridge"; import openTransportAsSubject, { BidirectionalEvent } from "../hw/openTransportAsSubject"; import { AppResult } from "../hw/actions/app"; import { Transaction } from "../generated/types"; import { DISCOVER_INITIAL_CATEGORY, INITIAL_PLATFORM_STATE, MAX_RECENTLY_USED_LENGTH, } from "./constants"; import { LiveAppManifest } from "../platform/types"; import { ModularDrawerConfiguration } from "./ModularDrawer/types"; import { useCurrenciesUnderFeatureFlag } from "../modularDrawer/hooks/useCurrenciesUnderFeatureFlag"; export function safeGetRefValue<T>(ref: RefObject<T>): NonNullable<T> { if (!ref.current) { throw new Error("Ref objects doesn't have a current value"); } return ref.current; } export function useSetWalletAPIAccounts(accounts: AccountLike[]): void { useEffect(() => { accounts.forEach(account => { setWalletApiIdForAccountId(account.id); }); }, [accounts]); } export function useDAppManifestCurrencyIds(manifest: AppManifest) { return useMemo(() => { return ( manifest.dapp?.networks.map(network => { return network.currency; }) ?? [] ); }, [manifest.dapp?.networks]); } export interface UiHook { "account.request": (params: { currencyIds?: string[]; areCurrenciesFiltered?: boolean; useCase?: string; uiUseCase?: string; drawerConfiguration?: ModularDrawerConfiguration; onSuccess: (account: AccountLike, parentAccount: Account | undefined) => void; onCancel: () => void; }) => void; "account.receive": (params: { account: AccountLike; parentAccount: Account | undefined; accountAddress: string; onSuccess: (address: string) => void; onCancel: () => void; onError: (error: Error) => void; }) => void; "message.sign": (params: { account: AccountLike; message: AnyMessage; options: Parameters<WalletHandlers["message.sign"]>[0]["options"]; onSuccess: (signature: string) => void; onError: (error: Error) => void; onCancel: () => void; }) => void; "storage.get": WalletHandlers["storage.get"]; "storage.set": WalletHandlers["storage.set"]; "transaction.signRaw": (params: { account: AccountLike; parentAccount: Account | undefined; transaction: string; broadcast?: boolean; options: Parameters<WalletHandlers["transaction.sign"]>[0]["options"]; onSuccess: (signedOperation: SignedOperation) => void; onError: (error: Error) => void; }) => void; "transaction.sign": (params: { account: AccountLike; parentAccount: Account | undefined; signFlowInfos: { canEditFees: boolean; hasFeesProvided: boolean; liveTx: Partial<Transaction>; }; options: Parameters<WalletHandlers["transaction.sign"]>[0]["options"]; onSuccess: (signedOperation: SignedOperation) => void; onError: (error: Error) => void; }) => void; "transaction.broadcast": ( account: AccountLike, parentAccount: Account | undefined, mainAccount: Account, optimisticOperation: Operation, ) => void; "device.transport": (params: { appName: string | undefined; onSuccess: (result: AppResult) => void; onCancel: () => void; }) => void; "device.select": (params: { appName: string | undefined; onSuccess: (result: AppResult) => void; onCancel: () => void; }) => void; "exchange.start": (params: { exchangeType: "SWAP" | "FUND" | "SELL" | "SWAP_NG" | "SELL_NG" | "FUND_NG"; onSuccess: (nonce: string) => void; onCancel: (error: Error) => void; }) => void; "exchange.complete": (params: { exchangeParams: CompleteExchangeUiRequest; onSuccess: (hash: string) => void; onCancel: (error: Error) => void; }) => void; } export function usePermission(manifest: AppManifest): Omit<Permission, "currencyIds"> { return useMemo( () => ({ methodIds: manifest.permissions, }), [manifest], ); } function useTransport(postMessage: (message: string) => void | undefined): Transport { return useMemo(() => { return { onMessage: undefined, send: postMessage, }; }, [postMessage]); } export function useConfig({ appId, userId, tracking, wallet, mevProtected, }: ServerConfig): ServerConfig { return useMemo( () => ({ appId, userId, tracking, wallet, mevProtected, }), [appId, mevProtected, tracking, userId, wallet], ); } function useDeviceTransport({ manifest, tracking }) { const ref = useRef<Subject<BidirectionalEvent> | undefined>(undefined); const subscribe = useCallback((deviceId: string) => { ref.current = openTransportAsSubject({ deviceId }); ref.current.subscribe({ complete: () => { ref.current = undefined; }, }); }, []); const close = useCallback(() => { ref.current?.complete(); }, []); const exchange = useCallback<WalletHandlers["device.exchange"]>( ({ apduHex }) => { const subject$ = ref.current; return new Promise((resolve, reject) => { if (!subject$) { reject(new Error("No device transport")); return; } subject$.pipe(first(e => e.type === "device-response" || e.type === "error")).subscribe({ next: e => { if (e.type === "device-response") { tracking.deviceExchangeSuccess(manifest); resolve(e.data); return; } if (e.type === "error") { tracking.deviceExchangeFail(manifest); reject(e.error || new Error("deviceExchange: unknown error")); } }, error: error => { tracking.deviceExchangeFail(manifest); reject(error); }, }); subject$.next({ type: "input-frame", apduHex }); }); }, [manifest, tracking], ); useEffect(() => { return () => { ref.current?.complete(); }; }, []); return useMemo(() => ({ ref, subscribe, close, exchange }), [close, exchange, subscribe]); } export type useWalletAPIServerOptions = { walletState: WalletState; manifest: AppManifest; accounts: AccountLike[]; tracking: TrackingAPI; config: ServerConfig; webviewHook: { reload: () => void; postMessage: (message: string) => void; }; uiHook: Partial<UiHook>; customHandlers?: WalletAPICustomHandlers; }; export function useWalletAPIServer({ walletState, manifest, accounts, tracking, config, webviewHook, uiHook: { "account.request": uiAccountRequest, "account.receive": uiAccountReceive, "message.sign": uiMessageSign, "storage.get": uiStorageGet, "storage.set": uiStorageSet, "transaction.sign": uiTxSign, "transaction.signRaw": uiTxSignRaw, "transaction.broadcast": uiTxBroadcast, "device.transport": uiDeviceTransport, "device.select": uiDeviceSelect, "exchange.start": uiExchangeStart, "exchange.complete": uiExchangeComplete, }, customHandlers, }: useWalletAPIServerOptions): { onMessage: (event: string) => void; server: WalletAPIServer; onLoad: () => void; onReload: () => void; onLoadError: () => void; widgetLoaded: boolean; } { // Enables the proper typing on dispatch with RTK // eslint-disable-next-line @typescript-eslint/no-explicit-any const dispatch = useDispatch<ThunkDispatch<any, any, UnknownAction>>(); const { deactivatedCurrencyIds } = useCurrenciesUnderFeatureFlag(); const { getFeature } = useFeatureFlags(); const permission = usePermission(manifest); const transport = useTransport(webviewHook.postMessage); const [widgetLoaded, setWidgetLoaded] = useState(false); // We need to set the wallet API account IDs mapping upfront // If we don't want the map to be empty when requesting an account useSetWalletAPIAccounts(accounts); // Merge featureFlags handler with customHandlers const mergedCustomHandlers = useMemo(() => { const featureFlagsHandlersInstance = featureFlagsHandlers({ manifest, getFeature }); return { ...featureFlagsHandlersInstance, ...customHandlers, }; }, [manifest, customHandlers, getFeature]); const serverRef = useRef<WalletAPIServer | undefined>(undefined); // Lazily initialize WalletAPIServer once to avoid re-creation on re-renders // https://react.dev/reference/react/useRef#avoiding-recreating-the-ref-contents if (serverRef.current === undefined) { serverRef.current = new WalletAPIServer(transport, config, undefined, mergedCustomHandlers); } const server = serverRef.current; useEffect(() => { if (mergedCustomHandlers) { server.setCustomHandlers(mergedCustomHandlers); } }, [mergedCustomHandlers, server]); useEffect(() => { server.setConfig(config); }, [config, server]); useEffect(() => { server.setPermissions(permission); }, [permission, server]); const onMessage = useCallback( (event: string) => { transport.onMessage?.(event); }, [transport], ); useEffect(() => { tracking.load(manifest); }, [tracking, manifest]); // TODO: refactor each handler into its own logic function for clarity useEffect(() => { server.setHandler("currency.list", async ({ currencyIds }) => { // 1. Parse manifest currency patterns to determine what to include const manifestCurrencyIds = manifest.currencies === "*" ? ["**"] : manifest.currencies; // 2. Apply query filter early - intersect with manifest patterns const queryCurrencyIdsSet = currencyIds ? new Set(currencyIds) : undefined; let effectiveCurrencyIds = manifestCurrencyIds; if (queryCurrencyIdsSet) { // If we have a query filter, narrow down what we need to fetch effectiveCurrencyIds = manifestCurrencyIds.flatMap(manifestId => { if (manifestId === "**") { // Query can ask for anything, so use the query list return [...queryCurrencyIdsSet]; } else if (manifestId.endsWith("/**")) { // Pattern like "ethereum/**" - keep tokens from query that match this family const family = manifestId.slice(0, -3); return [...queryCurrencyIdsSet].filter(qId => qId.startsWith(`${family}/`)); } else if (queryCurrencyIdsSet.has(manifestId)) { // Specific currency/token that's in the query return [manifestId]; } // Not in query, skip it return []; }); } // 3. Parse effective currency IDs to determine what to fetch const includeAllCurrencies = effectiveCurrencyIds.includes("**"); const specificCurrencies = new Set<string>(); const tokenFamilies = new Set<string>(); const specificTokenIds = new Set<string>(); for (const id of effectiveCurrencyIds) { if (id === "**") { // Already handled above continue; } else if (id.endsWith("/**")) { // Pattern like "ethereum/**" or "solana/**" - include tokens for this family const family = id.slice(0, -3); tokenFamilies.add(family); // Additionally include the parent currency itself specificCurrencies.add(family); } else if (id.includes("/")) { // Specific token ID like "ethereum/erc20/usd__coin" specificTokenIds.add(id); } else { // Specific currency like "bitcoin" or "ethereum" specificCurrencies.add(id); } } // 4. Gather all supported parent currencies const allCurrencies = listSupportedCurrencies().reduce<WalletAPICurrency[]>((acc, c) => { if (isWalletAPISupportedCurrency(c) && !deactivatedCurrencyIds.has(c.id)) acc.push(currencyToWalletAPICurrency(c)); return acc; }, []); // 5. Determine which currencies to include based on patterns let includedCurrencies: WalletAPICurrency[] = []; if (includeAllCurrencies) { includedCurrencies = allCurrencies; } else { includedCurrencies = allCurrencies.filter(c => specificCurrencies.has(c.id)); } // 6. Fetch specific tokens by ID if any const specificTokens: WalletAPICurrency[] = []; if (specificTokenIds.size > 0) { const tokenPromises = [...specificTokenIds].map(async tokenId => { const token = await getCryptoAssetsStore().findTokenById(tokenId); return token ? currencyToWalletAPICurrency(token) : null; }); const resolvedTokens = await Promise.all(tokenPromises); specificTokens.push(...resolvedTokens.filter((t): t is WalletAPICurrency => t !== null)); } // 7. Determine which token families to fetch (only if not already fetched as specific tokens) const familiesToFetch = new Set<string>(); if (includeAllCurrencies) { // Fetch tokens for all currency families allCurrencies.forEach(c => { if (c.type === "CryptoCurrency") familiesToFetch.add(c.family); }); } else if (tokenFamilies.size > 0) { // Only fetch tokens for families explicitly marked with /** tokenFamilies.forEach(family => familiesToFetch.add(family)); } // 8. Fetch tokens for relevant families const fetchAllPagesForFamily = async (family: string) => { const args = { networkFamily: family, pageSize: 1000 }; let hasNextPage = true; let data: InfiniteData<TokensDataWithPagination, PageParam> | undefined; while (hasNextPage) { const querySub = dispatch( calEndpoints.getTokensData.initiate(args, data ? { direction: "forward" } : undefined), ); try { const result = await querySub; data = result.data; hasNextPage = result.hasNextPage; if (result.error) throw result.error; } finally { querySub.unsubscribe(); } } return (data?.pages ?? []).flatMap(p => p.tokens); }; const tokensByFamily = await Promise.all( [...familiesToFetch].map(f => fetchAllPagesForFamily(f)), ); // 9. Combine all results (no additional filter needed since we pre-filtered) const result = tokensByFamily.reduce<WalletAPICurrency[]>( (acc, tokens) => [...acc, ...tokens.map(t => currencyToWalletAPICurrency(t))], [...includedCurrencies, ...specificTokens], ); return result; }); }, [walletState, manifest, server, tracking, dispatch, deactivatedCurrencyIds]); useEffect(() => { server.setHandler("account.list", ({ currencyIds }) => { // 1. Parse manifest currency patterns to determine what to include const manifestCurrencyIds = manifest.currencies === "*" ? ["**"] : manifest.currencies; // 2. Apply query filter early - intersect with manifest patterns const queryCurrencyIdsSet = currencyIds ? new Set(currencyIds) : undefined; let effectiveCurrencyIds = manifestCurrencyIds; if (queryCurrencyIdsSet) { // If we have a query filter, narrow down what we need to check effectiveCurrencyIds = manifestCurrencyIds.flatMap(manifestId => { if (manifestId === "**") { // Query can ask for anything, so use the query list return [...queryCurrencyIdsSet]; } else if (manifestId.endsWith("/**")) { // Pattern like "ethereum/**" - keep tokens from query that match this family const family = manifestId.slice(0, -3); return [...queryCurrencyIdsSet].filter(qId => qId.startsWith(`${family}/`)); } else if (queryCurrencyIdsSet.has(manifestId)) { // Specific currency/token that's in the query return [manifestId]; } // Not in query, skip it return []; }); } // 3. Build a set of allowed currency IDs based on effective patterns const allowedCurrencyIds = new Set<string>(); const includeAllCurrencies = effectiveCurrencyIds.includes("**"); const tokenFamilyPrefixes = new Set<string>(); for (const id of effectiveCurrencyIds) { if (id === "**") { // Will match all currencies continue; } else if (id.endsWith("/**")) { // Pattern like "ethereum/**" - store prefix for matching const family = id.slice(0, -3); tokenFamilyPrefixes.add(family); } else { // Specific currency/token ID allowedCurrencyIds.add(id); } } // 4. Filter accounts based on effective currency IDs const wapiAccounts = accounts.reduce<WalletAPIAccount[]>((acc, account) => { const parentAccount = getParentAccount(account, accounts); const accountCurrencyId = account.type === "TokenAccount" ? account.token.id : account.currency.id; const parentCurrencyId = account.type === "TokenAccount" ? account.token.parentCurrency.id : account.currency.id; // Check if account currency ID matches the effective patterns const isAllowed = includeAllCurrencies || allowedCurrencyIds.has(accountCurrencyId) || tokenFamilyPrefixes.has(parentCurrencyId); if (isAllowed) { acc.push(accountToWalletAPIAccount(walletState, account, parentAccount)); } return acc; }, []); return wapiAccounts; }); }, [walletState, manifest, server, accounts]); useEffect(() => { if (!uiAccountRequest) return; server.setHandler( "account.request", async ({ currencyIds, drawerConfiguration, areCurrenciesFiltered, useCase, uiUseCase }) => { tracking.requestAccountRequested(manifest); return new Promise((resolve, reject) => { let done = false; try { uiAccountRequest({ currencyIds, drawerConfiguration, areCurrenciesFiltered, useCase, uiUseCase, onSuccess: (account: AccountLike, parentAccount: Account | undefined) => { if (done) return; done = true; tracking.requestAccountSuccess(manifest); resolve(accountToWalletAPIAccount(walletState, account, parentAccount)); }, onCancel: () => { if (done) return; done = true; tracking.requestAccountFail(manifest); reject(new Error("Canceled by user")); }, }); } catch (error) { tracking.requestAccountFail(manifest); reject(error); } }); }, ); }, [walletState, manifest, server, tracking, uiAccountRequest]); useEffect(() => { if (!uiAccountReceive) return; server.setHandler("account.receive", ({ accountId, tokenCurrency }) => receiveOnAccountLogic( walletState, { manifest, accounts, tracking }, accountId, (account, parentAccount, accountAddress) => new Promise((resolve, reject) => { let done = false; return uiAccountReceive({ account, parentAccount, accountAddress, onSuccess: accountAddress => { if (done) return; done = true; tracking.receiveSuccess(manifest); resolve(accountAddress); }, onCancel: () => { if (done) return; done = true; tracking.receiveFail(manifest); reject(new Error("User cancelled")); }, onError: error => { if (done) return; done = true; tracking.receiveFail(manifest); reject(error); }, }); }), tokenCurrency, ), ); }, [walletState, accounts, manifest, server, tracking, uiAccountReceive]); useEffect(() => { if (!uiMessageSign) return; server.setHandler("message.sign", ({ accountId, message, options }) => signMessageLogic( { manifest, accounts, tracking }, accountId, message.toString("hex"), (account: AccountLike, message: AnyMessage) => new Promise((resolve, reject) => { let done = false; return uiMessageSign({ account, message, options, onSuccess: signature => { if (done) return; done = true; tracking.signMessageSuccess(manifest); resolve( signature.startsWith("0x") ? Buffer.from(signature.replace("0x", ""), "hex") : Buffer.from(signature), ); }, onCancel: () => { if (done) return; done = true; tracking.signMessageFail(manifest); reject(new UserRefusedOnDevice()); }, onError: error => { if (done) return; done = true; tracking.signMessageFail(manifest); reject(error); }, }); }), ), ); }, [accounts, manifest, server, tracking, uiMessageSign]); useEffect(() => { if (!uiStorageGet) return; server.setHandler("storage.get", protectStorageLogic(manifest, uiStorageGet)); }, [manifest, server, uiStorageGet]); useEffect(() => { if (!uiStorageSet) return; server.setHandler("storage.set", protectStorageLogic(manifest, uiStorageSet)); }, [manifest, server, uiStorageSet]); useEffect(() => { if (!uiTxSignRaw) return; server.setHandler("bitcoin.signPsbt", async ({ accountId, psbt, broadcast }) => { const signedOperation = await signRawTransactionLogic( { manifest, accounts, tracking }, accountId, psbt, (account, parentAccount, tx) => new Promise((resolve, reject) => { let done = false; return uiTxSignRaw({ account, parentAccount, transaction: tx, broadcast, options: undefined, onSuccess: signedOperation => { if (done) return; done = true; tracking.signRawTransactionSuccess(manifest); resolve(signedOperation); }, onError: error => { if (done) return; done = true; tracking.signRawTransactionFail(manifest); reject(error); }, }); }), ); const rawData = signedOperation.rawData; if (!rawData || typeof rawData.psbtSigned !== "string") { throw new Error("Missing psbtSigned in signed operation rawData"); } const psbtSigned = rawData.psbtSigned; if (broadcast) { const txHash = await broadcastTransactionLogic( { manifest, accounts, tracking }, accountId, signedOperation, async (account, parentAccount, signedOperation) => { const bridge = getAccountBridge(account, parentAccount); const mainAccount = getMainAccount(account, parentAccount); let optimisticOperation: Operation = signedOperation.operation; const networkId = account.type === "TokenAccount" ? account.token.parentCurrency.id : account.currency.id; const broadcastTrackingData = { sourceCurrency: account.type === "TokenAccount" ? account.token.name : account.currency.name, network: networkId, }; if (!getEnv("DISABLE_TRANSACTION_BROADCAST")) { try { optimisticOperation = await bridge.broadcast({ account: mainAccount, signedOperation, }); tracking.broadcastSuccess(manifest, broadcastTrackingData); } catch (error) { tracking.broadcastFail(manifest, broadcastTrackingData); throw error; } } if (uiTxBroadcast) { uiTxBroadcast(account, parentAccount, mainAccount, optimisticOperation); } return optimisticOperation.hash; }, ); return { psbtSigned, txHash }; } return { psbtSigned }; }); }, [accounts, config.mevProtected, manifest, server, tracking, uiTxBroadcast, uiTxSignRaw]); useEffect(() => { if (!uiTxSign) return; server.setHandler( "transaction.sign", async ({ accountId, tokenCurrency, transaction, options }) => { let currency: string | undefined; const signedOperation = await signTransactionLogic( { manifest, accounts, tracking }, accountId, transaction, (account, parentAccount, signFlowInfos) => { currency = account.type === "TokenAccount" ? account.token.parentCurrency.id : account.currency.id; return new Promise((resolve, reject) => { let done = false; return uiTxSign({ account, parentAccount, signFlowInfos, options, onSuccess: signedOperation => { if (done) return; done = true; tracking.signTransactionSuccess(manifest); resolve(signedOperation); }, onError: error => { if (done) return; done = true; tracking.signTransactionFail(manifest); reject(error); }, }); }); }, tokenCurrency, ); return currency === "solana" ? Buffer.from(signedOperation.signature, "hex") : Buffer.from(signedOperation.signature); }, ); }, [accounts, manifest, server, tracking, uiTxSign]); useEffect(() => { if (!uiTxSignRaw) return; server.setHandler( "transaction.signRaw", async ({ accountId, transaction, broadcast, options }) => { const signedOperation = await signRawTransactionLogic( { manifest, accounts, tracking }, accountId, transaction, (account, parentAccount, tx) => new Promise((resolve, reject) => { let done = false; return uiTxSignRaw({ account, parentAccount, transaction: tx, broadcast, options, onSuccess: signedOperation => { if (done) return; done = true; tracking.signRawTransactionSuccess(manifest); resolve(signedOperation); }, onError: error => { if (done) return; done = true; tracking.signRawTransactionFail(manifest); reject(error); }, }); }), ); let hash: string | undefined; if (broadcast) { hash = await broadcastTransactionLogic( { manifest, accounts, tracking }, accountId, signedOperation, async (account, parentAccount, signedOperation) => { const bridge = getAccountBridge(account, parentAccount); const mainAccount = getMainAccount(account, parentAccount); const networkId = account.type === "TokenAccount" ? account.token.parentCurrency.id : account.currency.id; const broadcastTrackingData = { sourceCurrency: account.type === "TokenAccount" ? account.token.name : account.currency.name, network: networkId, }; let optimisticOperation: Operation = signedOperation.operation; if (!getEnv("DISABLE_TRANSACTION_BROADCAST")) { try { optimisticOperation = await bridge.broadcast({ account: mainAccount, signedOperation, broadcastConfig: { mevProtected: !!config.mevProtected, source: { type: "live-app", name: manifest.id }, }, }); tracking.broadcastSuccess(manifest, broadcastTrackingData); } catch (error) { tracking.broadcastFail(manifest, broadcastTrackingData); throw error; } } uiTxBroadcast && uiTxBroadcast(account, parentAccount, mainAccount, optimisticOperation); return optimisticOperation.hash; }, ); } return { signedTransactionHex: signedOperation.signature, transactionHash: hash, }; }, ); }, [accounts, config.mevProtected, manifest, server, tracking, uiTxBroadcast, uiTxSignRaw]); useEffect(() => { if (!uiTxSign) return; server.setHandler( "transaction.signAndBroadcast", async ({ accountId, tokenCurrency, transaction, options, meta }) => { const sponsored = transaction.family === "ethereum" && transaction.sponsored; // isEmbedded and partner are passed via meta (not transaction) as they're tracking params, not tx properties const isEmbeddedSwap = (meta as { isEmbedded?: boolean } | undefined)?.isEmbedded; const partner = (meta as { partner?: string } | undefined)?.partner; const signedTransaction = await signTransactionLogic( { manifest, accounts, tracking }, accountId, transaction, (account, parentAccount, signFlowInfos) => new Promise((resolve, reject) => { let done = false; return uiTxSign({ account, parentAccount, signFlowInfos, options, onSuccess: signedOperation => { if (done) return; done = true; tracking.signTransactionSuccess(manifest, isEmbeddedSwap, partner); resolve(signedOperation); }, onError: error => { if (done) return; done = true; tracking.signTransactionFail(manifest, isEmbeddedSwap, partner); reject(error); }, }); }), tokenCurrency, isEmbeddedSwap, partner, ); return broadcastTransactionLogic( { manifest, accounts, tracking }, accountId, signedTransaction, async (account, parentAccount, signedOperation) => { const bridge = getAccountBridge(account, parentAccount); const mainAccount = getMainAccount(account, parentAccount); const networkId = account.type === "TokenAccount" ? account.token.parentCurrency.id : account.currency.id; const broadcastTrackingData = { isEmbeddedSwap, partner, sourceCurrency: account.type === "TokenAccount" ? account.token.name : account.currency.name, network: networkId, }; let optimisticOperation: Operation = signedOperation.operation; if (!getEnv("DISABLE_TRANSACTION_BROADCAST")) { try { optimisticOperation = await bridge.broadcast({ account: mainAccount, signedOperation, broadcastConfig: { mevProtected: !!config.mevProtected, sponsored, source: { type: "live-app", name: manifest.id }, }, }); tracking.broadcastSuccess(manifest, broadcastTrackingData); } catch (error) { tracking.broadcastFail(manifest, broadcastTrackingData); throw error; } } uiTxBroadcast && uiTxBroadcast(account, parentAccount, mainAccount, optimisticOperation); return optimisticOperation.hash; }, tokenCurrency, ); }, ); }, [accounts, config.mevProtected, manifest, server, tracking, uiTxBroadcast, uiTxSign]); const onLoad = useCallback(() => { tracking.loadSuccess(manifest); setWidgetLoaded(true); }, [manifest, tracking]); const onReload = useCallback(() => { tracking.reload(manifest); setWidgetLoaded(false); webviewHook.reload(); }, [manifest, tracking, webviewHook]); const onLoadError = useCallback(() => { tracking.loadFail(manifest); }, [manifest, tracking]); const device = useDeviceTransport({ manifest, tracking }); useEffect(() => { if (!uiDeviceTransport) return; server.setHandler( "device.transport", ({ appName, appVersionRange, devices }) => new Promise((resolve, reject) => { if (device.ref.current) { return reject(new Error("Device already opened")); } tracking.deviceTransportRequested(manifest); let done = false; return uiDeviceTransport({ appName, onSuccess: ({ device: deviceParam, appAndVersion }) => { if (done) return; done = true; tracking.deviceTransportSuccess(manifest); if (!deviceParam) { reject(new Error("No device")); return; } if (devices && !devices.includes(deviceParam.modelId)) { reject(new Error("Device not in the devices list")); return; } if ( appVersionRange && appAndVersion && semver.satisfies(appAndVersion.version, appVersionRange) ) { reject(new Error("App version doesn't satisfies the range")); return; } // TODO handle appFirmwareRange & seeded params device.subscribe(deviceParam.deviceId); resolve("1"); }, onCancel: () => { if (done) return; done = true; tracking.deviceTransportFail(manifest); reject(new Error("User cancelled")); }, }); }), ); }, [device, manifest, server, tracking, uiDeviceTransport]); useEffect(() => { if (!uiDeviceSelect) return; server.setHandler( "device.select", ({ appName, appVersionRange, devices }) => new Promise((resolve, reject) => { if (device.ref.current) { return reject(new Error("Device already opened")); } tracking.deviceSelectRequested(manifest); let done = false; return uiDeviceSelect({ appName, onSuccess: ({ device: deviceParam, appAndVersion }) => { if (done) return; done = true; tracking.deviceSelectSuccess(manifest); if (!deviceParam) { reject(new Error("No device")); return; } if (devices && !devices.includes(deviceParam.modelId)) { reject(new Error("Device not in the devices list")); return; } if ( appVersionRange && appAndVersion && semver.satisfies(appAndVersion.version, appVersionRange) ) { reject(new Error("App version doesn't satisfies the range")); return; } resolve(deviceParam.deviceId); }, onCancel: () => { if (done) return; done = true; tracking.deviceSelectFail(manifest); reject(new Error("User cancelled")); }, }); }), ); }, [device.ref, manifest, server, tracking, uiDeviceSelect]); useEffect(() => { server.setHandler("device.open", params => { if (device.ref.current) { return Promise.reject(new Error("Device already opened")); } tracking.deviceOpenRequested(manifest); device.subscribe(params.deviceId); return "1"; }); }, [device, manifest, server, tracking]); useEffect(() => { server.setHandler("device.exchange", params => { if (!device.ref.current) { return Promise.reject(new Error("No device opened")); } tracking.deviceExchangeRequested(manifest); return device.exchange(params); }); }, [device, manifest, server, tracking]); useEffect(() => { server.setHandler("device.close", ({ transportId }) => { if (!device.ref.current) { return Promise.reject(new Error("No device opened")); } tracking.deviceCloseRequested(manifest); device.close(); tracking.deviceCloseSuccess(manifest); return Promise.resolve(transportId); }); }, [device, manifest, server, tracking]); useEffect(() => { server.setHandler("bitcoin.getAddress", ({ accountId, derivationPath }) => { return bitcoinFamilyAccountGetAddressLogic( { manifest, accounts, tracking }, accountId, derivationPath, ); }); }, [accounts, manifest, server, tracking]); useEffect(() => { server.setHandler("bitcoin.getAddresses", ({ accountId, intentions }) => { return bitcoinFamilyAccountGetAddressesLogic( { manifest, accounts, tracking }, accountId, intentions, ); }); }, [accounts, manifest, server, tracking]); useEffect(() => { server.setHandler("bitcoin.getPublicKey", ({ accountId, derivationPath }) => { return bitcoinFamilyAccountGetPublicKeyLogic( { manifest, accounts, tracking }, accountId, derivationPath, ); }); }, [accounts, manifest, server, tracking]); useEffect(() => { server.setHandler("bitcoin.getXPub", ({ accountId }) => { return bitcoinFamilyAccountGetXPubLogic({ manifest, accounts, tracking }, accountId); }); }, [accounts, manifest, server, tracking]); useEffect(() => { if (!uiExchangeStart) { return; } server.setHandler("exchange.start", ({ exchangeType }) => { return startExchangeLogic( { manifest, accounts, tracking }, exchangeType, exchangeType => new Promise((resolve, reject) => { let done = false; return uiExchangeStart({ exchangeType, onSuccess: (nonce: string) => { if (done) return; done = true; tracking.startExchangeSuccess(manifest); resolve(nonce); }, onCancel: error => { if (done) return; done = true; tracking.completeExchangeFail(manifest); reject(error); }, }); }), ); }); }, [uiExchangeStart, accounts, manifest, server, tracking]); useEffect(() => { if (!uiExchangeComplete) { return; } server.setHandler("exchange.complete", params => { // retrofit of the exchange params to fit the old platform spec const request: CompleteExchangeRequest = { provider: params.provider, fromAccountId: params.fromAccountId, toAccountId: params.exchangeType === "SWAP" ? params.toAccountId : undefined, transaction: params.transaction, binaryPayload: params.binaryPayload.toString("hex"), signature: params.signature.toString("hex"), feesStrategy: params.feeStrategy, exchangeType: ExchangeType[params.exchangeType], swapId: params.exchangeType === "SWAP" ? params.swapId : undefined, rate: params.exchangeType === "SWAP" ? params.rate : undefined, tokenCurrency: params.exchangeType !== "SELL" ? params.tokenCurrency : undefined, }; return completeExchangeLogic( { manifest, accounts, tracking }, request, request => new Promise((resolve, reject) => { let done = false; return uiExchangeComplete({ exchangeParams: request, onSuccess: (hash: string) => { if (done) return; done = true; tracking.completeExchangeSuccess(manifest); resolve(hash); }, onCancel: error => { if (done) return; done = true; tracking.completeExchangeFail(manifest); reject(error); }, }); }), ); }); }, [uiExchangeComplete, accounts, manifest, server, tracking]); return { widgetLoaded: widgetLoaded, onMessage, onLoad, onReload, onLoadError, server, }; } export enum ExchangeType { SWAP = 0x00, SELL = 0x01, FUND = 0x02, SWAP_NG = 0x03, SELL_NG = 0x04, FUND_NG = 0x05, } export interface Categories { categories: string[]; manifestsByCategories: Map<string, AppManifest[]>; selected: string; setSelected: (val: string) => void; reset: () => void; } /** e.g. "all", "restaking", "services", etc */ export type CategoryId = Categories["selected"]; export function useCategories(manifests, initialCategory?: CategoryId | null): Categories { const [selected, setSelected] = useState(initialCategory || DISCOVER_INITIAL_CATEGORY); const reset = useCallback(() => { setSelected(DISCOVER_INITIAL_CATEGORY); }, []); const manifestsByCategories = useMemo(() => { const res = manifests.reduce( (res, manifest) => { manifest.categories.forEach(category => { const list = res.has(category) ? [...res.get(category), manifest] : [manifest]; res.set(category, list); }); return res; }, new Map().set("all", manifests), ); return res; }, [manifests]); const categories = useMemo(() => [...manifestsByCategories.keys()], [manifestsByCategories]); return useMemo( () => ({ categories, manifestsByCategories, selected, setSelected, reset, }), [categories, manifestsByCategories, selected, reset], ); } export type RecentlyUsedDB = StateDB<DiscoverDB, DiscoverDB["recentlyUsed"]>; export type CacheBustedLiveAppsdDB = StateDB<DiscoverDB, DiscoverDB["cacheBustedLiveApps"]>; export type LocalLiveAppDB = StateDB<DiscoverDB, DiscoverDB["localLiveApp"]>; export type CurrentAccountHistDB = StateDB<DiscoverDB, DiscoverDB["currentAccountHist"]>; export interface LocalLiveApp { state: LiveAppManifest[]; addLocalManifest: (LiveAppManifest) => void; removeLocalManifestById: (string) => void; getLocalLiveAppManifestById: (string) => LiveAppManifest | undefined; } export function useLocalLiveApp([LocalLiveAppDb, setState]: LocalLiveAppDB): LocalLiveApp { useEffect(() => { if (LocalLiveAppDb === undefined) { setState(discoverDB => { return { ...discoverDB, localLiveApp: INITIAL_PLATFORM_STATE.localLiveApp }; }); } }, [LocalLiveAppDb, setState]); const addLocalManifest = useCallback( (newLocalManifest: LiveAppManifest) => { setState(discoverDB => { const newLocalLiveAppList = discoverDB.localLiveApp?.filter( manifest => manifest.id !== newLocalManifest.id, ); newLocalLiveAppList.push(newLocalManifest); return { ...discoverDB, localLiveApp: newLocalLiveAppList }; }); }, [setState], ); const removeLocalManifestById = useCallback( (manifestId: string) => { setState(discoverDB => { const newLocalLiveAppList = discoverDB.localLiveApp.filter( manifest => manifest.id !== manifestId, ); return { ...discoverDB, localLiveApp: newLocalLiveAppList }; }); }, [setState], ); const getLocalLiveAppManifestById = useCallback( (manifestId: string): LiveAppManifest | undefined => { return LocalLiveAppDb.find(manifest => manifest.id === manifestId); }, [LocalLiveAppDb], ); return { state: LocalLiveAppDb, addLocalManifest, removeLocalManifestById, getLocalLiveAppManifestById, }; } export interface RecentlyUsed { data: RecentlyUsedManifest[]; append: (manifest: AppManifest) => void; clear: () => void; } export type RecentlyUsedManifest = AppManifest & { usedAt: UsedAt }; export type UsedAt = { unit?: Intl.RelativeTimeFormatUnit; diff: number; }; function calculateTimeDiff(usedAt: string): UsedAt { const start = new Date(); const end = new Date(usedAt); const interval = intervalToDuration({ start, end }); const units: Intl.RelativeTimeFormatUnit[] = [ "years", "months", "weeks", "days", "hours", "minutes", "seconds", ]; let timeDiff: UsedAt = { unit: undefined, diff: 0 }; for (const unit of units) { if (interval[unit] > 0) { timeDiff = { unit, diff: interval[unit] }; break; } } return timeDiff; } export function useCacheBustedLiveApps([cacheBustedLiveAppsDb, setState]: CacheBustedLiveAppsdDB) { const getLatest = useCallback( (manifestId: string) => { return cacheBustedLiveAppsDb?.[manifestId]; }, [cacheBustedLiveAppsDb], ); const edit = useCallback( (manifestId: string, cacheBustingId: number) => { const _cacheBustedLiveAppsDb = { ...cacheBustedLiveAppsDb, [manifestId]: cacheBustingId, init: 1, }; setState(state => { const newstate = { ...state, cacheBustedLiveApps: _cacheBustedLiveAppsDb }; return newstate; }); }, [setState, cacheBustedLiveAppsDb], ); return { getLatest, edit }; } export function useRecentlyUsed( manifests: AppManifest[], [recentlyUsedManifestsDb, setState]: RecentlyUsedDB, ): RecentlyUsed { const data = useMemo( () => recentlyUsedManifestsDb .map(recentlyUsed => { const res = manifests.find(manifest => manifest.id === recentlyUsed.id); return res ? { ...res, usedAt: calculateTimeDiff(recentlyUsed.usedAt), } : undefined; }) .filter((manifest): manifest is RecentlyUsedManifest => manifest !== undefined), [recentlyUsedManifestsDb, manifests], ); const append = useCallback( (manifest: AppManifest) => { setState(state => { const index = state.recentlyUsed.findIndex(({ id }) => id === manifest.id); // Manifest already in first position if (index === 0) { return { ...state, recentlyUsed: [ { ...state.recentlyUsed[0], usedAt: new Date().toISOString() }, ...state.recentlyUsed.slice(1), ], }; } // Manifest present we move it to the first position // No need to check for MAX_LENGTH as we only move it if (index !== -1) {