diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelKeyring.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelKeyring.kt index bdedd2b988..5a50702174 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelKeyring.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelKeyring.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull @@ -61,6 +62,9 @@ object ConcordChannelKeyring { const val CHANNEL_CUTS = "channel_cuts" const val PRIORS = "priors" + /** How many retired keys a channel keeps in `priors`, newest epochs first. */ + const val MAX_PRIORS = 32 + private val HEX64 = Regex("^[0-9a-fA-F]{64}$") /** The current key held for [channelIdHex], or null (a keyless listing is not a key). */ @@ -185,9 +189,15 @@ object ConcordChannelKeyring { JsonObject(mapOf("key" to JsonPrimitive(k), "epoch" to JsonPrimitive(epoch), "retired_at" to JsonPrimitive(at))) } if (added.isEmpty()) return extras - return JsonObject(extras + (PRIORS to JsonArray(existing + added))) + // Bounded: every rotation adds one, and a List entry that outgrows its fragment fails every + // List write (joins and leaves included), which is worse than losing the oldest era's history. + val all = existing + added + val kept = if (all.size <= MAX_PRIORS) all else all.sortedByDescending { priorEpoch(it) }.take(MAX_PRIORS) + return JsonObject(extras + (PRIORS to JsonArray(kept))) } + private fun priorEpoch(prior: JsonElement): Long = ((prior as? JsonObject)?.get("epoch") as? JsonPrimitive)?.longOrNull ?: Long.MIN_VALUE + /** * [entry] after a rotation to [cutEpoch] cut this member from [channelIdHex] (CORD-06 ยง2): the * key leaves `channels` and the cut is recorded, so no older key can come back. diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelKeyringTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelKeyringTest.kt index 82c2dd4087..d3081dd53c 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelKeyringTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelKeyringTest.kt @@ -145,6 +145,17 @@ class ConcordChannelKeyringTest { assertFalse(ConcordChannelKeyring.historicalKeys(rotated, chan).any { it.key == "13".repeat(32) }) } + @Test + fun priorsKeepOnlyTheNewestEpochs() { + var e = entry(listOf(PrivateChannelKey(chan, 1.toString(16).padStart(64, '0'), 1, "mods"))) + for (epoch in 2L..(ConcordChannelKeyring.MAX_PRIORS + 10)) { + e = assertNotNull(ConcordChannelKeyring.withRotatedKey(e, chan, epoch.toString(16).padStart(64, '0'), epoch, retiredAt = epoch)) + } + val epochs = ConcordChannelKeyring.historicalKeys(e, chan).map { it.epoch } + assertEquals(ConcordChannelKeyring.MAX_PRIORS, epochs.size) + assertEquals(ConcordChannelKeyring.MAX_PRIORS + 9L, epochs.first()) + } + @Test fun aRotationKeepsEveryEarlierKeyReadableAfterARoundTrip() { // A channel created after the join has no `seed` anchor: the List is the only place its