Merge pull request #2758 from mstrofnone/feat/nip9a-community-rules

feat(quartz): NIP-9A community rules parser + validator (kind:34551)
This commit is contained in:
Vitor Pamplona
2026-05-08 08:30:50 -04:00
committed by GitHub
8 changed files with 784 additions and 0 deletions
@@ -0,0 +1,131 @@
/*
* 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.nip72ModCommunities.rules
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.KindRuleTag
import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.MaxEventSizeTag
import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.MinRulesCreatedAtTag
import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.PubkeyRuleTag
import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.WotTag
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* NIP-9A Verifiable Community Rules.
*
* Addressable replaceable event (`kind:34551`) carrying a machine-readable, signed
* rules document for a community defined by NIP-72 (`kind:34550`). Clients fetch
* the latest rules event before publishing into a community and reject drafts
* locally if they would violate any rule, surfacing the violation to the user
* before send.
*
* Strictly additive to NIP-72: the freeform `rules` tag on `kind:34550` continues
* to carry human-readable text. This event carries the machine-readable companion.
*
* See `9A.md` in nostr-protocol/nips for the full specification.
*/
@Immutable
class CommunityRulesEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
AddressHintProvider {
override fun addressHints() = tags.mapNotNull(ATag::parseAsHint)
override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId)
/** All `k` rules. May contain duplicates; callers usually want [allowedKinds]. */
fun kindRules(): List<KindRuleTag> = tags.mapNotNull(KindRuleTag::parse)
/** Distinct kinds whitelisted by this rules document. */
fun allowedKinds(): Set<Int> = kindRules().map { it.kind }.toSet()
/** Returns the rule for [kind], or null if the kind is not whitelisted. */
fun ruleForKind(kind: Int): KindRuleTag? = kindRules().firstOrNull { it.kind == kind }
fun pubkeyRules(): List<PubkeyRuleTag> = tags.mapNotNull(PubkeyRuleTag::parse)
/**
* Returns the policy declared for [pubkey], or null if no `p` rule applies.
* `deny` overrides `allow`; if both exist for the same pubkey, deny wins.
*/
fun policyFor(pubkey: HexKey): PubkeyRuleTag.Policy? {
var allow: PubkeyRuleTag.Policy? = null
for (rule in pubkeyRules()) {
if (rule.pubkey != pubkey) continue
if (rule.policy == PubkeyRuleTag.Policy.DENY) return PubkeyRuleTag.Policy.DENY
allow = PubkeyRuleTag.Policy.ALLOW
}
return allow
}
fun wotGates(): List<WotTag> = tags.mapNotNull(WotTag::parse)
fun maxEventSize(): Int? = tags.firstNotNullOfOrNull(MaxEventSizeTag::parse)
fun minRulesCreatedAt(): Long? = tags.firstNotNullOfOrNull(MinRulesCreatedAtTag::parse)
/** Address (`a` tag) of the community this rules document governs. */
fun communityAddress(): String? = tags.firstNotNullOfOrNull(ATag::parseAddressId)
companion object {
const val KIND = 34551
const val KIND_STR = "34551"
const val ALT_DESCRIPTION = "Community rules"
fun build(
dTag: String,
communityAddress: ATag,
kindRules: List<KindRuleTag>,
pubkeyRules: List<PubkeyRuleTag> = emptyList(),
wotGates: List<WotTag> = emptyList(),
maxEventSize: Int? = null,
minRulesCreatedAt: Long? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<CommunityRulesEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
alt(ALT_DESCRIPTION)
dTag(dTag)
add(communityAddress.toATagArray())
kindRules.forEach { add(it.toTagArray()) }
pubkeyRules.forEach { add(it.toTagArray()) }
wotGates.forEach { add(it.toTagArray()) }
maxEventSize?.let { add(MaxEventSizeTag.assemble(it)) }
minRulesCreatedAt?.let { add(MinRulesCreatedAtTag.assemble(it)) }
initializer()
}
}
}
@@ -0,0 +1,155 @@
/*
* 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.nip72ModCommunities.rules
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.PubkeyRuleTag
/**
* Validates draft events against a [CommunityRulesEvent] (NIP-9A).
*
* Pure logic; no I/O. Web-of-trust gates are deferred to the caller via
* [WotResolver] because NIP-9A leaves the lookup mechanism unspecified
* (NIP-02 follow lists, NIP-85 trusted assertions, etc).
*/
class CommunityRulesValidator(
private val rules: CommunityRulesEvent,
) {
/** A reason a draft event would be rejected. */
sealed interface Violation {
/** The rules document was signed before [minRulesCreatedAt] and must be ignored. */
data class StaleRules(
val rulesCreatedAt: Long,
val minRulesCreatedAt: Long,
) : Violation
/** Author is on the deny-list. */
data class AuthorDenied(
val author: HexKey,
) : Violation
/** No `k` rule whitelists this kind. */
data class KindNotAllowed(
val kind: Int,
) : Violation
/** Event exceeds the kind-specific size limit. */
data class KindSizeExceeded(
val kind: Int,
val sizeBytes: Int,
val maxBytes: Int,
) : Violation
/** Event exceeds the global `max_event_size`. */
data class MaxSizeExceeded(
val sizeBytes: Int,
val maxBytes: Int,
) : Violation
/** Author exceeded the per-day quota for this kind. */
data class QuotaExceeded(
val kind: Int,
val postsToday: Int,
val maxPerDay: Int,
) : Violation
/** Author failed every web-of-trust gate. */
data class WotGateFailed(
val gateCount: Int,
) : Violation
}
/** Looks up whether [author] is reachable from [root] in the follow graph within [depth] hops. */
fun interface WotResolver {
fun isReachable(
author: HexKey,
root: HexKey,
depth: Int,
): Boolean
}
/**
* Validates a draft event. Returns null on success, or the first violation found.
*
* @param author pubkey of the draft author
* @param kind kind of the draft event
* @param sizeBytes JSON-encoded event size in bytes
* @param postsTodayByKind callback returning the count of events of [kind] this author has
* already published today, or null if unknown (in which case quota checks are skipped)
* @param wot resolver for `wot` gates, or null to skip WoT checks
*/
fun validate(
author: HexKey,
kind: Int,
sizeBytes: Int,
postsTodayByKind: ((Int) -> Int?)? = null,
wot: WotResolver? = null,
): Violation? {
// 1. Stale-rules ratchet: callers should pre-filter, but defend in depth.
rules.minRulesCreatedAt()?.let { minAt ->
if (rules.createdAt < minAt) {
return Violation.StaleRules(rules.createdAt, minAt)
}
}
// 2. Author deny-list overrides everything else.
if (rules.policyFor(author) == PubkeyRuleTag.Policy.DENY) {
return Violation.AuthorDenied(author)
}
// 3. Kind whitelist.
val kindRule = rules.ruleForKind(kind) ?: return Violation.KindNotAllowed(kind)
// 4. Per-kind size limit.
kindRule.maxBytes?.let { maxBytes ->
if (sizeBytes > maxBytes) {
return Violation.KindSizeExceeded(kind, sizeBytes, maxBytes)
}
}
// 5. Global size cap.
rules.maxEventSize()?.let { maxBytes ->
if (sizeBytes > maxBytes) {
return Violation.MaxSizeExceeded(sizeBytes, maxBytes)
}
}
// 6. Per-day quota.
if (kindRule.maxPerAuthorPerDay != null && postsTodayByKind != null) {
val today = postsTodayByKind(kind)
if (today != null && today >= kindRule.maxPerAuthorPerDay) {
return Violation.QuotaExceeded(kind, today, kindRule.maxPerAuthorPerDay)
}
}
// 7. WoT gates: pass if any one gate accepts the author. Allow-listed pubkeys
// bypass WoT (an explicit `allow` is a stronger signal than a graph traversal).
if (rules.policyFor(author) != PubkeyRuleTag.Policy.ALLOW) {
val gates = rules.wotGates()
if (gates.isNotEmpty() && wot != null) {
val anyPasses = gates.any { wot.isReachable(author, it.rootPubkey, it.depth) }
if (!anyPasses) return Violation.WotGateFailed(gates.size)
}
}
return null
}
}
@@ -0,0 +1,71 @@
/*
* 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.nip72ModCommunities.rules.tags
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.arrayOfNotNull
import com.vitorpamplona.quartz.utils.ensure
/**
* NIP-9A `k` tag: whitelists one event kind for the community, optionally with a
* max event size in bytes and a per-author quota per day.
*
* Form: `["k", "<kind>", "<max-bytes?>", "<max-per-author-per-day?>"]`
*
* Position 2 (kind) is required. Positions 3 and 4 are optional; an empty string
* is treated as "unset".
*/
@Immutable
data class KindRuleTag(
val kind: Int,
val maxBytes: Int?,
val maxPerAuthorPerDay: Int?,
) {
fun toTagArray() = assemble(kind, maxBytes, maxPerAuthorPerDay)
companion object {
const val TAG_NAME = "k"
fun parse(tag: Array<String>): KindRuleTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
val kind = tag[1].toIntOrNull() ?: return null
val maxBytes = tag.getOrNull(2)?.takeIf { it.isNotEmpty() }?.toIntOrNull()
val maxPerDay = tag.getOrNull(3)?.takeIf { it.isNotEmpty() }?.toIntOrNull()
return KindRuleTag(kind, maxBytes, maxPerDay)
}
fun assemble(
kind: Int,
maxBytes: Int?,
maxPerAuthorPerDay: Int?,
) = arrayOfNotNull(
TAG_NAME,
kind.toString(),
maxBytes?.toString(),
maxPerAuthorPerDay?.toString(),
)
}
}
@@ -0,0 +1,44 @@
/*
* 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.nip72ModCommunities.rules.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* NIP-9A `max_event_size` tag: hard cap on the JSON-encoded event byte size,
* applied in addition to any per-kind size limit from `KindRuleTag`.
*
* Form: `["max_event_size", "<bytes>"]`
*/
class MaxEventSizeTag {
companion object {
const val TAG_NAME = "max_event_size"
fun parse(tag: Array<String>): Int? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
return tag[1].toIntOrNull()?.takeIf { it > 0 }
}
fun assemble(maxBytes: Int) = arrayOf(TAG_NAME, maxBytes.toString())
}
}
@@ -0,0 +1,47 @@
/*
* 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.nip72ModCommunities.rules.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* NIP-9A `min_rules_created_at` tag: anti-rollback ratchet.
*
* Clients MUST refuse rules events whose `created_at` is below this value, even
* when signed by the legitimate owner. Limits the damage window from a stolen
* key replay.
*
* Form: `["min_rules_created_at", "<unix-seconds>"]`
*/
class MinRulesCreatedAtTag {
companion object {
const val TAG_NAME = "min_rules_created_at"
fun parse(tag: Array<String>): Long? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
return tag[1].toLongOrNull()?.takeIf { it >= 0 }
}
fun assemble(minCreatedAt: Long) = arrayOf(TAG_NAME, minCreatedAt.toString())
}
}
@@ -0,0 +1,77 @@
/*
* 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.nip72ModCommunities.rules.tags
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.arrayOfNotNull
import com.vitorpamplona.quartz.utils.ensure
/**
* NIP-9A `p` tag: per-pubkey allow/deny override with optional role.
*
* Form: `["p", "<hex>", "<allow|deny>", "<role?>"]`
*
* `deny` overrides any `allow` and any other rule.
*/
@Immutable
data class PubkeyRuleTag(
val pubkey: HexKey,
val policy: Policy,
val role: String?,
) {
enum class Policy { ALLOW, DENY }
fun toTagArray() = assemble(pubkey, policy, role)
companion object {
const val TAG_NAME = "p"
const val ALLOW = "allow"
const val DENY = "deny"
fun parse(tag: Array<String>): PubkeyRuleTag? {
ensure(tag.has(2)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
val policy =
when (tag[2]) {
ALLOW -> Policy.ALLOW
DENY -> Policy.DENY
else -> return null
}
return PubkeyRuleTag(tag[1], policy, tag.getOrNull(3)?.takeIf { it.isNotEmpty() })
}
fun assemble(
pubkey: HexKey,
policy: Policy,
role: String?,
) = arrayOfNotNull(
TAG_NAME,
pubkey,
if (policy == Policy.ALLOW) ALLOW else DENY,
role,
)
}
}
@@ -0,0 +1,63 @@
/*
* 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.nip72ModCommunities.rules.tags
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* NIP-9A `wot` tag: optional web-of-trust gate.
*
* Form: `["wot", "<root-pubkey>", "<depth>"]`
*
* Posts are allowed only if the author is reachable from `<root-pubkey>` through
* follow lists within `<depth>` hops. Multiple `wot` tags act as OR (any one
* passing is enough). The lookup mechanism is unspecified: clients MAY use NIP-02
* follow lists, NIP-85 trusted assertions, or any other source.
*/
@Immutable
data class WotTag(
val rootPubkey: HexKey,
val depth: Int,
) {
fun toTagArray() = assemble(rootPubkey, depth)
companion object {
const val TAG_NAME = "wot"
fun parse(tag: Array<String>): WotTag? {
ensure(tag.has(2)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
val depth = tag[2].toIntOrNull()?.takeIf { it > 0 } ?: return null
return WotTag(tag[1], depth)
}
fun assemble(
rootPubkey: HexKey,
depth: Int,
) = arrayOf(TAG_NAME, rootPubkey, depth.toString())
}
}
@@ -0,0 +1,196 @@
/*
* 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.nip72ModCommunities.rules
import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.KindRuleTag
import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.MaxEventSizeTag
import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.MinRulesCreatedAtTag
import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.PubkeyRuleTag
import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.WotTag
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
class CommunityRulesValidatorTest {
private val owner = "a".repeat(64)
private val author = "b".repeat(64)
private val villain = "c".repeat(64)
private val communityAddr = "34550:$owner:my-community"
private val rulesD = "my-community"
private fun rules(
kindRules: List<KindRuleTag> = listOf(KindRuleTag(1111, 16384, null)),
pubkeyRules: List<PubkeyRuleTag> = emptyList(),
wotGates: List<WotTag> = emptyList(),
maxSize: Int? = null,
minRulesCreatedAt: Long? = null,
createdAt: Long = 1_700_000_000L,
): CommunityRulesEvent {
val tags =
buildList {
add(arrayOf("d", rulesD))
add(arrayOf("a", communityAddr))
kindRules.forEach { add(it.toTagArray()) }
pubkeyRules.forEach { add(it.toTagArray()) }
wotGates.forEach { add(it.toTagArray()) }
maxSize?.let { add(MaxEventSizeTag.assemble(it)) }
minRulesCreatedAt?.let { add(MinRulesCreatedAtTag.assemble(it)) }
}.toTypedArray()
return CommunityRulesEvent("id", owner, createdAt, tags, "", "sig")
}
@Test
fun allowsConformantPost() {
val v = CommunityRulesValidator(rules()).validate(author, 1111, 1024)
assertNull(v)
}
@Test
fun rejectsDisallowedKind() {
val v = CommunityRulesValidator(rules()).validate(author, 30023, 1024)
val violation = assertIs<CommunityRulesValidator.Violation.KindNotAllowed>(v)
assertEquals(30023, violation.kind)
}
@Test
fun rejectsOversizedPostByKindLimit() {
val v = CommunityRulesValidator(rules()).validate(author, 1111, 20_000)
val violation = assertIs<CommunityRulesValidator.Violation.KindSizeExceeded>(v)
assertEquals(1111, violation.kind)
assertEquals(20_000, violation.sizeBytes)
assertEquals(16384, violation.maxBytes)
}
@Test
fun rejectsOversizedPostByGlobalCap() {
val r = rules(kindRules = listOf(KindRuleTag(1111, null, null)), maxSize = 8000)
val v = CommunityRulesValidator(r).validate(author, 1111, 9000)
val violation = assertIs<CommunityRulesValidator.Violation.MaxSizeExceeded>(v)
assertEquals(8000, violation.maxBytes)
}
@Test
fun rejectsDeniedAuthorRegardlessOfKind() {
val r =
rules(
pubkeyRules = listOf(PubkeyRuleTag(villain, PubkeyRuleTag.Policy.DENY, null)),
)
val v = CommunityRulesValidator(r).validate(villain, 1111, 100)
val violation = assertIs<CommunityRulesValidator.Violation.AuthorDenied>(v)
assertEquals(villain, violation.author)
}
@Test
fun denyOverridesAllowForSamePubkey() {
val r =
rules(
pubkeyRules =
listOf(
PubkeyRuleTag(villain, PubkeyRuleTag.Policy.ALLOW, null),
PubkeyRuleTag(villain, PubkeyRuleTag.Policy.DENY, null),
),
)
val v = CommunityRulesValidator(r).validate(villain, 1111, 100)
assertIs<CommunityRulesValidator.Violation.AuthorDenied>(v)
}
@Test
fun enforcesPerKindDailyQuota() {
val r = rules(kindRules = listOf(KindRuleTag(1111, null, 5)))
val v = CommunityRulesValidator(r).validate(author, 1111, 100, postsTodayByKind = { 5 })
val violation = assertIs<CommunityRulesValidator.Violation.QuotaExceeded>(v)
assertEquals(5, violation.postsToday)
assertEquals(5, violation.maxPerDay)
}
@Test
fun unknownQuotaPostsDoesNotBlock() {
val r = rules(kindRules = listOf(KindRuleTag(1111, null, 5)))
val v = CommunityRulesValidator(r).validate(author, 1111, 100, postsTodayByKind = { null })
assertNull(v)
}
@Test
fun wotGatePassesWhenReachable() {
val r = rules(wotGates = listOf(WotTag(owner, 2)))
val v =
CommunityRulesValidator(r).validate(author, 1111, 100) { _, _, _ -> true }
assertNull(v)
}
@Test
fun wotGateFailsWhenUnreachable() {
val r = rules(wotGates = listOf(WotTag(owner, 2)))
val v =
CommunityRulesValidator(r).validate(author, 1111, 100) { _, _, _ -> false }
val violation = assertIs<CommunityRulesValidator.Violation.WotGateFailed>(v)
assertEquals(1, violation.gateCount)
}
@Test
fun explicitAllowBypassesWotGate() {
val r =
rules(
pubkeyRules = listOf(PubkeyRuleTag(author, PubkeyRuleTag.Policy.ALLOW, "vip")),
wotGates = listOf(WotTag(owner, 2)),
)
val v =
CommunityRulesValidator(r).validate(author, 1111, 100) { _, _, _ -> false }
assertNull(v)
}
@Test
fun multipleWotGatesActAsOr() {
val r =
rules(
wotGates =
listOf(
WotTag(owner, 1),
WotTag(villain, 5),
),
)
// Only the second gate accepts.
val v =
CommunityRulesValidator(r).validate(author, 1111, 100) { _, root, _ ->
root == villain
}
assertNull(v)
}
@Test
fun staleRulesAreRejected() {
val r = rules(minRulesCreatedAt = 1_800_000_000L, createdAt = 1_700_000_000L)
val v = CommunityRulesValidator(r).validate(author, 1111, 100)
val violation = assertIs<CommunityRulesValidator.Violation.StaleRules>(v)
assertEquals(1_700_000_000L, violation.rulesCreatedAt)
assertEquals(1_800_000_000L, violation.minRulesCreatedAt)
}
@Test
fun parsesEventAccessors() {
val r = rules()
assertEquals(communityAddr, r.communityAddress())
assertEquals(setOf(1111), r.allowedKinds())
assertTrue(r.kindRules().isNotEmpty())
}
}