Merge pull request #2719 from nrobi144/fix/bunker-timeout-and-decrypt
fix(nip46): fix bunker decrypt/encrypt parsing and increase timeout
This commit is contained in:
+3
-1
@@ -37,7 +37,9 @@ class Nip04DecryptResponse {
|
||||
}
|
||||
|
||||
else -> {
|
||||
SignerResult.RequestAddressed.ReceivedButCouldNotPerform()
|
||||
response.result?.let {
|
||||
SignerResult.RequestAddressed.Successful(DecryptionResult(it))
|
||||
} ?: SignerResult.RequestAddressed.ReceivedButCouldNotPerform("No result in response")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -37,7 +37,9 @@ class Nip04EncryptResponse {
|
||||
}
|
||||
|
||||
else -> {
|
||||
SignerResult.RequestAddressed.ReceivedButCouldNotPerform()
|
||||
response.result?.let {
|
||||
SignerResult.RequestAddressed.Successful(EncryptionResult(it))
|
||||
} ?: SignerResult.RequestAddressed.ReceivedButCouldNotPerform("No result in response")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -37,7 +37,9 @@ class Nip44DecryptResponse {
|
||||
}
|
||||
|
||||
else -> {
|
||||
SignerResult.RequestAddressed.ReceivedButCouldNotPerform()
|
||||
response.result?.let {
|
||||
SignerResult.RequestAddressed.Successful(DecryptionResult(it))
|
||||
} ?: SignerResult.RequestAddressed.ReceivedButCouldNotPerform("No result in response")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -37,7 +37,9 @@ class Nip44EncryptResponse {
|
||||
}
|
||||
|
||||
else -> {
|
||||
SignerResult.RequestAddressed.ReceivedButCouldNotPerform()
|
||||
response.result?.let {
|
||||
SignerResult.RequestAddressed.Successful(EncryptionResult(it))
|
||||
} ?: SignerResult.RequestAddressed.ReceivedButCouldNotPerform("No result in response")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+31
-16
@@ -29,15 +29,17 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
|
||||
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
||||
import com.vitorpamplona.quartz.utils.tryAndWait
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.coroutines.Continuation
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class RemoteSignerManager(
|
||||
val timeout: Long = 30000,
|
||||
val timeout: Long = 65_000,
|
||||
val client: INostrClient,
|
||||
val signer: NostrSignerInternal,
|
||||
val remoteKey: String,
|
||||
val relayList: Set<NormalizedRelayUrl>,
|
||||
val maxRetries: Int = 1,
|
||||
) {
|
||||
private val awaitingRequests = LargeCache<String, Continuation<BunkerResponse>>()
|
||||
|
||||
@@ -48,15 +50,14 @@ class RemoteSignerManager(
|
||||
}
|
||||
|
||||
/**
|
||||
* Launches the signer, waits and parses the result
|
||||
* Launches the signer, waits and parses the result.
|
||||
*
|
||||
* Builds the request once and republishes the same event on retry to ensure
|
||||
* the bunker's response (keyed by request ID) can always be matched.
|
||||
*
|
||||
* @param bunkerRequestBuilder The BunkerRequest to be sent.
|
||||
* @param parser A function that parses the BunkerResponse into a [SignerResult.RequestAddressed<T>].
|
||||
* @return The result after parsing the BunkerResponse using the provided parser.
|
||||
*
|
||||
* This function uses the [tryAndWait] utility to implement a timeout on the Bunker Request approval.
|
||||
* It assigns a unique ID to the request and keeps a continuation to resume once the result is received.
|
||||
* If the timeout occurs or the continuation is cancelled, the request ID is cleaned up from [awaitingRequests].
|
||||
*/
|
||||
suspend fun <T : IResult> launchWaitAndParse(
|
||||
bunkerRequestBuilder: () -> BunkerRequest,
|
||||
@@ -69,20 +70,34 @@ class RemoteSignerManager(
|
||||
remoteKey = remoteKey,
|
||||
signer = signer,
|
||||
)
|
||||
val result =
|
||||
tryAndWait(timeout) { continuation ->
|
||||
continuation.invokeOnCancellation {
|
||||
awaitingRequests.remove(request.id)
|
||||
|
||||
var attempt = 0
|
||||
while (true) {
|
||||
val result =
|
||||
tryAndWait(timeout) { continuation ->
|
||||
continuation.invokeOnCancellation {
|
||||
awaitingRequests.remove(request.id)
|
||||
}
|
||||
|
||||
awaitingRequests.put(request.id, continuation)
|
||||
|
||||
client.publish(event, relayList = relayList)
|
||||
}
|
||||
|
||||
awaitingRequests.put(request.id, continuation)
|
||||
when {
|
||||
result != null -> {
|
||||
return parser(result)
|
||||
}
|
||||
|
||||
client.publish(event, relayList = relayList)
|
||||
attempt >= maxRetries -> {
|
||||
return SignerResult.RequestAddressed.TimedOut()
|
||||
}
|
||||
|
||||
else -> {
|
||||
attempt++
|
||||
delay(2_000L)
|
||||
}
|
||||
}
|
||||
|
||||
return when (result) {
|
||||
null -> SignerResult.RequestAddressed.TimedOut()
|
||||
else -> parser(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* 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.quartz.nip46RemoteSigner.signer
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
|
||||
class RemoteSignerManagerRetryTest {
|
||||
private val signer = NostrSignerInternal(KeyPair())
|
||||
private val remoteKeyPair = KeyPair()
|
||||
private val remoteKey = Hex.encode(remoteKeyPair.pubKey)
|
||||
private val relay = NormalizedRelayUrl("wss://relay.test")
|
||||
|
||||
@Test
|
||||
fun timeoutReturnsTimedOutAfterMaxRetries() =
|
||||
runTest {
|
||||
val manager =
|
||||
RemoteSignerManager(
|
||||
timeout = 100, // Very short for test
|
||||
client = EmptyNostrClient(),
|
||||
signer = signer,
|
||||
remoteKey = remoteKey,
|
||||
relayList = setOf(relay),
|
||||
maxRetries = 0, // No retries
|
||||
)
|
||||
|
||||
val result =
|
||||
manager.launchWaitAndParse(
|
||||
bunkerRequestBuilder = { BunkerRequestPing() },
|
||||
parser = PingResponse::parse,
|
||||
)
|
||||
|
||||
assertIs<SignerResult.RequestAddressed.TimedOut<PingResult>>(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun timeoutWithRetryStillTimesOut() =
|
||||
runTest {
|
||||
val manager =
|
||||
RemoteSignerManager(
|
||||
timeout = 100,
|
||||
client = EmptyNostrClient(),
|
||||
signer = signer,
|
||||
remoteKey = remoteKey,
|
||||
relayList = setOf(relay),
|
||||
maxRetries = 1, // 1 retry = 2 total attempts
|
||||
)
|
||||
|
||||
val result =
|
||||
manager.launchWaitAndParse(
|
||||
bunkerRequestBuilder = { BunkerRequestPing() },
|
||||
parser = PingResponse::parse,
|
||||
)
|
||||
|
||||
assertIs<SignerResult.RequestAddressed.TimedOut<PingResult>>(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun publishCalledOnEachAttempt() =
|
||||
runTest {
|
||||
var publishCount = 0
|
||||
val countingClient = CountingNostrClient { publishCount++ }
|
||||
|
||||
val manager =
|
||||
RemoteSignerManager(
|
||||
timeout = 100,
|
||||
client = countingClient,
|
||||
signer = signer,
|
||||
remoteKey = remoteKey,
|
||||
relayList = setOf(relay),
|
||||
maxRetries = 2, // 2 retries = 3 total attempts
|
||||
)
|
||||
|
||||
manager.launchWaitAndParse(
|
||||
bunkerRequestBuilder = { BunkerRequestPing() },
|
||||
parser = PingResponse::parse,
|
||||
)
|
||||
|
||||
assertEquals(3, publishCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun requestBuilderCalledOnlyOnce() =
|
||||
runTest {
|
||||
var builderCount = 0
|
||||
|
||||
val manager =
|
||||
RemoteSignerManager(
|
||||
timeout = 100,
|
||||
client = EmptyNostrClient(),
|
||||
signer = signer,
|
||||
remoteKey = remoteKey,
|
||||
relayList = setOf(relay),
|
||||
maxRetries = 2,
|
||||
)
|
||||
|
||||
manager.launchWaitAndParse(
|
||||
bunkerRequestBuilder = {
|
||||
builderCount++
|
||||
BunkerRequestPing()
|
||||
},
|
||||
parser = PingResponse::parse,
|
||||
)
|
||||
|
||||
assertEquals(1, builderCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultTimeoutIs65Seconds() {
|
||||
val manager =
|
||||
RemoteSignerManager(
|
||||
client = EmptyNostrClient(),
|
||||
signer = signer,
|
||||
remoteKey = remoteKey,
|
||||
relayList = setOf(relay),
|
||||
)
|
||||
|
||||
assertEquals(65_000L, manager.timeout)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultMaxRetriesIs1() {
|
||||
val manager =
|
||||
RemoteSignerManager(
|
||||
client = EmptyNostrClient(),
|
||||
signer = signer,
|
||||
remoteKey = remoteKey,
|
||||
relayList = setOf(relay),
|
||||
)
|
||||
|
||||
assertEquals(1, manager.maxRetries)
|
||||
}
|
||||
}
|
||||
|
||||
private class CountingNostrClient(
|
||||
private val onPublish: () -> Unit,
|
||||
) : INostrClient {
|
||||
override fun connectedRelaysFlow(): StateFlow<Set<NormalizedRelayUrl>> = MutableStateFlow(emptySet())
|
||||
|
||||
override fun availableRelaysFlow(): StateFlow<Set<NormalizedRelayUrl>> = MutableStateFlow(emptySet())
|
||||
|
||||
override fun connect() {}
|
||||
|
||||
override fun disconnect() {}
|
||||
|
||||
override fun reconnect(
|
||||
onlyIfChanged: Boolean,
|
||||
ignoreRetryDelays: Boolean,
|
||||
) {}
|
||||
|
||||
override fun isActive(): Boolean = false
|
||||
|
||||
override fun syncFilters(relay: IRelayClient) {}
|
||||
|
||||
override fun subscribe(
|
||||
subId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
listener: SubscriptionListener?,
|
||||
) {}
|
||||
|
||||
override fun count(
|
||||
subId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
) {}
|
||||
|
||||
override fun unsubscribe(subId: String) {}
|
||||
|
||||
override fun publish(
|
||||
event: Event,
|
||||
relayList: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
onPublish()
|
||||
}
|
||||
|
||||
override fun addConnectionListener(listener: RelayConnectionListener) {}
|
||||
|
||||
override fun removeConnectionListener(listener: RelayConnectionListener) {}
|
||||
|
||||
override fun getReqFiltersOrNull(subId: String): Map<NormalizedRelayUrl, List<Filter>>? = null
|
||||
|
||||
override fun getCountFiltersOrNull(subId: String): Map<NormalizedRelayUrl, List<Filter>>? = null
|
||||
|
||||
override fun activeRequests(url: NormalizedRelayUrl): Map<String, List<Filter>> = emptyMap()
|
||||
|
||||
override fun activeCounts(url: NormalizedRelayUrl): Map<String, List<Filter>> = emptyMap()
|
||||
|
||||
override fun activeOutboxCache(url: NormalizedRelayUrl): Set<HexKey> = emptySet()
|
||||
|
||||
override fun close() {}
|
||||
}
|
||||
+44
-8
@@ -204,12 +204,21 @@ class ResponseParserTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nip04EncryptParseUnexpected() {
|
||||
val response = BunkerResponsePong("req-4")
|
||||
fun nip04EncryptParseUnexpectedNullResult() {
|
||||
val response = BunkerResponse("req-4", null, null)
|
||||
val result = Nip04EncryptResponse.parse(response)
|
||||
assertIs<SignerResult.RequestAddressed.ReceivedButCouldNotPerform<EncryptionResult>>(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nip04EncryptParseGenericBunkerResponse() {
|
||||
// Production case: deserializer produces generic BunkerResponse with ciphertext
|
||||
val response = BunkerResponse("req-4", "encrypted-ciphertext-data", null)
|
||||
val result = Nip04EncryptResponse.parse(response)
|
||||
assertIs<SignerResult.RequestAddressed.Successful<EncryptionResult>>(result)
|
||||
assertEquals("encrypted-ciphertext-data", result.result.ciphertext)
|
||||
}
|
||||
|
||||
// --- Nip04DecryptResponse ---
|
||||
|
||||
@Test
|
||||
@@ -228,12 +237,21 @@ class ResponseParserTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nip04DecryptParseUnexpected() {
|
||||
val response = BunkerResponsePong("req-5")
|
||||
fun nip04DecryptParseUnexpectedNullResult() {
|
||||
val response = BunkerResponse("req-5", null, null)
|
||||
val result = Nip04DecryptResponse.parse(response)
|
||||
assertIs<SignerResult.RequestAddressed.ReceivedButCouldNotPerform<DecryptionResult>>(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nip04DecryptParseGenericBunkerResponse() {
|
||||
// Production case: deserializer produces generic BunkerResponse with plaintext
|
||||
val response = BunkerResponse("req-5", "Hello, this is the decrypted message!", null)
|
||||
val result = Nip04DecryptResponse.parse(response)
|
||||
assertIs<SignerResult.RequestAddressed.Successful<DecryptionResult>>(result)
|
||||
assertEquals("Hello, this is the decrypted message!", result.result.plaintext)
|
||||
}
|
||||
|
||||
// --- Nip44EncryptResponse ---
|
||||
|
||||
@Test
|
||||
@@ -252,12 +270,21 @@ class ResponseParserTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nip44EncryptParseUnexpected() {
|
||||
val response = BunkerResponseAck("req-6")
|
||||
fun nip44EncryptParseUnexpectedNullResult() {
|
||||
val response = BunkerResponse("req-6", null, null)
|
||||
val result = Nip44EncryptResponse.parse(response)
|
||||
assertIs<SignerResult.RequestAddressed.ReceivedButCouldNotPerform<EncryptionResult>>(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nip44EncryptParseGenericBunkerResponse() {
|
||||
// Production case: deserializer produces generic BunkerResponse with ciphertext
|
||||
val response = BunkerResponse("req-6", "nip44-encrypted-payload", null)
|
||||
val result = Nip44EncryptResponse.parse(response)
|
||||
assertIs<SignerResult.RequestAddressed.Successful<EncryptionResult>>(result)
|
||||
assertEquals("nip44-encrypted-payload", result.result.ciphertext)
|
||||
}
|
||||
|
||||
// --- Nip44DecryptResponse ---
|
||||
|
||||
@Test
|
||||
@@ -276,9 +303,18 @@ class ResponseParserTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nip44DecryptParseUnexpected() {
|
||||
val response = BunkerResponseAck("req-7")
|
||||
fun nip44DecryptParseUnexpectedNullResult() {
|
||||
val response = BunkerResponse("req-7", null, null)
|
||||
val result = Nip44DecryptResponse.parse(response)
|
||||
assertIs<SignerResult.RequestAddressed.ReceivedButCouldNotPerform<DecryptionResult>>(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nip44DecryptParseGenericBunkerResponse() {
|
||||
// Production case: deserializer produces generic BunkerResponse with plaintext
|
||||
val response = BunkerResponse("req-7", "Decrypted NIP-44 content here", null)
|
||||
val result = Nip44DecryptResponse.parse(response)
|
||||
assertIs<SignerResult.RequestAddressed.Successful<DecryptionResult>>(result)
|
||||
assertEquals("Decrypted NIP-44 content here", result.result.plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user