mirror of
https://github.com/greenart7c3/Amber.git
synced 2026-10-05 19:08:23 +00:00
Bunker proxy: await bunker response concurrently with publish confirmation
This commit is contained in:
@@ -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()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user