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,