perf(quartz): cap NIP-17 wrap building at 4 concurrent bunker RPCs

NIP17Factory.createWraps launches all per-recipient seal builds via
mapNotNullAsync, which today runs them fully parallel. Each seal
needs nip44_encrypt + sign — for a NIP-46 (bunker) signer that means
two round-trips per recipient. A 5-recipient group send launches 10
concurrent in-flight requests against the bunker socket, and nsec.app
/ Amber / Keychat typically serialize past ~10 in-flight, so some
requests queue past the 65s timeout and silently fail.

Cap at 4 concurrent when signer is NostrSignerRemote. Local signers
(NostrSignerInternal, NostrSignerSync) bypass the semaphore and stay
fully parallel — no overhead, no behaviour change for nsec users.

The real fix is the batched nip44_get_conversation_keys NIP-46 RPC
(separate spec PR + plan) which collapses N×2 round-trips into ~2.
This commit is the interim throttle until that lands.
This commit is contained in:
nrobi144
2026-07-09 07:03:56 +03:00
parent d6c1b13136
commit a854b38cd8
@@ -34,9 +34,12 @@ import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.utils.mapNotNullAsync
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
class NIP17Factory {
data class Result(
@@ -56,6 +59,16 @@ class NIP17Factory {
* DM inbox relay as a hint. Pass [recipientRelayHints] to surface those;
* the default `{ null }` lambda preserves the historical 2-element tag
* shape for every recipient.
*
* When [signer] is a [NostrSignerRemote] (NIP-46 bunker), seal building
* is rate-limited to [BUNKER_PARALLELISM] concurrent operations. Each
* seal needs `nip44_encrypt` + `sign` round-trips against the bunker; a
* 5-recipient group otherwise launches 10 concurrent in-flight RPCs and
* saturates the bunker socket. Local signers (NostrSignerInternal,
* NostrSignerSync) run fully parallel — no semaphore overhead.
*
* The proper fix is the batched `nip44_get_conversation_keys` NIP-46
* RPC (separate plan); this is the interim throttle until that lands.
*/
private suspend fun createWraps(
event: Event,
@@ -72,24 +85,40 @@ class NIP17Factory {
}
}
val bunkerLimiter = if (signer is NostrSignerRemote) Semaphore(BUNKER_PARALLELISM) else null
return mapNotNullAsync(
to.toList(),
) { next ->
GiftWrapEvent.create(
event =
SealedRumorEvent.create(
event = event,
encryptTo = next,
expirationDelta = innerExpDelta,
signer = signer,
),
recipientPubKey = next,
expirationDelta = innerExpDelta,
recipientRelayHint = recipientRelayHints(next),
)
val build: suspend () -> GiftWrapEvent = {
GiftWrapEvent.create(
event =
SealedRumorEvent.create(
event = event,
encryptTo = next,
expirationDelta = innerExpDelta,
signer = signer,
),
recipientPubKey = next,
expirationDelta = innerExpDelta,
recipientRelayHint = recipientRelayHints(next),
)
}
bunkerLimiter?.withPermit { build() } ?: build()
}
}
companion object {
/**
* Max concurrent in-flight NIP-46 RPCs when building wraps via a
* remote signer. Empirically a sweet spot — covers parallelism
* speedup for 2–4 recipient sends without saturating typical
* bunker apps (nsec.app, Amber, Keychat) that serialize requests
* internally past ~10 in-flight.
*/
const val BUNKER_PARALLELISM = 4
}
suspend fun createMessageNIP17(
template: EventTemplate<ChatMessageEvent>,
signer: NostrSigner,