Bunker proxy: await bunker response concurrently with publish confirmation

This commit is contained in:
greenart7c3
2026-09-25 05:08:21 -03:00
parent 7e455572f0
commit 45434aaf5f
2 changed files with 146 additions and 22 deletions
@@ -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)
}
@@ -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<NostrClient>(relaxed = true)
private val signer = mockk<NostrSignerInternal>(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<LogDatabase>(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<String>()).get("id").asText()
"enc"
}
every { signer.signerSync.sign<Event>(any(), any(), any(), any()) } returns mockk<Event>(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()) }
}
}
}