diff --git a/package.json b/package.json index 7e66eb938c..0932fd64f1 100644 --- a/package.json +++ b/package.json @@ -116,6 +116,7 @@ "@visx/tooltip": "^3.1.2", "@visx/xychart": "^3.1.2", "@wagmi/core": "^2.16.3", + "@yudiel/react-qr-scanner": "^2.5.0", "bignumber.js": "^9.1.1", "bs58": "^6.0.0", "cmdk": "^0.2.0", @@ -126,6 +127,7 @@ "fast-json-stable-stringify": "^2.1.0", "graz": "^0.1.19", "immer": "^10.1.1", + "input-otp": "^1.4.2", "jsdom": "^24.1.0", "jwt-decode": "^4.0.0", "lodash": "^4.17.21", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 185785a504..0d438840d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -215,6 +215,9 @@ dependencies: '@wagmi/core': specifier: ^2.16.3 version: 2.21.2(@types/react@18.3.26)(immer@10.1.3)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0)(viem@2.38.0) + '@yudiel/react-qr-scanner': + specifier: ^2.5.0 + version: 2.5.0(@types/emscripten@1.41.5)(react-dom@18.3.1)(react@18.3.1) bignumber.js: specifier: ^9.1.1 version: 9.3.1 @@ -245,6 +248,9 @@ dependencies: immer: specifier: ^10.1.1 version: 10.1.3 + input-otp: + specifier: ^1.4.2 + version: 1.4.2(react-dom@18.3.1)(react@18.3.1) jsdom: specifier: ^24.1.0 version: 24.1.3 @@ -10089,6 +10095,10 @@ packages: '@types/bn.js': 5.2.0 dev: false + /@types/emscripten@1.41.5: + resolution: {integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==} + dev: false + /@types/eslint-scope@3.7.7: resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} dependencies: @@ -13004,6 +13014,20 @@ packages: resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} dev: true + /@yudiel/react-qr-scanner@2.5.0(@types/emscripten@1.41.5)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-wVWvm0z5kGGc3tiMcxyr2aji3C6aU5K3Q7R08MOdyFDFkvMbFMF1Hz5P5WFYW+zuuqxDPBcRZPCvmVs6jBheTQ==} + peerDependencies: + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + dependencies: + barcode-detector: 3.0.8(@types/emscripten@1.41.5) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + webrtc-adapter: 9.0.3 + transitivePeerDependencies: + - '@types/emscripten' + dev: false + /@zip.js/zip.js@2.8.7: resolution: {integrity: sha512-8daf29EMM3gUpH/vSBSCYo2bY/wbamgRPxPpE2b+cDnbOLBHAcZikWad79R4Guemth/qtipzEHrZMq1lFXxWIA==} engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=18.0.0'} @@ -13607,6 +13631,14 @@ packages: /balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + /barcode-detector@3.0.8(@types/emscripten@1.41.5): + resolution: {integrity: sha512-Z9jzzE8ngEDyN9EU7lWdGgV07mcnEQnrX8W9WecXDqD2v+5CcVjt9+a134a5zb+kICvpsrDx6NYA6ay4LGFs8A==} + dependencies: + zxing-wasm: 2.2.4(@types/emscripten@1.41.5) + transitivePeerDependencies: + - '@types/emscripten' + dev: false + /bare-events@2.7.0: resolution: {integrity: sha512-b3N5eTW1g7vXkw+0CXh/HazGTcO5KYuu/RCNaJbDMPI6LHDi+7qe8EmxKUVe1sUbY2KZOVZFyj62x0OEz9qyAA==} requiresBuild: true @@ -18169,6 +18201,16 @@ packages: resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==} dev: true + /input-otp@1.4.2(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + /inquirer@8.2.7(@types/node@22.18.8): resolution: {integrity: sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==} engines: {node: '>=12.0.0'} @@ -23266,6 +23308,10 @@ packages: resolution: {integrity: sha512-cdwTTnqPu0Hyvf5in5asVdZocVDTNRmR7XEcJuIzMjJeSHybHl7vpB66AzwTaIg6CLSbtjcxc8fqcySfnTkccA==} dev: false + /sdp@3.2.1: + resolution: {integrity: sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==} + dev: false + /secp256k1@4.0.4: resolution: {integrity: sha512-6JfvwvjUOn8F/jUoBY2Q1v5WY5XS+rj8qSe0v8Y4ezH4InLgTEeOOPQsRll9OV429Pvo6BCHGavIyJfr3TAhsw==} engines: {node: '>=18.0.0'} @@ -24069,6 +24115,11 @@ packages: resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} dev: false + /tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + dev: false + /tailwindcss@3.4.18(tsx@4.20.6): resolution: {integrity: sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==} engines: {node: '>=14.0.0'} @@ -24609,6 +24660,13 @@ packages: engines: {node: '>=16'} dev: true + /type-fest@5.4.1: + resolution: {integrity: sha512-xygQcmneDyzsEuKZrFbRMne5HDqMs++aFzefrJTgEIKjQ3rekM+RPfFCVq2Gp1VIDqddoYeppCj4Pcb+RZW0GQ==} + engines: {node: '>=20'} + dependencies: + tagged-tag: 1.0.0 + dev: false + /type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -26169,6 +26227,13 @@ packages: - uglify-js dev: true + /webrtc-adapter@9.0.3: + resolution: {integrity: sha512-5fALBcroIl31OeXAdd1YUntxiZl1eHlZZWzNg3U4Fn+J9/cGL3eT80YlrsWGvj2ojuz1rZr2OXkgCzIxAZ7vRQ==} + engines: {node: '>=6.0.0', npm: '>=3.10.0'} + dependencies: + sdp: 3.2.1 + dev: false + /websocket@1.0.35: resolution: {integrity: sha512-/REy6amwPZl44DDzvRCkaI1q1bIiQB0mEFQLUrhz3z2EK91cp3n72rAjUlrTP0zV22HJIUOVHQGPxhFRjxjt+Q==} engines: {node: '>=4.0.0'} @@ -26735,3 +26800,12 @@ packages: /zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} dev: true + + /zxing-wasm@2.2.4(@types/emscripten@1.41.5): + resolution: {integrity: sha512-1gq5zs4wuNTs5umWLypzNNeuJoluFvwmvjiiT3L9z/TMlVveeJRWy7h90xyUqCe+Qq0zL0w7o5zkdDMWDr9aZA==} + peerDependencies: + '@types/emscripten': '>=1.39.6' + dependencies: + '@types/emscripten': 1.41.5 + type-fest: 5.4.1 + dev: false diff --git a/src/bonsai/lifecycles/usdcRebalanceLifecycle.ts b/src/bonsai/lifecycles/usdcRebalanceLifecycle.ts index cff7682d2f..ccc647a41e 100644 --- a/src/bonsai/lifecycles/usdcRebalanceLifecycle.ts +++ b/src/bonsai/lifecycles/usdcRebalanceLifecycle.ts @@ -64,7 +64,11 @@ export function setUpUsdcRebalanceLifecycle(store: RootStore) { const { localDydxWallet, parentSubaccountInfo, sourceAccount, rebalanceAction } = data!; // context: Cosmos wallets do not support our lifecycle methods and are instead handled within useNotificationTypes - if (rebalanceAction == null || sourceAccount.chain === WalletNetworkType.Cosmos) { + if ( + rebalanceAction == null || + sourceAccount.chain === WalletNetworkType.Cosmos || + sourceAccount.chain == null + ) { return; } diff --git a/src/bonsai/rest/lib/nobleTransactionStoreEffect.ts b/src/bonsai/rest/lib/nobleTransactionStoreEffect.ts index 05623752b9..1fb0ec969f 100644 --- a/src/bonsai/rest/lib/nobleTransactionStoreEffect.ts +++ b/src/bonsai/rest/lib/nobleTransactionStoreEffect.ts @@ -65,13 +65,15 @@ const selectNobleTxAuthorizedAccount = createAppSelector( } const localNobleWallet = localWalletManager.getLocalNobleWallet(localWalletNonce); + const nobleAddress = convertBech32Address({ address: parentSubaccountInfo.wallet, bech32Prefix: NOBLE_BECH32_PREFIX, }); + const isCorrectWallet = localNobleWallet?.address === nobleAddress; - if (!isCorrectWallet || localNobleWallet == null) return undefined; + if (!isCorrectWallet) return undefined; return { localNobleWallet, diff --git a/src/components/InputOtp.tsx b/src/components/InputOtp.tsx new file mode 100644 index 0000000000..7be793ae85 --- /dev/null +++ b/src/components/InputOtp.tsx @@ -0,0 +1,126 @@ +import * as React from 'react'; + +import { OTPInput, OTPInputContext, REGEXP_ONLY_DIGITS } from 'input-otp'; +import styled, { createGlobalStyle } from 'styled-components'; + +const InputOTP = ({ + className, + ...props +}: React.ComponentProps & { + containerClassName?: string; +}) => { + return ( + + + + + ); +}; + +const InputOTPGroup = ({ className, ...props }: React.ComponentProps<'div'>) => { + return ( +
+ ); +}; + +const InputOTPSlot = ({ + index, + className, + ...props +}: React.ComponentProps<'div'> & { + index: number; +}) => { + const inputOTPContext = React.useContext(OTPInputContext); + const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}; + return ( + <$InputOTPSlot + data-slot="input-otp-slot" + data-active={isActive} + className={className} + {...props} + > + {char} + {hasFakeCaret && ( +
+
+
+ )} + + ); +}; + +const InputOTPSeparator = ({ ...props }: React.ComponentProps<'div'>) => { + return ( +
+ - +
+ ); +}; + +/** + * This is necessary to access containerClassName. + * Our twin.macro tailwind classes do not work in this case so we fall back on good ole CSS. + */ +const InputOtpContainerStyle = createGlobalStyle` + .input-otp-container { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; + margin-right: auto; + + &:has(input:disabled) { + opacity: 0.5; + } + } +`; + +const $InputOTPSlot = styled.div` + position: relative; + display: flex; + height: 3rem; + width: 2.625rem; + align-items: center; + justify-content: center; + font-size: 0.875rem; + line-height: 1.25rem; + border: var(--default-border-width) solid var(--input-otp-slot-border-color, var(--color-border)); + outline: none; + transition: all 0.15s ease-in-out; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + + &:first-child { + border-left: 1px solid var(--input-otp-slot-border-color, var(--color-border)); + border-top-left-radius: var(--input-otp-slot-border-radius, 0.75rem); + border-bottom-left-radius: var(--input-otp-slot-border-radius, 0.75rem); + } + + &:last-child { + border-top-right-radius: var(--input-otp-slot-border-radius, 0.75rem); + border-bottom-right-radius: var(--input-otp-slot-border-radius, 0.75rem); + } + + &[data-active='true'] { + border-color: var(--color-accent); + box-shadow: 0 0 0 3px rgba(var(--color-accent-rgb), 0.1); + z-index: 10; + } + + &[aria-invalid='true'] { + border-color: var(--color-error); + } + + &[data-active='true'][aria-invalid='true'] { + border-color: var(--color-error); + box-shadow: 0 0 0 3px var(--color-error); + } +`; + +export { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot }; diff --git a/src/constants/account.ts b/src/constants/account.ts index 73cfa2d64e..4cd4070f17 100644 --- a/src/constants/account.ts +++ b/src/constants/account.ts @@ -1,9 +1,5 @@ -import type { DydxAddress, EvmAddress } from './wallets'; -import { SolAddress } from './wallets'; - export enum OnboardingSteps { SignIn = 'SignIn', - ChooseWallet = 'ChooseWallet', KeyDerivation = 'KeyDerivation', } @@ -48,24 +44,6 @@ export enum SpotWalletStatus { Connected = 'Connected', } -export type EvmDerivedAddresses = { - version?: string; - [EvmAddress: EvmAddress]: { - encryptedSignature?: string; - dydxAddress?: DydxAddress; - }; -}; - -export type SolDerivedAddresses = { - version?: string; -} & Record< - SolAddress, - { - encryptedSignature?: string; - dydxAddress?: DydxAddress; - } ->; - export type Hdkey = { mnemonic: string; privateKey: Uint8Array | null; diff --git a/src/constants/localStorage.ts b/src/constants/localStorage.ts index 2d7f4efdf9..356360c43c 100644 --- a/src/constants/localStorage.ts +++ b/src/constants/localStorage.ts @@ -5,9 +5,10 @@ export enum LocalStorageKey { DydxAddress = 'dydx.DydxAddress', OnboardingSelectedWallet = 'dydx.OnboardingSelectedWallet', OnboardingHasAcknowledgedTerms = 'dydx.OnboardingHasAcknowledgedTerms', - EvmDerivedAddresses = 'dydx.EvmDerivedAddresses', // Deprecated KeplrCompliance = 'dydx.KeplrCompliance', - SolDerivedAddresses = 'dydx.SolDerivedAddresses', + + EvmDerivedAddresses = 'dydx.EvmDerivedAddresses', // Deprecated + SolDerivedAddresses = 'dydx.SolDerivedAddresses', // Deprecated // Gas SelectedGasDenom = 'dydx.SelectedGasDenom', diff --git a/src/constants/wallets.ts b/src/constants/wallets.ts index 68564fcd94..c8e3516ebe 100644 --- a/src/constants/wallets.ts +++ b/src/constants/wallets.ts @@ -78,12 +78,14 @@ export enum ConnectorType { Privy = 'privy', PhantomSolana = 'phantomSolana', Turnkey = 'turnkey', + Import = 'import', } export enum WalletNetworkType { Evm = 'evm', Cosmos = 'cosmos', Solana = 'solana', + Dydx = 'dydx', } // This is the type stored in localstorage, so it must consist of only serializable fields @@ -112,6 +114,7 @@ export type WalletInfo = name: CosmosWalletType; } | { connectorType: ConnectorType.Test; name: WalletType.TestWallet } + | { connectorType: ConnectorType.Import; name: 'Import' } | { connectorType: ConnectorType.DownloadWallet; name: string; downloadLink: string }; type WalletConfig = { diff --git a/src/hooks/Onboarding/useAutoconnectMobileWalletBrowser.ts b/src/hooks/Onboarding/useAutoconnectMobileWalletBrowser.ts index 7594565c16..e1a4bba45a 100644 --- a/src/hooks/Onboarding/useAutoconnectMobileWalletBrowser.ts +++ b/src/hooks/Onboarding/useAutoconnectMobileWalletBrowser.ts @@ -17,7 +17,6 @@ import { calculateOnboardingStep } from '@/state/accountCalculators'; import { sleep } from '@/lib/timeUtils'; import { useAccounts } from '../useAccounts'; -import { useEnableTurnkey } from '../useEnableTurnkey'; import { useAppSelectorWithArgs } from '../useParameterizedSelector'; import { useWalletConnection } from '../useWalletConnection'; import { useGenerateKeys } from './useGenerateKeys'; @@ -25,8 +24,7 @@ import { useGenerateKeys } from './useGenerateKeys'; export function useAutoconnectMobileWalletBrowser() { const { detectedBrowser } = useDetectedWalletBrowser(); const displayedWallets = useDisplayedWallets(); - const isTurnkeyEnabled = useEnableTurnkey(); - const currentOnboardingStep = useAppSelectorWithArgs(calculateOnboardingStep, isTurnkeyEnabled); + const currentOnboardingStep = useAppSelectorWithArgs(calculateOnboardingStep); const isSimpleUi = useSimpleUiEnabled(); const { hasAttemptedMobileWalletConnect, selectWallet, setHasAttemptedMobileWalletConnect } = useWalletConnection(); @@ -84,7 +82,7 @@ export function useAutoconnectMobileWalletBrowser() { isSimpleUi && isUsingWalletBrowser && hasValidWallet && - currentOnboardingStep === OnboardingSteps.ChooseWallet + currentOnboardingStep === OnboardingSteps.SignIn ); }, [isSimpleUi, isUsingWalletBrowser, displayedWallets, currentOnboardingStep, walletToConnect]); diff --git a/src/hooks/Onboarding/useGenerateKeys.ts b/src/hooks/Onboarding/useGenerateKeys.ts index 446fb0957d..47ffe7cae8 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,10 @@ 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, handleWalletConnectionResult } = useAccounts(); const isDeriving = ![ EvmDerivedAccountStatus.NotDerived, @@ -90,74 +87,58 @@ export function useGenerateKeys(generateKeysProps?: GenerateKeysProps) { const signMessageAsync = useSignForWalletDerivation(sourceAccount.walletInfo); - const staticEncryptionKey = import.meta.env.VITE_PK_ENCRYPTION_KEY; - const deriveKeys = async () => { setError(undefined); try { - // 1. First signature setDerivationStatus(EvmDerivedAccountStatus.Deriving); - const signature = await signMessageAsync(); - track( - AnalyticsEvents.OnboardingDeriveKeysSignatureReceived({ - signatureNumber: 1, - }) - ); - const { wallet: dydxWallet } = await getWalletFromSignature({ signature }); + // Track first signature request + const wrappedSignMessage = async (requestNumber: 1 | 2) => { + if (requestNumber === 2) { + setDerivationStatus(EvmDerivedAccountStatus.EnsuringDeterminism); + } + + const sig = await signMessageAsync(); - // 2. Ensure signature is deterministic - // Check if subaccounts exist - const dydxAddress = dydxWallet.address as DydxAddress; - let hasPreviousTransactions = false; + track( + AnalyticsEvents.OnboardingDeriveKeysSignatureReceived({ signatureNumber: requestNumber }) + ); - try { + return sig; + }; + + // Check for previous transactions + const checkPreviousTransactions = async (dydxAddress: DydxAddress) => { const subaccounts = await getSubaccounts({ dydxAddress }); - hasPreviousTransactions = subaccounts.length > 0; + const hasPreviousTransactions = subaccounts.length > 0; track(AnalyticsEvents.OnboardingAccountDerived({ hasPreviousTransactions })); + identify(AnalyticsUserProperties.IsNewUser(!hasPreviousTransactions)); - if (!hasPreviousTransactions) { - identify(AnalyticsUserProperties.IsNewUser(true)); - setDerivationStatus(EvmDerivedAccountStatus.EnsuringDeterminism); + return hasPreviousTransactions; + }; - // Second signature - const additionalSignature = await signMessageAsync(); - track( - AnalyticsEvents.OnboardingDeriveKeysSignatureReceived({ - signatureNumber: 2, - }) - ); - - if (signature !== additionalSignature) { - throw new Error( - 'Your wallet does not support deterministic signing. Please switch to a different wallet provider.' - ); - } - } else { - identify(AnalyticsUserProperties.IsNewUser(false)); - } - } catch (err) { + // Derive with determinism check + const result = await onboardingManager.deriveKeysWithDeterminismCheck({ + signMessageAsync: wrappedSignMessage, + getWalletFromSignature, + checkPreviousTransactions, + handleWalletConnectionResult, + }); + + if (!result.success) { setDerivationStatus(EvmDerivedAccountStatus.NotDerived); - const { message } = parseWalletError({ error: err, stringGetter }); - if (message) { + if (result.isDeterminismError) { track(AnalyticsEvents.OnboardingWalletIsNonDeterministic()); - setError(message); } - return; - } - - await setWalletFromSignature(signature); - // 3: Remember me (encrypt and store signature) - if (staticEncryptionKey) { - const encryptedSignature = AES.encrypt(signature, staticEncryptionKey).toString(); - dispatch(setSavedEncryptedSignature(encryptedSignature)); + setError(result.error); + return; } - // 4. Done + // Done - wallet is already persisted to SecureStorage by OnboardingSupervisor setDerivationStatus(EvmDerivedAccountStatus.Derived); } catch (err) { setDerivationStatus(EvmDerivedAccountStatus.NotDerived); diff --git a/src/hooks/useAccounts.tsx b/src/hooks/useAccounts.tsx index 96c17a6c3f..c1d2deee19 100644 --- a/src/hooks/useAccounts.tsx +++ b/src/hooks/useAccounts.tsx @@ -1,20 +1,10 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; -import { getLazyLocalWallet } from '@/bonsai/lib/lazyDynamicLibs'; -import { BonsaiCore } from '@/bonsai/ontology'; -import { type LocalWallet, NOBLE_BECH32_PREFIX, type Subaccount } from '@dydxprotocol/v4-client-js'; +import { type LocalWallet, type Subaccount } from '@dydxprotocol/v4-client-js'; import { usePrivy } from '@privy-io/react-auth'; import { Keypair } from '@solana/web3.js'; -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, @@ -28,14 +18,15 @@ import { useTurnkeyWallet } from '@/providers/TurnkeyWalletProvider'; import { setOnboardingGuard, setOnboardingState } from '@/state/account'; 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 { onboardingManager, WalletDerivationResult } from '@/lib/onboarding/OnboardingSupervisor'; import { deriveSolanaKeypairFromMnemonic } from '@/lib/solanaWallet'; -import { log } from '@/lib/telemetry'; -import { sleep } from '@/lib/timeUtils'; +import { dydxPersistedWalletService } from '@/lib/wallet/dydxPersistedWalletService'; +import { useCosmosWallets } from './useCosmosWallets'; import { useDydxClient } from './useDydxClient'; import { useLocalStorage } from './useLocalStorage'; import useSignForWalletDerivation from './useSignForWalletDerivation'; @@ -68,41 +59,10 @@ const useAccountsContext = () => { dydxAccountGraz, } = useWalletConnection(); - const hasSubAccount = useAppSelector(BonsaiCore.account.parentSubaccountSummary.data) != null; const sourceAccount = useAppSelector(getSourceAccount); const { ready, authenticated } = usePrivy(); - const [previousAddress, setPreviousAddress] = useState(sourceAccount.address); - - useEffect(() => { - const { address } = sourceAccount; - // wallet accounts switched - if (previousAddress && address !== previousAddress) { - // Disconnect local wallet - disconnectLocalDydxWallet(); - } - - setPreviousAddress(address); - // We only want to set the source wallet address if the address changes - // OR when our connection state changes. - // The address can be cached via local storage, so it won't change when we reconnect - // But the hasSubAccount value will become true once you reconnect - // This allows us to trigger a state update - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sourceAccount.address, sourceAccount.chain, hasSubAccount]); - - const decryptSignature = (encryptedSignature: string | undefined) => { - const staticEncryptionKey = import.meta.env.VITE_PK_ENCRYPTION_KEY; - - if (!staticEncryptionKey) throw new Error('No decryption key found'); - if (!encryptedSignature) throw new Error('No signature found'); - - const decrypted = AES.decrypt(encryptedSignature, staticEncryptionKey); - const signature = decrypted.toString(enc.Utf8); - return signature; - }; - // dYdXClient Onboarding & Account Helpers const { indexerClient, getWalletFromSignature } = useDydxClient(); // dYdX subaccounts @@ -127,10 +87,7 @@ const useAccountsContext = () => { // dYdX wallet / onboarding state const [localDydxWallet, setLocalDydxWallet] = useState(); const [localNobleWallet, setLocalNobleWallet] = useState(); - const [localOsmosisWallet, setLocalOsmosisWallet] = useState(); - const [localNeutronWallet, setLocalNeutronWallet] = useState(); const [localSolanaKeypair, setLocalSolanaKeypair] = useState(); - const [hdKey, setHdKey] = useState(); const dydxAccounts = useMemo(() => localDydxWallet?.accounts, [localDydxWallet]); @@ -149,22 +106,38 @@ const useAccountsContext = () => { [localSolanaKeypair] ); + const nobleAddress = useMemo( + () => localNobleWallet?.address as string | undefined, + [localNobleWallet] + ); + useEffect(() => { dispatch(setLocalWallet({ address: dydxAddress, solanaAddress, subaccountNumber: 0 })); }, [dispatch, dydxAddress, solanaAddress]); - const nobleAddress = localNobleWallet?.address; - const osmosisAddress = localOsmosisWallet?.address; - const neutronAddress = localNeutronWallet?.address; - - const setWalletFromSignature = useCallback( + const setWalletFromTurnkeySignature = useCallback( async (signature: string) => { - const { wallet, mnemonic, privateKey, publicKey } = await getWalletFromSignature({ - signature, - }); + const { wallet, nobleWallet, mnemonic, privateKey, publicKey } = await getWalletFromSignature( + { + signature, + } + ); + const key = { mnemonic, privateKey, publicKey }; hdKeyManager.setHdkey(wallet.address, key); + + // Persist to SecureStorage for session restoration + await dydxPersistedWalletService.secureStorePhrase(mnemonic); + + if (!mnemonic) { + throw new Error('Could not derive keys from Turnkey signature'); + } + + const solanaKeypair = deriveSolanaKeypairFromMnemonic(mnemonic); + setLocalDydxWallet(wallet); + setLocalNobleWallet(nobleWallet); + setLocalSolanaKeypair(solanaKeypair); setHdKey(key); return wallet.address; }, @@ -172,8 +145,8 @@ const useAccountsContext = () => { ); const signMessageAsync = useSignForWalletDerivation(sourceAccount.walletInfo); - const hasLocalDydxWallet = Boolean(localDydxWallet); + const cosmosWallets = useCosmosWallets(hdKey, getCosmosOfflineSigner); useEffect(() => { if (localDydxWallet && localNobleWallet && localSolanaKeypair) { @@ -183,165 +156,45 @@ const useAccountsContext = () => { } }, [localDydxWallet, localNobleWallet, localSolanaKeypair]); + /** + * Reconnect Side Effect - This is used to handle the reconnection flow when the user returns to the app. + */ 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) { - 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)); - } + if ( + sourceAccount.walletInfo?.connectorType === ConnectorType.Import && + !dydxPersistedWalletService.hasStoredWallet() + ) { return; } - /** - * 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) { - 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) { - try { - const signature = decryptSignature(sourceAccount.encryptedSignature); - await setWalletFromSignature(signature); - dispatch(setOnboardingState(OnboardingState.AccountConnected)); - } catch (error) { - log('useAccounts/decryptSignature', error); - dispatch(clearSavedEncryptedSignature()); - } - } - } else { - dispatch(setOnboardingState(OnboardingState.AccountConnected)); - } - } else { - disconnectLocalDydxWallet(); - dispatch(setOnboardingState(OnboardingState.Disconnected)); - } - })(); - }, [signerWagmi, isConnectedGraz, sourceAccount, hasLocalDydxWallet]); - - useEffect(() => { - const setCosmosWallets = async () => { - let nobleWallet: LocalWallet | undefined; - let osmosisWallet: LocalWallet | undefined; - let neutronWallet: LocalWallet | undefined; - let solanaKeypair: Keypair | 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); - solanaKeypair = deriveSolanaKeypairFromMnemonic(hdKey.mnemonic); - } - - 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); - } + const result = await onboardingManager.handleWalletConnection({ + context: { + sourceAccount, + hasLocalDydxWallet, + isConnectedGraz, + authenticated, + ready, + }, + getWalletFromSignature, + signMessageAsync, + getCosmosOfflineSigner, + selectedDydxChainId, + }); - if (nobleWallet !== undefined) { - setLocalNobleWallet(nobleWallet); - } - if (osmosisWallet !== undefined) { - setLocalOsmosisWallet(osmosisWallet); - } - if (neutronWallet !== undefined) { - setLocalNeutronWallet(neutronWallet); - } - if (solanaKeypair !== undefined) { - setLocalSolanaKeypair(solanaKeypair); - } - } catch (error) { - log('useAccounts/setCosmosWallets', error); - } - }; - setCosmosWallets(); - }, [hdKey?.mnemonic, getCosmosOfflineSigner]); + // Handle the result + handleWalletConnectionResult(result); + })(); + // we don't want to re-run on `authenticated` or `ready` because this is for the Reconnection Flow + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + dispatch, + signMessageAsync, + selectedDydxChainId, + signerWagmi, + isConnectedGraz, + sourceAccount, + ]); // clear subaccounts when no dydxAddress is set useEffect(() => { @@ -379,31 +232,83 @@ const useAccountsContext = () => { }, [dispatch, dydxSubaccounts]); // Disconnect wallet / accounts - const disconnectLocalDydxWallet = () => { + const disconnectLocalWallets = useCallback(() => { + // Clear persisted mnemonic from SecureStorage + dydxPersistedWalletService.clearStoredWallet(); + setLocalDydxWallet(undefined); setLocalNobleWallet(undefined); - setLocalOsmosisWallet(undefined); - setLocalNeutronWallet(undefined); setLocalSolanaKeypair(undefined); setHdKey(undefined); hdKeyManager.clearHdkey(); - }; + }, []); - const disconnect = async () => { + const disconnect = useCallback(async () => { // Turnkey Signout if (sourceAccount.walletInfo?.connectorType === ConnectorType.Turnkey) { await endTurnkeySession(); } - // Disconnect local wallet - disconnectLocalDydxWallet(); + // Disconnect local wallets + disconnectLocalWallets(); selectWallet(undefined); - }; + }, [ + disconnectLocalWallets, + selectWallet, + endTurnkeySession, + sourceAccount.walletInfo?.connectorType, + ]); + + const handleWalletConnectionResult = useCallback( + (result: WalletDerivationResult) => { + if (result.wallet) { + setLocalDydxWallet(result.wallet); + } + + if (result.nobleWallet) { + setLocalNobleWallet(result.nobleWallet); + } + + if (result.solanaKeypair) { + setLocalSolanaKeypair(result.solanaKeypair); + } + + if (result.hdKey) { + setHdKey(result.hdKey); + } + + // Dispatch onboarding state + dispatch(setOnboardingState(result.onboardingState)); + + // Handle disconnected state + if (result.onboardingState === OnboardingState.Disconnected && !result.wallet) { + disconnectLocalWallets(); + } + }, + [dispatch, disconnectLocalWallets] + ); + + // Import wallet from phrase + const importWallet = useCallback( + async (mnemonic: string): Promise<{ success: boolean; error?: string }> => { + selectWallet({ + connectorType: ConnectorType.Import, + name: 'Import', + }); + + const result = await onboardingManager.handleWalletImport({ + mnemonic, + handleWalletConnectionResult, + }); + + return result; + }, + [handleWalletConnectionResult, selectWallet] + ); return { // Wallet connection sourceAccount, - localNobleWallet, // Wallet selection selectWallet, @@ -414,17 +319,22 @@ const useAccountsContext = () => { signerWagmi, publicClientWagmi, - setWalletFromSignature, + setWalletFromTurnkeySignature, + importWallet, // dYdX accounts hdKey, localDydxWallet, dydxAccounts, dydxAddress, + setHdKey, + // Noble accounts + localNobleWallet, nobleAddress, - osmosisAddress, - neutronAddress, + + // Cosmos wallets (on-demand) + ...cosmosWallets, // Solana spot accounts solanaAddress, @@ -433,6 +343,7 @@ const useAccountsContext = () => { // Onboarding state saveHasAcknowledgedTerms, + handleWalletConnectionResult, // Disconnect wallet / accounts disconnect, diff --git a/src/hooks/useAnalytics.ts b/src/hooks/useAnalytics.ts index 132ccb358a..9e5105c527 100644 --- a/src/hooks/useAnalytics.ts +++ b/src/hooks/useAnalytics.ts @@ -20,12 +20,12 @@ import { getSelectedLocale } from '@/state/localizationSelectors'; import { getTradeFormValues } from '@/state/tradeFormSelectors'; import { identify, track } from '@/lib/analytics/analytics'; +import { dydxPersistedWalletService } from '@/lib/wallet/dydxPersistedWalletService'; import { useAccounts } from './useAccounts'; import { useApiState } from './useApiState'; import { useBreakpoints } from './useBreakpoints'; import { useDydxClient } from './useDydxClient'; -import { useEnableTurnkey } from './useEnableTurnkey'; import { useAppSelectorWithArgs } from './useParameterizedSelector'; import { useReferredBy } from './useReferredBy'; import { useSelectedNetwork } from './useSelectedNetwork'; @@ -158,10 +158,10 @@ export const useAnalytics = () => { useEffect(() => { identify( AnalyticsUserProperties.IsRememberMe( - dydxAddress ? Boolean(sourceAccount.encryptedSignature) : null + dydxAddress ? dydxPersistedWalletService.hasStoredWallet() : null ) ); - }, [dydxAddress, sourceAccount.encryptedSignature]); + }, [dydxAddress]); // AnalyticsUserProperty.SubaccountNumber const subaccountNumber = useAppSelector(getSubaccountId); @@ -259,8 +259,7 @@ export const useAnalytics = () => { }, []); // AnalyticsEvent.OnboardingStepChanged - const isTurnkeyEnabled = useEnableTurnkey(); - const currentOnboardingStep = useAppSelectorWithArgs(calculateOnboardingStep, isTurnkeyEnabled); + const currentOnboardingStep = useAppSelectorWithArgs(calculateOnboardingStep); const onboardingState = useAppSelector(getOnboardingState); const [hasOnboardingStateChanged, setHasOnboardingStateChanged] = useState(false); diff --git a/src/hooks/useCameraDetection.ts b/src/hooks/useCameraDetection.ts new file mode 100644 index 0000000000..dfc0fb2d66 --- /dev/null +++ b/src/hooks/useCameraDetection.ts @@ -0,0 +1,66 @@ +import { useEffect, useState } from 'react'; + +import { logBonsaiError } from '@/bonsai/logs'; + +export interface CameraInfo { + hasCamera: boolean; + isLoading: boolean; + error: string | null; + cameraCount: number; +} + +/** + * @description Detects if the user has a camera on their device + * @returns The camera information { hasCamera, isLoading, error, cameraCount } + */ +export const useCameraDetection = (): CameraInfo => { + const [cameraInfo, setCameraInfo] = useState({ + hasCamera: false, + isLoading: true, + error: null, + cameraCount: 0, + }); + + useEffect(() => { + const checkCamera = async () => { + try { + // Check if MediaDevices API is supported + if (typeof navigator.mediaDevices === 'undefined') { + setCameraInfo({ + hasCamera: false, + isLoading: false, + error: 'MediaDevices API not supported', + cameraCount: 0, + }); + return; + } + + // Enumerate all media devices + const devices = await navigator.mediaDevices.enumerateDevices(); + + // Filter video input devices (cameras) + const cameras = devices.filter((device) => device.kind === 'videoinput'); + + setCameraInfo({ + hasCamera: cameras.length > 0, + isLoading: false, + error: null, + cameraCount: cameras.length, + }); + } catch (error) { + logBonsaiError('useCameraDetection', 'Error checking for camera', { error }); + + setCameraInfo({ + hasCamera: false, + isLoading: false, + error: error instanceof Error ? error.message : 'Unknown error', + cameraCount: 0, + }); + } + }; + + checkCamera(); + }, []); + + return cameraInfo; +}; diff --git a/src/hooks/useCosmosWallets.ts b/src/hooks/useCosmosWallets.ts new file mode 100644 index 0000000000..00df467282 --- /dev/null +++ b/src/hooks/useCosmosWallets.ts @@ -0,0 +1,134 @@ +import { useCallback } from 'react'; + +import { logBonsaiError } from '@/bonsai/logs'; +import type { LocalWallet } from '@dydxprotocol/v4-client-js'; + +import { getNeutronChainId, getOsmosisChainId } from '@/constants/graz'; +import type { PrivateInformation } from '@/constants/wallets'; + +import { + deriveCosmosWallet, + deriveCosmosWalletFromPrivateKey, + deriveCosmosWalletFromSigner, +} from '@/lib/onboarding/deriveCosmosWallets'; + +/** + * + * @param hdKey - The HD key material containing the mnemonic + * @param getCosmosOfflineSigner - Function to get offline signer (for native Cosmos wallets) + * @returns Functions to get Noble, Osmosis, and Neutron wallets + */ +export function useCosmosWallets( + hdKey: PrivateInformation | undefined, + getCosmosOfflineSigner?: (chainId: string) => Promise +) { + /** + * Get Osmosis wallet on-demand + */ + const getOsmosisWallet = useCallback(async (): Promise => { + if (hdKey?.mnemonic) { + try { + return await deriveCosmosWallet(hdKey.mnemonic, 'osmosis'); + } catch (error) { + logBonsaiError('useCosmosWallets', 'Failed to derive Osmosis wallet w/ mnemonic', { + error, + }); + } + } + + 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, + }); + } + } + + if (getCosmosOfflineSigner) { + try { + const osmosisOfflineSigner = await getCosmosOfflineSigner(getOsmosisChainId()); + if (osmosisOfflineSigner) { + return await deriveCosmosWalletFromSigner(osmosisOfflineSigner, 'osmosis'); + } + } catch (error) { + return null; + } + } + + return null; + }, [hdKey?.mnemonic, hdKey?.privateKey, getCosmosOfflineSigner]); + + /** + * Get Neutron wallet on-demand + */ + const getNeutronWallet = useCallback(async (): Promise => { + if (hdKey?.mnemonic) { + try { + return await deriveCosmosWallet(hdKey.mnemonic, 'neutron'); + } catch (error) { + logBonsaiError('useCosmosWallets', 'Failed to derive Neutron wallet w/ mnemonic', { + error, + }); + return null; + } + } + + if (hdKey?.privateKey) { + try { + return await deriveCosmosWalletFromPrivateKey( + Buffer.from(hdKey.privateKey).toString('hex'), + 'neutron' + ); + } catch (error) { + logBonsaiError('useCosmosWallets', 'Failed to derive Neutron wallet w/ private key', { + error, + }); + return null; + } + } + + if (getCosmosOfflineSigner) { + try { + const neutronOfflineSigner = await getCosmosOfflineSigner(getNeutronChainId()); + if (neutronOfflineSigner) { + return await deriveCosmosWalletFromSigner(neutronOfflineSigner, 'neutron'); + } + } catch (error) { + return null; + } + } + + return null; + }, [hdKey?.mnemonic, hdKey?.privateKey, getCosmosOfflineSigner]); + + /** + * Get 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 + getOsmosisWallet, + getNeutronWallet, + + // Address getters (convenience methods) + getOsmosisAddress, + getNeutronAddress, + }; +} diff --git a/src/hooks/useDydxClient.tsx b/src/hooks/useDydxClient.tsx index 4f18a492c4..9740f107f3 100644 --- a/src/hooks/useDydxClient.tsx +++ b/src/hooks/useDydxClient.tsx @@ -6,6 +6,7 @@ import { useCompositeClient, useIndexerClient } from '@/bonsai/rest/lib/useIndex import { BECH32_PREFIX, FaucetClient, + NOBLE_BECH32_PREFIX, PnlTickInterval, SelectedGasDenom, onboarding, @@ -86,6 +87,7 @@ const useDydxClientContext = () => { return { wallet: await (await getLazyLocalWallet()).fromMnemonic(mnemonic, BECH32_PREFIX), + nobleWallet: await (await getLazyLocalWallet()).fromMnemonic(mnemonic, NOBLE_BECH32_PREFIX), mnemonic, privateKey, publicKey, diff --git a/src/hooks/useNotificationTypes.tsx b/src/hooks/useNotificationTypes.tsx index 937dac0b16..ffd9023208 100644 --- a/src/hooks/useNotificationTypes.tsx +++ b/src/hooks/useNotificationTypes.tsx @@ -47,6 +47,7 @@ import { TradeNotification } from '@/views/notifications/TradeNotification'; import { getUserWalletAddress } from '@/state/accountInfoSelectors'; import { + getIsAccountConnected, getSubaccountFreeCollateral, selectOrphanedTriggerOrders, selectReclaimableChildSubaccountFunds, @@ -1231,12 +1232,13 @@ export const notificationTypes: NotificationTypeConfig[] = [ const dispatch = useAppDispatch(); const stringGetter = useStringGetter(); const isKeplr = useAppSelector(selectIsKeplrConnected); + const isAccountConnected = useAppSelector(getIsAccountConnected); const reclaimableChildSubaccountFunds = useAppSelector(selectReclaimableChildSubaccountFunds); const ordersToCancel = useAppSelector(selectOrphanedTriggerOrders); const maybeRebalanceAction = useAppSelector(selectShouldAccountRebalanceUsdc); useEffect(() => { - if (!isKeplr) return; + if (!isKeplr || !isAccountConnected) return; if (reclaimableChildSubaccountFunds && reclaimableChildSubaccountFunds.length > 0) { const amountBN = reclaimableChildSubaccountFunds.reduce( @@ -1276,6 +1278,7 @@ export const notificationTypes: NotificationTypeConfig[] = [ hideNotification, stringGetter, reclaimableChildSubaccountFunds, + isAccountConnected, ]); useEffect(() => { diff --git a/src/hooks/useSignForWalletDerivation.tsx b/src/hooks/useSignForWalletDerivation.tsx index 6dd788b2d5..c3126e6049 100644 --- a/src/hooks/useSignForWalletDerivation.tsx +++ b/src/hooks/useSignForWalletDerivation.tsx @@ -1,26 +1,54 @@ import { useCallback, useMemo } from 'react'; +import { BECH32_PREFIX } from '@dydxprotocol/v4-client-js'; import stableStringify from 'fast-json-stable-stringify'; import { useSignTypedData } from 'wagmi'; -import { ConnectorType, getSignTypedData, WalletInfo } from '@/constants/wallets'; +import { ConnectorType, DydxAddress, getSignTypedData, WalletInfo } from '@/constants/wallets'; import { usePhantomWallet } from '@/hooks/usePhantomWallet'; import { getSelectedDydxChainId } from '@/state/appSelectors'; import { useAppSelector } from '@/state/appTypes'; +import { signMessageWithKeplr } from '@/lib/keplrUtils'; +import { deriveCosmosWalletFromSigner } from '@/lib/onboarding/deriveCosmosWallets'; + import { useEnvConfig } from './useEnvConfig'; +import { useWalletConnection } from './useWalletConnection'; export default function useSignForWalletDerivation(wallet: WalletInfo | undefined) { const selectedDydxChainId = useAppSelector(getSelectedDydxChainId); const ethereumChainId = useEnvConfig('ethereumChainId'); const chainId = Number(ethereumChainId); + const { getCosmosOfflineSigner } = useWalletConnection(); const signTypedData = useMemo(() => getSignTypedData(selectedDydxChainId), [selectedDydxChainId]); const { signTypedDataAsync } = useSignTypedData(); + const signCosmosMessage = useCallback(async (): Promise => { + const offlineSigner = await getCosmosOfflineSigner(selectedDydxChainId); + + if (!offlineSigner) { + throw new Error('No offline signer found'); + } + + const dydxWallet = await deriveCosmosWalletFromSigner(offlineSigner, BECH32_PREFIX); + + if (!dydxWallet) { + throw new Error('Failed to derive Cosmos wallet'); + } + + const signature = await signMessageWithKeplr( + stableStringify(signTypedData), + dydxWallet.address as DydxAddress, + selectedDydxChainId + ); + + return signature; + }, [signTypedData, selectedDydxChainId, getCosmosOfflineSigner]); + const signEvmMessage = useCallback( (isMetaMask: boolean) => signTypedDataAsync({ @@ -43,6 +71,10 @@ export default function useSignForWalletDerivation(wallet: WalletInfo | undefine }, [phantomSignMessage, signTypedData]); const signMessage = useCallback(async (): Promise => { + if (wallet?.connectorType === ConnectorType.Cosmos) { + return signCosmosMessage(); + } + if (wallet?.connectorType === ConnectorType.PhantomSolana) { return signSolanaMessage(); } @@ -51,7 +83,7 @@ export default function useSignForWalletDerivation(wallet: WalletInfo | undefine wallet?.connectorType === ConnectorType.Injected && wallet.name === 'MetaMask'; return signEvmMessage(isMetaMask); - }, [signEvmMessage, signSolanaMessage, wallet?.connectorType, wallet?.name]); + }, [signEvmMessage, signSolanaMessage, signCosmosMessage, wallet?.connectorType, wallet?.name]); return signMessage; } diff --git a/src/hooks/useUpdateSwaps.tsx b/src/hooks/useUpdateSwaps.tsx index 700f889387..7f381ef7ed 100644 --- a/src/hooks/useUpdateSwaps.tsx +++ b/src/hooks/useUpdateSwaps.tsx @@ -28,7 +28,7 @@ const SWAP_SLIPPAGE_PERCENT = '0.50'; // 0.50% (50 bps) export const useUpdateSwaps = () => { const { withdraw } = useSubaccount(); const dispatch = useAppDispatch(); - const { nobleAddress, dydxAddress, osmosisAddress, neutronAddress } = useAccounts(); + const { dydxAddress, getOsmosisAddress, getNeutronAddress, nobleAddress } = useAccounts(); const { skipClient } = useSkipClient(); const pendingSwaps = useAppSelector(getPendingSwaps); @@ -71,6 +71,17 @@ export const useUpdateSwaps = () => { const executeSwap = useCallback( async (swap: Swap) => { const { route } = swap; + + // Derive Cosmos addresses on-demand + const [osmosisAddress, neutronAddress] = await Promise.all([ + getOsmosisAddress(), + getNeutronAddress(), + ]); + + if (!nobleAddress || !osmosisAddress || !neutronAddress) { + throw new Error('Failed to derive Cosmos addresses'); + } + const userAddresses = getUserAddressesForRoute( route, // Don't need source account for swaps @@ -110,7 +121,7 @@ export const useUpdateSwaps = () => { }, }); }, - [dispatch, dydxAddress, neutronAddress, nobleAddress, osmosisAddress, skipClient] + [dispatch, dydxAddress, nobleAddress, getNeutronAddress, getOsmosisAddress, skipClient] ); useEffect(() => { diff --git a/src/hooks/useWalletConnection.tsx b/src/hooks/useWalletConnection.tsx index a3539245d5..0b1c1df119 100644 --- a/src/hooks/useWalletConnection.tsx +++ b/src/hooks/useWalletConnection.tsx @@ -12,7 +12,6 @@ import { useConnect as useConnectWagmi, useDisconnect as useDisconnectWagmi, usePublicClient as usePublicClientWagmi, - useReconnect as useReconnectWagmi, useWalletClient as useWalletClientWagmi, } from 'wagmi'; @@ -149,7 +148,6 @@ export const useWalletConnectionContext = () => { ); const { connectAsync: connectWagmi } = useConnectWagmi(); - const { reconnectAsync: reconnectWagmi } = useReconnectWagmi(); const { connectAsync: connectGraz } = useConnectGraz(); const { ready, authenticated } = usePrivy(); @@ -174,15 +172,7 @@ export const useWalletConnectionContext = () => { const { logout } = useLogout(); const connectWallet = useCallback( - async ({ - wallet, - forceConnect, - isEvmAccountConnected, - }: { - wallet: WalletInfo | undefined; - forceConnect?: boolean; - isEvmAccountConnected?: boolean; - }) => { + async ({ wallet }: { wallet: WalletInfo | undefined }) => { if (!wallet) return; try { @@ -205,12 +195,11 @@ export const useWalletConnectionContext = () => { } else if (wallet.connectorType === ConnectorType.PhantomSolana) { await connectPhantom(); } else if (isWagmiConnectorType(wallet)) { - if (!isConnectedWagmi && (!!forceConnect || !isEvmAccountConnected)) { + if (!isConnectedWagmi) { const connector = resolveWagmiConnector({ wallet, walletConnectConfig }); // This could happen in the mipd case if the user has uninstalled or disabled the injected wallet they've previously selected // TODO: add analytics to see how often this happens? if (!connector) return; - await connectWagmi({ connector }); } } @@ -255,45 +244,6 @@ export const useWalletConnectionContext = () => { // Wallet selection const [selectedWalletError, setSelectedWalletError] = useState(); - // Auto-reconnect to wallet from last browser session - useEffect(() => { - (async () => { - setSelectedWalletError(undefined); - - if (selectedWallet) { - if (selectedWallet.connectorType === ConnectorType.Turnkey) { - // Turnkey does not initiate a wallet connection, so we should no op. - return; - } - - const isEvmAccountConnected = - sourceAccount.chain === WalletNetworkType.Evm && sourceAccount.encryptedSignature; - - if (isWagmiConnectorType(selectedWallet) && !isConnectedWagmi && !isEvmAccountConnected) { - const connector = resolveWagmiConnector({ wallet: selectedWallet, walletConnectConfig }); - if (!connector) return; - - await reconnectWagmi({ - connectors: [connector], - }); - } else if ( - selectedWallet.connectorType === ConnectorType.PhantomSolana && - !sourceAccount.address - ) { - await connectPhantom(); - } - } - })(); - }, [ - selectedWallet, - signerWagmi, - sourceAccount, - reconnectWagmi, - isConnectedWagmi, - walletConnectConfig, - connectPhantom, - ]); - const selectWallet = useCallback( async (wallet: WalletInfo | undefined) => { // Disconnect all wallets prior to selecting a new wallet. @@ -311,9 +261,6 @@ export const useWalletConnectionContext = () => { } else { await connectWallet({ wallet, - isEvmAccountConnected: Boolean( - sourceAccount.chain === WalletNetworkType.Evm && sourceAccount.encryptedSignature - ), }); dispatch(setWalletInfo(wallet)); @@ -333,14 +280,7 @@ export const useWalletConnectionContext = () => { await disconnectWallet(); } }, - [ - connectWallet, - disconnectWallet, - dispatch, - sourceAccount.chain, - sourceAccount.encryptedSignature, - stringGetter, - ] + [connectWallet, disconnectWallet, dispatch, stringGetter] ); // On page load, if testFlag.address is set, connect to the test wallet. diff --git a/src/layout/Header/HeaderDesktop.tsx b/src/layout/Header/HeaderDesktop.tsx index 4d118e4e23..2bd6156568 100644 --- a/src/layout/Header/HeaderDesktop.tsx +++ b/src/layout/Header/HeaderDesktop.tsx @@ -216,7 +216,7 @@ export const HeaderDesktop = () => { {onboardingState === OnboardingState.AccountConnected ? ( <$IconButton shape={ButtonShape.Rectangle} - iconName={IconName.Mobile} + iconName={IconName.DevicesStroke} onClick={() => dispatch(openDialog(DialogTypes.MobileSignIn({ skipWaiting: true })))} /> ) : ( 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/keplrUtils.ts b/src/lib/keplrUtils.ts new file mode 100644 index 0000000000..4152ed8fec --- /dev/null +++ b/src/lib/keplrUtils.ts @@ -0,0 +1,39 @@ +import { onboarding } from '@dydxprotocol/v4-client-js'; +import { keccak256 } from 'viem'; + +import { Hdkey } from '@/constants/account'; + +/** + * Signs a message with Keplr + * @param message - The message to sign + * @param signer - The signer address + * @param chainId - The chain ID + * @returns The signature + */ +export const signMessageWithKeplr = async ( + message: string, + signer: string, + chainId: string +): Promise => { + if (!window.keplr) { + throw new Error('Keplr not found'); + } + + const { signature } = await window.keplr.signArbitrary(chainId, signer, message); + return signature; +}; + +/** + * + * @param signature - The signature to get the HD key from + * @returns The HD key + */ +export const getHDKeyFromKeplrSignature = (signature: string): Hdkey => { + if (!window.keplr) { + throw new Error('Keplr not found'); + } + + const buffer = Buffer.from(signature, 'hex'); + const entropy = keccak256(buffer, 'bytes'); + return onboarding.exportMnemonicAndPrivateKey(entropy); +}; diff --git a/src/lib/onboarding/OnboardingSupervisor.ts b/src/lib/onboarding/OnboardingSupervisor.ts new file mode 100644 index 0000000000..d92f50956d --- /dev/null +++ b/src/lib/onboarding/OnboardingSupervisor.ts @@ -0,0 +1,589 @@ +import { getLazyLocalWallet } from '@/bonsai/lib/lazyDynamicLibs'; +import { logBonsaiError } from '@/bonsai/logs'; +import { + BECH32_PREFIX, + NOBLE_BECH32_PREFIX, + onboarding as OnboardingHelper, + type LocalWallet, +} from '@dydxprotocol/v4-client-js'; +import { OfflineAminoSigner, OfflineDirectSigner } from '@keplr-wallet/types'; +import { Keypair } from '@solana/web3.js'; + +import { OnboardingState } from '@/constants/account'; +import { getNobleChainId } from '@/constants/graz'; +import { + ConnectorType, + DydxAddress, + PrivateInformation, + WalletNetworkType, + type WalletInfo, +} from '@/constants/wallets'; + +import { convertBech32Address } from '@/lib/addressUtils'; +import { hdKeyManager } from '@/lib/hdKeyManager'; +import { sleep } from '@/lib/timeUtils'; + +import { deriveSolanaKeypairFromMnemonic } from '../solanaWallet'; +import { dydxPersistedWalletService } from '../wallet/dydxPersistedWalletService'; + +export interface SourceAccount { + address?: string; + chain?: WalletNetworkType; + walletInfo?: WalletInfo; +} + +export interface OnboardingContext { + sourceAccount: SourceAccount; + hasLocalDydxWallet: boolean; + isConnectedGraz?: boolean; + authenticated?: boolean; + ready?: boolean; +} + +export type WalletDerivationResult = + | { + wallet?: undefined; + nobleWallet?: undefined; + solanaKeypair?: undefined; + hdKey?: PrivateInformation; + onboardingState: OnboardingState; + error?: string; + } + | { + wallet: LocalWallet; + nobleWallet: LocalWallet; + solanaKeypair: Keypair | undefined; + hdKey?: PrivateInformation; + onboardingState: OnboardingState; + error?: string; + }; + +class OnboardingSupervisor { + /** + * Import wallet from private key + * Called directly from ImportPrivateKey component + */ + async handleWalletImport({ + mnemonic, + handleWalletConnectionResult, + }: { + mnemonic: string; + handleWalletConnectionResult: (result: WalletDerivationResult) => void; + }): Promise<{ success: true } | { success: false; error: string }> { + try { + const LocalWallet = await getLazyLocalWallet(); + + // Create wallets from private key + const wallet = await LocalWallet.fromMnemonic(mnemonic, BECH32_PREFIX); + const nobleWallet = await LocalWallet.fromMnemonic(mnemonic, NOBLE_BECH32_PREFIX); + const solanaKeypair = deriveSolanaKeypairFromMnemonic(mnemonic); + + if (!wallet.address || !nobleWallet.address) { + logBonsaiError('OnboardingSupervisor', 'local wallet is missing address', { + error: new Error('Could not derive a local wallet from imported recovery phrase'), + }); + return { + success: false, + error: 'Could not derive a local wallet from imported recovery phrase', + }; + } + + const walletConnectionResult: WalletDerivationResult = { + wallet, + nobleWallet, + solanaKeypair, + onboardingState: OnboardingState.AccountConnected, + }; + + handleWalletConnectionResult(walletConnectionResult); + + // Store the recovery phrase in SecureStorage + await dydxPersistedWalletService.secureStorePhrase(mnemonic); + + return { + success: true, + }; + } catch (error) { + logBonsaiError('OnboardingSupervisor', 'handleWalletImport failed', { error }); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to import private key', + }; + } + } + + /** + * Derive dYdX wallet from signature using DydxWalletService + * Used for EVM, Solana, and Turnkey wallets + */ + private async deriveWalletFromSignature( + signature: string, + getWalletFromSignature: (params: { signature: string }) => Promise<{ + wallet: LocalWallet; + nobleWallet: LocalWallet; + mnemonic: string; + privateKey: Uint8Array | null; + publicKey: Uint8Array | null; + }> + ): Promise<{ + wallet: LocalWallet; + nobleWallet: LocalWallet; + solanaKeypair: Keypair; + hdKey: PrivateInformation; + }> { + const { wallet, nobleWallet, mnemonic, privateKey, publicKey } = await getWalletFromSignature({ + signature, + }); + + if (!privateKey || !publicKey) { + throw new Error('Failed to derive wallet from signature'); + } + + const hdKey: PrivateInformation = { + mnemonic, + privateKey, + publicKey, + }; + + const solanaKeypair = deriveSolanaKeypairFromMnemonic(mnemonic); + hdKeyManager.setHdkey(wallet.address, hdKey); + await dydxPersistedWalletService.secureStorePhrase(mnemonic); + + return { wallet, nobleWallet, solanaKeypair, hdKey }; + } + + /** + * Derive keys with determinism check for first-time users + * Ensures wallets support deterministic signing by requesting two signatures + * + * @param signMessageAsync - Function to sign a message + * @param getWalletFromSignature - Function to derive wallet from signature + * @param checkPreviousTransactions - Function to check if user has transaction history + * @returns Wallet derivation result with determinism validation + */ + async deriveKeysWithDeterminismCheck(params: { + signMessageAsync: (requestNumber: 1 | 2) => Promise; + getWalletFromSignature: (params: { signature: string }) => Promise<{ + wallet: LocalWallet; + mnemonic: string; + nobleWallet: LocalWallet; + privateKey: Uint8Array | null; + publicKey: Uint8Array | null; + }>; + checkPreviousTransactions: (dydxAddress: DydxAddress) => Promise; + handleWalletConnectionResult: (result: WalletDerivationResult) => void; + }): Promise< + | { + success: true; + } + | { success: false; error: string; isDeterminismError?: boolean } + > { + const { + signMessageAsync, + getWalletFromSignature, + checkPreviousTransactions, + handleWalletConnectionResult, + } = params; + + try { + // Step 1: Get first signature and derive wallet + const firstSignature = await signMessageAsync(1); + const { wallet, nobleWallet, mnemonic, privateKey, publicKey } = await getWalletFromSignature( + { + signature: firstSignature, + } + ); + + if (!privateKey || !publicKey || !wallet.address) { + return { + success: false, + error: 'Failed to derive wallet from signature', + }; + } + + // Step 2: Check for previous transactions + const hasPreviousTransactions = await checkPreviousTransactions( + wallet.address as DydxAddress + ); + + // Step 3: For new users, ensure determinism with second signature + if (!hasPreviousTransactions) { + const secondSignature = await signMessageAsync(2); + + if (firstSignature !== secondSignature) { + return { + success: false, + error: + 'Your wallet does not support deterministic signing. Please switch to a different wallet provider.', + isDeterminismError: true, + }; + } + } + + // Step 4: Persist to SecureStorage + await dydxPersistedWalletService.secureStorePhrase(mnemonic); + + // Step 5: Set up hdKey + const hdKey: PrivateInformation = { + mnemonic, + privateKey, + publicKey, + }; + + hdKeyManager.setHdkey(wallet.address, hdKey); + + // Step 6: Derive Solana keypair + const solanaKeypair = deriveSolanaKeypairFromMnemonic(mnemonic); + + const walletDerivationResult: WalletDerivationResult = { + wallet, + nobleWallet, + solanaKeypair, + hdKey, + onboardingState: OnboardingState.AccountConnected, + }; + + handleWalletConnectionResult(walletDerivationResult); + + return { + success: true, + }; + } catch (error) { + logBonsaiError('OnboardingSupervisor', 'deriveKeysWithDeterminismCheck failed', { error }); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + /** + * Restore wallet from SecureStorage if available + * Called at the start of handleWalletConnection to check for persisted session + */ + private async restoreFromSecureStorage(): Promise { + try { + const storedPhrase = await dydxPersistedWalletService.exportPhrase(); + if (!storedPhrase) { + return null; + } + + const LocalWallet = await getLazyLocalWallet(); + const wallet = await LocalWallet.fromMnemonic(storedPhrase, BECH32_PREFIX); + const nobleWallet = await LocalWallet.fromMnemonic(storedPhrase, NOBLE_BECH32_PREFIX); + const solanaKeypair = deriveSolanaKeypairFromMnemonic(storedPhrase); + + const { privateKey, publicKey } = OnboardingHelper.deriveHDKeyFromMnemonic(storedPhrase); + + const hdKey: PrivateInformation = { + mnemonic: storedPhrase, + privateKey, + publicKey, + }; + + return { + wallet, + nobleWallet, + solanaKeypair, + hdKey, + onboardingState: OnboardingState.AccountConnected, + }; + } catch (error) { + logBonsaiError('OnboardingSupervisor', 'Failed to restore from SecureStorage', { error }); + return null; + } + } + + /** + * Handles all wallet type flows and determines next onboarding state + */ + async handleWalletConnection(params: { + context: OnboardingContext; + getWalletFromSignature: (params: { signature: string }) => Promise<{ + wallet: LocalWallet; + nobleWallet: LocalWallet; + mnemonic: string; + privateKey: Uint8Array | null; + publicKey: Uint8Array | null; + }>; + signMessageAsync: () => Promise; + getCosmosOfflineSigner?: ( + chainId: string + ) => Promise<(OfflineAminoSigner & OfflineDirectSigner) | undefined>; + selectedDydxChainId?: string; + }): Promise { + const { + context, + getWalletFromSignature, + signMessageAsync, + getCosmosOfflineSigner, + selectedDydxChainId, + } = params; + const { sourceAccount, hasLocalDydxWallet, isConnectedGraz, authenticated, ready } = context; + + try { + // ------ Restore from SecureStorage ------ // + // Check for persisted session before processing wallet connections + if (dydxPersistedWalletService.hasStoredWallet()) { + const restored = await this.restoreFromSecureStorage(); + + if (restored) { + return restored; + } + } + + // ------ Import Flow ------ // + // Import is handled directly via handleWalletImport(), not through this flow + if (sourceAccount.walletInfo?.connectorType === ConnectorType.Import) { + throw new Error('Import flow is handled in useAccounts.tsx'); + } + + // ------ Turnkey Flow ------ // + if (sourceAccount.walletInfo?.connectorType === ConnectorType.Turnkey) { + return await this.handleTurnkeyFlow({ + hasLocalDydxWallet, + }); + } + + // ------ Impersonate Wallet Flow ------ // + if (sourceAccount.walletInfo?.connectorType === ConnectorType.Test) { + return await this.handleTestWalletFlow(sourceAccount); + } + + // ------ Cosmos Flow ------ // + if (sourceAccount.chain === WalletNetworkType.Cosmos && isConnectedGraz) { + return await this.handleCosmosFlow({ + getCosmosOfflineSigner, + selectedDydxChainId, + signMessageAsync, + }); + } + + // ------ Evm Flow ------ // + if (sourceAccount.chain === WalletNetworkType.Evm) { + return await this.handleEvmFlow({ + sourceAccount, + hasLocalDydxWallet, + authenticated, + ready, + signMessageAsync, + getWalletFromSignature, + }); + } + + // ------ Solana Flow ------ // + if (sourceAccount.chain === WalletNetworkType.Solana) { + return await this.handleSolanaFlow({ + sourceAccount, + hasLocalDydxWallet, + getWalletFromSignature, + signMessageAsync, + }); + } + + return { + onboardingState: OnboardingState.Disconnected, + }; + } catch (error) { + logBonsaiError('OnboardingSupervisor', 'handleWalletConnection failed', { error }); + return { + onboardingState: OnboardingState.Disconnected, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + /** + * Handle Turnkey wallet flow + * Turnkey is an embedded wallet managed entirely by TurnkeyAuthProvider + * Signing is done via Turnkey SDK, persistence via setWalletFromTurnkeySignature() + * OnboardingSupervisor only validates state + */ + private async handleTurnkeyFlow(params: { + hasLocalDydxWallet: boolean; + }): Promise { + const { hasLocalDydxWallet } = params; + + // If wallet already exists (restored from SecureStorage or set by TurnkeyAuthProvider) + if (hasLocalDydxWallet) { + return { + onboardingState: OnboardingState.AccountConnected, + }; + } + + // Wallet is being set up by TurnkeyAuthProvider + // Return Disconnected until TurnkeyAuthProvider completes the flow + return { + onboardingState: OnboardingState.Disconnected, + }; + } + + /** + * Handle test wallet flow + */ + private async handleTestWalletFlow( + sourceAccount: SourceAccount + ): Promise { + const LocalWallet = await getLazyLocalWallet(); + + // Create dYdX test wallet + const wallet = new LocalWallet(); + wallet.address = sourceAccount.address!; + + // Create Noble test wallet with bech32 conversion + const nobleWallet = new LocalWallet(); + nobleWallet.address = convertBech32Address({ + address: sourceAccount.address!, + bech32Prefix: NOBLE_BECH32_PREFIX, + }); + + return { + wallet, + nobleWallet, + solanaKeypair: undefined, + // Test wallets don't have hdKey material + onboardingState: OnboardingState.AccountConnected, + }; + } + + /** + * Handle Cosmos wallet flow + */ + private async handleCosmosFlow(params: { + getCosmosOfflineSigner?: ( + chainId: string + ) => Promise<(OfflineAminoSigner & OfflineDirectSigner) | undefined>; + selectedDydxChainId?: string; + signMessageAsync: () => Promise; + }): Promise { + const { getCosmosOfflineSigner, selectedDydxChainId } = params; + + if (!getCosmosOfflineSigner || !selectedDydxChainId) { + return { + onboardingState: OnboardingState.Disconnected, + error: 'Missing Cosmos dependencies', + }; + } + + try { + const dydxOfflineSigner = await getCosmosOfflineSigner(selectedDydxChainId); + const nobleOfflineSigner = await getCosmosOfflineSigner(getNobleChainId()); + + if (dydxOfflineSigner && nobleOfflineSigner) { + const wallet = await (await getLazyLocalWallet()).fromOfflineSigner(dydxOfflineSigner); + const nobleWallet = await ( + await getLazyLocalWallet() + ).fromOfflineSigner(nobleOfflineSigner); + + return { + wallet, + nobleWallet, + solanaKeypair: undefined, + // Cosmos wallets from offline signer don't expose hdKey material + onboardingState: OnboardingState.AccountConnected, + }; + } + } catch (error) { + logBonsaiError('OnboardingSupervisor', 'Cosmos wallet creation failed', { error }); + } + + return { + onboardingState: OnboardingState.Disconnected, + error: 'Failed to create Cosmos wallet', + }; + } + + /** + * Handle EVM wallet flow + */ + private async handleEvmFlow(params: { + sourceAccount: SourceAccount; + hasLocalDydxWallet: boolean; + authenticated?: boolean; + ready?: boolean; + signMessageAsync: () => Promise; + getWalletFromSignature: (params: { signature: string }) => Promise<{ + wallet: LocalWallet; + nobleWallet: LocalWallet; + mnemonic: string; + privateKey: Uint8Array | null; + publicKey: Uint8Array | null; + }>; + }): Promise { + const { + sourceAccount, + hasLocalDydxWallet, + authenticated, + ready, + signMessageAsync, + getWalletFromSignature, + } = params; + + // If wallet already exists (restored from SecureStorage), 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, nobleWallet, solanaKeypair, hdKey } = await this.deriveWalletFromSignature( + signature, + getWalletFromSignature + ); + + return { + wallet, + nobleWallet, + solanaKeypair, + hdKey, + onboardingState: OnboardingState.AccountConnected, + }; + } catch (error) { + logBonsaiError('OnboardingSupervisor', 'Privy signing failed', { error }); + + return { + onboardingState: OnboardingState.WalletConnected, + error: 'Failed to sign with Privy', + }; + } + } + + // Other EVM wallets - need to trigger signing flow in UI + // Wallet connected but waiting for signature + return { + onboardingState: OnboardingState.WalletConnected, + }; + } + + /** + * Handle Solana wallet flow + */ + private async handleSolanaFlow(params: { + sourceAccount: SourceAccount; + hasLocalDydxWallet: boolean; + getWalletFromSignature: any; + signMessageAsync?: () => Promise; + }): Promise { + const { hasLocalDydxWallet } = params; + + // If wallet already exists (restored from SecureStorage), just set state + if (hasLocalDydxWallet) { + return { + onboardingState: OnboardingState.AccountConnected, + }; + } + + // Wallet connected but waiting for signature + return { + onboardingState: OnboardingState.WalletConnected, + }; + } +} + +export const onboardingManager = new OnboardingSupervisor(); diff --git a/src/lib/onboarding/deriveCosmosWallets.ts b/src/lib/onboarding/deriveCosmosWallets.ts new file mode 100644 index 0000000000..82cbbce16c --- /dev/null +++ b/src/lib/onboarding/deriveCosmosWallets.ts @@ -0,0 +1,84 @@ +import { getLazyLocalWallet } from '@/bonsai/lib/lazyDynamicLibs'; +import { logBonsaiError } from '@/bonsai/logs'; +import { LocalWallet, NOBLE_BECH32_PREFIX } from '@dydxprotocol/v4-client-js'; + +import { NEUTRON_BECH32_PREFIX, OSMO_BECH32_PREFIX } from '@/constants/graz'; + +export type SupportedCosmosChain = 'noble' | 'osmosis' | 'neutron'; + +/** + * Derive a Cosmos wallet on-demand from mnemonic + * Used for Noble, Osmosis, Neutron wallets when needed + * + * @param mnemonic - The mnemonic to derive from + * @param chain - Which Cosmos chain wallet to derive + * @returns LocalWallet for the specified chain + */ +export async function deriveCosmosWallet( + mnemonic: string, + chain: SupportedCosmosChain +): Promise { + try { + const prefix = getCosmosPrefix(chain); + const LazyLocalWallet = await getLazyLocalWallet(); + return await LazyLocalWallet.fromMnemonic(mnemonic, prefix); + } catch (error) { + logBonsaiError('OnboardingSupervisor', `Failed to derive ${chain} wallet from mnemonic`, { + error, + }); + + return null; + } +} + +export async function deriveCosmosWalletFromPrivateKey( + privateKey: string, + chain: SupportedCosmosChain +): Promise { + try { + const prefix = getCosmosPrefix(chain); + const LazyLocalWallet = await getLazyLocalWallet(); + return await LazyLocalWallet.fromPrivateKey(privateKey, prefix); + } catch (error) { + logBonsaiError('OnboardingSupervisor', `Failed to derive ${chain} wallet from private key`, { + error, + }); + + return null; + } +} + +/** + * Derive a Cosmos wallet from offline signer + * Used when user has a native Cosmos wallet connected + */ +export async function deriveCosmosWalletFromSigner( + offlineSigner: any, + chain: string +): Promise { + try { + const LazyLocalWallet = await getLazyLocalWallet(); + return await LazyLocalWallet.fromOfflineSigner(offlineSigner); + } catch (error) { + logBonsaiError('OnboardingSupervisor', `Failed to derive ${chain} wallet from signer`, { + error, + }); + return null; + } +} + +/** + * Get the Bech32 prefix for a Cosmos chain + */ +function getCosmosPrefix(chain: SupportedCosmosChain): string { + switch (chain) { + case 'noble': + return NOBLE_BECH32_PREFIX; + case 'osmosis': + return OSMO_BECH32_PREFIX; + case 'neutron': + return NEUTRON_BECH32_PREFIX; + default: + throw new Error(`Unknown Cosmos chain: ${chain}`); + } +} diff --git a/src/lib/wallet/dydxPersistedWalletService.ts b/src/lib/wallet/dydxPersistedWalletService.ts new file mode 100644 index 0000000000..b7c6a65eff --- /dev/null +++ b/src/lib/wallet/dydxPersistedWalletService.ts @@ -0,0 +1,56 @@ +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); + } + + /** + * @param mnemonic - Mnemonic to store + */ + async secureStorePhrase(mnemonic?: string | null): Promise { + try { + if (!mnemonic) { + this.clearStoredWallet(); + throw new Error('PrivateKey was not derived from Signature'); + } + + await secureStorage.store(STORAGE_KEY, mnemonic); + } catch (error) { + logBonsaiError('DydxWalletService', `Failed to secure store ${STORAGE_KEY}`, { error }); + } + } + + /** + * @returns Decrypted trading key or null if not found + */ + async exportPhrase(): Promise { + try { + return await secureStorage.retrieve(STORAGE_KEY); + } catch (error) { + logBonsaiError('DydxWalletService', `Failed to export ${STORAGE_KEY}`, { error }); + return null; + } + } +} + +export const dydxPersistedWalletService = new DydxPersistedWalletService(); diff --git a/src/lib/wallet/secureStorage.ts b/src/lib/wallet/secureStorage.ts new file mode 100644 index 0000000000..5f1f4943a8 --- /dev/null +++ b/src/lib/wallet/secureStorage.ts @@ -0,0 +1,173 @@ +import { logBonsaiError } from '@/bonsai/logs'; + +import { arrayBufferToBase64 } from '@/lib/arrayBufferToBase64'; +import { base64ToArrayBuffer } from '@/lib/base64ToArrayBuffer'; + +const STORAGE_PREFIX = 'dydx.secure.'; +const SALT_KEY = `${STORAGE_PREFIX}salt`; + +interface EncryptedData { + data: string; + iv: string; + version: number; // Version for future migrations +} + +/** + * @class SecureStorageService + * @description Provides encrypted storage for sensitive data using the Web Crypto API. + * Uses a browser-specific encryption key derived from a random salt. + */ +export class SecureStorageService { + private encryptionKey: CryptoKey | null = null; + + /** + * Get or create a browser-specific encryption key + * The key is derived from a random salt stored in localStorage + */ + private async getOrCreateEncryptionKey(): Promise { + // Return cached key if available + if (this.encryptionKey) { + return this.encryptionKey; + } + + // Get or create salt + let salt = localStorage.getItem(SALT_KEY); + if (!salt) { + // First time - generate random salt + const saltArray = crypto.getRandomValues(new Uint8Array(32)); + salt = arrayBufferToBase64(saltArray.buffer); + localStorage.setItem(SALT_KEY, salt); + } + + // Import the salt as key material + const saltBuffer = base64ToArrayBuffer(salt); + const keyMaterial = await crypto.subtle.importKey( + 'raw', + saltBuffer, + { name: 'PBKDF2' }, + false, + ['deriveKey'] + ); + + // Derive encryption key from salt + // Using a static pepper for additional entropy + const pepper = new TextEncoder().encode('dydx-v4-web-secure-storage'); + this.encryptionKey = await crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: pepper, + iterations: 100000, + hash: 'SHA-256', + }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); + + return this.encryptionKey; + } + + /** + * Encrypt and store data + * @param key - Storage key (will be prefixed) + * @param data - String data to encrypt + */ + async store(key: string, data: string): Promise { + const encryptionKey = await this.getOrCreateEncryptionKey(); + + // Generate random IV for this encryption + const iv = crypto.getRandomValues(new Uint8Array(12)); + + // Encrypt the data + const encodedData = new TextEncoder().encode(data); + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + encryptionKey, + encodedData + ); + + // Store encrypted data with IV + const encryptedData: EncryptedData = { + data: arrayBufferToBase64(encrypted), + iv: arrayBufferToBase64(iv.buffer), + version: 1, + }; + + localStorage.setItem(`${STORAGE_PREFIX}${key}`, JSON.stringify(encryptedData)); + } + + /** + * Retrieve and decrypt data + * @param key - Storage key (will be prefixed) + * @returns Decrypted string or null if not found + */ + async retrieve(key: string): Promise { + const stored = localStorage.getItem(`${STORAGE_PREFIX}${key}`); + if (!stored) { + return null; + } + + try { + const encryptedData: EncryptedData = JSON.parse(stored); + const encryptionKey = await this.getOrCreateEncryptionKey(); + + // Decrypt the data + const encryptedBuffer = base64ToArrayBuffer(encryptedData.data); + const ivBuffer = base64ToArrayBuffer(encryptedData.iv); + + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: ivBuffer }, + encryptionKey, + encryptedBuffer + ); + + return new TextDecoder().decode(decrypted); + } catch (error) { + logBonsaiError('SecureStorage', 'Failed to decrypt data', { error }); + // Data might be corrupted or key changed - remove it + this.remove(key); + return null; + } + } + + /** + * Remove encrypted data + * @param key - Storage key (will be prefixed) + */ + remove(key: string): void { + localStorage.removeItem(`${STORAGE_PREFIX}${key}`); + } + + /** + * Clear all secure storage data including salt + * WARNING: This will make all encrypted data unrecoverable + */ + clearAll(): void { + // Remove salt + localStorage.removeItem(SALT_KEY); + + // Remove all encrypted items + const keysToRemove: string[] = []; + for (let i = 0; i < localStorage.length; i += 1) { + const key = localStorage.key(i); + if (key?.startsWith(STORAGE_PREFIX)) { + keysToRemove.push(key); + } + } + keysToRemove.forEach((key) => localStorage.removeItem(key)); + + // Clear cached key + this.encryptionKey = null; + } + + /** + * Check if data exists for a key + * @param key - Storage key (will be prefixed) + */ + has(key: string): boolean { + return localStorage.getItem(`${STORAGE_PREFIX}${key}`) !== null; + } +} + +export const secureStorage = new SecureStorageService(); diff --git a/src/providers/TurnkeyAuthProvider.tsx b/src/providers/TurnkeyAuthProvider.tsx index a8086507b5..04ec11c15d 100644 --- a/src/providers/TurnkeyAuthProvider.tsx +++ b/src/providers/TurnkeyAuthProvider.tsx @@ -31,6 +31,7 @@ import { getSourceAccount, getTurnkeyEmailOnboardingData } from '@/state/walletS import { identify, track } from '@/lib/analytics/analytics'; import { parseTurnkeyError } from '@/lib/turnkey/turnkeyUtils'; +import { dydxPersistedWalletService } from '@/lib/wallet/dydxPersistedWalletService'; import { useTurnkeyWallet } from './TurnkeyWalletProvider'; @@ -70,7 +71,12 @@ const useTurnkeyAuthContext = () => { const indexerUrl = useAppSelector(selectIndexerUrl); const sourceAccount = useAppSelector(getSourceAccount); const { indexedDbClient, authIframeClient } = useTurnkey(); - const { dydxAddress: connectedDydxAddress, setWalletFromSignature, selectWallet } = useAccounts(); + const { + dydxAddress: connectedDydxAddress, + setWalletFromTurnkeySignature, + selectWallet, + } = useAccounts(); + const [searchParams, setSearchParams] = useSearchParams(); const [emailToken, setEmailToken] = useState(); const [emailSignInError, setEmailSignInError] = useState(); @@ -309,7 +315,7 @@ const useTurnkeyAuthContext = () => { await indexedDbClient?.loginWithSession(session); const derivedDydxAddress = await onboardDydx({ salt, - setWalletFromSignature, + setWalletFromTurnkeySignature, tkClient: indexedDbClient, }); @@ -329,7 +335,7 @@ const useTurnkeyAuthContext = () => { setEmailSignInStatus('success'); setEmailSignInError(undefined); }, - [onboardDydx, indexedDbClient, setWalletFromSignature, uploadAddress] + [onboardDydx, indexedDbClient, setWalletFromTurnkeySignature, uploadAddress] ); /* ----------------------------- Email Sign In ----------------------------- */ @@ -402,7 +408,7 @@ const useTurnkeyAuthContext = () => { await indexedDbClient.loginWithSession(session); const derivedDydxAddress = await onboardDydx({ - setWalletFromSignature, + setWalletFromTurnkeySignature, tkClient: indexedDbClient, }); @@ -476,7 +482,7 @@ const useTurnkeyAuthContext = () => { targetPublicKeys, turnkeyEmailOnboardingData, onboardDydx, - setWalletFromSignature, + setWalletFromTurnkeySignature, searchParams, setSearchParams, stringGetter, @@ -532,12 +538,12 @@ const useTurnkeyAuthContext = () => { */ useEffect(() => { const turnkeyOnboardingToken = searchParams.get('token'); - const hasEncryptedSignature = sourceAccount.encryptedSignature != null; + const hasStoredWallet = dydxPersistedWalletService.hasStoredWallet(); if (turnkeyOnboardingToken && connectedDydxAddress != null) { searchParams.delete('token'); setSearchParams(searchParams); - } else if (turnkeyOnboardingToken && !hasEncryptedSignature) { + } else if (turnkeyOnboardingToken && !hasStoredWallet) { setEmailToken(turnkeyOnboardingToken); dispatch(openDialog(DialogTypes.EmailSignInStatus({}))); } diff --git a/src/providers/TurnkeyWalletProvider.tsx b/src/providers/TurnkeyWalletProvider.tsx index 45e2668f49..a8f1aea20d 100644 --- a/src/providers/TurnkeyWalletProvider.tsx +++ b/src/providers/TurnkeyWalletProvider.tsx @@ -4,7 +4,6 @@ import { logBonsaiError } from '@/bonsai/logs'; import { uncompressRawPublicKey } from '@turnkey/crypto'; import { TurnkeyIndexedDbClient } from '@turnkey/sdk-browser'; import { useTurnkey } from '@turnkey/sdk-react'; -import { AES } from 'crypto-js'; import { hashMessage, hashTypedData, toHex } from 'viem'; import { ConnectorType, getSignTypedDataForTurnkey } from '@/constants/wallets'; @@ -18,11 +17,7 @@ import { import { getSelectedDydxChainId } from '@/state/appSelectors'; import { useAppDispatch, useAppSelector } from '@/state/appTypes'; -import { - clearTurnkeyPrimaryWallet, - setSavedEncryptedSignature, - setTurnkeyPrimaryWallet, -} from '@/state/wallet'; +import { clearTurnkeyPrimaryWallet, setTurnkeyPrimaryWallet } from '@/state/wallet'; import { getSourceAccount, getTurnkeyEmailOnboardingData, @@ -194,11 +189,11 @@ const useTurnkeyWalletContext = () => { const onboardDydx = useCallback( async ({ salt, - setWalletFromSignature, + setWalletFromTurnkeySignature, tkClient, }: { salt?: string; - setWalletFromSignature: (signature: string) => Promise; + setWalletFromTurnkeySignature: (signature: string) => Promise; tkClient?: TurnkeyIndexedDbClient; }) => { const selectedTurnkeyWallet = primaryTurnkeyWallet ?? (await getPrimaryUserWallets(tkClient)); @@ -234,18 +229,10 @@ const useTurnkeyWalletContext = () => { }); const signature = `${response.r}${response.s}${response.v}`; - const staticEncryptionKey = import.meta.env.VITE_PK_ENCRYPTION_KEY; - - if (staticEncryptionKey) { - const encryptedSignature = AES.encrypt(signature, staticEncryptionKey).toString(); - dispatch(setSavedEncryptedSignature(encryptedSignature)); - } - - const dydxAddress = await setWalletFromSignature(signature); + const dydxAddress = await setWalletFromTurnkeySignature(signature); return dydxAddress; }, [ - dispatch, turnkeyEmailOnboardingData, primaryTurnkeyWallet, getPrimaryUserWallets, diff --git a/src/state/_store.ts b/src/state/_store.ts index e7da305cf8..7aa884c282 100644 --- a/src/state/_store.ts +++ b/src/state/_store.ts @@ -69,7 +69,7 @@ const rootReducer = combineReducers(reducers); const persistConfig = { key: 'root', - version: 6, + version: 7, storage, whitelist: [ 'affiliates', diff --git a/src/state/account.ts b/src/state/account.ts index 0ce26cfa36..2b9bc823af 100644 --- a/src/state/account.ts +++ b/src/state/account.ts @@ -9,7 +9,7 @@ export type AccountState = { onboardingGuards: Record; onboardingState: OnboardingState; onboardedThisSession: boolean; - displayChooseWallet: boolean; + chooseWalletDisplay: 'signin' | 'wallets' | 'qr'; }; const initialState: AccountState = { @@ -25,7 +25,7 @@ const initialState: AccountState = { }, onboardingState: OnboardingState.Disconnected, onboardedThisSession: false, - displayChooseWallet: false, + chooseWalletDisplay: 'signin', }; export const accountSlice = createSlice({ @@ -50,9 +50,9 @@ export const accountSlice = createSlice({ ...state, onboardedThisSession: action.payload, }), - setDisplayChooseWallet: (state, action: PayloadAction) => ({ + setChooseWalletDisplay: (state, action: PayloadAction<'signin' | 'wallets' | 'qr'>) => ({ ...state, - displayChooseWallet: action.payload, + chooseWalletDisplay: action.payload, }), }, }); @@ -61,5 +61,5 @@ export const { setOnboardingGuard, setOnboardingState, setOnboardedThisSession, - setDisplayChooseWallet, + setChooseWalletDisplay, } = accountSlice.actions; diff --git a/src/state/accountCalculators.ts b/src/state/accountCalculators.ts index 013a38ec90..7948c1aa67 100644 --- a/src/state/accountCalculators.ts +++ b/src/state/accountCalculators.ts @@ -5,7 +5,6 @@ import { OnboardingState, OnboardingSteps, SpotWalletStatus } from '@/constants/ import { ConnectorType, WalletNetworkType } from '@/constants/wallets'; import { - getDisplayChooseWallet, getOnboardingGuards, getOnboardingState, getSubaccountOpenOrders, @@ -19,13 +18,10 @@ import { getCurrentMarketId } from './currentMarketSelectors'; import { getSourceAccount } from './walletSelectors'; export const calculateOnboardingStep = createAppSelector( - [getOnboardingState, getDisplayChooseWallet, (s, isTurnkeyEnabled: boolean) => isTurnkeyEnabled], - (onboardingState: OnboardingState, displayChooseWallet: boolean, isTurnkeyEnabled: boolean) => { + [getOnboardingState], + (onboardingState: OnboardingState) => { return { - [OnboardingState.Disconnected]: - displayChooseWallet || !isTurnkeyEnabled - ? OnboardingSteps.ChooseWallet - : OnboardingSteps.SignIn, + [OnboardingState.Disconnected]: OnboardingSteps.SignIn, [OnboardingState.WalletConnected]: OnboardingSteps.KeyDerivation, [OnboardingState.AccountConnected]: undefined, }[onboardingState]; diff --git a/src/state/accountSelectors.ts b/src/state/accountSelectors.ts index a78f163688..b6c0237818 100644 --- a/src/state/accountSelectors.ts +++ b/src/state/accountSelectors.ts @@ -264,7 +264,7 @@ export const getOnboardingState = (state: RootState) => state.account.onboarding /** * @returns whether to display the choose wallet step in the onboarding flow */ -export const getDisplayChooseWallet = (state: RootState) => state.account.displayChooseWallet; +export const getChooseWalletDisplay = (state: RootState) => state.account.chooseWalletDisplay; /** * @param state diff --git a/src/state/migrations.ts b/src/state/migrations.ts index 10e0d0473f..6e730c0a8e 100644 --- a/src/state/migrations.ts +++ b/src/state/migrations.ts @@ -7,6 +7,7 @@ import { migration2 } from './migrations/2'; import { migration3 } from './migrations/3'; import { migration4 } from './migrations/4'; import { migration5 } from './migrations/5'; +import { migration6 } from './migrations/6'; /** * @description Migrate function should be used when the expected param for your migration is a previous state with reducer data @@ -27,6 +28,7 @@ export const migrations: MigrationManifest = { 3: migration3, 4: (state: PersistedState) => migrate(state, migration4), 6: migration5, + 7: migration6, } as const; /* diff --git a/src/state/migrations/6.ts b/src/state/migrations/6.ts new file mode 100644 index 0000000000..e51884ecd9 --- /dev/null +++ b/src/state/migrations/6.ts @@ -0,0 +1,35 @@ +import { PersistedState } from 'redux-persist'; + +type PersistAppStateV6 = PersistedState & { + wallet: { + sourceAccount: { + address?: string; + chain?: string; + walletInfo?: any; + }; + }; +}; + +/** + * Remove encrypted signatures from state + * Users will need to reconnect and sign again + * New signatures will be stored in SecureStorage instead + */ +export function migration6(state: PersistedState | undefined): PersistAppStateV6 { + if (!state) throw new Error('state must be defined'); + + const walletState = (state as any).wallet; + + return { + ...state, + wallet: { + ...walletState, + sourceAccount: { + address: walletState?.sourceAccount?.address, + chain: walletState?.sourceAccount?.chain, + walletInfo: walletState?.sourceAccount?.walletInfo, + // encryptedSignature removed - users will need to reconnect + }, + }, + }; +} diff --git a/src/state/wallet.ts b/src/state/wallet.ts index b539c3da32..ac678fc471 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; }; @@ -18,6 +17,9 @@ export interface WalletState { address?: string; solanaAddress?: 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; @@ -27,12 +29,12 @@ const initialState: WalletState = { sourceAccount: { address: undefined, chain: undefined, - encryptedSignature: undefined, walletInfo: undefined, }, localWallet: { address: undefined, subaccountNumber: 0, + walletSource: undefined, }, turnkeyEmailOnboardingData: undefined, turnkeyPrimaryWallet: undefined, @@ -47,36 +49,22 @@ export const walletSlice = createSlice({ action: PayloadAction<{ address: string; chain: WalletNetworkType }> ) => { const { address, chain } = action.payload; - if (!state.sourceAccount) { - throw new Error('cannot set source address if source account is not defined'); - } - - // if the source wallet address has changed, clear the derived signature - if (state.sourceAccount.address !== address) { - state.sourceAccount.encryptedSignature = undefined; - } - state.sourceAccount.address = address; state.sourceAccount.chain = chain; }, setWalletInfo: (state, action: PayloadAction) => { state.sourceAccount.walletInfo = action.payload; }, - setSavedEncryptedSignature: (state, action: PayloadAction) => { - if (state.sourceAccount.chain === WalletNetworkType.Cosmos) { - throw new Error('cosmos wallets should not require signatures for derived addresses'); - } - - state.sourceAccount.encryptedSignature = action.payload; - }, - clearSavedEncryptedSignature: (state) => { - state.sourceAccount.encryptedSignature = undefined; - }, setLocalWallet: ( state, { payload, - }: PayloadAction<{ address?: string; solanaAddress?: string; subaccountNumber?: number }> + }: PayloadAction<{ + address?: string; + solanaAddress?: string; + subaccountNumber?: number; + walletSource?: 'imported' | 'derived'; + }> ) => { state.localWallet = payload; }, @@ -96,7 +84,6 @@ export const walletSlice = createSlice({ state.sourceAccount = { address: undefined, chain: undefined, - encryptedSignature: undefined, walletInfo: undefined, }; state.turnkeyPrimaryWallet = undefined; @@ -130,8 +117,6 @@ export const walletEphemeralSlice = createSlice({ export const { setSourceAddress, setWalletInfo, - setSavedEncryptedSignature, - clearSavedEncryptedSignature, clearSourceAccount, setLocalWallet, setTurnkeyEmailOnboardingData, diff --git a/src/views/dialogs/OnboardingDialog.tsx b/src/views/dialogs/OnboardingDialog.tsx index 5a7f13a6ef..df6a8380d8 100644 --- a/src/views/dialogs/OnboardingDialog.tsx +++ b/src/views/dialogs/OnboardingDialog.tsx @@ -34,17 +34,21 @@ import { Ring } from '@/components/Ring'; import { WalletIcon } from '@/components/WalletIcon'; import { WithTooltip } from '@/components/WithTooltip'; -import { setDisplayChooseWallet, setOnboardedThisSession } from '@/state/account'; +import { setChooseWalletDisplay, setOnboardedThisSession } from '@/state/account'; import { calculateOnboardingStep } from '@/state/accountCalculators'; -import { useAppDispatch } from '@/state/appTypes'; +import { getChooseWalletDisplay } from '@/state/accountSelectors'; +import { useAppDispatch, useAppSelector } from '@/state/appTypes'; import { openDialog } from '@/state/dialogs'; import { track } from '@/lib/analytics/analytics'; +import { assertNever } from '@/lib/assertNever'; +import { calc } from '@/lib/do'; import { testFlags } from '@/lib/testFlags'; import { LanguageSelector } from '../menus/LanguageSelector'; import { ChooseWallet } from './OnboardingDialog/ChooseWallet'; import { GenerateKeys } from './OnboardingDialog/GenerateKeys'; +import { MobileQrScanner } from './OnboardingDialog/MobileQrScanner'; import { SignIn } from './OnboardingDialog/SignIn'; export const OnboardingDialog = ({ @@ -60,10 +64,12 @@ export const OnboardingDialog = ({ const showNewDepositFlow = useStatsigGateValue(StatsigFlags.ffDepositRewrite) || testFlags.showNewDepositFlow; const isTurnkeyEnabled = useEnableTurnkey(); - const currentOnboardingStep = useAppSelectorWithArgs(calculateOnboardingStep, isTurnkeyEnabled); + const currentOnboardingStep = useAppSelectorWithArgs(calculateOnboardingStep); + const chooseWalletDisplay = useAppSelector(getChooseWalletDisplay); const isSimpleUi = useSimpleUiEnabled(); const { dydxAddress } = useAccounts(); const privyWallet = useDisplayedWallets().find((wallet) => wallet.name === WalletType.Privy); + const [hasScannedQrCode, setHasScannedQrCode] = useState(false); const setIsOpen = useCallback( (open: boolean) => { @@ -77,7 +83,7 @@ export const OnboardingDialog = ({ useEffect(() => { return () => { - dispatch(setDisplayChooseWallet(false)); + dispatch(setChooseWalletDisplay('signin')); }; }, [dispatch]); @@ -96,12 +102,18 @@ export const OnboardingDialog = ({ const onDisplayChooseWallet = () => { track(AnalyticsEvents.OnboardingSignInWithWalletClick()); - dispatch(setDisplayChooseWallet(true)); + dispatch(setChooseWalletDisplay('wallets')); }; const onSignInWithSocials = () => { track(AnalyticsEvents.OnboardingSignInWithSocialsClick()); - dispatch(setDisplayChooseWallet(false)); + dispatch(setChooseWalletDisplay('signin')); + }; + + const onSyncFromDesktopQrCode = () => { + // TODO: Add tracking + setHasScannedQrCode(false); + dispatch(setChooseWalletDisplay('qr')); }; const onSignInWithPasskey = () => { @@ -146,87 +158,117 @@ export const OnboardingDialog = ({ ); + const signInContent = calc(() => { + switch (chooseWalletDisplay) { + case 'signin': { + return { + title: ( +
+ {stringGetter({ key: STRING_KEYS.SIGN_IN_TITLE })} + {privyUserOption} +
+ ), + description: stringGetter({ + key: STRING_KEYS.SIGN_IN_DESCRIPTION, + }), + children: ( + onSubmitEmail({ userEmail })} + /> + ), + }; + } + case 'wallets': { + return { + title: isTurnkeyEnabled ? ( + stringGetter({ key: STRING_KEYS.SIGN_IN_WITH_WALLET }) + ) : ( +
+ {stringGetter({ key: STRING_KEYS.CONNECT_YOUR_WALLET })} + <$WithTooltip + tw="text-color-text-0" + tooltipString={stringGetter({ + key: STRING_KEYS.WALLET_DEFINITION, + params: { + ABOUT_WALLETS_LINK: ( + + {stringGetter({ key: STRING_KEYS.ABOUT_WALLETS })} + + ), + }, + })} + > + <$QuestionIcon iconName={IconName.QuestionMark} /> + +
+ ), + description: isTurnkeyEnabled + ? stringGetter({ key: STRING_KEYS.SIGN_IN_DESCRIPTION }) + : stringGetter({ key: STRING_KEYS.SELECT_WALLET_FROM_OPTIONS }), + children: ( + + ), + hasFooterBorder: true, + slotFooter: !isSimpleUi && !isTurnkeyEnabled && ( + <$Footer> +
+

+ {stringGetter({ key: STRING_KEYS.SELECT_LANGUAGE })} +

+ {stringGetter({ key: STRING_KEYS.CHOOSE_PREFERRED_LANGUAGE })} +
+ <$LanguageSelector /> + + ), + }; + } + case 'qr': { + return { + title: hasScannedQrCode + ? stringGetter({ key: STRING_KEYS.VERIFY_ENCRYPTION_KEY }) + : stringGetter({ key: STRING_KEYS.SCAN_QR_CODE }), + description: hasScannedQrCode ? ( + stringGetter({ key: STRING_KEYS.VERIFY_ENCRYPTION_KEY_DIRECTIONS }) + ) : ( + + {stringGetter({ + key: STRING_KEYS.SCAN_QR_CODE_DIRECTIONS, + params: { + ICON: , + }, + })} + + ), + children: ( +
+ +
+ ), + }; + } + default: + assertNever(chooseWalletDisplay); + return null; + } + }); + return ( <$Dialog isOpen={Boolean(currentOnboardingStep)} - onBack={ - isTurnkeyEnabled && currentOnboardingStep === OnboardingSteps.ChooseWallet - ? onSignInWithSocials - : undefined - } + onBack={['signin', 'qr'].includes(chooseWalletDisplay) ? onSignInWithSocials : undefined} setIsOpen={setIsOpenFromDialog} {...(currentOnboardingStep && { [OnboardingSteps.SignIn]: { - title: ( -
- {stringGetter({ key: STRING_KEYS.SIGN_IN_TITLE })} - {privyUserOption} -
- ), - description: stringGetter({ - key: STRING_KEYS.SIGN_IN_DESCRIPTION, - }), - children: ( - <$Content> - - onSubmitEmail({ userEmail }) - } - /> - - ), - }, - [OnboardingSteps.ChooseWallet]: { - title: isTurnkeyEnabled ? ( - stringGetter({ key: STRING_KEYS.SIGN_IN_WITH_WALLET }) - ) : ( -
- {stringGetter({ key: STRING_KEYS.CONNECT_YOUR_WALLET })} - <$WithTooltip - tw="text-color-text-0" - tooltipString={stringGetter({ - key: STRING_KEYS.WALLET_DEFINITION, - params: { - ABOUT_WALLETS_LINK: ( - - {stringGetter({ key: STRING_KEYS.ABOUT_WALLETS })} - - ), - }, - })} - > - <$QuestionIcon iconName={IconName.QuestionMark} /> - -
- ), - description: isTurnkeyEnabled - ? stringGetter({ key: STRING_KEYS.SIGN_IN_DESCRIPTION }) - : stringGetter({ key: STRING_KEYS.SELECT_WALLET_FROM_OPTIONS }), - children: ( - <$Content> - - - ), - hasFooterBorder: true, - slotFooter: !isSimpleUi && !isTurnkeyEnabled && ( - <$Footer> -
-

- {stringGetter({ key: STRING_KEYS.SELECT_LANGUAGE })} -

- {stringGetter({ key: STRING_KEYS.CHOOSE_PREFERRED_LANGUAGE })} -
- <$LanguageSelector /> - - ), + ...signInContent, }, [OnboardingSteps.KeyDerivation]: { slotIcon: isSimpleUi diff --git a/src/views/dialogs/OnboardingDialog/MobileQrScanner.tsx b/src/views/dialogs/OnboardingDialog/MobileQrScanner.tsx new file mode 100644 index 0000000000..4eac689384 --- /dev/null +++ b/src/views/dialogs/OnboardingDialog/MobileQrScanner.tsx @@ -0,0 +1,159 @@ +import { useCallback, useLayoutEffect, useRef, useState } from 'react'; + +import { logBonsaiError } from '@/bonsai/logs'; +import { IDetectedBarcode, Scanner } from '@yudiel/react-qr-scanner'; +import { AES, enc } from 'crypto-js'; +import styled from 'styled-components'; + +import { AlertType } from '@/constants/alerts'; +import { ButtonAction, ButtonSize } from '@/constants/buttons'; +import { STRING_KEYS } from '@/constants/localization'; + +import { useAccounts } from '@/hooks/useAccounts'; +import { useStringGetter } from '@/hooks/useStringGetter'; + +import { AlertMessage } from '@/components/AlertMessage'; +import { Button } from '@/components/Button'; +import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/InputOtp'; + +export const MobileQrScanner = ({ + setHasScannedQrCode, +}: { + setHasScannedQrCode: (hasScannedQrCode: boolean) => void; +}) => { + const stringGetter = useStringGetter(); + const [encryptedPayload, setEncryptedPayload] = useState(''); + const [error, setError] = useState(''); + const [encryptionKey, setEncryptionKey] = useState(''); + const locked = useRef(false); // prevent duplicate fires + + const { importWallet } = useAccounts(); + + const onScan = useCallback((detectedCodes: IDetectedBarcode[]) => { + if (locked.current) return; + locked.current = true; + setEncryptedPayload(detectedCodes[0]?.rawValue ?? ''); + }, []); + + const handleError = useCallback( + (err?: unknown) => { + logBonsaiError('MobileQrScanner', 'scan error', { error: err }); + if (!encryptedPayload) { + setError(stringGetter({ key: STRING_KEYS.QR_SCAN_ERROR })); + } + }, + [encryptedPayload, stringGetter] + ); + + const reset = () => { + locked.current = false; + setEncryptedPayload(''); + setError(''); + setEncryptionKey(''); + }; + + useLayoutEffect(() => { + setHasScannedQrCode(encryptedPayload.trim().length > 0); + }, [encryptedPayload, setHasScannedQrCode]); + + const onSubmit = async () => { + try { + const decryptedPayload = AES.decrypt(encryptedPayload, encryptionKey).toString(enc.Utf8); + const payload = JSON.parse(decryptedPayload); + + if (payload && typeof payload.mnemonic === 'string' && payload.mnemonic.length > 0) { + const result = await importWallet(payload.mnemonic); + + if (result.error) { + setError(stringGetter({ key: STRING_KEYS.SOMETHING_WENT_WRONG })); + } + } else { + throw new Error('QR code could not be decrypted'); + } + } catch (err) { + logBonsaiError('MobileQrScanner', 'onSubmit failed', { error: err }); + setError(stringGetter({ key: STRING_KEYS.SOMETHING_WENT_WRONG })); + } + }; + + const displayResetButton = error && encryptedPayload; + + return ( +
+ {!encryptedPayload ? ( +
+ + + +
+ ) : ( +
+ setEncryptionKey(value)} + maxLength={6} + > + {[0, 1, 2, 3, 4, 5].map((index) => ( + // eslint-disable-next-line react/no-array-index-key + + + + ))} + +
+ )} + + {error && {error}} + +
+ {displayResetButton && ( + + )} + {!error && encryptedPayload && ( + + )} +
+
+ ); +}; + +const Finder = () => { + return ( +
+
+ <$FinderEdge tw="left-0 top-0 border-b-0 border-r-0" /> + <$FinderEdge tw="right-0 top-0 border-b-0 border-l-0" /> + <$FinderEdge tw="bottom-0 left-0 border-r-0 border-t-0" /> + <$FinderEdge tw="bottom-0 right-0 border-l-0 border-t-0" /> +
+
+ ); +}; + +const $FinderEdge = styled.div` + position: absolute; + height: 1rem; + width: 1rem; + border: 0.25rem solid var(--color-accent); +`; diff --git a/src/views/dialogs/OnboardingDialog/SignIn.tsx b/src/views/dialogs/OnboardingDialog/SignIn.tsx index 721defc6f9..c57704f145 100644 --- a/src/views/dialogs/OnboardingDialog/SignIn.tsx +++ b/src/views/dialogs/OnboardingDialog/SignIn.tsx @@ -10,6 +10,8 @@ import { STRING_KEYS } from '@/constants/localization'; import { ConnectorType, WalletInfo, wallets, WalletType } from '@/constants/wallets'; import { useAccounts } from '@/hooks/useAccounts'; +import { useBreakpoints } from '@/hooks/useBreakpoints'; +import { useCameraDetection } from '@/hooks/useCameraDetection'; import { useDisplayedWallets } from '@/hooks/useDisplayedWallets'; import { useStringGetter } from '@/hooks/useStringGetter'; import { useURLConfigs } from '@/hooks/useURLConfigs'; @@ -41,11 +43,13 @@ export const SignIn = ({ // eslint-disable-next-line @typescript-eslint/no-unused-vars onSignInWithPasskey, onSubmitEmail, + onSyncFromDesktopQrCode, }: { onChooseWallet: (wallet: WalletInfo) => void; onDisplayChooseWallet: () => void; onSignInWithPasskey: () => void; onSubmitEmail: ({ userEmail }: { userEmail: string }) => void; + onSyncFromDesktopQrCode: () => void; }) => { const stringGetter = useStringGetter(); const [email, setEmail] = useState(''); @@ -56,6 +60,8 @@ export const SignIn = ({ const { tos, privacy } = useURLConfigs(); const displayedWallets = useDisplayedWallets(); const { selectedWallet, selectedWalletError } = useAccounts(); + const { isMobile } = useBreakpoints(); + const { hasCamera } = useCameraDetection(); const onSubmit = useCallback( async (e: React.FormEvent) => { @@ -151,6 +157,22 @@ export const SignIn = ({ */} + {isMobile && hasCamera && ( + <$OtherOptionButton + type={ButtonType.Button} + action={ButtonAction.Base} + size={ButtonSize.BasePlus} + onClick={onSyncFromDesktopQrCode} + > +
+ + {stringGetter({ key: STRING_KEYS.LINK_DESKTOP_WALLET })} +
+ + + + )} + {displayedWallets .filter( (wallet) => @@ -182,7 +204,7 @@ export const SignIn = ({ onClick={onDisplayChooseWallet} >
- + {stringGetter({ key: STRING_KEYS.VIEW_MORE_WALLETS })}
diff --git a/src/views/dialogs/TransferDialogs/DepositDialog2/DepositForm/DepositForm.tsx b/src/views/dialogs/TransferDialogs/DepositDialog2/DepositForm/DepositForm.tsx index 56ed6887f6..3685030263 100644 --- a/src/views/dialogs/TransferDialogs/DepositDialog2/DepositForm/DepositForm.tsx +++ b/src/views/dialogs/TransferDialogs/DepositDialog2/DepositForm/DepositForm.tsx @@ -211,7 +211,7 @@ export const DepositForm = ({ const connectWagmi = async () => { try { setAwaitingWalletAction(true); - await connectWallet({ wallet: selectedWallet, forceConnect: true }); + await connectWallet({ wallet: selectedWallet }); setAwaitingWalletAction(false); } catch (e) { setAwaitingWalletAction(false); diff --git a/src/views/dialogs/TransferDialogs/DepositDialog2/depositHooks.ts b/src/views/dialogs/TransferDialogs/DepositDialog2/depositHooks.ts index 3a889a5315..a03d60d4a3 100644 --- a/src/views/dialogs/TransferDialogs/DepositDialog2/depositHooks.ts +++ b/src/views/dialogs/TransferDialogs/DepositDialog2/depositHooks.ts @@ -1,3 +1,5 @@ +import { useMemo } from 'react'; + import { logBonsaiError, logBonsaiInfo } from '@/bonsai/logs'; import { OfflineSigner } from '@cosmjs/proto-signing'; import { Erc20Approval, RouteResponse } from '@skip-go/client'; @@ -8,6 +10,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 +22,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 +63,16 @@ export function useDepositSteps({ const stringGetter = useStringGetter(); const walletChainId = useChainId(); const { skipClient } = useSkipClient(); - const { nobleAddress, dydxAddress, osmosisAddress } = useAccounts(); + const { dydxAddress, nobleAddress } = useAccounts(); + + const osmosisAddress = useMemo(() => { + if (!dydxAddress) return undefined; + + return 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..a169e16a74 100644 --- a/src/views/dialogs/TransferDialogs/DepositDialog2/queries.ts +++ b/src/views/dialogs/TransferDialogs/DepositDialog2/queries.ts @@ -9,7 +9,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 +26,30 @@ 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, nobleAddress } = useAccounts(); const { skipClient } = useSkipClient(); + const { osmosisAddress, neutronAddress } = useMemo(() => { + if (!dydxAddress) return { osmosisAddress: undefined, neutronAddress: undefined }; + + return { + osmosisAddress: convertBech32Address({ + address: dydxAddress as string, + bech32Prefix: OSMO_BECH32_PREFIX, + }), + neutronAddress: convertBech32Address({ + address: dydxAddress as string, + bech32Prefix: NEUTRON_BECH32_PREFIX, + }), + }; + }, [dydxAddress]); + return useQuery({ queryKey: ['balances', sourceAccount.address, nobleAddress, osmosisAddress, neutronAddress], queryFn: async () => { diff --git a/src/views/dialogs/TransferDialogs/WithdrawDialog2/withdrawHooks.ts b/src/views/dialogs/TransferDialogs/WithdrawDialog2/withdrawHooks.ts index 719ade2032..1bc578cbaa 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, + localNobleWallet, + 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,15 @@ export function useWithdrawStep({ return undefined; } + const [osmosisAddress, neutronAddress] = await Promise.all([ + getOsmosisAddress(), + getNeutronAddress(), + ]); + + if (!nobleAddress || !osmosisAddress || !neutronAddress) { + throw new Error('Failed to derive Cosmos addresses'); + } + return getUserAddressesForRoute( withdrawRoute, sourceAccount, @@ -85,9 +95,8 @@ export function useWithdrawStep({ ); }, [ dydxAddress, - neutronAddress, - nobleAddress, - osmosisAddress, + getOsmosisAddress, + getNeutronAddress, sourceAccount, withdrawRoute, destinationAddress, @@ -114,6 +123,10 @@ export function useWithdrawStep({ try { setIsLoading(true); if (!withdrawRoute) throw new Error('No route found'); + + // Derive user addresses and Noble wallet on-demand + const userAddresses = await getUserAddresses(); + if (!userAddresses) throw new Error('No user addresses found'); if (!localDydxWallet || !localNobleWallet || !dydxAddress) { throw new Error('No local wallets found'); diff --git a/src/views/menus/AccountMenu/AccountMenu.tsx b/src/views/menus/AccountMenu/AccountMenu.tsx index 1ac4873471..e44a8a09c3 100644 --- a/src/views/menus/AccountMenu/AccountMenu.tsx +++ b/src/views/menus/AccountMenu/AccountMenu.tsx @@ -411,7 +411,7 @@ export const AccountMenu = () => { onboardingState === OnboardingState.AccountConnected && hdKey && { value: 'MobileQrSignIn', - icon: , + icon: , label: stringGetter({ key: STRING_KEYS.TITLE_SIGN_INTO_MOBILE }), onSelect: () => dispatch(openDialog(DialogTypes.MobileSignIn({}))), },