refactor: reconcile duplicate LIMITS code and rename client accessory

Cleanup after adding LimitsMessage + the client-side cache:

- Remove the unused experimental LIMITS prototype
  (experimental/limits/Limits.kt + LimitProcessor.kt). Its @Serializable
  model duplicated LimitsMessage (minus auth_for_read/auth_for_write and
  the Message wiring); the processor's clamp/reject logic is superseded by
  the server-side LimitsPolicy and will be reincarnated as pure helpers on
  LimitsMessage. Both were prototypes with no references (preserved in git
  history).
- Rename the client accessory RelayLimits -> RelayLimitsTracker so it no
  longer collides on simple name with the relay-server-side
  nip01Core.relay.server.policies.RelayLimits (the operator-configured
  limits a relay enforces and advertises). Updates AppModules and the test.

No behavior change. quartz:jvmTest (RelayLimitsTrackerTest 5/5) and
amethyst play-flavor compile are green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ
This commit is contained in:
Claude
2026-07-16 17:21:00 +00:00
parent 9daa0b3e48
commit 041abf9e37
5 changed files with 13 additions and 166 deletions
@@ -122,7 +122,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker
import com.vitorpamplona.quartz.nip01Core.relay.client.limits.RelayLimits
import com.vitorpamplona.quartz.nip01Core.relay.client.limits.RelayLimitsTracker
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats.RelayReqStats
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CachingEventDecoder
@@ -712,7 +712,7 @@ class AppModules(
val relayStats = RelayStats(client)
// Caches the latest LIMITS (rights + limits) each relay advertises.
val relayLimits = RelayLimits(client)
val relayLimits = RelayLimitsTracker(client)
// Resource-usage ledger: relay traffic/reconnect + connection-time,
// foreground-time, process-CPU, and signature-verification collectors.
@@ -1,113 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.limits
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip13Pow.pow
import com.vitorpamplona.quartz.utils.TimeUtils
class LimitProcessor {
fun wrapFilterToLimits(
filter: Filter,
sendingStr: String,
limits: Limits,
): Filter? {
var newFilter: Filter? = filter
if (limits.canRead != null && !limits.canRead) {
newFilter = null
}
if (limits.maxLimit != null && filter.limit != null && filter.limit > limits.maxLimit) {
newFilter = filter.copy(limit = limits.maxLimit)
}
if (!limits.acceptedEventKinds.isNullOrEmpty() && !filter.kinds.isNullOrEmpty()) {
val intersect = filter.kinds.filter { it in limits.acceptedEventKinds }
if (intersect.isNotEmpty()) {
newFilter = filter.copy(kinds = intersect)
} else {
newFilter = null
}
}
if (!limits.blockedEventKinds.isNullOrEmpty() && !filter.kinds.isNullOrEmpty()) {
val intersect = filter.kinds.filter { it !in limits.blockedEventKinds }
if (intersect.isNotEmpty()) {
newFilter = filter.copy(kinds = intersect)
} else {
newFilter = null
}
}
if (limits.maxMessageLength != null && sendingStr.length > limits.maxMessageLength) {
// TODO: figure out how to dynamically reduce filter size
newFilter = null
}
return newFilter
}
fun canSendEvent(
ev: Event,
sendingStr: String,
limits: Limits,
): Boolean {
if (limits.canWrite != null && !limits.canWrite) return false
if (!limits.acceptedEventKinds.isNullOrEmpty() && ev.kind !in limits.acceptedEventKinds) return false
if (!limits.blockedEventKinds.isNullOrEmpty() && ev.kind in limits.blockedEventKinds) return false
if (limits.minPoW != null && ev.pow() < limits.minPoW) return false
if (limits.maxEventTags != null && ev.tags.size > limits.maxEventTags) return false
if (limits.maxContentLength != null && ev.content.length > limits.maxContentLength) return false
if (limits.createdAtMillisecsAgo != null && ev.createdAt < TimeUtils.now() - limits.createdAtMillisecsAgo) return false
if (limits.createdAtMillisecsAhead != null && ev.createdAt > TimeUtils.now() + limits.createdAtMillisecsAhead) return false
if (limits.requiredTags != null && !matchAll(ev, limits.requiredTags)) return false
if (limits.maxMessageLength != null && sendingStr.length > limits.maxMessageLength) return false
return true
}
private fun matchAll(
ev: Event,
requiredTags: Array<Array<String>>,
): Boolean =
requiredTags.all { requiredTag ->
if (requiredTag.isNotEmpty()) {
if (requiredTag.getOrNull(1) == null) {
ev.tags.any { eventTag ->
eventTag.getOrNull(0) == requiredTag[0]
}
} else {
ev.tags.any { eventTag ->
eventTag.getOrNull(0) == requiredTag[0] && eventTag.getOrNull(1) == requiredTag[1]
}
}
} else {
true
}
}
}
@@ -1,44 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.limits
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
class Limits(
@SerialName("can_write") val canWrite: Boolean?,
@SerialName("can_read") val canRead: Boolean?,
@SerialName("accepted_event_kinds") val acceptedEventKinds: Set<Int>?,
@SerialName("blocked_event_kinds") val blockedEventKinds: Set<Int>?,
@SerialName("min_pow_difficulty") val minPoW: Int?,
@SerialName("max_message_length") val maxMessageLength: Int?,
@SerialName("max_subscriptions") val maxSubscriptions: Int?,
@SerialName("max_filters") val maxFilters: Int?,
@SerialName("max_limit") val maxLimit: Int?,
@SerialName("max_event_tags") val maxEventTags: Int?,
@SerialName("max_content_length") val maxContentLength: Int?,
@SerialName("created_at_msecs_ago") val createdAtMillisecsAgo: Long?,
@SerialName("created_at_msecs_ahead") val createdAtMillisecsAhead: Long?,
@SerialName("filter_rate_limit") val filterRateLimit: Long?,
@SerialName("publishing_rate_limit") val publishingRateLimit: Long?,
@SerialName("required_tags") val requiredTags: Array<Array<String>>?,
)
@@ -47,8 +47,12 @@ import kotlinx.coroutines.flow.update
* The cache is connection-scoped: a relay's entry is dropped on disconnect, so a
* stale limit from a previous session never leaks into a new one. A fresh
* connection re-advertises `LIMITS` on connect.
*
* Named `Tracker` to avoid colliding with the relay-*server* side's
* [com.vitorpamplona.quartz.nip01Core.relay.server.policies.RelayLimits], which
* is the operator-configured source of truth a relay enforces and advertises.
*/
class RelayLimits(
class RelayLimitsTracker(
val client: INostrClient,
) {
// onIncomingMessage / onDisconnected fire on the per-relay socket dispatcher
@@ -88,13 +92,13 @@ class RelayLimits(
}
init {
Log.d("RelayLimits", "Init, Subscribe")
Log.d("RelayLimitsTracker", "Init, Subscribe")
client.addConnectionListener(clientListener)
}
fun destroy() {
// makes sure to run
Log.d("RelayLimits", "Destroy, Unsubscribe")
Log.d("RelayLimitsTracker", "Destroy, Unsubscribe")
client.removeConnectionListener(clientListener)
}
}
@@ -33,7 +33,7 @@ import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class RelayLimitsTest {
class RelayLimitsTrackerTest {
private class CapturingClient(
private val delegate: INostrClient = EmptyNostrClient(),
) : INostrClient by delegate {
@@ -62,10 +62,10 @@ class RelayLimitsTest {
override fun disconnect() = Unit
}
private fun setup(): Pair<RelayLimits, RelayConnectionListener> {
private fun setup(): Pair<RelayLimitsTracker, RelayConnectionListener> {
val client = CapturingClient()
val limits = RelayLimits(client)
val listener = client.captured ?: error("RelayLimits did not register a listener")
val limits = RelayLimitsTracker(client)
val listener = client.captured ?: error("RelayLimitsTracker did not register a listener")
return limits to listener
}