From a854b38cd8a57458944bd0796672e0654f581112 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 11 Jun 2026 10:52:44 +0300 Subject: [PATCH] perf(quartz): cap NIP-17 wrap building at 4 concurrent bunker RPCs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../quartz/nip17Dm/NIP17Factory.kt | 53 ++++++++++++++----- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt index 9a848a319b..ceb97f6678 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt @@ -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, signer: NostrSigner,