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>@2across 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_urievent 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_urievents, 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>@2URI across reloads. -
Clean, declarative deep-link helper
We expose a simpleopenWallet()action from the store. Under the hood it always rebuilds the standardwc:<topic>@2URI 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 readdisplayUrior 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 ifdisplayUriis 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 |
|---|---|
dapp/components/providers/WalletConnectProvider.tsx (@providers/...) | Bootstraps the global session hook so it runs once near the app root |
dapp/app/hooks/useWalletConnectSession.ts | Listens for & sanitizes WalletConnect URIs, manages lifecycle + persistence |
dapp/app/store/walletConnectStore.ts | Persists session topic + URI (topic only on disk), exposes the openWallet() API |
dapp/app/hooks/useMintBurn.ts | Calls openWallet() immediately after write actions whenever a displayUri exists |
dapp/app/mint/components/ButtonSection/ButtonSection.tsx (and friends) | Consume useMintBurn(); UI never touches the WalletConnect store directly |
1. Global Provider
// dapp/components/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. (In code you import it via the @providers/WalletConnectProvider alias, but the file physically lives at dapp/components/providers/WalletConnectProvider.tsx.)
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_urievents 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 (via useMintBurn + Buttons)
React components don’t touch the WalletConnect store directly. Instead, hooks that manage blockchain writes (currently useMintBurn) read displayUri / openWallet() from the store and invoke the deeplink immediately after a write is dispatched. UI components (e.g. ButtonSection) just call mint()/burn() from that hook.
// dapp/app/hooks/useMintBurn.ts (excerpt)
const { openWallet, displayUri } = useWalletConnectStore();
const mint = useCallback(() => {
executeWithNetworkCheck(() => {
const action = createMintAction();
mintContract(action);
if (displayUri) openWallet();
});
}, [executeWithNetworkCheck, mintContract, displayUri, openWallet]);
const burn = useCallback((tokenId: string | number | null) => {
executeWithNetworkCheck(() => {
const action = createBurnAction(tokenId as string | number);
burnContract(action);
if (displayUri) openWallet();
});
}, [executeWithNetworkCheck, burnContract, displayUri, openWallet]);ButtonSection (and any other transaction UI) simply calls the hook’s mint()/burn() functions—no WalletConnect knowledge is needed in presentation code.
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: ButtonSection wiring
// dapp/app/mint/components/ButtonSection/ButtonSection.tsx (excerpt)
const { mint, burn, isProcessing } = useMintBurn({
onMintSuccess: handleMintSuccess,
onBurnSuccess: handleBurnSuccess,
});
const handleMint = () => {
setBlockProcessingText(false);
setLoading(true);
mint(); // triggers openWallet() internally if a displayUri exists
};
const handleBurn = () => {
setBlockProcessingText(false);
setLoading(true);
burn(tokenId);
};
return (
<button onClick={handleMint} disabled={isProcessing}>
{isProcessing ? 'Processing…' : 'Mint NFT'}
</button>
);Error Handling & Edge Cases
openWallet() behavior
What it does
- Builds
wc:${sessionTopic}@2when possible and assigns it towindow.location.href. - Falls back to the last
displayUri(e.g. a URI with query params from the provider) if no topic is known yet. - Logs
console.warn('No WalletConnect session found')when neither value is available. It does not throw or return a status value.
Usage pattern
if (displayUri) {
openWallet();
} else {
toast("No WalletConnect session yet – retry once pairing completes");
}Because openWallet() is void and relies on OS-level navigation, any UI retry/backoff logic should live outside the helper (e.g. by checking displayUri, storing attempt timestamps, etc.).
Provider initialization errors
useWalletConnectSession() currently handles provider bootstrap failures by logging the error, clearing its initRef, and letting React re-run the effect on the next render. If you want to surface failures in the UI, wrap the hook call in your own provider and capture errors there (e.g. try/catch around connector.getProvider() inside a forked hook) or watch for repeated console errors through your telemetry tooling.
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
sessionTopicis 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.



