From 9d539b22f67bccd4b7928df2fa554bced750b748 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 10 Jun 2026 11:20:32 +0300 Subject: [PATCH] fix(quartz): don't count auth-required: against the publish try cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NIP-42 AUTH challenges arrive as `auth-required:` OK responses. Today they accumulate via PoolEventOutboxState.newResponse → Tries.addResponse, and after three of them the relay is silently dropped from the outbox on the next newTry — even though RelayAuthenticator is concurrently signing the AUTH event and the relay would have accepted the original publish once authenticated. Carve `auth-required:` out of the failure path: it's a "wait, AUTH in flight" signal, not a rejection. The existing RelayAuthenticator.checkAuthResults → client.syncFilters hook re-pumps the outbox after AUTH-OK, so the original event is retried naturally. Adds PoolEventOutboxStateTest covering the carve-out plus regressions for regular rejections, terminal rejections, and success. --- .../relay/client/pool/PoolEventOutboxState.kt | 8 +- .../client/pool/PoolEventOutboxStateTest.kt | 97 +++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxStateTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt index 4df3f7d538..a296709711 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt @@ -66,11 +66,15 @@ class PoolEventOutboxState( success: Boolean, message: String, ) { - val currentTries = failures[url] if (success || message.shouldDiscard()) { relaysRemaining = relaysRemaining - url failures = failures - url + } else if (message.isAuthRequired()) { + // NIP-42 AUTH challenge in flight — don't count toward the try cap. + // RelayAuthenticator signs + relay re-issues OK; syncFilters() then + // re-pumps this outbox so the original publish is retried. } else { + val currentTries = failures[url] if (currentTries != null) { currentTries.addResponse(message) } else { @@ -91,6 +95,8 @@ class PoolEventOutboxState( this.startsWith("deleted:") || this.startsWith("invalid:") + fun String.isAuthRequired() = this.startsWith("auth-required:") + // Tries 3 times class Tries( var tries: List = listOf(), diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxStateTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxStateTest.kt new file mode 100644 index 0000000000..5576aae0b7 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxStateTest.kt @@ -0,0 +1,97 @@ +/* + * 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.nip01Core.relay.client.pool + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PoolEventOutboxStateTest { + private val relay = NormalizedRelayUrl("wss://relay.example/") + + private fun fakeEvent() = + Event( + id = "0".repeat(64), + pubKey = "0".repeat(64), + createdAt = 0L, + kind = 1, + tags = emptyArray(), + content = "", + sig = "0".repeat(128), + ) + + @Test + fun authRequiredResponseDoesNotConsumeTryBudget() { + val state = PoolEventOutboxState(fakeEvent(), setOf(relay)) + + // Simulate 5 `auth-required:` responses — relay keeps challenging while + // RelayAuthenticator signs + sends AUTH events asynchronously. None of + // these should be counted against the 3-response try cap. + repeat(5) { + state.newResponse(relay, success = false, message = "auth-required: please authenticate") + } + + // Even after a follow-up newTry, the relay must remain in the outbox so + // syncFilters() can re-publish once AUTH succeeds. + state.newTry(relay) + assertContains(state.relaysLeft(), relay) + assertFalse(state.isDone()) + } + + @Test + fun regularRejectionStillBoundedByTryCap() { + val state = PoolEventOutboxState(fakeEvent(), setOf(relay)) + + // 3 non-AUTH rejections accumulate normally. + repeat(3) { + state.newResponse(relay, success = false, message = "error: rate limited") + } + state.newTry(relay) + + // After the 4th newTry (with 3 prior responses already in flight), the + // Tries cap kicks in and the relay is dropped from the outbox. + assertFalse(state.relaysLeft().contains(relay)) + } + + @Test + fun terminalRejectionImmediatelyDropsRelay() { + val state = PoolEventOutboxState(fakeEvent(), setOf(relay)) + + state.newResponse(relay, success = false, message = "invalid: malformed event") + + assertFalse(state.relaysLeft().contains(relay)) + assertTrue(state.isDone()) + } + + @Test + fun successDropsRelayFromOutbox() { + val state = PoolEventOutboxState(fakeEvent(), setOf(relay)) + + state.newResponse(relay, success = true, message = "") + + assertEquals(emptySet(), state.relaysLeft()) + assertTrue(state.isDone()) + } +}