Mobile Wallet Deeplinking
Preface
In building the mobile workflow with wallet integration, Rito experimented with a variety of approaches to connecting mobile wallet apps with the RitoSwap dApp in mobile browsers, mostly deep-link schemes (MetaMask SDK, custom URIs from other wallets, etc.) and studied how leading dApps (like OpenSea) implement them. WalletConnect v2 using the WC URI deeplink proved universally supported and predictably reliable across major mobile browsers and wallet apps, and is already a popular choice among Web 3 users. Other methods proved unreliable or inconsistent so the decision was made to ONLY use WC for external mobile sessions. This minimizes surface area for bugs and allows a focused optimization effort on a single, battle-tested protocol.
This document provides an architectural breakdown of a solution to a notable workflow issue in using Wallet Connect deeplinking on mobile to ensure a smooth user experience. Given WC is the only method of mobile connection supported by RitoSwap, this architecture is critically important.
Overview
Quick Summary
We’ve built a small wrapper around Wagmi’s WalletConnect connector that captures, sanitizes, and persists your mobile deep-link URI so you can programmatically pop up the OS wallet chooser via openWallet()
.
- Capture & sanitize the WalletConnect deep-link URI on pairing/tx request.
- Persist only the session “topic” so you can rebuild
wc:<topic>@2
across reloads. - Expose an
openWallet()
helper that doeswindow.location.href = "wc:<topic>@2"
. - Decouple UI from session logic: React components read from a central Zustand store.
Deeper Dive
This integration layer extends Wagmi’s built-in WalletConnect connector to fill two critical gaps:
-
URI capture & persistence
While Wagmi fires adisplay_uri
event whenever it generates or receives a WalletConnect deep-link URI, it doesn’t persist that URI or extract the session topic for later use—once you reload the page, all you know is “I’m connected,” not how to reconnect or re-invoke the wallet. Our hook listens for Wagmi’sdisplay_uri
events, sanitizes and normalizes the URI (stripping out unwanted HTTP redirects or blacklisted domains), extracts the topic (the 32-byte ID afterwc:
), and then stores only that topic in a persistent Zustand store. From that topic we can always reconstruct a cleanwc:<topic>@2
URI across reloads. -
Clean, declarative deep-link helper
We expose a simpleopenWallet()
action from the store. Under the hood it always rebuilds the standardwc:<topic>@2
URI and assigns it towindow.location.href
, which triggers the OS “Choose an App” dialog on mobile and hands off to whichever WalletConnect-compliant wallet the user prefers. All of this happens outside of your React components—your UI only needs to readdisplayUri
or callopenWallet()
, never worry about event listeners, sanitization, or persistence.
By decoupling the UI from session logic (using React components to read state from a central store, rather than manage WalletConnect events themselves), we keep the UX layer simple and focused while providing a rock-solid deep-link plumbing layer for mobile wallets.
UX Problem & Solution
Problem (general dApp pain point)
Most WalletConnect v2 integrations silently send the eth_sendTransaction
RPC to the connected mobile wallet in the background. On mobile browsers this means:
- The user never leaves the browser automatically.
- They have to manually switch to their wallet app, approve the tx, then switch back to the browser to see confirmation.
- This manual app switching is confusing, easy to forget, and disrupts a smooth dApp interaction.
Wagmi’s connector helps by emitting display_uri
events so your code can know when a URI is ready, but it doesn’t store that URI or topic anywhere—so after a reload you can’t reconstruct your deep-link, and you still have no way to programmatically trigger the wallet chooser at the right moment.
Injected wallets inside a mobile browser (e.g. MetaMask Mobile “in-app”) already avoid this issue—this deep-link layer is only for external WalletConnect wallets.
Solution
- Immediate deep-link invocation
After you call your write/contract method (e.g.mint()
), check ifdisplayUri
is set and immediately invokeopenWallet()
. That reliably pops up the OS app chooser so the user can tap their wallet and get straight into approval. - Auto app switch-back
Most mobile wallets (including MetaMask Mobile) automatically return focus to the browser once the transaction is confirmed, so the user lands back in your dApp without manual navigation. - Cross-wallet compatibility
Any WalletConnect v2-compliant wallet will listen forwc:<topic>@2
. By persisting and normalizing just the topic, we ensure every major mobile wallet like Rainbow, Trust, OneKey, etc., works out of the box—no custom URIs, no edge-case redirects.
Deeplinking is generally the default behavior of first connecting a mobile browser dApp with a mobile wallet app through Wallet Connect. But deeplinking thereafter to interact with a connected wallet is normally absent. Thus the user has experience using deeplinking during the connection process, so continuing to use it for other purposes maintains continuity rather than introducing new hurdles to learn.
Userflow
Video
Screenshots of WC Deeplink Triggering OS Chooser Across Browsers
Chrome

Architecture
- useWalletConnectSession.ts
- walletConnectStore.ts
- WalletConnectProvider.tsx
File Responsibility Matrix
File Path | Responsibility |
---|---|
app/providers/WalletConnectProvider.tsx | Bootstraps the global session hook |
app/hooks/useWalletConnectSession.ts | Listens for & sanitizes WalletConnect URIs, manages lifecycle |
app/store/walletConnectStore.ts | Persists session topic + URI, exposes openWallet() helper |
app/mint/components/ButtonSection/ButtonSection.tsx | Consumes the store to send txs and invoke openWallet() |
1. Global Provider
// app/providers/WalletConnectProvider.tsx
"use client";
import { useWalletConnectSession } from '@/app/hooks/useWalletConnectSession';
export function WalletConnectProvider({ children }: { children: React.ReactNode }) {
// Activates the global WalletConnect session‐listening hook
useWalletConnectSession();
return <>{children}</>;
}
Purpose: Ensures useWalletConnectSession()
runs once at the top level (e.g. root layout), so WalletConnect wiring is active on every page.
2. Session Hook (useWalletConnectSession)
// app/hooks/useWalletConnectSession.ts
import { useEffect, useRef } from 'react';
import { useAccount } from 'wagmi';
import { useWalletConnectStore } from '@/app/store/walletConnectStore';
export function useWalletConnectSession() {
const { connector, isConnected } = useAccount();
const { setDisplayUri, setSessionTopic, clearSession } = useWalletConnectStore();
const initRef = useRef(false);
const cleanupRef = useRef<() => void>(null);
// A) Prevent blacklisted popups/redirects on Android Chrome
useEffect(() => {
// …preventRedirect & window.open override…
}, []);
// B) When the WalletConnect connector is active:
useEffect(() => {
if (connector?.id !== 'walletConnect' || !isConnected) return;
if (initRef.current) return;
initRef.current = true;
(async () => {
const provider = await connector.getProvider();
const handleDisplayUri = (uri: string) => {
// sanitize, extract topic, then save
setSessionTopic(topic);
setDisplayUri(cleanUri);
};
provider.on('display_uri', handleDisplayUri);
// restore existing session after reload
if (provider.session?.topic) {
setSessionTopic(provider.session.topic);
setDisplayUri(`wc:${provider.session.topic}@2`);
}
cleanupRef.current = () => provider.off('display_uri', handleDisplayUri);
})();
}, [connector?.id, isConnected]);
// C) On disconnect, clear session
useEffect(() => {
if (!isConnected) clearSession();
}, [isConnected, clearSession]);
return useWalletConnectStore();
}
Key Tasks:
- Listen for
display_uri
events from the WC provider. - Sanitize & extract the 64‑hex topic.
- Persist only the
sessionTopic
. - Block unwanted browser popups/redirects on mobile Chrome.
- Cleanup event listeners on unmount.
3. Persisted Store (walletConnectStore)
// app/store/walletConnectStore.ts
import create from 'zustand';
import { persist } from 'zustand/middleware';
export const useWalletConnectStore = create(
persist(
(set, get) => ({
displayUri: null as string | null,
sessionTopic: null as string | null,
setDisplayUri: (uri) => set({ displayUri: uri }),
setSessionTopic: (topic) => set({ sessionTopic: topic }),
clearSession: () => set({ displayUri: null, sessionTopic: null }),
openWallet: () => {
const { sessionTopic, displayUri } = get();
const uri = sessionTopic ? `wc:${sessionTopic}@2` : displayUri;
if (uri) window.location.href = uri;
else console.warn('No WalletConnect session found');
},
}),
{
name: 'wallet-connect-session',
partialize: state => ({ sessionTopic: state.sessionTopic }),
}
)
);
Method | Signature | Description | |
---|---|---|---|
setDisplayUri | `(uri: string | null) => void` | Store raw or sanitized WalletConnect URI. |
setSessionTopic | `(topic: string | null) => void` | Store 64‑hex WalletConnect session topic. |
clearSession | () => void | Reset both displayUri and sessionTopic to null . | |
openWallet | () => void | Reconstructs wc:topic@2 or uses displayUri , then opens it. |
4. Consuming UI (e.g. Mint/Burn Buttons)
Your on-chain components simply:
- Send a transaction via Wagmi’s
useWriteContract
. - Immediately call
openWallet()
if adisplayUri
is available.
This keeps UI code decoupled from all session-management logic.
Workflow Diagram

API Reference
Below is the complete reference for the useWalletConnectStore hook and its returned store interface. Use this to understand how to import, invoke, and interact with the WalletConnect session state in your components.
Import
import { useWalletConnectStore } from '@/app/store/walletConnectStore';
Hook Signature & Description
/**
* React hook for accessing and controlling the WalletConnect session.
*
* Internally backed by Zustand + persistence.
* Exposes both session state (deep-link URI + session topic) and control actions.
*
* @returns {WalletConnectStore} The WalletConnect session store.
*/
export function useWalletConnectStore(): WalletConnectStore;
Return Type Definition
/**
* WalletConnect session store interface.
*/
interface WalletConnectStore {
/**
* The sanitized WalletConnect URI for QR-code display or deep-linking.
* On reload, this is rebuilt as `wc:${sessionTopic}@2`.
*/
displayUri: string | null;
/**
* The 64-hex session topic, persisted across reloads.
*/
sessionTopic: string | null;
/**
* Overwrites the stored deep-link URI.
* @param uri - A valid WalletConnect URI (e.g. `wc:<topic>@2`) or `null` to clear.
*/
setDisplayUri(uri: string | null): void;
/**
* Overwrites the stored session topic.
* @param topic - A 64-hex string representing the session topic, or `null` to clear.
*/
setSessionTopic(topic: string | null): void;
/**
* Clears both `displayUri` and `sessionTopic` from the store.
*/
clearSession(): void;
/**
* Triggers the OS wallet chooser by setting `window.location.href` to the deep-link URI.
* Constructs `wc:${sessionTopic}@2` if `sessionTopic` is present; otherwise falls back to `displayUri`.
*/
openWallet(): void;
}
Usage Example: Mint Button
"use client";
import { useAccount, useWriteContract } from 'wagmi';
import { useWalletConnectStore } from '@/app/store/walletConnectStore';
import { KEY_TOKEN_ADDRESS, fullKeyTokenAbi } from '@/app/config/contracts';
export function MintButton() {
const { isConnected } = useAccount();
const { displayUri, openWallet } = useWalletConnectStore();
const { writeContract: mint, isPending } = useWriteContract({
address: KEY_TOKEN_ADDRESS,
abi: fullKeyTokenAbi,
functionName: 'mint',
});
const handleMint = () => {
if (!isConnected) return alert('Connect your wallet first');
mint();
if (displayUri) openWallet();
};
return (
<button onClick={handleMint} disabled={isPending}>
{isPending ? 'Processing…' : 'Mint NFT'}
</button>
);
}
Error Handling & Edge Cases
openWallet() failures
Behavior
- Returns
true
if it successfully setwindow.location.href
. - Returns
false
(and logs a warning) if there’s no session or if the browser blocks the redirect.
How to use
const success = openWallet();
if (!success) {
// show UI feedback, e.g. toast("Couldn't open wallet, please try again");
}
Documentation
### openWallet(): boolean
Attempts to deep-link into the mobile wallet.
- **Returns**
- `true` if a URI was found and navigation was attempted
- `false` if no session exists or navigation was blocked
- **Logs**
- `console.log('Opening wallet…')` on success
- `console.warn('No WalletConnect session found')` or `console.error('Failed to open wallet')` on failure
Provider initialization errors
Hook exposes
const { hasProviderError } = useWalletConnectSession();
Behavior
hasProviderError
flips totrue
ifconnector.getProvider()
throws.- The hook will retry on the next render if
isConnected
orconnector.id
changes.
How to use
const { hasProviderError } = useWalletConnectSession();
if (hasProviderError) {
return <ErrorBanner message="WalletConnect initialization failed. Try reconnecting." />;
}
URI sanitization & parsing warnings
What happens
If the incoming URI can’t be parsed, we still store a “best-effort” clean URI and emit:
console.warn('Could not extract topic from URI:', originalUri);
Consumer tip
Watch the console in development; in production you can hook into a logging service (e.g. Sentry).
Best Practices & Notes
- Wrap Root: Always place
<WalletConnectProvider>
at your app’s top level. - Single Hook: Don’t call
useWalletConnectSession()
elsewhere—components only use the store. - Session Persistence: Only
sessionTopic
is saved; full URI is rebuilt on mount. - Error Handling: Handle RPC errors via Wagmi’s hooks; this layer only manages deep-link UX.
- Injected Wallets: Skip deep-link for in-browser injected providers (e.g. MetaMask Mobile in-app).
Appendix: Why Mobile Deep-Link Matters
Default Flow: manual app switching → low conversion.
Deep-Linking (“):
- Launches wallet via OS chooser.
- After tx approval, many wallets auto-return you to the browser.
- Universal: any WalletConnect v2–compliant app will respond to
wc:…@2
.