From 45434aaf5f6263fa15f0c3516990b50cb8f1b41d Mon Sep 17 00:00:00 2001 From: greenart7c3 Date: Fri, 25 Sep 2026 05:08:21 -0300 Subject: [PATCH] Bunker proxy: await bunker response concurrently with publish confirmation --- .../nostrsigner/service/RemoteBunkerClient.kt | 53 ++++---- .../nostrsigner/RemoteBunkerClientTest.kt | 115 ++++++++++++++++++ 2 files changed, 146 insertions(+), 22 deletions(-) create mode 100644 app/src/test/java/com/greenart7c3/nostrsigner/RemoteBunkerClientTest.kt diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/RemoteBunkerClient.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/RemoteBunkerClient.kt index 1b1b8935..f2ffe0be 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/RemoteBunkerClient.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/RemoteBunkerClient.kt @@ -18,6 +18,7 @@ import java.util.UUID import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull /** @@ -90,27 +91,31 @@ object RemoteBunkerClient { ), ) - // Publish with retry/backoff. The first attempt may be rejected if the - // relay is still negotiating NIP-42 AUTH; the auth coordinator answers - // the challenge in the background and subsequent retries succeed once - // the relay accepts our AUTH event. - val published = publishWithAuthRetry(event, proxy.relays.toSet()) - - if (!published) { - Log.w(Amber.TAG, "Failed to publish bunker proxy request id=$id") - Amber.instance.getLogDatabase(account.npub).dao().insertLog( - LogEntity( - id = 0, - url = proxy.remotePubkey, - type = "bunker proxy request", - message = "publish failed for $method id=$id", - time = System.currentTimeMillis(), - ), - ) - return null + // Publish with retry/backoff concurrently with awaiting the response: + // a slow or silent relay must not delay the response wait. The first + // attempt may be rejected if the relay is still negotiating NIP-42 + // AUTH; the auth coordinator answers the challenge in the background + // and subsequent retries succeed once the relay accepts our AUTH + // event. If the event can never be confirmed, this surfaces as a + // response timeout below. + val publishJob = Amber.instance.applicationIOScope.launch { + val published = publishWithAuthRetry(event, proxy.relays.toSet()) + if (!published) { + Log.w(Amber.TAG, "Failed to publish bunker proxy request id=$id") + Amber.instance.getLogDatabase(account.npub).dao().insertLog( + LogEntity( + id = 0, + url = proxy.remotePubkey, + type = "bunker proxy request", + message = "publish failed for $method id=$id", + time = System.currentTimeMillis(), + ), + ) + } } val response = withTimeoutOrNull(timeoutMs) { deferred.await() } + publishJob.cancel() if (response == null) { Log.w(Amber.TAG, "Bunker proxy request timed out id=$id method=$method") Amber.instance.getLogDatabase(account.npub).dao().insertLog( @@ -297,10 +302,14 @@ object RemoteBunkerClient { // Retry-with-backoff so the relay's NIP-42 AUTH challenge has time to // be answered by the auth coordinator before the bunker request itself // is published — without this, the very first bunker `connect` attempt - // races the AUTH and is rejected. - val published = publishWithAuthRetry(event, relays.toSet()) - if (!published) return null - return withTimeoutOrNull(timeoutMs) { deferred.await() } + // races the AUTH and is rejected. Runs concurrently with the response + // wait so publish confirmation never delays the await. + val publishJob = Amber.instance.applicationIOScope.launch { + publishWithAuthRetry(event, relays.toSet()) + } + val response = withTimeoutOrNull(timeoutMs) { deferred.await() } + publishJob.cancel() + return response } finally { pending.remove(id) } diff --git a/app/src/test/java/com/greenart7c3/nostrsigner/RemoteBunkerClientTest.kt b/app/src/test/java/com/greenart7c3/nostrsigner/RemoteBunkerClientTest.kt new file mode 100644 index 00000000..a72568f3 --- /dev/null +++ b/app/src/test/java/com/greenart7c3/nostrsigner/RemoteBunkerClientTest.kt @@ -0,0 +1,115 @@ +package com.greenart7c3.nostrsigner + +import com.greenart7c3.nostrsigner.database.LogDatabase +import com.greenart7c3.nostrsigner.models.Account +import com.greenart7c3.nostrsigner.models.ProxyAccountMetadata +import com.greenart7c3.nostrsigner.service.RemoteBunkerClient +import com.greenart7c3.nostrsigner.service.installAmberInstance +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +/** + * Regression test for the proxy-mode latency fix: awaiting a NIP-46 response must + * not wait behind publish confirmation. A bunker relay may never send an OK + * (slow/silent relay, NIP-42 auth race), yet the bunker itself may already have + * received and answered the request — the response must resolve while publish + * confirmation is still pending. + */ +class RemoteBunkerClientTest { + private lateinit var amber: Amber + private val client = mockk(relaxed = true) + private val signer = mockk(relaxed = true) + private var capturedId: String? = null + + companion object { + private val REMOTE_PUB = "ab".repeat(32) + private const val RELAY_URL = "wss://relay.example.com" + } + + @Before + fun setUp() { + amber = mockk() + every { amber.applicationIOScope } returns CoroutineScope(Dispatchers.Unconfined) + every { amber.client } returns client + every { amber.getLogDatabase(any()) } returns mockk(relaxed = true) + installAmberInstance(amber) + + // Publish never confirms: the first attempt hangs past the test timeout. + mockkStatic("com.vitorpamplona.quartz.nip01Core.relay.client.accessories.NostrClientPublishExtKt") + coEvery { client.publishAndConfirm(any(), any(), any()) } coAnswers { + delay(60_000) + false + } + + coEvery { signer.nip44Encrypt(any(), any()) } answers { + capturedId = JacksonMapper.mapper.readTree(firstArg()).get("id").asText() + "enc" + } + every { signer.signerSync.sign(any(), any(), any(), any()) } returns mockk(relaxed = true) + } + + @After + fun tearDown() { + unmockkAll() + } + + private fun proxyAccount(): Account = Account( + signer = signer, + hexKey = REMOTE_PUB, + npub = "npub1proxytest", + name = MutableStateFlow(""), + picture = MutableStateFlow(""), + signPolicy = 1, + didBackup = true, + scope = CoroutineScope(Dispatchers.Unconfined), + proxy = ProxyAccountMetadata( + remotePubkey = REMOTE_PUB, + relays = listOf(NormalizedRelayUrl(RELAY_URL)), + bunkerName = "bunker", + nostrConnectSecret = "", + ), + ) + + @Test + fun `request returns response even when publish never confirms`() { + runBlocking { + val requestDeferred = async { + RemoteBunkerClient.request(proxyAccount(), "sign_message", listOf("hello")) + } + launch { + delay(200) + RemoteBunkerClient.deliverResponse(BunkerResponse(capturedId!!, "sig", null)) + } + + val response = withTimeout(5_000) { requestDeferred.await() } + + assertEquals("sig", response?.result) + // The response arrived while publish confirmation was pending; cancelling + // the publish job must stop the retry loop after the first attempt. + coVerify(exactly = 1) { client.publishAndConfirm(any(), any(), any()) } + } + } +}