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)
  • 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

  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 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

MethodDescription
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

PropertyTypeDescription
initializedBoolWhether WalletConnect has been initialized.
sessions[String: WcSession]Currently active sessions keyed by topic.
sessionsChangesAnyPublisher<[String: WcSession], Never>Reactive stream of session updates.
onSessionProposalAnyPublisher<WcSessionProposal, Never>Fires when a dApp proposes a new session.
onSessionRequestAnyPublisher<WcSessionRequest, Never>Fires when a dApp sends a signing request.
onSessionDeleteAnyPublisher<String, Never>Fires when a session is disconnected (emits the topic).
initializedChangesAnyPublisher<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