Skip to main content

Overview

WalletConnect integration lets your end-users connect their Dynamic embedded wallet to any WalletConnect-compatible dApp. Users scan a QR code (or paste a URI) from a dApp, approve the session, and can then sign transactions and messages directly from your app. This turns your app into a full-featured wallet that works across the web3 ecosystem.
This feature is currently restricted to EVM embedded wallets. Solana support may be added as more dApps adopt WalletConnect for Solana.

Prerequisites

  • Dynamic SDK initialized with a Reown Project ID (see Quickstart)
  • User authenticated (see Authentication)
  • An EVM embedded wallet created (see Wallet Creation)
  • The mobile_scanner package for QR scanning:
dependencies:
  dynamic_sdk: ^1.2.10
  mobile_scanner: ^6.0.2
  • Camera permission configured:
    • iOS: Add to Info.plist:
      <key>NSCameraUsageDescription</key>
      <string>Camera access is needed to scan WalletConnect QR codes</string>
      
    • Android: Add to AndroidManifest.xml:
      <uses-permission android:name="android.permission.CAMERA" />
      

How It Works

  1. User opens a dApp on desktop or another device and clicks “Connect Wallet”
  2. The dApp shows a WalletConnect QR code
  3. User scans the QR code (or pastes the wc: URI) in your app
  4. Your app shows a session proposal with the dApp’s details and requested chains
  5. User approves or rejects the connection
  6. Once connected, signing requests from the dApp appear as approval dialogs in your app
  7. User approves or rejects each request

Setup

1. Configure the Reown Project ID

Pass your Reown (formerly WalletConnect) project ID when initializing the SDK. Get one at cloud.reown.com.
import 'package:dynamic_sdk/dynamic_sdk.dart';

await DynamicSDK.init(
  clientProps: ClientProps(
    environmentId: 'YOUR_ENVIRONMENT_ID',
    appName: 'My App',
    appLogoUrl: 'https://example.com/logo.png',
    redirectUrl: 'myapp://',
    appOrigin: 'https://example.com',
    apiBaseUrl: 'https://app.dynamicauth.com/api/v0',
    // Required for WalletConnect
    reownProjectId: 'YOUR_REOWN_PROJECT_ID',
  ),
);

2. Add the Global Listener

The WcGlobalListener is a widget that handles the entire WalletConnect lifecycle: initialization, session proposals, signing requests, and disconnection events. Place it at the root of your app so it can show approval dialogs from any screen.
import 'package:flutter/material.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // Initialize Dynamic SDK (see Quickstart)
  await DynamicSDK.init(clientProps: ClientProps(...));

  runApp(
    MaterialApp(
      navigatorKey: wcNavigatorKey, // Required for global dialogs
      builder: (context, child) {
        return WcGlobalListener(child: child ?? const SizedBox());
      },
      home: const HomeScreen(),
    ),
  );
}

3. Implement the WcGlobalListener

This is the full implementation. It auto-initializes WC when the user logs in and shows dialogs for session proposals and signing requests.
import 'dart:async';
import 'dart:convert';

import 'package:dynamic_sdk/dynamic_sdk.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

/// Global navigator key used by WcGlobalListener to show dialogs
/// from any context, even above the Navigator in the widget tree.
final wcNavigatorKey = GlobalKey<NavigatorState>();

class WcGlobalListener extends StatefulWidget {
  final Widget child;
  const WcGlobalListener({super.key, required this.child});

  @override
  State<WcGlobalListener> createState() => _WcGlobalListenerState();
}

class _WcGlobalListenerState extends State<WcGlobalListener> {
  final _wc = DynamicSDK.instance.walletConnect;

  StreamSubscription<WcSessionProposal?>? _proposalSub;
  StreamSubscription<WcSessionRequest?>? _requestSub;
  StreamSubscription<String?>? _deleteSub;
  StreamSubscription<String?>? _authSub;

  bool _initialized = false;
  bool _initializing = false;

  @override
  void initState() {
    super.initState();
    _listenForAuth();
  }

  @override
  void dispose() {
    _proposalSub?.cancel();
    _requestSub?.cancel();
    _deleteSub?.cancel();
    _authSub?.cancel();
    super.dispose();
  }

  void _listenForAuth() {
    _authSub = DynamicSDK.instance.auth.tokenChanges.listen((token) {
      if (token != null &&
          token.isNotEmpty &&
          !_initialized &&
          !_initializing) {
        _initialize();
      } else if (token == null || token.isEmpty) {
        _initialized = false;
        _initializing = false;
      }
    });

    // Check if already logged in
    final token = DynamicSDK.instance.auth.token;
    if (token != null && token.isNotEmpty) {
      _initialize();
    }
  }

  Future<void> _initialize() async {
    if (_initializing || _initialized) return;
    _initializing = true;

    try {
      await _wc.initialize();
      _initialized = true;
      _listenForEvents();
    } catch (e) {
      debugPrint('[WcGlobalListener] Failed to initialize: $e');
    }

    _initializing = false;
  }

  BuildContext? get _navigatorContext =>
      wcNavigatorKey.currentState?.overlay?.context;

  void _listenForEvents() {
    _proposalSub?.cancel();
    _requestSub?.cancel();
    _deleteSub?.cancel();

    // Session proposal — a dApp wants to connect
    _proposalSub = _wc.onSessionProposal.listen((proposal) {
      if (proposal != null && mounted && _navigatorContext != null) {
        _showProposalDialog(proposal);
      }
    });

    // Session request — a dApp wants the user to sign something
    _requestSub = _wc.onSessionRequest.listen((request) {
      if (request != null && mounted && _navigatorContext != null) {
        _showRequestDialog(request);
      }
    });

    // Session deleted
    _deleteSub = _wc.onSessionDelete.listen((topic) {
      final navCtx = _navigatorContext;
      if (topic != null && mounted && navCtx != null) {
        ScaffoldMessenger.of(navCtx).showSnackBar(
          SnackBar(
            content: Text(
              'Session disconnected: ${topic.substring(0, 8)}...',
            ),
          ),
        );
      }
    });
  }

  void _showProposalDialog(WcSessionProposal proposal) {
    final navCtx = _navigatorContext;
    if (navCtx == null) return;

    showDialog(
      context: navCtx,
      barrierDismissible: false,
      builder: (ctx) => AlertDialog(
        title: const Text('Session Proposal'),
        content: ConstrainedBox(
          constraints: BoxConstraints(
            maxHeight: MediaQuery.of(ctx).size.height * 0.6,
          ),
          child: SingleChildScrollView(
            child: Column(
              mainAxisSize: MainAxisSize.min,
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  proposal.proposer.name,
                  style: const TextStyle(
                    fontSize: 18,
                    fontWeight: FontWeight.bold,
                  ),
                ),
                if (proposal.proposer.description.isNotEmpty)
                  Padding(
                    padding: const EdgeInsets.only(top: 4),
                    child: Text(
                      proposal.proposer.description,
                      style: TextStyle(color: Colors.grey[600]),
                    ),
                  ),
                if (proposal.proposer.url.isNotEmpty)
                  Padding(
                    padding: const EdgeInsets.only(top: 4),
                    child: Text(
                      proposal.proposer.url,
                      style: const TextStyle(
                        color: Colors.blue,
                        fontSize: 12,
                      ),
                    ),
                  ),
                const SizedBox(height: 16),
                if (proposal.requiredNamespaces != null) ...[
                  const Text(
                    'Required chains:',
                    style: TextStyle(
                      fontWeight: FontWeight.w600,
                      fontSize: 13,
                    ),
                  ),
                  ...proposal.requiredNamespaces!.entries.map(
                    (e) => Text(
                      '  ${e.key}: ${e.value.chains.join(", ")}',
                    ),
                  ),
                ],
                if (proposal.optionalNamespaces != null &&
                    proposal.optionalNamespaces!.isNotEmpty) ...[
                  const SizedBox(height: 8),
                  const Text(
                    'Optional chains:',
                    style: TextStyle(
                      fontWeight: FontWeight.w600,
                      fontSize: 13,
                    ),
                  ),
                  ...proposal.optionalNamespaces!.entries.map(
                    (e) => Text(
                      '  ${e.key}: ${e.value.chains.join(", ")}',
                    ),
                  ),
                ],
              ],
            ),
          ),
        ),
        actions: [
          TextButton(
            onPressed: () async {
              Navigator.of(ctx).pop();
              await _wc.confirmPairing(confirm: false);
            },
            child: const Text('Reject'),
          ),
          FilledButton(
            onPressed: () async {
              Navigator.of(ctx).pop();
              try {
                await _wc.confirmPairing(confirm: true);
                final snackCtx = _navigatorContext;
                if (mounted && snackCtx != null) {
                  ScaffoldMessenger.of(snackCtx).showSnackBar(
                    const SnackBar(
                      content: Text('Session approved'),
                    ),
                  );
                }
              } catch (e) {
                final snackCtx = _navigatorContext;
                if (mounted && snackCtx != null) {
                  ScaffoldMessenger.of(snackCtx).showSnackBar(
                    SnackBar(
                      content: Text('Approval failed: $e'),
                    ),
                  );
                }
              }
            },
            child: const Text('Approve'),
          ),
        ],
      ),
    );
  }

  void _showRequestDialog(WcSessionRequest request) {
    final navCtx = _navigatorContext;
    if (navCtx == null) return;

    showDialog(
      context: navCtx,
      barrierDismissible: false,
      builder: (ctx) => AlertDialog(
        title: const Text('Session Request'),
        content: ConstrainedBox(
          constraints: BoxConstraints(
            maxHeight: MediaQuery.of(ctx).size.height * 0.6,
          ),
          child: SingleChildScrollView(
            child: Column(
              mainAxisSize: MainAxisSize.min,
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                _infoRow('Method', request.method),
                _infoRow('Chain', request.chainId),
                _infoRow(
                  'Topic',
                  '${request.topic.substring(0, 12)}...',
                ),
                if (request.params != null) ...[
                  const SizedBox(height: 8),
                  Row(
                    children: [
                      const Text(
                        'Params:',
                        style: TextStyle(
                          fontWeight: FontWeight.w600,
                          fontSize: 13,
                        ),
                      ),
                      const Spacer(),
                      InkWell(
                        onTap: () {
                          final json =
                              const JsonEncoder.withIndent('  ')
                                  .convert(request.params);
                          Clipboard.setData(
                            ClipboardData(text: json),
                          );
                          ScaffoldMessenger.of(ctx).showSnackBar(
                            const SnackBar(
                              content: Text('Params copied'),
                              duration: Duration(seconds: 1),
                            ),
                          );
                        },
                        child: const Padding(
                          padding: EdgeInsets.all(4),
                          child: Icon(Icons.copy, size: 16),
                        ),
                      ),
                    ],
                  ),
                  Container(
                    margin: const EdgeInsets.only(top: 4),
                    padding: const EdgeInsets.all(8),
                    decoration: BoxDecoration(
                      color: Colors.grey[100],
                      borderRadius: BorderRadius.circular(8),
                    ),
                    constraints:
                        const BoxConstraints(maxHeight: 200),
                    child: SingleChildScrollView(
                      child: Text(
                        request.params.toString(),
                        style: const TextStyle(
                          fontSize: 11,
                          fontFamily: 'monospace',
                        ),
                      ),
                    ),
                  ),
                ],
              ],
            ),
          ),
        ),
        actions: [
          TextButton(
            onPressed: () async {
              Navigator.of(ctx).pop();
              await _wc.respondSessionRequest(
                id: request.id,
                topic: request.topic,
                approved: false,
              );
            },
            child: const Text('Reject'),
          ),
          FilledButton(
            onPressed: () async {
              Navigator.of(ctx).pop();
              await _wc.respondSessionRequest(
                id: request.id,
                topic: request.topic,
                approved: true,
              );
            },
            child: const Text('Approve'),
          ),
        ],
      ),
    );
  }

  Widget _infoRow(String label, String value) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 4),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          SizedBox(
            width: 60,
            child: Text(
              '$label:',
              style: const TextStyle(
                fontWeight: FontWeight.w600,
                fontSize: 13,
              ),
            ),
          ),
          Expanded(
            child: Text(
              value,
              style: const TextStyle(fontSize: 13),
            ),
          ),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) => widget.child;
}

4. Build the WalletConnect Screen

This screen provides the UI for scanning QR codes, pasting URIs, and managing active sessions.
import 'package:dynamic_sdk/dynamic_sdk.dart';
import 'package:flutter/material.dart';

class WalletConnectScreen extends StatefulWidget {
  const WalletConnectScreen({super.key});

  @override
  State<WalletConnectScreen> createState() =>
      _WalletConnectScreenState();
}

class _WalletConnectScreenState extends State<WalletConnectScreen> {
  final _uriController = TextEditingController();
  final _wc = DynamicSDK.instance.walletConnect;

  bool _isPairing = false;
  String? _error;

  @override
  void dispose() {
    _uriController.dispose();
    super.dispose();
  }

  Future<void> _pair(String uri) async {
    if (uri.isEmpty) return;

    setState(() {
      _isPairing = true;
      _error = null;
    });

    try {
      await _wc.pair(uri: uri);
      _uriController.clear();
    } catch (e) {
      setState(() => _error = 'Pairing failed: $e');
    }

    if (mounted) setState(() => _isPairing = false);
  }

  Future<void> _scanQrCode() async {
    final uri = await Navigator.of(context).push<String>(
      MaterialPageRoute(
        builder: (_) => const QrScannerScreen(),
      ),
    );

    if (uri != null) {
      _uriController.text = uri;
      await _pair(uri);
    }
  }

  Future<void> _disconnect(String topic) async {
    try {
      await _wc.disconnectSession(topic: topic);
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(
            content: Text('Session disconnected'),
          ),
        );
      }
    } catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Disconnect failed: $e')),
        );
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('WalletConnect')),
      body: SafeArea(
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              // Pair section
              Row(
                children: [
                  Expanded(
                    child: TextField(
                      controller: _uriController,
                      decoration: const InputDecoration(
                        hintText: 'wc:...',
                        labelText: 'WalletConnect URI',
                        border: OutlineInputBorder(),
                        contentPadding: EdgeInsets.symmetric(
                          horizontal: 12,
                          vertical: 10,
                        ),
                      ),
                      style: const TextStyle(fontSize: 13),
                    ),
                  ),
                  const SizedBox(width: 8),
                  IconButton.filled(
                    onPressed: _isPairing ? null : _scanQrCode,
                    icon: const Icon(Icons.qr_code_scanner),
                    tooltip: 'Scan QR',
                  ),
                ],
              ),
              const SizedBox(height: 8),
              FilledButton(
                onPressed: _isPairing
                    ? null
                    : () => _pair(_uriController.text.trim()),
                child: _isPairing
                    ? const SizedBox(
                        width: 18,
                        height: 18,
                        child: CircularProgressIndicator(
                          strokeWidth: 2,
                          color: Colors.white,
                        ),
                      )
                    : const Text('Pair'),
              ),
              if (_error != null) ...[
                const SizedBox(height: 8),
                Text(
                  _error!,
                  style: const TextStyle(
                    color: Colors.red,
                    fontSize: 13,
                  ),
                ),
              ],
              const SizedBox(height: 24),

              // Sessions section
              const Text(
                'Active Sessions',
                style: TextStyle(
                  fontSize: 18,
                  fontWeight: FontWeight.bold,
                ),
              ),
              const SizedBox(height: 8),
              Expanded(
                child: StreamBuilder<Map<String, WcSession>>(
                  stream: _wc.sessionsChanges,
                  builder: (context, snapshot) {
                    final sessions =
                        snapshot.data ?? _wc.sessions;

                    if (sessions.isEmpty) {
                      return Center(
                        child: Column(
                          mainAxisSize: MainAxisSize.min,
                          children: [
                            Icon(
                              Icons.link_off,
                              size: 48,
                              color: Colors.grey[400],
                            ),
                            const SizedBox(height: 8),
                            Text(
                              'No active sessions',
                              style: TextStyle(
                                color: Colors.grey[500],
                              ),
                            ),
                            Text(
                              'Scan a QR code or paste a URI',
                              style: TextStyle(
                                color: Colors.grey[400],
                                fontSize: 12,
                              ),
                            ),
                          ],
                        ),
                      );
                    }

                    return ListView.builder(
                      itemCount: sessions.length,
                      itemBuilder: (context, index) {
                        final entry =
                            sessions.entries.elementAt(index);
                        final session = entry.value;

                        return SessionCard(
                          session: session,
                          onDisconnect: () =>
                              _disconnect(entry.key),
                        );
                      },
                    );
                  },
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

5. Build the QR Scanner

The QR scanner uses the mobile_scanner package to detect WalletConnect QR codes (URIs starting with wc:).
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';

class QrScannerScreen extends StatefulWidget {
  const QrScannerScreen({super.key});

  @override
  State<QrScannerScreen> createState() => _QrScannerScreenState();
}

class _QrScannerScreenState extends State<QrScannerScreen> {
  final MobileScannerController _controller =
      MobileScannerController();
  bool _hasScanned = false;

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  void _onDetect(BarcodeCapture capture) {
    if (_hasScanned) return;

    final barcode = capture.barcodes.firstOrNull;
    final value = barcode?.rawValue;

    if (value != null && value.startsWith('wc:')) {
      _hasScanned = true;
      Navigator.of(context).pop(value);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Scan WalletConnect QR'),
      ),
      body: Stack(
        children: [
          MobileScanner(
            controller: _controller,
            onDetect: _onDetect,
          ),
          Center(
            child: Container(
              width: 250,
              height: 250,
              decoration: BoxDecoration(
                border: Border.all(
                  color: Colors.white.withOpacity(0.7),
                  width: 2,
                ),
                borderRadius: BorderRadius.circular(12),
              ),
            ),
          ),
          Positioned(
            bottom: 40,
            left: 0,
            right: 0,
            child: Center(
              child: Text(
                'Point camera at a WalletConnect QR code',
                style: TextStyle(
                  color: Colors.white.withOpacity(0.8),
                  fontSize: 14,
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

Session Card Component

A reusable card widget for displaying connected session details:
class SessionCard extends StatelessWidget {
  final WcSession session;
  final VoidCallback onDisconnect;

  const SessionCard({
    super.key,
    required this.session,
    required this.onDisconnect,
  });

  @override
  Widget build(BuildContext context) {
    final namespaceKeys = session.namespaces.keys.toList();

    return Card(
      margin: const EdgeInsets.only(bottom: 8),
      child: Padding(
        padding: const EdgeInsets.all(12),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                if (session.peer.icons.isNotEmpty)
                  Padding(
                    padding: const EdgeInsets.only(right: 8),
                    child: ClipRRect(
                      borderRadius: BorderRadius.circular(6),
                      child: Image.network(
                        session.peer.icons.first,
                        width: 32,
                        height: 32,
                        errorBuilder: (_, __, ___) =>
                            const Icon(Icons.language, size: 32),
                      ),
                    ),
                  ),
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        session.peer.name,
                        style: const TextStyle(
                          fontWeight: FontWeight.bold,
                          fontSize: 15,
                        ),
                      ),
                      if (session.peer.url.isNotEmpty)
                        Text(
                          session.peer.url,
                          style: TextStyle(
                            color: Colors.grey[500],
                            fontSize: 12,
                          ),
                        ),
                    ],
                  ),
                ),
                IconButton(
                  onPressed: onDisconnect,
                  icon: const Icon(
                    Icons.link_off,
                    color: Colors.red,
                  ),
                  tooltip: 'Disconnect',
                ),
              ],
            ),
            if (namespaceKeys.isNotEmpty) ...[
              const SizedBox(height: 8),
              Wrap(
                spacing: 4,
                runSpacing: 4,
                children: namespaceKeys
                    .map(
                      (ns) => Chip(
                        label: Text(
                          ns,
                          style: const TextStyle(fontSize: 11),
                        ),
                        padding: EdgeInsets.zero,
                        materialTapTargetSize:
                            MaterialTapTargetSize.shrinkWrap,
                        visualDensity: VisualDensity.compact,
                      ),
                    )
                    .toList(),
              ),
            ],
            Text(
              'Topic: ${session.topic.substring(0, 12)}...',
              style: TextStyle(
                color: Colors.grey[400],
                fontSize: 11,
                fontFamily: 'monospace',
              ),
            ),
          ],
        ),
      ),
    );
  }
}

WalletConnect API Reference

WalletConnectModule

MethodDescription
initialize()Initialize WalletConnect. Call after user authentication.
pair({required String uri})Pair with a dApp using a WalletConnect URI from a QR code.
confirmPairing({required bool confirm})Approve or reject a pending session proposal.
respondSessionRequest({required int id, required String topic, required bool approved})Approve or reject a signing request.
disconnectSession({required String topic})Disconnect a specific session.
disconnectAll()Disconnect all sessions and clean up storage.
getConnectedSessions()Retrieve all active sessions.
getPendingPairing()Get the current pending session proposal (if any).

Properties & Streams

PropertyTypeDescription
initializedboolWhether WalletConnect has been initialized.
sessionsMap<String, WcSession>Currently active sessions keyed by topic.
sessionsChangesStream<Map<String, WcSession>>Reactive stream of session updates.
onSessionProposalStream<WcSessionProposal?>Fires when a dApp proposes a new session.
onSessionRequestStream<WcSessionRequest?>Fires when a dApp sends a signing request.
onSessionDeleteStream<String?>Fires when a session is disconnected (emits the topic).
initializedChangesStream<bool>Fires when initialization state changes.

Data Models

class WcSession {
  final String topic;
  final WcPeerMetadata peer;
  final Map<String, WcNamespace> namespaces;
  final int? expiry;
}

class WcSessionProposal {
  final int id;
  final WcPeerMetadata proposer;
  final Map<String, WcNamespace>? requiredNamespaces;
  final Map<String, WcNamespace>? optionalNamespaces;
}

class WcSessionRequest {
  final int id;
  final String topic;
  final String method;    // e.g. "personal_sign", "eth_sendTransaction"
  final String chainId;   // e.g. "eip155:1"
  final dynamic params;
}

class WcPeerMetadata {
  final String name;
  final String description;
  final String url;
  final List<String> icons;
}

class WcNamespace {
  final List<String> chains;
  final List<String> methods;
  final List<String> events;
  final List<String>? accounts;
}

Best Practices

  • Place WcGlobalListener at the app root so session proposals and signing requests are handled regardless of which screen the user is on.
  • Set wcNavigatorKey on your MaterialApp so the global listener can show dialogs from any context.
  • Always initialize WalletConnect after authentication — the module requires an active auth token.
  • Show clear approval UIs — display the dApp name, URL, and requested chains so users can make informed decisions.
  • Handle disconnections gracefully — listen to onSessionDelete and update your UI accordingly.

Security

When users connect to third-party dApps, always encourage them to verify the dApp URL and details shown in the session proposal before approving. For enhanced security, Dynamic integrates with Blockaid for transaction simulation on the web SDK — consider warning users about unfamiliar dApps on mobile.

Next Steps