diff --git a/.changeset/headless-wallets-on-client.md b/.changeset/headless-wallets-on-client.md new file mode 100644 index 0000000000..1769051e83 --- /dev/null +++ b/.changeset/headless-wallets-on-client.md @@ -0,0 +1,32 @@ +--- +'@reown/appkit-utils': patch +'@reown/appkit': patch +'@reown/appkit-cdn': patch +'@reown/appkit-cli': patch +'@reown/appkit-codemod': patch +'@reown/appkit-common': patch +'@reown/appkit-core': patch +'@reown/appkit-experimental': patch +'@reown/appkit-pay': patch +'@reown/appkit-polyfills': patch +'@reown/appkit-scaffold-ui': patch +'@reown/appkit-siwe': patch +'@reown/appkit-siwx': patch +'@reown/appkit-testing': patch +'@reown/appkit-ui': patch +'@reown/appkit-universal-connector': patch +'@reown/appkit-wallet-button': patch +'@reown/appkit-wallet': patch +'@reown/appkit-controllers': patch +'@reown/appkit-adapter-bitcoin': patch +'@reown/appkit-adapter-ethers': patch +'@reown/appkit-adapter-ethers5': patch +'@reown/appkit-adapter-solana': patch +'@reown/appkit-adapter-ton': patch +'@reown/appkit-adapter-tron': patch +'@reown/appkit-adapter-wagmi': patch +--- + +Expose the headless wallet list imperatively on the AppKit client, so a non-React host can list / search / connect wallets without the `useAppKitWallets` React hook. + +New `AppKit` instance methods: `fetchWallets(options?)`, `getWalletList()`, `subscribeWalletList(cb)`, `getWalletConnectUri(options?)`, and `connectWallet(wallet, namespace?, options?)`. The shared imperative logic lives in a new `HeadlessWalletUtil` (`@reown/appkit-controllers`), which both the client methods and the React hook can use — one tested code path for headless wallet listing, search, pagination, the WalletConnect URI, and programmatic connect (injected / API / mobile-deeplink). diff --git a/examples/html-headless/index.html b/examples/html-headless/index.html new file mode 100644 index 0000000000..43bfa5ac39 --- /dev/null +++ b/examples/html-headless/index.html @@ -0,0 +1,46 @@ + + + + + + AppKit Headless (vanilla) Example + + + +
+

AppKit Headless — vanilla JS

+

+ A custom wallet modal driven entirely by the imperative AppKit client API (appKit.fetchWallets + / getWalletList / subscribeWalletList / + connectWallet) — no React, no AppKit modal. +

+ + + + + + + + + +
+ + + + diff --git a/examples/html-headless/package.json b/examples/html-headless/package.json new file mode 100644 index 0000000000..c91c909ccd --- /dev/null +++ b/examples/html-headless/package.json @@ -0,0 +1,16 @@ +{ + "name": "@examples/html-headless", + "private": true, + "version": "1.2.0", + "scripts": { + "dev": "vite --port 3013", + "build": "vite build" + }, + "dependencies": { + "@reown/appkit-adapter-wagmi": "workspace:*", + "@reown/appkit": "workspace:*" + }, + "devDependencies": { + "vite": "5.4.20" + } +} diff --git a/examples/html-headless/src/main.css b/examples/html-headless/src/main.css new file mode 100644 index 0000000000..4ec22e7442 --- /dev/null +++ b/examples/html-headless/src/main.css @@ -0,0 +1,172 @@ +:root { + --bg: #0b0d12; + --panel: #151821; + --border: #262b38; + --text: #e7e9ee; + --muted: #9aa3b2; + --accent: #3396ff; + font-family: + ui-sans-serif, + system-ui, + -apple-system, + sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: var(--bg); + color: var(--text); + display: flex; + justify-content: center; + padding: 3rem 1rem; +} + +.page { + width: 100%; + max-width: 30rem; +} + +.title { + margin: 0; + font-size: 1.5rem; +} + +.subtitle { + color: var(--muted); + font-size: 0.9rem; + line-height: 1.5; +} + +.subtitle code { + color: var(--accent); + font-size: 0.82rem; +} + +.btn { + padding: 0.7rem 1.1rem; + border: 0; + border-radius: 10px; + background: var(--accent); + color: #fff; + font-size: 0.95rem; + cursor: pointer; +} + +.btn--outline { + background: transparent; + border: 1px solid var(--border); + color: var(--text); + padding: 0.4rem 0.75rem; + font-size: 0.85rem; +} + +.account { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.85rem 1rem; + margin-bottom: 1rem; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 12px; +} + +.account__address { + font-family: ui-monospace, monospace; +} + +/* -- Modal ----------------------------------------------------------------- */ +.modal { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.modal__backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.55); +} + +.modal__card { + position: relative; + width: 100%; + max-width: 24rem; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 16px; + padding: 1.25rem; +} + +.modal__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; +} + +.modal__header h2 { + margin: 0; + font-size: 1.1rem; +} + +.modal__close { + background: none; + border: 0; + color: var(--muted); + font-size: 1.4rem; + cursor: pointer; + line-height: 1; +} + +.wallet-list { + max-height: 22rem; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.wallet { + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; + padding: 0.6rem 0.7rem; + background: transparent; + border: 1px solid var(--border); + border-radius: 10px; + color: var(--text); + cursor: pointer; + font-size: 0.95rem; + text-align: left; +} + +.wallet:hover { + border-color: var(--accent); +} + +.wallet__icon { + width: 2rem; + height: 2rem; + border-radius: 8px; + object-fit: cover; + background: var(--bg); + flex-shrink: 0; +} + +.wallet-list__status { + margin: 0.75rem 0 0; + text-align: center; + font-size: 0.8rem; + color: var(--muted); +} diff --git a/examples/html-headless/src/main.js b/examples/html-headless/src/main.js new file mode 100644 index 0000000000..6b32ba28d2 --- /dev/null +++ b/examples/html-headless/src/main.js @@ -0,0 +1,140 @@ +import { createAppKit } from '@reown/appkit' +import { WagmiAdapter } from '@reown/appkit-adapter-wagmi' +import { mainnet, polygon } from '@reown/appkit/networks' + +// Public projectId for localhost only — create your own at https://dashboard.reown.com +const projectId = import.meta.env.VITE_PROJECT_ID || 'b56e18d47c72ab683b10814fe9495694' +const networks = [mainnet, polygon] + +const wagmiAdapter = new WagmiAdapter({ networks, projectId }) + +// Headless: no AppKit modal — this app renders its own wallet list and connects +// programmatically through the imperative client API. +const appKit = createAppKit({ + adapters: [wagmiAdapter], + networks, + projectId, + features: { headless: true }, + metadata: { + name: 'AppKit Headless HTML', + description: 'Headless wallet list driven by the imperative AppKit client API', + url: window.location.origin, + icons: [] + } +}) + +// -- DOM --------------------------------------------------------------------- // +const openBtn = document.getElementById('open') +const modal = document.getElementById('modal') +const listEl = document.getElementById('wallet-list') +const statusEl = document.getElementById('list-status') +const accountEl = document.getElementById('account') +const accountAddressEl = document.getElementById('account-address') +const disconnectBtn = document.getElementById('disconnect') + +// -- Wallet list (infinite loading) ----------------------------------------- // +let page = 0 +let loading = false + +/** Whether more pages are available (the explorer reports a higher total than loaded). */ +function hasMore() { + const { wcWallets, count } = appKit.getWalletList() + + return wcWallets.length < count +} + +/** Fetch the next page of WalletConnect wallets (appends to the list). */ +async function loadMore() { + if (loading || (page > 0 && !hasMore())) { + return + } + loading = true + statusEl.textContent = 'Loading…' + try { + page += 1 + await appKit.fetchWallets({ page }) // ← imperative client API; appends to the list + } catch (error) { + statusEl.textContent = 'Failed to load wallets.' + // eslint-disable-next-line no-console + console.error(error) + } finally { + loading = false + } +} + +/** Render the current wallet list (called on every wallet-list change). */ +function renderWallets() { + const { wcWallets, count } = appKit.getWalletList() // ← imperative client API + listEl.replaceChildren( + ...wcWallets.map(wallet => { + const item = document.createElement('button') + item.className = 'wallet' + item.innerHTML = ` + + ${wallet.name}` + item.addEventListener('click', () => connect(wallet)) + + return item + }) + ) + statusEl.textContent = wcWallets.length + ? `${wcWallets.length} of ${count} wallets` + : 'No wallets yet' +} + +// Re-render whenever the wallet list / search / pagination state changes. +appKit.subscribeWalletList(renderWallets) // ← imperative client API + +// Infinite scroll: load the next page as the user nears the bottom. +listEl.addEventListener('scroll', () => { + if (listEl.scrollTop + listEl.clientHeight >= listEl.scrollHeight - 120 && hasMore()) { + void loadMore() + } +}) + +// -- Connect ----------------------------------------------------------------- // +async function connect(wallet) { + statusEl.textContent = `Connecting ${wallet.name}…` + try { + await appKit.connectWallet(wallet, 'eip155') // ← imperative client API + } catch (error) { + statusEl.textContent = `Failed to connect ${wallet.name}.` + // eslint-disable-next-line no-console + console.error(error) + } +} + +// -- Account state ----------------------------------------------------------- // +function renderAccount() { + const account = appKit.getAccount('eip155') + const connected = Boolean(account?.address) + accountEl.hidden = !connected + openBtn.hidden = connected + if (connected) { + const a = account.address + accountAddressEl.textContent = `${a.slice(0, 6)}…${a.slice(-4)}` + closeModal() + } +} + +appKit.subscribeAccount(renderAccount, 'eip155') +disconnectBtn.addEventListener('click', () => appKit.disconnect('eip155')) + +// -- Modal open/close -------------------------------------------------------- // +function openModal() { + modal.hidden = false + if (page === 0) { + void loadMore() // first page on first open + } +} + +function closeModal() { + modal.hidden = true +} + +openBtn.addEventListener('click', openModal) +modal.querySelectorAll('[data-close]').forEach(el => el.addEventListener('click', closeModal)) + +// Initial paint. +renderAccount() +renderWallets() diff --git a/packages/adapters/tron/src/connectors/TronWalletConnectConnector.ts b/packages/adapters/tron/src/connectors/TronWalletConnectConnector.ts index b3b384bee8..aab659645a 100644 --- a/packages/adapters/tron/src/connectors/TronWalletConnectConnector.ts +++ b/packages/adapters/tron/src/connectors/TronWalletConnectConnector.ts @@ -110,10 +110,12 @@ export class TronWalletConnectConnector throw new Error(unsignedTx?.Error || 'Failed to create transaction') } - // Step 2: Send full transaction to wallet for signing via WalletConnect. - // Wallets opt into the simplified (v1) payload shape by advertising - // `tron_method_version: "v1"` in sessionProperties during the handshake. - // Otherwise the spec mandates the legacy nested `transaction.transaction` shape. + /* + * Step 2: Send full transaction to wallet for signing via WalletConnect. + * Wallets opt into the simplified (v1) payload shape by advertising + * `tron_method_version: "v1"` in sessionProperties during the handshake. + * Otherwise the spec mandates the legacy nested `transaction.transaction` shape. + */ // See https://docs.reown.com/advanced/multichain/rpc-reference/tron-rpc const usesV1Format = this.provider.session?.sessionProperties?.['tron_method_version'] === 'v1' const signRequest = { diff --git a/packages/appkit/src/client/appkit-base-client.ts b/packages/appkit/src/client/appkit-base-client.ts index 7e702aaa44..c79d601427 100644 --- a/packages/appkit/src/client/appkit-base-client.ts +++ b/packages/appkit/src/client/appkit-base-client.ts @@ -20,6 +20,7 @@ import type { ChainAdapterConnector, ConnectExternalOptions, ConnectMethod, + ConnectOptions, ConnectedWalletInfo, ConnectionControllerClient, ConnectionControllerState, @@ -27,6 +28,7 @@ import type { EstimateGasTransactionArgs, EventsControllerState, Features, + FetchWalletsOptions, ModalControllerState, NamespaceTypeMap, OptionsControllerState, @@ -41,6 +43,8 @@ import type { UseAppKitNetworkReturn, User, WalletFeature, + WalletItem, + WalletListSnapshot, WriteContractArgs, WriteSolanaTransactionArgs } from '@reown/appkit-controllers' @@ -59,6 +63,7 @@ import { CoreHelperUtil, EnsController, EventsController, + HeadlessWalletUtil, ModalController, OnRampController, OptionsController, @@ -2317,6 +2322,51 @@ export abstract class AppKitBaseClient { await ConnectionController.disconnect({ namespace: chainNamespace }) } + /* + * Headless wallet list — imperative counterparts of the `useAppKitWallets` React hook, + * so a non-React host (e.g. `@walletconnect/pay-appkit`) can list / search / connect + * wallets headlessly through the AppKit instance. Both share the same code path via + * `HeadlessWalletUtil`. + */ + + /** + * Fetch / search / paginate the WalletConnect wallet list (WalletGuide explorer). Read + * the results with {@link getWalletList}; subscribe with {@link subscribeWalletList}. + */ + public async fetchWallets(options?: FetchWalletsOptions) { + await HeadlessWalletUtil.fetchWallets(options) + } + + /** The current headless wallet list (initial view + WalletConnect list + pagination). */ + public getWalletList(): WalletListSnapshot { + return HeadlessWalletUtil.getWalletList() + } + + /** Subscribe to wallet-list changes. Returns an unsubscribe. */ + public subscribeWalletList(callback: () => void) { + return HeadlessWalletUtil.subscribeWalletList(callback) + } + + /** + * Pre-fetch the WalletConnect URI (read from {@link getState} / `subscribeConnections`). + * Call when a wallet is selected so a later connect can deeplink synchronously (iOS). + */ + public async getWalletConnectUri(options?: ConnectOptions) { + await HeadlessWalletUtil.getWalletConnectUri(options) + } + + /** + * Connect a chosen wallet programmatically (headless — no modal). Handles injected, + * API ("all wallets"), and mobile-deeplink wallets. + */ + public async connectWallet( + wallet: WalletItem, + namespace?: ChainNamespace, + options?: ConnectOptions + ) { + await HeadlessWalletUtil.connect(wallet, namespace, options) + } + public getSIWX() { return OptionsController.state.siwx as SIWXConfigInterface | undefined } diff --git a/packages/controllers/exports/index.ts b/packages/controllers/exports/index.ts index 5677558ee6..ecc8984c43 100644 --- a/packages/controllers/exports/index.ts +++ b/packages/controllers/exports/index.ts @@ -130,5 +130,11 @@ export { MobileWalletUtil } from '../src/utils/MobileWallet.js' export type * from '../src/utils/TypeUtil.js' export type * from '../src/utils/SIWXUtil.js' export type { WalletItem } from '../src/utils/ConnectUtil.js' +export { HeadlessWalletUtil } from '../src/utils/HeadlessWalletUtil.js' +export type { + ConnectOptions, + FetchWalletsOptions, + WalletListSnapshot +} from '../src/utils/HeadlessWalletUtil.js' export * from '../src/utils/ChainControllerUtil.js' export * from '../src/utils/WalletConnectUtil.js' diff --git a/packages/controllers/exports/react.ts b/packages/controllers/exports/react.ts index 35b3fa9ac2..024be73d6e 100644 --- a/packages/controllers/exports/react.ts +++ b/packages/controllers/exports/react.ts @@ -23,9 +23,9 @@ import { ConnectUtil, 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 { type ConnectOptions, type FetchWalletsOptions } from '../src/utils/HeadlessWalletUtil.js' import { MobileWalletUtil } from '../src/utils/MobileWallet.js' import type { - BadgeType, UseAppKitAccountReturn, UseAppKitNetworkReturn, WcWallet @@ -62,9 +62,11 @@ interface DeleteRecentConnectionProps { connectorId: string } -export interface ConnectOptions { - wcPayUrl?: string -} +/* + * `ConnectOptions` / `FetchWalletsOptions` are defined once in `HeadlessWalletUtil` + * (the framework-neutral home) and re-exported here for the hook's public surface. + */ +export type { ConnectOptions, FetchWalletsOptions } from '../src/utils/HeadlessWalletUtil.js' // -- Hooks ------------------------------------------------------------ export function useAppKitProvider(chainNamespace: ChainNamespace) { @@ -310,30 +312,6 @@ export function useAppKitConnection({ namespace, onSuccess, onError }: UseAppKit } } -export interface FetchWalletsOptions { - /** Page number to fetch (default: 1) */ - page?: number - /** @deprecated Use `search` instead */ - query?: string - /** Search query to filter wallets. When provided, switches to search mode. */ - search?: string - /** Number of entries per page. Defaults to 40 for list mode, 100 for search mode. */ - entries?: number - /** Filter wallets by badge type ('none' | 'certified') */ - badge?: BadgeType - /** Wallet IDs to include. Overrides the global includeWalletIds config when provided. */ - include?: string[] - /** Wallet IDs to exclude. Overrides the default exclude list when provided. */ - exclude?: string[] - /** - * Include wallets that support WalletConnect Pay but are not v2-compatible. - * By default these are filtered out. Enable for WalletConnect Pay surfaces. - */ - includePayOnly?: boolean - /** Sort mode. 'wcpay' bubbles WalletConnect Pay-supporting wallets to the top. */ - sort?: 'default' | 'wcpay' -} - export interface UseAppKitWalletsReturn { /** * List of wallets for the initial connect view including WalletConnect wallet and injected wallets together. If user doesn't have any injected wallets, it'll fill the list with most ranked WalletConnect wallets. diff --git a/packages/controllers/src/utils/HeadlessWalletUtil.ts b/packages/controllers/src/utils/HeadlessWalletUtil.ts new file mode 100644 index 0000000000..1ecf1c3dd7 --- /dev/null +++ b/packages/controllers/src/utils/HeadlessWalletUtil.ts @@ -0,0 +1,205 @@ +import { subscribeKey as subKey } from 'valtio/vanilla/utils' + +import type { ChainNamespace } from '@reown/appkit-common' + +import { ApiController } from '../controllers/ApiController.js' +import { ChainController } from '../controllers/ChainController.js' +import { ConnectionController } from '../controllers/ConnectionController.js' +import { ConnectorController } from '../controllers/ConnectorController.js' +import { OptionsController } from '../controllers/OptionsController.js' +import { PublicStateController } from '../controllers/PublicStateController.js' +import { ConnectUtil, type WalletItem } from './ConnectUtil.js' +import { ConnectionControllerUtil } from './ConnectionControllerUtil.js' +import { ConnectorControllerUtil } from './ConnectorControllerUtil.js' +import { CoreHelperUtil } from './CoreHelperUtil.js' +import { MobileWalletUtil } from './MobileWallet.js' + +// -- Types ------------------------------------------------------------------ // + +/** Options for {@link HeadlessWalletUtil.fetchWallets}. */ +export interface FetchWalletsOptions { + /** Page number to fetch (default: 1) */ + page?: number + /** @deprecated Use `search` instead */ + query?: string + /** Search query to filter wallets. When provided, switches to search mode. */ + search?: string + /** Number of entries per page. Defaults to 40 for list mode, 100 for search mode. */ + entries?: number + /** Filter wallets by badge type ('none' | 'certified') */ + badge?: 'none' | 'certified' + /** Wallet IDs to include. Overrides the global includeWalletIds config when provided. */ + include?: string[] + /** Wallet IDs to exclude. Overrides the default exclude list when provided. */ + exclude?: string[] + /** + * Include wallets that support WalletConnect Pay but are not v2-compatible. + * By default these are filtered out. Enable for WalletConnect Pay surfaces. + */ + includePayOnly?: boolean + /** Sort mode. 'wcpay' bubbles WalletConnect Pay-supporting wallets to the top. */ + sort?: 'default' | 'wcpay' +} + +/** Options for {@link HeadlessWalletUtil.connect} / {@link HeadlessWalletUtil.getWalletConnectUri}. */ +export interface ConnectOptions { + /** + * A WalletConnect Pay deeplink appended to the WC URI, so a WCPay-capable wallet + * returns to — and processes — this payment after pairing. + */ + wcPayUrl?: string +} + +/** The headless wallet list, read imperatively. */ +export interface WalletListSnapshot { + /** Initial connect-view wallets: installed extensions + top-ranked WalletConnect wallets. */ + wallets: WalletItem[] + /** The full WalletGuide WalletConnect list (with the current search results applied). */ + wcWallets: WalletItem[] + /** Current page of the WalletConnect list. */ + page: number + /** Total number of available WalletConnect wallets for the current parameters. */ + count: number +} + +/** + * Framework-agnostic headless wallet-list logic — the imperative core behind the + * `useAppKitWallets` React hook and the `AppKit` client's wallet methods. + * + * AppKit runs headless (no modal): the host renders its own picker and connects a + * chosen wallet programmatically. That orchestration (list / search / paginate the + * WalletGuide wallets, fetch the WalletConnect URI, and connect injected / API / mobile + * wallets) lived only inside the React hook; lifting it here lets a non-React host + * (e.g. `@walletconnect/pay-appkit`) drive the same flow through `appKit.*` methods, + * with the hook and the client sharing one tested code path. + * + * Reads/writes the global controllers directly (valtio singletons), so it takes no + * AppKit instance — exactly like {@link ConnectionControllerUtil}. + */ +export const HeadlessWalletUtil = { + /** + * Fetch / search / paginate the WalletConnect wallet list from the explorer API. + * With a `search` (or the deprecated `query`), switches to search mode; otherwise + * lists/paginates. Reads results from `ApiController.state` (see {@link getWalletList}). + */ + async fetchWallets(fetchOptions?: FetchWalletsOptions): Promise { + const { query, ...options } = fetchOptions ?? {} + const search = options.search ?? query + + if (search) { + await ApiController.searchWallet({ ...options, search }) + } else { + ApiController.state.search = [] + await ApiController.fetchWalletsByPage({ page: 1, ...options }) + } + }, + + /** Read the current wallet list (initial view + WalletConnect list + pagination). */ + getWalletList(): WalletListSnapshot { + return { + wallets: ConnectUtil.getInitialWallets(), + wcWallets: ConnectUtil.getWalletConnectWallets( + ApiController.state.wallets, + ApiController.state.search + ), + page: ApiController.state.page, + count: ApiController.state.count + } + }, + + /** Subscribe to wallet-list changes (the WalletGuide list + search results). */ + subscribeWalletList(callback: () => void): () => void { + const unsubscribers = [ + subKey(ApiController.state, 'wallets', callback), + subKey(ApiController.state, 'search', callback), + subKey(ApiController.state, 'page', callback), + subKey(ApiController.state, 'count', callback) + ] + + return () => unsubscribers.forEach(unsubscribe => unsubscribe()) + }, + + /** + * Pre-fetch the WalletConnect URI (read from `ConnectionController.state.wcUri`). + * Call when a wallet is selected so a later connect can deeplink synchronously + * (required for iOS Safari). Uses 'auto' cache to reuse a valid URI or fetch a new one. + */ + async getWalletConnectUri(_options?: ConnectOptions): Promise { + this.resetWcUri() + await ConnectionController.connectWalletConnect({ cache: 'auto' }) + }, + + /** + * Connect a chosen wallet programmatically (headless — no modal). + * + * Handles injected wallets, API wallets (from the "all wallets" list), and mobile + * deeplinks. For API wallets without pre-populated connectors, falls back to a lookup + * by the wallet's ID via `explorerId` matching (e.g. Coinbase/Base from "all wallets"). + */ + async connect( + wallet: WalletItem, + namespace?: ChainNamespace, + options?: ConnectOptions + ): Promise { + PublicStateController.set({ connectingWallet: wallet }) + const isMobileDevice = CoreHelperUtil.isMobile() + + // Fall back to the active chain if no namespace is given (matches headful behavior). + const activeNamespace = namespace || ChainController.state.activeChain + + try { + const walletConnector = wallet?.connectors.find(c => c.chain === activeNamespace) + + const connector = + walletConnector && activeNamespace + ? ConnectorController.getConnector({ + id: walletConnector?.id, + namespace: activeNamespace + }) + : undefined + + /* + * Fallback connector lookup for API wallets (empty `connectors`): find a connector + * by the wallet's API id (getConnector matches both `c.id` and `c.explorerId`), so + * e.g. the Base Account connector opens Coinbase's web wallet instead of falling + * through to WalletConnect. Matches headful `ConnectorController.selectWalletConnector`. + */ + const fallbackConnector = + !connector && activeNamespace + ? ConnectorController.getConnector({ id: wallet?.id, namespace: activeNamespace }) + : undefined + + if (wallet?.isInjected && connector) { + await ConnectorControllerUtil.connectExternal(connector) + } else if (fallbackConnector) { + await ConnectorControllerUtil.connectExternal(fallbackConnector) + } else if (isMobileDevice) { + const wcWallet = ConnectUtil.mapWalletItemToWcWallet(wallet) + + if (wcWallet.mobile_link) { + ConnectionControllerUtil.onConnectMobile(wcWallet, options?.wcPayUrl) + } else { + MobileWalletUtil.handleMobileDeeplinkRedirect(wallet.id, activeNamespace, { + isCoinbaseDisabled: OptionsController.state.enableCoinbase === false + }) + } + } else { + await ConnectionController.connectWalletConnect({ cache: 'never' }) + } + } catch (error) { + PublicStateController.set({ connectingWallet: undefined }) + throw error + } + }, + + /** Reset the WalletConnect URI + linking state (e.g. when a QR is closed). */ + resetWcUri(): void { + ConnectionController.resetUri() + ConnectionController.setWcLinking(undefined) + }, + + /** Clear the `connectingWallet` state. */ + resetConnectingWallet(): void { + PublicStateController.set({ connectingWallet: undefined }) + } +} diff --git a/packages/controllers/tests/utils/HeadlessWalletUtil.test.ts b/packages/controllers/tests/utils/HeadlessWalletUtil.test.ts new file mode 100644 index 0000000000..ccb4bab764 --- /dev/null +++ b/packages/controllers/tests/utils/HeadlessWalletUtil.test.ts @@ -0,0 +1,233 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { ChainNamespace } from '@reown/appkit-common' + +import { ApiController } from '../../src/controllers/ApiController.js' +import { ChainController } from '../../src/controllers/ChainController.js' +import { ConnectionController } from '../../src/controllers/ConnectionController.js' +import { ConnectorController } from '../../src/controllers/ConnectorController.js' +import { OptionsController } from '../../src/controllers/OptionsController.js' +import { PublicStateController } from '../../src/controllers/PublicStateController.js' +import { ConnectUtil, 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 { HeadlessWalletUtil } from '../../src/utils/HeadlessWalletUtil.js' +import { MobileWalletUtil } from '../../src/utils/MobileWallet.js' + +const injectedWallet: WalletItem = { + id: 'mm', + name: 'MetaMask', + imageUrl: 'icon', + connectors: [{ id: 'io.metamask', chain: 'eip155' }], + walletInfo: {}, + isInjected: true, + isRecent: false +} + +const apiWallet: WalletItem = { + id: 'wc_wallet', + name: 'Some Wallet', + imageUrl: 'icon', + connectors: [], + walletInfo: {}, + isInjected: false, + isRecent: false +} + +beforeEach(() => { + vi.restoreAllMocks() + vi.spyOn(ChainController, 'state', 'get').mockReturnValue({ + activeChain: 'eip155' + } as never) + vi.spyOn(PublicStateController, 'set').mockImplementation(vi.fn()) +}) + +describe('HeadlessWalletUtil.fetchWallets', () => { + it('searches when a search term is given', async () => { + const searchWallet = vi + .spyOn(ApiController, 'searchWallet') + .mockResolvedValue(undefined as never) + const fetchByPage = vi.spyOn(ApiController, 'fetchWalletsByPage') + + await HeadlessWalletUtil.fetchWallets({ search: 'rainbow', includePayOnly: true }) + + expect(searchWallet).toHaveBeenCalledWith({ includePayOnly: true, search: 'rainbow' }) + expect(fetchByPage).not.toHaveBeenCalled() + }) + + it('maps the deprecated `query` to `search`', async () => { + const searchWallet = vi + .spyOn(ApiController, 'searchWallet') + .mockResolvedValue(undefined as never) + + await HeadlessWalletUtil.fetchWallets({ query: 'rbw' }) + + expect(searchWallet).toHaveBeenCalledWith({ search: 'rbw' }) + }) + + it('lists from page 1 and clears prior search when no search term', async () => { + const fetchByPage = vi + .spyOn(ApiController, 'fetchWalletsByPage') + .mockResolvedValue(undefined as never) + ApiController.state.search = [{ id: 'stale' }] as never + + await HeadlessWalletUtil.fetchWallets({ sort: 'wcpay' }) + + expect(ApiController.state.search).toEqual([]) + expect(fetchByPage).toHaveBeenCalledWith({ page: 1, sort: 'wcpay' }) + }) +}) + +describe('HeadlessWalletUtil.getWalletList', () => { + it('projects the initial + WalletConnect lists with pagination', () => { + vi.spyOn(ConnectUtil, 'getInitialWallets').mockReturnValue([injectedWallet]) + vi.spyOn(ConnectUtil, 'getWalletConnectWallets').mockReturnValue([apiWallet]) + ApiController.state.page = 2 + ApiController.state.count = 99 + + expect(HeadlessWalletUtil.getWalletList()).toEqual({ + wallets: [injectedWallet], + wcWallets: [apiWallet], + page: 2, + count: 99 + }) + }) +}) + +describe('HeadlessWalletUtil.connect', () => { + it('connects an injected wallet via its connector', async () => { + const connector = { id: 'io.metamask' } + vi.spyOn(ConnectorController, 'getConnector').mockReturnValue(connector as never) + const connectExternal = vi + .spyOn(ConnectorControllerUtil, 'connectExternal') + .mockResolvedValue(undefined as never) + + await HeadlessWalletUtil.connect(injectedWallet, 'eip155') + + expect(PublicStateController.set).toHaveBeenCalledWith({ connectingWallet: injectedWallet }) + expect(connectExternal).toHaveBeenCalledWith(connector) + }) + + it('falls back to a connector found by wallet id (API wallet)', async () => { + const fallback = { id: 'baseAccount', explorerId: 'wc_wallet' } + // apiWallet has no connectors, so the first (guarded) lookup is skipped — only the + // fallback lookup by wallet id runs, and it resolves the Base Account connector. + const getConnector = vi + .spyOn(ConnectorController, 'getConnector') + .mockReturnValue(fallback as never) + const connectExternal = vi + .spyOn(ConnectorControllerUtil, 'connectExternal') + .mockResolvedValue(undefined as never) + + await HeadlessWalletUtil.connect(apiWallet, 'eip155') + + expect(getConnector).toHaveBeenCalledTimes(1) + expect(getConnector).toHaveBeenCalledWith({ id: 'wc_wallet', namespace: 'eip155' }) + expect(connectExternal).toHaveBeenCalledWith(fallback) + }) + + it('deeplinks on mobile when the wallet declares a mobile link', async () => { + vi.spyOn(ConnectorController, 'getConnector').mockReturnValue(undefined as never) + vi.spyOn(CoreHelperUtil, 'isMobile').mockReturnValue(true) + vi.spyOn(ConnectUtil, 'mapWalletItemToWcWallet').mockReturnValue({ + id: 'wc_wallet', + name: 'Some Wallet', + mobile_link: 'somewallet://' + } as never) + const onConnectMobile = vi + .spyOn(ConnectionControllerUtil, 'onConnectMobile') + .mockImplementation(vi.fn()) + + await HeadlessWalletUtil.connect(apiWallet, 'eip155', { wcPayUrl: 'https://pay/x' }) + + expect(onConnectMobile).toHaveBeenCalledWith( + expect.objectContaining({ mobile_link: 'somewallet://' }), + 'https://pay/x' + ) + }) + + it('redirects via MobileWalletUtil on mobile when there is no mobile link', async () => { + vi.spyOn(ConnectorController, 'getConnector').mockReturnValue(undefined as never) + vi.spyOn(CoreHelperUtil, 'isMobile').mockReturnValue(true) + vi.spyOn(ConnectUtil, 'mapWalletItemToWcWallet').mockReturnValue({ + id: 'wc_wallet', + name: 'Some Wallet' + } as never) + vi.spyOn(OptionsController, 'state', 'get').mockReturnValue({ enableCoinbase: true } as never) + const redirect = vi + .spyOn(MobileWalletUtil, 'handleMobileDeeplinkRedirect') + .mockImplementation(vi.fn()) + + await HeadlessWalletUtil.connect(apiWallet, 'eip155') + + expect(redirect).toHaveBeenCalledWith('wc_wallet', 'eip155', { isCoinbaseDisabled: false }) + }) + + it('falls back to WalletConnect on desktop', async () => { + vi.spyOn(ConnectorController, 'getConnector').mockReturnValue(undefined as never) + vi.spyOn(CoreHelperUtil, 'isMobile').mockReturnValue(false) + const connectWc = vi + .spyOn(ConnectionController, 'connectWalletConnect') + .mockResolvedValue(undefined as never) + + await HeadlessWalletUtil.connect(apiWallet, 'eip155') + + expect(connectWc).toHaveBeenCalledWith({ cache: 'never' }) + }) + + it('clears connectingWallet and rethrows on failure', async () => { + vi.spyOn(ConnectorController, 'getConnector').mockReturnValue({ id: 'io.metamask' } as never) + vi.spyOn(ConnectorControllerUtil, 'connectExternal').mockRejectedValue(new Error('boom')) + + await expect(HeadlessWalletUtil.connect(injectedWallet, 'eip155')).rejects.toThrow('boom') + expect(PublicStateController.set).toHaveBeenLastCalledWith({ connectingWallet: undefined }) + }) + + it('falls back to the active chain when no namespace is given', async () => { + const getConnector = vi + .spyOn(ConnectorController, 'getConnector') + .mockReturnValue({ id: 'io.metamask' } as never) + vi.spyOn(ConnectorControllerUtil, 'connectExternal').mockResolvedValue(undefined as never) + + await HeadlessWalletUtil.connect(injectedWallet) + + expect(getConnector).toHaveBeenCalledWith( + expect.objectContaining({ namespace: 'eip155' as ChainNamespace }) + ) + }) +}) + +describe('HeadlessWalletUtil.getWalletConnectUri', () => { + it('resets then fetches the URI with auto cache', async () => { + const resetUri = vi.spyOn(ConnectionController, 'resetUri').mockImplementation(vi.fn()) + const setWcLinking = vi.spyOn(ConnectionController, 'setWcLinking').mockImplementation(vi.fn()) + const connectWc = vi + .spyOn(ConnectionController, 'connectWalletConnect') + .mockResolvedValue(undefined as never) + + await HeadlessWalletUtil.getWalletConnectUri() + + expect(resetUri).toHaveBeenCalled() + expect(setWcLinking).toHaveBeenCalledWith(undefined) + expect(connectWc).toHaveBeenCalledWith({ cache: 'auto' }) + }) +}) + +describe('HeadlessWalletUtil resets', () => { + it('resetWcUri resets the URI + linking', () => { + const resetUri = vi.spyOn(ConnectionController, 'resetUri').mockImplementation(vi.fn()) + const setWcLinking = vi.spyOn(ConnectionController, 'setWcLinking').mockImplementation(vi.fn()) + + HeadlessWalletUtil.resetWcUri() + + expect(resetUri).toHaveBeenCalled() + expect(setWcLinking).toHaveBeenCalledWith(undefined) + }) + + it('resetConnectingWallet clears the connecting wallet', () => { + HeadlessWalletUtil.resetConnectingWallet() + + expect(PublicStateController.set).toHaveBeenCalledWith({ connectingWallet: undefined }) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 809e0bde2b..3dca2acb08 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -896,6 +896,19 @@ importers: specifier: 5.4.20 version: 5.4.20(@types/node@22.13.9)(lightningcss@1.30.2)(terser@5.44.1) + examples/html-headless: + dependencies: + '@reown/appkit': + specifier: workspace:* + version: link:../../packages/appkit + '@reown/appkit-adapter-wagmi': + specifier: workspace:* + version: link:../../packages/adapters/wagmi + devDependencies: + vite: + specifier: 5.4.20 + version: 5.4.20(@types/node@22.13.9)(lightningcss@1.30.2)(terser@5.44.1) + examples/html-solana: dependencies: '@reown/appkit':