diff --git a/.changeset/headless-read-wcuri.md b/.changeset/headless-read-wcuri.md new file mode 100644 index 0000000000..446f4c3c97 --- /dev/null +++ b/.changeset/headless-read-wcuri.md @@ -0,0 +1,34 @@ +--- +'@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 +--- + +Add a headless read for the WalletConnect URI, so a host can render a QR without the `useAppKitWallets` React hook. + +The AppKit instance now exposes `getWalletConnectUri()` — returning `{ wcUri, wcError, wcFetchingUri }` — and `subscribeWalletConnectUri()`. Both read the connection layer directly (mirroring the existing `getWalletList()` / `subscribeWalletList()` pair), so a headless host gets the URI ungated through the instance without importing `@reown/appkit-controllers` (which can otherwise resolve to a different valtio singleton). This replaces the connection-level `subscribeConnections`, which is gated behind the `multiWallet` remote feature and so can't serve the URI for a single-wallet QR. + +**Breaking:** the imperative pre-fetch trigger previously named `getWalletConnectUri()` is renamed to `prefetchWalletConnectUri()`, freeing `getWalletConnectUri()` for the new read. diff --git a/packages/appkit/src/client/appkit-base-client.ts b/packages/appkit/src/client/appkit-base-client.ts index c79d601427..b5caee5e56 100644 --- a/packages/appkit/src/client/appkit-base-client.ts +++ b/packages/appkit/src/client/appkit-base-client.ts @@ -42,6 +42,7 @@ import type { UseAppKitAccountReturn, UseAppKitNetworkReturn, User, + WalletConnectUriSnapshot, WalletFeature, WalletItem, WalletListSnapshot, @@ -2348,11 +2349,26 @@ export abstract class AppKitBaseClient { } /** - * Pre-fetch the WalletConnect URI (read from {@link getState} / `subscribeConnections`). - * Call when a wallet is selected so a later connect can deeplink synchronously (iOS). + * Pre-fetch the WalletConnect URI. Read the result with {@link getWalletConnectUri}; subscribe + * with {@link subscribeWalletConnectUri}. Call when a wallet is selected so a later connect can + * deeplink synchronously (iOS) or render a QR. */ - public async getWalletConnectUri(options?: ConnectOptions) { - await HeadlessWalletUtil.getWalletConnectUri(options) + public async prefetchWalletConnectUri(options?: ConnectOptions) { + await HeadlessWalletUtil.prefetchWalletConnectUri(options) + } + + /** + * The current WalletConnect URI state (QR / deeplink URI + fetch/error signals) — the + * symmetric read for {@link prefetchWalletConnectUri}. Reads the connection layer directly, so + * a headless host gets it ungated through the AppKit instance. + */ + public getWalletConnectUri(): WalletConnectUriSnapshot { + return HeadlessWalletUtil.getWalletConnectUri() + } + + /** Subscribe to WalletConnect URI state changes. Returns an unsubscribe. */ + public subscribeWalletConnectUri(callback: () => void) { + return HeadlessWalletUtil.subscribeWalletConnectUri(callback) } /** diff --git a/packages/controllers/exports/index.ts b/packages/controllers/exports/index.ts index ecc8984c43..3a785dab5a 100644 --- a/packages/controllers/exports/index.ts +++ b/packages/controllers/exports/index.ts @@ -134,6 +134,7 @@ export { HeadlessWalletUtil } from '../src/utils/HeadlessWalletUtil.js' export type { ConnectOptions, FetchWalletsOptions, + WalletConnectUriSnapshot, WalletListSnapshot } from '../src/utils/HeadlessWalletUtil.js' export * from '../src/utils/ChainControllerUtil.js' diff --git a/packages/controllers/src/utils/HeadlessWalletUtil.ts b/packages/controllers/src/utils/HeadlessWalletUtil.ts index 1ecf1c3dd7..96f3b41cf7 100644 --- a/packages/controllers/src/utils/HeadlessWalletUtil.ts +++ b/packages/controllers/src/utils/HeadlessWalletUtil.ts @@ -41,7 +41,7 @@ export interface FetchWalletsOptions { sort?: 'default' | 'wcpay' } -/** Options for {@link HeadlessWalletUtil.connect} / {@link HeadlessWalletUtil.getWalletConnectUri}. */ +/** Options for {@link HeadlessWalletUtil.connect} / {@link HeadlessWalletUtil.prefetchWalletConnectUri}. */ export interface ConnectOptions { /** * A WalletConnect Pay deeplink appended to the WC URI, so a WCPay-capable wallet @@ -62,6 +62,16 @@ export interface WalletListSnapshot { count: number } +/** The WalletConnect URI + connection-attempt signals, read imperatively. */ +export interface WalletConnectUriSnapshot { + /** Active WalletConnect pairing URI (for rendering a QR / deeplink), or undefined when none. */ + wcUri: string | undefined + /** Whether the last WalletConnect URI fetch / connection attempt errored. */ + wcError: boolean + /** Whether a WalletConnect URI is currently being fetched. */ + wcFetchingUri: boolean +} + /** * Framework-agnostic headless wallet-list logic — the imperative core behind the * `useAppKitWallets` React hook and the `AppKit` client's wallet methods. @@ -120,15 +130,45 @@ export const HeadlessWalletUtil = { }, /** - * 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. + * Pre-fetch the WalletConnect URI. Read the result with {@link getWalletConnectUri}; + * subscribe with {@link subscribeWalletConnectUri}. Call when a wallet is selected so a + * later connect can deeplink synchronously (required for iOS Safari) or render a QR. Uses + * 'auto' cache to reuse a valid URI or fetch a new one. */ - async getWalletConnectUri(_options?: ConnectOptions): Promise { + async prefetchWalletConnectUri(_options?: ConnectOptions): Promise { this.resetWcUri() await ConnectionController.connectWalletConnect({ cache: 'auto' }) }, + /** + * Read the current WalletConnect URI state (the QR / deeplink URI plus the fetch/error + * signals) — the symmetric read for {@link prefetchWalletConnectUri}. Reads + * `ConnectionController` directly, so a headless host gets it ungated through the AppKit + * instance without importing the controllers package. + */ + getWalletConnectUri(): WalletConnectUriSnapshot { + return { + wcUri: ConnectionController.state.wcUri, + wcError: Boolean(ConnectionController.state.wcError), + wcFetchingUri: ConnectionController.state.wcFetchingUri + } + }, + + /** + * Subscribe to WalletConnect URI state changes (wcUri / wcError / wcFetchingUri). The + * callback receives no arguments — read the current values with {@link getWalletConnectUri}. + * Returns an unsubscribe. + */ + subscribeWalletConnectUri(callback: () => void): () => void { + const unsubscribers = [ + subKey(ConnectionController.state, 'wcUri', callback), + subKey(ConnectionController.state, 'wcError', callback), + subKey(ConnectionController.state, 'wcFetchingUri', callback) + ] + + return () => unsubscribers.forEach(unsubscribe => unsubscribe()) + }, + /** * Connect a chosen wallet programmatically (headless — no modal). * diff --git a/packages/controllers/tests/utils/HeadlessWalletUtil.test.ts b/packages/controllers/tests/utils/HeadlessWalletUtil.test.ts index ccb4bab764..b2c5d00672 100644 --- a/packages/controllers/tests/utils/HeadlessWalletUtil.test.ts +++ b/packages/controllers/tests/utils/HeadlessWalletUtil.test.ts @@ -198,7 +198,7 @@ describe('HeadlessWalletUtil.connect', () => { }) }) -describe('HeadlessWalletUtil.getWalletConnectUri', () => { +describe('HeadlessWalletUtil.prefetchWalletConnectUri', () => { 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()) @@ -206,7 +206,7 @@ describe('HeadlessWalletUtil.getWalletConnectUri', () => { .spyOn(ConnectionController, 'connectWalletConnect') .mockResolvedValue(undefined as never) - await HeadlessWalletUtil.getWalletConnectUri() + await HeadlessWalletUtil.prefetchWalletConnectUri() expect(resetUri).toHaveBeenCalled() expect(setWcLinking).toHaveBeenCalledWith(undefined) @@ -214,6 +214,58 @@ describe('HeadlessWalletUtil.getWalletConnectUri', () => { }) }) +describe('HeadlessWalletUtil.getWalletConnectUri', () => { + it('reads wcUri / wcError / wcFetchingUri from the connection layer', () => { + ConnectionController.state.wcUri = 'wc:read-test' + ConnectionController.state.wcError = true + ConnectionController.state.wcFetchingUri = false + + expect(HeadlessWalletUtil.getWalletConnectUri()).toEqual({ + wcUri: 'wc:read-test', + wcError: true, + wcFetchingUri: false + }) + }) + + it('coerces a missing wcError to a boolean', () => { + ConnectionController.state.wcUri = undefined + ConnectionController.state.wcError = undefined + ConnectionController.state.wcFetchingUri = true + + expect(HeadlessWalletUtil.getWalletConnectUri()).toEqual({ + wcUri: undefined, + wcError: false, + wcFetchingUri: true + }) + }) +}) + +describe('HeadlessWalletUtil.subscribeWalletConnectUri', () => { + it('fires on wcUri / wcError / wcFetchingUri changes and unsubscribes cleanly', async () => { + const flush = () => new Promise(resolve => setTimeout(resolve, 0)) + const callback = vi.fn() + + // Establish a known baseline before subscribing so each mutation below is a real change. + ConnectionController.state.wcUri = undefined + ConnectionController.state.wcFetchingUri = false + + const unsubscribe = HeadlessWalletUtil.subscribeWalletConnectUri(callback) + + ConnectionController.state.wcUri = 'wc:sub-test' + await flush() + expect(callback).toHaveBeenCalledTimes(1) + + ConnectionController.state.wcFetchingUri = true + await flush() + expect(callback).toHaveBeenCalledTimes(2) + + unsubscribe() + ConnectionController.state.wcUri = 'wc:after-unsub' + await flush() + expect(callback).toHaveBeenCalledTimes(2) + }) +}) + describe('HeadlessWalletUtil resets', () => { it('resetWcUri resets the URI + linking', () => { const resetUri = vi.spyOn(ConnectionController, 'resetUri').mockImplementation(vi.fn())