feat(desktop): add nostrconnect:// login flow for QR-based signer pairing

Adds client-initiated NIP-46 (nostrconnect://) flow so users can pair
with a remote signer (e.g. Amber) by scanning a QR code instead of
manually copying a bunker:// URI.

Changes:
- Fix RemoteSignerManager to decrypt NIP-44 content before parsing
- Add CoroutineScope to NostrSignerRemote for async event callbacks
- Add loginWithNostrConnect() to AccountManager with 120s timeout
- Add QrCodeCanvas composable using ZXing for QR generation
- Add "Connect" tab to LoginCard showing QR + copyable URI + waiting state
- Wire nostrconnect flow through LoginScreen

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-05 15:03:37 +02:00
parent bff6723ba6
commit 12c91aae3d
7 changed files with 511 additions and 88 deletions
+3
View File
@@ -49,6 +49,9 @@ dependencies {
// Collections
implementation(libs.kotlinx.collections.immutable)
// QR code generation (ZXing core)
implementation(libs.zxing)
// Testing
testImplementation(libs.kotlin.test)
testImplementation(libs.kotlinx.coroutines.test)
@@ -23,10 +23,15 @@ package com.vitorpamplona.amethyst.desktop.account
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
import com.vitorpamplona.amethyst.commons.keystorage.SecureStorageException
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.req
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
@@ -34,8 +39,13 @@ import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip19Bech32.toNsec
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseAck
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -44,7 +54,9 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import java.io.File
import java.security.SecureRandom
sealed class SignerType {
data object Internal : SignerType()
@@ -93,6 +105,8 @@ class AccountManager internal constructor(
internal const val HEARTBEAT_INTERVAL_MS = 60_000L
internal const val MAX_CONSECUTIVE_FAILURES = 3
internal const val BUNKER_EPHEMERAL_KEY_ALIAS = "bunker_ephemeral"
internal const val NOSTRCONNECT_TIMEOUT_MS = 120_000L
internal val NIP46_RELAYS = listOf("wss://relay.nsec.app")
}
private val amethystDir: File by lazy {
@@ -226,6 +240,148 @@ class AccountManager internal constructor(
Result.failure(Exception("Connection failed: ${e.message}"))
}
// --- Nostrconnect login ---
suspend fun loginWithNostrConnect(
client: INostrClient,
onUriGenerated: (String) -> Unit,
): Result<AccountState.LoggedIn> =
try {
val ephemeralKeyPair = KeyPair()
val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair)
val secret = generateNostrConnectSecret()
val ephemeralPubKey = ephemeralKeyPair.pubKey.toHexKey()
val relays = NIP46_RELAYS
val relayParams = relays.joinToString("&") { "relay=$it" }
val uri = "nostrconnect://$ephemeralPubKey?$relayParams&secret=$secret&name=Amethyst%20Desktop"
onUriGenerated(uri)
val normalizedRelays = relays.map { NormalizedRelayUrl(it) }.toSet()
val connectData = waitForConnectRequest(ephemeralSigner, ephemeralPubKey, normalizedRelays, secret, client)
sendAckResponse(ephemeralSigner, connectData, normalizedRelays, client)
val remoteSigner =
NostrSignerRemote(
signer = ephemeralSigner,
remotePubkey = connectData.signerPubkey,
relays = normalizedRelays,
client = client,
)
remoteSigner.openSubscription()
val syntheticBunkerUri = "bunker://${connectData.signerPubkey}?$relayParams"
val state =
AccountState.LoggedIn(
signer = remoteSigner,
pubKeyHex = connectData.userPubkey,
npub = connectData.userPubkey.hexToByteArray().toNpub(),
nsec = null,
isReadOnly = false,
signerType = SignerType.Remote(syntheticBunkerUri),
)
_accountState.value = state
_signerConnectionState.value = SignerConnectionState.Connected
saveBunkerAccount(
bunkerUri = syntheticBunkerUri,
ephemeralPrivKeyHex = ephemeralKeyPair.privKey!!.toHexKey(),
npub = state.npub,
)
Result.success(state)
} catch (e: kotlinx.coroutines.TimeoutCancellationException) {
Result.failure(Exception("Timed out waiting for signer. Ensure the signer app scanned the QR code."))
} catch (e: Exception) {
Result.failure(Exception("Connection failed: ${e.message}"))
}
private suspend fun waitForConnectRequest(
ephemeralSigner: NostrSignerInternal,
ephemeralPubKey: HexKey,
relays: Set<NormalizedRelayUrl>,
expectedSecret: String,
client: INostrClient,
): ConnectRequestData {
val deferred = CompletableDeferred<NostrConnectEvent>()
val subscription =
client.req(
relays = relays.toList(),
filter =
Filter(
kinds = listOf(NostrConnectEvent.KIND),
tags = mapOf("p" to listOf(ephemeralPubKey)),
),
) { event ->
if (event is NostrConnectEvent && !deferred.isCompleted) {
deferred.complete(event)
}
}
try {
val event =
withTimeout(NOSTRCONNECT_TIMEOUT_MS) {
deferred.await()
}
val otherPubkey = event.talkingWith(ephemeralSigner.pubKey)
val decryptedJson = ephemeralSigner.decrypt(event.content, otherPubkey)
val message = OptimizedJsonMapper.fromJsonTo<BunkerRequest>(decryptedJson)
if (message.method != BunkerRequestConnect.METHOD_NAME) {
throw Exception("Expected 'connect' method, got '${message.method}'")
}
val userPubkey =
message.params.getOrNull(0)
?: throw Exception("Missing user pubkey in connect request")
val receivedSecret = message.params.getOrNull(1)
if (receivedSecret != expectedSecret) {
throw Exception("Secret mismatch in connect request")
}
return ConnectRequestData(
requestId = message.id,
signerPubkey = event.pubKey,
userPubkey = userPubkey,
)
} finally {
subscription.close()
}
}
private suspend fun sendAckResponse(
ephemeralSigner: NostrSignerInternal,
connectData: ConnectRequestData,
relays: Set<NormalizedRelayUrl>,
client: INostrClient,
) {
val ackResponse = BunkerResponseAck(id = connectData.requestId)
val ackEvent =
NostrConnectEvent.create(
message = ackResponse,
remoteKey = connectData.signerPubkey,
signer = ephemeralSigner,
)
client.send(ackEvent, relays)
}
private fun generateNostrConnectSecret(): String {
val bytes = ByteArray(32)
SecureRandom().nextBytes(bytes)
return bytes.joinToString("") { "%02x".format(it) }
}
private data class ConnectRequestData(
val requestId: String,
val signerPubkey: HexKey,
val userPubkey: HexKey,
)
private suspend fun saveBunkerAccount(
bunkerUri: String,
ephemeralPrivKeyHex: String,
@@ -106,6 +106,16 @@ fun LoginScreen(
} else {
null
},
onLoginNostrConnect =
if (relayClient != null) {
{ onUriGenerated ->
accountManager.loginWithNostrConnect(relayClient, onUriGenerated).map {
onLoginSuccess()
}
}
} else {
null
},
)
val account = generatedAccount
@@ -30,21 +30,30 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.resources.Res
@@ -83,16 +92,14 @@ fun LoginCard(
onLogin: (String) -> Result<Unit>,
onGenerateNew: () -> Unit,
onLoginBunker: (suspend (String) -> Result<Unit>)? = null,
onLoginNostrConnect: (suspend (onUriGenerated: (String) -> Unit) -> Result<Unit>)? = null,
modifier: Modifier = Modifier,
cardWidth: Dp = 400.dp,
title: String = stringResource(Res.string.login_card_title),
subtitle: String = stringResource(Res.string.login_card_subtitle),
) {
var keyInput by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf<String?>(null) }
var isConnecting by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val isBunker = keyInput.trim().startsWith("bunker://", ignoreCase = true)
var selectedTab by remember { mutableIntStateOf(0) }
val tabs = listOf("Paste Key", "Connect")
Card(
modifier = modifier.width(cardWidth),
@@ -113,97 +120,254 @@ fun LoginCard(
Spacer(Modifier.height(16.dp))
KeyInputField(
value = keyInput,
onValueChange = {
keyInput = it
errorMessage = null
if (onLoginNostrConnect != null) {
@Suppress("DEPRECATION")
PrimaryTabRow(
selectedTabIndex = selectedTab,
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)),
) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(title) },
)
}
}
Spacer(Modifier.height(16.dp))
}
when (selectedTab) {
0 -> PasteKeyContent(onLogin, onGenerateNew, onLoginBunker, subtitle)
1 -> if (onLoginNostrConnect != null) NostrConnectContent(onLoginNostrConnect)
}
}
}
}
@Composable
private fun PasteKeyContent(
onLogin: (String) -> Result<Unit>,
onGenerateNew: () -> Unit,
onLoginBunker: (suspend (String) -> Result<Unit>)?,
subtitle: String,
) {
var keyInput by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf<String?>(null) }
var isConnecting by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val isBunker = keyInput.trim().startsWith("bunker://", ignoreCase = true)
KeyInputField(
value = keyInput,
onValueChange = {
keyInput = it
errorMessage = null
},
errorMessage = errorMessage,
)
Spacer(Modifier.height(8.dp))
if (isBunker) {
Text(
"This URI connects to your remote signer. Treat it like a password.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
} else {
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(16.dp))
if (isConnecting) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
)
Spacer(Modifier.width(12.dp))
Text(
"Connecting to remote signer...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = {
if (isBunker && onLoginBunker != null) {
val validationError = validateBunkerUri(keyInput)
if (validationError != null) {
errorMessage = validationError
return@Button
}
isConnecting = true
errorMessage = null
scope.launch(Dispatchers.IO) {
val result = onLoginBunker(keyInput.trim())
withContext(Dispatchers.Main) {
result.fold(
onSuccess = { isConnecting = false },
onFailure = {
errorMessage = it.message
isConnecting = false
},
)
}
}
} else {
onLogin(keyInput).fold(
onSuccess = { /* handled by caller */ },
onFailure = { errorMessage = it.message },
)
}
},
errorMessage = errorMessage,
modifier = Modifier.weight(1f),
enabled = keyInput.isNotBlank(),
) {
Text(if (isBunker) "Connect to Signer" else stringResource(Res.string.login_button))
}
OutlinedButton(
onClick = onGenerateNew,
modifier = Modifier.weight(1f),
) {
Text(stringResource(Res.string.login_generate_button))
}
}
}
}
@Composable
private fun NostrConnectContent(onLoginNostrConnect: suspend (onUriGenerated: (String) -> Unit) -> Result<Unit>) {
var nostrConnectUri by remember { mutableStateOf<String?>(null) }
var isConnecting by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
@Suppress("DEPRECATION")
val clipboardManager = LocalClipboardManager.current
if (errorMessage != null) {
Text(
errorMessage!!,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(12.dp))
Button(onClick = {
errorMessage = null
nostrConnectUri = null
}) {
Text("Try Again")
}
} else if (!isConnecting) {
Text(
"Show a QR code for your signer app (e.g. Amber) to scan.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(16.dp))
Button(
onClick = {
isConnecting = true
errorMessage = null
scope.launch(Dispatchers.IO) {
val result =
onLoginNostrConnect { uri ->
nostrConnectUri = uri
}
withContext(Dispatchers.Main) {
result.onFailure {
errorMessage = it.message
isConnecting = false
nostrConnectUri = null
}
}
}
},
modifier = Modifier.fillMaxWidth(),
) {
Text("Start Connection")
}
} else {
val uri = nostrConnectUri
if (uri != null) {
QrCodeCanvas(
data = uri,
size = 200.dp,
)
Spacer(Modifier.height(12.dp))
Text(
uri,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
if (isBunker) {
Text(
"This URI connects to your remote signer. Treat it like a password.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
OutlinedButton(
onClick = { clipboardManager.setText(AnnotatedString(uri)) },
modifier = Modifier.fillMaxWidth(),
) {
Text("Copy URI")
}
Spacer(Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
} else {
Spacer(Modifier.width(8.dp))
Text(
subtitle,
"Waiting for signer to connect...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(16.dp))
if (isConnecting) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
)
Spacer(Modifier.width(12.dp))
Text(
"Connecting to remote signer...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = {
if (isBunker && onLoginBunker != null) {
val validationError = validateBunkerUri(keyInput)
if (validationError != null) {
errorMessage = validationError
return@Button
}
isConnecting = true
errorMessage = null
scope.launch(Dispatchers.IO) {
val result = onLoginBunker(keyInput.trim())
withContext(Dispatchers.Main) {
result.fold(
onSuccess = { isConnecting = false },
onFailure = {
errorMessage = it.message
isConnecting = false
},
)
}
}
} else {
onLogin(keyInput).fold(
onSuccess = { /* handled by caller */ },
onFailure = { errorMessage = it.message },
)
}
},
modifier = Modifier.weight(1f),
enabled = keyInput.isNotBlank(),
) {
Text(if (isBunker) "Connect to Signer" else stringResource(Res.string.login_button))
}
OutlinedButton(
onClick = onGenerateNew,
modifier = Modifier.weight(1f),
) {
Text(stringResource(Res.string.login_generate_button))
}
}
} else {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
)
Spacer(Modifier.width(8.dp))
Text(
"Generating connection...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -0,0 +1,75 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.auth
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
@Composable
fun QrCodeCanvas(
data: String,
modifier: Modifier = Modifier,
size: Dp = 200.dp,
foreground: Color = MaterialTheme.colorScheme.onSurface,
background: Color = Color.White,
) {
val matrix =
remember(data) {
QRCodeWriter().encode(
data,
BarcodeFormat.QR_CODE,
0,
0,
mapOf(EncodeHintType.MARGIN to 1),
)
}
Canvas(modifier = modifier.size(size)) {
val cellWidth = this.size.width / matrix.width
val cellHeight = this.size.height / matrix.height
drawRect(background, Offset.Zero, this.size)
for (x in 0 until matrix.width) {
for (y in 0 until matrix.height) {
if (matrix.get(x, y)) {
drawRect(
color = foreground,
topLeft = Offset(x * cellWidth, y * cellHeight),
size = Size(cellWidth, cellHeight),
)
}
}
}
}
}
@@ -42,6 +42,11 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.utils.Hex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
class NostrSignerRemote(
val signer: NostrSignerInternal,
@@ -51,6 +56,8 @@ class NostrSignerRemote(
val permissions: String? = null,
val secret: String? = null,
) : NostrSigner(signer.pubKey) {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private val manager =
RemoteSignerManager(
signer = signer,
@@ -69,7 +76,13 @@ class NostrSignerRemote(
),
) { event ->
if (event is NostrConnectEvent) {
manager.newResponse(event)
scope.launch {
try {
manager.newResponse(event)
} catch (_: Exception) {
// Ignore events we can't decrypt or parse
}
}
}
}
@@ -79,6 +92,7 @@ class NostrSignerRemote(
fun closeSubscription() {
subscription.close()
scope.cancel()
}
override fun isWriteable(): Boolean = true
@@ -41,8 +41,9 @@ class RemoteSignerManager(
) {
private val awaitingRequests = LargeCache<String, Continuation<BunkerResponse>>()
fun newResponse(responseEvent: NostrConnectEvent) {
val bunkerResponse = OptimizedJsonMapper.fromJsonTo<BunkerResponse>(responseEvent.content)
suspend fun newResponse(responseEvent: NostrConnectEvent) {
val decryptedJson = signer.decrypt(responseEvent.content, remoteKey)
val bunkerResponse = OptimizedJsonMapper.fromJsonTo<BunkerResponse>(decryptedJson)
awaitingRequests.get(bunkerResponse.id)?.resume(bunkerResponse)
}