Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-headless-solflare-mobile-deeplink.md
Original file line number Diff line number Diff line change
@@ -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 `<mobile_link>wc?uri=…` URL that Solflare's app does not handle. Matches the existing headful `selectWalletConnector` behavior.
5 changes: 5 additions & 0 deletions .changeset/fix-solflare-evm-universal-link.md
Original file line number Diff line number Diff line change
@@ -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/<dapp_url>` 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.
17 changes: 14 additions & 3 deletions packages/controllers/exports/react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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://<wallet>/ul/v1/browse/<dapp_url>`). Building
* `<mobile_link>wc?uri=<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' })
Expand Down
13 changes: 10 additions & 3 deletions packages/controllers/src/utils/MobileWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<dapp_url>`
* 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) {
Expand Down Expand Up @@ -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}`
Expand Down
165 changes: 165 additions & 0 deletions packages/controllers/tests/hooks/react.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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>): 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()
})
})
})
24 changes: 21 additions & 3 deletions packages/controllers/tests/utils/MobileWallet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})

Expand Down Expand Up @@ -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)
})

Expand Down
Loading