From c047836aa62fdd53870ca1e22b49a444e5959221 Mon Sep 17 00:00:00 2001 From: jaredvu Date: Mon, 17 Nov 2025 10:18:16 -0800 Subject: [PATCH 01/19] add helper classes --- src/lib/arrayBufferToBase64.ts | 11 + src/lib/base64ToArrayBuffer.ts | 11 + src/lib/onboarding/OnboardingOrchestrator.ts | 521 +++++++++++++++++++ src/lib/wallet/dydxWalletService.ts | 270 ++++++++++ src/lib/wallet/secureStorage.ts | 182 +++++++ src/state/wallet.ts | 12 +- 6 files changed, 1006 insertions(+), 1 deletion(-) create mode 100644 src/lib/arrayBufferToBase64.ts create mode 100644 src/lib/base64ToArrayBuffer.ts create mode 100644 src/lib/onboarding/OnboardingOrchestrator.ts create mode 100644 src/lib/wallet/dydxWalletService.ts create mode 100644 src/lib/wallet/secureStorage.ts 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/OnboardingOrchestrator.ts b/src/lib/onboarding/OnboardingOrchestrator.ts new file mode 100644 index 0000000000..cb76052ef9 --- /dev/null +++ b/src/lib/onboarding/OnboardingOrchestrator.ts @@ -0,0 +1,521 @@ +/** + * OnboardingOrchestrator + * + * Vanilla JS class that handles all onboarding business logic. + * Replaces the complex useEffect logic in useAccounts. + * + * Design principles: + * - No React hooks or dependencies + * - Pure business logic, fully testable + * - Returns wallet data rather than managing state directly + * - Explicit inputs and outputs for each method + * + * Wallet storage strategy: + * - STORED: localDydxWallet only + * - ON-DEMAND: Noble, Osmosis, Neutron wallets derived from mnemonic when needed + */ +import { getLazyLocalWallet } from '@/bonsai/lib/lazyDynamicLibs'; +import { logBonsaiError } from '@/bonsai/logs'; +import { NOBLE_BECH32_PREFIX, type LocalWallet } from '@dydxprotocol/v4-client-js'; +import { AES, enc } from 'crypto-js'; + +import { OnboardingState } from '@/constants/account'; +import { NEUTRON_BECH32_PREFIX, OSMO_BECH32_PREFIX } from '@/constants/graz'; +import { + ConnectorType, + PrivateInformation, + WalletNetworkType, + type WalletInfo, +} from '@/constants/wallets'; + +import { hdKeyManager } from '@/lib/hdKeyManager'; +import { sleep } from '@/lib/timeUtils'; + +// ============================================================================ +// Types & Interfaces +// ============================================================================ + +export interface SourceAccount { + address?: string; + chain?: WalletNetworkType; + encryptedSignature?: string; + walletInfo?: WalletInfo; +} + +export interface OnboardingContext { + sourceAccount: SourceAccount; + hasLocalDydxWallet: boolean; + blockedGeo: boolean; + isConnectedGraz?: boolean; + authenticated?: boolean; + ready?: boolean; +} + +export interface WalletDerivationResult { + wallet?: LocalWallet; + hdKey?: PrivateInformation; + onboardingState: OnboardingState; + error?: string; + shouldClearSignature?: boolean; +} + +export type CosmosChain = 'noble' | 'osmosis' | 'neutron'; + +// ============================================================================ +// OnboardingOrchestrator Class +// ============================================================================ + +export class OnboardingOrchestrator { + /** + * Decrypt encrypted signature using static key + * TODO: Replace with secure per-user encryption + */ + private decryptSignature(encryptedSignature: string): string { + 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); + + if (!signature) { + throw new Error('Failed to decrypt signature'); + } + + return signature; + } + + /** + * Derive dYdX wallet from signature + * Used for EVM, Solana, and Turnkey wallets + */ + private async deriveWalletFromSignature( + signature: string, + getWalletFromSignature: (params: { signature: string }) => Promise<{ + wallet: LocalWallet; + mnemonic: string; + privateKey: Uint8Array; + publicKey: Uint8Array; + }> + ): Promise<{ wallet: LocalWallet; hdKey: PrivateInformation }> { + const { wallet, mnemonic, privateKey, publicKey } = await getWalletFromSignature({ + signature, + }); + + const hdKey: PrivateInformation = { + mnemonic, + privateKey, + publicKey, + }; + + // Update HD key manager + hdKeyManager.setHdkey(wallet.address, hdKey); + + return { wallet, hdKey }; + } + + /** + * MAIN ORCHESTRATION METHOD + * Replaces Side Effect #4 (lines 182-283 in useAccounts) + * + * Handles all wallet type flows and determines next onboarding state + */ + async handleWalletConnection(params: { + context: OnboardingContext; + getWalletFromSignature: (params: { signature: string }) => Promise<{ + wallet: LocalWallet; + mnemonic: Uint8Array; + privateKey: Uint8Array; + publicKey: Uint8Array; + }>; + signMessageAsync?: () => Promise; + getCosmosOfflineSigner?: (chainId: string) => Promise; + selectedDydxChainId?: string; + }): Promise { + const { + context, + getWalletFromSignature, + signMessageAsync, + getCosmosOfflineSigner, + selectedDydxChainId, + } = params; + const { sourceAccount, hasLocalDydxWallet, blockedGeo, isConnectedGraz, authenticated, ready } = + context; + + try { + // ============================================================ + // TURNKEY FLOW + // ============================================================ + if (sourceAccount.walletInfo?.connectorType === ConnectorType.Turnkey) { + return await this.handleTurnkeyFlow({ + sourceAccount, + hasLocalDydxWallet, + blockedGeo, + getWalletFromSignature, + }); + } + + // ============================================================ + // TEST 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, + }); + } + + // ============================================================ + // NO VALID WALLET - DISCONNECT + // ============================================================ + return { + onboardingState: OnboardingState.Disconnected, + }; + } catch (error) { + logBonsaiError('OnboardingOrchestrator', 'handleWalletConnection failed', { error }); + return { + onboardingState: OnboardingState.Disconnected, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + // ============================================================================ + // Wallet Type Handlers + // ============================================================================ + + /** + * Handle Turnkey wallet flow + * Turnkey is an embedded wallet - no WalletConnected state, only AccountConnected or Disconnected + */ + private async handleTurnkeyFlow(params: { + sourceAccount: SourceAccount; + hasLocalDydxWallet: boolean; + blockedGeo: boolean; + getWalletFromSignature: any; + }): Promise { + const { sourceAccount, hasLocalDydxWallet, blockedGeo, getWalletFromSignature } = params; + + // If wallet already exists, just set state + if (hasLocalDydxWallet) { + return { + onboardingState: OnboardingState.AccountConnected, + }; + } + + // If we have encrypted signature and not geo-blocked, derive wallet + if (sourceAccount.encryptedSignature && !blockedGeo) { + try { + const signature = this.decryptSignature(sourceAccount.encryptedSignature); + const { wallet, hdKey } = await this.deriveWalletFromSignature( + signature, + getWalletFromSignature + ); + + return { + wallet, + hdKey, + onboardingState: OnboardingState.AccountConnected, + }; + } catch (error) { + logBonsaiError('OnboardingOrchestrator', 'Turnkey signature decryption failed', { error }); + return { + onboardingState: OnboardingState.Disconnected, + error: 'Failed to decrypt Turnkey signature', + shouldClearSignature: true, + }; + } + } + + // No wallet and no signature - disconnected + return { + onboardingState: OnboardingState.Disconnected, + }; + } + + /** + * Handle test wallet flow + */ + private async handleTestWalletFlow( + sourceAccount: SourceAccount + ): Promise { + const wallet = new (await getLazyLocalWallet())(); + wallet.address = sourceAccount.address!; + + return { + wallet, + // Test wallets don't have hdKey material + onboardingState: OnboardingState.AccountConnected, + }; + } + + /** + * Handle Cosmos wallet flow + */ + private async handleCosmosFlow(params: { + getCosmosOfflineSigner?: (chainId: string) => Promise; + selectedDydxChainId?: string; + }): Promise { + const { getCosmosOfflineSigner, selectedDydxChainId } = params; + + if (!getCosmosOfflineSigner || !selectedDydxChainId) { + return { + onboardingState: OnboardingState.Disconnected, + error: 'Missing Cosmos dependencies', + }; + } + + try { + const dydxOfflineSigner = await getCosmosOfflineSigner(selectedDydxChainId); + if (dydxOfflineSigner) { + const wallet = await (await getLazyLocalWallet()).fromOfflineSigner(dydxOfflineSigner); + + return { + wallet, + // Cosmos wallets from offline signer don't expose hdKey material + onboardingState: OnboardingState.AccountConnected, + }; + } + } catch (error) { + logBonsaiError('OnboardingOrchestrator', '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, just set state + if (hasLocalDydxWallet) { + return { + onboardingState: OnboardingState.AccountConnected, + }; + } + + // 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, hdKey } = await this.deriveWalletFromSignature( + signature, + getWalletFromSignature + ); + + return { + wallet, + hdKey, + onboardingState: OnboardingState.AccountConnected, + }; + } catch (error) { + logBonsaiError('OnboardingOrchestrator', 'Privy signing failed', { error }); + return { + onboardingState: OnboardingState.WalletConnected, + error: 'Failed to sign with Privy', + shouldClearSignature: true, + }; + } + } + + // Other EVM wallets - use encrypted signature + if (sourceAccount.encryptedSignature && !blockedGeo) { + try { + const signature = this.decryptSignature(sourceAccount.encryptedSignature); + const { wallet, hdKey } = await this.deriveWalletFromSignature( + signature, + getWalletFromSignature + ); + + return { + wallet, + hdKey, + onboardingState: OnboardingState.AccountConnected, + }; + } catch (error) { + logBonsaiError('OnboardingOrchestrator', 'EVM signature decryption failed', { error }); + return { + onboardingState: OnboardingState.WalletConnected, + error: 'Failed to decrypt signature', + shouldClearSignature: true, + }; + } + } + + // 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; + }): Promise { + const { sourceAccount, hasLocalDydxWallet, blockedGeo, getWalletFromSignature } = params; + + // If wallet already exists, just set state + if (hasLocalDydxWallet) { + return { + onboardingState: OnboardingState.AccountConnected, + }; + } + + // Derive from encrypted signature + if (sourceAccount.encryptedSignature && !blockedGeo) { + try { + const signature = this.decryptSignature(sourceAccount.encryptedSignature); + const { wallet, hdKey } = await this.deriveWalletFromSignature( + signature, + getWalletFromSignature + ); + + return { + wallet, + hdKey, + onboardingState: OnboardingState.AccountConnected, + }; + } catch (error) { + logBonsaiError('OnboardingOrchestrator', 'Solana signature decryption failed', { error }); + return { + onboardingState: OnboardingState.WalletConnected, + error: 'Failed to decrypt signature', + shouldClearSignature: true, + }; + } + } + + // Wallet connected but waiting for signature + return { + onboardingState: OnboardingState.WalletConnected, + }; + } + + // ============================================================================ + // On-Demand Cosmos Wallet Derivation + // ============================================================================ + + /** + * 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 + */ + async deriveCosmosWallet(mnemonic: string, chain: CosmosChain): Promise { + try { + const prefix = this.getCosmosPrefix(chain); + const LazyLocalWallet = await getLazyLocalWallet(); + return await LazyLocalWallet.fromMnemonic(mnemonic, prefix); + } catch (error) { + logBonsaiError('OnboardingOrchestrator', `Failed to derive ${chain} wallet`, { error }); + return null; + } + } + + /** + * Derive a Cosmos wallet from offline signer + * Used when user has a native Cosmos wallet connected + */ + async deriveCosmosWalletFromSigner( + offlineSigner: any, + chain: CosmosChain + ): Promise { + try { + const LazyLocalWallet = await getLazyLocalWallet(); + return await LazyLocalWallet.fromOfflineSigner(offlineSigner); + } catch (error) { + logBonsaiError('OnboardingOrchestrator', `Failed to derive ${chain} wallet from signer`, { + error, + }); + return null; + } + } + + /** + * Get the Bech32 prefix for a Cosmos chain + */ + private getCosmosPrefix(chain: CosmosChain): 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}`); + } + } +} + +// Export singleton instance +export const onboardingOrchestrator = new OnboardingOrchestrator(); diff --git a/src/lib/wallet/dydxWalletService.ts b/src/lib/wallet/dydxWalletService.ts new file mode 100644 index 0000000000..34e9088e24 --- /dev/null +++ b/src/lib/wallet/dydxWalletService.ts @@ -0,0 +1,270 @@ +import { getLazyLocalWallet } from '@/bonsai/lib/lazyDynamicLibs'; +import { logBonsaiError, logBonsaiInfo } from '@/bonsai/logs'; +import { BECH32_PREFIX, onboarding, type LocalWallet } from '@dydxprotocol/v4-client-js'; + +import { OnboardingState } from '@/constants/account'; +import { DydxAddress, PrivateInformation } from '@/constants/wallets'; + +import { store } from '@/state/_store'; +import { setOnboardingState } from '@/state/account'; +import { setLocalWallet } from '@/state/wallet'; + +import { hdKeyManager } from '@/lib/hdKeyManager'; +import { log } from '@/lib/telemetry'; + +import { secureStorage } from './secureStorage'; + +const MNEMONIC_STORAGE_KEY = 'imported_mnemonic'; + +export interface WalletCreationResult { + success: boolean; + dydxAddress?: DydxAddress; + error?: string; +} + +/** + * DydxWalletService + * + * Manages dYdX wallet creation, import, and derivation. + * Supports multiple wallet sources: + * - Direct mnemonic import (bypass source wallet) + * - Derivation from source wallet signature (existing flow) + * - Private key import + * + * This service enables AccountConnected state without requiring + * a connected source wallet (WalletConnected state). + */ +export class DydxWalletService { + /** + * Import wallet from mnemonic phrase + * This bypasses the need for a source wallet connection + * + * @param mnemonic - 12 or 24 word mnemonic phrase + * @param persist - Whether to encrypt and store mnemonic for future sessions + * @returns Wallet creation result with dYdX address + */ + async importFromMnemonic( + mnemonic: string, + persist: boolean = true + ): Promise { + try { + // Validate mnemonic format + const words = mnemonic.trim().split(/\s+/); + if (words.length !== 12 && words.length !== 24) { + return { + success: false, + error: 'Invalid mnemonic. Must be 12 or 24 words.', + }; + } + + // Create wallet from mnemonic + const LocalWallet = await getLazyLocalWallet(); + const wallet = await LocalWallet.fromMnemonic(mnemonic, BECH32_PREFIX); + + if (!wallet || !wallet.address) { + return { + success: false, + error: 'Failed to create wallet from mnemonic.', + }; + } + + // Store encrypted mnemonic if persistence requested + if (persist) { + await secureStorage.store(MNEMONIC_STORAGE_KEY, mnemonic); + } + + // Update app state + await this.setWalletInState(wallet, mnemonic); + + logBonsaiInfo('DydxWalletService', 'importFromMnemonic', { + address: wallet.address, + persisted: persist, + }); + + return { + success: true, + dydxAddress: wallet.address as DydxAddress, + }; + } catch (error) { + logBonsaiError('DydxWalletService', 'Failed to importFromMnemonic', { error }); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + }; + } + } + + /** + * Restore wallet from previously stored mnemonic + * Called on app initialization to restore session + * + * @returns Wallet creation result or null if no stored mnemonic + */ + async restoreFromStorage(): Promise { + try { + const storedMnemonic = await secureStorage.retrieve(MNEMONIC_STORAGE_KEY); + + if (!storedMnemonic) { + return null; + } + + // Re-import without re-storing (it's already stored) + const result = await this.importFromMnemonic(storedMnemonic, false); + + if (result.success) { + logBonsaiInfo('DydxWalletService', 'restoreFromStorage', { + address: result.dydxAddress, + }); + } + + return result; + } catch (error) { + log('DydxWalletService/restoreFromStorage/error', error); + // If restoration fails, clear corrupted data + this.clearStoredWallet(); + return null; + } + } + + /** + * Derive wallet from source wallet signature + * This is the existing flow for connected wallets + * + * @param signature - Signature from source wallet + * @param persist - Whether to store encrypted signature + * @returns Wallet creation result with dYdX address + */ + async deriveFromSignature(signature: string): Promise { + try { + // Derive HD key from signature + const { mnemonic, privateKey, publicKey } = + onboarding.deriveHDKeyFromEthereumSignature(signature); + + // Create wallet from derived mnemonic + const LocalWallet = await getLazyLocalWallet(); + const wallet = await LocalWallet.fromMnemonic(mnemonic, BECH32_PREFIX); + + if (!wallet.address || !privateKey || !publicKey) { + return { + success: false, + error: 'Failed to derive wallet from signature.', + }; + } + + // Note: We don't persist signature-derived wallets to secure storage + // They remain in memory only, tied to the source wallet session + + // Update app state + await this.setWalletInState(wallet, mnemonic, privateKey, publicKey); + + logBonsaiInfo('DydxWalletService', 'deriveFromSignature', { + address: wallet.address, + }); + + return { + success: true, + dydxAddress: wallet.address as DydxAddress, + }; + } catch (error) { + logBonsaiError('DydxWalletService', 'Failed to deriveFromSignature', { error }); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + }; + } + } + + /** + * Update Redux state and managers with wallet information + * @private + */ + private async setWalletInState( + wallet: LocalWallet, + mnemonic: string, + privateKey?: Uint8Array | null, + publicKey?: Uint8Array | null + ): Promise { + if (privateKey && publicKey) { + const hdKey: PrivateInformation = { + mnemonic, + privateKey, + publicKey, + }; + + // Update HD key manager + hdKeyManager.setHdkey(wallet.address, hdKey); + } + + // Update Redux state + store.dispatch( + setLocalWallet({ + address: wallet.address as DydxAddress, + subaccountNumber: 0, + }) + ); + + // Set onboarding state to AccountConnected + store.dispatch(setOnboardingState(OnboardingState.AccountConnected)); + + // Note: Noble wallet derivation happens separately in useAccounts + // We could potentially add that here in the future for a more complete service + } + + /** + * Check if a wallet is stored in secure storage + */ + hasStoredWallet(): boolean { + return secureStorage.has(MNEMONIC_STORAGE_KEY); + } + + /** + * Clear stored wallet data + * Called on user sign out + */ + clearStoredWallet(): void { + secureStorage.remove(MNEMONIC_STORAGE_KEY); + logBonsaiInfo('DydxWalletService', 'clearStoredWallet'); + } + + /** + * Export current wallet mnemonic (for backup) + * WARNING: Only call this when user explicitly requests backup + * + * @returns Decrypted mnemonic or null if not found + */ + async exportMnemonic(): Promise { + try { + return await secureStorage.retrieve(MNEMONIC_STORAGE_KEY); + } catch (error) { + logBonsaiError('DydxWalletService', 'Failed to exportMnemonic', { error }); + return null; + } + } + + /** + * Validate mnemonic format + */ + validateMnemonic(mnemonic: string): { valid: boolean; error?: string } { + const words = mnemonic.trim().split(/\s+/); + + if (words.length !== 12 && words.length !== 24) { + return { + valid: false, + error: 'Mnemonic must be 12 or 24 words', + }; + } + + // Check for empty words + if (words.some((word) => !word)) { + return { + valid: false, + error: 'Mnemonic contains empty words', + }; + } + + return { valid: true }; + } +} + +// Export singleton instance +export const dydxWalletService = new DydxWalletService(); diff --git a/src/lib/wallet/secureStorage.ts b/src/lib/wallet/secureStorage.ts new file mode 100644 index 0000000000..95f8185597 --- /dev/null +++ b/src/lib/wallet/secureStorage.ts @@ -0,0 +1,182 @@ +/** + * SecureStorageService + * + * Provides encrypted storage for sensitive data using the Web Crypto API. + * Uses a browser-specific encryption key derived from a random salt. + * + * Security Model: + * - Uses AES-GCM encryption with 256-bit keys + * - Unique salt per browser/device stored in localStorage + * - Protects against casual file system inspection + * - Does NOT protect against XSS attacks or code execution + * - Similar security model to MetaMask's encrypted vault + */ +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; // Base64 encoded encrypted data + iv: string; // Base64 encoded initialization vector + version: number; // Version for future migrations +} + +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 singleton instance +export const secureStorage = new SecureStorageService(); diff --git a/src/state/wallet.ts b/src/state/wallet.ts index a0a97e7bbc..04ef0b386c 100644 --- a/src/state/wallet.ts +++ b/src/state/wallet.ts @@ -17,6 +17,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; @@ -32,6 +35,7 @@ const initialState: WalletState = { localWallet: { address: undefined, subaccountNumber: 0, + walletSource: undefined, }, turnkeyEmailOnboardingData: undefined, turnkeyPrimaryWallet: undefined, @@ -73,7 +77,13 @@ export const walletSlice = createSlice({ }, setLocalWallet: ( state, - { payload }: PayloadAction<{ address?: string; subaccountNumber?: number }> + { + payload, + }: PayloadAction<{ + address?: string; + subaccountNumber?: number; + walletSource?: 'imported' | 'derived'; + }> ) => { state.localWallet = payload; }, From bdc1f52c492a0335d3200aa5a65b4d58a86a216a Mon Sep 17 00:00:00 2001 From: jaredvu Date: Mon, 17 Nov 2025 10:19:22 -0800 Subject: [PATCH 02/19] onboarding orchestrator --- src/lib/onboarding/OnboardingOrchestrator.ts | 39 ++++++-------------- 1 file changed, 12 insertions(+), 27 deletions(-) diff --git a/src/lib/onboarding/OnboardingOrchestrator.ts b/src/lib/onboarding/OnboardingOrchestrator.ts index cb76052ef9..7d9b2c9e9d 100644 --- a/src/lib/onboarding/OnboardingOrchestrator.ts +++ b/src/lib/onboarding/OnboardingOrchestrator.ts @@ -120,16 +120,13 @@ export class OnboardingOrchestrator { } /** - * MAIN ORCHESTRATION METHOD - * Replaces Side Effect #4 (lines 182-283 in useAccounts) - * * Handles all wallet type flows and determines next onboarding state */ async handleWalletConnection(params: { context: OnboardingContext; getWalletFromSignature: (params: { signature: string }) => Promise<{ wallet: LocalWallet; - mnemonic: Uint8Array; + mnemonic: string; privateKey: Uint8Array; publicKey: Uint8Array; }>; @@ -148,9 +145,7 @@ export class OnboardingOrchestrator { context; try { - // ============================================================ - // TURNKEY FLOW - // ============================================================ + // ------ Turnkey Flow ------ // if (sourceAccount.walletInfo?.connectorType === ConnectorType.Turnkey) { return await this.handleTurnkeyFlow({ sourceAccount, @@ -160,16 +155,12 @@ export class OnboardingOrchestrator { }); } - // ============================================================ - // TEST WALLET FLOW - // ============================================================ + // ------ Impersonate Wallet Flow ------ // if (sourceAccount.walletInfo?.connectorType === ConnectorType.Test) { return await this.handleTestWalletFlow(sourceAccount); } - // ============================================================ - // COSMOS FLOW - // ============================================================ + // ------ Cosmos Flow ------ // if (sourceAccount.chain === WalletNetworkType.Cosmos && isConnectedGraz) { return await this.handleCosmosFlow({ getCosmosOfflineSigner, @@ -177,9 +168,7 @@ export class OnboardingOrchestrator { }); } - // ============================================================ - // EVM FLOW - // ============================================================ + // ------ Evm Flow ------ // if (sourceAccount.chain === WalletNetworkType.Evm) { return await this.handleEvmFlow({ sourceAccount, @@ -192,9 +181,7 @@ export class OnboardingOrchestrator { }); } - // ============================================================ - // SOLANA FLOW - // ============================================================ + // ------ Solana Flow ------ // if (sourceAccount.chain === WalletNetworkType.Solana) { return await this.handleSolanaFlow({ sourceAccount, @@ -204,9 +191,6 @@ export class OnboardingOrchestrator { }); } - // ============================================================ - // NO VALID WALLET - DISCONNECT - // ============================================================ return { onboardingState: OnboardingState.Disconnected, }; @@ -219,10 +203,6 @@ export class OnboardingOrchestrator { } } - // ============================================================================ - // Wallet Type Handlers - // ============================================================================ - /** * Handle Turnkey wallet flow * Turnkey is an embedded wallet - no WalletConnected state, only AccountConnected or Disconnected @@ -231,7 +211,12 @@ export class OnboardingOrchestrator { sourceAccount: SourceAccount; hasLocalDydxWallet: boolean; blockedGeo: boolean; - getWalletFromSignature: any; + getWalletFromSignature: (params: { signature: string }) => Promise<{ + wallet: LocalWallet; + mnemonic: string; + privateKey: Uint8Array; + publicKey: Uint8Array; + }>; }): Promise { const { sourceAccount, hasLocalDydxWallet, blockedGeo, getWalletFromSignature } = params; From a4506f0fee45e13a9e4f81a4455f4fad341f9c6c Mon Sep 17 00:00:00 2001 From: jaredvu Date: Mon, 17 Nov 2025 14:42:07 -0800 Subject: [PATCH 03/19] add cosmos wallet derivation helper --- src/hooks/useCosmosWallets.ts | 158 +++++++++++++++++++ src/lib/onboarding/OnboardingOrchestrator.ts | 23 +-- 2 files changed, 165 insertions(+), 16 deletions(-) create mode 100644 src/hooks/useCosmosWallets.ts diff --git a/src/hooks/useCosmosWallets.ts b/src/hooks/useCosmosWallets.ts new file mode 100644 index 0000000000..5bfc019f45 --- /dev/null +++ b/src/hooks/useCosmosWallets.ts @@ -0,0 +1,158 @@ +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 { onboardingOrchestrator } from '@/lib/onboarding/OnboardingOrchestrator'; + +/** + * + * @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 => { + // Try to get from offline signer first (for native Cosmos wallets) + if (getCosmosOfflineSigner) { + try { + const nobleOfflineSigner = await getCosmosOfflineSigner(getNobleChainId()); + if (nobleOfflineSigner) { + return await onboardingOrchestrator.deriveCosmosWalletFromSigner( + nobleOfflineSigner, + 'noble' + ); + } + } catch (error) { + // Fall through to mnemonic derivation + } + } + + // Derive from mnemonic if available + if (hdKey?.mnemonic) { + try { + return await onboardingOrchestrator.deriveCosmosWallet(hdKey.mnemonic, 'noble'); + } catch (error) { + logBonsaiError('useCosmosWallets', 'Failed to derive Noble wallet w/ mnemonic', { error }); + return null; + } + } + + return null; + }, [hdKey?.mnemonic, getCosmosOfflineSigner]); + + /** + * Get Osmosis wallet on-demand + */ + const getOsmosisWallet = useCallback(async (): Promise => { + // Try to get from offline signer first (for native Cosmos wallets) + if (getCosmosOfflineSigner) { + try { + const osmosisOfflineSigner = await getCosmosOfflineSigner(getOsmosisChainId()); + if (osmosisOfflineSigner) { + return await onboardingOrchestrator.deriveCosmosWalletFromSigner( + osmosisOfflineSigner, + 'osmosis' + ); + } + } catch (error) { + // Fall through to mnemonic derivation + } + } + + // Derive from mnemonic if available + if (hdKey?.mnemonic) { + try { + return await onboardingOrchestrator.deriveCosmosWallet(hdKey.mnemonic, 'osmosis'); + } catch (error) { + logBonsaiError('useCosmosWallets', 'Failed to derive Osmosis wallet w/ mnemonic', { + error, + }); + + return null; + } + } + + return null; + }, [hdKey?.mnemonic, getCosmosOfflineSigner]); + + /** + * Get Neutron wallet on-demand + */ + const getNeutronWallet = useCallback(async (): Promise => { + // Try to get from offline signer first (for native Cosmos wallets) + if (getCosmosOfflineSigner) { + try { + const neutronOfflineSigner = await getCosmosOfflineSigner(getNeutronChainId()); + if (neutronOfflineSigner) { + return await onboardingOrchestrator.deriveCosmosWalletFromSigner( + neutronOfflineSigner, + 'neutron' + ); + } + } catch (error) { + // Fall through to mnemonic derivation + } + } + + // Derive from mnemonic if available + if (hdKey?.mnemonic) { + try { + return await onboardingOrchestrator.deriveCosmosWallet(hdKey.mnemonic, 'neutron'); + } catch (error) { + logBonsaiError('useCosmosWallets', 'Failed to derive Neutron wallet w/ mnemonic', { + error, + }); + return null; + } + } + + return null; + }, [hdKey?.mnemonic, 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/lib/onboarding/OnboardingOrchestrator.ts b/src/lib/onboarding/OnboardingOrchestrator.ts index 7d9b2c9e9d..338e5ab6ca 100644 --- a/src/lib/onboarding/OnboardingOrchestrator.ts +++ b/src/lib/onboarding/OnboardingOrchestrator.ts @@ -31,10 +31,6 @@ import { import { hdKeyManager } from '@/lib/hdKeyManager'; import { sleep } from '@/lib/timeUtils'; -// ============================================================================ -// Types & Interfaces -// ============================================================================ - export interface SourceAccount { address?: string; chain?: WalletNetworkType; @@ -59,11 +55,7 @@ export interface WalletDerivationResult { shouldClearSignature?: boolean; } -export type CosmosChain = 'noble' | 'osmosis' | 'neutron'; - -// ============================================================================ -// OnboardingOrchestrator Class -// ============================================================================ +export type SupportedCosmosChain = 'noble' | 'osmosis' | 'neutron'; export class OnboardingOrchestrator { /** @@ -443,10 +435,6 @@ export class OnboardingOrchestrator { }; } - // ============================================================================ - // On-Demand Cosmos Wallet Derivation - // ============================================================================ - /** * Derive a Cosmos wallet on-demand from mnemonic * Used for Noble, Osmosis, Neutron wallets when needed @@ -455,7 +443,10 @@ export class OnboardingOrchestrator { * @param chain - Which Cosmos chain wallet to derive * @returns LocalWallet for the specified chain */ - async deriveCosmosWallet(mnemonic: string, chain: CosmosChain): Promise { + async deriveCosmosWallet( + mnemonic: string, + chain: SupportedCosmosChain + ): Promise { try { const prefix = this.getCosmosPrefix(chain); const LazyLocalWallet = await getLazyLocalWallet(); @@ -472,7 +463,7 @@ export class OnboardingOrchestrator { */ async deriveCosmosWalletFromSigner( offlineSigner: any, - chain: CosmosChain + chain: string ): Promise { try { const LazyLocalWallet = await getLazyLocalWallet(); @@ -488,7 +479,7 @@ export class OnboardingOrchestrator { /** * Get the Bech32 prefix for a Cosmos chain */ - private getCosmosPrefix(chain: CosmosChain): string { + private getCosmosPrefix(chain: SupportedCosmosChain): string { switch (chain) { case 'noble': return NOBLE_BECH32_PREFIX; From b30d18bf66dcf03d8b6e50a3fa3ee0c3f3e3371f Mon Sep 17 00:00:00 2001 From: jaredvu Date: Mon, 17 Nov 2025 15:50:31 -0800 Subject: [PATCH 04/19] replace local wallet useStates w/ derived approach --- .../rest/lib/nobleTransactionStoreEffect.ts | 2 +- src/hooks/useAccounts.tsx | 86 ++----------------- src/hooks/useUpdateSwaps.tsx | 16 +++- src/lib/hdKeyManager.ts | 57 ++++++++++-- .../WithdrawDialog2/withdrawHooks.ts | 39 ++++++--- 5 files changed, 103 insertions(+), 97 deletions(-) diff --git a/src/bonsai/rest/lib/nobleTransactionStoreEffect.ts b/src/bonsai/rest/lib/nobleTransactionStoreEffect.ts index 94a7618631..98715ecb9f 100644 --- a/src/bonsai/rest/lib/nobleTransactionStoreEffect.ts +++ b/src/bonsai/rest/lib/nobleTransactionStoreEffect.ts @@ -64,7 +64,7 @@ const selectNobleTxAuthorizedAccount = createAppSelector( return undefined; } - const localNobleWallet = localWalletManager.getLocalNobleWallet(localWalletNonce); + const localNobleWallet = localWalletManager.getCachedLocalNobleWallet(localWalletNonce); const nobleAddress = convertBech32Address({ address: parentSubaccountInfo.wallet, bech32Prefix: NOBLE_BECH32_PREFIX, diff --git a/src/hooks/useAccounts.tsx b/src/hooks/useAccounts.tsx index 2337a01e88..fb05a429cf 100644 --- a/src/hooks/useAccounts.tsx +++ b/src/hooks/useAccounts.tsx @@ -2,18 +2,11 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } 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, @@ -35,6 +28,7 @@ import { hdKeyManager, localWalletManager } from '@/lib/hdKeyManager'; import { log } from '@/lib/telemetry'; import { sleep } from '@/lib/timeUtils'; +import { useCosmosWallets } from './useCosmosWallets'; import { useDydxClient } from './useDydxClient'; import { useEnvFeatures } from './useEnvFeatures'; import { useLocalStorage } from './useLocalStorage'; @@ -132,10 +126,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,10 +139,6 @@ 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( async (signature: string) => { const { wallet, mnemonic, privateKey, publicKey } = await getWalletFromSignature({ @@ -168,16 +154,16 @@ const useAccountsContext = () => { ); const signMessageAsync = useSignForWalletDerivation(sourceAccount.walletInfo); - const hasLocalDydxWallet = Boolean(localDydxWallet); + const cosmosWallets = useCosmosWallets(hdKey, getCosmosOfflineSigner); useEffect(() => { - if (localDydxWallet && localNobleWallet) { - localWalletManager.setLocalWallet(localDydxWallet, localNobleWallet); + if (localDydxWallet && hdKey) { + localWalletManager.setLocalWallet(localDydxWallet, hdKey); } else { localWalletManager.clearLocalWallet(); } - }, [localDydxWallet, localNobleWallet]); + }, [localDydxWallet, hdKey]); useEffect(() => { (async () => { @@ -282,57 +268,6 @@ const useAccountsContext = () => { })(); }, [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); - } - - 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); - } - - if (nobleWallet !== undefined) { - setLocalNobleWallet(nobleWallet); - } - if (osmosisWallet !== undefined) { - setLocalOsmosisWallet(osmosisWallet); - } - if (neutronWallet !== undefined) { - setLocalNeutronWallet(neutronWallet); - } - } catch (error) { - log('useAccounts/setCosmosWallets', error); - } - }; - setCosmosWallets(); - }, [hdKey?.mnemonic, getCosmosOfflineSigner]); - // clear subaccounts when no dydxAddress is set useEffect(() => { (async () => { @@ -377,9 +312,6 @@ const useAccountsContext = () => { // Disconnect wallet / accounts const disconnectLocalDydxWallet = () => { setLocalDydxWallet(undefined); - setLocalNobleWallet(undefined); - setLocalOsmosisWallet(undefined); - setLocalNeutronWallet(undefined); setHdKey(undefined); hdKeyManager.clearHdkey(); }; @@ -398,7 +330,6 @@ const useAccountsContext = () => { return { // Wallet connection sourceAccount, - localNobleWallet, // Wallet selection selectWallet, @@ -417,9 +348,8 @@ const useAccountsContext = () => { dydxAccounts, dydxAddress, - nobleAddress, - osmosisAddress, - neutronAddress, + // Cosmos wallets (on-demand) + ...cosmosWallets, // Onboarding state saveHasAcknowledgedTerms, diff --git a/src/hooks/useUpdateSwaps.tsx b/src/hooks/useUpdateSwaps.tsx index 39d4f459cb..75ef0e20ed 100644 --- a/src/hooks/useUpdateSwaps.tsx +++ b/src/hooks/useUpdateSwaps.tsx @@ -25,7 +25,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); @@ -68,6 +68,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 @@ -102,7 +114,7 @@ export const useUpdateSwaps = () => { }, }); }, - [dispatch, dydxAddress, neutronAddress, nobleAddress, osmosisAddress, skipClient] + [dispatch, dydxAddress, getNeutronAddress, getNobleAddress, getOsmosisAddress, skipClient] ); useEffect(() => { diff --git a/src/lib/hdKeyManager.ts b/src/lib/hdKeyManager.ts index 5fab0a9269..d707153fcf 100644 --- a/src/lib/hdKeyManager.ts +++ b/src/lib/hdKeyManager.ts @@ -57,16 +57,22 @@ class LocalWalletManager { private localWallet: LocalWallet | undefined; - private localNobleWallet: LocalWallet | undefined; + private hdKey: Hdkey | undefined; + + // Cache for derived Noble wallet + private localNobleWalletCache: LocalWallet | undefined; setStore(store: RootStore) { this.store = store; } - setLocalWallet(localWallet: LocalWallet, localNobleWallet: LocalWallet) { + setLocalWallet(localWallet: LocalWallet, hdKey: Hdkey) { this.localWalletNonce = this.localWalletNonce != null ? this.localWalletNonce + 1 : 0; this.localWallet = localWallet; - this.localNobleWallet = localNobleWallet; + this.hdKey = hdKey; + + // Clear Noble wallet cache when wallet changes + this.localNobleWalletCache = undefined; if (!this.store) { log('LocalWalletManager: store has not been set'); @@ -84,18 +90,57 @@ class LocalWalletManager { return this.localWallet; } - getLocalNobleWallet(localWalletNonce: number): LocalWallet | undefined { + /** + * Get Noble wallet - derives on-demand from hdKey + * Returns cached version if already derived for this nonce + */ + async getLocalNobleWallet(localWalletNonce: number): Promise { + if (localWalletNonce !== this.localWalletNonce) { + return undefined; + } + + // Return cached if available + if (this.localNobleWalletCache) { + return this.localNobleWalletCache; + } + + // Derive from hdKey if available + if (this.hdKey?.mnemonic) { + try { + const { onboardingOrchestrator } = await import('@/lib/onboarding/OnboardingOrchestrator'); + + this.localNobleWalletCache = + (await onboardingOrchestrator.deriveCosmosWallet(this.hdKey.mnemonic, 'noble')) ?? + undefined; + + return this.localNobleWalletCache; + } catch (error) { + log('LocalWalletManager: Failed to derive Noble wallet', error); + return undefined; + } + } + + return undefined; + } + + /** + * Get cached Noble wallet synchronously (for selectors) + * Returns cached version if available, otherwise undefined + * Does NOT trigger derivation - use getLocalNobleWallet() for that + */ + getCachedLocalNobleWallet(localWalletNonce: number): LocalWallet | undefined { if (localWalletNonce !== this.localWalletNonce) { return undefined; } - return this.localNobleWallet; + return this.localNobleWalletCache; } clearLocalWallet() { this.localWalletNonce = undefined; this.localWallet = undefined; - this.localNobleWallet = undefined; + this.hdKey = undefined; + this.localNobleWalletCache = undefined; this.store?.dispatch(setLocalWalletNonce(undefined)); } } 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'); From 61aca4005c5369a04089ed92efb1c83587d12619 Mon Sep 17 00:00:00 2001 From: jaredvu Date: Mon, 17 Nov 2025 17:04:44 -0800 Subject: [PATCH 05/19] replace main side-effect in useAccounts --- src/hooks/useAccounts.tsx | 147 ++++--------------- src/lib/onboarding/OnboardingOrchestrator.ts | 131 +++++++---------- src/lib/wallet/dydxWalletService.ts | 15 +- 3 files changed, 94 insertions(+), 199 deletions(-) diff --git a/src/hooks/useAccounts.tsx b/src/hooks/useAccounts.tsx index fb05a429cf..ea60ab4c9e 100644 --- a/src/hooks/useAccounts.tsx +++ b/src/hooks/useAccounts.tsx @@ -1,19 +1,12 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; -import { getLazyLocalWallet } from '@/bonsai/lib/lazyDynamicLibs'; import { BonsaiCore } from '@/bonsai/ontology'; 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 { LocalStorageKey } from '@/constants/localStorage'; -import { - ConnectorType, - DydxAddress, - PrivateInformation, - WalletNetworkType, -} from '@/constants/wallets'; +import { ConnectorType, DydxAddress, PrivateInformation } from '@/constants/wallets'; import { useTurnkeyWallet } from '@/providers/TurnkeyWalletProvider'; @@ -21,12 +14,10 @@ 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 { useCosmosWallets } from './useCosmosWallets'; import { useDydxClient } from './useDydxClient'; @@ -92,17 +83,6 @@ const useAccountsContext = () => { // 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 @@ -167,103 +147,38 @@ 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 { onboardingOrchestrator } = await import('@/lib/onboarding/OnboardingOrchestrator'); + + const result = await onboardingOrchestrator.handleWalletConnection({ + context: { + sourceAccount, + hasLocalDydxWallet, + blockedGeo, + isConnectedGraz, + authenticated, + ready, + }, + getWalletFromSignature, + signMessageAsync, + getCosmosOfflineSigner, + selectedDydxChainId, + }); + + // Handle the result + if (result.wallet) { + setLocalDydxWallet(result.wallet); + } + + if (result.hdKey) { + setHdKey(result.hdKey); } - /** - * 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 { + // Dispatch onboarding state + dispatch(setOnboardingState(result.onboardingState)); + + // Handle disconnected state + if (result.onboardingState === OnboardingState.Disconnected && !result.wallet) { disconnectLocalDydxWallet(); - dispatch(setOnboardingState(OnboardingState.Disconnected)); } })(); }, [signerWagmi, isConnectedGraz, sourceAccount, hasLocalDydxWallet, blockedGeo]); diff --git a/src/lib/onboarding/OnboardingOrchestrator.ts b/src/lib/onboarding/OnboardingOrchestrator.ts index 338e5ab6ca..2dae46786c 100644 --- a/src/lib/onboarding/OnboardingOrchestrator.ts +++ b/src/lib/onboarding/OnboardingOrchestrator.ts @@ -11,13 +11,14 @@ * - Explicit inputs and outputs for each method * * Wallet storage strategy: - * - STORED: localDydxWallet only + * - All mnemonics stored in SecureStorage (Web Crypto API) + * - No more encrypted signatures with static keys + * - STORED: localDydxWallet mnemonic * - ON-DEMAND: Noble, Osmosis, Neutron wallets derived from mnemonic when needed */ import { getLazyLocalWallet } from '@/bonsai/lib/lazyDynamicLibs'; import { logBonsaiError } from '@/bonsai/logs'; import { NOBLE_BECH32_PREFIX, type LocalWallet } from '@dydxprotocol/v4-client-js'; -import { AES, enc } from 'crypto-js'; import { OnboardingState } from '@/constants/account'; import { NEUTRON_BECH32_PREFIX, OSMO_BECH32_PREFIX } from '@/constants/graz'; @@ -31,10 +32,11 @@ import { import { hdKeyManager } from '@/lib/hdKeyManager'; import { sleep } from '@/lib/timeUtils'; +import { dydxWalletService } from '../wallet/dydxWalletService'; + export interface SourceAccount { address?: string; chain?: WalletNetworkType; - encryptedSignature?: string; walletInfo?: WalletInfo; } @@ -52,38 +54,13 @@ export interface WalletDerivationResult { hdKey?: PrivateInformation; onboardingState: OnboardingState; error?: string; - shouldClearSignature?: boolean; } export type SupportedCosmosChain = 'noble' | 'osmosis' | 'neutron'; export class OnboardingOrchestrator { /** - * Decrypt encrypted signature using static key - * TODO: Replace with secure per-user encryption - */ - private decryptSignature(encryptedSignature: string): string { - 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); - - if (!signature) { - throw new Error('Failed to decrypt signature'); - } - - return signature; - } - - /** - * Derive dYdX wallet from signature + * Derive dYdX wallet from signature using DydxWalletService * Used for EVM, Solana, and Turnkey wallets */ private async deriveWalletFromSignature( @@ -91,22 +68,26 @@ export class OnboardingOrchestrator { getWalletFromSignature: (params: { signature: string }) => Promise<{ wallet: LocalWallet; mnemonic: string; - privateKey: Uint8Array; - publicKey: Uint8Array; + privateKey: Uint8Array | null; + publicKey: Uint8Array | null; }> ): Promise<{ wallet: LocalWallet; hdKey: PrivateInformation }> { const { wallet, mnemonic, privateKey, publicKey } = await getWalletFromSignature({ signature, }); + if (!privateKey || !publicKey) { + throw new Error('Failed to derive wallet from signature'); + } + const hdKey: PrivateInformation = { mnemonic, privateKey, publicKey, }; - // Update HD key manager hdKeyManager.setHdkey(wallet.address, hdKey); + await dydxWalletService.deriveFromSignature(signature); return { wallet, hdKey }; } @@ -119,8 +100,8 @@ export class OnboardingOrchestrator { getWalletFromSignature: (params: { signature: string }) => Promise<{ wallet: LocalWallet; mnemonic: string; - privateKey: Uint8Array; - publicKey: Uint8Array; + privateKey: Uint8Array | null; + publicKey: Uint8Array | null; }>; signMessageAsync?: () => Promise; getCosmosOfflineSigner?: (chainId: string) => Promise; @@ -144,6 +125,7 @@ export class OnboardingOrchestrator { hasLocalDydxWallet, blockedGeo, getWalletFromSignature, + signMessageAsync, }); } @@ -180,6 +162,7 @@ export class OnboardingOrchestrator { hasLocalDydxWallet, blockedGeo, getWalletFromSignature, + signMessageAsync, }); } @@ -206,23 +189,24 @@ export class OnboardingOrchestrator { getWalletFromSignature: (params: { signature: string }) => Promise<{ wallet: LocalWallet; mnemonic: string; - privateKey: Uint8Array; - publicKey: Uint8Array; + privateKey: Uint8Array | null; + publicKey: Uint8Array | null; }>; + signMessageAsync?: () => Promise; }): Promise { - const { sourceAccount, hasLocalDydxWallet, blockedGeo, getWalletFromSignature } = params; + const { hasLocalDydxWallet, blockedGeo, getWalletFromSignature, signMessageAsync } = params; - // If wallet already exists, just set state + // If wallet already exists (restored from SecureStorage), just set state if (hasLocalDydxWallet) { return { onboardingState: OnboardingState.AccountConnected, }; } - // If we have encrypted signature and not geo-blocked, derive wallet - if (sourceAccount.encryptedSignature && !blockedGeo) { + // If not geo-blocked, derive wallet from signature + if (!blockedGeo && signMessageAsync) { try { - const signature = this.decryptSignature(sourceAccount.encryptedSignature); + const signature = await signMessageAsync(); const { wallet, hdKey } = await this.deriveWalletFromSignature( signature, getWalletFromSignature @@ -234,16 +218,15 @@ export class OnboardingOrchestrator { onboardingState: OnboardingState.AccountConnected, }; } catch (error) { - logBonsaiError('OnboardingOrchestrator', 'Turnkey signature decryption failed', { error }); + logBonsaiError('OnboardingOrchestrator', 'Turnkey signing failed', { error }); return { onboardingState: OnboardingState.Disconnected, - error: 'Failed to decrypt Turnkey signature', - shouldClearSignature: true, + error: 'Failed to sign with Turnkey', }; } } - // No wallet and no signature - disconnected + // No wallet and geo-blocked or can't sign - disconnected return { onboardingState: OnboardingState.Disconnected, }; @@ -324,13 +307,20 @@ export class OnboardingOrchestrator { getWalletFromSignature, } = params; - // If wallet already exists, just set state + // 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 { @@ -352,35 +342,11 @@ export class OnboardingOrchestrator { return { onboardingState: OnboardingState.WalletConnected, error: 'Failed to sign with Privy', - shouldClearSignature: true, - }; - } - } - - // Other EVM wallets - use encrypted signature - if (sourceAccount.encryptedSignature && !blockedGeo) { - try { - const signature = this.decryptSignature(sourceAccount.encryptedSignature); - const { wallet, hdKey } = await this.deriveWalletFromSignature( - signature, - getWalletFromSignature - ); - - return { - wallet, - hdKey, - onboardingState: OnboardingState.AccountConnected, - }; - } catch (error) { - logBonsaiError('OnboardingOrchestrator', 'EVM signature decryption failed', { error }); - return { - onboardingState: OnboardingState.WalletConnected, - error: 'Failed to decrypt signature', - shouldClearSignature: true, }; } } + // Other EVM wallets - need to trigger signing flow in UI // Wallet connected but waiting for signature return { onboardingState: OnboardingState.WalletConnected, @@ -395,20 +361,28 @@ export class OnboardingOrchestrator { hasLocalDydxWallet: boolean; blockedGeo: boolean; getWalletFromSignature: any; + signMessageAsync?: () => Promise; }): Promise { - const { sourceAccount, hasLocalDydxWallet, blockedGeo, getWalletFromSignature } = params; + const { hasLocalDydxWallet, blockedGeo, getWalletFromSignature, signMessageAsync } = params; - // If wallet already exists, just set state + // If wallet already exists (restored from SecureStorage), just set state if (hasLocalDydxWallet) { return { onboardingState: OnboardingState.AccountConnected, }; } - // Derive from encrypted signature - if (sourceAccount.encryptedSignature && !blockedGeo) { + // If geo-blocked, stay in WalletConnected state + if (blockedGeo) { + return { + onboardingState: OnboardingState.WalletConnected, + }; + } + + // Derive from signature if available + if (signMessageAsync) { try { - const signature = this.decryptSignature(sourceAccount.encryptedSignature); + const signature = await signMessageAsync(); const { wallet, hdKey } = await this.deriveWalletFromSignature( signature, getWalletFromSignature @@ -420,11 +394,10 @@ export class OnboardingOrchestrator { onboardingState: OnboardingState.AccountConnected, }; } catch (error) { - logBonsaiError('OnboardingOrchestrator', 'Solana signature decryption failed', { error }); + logBonsaiError('OnboardingOrchestrator', 'Solana signing failed', { error }); return { onboardingState: OnboardingState.WalletConnected, - error: 'Failed to decrypt signature', - shouldClearSignature: true, + error: 'Failed to sign with Solana wallet', }; } } diff --git a/src/lib/wallet/dydxWalletService.ts b/src/lib/wallet/dydxWalletService.ts index 34e9088e24..083da9ef46 100644 --- a/src/lib/wallet/dydxWalletService.ts +++ b/src/lib/wallet/dydxWalletService.ts @@ -131,10 +131,13 @@ export class DydxWalletService { * This is the existing flow for connected wallets * * @param signature - Signature from source wallet - * @param persist - Whether to store encrypted signature + * @param persist - Whether to persist the derived mnemonic for future sessions * @returns Wallet creation result with dYdX address */ - async deriveFromSignature(signature: string): Promise { + async deriveFromSignature( + signature: string, + persist: boolean = true + ): Promise { try { // Derive HD key from signature const { mnemonic, privateKey, publicKey } = @@ -151,14 +154,18 @@ export class DydxWalletService { }; } - // Note: We don't persist signature-derived wallets to secure storage - // They remain in memory only, tied to the source wallet session + // Persist derived mnemonic to secure storage + // This replaces the old encrypted signature approach with more secure Web Crypto API + if (persist) { + await secureStorage.store(MNEMONIC_STORAGE_KEY, mnemonic); + } // Update app state await this.setWalletInState(wallet, mnemonic, privateKey, publicKey); logBonsaiInfo('DydxWalletService', 'deriveFromSignature', { address: wallet.address, + persisted: persist, }); return { From 64ad340f8ed35d89a2751b986fb1b4bb67e11630 Mon Sep 17 00:00:00 2001 From: jaredvu Date: Mon, 17 Nov 2025 17:22:47 -0800 Subject: [PATCH 06/19] migration --- src/state/_store.ts | 2 +- src/state/migrations.ts | 2 ++ src/state/migrations/6.ts | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 src/state/migrations/6.ts 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 + }, + }, + }; +} From 947f0ba5cc3d9fe02aa62698662c833d8115bae4 Mon Sep 17 00:00:00 2001 From: jaredvu Date: Tue, 18 Nov 2025 10:16:38 -0800 Subject: [PATCH 07/19] clean --- src/hooks/useAccounts.tsx | 5 +- src/hooks/useCosmosWallets.ts | 17 ++-- src/lib/hdKeyManager.ts | 6 +- ...rchestrator.ts => OnboardingSupervisor.ts} | 94 ++----------------- src/lib/onboarding/deriveCosmosWallets.ts | 64 +++++++++++++ 5 files changed, 84 insertions(+), 102 deletions(-) rename src/lib/onboarding/{OnboardingOrchestrator.ts => OnboardingSupervisor.ts} (77%) create mode 100644 src/lib/onboarding/deriveCosmosWallets.ts diff --git a/src/hooks/useAccounts.tsx b/src/hooks/useAccounts.tsx index ea60ab4c9e..781964bcd2 100644 --- a/src/hooks/useAccounts.tsx +++ b/src/hooks/useAccounts.tsx @@ -124,6 +124,7 @@ const useAccountsContext = () => { const { wallet, mnemonic, privateKey, publicKey } = await getWalletFromSignature({ signature, }); + const key = { mnemonic, privateKey, publicKey }; hdKeyManager.setHdkey(wallet.address, key); setLocalDydxWallet(wallet); @@ -147,9 +148,9 @@ const useAccountsContext = () => { useEffect(() => { (async () => { - const { onboardingOrchestrator } = await import('@/lib/onboarding/OnboardingOrchestrator'); + const { onboardingManager } = await import('@/lib/onboarding/OnboardingSupervisor'); - const result = await onboardingOrchestrator.handleWalletConnection({ + const result = await onboardingManager.handleWalletConnection({ context: { sourceAccount, hasLocalDydxWallet, diff --git a/src/hooks/useCosmosWallets.ts b/src/hooks/useCosmosWallets.ts index 5bfc019f45..158634b3bf 100644 --- a/src/hooks/useCosmosWallets.ts +++ b/src/hooks/useCosmosWallets.ts @@ -6,7 +6,7 @@ import type { LocalWallet } from '@dydxprotocol/v4-client-js'; import { getNeutronChainId, getNobleChainId, getOsmosisChainId } from '@/constants/graz'; import type { PrivateInformation } from '@/constants/wallets'; -import { onboardingOrchestrator } from '@/lib/onboarding/OnboardingOrchestrator'; +import { onboardingManager } from '@/lib/onboarding/OnboardingSupervisor'; /** * @@ -27,10 +27,7 @@ export function useCosmosWallets( try { const nobleOfflineSigner = await getCosmosOfflineSigner(getNobleChainId()); if (nobleOfflineSigner) { - return await onboardingOrchestrator.deriveCosmosWalletFromSigner( - nobleOfflineSigner, - 'noble' - ); + return await onboardingManager.deriveCosmosWalletFromSigner(nobleOfflineSigner, 'noble'); } } catch (error) { // Fall through to mnemonic derivation @@ -40,7 +37,7 @@ export function useCosmosWallets( // Derive from mnemonic if available if (hdKey?.mnemonic) { try { - return await onboardingOrchestrator.deriveCosmosWallet(hdKey.mnemonic, 'noble'); + return await onboardingManager.deriveCosmosWallet(hdKey.mnemonic, 'noble'); } catch (error) { logBonsaiError('useCosmosWallets', 'Failed to derive Noble wallet w/ mnemonic', { error }); return null; @@ -59,7 +56,7 @@ export function useCosmosWallets( try { const osmosisOfflineSigner = await getCosmosOfflineSigner(getOsmosisChainId()); if (osmosisOfflineSigner) { - return await onboardingOrchestrator.deriveCosmosWalletFromSigner( + return await onboardingManager.deriveCosmosWalletFromSigner( osmosisOfflineSigner, 'osmosis' ); @@ -72,7 +69,7 @@ export function useCosmosWallets( // Derive from mnemonic if available if (hdKey?.mnemonic) { try { - return await onboardingOrchestrator.deriveCosmosWallet(hdKey.mnemonic, 'osmosis'); + return await onboardingManager.deriveCosmosWallet(hdKey.mnemonic, 'osmosis'); } catch (error) { logBonsaiError('useCosmosWallets', 'Failed to derive Osmosis wallet w/ mnemonic', { error, @@ -94,7 +91,7 @@ export function useCosmosWallets( try { const neutronOfflineSigner = await getCosmosOfflineSigner(getNeutronChainId()); if (neutronOfflineSigner) { - return await onboardingOrchestrator.deriveCosmosWalletFromSigner( + return await onboardingManager.deriveCosmosWalletFromSigner( neutronOfflineSigner, 'neutron' ); @@ -107,7 +104,7 @@ export function useCosmosWallets( // Derive from mnemonic if available if (hdKey?.mnemonic) { try { - return await onboardingOrchestrator.deriveCosmosWallet(hdKey.mnemonic, 'neutron'); + return await onboardingManager.deriveCosmosWallet(hdKey.mnemonic, 'neutron'); } catch (error) { logBonsaiError('useCosmosWallets', 'Failed to derive Neutron wallet w/ mnemonic', { error, diff --git a/src/lib/hdKeyManager.ts b/src/lib/hdKeyManager.ts index d707153fcf..fc550f9abd 100644 --- a/src/lib/hdKeyManager.ts +++ b/src/lib/hdKeyManager.ts @@ -6,6 +6,7 @@ import { Hdkey } from '@/constants/account'; import type { RootStore } from '@/state/_store'; import { setHdKeyNonce, setLocalWalletNonce } from '@/state/wallet'; +import { deriveCosmosWallet } from './onboarding/deriveCosmosWallets'; import { log } from './telemetry'; class HDKeyManager { @@ -107,11 +108,8 @@ class LocalWalletManager { // Derive from hdKey if available if (this.hdKey?.mnemonic) { try { - const { onboardingOrchestrator } = await import('@/lib/onboarding/OnboardingOrchestrator'); - this.localNobleWalletCache = - (await onboardingOrchestrator.deriveCosmosWallet(this.hdKey.mnemonic, 'noble')) ?? - undefined; + (await deriveCosmosWallet(this.hdKey.mnemonic, 'noble')) ?? undefined; return this.localNobleWalletCache; } catch (error) { diff --git a/src/lib/onboarding/OnboardingOrchestrator.ts b/src/lib/onboarding/OnboardingSupervisor.ts similarity index 77% rename from src/lib/onboarding/OnboardingOrchestrator.ts rename to src/lib/onboarding/OnboardingSupervisor.ts index 2dae46786c..ac5b6b544e 100644 --- a/src/lib/onboarding/OnboardingOrchestrator.ts +++ b/src/lib/onboarding/OnboardingSupervisor.ts @@ -1,27 +1,8 @@ -/** - * OnboardingOrchestrator - * - * Vanilla JS class that handles all onboarding business logic. - * Replaces the complex useEffect logic in useAccounts. - * - * Design principles: - * - No React hooks or dependencies - * - Pure business logic, fully testable - * - Returns wallet data rather than managing state directly - * - Explicit inputs and outputs for each method - * - * Wallet storage strategy: - * - All mnemonics stored in SecureStorage (Web Crypto API) - * - No more encrypted signatures with static keys - * - STORED: localDydxWallet mnemonic - * - ON-DEMAND: Noble, Osmosis, Neutron wallets derived from mnemonic when needed - */ import { getLazyLocalWallet } from '@/bonsai/lib/lazyDynamicLibs'; import { logBonsaiError } from '@/bonsai/logs'; -import { NOBLE_BECH32_PREFIX, type LocalWallet } from '@dydxprotocol/v4-client-js'; +import { type LocalWallet } from '@dydxprotocol/v4-client-js'; import { OnboardingState } from '@/constants/account'; -import { NEUTRON_BECH32_PREFIX, OSMO_BECH32_PREFIX } from '@/constants/graz'; import { ConnectorType, PrivateInformation, @@ -56,9 +37,7 @@ export interface WalletDerivationResult { error?: string; } -export type SupportedCosmosChain = 'noble' | 'osmosis' | 'neutron'; - -export class OnboardingOrchestrator { +class OnboardingSupervisor { /** * Derive dYdX wallet from signature using DydxWalletService * Used for EVM, Solana, and Turnkey wallets @@ -170,7 +149,7 @@ export class OnboardingOrchestrator { onboardingState: OnboardingState.Disconnected, }; } catch (error) { - logBonsaiError('OnboardingOrchestrator', 'handleWalletConnection failed', { error }); + logBonsaiError('OnboardingSupervisor', 'handleWalletConnection failed', { error }); return { onboardingState: OnboardingState.Disconnected, error: error instanceof Error ? error.message : 'Unknown error', @@ -218,7 +197,7 @@ export class OnboardingOrchestrator { onboardingState: OnboardingState.AccountConnected, }; } catch (error) { - logBonsaiError('OnboardingOrchestrator', 'Turnkey signing failed', { error }); + logBonsaiError('OnboardingSupervisor', 'Turnkey signing failed', { error }); return { onboardingState: OnboardingState.Disconnected, error: 'Failed to sign with Turnkey', @@ -276,7 +255,7 @@ export class OnboardingOrchestrator { }; } } catch (error) { - logBonsaiError('OnboardingOrchestrator', 'Cosmos wallet creation failed', { error }); + logBonsaiError('OnboardingSupervisor', 'Cosmos wallet creation failed', { error }); } return { @@ -338,7 +317,7 @@ export class OnboardingOrchestrator { onboardingState: OnboardingState.AccountConnected, }; } catch (error) { - logBonsaiError('OnboardingOrchestrator', 'Privy signing failed', { error }); + logBonsaiError('OnboardingSupervisor', 'Privy signing failed', { error }); return { onboardingState: OnboardingState.WalletConnected, error: 'Failed to sign with Privy', @@ -394,7 +373,7 @@ export class OnboardingOrchestrator { onboardingState: OnboardingState.AccountConnected, }; } catch (error) { - logBonsaiError('OnboardingOrchestrator', 'Solana signing failed', { error }); + logBonsaiError('OnboardingSupervisor', 'Solana signing failed', { error }); return { onboardingState: OnboardingState.WalletConnected, error: 'Failed to sign with Solana wallet', @@ -407,64 +386,7 @@ export class OnboardingOrchestrator { onboardingState: OnboardingState.WalletConnected, }; } - - /** - * 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 - */ - async deriveCosmosWallet( - mnemonic: string, - chain: SupportedCosmosChain - ): Promise { - try { - const prefix = this.getCosmosPrefix(chain); - const LazyLocalWallet = await getLazyLocalWallet(); - return await LazyLocalWallet.fromMnemonic(mnemonic, prefix); - } catch (error) { - logBonsaiError('OnboardingOrchestrator', `Failed to derive ${chain} wallet`, { error }); - return null; - } - } - - /** - * Derive a Cosmos wallet from offline signer - * Used when user has a native Cosmos wallet connected - */ - async deriveCosmosWalletFromSigner( - offlineSigner: any, - chain: string - ): Promise { - try { - const LazyLocalWallet = await getLazyLocalWallet(); - return await LazyLocalWallet.fromOfflineSigner(offlineSigner); - } catch (error) { - logBonsaiError('OnboardingOrchestrator', `Failed to derive ${chain} wallet from signer`, { - error, - }); - return null; - } - } - - /** - * Get the Bech32 prefix for a Cosmos chain - */ - private 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}`); - } - } } // Export singleton instance -export const onboardingOrchestrator = new OnboardingOrchestrator(); +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..d741e03333 --- /dev/null +++ b/src/lib/onboarding/deriveCosmosWallets.ts @@ -0,0 +1,64 @@ +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`, { 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}`); + } +} From 63348fda58d59b2e8f443b6336ee29854d095fe1 Mon Sep 17 00:00:00 2001 From: jaredvu Date: Tue, 18 Nov 2025 15:39:00 -0800 Subject: [PATCH 08/19] add restoration logic --- src/hooks/useAccounts.tsx | 7 ++- src/lib/onboarding/OnboardingSupervisor.ts | 51 ++++++++++++++++++++++ src/lib/wallet/dydxWalletService.ts | 3 +- 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/hooks/useAccounts.tsx b/src/hooks/useAccounts.tsx index 781964bcd2..ef66ee4465 100644 --- a/src/hooks/useAccounts.tsx +++ b/src/hooks/useAccounts.tsx @@ -67,6 +67,7 @@ const useAccountsContext = () => { const [previousAddress, setPreviousAddress] = useState(sourceAccount.address); useEffect(() => { + return; const { address } = sourceAccount; // wallet accounts switched if (previousAddress && address !== previousAddress) { @@ -226,7 +227,11 @@ const useAccountsContext = () => { }, [blockedGeo]); // Disconnect wallet / accounts - const disconnectLocalDydxWallet = () => { + const disconnectLocalDydxWallet = async () => { + // Clear persisted mnemonic from SecureStorage + const { dydxWalletService } = await import('@/lib/wallet/dydxWalletService'); + dydxWalletService.clearStoredWallet(); + setLocalDydxWallet(undefined); setHdKey(undefined); hdKeyManager.clearHdkey(); diff --git a/src/lib/onboarding/OnboardingSupervisor.ts b/src/lib/onboarding/OnboardingSupervisor.ts index ac5b6b544e..a7ca599094 100644 --- a/src/lib/onboarding/OnboardingSupervisor.ts +++ b/src/lib/onboarding/OnboardingSupervisor.ts @@ -71,6 +71,48 @@ class OnboardingSupervisor { return { wallet, hdKey }; } + /** + * Restore wallet from SecureStorage if available + * Called at the start of handleWalletConnection to check for persisted session + */ + private async restoreFromSecureStorage(): Promise { + try { + const storedMnemonic = await dydxWalletService.exportMnemonic(); + + if (!storedMnemonic) { + return null; + } + + // Derive wallet from stored mnemonic + const { onboarding, BECH32_PREFIX } = await import('@dydxprotocol/v4-client-js'); + const { privateKey, publicKey } = onboarding.deriveHDKeyFromMnemonic(storedMnemonic); + + if (!privateKey || !publicKey) { + return null; + } + + const LocalWallet = await getLazyLocalWallet(); + const wallet = await LocalWallet.fromMnemonic(storedMnemonic, BECH32_PREFIX); + + const hdKey: PrivateInformation = { + mnemonic: storedMnemonic, + privateKey, + publicKey, + }; + + hdKeyManager.setHdkey(wallet.address, hdKey); + + return { + wallet, + 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 */ @@ -97,6 +139,15 @@ class OnboardingSupervisor { context; try { + // ------ Restore from SecureStorage ------ // + // Check for persisted session before processing wallet connections + if (!hasLocalDydxWallet && !blockedGeo) { + const restored = await this.restoreFromSecureStorage(); + if (restored) { + return restored; + } + } + // ------ Turnkey Flow ------ // if (sourceAccount.walletInfo?.connectorType === ConnectorType.Turnkey) { return await this.handleTurnkeyFlow({ diff --git a/src/lib/wallet/dydxWalletService.ts b/src/lib/wallet/dydxWalletService.ts index 083da9ef46..a5f1b2e562 100644 --- a/src/lib/wallet/dydxWalletService.ts +++ b/src/lib/wallet/dydxWalletService.ts @@ -61,7 +61,7 @@ export class DydxWalletService { const LocalWallet = await getLazyLocalWallet(); const wallet = await LocalWallet.fromMnemonic(mnemonic, BECH32_PREFIX); - if (!wallet || !wallet.address) { + if (!wallet.address) { return { success: false, error: 'Failed to create wallet from mnemonic.', @@ -157,6 +157,7 @@ export class DydxWalletService { // Persist derived mnemonic to secure storage // This replaces the old encrypted signature approach with more secure Web Crypto API if (persist) { + console.log('storing mnemonic', mnemonic); await secureStorage.store(MNEMONIC_STORAGE_KEY, mnemonic); } From ed944865f4aa1475d6f867938b1136fbeefd46ca Mon Sep 17 00:00:00 2001 From: jaredvu Date: Tue, 18 Nov 2025 15:43:05 -0800 Subject: [PATCH 09/19] remove debug log --- src/lib/wallet/dydxWalletService.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/wallet/dydxWalletService.ts b/src/lib/wallet/dydxWalletService.ts index a5f1b2e562..10a099cec8 100644 --- a/src/lib/wallet/dydxWalletService.ts +++ b/src/lib/wallet/dydxWalletService.ts @@ -157,7 +157,6 @@ export class DydxWalletService { // Persist derived mnemonic to secure storage // This replaces the old encrypted signature approach with more secure Web Crypto API if (persist) { - console.log('storing mnemonic', mnemonic); await secureStorage.store(MNEMONIC_STORAGE_KEY, mnemonic); } From cac211c44bb4f3a248cff77c99e2fa4c213af190 Mon Sep 17 00:00:00 2001 From: jaredvu Date: Tue, 18 Nov 2025 15:47:50 -0800 Subject: [PATCH 10/19] remove async import --- src/hooks/useAccounts.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/useAccounts.tsx b/src/hooks/useAccounts.tsx index ef66ee4465..fd936cd351 100644 --- a/src/hooks/useAccounts.tsx +++ b/src/hooks/useAccounts.tsx @@ -18,6 +18,7 @@ import { setLocalWallet } from '@/state/wallet'; import { getSourceAccount } from '@/state/walletSelectors'; import { hdKeyManager, localWalletManager } from '@/lib/hdKeyManager'; +import { dydxWalletService } from '@/lib/wallet/dydxWalletService'; import { useCosmosWallets } from './useCosmosWallets'; import { useDydxClient } from './useDydxClient'; @@ -227,9 +228,8 @@ const useAccountsContext = () => { }, [blockedGeo]); // Disconnect wallet / accounts - const disconnectLocalDydxWallet = async () => { + const disconnectLocalDydxWallet = () => { // Clear persisted mnemonic from SecureStorage - const { dydxWalletService } = await import('@/lib/wallet/dydxWalletService'); dydxWalletService.clearStoredWallet(); setLocalDydxWallet(undefined); From 0326f4b5dc4ddb4a57e6197970a3bffb088d4a46 Mon Sep 17 00:00:00 2001 From: jaredvu Date: Wed, 19 Nov 2025 07:32:40 -0800 Subject: [PATCH 11/19] update useGenerateKeys --- .../rest/lib/nobleTransactionStoreEffect.ts | 4 +- src/constants/account.ts | 10 +- src/hooks/Onboarding/useGenerateKeys.ts | 93 ++++++++----------- src/hooks/useAccounts.tsx | 24 +---- src/hooks/useWalletConnection.tsx | 57 +----------- src/lib/onboarding/OnboardingSupervisor.ts | 85 +++++++++++++++++ src/lib/wallet/dydxWalletService.ts | 63 ++++++++++--- src/providers/TurnkeyWalletProvider.tsx | 15 +-- src/state/wallet.ts | 24 ----- 9 files changed, 182 insertions(+), 193 deletions(-) diff --git a/src/bonsai/rest/lib/nobleTransactionStoreEffect.ts b/src/bonsai/rest/lib/nobleTransactionStoreEffect.ts index 98715ecb9f..7b827a32c5 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.getCachedLocalNobleWallet(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..24b606e005 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, setHdKey } = useAccounts(); const isDeriving = ![ EvmDerivedAccountStatus.NotDerived, @@ -90,74 +86,59 @@ 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 }); - - // 2. Ensure signature is deterministic - // Check if subaccounts exist - const dydxAddress = dydxWallet.address as DydxAddress; - let hasPreviousTransactions = false; + // Track first signature request + const wrappedSignMessage = async () => { + const sig = await signMessageAsync(); + track(AnalyticsEvents.OnboardingDeriveKeysSignatureReceived({ signatureNumber: 1 })); + return sig; + }; - try { + // 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); - - // 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) { + return hasPreviousTransactions; + }; + + // 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)); + // Track second signature for new users + if (result.isNewUser) { + setDerivationStatus(EvmDerivedAccountStatus.EnsuringDeterminism); + track(AnalyticsEvents.OnboardingDeriveKeysSignatureReceived({ signatureNumber: 2 })); } - // 4. Done + // Set wallet in useAccounts state + setLocalDydxWallet(result.wallet); + setHdKey(result.hdKey); + + // 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 fd936cd351..12401c017b 100644 --- a/src/hooks/useAccounts.tsx +++ b/src/hooks/useAccounts.tsx @@ -1,6 +1,5 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; -import { BonsaiCore } from '@/bonsai/ontology'; import { type LocalWallet, type Subaccount } from '@dydxprotocol/v4-client-js'; import { usePrivy } from '@privy-io/react-auth'; @@ -56,7 +55,6 @@ const useAccountsContext = () => { dydxAccountGraz, } = useWalletConnection(); - const hasSubAccount = useAppSelector(BonsaiCore.account.parentSubaccountSummary.data) != null; const sourceAccount = useAppSelector(getSourceAccount); const { ready, authenticated } = usePrivy(); @@ -65,26 +63,6 @@ const useAccountsContext = () => { return geo.currentlyGeoBlocked && checkForGeo; }, [geo, checkForGeo]); - const [previousAddress, setPreviousAddress] = useState(sourceAccount.address); - - useEffect(() => { - return; - 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]); - // dYdXClient Onboarding & Account Helpers const { indexerClient, getWalletFromSignature } = useDydxClient(); // dYdX subaccounts @@ -268,6 +246,8 @@ const useAccountsContext = () => { localDydxWallet, dydxAccounts, dydxAddress, + setLocalDydxWallet, + setHdKey, // Cosmos wallets (on-demand) ...cosmosWallets, diff --git a/src/hooks/useWalletConnection.tsx b/src/hooks/useWalletConnection.tsx index a3539245d5..43d8cbff26 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(); @@ -177,11 +175,9 @@ export const useWalletConnectionContext = () => { async ({ wallet, forceConnect, - isEvmAccountConnected, }: { wallet: WalletInfo | undefined; forceConnect?: boolean; - isEvmAccountConnected?: boolean; }) => { if (!wallet) return; @@ -205,7 +201,7 @@ export const useWalletConnectionContext = () => { } else if (wallet.connectorType === ConnectorType.PhantomSolana) { await connectPhantom(); } else if (isWagmiConnectorType(wallet)) { - if (!isConnectedWagmi && (!!forceConnect || !isEvmAccountConnected)) { + if (!isConnectedWagmi && !!forceConnect) { 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? @@ -255,45 +251,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 +268,6 @@ export const useWalletConnectionContext = () => { } else { await connectWallet({ wallet, - isEvmAccountConnected: Boolean( - sourceAccount.chain === WalletNetworkType.Evm && sourceAccount.encryptedSignature - ), }); dispatch(setWalletInfo(wallet)); @@ -333,14 +287,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/onboarding/OnboardingSupervisor.ts b/src/lib/onboarding/OnboardingSupervisor.ts index a7ca599094..958f2fb609 100644 --- a/src/lib/onboarding/OnboardingSupervisor.ts +++ b/src/lib/onboarding/OnboardingSupervisor.ts @@ -5,6 +5,7 @@ import { type LocalWallet } from '@dydxprotocol/v4-client-js'; import { OnboardingState } from '@/constants/account'; import { ConnectorType, + DydxAddress, PrivateInformation, WalletNetworkType, type WalletInfo, @@ -71,6 +72,90 @@ class OnboardingSupervisor { return { wallet, 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: () => Promise; + getWalletFromSignature: (params: { signature: string }) => Promise<{ + wallet: LocalWallet; + mnemonic: string; + privateKey: Uint8Array | null; + publicKey: Uint8Array | null; + }>; + checkPreviousTransactions: (dydxAddress: DydxAddress) => Promise; + }): Promise< + | { success: true; wallet: 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(); + const { wallet, 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(); + + 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 (replaces old encrypted signature approach) + await dydxWalletService.deriveFromSignature(firstSignature); + + // Step 5: Set up hdKey + const hdKey: PrivateInformation = { + mnemonic, + privateKey, + publicKey, + }; + + hdKeyManager.setHdkey(wallet.address, hdKey); + + return { + success: true, + wallet, + 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 diff --git a/src/lib/wallet/dydxWalletService.ts b/src/lib/wallet/dydxWalletService.ts index 10a099cec8..721bbb6baa 100644 --- a/src/lib/wallet/dydxWalletService.ts +++ b/src/lib/wallet/dydxWalletService.ts @@ -22,19 +22,58 @@ export interface WalletCreationResult { error?: string; } -/** - * DydxWalletService - * - * Manages dYdX wallet creation, import, and derivation. - * Supports multiple wallet sources: - * - Direct mnemonic import (bypass source wallet) - * - Derivation from source wallet signature (existing flow) - * - Private key import - * - * This service enables AccountConnected state without requiring - * a connected source wallet (WalletConnected state). - */ export class DydxWalletService { + /** + * Import wallet from private key + * Direct private key import without mnemonic + * + * @param privateKey - Private key as hex string (with or without 0x prefix) + * @param persist - Whether to store for future sessions + * @returns Wallet creation result with dYdX address + */ + async importFromPrivateKey( + privateKey: string, + persist: boolean = true + ): Promise { + try { + // Create wallet from private key + const LocalWallet = await getLazyLocalWallet(); + const wallet = await LocalWallet.fromPrivateKey(privateKey); + + if (!wallet.address) { + return { + success: false, + error: 'Failed to create wallet from private key.', + }; + } + + // Note: Private key imports don't have mnemonic, so we can't derive Cosmos wallets + // Store the private key directly + if (persist) { + await secureStorage.store('imported_private_key', privateKey); + } + + // Update app state (no hdKey material for private key imports) + await this.setWalletInState(wallet, ''); // Empty string for mnemonic + + logBonsaiInfo('DydxWalletService', 'importFromPrivateKey', { + address: wallet.address, + persisted: persist, + }); + + return { + success: true, + dydxAddress: wallet.address as DydxAddress, + }; + } catch (error) { + logBonsaiError('DydxWalletService', 'Failed to importFromPrivateKey', { error }); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + }; + } + } + /** * Import wallet from mnemonic phrase * This bypasses the need for a source wallet connection diff --git a/src/providers/TurnkeyWalletProvider.tsx b/src/providers/TurnkeyWalletProvider.tsx index 45e2668f49..d8d2e7e32f 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, @@ -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); return dydxAddress; }, [ - dispatch, turnkeyEmailOnboardingData, primaryTurnkeyWallet, getPrimaryUserWallets, diff --git a/src/state/wallet.ts b/src/state/wallet.ts index 04ef0b386c..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; }; @@ -29,7 +28,6 @@ const initialState: WalletState = { sourceAccount: { address: undefined, chain: undefined, - encryptedSignature: undefined, walletInfo: undefined, }, localWallet: { @@ -50,31 +48,12 @@ 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, { @@ -103,7 +82,6 @@ export const walletSlice = createSlice({ state.sourceAccount = { address: undefined, chain: undefined, - encryptedSignature: undefined, walletInfo: undefined, }; state.turnkeyPrimaryWallet = undefined; @@ -137,8 +115,6 @@ export const walletEphemeralSlice = createSlice({ export const { setSourceAddress, setWalletInfo, - setSavedEncryptedSignature, - clearSavedEncryptedSignature, clearSourceAccount, setLocalWallet, setTurnkeyEmailOnboardingData, From c333e34d50dbedb3e982d32e625cf4769da2ab3c Mon Sep 17 00:00:00 2001 From: jaredvu Date: Mon, 24 Nov 2025 17:32:33 -0800 Subject: [PATCH 12/19] fix --- src/hooks/Onboarding/useGenerateKeys.ts | 18 +-- src/hooks/useWalletConnection.tsx | 11 +- src/lib/hdKeyManager.ts | 16 ++- src/lib/onboarding/OnboardingSupervisor.ts | 59 +++------- src/lib/onboarding/deriveCosmosWallets.ts | 22 +++- src/lib/wallet/dydxWalletService.ts | 103 ++---------------- src/providers/TurnkeyAuthProvider.tsx | 5 +- .../DepositForm/DepositForm.tsx | 2 +- 8 files changed, 75 insertions(+), 161 deletions(-) diff --git a/src/hooks/Onboarding/useGenerateKeys.ts b/src/hooks/Onboarding/useGenerateKeys.ts index 24b606e005..0d9b02fce0 100644 --- a/src/hooks/Onboarding/useGenerateKeys.ts +++ b/src/hooks/Onboarding/useGenerateKeys.ts @@ -93,9 +93,17 @@ export function useGenerateKeys(generateKeysProps?: GenerateKeysProps) { setDerivationStatus(EvmDerivedAccountStatus.Deriving); // Track first signature request - const wrappedSignMessage = async () => { + const wrappedSignMessage = async (requestNumber: number) => { + if (requestNumber === 2) { + setDerivationStatus(EvmDerivedAccountStatus.EnsuringDeterminism); + } + const sig = await signMessageAsync(); - track(AnalyticsEvents.OnboardingDeriveKeysSignatureReceived({ signatureNumber: 1 })); + + track( + AnalyticsEvents.OnboardingDeriveKeysSignatureReceived({ signatureNumber: requestNumber }) + ); + return sig; }; @@ -128,12 +136,6 @@ export function useGenerateKeys(generateKeysProps?: GenerateKeysProps) { return; } - // Track second signature for new users - if (result.isNewUser) { - setDerivationStatus(EvmDerivedAccountStatus.EnsuringDeterminism); - track(AnalyticsEvents.OnboardingDeriveKeysSignatureReceived({ signatureNumber: 2 })); - } - // Set wallet in useAccounts state setLocalDydxWallet(result.wallet); setHdKey(result.hdKey); diff --git a/src/hooks/useWalletConnection.tsx b/src/hooks/useWalletConnection.tsx index 43d8cbff26..0b1c1df119 100644 --- a/src/hooks/useWalletConnection.tsx +++ b/src/hooks/useWalletConnection.tsx @@ -172,13 +172,7 @@ export const useWalletConnectionContext = () => { const { logout } = useLogout(); const connectWallet = useCallback( - async ({ - wallet, - forceConnect, - }: { - wallet: WalletInfo | undefined; - forceConnect?: boolean; - }) => { + async ({ wallet }: { wallet: WalletInfo | undefined }) => { if (!wallet) return; try { @@ -201,12 +195,11 @@ export const useWalletConnectionContext = () => { } else if (wallet.connectorType === ConnectorType.PhantomSolana) { await connectPhantom(); } else if (isWagmiConnectorType(wallet)) { - if (!isConnectedWagmi && !!forceConnect) { + 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 }); } } diff --git a/src/lib/hdKeyManager.ts b/src/lib/hdKeyManager.ts index fc550f9abd..d6458be791 100644 --- a/src/lib/hdKeyManager.ts +++ b/src/lib/hdKeyManager.ts @@ -6,7 +6,10 @@ import { Hdkey } from '@/constants/account'; import type { RootStore } from '@/state/_store'; import { setHdKeyNonce, setLocalWalletNonce } from '@/state/wallet'; -import { deriveCosmosWallet } from './onboarding/deriveCosmosWallets'; +import { + deriveCosmosWallet, + deriveCosmosWalletFromPrivateKey, +} from './onboarding/deriveCosmosWallets'; import { log } from './telemetry'; class HDKeyManager { @@ -111,6 +114,17 @@ class LocalWalletManager { this.localNobleWalletCache = (await deriveCosmosWallet(this.hdKey.mnemonic, 'noble')) ?? undefined; + return this.localNobleWalletCache; + } catch (error) { + log('LocalWalletManager: Failed to derive Noble wallet', error); + return undefined; + } + } else if (this.hdKey?.privateKey) { + try { + const privateKey = Buffer.from(this.hdKey.privateKey).toString('hex'); + this.localNobleWalletCache = + (await deriveCosmosWalletFromPrivateKey(privateKey, 'noble')) ?? undefined; + return this.localNobleWalletCache; } catch (error) { log('LocalWalletManager: Failed to derive Noble wallet', error); diff --git a/src/lib/onboarding/OnboardingSupervisor.ts b/src/lib/onboarding/OnboardingSupervisor.ts index 958f2fb609..482da2cb99 100644 --- a/src/lib/onboarding/OnboardingSupervisor.ts +++ b/src/lib/onboarding/OnboardingSupervisor.ts @@ -1,6 +1,6 @@ import { getLazyLocalWallet } from '@/bonsai/lib/lazyDynamicLibs'; import { logBonsaiError } from '@/bonsai/logs'; -import { type LocalWallet } from '@dydxprotocol/v4-client-js'; +import { BECH32_PREFIX, type LocalWallet } from '@dydxprotocol/v4-client-js'; import { OnboardingState } from '@/constants/account'; import { @@ -82,7 +82,7 @@ class OnboardingSupervisor { * @returns Wallet derivation result with determinism validation */ async deriveKeysWithDeterminismCheck(params: { - signMessageAsync: () => Promise; + signMessageAsync: (requestNumber: number) => Promise; getWalletFromSignature: (params: { signature: string }) => Promise<{ wallet: LocalWallet; mnemonic: string; @@ -98,7 +98,7 @@ class OnboardingSupervisor { try { // Step 1: Get first signature and derive wallet - const firstSignature = await signMessageAsync(); + const firstSignature = await signMessageAsync(1); const { wallet, mnemonic, privateKey, publicKey } = await getWalletFromSignature({ signature: firstSignature, }); @@ -117,7 +117,7 @@ class OnboardingSupervisor { // Step 3: For new users, ensure determinism with second signature if (!hasPreviousTransactions) { - const secondSignature = await signMessageAsync(); + const secondSignature = await signMessageAsync(2); if (firstSignature !== secondSignature) { return { @@ -162,31 +162,20 @@ class OnboardingSupervisor { */ private async restoreFromSecureStorage(): Promise { try { - const storedMnemonic = await dydxWalletService.exportMnemonic(); - - if (!storedMnemonic) { - return null; - } - - // Derive wallet from stored mnemonic - const { onboarding, BECH32_PREFIX } = await import('@dydxprotocol/v4-client-js'); - const { privateKey, publicKey } = onboarding.deriveHDKeyFromMnemonic(storedMnemonic); - - if (!privateKey || !publicKey) { + const storedPrivateKey = await dydxWalletService.exportPrivateKey(); + if (!storedPrivateKey) { return null; } const LocalWallet = await getLazyLocalWallet(); - const wallet = await LocalWallet.fromMnemonic(storedMnemonic, BECH32_PREFIX); + const wallet = await LocalWallet.fromPrivateKey(storedPrivateKey, BECH32_PREFIX); const hdKey: PrivateInformation = { - mnemonic: storedMnemonic, - privateKey, - publicKey, + mnemonic: '', + privateKey: Buffer.from(storedPrivateKey, 'hex'), + publicKey: null, }; - hdKeyManager.setHdkey(wallet.address, hdKey); - return { wallet, hdKey, @@ -226,8 +215,9 @@ class OnboardingSupervisor { try { // ------ Restore from SecureStorage ------ // // Check for persisted session before processing wallet connections - if (!hasLocalDydxWallet && !blockedGeo) { + if (dydxWalletService.hasStoredWallet() && !blockedGeo) { const restored = await this.restoreFromSecureStorage(); + if (restored) { return restored; } @@ -478,7 +468,7 @@ class OnboardingSupervisor { getWalletFromSignature: any; signMessageAsync?: () => Promise; }): Promise { - const { hasLocalDydxWallet, blockedGeo, getWalletFromSignature, signMessageAsync } = params; + const { hasLocalDydxWallet, blockedGeo } = params; // If wallet already exists (restored from SecureStorage), just set state if (hasLocalDydxWallet) { @@ -494,29 +484,6 @@ class OnboardingSupervisor { }; } - // Derive from signature if available - if (signMessageAsync) { - try { - const signature = await signMessageAsync(); - const { wallet, hdKey } = await this.deriveWalletFromSignature( - signature, - getWalletFromSignature - ); - - return { - wallet, - hdKey, - onboardingState: OnboardingState.AccountConnected, - }; - } catch (error) { - logBonsaiError('OnboardingSupervisor', 'Solana signing failed', { error }); - return { - onboardingState: OnboardingState.WalletConnected, - error: 'Failed to sign with Solana wallet', - }; - } - } - // Wallet connected but waiting for signature return { onboardingState: OnboardingState.WalletConnected, diff --git a/src/lib/onboarding/deriveCosmosWallets.ts b/src/lib/onboarding/deriveCosmosWallets.ts index d741e03333..82cbbce16c 100644 --- a/src/lib/onboarding/deriveCosmosWallets.ts +++ b/src/lib/onboarding/deriveCosmosWallets.ts @@ -23,7 +23,27 @@ export async function deriveCosmosWallet( const LazyLocalWallet = await getLazyLocalWallet(); return await LazyLocalWallet.fromMnemonic(mnemonic, prefix); } catch (error) { - logBonsaiError('OnboardingSupervisor', `Failed to derive ${chain} wallet`, { 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; } } diff --git a/src/lib/wallet/dydxWalletService.ts b/src/lib/wallet/dydxWalletService.ts index 721bbb6baa..60b1f73fc5 100644 --- a/src/lib/wallet/dydxWalletService.ts +++ b/src/lib/wallet/dydxWalletService.ts @@ -14,7 +14,7 @@ import { log } from '@/lib/telemetry'; import { secureStorage } from './secureStorage'; -const MNEMONIC_STORAGE_KEY = 'imported_mnemonic'; +const STORAGE_KEY = 'trading_wallet_key'; export interface WalletCreationResult { success: boolean; @@ -50,7 +50,7 @@ export class DydxWalletService { // Note: Private key imports don't have mnemonic, so we can't derive Cosmos wallets // Store the private key directly if (persist) { - await secureStorage.store('imported_private_key', privateKey); + await secureStorage.store(STORAGE_KEY, privateKey); } // Update app state (no hdKey material for private key imports) @@ -74,65 +74,6 @@ export class DydxWalletService { } } - /** - * Import wallet from mnemonic phrase - * This bypasses the need for a source wallet connection - * - * @param mnemonic - 12 or 24 word mnemonic phrase - * @param persist - Whether to encrypt and store mnemonic for future sessions - * @returns Wallet creation result with dYdX address - */ - async importFromMnemonic( - mnemonic: string, - persist: boolean = true - ): Promise { - try { - // Validate mnemonic format - const words = mnemonic.trim().split(/\s+/); - if (words.length !== 12 && words.length !== 24) { - return { - success: false, - error: 'Invalid mnemonic. Must be 12 or 24 words.', - }; - } - - // Create wallet from mnemonic - const LocalWallet = await getLazyLocalWallet(); - const wallet = await LocalWallet.fromMnemonic(mnemonic, BECH32_PREFIX); - - if (!wallet.address) { - return { - success: false, - error: 'Failed to create wallet from mnemonic.', - }; - } - - // Store encrypted mnemonic if persistence requested - if (persist) { - await secureStorage.store(MNEMONIC_STORAGE_KEY, mnemonic); - } - - // Update app state - await this.setWalletInState(wallet, mnemonic); - - logBonsaiInfo('DydxWalletService', 'importFromMnemonic', { - address: wallet.address, - persisted: persist, - }); - - return { - success: true, - dydxAddress: wallet.address as DydxAddress, - }; - } catch (error) { - logBonsaiError('DydxWalletService', 'Failed to importFromMnemonic', { error }); - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', - }; - } - } - /** * Restore wallet from previously stored mnemonic * Called on app initialization to restore session @@ -141,14 +82,14 @@ export class DydxWalletService { */ async restoreFromStorage(): Promise { try { - const storedMnemonic = await secureStorage.retrieve(MNEMONIC_STORAGE_KEY); + const storedPrivateKey = await secureStorage.retrieve(STORAGE_KEY); - if (!storedMnemonic) { + if (!storedPrivateKey) { return null; } // Re-import without re-storing (it's already stored) - const result = await this.importFromMnemonic(storedMnemonic, false); + const result = await this.importFromPrivateKey(storedPrivateKey, false); if (result.success) { logBonsaiInfo('DydxWalletService', 'restoreFromStorage', { @@ -196,7 +137,7 @@ export class DydxWalletService { // Persist derived mnemonic to secure storage // This replaces the old encrypted signature approach with more secure Web Crypto API if (persist) { - await secureStorage.store(MNEMONIC_STORAGE_KEY, mnemonic); + await secureStorage.store(STORAGE_KEY, Buffer.from(privateKey).toString('hex')); } // Update app state @@ -260,7 +201,7 @@ export class DydxWalletService { * Check if a wallet is stored in secure storage */ hasStoredWallet(): boolean { - return secureStorage.has(MNEMONIC_STORAGE_KEY); + return secureStorage.has(STORAGE_KEY); } /** @@ -268,7 +209,7 @@ export class DydxWalletService { * Called on user sign out */ clearStoredWallet(): void { - secureStorage.remove(MNEMONIC_STORAGE_KEY); + secureStorage.remove(STORAGE_KEY); logBonsaiInfo('DydxWalletService', 'clearStoredWallet'); } @@ -278,38 +219,14 @@ export class DydxWalletService { * * @returns Decrypted mnemonic or null if not found */ - async exportMnemonic(): Promise { + async exportPrivateKey(): Promise { try { - return await secureStorage.retrieve(MNEMONIC_STORAGE_KEY); + return await secureStorage.retrieve(STORAGE_KEY); } catch (error) { logBonsaiError('DydxWalletService', 'Failed to exportMnemonic', { error }); return null; } } - - /** - * Validate mnemonic format - */ - validateMnemonic(mnemonic: string): { valid: boolean; error?: string } { - const words = mnemonic.trim().split(/\s+/); - - if (words.length !== 12 && words.length !== 24) { - return { - valid: false, - error: 'Mnemonic must be 12 or 24 words', - }; - } - - // Check for empty words - if (words.some((word) => !word)) { - return { - valid: false, - error: 'Mnemonic contains empty words', - }; - } - - return { valid: true }; - } } // Export singleton instance diff --git a/src/providers/TurnkeyAuthProvider.tsx b/src/providers/TurnkeyAuthProvider.tsx index a8086507b5..4007d8cf86 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 { dydxWalletService } from '@/lib/wallet/dydxWalletService'; import { useTurnkeyWallet } from './TurnkeyWalletProvider'; @@ -532,12 +533,12 @@ const useTurnkeyAuthContext = () => { */ useEffect(() => { const turnkeyOnboardingToken = searchParams.get('token'); - const hasEncryptedSignature = sourceAccount.encryptedSignature != null; + const hasStoredWallet = dydxWalletService.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/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); From a4deb1365a036fc2e5d6e5d84ed5ad61d6fdf713 Mon Sep 17 00:00:00 2001 From: jaredvu Date: Tue, 25 Nov 2025 07:55:51 -0800 Subject: [PATCH 13/19] cosmos wallet derivation fix --- src/hooks/useCosmosWallets.ts | 127 +++++++++++++++++++++++----------- 1 file changed, 85 insertions(+), 42 deletions(-) diff --git a/src/hooks/useCosmosWallets.ts b/src/hooks/useCosmosWallets.ts index 158634b3bf..450bc7cbf9 100644 --- a/src/hooks/useCosmosWallets.ts +++ b/src/hooks/useCosmosWallets.ts @@ -6,7 +6,11 @@ import type { LocalWallet } from '@dydxprotocol/v4-client-js'; import { getNeutronChainId, getNobleChainId, getOsmosisChainId } from '@/constants/graz'; import type { PrivateInformation } from '@/constants/wallets'; -import { onboardingManager } from '@/lib/onboarding/OnboardingSupervisor'; +import { + deriveCosmosWallet, + deriveCosmosWalletFromPrivateKey, + deriveCosmosWalletFromSigner, +} from '@/lib/onboarding/deriveCosmosWallets'; /** * @@ -22,54 +26,54 @@ export function useCosmosWallets( * Get Noble wallet on-demand */ const getNobleWallet = useCallback(async (): Promise => { - // Try to get from offline signer first (for native Cosmos wallets) - if (getCosmosOfflineSigner) { + // Derive from mnemonic if available + if (hdKey?.mnemonic) { try { - const nobleOfflineSigner = await getCosmosOfflineSigner(getNobleChainId()); - if (nobleOfflineSigner) { - return await onboardingManager.deriveCosmosWalletFromSigner(nobleOfflineSigner, 'noble'); - } + return await deriveCosmosWallet(hdKey.mnemonic, 'noble'); } catch (error) { - // Fall through to mnemonic derivation + logBonsaiError('useCosmosWallets', 'Failed to derive Noble wallet w/ mnemonic', { error }); + return null; } } - // Derive from mnemonic if available - if (hdKey?.mnemonic) { + // Derive from private key if available + if (hdKey?.privateKey) { try { - return await onboardingManager.deriveCosmosWallet(hdKey.mnemonic, 'noble'); + return await deriveCosmosWalletFromPrivateKey( + Buffer.from(hdKey.privateKey).toString('hex'), + 'noble' + ); } catch (error) { - logBonsaiError('useCosmosWallets', 'Failed to derive Noble wallet w/ mnemonic', { error }); + logBonsaiError('useCosmosWallets', 'Failed to derive Noble wallet w/ private key', { + error, + }); return null; } } - return null; - }, [hdKey?.mnemonic, getCosmosOfflineSigner]); - - /** - * Get Osmosis wallet on-demand - */ - const getOsmosisWallet = useCallback(async (): Promise => { - // Try to get from offline signer first (for native Cosmos wallets) + // Fall through to offline signer derivation if (getCosmosOfflineSigner) { try { - const osmosisOfflineSigner = await getCosmosOfflineSigner(getOsmosisChainId()); - if (osmosisOfflineSigner) { - return await onboardingManager.deriveCosmosWalletFromSigner( - osmosisOfflineSigner, - 'osmosis' - ); + const nobleOfflineSigner = await getCosmosOfflineSigner(getNobleChainId()); + if (nobleOfflineSigner) { + return await deriveCosmosWalletFromSigner(nobleOfflineSigner, 'noble'); } } catch (error) { // Fall through to mnemonic derivation } } + return null; + }, [hdKey?.mnemonic, hdKey?.privateKey, getCosmosOfflineSigner]); + + /** + * Get Osmosis wallet on-demand + */ + const getOsmosisWallet = useCallback(async (): Promise => { // Derive from mnemonic if available if (hdKey?.mnemonic) { try { - return await onboardingManager.deriveCosmosWallet(hdKey.mnemonic, 'osmosis'); + return await deriveCosmosWallet(hdKey.mnemonic, 'osmosis'); } catch (error) { logBonsaiError('useCosmosWallets', 'Failed to derive Osmosis wallet w/ mnemonic', { error, @@ -79,32 +83,44 @@ export function useCosmosWallets( } } - return null; - }, [hdKey?.mnemonic, getCosmosOfflineSigner]); + // Derive from private key if available + 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; + } + } - /** - * Get Neutron wallet on-demand - */ - const getNeutronWallet = useCallback(async (): Promise => { - // Try to get from offline signer first (for native Cosmos wallets) + // Fall through to offline signer derivation if (getCosmosOfflineSigner) { try { - const neutronOfflineSigner = await getCosmosOfflineSigner(getNeutronChainId()); - if (neutronOfflineSigner) { - return await onboardingManager.deriveCosmosWalletFromSigner( - neutronOfflineSigner, - 'neutron' - ); + const osmosisOfflineSigner = await getCosmosOfflineSigner(getOsmosisChainId()); + if (osmosisOfflineSigner) { + return await deriveCosmosWalletFromSigner(osmosisOfflineSigner, 'osmosis'); } } catch (error) { // Fall through to mnemonic derivation } } + return null; + }, [hdKey?.mnemonic, hdKey?.privateKey, getCosmosOfflineSigner]); + + /** + * Get Neutron wallet on-demand + */ + const getNeutronWallet = useCallback(async (): Promise => { // Derive from mnemonic if available if (hdKey?.mnemonic) { try { - return await onboardingManager.deriveCosmosWallet(hdKey.mnemonic, 'neutron'); + return await deriveCosmosWallet(hdKey.mnemonic, 'neutron'); } catch (error) { logBonsaiError('useCosmosWallets', 'Failed to derive Neutron wallet w/ mnemonic', { error, @@ -113,8 +129,35 @@ export function useCosmosWallets( } } + // Derive from private key if available + 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; + } + } + + // Fall through to offline signer derivation + if (getCosmosOfflineSigner) { + try { + const neutronOfflineSigner = await getCosmosOfflineSigner(getNeutronChainId()); + if (neutronOfflineSigner) { + return await deriveCosmosWalletFromSigner(neutronOfflineSigner, 'neutron'); + } + } catch (error) { + // Fall through to mnemonic derivation + } + } + return null; - }, [hdKey?.mnemonic, getCosmosOfflineSigner]); + }, [hdKey?.mnemonic, hdKey?.privateKey, getCosmosOfflineSigner]); /** * Get Noble wallet address without creating full wallet From 6a91d95e167bfc5450055ab07790628a546ea77b Mon Sep 17 00:00:00 2001 From: jaredvu Date: Tue, 25 Nov 2025 09:40:07 -0800 Subject: [PATCH 14/19] fix --- src/hooks/useAnalytics.ts | 7 +++-- src/hooks/useCosmosWallets.ts | 12 +++------ src/views/dialogs/CoinbaseDepositDialog.tsx | 13 +++++++++- .../DepositDialog2/DepositForm/QrDeposit.tsx | 11 +++++++- .../DepositDialog2/depositHooks.ts | 22 +++++++++++++++- .../TransferDialogs/DepositDialog2/queries.ts | 26 +++++++++++++++++-- 6 files changed, 73 insertions(+), 18 deletions(-) diff --git a/src/hooks/useAnalytics.ts b/src/hooks/useAnalytics.ts index 689bb2ee94..8c41ba2070 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 { dydxWalletService } from '@/lib/wallet/dydxWalletService'; import { useAccounts } from './useAccounts'; import { useApiState } from './useApiState'; @@ -152,11 +153,9 @@ export const useAnalytics = () => { useEffect(() => { identify( - AnalyticsUserProperties.IsRememberMe( - dydxAddress ? Boolean(sourceAccount.encryptedSignature) : null - ) + AnalyticsUserProperties.IsRememberMe(dydxAddress ? dydxWalletService.hasStoredWallet() : null) ); - }, [dydxAddress, sourceAccount.encryptedSignature]); + }, [dydxAddress]); // AnalyticsUserProperty.SubaccountNumber const subaccountNumber = useAppSelector(getSubaccountId); diff --git a/src/hooks/useCosmosWallets.ts b/src/hooks/useCosmosWallets.ts index 450bc7cbf9..cb63399599 100644 --- a/src/hooks/useCosmosWallets.ts +++ b/src/hooks/useCosmosWallets.ts @@ -59,7 +59,7 @@ export function useCosmosWallets( return await deriveCosmosWalletFromSigner(nobleOfflineSigner, 'noble'); } } catch (error) { - // Fall through to mnemonic derivation + return null; } } @@ -70,7 +70,6 @@ export function useCosmosWallets( * Get Osmosis wallet on-demand */ const getOsmosisWallet = useCallback(async (): Promise => { - // Derive from mnemonic if available if (hdKey?.mnemonic) { try { return await deriveCosmosWallet(hdKey.mnemonic, 'osmosis'); @@ -83,7 +82,6 @@ export function useCosmosWallets( } } - // Derive from private key if available if (hdKey?.privateKey) { try { return await deriveCosmosWalletFromPrivateKey( @@ -98,7 +96,6 @@ export function useCosmosWallets( } } - // Fall through to offline signer derivation if (getCosmosOfflineSigner) { try { const osmosisOfflineSigner = await getCosmosOfflineSigner(getOsmosisChainId()); @@ -106,7 +103,7 @@ export function useCosmosWallets( return await deriveCosmosWalletFromSigner(osmosisOfflineSigner, 'osmosis'); } } catch (error) { - // Fall through to mnemonic derivation + return null; } } @@ -117,7 +114,6 @@ export function useCosmosWallets( * Get Neutron wallet on-demand */ const getNeutronWallet = useCallback(async (): Promise => { - // Derive from mnemonic if available if (hdKey?.mnemonic) { try { return await deriveCosmosWallet(hdKey.mnemonic, 'neutron'); @@ -129,7 +125,6 @@ export function useCosmosWallets( } } - // Derive from private key if available if (hdKey?.privateKey) { try { return await deriveCosmosWalletFromPrivateKey( @@ -144,7 +139,6 @@ export function useCosmosWallets( } } - // Fall through to offline signer derivation if (getCosmosOfflineSigner) { try { const neutronOfflineSigner = await getCosmosOfflineSigner(getNeutronChainId()); @@ -152,7 +146,7 @@ export function useCosmosWallets( return await deriveCosmosWalletFromSigner(neutronOfflineSigner, 'neutron'); } } catch (error) { - // Fall through to mnemonic derivation + return null; } } 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/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 () => { From 8364cf678ff4adaabd6a5c3cce57aaf7f551ae20 Mon Sep 17 00:00:00 2001 From: jaredvu Date: Tue, 25 Nov 2025 09:51:48 -0800 Subject: [PATCH 15/19] requestNumber type --- src/hooks/Onboarding/useGenerateKeys.ts | 2 +- src/lib/onboarding/OnboardingSupervisor.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/Onboarding/useGenerateKeys.ts b/src/hooks/Onboarding/useGenerateKeys.ts index 0d9b02fce0..2cf0fc6ae8 100644 --- a/src/hooks/Onboarding/useGenerateKeys.ts +++ b/src/hooks/Onboarding/useGenerateKeys.ts @@ -93,7 +93,7 @@ export function useGenerateKeys(generateKeysProps?: GenerateKeysProps) { setDerivationStatus(EvmDerivedAccountStatus.Deriving); // Track first signature request - const wrappedSignMessage = async (requestNumber: number) => { + const wrappedSignMessage = async (requestNumber: 1 | 2) => { if (requestNumber === 2) { setDerivationStatus(EvmDerivedAccountStatus.EnsuringDeterminism); } diff --git a/src/lib/onboarding/OnboardingSupervisor.ts b/src/lib/onboarding/OnboardingSupervisor.ts index 482da2cb99..0863925a9d 100644 --- a/src/lib/onboarding/OnboardingSupervisor.ts +++ b/src/lib/onboarding/OnboardingSupervisor.ts @@ -82,7 +82,7 @@ class OnboardingSupervisor { * @returns Wallet derivation result with determinism validation */ async deriveKeysWithDeterminismCheck(params: { - signMessageAsync: (requestNumber: number) => Promise; + signMessageAsync: (requestNumber: 1 | 2) => Promise; getWalletFromSignature: (params: { signature: string }) => Promise<{ wallet: LocalWallet; mnemonic: string; From 5daae23f8bc19d90935f0511c4bd2e6503167648 Mon Sep 17 00:00:00 2001 From: jaredvu Date: Tue, 25 Nov 2025 10:04:36 -0800 Subject: [PATCH 16/19] clean up --- src/lib/onboarding/OnboardingSupervisor.ts | 1 - src/lib/wallet/dydxWalletService.ts | 26 ++++------------------ src/lib/wallet/secureStorage.ts | 4 +--- 3 files changed, 5 insertions(+), 26 deletions(-) diff --git a/src/lib/onboarding/OnboardingSupervisor.ts b/src/lib/onboarding/OnboardingSupervisor.ts index 0863925a9d..aa28a36633 100644 --- a/src/lib/onboarding/OnboardingSupervisor.ts +++ b/src/lib/onboarding/OnboardingSupervisor.ts @@ -491,5 +491,4 @@ class OnboardingSupervisor { } } -// Export singleton instance export const onboardingManager = new OnboardingSupervisor(); diff --git a/src/lib/wallet/dydxWalletService.ts b/src/lib/wallet/dydxWalletService.ts index 60b1f73fc5..705e932dab 100644 --- a/src/lib/wallet/dydxWalletService.ts +++ b/src/lib/wallet/dydxWalletService.ts @@ -36,7 +36,6 @@ export class DydxWalletService { persist: boolean = true ): Promise { try { - // Create wallet from private key const LocalWallet = await getLazyLocalWallet(); const wallet = await LocalWallet.fromPrivateKey(privateKey); @@ -47,7 +46,6 @@ export class DydxWalletService { }; } - // Note: Private key imports don't have mnemonic, so we can't derive Cosmos wallets // Store the private key directly if (persist) { await secureStorage.store(STORAGE_KEY, privateKey); @@ -75,10 +73,7 @@ export class DydxWalletService { } /** - * Restore wallet from previously stored mnemonic - * Called on app initialization to restore session - * - * @returns Wallet creation result or null if no stored mnemonic + * @returns Wallet creation result or null if no stored private key */ async restoreFromStorage(): Promise { try { @@ -134,8 +129,7 @@ export class DydxWalletService { }; } - // Persist derived mnemonic to secure storage - // This replaces the old encrypted signature approach with more secure Web Crypto API + // Persist derived private key to secure storage using Web Crypto API if (persist) { await secureStorage.store(STORAGE_KEY, Buffer.from(privateKey).toString('hex')); } @@ -190,22 +184,14 @@ export class DydxWalletService { }) ); - // Set onboarding state to AccountConnected store.dispatch(setOnboardingState(OnboardingState.AccountConnected)); - - // Note: Noble wallet derivation happens separately in useAccounts - // We could potentially add that here in the future for a more complete service } - /** - * Check if a wallet is stored in secure storage - */ hasStoredWallet(): boolean { return secureStorage.has(STORAGE_KEY); } /** - * Clear stored wallet data * Called on user sign out */ clearStoredWallet(): void { @@ -214,20 +200,16 @@ export class DydxWalletService { } /** - * Export current wallet mnemonic (for backup) - * WARNING: Only call this when user explicitly requests backup - * - * @returns Decrypted mnemonic or null if not found + * @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 exportMnemonic', { error }); + logBonsaiError('DydxWalletService', `Failed to export ${STORAGE_KEY}`, { error }); return null; } } } -// Export singleton instance export const dydxWalletService = new DydxWalletService(); diff --git a/src/lib/wallet/secureStorage.ts b/src/lib/wallet/secureStorage.ts index 95f8185597..8e87ca3bdb 100644 --- a/src/lib/wallet/secureStorage.ts +++ b/src/lib/wallet/secureStorage.ts @@ -8,8 +8,7 @@ * - Uses AES-GCM encryption with 256-bit keys * - Unique salt per browser/device stored in localStorage * - Protects against casual file system inspection - * - Does NOT protect against XSS attacks or code execution - * - Similar security model to MetaMask's encrypted vault + * - Warning: Does NOT protect against XSS attacks or code execution */ import { logBonsaiError } from '@/bonsai/logs'; @@ -178,5 +177,4 @@ export class SecureStorageService { } } -// Export singleton instance export const secureStorage = new SecureStorageService(); From c0c5f2322abafc97babb1af5800f34379f7bfb6d Mon Sep 17 00:00:00 2001 From: jaredvu Date: Wed, 26 Nov 2025 10:12:45 -0800 Subject: [PATCH 17/19] cleanup turnkey flow --- src/hooks/useAccounts.tsx | 11 +++-- src/lib/onboarding/OnboardingSupervisor.ts | 53 +++++++--------------- src/lib/wallet/secureStorage.ts | 21 +++------ src/providers/TurnkeyAuthProvider.tsx | 15 ++++-- src/providers/TurnkeyWalletProvider.tsx | 6 +-- 5 files changed, 44 insertions(+), 62 deletions(-) diff --git a/src/hooks/useAccounts.tsx b/src/hooks/useAccounts.tsx index 12401c017b..0199c6d232 100644 --- a/src/hooks/useAccounts.tsx +++ b/src/hooks/useAccounts.tsx @@ -17,6 +17,7 @@ import { setLocalWallet } from '@/state/wallet'; import { getSourceAccount } from '@/state/walletSelectors'; import { hdKeyManager, localWalletManager } from '@/lib/hdKeyManager'; +import { onboardingManager } from '@/lib/onboarding/OnboardingSupervisor'; import { dydxWalletService } from '@/lib/wallet/dydxWalletService'; import { useCosmosWallets } from './useCosmosWallets'; @@ -99,7 +100,7 @@ const useAccountsContext = () => { dispatch(setLocalWallet({ address: dydxAddress, subaccountNumber: 0 })); }, [dispatch, dydxAddress]); - const setWalletFromSignature = useCallback( + const setWalletFromTurnkeySignature = useCallback( async (signature: string) => { const { wallet, mnemonic, privateKey, publicKey } = await getWalletFromSignature({ signature, @@ -107,6 +108,10 @@ const useAccountsContext = () => { const key = { mnemonic, privateKey, publicKey }; hdKeyManager.setHdkey(wallet.address, key); + + // Persist to SecureStorage for session restoration + await dydxWalletService.deriveFromSignature(signature); + setLocalDydxWallet(wallet); setHdKey(key); return wallet.address; @@ -128,8 +133,6 @@ const useAccountsContext = () => { useEffect(() => { (async () => { - const { onboardingManager } = await import('@/lib/onboarding/OnboardingSupervisor'); - const result = await onboardingManager.handleWalletConnection({ context: { sourceAccount, @@ -239,7 +242,7 @@ const useAccountsContext = () => { signerWagmi, publicClientWagmi, - setWalletFromSignature, + setWalletFromTurnkeySignature, // dYdX accounts hdKey, diff --git a/src/lib/onboarding/OnboardingSupervisor.ts b/src/lib/onboarding/OnboardingSupervisor.ts index aa28a36633..9f8765d9dd 100644 --- a/src/lib/onboarding/OnboardingSupervisor.ts +++ b/src/lib/onboarding/OnboardingSupervisor.ts @@ -212,6 +212,11 @@ class OnboardingSupervisor { const { sourceAccount, hasLocalDydxWallet, blockedGeo, isConnectedGraz, authenticated, ready } = context; + console.log('handleWalletConnection', { + stored: dydxWalletService.hasStoredWallet(), + blockedGeo, + }); + try { // ------ Restore from SecureStorage ------ // // Check for persisted session before processing wallet connections @@ -226,11 +231,8 @@ class OnboardingSupervisor { // ------ Turnkey Flow ------ // if (sourceAccount.walletInfo?.connectorType === ConnectorType.Turnkey) { return await this.handleTurnkeyFlow({ - sourceAccount, hasLocalDydxWallet, blockedGeo, - getWalletFromSignature, - signMessageAsync, }); } @@ -285,53 +287,32 @@ class OnboardingSupervisor { /** * Handle Turnkey wallet flow - * Turnkey is an embedded wallet - no WalletConnected state, only AccountConnected or Disconnected + * Turnkey is an embedded wallet managed entirely by TurnkeyAuthProvider + * Signing is done via Turnkey SDK, persistence via setWalletFromSignature() + * OnboardingSupervisor only validates state */ private async handleTurnkeyFlow(params: { - sourceAccount: SourceAccount; hasLocalDydxWallet: boolean; blockedGeo: boolean; - getWalletFromSignature: (params: { signature: string }) => Promise<{ - wallet: LocalWallet; - mnemonic: string; - privateKey: Uint8Array | null; - publicKey: Uint8Array | null; - }>; - signMessageAsync?: () => Promise; }): Promise { - const { hasLocalDydxWallet, blockedGeo, getWalletFromSignature, signMessageAsync } = params; + const { hasLocalDydxWallet, blockedGeo } = params; - // If wallet already exists (restored from SecureStorage), just set state + // If wallet already exists (restored from SecureStorage or set by TurnkeyAuthProvider) if (hasLocalDydxWallet) { return { onboardingState: OnboardingState.AccountConnected, }; } - // If not geo-blocked, derive wallet from signature - if (!blockedGeo && signMessageAsync) { - try { - const signature = await signMessageAsync(); - const { wallet, hdKey } = await this.deriveWalletFromSignature( - signature, - getWalletFromSignature - ); - - return { - wallet, - hdKey, - onboardingState: OnboardingState.AccountConnected, - }; - } catch (error) { - logBonsaiError('OnboardingSupervisor', 'Turnkey signing failed', { error }); - return { - onboardingState: OnboardingState.Disconnected, - error: 'Failed to sign with Turnkey', - }; - } + // If geo-blocked, user cannot proceed + if (blockedGeo) { + return { + onboardingState: OnboardingState.Disconnected, + }; } - // No wallet and geo-blocked or can't sign - disconnected + // Wallet is being set up by TurnkeyAuthProvider + // Return Disconnected until TurnkeyAuthProvider completes the flow return { onboardingState: OnboardingState.Disconnected, }; diff --git a/src/lib/wallet/secureStorage.ts b/src/lib/wallet/secureStorage.ts index 8e87ca3bdb..5f1f4943a8 100644 --- a/src/lib/wallet/secureStorage.ts +++ b/src/lib/wallet/secureStorage.ts @@ -1,15 +1,3 @@ -/** - * SecureStorageService - * - * Provides encrypted storage for sensitive data using the Web Crypto API. - * Uses a browser-specific encryption key derived from a random salt. - * - * Security Model: - * - Uses AES-GCM encryption with 256-bit keys - * - Unique salt per browser/device stored in localStorage - * - Protects against casual file system inspection - * - Warning: Does NOT protect against XSS attacks or code execution - */ import { logBonsaiError } from '@/bonsai/logs'; import { arrayBufferToBase64 } from '@/lib/arrayBufferToBase64'; @@ -19,11 +7,16 @@ const STORAGE_PREFIX = 'dydx.secure.'; const SALT_KEY = `${STORAGE_PREFIX}salt`; interface EncryptedData { - data: string; // Base64 encoded encrypted data - iv: string; // Base64 encoded initialization vector + 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; diff --git a/src/providers/TurnkeyAuthProvider.tsx b/src/providers/TurnkeyAuthProvider.tsx index 4007d8cf86..d6c7ffc7fa 100644 --- a/src/providers/TurnkeyAuthProvider.tsx +++ b/src/providers/TurnkeyAuthProvider.tsx @@ -71,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(); @@ -310,7 +315,7 @@ const useTurnkeyAuthContext = () => { await indexedDbClient?.loginWithSession(session); const derivedDydxAddress = await onboardDydx({ salt, - setWalletFromSignature, + setWalletFromTurnkeySignature, tkClient: indexedDbClient, }); @@ -330,7 +335,7 @@ const useTurnkeyAuthContext = () => { setEmailSignInStatus('success'); setEmailSignInError(undefined); }, - [onboardDydx, indexedDbClient, setWalletFromSignature, uploadAddress] + [onboardDydx, indexedDbClient, setWalletFromTurnkeySignature, uploadAddress] ); /* ----------------------------- Email Sign In ----------------------------- */ @@ -403,7 +408,7 @@ const useTurnkeyAuthContext = () => { await indexedDbClient.loginWithSession(session); const derivedDydxAddress = await onboardDydx({ - setWalletFromSignature, + setWalletFromTurnkeySignature, tkClient: indexedDbClient, }); @@ -477,7 +482,7 @@ const useTurnkeyAuthContext = () => { targetPublicKeys, turnkeyEmailOnboardingData, onboardDydx, - setWalletFromSignature, + setWalletFromTurnkeySignature, searchParams, setSearchParams, stringGetter, diff --git a/src/providers/TurnkeyWalletProvider.tsx b/src/providers/TurnkeyWalletProvider.tsx index d8d2e7e32f..a8f1aea20d 100644 --- a/src/providers/TurnkeyWalletProvider.tsx +++ b/src/providers/TurnkeyWalletProvider.tsx @@ -189,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)); @@ -229,7 +229,7 @@ const useTurnkeyWalletContext = () => { }); const signature = `${response.r}${response.s}${response.v}`; - const dydxAddress = await setWalletFromSignature(signature); + const dydxAddress = await setWalletFromTurnkeySignature(signature); return dydxAddress; }, [ From 72e215bc59fd82d67c646bd9d31a48da4b3d75f0 Mon Sep 17 00:00:00 2001 From: jaredvu Date: Wed, 26 Nov 2025 14:04:54 -0800 Subject: [PATCH 18/19] restore state version of localNobleWallet --- .../rest/lib/nobleTransactionStoreEffect.ts | 2 +- src/hooks/Onboarding/useGenerateKeys.ts | 3 +- src/hooks/useAccounts.tsx | 28 ++- src/hooks/useAnalytics.ts | 6 +- src/hooks/useDydxClient.tsx | 2 + src/lib/hdKeyManager.ts | 69 +----- src/lib/onboarding/OnboardingSupervisor.ts | 104 ++++++--- src/lib/onboarding/deriveCosmosWallets.ts | 10 +- src/lib/wallet/dydxPersistedWalletService.ts | 57 +++++ src/lib/wallet/dydxWalletService.ts | 215 ------------------ src/providers/TurnkeyAuthProvider.tsx | 4 +- 11 files changed, 172 insertions(+), 328 deletions(-) create mode 100644 src/lib/wallet/dydxPersistedWalletService.ts delete mode 100644 src/lib/wallet/dydxWalletService.ts diff --git a/src/bonsai/rest/lib/nobleTransactionStoreEffect.ts b/src/bonsai/rest/lib/nobleTransactionStoreEffect.ts index 7b827a32c5..a09070621c 100644 --- a/src/bonsai/rest/lib/nobleTransactionStoreEffect.ts +++ b/src/bonsai/rest/lib/nobleTransactionStoreEffect.ts @@ -64,7 +64,7 @@ const selectNobleTxAuthorizedAccount = createAppSelector( return undefined; } - const localNobleWallet = localWalletManager.getCachedLocalNobleWallet(localWalletNonce); + const localNobleWallet = localWalletManager.getLocalNobleWallet(localWalletNonce); const nobleAddress = convertBech32Address({ address: parentSubaccountInfo.wallet, diff --git a/src/hooks/Onboarding/useGenerateKeys.ts b/src/hooks/Onboarding/useGenerateKeys.ts index 2cf0fc6ae8..5bf645815b 100644 --- a/src/hooks/Onboarding/useGenerateKeys.ts +++ b/src/hooks/Onboarding/useGenerateKeys.ts @@ -77,7 +77,7 @@ export function useGenerateKeys(generateKeysProps?: GenerateKeysProps) { // 2. Derive keys from EVM account using OnboardingSupervisor const { getWalletFromSignature } = useDydxClient(); - const { getSubaccounts, setLocalDydxWallet, setHdKey } = useAccounts(); + const { getSubaccounts, setLocalDydxWallet, setLocalNobleWallet, setHdKey } = useAccounts(); const isDeriving = ![ EvmDerivedAccountStatus.NotDerived, @@ -138,6 +138,7 @@ export function useGenerateKeys(generateKeysProps?: GenerateKeysProps) { // Set wallet in useAccounts state setLocalDydxWallet(result.wallet); + setLocalNobleWallet(result.nobleWallet); setHdKey(result.hdKey); // Done - wallet is already persisted to SecureStorage by OnboardingSupervisor diff --git a/src/hooks/useAccounts.tsx b/src/hooks/useAccounts.tsx index 0199c6d232..f258c9677e 100644 --- a/src/hooks/useAccounts.tsx +++ b/src/hooks/useAccounts.tsx @@ -18,7 +18,7 @@ import { getSourceAccount } from '@/state/walletSelectors'; import { hdKeyManager, localWalletManager } from '@/lib/hdKeyManager'; import { onboardingManager } from '@/lib/onboarding/OnboardingSupervisor'; -import { dydxWalletService } from '@/lib/wallet/dydxWalletService'; +import { dydxPersistedWalletService } from '@/lib/wallet/dydxPersistedWalletService'; import { useCosmosWallets } from './useCosmosWallets'; import { useDydxClient } from './useDydxClient'; @@ -87,6 +87,7 @@ const useAccountsContext = () => { // dYdX wallet / onboarding state const [localDydxWallet, setLocalDydxWallet] = useState(); + const [localNobleWallet, setLocalNobleWallet] = useState(); const [hdKey, setHdKey] = useState(); const dydxAccounts = useMemo(() => localDydxWallet?.accounts, [localDydxWallet]); @@ -102,17 +103,20 @@ const useAccountsContext = () => { 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 dydxWalletService.deriveFromSignature(signature); + await dydxPersistedWalletService.secureStorePrivateKey(privateKey); setLocalDydxWallet(wallet); + setLocalNobleWallet(nobleWallet); setHdKey(key); return wallet.address; }, @@ -124,12 +128,12 @@ const useAccountsContext = () => { const cosmosWallets = useCosmosWallets(hdKey, getCosmosOfflineSigner); useEffect(() => { - if (localDydxWallet && hdKey) { - localWalletManager.setLocalWallet(localDydxWallet, hdKey); + if (localDydxWallet && localNobleWallet) { + localWalletManager.setLocalWallet(localDydxWallet, localNobleWallet); } else { localWalletManager.clearLocalWallet(); } - }, [localDydxWallet, hdKey]); + }, [localDydxWallet, localNobleWallet]); useEffect(() => { (async () => { @@ -153,6 +157,10 @@ const useAccountsContext = () => { setLocalDydxWallet(result.wallet); } + if (result.nobleWallet) { + setLocalNobleWallet(result.nobleWallet); + } + if (result.hdKey) { setHdKey(result.hdKey); } @@ -211,7 +219,7 @@ const useAccountsContext = () => { // Disconnect wallet / accounts const disconnectLocalDydxWallet = () => { // Clear persisted mnemonic from SecureStorage - dydxWalletService.clearStoredWallet(); + dydxPersistedWalletService.clearStoredWallet(); setLocalDydxWallet(undefined); setHdKey(undefined); @@ -247,9 +255,11 @@ const useAccountsContext = () => { // dYdX accounts hdKey, localDydxWallet, + localNobleWallet, dydxAccounts, dydxAddress, setLocalDydxWallet, + setLocalNobleWallet, setHdKey, // Cosmos wallets (on-demand) diff --git a/src/hooks/useAnalytics.ts b/src/hooks/useAnalytics.ts index 8c41ba2070..3d52ed44a0 100644 --- a/src/hooks/useAnalytics.ts +++ b/src/hooks/useAnalytics.ts @@ -20,7 +20,7 @@ import { getSelectedLocale } from '@/state/localizationSelectors'; import { getTradeFormValues } from '@/state/tradeFormSelectors'; import { identify, track } from '@/lib/analytics/analytics'; -import { dydxWalletService } from '@/lib/wallet/dydxWalletService'; +import { dydxPersistedWalletService } from '@/lib/wallet/dydxPersistedWalletService'; import { useAccounts } from './useAccounts'; import { useApiState } from './useApiState'; @@ -153,7 +153,9 @@ export const useAnalytics = () => { useEffect(() => { identify( - AnalyticsUserProperties.IsRememberMe(dydxAddress ? dydxWalletService.hasStoredWallet() : null) + AnalyticsUserProperties.IsRememberMe( + dydxAddress ? dydxPersistedWalletService.hasStoredWallet() : null + ) ); }, [dydxAddress]); diff --git a/src/hooks/useDydxClient.tsx b/src/hooks/useDydxClient.tsx index 4f18a492c4..1da4856621 100644 --- a/src/hooks/useDydxClient.tsx +++ b/src/hooks/useDydxClient.tsx @@ -15,6 +15,7 @@ import type { ResolutionString } from 'public/tradingview/charting_library'; import { RawSubaccountFill, RawSubaccountTransfer } from '@/constants/account'; import { RESOLUTION_MAP, RESOLUTION_TO_INTERVAL_MS, type Candle } from '@/constants/candles'; +import { getNobleChainId } from '@/constants/graz'; import { LocalStorageKey } from '@/constants/localStorage'; import { isDev } from '@/constants/networks'; @@ -86,6 +87,7 @@ const useDydxClientContext = () => { return { wallet: await (await getLazyLocalWallet()).fromMnemonic(mnemonic, BECH32_PREFIX), + nobleWallet: await (await getLazyLocalWallet()).fromMnemonic(mnemonic, getNobleChainId()), mnemonic, privateKey, publicKey, diff --git a/src/lib/hdKeyManager.ts b/src/lib/hdKeyManager.ts index d6458be791..5fab0a9269 100644 --- a/src/lib/hdKeyManager.ts +++ b/src/lib/hdKeyManager.ts @@ -6,10 +6,6 @@ import { Hdkey } from '@/constants/account'; import type { RootStore } from '@/state/_store'; import { setHdKeyNonce, setLocalWalletNonce } from '@/state/wallet'; -import { - deriveCosmosWallet, - deriveCosmosWalletFromPrivateKey, -} from './onboarding/deriveCosmosWallets'; import { log } from './telemetry'; class HDKeyManager { @@ -61,22 +57,16 @@ class LocalWalletManager { private localWallet: LocalWallet | undefined; - private hdKey: Hdkey | undefined; - - // Cache for derived Noble wallet - private localNobleWalletCache: LocalWallet | undefined; + private localNobleWallet: LocalWallet | undefined; setStore(store: RootStore) { this.store = store; } - setLocalWallet(localWallet: LocalWallet, hdKey: Hdkey) { + setLocalWallet(localWallet: LocalWallet, localNobleWallet: LocalWallet) { this.localWalletNonce = this.localWalletNonce != null ? this.localWalletNonce + 1 : 0; this.localWallet = localWallet; - this.hdKey = hdKey; - - // Clear Noble wallet cache when wallet changes - this.localNobleWalletCache = undefined; + this.localNobleWallet = localNobleWallet; if (!this.store) { log('LocalWalletManager: store has not been set'); @@ -94,65 +84,18 @@ class LocalWalletManager { return this.localWallet; } - /** - * Get Noble wallet - derives on-demand from hdKey - * Returns cached version if already derived for this nonce - */ - async getLocalNobleWallet(localWalletNonce: number): Promise { - if (localWalletNonce !== this.localWalletNonce) { - return undefined; - } - - // Return cached if available - if (this.localNobleWalletCache) { - return this.localNobleWalletCache; - } - - // Derive from hdKey if available - if (this.hdKey?.mnemonic) { - try { - this.localNobleWalletCache = - (await deriveCosmosWallet(this.hdKey.mnemonic, 'noble')) ?? undefined; - - return this.localNobleWalletCache; - } catch (error) { - log('LocalWalletManager: Failed to derive Noble wallet', error); - return undefined; - } - } else if (this.hdKey?.privateKey) { - try { - const privateKey = Buffer.from(this.hdKey.privateKey).toString('hex'); - this.localNobleWalletCache = - (await deriveCosmosWalletFromPrivateKey(privateKey, 'noble')) ?? undefined; - - return this.localNobleWalletCache; - } catch (error) { - log('LocalWalletManager: Failed to derive Noble wallet', error); - return undefined; - } - } - - return undefined; - } - - /** - * Get cached Noble wallet synchronously (for selectors) - * Returns cached version if available, otherwise undefined - * Does NOT trigger derivation - use getLocalNobleWallet() for that - */ - getCachedLocalNobleWallet(localWalletNonce: number): LocalWallet | undefined { + getLocalNobleWallet(localWalletNonce: number): LocalWallet | undefined { if (localWalletNonce !== this.localWalletNonce) { return undefined; } - return this.localNobleWalletCache; + return this.localNobleWallet; } clearLocalWallet() { this.localWalletNonce = undefined; this.localWallet = undefined; - this.hdKey = undefined; - this.localNobleWalletCache = undefined; + this.localNobleWallet = undefined; this.store?.dispatch(setLocalWalletNonce(undefined)); } } diff --git a/src/lib/onboarding/OnboardingSupervisor.ts b/src/lib/onboarding/OnboardingSupervisor.ts index 9f8765d9dd..370eaf54ab 100644 --- a/src/lib/onboarding/OnboardingSupervisor.ts +++ b/src/lib/onboarding/OnboardingSupervisor.ts @@ -1,8 +1,10 @@ import { getLazyLocalWallet } from '@/bonsai/lib/lazyDynamicLibs'; import { logBonsaiError } from '@/bonsai/logs'; import { 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, @@ -11,10 +13,11 @@ import { type WalletInfo, } from '@/constants/wallets'; +import { convertBech32Address } from '@/lib/addressUtils'; import { hdKeyManager } from '@/lib/hdKeyManager'; import { sleep } from '@/lib/timeUtils'; -import { dydxWalletService } from '../wallet/dydxWalletService'; +import { dydxPersistedWalletService } from '../wallet/dydxPersistedWalletService'; export interface SourceAccount { address?: string; @@ -31,12 +34,21 @@ export interface OnboardingContext { ready?: boolean; } -export interface WalletDerivationResult { - wallet?: LocalWallet; - hdKey?: PrivateInformation; - onboardingState: OnboardingState; - error?: string; -} +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 { /** @@ -47,12 +59,13 @@ class OnboardingSupervisor { signature: string, getWalletFromSignature: (params: { signature: string }) => Promise<{ wallet: LocalWallet; + nobleWallet: LocalWallet; mnemonic: string; privateKey: Uint8Array | null; publicKey: Uint8Array | null; }> - ): Promise<{ wallet: LocalWallet; hdKey: PrivateInformation }> { - const { wallet, mnemonic, privateKey, publicKey } = await getWalletFromSignature({ + ): Promise<{ wallet: LocalWallet; nobleWallet: LocalWallet; hdKey: PrivateInformation }> { + const { wallet, nobleWallet, mnemonic, privateKey, publicKey } = await getWalletFromSignature({ signature, }); @@ -67,9 +80,9 @@ class OnboardingSupervisor { }; hdKeyManager.setHdkey(wallet.address, hdKey); - await dydxWalletService.deriveFromSignature(signature); + await dydxPersistedWalletService.secureStorePrivateKey(privateKey); - return { wallet, hdKey }; + return { wallet, nobleWallet, hdKey }; } /** @@ -86,12 +99,19 @@ class OnboardingSupervisor { 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; hdKey: PrivateInformation; isNewUser: boolean } + | { + success: true; + wallet: LocalWallet; + nobleWallet: LocalWallet; + hdKey: PrivateInformation; + isNewUser: boolean; + } | { success: false; error: string; isDeterminismError?: boolean } > { const { signMessageAsync, getWalletFromSignature, checkPreviousTransactions } = params; @@ -99,9 +119,11 @@ class OnboardingSupervisor { try { // Step 1: Get first signature and derive wallet const firstSignature = await signMessageAsync(1); - const { wallet, mnemonic, privateKey, publicKey } = await getWalletFromSignature({ - signature: firstSignature, - }); + const { wallet, nobleWallet, mnemonic, privateKey, publicKey } = await getWalletFromSignature( + { + signature: firstSignature, + } + ); if (!privateKey || !publicKey || !wallet.address) { return { @@ -129,8 +151,8 @@ class OnboardingSupervisor { } } - // Step 4: Persist to SecureStorage (replaces old encrypted signature approach) - await dydxWalletService.deriveFromSignature(firstSignature); + // Step 4: Persist to SecureStorage + await dydxPersistedWalletService.secureStorePrivateKey(privateKey); // Step 5: Set up hdKey const hdKey: PrivateInformation = { @@ -144,6 +166,7 @@ class OnboardingSupervisor { return { success: true, wallet, + nobleWallet, hdKey, isNewUser: !hasPreviousTransactions, }; @@ -162,13 +185,14 @@ class OnboardingSupervisor { */ private async restoreFromSecureStorage(): Promise { try { - const storedPrivateKey = await dydxWalletService.exportPrivateKey(); + 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, getNobleChainId()); const hdKey: PrivateInformation = { mnemonic: '', @@ -178,6 +202,7 @@ class OnboardingSupervisor { return { wallet, + nobleWallet, hdKey, onboardingState: OnboardingState.AccountConnected, }; @@ -194,12 +219,15 @@ class OnboardingSupervisor { 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; + getCosmosOfflineSigner?: ( + chainId: string + ) => Promise<(OfflineAminoSigner & OfflineDirectSigner) | undefined>; selectedDydxChainId?: string; }): Promise { const { @@ -212,15 +240,10 @@ class OnboardingSupervisor { const { sourceAccount, hasLocalDydxWallet, blockedGeo, isConnectedGraz, authenticated, ready } = context; - console.log('handleWalletConnection', { - stored: dydxWalletService.hasStoredWallet(), - blockedGeo, - }); - try { // ------ Restore from SecureStorage ------ // // Check for persisted session before processing wallet connections - if (dydxWalletService.hasStoredWallet() && !blockedGeo) { + if (dydxPersistedWalletService.hasStoredWallet() && !blockedGeo) { const restored = await this.restoreFromSecureStorage(); if (restored) { @@ -288,7 +311,7 @@ class OnboardingSupervisor { /** * Handle Turnkey wallet flow * Turnkey is an embedded wallet managed entirely by TurnkeyAuthProvider - * Signing is done via Turnkey SDK, persistence via setWalletFromSignature() + * Signing is done via Turnkey SDK, persistence via setWalletFromTurnkeySignature() * OnboardingSupervisor only validates state */ private async handleTurnkeyFlow(params: { @@ -324,11 +347,22 @@ class OnboardingSupervisor { private async handleTestWalletFlow( sourceAccount: SourceAccount ): Promise { - const wallet = new (await getLazyLocalWallet())(); + 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: getNobleChainId(), + }); + return { wallet, + nobleWallet, // Test wallets don't have hdKey material onboardingState: OnboardingState.AccountConnected, }; @@ -338,7 +372,9 @@ class OnboardingSupervisor { * Handle Cosmos wallet flow */ private async handleCosmosFlow(params: { - getCosmosOfflineSigner?: (chainId: string) => Promise; + getCosmosOfflineSigner?: ( + chainId: string + ) => Promise<(OfflineAminoSigner & OfflineDirectSigner) | undefined>; selectedDydxChainId?: string; }): Promise { const { getCosmosOfflineSigner, selectedDydxChainId } = params; @@ -352,11 +388,17 @@ class OnboardingSupervisor { try { const dydxOfflineSigner = await getCosmosOfflineSigner(selectedDydxChainId); - if (dydxOfflineSigner) { + 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, }; @@ -413,18 +455,20 @@ class OnboardingSupervisor { // Give Privy time to finish auth flow await sleep(); const signature = await signMessageAsync!(); - const { wallet, hdKey } = await this.deriveWalletFromSignature( + 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', diff --git a/src/lib/onboarding/deriveCosmosWallets.ts b/src/lib/onboarding/deriveCosmosWallets.ts index 82cbbce16c..e1389b6f8c 100644 --- a/src/lib/onboarding/deriveCosmosWallets.ts +++ b/src/lib/onboarding/deriveCosmosWallets.ts @@ -1,8 +1,8 @@ import { getLazyLocalWallet } from '@/bonsai/lib/lazyDynamicLibs'; import { logBonsaiError } from '@/bonsai/logs'; -import { LocalWallet, NOBLE_BECH32_PREFIX } from '@dydxprotocol/v4-client-js'; +import { LocalWallet } from '@dydxprotocol/v4-client-js'; -import { NEUTRON_BECH32_PREFIX, OSMO_BECH32_PREFIX } from '@/constants/graz'; +import { getNeutronChainId, getNobleChainId, getOsmosisChainId } from '@/constants/graz'; export type SupportedCosmosChain = 'noble' | 'osmosis' | 'neutron'; @@ -73,11 +73,11 @@ export async function deriveCosmosWalletFromSigner( function getCosmosPrefix(chain: SupportedCosmosChain): string { switch (chain) { case 'noble': - return NOBLE_BECH32_PREFIX; + return getNobleChainId(); case 'osmosis': - return OSMO_BECH32_PREFIX; + return getOsmosisChainId(); case 'neutron': - return NEUTRON_BECH32_PREFIX; + return getNeutronChainId(); 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/dydxWalletService.ts b/src/lib/wallet/dydxWalletService.ts deleted file mode 100644 index 705e932dab..0000000000 --- a/src/lib/wallet/dydxWalletService.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { getLazyLocalWallet } from '@/bonsai/lib/lazyDynamicLibs'; -import { logBonsaiError, logBonsaiInfo } from '@/bonsai/logs'; -import { BECH32_PREFIX, onboarding, type LocalWallet } from '@dydxprotocol/v4-client-js'; - -import { OnboardingState } from '@/constants/account'; -import { DydxAddress, PrivateInformation } from '@/constants/wallets'; - -import { store } from '@/state/_store'; -import { setOnboardingState } from '@/state/account'; -import { setLocalWallet } from '@/state/wallet'; - -import { hdKeyManager } from '@/lib/hdKeyManager'; -import { log } from '@/lib/telemetry'; - -import { secureStorage } from './secureStorage'; - -const STORAGE_KEY = 'trading_wallet_key'; - -export interface WalletCreationResult { - success: boolean; - dydxAddress?: DydxAddress; - error?: string; -} - -export class DydxWalletService { - /** - * Import wallet from private key - * Direct private key import without mnemonic - * - * @param privateKey - Private key as hex string (with or without 0x prefix) - * @param persist - Whether to store for future sessions - * @returns Wallet creation result with dYdX address - */ - async importFromPrivateKey( - privateKey: string, - persist: boolean = true - ): Promise { - try { - const LocalWallet = await getLazyLocalWallet(); - const wallet = await LocalWallet.fromPrivateKey(privateKey); - - if (!wallet.address) { - return { - success: false, - error: 'Failed to create wallet from private key.', - }; - } - - // Store the private key directly - if (persist) { - await secureStorage.store(STORAGE_KEY, privateKey); - } - - // Update app state (no hdKey material for private key imports) - await this.setWalletInState(wallet, ''); // Empty string for mnemonic - - logBonsaiInfo('DydxWalletService', 'importFromPrivateKey', { - address: wallet.address, - persisted: persist, - }); - - return { - success: true, - dydxAddress: wallet.address as DydxAddress, - }; - } catch (error) { - logBonsaiError('DydxWalletService', 'Failed to importFromPrivateKey', { error }); - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', - }; - } - } - - /** - * @returns Wallet creation result or null if no stored private key - */ - async restoreFromStorage(): Promise { - try { - const storedPrivateKey = await secureStorage.retrieve(STORAGE_KEY); - - if (!storedPrivateKey) { - return null; - } - - // Re-import without re-storing (it's already stored) - const result = await this.importFromPrivateKey(storedPrivateKey, false); - - if (result.success) { - logBonsaiInfo('DydxWalletService', 'restoreFromStorage', { - address: result.dydxAddress, - }); - } - - return result; - } catch (error) { - log('DydxWalletService/restoreFromStorage/error', error); - // If restoration fails, clear corrupted data - this.clearStoredWallet(); - return null; - } - } - - /** - * Derive wallet from source wallet signature - * This is the existing flow for connected wallets - * - * @param signature - Signature from source wallet - * @param persist - Whether to persist the derived mnemonic for future sessions - * @returns Wallet creation result with dYdX address - */ - async deriveFromSignature( - signature: string, - persist: boolean = true - ): Promise { - try { - // Derive HD key from signature - const { mnemonic, privateKey, publicKey } = - onboarding.deriveHDKeyFromEthereumSignature(signature); - - // Create wallet from derived mnemonic - const LocalWallet = await getLazyLocalWallet(); - const wallet = await LocalWallet.fromMnemonic(mnemonic, BECH32_PREFIX); - - if (!wallet.address || !privateKey || !publicKey) { - return { - success: false, - error: 'Failed to derive wallet from signature.', - }; - } - - // Persist derived private key to secure storage using Web Crypto API - if (persist) { - await secureStorage.store(STORAGE_KEY, Buffer.from(privateKey).toString('hex')); - } - - // Update app state - await this.setWalletInState(wallet, mnemonic, privateKey, publicKey); - - logBonsaiInfo('DydxWalletService', 'deriveFromSignature', { - address: wallet.address, - persisted: persist, - }); - - return { - success: true, - dydxAddress: wallet.address as DydxAddress, - }; - } catch (error) { - logBonsaiError('DydxWalletService', 'Failed to deriveFromSignature', { error }); - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', - }; - } - } - - /** - * Update Redux state and managers with wallet information - * @private - */ - private async setWalletInState( - wallet: LocalWallet, - mnemonic: string, - privateKey?: Uint8Array | null, - publicKey?: Uint8Array | null - ): Promise { - if (privateKey && publicKey) { - const hdKey: PrivateInformation = { - mnemonic, - privateKey, - publicKey, - }; - - // Update HD key manager - hdKeyManager.setHdkey(wallet.address, hdKey); - } - - // Update Redux state - store.dispatch( - setLocalWallet({ - address: wallet.address as DydxAddress, - subaccountNumber: 0, - }) - ); - - store.dispatch(setOnboardingState(OnboardingState.AccountConnected)); - } - - hasStoredWallet(): boolean { - return secureStorage.has(STORAGE_KEY); - } - - /** - * Called on user sign out - */ - clearStoredWallet(): void { - secureStorage.remove(STORAGE_KEY); - logBonsaiInfo('DydxWalletService', 'clearStoredWallet'); - } - - /** - * @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 dydxWalletService = new DydxWalletService(); diff --git a/src/providers/TurnkeyAuthProvider.tsx b/src/providers/TurnkeyAuthProvider.tsx index d6c7ffc7fa..04ec11c15d 100644 --- a/src/providers/TurnkeyAuthProvider.tsx +++ b/src/providers/TurnkeyAuthProvider.tsx @@ -31,7 +31,7 @@ import { getSourceAccount, getTurnkeyEmailOnboardingData } from '@/state/walletS import { identify, track } from '@/lib/analytics/analytics'; import { parseTurnkeyError } from '@/lib/turnkey/turnkeyUtils'; -import { dydxWalletService } from '@/lib/wallet/dydxWalletService'; +import { dydxPersistedWalletService } from '@/lib/wallet/dydxPersistedWalletService'; import { useTurnkeyWallet } from './TurnkeyWalletProvider'; @@ -538,7 +538,7 @@ const useTurnkeyAuthContext = () => { */ useEffect(() => { const turnkeyOnboardingToken = searchParams.get('token'); - const hasStoredWallet = dydxWalletService.hasStoredWallet(); + const hasStoredWallet = dydxPersistedWalletService.hasStoredWallet(); if (turnkeyOnboardingToken && connectedDydxAddress != null) { searchParams.delete('token'); From c7d15b074caecf40d7e5e7efd69335e275070795 Mon Sep 17 00:00:00 2001 From: jaredvu Date: Wed, 26 Nov 2025 14:22:09 -0800 Subject: [PATCH 19/19] i mixed up chainId and bech32 prefix --- src/hooks/useDydxClient.tsx | 4 ++-- src/lib/onboarding/OnboardingSupervisor.ts | 6 +++--- src/lib/onboarding/deriveCosmosWallets.ts | 10 +++++----- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/hooks/useDydxClient.tsx b/src/hooks/useDydxClient.tsx index 1da4856621..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, @@ -15,7 +16,6 @@ import type { ResolutionString } from 'public/tradingview/charting_library'; import { RawSubaccountFill, RawSubaccountTransfer } from '@/constants/account'; import { RESOLUTION_MAP, RESOLUTION_TO_INTERVAL_MS, type Candle } from '@/constants/candles'; -import { getNobleChainId } from '@/constants/graz'; import { LocalStorageKey } from '@/constants/localStorage'; import { isDev } from '@/constants/networks'; @@ -87,7 +87,7 @@ const useDydxClientContext = () => { return { wallet: await (await getLazyLocalWallet()).fromMnemonic(mnemonic, BECH32_PREFIX), - nobleWallet: await (await getLazyLocalWallet()).fromMnemonic(mnemonic, getNobleChainId()), + nobleWallet: await (await getLazyLocalWallet()).fromMnemonic(mnemonic, NOBLE_BECH32_PREFIX), mnemonic, privateKey, publicKey, diff --git a/src/lib/onboarding/OnboardingSupervisor.ts b/src/lib/onboarding/OnboardingSupervisor.ts index 370eaf54ab..6a44c5a8ae 100644 --- a/src/lib/onboarding/OnboardingSupervisor.ts +++ b/src/lib/onboarding/OnboardingSupervisor.ts @@ -1,6 +1,6 @@ import { getLazyLocalWallet } from '@/bonsai/lib/lazyDynamicLibs'; import { logBonsaiError } from '@/bonsai/logs'; -import { BECH32_PREFIX, type LocalWallet } from '@dydxprotocol/v4-client-js'; +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'; @@ -192,7 +192,7 @@ class OnboardingSupervisor { const LocalWallet = await getLazyLocalWallet(); const wallet = await LocalWallet.fromPrivateKey(storedPrivateKey, BECH32_PREFIX); - const nobleWallet = await LocalWallet.fromPrivateKey(storedPrivateKey, getNobleChainId()); + const nobleWallet = await LocalWallet.fromPrivateKey(storedPrivateKey, NOBLE_BECH32_PREFIX); const hdKey: PrivateInformation = { mnemonic: '', @@ -357,7 +357,7 @@ class OnboardingSupervisor { const nobleWallet = new LocalWallet(); nobleWallet.address = convertBech32Address({ address: sourceAccount.address!, - bech32Prefix: getNobleChainId(), + bech32Prefix: NOBLE_BECH32_PREFIX, }); return { diff --git a/src/lib/onboarding/deriveCosmosWallets.ts b/src/lib/onboarding/deriveCosmosWallets.ts index e1389b6f8c..82cbbce16c 100644 --- a/src/lib/onboarding/deriveCosmosWallets.ts +++ b/src/lib/onboarding/deriveCosmosWallets.ts @@ -1,8 +1,8 @@ import { getLazyLocalWallet } from '@/bonsai/lib/lazyDynamicLibs'; import { logBonsaiError } from '@/bonsai/logs'; -import { LocalWallet } from '@dydxprotocol/v4-client-js'; +import { LocalWallet, NOBLE_BECH32_PREFIX } from '@dydxprotocol/v4-client-js'; -import { getNeutronChainId, getNobleChainId, getOsmosisChainId } from '@/constants/graz'; +import { NEUTRON_BECH32_PREFIX, OSMO_BECH32_PREFIX } from '@/constants/graz'; export type SupportedCosmosChain = 'noble' | 'osmosis' | 'neutron'; @@ -73,11 +73,11 @@ export async function deriveCosmosWalletFromSigner( function getCosmosPrefix(chain: SupportedCosmosChain): string { switch (chain) { case 'noble': - return getNobleChainId(); + return NOBLE_BECH32_PREFIX; case 'osmosis': - return getOsmosisChainId(); + return OSMO_BECH32_PREFIX; case 'neutron': - return getNeutronChainId(); + return NEUTRON_BECH32_PREFIX; default: throw new Error(`Unknown Cosmos chain: ${chain}`); }