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)
- Camera permission configured in
Info.plist for QR scanning:
<key>NSCameraUsageDescription</key>
<string>Camera access is needed to scan WalletConnect QR codes</string>
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
Pass your Reown (formerly WalletConnect) project ID when initializing the SDK. Get one at cloud.reown.com.
import DynamicSDKSwift
DynamicSDK.setup(
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 SwiftUI wrapper 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 SwiftUI
import Combine
import DynamicSDKSwift
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
WcGlobalListener {
// Your app's root view
ContentView()
}
}
}
}
3. Implement the WcGlobalListener
This is the full implementation of the global WalletConnect listener. It auto-initializes WC when the user logs in and presents sheets for session proposals and signing requests.
import SwiftUI
import Combine
import DynamicSDKSwift
// MARK: - ViewModel
private class WcGlobalListenerVM: ObservableObject {
@Published var showProposal: WcSessionProposal?
@Published var showRequest: WcSessionRequest?
@Published var toastMessage: String?
private var initialized = false
private var initializing = false
private var cancellables = Set<AnyCancellable>()
init() {
setupListeners()
}
func setupListeners() {
guard let sdk = try? DynamicSDK.getInstance() else { return }
let wc = sdk.walletConnect
// Auto-initialize WalletConnect when user logs in
sdk.auth.tokenChanges
.receive(on: DispatchQueue.main)
.sink { [weak self] token in
guard let self else { return }
if let token, !token.isEmpty, !self.initialized, !self.initializing {
self.initializing = true
Task {
do {
try await wc.initialize()
await MainActor.run {
self.initialized = true
self.initializing = false
}
} catch {
await MainActor.run { self.initializing = false }
}
}
} else if token == nil || token?.isEmpty == true {
self.initialized = false
self.initializing = false
}
}
.store(in: &cancellables)
// Session proposals (new dApp wants to connect)
wc.onSessionProposal
.receive(on: DispatchQueue.main)
.sink { [weak self] proposal in
self?.showProposal = proposal
}
.store(in: &cancellables)
// Session requests (dApp wants user to sign something)
wc.onSessionRequest
.receive(on: DispatchQueue.main)
.sink { [weak self] request in
self?.showRequest = request
}
.store(in: &cancellables)
// Session deletions
wc.onSessionDelete
.receive(on: DispatchQueue.main)
.sink { [weak self] topic in
self?.toastMessage = "Session disconnected: \(String(topic.prefix(8)))..."
}
.store(in: &cancellables)
}
func approveProposal() {
showProposal = nil
Task {
do {
let wc = try DynamicSDK.getInstance().walletConnect
try await wc.confirmPairing(confirm: true)
await MainActor.run { toastMessage = "Session approved" }
} catch {
await MainActor.run {
toastMessage = "Approval failed: \(error.localizedDescription)"
}
}
}
}
func rejectProposal() {
showProposal = nil
Task {
try? await DynamicSDK.getInstance().walletConnect
.confirmPairing(confirm: false)
}
}
func approveRequest(_ request: WcSessionRequest) {
showRequest = nil
Task {
do {
try await DynamicSDK.getInstance().walletConnect
.respondSessionRequest(
id: request.id,
topic: request.topic,
approved: true
)
} catch {
await MainActor.run {
toastMessage = "Request failed: \(error.localizedDescription)"
}
}
}
}
func rejectRequest(_ request: WcSessionRequest) {
showRequest = nil
Task {
try? await DynamicSDK.getInstance().walletConnect
.respondSessionRequest(
id: request.id,
topic: request.topic,
approved: false
)
}
}
}
// MARK: - View
struct WcGlobalListener<Content: View>: View {
let content: Content
@StateObject private var vm = WcGlobalListenerVM()
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
ZStack {
content
// Toast overlay
if let toast = vm.toastMessage {
VStack {
Spacer()
Text(toast)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(Color(.systemGray2))
.cornerRadius(8)
.padding(.bottom, 40)
}
.transition(.move(edge: .bottom).combined(with: .opacity))
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
withAnimation { vm.toastMessage = nil }
}
}
}
}
// Session proposal sheet
.sheet(item: Binding(
get: { vm.showProposal.map { IdentifiableProposal(proposal: $0) } },
set: { if $0 == nil { vm.showProposal = nil } }
)) { item in
ProposalSheet(
proposal: item.proposal,
onApprove: { vm.approveProposal() },
onReject: { vm.rejectProposal() }
)
}
// Session request sheet
.sheet(item: Binding(
get: { vm.showRequest.map { IdentifiableRequest(request: $0) } },
set: { if $0 == nil { vm.showRequest = nil } }
)) { item in
RequestSheet(
request: item.request,
onApprove: { vm.approveRequest(item.request) },
onReject: { vm.rejectRequest(item.request) }
)
}
}
}
// MARK: - Identifiable wrappers
private struct IdentifiableProposal: Identifiable {
let id = UUID()
let proposal: WcSessionProposal
}
private struct IdentifiableRequest: Identifiable {
let id = UUID()
let request: WcSessionRequest
}
// MARK: - Proposal Sheet
private struct ProposalSheet: View {
let proposal: WcSessionProposal
let onApprove: () -> Void
let onReject: () -> Void
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
Text(proposal.proposer.name)
.font(.title2).fontWeight(.bold)
if !proposal.proposer.description.isEmpty {
Text(proposal.proposer.description)
.font(.subheadline)
.foregroundColor(.secondary)
}
if !proposal.proposer.url.isEmpty {
Text(proposal.proposer.url)
.font(.caption)
.foregroundColor(.blue)
}
Divider()
if let ns = proposal.requiredNamespaces, !ns.isEmpty {
Text("Required chains:").font(.subheadline).fontWeight(.semibold)
ForEach(Array(ns.keys.sorted()), id: \.self) { key in
if let namespace = ns[key] {
Text(" \(key): \(namespace.chains.joined(separator: ", "))")
.font(.caption)
}
}
}
if let ns = proposal.optionalNamespaces, !ns.isEmpty {
Text("Optional chains:").font(.subheadline).fontWeight(.semibold)
ForEach(Array(ns.keys.sorted()), id: \.self) { key in
if let namespace = ns[key] {
Text(" \(key): \(namespace.chains.joined(separator: ", "))")
.font(.caption)
}
}
}
}
.padding()
}
.navigationTitle("Session Proposal")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Reject", role: .destructive, action: onReject)
}
ToolbarItem(placement: .confirmationAction) {
Button("Approve", action: onApprove)
}
}
}
.presentationDetents([.medium, .large])
}
}
// MARK: - Request Sheet
private struct RequestSheet: View {
let request: WcSessionRequest
let onApprove: () -> Void
let onReject: () -> Void
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
LabeledContent("Method", value: request.method)
LabeledContent("Chain", value: request.chainId)
LabeledContent("Topic", value: "\(String(request.topic.prefix(12)))...")
if let params = request.params {
Divider()
HStack {
Text("Params:").font(.subheadline).fontWeight(.semibold)
Spacer()
Button {
UIPasteboard.general.string = "\(params)"
} label: {
Image(systemName: "doc.on.doc").font(.caption)
}
}
Text("\(params)")
.font(.caption2).monospaced()
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.systemGray6))
.cornerRadius(8)
}
}
.padding()
}
.navigationTitle("Session Request")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Reject", role: .destructive, action: onReject)
}
ToolbarItem(placement: .confirmationAction) {
Button("Approve", action: onApprove)
}
}
}
.presentationDetents([.medium, .large])
}
}
4. Build the WalletConnect Screen
This screen provides the UI for scanning QR codes, pasting URIs, and managing active sessions.
import SwiftUI
import Combine
import DynamicSDKSwift
struct WalletConnectScreen: View {
@State private var uriText = ""
@State private var isPairing = false
@State private var error: String?
@State private var sessions: [String: WcSession] = [:]
@State private var cancellables = Set<AnyCancellable>()
@State private var showScanner = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
VStack(spacing: 12) {
// QR Scanner button
Button {
showScanner = true
} label: {
HStack {
Image(systemName: "qrcode.viewfinder")
Text("Scan QR Code")
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(Color(.systemGray5))
.foregroundColor(.primary)
.cornerRadius(8)
}
// Divider
HStack {
Rectangle().frame(height: 1)
.foregroundColor(Color(.separator))
Text("or paste URI")
.font(.caption).foregroundColor(.secondary)
Rectangle().frame(height: 1)
.foregroundColor(Color(.separator))
}
// Manual URI input
TextField("wc:...", text: $uriText)
.textFieldStyle(RoundedBorderTextFieldStyle())
.autocapitalization(.none)
.disableAutocorrection(true)
Button(action: pair) {
HStack {
if isPairing {
ProgressView()
.progressViewStyle(
CircularProgressViewStyle(tint: .white)
)
} else {
Text("Pair")
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(8)
}
.disabled(isPairing || uriText.trimmingCharacters(
in: .whitespaces
).isEmpty)
if let error {
Text(error)
.foregroundColor(.red).font(.caption)
}
}
.padding()
Divider()
// Active sessions
Text("Active Sessions")
.font(.title2).fontWeight(.bold)
.padding(.horizontal)
.padding(.top, 16)
if sessions.isEmpty {
VStack(spacing: 8) {
Spacer()
Image(systemName: "link.badge.plus")
.font(.system(size: 48))
.foregroundColor(.secondary)
Text("No active sessions")
.foregroundColor(.secondary)
Text("Scan a QR code or paste a URI to connect")
.font(.caption)
.foregroundColor(.secondary.opacity(0.7))
Spacer()
}
.frame(maxWidth: .infinity)
} else {
ScrollView {
LazyVStack(spacing: 8) {
ForEach(
Array(sessions.keys.sorted()),
id: \.self
) { topic in
if let session = sessions[topic] {
SessionCard(session: session) {
disconnect(topic: topic)
}
}
}
}
.padding(.horizontal)
.padding(.top, 8)
}
}
}
.navigationTitle("WalletConnect")
.fullScreenCover(isPresented: $showScanner) {
QrScannerView(
onScanned: { scannedUri in
showScanner = false
uriText = scannedUri
pair()
},
onDismiss: { showScanner = false }
)
}
.onAppear {
guard let sdk = try? DynamicSDK.getInstance() else { return }
let wc = sdk.walletConnect
sessions = wc.sessions
wc.sessionsChanges
.receive(on: DispatchQueue.main)
.sink { sessions = $0 }
.store(in: &cancellables)
}
}
private func pair() {
let uri = uriText.trimmingCharacters(in: .whitespaces)
guard !uri.isEmpty else { return }
isPairing = true
error = nil
Task {
do {
let wc = try DynamicSDK.getInstance().walletConnect
try await wc.pair(uri: uri)
await MainActor.run {
uriText = ""
isPairing = false
}
} catch {
await MainActor.run {
self.error = "Pairing failed: \(error.localizedDescription)"
isPairing = false
}
}
}
}
private func disconnect(topic: String) {
Task {
do {
let wc = try DynamicSDK.getInstance().walletConnect
try await wc.disconnectSession(topic: topic)
} catch {
await MainActor.run {
self.error = "Disconnect failed: \(error.localizedDescription)"
}
}
}
}
}
5. Build the QR Scanner
The QR scanner uses AVFoundation to detect WalletConnect QR codes (URIs starting with wc:).
import SwiftUI
import AVFoundation
struct QrScannerView: View {
let onScanned: (String) -> Void
let onDismiss: () -> Void
@State private var hasCameraPermission = false
var body: some View {
ZStack {
if hasCameraPermission {
CameraPreview(onScanned: onScanned)
.ignoresSafeArea()
// Scanning overlay
GeometryReader { geo in
let size = min(geo.size.width, geo.size.height) * 0.65
ZStack {
Color.black.opacity(0.5).ignoresSafeArea()
RoundedRectangle(cornerRadius: 16)
.frame(width: size, height: size)
.blendMode(.destinationOut)
}
.compositingGroup()
RoundedRectangle(cornerRadius: 16)
.stroke(Color.white, lineWidth: 2)
.frame(width: size, height: size)
.position(
x: geo.size.width / 2,
y: geo.size.height / 2
)
}
VStack {
Spacer()
Text("Scan WalletConnect QR Code")
.foregroundColor(.white)
.font(.headline)
.padding(.bottom, 100)
}
} else {
VStack(spacing: 16) {
Text("Camera permission is required to scan QR codes")
Button("Grant Permission") { requestCameraAccess() }
}
.padding()
}
// Close button
VStack {
HStack {
Spacer()
Button(action: onDismiss) {
Image(systemName: "xmark.circle.fill")
.font(.title)
.foregroundColor(.white)
.shadow(radius: 4)
}
.padding()
}
Spacer()
}
}
.onAppear { requestCameraAccess() }
}
private func requestCameraAccess() {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
hasCameraPermission = true
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { granted in
DispatchQueue.main.async { hasCameraPermission = granted }
}
default:
hasCameraPermission = false
}
}
}
// MARK: - Camera Preview (UIKit bridge)
private struct CameraPreview: UIViewControllerRepresentable {
let onScanned: (String) -> Void
func makeUIViewController(context: Context) -> CameraScannerViewController {
let vc = CameraScannerViewController()
vc.onScanned = onScanned
return vc
}
func updateUIViewController(
_ uiViewController: CameraScannerViewController,
context: Context
) {}
}
private class CameraScannerViewController:
UIViewController, AVCaptureMetadataOutputObjectsDelegate
{
var onScanned: ((String) -> Void)?
private var captureSession: AVCaptureSession?
private var hasScanned = false
override func viewDidLoad() {
super.viewDidLoad()
let session = AVCaptureSession()
guard let device = AVCaptureDevice.default(for: .video),
let input = try? AVCaptureDeviceInput(device: device)
else { return }
session.addInput(input)
let output = AVCaptureMetadataOutput()
session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: .main)
output.metadataObjectTypes = [.qr]
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
previewLayer.frame = view.bounds
previewLayer.videoGravity = .resizeAspectFill
view.layer.addSublayer(previewLayer)
captureSession = session
DispatchQueue.global(qos: .userInitiated).async {
session.startRunning()
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if let layer = view.layer.sublayers?.first
as? AVCaptureVideoPreviewLayer
{
layer.frame = view.bounds
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
captureSession?.stopRunning()
}
func metadataOutput(
_ output: AVCaptureMetadataOutput,
didOutput metadataObjects: [AVMetadataObject],
from connection: AVCaptureConnection
) {
guard !hasScanned,
let object = metadataObjects.first
as? AVMetadataMachineReadableCodeObject,
let value = object.stringValue,
value.hasPrefix("wc:")
else { return }
hasScanned = true
onScanned?(value)
}
}
Session Card Component
A reusable card that displays connected session details:
private struct SessionCard: View {
let session: WcSession
let onDisconnect: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(session.peer.name).fontWeight(.bold)
if !session.peer.url.isEmpty {
Text(session.peer.url)
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
Button(action: onDisconnect) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red).font(.title3)
}
}
if !session.namespaces.isEmpty {
HStack(spacing: 4) {
ForEach(
Array(session.namespaces.keys.sorted()),
id: \.self
) { ns in
Text(ns)
.font(.caption2)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.blue.opacity(0.1))
.cornerRadius(12)
}
}
}
Text("Topic: \(String(session.topic.prefix(12)))...")
.font(.caption2)
.foregroundColor(.secondary.opacity(0.6))
.monospaced()
}
.padding(12)
.background(Color(.systemGray6))
.cornerRadius(12)
}
}
WalletConnect API Reference
WalletConnectModule
| Method | Description |
|---|
initialize() | Initialize WalletConnect. Call after user authentication. |
pair(uri: String) | Pair with a dApp using a WalletConnect URI from a QR code. |
confirmPairing(confirm: Bool) | Approve or reject a pending session proposal. |
respondSessionRequest(id: Int, topic: String, approved: Bool) | Approve or reject a signing request from a connected dApp. |
disconnectSession(topic: String) | 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 & Publishers
| Property | Type | Description |
|---|
initialized | Bool | Whether WalletConnect has been initialized. |
sessions | [String: WcSession] | Currently active sessions keyed by topic. |
sessionsChanges | AnyPublisher<[String: WcSession], Never> | Reactive stream of session updates. |
onSessionProposal | AnyPublisher<WcSessionProposal, Never> | Fires when a dApp proposes a new session. |
onSessionRequest | AnyPublisher<WcSessionRequest, Never> | Fires when a dApp sends a signing request. |
onSessionDelete | AnyPublisher<String, Never> | Fires when a session is disconnected (emits the topic). |
initializedChanges | AnyPublisher<Bool, Never> | Fires when initialization state changes. |
Data Models
struct WcSession {
let topic: String
let peer: WcPeerMetadata
let namespaces: [String: WcNamespace]
let expiry: Int?
}
struct WcSessionProposal {
let id: Int
let proposer: WcPeerMetadata
let requiredNamespaces: [String: WcNamespace]?
let optionalNamespaces: [String: WcNamespace]?
}
struct WcSessionRequest {
let id: Int
let topic: String
let method: String // e.g. "personal_sign", "eth_sendTransaction"
let chainId: String // e.g. "eip155:1"
let params: Any?
}
struct WcPeerMetadata {
let name: String
let description: String
let url: String
let icons: [String]
}
struct WcNamespace {
let chains: [String]
let methods: [String]
let events: [String]
let accounts: [String]?
}
Best Practices
- Place
WcGlobalListener at the app root so session proposals and signing requests are handled regardless of which screen the user is on.
- 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