feat(desktop): add bunker heartbeat indicator to sidebar navigation

Pulsating green heart icon shows NIP-46 signer connection status in both
DeckSidebar and SinglePaneLayout NavigationRail. Tooltip displays last
ping time. SignerConnectionState moved to commons for KMP sharing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-10 09:33:10 +02:00
parent 5475d6c2bc
commit 4d1d92db39
8 changed files with 212 additions and 181 deletions
@@ -0,0 +1,29 @@
/*
* 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.commons.domain.nip46
sealed class SignerConnectionState {
data object NotRemote : SignerConnectionState()
data object Connected : SignerConnectionState()
data object Disconnected : SignerConnectionState()
}
@@ -0,0 +1,117 @@
/*
* 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.commons.ui.components
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
import com.vitorpamplona.quartz.utils.TimeUtils
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BunkerHeartbeatIndicator(
signerConnectionState: SignerConnectionState,
lastPingTimeSec: Long?,
modifier: Modifier = Modifier,
) {
if (signerConnectionState is SignerConnectionState.NotRemote) return
val tooltipState = rememberTooltipState()
val tooltipText =
when (signerConnectionState) {
is SignerConnectionState.Connected -> {
if (lastPingTimeSec != null) {
val agoSeconds = TimeUtils.now() - lastPingTimeSec
"Bunker connected \u2014 last pinged ${agoSeconds}s ago"
} else {
"Bunker connected"
}
}
is SignerConnectionState.Disconnected -> {
"Bunker disconnected"
}
else -> {
""
}
}
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
tooltip = { PlainTooltip { Text(tooltipText) } },
state = tooltipState,
modifier = modifier,
) {
when (signerConnectionState) {
is SignerConnectionState.Connected -> {
val infiniteTransition = rememberInfiniteTransition(label = "heartbeat")
val scale by infiniteTransition.animateFloat(
initialValue = 0.85f,
targetValue = 1.15f,
animationSpec =
infiniteRepeatable(
animation = tween(800),
repeatMode = RepeatMode.Reverse,
),
label = "heartbeatScale",
)
Icon(
Icons.Default.Favorite,
contentDescription = "Bunker connected",
tint = Color(0xFF4CAF50),
modifier = Modifier.size(20.dp).scale(scale),
)
}
is SignerConnectionState.Disconnected -> {
Icon(
Icons.Default.FavoriteBorder,
contentDescription = "Bunker disconnected",
tint = Color(0xFFF44336),
modifier = Modifier.size(20.dp),
)
}
else -> {}
}
}
}
@@ -557,6 +557,8 @@ fun MainContent(
) {
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val signerConnectionState by accountManager.signerConnectionState.collectAsState()
val lastPingTimeSec by accountManager.lastPingTimeSec.collectAsState()
// DM infrastructure — hoisted here so it survives screen navigation
val dmSendTracker =
@@ -678,6 +680,8 @@ fun MainContent(
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onZapFeedback = onZapFeedback,
signerConnectionState = signerConnectionState,
lastPingTimeSec = lastPingTimeSec,
modifier = Modifier.weight(1f),
)
}
@@ -692,6 +696,8 @@ fun MainContent(
deckState.addColumn(DeckColumnType.Settings)
}
},
signerConnectionState = signerConnectionState,
lastPingTimeSec = lastPingTimeSec,
)
VerticalDivider()
@@ -21,22 +21,21 @@
package com.vitorpamplona.amethyst.desktop.account
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.domain.nip46.BunkerLoginUseCase
import com.vitorpamplona.amethyst.commons.domain.nip46.NostrConnectLoginUseCase
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
import com.vitorpamplona.amethyst.commons.keystorage.SecureStorageException
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
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.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.req
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
@@ -46,17 +45,9 @@ 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.BunkerMessage
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse
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 com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -72,7 +63,6 @@ import kotlinx.coroutines.withTimeout
import java.io.File
import java.nio.file.Files
import java.nio.file.attribute.PosixFilePermission
import java.security.SecureRandom
sealed class SignerType {
data object Internal : SignerType()
@@ -82,14 +72,6 @@ sealed class SignerType {
) : SignerType()
}
sealed class SignerConnectionState {
data object NotRemote : SignerConnectionState()
data object Connected : SignerConnectionState()
data object Disconnected : SignerConnectionState()
}
sealed class AccountState {
data object LoggedOut : AccountState()
@@ -119,7 +101,6 @@ 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 const val NIP46_RELAY_CONNECT_TIMEOUT_MS = 15_000L
internal val NIP46_RELAYS = listOf("wss://relay.nsec.app")
}
@@ -137,6 +118,9 @@ class AccountManager internal constructor(
private val _signerConnectionState = MutableStateFlow<SignerConnectionState>(SignerConnectionState.NotRemote)
val signerConnectionState: StateFlow<SignerConnectionState> = _signerConnectionState.asStateFlow()
private val _lastPingTimeSec = MutableStateFlow<Long?>(null)
val lastPingTimeSec: StateFlow<Long?> = _lastPingTimeSec.asStateFlow()
private val _forceLogoutReason = MutableStateFlow<String?>(null)
val forceLogoutReason: StateFlow<String?> = _forceLogoutReason.asStateFlow()
@@ -326,31 +310,26 @@ class AccountManager internal constructor(
val nip46Client = getOrCreateNip46Client()
client = nip46Client
val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, nip46Client)
// Emit connecting with initial relay statuses
val relaysFromUri = parseBunkerRelays(bunkerUri)
_loginProgress.value =
LoginProgress.ConnectingToRelays(
remoteSigner.relays.associateWith { RelayLoginStatus.CONNECTING },
relaysFromUri.associateWith { RelayLoginStatus.CONNECTING },
)
nip46Client.subscribe(listener)
remoteSigner.openSubscription()
// Wait for websocket to be ready before sending connect request
awaitNip46RelayConnection(nip46Client, remoteSigner.relays)
_loginProgress.value =
LoginProgress.WaitingForSigner(
relayStatuses = _loginProgress.value?.relayStatuses.orEmpty(),
)
val remotePubkey = remoteSigner.connect()
val result = BunkerLoginUseCase.execute(bunkerUri, ephemeralSigner, nip46Client)
val state =
AccountState.LoggedIn(
signer = remoteSigner,
pubKeyHex = remotePubkey,
npub = remotePubkey.hexToByteArray().toNpub(),
signer = result.signer,
pubKeyHex = result.pubKeyHex,
npub = result.pubKeyHex.hexToByteArray().toNpub(),
nsec = null,
isReadOnly = false,
signerType = SignerType.Remote(bunkerUri),
@@ -358,7 +337,6 @@ class AccountManager internal constructor(
_accountState.value = state
_signerConnectionState.value = SignerConnectionState.Connected
// Save bunker account — strip secret param (no longer needed after connect)
saveBunkerAccount(
bunkerUri = stripBunkerSecret(bunkerUri),
ephemeralPrivKeyHex = ephemeralKeyPair.privKey!!.toHexKey(),
@@ -389,61 +367,34 @@ class AccountManager internal constructor(
var client: NostrClient? = null
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"
val uriData = NostrConnectLoginUseCase.generateUri(ephemeralKeyPair, NIP46_RELAYS, "Amethyst%20Desktop")
val nip46Client = getOrCreateNip46Client()
client = nip46Client
val normalizedRelays = relays.map { NormalizedRelayUrl(it) }.toSet()
// Emit connecting with initial relay statuses
_loginProgress.value =
LoginProgress.ConnectingToRelays(
normalizedRelays.associateWith { RelayLoginStatus.CONNECTING },
uriData.relays.associateWith { RelayLoginStatus.CONNECTING },
)
nip46Client.subscribe(listener)
onUriGenerated(uri)
onUriGenerated(uriData.uri)
_loginProgress.value =
LoginProgress.WaitingForSigner(
relayStatuses = _loginProgress.value?.relayStatuses.orEmpty(),
)
val connectData = waitForConnectRequest(ephemeralSigner, ephemeralPubKey, normalizedRelays, secret, nip46Client)
if (connectData.requestId != null) {
_loginProgress.value =
LoginProgress.SendingAck(
relayStatuses = _loginProgress.value?.relayStatuses.orEmpty(),
)
sendAckResponse(ephemeralSigner, connectData, normalizedRelays, nip46Client)
}
val result = NostrConnectLoginUseCase.awaitAndLogin(uriData, nip46Client)
val remoteSigner =
NostrSignerRemote(
signer = ephemeralSigner,
remotePubkey = connectData.signerPubkey,
relays = normalizedRelays,
client = nip46Client,
)
remoteSigner.openSubscription()
// Verify user pubkey via get_public_key — connect params may contain
// the wrong pubkey (e.g. ephemeral key echoed back by some signers)
val verifiedPubkey = remoteSigner.getPublicKey()
val syntheticBunkerUri = "bunker://${connectData.signerPubkey}?$relayParams"
val relayParams = NIP46_RELAYS.joinToString("&") { "relay=$it" }
val syntheticBunkerUri = "bunker://${result.signer.remotePubkey}?$relayParams"
val state =
AccountState.LoggedIn(
signer = remoteSigner,
pubKeyHex = verifiedPubkey,
npub = verifiedPubkey.hexToByteArray().toNpub(),
signer = result.signer,
pubKeyHex = result.pubKeyHex,
npub = result.pubKeyHex.hexToByteArray().toNpub(),
nsec = null,
isReadOnly = false,
signerType = SignerType.Remote(syntheticBunkerUri),
@@ -468,116 +419,6 @@ class AccountManager internal constructor(
}
}
private suspend fun waitForConnectRequest(
ephemeralSigner: NostrSignerInternal,
ephemeralPubKey: HexKey,
relays: Set<NormalizedRelayUrl>,
expectedSecret: String,
client: NostrClient,
): ConnectRequestData {
val deferred = CompletableDeferred<NostrConnectEvent>()
val subscription =
client.req(
relays = relays.toList(),
filter =
Filter(
kinds = listOf(NostrConnectEvent.KIND),
tags = mapOf("p" to listOf(ephemeralPubKey)),
since = TimeUtils.now() - 60,
),
) { event ->
if (event is NostrConnectEvent && !deferred.isCompleted) {
deferred.complete(event)
}
}
try {
val event =
withTimeout(NOSTRCONNECT_TIMEOUT_MS) {
deferred.await()
}
val signerPubkey = event.talkingWith(ephemeralSigner.pubKey)
val decryptedJson = ephemeralSigner.decrypt(event.content, signerPubkey)
val message = OptimizedJsonMapper.fromJsonTo<BunkerMessage>(decryptedJson)
return when (message) {
is BunkerRequest -> {
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")
}
ConnectRequestData(
requestId = message.id,
signerPubkey = signerPubkey,
userPubkey = userPubkey,
)
}
is BunkerResponse -> {
if (message.error != null) {
throw Exception("Signer rejected connection: ${message.error}")
}
val userPubkey =
message.result
?.takeIf { it.length == 64 && Hex.isHex(it) }
?: signerPubkey
ConnectRequestData(
requestId = null,
signerPubkey = signerPubkey,
userPubkey = userPubkey,
)
}
else -> {
throw Exception("Unexpected NIP-46 message format")
}
}
} finally {
subscription.close()
}
}
private suspend fun sendAckResponse(
ephemeralSigner: NostrSignerInternal,
connectData: ConnectRequestData,
relays: Set<NormalizedRelayUrl>,
client: NostrClient,
) {
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,
@@ -709,6 +550,7 @@ class AccountManager internal constructor(
}
disconnectNip46Client()
_signerConnectionState.value = SignerConnectionState.NotRemote
_lastPingTimeSec.value = null
_accountState.value = AccountState.LoggedOut
// Cancel heartbeat LAST — may be called from within the heartbeat coroutine
stopHeartbeat()
@@ -738,6 +580,7 @@ class AccountManager internal constructor(
remoteSigner.ping()
consecutiveFailures = 0
_signerConnectionState.value = SignerConnectionState.Connected
_lastPingTimeSec.value = TimeUtils.now()
} catch (_: SignerExceptions.ManuallyUnauthorizedException) {
forceLogoutWithReason("Remote signer revoked access.")
return@launch
@@ -853,6 +696,17 @@ class AccountManager internal constructor(
private fun getBunkerFile(): File = File(amethystDir, "bunker_uri.txt")
}
internal fun parseBunkerRelays(uri: String): Set<NormalizedRelayUrl> {
val idx = uri.indexOf('?')
if (idx < 0) return emptySet()
return uri
.substring(idx + 1)
.split("&")
.filter { it.startsWith("relay=", ignoreCase = true) }
.map { NormalizedRelayUrl(it.removePrefix("relay=")) }
.toSet()
}
internal fun stripBunkerSecret(uri: String): String {
val idx = uri.indexOf('?')
if (idx < 0) return uri
@@ -41,11 +41,15 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
import com.vitorpamplona.amethyst.commons.ui.components.BunkerHeartbeatIndicator
@Composable
fun DeckSidebar(
onAddColumn: () -> Unit,
onOpenSettings: () -> Unit,
signerConnectionState: SignerConnectionState,
lastPingTimeSec: Long?,
modifier: Modifier = Modifier,
) {
Column(
@@ -78,6 +82,13 @@ fun DeckSidebar(
Spacer(Modifier.weight(1f))
BunkerHeartbeatIndicator(
signerConnectionState = signerConnectionState,
lastPingTimeSec = lastPingTimeSec,
)
Spacer(Modifier.size(8.dp))
IconButton(onClick = onOpenSettings) {
Icon(
Icons.Default.Settings,
@@ -62,6 +62,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
import com.vitorpamplona.amethyst.commons.ui.components.BunkerHeartbeatIndicator
import com.vitorpamplona.amethyst.desktop.DesktopScreen
import com.vitorpamplona.amethyst.desktop.account.AccountManager
import com.vitorpamplona.amethyst.desktop.account.AccountState
@@ -103,6 +105,8 @@ fun SinglePaneLayout(
onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
onZapFeedback: (ZapFeedback) -> Unit,
signerConnectionState: SignerConnectionState,
lastPingTimeSec: Long?,
modifier: Modifier = Modifier,
) {
var currentColumnType by remember { mutableStateOf<DeckColumnType>(DeckColumnType.HomeFeed) }
@@ -139,6 +143,14 @@ fun SinglePaneLayout(
},
)
}
Spacer(Modifier.weight(1f))
BunkerHeartbeatIndicator(
signerConnectionState = signerConnectionState,
lastPingTimeSec = lastPingTimeSec,
modifier = Modifier.padding(bottom = 12.dp),
)
}
VerticalDivider()
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.desktop.account
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip19Bech32.toNsec
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.desktop.account
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair