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 supports EVM and Solana embedded wallets.
Prerequisites
- Dynamic SDK initialized with a Reown Project ID (see Quickstart)
- User authenticated (see Authentication)
- An EVM or Solana embedded wallet created (see Wallet Creation)
- The
mobile_scannerpackage 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" />
- iOS: Add to
How It Works
- User opens a dApp on desktop or another device and clicks “Connect Wallet”
- The dApp shows a WalletConnect QR code
- User scans the QR code (or pastes the
wc:URI) in your app - Your app shows a session proposal with the dApp’s details and requested chains
- User approves or rejects the connection
- Once connected, signing requests from the dApp appear as approval dialogs in your app
- 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
TheWcGlobalListener 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 themobile_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
| Method | Description |
|---|---|
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
| Property | Type | Description |
|---|---|---|
initialized | bool | Whether WalletConnect has been initialized. |
sessions | Map<String, WcSession> | Currently active sessions keyed by topic. |
sessionsChanges | Stream<Map<String, WcSession>> | Reactive stream of session updates. |
onSessionProposal | Stream<WcSessionProposal?> | Fires when a dApp proposes a new session. |
onSessionRequest | Stream<WcSessionRequest?> | Fires when a dApp sends a signing request. |
onSessionDelete | Stream<String?> | Fires when a session is disconnected (emits the topic). |
initializedChanges | Stream<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
WcGlobalListenerat the app root so session proposals and signing requests are handled regardless of which screen the user is on. - Set
wcNavigatorKeyon yourMaterialAppso 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
onSessionDeleteand update your UI accordingly.