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 in AndroidManifest.xml for QR scanning:
<uses-permission android:name="android.permission.CAMERA" />
  • QR scanning dependencies in build.gradle.kts:
// CameraX for camera preview
implementation("androidx.camera:camera-core:1.3.1")
implementation("androidx.camera:camera-camera2:1.3.1")
implementation("androidx.camera:camera-lifecycle:1.3.1")
implementation("androidx.camera:camera-view:1.3.1")

// ML Kit Barcode Scanning
implementation("com.google.mlkit:barcode-scanning:17.2.0")

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 com.dynamic.sdk.android.DynamicSDK
import com.dynamic.sdk.android.Models.ClientProps
import com.dynamic.sdk.android.Models.LoggerLevel

val props = 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"
)

DynamicSDK.initialize(props, applicationContext, activity)

2. Add the Global Listener

The WcGlobalListener is a Composable 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 com.dynamic.sdk.android.DynamicSDK

@Composable
fun App() {
    WcGlobalListener {
        // Your app's root content
        AppNavigation()
    }
}

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 android.widget.Toast
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.dynamic.sdk.android.DynamicSDK
import com.dynamic.sdk.android.Models.WcSessionProposal
import com.dynamic.sdk.android.Models.WcSessionRequest
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonElement

@Composable
fun WcGlobalListener(content: @Composable () -> Unit) {
    val sdk = DynamicSDK.getInstance()
    val wc = sdk.walletConnect
    val scope = rememberCoroutineScope()
    val context = LocalContext.current

    var initialized by remember { mutableStateOf(false) }
    var initializing by remember { mutableStateOf(false) }

    // Dialog state
    var showProposal by remember {
        mutableStateOf<WcSessionProposal?>(null)
    }
    var showRequest by remember {
        mutableStateOf<WcSessionRequest?>(null)
    }

    val snackbarHostState = remember { SnackbarHostState() }

    // Wait for auth token AND WC webview initialization
    val token by sdk.auth.tokenChanges.collectAsState()
    val wcInitialized by wc.initializedChanges.collectAsState()

    LaunchedEffect(token, wcInitialized) {
        if (
            !token.isNullOrEmpty()
            && wcInitialized
            && !initialized
            && !initializing
        ) {
            initializing = true
            try {
                wc.initialize()
                initialized = true
            } catch (e: Exception) {
                android.util.Log.e(
                    "WcGlobalListener",
                    "Failed to initialize WC: ${e.message}"
                )
            }
            initializing = false
        } else if (token.isNullOrEmpty()) {
            initialized = false
            initializing = false
        }
    }

    // Listen for WC events
    LaunchedEffect(Unit) {
        launch {
            wc.onSessionProposal.collect { proposal ->
                showProposal = proposal
            }
        }
        launch {
            wc.onSessionRequest.collect { request ->
                showRequest = request
            }
        }
        launch {
            wc.onSessionDelete.collect { topic ->
                snackbarHostState.showSnackbar(
                    "Session disconnected: ${topic?.take(8)}..."
                )
            }
        }
    }

    Box(modifier = Modifier.fillMaxSize()) {
        content()

        SnackbarHost(
            hostState = snackbarHostState,
            modifier = Modifier.align(Alignment.BottomCenter)
        )
    }

    // Session Proposal Dialog
    showProposal?.let { proposal ->
        ProposalDialog(
            proposal = proposal,
            onApprove = {
                showProposal = null
                scope.launch {
                    try {
                        wc.confirmPairing(true)
                        snackbarHostState.showSnackbar(
                            "Session approved"
                        )
                    } catch (e: Exception) {
                        snackbarHostState.showSnackbar(
                            "Approval failed: ${e.message}"
                        )
                    }
                }
            },
            onReject = {
                showProposal = null
                scope.launch {
                    try { wc.confirmPairing(false) }
                    catch (_: Exception) {}
                }
            }
        )
    }

    // Session Request Dialog
    showRequest?.let { request ->
        RequestDialog(
            request = request,
            onApprove = {
                showRequest = null
                scope.launch {
                    try {
                        wc.respondSessionRequest(
                            request.id,
                            request.topic,
                            true
                        )
                    } catch (e: Exception) {
                        snackbarHostState.showSnackbar(
                            "Request failed: ${e.message}"
                        )
                    }
                }
            },
            onReject = {
                showRequest = null
                scope.launch {
                    try {
                        wc.respondSessionRequest(
                            request.id,
                            request.topic,
                            false
                        )
                    } catch (_: Exception) {}
                }
            }
        )
    }
}

@Composable
private fun ProposalDialog(
    proposal: WcSessionProposal,
    onApprove: () -> Unit,
    onReject: () -> Unit
) {
    AlertDialog(
        onDismissRequest = { /* non-dismissible */ },
        title = { Text("Session Proposal") },
        text = {
            Column(
                modifier = Modifier
                    .verticalScroll(rememberScrollState())
            ) {
                Text(
                    text = proposal.proposer.name,
                    style = MaterialTheme.typography.titleMedium,
                    fontWeight = FontWeight.Bold
                )

                if (proposal.proposer.description.isNotEmpty()) {
                    Spacer(modifier = Modifier.height(4.dp))
                    Text(
                        text = proposal.proposer.description,
                        style = MaterialTheme.typography.bodySmall,
                        color = MaterialTheme.colorScheme
                            .onSurfaceVariant
                    )
                }

                if (proposal.proposer.url.isNotEmpty()) {
                    Spacer(modifier = Modifier.height(4.dp))
                    Text(
                        text = proposal.proposer.url,
                        style = MaterialTheme.typography.bodySmall,
                        color = MaterialTheme.colorScheme.primary,
                        fontSize = 12.sp
                    )
                }

                Spacer(modifier = Modifier.height(16.dp))

                proposal.requiredNamespaces?.let { namespaces ->
                    Text(
                        "Required chains:",
                        fontWeight = FontWeight.SemiBold,
                        fontSize = 13.sp
                    )
                    namespaces.forEach { (key, ns) ->
                        Text(
                            "  $key: ${ns.chains.joinToString(", ")}",
                            fontSize = 13.sp
                        )
                    }
                }

                proposal.optionalNamespaces
                    ?.takeIf { it.isNotEmpty() }
                    ?.let { namespaces ->
                        Spacer(modifier = Modifier.height(8.dp))
                        Text(
                            "Optional chains:",
                            fontWeight = FontWeight.SemiBold,
                            fontSize = 13.sp
                        )
                        namespaces.forEach { (key, ns) ->
                            Text(
                                "  $key: ${ns.chains.joinToString(", ")}",
                                fontSize = 13.sp
                            )
                        }
                    }
            }
        },
        confirmButton = {
            Button(onClick = onApprove) { Text("Approve") }
        },
        dismissButton = {
            TextButton(onClick = onReject) { Text("Reject") }
        }
    )
}

@Composable
private fun RequestDialog(
    request: WcSessionRequest,
    onApprove: () -> Unit,
    onReject: () -> Unit
) {
    val clipboardManager = LocalClipboardManager.current
    val context = LocalContext.current

    AlertDialog(
        onDismissRequest = { /* non-dismissible */ },
        title = { Text("Session Request") },
        text = {
            Column(
                modifier = Modifier
                    .verticalScroll(rememberScrollState())
            ) {
                InfoRow(label = "Method", value = request.method)
                InfoRow(label = "Chain", value = request.chainId)
                InfoRow(
                    label = "Topic",
                    value = "${request.topic.take(12)}..."
                )

                request.params?.let { params ->
                    Spacer(modifier = Modifier.height(8.dp))
                    Row(
                        verticalAlignment = Alignment.CenterVertically
                    ) {
                        Text(
                            "Params:",
                            fontWeight = FontWeight.SemiBold,
                            fontSize = 13.sp
                        )
                        Spacer(modifier = Modifier.weight(1f))
                        IconButton(
                            onClick = {
                                clipboardManager.setText(
                                    AnnotatedString(params.toString())
                                )
                                Toast.makeText(
                                    context,
                                    "Params copied",
                                    Toast.LENGTH_SHORT
                                ).show()
                            },
                            modifier = Modifier.size(24.dp)
                        ) {
                            Icon(
                                imageVector = Icons.Default.ContentCopy,
                                contentDescription = "Copy",
                                modifier = Modifier.size(16.dp)
                            )
                        }
                    }

                    Surface(
                        shape = RoundedCornerShape(8.dp),
                        color = MaterialTheme.colorScheme
                            .surfaceVariant,
                        modifier = Modifier
                            .fillMaxWidth()
                            .heightIn(max = 200.dp)
                    ) {
                        Box(
                            modifier = Modifier
                                .padding(8.dp)
                                .verticalScroll(rememberScrollState())
                        ) {
                            Text(
                                text = params.toString(),
                                fontFamily = FontFamily.Monospace,
                                fontSize = 11.sp
                            )
                        }
                    }
                }
            }
        },
        confirmButton = {
            Button(onClick = onApprove) { Text("Approve") }
        },
        dismissButton = {
            TextButton(onClick = onReject) { Text("Reject") }
        }
    )
}

@Composable
private fun InfoRow(label: String, value: String) {
    Row(modifier = Modifier.padding(bottom = 4.dp)) {
        Text(
            "$label:",
            fontWeight = FontWeight.SemiBold,
            fontSize = 13.sp,
            modifier = Modifier.width(60.dp)
        )
        Text(value, fontSize = 13.sp)
    }
}

4. Build the WalletConnect Screen

This screen provides the UI for scanning QR codes, pasting URIs, and managing active sessions.
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.LinkOff
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.dynamic.sdk.android.DynamicSDK
import com.dynamic.sdk.android.Models.WcSession
import kotlinx.coroutines.launch

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WalletConnectScreen(onNavigateBack: () -> Unit) {
    val wc = DynamicSDK.getInstance().walletConnect
    val scope = rememberCoroutineScope()
    var uriText by remember { mutableStateOf("") }
    var isPairing by remember { mutableStateOf(false) }
    var error by remember { mutableStateOf<String?>(null) }
    var showScanner by remember { mutableStateOf(false) }
    val sessions by wc.sessionsChanges.collectAsState()

    fun doPair(uri: String) {
        scope.launch {
            isPairing = true
            error = null
            try {
                wc.pair(uri.trim())
                uriText = ""
            } catch (e: Exception) {
                error = "Pairing failed: ${e.message}"
            }
            isPairing = false
        }
    }

    if (showScanner) {
        QrScannerScreen(
            onQrCodeScanned = { scannedUri ->
                showScanner = false
                uriText = scannedUri
                doPair(scannedUri)
            },
            onDismiss = { showScanner = false }
        )
        return
    }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("WalletConnect") },
                navigationIcon = {
                    IconButton(onClick = onNavigateBack) {
                        Icon(
                            Icons.AutoMirrored.Filled.ArrowBack,
                            contentDescription = "Back"
                        )
                    }
                }
            )
        }
    ) { paddingValues ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues)
                .padding(16.dp)
        ) {
            // Scan QR button
            Button(
                onClick = { showScanner = true },
                modifier = Modifier.fillMaxWidth(),
                colors = ButtonDefaults.buttonColors(
                    containerColor = MaterialTheme.colorScheme
                        .secondaryContainer,
                    contentColor = MaterialTheme.colorScheme
                        .onSecondaryContainer
                )
            ) {
                Icon(
                    Icons.Default.QrCodeScanner,
                    contentDescription = null,
                    modifier = Modifier.size(20.dp)
                )
                Spacer(modifier = Modifier.width(8.dp))
                Text("Scan QR Code")
            }

            Spacer(modifier = Modifier.height(16.dp))

            // Divider
            Row(
                modifier = Modifier.fillMaxWidth(),
                verticalAlignment = Alignment.CenterVertically
            ) {
                HorizontalDivider(modifier = Modifier.weight(1f))
                Text(
                    "  or paste URI  ",
                    style = MaterialTheme.typography.bodySmall,
                    color = MaterialTheme.colorScheme
                        .onSurfaceVariant
                )
                HorizontalDivider(modifier = Modifier.weight(1f))
            }

            Spacer(modifier = Modifier.height(16.dp))

            // URI input
            OutlinedTextField(
                value = uriText,
                onValueChange = { uriText = it },
                label = { Text("WalletConnect URI") },
                placeholder = { Text("wc:...") },
                modifier = Modifier.fillMaxWidth(),
                singleLine = true
            )

            Spacer(modifier = Modifier.height(8.dp))

            Button(
                onClick = {
                    if (uriText.isNotBlank()) doPair(uriText)
                },
                enabled = !isPairing,
                modifier = Modifier.fillMaxWidth()
            ) {
                if (isPairing) {
                    CircularProgressIndicator(
                        modifier = Modifier.size(18.dp),
                        strokeWidth = 2.dp,
                        color = MaterialTheme.colorScheme.onPrimary
                    )
                } else {
                    Text("Pair")
                }
            }

            if (error != null) {
                Spacer(modifier = Modifier.height(8.dp))
                Text(
                    error!!,
                    color = MaterialTheme.colorScheme.error,
                    style = MaterialTheme.typography.bodySmall
                )
            }

            Spacer(modifier = Modifier.height(24.dp))

            Text(
                "Active Sessions",
                style = MaterialTheme.typography.titleLarge,
                fontWeight = FontWeight.Bold
            )

            Spacer(modifier = Modifier.height(8.dp))

            if (sessions.isEmpty()) {
                Box(
                    modifier = Modifier.fillMaxWidth().weight(1f),
                    contentAlignment = Alignment.Center
                ) {
                    Column(
                        horizontalAlignment = Alignment.CenterHorizontally
                    ) {
                        Icon(
                            Icons.Default.LinkOff,
                            contentDescription = null,
                            modifier = Modifier.size(48.dp),
                            tint = MaterialTheme.colorScheme
                                .onSurfaceVariant
                        )
                        Spacer(modifier = Modifier.height(8.dp))
                        Text(
                            "No active sessions",
                            color = MaterialTheme.colorScheme
                                .onSurfaceVariant
                        )
                        Text(
                            "Scan a QR code or paste a URI",
                            style = MaterialTheme.typography.bodySmall,
                            color = MaterialTheme.colorScheme
                                .onSurfaceVariant
                                .copy(alpha = 0.7f)
                        )
                    }
                }
            } else {
                Column(
                    modifier = Modifier
                        .weight(1f)
                        .verticalScroll(rememberScrollState())
                ) {
                    sessions.entries.forEach { (topic, session) ->
                        SessionCard(
                            session = session,
                            onDisconnect = {
                                scope.launch {
                                    try {
                                        wc.disconnectSession(topic)
                                    } catch (e: Exception) {
                                        error = "Disconnect failed: " +
                                            "${e.message}"
                                    }
                                }
                            }
                        )
                        Spacer(modifier = Modifier.height(8.dp))
                    }
                }
            }
        }
    }
}

@Composable
private fun SessionCard(
    session: WcSession,
    onDisconnect: () -> Unit
) {
    Card(
        modifier = Modifier.fillMaxWidth(),
        shape = RoundedCornerShape(12.dp)
    ) {
        Column(modifier = Modifier.padding(12.dp)) {
            Row(verticalAlignment = Alignment.CenterVertically) {
                Column(modifier = Modifier.weight(1f)) {
                    Text(
                        session.peer.name,
                        fontWeight = FontWeight.Bold,
                        style = MaterialTheme.typography.bodyLarge
                    )
                    if (session.peer.url.isNotEmpty()) {
                        Text(
                            session.peer.url,
                            style = MaterialTheme.typography.bodySmall,
                            color = MaterialTheme.colorScheme
                                .onSurfaceVariant
                        )
                    }
                }
                IconButton(onClick = onDisconnect) {
                    Icon(
                        Icons.Default.LinkOff,
                        contentDescription = "Disconnect",
                        tint = MaterialTheme.colorScheme.error
                    )
                }
            }

            if (session.namespaces.isNotEmpty()) {
                Spacer(modifier = Modifier.height(8.dp))
                Row(
                    horizontalArrangement = Arrangement.spacedBy(4.dp)
                ) {
                    session.namespaces.keys.forEach { ns ->
                        SuggestionChip(
                            onClick = {},
                            label = { Text(ns, fontSize = 11.sp) }
                        )
                    }
                }
            }

            Text(
                "Topic: ${session.topic.take(12)}...",
                style = MaterialTheme.typography.bodySmall,
                fontFamily = FontFamily.Monospace,
                color = MaterialTheme.colorScheme
                    .onSurfaceVariant.copy(alpha = 0.6f)
            )
        }
    }
}

5. Build the QR Scanner

The QR scanner uses CameraX and ML Kit Barcode Scanning to detect WalletConnect QR codes.
import android.Manifest
import android.util.Size
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage

@androidx.annotation.OptIn(
    androidx.camera.core.ExperimentalGetImage::class
)
@Composable
fun QrScannerScreen(
    onQrCodeScanned: (String) -> Unit,
    onDismiss: () -> Unit
) {
    val context = LocalContext.current
    val lifecycleOwner = LocalLifecycleOwner.current
    var hasCameraPermission by remember { mutableStateOf(false) }
    var hasScanned by remember { mutableStateOf(false) }

    val permissionLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestPermission()
    ) { granted ->
        hasCameraPermission = granted
    }

    LaunchedEffect(Unit) {
        permissionLauncher.launch(Manifest.permission.CAMERA)
    }

    Box(modifier = Modifier.fillMaxSize()) {
        if (hasCameraPermission) {
            AndroidView(
                factory = { ctx ->
                    val previewView = PreviewView(ctx)
                    val cameraProviderFuture =
                        ProcessCameraProvider.getInstance(ctx)

                    cameraProviderFuture.addListener({
                        val cameraProvider =
                            cameraProviderFuture.get()
                        val preview = Preview.Builder()
                            .build()
                            .also {
                                it.setSurfaceProvider(
                                    previewView.surfaceProvider
                                )
                            }

                        val scanner =
                            BarcodeScanning.getClient()
                        val imageAnalysis = ImageAnalysis
                            .Builder()
                            .setTargetResolution(
                                Size(1280, 720)
                            )
                            .setBackpressureStrategy(
                                ImageAnalysis
                                    .STRATEGY_KEEP_ONLY_LATEST
                            )
                            .build()

                        imageAnalysis.setAnalyzer(
                            ContextCompat.getMainExecutor(ctx)
                        ) { imageProxy ->
                            val mediaImage = imageProxy.image
                            if (
                                mediaImage != null && !hasScanned
                            ) {
                                val inputImage =
                                    InputImage.fromMediaImage(
                                        mediaImage,
                                        imageProxy.imageInfo
                                            .rotationDegrees
                                    )
                                scanner.process(inputImage)
                                    .addOnSuccessListener { barcodes ->
                                        for (barcode in barcodes) {
                                            if (
                                                barcode.format ==
                                                Barcode.FORMAT_QR_CODE
                                            ) {
                                                val value =
                                                    barcode.rawValue
                                                if (
                                                    value != null
                                                    && value.startsWith(
                                                        "wc:"
                                                    )
                                                    && !hasScanned
                                                ) {
                                                    hasScanned = true
                                                    onQrCodeScanned(
                                                        value
                                                    )
                                                }
                                            }
                                        }
                                    }
                                    .addOnCompleteListener {
                                        imageProxy.close()
                                    }
                            } else {
                                imageProxy.close()
                            }
                        }

                        cameraProvider.unbindAll()
                        cameraProvider.bindToLifecycle(
                            lifecycleOwner,
                            CameraSelector.DEFAULT_BACK_CAMERA,
                            preview,
                            imageAnalysis
                        )
                    }, ContextCompat.getMainExecutor(ctx))

                    previewView
                },
                modifier = Modifier.fillMaxSize()
            )

            // Scanning overlay
            Canvas(modifier = Modifier.fillMaxSize()) {
                val scanBoxSize = size.minDimension * 0.65f
                val left = (size.width - scanBoxSize) / 2
                val top = (size.height - scanBoxSize) / 2

                drawRect(
                    color = Color.Black.copy(alpha = 0.5f)
                )
                drawRoundRect(
                    color = Color.Transparent,
                    topLeft = Offset(left, top),
                    size = androidx.compose.ui.geometry.Size(
                        scanBoxSize, scanBoxSize
                    ),
                    cornerRadius = CornerRadius(16.dp.toPx()),
                    blendMode = BlendMode.Clear
                )
                drawRoundRect(
                    color = Color.White,
                    topLeft = Offset(left, top),
                    size = androidx.compose.ui.geometry.Size(
                        scanBoxSize, scanBoxSize
                    ),
                    cornerRadius = CornerRadius(16.dp.toPx()),
                    style = Stroke(width = 2.dp.toPx())
                )
            }

            Column(
                modifier = Modifier
                    .fillMaxWidth()
                    .align(Alignment.BottomCenter)
                    .padding(bottom = 100.dp),
                horizontalAlignment = Alignment.CenterHorizontally
            ) {
                Text(
                    "Scan WalletConnect QR Code",
                    color = Color.White,
                    style = MaterialTheme.typography.titleMedium
                )
            }
        } else {
            Column(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(32.dp),
                horizontalAlignment = Alignment.CenterHorizontally,
                verticalArrangement = Arrangement.Center
            ) {
                Text("Camera permission is required to scan QR codes")
                Spacer(modifier = Modifier.height(16.dp))
                Button(onClick = {
                    permissionLauncher.launch(
                        Manifest.permission.CAMERA
                    )
                }) {
                    Text("Grant Permission")
                }
            }
        }

        // Close button
        IconButton(
            onClick = onDismiss,
            modifier = Modifier
                .align(Alignment.TopEnd)
                .padding(16.dp)
                .statusBarsPadding(),
            colors = IconButtonDefaults.iconButtonColors(
                containerColor = Color.Black.copy(alpha = 0.5f),
                contentColor = Color.White
            )
        ) {
            Icon(
                Icons.Default.Close,
                contentDescription = "Close scanner"
            )
        }
    }
}

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(approved: Boolean)Approve or reject a pending session proposal.
respondSessionRequest(id: String, topic: String, approved: Boolean)Approve or reject a signing request from a connected dApp.
disconnectSession(topic: String)Disconnect a specific session.

Properties & Flows

PropertyTypeDescription
sessionsChangesStateFlow<Map<String, WcSession>>Reactive flow of active sessions.
onSessionProposalSharedFlow<WcSessionProposal>Emits when a dApp proposes a new session.
onSessionRequestSharedFlow<WcSessionRequest>Emits when a dApp sends a signing request.
onSessionDeleteSharedFlow<String?>Emits when a session is disconnected (topic).
initializedChangesStateFlow<Boolean>Emits when WC initialization state changes.

Data Models

data class WcSession(
    val topic: String,
    val peer: WcPeerMetadata,
    val namespaces: Map<String, WcNamespace>,
    val expiry: Int?
)

data class WcSessionProposal(
    val id: Int,
    val proposer: WcPeerMetadata,
    val requiredNamespaces: Map<String, WcNamespace>?,
    val optionalNamespaces: Map<String, WcNamespace>?
)

data class WcSessionRequest(
    val id: String,
    val topic: String,
    val method: String,    // e.g. "personal_sign", "eth_sendTransaction"
    val chainId: String,   // e.g. "eip155:1"
    val params: JsonElement?
)

data class WcPeerMetadata(
    val name: String,
    val description: String,
    val url: String,
    val icons: List<String>
)

data class WcNamespace(
    val chains: List<String>,
    val methods: List<String>,
    val events: List<String>,
    val accounts: List<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.
  • Wait for both auth token and initializedChanges before calling wc.initialize() — the Kotlin SDK requires both the user to be authenticated and the internal WebView to be ready.
  • Show clear approval UIs — display the dApp name, URL, and requested chains so users can make informed decisions.
  • Handle disconnections gracefully — collect 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