diff --git a/.changeset/fix-headless-solflare-mobile-deeplink.md b/.changeset/fix-headless-solflare-mobile-deeplink.md new file mode 100644 index 0000000000..ccb5ed12b5 --- /dev/null +++ b/.changeset/fix-headless-solflare-mobile-deeplink.md @@ -0,0 +1,5 @@ +--- +'@reown/appkit-controllers': patch +--- + +Fix Solflare and other custom-deeplink wallets failing to open on mobile in headless mode. The headless `connect()` now routes Phantom, Solflare, Coinbase, and Binance through `MobileWalletUtil.handleMobileDeeplinkRedirect` (their Universal Link `…/ul/v1/browse/…` format) instead of building a `wc?uri=…` URL that Solflare's app does not handle. Matches the existing headful `selectWalletConnector` behavior. diff --git a/.changeset/fix-solflare-evm-universal-link.md b/.changeset/fix-solflare-evm-universal-link.md new file mode 100644 index 0000000000..d4b07bf218 --- /dev/null +++ b/.changeset/fix-solflare-evm-universal-link.md @@ -0,0 +1,5 @@ +--- +'@reown/appkit-controllers': patch +--- + +Allow Solflare's Universal Link to also open when the active namespace is EVM, not only Solana. Solflare's in-app browser exposes both `window.solflare` (Solana) and `window.ethereum` (EVM), so the same `https://solflare.com/ul/v1/browse/` deeplink is the correct entry point for EVM dApps too. Without this, EVM-only consumers (no Solana adapter registered) fell through to `solflare://wc?uri=…`, which Solflare's app does not handle. diff --git a/packages/controllers/exports/react.ts b/packages/controllers/exports/react.ts index 1776e62377..02626360f8 100644 --- a/packages/controllers/exports/react.ts +++ b/packages/controllers/exports/react.ts @@ -555,13 +555,24 @@ export function useAppKitWallets(): UseAppKitWalletsReturn { await ConnectorControllerUtil.connectExternal(fallbackConnector) } else if (isMobileDevice) { const wcWallet = ConnectUtil.mapWalletItemToWcWallet(_wallet) + const isCustomDeeplinkWallet = MobileWalletUtil.isCustomDeeplinkWallet( + _wallet.id, + activeNamespace + ) - if (wcWallet.mobile_link) { - ConnectionControllerUtil.onConnectMobile(wcWallet, options?.wcPayUrl) - } else { + /* + * Custom-deeplink wallets (Phantom, Solflare, Coinbase, Binance) either + * don't support WalletConnect or expect a specific browse-URL format + * (`https:///ul/v1/browse/`). Building + * `wc?uri=` here would silently fail for Solflare. + * Matches headful selectWalletConnector behavior. + */ + if (isCustomDeeplinkWallet || !wcWallet.mobile_link) { MobileWalletUtil.handleMobileDeeplinkRedirect(_wallet.id, activeNamespace, { isCoinbaseDisabled: OptionsController.state.enableCoinbase === false }) + } else { + ConnectionControllerUtil.onConnectMobile(wcWallet, options?.wcPayUrl) } } else { await ConnectionController.connectWalletConnect({ cache: 'never' }) diff --git a/packages/controllers/src/utils/MobileWallet.ts b/packages/controllers/src/utils/MobileWallet.ts index 991109ba06..55d2751bbe 100644 --- a/packages/controllers/src/utils/MobileWallet.ts +++ b/packages/controllers/src/utils/MobileWallet.ts @@ -63,7 +63,14 @@ export const MobileWalletUtil = { } if (id === CUSTOM_DEEPLINK_WALLETS.SOLFLARE.id) { - return namespace === ConstantsUtil.CHAIN.SOLANA + /* + * Solflare's in-app browser exposes both `window.solflare` (Solana) and + * `window.ethereum` (EVM), so the same `…/ul/v1/browse/` + * Universal Link is the correct entry point for either chain. + * The `solflare://wc?uri=…` deeplink that the WC fallback would build + * is not handled by Solflare's app. + */ + return namespace === ConstantsUtil.CHAIN.SOLANA || namespace === ConstantsUtil.CHAIN.EVM } if (id === CUSTOM_DEEPLINK_WALLETS.COINBASE.id) { @@ -121,10 +128,10 @@ export const MobileWalletUtil = { } } - // Solflare only supports Solana + // Solflare's in-app browser supports both Solana and EVM dApps if ( id === CUSTOM_DEEPLINK_WALLETS.SOLFLARE.id && - namespace === ConstantsUtil.CHAIN.SOLANA && + (namespace === ConstantsUtil.CHAIN.SOLANA || namespace === ConstantsUtil.CHAIN.EVM) && !('solflare' in window) ) { window.location.href = `${CUSTOM_DEEPLINK_WALLETS.SOLFLARE.url}/ul/v1/browse/${encodedHref}?ref=${encodedHref}` diff --git a/packages/controllers/tests/hooks/react.test.ts b/packages/controllers/tests/hooks/react.test.ts index 23cd3ff2c0..9004057e65 100644 --- a/packages/controllers/tests/hooks/react.test.ts +++ b/packages/controllers/tests/hooks/react.test.ts @@ -27,6 +27,8 @@ import { ConnectUtil } from '../../src/utils/ConnectUtil.js' import type { WalletItem } from '../../src/utils/ConnectUtil.js' import { ConnectionControllerUtil } from '../../src/utils/ConnectionControllerUtil.js' import { ConnectorControllerUtil } from '../../src/utils/ConnectorControllerUtil.js' +import { CoreHelperUtil } from '../../src/utils/CoreHelperUtil.js' +import { CUSTOM_DEEPLINK_WALLETS, MobileWalletUtil } from '../../src/utils/MobileWallet.js' vi.mock('valtio', () => ({ useSnapshot: vi.fn() @@ -1695,4 +1697,167 @@ describe('useAppKitWallets', () => { expect(resetUriSpy).toHaveBeenCalled() expect(setWcLinkingSpy).toHaveBeenCalledWith(undefined) }) + + describe('connect() mobile deeplink routing', () => { + function mockHeadlessSnapshots() { + useSnapshot + .mockReturnValueOnce({ + features: { headless: true }, + remoteFeatures: { headless: true } + }) + .mockReturnValueOnce({ + wcUri: undefined, + wcFetchingUri: false + }) + .mockReturnValueOnce({ + wallets: [], + search: [], + page: 1, + count: 0 + }) + .mockReturnValueOnce({ + initialized: true, + connectingWallet: undefined + }) + .mockReturnValueOnce({ + clientId: null + }) + + vi.spyOn(ConnectUtil, 'getInitialWallets').mockReturnValue([]) + vi.spyOn(ConnectUtil, 'getWalletConnectWallets').mockReturnValue([]) + } + + function makeMobileWallet(overrides: Partial): WalletItem { + return { + id: 'wallet-id', + name: 'Wallet', + imageUrl: '', + connectors: [], + isInjected: false, + isRecent: false, + walletInfo: {}, + ...overrides + } + } + + it.each([['solana' as const], ['eip155' as const]])( + 'uses handleMobileDeeplinkRedirect for Solflare on %s even when mobile_link is set', + async namespace => { + mockHeadlessSnapshots() + vi.spyOn(CoreHelperUtil, 'isMobile').mockReturnValue(true) + vi.spyOn(ChainController.state, 'activeChain', 'get').mockReturnValue(namespace) + + const handleRedirectSpy = vi + .spyOn(MobileWalletUtil, 'handleMobileDeeplinkRedirect') + .mockImplementation(() => undefined) + const onConnectMobileSpy = vi + .spyOn(ConnectionControllerUtil, 'onConnectMobile') + .mockImplementation(() => undefined) + + const solflare = makeMobileWallet({ + id: CUSTOM_DEEPLINK_WALLETS.SOLFLARE.id, + name: 'Solflare', + walletInfo: { deepLink: 'solflare://' } + }) + + const result = useAppKitWallets() + await result.connect(solflare, namespace) + + expect(handleRedirectSpy).toHaveBeenCalledWith( + CUSTOM_DEEPLINK_WALLETS.SOLFLARE.id, + namespace, + expect.objectContaining({ isCoinbaseDisabled: expect.any(Boolean) }) + ) + expect(onConnectMobileSpy).not.toHaveBeenCalled() + } + ) + + it.each([['solana' as const], ['eip155' as const], ['bip122' as const]])( + 'uses handleMobileDeeplinkRedirect for Phantom on %s even when mobile_link is set', + async namespace => { + mockHeadlessSnapshots() + vi.spyOn(CoreHelperUtil, 'isMobile').mockReturnValue(true) + vi.spyOn(ChainController.state, 'activeChain', 'get').mockReturnValue(namespace) + + const handleRedirectSpy = vi + .spyOn(MobileWalletUtil, 'handleMobileDeeplinkRedirect') + .mockImplementation(() => undefined) + const onConnectMobileSpy = vi + .spyOn(ConnectionControllerUtil, 'onConnectMobile') + .mockImplementation(() => undefined) + + const phantom = makeMobileWallet({ + id: CUSTOM_DEEPLINK_WALLETS.PHANTOM.id, + name: 'Phantom', + walletInfo: { deepLink: 'https://phantom.app/ul/v1/' } + }) + + const result = useAppKitWallets() + await result.connect(phantom, namespace) + + expect(handleRedirectSpy).toHaveBeenCalledWith( + CUSTOM_DEEPLINK_WALLETS.PHANTOM.id, + namespace, + expect.objectContaining({ isCoinbaseDisabled: expect.any(Boolean) }) + ) + expect(onConnectMobileSpy).not.toHaveBeenCalled() + } + ) + + it('uses onConnectMobile for a regular WC wallet that has mobile_link', async () => { + mockHeadlessSnapshots() + vi.spyOn(CoreHelperUtil, 'isMobile').mockReturnValue(true) + vi.spyOn(ChainController.state, 'activeChain', 'get').mockReturnValue('eip155') + + const handleRedirectSpy = vi + .spyOn(MobileWalletUtil, 'handleMobileDeeplinkRedirect') + .mockImplementation(() => undefined) + const onConnectMobileSpy = vi + .spyOn(ConnectionControllerUtil, 'onConnectMobile') + .mockImplementation(() => undefined) + + const trust = makeMobileWallet({ + id: 'trust-wallet-id', + name: 'Trust', + walletInfo: { deepLink: 'trust://' } + }) + + const result = useAppKitWallets() + await result.connect(trust, 'eip155') + + expect(onConnectMobileSpy).toHaveBeenCalledTimes(1) + const [walletArg] = onConnectMobileSpy.mock.calls[0] ?? [] + expect(walletArg).toMatchObject({ id: 'trust-wallet-id', mobile_link: 'trust://' }) + expect(handleRedirectSpy).not.toHaveBeenCalled() + }) + + it('falls back to handleMobileDeeplinkRedirect when wallet has no mobile_link', async () => { + mockHeadlessSnapshots() + vi.spyOn(CoreHelperUtil, 'isMobile').mockReturnValue(true) + vi.spyOn(ChainController.state, 'activeChain', 'get').mockReturnValue('eip155') + + const handleRedirectSpy = vi + .spyOn(MobileWalletUtil, 'handleMobileDeeplinkRedirect') + .mockImplementation(() => undefined) + const onConnectMobileSpy = vi + .spyOn(ConnectionControllerUtil, 'onConnectMobile') + .mockImplementation(() => undefined) + + const unknown = makeMobileWallet({ + id: 'unknown-wallet-id', + name: 'Unknown', + walletInfo: {} + }) + + const result = useAppKitWallets() + await result.connect(unknown, 'eip155') + + expect(handleRedirectSpy).toHaveBeenCalledWith( + 'unknown-wallet-id', + 'eip155', + expect.objectContaining({ isCoinbaseDisabled: expect.any(Boolean) }) + ) + expect(onConnectMobileSpy).not.toHaveBeenCalled() + }) + }) }) diff --git a/packages/controllers/tests/utils/MobileWallet.test.ts b/packages/controllers/tests/utils/MobileWallet.test.ts index 1b81c596a1..32b763551a 100644 --- a/packages/controllers/tests/utils/MobileWallet.test.ts +++ b/packages/controllers/tests/utils/MobileWallet.test.ts @@ -193,13 +193,25 @@ describe('MobileWalletUtil', () => { expect(window.location.href).toBe(expectedUrl) }) - it('should not redirect to Solflare on non-Solana namespaces', () => { - const originalHref = window.location.href + it('should redirect to Solflare correctly on EVM (in-app browser supports EVM dApps)', () => { MobileWalletUtil.handleMobileDeeplinkRedirect( CUSTOM_DEEPLINK_WALLETS.SOLFLARE.id, ConstantsUtil.CHAIN.EVM ) + const encodedHref = encodeURIComponent(ORIGINAL_HREF) + const expectedUrl = `${CUSTOM_DEEPLINK_WALLETS.SOLFLARE.url}/ul/v1/browse/${encodedHref}?ref=${encodedHref}` + + expect(window.location.href).toBe(expectedUrl) + }) + + it('should not redirect to Solflare on unsupported namespaces (bitcoin)', () => { + const originalHref = window.location.href + MobileWalletUtil.handleMobileDeeplinkRedirect( + CUSTOM_DEEPLINK_WALLETS.SOLFLARE.id, + ConstantsUtil.CHAIN.BITCOIN + ) + expect(window.location.href).toBe(originalHref) }) @@ -246,9 +258,15 @@ describe('MobileWalletUtil', () => { ).toBe(true) }) - it('should return false for Solflare wallet on EVM', () => { + it('should return true for Solflare wallet on EVM', () => { expect( MobileWalletUtil.isCustomDeeplinkWallet(CUSTOM_DEEPLINK_WALLETS.SOLFLARE.id, 'eip155') + ).toBe(true) + }) + + it('should return false for Solflare wallet on Bitcoin', () => { + expect( + MobileWalletUtil.isCustomDeeplinkWallet(CUSTOM_DEEPLINK_WALLETS.SOLFLARE.id, 'bip122') ).toBe(false) })