diff --git a/src/bonsai/rest/lib/nobleTransactionStoreEffect.ts b/src/bonsai/rest/lib/nobleTransactionStoreEffect.ts index 94a7618631..a09070621c 100644 --- a/src/bonsai/rest/lib/nobleTransactionStoreEffect.ts +++ b/src/bonsai/rest/lib/nobleTransactionStoreEffect.ts @@ -65,13 +65,15 @@ const selectNobleTxAuthorizedAccount = createAppSelector( } const localNobleWallet = localWalletManager.getLocalNobleWallet(localWalletNonce); + const nobleAddress = convertBech32Address({ address: parentSubaccountInfo.wallet, bech32Prefix: NOBLE_BECH32_PREFIX, }); + const isCorrectWallet = localNobleWallet?.address === nobleAddress; - if (!isCorrectWallet || localNobleWallet == null) return undefined; + if (!isCorrectWallet) return undefined; return { localNobleWallet, diff --git a/src/constants/account.ts b/src/constants/account.ts index 86669d0de3..8ba5df2683 100644 --- a/src/constants/account.ts +++ b/src/constants/account.ts @@ -1,4 +1,4 @@ -import type { DydxAddress, EvmAddress } from './wallets'; +import type { DydxAddress } from './wallets'; import { SolAddress } from './wallets'; export enum OnboardingSteps { @@ -36,14 +36,6 @@ export enum EvmDerivedAccountStatus { Derived, } -export type EvmDerivedAddresses = { - version?: string; - [EvmAddress: EvmAddress]: { - encryptedSignature?: string; - dydxAddress?: DydxAddress; - }; -}; - export type SolDerivedAddresses = { version?: string; } & Record< diff --git a/src/hooks/Onboarding/useGenerateKeys.ts b/src/hooks/Onboarding/useGenerateKeys.ts index 446fb0957d..5bf645815b 100644 --- a/src/hooks/Onboarding/useGenerateKeys.ts +++ b/src/hooks/Onboarding/useGenerateKeys.ts @@ -1,16 +1,13 @@ import { useEffect, useState } from 'react'; import { log } from 'console'; -import { AES } from 'crypto-js'; import { EvmDerivedAccountStatus } from '@/constants/account'; import { AnalyticsEvents, AnalyticsUserProperties } from '@/constants/analytics'; import { DydxAddress } from '@/constants/wallets'; -import { useAppDispatch } from '@/state/appTypes'; -import { setSavedEncryptedSignature } from '@/state/wallet'; - import { identify, track } from '@/lib/analytics/analytics'; +import { onboardingManager } from '@/lib/onboarding/OnboardingSupervisor'; import { parseWalletError } from '@/lib/wallet'; import { useAccounts } from '../useAccounts'; @@ -28,9 +25,8 @@ type GenerateKeysProps = { export function useGenerateKeys(generateKeysProps?: GenerateKeysProps) { const stringGetter = useStringGetter(); - const dispatch = useAppDispatch(); const { status, setStatus, onKeysDerived } = generateKeysProps ?? {}; - const { sourceAccount, setWalletFromSignature } = useAccounts(); + const { sourceAccount } = useAccounts(); const [derivationStatus, setDerivationStatus] = useState( status ?? EvmDerivedAccountStatus.NotDerived ); @@ -79,9 +75,9 @@ export function useGenerateKeys(generateKeysProps?: GenerateKeysProps) { if (networkSwitched) await deriveKeys().then(onKeysDerived); }; - // 2. Derive keys from EVM account + // 2. Derive keys from EVM account using OnboardingSupervisor const { getWalletFromSignature } = useDydxClient(); - const { getSubaccounts } = useAccounts(); + const { getSubaccounts, setLocalDydxWallet, setLocalNobleWallet, setHdKey } = useAccounts(); const isDeriving = ![ EvmDerivedAccountStatus.NotDerived, @@ -90,74 +86,62 @@ export function useGenerateKeys(generateKeysProps?: GenerateKeysProps) { const signMessageAsync = useSignForWalletDerivation(sourceAccount.walletInfo); - const staticEncryptionKey = import.meta.env.VITE_PK_ENCRYPTION_KEY; - const deriveKeys = async () => { setError(undefined); try { - // 1. First signature setDerivationStatus(EvmDerivedAccountStatus.Deriving); - const signature = await signMessageAsync(); - track( - AnalyticsEvents.OnboardingDeriveKeysSignatureReceived({ - signatureNumber: 1, - }) - ); - const { wallet: dydxWallet } = await getWalletFromSignature({ signature }); + // Track first signature request + const wrappedSignMessage = async (requestNumber: 1 | 2) => { + if (requestNumber === 2) { + setDerivationStatus(EvmDerivedAccountStatus.EnsuringDeterminism); + } - // 2. Ensure signature is deterministic - // Check if subaccounts exist - const dydxAddress = dydxWallet.address as DydxAddress; - let hasPreviousTransactions = false; + const sig = await signMessageAsync(); - try { + track( + AnalyticsEvents.OnboardingDeriveKeysSignatureReceived({ signatureNumber: requestNumber }) + ); + + return sig; + }; + + // Check for previous transactions + const checkPreviousTransactions = async (dydxAddress: DydxAddress) => { const subaccounts = await getSubaccounts({ dydxAddress }); - hasPreviousTransactions = subaccounts.length > 0; + const hasPreviousTransactions = subaccounts.length > 0; track(AnalyticsEvents.OnboardingAccountDerived({ hasPreviousTransactions })); + identify(AnalyticsUserProperties.IsNewUser(!hasPreviousTransactions)); - if (!hasPreviousTransactions) { - identify(AnalyticsUserProperties.IsNewUser(true)); - setDerivationStatus(EvmDerivedAccountStatus.EnsuringDeterminism); + return hasPreviousTransactions; + }; - // Second signature - const additionalSignature = await signMessageAsync(); - track( - AnalyticsEvents.OnboardingDeriveKeysSignatureReceived({ - signatureNumber: 2, - }) - ); - - if (signature !== additionalSignature) { - throw new Error( - 'Your wallet does not support deterministic signing. Please switch to a different wallet provider.' - ); - } - } else { - identify(AnalyticsUserProperties.IsNewUser(false)); - } - } catch (err) { + // Derive with determinism check + const result = await onboardingManager.deriveKeysWithDeterminismCheck({ + signMessageAsync: wrappedSignMessage, + getWalletFromSignature, + checkPreviousTransactions, + }); + + if (!result.success) { setDerivationStatus(EvmDerivedAccountStatus.NotDerived); - const { message } = parseWalletError({ error: err, stringGetter }); - if (message) { + if (result.isDeterminismError) { track(AnalyticsEvents.OnboardingWalletIsNonDeterministic()); - setError(message); } + + setError(result.error); return; } - await setWalletFromSignature(signature); - - // 3: Remember me (encrypt and store signature) - if (staticEncryptionKey) { - const encryptedSignature = AES.encrypt(signature, staticEncryptionKey).toString(); - dispatch(setSavedEncryptedSignature(encryptedSignature)); - } + // Set wallet in useAccounts state + setLocalDydxWallet(result.wallet); + setLocalNobleWallet(result.nobleWallet); + setHdKey(result.hdKey); - // 4. Done + // Done - wallet is already persisted to SecureStorage by OnboardingSupervisor setDerivationStatus(EvmDerivedAccountStatus.Derived); } catch (err) { setDerivationStatus(EvmDerivedAccountStatus.NotDerived); diff --git a/src/hooks/useAccounts.tsx b/src/hooks/useAccounts.tsx index 2337a01e88..f258c9677e 100644 --- a/src/hooks/useAccounts.tsx +++ b/src/hooks/useAccounts.tsx @@ -1,26 +1,11 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; -import { getLazyLocalWallet } from '@/bonsai/lib/lazyDynamicLibs'; -import { BonsaiCore } from '@/bonsai/ontology'; -import { type LocalWallet, NOBLE_BECH32_PREFIX, type Subaccount } from '@dydxprotocol/v4-client-js'; +import { type LocalWallet, type Subaccount } from '@dydxprotocol/v4-client-js'; import { usePrivy } from '@privy-io/react-auth'; -import { AES, enc } from 'crypto-js'; import { OnboardingGuard, OnboardingState } from '@/constants/account'; -import { - getNeutronChainId, - getNobleChainId, - getOsmosisChainId, - NEUTRON_BECH32_PREFIX, - OSMO_BECH32_PREFIX, -} from '@/constants/graz'; import { LocalStorageKey } from '@/constants/localStorage'; -import { - ConnectorType, - DydxAddress, - PrivateInformation, - WalletNetworkType, -} from '@/constants/wallets'; +import { ConnectorType, DydxAddress, PrivateInformation } from '@/constants/wallets'; import { useTurnkeyWallet } from '@/providers/TurnkeyWalletProvider'; @@ -28,13 +13,14 @@ import { setOnboardingGuard, setOnboardingState } from '@/state/account'; import { getGeo } from '@/state/accountSelectors'; import { getSelectedDydxChainId } from '@/state/appSelectors'; import { useAppDispatch, useAppSelector } from '@/state/appTypes'; -import { clearSavedEncryptedSignature, setLocalWallet } from '@/state/wallet'; +import { setLocalWallet } from '@/state/wallet'; import { getSourceAccount } from '@/state/walletSelectors'; import { hdKeyManager, localWalletManager } from '@/lib/hdKeyManager'; -import { log } from '@/lib/telemetry'; -import { sleep } from '@/lib/timeUtils'; +import { onboardingManager } from '@/lib/onboarding/OnboardingSupervisor'; +import { dydxPersistedWalletService } from '@/lib/wallet/dydxPersistedWalletService'; +import { useCosmosWallets } from './useCosmosWallets'; import { useDydxClient } from './useDydxClient'; import { useEnvFeatures } from './useEnvFeatures'; import { useLocalStorage } from './useLocalStorage'; @@ -70,7 +56,6 @@ const useAccountsContext = () => { dydxAccountGraz, } = useWalletConnection(); - const hasSubAccount = useAppSelector(BonsaiCore.account.parentSubaccountSummary.data) != null; const sourceAccount = useAppSelector(getSourceAccount); const { ready, authenticated } = usePrivy(); @@ -79,36 +64,6 @@ const useAccountsContext = () => { return geo.currentlyGeoBlocked && checkForGeo; }, [geo, checkForGeo]); - const [previousAddress, setPreviousAddress] = useState(sourceAccount.address); - - useEffect(() => { - const { address } = sourceAccount; - // wallet accounts switched - if (previousAddress && address !== previousAddress) { - // Disconnect local wallet - disconnectLocalDydxWallet(); - } - - setPreviousAddress(address); - // We only want to set the source wallet address if the address changes - // OR when our connection state changes. - // The address can be cached via local storage, so it won't change when we reconnect - // But the hasSubAccount value will become true once you reconnect - // This allows us to trigger a state update - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sourceAccount.address, sourceAccount.chain, hasSubAccount]); - - const decryptSignature = (encryptedSignature: string | undefined) => { - const staticEncryptionKey = import.meta.env.VITE_PK_ENCRYPTION_KEY; - - if (!staticEncryptionKey) throw new Error('No decryption key found'); - if (!encryptedSignature) throw new Error('No signature found'); - - const decrypted = AES.decrypt(encryptedSignature, staticEncryptionKey); - const signature = decrypted.toString(enc.Utf8); - return signature; - }; - // dYdXClient Onboarding & Account Helpers const { indexerClient, getWalletFromSignature } = useDydxClient(); // dYdX subaccounts @@ -133,9 +88,6 @@ const useAccountsContext = () => { // dYdX wallet / onboarding state const [localDydxWallet, setLocalDydxWallet] = useState(); const [localNobleWallet, setLocalNobleWallet] = useState(); - const [localOsmosisWallet, setLocalOsmosisWallet] = useState(); - const [localNeutronWallet, setLocalNeutronWallet] = useState(); - const [hdKey, setHdKey] = useState(); const dydxAccounts = useMemo(() => localDydxWallet?.accounts, [localDydxWallet]); @@ -149,18 +101,22 @@ const useAccountsContext = () => { dispatch(setLocalWallet({ address: dydxAddress, subaccountNumber: 0 })); }, [dispatch, dydxAddress]); - const nobleAddress = localNobleWallet?.address; - const osmosisAddress = localOsmosisWallet?.address; - const neutronAddress = localNeutronWallet?.address; - - const setWalletFromSignature = useCallback( + const setWalletFromTurnkeySignature = useCallback( async (signature: string) => { - const { wallet, mnemonic, privateKey, publicKey } = await getWalletFromSignature({ - signature, - }); + const { wallet, nobleWallet, mnemonic, privateKey, publicKey } = await getWalletFromSignature( + { + signature, + } + ); + const key = { mnemonic, privateKey, publicKey }; hdKeyManager.setHdkey(wallet.address, key); + + // Persist to SecureStorage for session restoration + await dydxPersistedWalletService.secureStorePrivateKey(privateKey); + setLocalDydxWallet(wallet); + setLocalNobleWallet(nobleWallet); setHdKey(key); return wallet.address; }, @@ -168,8 +124,8 @@ const useAccountsContext = () => { ); const signMessageAsync = useSignForWalletDerivation(sourceAccount.walletInfo); - const hasLocalDydxWallet = Boolean(localDydxWallet); + const cosmosWallets = useCosmosWallets(hdKey, getCosmosOfflineSigner); useEffect(() => { if (localDydxWallet && localNobleWallet) { @@ -181,157 +137,43 @@ const useAccountsContext = () => { useEffect(() => { (async () => { - /** - * Handle Turnkey separately since it is an embedded wallet. - * There will not be an OnboardingState.WalletConnected state, only AccountConnected or Disconnected. - */ - if (sourceAccount.walletInfo?.connectorType === ConnectorType.Turnkey) { - if (!hasLocalDydxWallet && sourceAccount.encryptedSignature && !blockedGeo) { - try { - const signature = decryptSignature(sourceAccount.encryptedSignature); - await setWalletFromSignature(signature); - dispatch(setOnboardingState(OnboardingState.AccountConnected)); - } catch (error) { - log('useAccounts/decryptSignature', error); - dispatch(clearSavedEncryptedSignature()); - } - } else if (hasLocalDydxWallet) { - dispatch(setOnboardingState(OnboardingState.AccountConnected)); - } else { - dispatch(setOnboardingState(OnboardingState.Disconnected)); - } - return; + const result = await onboardingManager.handleWalletConnection({ + context: { + sourceAccount, + hasLocalDydxWallet, + blockedGeo, + isConnectedGraz, + authenticated, + ready, + }, + getWalletFromSignature, + signMessageAsync, + getCosmosOfflineSigner, + selectedDydxChainId, + }); + + // Handle the result + if (result.wallet) { + setLocalDydxWallet(result.wallet); } - /** - * Handle Test (dYdX), Cosmos (dYdX), Evm, and Solana wallets - */ - if (sourceAccount.walletInfo?.connectorType === ConnectorType.Test) { - dispatch(setOnboardingState(OnboardingState.WalletConnected)); - const wallet = new (await getLazyLocalWallet())(); - wallet.address = sourceAccount.address; - setLocalDydxWallet(wallet); - dispatch(setOnboardingState(OnboardingState.AccountConnected)); - } else if (sourceAccount.chain === WalletNetworkType.Cosmos && isConnectedGraz) { - try { - const dydxOfflineSigner = await getCosmosOfflineSigner(selectedDydxChainId); - if (dydxOfflineSigner) { - setLocalDydxWallet( - await (await getLazyLocalWallet()).fromOfflineSigner(dydxOfflineSigner) - ); - dispatch(setOnboardingState(OnboardingState.AccountConnected)); - } - } catch (error) { - log('useAccounts/setLocalDydxWallet', error); - } - } else if (sourceAccount.chain === WalletNetworkType.Evm) { - if (!hasLocalDydxWallet) { - dispatch(setOnboardingState(OnboardingState.WalletConnected)); - - if ( - sourceAccount.walletInfo?.connectorType === ConnectorType.Privy && - authenticated && - ready - ) { - try { - // Give Privy a second to finish the auth flow before getting the signature - await sleep(); - const signature = await signMessageAsync(); - - await setWalletFromSignature(signature); - dispatch(setOnboardingState(OnboardingState.AccountConnected)); - } catch (error) { - log('useAccounts/decryptSignature', error); - dispatch(clearSavedEncryptedSignature()); - } - } else if (sourceAccount.encryptedSignature && !blockedGeo) { - try { - const signature = decryptSignature(sourceAccount.encryptedSignature); - - await setWalletFromSignature(signature); - dispatch(setOnboardingState(OnboardingState.AccountConnected)); - } catch (error) { - log('useAccounts/decryptSignature', error); - dispatch(clearSavedEncryptedSignature()); - } - } - } else { - dispatch(setOnboardingState(OnboardingState.AccountConnected)); - } - } else if (sourceAccount.chain === WalletNetworkType.Solana) { - if (!hasLocalDydxWallet) { - dispatch(setOnboardingState(OnboardingState.WalletConnected)); - - if (sourceAccount.encryptedSignature && !blockedGeo) { - try { - const signature = decryptSignature(sourceAccount.encryptedSignature); - await setWalletFromSignature(signature); - dispatch(setOnboardingState(OnboardingState.AccountConnected)); - } catch (error) { - log('useAccounts/decryptSignature', error); - dispatch(clearSavedEncryptedSignature()); - } - } - } else { - dispatch(setOnboardingState(OnboardingState.AccountConnected)); - } - } else { - disconnectLocalDydxWallet(); - dispatch(setOnboardingState(OnboardingState.Disconnected)); + if (result.nobleWallet) { + setLocalNobleWallet(result.nobleWallet); } - })(); - }, [signerWagmi, isConnectedGraz, sourceAccount, hasLocalDydxWallet, blockedGeo]); - useEffect(() => { - const setCosmosWallets = async () => { - let nobleWallet: LocalWallet | undefined; - let osmosisWallet: LocalWallet | undefined; - let neutronWallet: LocalWallet | undefined; - if (hdKey?.mnemonic) { - nobleWallet = await ( - await getLazyLocalWallet() - ).fromMnemonic(hdKey.mnemonic, NOBLE_BECH32_PREFIX); - osmosisWallet = await ( - await getLazyLocalWallet() - ).fromMnemonic(hdKey.mnemonic, OSMO_BECH32_PREFIX); - neutronWallet = await ( - await getLazyLocalWallet() - ).fromMnemonic(hdKey.mnemonic, NEUTRON_BECH32_PREFIX); + if (result.hdKey) { + setHdKey(result.hdKey); } - try { - const nobleOfflineSigner = await getCosmosOfflineSigner(getNobleChainId()); - if (nobleOfflineSigner !== undefined) { - nobleWallet = await (await getLazyLocalWallet()).fromOfflineSigner(nobleOfflineSigner); - } - const osmosisOfflineSigner = await getCosmosOfflineSigner(getOsmosisChainId()); - if (osmosisOfflineSigner !== undefined) { - osmosisWallet = await ( - await getLazyLocalWallet() - ).fromOfflineSigner(osmosisOfflineSigner); - } - const neutronOfflineSigner = await getCosmosOfflineSigner(getNeutronChainId()); - if (neutronOfflineSigner !== undefined) { - neutronWallet = await ( - await getLazyLocalWallet() - ).fromOfflineSigner(neutronOfflineSigner); - } + // Dispatch onboarding state + dispatch(setOnboardingState(result.onboardingState)); - if (nobleWallet !== undefined) { - setLocalNobleWallet(nobleWallet); - } - if (osmosisWallet !== undefined) { - setLocalOsmosisWallet(osmosisWallet); - } - if (neutronWallet !== undefined) { - setLocalNeutronWallet(neutronWallet); - } - } catch (error) { - log('useAccounts/setCosmosWallets', error); + // Handle disconnected state + if (result.onboardingState === OnboardingState.Disconnected && !result.wallet) { + disconnectLocalDydxWallet(); } - }; - setCosmosWallets(); - }, [hdKey?.mnemonic, getCosmosOfflineSigner]); + })(); + }, [signerWagmi, isConnectedGraz, sourceAccount, hasLocalDydxWallet, blockedGeo]); // clear subaccounts when no dydxAddress is set useEffect(() => { @@ -376,10 +218,10 @@ const useAccountsContext = () => { // Disconnect wallet / accounts const disconnectLocalDydxWallet = () => { + // Clear persisted mnemonic from SecureStorage + dydxPersistedWalletService.clearStoredWallet(); + setLocalDydxWallet(undefined); - setLocalNobleWallet(undefined); - setLocalOsmosisWallet(undefined); - setLocalNeutronWallet(undefined); setHdKey(undefined); hdKeyManager.clearHdkey(); }; @@ -398,7 +240,6 @@ const useAccountsContext = () => { return { // Wallet connection sourceAccount, - localNobleWallet, // Wallet selection selectWallet, @@ -409,17 +250,20 @@ const useAccountsContext = () => { signerWagmi, publicClientWagmi, - setWalletFromSignature, + setWalletFromTurnkeySignature, // dYdX accounts hdKey, localDydxWallet, + localNobleWallet, dydxAccounts, dydxAddress, + setLocalDydxWallet, + setLocalNobleWallet, + setHdKey, - nobleAddress, - osmosisAddress, - neutronAddress, + // Cosmos wallets (on-demand) + ...cosmosWallets, // Onboarding state saveHasAcknowledgedTerms, diff --git a/src/hooks/useAnalytics.ts b/src/hooks/useAnalytics.ts index 689bb2ee94..3d52ed44a0 100644 --- a/src/hooks/useAnalytics.ts +++ b/src/hooks/useAnalytics.ts @@ -20,6 +20,7 @@ import { getSelectedLocale } from '@/state/localizationSelectors'; import { getTradeFormValues } from '@/state/tradeFormSelectors'; import { identify, track } from '@/lib/analytics/analytics'; +import { dydxPersistedWalletService } from '@/lib/wallet/dydxPersistedWalletService'; import { useAccounts } from './useAccounts'; import { useApiState } from './useApiState'; @@ -153,10 +154,10 @@ export const useAnalytics = () => { useEffect(() => { identify( AnalyticsUserProperties.IsRememberMe( - dydxAddress ? Boolean(sourceAccount.encryptedSignature) : null + dydxAddress ? dydxPersistedWalletService.hasStoredWallet() : null ) ); - }, [dydxAddress, sourceAccount.encryptedSignature]); + }, [dydxAddress]); // AnalyticsUserProperty.SubaccountNumber const subaccountNumber = useAppSelector(getSubaccountId); diff --git a/src/hooks/useCosmosWallets.ts b/src/hooks/useCosmosWallets.ts new file mode 100644 index 0000000000..cb63399599 --- /dev/null +++ b/src/hooks/useCosmosWallets.ts @@ -0,0 +1,192 @@ +import { useCallback } from 'react'; + +import { logBonsaiError } from '@/bonsai/logs'; +import type { LocalWallet } from '@dydxprotocol/v4-client-js'; + +import { getNeutronChainId, getNobleChainId, getOsmosisChainId } from '@/constants/graz'; +import type { PrivateInformation } from '@/constants/wallets'; + +import { + deriveCosmosWallet, + deriveCosmosWalletFromPrivateKey, + deriveCosmosWalletFromSigner, +} from '@/lib/onboarding/deriveCosmosWallets'; + +/** + * + * @param hdKey - The HD key material containing the mnemonic + * @param getCosmosOfflineSigner - Function to get offline signer (for native Cosmos wallets) + * @returns Functions to get Noble, Osmosis, and Neutron wallets + */ +export function useCosmosWallets( + hdKey: PrivateInformation | undefined, + getCosmosOfflineSigner?: (chainId: string) => Promise +) { + /** + * Get Noble wallet on-demand + */ + const getNobleWallet = useCallback(async (): Promise => { + // Derive from mnemonic if available + if (hdKey?.mnemonic) { + try { + return await deriveCosmosWallet(hdKey.mnemonic, 'noble'); + } catch (error) { + logBonsaiError('useCosmosWallets', 'Failed to derive Noble wallet w/ mnemonic', { error }); + return null; + } + } + + // Derive from private key if available + if (hdKey?.privateKey) { + try { + return await deriveCosmosWalletFromPrivateKey( + Buffer.from(hdKey.privateKey).toString('hex'), + 'noble' + ); + } catch (error) { + logBonsaiError('useCosmosWallets', 'Failed to derive Noble wallet w/ private key', { + error, + }); + return null; + } + } + + // Fall through to offline signer derivation + if (getCosmosOfflineSigner) { + try { + const nobleOfflineSigner = await getCosmosOfflineSigner(getNobleChainId()); + if (nobleOfflineSigner) { + return await deriveCosmosWalletFromSigner(nobleOfflineSigner, 'noble'); + } + } catch (error) { + return null; + } + } + + return null; + }, [hdKey?.mnemonic, hdKey?.privateKey, getCosmosOfflineSigner]); + + /** + * Get Osmosis wallet on-demand + */ + const getOsmosisWallet = useCallback(async (): Promise => { + if (hdKey?.mnemonic) { + try { + return await deriveCosmosWallet(hdKey.mnemonic, 'osmosis'); + } catch (error) { + logBonsaiError('useCosmosWallets', 'Failed to derive Osmosis wallet w/ mnemonic', { + error, + }); + + return null; + } + } + + if (hdKey?.privateKey) { + try { + return await deriveCosmosWalletFromPrivateKey( + Buffer.from(hdKey.privateKey).toString('hex'), + 'osmosis' + ); + } catch (error) { + logBonsaiError('useCosmosWallets', 'Failed to derive Osmosis wallet w/ private key', { + error, + }); + return null; + } + } + + if (getCosmosOfflineSigner) { + try { + const osmosisOfflineSigner = await getCosmosOfflineSigner(getOsmosisChainId()); + if (osmosisOfflineSigner) { + return await deriveCosmosWalletFromSigner(osmosisOfflineSigner, 'osmosis'); + } + } catch (error) { + return null; + } + } + + return null; + }, [hdKey?.mnemonic, hdKey?.privateKey, getCosmosOfflineSigner]); + + /** + * Get Neutron wallet on-demand + */ + const getNeutronWallet = useCallback(async (): Promise => { + if (hdKey?.mnemonic) { + try { + return await deriveCosmosWallet(hdKey.mnemonic, 'neutron'); + } catch (error) { + logBonsaiError('useCosmosWallets', 'Failed to derive Neutron wallet w/ mnemonic', { + error, + }); + return null; + } + } + + if (hdKey?.privateKey) { + try { + return await deriveCosmosWalletFromPrivateKey( + Buffer.from(hdKey.privateKey).toString('hex'), + 'neutron' + ); + } catch (error) { + logBonsaiError('useCosmosWallets', 'Failed to derive Neutron wallet w/ private key', { + error, + }); + return null; + } + } + + if (getCosmosOfflineSigner) { + try { + const neutronOfflineSigner = await getCosmosOfflineSigner(getNeutronChainId()); + if (neutronOfflineSigner) { + return await deriveCosmosWalletFromSigner(neutronOfflineSigner, 'neutron'); + } + } catch (error) { + return null; + } + } + + return null; + }, [hdKey?.mnemonic, hdKey?.privateKey, getCosmosOfflineSigner]); + + /** + * Get Noble wallet address without creating full wallet + * Useful for display purposes + */ + const getNobleAddress = useCallback(async (): Promise => { + const wallet = await getNobleWallet(); + return wallet?.address ?? null; + }, [getNobleWallet]); + + /** + * Get Osmosis wallet address without creating full wallet + */ + const getOsmosisAddress = useCallback(async (): Promise => { + const wallet = await getOsmosisWallet(); + return wallet?.address ?? null; + }, [getOsmosisWallet]); + + /** + * Get Neutron wallet address without creating full wallet + */ + const getNeutronAddress = useCallback(async (): Promise => { + const wallet = await getNeutronWallet(); + return wallet?.address ?? null; + }, [getNeutronWallet]); + + return { + // Wallet getters + getNobleWallet, + getOsmosisWallet, + getNeutronWallet, + + // Address getters (convenience methods) + getNobleAddress, + getOsmosisAddress, + getNeutronAddress, + }; +} diff --git a/src/hooks/useDydxClient.tsx b/src/hooks/useDydxClient.tsx index 4f18a492c4..9740f107f3 100644 --- a/src/hooks/useDydxClient.tsx +++ b/src/hooks/useDydxClient.tsx @@ -6,6 +6,7 @@ import { useCompositeClient, useIndexerClient } from '@/bonsai/rest/lib/useIndex import { BECH32_PREFIX, FaucetClient, + NOBLE_BECH32_PREFIX, PnlTickInterval, SelectedGasDenom, onboarding, @@ -86,6 +87,7 @@ const useDydxClientContext = () => { return { wallet: await (await getLazyLocalWallet()).fromMnemonic(mnemonic, BECH32_PREFIX), + nobleWallet: await (await getLazyLocalWallet()).fromMnemonic(mnemonic, NOBLE_BECH32_PREFIX), mnemonic, privateKey, publicKey, diff --git a/src/hooks/useUpdateSwaps.tsx b/src/hooks/useUpdateSwaps.tsx index 8d7ca01e3e..b77f45742a 100644 --- a/src/hooks/useUpdateSwaps.tsx +++ b/src/hooks/useUpdateSwaps.tsx @@ -28,7 +28,7 @@ const SWAP_SLIPPAGE_PERCENT = '0.50'; // 0.50% (50 bps) export const useUpdateSwaps = () => { const { withdraw } = useSubaccount(); const dispatch = useAppDispatch(); - const { nobleAddress, dydxAddress, osmosisAddress, neutronAddress } = useAccounts(); + const { dydxAddress, getNobleAddress, getOsmosisAddress, getNeutronAddress } = useAccounts(); const { skipClient } = useSkipClient(); const pendingSwaps = useAppSelector(getPendingSwaps); @@ -71,6 +71,18 @@ export const useUpdateSwaps = () => { const executeSwap = useCallback( async (swap: Swap) => { const { route } = swap; + + // Derive Cosmos addresses on-demand + const [nobleAddress, osmosisAddress, neutronAddress] = await Promise.all([ + getNobleAddress(), + getOsmosisAddress(), + getNeutronAddress(), + ]); + + if (!nobleAddress || !osmosisAddress || !neutronAddress) { + throw new Error('Failed to derive Cosmos addresses'); + } + const userAddresses = getUserAddressesForRoute( route, // Don't need source account for swaps @@ -110,7 +122,7 @@ export const useUpdateSwaps = () => { }, }); }, - [dispatch, dydxAddress, neutronAddress, nobleAddress, osmosisAddress, skipClient] + [dispatch, dydxAddress, getNeutronAddress, getNobleAddress, getOsmosisAddress, skipClient] ); useEffect(() => { diff --git a/src/hooks/useWalletConnection.tsx b/src/hooks/useWalletConnection.tsx index a3539245d5..0b1c1df119 100644 --- a/src/hooks/useWalletConnection.tsx +++ b/src/hooks/useWalletConnection.tsx @@ -12,7 +12,6 @@ import { useConnect as useConnectWagmi, useDisconnect as useDisconnectWagmi, usePublicClient as usePublicClientWagmi, - useReconnect as useReconnectWagmi, useWalletClient as useWalletClientWagmi, } from 'wagmi'; @@ -149,7 +148,6 @@ export const useWalletConnectionContext = () => { ); const { connectAsync: connectWagmi } = useConnectWagmi(); - const { reconnectAsync: reconnectWagmi } = useReconnectWagmi(); const { connectAsync: connectGraz } = useConnectGraz(); const { ready, authenticated } = usePrivy(); @@ -174,15 +172,7 @@ export const useWalletConnectionContext = () => { const { logout } = useLogout(); const connectWallet = useCallback( - async ({ - wallet, - forceConnect, - isEvmAccountConnected, - }: { - wallet: WalletInfo | undefined; - forceConnect?: boolean; - isEvmAccountConnected?: boolean; - }) => { + async ({ wallet }: { wallet: WalletInfo | undefined }) => { if (!wallet) return; try { @@ -205,12 +195,11 @@ export const useWalletConnectionContext = () => { } else if (wallet.connectorType === ConnectorType.PhantomSolana) { await connectPhantom(); } else if (isWagmiConnectorType(wallet)) { - if (!isConnectedWagmi && (!!forceConnect || !isEvmAccountConnected)) { + if (!isConnectedWagmi) { const connector = resolveWagmiConnector({ wallet, walletConnectConfig }); // This could happen in the mipd case if the user has uninstalled or disabled the injected wallet they've previously selected // TODO: add analytics to see how often this happens? if (!connector) return; - await connectWagmi({ connector }); } } @@ -255,45 +244,6 @@ export const useWalletConnectionContext = () => { // Wallet selection const [selectedWalletError, setSelectedWalletError] = useState(); - // Auto-reconnect to wallet from last browser session - useEffect(() => { - (async () => { - setSelectedWalletError(undefined); - - if (selectedWallet) { - if (selectedWallet.connectorType === ConnectorType.Turnkey) { - // Turnkey does not initiate a wallet connection, so we should no op. - return; - } - - const isEvmAccountConnected = - sourceAccount.chain === WalletNetworkType.Evm && sourceAccount.encryptedSignature; - - if (isWagmiConnectorType(selectedWallet) && !isConnectedWagmi && !isEvmAccountConnected) { - const connector = resolveWagmiConnector({ wallet: selectedWallet, walletConnectConfig }); - if (!connector) return; - - await reconnectWagmi({ - connectors: [connector], - }); - } else if ( - selectedWallet.connectorType === ConnectorType.PhantomSolana && - !sourceAccount.address - ) { - await connectPhantom(); - } - } - })(); - }, [ - selectedWallet, - signerWagmi, - sourceAccount, - reconnectWagmi, - isConnectedWagmi, - walletConnectConfig, - connectPhantom, - ]); - const selectWallet = useCallback( async (wallet: WalletInfo | undefined) => { // Disconnect all wallets prior to selecting a new wallet. @@ -311,9 +261,6 @@ export const useWalletConnectionContext = () => { } else { await connectWallet({ wallet, - isEvmAccountConnected: Boolean( - sourceAccount.chain === WalletNetworkType.Evm && sourceAccount.encryptedSignature - ), }); dispatch(setWalletInfo(wallet)); @@ -333,14 +280,7 @@ export const useWalletConnectionContext = () => { await disconnectWallet(); } }, - [ - connectWallet, - disconnectWallet, - dispatch, - sourceAccount.chain, - sourceAccount.encryptedSignature, - stringGetter, - ] + [connectWallet, disconnectWallet, dispatch, stringGetter] ); // On page load, if testFlag.address is set, connect to the test wallet. diff --git a/src/lib/arrayBufferToBase64.ts b/src/lib/arrayBufferToBase64.ts new file mode 100644 index 0000000000..a31b8f4d45 --- /dev/null +++ b/src/lib/arrayBufferToBase64.ts @@ -0,0 +1,11 @@ +/** + * Converts ArrayBuffer to base64 string + */ +export function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i += 1) { + binary += String.fromCharCode(bytes[i]!); + } + return btoa(binary); +} diff --git a/src/lib/base64ToArrayBuffer.ts b/src/lib/base64ToArrayBuffer.ts new file mode 100644 index 0000000000..2e6d0292af --- /dev/null +++ b/src/lib/base64ToArrayBuffer.ts @@ -0,0 +1,11 @@ +/** + * Converts base64 string to ArrayBuffer + */ +export function base64ToArrayBuffer(base64: string): ArrayBuffer { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +} diff --git a/src/lib/onboarding/OnboardingSupervisor.ts b/src/lib/onboarding/OnboardingSupervisor.ts new file mode 100644 index 0000000000..6a44c5a8ae --- /dev/null +++ b/src/lib/onboarding/OnboardingSupervisor.ts @@ -0,0 +1,519 @@ +import { getLazyLocalWallet } from '@/bonsai/lib/lazyDynamicLibs'; +import { logBonsaiError } from '@/bonsai/logs'; +import { BECH32_PREFIX, NOBLE_BECH32_PREFIX, type LocalWallet } from '@dydxprotocol/v4-client-js'; +import { OfflineAminoSigner, OfflineDirectSigner } from '@keplr-wallet/types'; + +import { OnboardingState } from '@/constants/account'; +import { getNobleChainId } from '@/constants/graz'; +import { + ConnectorType, + DydxAddress, + PrivateInformation, + WalletNetworkType, + type WalletInfo, +} from '@/constants/wallets'; + +import { convertBech32Address } from '@/lib/addressUtils'; +import { hdKeyManager } from '@/lib/hdKeyManager'; +import { sleep } from '@/lib/timeUtils'; + +import { dydxPersistedWalletService } from '../wallet/dydxPersistedWalletService'; + +export interface SourceAccount { + address?: string; + chain?: WalletNetworkType; + walletInfo?: WalletInfo; +} + +export interface OnboardingContext { + sourceAccount: SourceAccount; + hasLocalDydxWallet: boolean; + blockedGeo: boolean; + isConnectedGraz?: boolean; + authenticated?: boolean; + ready?: boolean; +} + +export type WalletDerivationResult = + | { + wallet?: undefined; + nobleWallet?: undefined; + hdKey?: PrivateInformation; + onboardingState: OnboardingState; + error?: string; + } + | { + wallet: LocalWallet; + nobleWallet: LocalWallet; + hdKey?: PrivateInformation; + onboardingState: OnboardingState; + error?: string; + }; + +class OnboardingSupervisor { + /** + * Derive dYdX wallet from signature using DydxWalletService + * Used for EVM, Solana, and Turnkey wallets + */ + private async deriveWalletFromSignature( + signature: string, + getWalletFromSignature: (params: { signature: string }) => Promise<{ + wallet: LocalWallet; + nobleWallet: LocalWallet; + mnemonic: string; + privateKey: Uint8Array | null; + publicKey: Uint8Array | null; + }> + ): Promise<{ wallet: LocalWallet; nobleWallet: LocalWallet; hdKey: PrivateInformation }> { + const { wallet, nobleWallet, mnemonic, privateKey, publicKey } = await getWalletFromSignature({ + signature, + }); + + if (!privateKey || !publicKey) { + throw new Error('Failed to derive wallet from signature'); + } + + const hdKey: PrivateInformation = { + mnemonic, + privateKey, + publicKey, + }; + + hdKeyManager.setHdkey(wallet.address, hdKey); + await dydxPersistedWalletService.secureStorePrivateKey(privateKey); + + return { wallet, nobleWallet, hdKey }; + } + + /** + * Derive keys with determinism check for first-time users + * Ensures wallets support deterministic signing by requesting two signatures + * + * @param signMessageAsync - Function to sign a message + * @param getWalletFromSignature - Function to derive wallet from signature + * @param checkPreviousTransactions - Function to check if user has transaction history + * @returns Wallet derivation result with determinism validation + */ + async deriveKeysWithDeterminismCheck(params: { + signMessageAsync: (requestNumber: 1 | 2) => Promise; + getWalletFromSignature: (params: { signature: string }) => Promise<{ + wallet: LocalWallet; + mnemonic: string; + nobleWallet: LocalWallet; + privateKey: Uint8Array | null; + publicKey: Uint8Array | null; + }>; + checkPreviousTransactions: (dydxAddress: DydxAddress) => Promise; + }): Promise< + | { + success: true; + wallet: LocalWallet; + nobleWallet: LocalWallet; + hdKey: PrivateInformation; + isNewUser: boolean; + } + | { success: false; error: string; isDeterminismError?: boolean } + > { + const { signMessageAsync, getWalletFromSignature, checkPreviousTransactions } = params; + + try { + // Step 1: Get first signature and derive wallet + const firstSignature = await signMessageAsync(1); + const { wallet, nobleWallet, mnemonic, privateKey, publicKey } = await getWalletFromSignature( + { + signature: firstSignature, + } + ); + + if (!privateKey || !publicKey || !wallet.address) { + return { + success: false, + error: 'Failed to derive wallet from signature', + }; + } + + // Step 2: Check for previous transactions + const hasPreviousTransactions = await checkPreviousTransactions( + wallet.address as DydxAddress + ); + + // Step 3: For new users, ensure determinism with second signature + if (!hasPreviousTransactions) { + const secondSignature = await signMessageAsync(2); + + if (firstSignature !== secondSignature) { + return { + success: false, + error: + 'Your wallet does not support deterministic signing. Please switch to a different wallet provider.', + isDeterminismError: true, + }; + } + } + + // Step 4: Persist to SecureStorage + await dydxPersistedWalletService.secureStorePrivateKey(privateKey); + + // Step 5: Set up hdKey + const hdKey: PrivateInformation = { + mnemonic, + privateKey, + publicKey, + }; + + hdKeyManager.setHdkey(wallet.address, hdKey); + + return { + success: true, + wallet, + nobleWallet, + hdKey, + isNewUser: !hasPreviousTransactions, + }; + } catch (error) { + logBonsaiError('OnboardingSupervisor', 'deriveKeysWithDeterminismCheck failed', { error }); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + /** + * Restore wallet from SecureStorage if available + * Called at the start of handleWalletConnection to check for persisted session + */ + private async restoreFromSecureStorage(): Promise { + try { + const storedPrivateKey = await dydxPersistedWalletService.exportPrivateKey(); + if (!storedPrivateKey) { + return null; + } + + const LocalWallet = await getLazyLocalWallet(); + const wallet = await LocalWallet.fromPrivateKey(storedPrivateKey, BECH32_PREFIX); + const nobleWallet = await LocalWallet.fromPrivateKey(storedPrivateKey, NOBLE_BECH32_PREFIX); + + const hdKey: PrivateInformation = { + mnemonic: '', + privateKey: Buffer.from(storedPrivateKey, 'hex'), + publicKey: null, + }; + + return { + wallet, + nobleWallet, + hdKey, + onboardingState: OnboardingState.AccountConnected, + }; + } catch (error) { + logBonsaiError('OnboardingSupervisor', 'Failed to restore from SecureStorage', { error }); + return null; + } + } + + /** + * Handles all wallet type flows and determines next onboarding state + */ + async handleWalletConnection(params: { + context: OnboardingContext; + getWalletFromSignature: (params: { signature: string }) => Promise<{ + wallet: LocalWallet; + nobleWallet: LocalWallet; + mnemonic: string; + privateKey: Uint8Array | null; + publicKey: Uint8Array | null; + }>; + signMessageAsync?: () => Promise; + getCosmosOfflineSigner?: ( + chainId: string + ) => Promise<(OfflineAminoSigner & OfflineDirectSigner) | undefined>; + selectedDydxChainId?: string; + }): Promise { + const { + context, + getWalletFromSignature, + signMessageAsync, + getCosmosOfflineSigner, + selectedDydxChainId, + } = params; + const { sourceAccount, hasLocalDydxWallet, blockedGeo, isConnectedGraz, authenticated, ready } = + context; + + try { + // ------ Restore from SecureStorage ------ // + // Check for persisted session before processing wallet connections + if (dydxPersistedWalletService.hasStoredWallet() && !blockedGeo) { + const restored = await this.restoreFromSecureStorage(); + + if (restored) { + return restored; + } + } + + // ------ Turnkey Flow ------ // + if (sourceAccount.walletInfo?.connectorType === ConnectorType.Turnkey) { + return await this.handleTurnkeyFlow({ + hasLocalDydxWallet, + blockedGeo, + }); + } + + // ------ Impersonate Wallet Flow ------ // + if (sourceAccount.walletInfo?.connectorType === ConnectorType.Test) { + return await this.handleTestWalletFlow(sourceAccount); + } + + // ------ Cosmos Flow ------ // + if (sourceAccount.chain === WalletNetworkType.Cosmos && isConnectedGraz) { + return await this.handleCosmosFlow({ + getCosmosOfflineSigner, + selectedDydxChainId, + }); + } + + // ------ Evm Flow ------ // + if (sourceAccount.chain === WalletNetworkType.Evm) { + return await this.handleEvmFlow({ + sourceAccount, + hasLocalDydxWallet, + blockedGeo, + authenticated, + ready, + signMessageAsync, + getWalletFromSignature, + }); + } + + // ------ Solana Flow ------ // + if (sourceAccount.chain === WalletNetworkType.Solana) { + return await this.handleSolanaFlow({ + sourceAccount, + hasLocalDydxWallet, + blockedGeo, + getWalletFromSignature, + signMessageAsync, + }); + } + + return { + onboardingState: OnboardingState.Disconnected, + }; + } catch (error) { + logBonsaiError('OnboardingSupervisor', 'handleWalletConnection failed', { error }); + return { + onboardingState: OnboardingState.Disconnected, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + /** + * Handle Turnkey wallet flow + * Turnkey is an embedded wallet managed entirely by TurnkeyAuthProvider + * Signing is done via Turnkey SDK, persistence via setWalletFromTurnkeySignature() + * OnboardingSupervisor only validates state + */ + private async handleTurnkeyFlow(params: { + hasLocalDydxWallet: boolean; + blockedGeo: boolean; + }): Promise { + const { hasLocalDydxWallet, blockedGeo } = params; + + // If wallet already exists (restored from SecureStorage or set by TurnkeyAuthProvider) + if (hasLocalDydxWallet) { + return { + onboardingState: OnboardingState.AccountConnected, + }; + } + + // If geo-blocked, user cannot proceed + if (blockedGeo) { + return { + onboardingState: OnboardingState.Disconnected, + }; + } + + // Wallet is being set up by TurnkeyAuthProvider + // Return Disconnected until TurnkeyAuthProvider completes the flow + return { + onboardingState: OnboardingState.Disconnected, + }; + } + + /** + * Handle test wallet flow + */ + private async handleTestWalletFlow( + sourceAccount: SourceAccount + ): Promise { + const LocalWallet = await getLazyLocalWallet(); + + // Create dYdX test wallet + const wallet = new LocalWallet(); + wallet.address = sourceAccount.address!; + + // Create Noble test wallet with bech32 conversion + const nobleWallet = new LocalWallet(); + nobleWallet.address = convertBech32Address({ + address: sourceAccount.address!, + bech32Prefix: NOBLE_BECH32_PREFIX, + }); + + return { + wallet, + nobleWallet, + // Test wallets don't have hdKey material + onboardingState: OnboardingState.AccountConnected, + }; + } + + /** + * Handle Cosmos wallet flow + */ + private async handleCosmosFlow(params: { + getCosmosOfflineSigner?: ( + chainId: string + ) => Promise<(OfflineAminoSigner & OfflineDirectSigner) | undefined>; + selectedDydxChainId?: string; + }): Promise { + const { getCosmosOfflineSigner, selectedDydxChainId } = params; + + if (!getCosmosOfflineSigner || !selectedDydxChainId) { + return { + onboardingState: OnboardingState.Disconnected, + error: 'Missing Cosmos dependencies', + }; + } + + try { + const dydxOfflineSigner = await getCosmosOfflineSigner(selectedDydxChainId); + const nobleOfflineSigner = await getCosmosOfflineSigner(getNobleChainId()); + + if (dydxOfflineSigner && nobleOfflineSigner) { + const wallet = await (await getLazyLocalWallet()).fromOfflineSigner(dydxOfflineSigner); + const nobleWallet = await ( + await getLazyLocalWallet() + ).fromOfflineSigner(nobleOfflineSigner); + + return { + wallet, + nobleWallet, + // Cosmos wallets from offline signer don't expose hdKey material + onboardingState: OnboardingState.AccountConnected, + }; + } + } catch (error) { + logBonsaiError('OnboardingSupervisor', 'Cosmos wallet creation failed', { error }); + } + + return { + onboardingState: OnboardingState.Disconnected, + error: 'Failed to create Cosmos wallet', + }; + } + + /** + * Handle EVM wallet flow + */ + private async handleEvmFlow(params: { + sourceAccount: SourceAccount; + hasLocalDydxWallet: boolean; + blockedGeo: boolean; + authenticated?: boolean; + ready?: boolean; + signMessageAsync?: () => Promise; + getWalletFromSignature: any; + }): Promise { + const { + sourceAccount, + hasLocalDydxWallet, + blockedGeo, + authenticated, + ready, + signMessageAsync, + getWalletFromSignature, + } = params; + + // If wallet already exists (restored from SecureStorage), just set state + if (hasLocalDydxWallet) { + return { + onboardingState: OnboardingState.AccountConnected, + }; + } + + // If geo-blocked, stay in WalletConnected state + if (blockedGeo) { + return { + onboardingState: OnboardingState.WalletConnected, + }; + } + + // Privy flow - needs authentication + if (sourceAccount.walletInfo?.connectorType === ConnectorType.Privy && authenticated && ready) { + try { + // Give Privy time to finish auth flow + await sleep(); + const signature = await signMessageAsync!(); + const { wallet, nobleWallet, hdKey } = await this.deriveWalletFromSignature( + signature, + getWalletFromSignature + ); + + return { + wallet, + nobleWallet, + hdKey, + onboardingState: OnboardingState.AccountConnected, + }; + } catch (error) { + logBonsaiError('OnboardingSupervisor', 'Privy signing failed', { error }); + + return { + onboardingState: OnboardingState.WalletConnected, + error: 'Failed to sign with Privy', + }; + } + } + + // Other EVM wallets - need to trigger signing flow in UI + // Wallet connected but waiting for signature + return { + onboardingState: OnboardingState.WalletConnected, + }; + } + + /** + * Handle Solana wallet flow + */ + private async handleSolanaFlow(params: { + sourceAccount: SourceAccount; + hasLocalDydxWallet: boolean; + blockedGeo: boolean; + getWalletFromSignature: any; + signMessageAsync?: () => Promise; + }): Promise { + const { hasLocalDydxWallet, blockedGeo } = params; + + // If wallet already exists (restored from SecureStorage), just set state + if (hasLocalDydxWallet) { + return { + onboardingState: OnboardingState.AccountConnected, + }; + } + + // If geo-blocked, stay in WalletConnected state + if (blockedGeo) { + return { + onboardingState: OnboardingState.WalletConnected, + }; + } + + // Wallet connected but waiting for signature + return { + onboardingState: OnboardingState.WalletConnected, + }; + } +} + +export const onboardingManager = new OnboardingSupervisor(); diff --git a/src/lib/onboarding/deriveCosmosWallets.ts b/src/lib/onboarding/deriveCosmosWallets.ts new file mode 100644 index 0000000000..82cbbce16c --- /dev/null +++ b/src/lib/onboarding/deriveCosmosWallets.ts @@ -0,0 +1,84 @@ +import { getLazyLocalWallet } from '@/bonsai/lib/lazyDynamicLibs'; +import { logBonsaiError } from '@/bonsai/logs'; +import { LocalWallet, NOBLE_BECH32_PREFIX } from '@dydxprotocol/v4-client-js'; + +import { NEUTRON_BECH32_PREFIX, OSMO_BECH32_PREFIX } from '@/constants/graz'; + +export type SupportedCosmosChain = 'noble' | 'osmosis' | 'neutron'; + +/** + * Derive a Cosmos wallet on-demand from mnemonic + * Used for Noble, Osmosis, Neutron wallets when needed + * + * @param mnemonic - The mnemonic to derive from + * @param chain - Which Cosmos chain wallet to derive + * @returns LocalWallet for the specified chain + */ +export async function deriveCosmosWallet( + mnemonic: string, + chain: SupportedCosmosChain +): Promise { + try { + const prefix = getCosmosPrefix(chain); + const LazyLocalWallet = await getLazyLocalWallet(); + return await LazyLocalWallet.fromMnemonic(mnemonic, prefix); + } catch (error) { + logBonsaiError('OnboardingSupervisor', `Failed to derive ${chain} wallet from mnemonic`, { + error, + }); + + return null; + } +} + +export async function deriveCosmosWalletFromPrivateKey( + privateKey: string, + chain: SupportedCosmosChain +): Promise { + try { + const prefix = getCosmosPrefix(chain); + const LazyLocalWallet = await getLazyLocalWallet(); + return await LazyLocalWallet.fromPrivateKey(privateKey, prefix); + } catch (error) { + logBonsaiError('OnboardingSupervisor', `Failed to derive ${chain} wallet from private key`, { + error, + }); + + return null; + } +} + +/** + * Derive a Cosmos wallet from offline signer + * Used when user has a native Cosmos wallet connected + */ +export async function deriveCosmosWalletFromSigner( + offlineSigner: any, + chain: string +): Promise { + try { + const LazyLocalWallet = await getLazyLocalWallet(); + return await LazyLocalWallet.fromOfflineSigner(offlineSigner); + } catch (error) { + logBonsaiError('OnboardingSupervisor', `Failed to derive ${chain} wallet from signer`, { + error, + }); + return null; + } +} + +/** + * Get the Bech32 prefix for a Cosmos chain + */ +function getCosmosPrefix(chain: SupportedCosmosChain): string { + switch (chain) { + case 'noble': + return NOBLE_BECH32_PREFIX; + case 'osmosis': + return OSMO_BECH32_PREFIX; + case 'neutron': + return NEUTRON_BECH32_PREFIX; + default: + throw new Error(`Unknown Cosmos chain: ${chain}`); + } +} diff --git a/src/lib/wallet/dydxPersistedWalletService.ts b/src/lib/wallet/dydxPersistedWalletService.ts new file mode 100644 index 0000000000..3e0237a650 --- /dev/null +++ b/src/lib/wallet/dydxPersistedWalletService.ts @@ -0,0 +1,57 @@ +import { logBonsaiError } from '@/bonsai/logs'; + +import { DydxAddress } from '@/constants/wallets'; + +import { secureStorage } from './secureStorage'; + +const STORAGE_KEY = 'trading_wallet_key'; + +export interface WalletCreationResult { + success: boolean; + dydxAddress?: DydxAddress; + error?: string; +} + +export class DydxPersistedWalletService { + hasStoredWallet(): boolean { + return secureStorage.has(STORAGE_KEY); + } + + /** + * Called on user sign out + */ + clearStoredWallet(): void { + secureStorage.remove(STORAGE_KEY); + } + + /** + * Store private key in secure storage + * @param privateKey - Private key to store + */ + async secureStorePrivateKey(privateKey?: Uint8Array | null): Promise { + try { + if (!privateKey) { + this.clearStoredWallet(); + throw new Error('PrivateKey was not derived from Signature'); + } + + await secureStorage.store(STORAGE_KEY, Buffer.from(privateKey).toString('hex')); + } catch (error) { + logBonsaiError('DydxWalletService', `Failed to secure store ${STORAGE_KEY}`, { error }); + } + } + + /** + * @returns Decrypted trading key or null if not found + */ + async exportPrivateKey(): Promise { + try { + return await secureStorage.retrieve(STORAGE_KEY); + } catch (error) { + logBonsaiError('DydxWalletService', `Failed to export ${STORAGE_KEY}`, { error }); + return null; + } + } +} + +export const dydxPersistedWalletService = new DydxPersistedWalletService(); diff --git a/src/lib/wallet/secureStorage.ts b/src/lib/wallet/secureStorage.ts new file mode 100644 index 0000000000..5f1f4943a8 --- /dev/null +++ b/src/lib/wallet/secureStorage.ts @@ -0,0 +1,173 @@ +import { logBonsaiError } from '@/bonsai/logs'; + +import { arrayBufferToBase64 } from '@/lib/arrayBufferToBase64'; +import { base64ToArrayBuffer } from '@/lib/base64ToArrayBuffer'; + +const STORAGE_PREFIX = 'dydx.secure.'; +const SALT_KEY = `${STORAGE_PREFIX}salt`; + +interface EncryptedData { + data: string; + iv: string; + version: number; // Version for future migrations +} + +/** + * @class SecureStorageService + * @description Provides encrypted storage for sensitive data using the Web Crypto API. + * Uses a browser-specific encryption key derived from a random salt. + */ +export class SecureStorageService { + private encryptionKey: CryptoKey | null = null; + + /** + * Get or create a browser-specific encryption key + * The key is derived from a random salt stored in localStorage + */ + private async getOrCreateEncryptionKey(): Promise { + // Return cached key if available + if (this.encryptionKey) { + return this.encryptionKey; + } + + // Get or create salt + let salt = localStorage.getItem(SALT_KEY); + if (!salt) { + // First time - generate random salt + const saltArray = crypto.getRandomValues(new Uint8Array(32)); + salt = arrayBufferToBase64(saltArray.buffer); + localStorage.setItem(SALT_KEY, salt); + } + + // Import the salt as key material + const saltBuffer = base64ToArrayBuffer(salt); + const keyMaterial = await crypto.subtle.importKey( + 'raw', + saltBuffer, + { name: 'PBKDF2' }, + false, + ['deriveKey'] + ); + + // Derive encryption key from salt + // Using a static pepper for additional entropy + const pepper = new TextEncoder().encode('dydx-v4-web-secure-storage'); + this.encryptionKey = await crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: pepper, + iterations: 100000, + hash: 'SHA-256', + }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); + + return this.encryptionKey; + } + + /** + * Encrypt and store data + * @param key - Storage key (will be prefixed) + * @param data - String data to encrypt + */ + async store(key: string, data: string): Promise { + const encryptionKey = await this.getOrCreateEncryptionKey(); + + // Generate random IV for this encryption + const iv = crypto.getRandomValues(new Uint8Array(12)); + + // Encrypt the data + const encodedData = new TextEncoder().encode(data); + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + encryptionKey, + encodedData + ); + + // Store encrypted data with IV + const encryptedData: EncryptedData = { + data: arrayBufferToBase64(encrypted), + iv: arrayBufferToBase64(iv.buffer), + version: 1, + }; + + localStorage.setItem(`${STORAGE_PREFIX}${key}`, JSON.stringify(encryptedData)); + } + + /** + * Retrieve and decrypt data + * @param key - Storage key (will be prefixed) + * @returns Decrypted string or null if not found + */ + async retrieve(key: string): Promise { + const stored = localStorage.getItem(`${STORAGE_PREFIX}${key}`); + if (!stored) { + return null; + } + + try { + const encryptedData: EncryptedData = JSON.parse(stored); + const encryptionKey = await this.getOrCreateEncryptionKey(); + + // Decrypt the data + const encryptedBuffer = base64ToArrayBuffer(encryptedData.data); + const ivBuffer = base64ToArrayBuffer(encryptedData.iv); + + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: ivBuffer }, + encryptionKey, + encryptedBuffer + ); + + return new TextDecoder().decode(decrypted); + } catch (error) { + logBonsaiError('SecureStorage', 'Failed to decrypt data', { error }); + // Data might be corrupted or key changed - remove it + this.remove(key); + return null; + } + } + + /** + * Remove encrypted data + * @param key - Storage key (will be prefixed) + */ + remove(key: string): void { + localStorage.removeItem(`${STORAGE_PREFIX}${key}`); + } + + /** + * Clear all secure storage data including salt + * WARNING: This will make all encrypted data unrecoverable + */ + clearAll(): void { + // Remove salt + localStorage.removeItem(SALT_KEY); + + // Remove all encrypted items + const keysToRemove: string[] = []; + for (let i = 0; i < localStorage.length; i += 1) { + const key = localStorage.key(i); + if (key?.startsWith(STORAGE_PREFIX)) { + keysToRemove.push(key); + } + } + keysToRemove.forEach((key) => localStorage.removeItem(key)); + + // Clear cached key + this.encryptionKey = null; + } + + /** + * Check if data exists for a key + * @param key - Storage key (will be prefixed) + */ + has(key: string): boolean { + return localStorage.getItem(`${STORAGE_PREFIX}${key}`) !== null; + } +} + +export const secureStorage = new SecureStorageService(); diff --git a/src/providers/TurnkeyAuthProvider.tsx b/src/providers/TurnkeyAuthProvider.tsx index a8086507b5..04ec11c15d 100644 --- a/src/providers/TurnkeyAuthProvider.tsx +++ b/src/providers/TurnkeyAuthProvider.tsx @@ -31,6 +31,7 @@ import { getSourceAccount, getTurnkeyEmailOnboardingData } from '@/state/walletS import { identify, track } from '@/lib/analytics/analytics'; import { parseTurnkeyError } from '@/lib/turnkey/turnkeyUtils'; +import { dydxPersistedWalletService } from '@/lib/wallet/dydxPersistedWalletService'; import { useTurnkeyWallet } from './TurnkeyWalletProvider'; @@ -70,7 +71,12 @@ const useTurnkeyAuthContext = () => { const indexerUrl = useAppSelector(selectIndexerUrl); const sourceAccount = useAppSelector(getSourceAccount); const { indexedDbClient, authIframeClient } = useTurnkey(); - const { dydxAddress: connectedDydxAddress, setWalletFromSignature, selectWallet } = useAccounts(); + const { + dydxAddress: connectedDydxAddress, + setWalletFromTurnkeySignature, + selectWallet, + } = useAccounts(); + const [searchParams, setSearchParams] = useSearchParams(); const [emailToken, setEmailToken] = useState(); const [emailSignInError, setEmailSignInError] = useState(); @@ -309,7 +315,7 @@ const useTurnkeyAuthContext = () => { await indexedDbClient?.loginWithSession(session); const derivedDydxAddress = await onboardDydx({ salt, - setWalletFromSignature, + setWalletFromTurnkeySignature, tkClient: indexedDbClient, }); @@ -329,7 +335,7 @@ const useTurnkeyAuthContext = () => { setEmailSignInStatus('success'); setEmailSignInError(undefined); }, - [onboardDydx, indexedDbClient, setWalletFromSignature, uploadAddress] + [onboardDydx, indexedDbClient, setWalletFromTurnkeySignature, uploadAddress] ); /* ----------------------------- Email Sign In ----------------------------- */ @@ -402,7 +408,7 @@ const useTurnkeyAuthContext = () => { await indexedDbClient.loginWithSession(session); const derivedDydxAddress = await onboardDydx({ - setWalletFromSignature, + setWalletFromTurnkeySignature, tkClient: indexedDbClient, }); @@ -476,7 +482,7 @@ const useTurnkeyAuthContext = () => { targetPublicKeys, turnkeyEmailOnboardingData, onboardDydx, - setWalletFromSignature, + setWalletFromTurnkeySignature, searchParams, setSearchParams, stringGetter, @@ -532,12 +538,12 @@ const useTurnkeyAuthContext = () => { */ useEffect(() => { const turnkeyOnboardingToken = searchParams.get('token'); - const hasEncryptedSignature = sourceAccount.encryptedSignature != null; + const hasStoredWallet = dydxPersistedWalletService.hasStoredWallet(); if (turnkeyOnboardingToken && connectedDydxAddress != null) { searchParams.delete('token'); setSearchParams(searchParams); - } else if (turnkeyOnboardingToken && !hasEncryptedSignature) { + } else if (turnkeyOnboardingToken && !hasStoredWallet) { setEmailToken(turnkeyOnboardingToken); dispatch(openDialog(DialogTypes.EmailSignInStatus({}))); } diff --git a/src/providers/TurnkeyWalletProvider.tsx b/src/providers/TurnkeyWalletProvider.tsx index 45e2668f49..a8f1aea20d 100644 --- a/src/providers/TurnkeyWalletProvider.tsx +++ b/src/providers/TurnkeyWalletProvider.tsx @@ -4,7 +4,6 @@ import { logBonsaiError } from '@/bonsai/logs'; import { uncompressRawPublicKey } from '@turnkey/crypto'; import { TurnkeyIndexedDbClient } from '@turnkey/sdk-browser'; import { useTurnkey } from '@turnkey/sdk-react'; -import { AES } from 'crypto-js'; import { hashMessage, hashTypedData, toHex } from 'viem'; import { ConnectorType, getSignTypedDataForTurnkey } from '@/constants/wallets'; @@ -18,11 +17,7 @@ import { import { getSelectedDydxChainId } from '@/state/appSelectors'; import { useAppDispatch, useAppSelector } from '@/state/appTypes'; -import { - clearTurnkeyPrimaryWallet, - setSavedEncryptedSignature, - setTurnkeyPrimaryWallet, -} from '@/state/wallet'; +import { clearTurnkeyPrimaryWallet, setTurnkeyPrimaryWallet } from '@/state/wallet'; import { getSourceAccount, getTurnkeyEmailOnboardingData, @@ -194,11 +189,11 @@ const useTurnkeyWalletContext = () => { const onboardDydx = useCallback( async ({ salt, - setWalletFromSignature, + setWalletFromTurnkeySignature, tkClient, }: { salt?: string; - setWalletFromSignature: (signature: string) => Promise; + setWalletFromTurnkeySignature: (signature: string) => Promise; tkClient?: TurnkeyIndexedDbClient; }) => { const selectedTurnkeyWallet = primaryTurnkeyWallet ?? (await getPrimaryUserWallets(tkClient)); @@ -234,18 +229,10 @@ const useTurnkeyWalletContext = () => { }); const signature = `${response.r}${response.s}${response.v}`; - const staticEncryptionKey = import.meta.env.VITE_PK_ENCRYPTION_KEY; - - if (staticEncryptionKey) { - const encryptedSignature = AES.encrypt(signature, staticEncryptionKey).toString(); - dispatch(setSavedEncryptedSignature(encryptedSignature)); - } - - const dydxAddress = await setWalletFromSignature(signature); + const dydxAddress = await setWalletFromTurnkeySignature(signature); return dydxAddress; }, [ - dispatch, turnkeyEmailOnboardingData, primaryTurnkeyWallet, getPrimaryUserWallets, diff --git a/src/state/_store.ts b/src/state/_store.ts index f308f1c8db..34cefa2077 100644 --- a/src/state/_store.ts +++ b/src/state/_store.ts @@ -65,7 +65,7 @@ const rootReducer = combineReducers(reducers); const persistConfig = { key: 'root', - version: 6, + version: 7, storage, whitelist: [ 'affiliates', diff --git a/src/state/migrations.ts b/src/state/migrations.ts index 10e0d0473f..6e730c0a8e 100644 --- a/src/state/migrations.ts +++ b/src/state/migrations.ts @@ -7,6 +7,7 @@ import { migration2 } from './migrations/2'; import { migration3 } from './migrations/3'; import { migration4 } from './migrations/4'; import { migration5 } from './migrations/5'; +import { migration6 } from './migrations/6'; /** * @description Migrate function should be used when the expected param for your migration is a previous state with reducer data @@ -27,6 +28,7 @@ export const migrations: MigrationManifest = { 3: migration3, 4: (state: PersistedState) => migrate(state, migration4), 6: migration5, + 7: migration6, } as const; /* diff --git a/src/state/migrations/6.ts b/src/state/migrations/6.ts new file mode 100644 index 0000000000..e51884ecd9 --- /dev/null +++ b/src/state/migrations/6.ts @@ -0,0 +1,35 @@ +import { PersistedState } from 'redux-persist'; + +type PersistAppStateV6 = PersistedState & { + wallet: { + sourceAccount: { + address?: string; + chain?: string; + walletInfo?: any; + }; + }; +}; + +/** + * Remove encrypted signatures from state + * Users will need to reconnect and sign again + * New signatures will be stored in SecureStorage instead + */ +export function migration6(state: PersistedState | undefined): PersistAppStateV6 { + if (!state) throw new Error('state must be defined'); + + const walletState = (state as any).wallet; + + return { + ...state, + wallet: { + ...walletState, + sourceAccount: { + address: walletState?.sourceAccount?.address, + chain: walletState?.sourceAccount?.chain, + walletInfo: walletState?.sourceAccount?.walletInfo, + // encryptedSignature removed - users will need to reconnect + }, + }, + }; +} diff --git a/src/state/wallet.ts b/src/state/wallet.ts index a0a97e7bbc..d458f317fa 100644 --- a/src/state/wallet.ts +++ b/src/state/wallet.ts @@ -7,7 +7,6 @@ import { TurnkeyEmailOnboardingData, TurnkeyWallet } from '@/types/turnkey'; export type SourceAccount = { address?: string; chain?: WalletNetworkType; - encryptedSignature?: string; walletInfo?: WalletInfo; }; @@ -17,6 +16,9 @@ export interface WalletState { localWallet?: { address?: string; subaccountNumber?: number; + // Indicates if wallet was directly imported (mnemonic) vs derived from source wallet + // When 'imported', sourceAccount is not required for AccountConnected state + walletSource?: 'imported' | 'derived'; }; turnkeyEmailOnboardingData?: TurnkeyEmailOnboardingData; turnkeyPrimaryWallet?: TurnkeyWallet; @@ -26,12 +28,12 @@ const initialState: WalletState = { sourceAccount: { address: undefined, chain: undefined, - encryptedSignature: undefined, walletInfo: undefined, }, localWallet: { address: undefined, subaccountNumber: 0, + walletSource: undefined, }, turnkeyEmailOnboardingData: undefined, turnkeyPrimaryWallet: undefined, @@ -46,34 +48,21 @@ export const walletSlice = createSlice({ action: PayloadAction<{ address: string; chain: WalletNetworkType }> ) => { const { address, chain } = action.payload; - if (!state.sourceAccount) { - throw new Error('cannot set source address if source account is not defined'); - } - - // if the source wallet address has changed, clear the derived signature - if (state.sourceAccount.address !== address) { - state.sourceAccount.encryptedSignature = undefined; - } - state.sourceAccount.address = address; state.sourceAccount.chain = chain; }, setWalletInfo: (state, action: PayloadAction) => { state.sourceAccount.walletInfo = action.payload; }, - setSavedEncryptedSignature: (state, action: PayloadAction) => { - if (state.sourceAccount.chain === WalletNetworkType.Cosmos) { - throw new Error('cosmos wallets should not require signatures for derived addresses'); - } - - state.sourceAccount.encryptedSignature = action.payload; - }, - clearSavedEncryptedSignature: (state) => { - state.sourceAccount.encryptedSignature = undefined; - }, setLocalWallet: ( state, - { payload }: PayloadAction<{ address?: string; subaccountNumber?: number }> + { + payload, + }: PayloadAction<{ + address?: string; + subaccountNumber?: number; + walletSource?: 'imported' | 'derived'; + }> ) => { state.localWallet = payload; }, @@ -93,7 +82,6 @@ export const walletSlice = createSlice({ state.sourceAccount = { address: undefined, chain: undefined, - encryptedSignature: undefined, walletInfo: undefined, }; state.turnkeyPrimaryWallet = undefined; @@ -127,8 +115,6 @@ export const walletEphemeralSlice = createSlice({ export const { setSourceAddress, setWalletInfo, - setSavedEncryptedSignature, - clearSavedEncryptedSignature, clearSourceAccount, setLocalWallet, setTurnkeyEmailOnboardingData, diff --git a/src/views/dialogs/CoinbaseDepositDialog.tsx b/src/views/dialogs/CoinbaseDepositDialog.tsx index ec6b9ec59f..3b3f54f55d 100644 --- a/src/views/dialogs/CoinbaseDepositDialog.tsx +++ b/src/views/dialogs/CoinbaseDepositDialog.tsx @@ -1,5 +1,7 @@ import { useState } from 'react'; +import { NOBLE_BECH32_PREFIX } from '@dydxprotocol/v4-client-js'; + import { ButtonAction, ButtonType } from '@/constants/buttons'; import { CoinbaseDepositDialogProps, DialogProps } from '@/constants/dialogs'; import { STRING_KEYS } from '@/constants/localization'; @@ -14,6 +16,8 @@ import { Dialog } from '@/components/Dialog'; import { GreenCheckCircle } from '@/components/GreenCheckCircle'; import { QrCode } from '@/components/QrCode'; +import { convertBech32Address } from '@/lib/addressUtils'; + const THREE_SECOND_DELAY = 3000; export const CoinbaseDepositDialog = ({ onBack, @@ -21,7 +25,14 @@ export const CoinbaseDepositDialog = ({ }: DialogProps) => { const stringGetter = useStringGetter(); const [showCopyLogo, setShowCopyLogo] = useState(true); - const { nobleAddress } = useAccounts(); + const { dydxAddress } = useAccounts(); + + const nobleAddress = + dydxAddress && + convertBech32Address({ + address: dydxAddress as string, + bech32Prefix: NOBLE_BECH32_PREFIX, + }); const onCopy = () => { if (!nobleAddress) return; diff --git a/src/views/dialogs/TransferDialogs/DepositDialog2/DepositForm/DepositForm.tsx b/src/views/dialogs/TransferDialogs/DepositDialog2/DepositForm/DepositForm.tsx index f98f2ec655..b6d74aad8b 100644 --- a/src/views/dialogs/TransferDialogs/DepositDialog2/DepositForm/DepositForm.tsx +++ b/src/views/dialogs/TransferDialogs/DepositDialog2/DepositForm/DepositForm.tsx @@ -211,7 +211,7 @@ export const DepositForm = ({ const connectWagmi = async () => { try { setAwaitingWalletAction(true); - await connectWallet({ wallet: selectedWallet, forceConnect: true }); + await connectWallet({ wallet: selectedWallet }); setAwaitingWalletAction(false); } catch (e) { setAwaitingWalletAction(false); diff --git a/src/views/dialogs/TransferDialogs/DepositDialog2/DepositForm/QrDeposit.tsx b/src/views/dialogs/TransferDialogs/DepositDialog2/DepositForm/QrDeposit.tsx index 29946baae2..1131799256 100644 --- a/src/views/dialogs/TransferDialogs/DepositDialog2/DepositForm/QrDeposit.tsx +++ b/src/views/dialogs/TransferDialogs/DepositDialog2/DepositForm/QrDeposit.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; +import { NOBLE_BECH32_PREFIX } from '@dydxprotocol/v4-client-js'; import styled from 'styled-components'; import { ButtonAction } from '@/constants/buttons'; @@ -16,13 +17,21 @@ import { Button } from '@/components/Button'; import { Icon, IconName } from '@/components/Icon'; import { QrCode } from '@/components/QrCode'; +import { convertBech32Address } from '@/lib/addressUtils'; import { truncateAddress } from '@/lib/wallet'; export const QrDeposit = ({ disabled }: { disabled: boolean }) => { const stringGetter = useStringGetter(); - const { nobleAddress } = useAccounts(); + const { dydxAddress } = useAccounts(); const [isCopied, setIsCopied] = useState(false); + const nobleAddress = + dydxAddress && + convertBech32Address({ + address: dydxAddress as string, + bech32Prefix: NOBLE_BECH32_PREFIX, + }); + const onCopy = () => { if (!nobleAddress || nobleAddress.trim() === '') return; diff --git a/src/views/dialogs/TransferDialogs/DepositDialog2/depositHooks.ts b/src/views/dialogs/TransferDialogs/DepositDialog2/depositHooks.ts index 3a889a5315..184aad4e5d 100644 --- a/src/views/dialogs/TransferDialogs/DepositDialog2/depositHooks.ts +++ b/src/views/dialogs/TransferDialogs/DepositDialog2/depositHooks.ts @@ -1,5 +1,8 @@ +import { useMemo } from 'react'; + import { logBonsaiError, logBonsaiInfo } from '@/bonsai/logs'; import { OfflineSigner } from '@cosmjs/proto-signing'; +import { NOBLE_BECH32_PREFIX } from '@dydxprotocol/v4-client-js'; import { Erc20Approval, RouteResponse } from '@skip-go/client'; import { useQuery } from '@tanstack/react-query'; import { Address, WalletClient, maxUint256 } from 'viem'; @@ -8,6 +11,7 @@ import { useChainId } from 'wagmi'; import ERC20ABI from '@/abi/erc20.json'; import { AnalyticsEvents } from '@/constants/analytics'; import { isEvmDepositChainId } from '@/constants/chains'; +import { OSMO_BECH32_PREFIX } from '@/constants/graz'; import { STRING_KEYS } from '@/constants/localization'; import { TokenForTransfer } from '@/constants/tokens'; import { WalletNetworkType } from '@/constants/wallets'; @@ -19,6 +23,7 @@ import { useStringGetter } from '@/hooks/useStringGetter'; import { Deposit } from '@/state/transfers'; import { SourceAccount } from '@/state/wallet'; +import { convertBech32Address } from '@/lib/addressUtils'; import { track } from '@/lib/analytics/analytics'; import { sleep } from '@/lib/timeUtils'; import { CHAIN_ID_TO_INFO, EvmDepositChainId, VIEM_PUBLIC_CLIENTS } from '@/lib/viem'; @@ -59,7 +64,22 @@ export function useDepositSteps({ const stringGetter = useStringGetter(); const walletChainId = useChainId(); const { skipClient } = useSkipClient(); - const { nobleAddress, dydxAddress, osmosisAddress } = useAccounts(); + const { dydxAddress } = useAccounts(); + + const [nobleAddress, osmosisAddress] = useMemo(() => { + if (!dydxAddress) return [undefined, undefined]; + + return [ + convertBech32Address({ + address: dydxAddress as string, + bech32Prefix: NOBLE_BECH32_PREFIX, + }), + convertBech32Address({ + address: dydxAddress as string, + bech32Prefix: OSMO_BECH32_PREFIX, + }), + ]; + }, [dydxAddress]); async function getStepsQuery() { if (!depositRoute || !sourceAccount.address) return []; diff --git a/src/views/dialogs/TransferDialogs/DepositDialog2/queries.ts b/src/views/dialogs/TransferDialogs/DepositDialog2/queries.ts index e343fd8971..404c592d43 100644 --- a/src/views/dialogs/TransferDialogs/DepositDialog2/queries.ts +++ b/src/views/dialogs/TransferDialogs/DepositDialog2/queries.ts @@ -2,6 +2,7 @@ import { useMemo } from 'react'; import { logBonsaiInfo } from '@/bonsai/logs'; import { BonsaiHelpers } from '@/bonsai/ontology'; +import { NOBLE_BECH32_PREFIX } from '@dydxprotocol/v4-client-js'; import { BalanceRequest, RouteRequest, RouteResponse } from '@skip-go/client'; import { useQuery } from '@tanstack/react-query'; import { orderBy, partition } from 'lodash'; @@ -9,7 +10,7 @@ import { Chain, parseUnits } from 'viem'; import { arbitrum, optimism } from 'viem/chains'; import { DYDX_DEPOSIT_CHAIN, EVM_DEPOSIT_CHAINS } from '@/constants/chains'; -import { CosmosChainId } from '@/constants/graz'; +import { CosmosChainId, NEUTRON_BECH32_PREFIX, OSMO_BECH32_PREFIX } from '@/constants/graz'; import { SOLANA_MAINNET_ID } from '@/constants/solana'; import { timeUnits } from '@/constants/time'; import { @@ -26,14 +27,35 @@ import { useAppSelectorWithArgs } from '@/hooks/useParameterizedSelector'; import { SourceAccount } from '@/state/wallet'; +import { convertBech32Address } from '@/lib/addressUtils'; import { AttemptBigNumber, MustBigNumber } from '@/lib/numbers'; import { ALLOW_UNSAFE_BELOW_USD_LIMIT, MAX_ALLOWED_SLIPPAGE_PERCENT } from '../consts'; export function useBalances() { - const { sourceAccount, nobleAddress, osmosisAddress, neutronAddress } = useAccounts(); + const { sourceAccount, dydxAddress } = useAccounts(); const { skipClient } = useSkipClient(); + const { nobleAddress, osmosisAddress, neutronAddress } = useMemo(() => { + if (!dydxAddress) + return { nobleAddress: undefined, osmosisAddress: undefined, neutronAddress: undefined }; + + return { + nobleAddress: convertBech32Address({ + address: dydxAddress as string, + bech32Prefix: NOBLE_BECH32_PREFIX, + }), + osmosisAddress: convertBech32Address({ + address: dydxAddress as string, + bech32Prefix: OSMO_BECH32_PREFIX, + }), + neutronAddress: convertBech32Address({ + address: dydxAddress as string, + bech32Prefix: NEUTRON_BECH32_PREFIX, + }), + }; + }, [dydxAddress]); + return useQuery({ queryKey: ['balances', sourceAccount.address, nobleAddress, osmosisAddress, neutronAddress], queryFn: async () => { diff --git a/src/views/dialogs/TransferDialogs/WithdrawDialog2/withdrawHooks.ts b/src/views/dialogs/TransferDialogs/WithdrawDialog2/withdrawHooks.ts index 719ade2032..66b5cce294 100644 --- a/src/views/dialogs/TransferDialogs/WithdrawDialog2/withdrawHooks.ts +++ b/src/views/dialogs/TransferDialogs/WithdrawDialog2/withdrawHooks.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useState } from 'react'; import { logBonsaiError, logBonsaiInfo } from '@/bonsai/logs'; import { TYPE_URL_MSG_WITHDRAW_FROM_SUBACCOUNT } from '@dydxprotocol/v4-client-js'; @@ -53,15 +53,16 @@ export function useWithdrawStep({ const { dydxAddress, localDydxWallet, - localNobleWallet, - nobleAddress, - osmosisAddress, - neutronAddress, + getNobleWallet, + getNobleAddress, + getOsmosisAddress, + getNeutronAddress, sourceAccount, } = useAccounts(); const [isLoading, setIsLoading] = useState(false); - const userAddresses: UserAddress[] | undefined = useMemo(() => { + // Derive user addresses on-demand when executing withdrawal + const getUserAddresses = useCallback(async (): Promise => { const lastChainId = withdrawRoute?.requiredChainAddresses.at(-1); if ( @@ -74,6 +75,16 @@ export function useWithdrawStep({ return undefined; } + const [nobleAddress, osmosisAddress, neutronAddress] = await Promise.all([ + getNobleAddress(), + getOsmosisAddress(), + getNeutronAddress(), + ]); + + if (!nobleAddress || !osmosisAddress || !neutronAddress) { + throw new Error('Failed to derive Cosmos addresses'); + } + return getUserAddressesForRoute( withdrawRoute, sourceAccount, @@ -85,9 +96,9 @@ export function useWithdrawStep({ ); }, [ dydxAddress, - neutronAddress, - nobleAddress, - osmosisAddress, + getNobleAddress, + getOsmosisAddress, + getNeutronAddress, sourceAccount, withdrawRoute, destinationAddress, @@ -96,6 +107,7 @@ export function useWithdrawStep({ const getCosmosSigner = useCallback( async (chainID: string) => { if (chainID === CosmosChainId.Noble) { + const localNobleWallet = await getNobleWallet(); if (!localNobleWallet?.offlineSigner) { throw new Error('No local noblewallet offline signer. Cannot submit tx'); } @@ -107,13 +119,20 @@ export function useWithdrawStep({ return localDydxWallet.offlineSigner; }, - [localDydxWallet, localNobleWallet] + [localDydxWallet, getNobleWallet] ); const executeWithdraw = async () => { try { setIsLoading(true); if (!withdrawRoute) throw new Error('No route found'); + + // Derive user addresses and Noble wallet on-demand + const [userAddresses, localNobleWallet] = await Promise.all([ + getUserAddresses(), + getNobleWallet(), + ]); + if (!userAddresses) throw new Error('No user addresses found'); if (!localDydxWallet || !localNobleWallet || !dydxAddress) { throw new Error('No local wallets found');