Bunker proxy: cache proxy account resolution out of the response hot path

This commit is contained in:
greenart7c3
2026-09-25 05:11:03 -03:00
parent 45434aaf5f
commit 3bb54b4cc4
2 changed files with 155 additions and 24 deletions
@@ -5,6 +5,7 @@ import android.util.Log
import com.greenart7c3.nostrsigner.Amber
import com.greenart7c3.nostrsigner.BuildFlavorChecker
import com.greenart7c3.nostrsigner.LocalPreferences
import com.greenart7c3.nostrsigner.models.Account
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
@@ -20,7 +21,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers
import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -49,10 +49,22 @@ class ProxyResponseSubscription(
private val transientLogins = ConcurrentHashMap<String, TransientLogin>()
private val pendingInitialConnect = ConcurrentHashMap<String, CompletableDeferred<String>>()
/**
* In-memory index of proxy accounts by their local proxy pubkey (hex), filled by
* [updateFilter]. Keeps the Keystore-backed account loading out of the per-event
* hot path; local proxy keys are freshly random per account, so the mapping is
* collision-free.
*/
private val proxyAccountsByLocalPub = ConcurrentHashMap<String, Account>()
internal fun resolveForTaggedKey(localPubHex: String): Account? = proxyAccountsByLocalPub[localPubHex]
private data class TransientLogin(
val localPrivKey: ByteArray,
val remotePubkey: String,
val secret: String,
val signer: NostrSignerInternal,
val localPubHex: String,
)
init {
@@ -72,27 +84,18 @@ class ProxyResponseSubscription(
if (event.kind != NostrConnectEvent.KIND) return
if (!event.verify()) return
val taggedKey = event.taggedUsers().firstOrNull() ?: return
val taggedNpub = taggedKey.toNPub()
val taggedKey = event.taggedUsers().firstOrNull()?.pubKey ?: return
Amber.instance.applicationIOScope.launch {
transientLogins.values.firstOrNull { tl ->
NostrSignerInternal(KeyPair(privKey = tl.localPrivKey)).keyPair.pubKey.toNpub() == taggedNpub
}?.let { tl ->
handleViaSigner(event, NostrSignerInternal(KeyPair(privKey = tl.localPrivKey)), tl.remotePubkey, tl.secret, relayUrl)
transientLogins.values.firstOrNull { it.localPubHex == taggedKey }?.let { tl ->
handleViaSigner(event, tl.signer, tl.remotePubkey, tl.secret, relayUrl, tl.localPubHex)
return@launch
}
val accounts = LocalPreferences.allSavedAccounts(appContext)
for (info in accounts) {
val account = LocalPreferences.loadFromEncryptedStorage(appContext, info.npub) ?: continue
val proxy = account.proxy ?: continue
val localPub = account.signer.keyPair.pubKey.toNpub()
if (localPub != taggedNpub) continue
if (event.pubKey != proxy.remotePubkey) continue
handleViaSigner(event, account.signer, proxy.remotePubkey, "", relayUrl)
return@launch
}
val account = resolveForTaggedKey(taggedKey) ?: return@launch
val proxy = account.proxy ?: return@launch
if (event.pubKey != proxy.remotePubkey) return@launch
handleViaSigner(event, account.signer, proxy.remotePubkey, "", relayUrl, taggedKey)
}
}
@@ -102,6 +105,7 @@ class ProxyResponseSubscription(
remotePubkey: String,
secret: String,
relayUrl: String,
localPubHex: String,
) {
val decrypted = try {
val isNip04 = EncryptedInfo.isNIP04(event.content)
@@ -123,12 +127,11 @@ class ProxyResponseSubscription(
if (response != null) {
RemoteBunkerClient.deliverResponse(response)
val localPub = signer.keyPair.pubKey.toHexKey()
pendingInitialConnect[localPub]?.let { deferred ->
pendingInitialConnect[localPubHex]?.let { deferred ->
val result = response.result
if (response.error.isNullOrBlank() && (secret.isEmpty() || result == secret || result == "ack")) {
deferred.complete(event.pubKey)
pendingInitialConnect.remove(localPub)
pendingInitialConnect.remove(localPubHex)
}
}
return
@@ -139,10 +142,9 @@ class ProxyResponseSubscription(
val node = JacksonMapper.mapper.readTree(decrypted)
val method = node.get("method")?.asText() ?: return
if (method == "connect") {
val localPub = signer.keyPair.pubKey.toHexKey()
pendingInitialConnect[localPub]?.let { deferred ->
pendingInitialConnect[localPubHex]?.let { deferred ->
deferred.complete(event.pubKey)
pendingInitialConnect.remove(localPub)
pendingInitialConnect.remove(localPubHex)
}
}
} catch (_: Exception) {
@@ -177,12 +179,15 @@ class ProxyResponseSubscription(
timeoutMs: Long,
): String? {
val localPub = localKeyPair.pubKey.toHexKey()
val signer = NostrSignerInternal(localKeyPair)
val deferred = CompletableDeferred<String>()
pendingInitialConnect[localPub] = deferred
transientLogins[localPub] = TransientLogin(
localPrivKey = localKeyPair.privKey!!,
remotePubkey = "",
secret = secret,
signer = signer,
localPubHex = localPub,
)
subscribeForLocalKey(localPub, "", relays)
return withTimeoutOrNull(timeoutMs) { deferred.await() }
@@ -190,10 +195,13 @@ class ProxyResponseSubscription(
fun registerTransientLogin(localPrivKey: ByteArray, remotePubkey: String) {
val signer = NostrSignerInternal(KeyPair(privKey = localPrivKey))
transientLogins[signer.keyPair.pubKey.toHexKey()] = TransientLogin(
val localPubHex = signer.keyPair.pubKey.toHexKey()
transientLogins[localPubHex] = TransientLogin(
localPrivKey = localPrivKey,
remotePubkey = remotePubkey,
secret = "",
signer = signer,
localPubHex = localPubHex,
)
}
@@ -208,6 +216,7 @@ class ProxyResponseSubscription(
if (BuildFlavorChecker.isOfflineFlavor()) return
val activeSubKeys = mutableSetOf<String>()
activeSubKeys += subIds.keys.filter { it.startsWith("transient_") }
val activeLocalPubs = mutableSetOf<String>()
val since = TimeUtils.now()
LocalPreferences.allAccounts(appContext).forEach { account ->
@@ -215,6 +224,8 @@ class ProxyResponseSubscription(
if (proxy.relays.isEmpty()) return@forEach
val localPub = account.signer.keyPair.pubKey.toHexKey()
proxyAccountsByLocalPub[localPub] = account
activeLocalPubs.add(localPub)
val subKey = "proxy_${account.npub}"
activeSubKeys.add(subKey)
if (!subIds.containsKey(subKey)) {
@@ -239,5 +250,6 @@ class ProxyResponseSubscription(
for (subKey in stale) {
subIds.remove(subKey)?.let { client.unsubscribe(it) }
}
proxyAccountsByLocalPub.keys.retainAll(activeLocalPubs)
}
}
@@ -0,0 +1,119 @@
package com.greenart7c3.nostrsigner
import android.content.Context
import com.greenart7c3.nostrsigner.models.Account
import com.greenart7c3.nostrsigner.models.ProxyAccountMetadata
import com.greenart7c3.nostrsigner.service.ProxyResponseSubscription
import com.greenart7c3.nostrsigner.service.installAmberInstance
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkAll
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assume.assumeFalse
import org.junit.Before
import org.junit.Test
/**
* Regression tests for the proxy-response hot path: resolution of an incoming
* kind-24133 response must come from the in-memory index built by
* [ProxyResponseSubscription.updateFilter], never from per-event Keystore-backed
* account loads.
*/
class ProxyResponseSubscriptionTest {
private val context = mockk<Context>(relaxed = true)
private val client = mockk<NostrClient>(relaxed = true)
private lateinit var subscription: ProxyResponseSubscription
private lateinit var proxyAccount: Account
private lateinit var normalAccount: Account
companion object {
private val PROXY_LOCAL_PUB = "cd".repeat(32)
private val NORMAL_LOCAL_PUB = "ef".repeat(32)
private val UNKNOWN_PUB = "01".repeat(32)
private val BUNKER_PUB = "ab".repeat(32)
}
@Before
fun setUp() {
val amber = mockk<Amber>()
every { amber.applicationIOScope } returns CoroutineScope(Dispatchers.Unconfined)
installAmberInstance(amber)
subscription = ProxyResponseSubscription(client, context)
proxyAccount = accountFor(
PROXY_LOCAL_PUB,
npub = "npub1proxy",
proxy = ProxyAccountMetadata(
remotePubkey = BUNKER_PUB,
relays = listOf(NormalizedRelayUrl("wss://relay.example.com")),
bunkerName = "bunker",
nostrConnectSecret = "",
),
)
normalAccount = accountFor(NORMAL_LOCAL_PUB, npub = "npub1normal", proxy = null)
mockkObject(LocalPreferences)
coEvery { LocalPreferences.allAccounts(any()) } returns listOf(proxyAccount, normalAccount)
}
@After
fun tearDown() {
unmockkAll()
}
/** Read-only keypair (pubkey given, no crypto) so the local pubkey hex is deterministic. */
private fun accountFor(localPubHex: String, npub: String, proxy: ProxyAccountMetadata?): Account = Account(
signer = NostrSignerInternal(KeyPair(pubKey = localPubHex.hexToByteArray())),
hexKey = BUNKER_PUB,
npub = npub,
name = MutableStateFlow(""),
picture = MutableStateFlow(""),
signPolicy = 1,
didBackup = true,
scope = CoroutineScope(Dispatchers.Unconfined),
proxy = proxy,
)
// updateFilter() is a no-op on the offline flavor by design (no network),
// so the proxy-account cache only exists in network flavors.
private fun skipOnOfflineFlavor() {
assumeFalse(BuildFlavorChecker.isOfflineFlavor())
}
@Test
fun `updateFilter caches proxy accounts and does not touch encrypted storage`() = runBlocking {
skipOnOfflineFlavor()
subscription.updateFilter()
assertEquals(proxyAccount, subscription.resolveForTaggedKey(PROXY_LOCAL_PUB))
assertNull(subscription.resolveForTaggedKey(NORMAL_LOCAL_PUB))
assertNull(subscription.resolveForTaggedKey(UNKNOWN_PUB))
coVerify(exactly = 0) { LocalPreferences.loadFromEncryptedStorage(any(), any()) }
}
@Test
fun `updateFilter prunes cache entries of removed accounts`() = runBlocking {
skipOnOfflineFlavor()
subscription.updateFilter()
coEvery { LocalPreferences.allAccounts(any()) } returns emptyList()
subscription.updateFilter()
assertNull(subscription.resolveForTaggedKey(PROXY_LOCAL_PUB))
}
}