Skip to Content
Welcome to RitoSwap's documentation!
dAppMobile Wallet Deeplinking

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 does window.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:

  1. URI capture & persistence
    While Wagmi fires a display_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’s display_uri events, sanitizes and normalizes the URI (stripping out unwanted HTTP redirects or blacklisted domains), extracts the topic (the 32-byte ID after wc:), and then stores only that topic in a persistent Zustand store. From that topic we can always reconstruct a clean wc:<topic>@2 URI across reloads.

  2. Clean, declarative deep-link helper
    We expose a simple openWallet() action from the store. Under the hood it always rebuilds the standard wc:<topic>@2 URI and assigns it to window.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 read displayUri or call openWallet(), 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:

  1. The user never leaves the browser automatically.
  2. They have to manually switch to their wallet app, approve the tx, then switch back to the browser to see confirmation.
  3. 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

  1. Immediate deep-link invocation
    After you call your write/contract method (e.g. mint()), check if displayUri is set and immediately invoke openWallet(). That reliably pops up the OS app chooser so the user can tap their wallet and get straight into approval.
  2. 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.
  3. Cross-wallet compatibility
    Any WalletConnect v2-compliant wallet will listen for wc:<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



Screenshots of WC Deeplink Triggering OS Chooser Across Browsers

Chrome Example

Architecture

      • useWalletConnectSession.ts
      • walletConnectStore.ts
      • WalletConnectProvider.tsx

File Responsibility Matrix

File PathResponsibility
dapp/components/providers/WalletConnectProvider.tsx (@providers/...)Bootstraps the global session hook so it runs once near the app root
dapp/app/hooks/useWalletConnectSession.tsListens for & sanitizes WalletConnect URIs, manages lifecycle + persistence
dapp/app/store/walletConnectStore.tsPersists session topic + URI (topic only on disk), exposes the openWallet() API
dapp/app/hooks/useMintBurn.tsCalls 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_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 }), } ) );
MethodSignatureDescription
setDisplayUri`(uri: stringnull) => void`Store raw or sanitized WalletConnect URI.
setSessionTopic`(topic: stringnull) => void`Store 64‑hex WalletConnect session topic.
clearSession() => voidReset both displayUri and sessionTopic to null.
openWallet() => voidReconstructs 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

WC Deeplink Swimlane

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}@2 when possible and assigns it to window.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 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).

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.