Skip to content
Merged
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
34 changes: 34 additions & 0 deletions .changeset/headless-read-wcuri.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 20 additions & 4 deletions packages/appkit/src/client/appkit-base-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import type {
UseAppKitAccountReturn,
UseAppKitNetworkReturn,
User,
WalletConnectUriSnapshot,
WalletFeature,
WalletItem,
WalletListSnapshot,
Expand Down Expand Up @@ -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)
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/controllers/exports/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
50 changes: 45 additions & 5 deletions packages/controllers/src/utils/HeadlessWalletUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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<void> {
async prefetchWalletConnectUri(_options?: ConnectOptions): Promise<void> {
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).
*
Expand Down
56 changes: 54 additions & 2 deletions packages/controllers/tests/utils/HeadlessWalletUtil.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,22 +198,74 @@ 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())
const connectWc = vi
.spyOn(ConnectionController, 'connectWalletConnect')
.mockResolvedValue(undefined as never)

await HeadlessWalletUtil.getWalletConnectUri()
await HeadlessWalletUtil.prefetchWalletConnectUri()

expect(resetUri).toHaveBeenCalled()
expect(setWcLinking).toHaveBeenCalledWith(undefined)
expect(connectWc).toHaveBeenCalledWith({ cache: 'auto' })
})
})

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())
Expand Down
Loading