From b8dad13265a5719f08084aef5bf8444966567d4d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 16:27:13 +0000 Subject: [PATCH 01/24] feat(android): opt into ARM Memory Tagging Extension (async) Declare android:memtagMode on the application so devices with MTE enabled (Pixel 8+ with the developer toggle or Advanced Protection, GrapheneOS) tag-check our native code: WebRTC, zxing-cpp, bundled SQLite, secp256k1 and Arti. Release and benchmark builds use async, the low-overhead production mode; debug uses sync so a tag fault crashes at the exact faulting access. The attribute is ignored on devices without MTE and below API 31. Closes #4196 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TUQgtrHcA21Npt8ajDjWYC --- amethyst/build.gradle.kts | 7 +++++++ amethyst/src/main/AndroidManifest.xml | 1 + 2 files changed, 8 insertions(+) diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index 1f432394be..432eac5d92 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -140,6 +140,12 @@ android { buildConfigField("String", "RELEASE_NOTES_ID", "\"f7914e7a7e293988485439eb2bea29c09c388d54c452c4a19f89e106dbf1969e\"") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + // ARM Memory Tagging Extension for our native code (WebRTC, zxing-cpp, SQLite, + // secp256k1, Arti). Async is the low-overhead production mode; debug overrides + // it to sync so a tag fault crashes at the exact faulting access. Ignored on + // devices without MTE hardware or with it switched off. + manifestPlaceholders["memtagMode"] = "async" vectorDrawables { useSupportLibrary = true } @@ -267,6 +273,7 @@ android { applicationIdSuffix = ".debug" versionNameSuffix = "-DEBUG" resValue("string", "app_name", "@string/app_name_debug") + manifestPlaceholders["memtagMode"] = "sync" } create("benchmark") { initWith(getByName("release")) diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index d2b9dabc65..91acb98039 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -184,6 +184,7 @@ android:networkSecurityConfig="@xml/network_security_config" android:hardwareAccelerated="true" android:localeConfig="@xml/locales_config" + android:memtagMode="${memtagMode}" tools:targetApi="34"> Date: Sun, 27 Sep 2026 17:05:19 +0000 Subject: [PATCH 02/24] feat(quartz): NIP-86 event allow list, roles and claims; NIP-43 roles NIP-86 server semantics now follow the spec: banpubkey/banevent drop the entry from the allow list, allowpubkey/allowevent lift the ban, and the un* methods never touch the opposite list. `allowevent` no longer means "unban": it adds the id to a new event allow list (with reason), which BanListPolicy honours by accepting that event without the pubkey/kind checks. An empty event allow list changes nothing. New methods on both client and server: unbanevent, unallowevent, listallowedevents, listdisallowedkinds, createrole/editrole/deleterole, assignrole/unassignrole, listclaims/createclaim/deleteclaim. Role and claim state lives in BanStore and is persisted in geode's state file. Requests now always serialize `params`, even when empty. NIP-43: kind 33534 RelayRoleEvent (label/description/color/order), role ids on kind 13534 member tags, join requests (28934) require the claim and carry the `-` tag, and kind 28935 is deprecated in favour of NIP-86 createclaim. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc --- .../geode/config/RuntimeConfig.kt | 40 +++- .../geode/admin/Nip86EndToEndTest.kt | 33 ++- .../geode/config/RuntimeConfigTest.kt | 42 ++++ .../vitorpamplona/quartz/kinds/KindNames.kt | 2 + .../inviteRequest/RelayInviteRequestEvent.kt | 8 + .../joinRequest/RelayJoinRequestEvent.kt | 20 +- .../list/RelayMembershipListEvent.kt | 15 ++ .../list/TagArrayBuilderExt.kt | 3 + .../nip43RelayMembers/list/TagArrayExt.kt | 5 +- .../nip43RelayMembers/list/tags/MemberTag.kt | 35 ++++ .../nip43RelayMembers/roles/RelayRole.kt | 47 +++++ .../nip43RelayMembers/roles/RelayRoleEvent.kt | 89 ++++++++ .../roles/TagArrayBuilderExt.kt | 35 ++++ .../nip43RelayMembers/roles/TagArrayExt.kt | 36 ++++ .../roles/tags/RoleColorTag.kt | 42 ++++ .../roles/tags/RoleDescriptionTag.kt | 40 ++++ .../roles/tags/RoleLabelTag.kt | 40 ++++ .../roles/tags/RoleOrderTag.kt | 39 ++++ .../nip86RelayManagement/Nip86Client.kt | 66 +++++- .../nip86RelayManagement/rpc/Nip86Method.kt | 20 ++ .../nip86RelayManagement/rpc/Nip86Request.kt | 115 ++++++++++ .../nip86RelayManagement/rpc/Nip86Response.kt | 6 + .../server/BanListPolicy.kt | 12 ++ .../nip86RelayManagement/server/BanStore.kt | 197 ++++++++++++++++-- .../server/Nip86Server.kt | 147 ++++++++++++- .../quartz/utils/EventFactory.kt | 2 + .../nip43RelayMembers/RelayRolesTest.kt | 128 ++++++++++++ .../Nip86RequestResponseTest.kt | 78 +++++++ .../server/BanStoreTest.kt | 149 ++++++++++++- .../server/Nip86ServerTest.kt | 168 ++++++++++++++- 30 files changed, 1627 insertions(+), 32 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/RelayRole.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/RelayRoleEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/TagArrayExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/tags/RoleColorTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/tags/RoleDescriptionTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/tags/RoleLabelTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/tags/RoleOrderTag.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/RelayRolesTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/Nip86RequestResponseTest.kt diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/config/RuntimeConfig.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/config/RuntimeConfig.kt index 5e0d532424..e861aa7158 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/config/RuntimeConfig.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/config/RuntimeConfig.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.geode.config import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation +import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole import com.vitorpamplona.quartz.nip86RelayManagement.server.BanStore import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -36,7 +37,9 @@ import java.nio.file.StandardCopyOption * - the live NIP-11 info doc (so `changerelayname/description/icon` * survive a restart), and * - the NIP-86 ban / allow / kind lists for pubkeys, events, and - * kinds (NIP-86 admin RPC mutates these directly). + * kinds (NIP-86 admin RPC mutates these directly), and + * - the NIP-43 role definitions, role assignments and invite codes + * managed through the NIP-86 role / claim methods. * * One JSON file per relay. Lives next to the SQLite event store by * convention, but the path is configurable independently via @@ -133,6 +136,33 @@ data class RuntimeConfigData( val bannedEvents: List = emptyList(), val allowedKinds: List = emptyList(), val disallowedKinds: List = emptyList(), + val allowedEvents: List = emptyList(), + val roles: List = emptyList(), + val roleAssignments: List = emptyList(), + val claims: List = emptyList(), +) + +/** A NIP-43 role definition (NIP-86 `createrole` params). */ +@Serializable +data class RoleEntry( + val id: String, + val label: String? = null, + val description: String? = null, + val color: Int? = null, + val order: Int? = null, +) { + fun toRole() = RelayRole(id, label, description, color, order) + + companion object { + fun of(role: RelayRole) = RoleEntry(role.id, role.label, role.description, role.color, role.order) + } +} + +/** The NIP-43 role ids assigned to one pubkey. */ +@Serializable +data class RoleAssignmentEntry( + val pubkey: String, + val roles: List, ) @Serializable @@ -149,6 +179,10 @@ fun RuntimeConfigData.seedInto(banStore: BanStore) { bannedEvents = bannedEvents.map { it.key to it.reason }, allowedKinds = allowedKinds, disallowedKinds = disallowedKinds, + allowedEvents = allowedEvents.map { it.key to it.reason }, + roles = roles.map { it.toRole() }, + roleAssignments = roleAssignments.map { it.pubkey to it.roles }, + claims = claims, ) } @@ -164,4 +198,8 @@ fun snapshotOf( bannedEvents = banStore.listBannedEvents().map { (k, r) -> BannedEntry(k, r) }, allowedKinds = banStore.listAllowedKinds(), disallowedKinds = banStore.listDisallowedKinds(), + allowedEvents = banStore.listAllowedEvents().map { (k, r) -> BannedEntry(k, r) }, + roles = banStore.listRoles().map { RoleEntry.of(it) }, + roleAssignments = banStore.listRoleAssignments().map { (pk, ids) -> RoleAssignmentEntry(pk, ids) }, + claims = banStore.listClaims(), ) diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/admin/Nip86EndToEndTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/admin/Nip86EndToEndTest.kt index 5c119e1cd3..66ef69a46a 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/admin/Nip86EndToEndTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/admin/Nip86EndToEndTest.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -39,6 +40,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.boolean import kotlinx.serialization.json.jsonPrimitive @@ -125,7 +127,7 @@ class Nip86EndToEndTest { fun supportedMethodsListsTheServersMethods() { rpc(Nip86Request.supportedMethods(), admin).use { assertEquals(200, it.code) - val json = JsonMapper.fromJson(it.body.string()) + val json = JsonMapper.fromJson(it.body.string()) val arr = json.result as JsonArray val names = arr.map { e -> e.jsonPrimitive.content } assertTrue(names.contains("supportedmethods")) @@ -169,7 +171,7 @@ class Nip86EndToEndTest { // Admin bans them. rpc(Nip86Request.banPubkey(targetUser.pubKey, "spam"), admin).use { assertEquals(200, it.code) - val resp = JsonMapper.fromJson(it.body.string()) + val resp = JsonMapper.fromJson(it.body.string()) assertEquals(true, (resp.result as JsonPrimitive).boolean) } @@ -178,6 +180,33 @@ class Nip86EndToEndTest { assertEquals(false, after, "BanListPolicy must reject events from banned pubkeys") } + @Test + fun allowEventLetsOneEventPastABanUntilUnallowed() = + runBlocking { + val relayUrl = server.url.normalizeRelayUrl() + rpc(Nip86Request.banPubkey(targetUser.pubKey, "spam"), admin).use { assertEquals(200, it.code) } + + // Admin approves one specific event from the banned author. + val approved = targetUser.sign(TextNoteEvent.build("approved")) + rpc(Nip86Request.allowEvent(approved.id, "reviewed"), admin).use { assertEquals(200, it.code) } + assertEquals(true, nostrClient.publishAndConfirm(approved, setOf(relayUrl)), "allow-listed event bypasses the pubkey ban") + + // Any other event from that author is still blocked. + val other = targetUser.sign(TextNoteEvent.build("other")) + assertEquals(false, nostrClient.publishAndConfirm(other, setOf(relayUrl))) + + rpc(Nip86Request.listAllowedEvents(), admin).use { + val resp = JsonMapper.fromJson(it.body.string()) + val ids = (resp.result as JsonArray).map { e -> (e as JsonObject)["id"]!!.jsonPrimitive.content } + assertEquals(listOf(approved.id), ids) + } + + // unallowevent drops the exemption without banning the event. + rpc(Nip86Request.unallowEvent(approved.id), admin).use { assertEquals(200, it.code) } + assertTrue(!relay.banStore.isAllowedEvent(approved.id)) + assertTrue(!relay.banStore.isBannedEvent(approved.id)) + } + @Test fun changeRelayNameFlowsToNip11Endpoint() { rpc(Nip86Request.changeRelayName("renamed-by-admin"), admin).use { diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/config/RuntimeConfigTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/config/RuntimeConfigTest.kt index 10bab8a7dd..04b21cd576 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/config/RuntimeConfigTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/config/RuntimeConfigTest.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.geode.RelayEngine import com.vitorpamplona.geode.RelayInfo import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation +import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole import java.io.File import java.nio.file.Files import kotlin.test.AfterTest @@ -124,6 +125,47 @@ class RuntimeConfigTest { } } + @Test + fun eventAllowListRolesAndClaimsSurviveRestart() { + val pk = "b".repeat(64) + val allowedId = "c".repeat(64) + val r1 = relayPersisted() + try { + r1.banStore.allowEvent(allowedId, "approved") + r1.banStore.createRole(RelayRole("mod", label = "Moderator", description = "keeps order", color = 120, order = 2)) + r1.banStore.assignRole(pk, "mod") + r1.banStore.createClaim("invite-123") + } finally { + r1.close() + } + + val r2 = relayPersisted() + try { + assertEquals(listOf(allowedId to "approved"), r2.banStore.listAllowedEvents()) + assertTrue(r2.banStore.isAllowedEvent(allowedId)) + assertEquals(RelayRole("mod", "Moderator", "keeps order", 120, 2), r2.banStore.getRole("mod")) + assertEquals(listOf("mod"), r2.banStore.rolesOf(pk)) + assertEquals(listOf("invite-123"), r2.banStore.listClaims()) + } finally { + r2.close() + } + } + + @Test + fun oldStateFileWithoutNewSectionsStillLoads() { + // A snapshot written before the event allow list / roles / claims existed. + stateFile.writeText("""{"info":{"name":"old"},"bannedEvents":[{"key":"${"d".repeat(64)}","reason":"x"}]}""") + val r = relayPersisted() + try { + assertTrue(r.banStore.isBannedEvent("d".repeat(64))) + assertEquals(emptyList(), r.banStore.listAllowedEvents()) + assertEquals(emptyList(), r.banStore.listRoles()) + assertEquals(emptyList(), r.banStore.listClaims()) + } finally { + r.close() + } + } + @Test fun corruptStateFileIsTolerated() { stateFile.writeText("not valid json {") diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt index b1ff7a9483..6bc8a37c3d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt @@ -150,6 +150,7 @@ import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEv import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent +import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRoleEvent import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent @@ -614,6 +615,7 @@ object KindNames { Ps1SaveEvent.KIND to KindName("PS1 Save", null), NwcInfoEvent.KIND to KindName("NWC Info", "47"), RelayMembershipListEvent.KIND to KindName("Relay Memberships", "43"), + RelayRoleEvent.KIND to KindName("Relay Role", "43"), RootSiteEvent.KIND to KindName("Website Root", "5A"), RootNappletEvent.KIND to KindName("Napplet Root", "5D"), CashuWalletEvent.KIND to KindName("Cashu Wallet", "60"), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/inviteRequest/RelayInviteRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/inviteRequest/RelayInviteRequestEvent.kt index fde64991fd..90fec26b71 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/inviteRequest/RelayInviteRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/inviteRequest/RelayInviteRequestEvent.kt @@ -27,7 +27,15 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.utils.TimeUtils +/** + * NIP-43 kind 28935: a relay-signed ephemeral event carrying an invite code. + * + * Removed from NIP-43: invite codes are now minted with the NIP-86 + * `createclaim` method ([com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request.createClaim]). + * Kept so events from relays that still emit it keep parsing. + */ @Immutable +@Deprecated("Removed from NIP-43. Mint invite codes with the NIP-86 `createclaim` method (Nip86Request.createClaim).") class RelayInviteRequestEvent( id: HexKey, pubKey: HexKey, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/joinRequest/RelayJoinRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/joinRequest/RelayJoinRequestEvent.kt index 3c64941867..39ac57af0b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/joinRequest/RelayJoinRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/joinRequest/RelayJoinRequestEvent.kt @@ -24,9 +24,17 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip70ProtectedEvts.protect import com.vitorpamplona.quartz.utils.TimeUtils +/** + * NIP-43 kind 28934: a request to join a relay. Must carry a NIP-70 `-` tag and + * a `claim` tag with the invite code; the relay answers with an `OK`. Invite + * codes are minted by the relay (NIP-86 `createclaim`), not requested with the + * deprecated kind 28935. + */ @Immutable class RelayJoinRequestEvent( id: HexKey, @@ -42,12 +50,16 @@ class RelayJoinRequestEvent( const val KIND = 28934 fun build( - claim: String? = null, + claim: String, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, "", createdAt) { - claim?.let { claim(it) } - initializer() + ): EventTemplate { + require(claim.isNotBlank()) { "NIP-43 join requests require an invite code (claim)" } + return eventTemplate(KIND, "", createdAt) { + protect() + claim(claim) + initializer() + } } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/RelayMembershipListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/RelayMembershipListEvent.kt index 9993cb05b7..6a8188cc5a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/RelayMembershipListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/RelayMembershipListEvent.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip43RelayMembers.list.tags.RelayMember import com.vitorpamplona.quartz.nip70ProtectedEvts.protect import com.vitorpamplona.quartz.utils.TimeUtils @@ -39,6 +40,9 @@ class RelayMembershipListEvent( ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { fun members() = tags.members() + /** Members with the role ids (NIP-43 kind 33534 `d` tags) the relay assigned to each. */ + fun membersWithRoles() = tags.membersWithRoles() + companion object { const val KIND = 13534 @@ -51,5 +55,16 @@ class RelayMembershipListEvent( members(members) initializer() } + + /** Like [build], but each member may carry its assigned role ids. */ + fun buildWithRoles( + members: List, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + protect() + membersWithRoles(members) + initializer() + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/TagArrayBuilderExt.kt index d622586deb..f6359a29dc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/TagArrayBuilderExt.kt @@ -23,5 +23,8 @@ package com.vitorpamplona.quartz.nip43RelayMembers.list import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip43RelayMembers.list.tags.MemberTag +import com.vitorpamplona.quartz.nip43RelayMembers.list.tags.RelayMember fun TagArrayBuilder.members(pubKeys: List) = addAll(MemberTag.assemble(pubKeys)) + +fun TagArrayBuilder.membersWithRoles(members: List) = addAll(members.map { MemberTag.assemble(it) }) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/TagArrayExt.kt index 39da8159b0..d84423c8ec 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/TagArrayExt.kt @@ -21,6 +21,9 @@ package com.vitorpamplona.quartz.nip43RelayMembers.list import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastMapNotNullDense import com.vitorpamplona.quartz.nip43RelayMembers.list.tags.MemberTag -fun TagArray.members() = mapNotNull(MemberTag::parse) +fun TagArray.members() = fastMapNotNullDense(MemberTag::parse) + +fun TagArray.membersWithRoles() = fastMapNotNullDense(MemberTag::parseMember) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/tags/MemberTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/tags/MemberTag.kt index c73004e935..fc93a663ae 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/tags/MemberTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/tags/MemberTag.kt @@ -20,10 +20,27 @@ */ package com.vitorpamplona.quartz.nip43RelayMembers.list.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 +/** + * A kind 13534 entry: the member's pubkey plus the NIP-43 role ids (kind + * 33534 `d` tags) assigned to them, in tag order. [roles] is empty for a + * member without roles, which is also what pre-roles relays publish. + */ +@Immutable +data class RelayMember( + val pubKey: HexKey, + val roles: List = emptyList(), +) + +/** + * NIP-43 `["member", , ...]`. Role ids after the pubkey are + * optional: [parse] ignores them (backward compatible) and [parseMember] + * returns them. + */ class MemberTag { companion object { const val TAG_NAME = "member" @@ -35,8 +52,26 @@ class MemberTag { return tag[1] } + fun parseMember(tag: Array): RelayMember? { + val pubKey = parse(tag) ?: return null + if (tag.size <= 2) return RelayMember(pubKey) + val roles = ArrayList(tag.size - 2) + for (i in 2 until tag.size) { + val role = tag[i] + if (role.isNotEmpty() && role !in roles) roles.add(role) + } + return RelayMember(pubKey, roles) + } + fun assemble(pubKey: HexKey) = arrayOf(TAG_NAME, pubKey) + fun assemble( + pubKey: HexKey, + roles: List, + ): Array = arrayOf(TAG_NAME, pubKey) + roles + + fun assemble(member: RelayMember) = assemble(member.pubKey, member.roles) + fun assemble(pubKeys: List) = pubKeys.map { assemble(it) } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/RelayRole.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/RelayRole.kt new file mode 100644 index 0000000000..59018cb2eb --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/RelayRole.kt @@ -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.nip43RelayMembers.roles + +import androidx.compose.runtime.Immutable + +/** + * A NIP-43 role as the relay defines it: the content of a kind 33534 + * [RelayRoleEvent], and the `[id, label, description, color, order]` params + * of the NIP-86 `createrole` / `editrole` methods. + * + * [color] is a hue in `0..360` (see [isValidHue]); [order] is a display-only + * sort key. Everything but [id] is optional. + */ +@Immutable +data class RelayRole( + val id: String, + val label: String? = null, + val description: String? = null, + val color: Int? = null, + val order: Int? = null, +) { + companion object { + const val MIN_HUE = 0 + const val MAX_HUE = 360 + + fun isValidHue(hue: Int) = hue in MIN_HUE..MAX_HUE + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/RelayRoleEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/RelayRoleEvent.kt new file mode 100644 index 0000000000..26c3193092 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/RelayRoleEvent.kt @@ -0,0 +1,89 @@ +/* + * 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.nip43RelayMembers.roles + +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.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip70ProtectedEvts.protect +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-43 kind 33534: a role the relay defines and may assign to members. + * + * Signed by the relay's NIP-11 `self` pubkey and protected (NIP-70 `-` tag). + * The `d` tag is the role id — the value a kind 13534 `member` tag lists after + * the pubkey. `label`, `description`, `color` (a hue, 0..360) and `order` + * (display-only sort key) are optional. + */ +@Immutable +class RelayRoleEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun roleId() = dTag() + + fun label() = tags.roleLabel() + + fun description() = tags.roleDescription() + + /** Hue in `0..360`, or null when absent or out of range. */ + fun color() = tags.roleColor() + + fun order() = tags.roleOrder() + + fun role() = + RelayRole( + id = roleId(), + label = label(), + description = description(), + color = color(), + order = order(), + ) + + companion object { + const val KIND = 33534 + + fun build( + role: RelayRole, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + protect() + dTag(role.id) + role.label?.let { roleLabel(it) } + role.description?.let { roleDescription(it) } + role.color?.let { + require(RelayRole.isValidHue(it)) { "role color must be a hue between 0 and 360, got $it" } + roleColor(it) + } + role.order?.let { roleOrder(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..40f1bdd0b0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/TagArrayBuilderExt.kt @@ -0,0 +1,35 @@ +/* + * 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.nip43RelayMembers.roles + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip43RelayMembers.roles.tags.RoleColorTag +import com.vitorpamplona.quartz.nip43RelayMembers.roles.tags.RoleDescriptionTag +import com.vitorpamplona.quartz.nip43RelayMembers.roles.tags.RoleLabelTag +import com.vitorpamplona.quartz.nip43RelayMembers.roles.tags.RoleOrderTag + +fun TagArrayBuilder.roleLabel(label: String) = addUnique(RoleLabelTag.assemble(label)) + +fun TagArrayBuilder.roleDescription(description: String) = addUnique(RoleDescriptionTag.assemble(description)) + +fun TagArrayBuilder.roleColor(hue: Int) = addUnique(RoleColorTag.assemble(hue)) + +fun TagArrayBuilder.roleOrder(order: Int) = addUnique(RoleOrderTag.assemble(order)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/TagArrayExt.kt new file mode 100644 index 0000000000..38dceef188 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/TagArrayExt.kt @@ -0,0 +1,36 @@ +/* + * 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.nip43RelayMembers.roles + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull +import com.vitorpamplona.quartz.nip43RelayMembers.roles.tags.RoleColorTag +import com.vitorpamplona.quartz.nip43RelayMembers.roles.tags.RoleDescriptionTag +import com.vitorpamplona.quartz.nip43RelayMembers.roles.tags.RoleLabelTag +import com.vitorpamplona.quartz.nip43RelayMembers.roles.tags.RoleOrderTag + +fun TagArray.roleLabel() = fastFirstNotNullOfOrNull(RoleLabelTag::parse) + +fun TagArray.roleDescription() = fastFirstNotNullOfOrNull(RoleDescriptionTag::parse) + +fun TagArray.roleColor() = fastFirstNotNullOfOrNull(RoleColorTag::parse) + +fun TagArray.roleOrder() = fastFirstNotNullOfOrNull(RoleOrderTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/tags/RoleColorTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/tags/RoleColorTag.kt new file mode 100644 index 0000000000..719e6b4ab4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/tags/RoleColorTag.kt @@ -0,0 +1,42 @@ +/* + * 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.nip43RelayMembers.roles.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole +import com.vitorpamplona.quartz.utils.ensure + +/** NIP-43 kind 33534 `color` tag: a hue from 0 to 360. Non-numeric or out-of-range values parse as null. */ +class RoleColorTag { + companion object { + const val TAG_NAME = "color" + + fun parse(tag: Array): Int? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + val hue = tag[1].trim().toIntOrNull() ?: return null + ensure(RelayRole.isValidHue(hue)) { return null } + return hue + } + + fun assemble(hue: Int) = arrayOf(TAG_NAME, hue.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/tags/RoleDescriptionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/tags/RoleDescriptionTag.kt new file mode 100644 index 0000000000..176b26773d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/tags/RoleDescriptionTag.kt @@ -0,0 +1,40 @@ +/* + * 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.nip43RelayMembers.roles.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** NIP-43 kind 33534 `description` tag. */ +class RoleDescriptionTag { + companion object { + const val TAG_NAME = "description" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(description: String) = arrayOf(TAG_NAME, description) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/tags/RoleLabelTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/tags/RoleLabelTag.kt new file mode 100644 index 0000000000..350b05d65c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/tags/RoleLabelTag.kt @@ -0,0 +1,40 @@ +/* + * 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.nip43RelayMembers.roles.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** NIP-43 kind 33534 `label` tag: the role's display name. */ +class RoleLabelTag { + companion object { + const val TAG_NAME = "label" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(label: String) = arrayOf(TAG_NAME, label) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/tags/RoleOrderTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/tags/RoleOrderTag.kt new file mode 100644 index 0000000000..00db5227bd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/tags/RoleOrderTag.kt @@ -0,0 +1,39 @@ +/* + * 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.nip43RelayMembers.roles.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** NIP-43 kind 33534 `order` tag: a display-only integer sort key. */ +class RoleOrderTag { + companion object { + const val TAG_NAME = "order" + + fun parse(tag: Array): Int? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return tag[1].trim().toIntOrNull() + } + + fun assemble(order: Int) = arrayOf(TAG_NAME, order.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/Nip86Client.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/Nip86Client.kt index 3685bb11bb..00b9a24c00 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/Nip86Client.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/Nip86Client.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedEvent import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedEvent import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedPubkey @@ -84,16 +85,31 @@ class Nip86Client( return JsonMapper.fromJson>(result.toString()) } + fun parseAllowedEvents(response: Nip86Response): List? { + val result = response.result ?: return null + return JsonMapper.fromJson>(result.toString()) + } + fun parseEventsNeedingModeration(response: Nip86Response): List? { val result = response.result ?: return null return JsonMapper.fromJson>(result.toString()) } - fun parseAllowedKinds(response: Nip86Response): List? { + fun parseAllowedKinds(response: Nip86Response): List? = parseKinds(response) + + fun parseDisallowedKinds(response: Nip86Response): List? = parseKinds(response) + + private fun parseKinds(response: Nip86Response): List? { val result = response.result ?: return null return (result as? JsonArray)?.map { it.jsonPrimitive.int } } + /** `listclaims` result: a plain array of NIP-43 invite codes. */ + fun parseClaims(response: Nip86Response): List? { + val result = response.result ?: return null + return (result as? JsonArray)?.map { it.jsonPrimitive.content } + } + fun parseBlockedIps(response: Nip86Response): List? { val result = response.result ?: return null return JsonMapper.fromJson>(result.toString()) @@ -134,13 +150,59 @@ class Nip86Client( reason: String? = null, ) = Nip86Request.allowEvent(eventId, reason) + fun unallowEventRequest( + eventId: String, + reason: String? = null, + ) = Nip86Request.unallowEvent(eventId, reason) + fun banEventRequest( eventId: String, reason: String? = null, ) = Nip86Request.banEvent(eventId, reason) + fun unbanEventRequest( + eventId: String, + reason: String? = null, + ) = Nip86Request.unbanEvent(eventId, reason) + fun listBannedEventsRequest() = Nip86Request.listBannedEvents() + fun listAllowedEventsRequest() = Nip86Request.listAllowedEvents() + + fun createRoleRequest( + id: String, + label: String? = null, + description: String? = null, + color: Int? = null, + order: Int? = null, + ) = Nip86Request.createRole(id, label, description, color, order) + + fun editRoleRequest( + id: String, + label: String? = null, + description: String? = null, + color: Int? = null, + order: Int? = null, + ) = Nip86Request.editRole(id, label, description, color, order) + + fun deleteRoleRequest(id: String) = Nip86Request.deleteRole(id) + + fun assignRoleRequest( + pubkey: String, + roleId: String, + ) = Nip86Request.assignRole(pubkey, roleId) + + fun unassignRoleRequest( + pubkey: String, + roleId: String, + ) = Nip86Request.unassignRole(pubkey, roleId) + + fun listClaimsRequest() = Nip86Request.listClaims() + + fun createClaimRequest(claim: String) = Nip86Request.createClaim(claim) + + fun deleteClaimRequest(claim: String) = Nip86Request.deleteClaim(claim) + fun changeRelayNameRequest(newName: String) = Nip86Request.changeRelayName(newName) fun changeRelayDescriptionRequest(newDescription: String) = Nip86Request.changeRelayDescription(newDescription) @@ -153,6 +215,8 @@ class Nip86Client( fun listAllowedKindsRequest() = Nip86Request.listAllowedKinds() + fun listDisallowedKindsRequest() = Nip86Request.listDisallowedKinds() + fun blockIpRequest( ip: String, reason: String? = null, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Method.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Method.kt index a52f48be72..41cb4ac81a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Method.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Method.kt @@ -28,16 +28,36 @@ object Nip86Method { const val ALLOW_PUBKEY = "allowpubkey" const val UNALLOW_PUBKEY = "unallowpubkey" const val LIST_ALLOWED_PUBKEYS = "listallowedpubkeys" + const val CREATE_ROLE = "createrole" + const val EDIT_ROLE = "editrole" + const val DELETE_ROLE = "deleterole" + const val ASSIGN_ROLE = "assignrole" + const val UNASSIGN_ROLE = "unassignrole" + const val LIST_CLAIMS = "listclaims" + const val CREATE_CLAIM = "createclaim" + const val DELETE_CLAIM = "deleteclaim" const val LIST_EVENTS_NEEDING_MODERATION = "listeventsneedingmoderation" + + /** Adds an event to the relay's allow list (and removes it from the ban list). */ const val ALLOW_EVENT = "allowevent" + + /** Removes an event from the allow list without banning it. */ + const val UNALLOW_EVENT = "unallowevent" + + /** Bans an event (and removes it from the allow list). */ const val BAN_EVENT = "banevent" + + /** Removes an event from the ban list without allow-listing it. */ + const val UNBAN_EVENT = "unbanevent" const val LIST_BANNED_EVENTS = "listbannedevents" + const val LIST_ALLOWED_EVENTS = "listallowedevents" const val CHANGE_RELAY_NAME = "changerelayname" const val CHANGE_RELAY_DESCRIPTION = "changerelaydescription" const val CHANGE_RELAY_ICON = "changerelayicon" const val ALLOW_KIND = "allowkind" const val DISALLOW_KIND = "disallowkind" const val LIST_ALLOWED_KINDS = "listallowedkinds" + const val LIST_DISALLOWED_KINDS = "listdisallowedkinds" const val BLOCK_IP = "blockip" const val UNBLOCK_IP = "unblockip" const val LIST_BLOCKED_IPS = "listblockedips" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Request.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Request.kt index 1260c0ad5e..c181927780 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Request.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Request.kt @@ -20,14 +20,20 @@ */ package com.vitorpamplona.quartz.nip86RelayManagement.rpc +import kotlinx.serialization.EncodeDefault +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonArray @Serializable class Nip86Request( val method: String, + // NIP-86 requests always carry `params`, even when empty (`[]`). + @OptIn(ExperimentalSerializationApi::class) + @EncodeDefault val params: JsonArray = JsonArray(emptyList()), ) { companion object { @@ -78,6 +84,74 @@ class Nip86Request( method = Nip86Method.LIST_ALLOWED_PUBKEYS, ) + /** + * NIP-86 `createrole`: `[id, label, description, color, order]`. Every + * position is always sent so the relay can read them positionally; a + * missing optional value goes out as JSON `null`. [color] is a hue + * (0..360) and [order] a display-only sort key, both sent as numbers. + */ + fun createRole( + id: String, + label: String? = null, + description: String? = null, + color: Int? = null, + order: Int? = null, + ) = Nip86Request( + method = Nip86Method.CREATE_ROLE, + params = roleParams(id, label, description, color, order), + ) + + /** NIP-86 `editrole`: same `[id, label, description, color, order]` shape as [createRole]. */ + fun editRole( + id: String, + label: String? = null, + description: String? = null, + color: Int? = null, + order: Int? = null, + ) = Nip86Request( + method = Nip86Method.EDIT_ROLE, + params = roleParams(id, label, description, color, order), + ) + + fun deleteRole(id: String) = + Nip86Request( + method = Nip86Method.DELETE_ROLE, + params = buildJsonArray { add(JsonPrimitive(id)) }, + ) + + fun assignRole( + pubkey: String, + roleId: String, + ) = Nip86Request( + method = Nip86Method.ASSIGN_ROLE, + params = buildParams(pubkey, roleId), + ) + + fun unassignRole( + pubkey: String, + roleId: String, + ) = Nip86Request( + method = Nip86Method.UNASSIGN_ROLE, + params = buildParams(pubkey, roleId), + ) + + fun listClaims() = + Nip86Request( + method = Nip86Method.LIST_CLAIMS, + ) + + fun createClaim(claim: String) = + Nip86Request( + method = Nip86Method.CREATE_CLAIM, + params = buildJsonArray { add(JsonPrimitive(claim)) }, + ) + + fun deleteClaim(claim: String) = + Nip86Request( + method = Nip86Method.DELETE_CLAIM, + params = buildJsonArray { add(JsonPrimitive(claim)) }, + ) + fun listEventsNeedingModeration() = Nip86Request( method = Nip86Method.LIST_EVENTS_NEEDING_MODERATION, @@ -91,6 +165,14 @@ class Nip86Request( params = buildParams(eventId, reason), ) + fun unallowEvent( + eventId: String, + reason: String? = null, + ) = Nip86Request( + method = Nip86Method.UNALLOW_EVENT, + params = buildParams(eventId, reason), + ) + fun banEvent( eventId: String, reason: String? = null, @@ -99,11 +181,24 @@ class Nip86Request( params = buildParams(eventId, reason), ) + fun unbanEvent( + eventId: String, + reason: String? = null, + ) = Nip86Request( + method = Nip86Method.UNBAN_EVENT, + params = buildParams(eventId, reason), + ) + fun listBannedEvents() = Nip86Request( method = Nip86Method.LIST_BANNED_EVENTS, ) + fun listAllowedEvents() = + Nip86Request( + method = Nip86Method.LIST_ALLOWED_EVENTS, + ) + fun changeRelayName(newName: String) = Nip86Request( method = Nip86Method.CHANGE_RELAY_NAME, @@ -139,6 +234,11 @@ class Nip86Request( method = Nip86Method.LIST_ALLOWED_KINDS, ) + fun listDisallowedKinds() = + Nip86Request( + method = Nip86Method.LIST_DISALLOWED_KINDS, + ) + fun blockIp( ip: String, reason: String? = null, @@ -158,6 +258,21 @@ class Nip86Request( method = Nip86Method.LIST_BLOCKED_IPS, ) + private fun roleParams( + id: String, + label: String?, + description: String?, + color: Int?, + order: Int?, + ): JsonArray = + buildJsonArray { + add(JsonPrimitive(id)) + add(label?.let { JsonPrimitive(it) } ?: JsonNull) + add(description?.let { JsonPrimitive(it) } ?: JsonNull) + add(color?.let { JsonPrimitive(it) } ?: JsonNull) + add(order?.let { JsonPrimitive(it) } ?: JsonNull) + } + private fun buildParams( primary: String, reason: String? = null, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Response.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Response.kt index 8f34cb275e..09642d7a43 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Response.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Response.kt @@ -47,6 +47,12 @@ class BannedEvent( val reason: String? = null, ) +@Serializable +class AllowedEvent( + val id: String, + val reason: String? = null, +) + @Serializable class EventNeedingModeration( val id: String, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanListPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanListPolicy.kt index dd693e80e1..aed433d44a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanListPolicy.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanListPolicy.kt @@ -30,6 +30,15 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult * non-empty pubkey allow list, or kind disallowed / not in the kind * allow list. * + * An event id on the NIP-86 event allow list (`allowevent`) is an + * explicit, per-event operator approval, so it is accepted without + * consulting the pubkey and kind rules — the most specific decision + * wins, the same way `banevent` rejects an event from an otherwise + * allowed author. The event allow list never restricts anything: with + * it empty (the default) this policy behaves exactly as before. It only + * short-circuits this policy; other policies stacked next to it still + * apply. + * * Functionally equivalent to (and a superset of) the static * [com.vitorpamplona.quartz.nip01Core.relay.server.policies.KindAllowDenyPolicy] + * [com.vitorpamplona.quartz.nip01Core.relay.server.policies.PubkeyAllowDenyPolicy]. @@ -47,6 +56,9 @@ class BanListPolicy( if (banStore.isBannedEvent(ev.id)) { return PolicyResult.Rejected("blocked: event id is banned") } + if (banStore.isAllowedEvent(ev.id)) { + return PolicyResult.Accepted(cmd) + } if (banStore.isBanned(ev.pubKey)) { return PolicyResult.Rejected("blocked: pubkey is banned") } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStore.kt index ba38eff154..b2ef70e00f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStore.kt @@ -21,17 +21,26 @@ package com.vitorpamplona.quartz.nip86RelayManagement.server import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole import kotlin.concurrent.atomics.AtomicReference import kotlin.concurrent.atomics.ExperimentalAtomicApi /** * Lock-free runtime state for the NIP-86 management API. Holds the * ban/allow lists that [BanListPolicy] consults on every accept call, - * plus an [onMutation] hook so the relay can persist the latest - * snapshot whenever an admin RPC mutates state. + * the NIP-43 role definitions / assignments and invite codes (claims) + * that the role and claim RPCs manage, plus an [onMutation] hook so the + * relay can persist the latest snapshot whenever an admin RPC mutates + * state. * - * Each entry carries an optional reason string so list-* RPCs can echo - * back why an admin took the action — useful for audit trails. + * Each list entry carries an optional reason string so list-* RPCs can + * echo back why an admin took the action — useful for audit trails. + * + * Ban / allow lists follow NIP-86: `ban*` removes the entry from the + * matching allow list and `allow*` removes it from the ban list, while + * `unban*` / `unallow*` only clear their own list — they never add the + * entry to the opposite one. So a pubkey or event id is never on both + * lists at once. * * Persistence is intentionally NOT inside this class; supply * [onMutation] to flush to disk (or wherever) and use [seedFromSnapshot] @@ -47,18 +56,21 @@ class BanStore( private val onMutation: (() -> Unit)? = null, ) { /** - * Single immutable snapshot of all ban/allow state. Combined into - * one object so kind allow/disallow lock-step (allow adds to - * allowedKinds AND removes from disallowedKinds) is naturally - * atomic — no possibility of an interleaved reader observing a - * kind in both sets. + * Single immutable snapshot of all state. Combined into one object so + * the lock-step moves (ban removes from allow, allow removes from + * ban, deleting a role unassigns it) are naturally atomic — no + * interleaved reader can observe an entry on both lists. */ private data class State( val bannedPubkeys: Map = emptyMap(), val allowedPubkeys: Map = emptyMap(), val bannedEventIds: Map = emptyMap(), + val allowedEventIds: Map = emptyMap(), val allowedKinds: Set = emptySet(), val disallowedKinds: Set = emptySet(), + val roles: Map = emptyMap(), + val memberRoles: Map> = emptyMap(), + val claims: Set = emptySet(), ) private val state = AtomicReference(State()) @@ -71,13 +83,33 @@ class BanStore( onMutation?.invoke() } + /** + * Like [mutate], but [transform] may return `null` to leave the state + * untouched (e.g. editing a role that doesn't exist). Returns whether + * the state changed; [onMutation] only fires when it did. + */ + private inline fun mutateIf(transform: (State) -> State?): Boolean { + while (true) { + val current = state.load() + val next = transform(current) ?: return false + if (state.compareAndSet(current, next)) break + } + onMutation?.invoke() + return true + } + // -- Pubkey ban list ----------------------------------------------------- + /** Bans [pubkey] and, per NIP-86, drops it from the allow list. */ fun banPubkey( pubkey: HexKey, reason: String? = null, - ) = mutate { it.copy(bannedPubkeys = it.bannedPubkeys + (pubkey.lowercase() to reason)) } + ) = mutate { + val pk = pubkey.lowercase() + it.copy(bannedPubkeys = it.bannedPubkeys + (pk to reason), allowedPubkeys = it.allowedPubkeys - pk) + } + /** Lifts a ban. Does not add [pubkey] to the allow list. */ fun unbanPubkey(pubkey: HexKey) = mutate { it.copy(bannedPubkeys = it.bannedPubkeys - pubkey.lowercase()) } fun isBanned(pubkey: HexKey): Boolean = pubkey.lowercase() in state.load().bannedPubkeys @@ -90,11 +122,16 @@ class BanStore( // -- Pubkey allow list --------------------------------------------------- + /** Allow-lists [pubkey] and, per NIP-86, drops it from the ban list. */ fun allowPubkey( pubkey: HexKey, reason: String? = null, - ) = mutate { it.copy(allowedPubkeys = it.allowedPubkeys + (pubkey.lowercase() to reason)) } + ) = mutate { + val pk = pubkey.lowercase() + it.copy(allowedPubkeys = it.allowedPubkeys + (pk to reason), bannedPubkeys = it.bannedPubkeys - pk) + } + /** Removes [pubkey] from the allow list. Does not ban it. */ fun unallowPubkey(pubkey: HexKey) = mutate { it.copy(allowedPubkeys = it.allowedPubkeys - pubkey.lowercase()) } fun isAllowedPubkey(pubkey: HexKey): Boolean = pubkey.lowercase() in state.load().allowedPubkeys @@ -107,24 +144,52 @@ class BanStore( fun hasAllowList(): Boolean = state.load().allowedPubkeys.isNotEmpty() - // -- Event id ban list --------------------------------------------------- + // -- Event id ban / allow lists ----------------------------------------- + /** Bans [eventId] and, per NIP-86, drops it from the event allow list. */ fun banEvent( eventId: HexKey, reason: String? = null, - ) = mutate { it.copy(bannedEventIds = it.bannedEventIds + (eventId.lowercase() to reason)) } + ) = mutate { + val id = eventId.lowercase() + it.copy(bannedEventIds = it.bannedEventIds + (id to reason), allowedEventIds = it.allowedEventIds - id) + } - /** Removes an event id from the ban list. Mirrors NIP-86 `allowevent`. */ - fun allowEvent(eventId: HexKey) = mutate { it.copy(bannedEventIds = it.bannedEventIds - eventId.lowercase()) } + /** NIP-86 `unbanevent`: removes [eventId] from the ban list without allow-listing it. */ + fun unbanEvent(eventId: HexKey) = mutate { it.copy(bannedEventIds = it.bannedEventIds - eventId.lowercase()) } + + /** + * NIP-86 `allowevent`: adds [eventId] to the event allow list and drops + * it from the ban list. See [BanListPolicy] for what an allow-listed + * event is exempt from. + */ + fun allowEvent( + eventId: HexKey, + reason: String? = null, + ) = mutate { + val id = eventId.lowercase() + it.copy(allowedEventIds = it.allowedEventIds + (id to reason), bannedEventIds = it.bannedEventIds - id) + } + + /** NIP-86 `unallowevent`: removes [eventId] from the allow list without banning it. */ + fun unallowEvent(eventId: HexKey) = mutate { it.copy(allowedEventIds = it.allowedEventIds - eventId.lowercase()) } fun isBannedEvent(eventId: HexKey): Boolean = eventId.lowercase() in state.load().bannedEventIds + fun isAllowedEvent(eventId: HexKey): Boolean = eventId.lowercase() in state.load().allowedEventIds + fun listBannedEvents(): List> = state .load() .bannedEventIds.entries .map { it.key to it.value } + fun listAllowedEvents(): List> = + state + .load() + .allowedEventIds.entries + .map { it.key to it.value } + // -- Kind allow / deny -------------------------------------------------- /** @@ -160,12 +225,94 @@ class BanStore( return kind in s.allowedKinds } + // -- NIP-43 roles ------------------------------------------------------- + + /** NIP-86 `createrole`. Returns false (and changes nothing) if a role with that id exists. */ + fun createRole(role: RelayRole): Boolean = + mutateIf { + if (role.id in it.roles) null else it.copy(roles = it.roles + (role.id to role)) + } + + /** NIP-86 `editrole`. Returns false (and changes nothing) if no role has that id. */ + fun editRole(role: RelayRole): Boolean = + mutateIf { + if (role.id !in it.roles) null else it.copy(roles = it.roles + (role.id to role)) + } + + /** NIP-86 `deleterole`. Also unassigns the role from every member. Idempotent. */ + fun deleteRole(roleId: String) = + mutate { s -> + s.copy( + roles = s.roles - roleId, + memberRoles = + s.memberRoles + .mapValues { (_, roles) -> roles - roleId } + .filterValues { it.isNotEmpty() }, + ) + } + + fun getRole(roleId: String): RelayRole? = state.load().roles[roleId] + + /** Roles sorted by their display [RelayRole.order] (unordered last), then id. */ + fun listRoles(): List = + state + .load() + .roles.values + .sortedWith(compareBy({ it.order ?: Int.MAX_VALUE }, { it.id })) + + /** NIP-86 `assignrole`. Returns false (and changes nothing) if the role doesn't exist. */ + fun assignRole( + pubkey: HexKey, + roleId: String, + ): Boolean = + mutateIf { s -> + if (roleId !in s.roles) return@mutateIf null + val pk = pubkey.lowercase() + val current = s.memberRoles[pk].orEmpty() + if (roleId in current) s else s.copy(memberRoles = s.memberRoles + (pk to current + roleId)) + } + + /** NIP-86 `unassignrole`. Idempotent. */ + fun unassignRole( + pubkey: HexKey, + roleId: String, + ) = mutate { s -> + val pk = pubkey.lowercase() + val remaining = s.memberRoles[pk].orEmpty() - roleId + s.copy(memberRoles = if (remaining.isEmpty()) s.memberRoles - pk else s.memberRoles + (pk to remaining)) + } + + fun rolesOf(pubkey: HexKey): List = state.load().memberRoles[pubkey.lowercase()].orEmpty() + + /** Every pubkey with at least one role, with its role ids in assignment order. */ + fun listRoleAssignments(): List>> = + state + .load() + .memberRoles.entries + .map { it.key to it.value } + + // -- NIP-43 invite codes (claims) --------------------------------------- + + /** NIP-86 `createclaim`. Idempotent. */ + fun createClaim(claim: String) = mutate { it.copy(claims = it.claims + claim) } + + /** NIP-86 `deleteclaim`. Idempotent. */ + fun deleteClaim(claim: String) = mutate { it.copy(claims = it.claims - claim) } + + /** True when [claim] is an invite code the relay currently accepts. */ + fun isValidClaim(claim: String): Boolean = claim in state.load().claims + + fun listClaims(): List = state.load().claims.toList() + /** * Bulk-load state without firing [onMutation]. Used at startup to * seed the in-memory state from a persisted snapshot — we don't * want every individual `put` to trigger another disk write. After * this call the store behaves exactly as if every entry had been * mutated through the public API. + * + * A snapshot that lists an id on both a ban and an allow list (only + * possible from a hand-edited file) keeps the ban and drops the allow. */ fun seedFromSnapshot( bannedPubkeys: List> = emptyList(), @@ -173,14 +320,28 @@ class BanStore( bannedEvents: List> = emptyList(), allowedKinds: List = emptyList(), disallowedKinds: List = emptyList(), + allowedEvents: List> = emptyList(), + roles: List = emptyList(), + roleAssignments: List>> = emptyList(), + claims: List = emptyList(), ) { + val banned = bannedPubkeys.associate { (k, r) -> k.lowercase() to r } + val bannedEv = bannedEvents.associate { (k, r) -> k.lowercase() to r } + val roleMap = roles.associateBy { it.id } state.store( State( - bannedPubkeys = bannedPubkeys.associate { (k, r) -> k.lowercase() to r }, - allowedPubkeys = allowedPubkeys.associate { (k, r) -> k.lowercase() to r }, - bannedEventIds = bannedEvents.associate { (k, r) -> k.lowercase() to r }, + bannedPubkeys = banned, + allowedPubkeys = allowedPubkeys.associate { (k, r) -> k.lowercase() to r } - banned.keys, + bannedEventIds = bannedEv, + allowedEventIds = allowedEvents.associate { (k, r) -> k.lowercase() to r } - bannedEv.keys, allowedKinds = allowedKinds.toSet(), disallowedKinds = disallowedKinds.toSet(), + roles = roleMap, + memberRoles = + roleAssignments + .associate { (pk, ids) -> pk.lowercase() to ids.filter { it in roleMap }.distinct() } + .filterValues { it.isNotEmpty() }, + claims = claims.toSet(), ), ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86Server.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86Server.kt index 9e8ce452ee..1a838ea773 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86Server.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86Server.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.nip86RelayManagement.server import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation +import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedEvent import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedEvent import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedPubkey @@ -60,7 +62,16 @@ import kotlinx.serialization.json.int * * [supportedMethods] is the canonical list this server actually * implements; methods returned outside of it are no-ops and a NIP-86 - * client must not advertise them. + * client must not advertise them. Every admin on the [allowList] holds + * every permission, so the list is the same for every authorized caller + * (NIP-86 lets it be tailored per caller; there is nothing to tailor). + * + * The NIP-43 role (`createrole`, `editrole`, `deleterole`, `assignrole`, + * `unassignrole`) and invite-code (`listclaims`, `createclaim`, + * `deleteclaim`) methods only maintain the [BanStore]'s records. A relay + * that publishes kind 13534 / 33534 events or admits kind 28934 join + * requests reads them from there ([BanStore.listRoles], + * [BanStore.rolesOf], [BanStore.isValidClaim]). */ class Nip86Server( val banStore: BanStore, @@ -118,12 +129,24 @@ class Nip86Server( Nip86Method.ALLOW_PUBKEY, Nip86Method.UNALLOW_PUBKEY, Nip86Method.LIST_ALLOWED_PUBKEYS, + Nip86Method.CREATE_ROLE, + Nip86Method.EDIT_ROLE, + Nip86Method.DELETE_ROLE, + Nip86Method.ASSIGN_ROLE, + Nip86Method.UNASSIGN_ROLE, + Nip86Method.LIST_CLAIMS, + Nip86Method.CREATE_CLAIM, + Nip86Method.DELETE_CLAIM, Nip86Method.BAN_EVENT, + Nip86Method.UNBAN_EVENT, Nip86Method.ALLOW_EVENT, + Nip86Method.UNALLOW_EVENT, Nip86Method.LIST_BANNED_EVENTS, + Nip86Method.LIST_ALLOWED_EVENTS, Nip86Method.ALLOW_KIND, Nip86Method.DISALLOW_KIND, Nip86Method.LIST_ALLOWED_KINDS, + Nip86Method.LIST_DISALLOWED_KINDS, Nip86Method.CHANGE_RELAY_NAME, Nip86Method.CHANGE_RELAY_DESCRIPTION, Nip86Method.CHANGE_RELAY_ICON, @@ -190,14 +213,67 @@ class Nip86Server( } } + Nip86Method.UNBAN_EVENT -> { + withHex(req, "event_id") { id -> banStore.unbanEvent(id) } + } + Nip86Method.ALLOW_EVENT -> { - withHex(req, "event_id") { id -> banStore.allowEvent(id) } + withHexAndReason(req, "event_id") { id, reason -> banStore.allowEvent(id, reason) } + } + + Nip86Method.UNALLOW_EVENT -> { + withHex(req, "event_id") { id -> banStore.unallowEvent(id) } } Nip86Method.LIST_BANNED_EVENTS -> { ok(banStore.listBannedEvents().map { (id, r) -> BannedEvent(id, r) }.toJsonArray(BannedEvent.serializer())) } + Nip86Method.LIST_ALLOWED_EVENTS -> { + ok(banStore.listAllowedEvents().map { (id, r) -> AllowedEvent(id, r) }.toJsonArray(AllowedEvent.serializer())) + } + + Nip86Method.CREATE_ROLE -> { + withRole(req) { role -> + if (banStore.createRole(role)) okTrue else Nip86Response(error = "role already exists: ${role.id}") + } + } + + Nip86Method.EDIT_ROLE -> { + withRole(req) { role -> + if (banStore.editRole(role)) okTrue else Nip86Response(error = "unknown role: ${role.id}") + } + } + + Nip86Method.DELETE_ROLE -> { + withNonBlankString(req, "id") { id -> banStore.deleteRole(id) } + } + + Nip86Method.ASSIGN_ROLE -> { + withPubkeyAndRole(req) { pk, roleId -> + if (banStore.assignRole(pk, roleId)) okTrue else Nip86Response(error = "unknown role: $roleId") + } + } + + Nip86Method.UNASSIGN_ROLE -> { + withPubkeyAndRole(req) { pk, roleId -> + banStore.unassignRole(pk, roleId) + okTrue + } + } + + Nip86Method.LIST_CLAIMS -> { + ok(buildJsonArray { banStore.listClaims().forEach { add(JsonPrimitive(it)) } }) + } + + Nip86Method.CREATE_CLAIM -> { + withNonBlankString(req, "claim") { claim -> banStore.createClaim(claim) } + } + + Nip86Method.DELETE_CLAIM -> { + withNonBlankString(req, "claim") { claim -> banStore.deleteClaim(claim) } + } + Nip86Method.ALLOW_KIND -> { withInt(req, "kind") { k -> banStore.allowKind(k) } } @@ -214,6 +290,10 @@ class Nip86Server( ok(buildJsonArray { banStore.listAllowedKinds().forEach { add(JsonPrimitive(it)) } }) } + Nip86Method.LIST_DISALLOWED_KINDS -> { + ok(buildJsonArray { banStore.listDisallowedKinds().forEach { add(JsonPrimitive(it)) } }) + } + Nip86Method.CHANGE_RELAY_NAME -> { withString(req, "name") { name -> rewriteInfo { it.copy(name = name) } } } @@ -281,6 +361,45 @@ class Nip86Server( return okTrue } + private inline fun withNonBlankString( + req: Nip86Request, + label: String, + action: (String) -> Unit, + ): Nip86Response { + val v = req.params.firstString()?.takeIf { it.isNotBlank() } ?: return malformed("expected [$label]") + action(v) + return okTrue + } + + /** + * Parses NIP-86 `[id, label, description, color, order]`. Only `id` is + * required; trailing params may be omitted or `null`. `color` (a hue, + * 0..360) and `order` accept a JSON number or a numeric string, since + * the kind 33534 tags carry them as strings. + */ + private inline fun withRole( + req: Nip86Request, + action: (RelayRole) -> Nip86Response, + ): Nip86Response { + val id = req.params.firstString()?.takeIf { it.isNotBlank() } ?: return malformed("expected [id, label?, description?, color?, order?]") + val label = req.params.optString(1) ?: return malformed("label must be a string") + val description = req.params.optString(2) ?: return malformed("description must be a string") + val color = req.params.optInt(3) ?: return malformed("color must be an integer hue") + if (color.value != null && !RelayRole.isValidHue(color.value)) return malformed("color must be a hue between 0 and 360") + val order = req.params.optInt(4) ?: return malformed("order must be an integer") + return action(RelayRole(id, label.value?.ifEmpty { null }, description.value?.ifEmpty { null }, color.value, order.value)) + } + + private inline fun withPubkeyAndRole( + req: Nip86Request, + action: (HexKey, String) -> Nip86Response, + ): Nip86Response { + val (pk, roleId) = req.params.stringPair() ?: return malformed("expected [pubkey, role_id]") + if (!Hex.isHex64(pk)) return malformed("pubkey must be 64-char hex") + if (roleId.isNullOrBlank()) return malformed("expected [pubkey, role_id]") + return action(pk.lowercase(), roleId) + } + private fun rewriteInfo(transform: (Nip11RelayInformation) -> Nip11RelayInformation) { infoHolder.set(transform(infoHolder.get())) } @@ -304,6 +423,30 @@ private fun JsonArray.stringPair(): Pair? { private fun JsonArray.firstString(): String? = (getOrNull(0) as? JsonPrimitive)?.contentOrNull() +/** An optional positional param: [value] is null when the param is missing or JSON `null`. */ +private class OptionalParam( + val value: T?, +) + +/** Missing / `null` -> empty; a JSON string -> its content; anything else -> null (malformed). */ +private fun JsonArray.optString(index: Int): OptionalParam? { + val el = getOrNull(index) ?: return OptionalParam(null) + if (el == JsonNull) return OptionalParam(null) + val prim = el as? JsonPrimitive ?: return null + if (!prim.isString) return null + return OptionalParam(prim.content) +} + +/** Missing / `null` / "" -> empty; an integer number or numeric string -> its value; anything else -> null (malformed). */ +private fun JsonArray.optInt(index: Int): OptionalParam? { + val el = getOrNull(index) ?: return OptionalParam(null) + if (el == JsonNull) return OptionalParam(null) + val prim = el as? JsonPrimitive ?: return null + val text = prim.content.trim() + if (prim.isString && text.isEmpty()) return OptionalParam(null) + return text.toIntOrNull()?.let { OptionalParam(it) } +} + private fun JsonArray.firstInt(): Int? = runCatching { (this[0] as? JsonPrimitive)?.int diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 15d8d83388..2534fe4d28 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -250,6 +250,7 @@ import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEv import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent +import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRoleEvent import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent @@ -857,6 +858,7 @@ class EventFactory { RelayAddMemberEvent.KIND -> RelayAddMemberEvent(id, pubKey, createdAt, tags, content, sig) RelayRemoveMemberEvent.KIND -> RelayRemoveMemberEvent(id, pubKey, createdAt, tags, content, sig) RelayMembershipListEvent.KIND -> RelayMembershipListEvent(id, pubKey, createdAt, tags, content, sig) + RelayRoleEvent.KIND -> RelayRoleEvent(id, pubKey, createdAt, tags, content, sig) RelayJoinRequestEvent.KIND -> RelayJoinRequestEvent(id, pubKey, createdAt, tags, content, sig) RelayInviteRequestEvent.KIND -> RelayInviteRequestEvent(id, pubKey, createdAt, tags, content, sig) RelayLeaveRequestEvent.KIND -> RelayLeaveRequestEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/RelayRolesTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/RelayRolesTest.kt new file mode 100644 index 0000000000..95f097be5c --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/RelayRolesTest.kt @@ -0,0 +1,128 @@ +/* + * 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.nip43RelayMembers + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip43RelayMembers.inviteRequest.RelayInviteRequestEvent +import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEvent +import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent +import com.vitorpamplona.quartz.nip43RelayMembers.list.tags.MemberTag +import com.vitorpamplona.quartz.nip43RelayMembers.list.tags.RelayMember +import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole +import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRoleEvent +import com.vitorpamplona.quartz.nip70ProtectedEvts.isProtected +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class RelayRolesTest { + private val signer = NostrSignerSync(KeyPair()) + private val alice = "c308e1f882c1f1dff2a43d4294239ddeec04e575f2d1aad1fa21ea7684e61fb5" + private val bob = "ee1d336e13779e4d4c527b988429d96de16088f958cbf6c074676ac9cfd9c958" + + @Test + fun roleEventBuildsTheSpecExampleAndParsesBack() { + val template = RelayRoleEvent.build(RelayRole("28b7e50f", "king", "ruler of the relay", 37, 1), createdAt = 1000) + assertEquals(RelayRoleEvent.KIND, template.kind) + assertContentEquals(arrayOf("-"), template.tags[0]) + assertContentEquals(arrayOf("d", "28b7e50f"), template.tags[1]) + assertContentEquals(arrayOf("label", "king"), template.tags[2]) + assertContentEquals(arrayOf("description", "ruler of the relay"), template.tags[3]) + assertContentEquals(arrayOf("color", "37"), template.tags[4]) + assertContentEquals(arrayOf("order", "1"), template.tags[5]) + + val signed = assertIs(signer.sign(template)) + assertTrue(signed.isProtected()) + assertEquals("28b7e50f", signed.roleId()) + assertEquals(RelayRole("28b7e50f", "king", "ruler of the relay", 37, 1), signed.role()) + } + + @Test + fun roleEventOptionalTagsAndBadValues() { + val minimal = assertIs(signer.sign(RelayRoleEvent.build(RelayRole("r1")))) + assertEquals(RelayRole("r1"), minimal.role()) + + // A foreign event with an out-of-range hue and a non-numeric order. + val odd = + assertIs( + signer.sign( + 1000, + RelayRoleEvent.KIND, + arrayOf(arrayOf("-"), arrayOf("d", "r2"), arrayOf("color", "400"), arrayOf("order", "first")), + "", + ), + ) + assertNull(odd.color()) + assertNull(odd.order()) + + assertFailsWith { RelayRoleEvent.build(RelayRole("r3", color = 361)) } + } + + @Test + fun memberTagCarriesOptionalRoles() { + val withRoles = arrayOf("member", bob, "28b7e50f", "", "abc", "28b7e50f") + assertEquals(bob, MemberTag.parse(withRoles)) + assertEquals(RelayMember(bob, listOf("28b7e50f", "abc")), MemberTag.parseMember(withRoles)) + assertEquals(RelayMember(alice), MemberTag.parseMember(arrayOf("member", alice))) + assertNull(MemberTag.parseMember(arrayOf("member", "short"))) + + assertContentEquals(arrayOf("member", bob, "r1", "r2"), MemberTag.assemble(bob, listOf("r1", "r2"))) + assertContentEquals(arrayOf("member", alice), MemberTag.assemble(RelayMember(alice))) + } + + @Test + fun membershipListWithRolesKeepsPlainMembersBackwardCompatible() { + val template = + RelayMembershipListEvent.buildWithRoles( + listOf(RelayMember(alice), RelayMember(bob, listOf("28b7e50f"))), + ) + val signed = assertIs(signer.sign(template)) + assertTrue(signed.isProtected()) + assertEquals(listOf(alice, bob), signed.members()) + assertEquals(listOf(RelayMember(alice), RelayMember(bob, listOf("28b7e50f"))), signed.membersWithRoles()) + + val legacy = assertIs(signer.sign(RelayMembershipListEvent.build(listOf(alice)))) + assertEquals(listOf(RelayMember(alice)), legacy.membersWithRoles()) + } + + @Test + fun joinRequestCarriesClaimAndProtectedTag() { + val signed = assertIs(signer.sign(RelayJoinRequestEvent.build("invite-code"))) + assertTrue(signed.isProtected()) + assertEquals("invite-code", signed.claim()) + + assertFailsWith { RelayJoinRequestEvent.build(" ") } + } + + @Suppress("DEPRECATION") + @Test + fun deprecatedInviteRequestStillParses() { + val signed = + signer.sign(1000, RelayInviteRequestEvent.KIND, arrayOf(arrayOf("-"), arrayOf("claim", "abc")), "") + assertIs(signed) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/Nip86RequestResponseTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/Nip86RequestResponseTest.kt new file mode 100644 index 0000000000..32b5168a99 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/Nip86RequestResponseTest.kt @@ -0,0 +1,78 @@ +/* + * 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.nip86RelayManagement + +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request +import kotlin.test.Test +import kotlin.test.assertEquals + +class Nip86RequestResponseTest { + private val client = Nip86Client(RelayUrlNormalizer.normalize("wss://relay.example.com"), NostrSignerInternal(KeyPair())) + private val id = "c".repeat(64) + private val pk = "a".repeat(64) + + private fun json(req: Nip86Request) = client.serializeRequest(req) + + @Test + fun eventAllowAndBanMethods() { + assertEquals("""{"method":"allowevent","params":["$id","ok"]}""", json(Nip86Request.allowEvent(id, "ok"))) + assertEquals("""{"method":"unallowevent","params":["$id"]}""", json(Nip86Request.unallowEvent(id))) + assertEquals("""{"method":"unbanevent","params":["$id","oops"]}""", json(Nip86Request.unbanEvent(id, "oops"))) + assertEquals("""{"method":"listallowedevents","params":[]}""", json(Nip86Request.listAllowedEvents())) + assertEquals("""{"method":"listdisallowedkinds","params":[]}""", json(Nip86Request.listDisallowedKinds())) + } + + @Test + fun roleMethods() { + assertEquals( + """{"method":"createrole","params":["28b7e50f","king","ruler of the relay",37,1]}""", + json(Nip86Request.createRole("28b7e50f", "king", "ruler of the relay", 37, 1)), + ) + assertEquals("""{"method":"editrole","params":["r",null,null,null,null]}""", json(Nip86Request.editRole("r"))) + assertEquals("""{"method":"deleterole","params":["r"]}""", json(Nip86Request.deleteRole("r"))) + assertEquals("""{"method":"assignrole","params":["$pk","r"]}""", json(Nip86Request.assignRole(pk, "r"))) + assertEquals("""{"method":"unassignrole","params":["$pk","r"]}""", json(Nip86Request.unassignRole(pk, "r"))) + } + + @Test + fun claimMethods() { + assertEquals("""{"method":"listclaims","params":[]}""", json(Nip86Request.listClaims())) + assertEquals("""{"method":"createclaim","params":["abc"]}""", json(Nip86Request.createClaim("abc"))) + assertEquals("""{"method":"deleteclaim","params":["abc"]}""", json(Nip86Request.deleteClaim("abc"))) + assertEquals(Nip86Method.CREATE_CLAIM, JsonMapper.fromJson(json(Nip86Request.createClaim("abc"))).method) + } + + @Test + fun parsesNewResponses() { + val allowed = client.parseAllowedEvents(client.parseResponse("""{"result":[{"id":"$id","reason":"ok"},{"id":"$id"}]}"""))!! + assertEquals(listOf(id, id), allowed.map { it.id }) + assertEquals(listOf("ok", null), allowed.map { it.reason }) + + assertEquals(listOf(4, 1059), client.parseDisallowedKinds(client.parseResponse("""{"result":[4,1059]}"""))) + assertEquals(listOf("a", "b"), client.parseClaims(client.parseResponse("""{"result":["a","b"]}"""))) + assertEquals(true, client.parseBooleanResult(client.parseResponse("""{"result":true}"""))) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStoreTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStoreTest.kt index 66bfdb0f29..3a1058f56f 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStoreTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStoreTest.kt @@ -20,6 +20,10 @@ */ package com.vitorpamplona.quartz.nip86RelayManagement.server +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult +import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -60,8 +64,151 @@ class BanStoreTest { val s = BanStore() s.banEvent("ee".padEnd(64, '0'), "policy") assertTrue(s.isBannedEvent("EE".padEnd(64, '0'))) - s.allowEvent("ee".padEnd(64, '0')) + s.unbanEvent("ee".padEnd(64, '0')) assertFalse(s.isBannedEvent("ee".padEnd(64, '0'))) + assertFalse(s.isAllowedEvent("ee".padEnd(64, '0')), "unban must not allow-list") + } + + @Test + fun banAndAllowAreMutuallyExclusiveForPubkeys() { + val s = BanStore() + val pk = "aa".padEnd(64, '0') + s.allowPubkey(pk, "trusted") + s.banPubkey(pk, "spam") + assertTrue(s.isBanned(pk)) + assertFalse(s.isAllowedPubkey(pk), "banpubkey must remove from the allow list") + + s.allowPubkey(pk.uppercase(), "trusted again") + assertTrue(s.isAllowedPubkey(pk)) + assertFalse(s.isBanned(pk), "allowpubkey must remove from the ban list") + + s.unallowPubkey(pk) + assertFalse(s.isAllowedPubkey(pk)) + assertFalse(s.isBanned(pk), "unallowpubkey must not ban") + + s.banPubkey(pk) + s.unbanPubkey(pk) + assertFalse(s.isBanned(pk)) + assertFalse(s.isAllowedPubkey(pk), "unbanpubkey must not allow-list") + } + + @Test + fun banAndAllowAreMutuallyExclusiveForEvents() { + val s = BanStore() + val id = "ee".padEnd(64, '0') + s.allowEvent(id, "approved") + assertTrue(s.isAllowedEvent(id)) + assertEquals(listOf(id to "approved"), s.listAllowedEvents()) + + s.banEvent(id, "spam") + assertTrue(s.isBannedEvent(id)) + assertFalse(s.isAllowedEvent(id), "banevent must remove from the allow list") + + s.allowEvent(id) + assertTrue(s.isAllowedEvent(id)) + assertFalse(s.isBannedEvent(id), "allowevent must remove from the ban list") + + s.unallowEvent(id) + assertFalse(s.isAllowedEvent(id)) + assertFalse(s.isBannedEvent(id), "unallowevent must not ban") + } + + @Test + fun rolesAssignmentsAndDeletion() { + val s = BanStore() + val pk = "aa".padEnd(64, '0') + assertTrue(s.createRole(RelayRole("b", label = "B", order = 2))) + assertTrue(s.createRole(RelayRole("a", label = "A", order = 1))) + assertTrue(s.createRole(RelayRole("z"))) + assertFalse(s.createRole(RelayRole("a", label = "dup"))) + assertEquals(listOf("a", "b", "z"), s.listRoles().map { it.id }, "sorted by order, unordered last") + + assertFalse(s.editRole(RelayRole("missing"))) + assertTrue(s.editRole(RelayRole("a", label = "Alpha", color = 30))) + assertEquals(RelayRole("a", label = "Alpha", color = 30), s.getRole("a")) + + assertFalse(s.assignRole(pk, "missing")) + assertTrue(s.assignRole(pk.uppercase(), "a")) + assertTrue(s.assignRole(pk, "b")) + assertTrue(s.assignRole(pk, "a")) + assertEquals(listOf("a", "b"), s.rolesOf(pk)) + + s.deleteRole("a") + assertEquals(listOf("b"), s.rolesOf(pk)) + s.unassignRole(pk, "b") + assertEquals(emptyList(), s.rolesOf(pk)) + assertEquals(emptyList(), s.listRoleAssignments()) + } + + @Test + fun claims() { + val s = BanStore() + s.createClaim("code-1") + s.createClaim("code-1") + s.createClaim("code-2") + assertEquals(listOf("code-1", "code-2"), s.listClaims()) + assertTrue(s.isValidClaim("code-2")) + s.deleteClaim("code-2") + assertFalse(s.isValidClaim("code-2")) + } + + @Test + fun mutationsFireHookButFailedRoleEditsDoNot() { + var count = 0 + val s = BanStore(onMutation = { count++ }) + s.allowEvent("ee".padEnd(64, '0')) + assertEquals(1, count) + s.editRole(RelayRole("missing")) + s.assignRole("aa".padEnd(64, '0'), "missing") + assertEquals(1, count) + } + + @Test + fun seedFromSnapshotRestoresNewSectionsAndResolvesConflicts() { + val s = BanStore() + val pk = "aa".padEnd(64, '0') + val id = "ee".padEnd(64, '0') + s.seedFromSnapshot( + bannedPubkeys = listOf(pk to "spam"), + allowedPubkeys = listOf(pk to "hand-edited conflict"), + bannedEvents = listOf(id to "x"), + allowedEvents = listOf(id to "conflict", "ff".padEnd(64, '0') to "ok"), + roles = listOf(RelayRole("mod")), + roleAssignments = listOf(pk to listOf("mod", "ghost")), + claims = listOf("c"), + ) + assertTrue(s.isBanned(pk)) + assertFalse(s.isAllowedPubkey(pk)) + assertTrue(s.isBannedEvent(id)) + assertFalse(s.isAllowedEvent(id)) + assertTrue(s.isAllowedEvent("ff".padEnd(64, '0'))) + assertEquals(listOf("mod"), s.rolesOf(pk), "assignments to unknown roles are dropped") + assertTrue(s.isValidClaim("c")) + } + + @Test + fun policyLetsAllowListedEventsBypassPubkeyAndKindRules() { + val s = BanStore() + val policy = BanListPolicy(s) + val author = "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d" + val allowedId = "a".repeat(64) + val otherId = "b".repeat(64) + + fun event(id: String) = Event(id, author, 1000L, 1, emptyArray(), "hi", "0".repeat(128)) + + // Empty event allow list changes nothing. + assertTrue(policy.accept(EventCmd(event(otherId))) is PolicyResult.Accepted) + + s.banPubkey(author) + s.disallowKind(1) + assertTrue(policy.accept(EventCmd(event(otherId))) is PolicyResult.Rejected) + + s.allowEvent(allowedId) + assertTrue(policy.accept(EventCmd(event(allowedId))) is PolicyResult.Accepted) + assertTrue(policy.accept(EventCmd(event(otherId))) is PolicyResult.Rejected) + + s.banEvent(allowedId) + assertTrue(policy.accept(EventCmd(event(allowedId))) is PolicyResult.Rejected) } @Test diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86ServerTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86ServerTest.kt index 534eb84b4e..615009c503 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86ServerTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86ServerTest.kt @@ -21,19 +21,25 @@ package com.vitorpamplona.quartz.nip86RelayManagement.server import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation +import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedEvent import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedEvent import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedPubkey import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request import kotlinx.coroutines.runBlocking +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonPrimitive import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -140,9 +146,167 @@ class Nip86ServerTest { assertEquals(eventId, list[0].id) assertEquals("off-topic", list[0].reason) - // allowevent (which is "unban") removes the entry. - server.dispatch(admin, Nip86Request.allowEvent(eventId)) + // unbanevent removes the entry without allow-listing it. + server.dispatch(admin, Nip86Request.unbanEvent(eventId)) assertTrue(banStore.listBannedEvents().isEmpty()) + assertTrue(banStore.listAllowedEvents().isEmpty()) + } + } + + @Test + fun allowEventAddsToAllowListWithReasonAndLiftsBan() { + runBlocking { + val (server, banStore, _) = fixture() + server.dispatch(admin, Nip86Request.banEvent(eventId, "spam")) + + val ok = server.dispatch(admin, Nip86Request.allowEvent(eventId, "reviewed")) + assertEquals(true, (ok.result as JsonPrimitive).boolean) + assertTrue(banStore.isAllowedEvent(eventId)) + assertFalse(banStore.isBannedEvent(eventId)) + + val resp = server.dispatch(admin, Nip86Request.listAllowedEvents()) + val list = Json.decodeFromJsonElement(ListSerializer(AllowedEvent.serializer()), resp.result as JsonArray) + assertEquals(1, list.size) + assertEquals(eventId, list[0].id) + assertEquals("reviewed", list[0].reason) + + // banevent moves it back off the allow list. + server.dispatch(admin, Nip86Request.banEvent(eventId, "again")) + assertTrue(banStore.isBannedEvent(eventId)) + assertFalse(banStore.isAllowedEvent(eventId)) + + // unallowevent / unbanevent never add to the opposite list. + server.dispatch(admin, Nip86Request.allowEvent(eventId)) + server.dispatch(admin, Nip86Request.unallowEvent(eventId)) + assertFalse(banStore.isAllowedEvent(eventId)) + assertFalse(banStore.isBannedEvent(eventId)) + } + } + + @Test + fun banAndAllowPubkeyAreMutuallyExclusive() { + runBlocking { + val (server, banStore, _) = fixture() + server.dispatch(admin, Nip86Request.allowPubkey(pk, "trusted")) + server.dispatch(admin, Nip86Request.banPubkey(pk, "spam")) + assertTrue(banStore.isBanned(pk)) + assertFalse(banStore.isAllowedPubkey(pk)) + + server.dispatch(admin, Nip86Request.allowPubkey(pk)) + assertTrue(banStore.isAllowedPubkey(pk)) + assertFalse(banStore.isBanned(pk)) + + server.dispatch(admin, Nip86Request.unallowPubkey(pk)) + assertFalse(banStore.isAllowedPubkey(pk)) + assertFalse(banStore.isBanned(pk)) + } + } + + @Test + fun listDisallowedKinds() { + runBlocking { + val (server, _, _) = fixture() + server.dispatch(admin, Nip86Request.disallowKind(4)) + server.dispatch(admin, Nip86Request.disallowKind(1059)) + val resp = server.dispatch(admin, Nip86Request.listDisallowedKinds()) + assertEquals(listOf(4, 1059), (resp.result as JsonArray).map { it.jsonPrimitive.int }) + } + } + + @Test + fun roleLifecycle() { + runBlocking { + val (server, banStore, _) = fixture() + val created = server.dispatch(admin, Nip86Request.createRole("mod", "Moderator", "keeps order", 120, 1)) + assertNull(created.error) + assertEquals(RelayRole("mod", "Moderator", "keeps order", 120, 1), banStore.getRole("mod")) + + // Creating an existing id is refused; edit replaces it. + assertNotNull(server.dispatch(admin, Nip86Request.createRole("mod")).error) + assertNull(server.dispatch(admin, Nip86Request.editRole("mod", "Mods", null, 200, null)).error) + assertEquals(RelayRole("mod", "Mods", null, 200, null), banStore.getRole("mod")) + assertNotNull(server.dispatch(admin, Nip86Request.editRole("nope", "x")).error) + + assertNull(server.dispatch(admin, Nip86Request.assignRole(pk, "mod")).error) + assertEquals(listOf("mod"), banStore.rolesOf(pk)) + assertNotNull(server.dispatch(admin, Nip86Request.assignRole(pk, "nope")).error) + + assertNull(server.dispatch(admin, Nip86Request.unassignRole(pk, "mod")).error) + assertEquals(emptyList(), banStore.rolesOf(pk)) + + server.dispatch(admin, Nip86Request.assignRole(pk2, "mod")) + assertNull(server.dispatch(admin, Nip86Request.deleteRole("mod")).error) + assertNull(banStore.getRole("mod")) + assertEquals(emptyList(), banStore.rolesOf(pk2), "deleting a role unassigns it") + } + } + + @Test + fun roleParamsAcceptStringNumbersAndRejectBadHues() { + runBlocking { + val (server, banStore, _) = fixture() + val stringy = + Nip86Request( + method = Nip86Method.CREATE_ROLE, + params = + buildJsonArray { + add(JsonPrimitive("king")) + add(JsonPrimitive("king")) + add(JsonPrimitive("ruler of the relay")) + add(JsonPrimitive("37")) + add(JsonPrimitive("1")) + }, + ) + assertNull(server.dispatch(admin, stringy).error) + assertEquals(RelayRole("king", "king", "ruler of the relay", 37, 1), banStore.getRole("king")) + + // Only the id is required. + val idOnly = Nip86Request(method = Nip86Method.CREATE_ROLE, params = buildJsonArray { add(JsonPrimitive("bare")) }) + assertNull(server.dispatch(admin, idOnly).error) + assertEquals(RelayRole("bare"), banStore.getRole("bare")) + + assertNotNull(server.dispatch(admin, Nip86Request.createRole("hot", color = 361)).error) + assertNull(banStore.getRole("hot")) + assertNotNull(server.dispatch(admin, Nip86Request(method = Nip86Method.CREATE_ROLE)).error) + } + } + + @Test + fun claimLifecycle() { + runBlocking { + val (server, banStore, _) = fixture() + assertNull(server.dispatch(admin, Nip86Request.createClaim("invite-1")).error) + assertNull(server.dispatch(admin, Nip86Request.createClaim("invite-2")).error) + assertTrue(banStore.isValidClaim("invite-1")) + + val listed = server.dispatch(admin, Nip86Request.listClaims()) + assertEquals(setOf("invite-1", "invite-2"), (listed.result as JsonArray).map { it.jsonPrimitive.content }.toSet()) + + assertNull(server.dispatch(admin, Nip86Request.deleteClaim("invite-1")).error) + assertFalse(banStore.isValidClaim("invite-1")) + assertNotNull(server.dispatch(admin, Nip86Request.createClaim(" ")).error) + } + } + + @Test + fun supportedMethodsAreAllDispatchable() { + runBlocking { + val (server, _, _) = fixture() + // Every advertised method must reach a real handler, never "method not supported". + server.supportedMethods.forEach { method -> + val resp = server.dispatch(admin, Nip86Request(method = method)) + assertFalse(resp.error?.startsWith("method not supported") == true, "advertised but not handled: " + method) + } + listOf( + Nip86Method.UNBAN_EVENT, + Nip86Method.UNALLOW_EVENT, + Nip86Method.LIST_ALLOWED_EVENTS, + Nip86Method.LIST_DISALLOWED_KINDS, + Nip86Method.CREATE_ROLE, + Nip86Method.ASSIGN_ROLE, + Nip86Method.LIST_CLAIMS, + Nip86Method.CREATE_CLAIM, + ).forEach { assertTrue(it in server.supportedMethods, it) } } } From 7ed0190fc3aca56307d760a576c0426ab2e4ee1f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:05:19 +0000 Subject: [PATCH 03/24] feat: NIP-86 roles/claims/event allow list in amy and relay management UI amy admin gains unban-event, unallow-event, list-allowed-events, list-disallowed-kinds, create/edit/delete-role, assign/unassign-role and list/create/delete-claim; allow-event is documented as "allow-list". Android relay management: the moderation queue's approve button keeps using allowevent (which now allow-lists and lifts any ban) and refreshes every event list; banned events can be unbanned, allowed events are listed with unallow, and disallowed kinds are shown read-only. NIP-43 members screen: filters 13534/33534 by the relay's NIP-11 self, shows each member's roles as hue-tinted chips, and asks for the invite code the join request (28934) now requires. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc --- .../relays/nip43/RelayMembersScreen.kt | 108 ++++++++++++++---- .../relays/nip86/RelayManagementScreen.kt | 88 +++++++++++++- .../relays/nip86/RelayManagementViewModel.kt | 92 +++++++++++++-- cli/README.md | 8 +- cli/ROADMAP.md | 2 +- .../amethyst/cli/commands/AdminCommand.kt | 60 +++++++++- .../composeResources/values/strings.xml | 7 ++ .../nip43RelayMembers/ui/RelayMemberCards.kt | 66 +++++++++++ 8 files changed, 389 insertions(+), 42 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt index fad68faca1..c33f421c56 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt @@ -41,6 +41,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -59,9 +60,12 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.nip43RelayMembers.ui.RelayRoleChips import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.relay_members_count import com.vitorpamplona.amethyst.commons.resources.relay_members_empty +import com.vitorpamplona.amethyst.commons.resources.relay_members_invite_code +import com.vitorpamplona.amethyst.commons.resources.relay_members_invite_code_hint import com.vitorpamplona.amethyst.commons.resources.relay_members_join_sent import com.vitorpamplona.amethyst.commons.resources.relay_members_leave_sent import com.vitorpamplona.amethyst.commons.resources.relay_members_loading @@ -72,9 +76,9 @@ import com.vitorpamplona.amethyst.commons.resources.relay_members_you_are_member import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo import com.vitorpamplona.amethyst.ui.note.UserCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.fetchAsFlow import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -83,6 +87,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEvent import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent +import com.vitorpamplona.quartz.nip43RelayMembers.list.tags.RelayMember +import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole +import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRoleEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.lastOrNull import kotlinx.coroutines.launch @@ -97,24 +104,32 @@ fun RelayMembersScreen( val normalizedRelayUrl = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } if (normalizedRelayUrl == null) return - var members by remember { mutableStateOf>(emptyList()) } + var members by remember { mutableStateOf>(emptyList()) } + var roles by remember { mutableStateOf>(emptyMap()) } var isLoading by remember { mutableStateOf(true) } var isMember by remember { mutableStateOf(false) } var joinRequestSent by remember { mutableStateOf(false) } var leaveRequestSent by remember { mutableStateOf(false) } + var inviteCode by remember { mutableStateOf("") } val scope = rememberCoroutineScope() - LaunchedEffect(normalizedRelayUrl) { + // NIP-43 lists (13534) and roles (33534) MUST be signed by the relay's NIP-11 `self`. + // Filter by it once the doc resolves; until then, take whatever the relay serves. + val relayInfo by loadRelayInfo(normalizedRelayUrl) + val relaySelf = relayInfo.self + + LaunchedEffect(normalizedRelayUrl, relaySelf) { launch(Dispatchers.IO) { - val filter = - Filter( - kinds = listOf(RelayMembershipListEvent.KIND), - limit = 1, + val authors = relaySelf?.let { listOf(it) } + val filters = + listOf( + Filter(kinds = listOf(RelayMembershipListEvent.KIND), authors = authors, limit = 1), + Filter(kinds = listOf(RelayRoleEvent.KIND), authors = authors), ) val events = accountViewModel.account.client - .fetchAsFlow(normalizedRelayUrl, filter) + .fetchAsFlow(normalizedRelayUrl, filters) .lastOrNull() val membershipEvent = @@ -122,9 +137,19 @@ fun RelayMembersScreen( ?.mapNotNull { it as? RelayMembershipListEvent } ?.maxByOrNull { it.createdAt } - val memberList = membershipEvent?.members() ?: emptyList() + // Only trust role definitions from whoever signed the member list. + val roleSigner = relaySelf ?: membershipEvent?.pubKey + roles = + events + ?.mapNotNull { it as? RelayRoleEvent } + ?.filter { it.pubKey == roleSigner } + ?.groupBy { it.roleId() } + ?.mapValues { (_, versions) -> versions.maxBy { it.createdAt }.role() } + ?: emptyMap() + + val memberList = membershipEvent?.membersWithRoles() ?: emptyList() members = memberList - isMember = memberList.contains(accountViewModel.account.signer.pubKey) + isMember = memberList.any { it.pubKey == accountViewModel.account.signer.pubKey } isLoading = false } } @@ -162,9 +187,12 @@ fun RelayMembersScreen( isLoading = isLoading, joinRequestSent = joinRequestSent, leaveRequestSent = leaveRequestSent, + inviteCode = inviteCode, + onInviteCodeChange = { inviteCode = it }, onJoinRequest = { + val claim = inviteCode.trim() accountViewModel.launchSigner { - sendJoinRequest(normalizedRelayUrl, accountViewModel) + sendJoinRequest(normalizedRelayUrl, claim, accountViewModel) joinRequestSent = true } }, @@ -215,13 +243,21 @@ fun RelayMembersScreen( ) LazyColumn(modifier = Modifier.fillMaxSize()) { - items(members, key = { it }) { memberPubKey -> - val user = remember(memberPubKey) { accountViewModel.account.cache.getOrCreateUser(memberPubKey) } - UserCompose( - baseUser = user, - accountViewModel = accountViewModel, - nav = nav, - ) + items(members, key = { it.pubKey }) { member -> + val user = remember(member.pubKey) { accountViewModel.account.cache.getOrCreateUser(member.pubKey) } + Column { + UserCompose( + baseUser = user, + accountViewModel = accountViewModel, + nav = nav, + ) + // Role ids without a published 33534 definition still show, by id. + val memberRoles = remember(member, roles) { member.roles.map { roles[it] ?: RelayRole(it) } } + RelayRoleChips( + roles = memberRoles, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp), + ) + } } } } @@ -235,9 +271,32 @@ fun MembershipActions( isLoading: Boolean, joinRequestSent: Boolean, leaveRequestSent: Boolean, + inviteCode: String, + onInviteCodeChange: (String) -> Unit, onJoinRequest: () -> Unit, onLeaveRequest: () -> Unit, ) { + if (!isLoading && !isMember && !joinRequestSent) { + // NIP-43 join requests (kind 28934) must carry the invite code the relay issued. + Column( + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 16.dp), + ) { + Text( + text = stringRes(Res.string.relay_members_invite_code_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = inviteCode, + onValueChange = onInviteCodeChange, + label = { Text(stringRes(Res.string.relay_members_invite_code)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + } + Row( modifier = Modifier @@ -293,7 +352,7 @@ fun MembershipActions( fontWeight = FontWeight.Bold, ) } else { - Button(onClick = onJoinRequest) { + Button(onClick = onJoinRequest, enabled = inviteCode.isNotBlank()) { Icon( symbol = MaterialSymbols.PersonAdd, contentDescription = null, @@ -316,6 +375,8 @@ private fun MembershipActionsNotMemberPreview() { isLoading = false, joinRequestSent = false, leaveRequestSent = false, + inviteCode = "", + onInviteCodeChange = {}, onJoinRequest = {}, onLeaveRequest = {}, ) @@ -331,6 +392,8 @@ private fun MembershipActionsIsMemberPreview() { isLoading = false, joinRequestSent = false, leaveRequestSent = false, + inviteCode = "", + onInviteCodeChange = {}, onJoinRequest = {}, onLeaveRequest = {}, ) @@ -346,6 +409,8 @@ private fun MembershipActionsJoinSentPreview() { isLoading = false, joinRequestSent = true, leaveRequestSent = false, + inviteCode = "", + onInviteCodeChange = {}, onJoinRequest = {}, onLeaveRequest = {}, ) @@ -361,6 +426,8 @@ private fun MembershipActionsLeaveSentPreview() { isLoading = false, joinRequestSent = false, leaveRequestSent = true, + inviteCode = "", + onInviteCodeChange = {}, onJoinRequest = {}, onLeaveRequest = {}, ) @@ -369,9 +436,10 @@ private fun MembershipActionsLeaveSentPreview() { suspend fun sendJoinRequest( relay: NormalizedRelayUrl, + claim: String, accountViewModel: AccountViewModel, ) { - val template = RelayJoinRequestEvent.build() + val template = RelayJoinRequestEvent.build(claim) val signedEvent = accountViewModel.account.signer.sign(template) accountViewModel.account.cache.justConsumeMyOwnEvent(signedEvent) accountViewModel.account.client.publish(signedEvent, setOf(relay)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt index fd0e1f5a7c..5f404dc434 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt @@ -82,8 +82,10 @@ import com.vitorpamplona.amethyst.commons.relayManagement.Nip86Retriever import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.relay_management_add import com.vitorpamplona.amethyst.commons.resources.relay_management_allow +import com.vitorpamplona.amethyst.commons.resources.relay_management_allow_event import com.vitorpamplona.amethyst.commons.resources.relay_management_allow_kind import com.vitorpamplona.amethyst.commons.resources.relay_management_allow_pubkey +import com.vitorpamplona.amethyst.commons.resources.relay_management_allowed_events import com.vitorpamplona.amethyst.commons.resources.relay_management_allowed_kinds import com.vitorpamplona.amethyst.commons.resources.relay_management_allowed_pubkeys import com.vitorpamplona.amethyst.commons.resources.relay_management_apply @@ -96,6 +98,7 @@ import com.vitorpamplona.amethyst.commons.resources.relay_management_block_ip import com.vitorpamplona.amethyst.commons.resources.relay_management_blocked_ips import com.vitorpamplona.amethyst.commons.resources.relay_management_cancel import com.vitorpamplona.amethyst.commons.resources.relay_management_confirm +import com.vitorpamplona.amethyst.commons.resources.relay_management_disallowed_kinds import com.vitorpamplona.amethyst.commons.resources.relay_management_dismiss import com.vitorpamplona.amethyst.commons.resources.relay_management_error import com.vitorpamplona.amethyst.commons.resources.relay_management_event_id_hex @@ -103,11 +106,13 @@ import com.vitorpamplona.amethyst.commons.resources.relay_management_ip_address import com.vitorpamplona.amethyst.commons.resources.relay_management_kind_number import com.vitorpamplona.amethyst.commons.resources.relay_management_loading import com.vitorpamplona.amethyst.commons.resources.relay_management_moderation_queue +import com.vitorpamplona.amethyst.commons.resources.relay_management_no_allowed_events import com.vitorpamplona.amethyst.commons.resources.relay_management_no_allowed_kinds import com.vitorpamplona.amethyst.commons.resources.relay_management_no_allowed_pubkeys import com.vitorpamplona.amethyst.commons.resources.relay_management_no_banned_events import com.vitorpamplona.amethyst.commons.resources.relay_management_no_banned_pubkeys import com.vitorpamplona.amethyst.commons.resources.relay_management_no_blocked_ips +import com.vitorpamplona.amethyst.commons.resources.relay_management_no_disallowed_kinds import com.vitorpamplona.amethyst.commons.resources.relay_management_no_methods import com.vitorpamplona.amethyst.commons.resources.relay_management_no_moderation_events import com.vitorpamplona.amethyst.commons.resources.relay_management_reason_optional @@ -288,12 +293,19 @@ private fun RelayManagementContent( add(ManagementTab.PUBKEYS) } if (supportedMethods.any { - it in listOf(Nip86Method.BAN_EVENT, Nip86Method.LIST_BANNED_EVENTS, Nip86Method.ALLOW_EVENT, Nip86Method.LIST_EVENTS_NEEDING_MODERATION) + it in + listOf( + Nip86Method.BAN_EVENT, + Nip86Method.LIST_BANNED_EVENTS, + Nip86Method.ALLOW_EVENT, + Nip86Method.LIST_ALLOWED_EVENTS, + Nip86Method.LIST_EVENTS_NEEDING_MODERATION, + ) } ) { add(ManagementTab.EVENTS) } - if (supportedMethods.any { it in listOf(Nip86Method.ALLOW_KIND, Nip86Method.DISALLOW_KIND, Nip86Method.LIST_ALLOWED_KINDS) }) { + if (supportedMethods.any { it in listOf(Nip86Method.ALLOW_KIND, Nip86Method.DISALLOW_KIND, Nip86Method.LIST_ALLOWED_KINDS, Nip86Method.LIST_DISALLOWED_KINDS) }) { add(ManagementTab.KINDS) } if (supportedMethods.any { it in listOf(Nip86Method.BLOCK_IP, Nip86Method.UNBLOCK_IP, Nip86Method.LIST_BLOCKED_IPS) }) { @@ -571,8 +583,10 @@ private fun EventsTab( supportedMethods: List, ) { val bannedEvents by viewModel.bannedEvents.collectAsState() + val allowedEvents by viewModel.allowedEvents.collectAsState() val eventsNeedingModeration by viewModel.eventsNeedingModeration.collectAsState() var showBanDialog by remember { mutableStateOf(false) } + var showAllowDialog by remember { mutableStateOf(false) } LazyColumn( contentPadding = PaddingValues(10.dp), @@ -596,6 +610,7 @@ private fun EventsTab( reason = entry.reason, canAllow = supportedMethods.contains(Nip86Method.ALLOW_EVENT), canBan = supportedMethods.contains(Nip86Method.BAN_EVENT), + // Approve: `allowevent` allow-lists the event (and lifts any ban). onAllow = { viewModel.allowEvent(entry.id) }, onBan = { viewModel.banEvent(entry.id) }, ) @@ -616,16 +631,52 @@ private fun EventsTab( if (bannedEvents.isEmpty()) { item { EmptyListMessage(stringRes(Res.string.relay_management_no_banned_events)) } } else { - items(bannedEvents, key = { it.id }) { entry -> + items(bannedEvents, key = { "banned" + it.id }) { entry -> HexEntryCard( hex = entry.id, reason = entry.reason, - showRemove = false, - onRemove = {}, + showRemove = supportedMethods.contains(Nip86Method.UNBAN_EVENT), + onRemove = { viewModel.unbanEvent(entry.id) }, ) } } } + + if (supportedMethods.contains(Nip86Method.LIST_ALLOWED_EVENTS)) { + item { Spacer(modifier = Modifier.height(8.dp)) } + item { + SectionHeaderWithAdd( + stringRes(Res.string.relay_management_allowed_events), + showAdd = supportedMethods.contains(Nip86Method.ALLOW_EVENT), + onAdd = { showAllowDialog = true }, + ) + } + + if (allowedEvents.isEmpty()) { + item { EmptyListMessage(stringRes(Res.string.relay_management_no_allowed_events)) } + } else { + items(allowedEvents, key = { "allowed" + it.id }) { entry -> + HexEntryCard( + hex = entry.id, + reason = entry.reason, + showRemove = supportedMethods.contains(Nip86Method.UNALLOW_EVENT), + onRemove = { viewModel.unallowEvent(entry.id) }, + ) + } + } + } + } + + if (showAllowDialog) { + HexInputDialog( + title = stringRes(Res.string.relay_management_allow_event), + label = stringRes(Res.string.relay_management_event_id_hex), + onConfirm = { hex, reason -> + viewModel.allowEvent(hex, reason.ifBlank { null }) + showAllowDialog = false + }, + onDismiss = { showAllowDialog = false }, + ) } if (showBanDialog) { @@ -648,6 +699,7 @@ private fun KindsTab( supportedMethods: List, ) { val allowedKinds by viewModel.allowedKinds.collectAsState() + val disallowedKinds by viewModel.disallowedKinds.collectAsState() var showAddDialog by remember { mutableStateOf(false) } LazyColumn( @@ -665,7 +717,7 @@ private fun KindsTab( if (allowedKinds.isEmpty()) { item { EmptyListMessage(stringRes(Res.string.relay_management_no_allowed_kinds)) } } else { - items(allowedKinds, key = { it }) { kind -> + items(allowedKinds, key = { "allowed$it" }) { kind -> KindEntryCard( kind = kind, showRemove = supportedMethods.contains(Nip86Method.DISALLOW_KIND), @@ -673,6 +725,30 @@ private fun KindsTab( ) } } + + // Read-only: NIP-86 has no "undisallow"; `allowkind` would also turn on the allow list. + if (supportedMethods.contains(Nip86Method.LIST_DISALLOWED_KINDS)) { + item { Spacer(modifier = Modifier.height(8.dp)) } + item { + SectionHeaderWithAdd( + stringRes(Res.string.relay_management_disallowed_kinds), + showAdd = false, + onAdd = {}, + ) + } + + if (disallowedKinds.isEmpty()) { + item { EmptyListMessage(stringRes(Res.string.relay_management_no_disallowed_kinds)) } + } else { + items(disallowedKinds, key = { "disallowed$it" }) { kind -> + KindEntryCard( + kind = kind, + showRemove = false, + onRemove = {}, + ) + } + } + } } if (showAddDialog) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementViewModel.kt index 9b81f386fc..b67f9fa9c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementViewModel.kt @@ -29,11 +29,13 @@ import com.vitorpamplona.amethyst.commons.relayManagement.Nip86Retriever import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip86RelayManagement.Nip86Client +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedEvent import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedEvent import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedPubkey import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BlockedIp import com.vitorpamplona.quartz.nip86RelayManagement.rpc.EventNeedingModeration +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -69,12 +71,18 @@ class RelayManagementViewModel( private val _bannedEvents = MutableStateFlow>(emptyList()) val bannedEvents: StateFlow> = _bannedEvents + private val _allowedEvents = MutableStateFlow>(emptyList()) + val allowedEvents: StateFlow> = _allowedEvents + private val _eventsNeedingModeration = MutableStateFlow>(emptyList()) val eventsNeedingModeration: StateFlow> = _eventsNeedingModeration private val _allowedKinds = MutableStateFlow>(emptyList()) val allowedKinds: StateFlow> = _allowedKinds + private val _disallowedKinds = MutableStateFlow>(emptyList()) + val disallowedKinds: StateFlow> = _disallowedKinds + private val _blockedIps = MutableStateFlow>(emptyList()) val blockedIps: StateFlow> = _blockedIps @@ -147,6 +155,17 @@ class RelayManagementViewModel( } } + fun loadAllowedEvents() { + viewModelScope.launch { + val response = retriever.execute(client, Nip86Request.listAllowedEvents()) + if (response.error != null) { + _error.value = response.error + } else { + _allowedEvents.value = client.parseAllowedEvents(response)?.distinctBy { it.id } ?: emptyList() + } + } + } + fun loadEventsNeedingModeration() { viewModelScope.launch { val response = retriever.execute(client, Nip86Request.listEventsNeedingModeration()) @@ -169,6 +188,17 @@ class RelayManagementViewModel( } } + fun loadDisallowedKinds() { + viewModelScope.launch { + val response = retriever.execute(client, Nip86Request.listDisallowedKinds()) + if (response.error != null) { + _error.value = response.error + } else { + _disallowedKinds.value = client.parseDisallowedKinds(response)?.distinctBy { it } ?: emptyList() + } + } + } + fun loadBlockedIps() { viewModelScope.launch { val response = retriever.execute(client, Nip86Request.listBlockedIps()) @@ -189,7 +219,9 @@ class RelayManagementViewModel( if (response.error != null) { _error.value = response.error } else { + // NIP-86: banning also drops the pubkey from the allow list. loadBannedPubkeys() + loadIfSupported(Nip86Method.LIST_ALLOWED_PUBKEYS) { loadAllowedPubkeys() } } } } @@ -214,7 +246,9 @@ class RelayManagementViewModel( if (response.error != null) { _error.value = response.error } else { + // NIP-86: allowing also lifts any ban on the pubkey. loadAllowedPubkeys() + loadIfSupported(Nip86Method.LIST_BANNED_PUBKEYS) { loadBannedPubkeys() } } } } @@ -236,6 +270,17 @@ class RelayManagementViewModel( ) { viewModelScope.launch { val response = retriever.execute(client, Nip86Request.banEvent(eventId, reason)) + if (response.error != null) { + _error.value = response.error + } else { + reloadEventLists() + } + } + } + + fun unbanEvent(eventId: String) { + viewModelScope.launch { + val response = retriever.execute(client, Nip86Request.unbanEvent(eventId)) if (response.error != null) { _error.value = response.error } else { @@ -244,6 +289,10 @@ class RelayManagementViewModel( } } + /** + * Approves an event: NIP-86 `allowevent` puts it on the relay's event + * allow list and lifts any ban on it (it no longer means "unban"). + */ fun allowEvent( eventId: String, reason: String? = null, @@ -253,11 +302,35 @@ class RelayManagementViewModel( if (response.error != null) { _error.value = response.error } else { - loadEventsNeedingModeration() + reloadEventLists() } } } + fun unallowEvent(eventId: String) { + viewModelScope.launch { + val response = retriever.execute(client, Nip86Request.unallowEvent(eventId)) + if (response.error != null) { + _error.value = response.error + } else { + loadAllowedEvents() + } + } + } + + private fun reloadEventLists() { + loadIfSupported(Nip86Method.LIST_EVENTS_NEEDING_MODERATION) { loadEventsNeedingModeration() } + loadIfSupported(Nip86Method.LIST_BANNED_EVENTS) { loadBannedEvents() } + loadIfSupported(Nip86Method.LIST_ALLOWED_EVENTS) { loadAllowedEvents() } + } + + private inline fun loadIfSupported( + method: String, + load: () -> Unit, + ) { + if (_supportedMethods.value.contains(method)) load() + } + fun changeRelayName(newName: String) { viewModelScope.launch { val response = retriever.execute(client, Nip86Request.changeRelayName(newName)) @@ -292,6 +365,7 @@ class RelayManagementViewModel( _error.value = response.error } else { loadAllowedKinds() + loadIfSupported(Nip86Method.LIST_DISALLOWED_KINDS) { loadDisallowedKinds() } } } } @@ -303,6 +377,7 @@ class RelayManagementViewModel( _error.value = response.error } else { loadAllowedKinds() + loadIfSupported(Nip86Method.LIST_DISALLOWED_KINDS) { loadDisallowedKinds() } } } } @@ -337,12 +412,13 @@ class RelayManagementViewModel( } fun loadAllLists() { - val methods = _supportedMethods.value - if (methods.contains("listbannedpubkeys")) loadBannedPubkeys() - if (methods.contains("listallowedpubkeys")) loadAllowedPubkeys() - if (methods.contains("listbannedevents")) loadBannedEvents() - if (methods.contains("listeventsneedingmoderation")) loadEventsNeedingModeration() - if (methods.contains("listallowedkinds")) loadAllowedKinds() - if (methods.contains("listblockedips")) loadBlockedIps() + loadIfSupported(Nip86Method.LIST_BANNED_PUBKEYS) { loadBannedPubkeys() } + loadIfSupported(Nip86Method.LIST_ALLOWED_PUBKEYS) { loadAllowedPubkeys() } + loadIfSupported(Nip86Method.LIST_BANNED_EVENTS) { loadBannedEvents() } + loadIfSupported(Nip86Method.LIST_ALLOWED_EVENTS) { loadAllowedEvents() } + loadIfSupported(Nip86Method.LIST_EVENTS_NEEDING_MODERATION) { loadEventsNeedingModeration() } + loadIfSupported(Nip86Method.LIST_ALLOWED_KINDS) { loadAllowedKinds() } + loadIfSupported(Nip86Method.LIST_DISALLOWED_KINDS) { loadDisallowedKinds() } + loadIfSupported(Nip86Method.LIST_BLOCKED_IPS) { loadBlockedIps() } } } diff --git a/cli/README.md b/cli/README.md index 359e2c5787..ddb37079c3 100644 --- a/cli/README.md +++ b/cli/README.md @@ -453,8 +453,12 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever` | `amy admin RELAY supported-methods` | List the NIP-86 methods the relay implements. | | `amy admin RELAY ban-pubkey HEX [--reason R]` / `unban-pubkey HEX` / `list-banned-pubkeys` | Pubkey ban list. | | `amy admin RELAY allow-pubkey HEX [--reason R]` / `unallow-pubkey HEX` / `list-allowed-pubkeys` | Pubkey allow list. | -| `amy admin RELAY ban-event ID [--reason R]` / `allow-event ID` / `list-banned-events` / `list-needing-moderation` | Event moderation. | -| `amy admin RELAY allow-kind N` / `disallow-kind N` / `list-allowed-kinds` | Kind allow list. | +| `amy admin RELAY ban-event ID [--reason R]` / `unban-event ID` / `list-banned-events` | Event ban list. `ban-event` also drops the id from the allow list; `unban-event` does not allow-list it. | +| `amy admin RELAY allow-event ID [--reason R]` / `unallow-event ID` / `list-allowed-events` / `list-needing-moderation` | Event allow list (approve an event: it also lifts any ban) and the moderation queue. | +| `amy admin RELAY create-role ID [--label L] [--description D] [--color HUE] [--order N]` / `edit-role ID …` / `delete-role ID` | NIP-43 member roles (kind 33534); `--color` is a hue 0–360. | +| `amy admin RELAY assign-role HEX ROLE` / `unassign-role HEX ROLE` | Give / take a role. | +| `amy admin RELAY create-claim CODE` / `delete-claim CODE` / `list-claims` | NIP-43 invite codes for kind 28934 join requests. | +| `amy admin RELAY allow-kind N` / `disallow-kind N` / `list-allowed-kinds` / `list-disallowed-kinds` | Kind allow / deny lists. | | `amy admin RELAY block-ip IP [--reason R]` / `unblock-ip IP` / `list-blocked-ips` | IP block list. | | `amy admin RELAY change-name S` / `change-description S` / `change-icon URL` | Relay metadata. | diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 09b80c2072..94664b3048 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -116,7 +116,7 @@ vs streaming `subscribe`). Stateless verbs run with no account or network. | `git` | `amy git` | ✅ (events + read) | NIP-34: `init` bootstraps a repo from the local `git` checkout (announce + state, like `ngit init`); repo announce (30617) + state (30618), patches (1617), pull requests (1618/1619), issues (1621), NIP-22 comments (1111), NIP-32 labels (1985), status open/applied/closed/draft (1630-1633), GRASP server list (10317); `issues`/`patches`/`prs`/`thread` reads derive status; `apply` applies a fetched patch to the local tree (`git am`); `browse`/`cat`/`log` read git objects over smart-HTTP v2 (quartz `GitHttpClient`, the same shallow-clone path the Android browser uses). Only git-packfile **push** (writing objects to clone/GRASP servers) and NIP-34 cover notes (1624, no quartz builder yet) are out of scope. Event tag shapes were verified byte-for-byte against the ngit reference implementation and the NIP-34 spec (`clone`/`web` as single multi-value tags, issue `p`-tag for maintainer routing, plain patch/PR `r` tags); the quartz readers stay tolerant of the legacy repeated form. See `quartz/…/nip34Git/GitNip34InteropTest`. | | `podcast` | `amy podcast` | ✅ | NIP-F4 show metadata (10154) + episode publish (54) + list. | | `bunker` | `amy bunker[ connect]` + `amy login bunker://`/`--nostrconnect` | ✅ | NIP-46 remote signer + login, both the `bunker://` and `nostrconnect://` flows, each direction, plus `auth_url` challenge handling (client surfaces the URL + keeps waiting). Interop-verified vs real `nak`. | -| `admin` | `amy admin RELAY METHOD` | ✅ | NIP-86 Relay Management over NIP-98 HTTP auth — full method set (ban/allow pubkey + event, kinds, IP block, change name/desc/icon, list-*). Reuses quartz `Nip86Client` + shared `commons` `Nip86Retriever`. Interop-verified against `amy serve`. | +| `admin` | `amy admin RELAY METHOD` | ✅ | NIP-86 Relay Management over NIP-98 HTTP auth — full method set (ban/allow pubkey + event, NIP-43 roles + invite claims, kinds, IP block, change name/desc/icon, list-*). Reuses quartz `Nip86Client` + shared `commons` `Nip86Retriever`. Interop-verified against `amy serve`. | | `serve` | `amy serve` | ✅ | Embeds **geode** (the standalone Ktor relay on quartz's relay-server code) — in-memory by default, `--db FILE` for SQLite, account is admin so `amy admin` works against it. NIP-86 + NIP-77 included. | | `wallet` (NIP-60 Cashu) | `amy cashu` | ✅ | See the Cashu row above — full NIP-60/61 wallet + nutzaps. | | `mcp` / `fs` / `spell` | — | 🆕 (niche) | MCP server, FUSE mount, MuSig2/FROST; some pull new deps. | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AdminCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AdminCommand.kt index e97f71745c..345f3bbbee 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AdminCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AdminCommand.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.amethyst.commons.relayManagement.Nip86Retriever import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole import com.vitorpamplona.quartz.nip86RelayManagement.Nip86Client import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request import okhttp3.OkHttpClient @@ -41,10 +42,15 @@ import okhttp3.OkHttpClient * admin wss://relay ban-pubkey HEX [--reason R] / unban-pubkey HEX * admin wss://relay allow-pubkey HEX [--reason R] / list-allowed-pubkeys * admin wss://relay list-banned-pubkeys - * admin wss://relay ban-event ID [--reason R] / allow-event ID / list-banned-events + * admin wss://relay ban-event ID [--reason R] / unban-event ID / list-banned-events + * admin wss://relay allow-event ID [--reason R] / unallow-event ID / list-allowed-events * admin wss://relay list-needing-moderation + * admin wss://relay create-role ID [--label L] [--description D] [--color HUE] [--order N] + * admin wss://relay edit-role ID [...] / delete-role ID + * admin wss://relay assign-role HEX ROLE / unassign-role HEX ROLE + * admin wss://relay create-claim CODE / delete-claim CODE / list-claims * admin wss://relay change-name S / change-description S / change-icon URL - * admin wss://relay allow-kind N / disallow-kind N / list-allowed-kinds + * admin wss://relay allow-kind N / disallow-kind N / list-allowed-kinds / list-disallowed-kinds * admin wss://relay block-ip IP [--reason R] / unblock-ip IP / list-blocked-ips */ object AdminCommand { @@ -58,16 +64,30 @@ object AdminCommand { | admin RELAY allow-pubkey HEX [--reason R] allow-list a pubkey | admin RELAY unallow-pubkey HEX [--reason R] remove a pubkey from the allow-list | admin RELAY list-allowed-pubkeys list allowed pubkeys - | admin RELAY ban-event ID [--reason R] ban an event id - | admin RELAY allow-event ID [--reason R] allow an event id + | admin RELAY ban-event ID [--reason R] ban an event id (drops it from the allow-list) + | admin RELAY unban-event ID [--reason R] lift an event ban (does not allow-list it) | admin RELAY list-banned-events list banned events + | admin RELAY allow-event ID [--reason R] allow-list an event id (lifts any ban) + | admin RELAY unallow-event ID [--reason R] remove an event id from the allow-list + | admin RELAY list-allowed-events list allow-listed events | admin RELAY list-needing-moderation list events flagged for moderation + | admin RELAY create-role ID [--label L] [--description D] [--color HUE] [--order N] + | define a NIP-43 member role (hue 0-360) + | admin RELAY edit-role ID [--label L] [--description D] [--color HUE] [--order N] + | replace a role's definition + | admin RELAY delete-role ID delete a role + | admin RELAY assign-role HEX ROLE give a pubkey a role + | admin RELAY unassign-role HEX ROLE take a role from a pubkey + | admin RELAY list-claims list NIP-43 invite codes the relay accepts + | admin RELAY create-claim CODE create a NIP-43 invite code + | admin RELAY delete-claim CODE revoke a NIP-43 invite code | admin RELAY change-name NAME set the relay's name | admin RELAY change-description TEXT set the relay's description | admin RELAY change-icon URL set the relay's icon | admin RELAY allow-kind N allow an event kind | admin RELAY disallow-kind N disallow an event kind | admin RELAY list-allowed-kinds list allowed kinds + | admin RELAY list-disallowed-kinds list disallowed kinds | admin RELAY block-ip IP [--reason R] block an IP address | admin RELAY unblock-ip IP unblock an IP address | admin RELAY list-blocked-ips list blocked IPs @@ -86,7 +106,12 @@ object AdminCommand { val method = args.positionalOrNull(1) ?: return Output.error("bad_args", "missing method; e.g. supported-methods") val relay = RelayUrlNormalizer.normalizeOrNull(relayArg) ?: return Output.invalidRelayUrl(relayArg) val p2 = args.positionalOrNull(2) + val p3 = args.positionalOrNull(3) val reason = args.flag("reason") + val label = args.flag("label") + val description = args.flag("description") + val color = args.flag("color") + val order = args.flag("order") args.rejectUnknown() fun needArg(name: String): String? = @@ -95,6 +120,19 @@ object AdminCommand { null } + fun needSecondArg(name: String): String? = + p3 ?: run { + Output.error("bad_args", "$method requires a $name argument") + null + } + + val colorInt = + color?.let { + it.toIntOrNull()?.takeIf { hue -> RelayRole.isValidHue(hue) } + ?: return Output.error("bad_args", "--color must be a hue between 0 and 360") + } + val orderInt = order?.let { it.toIntOrNull() ?: return Output.error("bad_args", "--order must be an integer") } + val request: Nip86Request = when (method) { "supported-methods" -> Nip86Request.supportedMethods() @@ -105,15 +143,27 @@ object AdminCommand { "unallow-pubkey" -> Nip86Request.unallowPubkey(needArg("pubkey") ?: return 2, reason) "list-allowed-pubkeys" -> Nip86Request.listAllowedPubkeys() "ban-event" -> Nip86Request.banEvent(needArg("event-id") ?: return 2, reason) - "allow-event" -> Nip86Request.allowEvent(needArg("event-id") ?: return 2, reason) + "unban-event" -> Nip86Request.unbanEvent(needArg("event-id") ?: return 2, reason) "list-banned-events" -> Nip86Request.listBannedEvents() + "allow-event" -> Nip86Request.allowEvent(needArg("event-id") ?: return 2, reason) + "unallow-event" -> Nip86Request.unallowEvent(needArg("event-id") ?: return 2, reason) + "list-allowed-events" -> Nip86Request.listAllowedEvents() "list-needing-moderation" -> Nip86Request.listEventsNeedingModeration() + "create-role" -> Nip86Request.createRole(needArg("role-id") ?: return 2, label, description, colorInt, orderInt) + "edit-role" -> Nip86Request.editRole(needArg("role-id") ?: return 2, label, description, colorInt, orderInt) + "delete-role" -> Nip86Request.deleteRole(needArg("role-id") ?: return 2) + "assign-role" -> Nip86Request.assignRole(needArg("pubkey") ?: return 2, needSecondArg("role-id") ?: return 2) + "unassign-role" -> Nip86Request.unassignRole(needArg("pubkey") ?: return 2, needSecondArg("role-id") ?: return 2) + "list-claims" -> Nip86Request.listClaims() + "create-claim" -> Nip86Request.createClaim(needArg("claim") ?: return 2) + "delete-claim" -> Nip86Request.deleteClaim(needArg("claim") ?: return 2) "change-name" -> Nip86Request.changeRelayName(needArg("name") ?: return 2) "change-description" -> Nip86Request.changeRelayDescription(needArg("description") ?: return 2) "change-icon" -> Nip86Request.changeRelayIcon(needArg("icon-url") ?: return 2) "allow-kind" -> Nip86Request.allowKind((needArg("kind") ?: return 2).toIntOrNull() ?: return Output.error("bad_args", "kind must be an integer")) "disallow-kind" -> Nip86Request.disallowKind((needArg("kind") ?: return 2).toIntOrNull() ?: return Output.error("bad_args", "kind must be an integer")) "list-allowed-kinds" -> Nip86Request.listAllowedKinds() + "list-disallowed-kinds" -> Nip86Request.listDisallowedKinds() "block-ip" -> Nip86Request.blockIp(needArg("ip") ?: return 2, reason) "unblock-ip" -> Nip86Request.unblockIp(needArg("ip") ?: return 2) "list-blocked-ips" -> Nip86Request.listBlockedIps() diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index a0b587b986..9e20dddadd 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -2809,8 +2809,13 @@ No events needing moderation Banned Events No banned events + Allowed Events + No allowed events + Allow Event Allowed Kinds No allowed kinds + Disallowed Kinds + No disallowed kinds Blocked IPs No blocked IPs Add @@ -2843,6 +2848,8 @@ Join request sent Leave request sent You are a member + Invite code + This relay admits members with an invite code from its operator Relay membership list Member added to relay %1$d members added to relay diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip43RelayMembers/ui/RelayMemberCards.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip43RelayMembers/ui/RelayMemberCards.kt index 731198bcec..8ad7a55ca3 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip43RelayMembers/ui/RelayMemberCards.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip43RelayMembers/ui/RelayMemberCards.kt @@ -20,18 +20,23 @@ */ package com.vitorpamplona.amethyst.commons.nip43RelayMembers.ui +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -52,6 +57,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent +import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole /** NIP-43 kind 13534: the relay's current member list, shown as a count. */ @Composable @@ -123,6 +129,52 @@ fun RelayLeaveRequestCard() { ) } +/** + * The NIP-43 roles (kind 33534) a relay assigned to a member, as small labelled + * chips tinted with each role's hue. Roles without a label show their id; roles + * without a color use the theme's secondary container. Sorted by the roles' + * display `order`. + */ +@Composable +fun RelayRoleChips( + roles: List, + modifier: Modifier = Modifier, +) { + if (roles.isEmpty()) return + val sorted = remember(roles) { roles.sortedWith(compareBy({ it.order ?: Int.MAX_VALUE }, { it.label ?: it.id })) } + FlowRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + sorted.forEach { RelayRoleChip(it) } + } +} + +@Composable +fun RelayRoleChip(role: RelayRole) { + val hue = role.color + val background = + if (hue != null && RelayRole.isValidHue(hue)) { + Color.hsv(hue.toFloat(), 0.45f, 0.85f) + } else { + MaterialTheme.colorScheme.secondaryContainer + } + val content = if (hue != null && RelayRole.isValidHue(hue)) Color.Black else MaterialTheme.colorScheme.onSecondaryContainer + + Text( + text = role.label ?: role.id, + style = MaterialTheme.typography.labelSmall, + color = content, + maxLines = 1, + modifier = + Modifier + .clip(RoundedCornerShape(50)) + .background(background) + .padding(horizontal = 8.dp, vertical = 2.dp), + ) +} + @Composable private fun RelayMemberEventCard( icon: MaterialSymbol, @@ -175,6 +227,20 @@ private fun RelayMembershipListCardPreview() { } } +@Preview +@Composable +private fun RelayRoleChipsPreview() { + ThemeComparisonColumn { + RelayRoleChips( + listOf( + RelayRole("28b7e50f", label = "king", color = 37, order = 1), + RelayRole("mod", label = "moderator", color = 200, order = 2), + RelayRole("plain"), + ), + ) + } +} + @Preview @Composable private fun RelayAddMemberCardPreview() { From 3f3f420d0f872e1d0fd354e4d7b2da1d7fa3a3e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:13:13 +0000 Subject: [PATCH 04/24] =?UTF-8?q?feat(quartz):=20NIP-29=20spec=20updates?= =?UTF-8?q?=20=E2=80=94=20single=20previous=20tag,=20e/a=20pin=20lists,=20?= =?UTF-8?q?banner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `previous` is now ONE tag carrying every prefix (spec example ["previous","eb96c864","2db75638","b5d1065f"]); relay29 only reads the first tag, so the old one-tag-per-prefix form had it check a single ref. Parsing reads all values of all `previous` tags (legacy form tolerated). - Pin lists (9010 / 39005) are an ordered list of `e` and `a` references (GroupPin: EventPin / AddressPin), extra tag values kept verbatim so a re-submitted list doesn't lose other clients' pins. GroupPin.fromReference parses hex/note/nevent/naddr/kind:pk:d. - kind:39000 `banner` (read + build), and 9002 edits can carry the current metadata's unmanaged tags (GroupMetadataEvent.unmanagedTags / extraTags). - GroupNAddrInvite: URL-decode the invite code; parseReference() reads a whole `naddr1…?invite=CODE` identifier. - SimpleGroupListEvent.replace: swap one group entry for another in a single signed version (group migration). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc --- .../nip29RelayGroups/GroupNAddrInvite.kt | 52 +++++ .../metadata/GroupMetadataEvent.kt | 43 ++++ .../metadata/GroupPinnedEvent.kt | 25 ++- .../moderation/EditMetadataEvent.kt | 11 + .../moderation/TagArrayBuilderExt.kt | 16 +- .../moderation/TagArrayExt.kt | 24 ++- .../moderation/UpdatePinListEvent.kt | 14 +- .../quartz/nip29RelayGroups/tags/GroupPin.kt | 121 +++++++++++ .../nip29RelayGroups/tags/PreviousTag.kt | 34 ++- .../simpleGroupList/SimpleGroupListEvent.kt | 25 +++ .../nip29RelayGroups/Nip29SpecUpdatesTest.kt | 193 ++++++++++++++++++ .../quartz/nip29RelayGroups/PinEventsTest.kt | 81 +++++++- 12 files changed, 614 insertions(+), 25 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/GroupPin.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29SpecUpdatesTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupNAddrInvite.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupNAddrInvite.kt index ac59490d4a..d668da1998 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupNAddrInvite.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupNAddrInvite.kt @@ -20,6 +20,9 @@ */ package com.vitorpamplona.quartz.nip29RelayGroups +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent + /** * Reads the optional invite code appended to a NIP-29 group identifier. * @@ -37,6 +40,28 @@ package com.vitorpamplona.quartz.nip29RelayGroups object GroupNAddrInvite { private const val PARAM = "invite" + /** A group referenced by its `kind:39000` `naddr`, plus the invite code suffix if any. */ + data class Reference( + val groupId: GroupId, + val inviteCode: String?, + ) + + /** + * Parses a whole group identifier — `naddr1…` or `naddr1…?invite=`, optionally + * with a `nostr:` prefix. Returns null unless it is a `kind:39000` naddr carrying a + * relay hint, since a group only exists on its host relay. + */ + fun parseReference(input: String): Reference? { + val trimmed = input.trim().removePrefix("nostr:") + val queryIdx = trimmed.indexOf('?') + val bech32 = if (queryIdx >= 0) trimmed.substring(0, queryIdx) else trimmed + val naddr = NAddress.parse(bech32) ?: return null + if (naddr.kind != GroupMetadataEvent.KIND) return null + val relay = naddr.relay.firstOrNull() ?: return null + val code = if (queryIdx >= 0) parse(trimmed.substring(queryIdx)) else null + return Reference(GroupId(naddr.dTag, relay), code) + } + /** * Extracts the invite code from the [suffix] that trails a group `naddr` (e.g. * `?invite=abc123` or `?foo=bar&invite=abc123`), or null when there is none. @@ -52,6 +77,33 @@ object GroupNAddrInvite { .split('&') .firstOrNull { it.startsWith("$PARAM=") } ?.removePrefix("$PARAM=") + ?.let(::percentDecode) ?.takeIf { it.isNotEmpty() } } + + /** + * Decodes `%XX` escapes (UTF-8, byte by byte) so a code shared as `?invite=a%2Fb` reaches + * the relay as `a/b`. `+` is left alone (it's a literal plus in a URI query, not a space + * here), and a malformed escape passes through verbatim. + */ + private fun percentDecode(input: String): String { + if ('%' !in input) return input + val bytes = ArrayList(input.length) + var i = 0 + while (i < input.length) { + val c = input[i] + if (c == '%' && i + 2 <= input.lastIndex) { + val hi = input[i + 1].digitToIntOrNull(16) + val lo = input[i + 2].digitToIntOrNull(16) + if (hi != null && lo != null) { + bytes.add(((hi shl 4) or lo).toByte()) + i += 3 + continue + } + } + c.toString().encodeToByteArray().forEach { bytes.add(it) } + i++ + } + return bytes.toByteArray().decodeToString() + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt index 6d235c3d1a..fbd8665f54 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt @@ -65,6 +65,18 @@ class GroupMetadataEvent( fun picture() = tags.firstTagValue("picture") + /** Wide header image for the group (NIP-29 `banner`), shown behind/above the picture. */ + fun banner() = tags.firstTagValue("banner") + + /** + * The tags of this metadata that a client's edit form doesn't manage — e.g. `livekit`, + * `supported_kinds`, or a field from a newer spec revision. A kind-9002 re-asserts the + * whole metadata ("all the fields of group-metadata"), so an edit should carry these over + * verbatim; otherwise saving a rename would silently erase them on relays that replace + * the metadata wholesale. + */ + fun unmanagedTags(): List> = tags.filter { it.isNotEmpty() && it[0] !in EDIT_MANAGED_TAG_NAMES } + /** * Buzz-only: whether the relay has marked this channel **archived** — a hide-from-the-sidebar * state, distinct from a delete (the channel and its history live on). The Buzz relay stamps an @@ -176,11 +188,41 @@ class GroupMetadataEvent( const val KIND = 39000 + /** + * Tag names a metadata edit sets explicitly from its own inputs, so [unmanagedTags] + * must not carry them over (they'd be duplicated or resurrect a cleared value). + * Includes both polarities of each status flag and the Buzz `visibility` knob. + */ + val EDIT_MANAGED_TAG_NAMES = + setOf( + "d", + "h", + "name", + "about", + "picture", + "banner", + "t", + "g", + ParentTag.TAG_NAME, + ChildTag.TAG_NAME, + "previous", + "private", + "public", + "restricted", + "unrestricted", + "hidden", + "visible", + "closed", + "open", + "visibility", + ) + fun build( groupId: String, name: String? = null, about: String? = null, picture: String? = null, + banner: String? = null, status: Set = emptySet(), supportedKinds: List? = null, hashtags: List = emptyList(), @@ -194,6 +236,7 @@ class GroupMetadataEvent( name?.let { add(arrayOf("name", it)) } about?.let { add(arrayOf("about", it)) } picture?.let { add(arrayOf("picture", it)) } + banner?.let { add(arrayOf("banner", it)) } status.forEach { add(arrayOf(it.code)) } supportedKinds?.let { kinds -> add((listOf("supported_kinds") + kinds.map { it.toString() }).toTypedArray()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupPinnedEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupPinnedEvent.kt index a15aa5f589..299edf77cf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupPinnedEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupPinnedEvent.kt @@ -21,12 +21,16 @@ package com.vitorpamplona.quartz.nip29RelayGroups.metadata import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address 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.core.mapValueTagged import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.groupPins +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.pinnedAddresses +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.pinnedEventIds +import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupPin import com.vitorpamplona.quartz.utils.TimeUtils /** @@ -35,8 +39,9 @@ import com.vitorpamplona.quartz.utils.TimeUtils * (kind 9010) moderation actions, so this is the read side clients render — the * source of truth for which messages are pinned and in what display order. * - * Addressed by the group id (`d` tag). The pinned event ids are carried as `e` - * tags in display order. + * Addressed by the group id (`d` tag). The pins are carried as `e` tags (regular + * events, by id) and `a` tags (addressable events, by `kind:pubkey:d`), interleaved in + * display order. */ @Immutable class GroupPinnedEvent( @@ -49,20 +54,26 @@ class GroupPinnedEvent( ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { fun groupId() = dTag() - /** Pinned message ids, in the relay's display order. */ - fun pinnedEventIds(): List = tags.mapValueTagged("e") { it } + /** The full ordered pin list — `e` and `a` references — in the relay's display order. */ + fun pins(): List = tags.groupPins() + + /** Only the `e`-tagged pinned event ids, in order. Prefer [pins], which also carries `a` pins. */ + fun pinnedEventIds(): List = tags.pinnedEventIds() + + /** Only the `a`-tagged pinned addresses, in order. */ + fun pinnedAddresses(): List
= tags.pinnedAddresses() companion object { const val KIND = 39005 fun build( groupId: String, - pinnedEventIds: List, + pins: List, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { dTag(groupId) - pinnedEventIds.forEach { add(arrayOf("e", it)) } + groupPins(pins) initializer() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt index 76d0b93ac1..8d8da55926 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt @@ -51,6 +51,10 @@ class EditMetadataEvent( fun about() = tags.firstTagValue("about") + fun picture() = tags.firstTagValue("picture") + + fun banner() = tags.firstTagValue("banner") + fun hashtags() = tags.hashtags() fun geohashes() = tags.geohashes() @@ -84,6 +88,7 @@ class EditMetadataEvent( name: String? = null, about: String? = null, picture: String? = null, + banner: String? = null, status: Set = emptySet(), hashtags: List = emptyList(), geohashes: List = emptyList(), @@ -96,6 +101,10 @@ class EditMetadataEvent( // this tag to take. [archived] toggles the channel's archived state ("true"/"false"). visibility: String? = null, archived: Boolean? = null, + // Metadata tags this edit doesn't set itself (e.g. `livekit`, `supported_kinds`, a newer + // spec field), usually GroupMetadataEvent.unmanagedTags() of the current 39000, so the + // edit doesn't erase them. Tags whose names this builder manages are skipped. + extraTags: List> = emptyList(), createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { @@ -103,6 +112,7 @@ class EditMetadataEvent( name?.let { add(arrayOf("name", it)) } about?.let { add(arrayOf("about", it)) } picture?.let { add(arrayOf("picture", it)) } + banner?.let { add(arrayOf("banner", it)) } status.forEach { add(arrayOf(it.code)) } visibility?.let { add(arrayOf("visibility", it)) } archived?.let { add(arrayOf("archived", if (it) "true" else "false")) } @@ -112,6 +122,7 @@ class EditMetadataEvent( parent?.let { parentGroup(it) } childGroups(children) previous(previousEvents) + extraTags.forEach { if (it.isNotEmpty() && it[0] !in GroupMetadataEvent.EDIT_MANAGED_TAG_NAMES) add(it) } initializer() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayBuilderExt.kt index 89e095edc2..6e00fd2640 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayBuilderExt.kt @@ -26,12 +26,26 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip29RelayGroups.tags.ChildTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.CodeTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupPin import com.vitorpamplona.quartz.nip29RelayGroups.tags.ParentTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.PreviousTag fun TagArrayBuilder.groupId(groupId: String) = addUnique(GroupIdTag.assemble(groupId)) -fun TagArrayBuilder.previous(eventIdPrefixes: List) = addAll(PreviousTag.assemble(eventIdPrefixes)) +/** + * Adds the NIP-29 timeline references as ONE `previous` tag holding every prefix + * (`["previous", "eb96c864", "2db75638", …]`). Nothing is added for an empty list. + */ +fun TagArrayBuilder.previous(eventIdPrefixes: List): TagArrayBuilder { + PreviousTag.assemble(eventIdPrefixes)?.let { addUnique(it) } + return this +} + +/** Appends the ordered pin list (`e` and `a` references) of a kind-9010 / kind-39005 event. */ +fun TagArrayBuilder.groupPins(pins: List): TagArrayBuilder { + pins.forEach { add(it.toTagArray()) } + return this +} /** Sets the subgroup `parent` tag (the parent group's id). At most one per event. */ fun TagArrayBuilder.parentGroup(parentGroupId: String) = addUnique(ParentTag.assemble(parentGroupId)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt index e3cc924fc2..4a1e9f26d0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt @@ -20,20 +20,33 @@ */ package com.vitorpamplona.quartz.nip29RelayGroups.moderation +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastForEach import com.vitorpamplona.quartz.nip01Core.core.firstTagValue import com.vitorpamplona.quartz.nip01Core.core.mapValueTagged import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.AddressPin import com.vitorpamplona.quartz.nip29RelayGroups.tags.ChildTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.CodeTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.EventPin import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupPin import com.vitorpamplona.quartz.nip29RelayGroups.tags.ParentTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.PreviousTag fun TagArray.groupId() = firstTagValue(GroupIdTag.TAG_NAME) -fun TagArray.previousEvents() = mapNotNull(PreviousTag::parse) +/** + * Every `previous` reference prefix, across ALL `previous` tags: the spec's single + * multi-value tag as well as the legacy one-tag-per-prefix form. + */ +fun TagArray.previousEvents(): List { + val result = ArrayList() + fastForEach { tag -> PreviousTag.parse(tag)?.let { result.addAll(it) } } + return result +} /** The `parent` group id (subgroups), or null when this is a root group. At most one is expected. */ fun TagArray.parentGroupId() = firstNotNullOfOrNull(ParentTag::parse) @@ -45,6 +58,13 @@ fun TagArray.userPubKeys(): List = mapNotNull(PTag::parseKey) fun TagArray.deletedEventIds(): List = mapValueTagged("e") { it } -fun TagArray.pinnedEventIds(): List = mapValueTagged("e") { it } +/** The ordered pin list: `e` (event id) and `a` (address) references, interleaved as sent. */ +fun TagArray.groupPins(): List = mapNotNull(GroupPin::parse) + +/** Just the `e`-tagged pinned event ids, in order. Use [groupPins] to also see `a` pins. */ +fun TagArray.pinnedEventIds(): List = mapNotNull { EventPin.parse(it)?.eventId } + +/** Just the `a`-tagged pinned addresses, in order. */ +fun TagArray.pinnedAddresses(): List
= mapNotNull { AddressPin.parse(it)?.address } fun TagArray.inviteCode() = firstNotNullOfOrNull(CodeTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/UpdatePinListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/UpdatePinListEvent.kt index 9a53d7e7cc..2c8a313908 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/UpdatePinListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/UpdatePinListEvent.kt @@ -25,11 +25,13 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupPin import com.vitorpamplona.quartz.utils.TimeUtils /** * NIP-29 `update-pin-list` moderation event (kind 9010). Carries the group `h` - * tag plus the FULL list of pinned message ids as `e` tags — pinning, unpinning, + * tag plus the FULL ordered pin list as `e` tags (regular events) and `a` tags + * (addressable events) — pinning, unpinning, * reordering and clearing pins are all done by submitting a new complete list. * The relay checks the sender's role, applies it, and republishes the group's * kind-39005 [com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent]. @@ -45,19 +47,25 @@ class UpdatePinListEvent( ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { fun groupId() = tags.groupId() + /** The full ordered pin list — `e` and `a` references. */ + fun pins() = tags.groupPins() + + /** Only the `e`-tagged pinned event ids. Prefer [pins], which also carries `a` pins. */ fun pinnedEventIds() = tags.pinnedEventIds() + fun pinnedAddresses() = tags.pinnedAddresses() + companion object { const val KIND = 9010 fun build( groupId: String, - pinnedEventIds: List, + pins: List, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { groupId(groupId) - pinnedEventIds.forEach { add(arrayOf("e", it)) } + groupPins(pins) initializer() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/GroupPin.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/GroupPin.kt new file mode 100644 index 0000000000..b1a4fe2a0a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/GroupPin.kt @@ -0,0 +1,121 @@ +/* + * 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.nip29RelayGroups.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote +import com.vitorpamplona.quartz.utils.Hex + +/** + * One entry of a NIP-29 group pin list — the kind-9010 `update-pin-list` write and the + * relay-signed kind-39005 read side. Pins are either a regular event (`e` tag, event id + * hex) or an addressable event (`a` tag, `::`), interleaved in display + * order. + * + * [ref] is the pin's identity (the id or the address value), so a list can be edited + * without caring which kind of reference each entry is. Any extra tag values (relay hint, + * marker…) are kept verbatim so re-submitting another client's pins doesn't lose them. + */ +@Immutable +sealed interface GroupPin { + /** The event id hex (for `e`) or the `kind:pubkey:d` address value (for `a`). */ + val ref: String + + fun toTagArray(): Array + + companion object { + fun parse(tag: Array): GroupPin? = EventPin.parse(tag) ?: AddressPin.parse(tag) + + /** + * A pin from a user-facing reference: a 64-hex id, `note1…` or `nevent1…` becomes an `e` + * pin; `naddr1…` or a raw `::` becomes an `a` pin. Null when unparseable. + */ + fun fromReference(input: String): GroupPin? { + val trimmed = input.trim().removePrefix("nostr:") + if (trimmed.length == 64 && Hex.isHex64(trimmed)) return EventPin(trimmed.lowercase()) + if (':' in trimmed) return Address.parse(trimmed)?.let { AddressPin(it) } + return when (val entity = Nip19Parser.uriToRoute(trimmed)?.entity) { + is NAddress -> AddressPin(entity.address()) + is NEvent -> EventPin(entity.hex) + is NNote -> EventPin(entity.hex) + else -> null + } + } + } +} + +@Immutable +class EventPin( + val eventId: HexKey, + val extras: List = emptyList(), +) : GroupPin { + override val ref: String get() = eventId + + override fun toTagArray() = arrayOf(TAG_NAME, eventId, *extras.toTypedArray()) + + override fun equals(other: Any?) = other is EventPin && other.eventId == eventId + + override fun hashCode() = eventId.hashCode() + + override fun toString() = "EventPin($eventId)" + + companion object { + const val TAG_NAME = "e" + + fun parse(tag: Array): EventPin? { + if (!tag.has(1) || tag[0] != TAG_NAME) return null + if (tag[1].length != 64 || !Hex.isHex64(tag[1])) return null + return EventPin(tag[1], if (tag.size > 2) tag.copyOfRange(2, tag.size).asList() else emptyList()) + } + } +} + +@Immutable +class AddressPin( + val address: Address, + val extras: List = emptyList(), +) : GroupPin { + override val ref: String get() = address.toValue() + + override fun toTagArray() = arrayOf(TAG_NAME, address.toValue(), *extras.toTypedArray()) + + override fun equals(other: Any?) = other is AddressPin && other.ref == ref + + override fun hashCode() = ref.hashCode() + + override fun toString() = "AddressPin($ref)" + + companion object { + const val TAG_NAME = "a" + + fun parse(tag: Array): AddressPin? { + if (!tag.has(1) || tag[0] != TAG_NAME) return null + val address = Address.parse(tag[1]) ?: return null + return AddressPin(address, if (tag.size > 2) tag.copyOfRange(2, tag.size).asList() else emptyList()) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/PreviousTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/PreviousTag.kt index 7e7cf80b79..8d9d7b887f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/PreviousTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/PreviousTag.kt @@ -21,21 +21,37 @@ package com.vitorpamplona.quartz.nip29RelayGroups.tags import com.vitorpamplona.quartz.nip01Core.core.has -import com.vitorpamplona.quartz.utils.ensure +/** + * NIP-29 timeline references: a SINGLE `previous` tag carrying every referenced + * event-id prefix (the first 8 hex chars) as its values: + * ``` + * ["previous", "eb96c864", "2db75638", "b5d1065f"] + * ``` + * relay29 only reads the first `previous` tag (`Tags.GetFirst`), so the old shape — one + * tag per prefix — made the relay check just the first reference. Parsing still accepts + * that legacy multi-tag form: [parse] returns every value of one tag, and callers read + * all `previous` tags. + */ class PreviousTag { companion object { const val TAG_NAME = "previous" - fun parse(tag: Array): String? { - ensure(tag.has(1)) { return null } - ensure(tag[0] == TAG_NAME) { return null } - ensure(tag[1].isNotEmpty()) { return null } - return tag[1] + /** Every non-empty prefix carried by one `previous` tag, or null when [tag] isn't one. */ + fun parse(tag: Array): List? { + if (!tag.has(1) || tag[0] != TAG_NAME) return null + val prefixes = ArrayList(tag.size - 1) + for (i in 1 until tag.size) { + if (tag[i].isNotEmpty()) prefixes.add(tag[i]) + } + return prefixes.ifEmpty { null } } - fun assemble(eventIdPrefix: String) = arrayOf(TAG_NAME, eventIdPrefix) - - fun assemble(eventIdPrefixes: List) = eventIdPrefixes.map { assemble(it) } + /** One `previous` tag holding all [eventIdPrefixes], or null when there are none to send. */ + fun assemble(eventIdPrefixes: List): Array? { + val values = eventIdPrefixes.filter { it.isNotEmpty() } + if (values.isEmpty()) return null + return arrayOf(TAG_NAME, *values.toTypedArray()) + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/SimpleGroupListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/SimpleGroupListEvent.kt index 8fb1843951..d7041b22f7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/SimpleGroupListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/SimpleGroupListEvent.kt @@ -114,6 +114,31 @@ class SimpleGroupListEvent( ) } + /** + * Swaps [from] for [to] in one signed version — e.g. a NIP-29 group that migrated to another + * relay keeps its id but gets a new relay hint. [from] is dropped from both the public tags and + * the private items; [to] is added as a public tag. + */ + suspend fun replace( + earlierVersion: SimpleGroupListEvent, + from: GroupTag, + to: GroupTag, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): SimpleGroupListEvent { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + return resign( + privateTags = privateTags.remove(from.toTagIdOnly()), + tags = + earlierVersion.tags + .remove(from.toTagIdOnly()) + .remove(to.toTagIdOnly()) + .plus(to.toTagArray()), + signer = signer, + createdAt = createdAt, + ) + } + suspend fun resign( tags: TagArray, privateTags: TagArray, diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29SpecUpdatesTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29SpecUpdatesTest.kt new file mode 100644 index 0000000000..331bbe3dec --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29SpecUpdatesTest.kt @@ -0,0 +1,193 @@ +/* + * 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.nip29RelayGroups + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.previous +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.previousEvents +import com.vitorpamplona.quartz.nip29RelayGroups.tags.AddressPin +import com.vitorpamplona.quartz.nip29RelayGroups.tags.EventPin +import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupPin +import com.vitorpamplona.quartz.nip29RelayGroups.tags.PreviousTag +import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag +import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * NIP-29 revisions from 2026-07/08: the single multi-value `previous` tag, the `banner` + * metadata field, carrying unknown metadata through a kind-9002 edit, and URL-decoded + * `naddr1…?invite=` codes. + */ +class Nip29SpecUpdatesTest { + private val gid = "0123456789abcdef" + private val relaySelf = "aa".repeat(32) + + @Test + fun previousIsOneTagWithEveryPrefix() { + val template = eventTemplate(9, "hi") { previous(listOf("eb96c864", "2db75638", "b5d1065f")) } + val previousTags = template.tags.filter { it[0] == PreviousTag.TAG_NAME } + + // Spec example: ["previous", "eb96c864", "2db75638", "b5d1065f"] — relay29 reads only the first tag. + assertEquals(1, previousTags.size) + assertEquals(listOf("previous", "eb96c864", "2db75638", "b5d1065f"), previousTags.single().toList()) + } + + @Test + fun noPreviousTagWhenThereAreNoReferences() { + val template = eventTemplate(9, "hi") { previous(emptyList()) } + assertEquals(0, template.tags.count { it[0] == PreviousTag.TAG_NAME }) + } + + @Test + fun previousEventsReadsAllValuesOfTheSpecForm() { + val tags = arrayOf(arrayOf("h", gid), arrayOf("previous", "eb96c864", "2db75638", "b5d1065f")) + assertEquals(listOf("eb96c864", "2db75638", "b5d1065f"), tags.previousEvents()) + } + + @Test + fun previousEventsToleratesTheLegacyOneTagPerPrefixForm() { + val tags = arrayOf(arrayOf("previous", "eb96c864"), arrayOf("h", gid), arrayOf("previous", "2db75638", "b5d1065f"), arrayOf("previous", "")) + assertEquals(listOf("eb96c864", "2db75638", "b5d1065f"), tags.previousEvents()) + } + + @Test + fun editMetadataCarriesPreviousAsOneTag() { + val template = EditMetadataEvent.build(gid, name = "x", previousEvents = listOf("11111111", "22222222")) + assertEquals(listOf(listOf("previous", "11111111", "22222222")), template.tags.filter { it[0] == "previous" }.map { it.toList() }) + } + + @Test + fun bannerRoundTripsThroughMetadataAndEdit() { + val meta = GroupMetadataEvent.build(gid, name = "Pizza", picture = "https://p/p.png", banner = "https://p/banner.png") + val parsed = GroupMetadataEvent("00".repeat(32), relaySelf, 1, meta.tags, "", "22".repeat(64)) + assertEquals("https://p/banner.png", parsed.banner()) + assertEquals("https://p/p.png", parsed.picture()) + + val edit = EditMetadataEvent.build(gid, name = "Pizza", banner = "https://p/banner2.png") + assertEquals("https://p/banner2.png", EditMetadataEvent("00".repeat(32), relaySelf, 1, edit.tags, "", "22".repeat(64)).banner()) + } + + @Test + fun unmanagedMetadataTagsSurviveAnEdit() { + val tags = + arrayOf( + arrayOf("d", gid), + arrayOf("name", "Pizza"), + arrayOf("banner", "https://p/banner.png"), + arrayOf("private"), + arrayOf("livekit"), + arrayOf("supported_kinds", "9", "11"), + arrayOf("future_field", "v"), + arrayOf("t", "food"), + arrayOf("parent", "root"), + ) + val current = GroupMetadataEvent("00".repeat(32), relaySelf, 1, tags, "", "22".repeat(64)) + + assertEquals( + listOf(listOf("livekit"), listOf("supported_kinds", "9", "11"), listOf("future_field", "v")), + current.unmanagedTags().map { it.toList() }, + ) + + val edit = + EditMetadataEvent.build( + gid, + name = "Pizza Lovers", + banner = current.banner(), + extraTags = current.unmanagedTags() + listOf(arrayOf("name", "should be ignored")), + ) + val names = edit.tags.map { it[0] } + assertEquals(1, names.count { it == "name" }) + assertEquals("Pizza Lovers", edit.tags.first { it[0] == "name" }[1]) + assertEquals(listOf("h", "name", "banner", "livekit", "supported_kinds", "future_field"), names) + } + + @Test + fun inviteCodeIsUrlDecoded() { + assertEquals("a/b c", GroupNAddrInvite.parse("?invite=a%2Fb%20c")) + assertEquals("café", GroupNAddrInvite.parse("?invite=caf%C3%A9")) + assertEquals("100%", GroupNAddrInvite.parse("?invite=100%")) + assertEquals("a+b", GroupNAddrInvite.parse("?invite=a+b")) + } + + @Test + fun parsesAWholeNaddrWithInviteSuffix() { + val relay = RelayUrlNormalizer.normalizeOrNull("wss://groups.example.com")!! + val naddr = NAddress.create(GroupMetadataEvent.KIND, relaySelf, gid, relay) + + val withCode = assertNotNull(GroupNAddrInvite.parseReference("$naddr?invite=xyz%21")) + assertEquals(GroupId(gid, relay), withCode.groupId) + assertEquals("xyz!", withCode.inviteCode) + + val bare = assertNotNull(GroupNAddrInvite.parseReference("nostr:$naddr")) + assertEquals(GroupId(gid, relay), bare.groupId) + assertNull(bare.inviteCode) + + // Not a group: wrong kind, or no relay hint to host it. + assertNull(GroupNAddrInvite.parseReference(NAddress.create(30023, relaySelf, gid, relay))) + assertNull(GroupNAddrInvite.parseReference(NAddress.create(GroupMetadataEvent.KIND, relaySelf, gid, null))) + assertNull(GroupNAddrInvite.parseReference("wss://groups.example.com")) + } + + @Test + fun pinReferencesParseToEventOrAddressPins() { + val id = "ab".repeat(32) + val author = "cd".repeat(32) + val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!! + + assertEquals(EventPin(id), GroupPin.fromReference(id)) + assertEquals(EventPin(id), GroupPin.fromReference(NNote.create(id))) + assertEquals(EventPin(id), GroupPin.fromReference("nostr:" + NEvent.create(id, author, 1, relay))) + assertEquals(AddressPin(Address(30023, author, "art")), GroupPin.fromReference("30023:$author:art")) + assertEquals(AddressPin(Address(30023, author, "art")), GroupPin.fromReference(NAddress.create(30023, author, "art", relay))) + assertNull(GroupPin.fromReference("not a reference")) + } + + @Test + fun replaceMovesAGroupToANewRelayInOneVersion() = + runTest { + val signer = NostrSignerInternal(KeyPair()) + val old = GroupTag(gid, "wss://old.example.com/", "Pizza") + val other = GroupTag("other", "wss://old.example.com/", null) + val list = SimpleGroupListEvent.create(publicGroups = listOf(old, other), signer = signer) + + val moved = SimpleGroupListEvent.replace(list, old, GroupTag(gid, "wss://new.example.com/", "Pizza"), signer) + + assertEquals( + listOf(gid to "wss://new.example.com/", "other" to "wss://old.example.com/").toSet(), + moved.publicGroups().map { it.groupId to it.relayUrl }.toSet(), + ) + assertEquals(2, moved.publicGroups().size) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/PinEventsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/PinEventsTest.kt index b5ee8e80f3..548093c792 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/PinEventsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/PinEventsTest.kt @@ -22,13 +22,17 @@ package com.vitorpamplona.quartz.nip29RelayGroups import com.vitorpamplona.quartz.buzz.cwChannelWindow.ThreadSummaryContent import com.vitorpamplona.quartz.buzz.cwChannelWindow.ThreadSummaryEvent +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent +import com.vitorpamplona.quartz.nip29RelayGroups.tags.AddressPin +import com.vitorpamplona.quartz.nip29RelayGroups.tags.EventPin import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag import com.vitorpamplona.quartz.utils.EventFactory import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue /** * NIP-29 message-pinning wire format (PR #2379): the kind-9010 `update-pin-list` @@ -77,7 +81,7 @@ class PinEventsTest { /** Both classes' own builders keep that shape, so outbound signing routes back to the right class. */ @Test fun bothBuildersRoundTripThroughTheFactory() { - val pin = GroupPinnedEvent.build(gid, listOf(id1, id2)) + val pin = GroupPinnedEvent.build(gid, listOf(EventPin(id1), EventPin(id2))) val summary = ThreadSummaryEvent.build(id1, gid, ThreadSummaryContent(replyCount = 1, descendantCount = 1)) val parsedPin: Event = EventFactory.create("00".repeat(32), relaySelf, 100, pin.kind, pin.tags, pin.content, "22".repeat(64)) @@ -100,7 +104,7 @@ class PinEventsTest { @Test fun updatePinListBuildCarriesHTagAndFullList() { - val template = UpdatePinListEvent.build(gid, listOf(id1, id2)) + val template = UpdatePinListEvent.build(gid, listOf(EventPin(id1), EventPin(id2))) assertEquals(UpdatePinListEvent.KIND, template.kind) assertEquals(gid, template.tags.firstOrNull { it[0] == GroupIdTag.TAG_NAME }?.getOrNull(1)) @@ -109,10 +113,81 @@ class PinEventsTest { @Test fun groupPinnedBuildCarriesDTagAndFullList() { - val template = GroupPinnedEvent.build(gid, listOf(id1, id2, id3)) + val template = GroupPinnedEvent.build(gid, listOf(EventPin(id1), EventPin(id2), EventPin(id3))) assertEquals(GroupPinnedEvent.KIND, template.kind) assertEquals(gid, template.tags.firstOrNull { it[0] == "d" }?.getOrNull(1)) assertEquals(listOf(id1, id2, id3), template.tags.filter { it[0] == "e" }.map { it[1] }) } + + private val author = "bb".repeat(32) + private val addr = "30023:$author:my-article" + + /** NIP-29 (#2416): the 39005 list interleaves `e` and `a` references and keeps their order. */ + @Test + fun groupPinnedEventParsesMixedEventAndAddressPinsInOrder() { + val tags = + arrayOf( + arrayOf("d", gid), + arrayOf("e", id1), + arrayOf("a", addr, "wss://relay.example.com/"), + arrayOf("e", id2), + ) + val event: Event = EventFactory.create("00".repeat(32), relaySelf, 100, GroupPinnedEvent.KIND, tags, "", "22".repeat(64)) + + assertTrue(event is GroupPinnedEvent, "an a-tagged pin list has no `h`, so it is still a pin list, not a Buzz summary") + event as GroupPinnedEvent + assertEquals(listOf(id1, addr, id2), event.pins().map { it.ref }) + assertTrue(event.pins()[1] is AddressPin) + assertEquals(listOf(id1, id2), event.pinnedEventIds()) + assertEquals(listOf(addr), event.pinnedAddresses().map { it.toValue() }) + } + + @Test + fun updatePinListParsesAddressPins() { + val tags = arrayOf(arrayOf("h", gid), arrayOf("a", addr), arrayOf("e", id1)) + val event: Event = EventFactory.create("00".repeat(32), relaySelf, 100, UpdatePinListEvent.KIND, tags, "", "22".repeat(64)) + + event as UpdatePinListEvent + assertEquals(listOf(addr, id1), event.pins().map { it.ref }) + } + + /** Re-submitting a pin list must reproduce every entry — relay hints included — in order. */ + @Test + fun updatePinListBuildRoundTripsMixedPinsVerbatim() { + val pinned = + GroupPinnedEvent( + "00".repeat(32), + relaySelf, + 100, + arrayOf(arrayOf("d", gid), arrayOf("e", id1, "wss://r.example/"), arrayOf("a", addr, "wss://relay.example.com/")), + "", + "22".repeat(64), + ) + val newPin = AddressPin(Address(30023, author, "second")) + val template = UpdatePinListEvent.build(gid, pinned.pins() + newPin) + + assertEquals( + listOf( + listOf("e", id1, "wss://r.example/"), + listOf("a", addr, "wss://relay.example.com/"), + listOf("a", "30023:$author:second"), + ), + template.tags.filter { it[0] == "e" || it[0] == "a" }.map { it.toList() }, + ) + } + + @Test + fun malformedPinsAreSkipped() { + val tags = arrayOf(arrayOf("d", gid), arrayOf("e", "not-hex"), arrayOf("a", "garbage"), arrayOf("e", id3)) + val event = GroupPinnedEvent("00".repeat(32), relaySelf, 100, tags, "", "22".repeat(64)) + + assertEquals(listOf(id3), event.pins().map { it.ref }) + } + + @Test + fun pinEqualityIsByReference() { + assertEquals(EventPin(id1), EventPin(id1, listOf("wss://r.example/"))) + assertEquals(AddressPin(Address(30023, author, "my-article")), AddressPin(Address(30023, author, "my-article"), listOf("wss://x/"))) + } } From e40938bd8856a9babdfe806d77a1cf2c5b801e9c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:13:23 +0000 Subject: [PATCH 05/24] feat(nip29): preserve a-pins, banner, metadata carry-over, migration detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RelayGroupChannel models the 39005 pin list as ordered e/a GroupPins; pin/unpin re-submit the full list (other clients' `a` pins intact) and addressable notes are pinned by address. The pinned bar renders `a` pins and the state filter back-fills them by kind/author/#d. - Metadata edits carry `banner` and the current 39000's unmanaged tags (livekit, supported_kinds, …) except on Buzz; the edit form gains a banner field and the invite card shows the banner. - The subgroup parent picker only shows when the relay's NIP-11 advertises nip29.subgroups (or the group already has a parent). - Migration/fork detection: joined groups cache their admins (RelayGroupAdminCache, persisted), watch admins' and followed members' kind:10009 and, when one lists the group on another relay, show a bar offering to open it there or move (rewrites our own 10009). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc --- .../com/vitorpamplona/amethyst/AppModules.kt | 5 + .../model/AccountRelayGroupActions.kt | 79 +++++-- .../amethyst/ui/components/RelayGroupCard.kt | 14 ++ .../ui/screen/loggedIn/AccountViewModel.kt | 10 +- .../ChannelFilterAssemblerSubscription.kt | 2 +- .../relayGroup/RelayGroupChannelView.kt | 6 + .../relayGroup/RelayGroupMetadataScreen.kt | 36 +++- .../relayGroup/RelayGroupMetadataViewModel.kt | 13 +- .../relayGroup/RelayGroupMigrationBar.kt | 192 ++++++++++++++++++ .../relayGroup/RelayGroupPinnedBar.kt | 18 +- .../nip29RelayGroups/RelayGroupChannel.kt | 40 +++- .../nip29RelayGroups/RelayGroupListState.kt | 13 ++ .../nip29RelayGroups/RelayGroupMigration.kt | 147 ++++++++++++++ .../preferences/RelayGroupAdminCacheStore.kt | 92 +++++++++ .../channel/FilterRelayGroupState.kt | 25 ++- .../RelayGroupMigrationDetectorTest.kt | 139 +++++++++++++ .../channel/FilterRelayGroupStateTest.kt | 27 +++ .../nip29RelayGroups/RelayGroupChannelTest.kt | 26 +++ .../composeResources/values/strings.xml | 8 + 19 files changed, 860 insertions(+), 32 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMigrationBar.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMigration.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayGroupAdminCacheStore.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMigrationDetectorTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 3e0c5f6a6e..7a188295a3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.commons.model.preferences.BuzzWorkspaceStore import com.vitorpamplona.amethyst.commons.model.preferences.DrawerSectionCollapsePreferences import com.vitorpamplona.amethyst.commons.model.preferences.NamecoinSettingsStore import com.vitorpamplona.amethyst.commons.model.preferences.OtsSettingsStore +import com.vitorpamplona.amethyst.commons.model.preferences.RelayGroupAdminCacheStore import com.vitorpamplona.amethyst.commons.model.preferences.RelayGroupDeletionStore import com.vitorpamplona.amethyst.commons.model.preferences.TorSettingsStore import com.vitorpamplona.amethyst.commons.model.preferences.UiSettingsStore @@ -363,6 +364,10 @@ class AppModules( val relayGroupDeletionPrefs = RelayGroupDeletionStore(sharedSettingsStore, applicationIOScope) + // Restore + persist the last-known admins of joined NIP-29 groups, so the migration/fork check + // can read their kind-10009 lists even while a group's host relay is down. + val relayGroupAdminCachePrefs = RelayGroupAdminCacheStore(sharedSettingsStore, applicationIOScope) + // Restore + persist which drawer section headings the user has folded away, so the side menu // opens the way they left it (device-global: a collapsed heading is a per-device view choice, // not an account setting worth syncing, unlike the hidden rows beside it in the drawer). diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt index 33612b11ef..e1aee49ee1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.model +import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunPayload import com.vitorpamplona.amethyst.commons.model.cache.LocalCache @@ -43,6 +44,8 @@ import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_ADMIN import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_MEMBER import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_OPEN import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_PRIVATE +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks @@ -67,7 +70,10 @@ import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.previous import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent +import com.vitorpamplona.quartz.nip29RelayGroups.tags.AddressPin +import com.vitorpamplona.quartz.nip29RelayGroups.tags.EventPin import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupPin import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent import com.vitorpamplona.quartz.utils.RandomInstance import kotlinx.serialization.encodeToString @@ -335,6 +341,7 @@ class AccountRelayGroupActions( geohashes: List = emptyList(), parent: String? = null, channelType: String? = null, + banner: String? = null, ): GroupId { // The metadata rides the create event as well as the 9002 below. A plain NIP-29 relay takes // its metadata from the 9002 and ignores these tags; Buzz rejects the 9007 outright without @@ -356,6 +363,7 @@ class AccountRelayGroupActions( name = name, about = about, picture = picture, + banner = banner, status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted), hashtags = hashtags, geohashes = geohashes, @@ -410,34 +418,69 @@ class AccountRelayGroupActions( } /** - * Replace the group's pinned-message list with a kind 9010 update-pin-list event - * (admin/moderator only). NIP-29 carries the FULL list, so the relay applies it and - * republishes the kind-39005 [com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent]. + * Replace the group's pin list with a kind 9010 update-pin-list event (admin/moderator + * only). NIP-29 carries the FULL ordered list — `e` pins and `a` pins — so the relay applies + * it and republishes the kind-39005 [com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent]. */ suspend fun updateRelayGroupPins( channel: RelayGroupChannel, - pinnedEventIds: List, + pins: List, ) { - val template = UpdatePinListEvent.build(channel.groupId.id, pinnedEventIds) + val template = UpdatePinListEvent.build(channel.groupId.id, pins) account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } } - /** Pin [eventId] by appending it to the current list (no-op if already pinned). */ + /** + * Pin [note] by appending it to the current list (no-op if already pinned). An addressable + * note is pinned by its address (`a`), so the pin follows its latest version; anything else + * by event id (`e`). Every existing pin — including other clients' `a` pins — is kept. + */ suspend fun pinRelayGroupMessage( channel: RelayGroupChannel, - eventId: HexKey, + note: Note, ) { - if (channel.isPinned(eventId)) return - updateRelayGroupPins(channel, channel.pinnedEventIds + eventId) + val pin = pinFor(note) ?: return + if (channel.isPinned(pin.ref)) return + updateRelayGroupPins(channel, channel.pinsWith(pin)) } - /** Unpin [eventId] by removing it from the current list (no-op if not pinned). */ + /** Unpin [note] (by address or id) from the current list, keeping every other pin intact. */ suspend fun unpinRelayGroupMessage( channel: RelayGroupChannel, - eventId: HexKey, + note: Note, ) { - if (!channel.isPinned(eventId)) return - updateRelayGroupPins(channel, channel.pinnedEventIds - eventId) + val refs = pinRefsFor(note) + if (refs.none { channel.isPinned(it) }) return + updateRelayGroupPins(channel, channel.pins.filter { it.ref !in refs }) + } + + private fun addressOf(note: Note): Address? = note.address() ?: (note.event as? AddressableEvent)?.address() + + private fun pinFor(note: Note): GroupPin? { + addressOf(note)?.let { return AddressPin(it) } + return EventPin(note.event?.id ?: note.idHex) + } + + /** Both references a note can be pinned under: its address (if addressable) and its event id. */ + private fun pinRefsFor(note: Note): Set = + buildSet { + addressOf(note)?.let { add(it.toValue()) } + note.event?.id?.let { add(it) } + add(note.idHex) + } + + /** + * NIP-29 group migration: the same group id now lives on [newRelay] (moved or forked). Swap this + * group's entry in our kind-10009 for the one on [newRelay] and return the new channel, which the + * caller opens so its events load from the new relay. + */ + suspend fun moveRelayGroup( + channel: RelayGroupChannel, + newRelay: NormalizedRelayUrl, + ): RelayGroupChannel { + val target = LocalCache.getOrCreateRelayGroupChannel(GroupId(channel.groupId.id, newRelay)) + account.sendMyPublicAndPrivateOutbox(account.relayGroupList.move(channel, target)) + return target } /** Kick [pubkey] out of the group with a kind 9001 remove-user event (moderator only). */ @@ -505,6 +548,11 @@ class AccountRelayGroupActions( * re-parenting, we re-carry the group's current [parent] and full [children] list * from its latest known metadata to keep the tree intact across a plain name/flag * edit. Pass an explicit value to change them. + * + * A 9002 also carries "all the fields of group-metadata", so the current 39000's [banner] + * (unless replaced) and every tag this form doesn't manage (`livekit`, `supported_kinds`, a + * newer spec field…) ride along verbatim — otherwise a rename would erase them on a relay that + * rebuilds the metadata from the edit. */ suspend fun editRelayGroupMetadata( channel: RelayGroupChannel, @@ -519,6 +567,7 @@ class AccountRelayGroupActions( geohashes: List = emptyList(), parent: String? = channel.parentGroupId(), children: List = channel.childGroupIds(), + banner: String? = channel.bannerPicture(), ) { // On a Buzz relay, visibility rides a `visibility` ("open"/"private") tag — the relay does NOT // read NIP-29's `private` status flag — so a Buzz channel's visibility only actually changes on @@ -530,12 +579,16 @@ class AccountRelayGroupActions( name = name, about = about, picture = picture, + banner = banner, status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted), hashtags = hashtags, geohashes = geohashes, parent = parent, children = children, visibility = if (isBuzz) (if (isPrivate) BUZZ_VISIBILITY_PRIVATE else BUZZ_VISIBILITY_OPEN) else null, + // Buzz honours only its own subset of tags on a 9002 and stamps relay-internal ones on + // its 39000, so don't echo those back; a NIP-29 relay gets the unmanaged tags verbatim. + extraTags = if (isBuzz) emptyList() else channel.event?.unmanagedTags() ?: emptyList(), ) account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt index cda380757e..acdedd0dd0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -40,10 +41,12 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.navigation.Route @@ -51,6 +54,7 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChann import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.relay_group_badge_invite_only import com.vitorpamplona.amethyst.commons.resources.relay_group_badge_private +import com.vitorpamplona.amethyst.commons.resources.relay_group_field_banner import com.vitorpamplona.amethyst.commons.resources.relay_group_member_count import com.vitorpamplona.amethyst.commons.resources.relay_group_open import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage @@ -134,6 +138,16 @@ private fun RelayGroupCardContent( elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), ) { + // NIP-29 `banner`: a wide header strip across the top of the card, when the group has one. + val banner = channel.bannerPicture()?.takeIf { it.isNotBlank() } + if (banner != null && LocalDisplaySettings.current.showProfilePictures) { + AsyncImage( + model = banner, + contentDescription = stringRes(Res.string.relay_group_field_banner), + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxWidth().height(72.dp), + ) + } Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) { Row( modifier = Modifier.fillMaxWidth(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 64c2e1ec1e..fa0fff99a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1725,6 +1725,12 @@ class AccountViewModel( fun leaveRelayGroup(channel: RelayGroupChannel) = launchSigner { account.relayGroups.leaveRelayGroup(channel) } + /** NIP-29 migration: point our kind-10009 entry for [channel] at [newRelay] instead. */ + fun moveRelayGroup( + channel: RelayGroupChannel, + newRelay: NormalizedRelayUrl, + ) = launchSigner { account.relayGroups.moveRelayGroup(channel, newRelay) } + /** Delete the channel/group for everyone (kind-9008). Owner/admin only; the relay enforces it. */ fun deleteRelayGroup(channel: RelayGroupChannel) = launchSigner { account.relayGroups.deleteRelayGroup(channel) } @@ -1859,12 +1865,12 @@ class AccountViewModel( fun pinRelayGroupMessage( channel: RelayGroupChannel, note: Note, - ) = launchSigner { account.relayGroups.pinRelayGroupMessage(channel, note.idHex) } + ) = launchSigner { account.relayGroups.pinRelayGroupMessage(channel, note) } fun unpinRelayGroupMessage( channel: RelayGroupChannel, note: Note, - ) = launchSigner { account.relayGroups.unpinRelayGroupMessage(channel, note.idHex) } + ) = launchSigner { account.relayGroups.unpinRelayGroupMessage(channel, note) } fun removeRelayGroupUser( channel: RelayGroupChannel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt index 5c44846340..13b127cec4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt @@ -70,7 +70,7 @@ fun ChannelFilterAssemblerSubscription( .flow() .metadata.stateFlow .collectAsStateWithLifecycle() - val pinnedIds = (metadataState.channel as? RelayGroupChannel)?.pinnedEventIds ?: channel.pinnedEventIds + val pinnedIds = (metadataState.channel as? RelayGroupChannel)?.pins ?: channel.pins LaunchedEffect(pinnedIds) { dataSource.invalidateFilters() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt index 9a6aec581c..ea89d2fc92 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt @@ -190,6 +190,12 @@ private fun ChannelView( val jumpToNoteId = remember { mutableStateOf(null) } Column(Modifier.fillMaxHeight()) { + RelayGroupMigrationBar( + channel = liveChannel, + accountViewModel = accountViewModel, + nav = nav, + ) + RelayGroupPinnedBar( channel = liveChannel, accountViewModel = accountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt index ad1cef5f5d..1cbb3cdd66 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt @@ -81,6 +81,7 @@ import com.vitorpamplona.amethyst.commons.resources.relay_group_change_photo import com.vitorpamplona.amethyst.commons.resources.relay_group_create_title import com.vitorpamplona.amethyst.commons.resources.relay_group_edit_title import com.vitorpamplona.amethyst.commons.resources.relay_group_field_about +import com.vitorpamplona.amethyst.commons.resources.relay_group_field_banner import com.vitorpamplona.amethyst.commons.resources.relay_group_field_geohash import com.vitorpamplona.amethyst.commons.resources.relay_group_field_geohash_hint import com.vitorpamplona.amethyst.commons.resources.relay_group_field_name @@ -212,6 +213,16 @@ private fun RelayGroupMetadataScaffold( val context = LocalContext.current val scrollState = rememberScrollState() + // NIP-29 §Subgroups relay support detection: `"nip29": { "subgroups": true }` in the NIP-11. + val subgroupsSupported by produceState(initialValue = false, viewModel.relay) { + val relay = viewModel.relay ?: return@produceState + Amethyst.instance.nip11Cache.loadRelayInfo( + relay = relay, + onInfo = { info -> value = info.nip29?.subgroups == true }, + onError = { _, _, _ -> value = false }, + ) + } + var wantsToPickImage by remember { mutableStateOf(false) } if (wantsToPickImage) { GallerySelectSingle( @@ -281,8 +292,11 @@ private fun RelayGroupMetadataScaffold( GroupMetadataFields(viewModel) - // Sub-groups are a NIP-29 relation; Buzz has no parent channel. - if (!viewModel.isBuzzRelay) { + // Sub-groups are a NIP-29 relation; Buzz has no parent channel. Offer the parent picker + // only when the relay advertises `nip29: { subgroups: true }` in its NIP-11 (otherwise + // it'd reject the `parent` tag), or when this group already has a parent so an admin + // can still detach it. + if (!viewModel.isBuzzRelay && (subgroupsSupported || viewModel.parentGroupId != null)) { Spacer(Modifier.height(16.dp)) ParentGroupSection(viewModel, accountViewModel) } @@ -403,10 +417,22 @@ private fun GroupMetadataFields(viewModel: RelayGroupMetadataViewModel) { ) // Everything below is NIP-29 vocabulary. A Buzz relay stores only `name`, `about` and a - // two-valued `visibility` (its 9002 handler accepts nothing else), so offering hashtags, a - // geohash, or the invite-only/restricted/hidden flags there would be four controls that look - // like they configure the channel and are silently dropped by the relay. + // two-valued `visibility` (its 9002 handler accepts nothing else), so offering a banner, hashtags, + // a geohash, or the invite-only/restricted/hidden flags there would be controls that look like + // they configure the channel and are silently dropped by the relay. if (!viewModel.isBuzzRelay) { + OutlinedTextField( + value = viewModel.banner.value, + onValueChange = { + viewModel.banner.value = it + viewModel.markTouched() + }, + singleLine = true, + label = { Text(stringRes(Res.string.relay_group_field_banner)) }, + placeholder = { Text("https://…") }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + Spacer(Modifier.height(12.dp)) Text( text = stringRes(Res.string.relay_group_section_discovery), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt index a111f8e47c..06273e016a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt @@ -65,7 +65,7 @@ import kotlin.coroutines.cancellation.CancellationException /** * Backs the create/edit NIP-29 group metadata screens. Holds the full editable metadata - * (name, about, picture, and the four status flags), lets the user pick a picture from the + * (name, about, picture, banner, and the four status flags), lets the user pick a picture from the * gallery, uploads it to their configured media server on submit, then publishes the kind * 9007+9002 (create) or 9002 (edit) events. Mirrors [EmojiPackMetadataViewModel]. */ @@ -104,6 +104,9 @@ class RelayGroupMetadataViewModel : ViewModel() { val about = mutableStateOf(TextFieldValue()) val picture = mutableStateOf(TextFieldValue()) + /** NIP-29 `banner`: a wide header image URL for the group. */ + val banner = mutableStateOf(TextFieldValue()) + /** Comma/space-separated topic hashtags; drives the discovery hashtag filter. */ val topics = mutableStateOf(TextFieldValue()) @@ -172,6 +175,7 @@ class RelayGroupMetadataViewModel : ViewModel() { name.value = TextFieldValue(event?.name() ?: "") about.value = TextFieldValue(event?.about() ?: "") picture.value = TextFieldValue(event?.picture() ?: "") + banner.value = TextFieldValue(event?.banner() ?: "") isPrivate = channel.isPrivate() isClosed = channel.isClosed() isHidden = event?.isHidden() ?: false @@ -258,6 +262,10 @@ class RelayGroupMetadataViewModel : ViewModel() { picture.value.text .trim() .ifBlank { null } + val banner = + banner.value.text + .trim() + .ifBlank { null } val hashtags = parseTopics() val geohashes = parseGeohashes() val existing = channel @@ -268,6 +276,7 @@ class RelayGroupMetadataViewModel : ViewModel() { name = name, about = about, picture = picture, + banner = banner, isPrivate = isPrivate, isClosed = isClosed, isHidden = isHidden, @@ -283,6 +292,8 @@ class RelayGroupMetadataViewModel : ViewModel() { name = name, about = about, picture = picture, + // Buzz has no banner field: keep whatever the channel carries rather than clear it. + banner = if (isBuzzRelay) existing.bannerPicture() else banner, isPrivate = isPrivate, isClosed = isClosed, isHidden = isHidden, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMigrationBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMigrationBar.kt new file mode 100644 index 0000000000..315adef376 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMigrationBar.kt @@ -0,0 +1,192 @@ +/* + * 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.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupAdminCache +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMigrationDetector +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.relay_group_migration_by_admins +import com.vitorpamplona.amethyst.commons.resources.relay_group_migration_by_friends +import com.vitorpamplona.amethyst.commons.resources.relay_group_migration_dismiss +import com.vitorpamplona.amethyst.commons.resources.relay_group_migration_fork_title +import com.vitorpamplona.amethyst.commons.resources.relay_group_migration_move +import com.vitorpamplona.amethyst.commons.resources.relay_group_migration_moved_title +import com.vitorpamplona.amethyst.commons.resources.relay_group_migration_open +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent + +/** Cap on how many people's kind-10009 lists one open group fetches for the migration check. */ +private const val MAX_WATCHED_LISTS = 24 + +/** + * NIP-29 §Detecting migrations and forks, for a group the user is in: watches the kind-10009 lists + * of the group's admins (live from kind 39001, plus the locally cached set so this still works while + * the host relay is down) and of the members the user follows. When any of them lists this group id + * on a different relay, a self-hiding bar tells the user the group may have moved (or forked) and + * offers to open it on the new relay, or to move there — which rewrites the user's own kind-10009. + */ +@Composable +fun RelayGroupMigrationBar( + channel: RelayGroupChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val joined by accountViewModel.account.relayGroupList.liveRelayGroupIds + .collectAsStateWithLifecycle() + if (channel.groupId !in joined) return + + val liveAdmins = channel.admins + LaunchedEffect(channel.groupId, liveAdmins) { + RelayGroupAdminCache.remember(channel.groupId, liveAdmins.mapTo(HashSet()) { it.pubKey }) + } + val cachedAdmins by RelayGroupAdminCache.flow.collectAsStateWithLifecycle() + + val follows by accountViewModel.account.kind3FollowList.flow + .collectAsStateWithLifecycle() + + val me = accountViewModel.userProfile().pubkeyHex + val admins = + remember(liveAdmins, cachedAdmins) { + liveAdmins.mapTo(HashSet()) { it.pubKey } + (cachedAdmins[channel.groupId.toKey()] ?: emptySet()) + } + val friends = remember(channel.memberCount(), follows, admins) { channel.participatingFollows(follows.authors).toSet() - admins } + val watched = remember(admins, friends) { (admins + friends - me).take(MAX_WATCHED_LISTS) } + + // Fetches (via the event finder) and observes each watched person's kind-10009. + val lists = + watched.map { pubkey -> + key(pubkey) { + val note = remember(pubkey) { LocalCache.getOrCreateAddressableNote(SimpleGroupListEvent.createAddress(pubkey)) } + observeNoteEvent(note, accountViewModel).value + } + } + + val relocations = + remember(lists, admins, friends) { + RelayGroupMigrationDetector.detect(channel.groupId, lists.filterNotNull(), admins, friends, me) + } + + var dismissed by rememberSaveable(channel.groupId.toKey()) { mutableStateOf(emptyList()) } + val relocation = relocations.firstOrNull { it.relay.url !in dismissed } ?: return + + Surface( + color = MaterialTheme.colorScheme.tertiaryContainer, + modifier = Modifier.fillMaxWidth(), + ) { + Column { + Column(Modifier.padding(start = 12.dp, end = 4.dp, top = 8.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + symbol = MaterialSymbols.SwapHoriz, + contentDescription = null, + tint = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.size(20.dp), + ) + Column(Modifier.weight(1f)) { + Text( + text = + stringRes( + if (relocation.looksLikeMove) { + Res.string.relay_group_migration_moved_title + } else { + Res.string.relay_group_migration_fork_title + }, + ), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + Text( + text = + stringRes( + if (relocation.admins.isNotEmpty()) { + Res.string.relay_group_migration_by_admins + } else { + Res.string.relay_group_migration_by_friends + }, + relocation.relay.displayUrl(), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = { dismissed = dismissed + relocation.relay.url }) { + Text(stringRes(Res.string.relay_group_migration_dismiss)) + } + TextButton(onClick = { nav.nav(Route.RelayGroup(channel.groupId.id, relocation.relay.url)) }) { + Text(stringRes(Res.string.relay_group_migration_open)) + } + TextButton( + onClick = { + accountViewModel.moveRelayGroup(channel, relocation.relay) + nav.nav(Route.RelayGroup(channel.groupId.id, relocation.relay.url)) + }, + ) { + Text(stringRes(Res.string.relay_group_migration_move)) + } + } + } + HorizontalDivider(thickness = DividerThickness) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupPinnedBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupPinnedBar.kt index b4fb5f5abb..71f21dc2a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupPinnedBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupPinnedBar.kt @@ -54,11 +54,13 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip29RelayGroups.tags.AddressPin +import com.vitorpamplona.quartz.nip29RelayGroups.tags.EventPin /** * Self-hiding NIP-29 pinned-message bar shown under the group's top bar. Renders * nothing when the group has no pins (kind-39005 empty), so it never obstructs a - * normal chat. When there are pins it shows a single-line preview of the current + * normal chat. When there are pins (`e` ids or `a` addresses) it shows a single-line preview of the current * one; tapping [onJumpToNote] scrolls the feed to that message and, if the group * has more than one pin, advances to the next for the following tap (Telegram-style * cycling) rather than piling every pin on screen at once. @@ -69,15 +71,23 @@ fun RelayGroupPinnedBar( accountViewModel: AccountViewModel, onJumpToNote: (Note) -> Unit, ) { - val pinnedIds = channel.pinnedEventIds + // The full pin list: `e` pins (regular events) and `a` pins (addressable events) interleaved. + val pinnedIds = channel.pins if (pinnedIds.isEmpty()) return // Newest pin (last in the relay's display order) surfaced first; reset when the list changes. var index by remember(pinnedIds) { mutableIntStateOf(pinnedIds.lastIndex) } val safeIndex = index.coerceIn(0, pinnedIds.lastIndex) - val currentId = pinnedIds[safeIndex] + val currentPin = pinnedIds[safeIndex] - val note = remember(currentId) { LocalCache.checkGetOrCreateNote(currentId) } ?: return + // An `a` pin resolves to the addressable note, whose latest version loads by address. + val note = + remember(currentPin) { + when (currentPin) { + is AddressPin -> LocalCache.getOrCreateAddressableNote(currentPin.address) + is EventPin -> LocalCache.checkGetOrCreateNote(currentPin.eventId) + } + } ?: return // Fetch + observe the pinned message so its author and content fill in once it loads. val noteState by observeNote(note, accountViewModel) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt index 9c029296f8..185928c8c0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.withLock +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress @@ -36,7 +37,10 @@ import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent +import com.vitorpamplona.quartz.nip29RelayGroups.tags.AddressPin +import com.vitorpamplona.quartz.nip29RelayGroups.tags.EventPin import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupAdminTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupPin import com.vitorpamplona.quartz.nip29RelayGroups.tags.RoleTag import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.flow.MutableStateFlow @@ -96,11 +100,24 @@ class RelayGroupChannel( private var adminsUpdatedAt: Long = 0 /** - * Relay-signed pinned message ids (kind 39005), in the relay's display order. - * Pinning replaces the whole list, so this is always the full current set. + * Relay-signed pin list (kind 39005) in the relay's display order: `e` pins (regular + * events, by id) and `a` pins (addressable events, by address) interleaved. Pinning + * replaces the whole list, so this is always the full current set — and a pin/unpin must + * re-submit it intact, `a` entries and their extra tag values included. */ + @Volatile + var pins: List = emptyList() + private set + + /** Only the `e`-tagged pinned event ids of [pins], in order. */ + @Volatile var pinnedEventIds: List = emptyList() private set + + /** Only the `a`-tagged pinned addresses of [pins], in order. */ + @Volatile + var pinnedAddresses: List
= emptyList() + private set private var pinnedUpdatedAt: Long = 0 /** @@ -165,7 +182,7 @@ class RelayGroupChannel( members.isNotEmpty() || admins.isNotEmpty() || supportedRoles.isNotEmpty() || - pinnedEventIds.isNotEmpty() + pins.isNotEmpty() /** A relay group lives on exactly one relay: its host. */ override fun relays() = setOf(groupId.relayUrl) @@ -176,6 +193,9 @@ class RelayGroupChannel( fun profilePicture(): String? = event?.picture() + /** The NIP-29 `banner` header image, when the relay's metadata carries one. */ + fun bannerPicture(): String? = event?.banner() + fun isPrivate(): Boolean = event?.isPrivate() ?: false /** Buzz-only: the relay has archived this channel (hidden from the sidebar, but not deleted). */ @@ -232,7 +252,10 @@ class RelayGroupChannel( // Only newer lists supersede; equal-or-older is dropped so a duplicate // arrival isn't reprocessed (no redundant emit). if (event.createdAt <= pinnedUpdatedAt) return - pinnedEventIds = event.pinnedEventIds() + val newPins = event.pins() + pins = newPins + pinnedEventIds = newPins.mapNotNull { (it as? EventPin)?.eventId } + pinnedAddresses = newPins.mapNotNull { (it as? AddressPin)?.address } pinnedUpdatedAt = event.createdAt updateChannelInfo() } @@ -245,7 +268,14 @@ class RelayGroupChannel( updateChannelInfo() } - fun isPinned(eventId: HexKey): Boolean = eventId in pinnedEventIds + /** Whether [ref] — an event id hex or a `kind:pubkey:d` address value — is pinned. */ + fun isPinned(ref: String): Boolean = pins.any { it.ref == ref } + + /** The current pin list with [pin] appended (unchanged if it's already pinned). */ + fun pinsWith(pin: GroupPin): List = if (isPinned(pin.ref)) pins else pins + pin + + /** The current pin list without the entry for [ref], every other pin kept verbatim. */ + fun pinsWithout(ref: String): List = pins.filter { it.ref != ref } /** * NIP-29 timeline references (`previous` tag) for an event about to be sent to this diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupListState.kt index 1ded0b53f5..dfc8dca1b9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupListState.kt @@ -156,6 +156,19 @@ class RelayGroupListState( ) } + /** + * NIP-29 group migration: replace [from]'s entry with the same group id on [to]'s relay, in one + * signed version of the list (the group keeps its id; only the relay hint changes). + */ + suspend fun move( + from: RelayGroupChannel, + to: RelayGroupChannel, + ): SimpleGroupListEvent { + val target = GroupTag(to.groupId.id, to.groupId.relayUrl.url, to.event?.name() ?: from.event?.name()) + val relayGroupList = getRelayGroupList() ?: return SimpleGroupListEvent.create(publicGroups = listOf(target), signer = signer) + return SimpleGroupListEvent.replace(relayGroupList, from.toGroupTag(), target, signer) + } + init { settings.relayGroupList()?.let { event -> Log.d("AccountRegisterObservers", "Loading saved relay group list") diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMigration.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMigration.kt new file mode 100644 index 0000000000..523d87f95b --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMigration.kt @@ -0,0 +1,147 @@ +/* + * 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.amethyst.commons.model.nip29RelayGroups + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Another relay that people this group trusts now list for the same group id — a sign the group + * moved there, or forked (NIP-29 §Detecting migrations and forks). [admins] are the group's + * (cached) admins pointing at [relay]; [friends] the user's follows doing so. [stillListsCurrent] + * holds everyone in either set who ALSO still lists the relay we use, which reads more like a + * replica/fork than a move. + */ +@Immutable +data class RelayGroupRelocation( + val relay: NormalizedRelayUrl, + val admins: Set, + val friends: Set, + val stillListsCurrent: Set, +) { + /** How many distinct people point at [relay]. */ + val supporters: Int get() = (admins + friends).size + + /** Everyone pointing there dropped our relay: most likely a migration rather than a fork. */ + val looksLikeMove: Boolean get() = (admins + friends).all { it !in stillListsCurrent } +} + +/** + * NIP-29 migration/fork detection. A group's identity is its id plus the relay that enforces it, + * so when the group's admins (or the user's friends) update their kind-10009 list to put the same + * id on another relay, the client should tell the user and offer to follow it there. + * + * Only public `group` tags are readable: other people's private items are encrypted to them. + */ +object RelayGroupMigrationDetector { + /** + * Scans [lists] (kind-10009 events of [admins] and [friends]; others are ignored) for entries of + * [current]'s group id that point at a different relay. Returns one [RelayGroupRelocation] per + * alternative relay, admin-backed ones first, then by number of supporters. [self] is skipped — + * our own list already tells us where we think the group is. + */ + fun detect( + current: GroupId, + lists: Iterable, + admins: Set, + friends: Set, + self: HexKey? = null, + ): List { + val adminsByRelay = HashMap>() + val friendsByRelay = HashMap>() + val stillCurrent = HashSet() + + // Newest list per author only: an older version doesn't reflect where they are now. + val newest = HashMap() + lists.forEach { list -> + if (list.pubKey == self) return@forEach + if (list.pubKey !in admins && list.pubKey !in friends) return@forEach + val previous = newest[list.pubKey] + if (previous == null || list.createdAt > previous.createdAt) newest[list.pubKey] = list + } + + newest.values.forEach { list -> + val author = list.pubKey + list.publicGroups().forEach { tag -> + if (tag.groupId != current.id) return@forEach + val relay = RelayUrlNormalizer.normalizeOrNull(tag.relayUrl) ?: return@forEach + if (relay == current.relayUrl) { + stillCurrent.add(author) + } else if (author in admins) { + adminsByRelay.getOrPut(relay) { HashSet() }.add(author) + } else { + friendsByRelay.getOrPut(relay) { HashSet() }.add(author) + } + } + } + + return (adminsByRelay.keys + friendsByRelay.keys) + .map { relay -> + val relayAdmins = adminsByRelay[relay] ?: emptySet() + val relayFriends = friendsByRelay[relay] ?: emptySet() + RelayGroupRelocation(relay, relayAdmins, relayFriends, (relayAdmins + relayFriends).filterTo(HashSet()) { it in stillCurrent }) + }.sortedWith(compareByDescending { it.admins.size }.thenByDescending { it.supporters }) + } +} + +/** + * Last-known admin pubkeys per NIP-29 group, keyed by [GroupId.toKey]. NIP-29 asks clients to cache + * these so the migration check can still read the admins' kind-10009 lists when the host relay (the + * only source of the kind-39001 admin list) is down. A process-wide singleton mirrored to disk by + * [com.vitorpamplona.amethyst.commons.model.preferences.RelayGroupAdminCacheStore]. + */ +object RelayGroupAdminCache { + private val admins = MutableStateFlow>>(emptyMap()) + + val flow: StateFlow>> = admins + + fun adminsOf(groupId: GroupId): Set = admins.value[groupId.toKey()] ?: emptySet() + + /** Records the current admin set of [groupId] (no-op when unchanged or empty). */ + fun remember( + groupId: GroupId, + pubkeys: Set, + ) { + if (pubkeys.isEmpty()) return + val key = groupId.toKey() + while (true) { + val current = admins.value + if (current[key] == pubkeys) return + if (admins.compareAndSet(current, current + (key to pubkeys))) return + } + } + + /** Replaces the whole map — used to restore from disk at startup. */ + fun restore(map: Map>) { + admins.value = map + } + + /** Test-only: clears the cache so unit tests don't leak state into each other. */ + fun clearForTesting() { + admins.value = emptyMap() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayGroupAdminCacheStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayGroupAdminCacheStore.kt new file mode 100644 index 0000000000..2fcbf9eaf7 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayGroupAdminCacheStore.kt @@ -0,0 +1,92 @@ +/* + * 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.amethyst.commons.model.preferences + +import androidx.compose.runtime.Stable +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringSetPreferencesKey +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupAdminCache +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException + +/** + * Device-global persistence for [RelayGroupAdminCache] — the last-known admins of the NIP-29 groups + * the user is in — so the migration/fork check can read those admins' kind-10009 lists even after a + * restart while the group's host relay is down. Mirrors [RelayGroupDeletionStore]: loads the saved + * map into the singleton on construction, then writes every later change back. Construct once. + */ +@Stable +class RelayGroupAdminCacheStore( + private val store: DataStore, + private val scope: CoroutineScope, +) { + init { + scope.launch { + restoreFromDisk() + RelayGroupAdminCache.flow.drop(1).collect { persist(it) } + } + } + + private suspend fun restoreFromDisk() { + try { + val raw = store.data.first()[KEY] ?: return + if (raw.isNotEmpty()) RelayGroupAdminCache.restore(decode(raw)) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("RelayGroupAdminCache") { "Error reading cached group admins: ${e.message}" } + } + } + + private suspend fun persist(map: Map>) { + try { + store.edit { prefs -> prefs[KEY] = encode(map) } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("RelayGroupAdminCache") { "Error writing cached group admins: ${e.message}" } + } + } + + companion object { + private val KEY = stringSetPreferencesKey("nip29.groupAdmins") + + // One entry per group: "\t,…". Group keys are `id@relay-url` + // and pubkeys are hex, so neither contains a tab or a comma. + private const val SEP = '\t' + + fun encode(map: Map>): Set = map.entries.mapTo(HashSet()) { (key, admins) -> key + SEP + admins.joinToString(",") } + + fun decode(raw: Set): Map> = + raw + .mapNotNull { entry -> + val idx = entry.lastIndexOf(SEP) + if (idx <= 0) return@mapNotNull null + val admins = entry.substring(idx + 1).split(',').filterTo(HashSet()) { it.length == 64 } + if (admins.isEmpty()) null else entry.substring(0, idx) to admins + }.toMap() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupState.kt index 8f7da09578..92c1253805 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupState.kt @@ -66,5 +66,28 @@ fun filterRelayGroupState( relays.map { RelayBasedFilter(relay = it, filter = ExplainedFilter(purpose = SubPurpose.RELAY_GROUPS, ids = pinnedIds)) } } - return directory + pins + // Same back-fill for `a` pins (addressable events, NIP-29 #2416): one kind+author+#d filter per + // pinned address. No `since` either — the pin stays valid however old the latest version is. + val pinnedAddresses = channel.pinnedAddresses + val addressPins = + if (pinnedAddresses.isEmpty()) { + emptyList() + } else { + relays.flatMap { relay -> + pinnedAddresses.map { address -> + RelayBasedFilter( + relay = relay, + filter = + ExplainedFilter( + purpose = SubPurpose.RELAY_GROUPS, + kinds = listOf(address.kind), + authors = listOf(address.pubKeyHex), + tags = mapOf("d" to listOf(address.dTag)), + ), + ) + } + } + } + + return directory + pins + addressPins } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMigrationDetectorTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMigrationDetectorTest.kt new file mode 100644 index 0000000000..edf8158329 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMigrationDetectorTest.kt @@ -0,0 +1,139 @@ +/* + * 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.amethyst.commons.model.nip29RelayGroups + +import com.vitorpamplona.amethyst.commons.model.preferences.RelayGroupAdminCacheStore +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** NIP-29 §Detecting migrations and forks, over other people's kind-10009 lists. */ +class RelayGroupMigrationDetectorTest { + private val gid = "pizza" + private val current = GroupId(gid, RelayUrlNormalizer.normalize("wss://old.example.com")) + private val newRelay = RelayUrlNormalizer.normalize("wss://new.example.com") + private val forkRelay = RelayUrlNormalizer.normalize("wss://fork.example.com") + + private val admin = "aa".repeat(32) + private val friend = "bb".repeat(32) + private val stranger = "cc".repeat(32) + private val me = "dd".repeat(32) + + private fun list( + author: String, + createdAt: Long, + vararg entries: Pair, + ) = SimpleGroupListEvent( + "00".repeat(32), + author, + createdAt, + entries.map { (id, relay) -> arrayOf("group", id, relay) }.toTypedArray(), + "", + "22".repeat(64), + ) + + @AfterTest + fun cleanup() = RelayGroupAdminCache.clearForTesting() + + @Test + fun adminMovedGroupIsReportedAsAMove() { + val found = + RelayGroupMigrationDetector.detect( + current, + listOf(list(admin, 10, gid to "wss://new.example.com/")), + admins = setOf(admin), + friends = emptySet(), + ) + + assertEquals(1, found.size) + assertEquals(newRelay, found[0].relay) + assertEquals(setOf(admin), found[0].admins) + assertTrue(found[0].looksLikeMove) + } + + @Test + fun listingBothRelaysReadsAsAFork() { + val found = + RelayGroupMigrationDetector.detect( + current, + listOf(list(friend, 10, gid to "wss://old.example.com", gid to "wss://fork.example.com")), + admins = emptySet(), + friends = setOf(friend), + ) + + assertEquals(forkRelay, found.single().relay) + assertEquals(setOf(friend), found.single().friends) + assertFalse(found.single().looksLikeMove) + } + + @Test + fun ignoresSameRelayOtherGroupsStrangersSelfAndOlderLists() { + val found = + RelayGroupMigrationDetector.detect( + current, + listOf( + list(admin, 5, gid to "wss://fork.example.com"), // superseded by the newer list below + list(admin, 10, gid to "wss://old.example.com/", "other" to "wss://new.example.com"), + list(stranger, 10, gid to "wss://new.example.com"), + list(me, 10, gid to "wss://new.example.com"), + ), + admins = setOf(admin, me), + friends = setOf(friend), + self = me, + ) + + assertTrue(found.isEmpty(), "got $found") + } + + @Test + fun adminBackedRelaysSortFirst() { + val friend2 = "ee".repeat(32) + val found = + RelayGroupMigrationDetector.detect( + current, + listOf( + list(friend, 10, gid to "wss://fork.example.com"), + list(friend2, 10, gid to "wss://fork.example.com"), + list(admin, 10, gid to "wss://new.example.com"), + ), + admins = setOf(admin), + friends = setOf(friend, friend2), + ) + + assertEquals(listOf(newRelay, forkRelay), found.map { it.relay }) + assertEquals(2, found[1].supporters) + } + + @Test + fun adminCacheRoundTripsThroughItsDiskEncoding() { + RelayGroupAdminCache.remember(current, setOf(admin, friend)) + RelayGroupAdminCache.remember(current, emptySet()) // an empty roster never wipes the cache + + val restored = RelayGroupAdminCacheStore.decode(RelayGroupAdminCacheStore.encode(RelayGroupAdminCache.flow.value)) + assertEquals(mapOf(current.toKey() to setOf(admin, friend)), restored) + assertEquals(setOf(admin, friend), RelayGroupAdminCache.adminsOf(current)) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupStateTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupStateTest.kt index d0ef97fdf9..0fbb5497f6 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupStateTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupStateTest.kt @@ -125,4 +125,31 @@ class FilterRelayGroupStateTest { assertNull(it.filter.tags!!["h"]) } } + + @Test + fun `address pins add a kind-author-d backfill filter`() { + val channel = RelayGroupChannel(groupId) + val author = "b".repeat(64) + channel.updatePinned( + GroupPinnedEvent( + id = "d".repeat(64), + pubKey = relaySignKey, + createdAt = 100L, + tags = arrayOf(arrayOf("d", "g1"), arrayOf("a", "30023:$author:article")), + content = "", + sig = sig, + ), + ) + + val filters = filterRelayGroupState(channel, since = null) + assertEquals(3, filters.size, "state + pins filters plus one address back-fill, no id filter") + assertTrue(filters.none { it.filter.ids != null }) + + val addressFilter = filters.first { it.filter.authors != null } + assertEquals(relayA, addressFilter.relay) + assertEquals(listOf(30023), addressFilter.filter.kinds) + assertEquals(listOf(author), addressFilter.filter.authors) + assertEquals(listOf("article"), addressFilter.filter.tags!!["d"]) + assertNull(addressFilter.filter.since) + } } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannelTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannelTest.kt index 2d9a77fbbd..5cedd1e6fe 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannelTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannelTest.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent +import com.vitorpamplona.quartz.nip29RelayGroups.tags.EventPin import com.vitorpamplona.quartz.utils.EventFactory import kotlin.test.Test import kotlin.test.assertEquals @@ -391,4 +392,29 @@ class RelayGroupChannelTest { assertEquals(1, c.threadCount()) assertTrue(c.threads.value.none { it.idHex == t1.idHex }) } + + /** + * NIP-29 #2416: a pin list mixes `e` and `a` entries. Pinning or unpinning one entry must + * re-submit every OTHER entry intact — another client's `a` pin (with its relay hint) included. + */ + @Test + fun pinEditsPreserveOtherClientsAddressPins() { + val c = channel() + val addr = "30023:$alice:article" + val tags = arrayOf(arrayOf("d", gid), arrayOf("e", msg1), arrayOf("a", addr, "wss://hint.example/"), arrayOf("e", msg2)) + c.updatePinned(EventFactory.create("00".repeat(32), relaySelf, 100, GroupPinnedEvent.KIND, tags, "", "22".repeat(64)) as GroupPinnedEvent) + + assertEquals(listOf(msg1, addr, msg2), c.pins.map { it.ref }) + assertEquals(listOf(msg1, msg2), c.pinnedEventIds) + assertEquals(listOf(addr), c.pinnedAddresses.map { it.toValue() }) + assertTrue(c.isPinned(addr)) + + val afterUnpin = c.pinsWithout(msg1) + assertEquals(listOf(listOf("a", addr, "wss://hint.example/"), listOf("e", msg2)), afterUnpin.map { it.toTagArray().toList() }) + + val afterPin = c.pinsWith(EventPin(msg3)) + assertEquals(listOf(msg1, addr, msg2, msg3), afterPin.map { it.ref }) + assertEquals(c.pins, c.pinsWith(EventPin(msg2)), "re-pinning an already pinned id is a no-op") + assertTrue(c.hasRelaySignedState()) + } } diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index a0b587b986..70596c0ac7 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -1738,6 +1738,14 @@ Name Description Group picture + Banner image URL + This group may have moved + This group may have a fork + Its admins now list it on %1$s + People you follow now list it on %1$s + Open there + Move + Dismiss Add photo Change photo Discovery From 56b49c851cba952c90977773d1e6eae8b8e0b8f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:13:23 +0000 Subject: [PATCH 06/24] feat(cli): relaygroup join naddr?invite=, pin/unpin, banner/parent options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `relaygroup join naddr1…[?invite=CODE]` via GroupNAddrInvite.parseReference. - `relaygroup pin|unpin RELAY GID REF` read the current 39005 and publish a 9010 with REF added/removed, keeping every other e/a pin. - create/edit take --picture/--banner/--parent (edit also --root); edit now carries picture, banner, hidden/restricted, parent/children and unknown tags from the current 39000 instead of dropping them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc --- cli/README.md | 6 +- .../cli/commands/RelayGroupCommands.kt | 66 +++++++++++--- .../commands/RelayGroupModerationCommands.kt | 91 +++++++++++++++++-- 3 files changed, 138 insertions(+), 25 deletions(-) diff --git a/cli/README.md b/cli/README.md index 359e2c5787..4a95c7f29a 100644 --- a/cli/README.md +++ b/cli/README.md @@ -586,14 +586,16 @@ screen speaks. | `amy relaygroup list` | Your joined groups, from your kind:10009 list (public + private). | | `amy relaygroup browse RELAY` | Every group a relay hosts (its 39000-39003 directory). | | `amy relaygroup info RELAY GID` | A group's metadata + admin/member roster. | -| `amy relaygroup create RELAY --name X [--about A] [--private] [--closed]` | Create a group (publishes 9007 + 9002); prints the new `group_id`. | +| `amy relaygroup create RELAY --name X [--about A] [--picture URL] [--banner URL] [--parent GID] [--private] [--closed]` | Create a group (publishes 9007 + 9002); prints the new `group_id`. `--parent` nests it under a subgroup-capable relay's group. | | `amy relaygroup join RELAY GID [--code CODE]` | Request to join (9021) and add it to your kind:10009 list. | +| `amy relaygroup join naddr1…[?invite=CODE]` | Same, from NIP-29's shareable group identifier; the `?invite=` suffix becomes the join `code`. | | `amy relaygroup leave RELAY GID` | Leave (9022) and drop it from your kind:10009 list. | | `amy relaygroup message RELAY GID TEXT` | Post a kind:9 chat message into the group. | -| `amy relaygroup edit RELAY GID [--name X] [--about A] [--private\|--public] [--closed\|--open]` | Edit metadata (9002, admin only). Reads current visibility and changes only the axis you pass, so re-asserting one flag never resets the other. | +| `amy relaygroup edit RELAY GID [--name X] [--about A] [--picture URL] [--banner URL] [--parent GID\|--root] [--private\|--public] [--closed\|--open]` | Edit metadata (9002, admin only). Reads the current 39000 and changes only what you pass: picture, banner, subgroup links, other flags and unknown tags are carried over. | | `amy relaygroup invite RELAY GID --code CODE` | Mint an invite code (9009, moderator). | | `amy relaygroup put-user RELAY GID PUBKEY [--role admin\|moderator]` | Add or promote a user (9000, moderator). | | `amy relaygroup remove-user RELAY GID PUBKEY` | Kick a user (9001, moderator). | +| `amy relaygroup pin RELAY GID REF` / `unpin …` | Add/remove a pin (9010, moderator). REF is a note1/nevent1/hex id (`e`) or naddr1/`kind:pubkey:d` (`a`); the rest of the current 39005 list is kept. | ### Buzz workspaces (block/buzz — NIP-29 dialect) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupCommands.kt index 2f7e08bfdf..1a05d96c46 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupCommands.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip29RelayGroups.GroupNAddrInvite import com.vitorpamplona.quartz.nip29RelayGroups.hTag import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent @@ -56,18 +57,24 @@ object RelayGroupCommands { | relaygroup browse RELAY [--timeout S] every group a relay hosts | relaygroup info RELAY GID [--timeout S] a group's metadata + roster | relaygroup create RELAY --name NAME create a group (publishes 9007+9002) - | [--about A] [--private] [--closed] + | [--about A] [--picture URL] [--banner URL] + | [--parent GID] [--private] [--closed] | relaygroup join RELAY GID [--code CODE] request to join (kind 9021) | [--reason R] + | relaygroup join naddr1…[?invite=CODE] same, from a shared group identifier | relaygroup leave RELAY GID leave (kind 9022) | relaygroup message RELAY GID TEXT post a kind-9 chat to the group | relaygroup edit RELAY GID [--name N] edit metadata (kind 9002, admin); - | [--about A] [--private|--public] reads current visibility and only - | [--closed|--open] changes the axis you specify + | [--about A] [--picture URL] carries every field you don't pass + | [--banner URL] [--parent GID|--root] over from the current kind 39000 + | [--private|--public] [--closed|--open] | relaygroup invite RELAY GID --code CODE mint an invite code (kind 9009) | relaygroup put-user RELAY GID PUBKEY add/promote a user (kind 9000) | [--role admin|moderator] | relaygroup remove-user RELAY GID PUBKEY kick a user (kind 9001) + | relaygroup pin RELAY GID REF pin an event (kind 9010); REF is a + | note1/nevent1/hex id or naddr1/kind:pk:d + | relaygroup unpin RELAY GID REF unpin it, keeping every other pin """.trimMargin() suspend fun dispatch( @@ -77,7 +84,7 @@ object RelayGroupCommands { route( "relaygroup", tail, - "relaygroup …", + "relaygroup …", help = USAGE, routes = mapOf( @@ -92,10 +99,15 @@ object RelayGroupCommands { "invite" to { rest -> RelayGroupModerationCommands.invite(dataDir, rest) }, "put-user" to { rest -> RelayGroupModerationCommands.putUser(dataDir, rest) }, "remove-user" to { rest -> RelayGroupModerationCommands.removeUser(dataDir, rest) }, + "pin" to { rest -> RelayGroupModerationCommands.pin(dataDir, rest, pin = true) }, + "unpin" to { rest -> RelayGroupModerationCommands.pin(dataDir, rest, pin = false) }, ), ) - /** `relaygroup create RELAY --name NAME [--about A] [--private] [--closed]` → publishes 9007 + 9002. */ + /** + * `relaygroup create RELAY --name NAME [--about A] [--picture URL] [--banner URL] [--parent GID] + * [--private] [--closed]` → publishes 9007 + 9002. + */ private suspend fun create( dataDir: DataDir, rest: Array, @@ -105,6 +117,9 @@ object RelayGroupCommands { val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl") val name = args.flag("name") ?: return Output.error("bad_args", "relaygroup create requires --name") val about = args.flag("about") + val picture = args.flag("picture") + val banner = args.flag("banner") + val parent = args.flag("parent") val isPrivate = args.bool("private") val isClosed = args.bool("closed") args.rejectUnknown() @@ -116,7 +131,7 @@ object RelayGroupCommands { val createAck = ctx.publish(ctx.signer.sign(CreateGroupEvent.build(groupId)), target) val status = groupStatus(isPrivate, isClosed) - val edit = EditMetadataEvent.build(groupId, name = name, about = about, status = status) + val edit = EditMetadataEvent.build(groupId, name = name, about = about, picture = picture, banner = banner, status = status, parent = parent) val editAck = ctx.publish(ctx.signer.sign(edit), target) // Track it in our own kind:10009 so `relaygroup list` shows it, matching // the Android create flow (Account.createRelayGroup → follow). @@ -127,6 +142,7 @@ object RelayGroupCommands { "group_id" to groupId, "relay" to relay.url, "name" to name, + "parent" to parent, "private" to isPrivate, "closed" to isClosed, "published" to (createAck.values.any { it.accepted } && editAck.values.any { it.accepted }), @@ -138,30 +154,50 @@ object RelayGroupCommands { } /** - * `relaygroup join RELAY GROUP_ID [--code CODE] [--reason R]` — publishes the - * 9021 join request to the host relay AND adds the group to the caller's - * kind-10009 list (private item), so `relaygroup list` reflects it. + * `relaygroup join RELAY GROUP_ID [--code CODE] [--reason R]` or + * `relaygroup join naddr1…[?invite=CODE] [--reason R]` — publishes the 9021 join request to the + * host relay AND adds the group to the caller's kind-10009 list, so `relaygroup list` reflects it. + * The naddr form is NIP-29's shareable group identifier; its `?invite=` suffix becomes the `code` + * tag (an explicit `--code` wins). */ private suspend fun join( dataDir: DataDir, rest: Array, ): Int { val args = Args(rest) - val usage = "relaygroup join RELAY GROUP_ID [--code CODE]" - val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage) - val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage) - val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl") + val usage = "relaygroup join RELAY GROUP_ID [--code CODE] | relaygroup join naddr1…[?invite=CODE]" + val first = args.positionalOrNull(0) ?: return Output.error("bad_args", usage) + val reference = GroupNAddrInvite.parseReference(first) + val relay: NormalizedRelayUrl + val groupId: String + if (reference != null) { + relay = reference.groupId.relayUrl + groupId = reference.groupId.id + } else { + if (first.trim().removePrefix("nostr:").startsWith("naddr")) { + return Output.error("bad_args", "not a NIP-29 group naddr (kind 39000 with a relay hint): $first") + } + groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage) + relay = normalizeGroupRelay(first) ?: return Output.error("bad_args", "invalid relay url: $first") + } + val code = args.flag("code") ?: reference?.inviteCode Context.open(dataDir).use { ctx -> ctx.prepare() - val join = JoinRequestEvent.build(groupId, reason = args.flag("reason") ?: "", inviteCode = args.flag("code")) + val join = JoinRequestEvent.build(groupId, reason = args.flag("reason") ?: "", inviteCode = code) args.rejectUnknown() val signed = ctx.signer.sign(join) val ack = ctx.publish(signed, setOf(relay)) RawEventSupport.publishGuard(ack, signed.id)?.let { return it } val listed = updateGroupList(ctx, relay, groupId, add = true) Output.emit( - mapOf("group_id" to groupId, "relay" to relay.url, "published" to ack.values.any { it.accepted }, "listed" to listed), + mapOf( + "group_id" to groupId, + "relay" to relay.url, + "code" to code, + "published" to ack.values.any { it.accepted }, + "listed" to listed, + ), ) return 0 } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt index 12bde96071..5dfe110168 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt @@ -26,10 +26,13 @@ import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent +import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupPin /** * Moderator/admin write verbs for a relay group (the relay is the final authority @@ -38,20 +41,22 @@ import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent */ object RelayGroupModerationCommands { /** - * `relaygroup edit RELAY GROUP_ID [--name N] [--about A] [--private|--public] [--closed|--open]` → 9002. + * `relaygroup edit RELAY GROUP_ID [--name N] [--about A] [--picture URL] [--banner URL] + * [--parent GID|--root] [--private|--public] [--closed|--open]` → 9002. * - * A kind-9002 edit re-asserts the group's status flags, so sending only one - * axis would silently reset the other (e.g. `--closed` on a private group - * would drop `private` and leak it public). To avoid that we read the group's - * current 39000 metadata and merge: each axis keeps its current value unless - * the caller explicitly changes it with the flag or its counter-flag. + * A kind-9002 edit re-asserts the whole group metadata ("all the fields of group-metadata"), so + * sending only one field would silently reset the rest (e.g. `--closed` on a private group would + * drop `private` and leak it public; a rename would drop the picture, banner, subgroup links and + * any tag we don't model). To avoid that we read the group's current 39000 metadata and merge: + * every field keeps its current value unless the caller explicitly changes it, and unknown tags + * (`livekit`, `supported_kinds`, …) ride along verbatim. */ suspend fun edit( dataDir: DataDir, rest: Array, ): Int { val args = Args(rest) - val usage = "relaygroup edit RELAY GROUP_ID [--name N] [--about A] [--private|--public] [--closed|--open]" + val usage = "relaygroup edit RELAY GROUP_ID [--name N] [--about A] [--picture URL] [--banner URL] [--parent GID|--root] [--private|--public] [--closed|--open]" val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage) val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage) val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl") @@ -98,9 +103,16 @@ object RelayGroupModerationCommands { groupId, name = args.flag("name") ?: meta?.name(), about = args.flag("about") ?: meta?.about(), - status = groupStatus(isPrivate, isClosed), + picture = args.flag("picture") ?: meta?.picture(), + banner = args.flag("banner") ?: meta?.banner(), + status = groupStatus(isPrivate, isClosed) + carriedStatus(meta), hashtags = meta?.hashtags() ?: emptyList(), geohashes = meta?.geohashes()?.maxByOrNull { it.length }?.let { listOf(it) } ?: emptyList(), + // Subgroups: a 9002 without `parent` re-roots the group and one missing a `child` is + // rejected, so keep both unless the caller re-parents (--parent) or detaches (--root). + parent = if (args.bool("root")) null else args.flag("parent") ?: meta?.parent(), + children = meta?.children() ?: emptyList(), + extraTags = meta?.unmanagedTags() ?: emptyList(), ) args.rejectUnknown() val signed = ctx.signer.sign(template) @@ -120,6 +132,69 @@ object RelayGroupModerationCommands { } } + /** The status flags the CLI has no switch for (`hidden`, `restricted`), carried from [meta]. */ + private fun carriedStatus(meta: GroupMetadataEvent?): Set = + buildSet { + if (meta?.isHidden() == true) add(GroupMetadataEvent.GroupStatus.HIDDEN) + if (meta?.isRestricted() == true) add(GroupMetadataEvent.GroupStatus.RESTRICTED) + } + + /** + * `relaygroup pin|unpin RELAY GROUP_ID REF` → 9010 update-pin-list. NIP-29 replaces the whole + * list, so this reads the group's current kind-39005 and re-submits it with REF added (at the + * end) or removed — every other pin, `e` or `a`, kept verbatim. REF is an event (`note1`, + * `nevent1`, 64-hex → `e`) or an addressable event (`naddr1`, `kind:pubkey:d` → `a`). + */ + suspend fun pin( + dataDir: DataDir, + rest: Array, + pin: Boolean, + ): Int { + val args = Args(rest) + val verb = if (pin) "pin" else "unpin" + val usage = "relaygroup $verb RELAY GROUP_ID REF" + val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage) + val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage) + val ref = args.positionalOrNull(2) ?: return Output.error("bad_args", usage) + val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl") + val target = GroupPin.fromReference(ref) ?: return Output.error("bad_args", "not an event id, nevent, naddr or kind:pubkey:d: $ref") + args.rejectUnknown() + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val filter = Filter(kinds = listOf(GroupPinnedEvent.KIND), tags = mapOf("d" to listOf(groupId)), limit = 1) + val current = + ctx + .drain(mapOf(relay to listOf(filter)), 6_000) + .map { it.second } + .filterIsInstance() + .maxByOrNull { it.createdAt } + ?.pins() ?: emptyList() + + val updated = + if (pin) { + if (current.any { it.ref == target.ref }) current else current + target + } else { + current.filter { it.ref != target.ref } + } + + val signed = ctx.signer.sign(UpdatePinListEvent.build(groupId, updated)) + val ack = ctx.publish(signed, setOf(relay)) + RawEventSupport.publishGuard(ack, signed.id)?.let { return it } + Output.emit( + mapOf( + "event_id" to signed.id, + "group_id" to groupId, + "relay" to relay.url, + "pins" to updated.map { it.ref }, + "changed" to (updated.map { it.ref } != current.map { it.ref }), + "published" to ack.values.any { it.accepted }, + ), + ) + return 0 + } + } + /** `relaygroup invite RELAY GROUP_ID --code CODE` → 9009. */ suspend fun invite( dataDir: DataDir, From 7b91dd234e13e05a3fea59006f7b5a1463c6f89f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:18:38 +0000 Subject: [PATCH 07/24] fix(quartz): NIP-44 integer padding math, strict extended prefix, payload cap - calcPaddedLen used Float math and returned wrong buckets above 2^24 (20971520 -> 25165824, 33554432 -> 41943040) and overflowed Int past 2^30. It is now integer-only on Long, as the spec requires. - unpad rejects an extended [0,0][u32] prefix whose length is < 65536, so a short message has a single valid encoding. - decodePayload enforces a configurable maximum (default 64M chars) before base64 decoding, and rejects payloads that decode to fewer than 99 bytes. - Nip44 reads only the first base64 quantum to find the version byte. - Adds the spec's 65535/65536/65537-byte vectors and padding tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc --- .../resources/nip44.vectors.json | 24 +++ .../quartz/nip44Encryption/Nip44.kt | 11 +- .../quartz/nip44Encryption/Nip44v2.kt | 140 +++++++++++------- .../quartz/nip44Encryption/Nip44v2BaseTest.kt | 2 +- .../nip44Encryption/Nip44v2PaddingTest.kt | 136 +++++++++++++++++ .../commonTest/resources/nip44.vectors.json | 24 +++ 6 files changed, 277 insertions(+), 60 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2PaddingTest.kt diff --git a/quartz/src/androidDeviceTest/resources/nip44.vectors.json b/quartz/src/androidDeviceTest/resources/nip44.vectors.json index 4d105420f3..09b7a7882b 100644 --- a/quartz/src/androidDeviceTest/resources/nip44.vectors.json +++ b/quartz/src/androidDeviceTest/resources/nip44.vectors.json @@ -526,6 +526,30 @@ "repeat": 20000000, "plaintext_sha256": "aded0ea9b4d06589b13d00bab483faf479d61ed5de21f1760aa7018a28e330e5", "payload_sha256": "9e683311894d52e48a825837883c539263c7787c7fe024e1590d96776a31684b" + }, + { + "conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d", + "nonce": "0000000000000000000000000000000000000000000000000000000000000001", + "pattern": "a", + "repeat": 65535, + "plaintext_sha256": "6e1bebca6a8229364a162a72ef064826c4cd7457bf54f190ef782bd9deff3e42", + "payload_sha256": "6d8c2810d1e870fbaa1f0a0937126cca837a15f9260e27060c331d70a3c0bc84" + }, + { + "conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d", + "nonce": "0000000000000000000000000000000000000000000000000000000000000001", + "pattern": "a", + "repeat": 65536, + "plaintext_sha256": "bf718b6f653bebc184e1479f1935b8da974d701b893afcf49e701f3e2f9f9c5a", + "payload_sha256": "b7b4edb36ba92e267d322d56d9aebc22e7fa96ff52e3c12adc07f07a43cbc616" + }, + { + "conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d", + "nonce": "0000000000000000000000000000000000000000000000000000000000000001", + "pattern": "a", + "repeat": 65537, + "plaintext_sha256": "008ffc88d3c96a9f307524eb361e47c5222a887fc45fa0c1fb8d429c5c23b430", + "payload_sha256": "eeb7c7c5373894ea2c1547cfd3ccb15d5a0b2d619da852e5c79df792dcc9e435" } ] }, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44.kt index c5eb78b4f8..c53f8862ed 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44.kt @@ -115,14 +115,17 @@ object Nip44 { ): String { require(ciphertext.isNotBlank()) { "ciphertext must not be blank" } - // Ignores if it is not base64 - val byteArray = Base64.decode(ciphertext) + require(ciphertext.length >= 4) { "ciphertext is too short" } - return when (byteArray[0].toInt()) { + // Only the version byte is needed here: the first base64 quantum (4 chars) holds it. + // Decoding the whole payload would allocate its full size just to be thrown away. + val version = Base64.decode(ciphertext, 0, 4)[0].toInt() + + return when (version) { EncryptedInfo.V -> Nip04.decrypt(ciphertext, privateKey, pubKey) Nip44v1.EncryptedInfo.V -> v1.decrypt(ciphertext, privateKey, pubKey) Nip44v2.EncryptedInfo.V -> v2.decrypt(ciphertext, privateKey, pubKey) - else -> throw IllegalArgumentException("Invalid or unsupported NIP-44 version code ${byteArray[0].toInt()}") + else -> throw IllegalArgumentException("Invalid or unsupported NIP-44 version code $version") } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt index 98baa80452..5c3efeceac 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt @@ -28,10 +28,17 @@ import com.vitorpamplona.quartz.utils.Secp256k1Instance import com.vitorpamplona.quartz.utils.equalsConstantTime import kotlinx.coroutines.CancellationException import kotlin.io.encoding.Base64 -import kotlin.math.floor -import kotlin.math.log2 -class Nip44v2 { +/** + * NIP-44 v2 encryption. + * + * @param maxPayloadLength the largest base64 payload [decrypt] accepts. NIP-44 lets plaintexts reach + * 2^32 - 1 bytes but asks implementations to set their own ceiling and to enforce it before base64 + * decoding, since decrypting needs several times the payload size in working memory. + */ +class Nip44v2( + val maxPayloadLength: Int = DEFAULT_MAX_PAYLOAD_LENGTH, +) { private val sharedKeyCache = SharedKeyCache() private val hkdf = Hkdf() private val chaCha = ChaCha20() @@ -40,10 +47,11 @@ class Nip44v2 { private val hashLength = 32 private val minPlaintextSize: Int = 0x0001 // 1b msg => padded to 32b - private val maxPlaintextSize: Int = 0xffff // 65535 (64kb-1) => padded to 64kb + private val maxPlaintextSize: Int = 0xffff // 65535 (64kb-1) => padded to 64kb, u16 prefix - private val extMinPlaintextSize: Int = 0x00000001 // 1b msg => padded to 32b - private val extMaxPlaintextSize: Long = 0xffffffff // 4294967294 => padded + // Lengths at or above this use the extended [0,0][u32] prefix, and only those may. + private val extMinPlaintextSize: Long = 0x10000 // 65536 => padded to 64kb, 6-byte prefix + private val extMaxPlaintextSize: Long = 0xffffffff // 4294967295 => padded to 2^32 fun clearCache() { sharedKeyCache.clearCache() @@ -97,7 +105,7 @@ class Nip44v2 { fun decrypt( payload: String, conversationKey: ByteArray, - ): String = decrypt(EncryptedInfo.decodePayload(payload), conversationKey) + ): String = decrypt(EncryptedInfo.decodePayload(payload, maxPayloadLength), conversationKey) fun checkHMacAad( messageKey: Hkdf.MessageKey, @@ -137,58 +145,76 @@ class Nip44v2 { return computed } - fun calcPaddedLen(len: Int): Int { + fun calcPaddedLen(len: Int): Long = calcPaddedLen(len.toLong()) + + /** + * NIP-44 padded length. Integer-only: floating point loses precision above 2^24, and the + * result can reach 2^32 (for 2^32 - 1), which only fits 64-bit arithmetic. + */ + fun calcPaddedLen(len: Long): Long { check(len > 0) { "expected positive integer" } if (len <= 32) return 32 - val nextPower = 1 shl (floor(log2(len - 1f)) + 1).toInt() - val chunk = if (nextPower <= 256) 32 else nextPower / 8 - return chunk * (floor((len - 1f) / chunk).toInt() + 1) + // 1 shl (floor(log2(len - 1)) + 1) == 1 shl bitLength(len - 1) + val nextPower = 1L shl (Long.SIZE_BITS - (len - 1).countLeadingZeroBits()) + val chunk = if (nextPower <= 256) 32L else nextPower / 8 + return chunk * ((len - 1) / chunk + 1) } fun pad(plaintext: String): ByteArray { val unpadded = plaintext.encodeToByteArray() val unpaddedLen = unpadded.size - check(unpaddedLen > 0) { "Message is empty ($unpaddedLen): $plaintext" } + check(unpaddedLen >= minPlaintextSize) { "Message is empty ($unpaddedLen): $plaintext" } - val prefix = - if (unpaddedLen <= maxPlaintextSize) { - // 2 bytes in big endian - intTo2BytesBigEndian(unpaddedLen) - } else if (unpaddedLen <= extMaxPlaintextSize) { - // Extension to allow > 65KB payloads - // 2+4 bytes in big endian - byteArrayOf(0, 0) + intTo4BytesBigEndian(unpaddedLen) - } else { - throw IllegalArgumentException("Message is too long ($unpaddedLen): $plaintext") - } + val prefixLen = if (unpaddedLen <= maxPlaintextSize) 2 else 6 + val totalLen = prefixLen + calcPaddedLen(unpaddedLen) - val suffix = ByteArray(calcPaddedLen(unpaddedLen) - unpaddedLen) - return prefix + unpadded + suffix + require(totalLen <= Int.MAX_VALUE) { + "Message is too long for this platform ($unpaddedLen bytes pad to $totalLen)" + } + + // Zero-filled: everything after the plaintext is already the padding. + val padded = ByteArray(totalLen.toInt()) + if (prefixLen == 2) { + // 2 bytes in big endian + padded[0] = (unpaddedLen shr 8).toByte() + padded[1] = (unpaddedLen and 0xFF).toByte() + } else { + // Extension to allow >= 64KB payloads: [0, 0] + 4 bytes in big endian + padded[2] = (unpaddedLen shr 24).toByte() + padded[3] = (unpaddedLen shr 16).toByte() + padded[4] = (unpaddedLen shr 8).toByte() + padded[5] = (unpaddedLen and 0xFF).toByte() + } + unpadded.copyInto(padded, prefixLen) + return padded } fun unpad(padded: ByteArray): String { + check(padded.size >= 2) { "Invalid padding: ${padded.size} bytes" } val unpaddedLenPreExt: Int = bytesToIntBigEndian(padded[0], padded[1]) return if (unpaddedLenPreExt == 0) { // NIP-44 extension to handle bigger than 65K payloads - val unpaddedLenExt: Int = bytesToIntBigEndian(padded[2], padded[3], padded[4], padded[5]) + check(padded.size >= 6) { "Invalid padding: ${padded.size} bytes" } + val unpaddedLenExt: Long = bytesToLongBigEndian(padded[2], padded[3], padded[4], padded[5]) + // A length that fits the u16 prefix must use it: rejects a second encoding of short messages. check(unpaddedLenExt in extMinPlaintextSize..extMaxPlaintextSize) { "Invalid size $unpaddedLenExt not between $extMinPlaintextSize and $extMaxPlaintextSize" } - check(padded.size == 6 + calcPaddedLen(unpaddedLenExt)) { + check(padded.size.toLong() == 6 + calcPaddedLen(unpaddedLenExt)) { "Invalid padding ${calcPaddedLen(unpaddedLenExt)} != $unpaddedLenExt" } - padded.decodeToString(6, 6 + unpaddedLenExt) + padded.decodeToString(6, 6 + unpaddedLenExt.toInt()) } else { check(unpaddedLenPreExt in minPlaintextSize..maxPlaintextSize) { "Invalid size $unpaddedLenPreExt not between $minPlaintextSize and $maxPlaintextSize" } - check(padded.size == 2 + calcPaddedLen(unpaddedLenPreExt)) { + check(padded.size.toLong() == 2 + calcPaddedLen(unpaddedLenPreExt)) { "Invalid padding ${calcPaddedLen(unpaddedLenPreExt)} != $unpaddedLenPreExt" } @@ -235,15 +261,29 @@ class Nip44v2 { companion object { const val V: Int = 2 - fun decodePayload(payload: String): EncryptedInfo { - check(payload.length >= 132) { + // version (1) + nonce (32) + smallest padded plaintext with prefix (2 + 32) + mac (32) + const val MIN_DECODED_LENGTH: Int = 99 + + // base64 of [MIN_DECODED_LENGTH] bytes + const val MIN_PAYLOAD_LENGTH: Int = 132 + + fun decodePayload( + payload: String, + maxPayloadLength: Int = DEFAULT_MAX_PAYLOAD_LENGTH, + ): EncryptedInfo { + check(payload.isNotEmpty() && payload[0] != '#') { "Unknown encryption version ${payload.getOrNull(0)}" } + check(payload.length >= MIN_PAYLOAD_LENGTH) { "Invalid payload length ${payload.length} for $payload" } - check(payload[0] != '#') { "Unknown encryption version ${payload.get(0)}" } + // Before base64 decoding, so an oversized payload costs nothing to reject. + check(payload.length <= maxPayloadLength) { + "Payload length ${payload.length} exceeds the maximum of $maxPayloadLength" + } return try { val byteArray = Base64.decode(payload) - check(byteArray[0].toInt() == V) + check(byteArray.size >= MIN_DECODED_LENGTH) { "Invalid decoded length ${byteArray.size}" } + check(byteArray[0].toInt() == V) { "Unknown encryption version ${byteArray[0]}" } EncryptedInfo( nonce = byteArray.copyOfRange(1, 33), ciphertext = byteArray.copyOfRange(33, byteArray.size - 32), @@ -267,32 +307,22 @@ class Nip44v2 { byte2: Byte, ): Int = (byte1.toInt() and 0xFF shl 8 or (byte2.toInt() and 0xFF)) - private fun bytesToIntBigEndian( + private fun bytesToLongBigEndian( byte1: Byte, byte2: Byte, byte3: Byte, byte4: Byte, - ): Int { - val result = - ((byte1.toLong() and 0xFF) shl 24) or - ((byte2.toLong() and 0xFF) shl 16) or - ((byte3.toLong() and 0xFF) shl 8) or - (byte4.toLong() and 0xFF) + ): Long = + ((byte1.toLong() and 0xFF) shl 24) or + ((byte2.toLong() and 0xFF) shl 16) or + ((byte3.toLong() and 0xFF) shl 8) or + (byte4.toLong() and 0xFF) - check(result <= Int.MAX_VALUE) { - "JVM cannot handle more than 2GB payloads. Current length: $result" - } - - return result.toInt() + companion object { + /** + * Default ceiling for a base64 payload: 64M chars (~48 MB decoded). Comfortably above the + * spec's 20,000,000-byte test vector while bounding what a hostile event can make us allocate. + */ + const val DEFAULT_MAX_PAYLOAD_LENGTH: Int = 64 * 1024 * 1024 } - - private fun intTo2BytesBigEndian(value: Int): ByteArray = byteArrayOf((value shr 8).toByte(), (value and 0xFF).toByte()) - - private fun intTo4BytesBigEndian(value: Int): ByteArray = - byteArrayOf( - (value shr 24).toByte(), - (value shr 16).toByte(), - (value shr 8).toByte(), - (value and 0xFF).toByte(), - ) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2BaseTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2BaseTest.kt index 5ddbde8c18..f31161bb67 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2BaseTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2BaseTest.kt @@ -56,7 +56,7 @@ class Nip44v2BaseTest { fun paddingTest() { for (v in vectors.v2?.valid?.calcPaddedLen!!) { val actual = nip44v2.calcPaddedLen(v[0]) - assertEquals(v[1], actual) + assertEquals(v[1].toLong(), actual) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2PaddingTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2PaddingTest.kt new file mode 100644 index 0000000000..9ff794032c --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2PaddingTest.kt @@ -0,0 +1,136 @@ +/* + * 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.nip44Encryption + +import com.vitorpamplona.quartz.utils.RandomInstance +import kotlin.io.encoding.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * Padding and payload-size rules from NIP-44 v2 (extended length prefix, 64-bit padding math). + */ +class Nip44v2PaddingTest { + private val nip44v2 = Nip44v2() + + @Test + fun paddingUsesIntegerMathAboveFloatPrecision() { + // Float math rounds these up to the next bucket (25165824 / 41943040). + assertEquals(20_971_520L, nip44v2.calcPaddedLen(20_971_520)) + assertEquals(33_554_432L, nip44v2.calcPaddedLen(33_554_432)) + assertEquals(41_943_040L, nip44v2.calcPaddedLen(33_554_433)) + assertEquals(20_971_520L, nip44v2.calcPaddedLen(16_777_217)) + } + + @Test + fun paddingDoesNotOverflowNearTheJvmArrayLimit() { + assertEquals(1L shl 30, nip44v2.calcPaddedLen(1 shl 30)) + assertEquals(1_342_177_280L, nip44v2.calcPaddedLen((1 shl 30) + 1)) + assertEquals(1L shl 31, nip44v2.calcPaddedLen(Int.MAX_VALUE)) + // The spec's theoretical maximum: 2^32 - 1 pads to 2^32, which needs 64-bit math. + assertEquals(1L shl 32, nip44v2.calcPaddedLen(0xffffffffL)) + } + + @Test + fun paddingMatchesSpecFormulaAroundBoundaries() { + val probes = listOf(33L, 256L, 257L, 65_535L, 65_536L, 65_537L, 1L shl 24, (1L shl 24) + 1, 20_000_000L, 100_000_000L) + for (n in probes) { + assertEquals(specPaddedLen(n), nip44v2.calcPaddedLen(n), "calcPaddedLen($n)") + } + } + + @Test + fun padUsesExtendedPrefixFrom65536() { + val small = nip44v2.pad("a".repeat(65_535)) + assertEquals(2 + 65_536, small.size) + assertEquals(0xff.toByte(), small[0]) + assertEquals(0xff.toByte(), small[1]) + + val big = nip44v2.pad("a".repeat(65_536)) + assertEquals(6 + 65_536, big.size) + assertEquals(listOf(0, 0, 0, 1, 0, 0), big.copyOfRange(0, 6).toList()) + assertEquals("a".repeat(65_536), nip44v2.unpad(big)) + } + + @Test + fun unpadRejectsExtendedPrefixForShortLengths() { + // [0,0][u32 = 5]["hello"][zeros] would be a second encoding of a 5-byte plaintext. + val padded = ByteArray(6 + 32) + padded[5] = 5 + "hello".encodeToByteArray().copyInto(padded, 6) + assertFailsWith { nip44v2.unpad(padded) } + + // The largest length that must not use the extended prefix. + val padded2 = ByteArray(6 + 65_536) + padded2[4] = 0xff.toByte() + padded2[5] = 0xff.toByte() + assertFailsWith { nip44v2.unpad(padded2) } + } + + @Test + fun unpadRejectsZeroLength() { + assertFailsWith { nip44v2.unpad(ByteArray(2 + 32)) } + assertFailsWith { nip44v2.unpad(ByteArray(6 + 32)) } + } + + @Test + fun rejectsOversizedPayloadBeforeDecoding() { + val key = RandomInstance.bytes(32) + val payload = nip44v2.encrypt("a".repeat(1000), key).encodePayload() + + val limited = Nip44v2(maxPayloadLength = payload.length - 1) + val error = assertFailsWith { limited.decrypt(payload, key) } + assertTrue(error.message!!.contains("exceeds"), error.message) + + // Not base64 at all: must still fail on the size check, without reaching the decoder. + val garbage = "!".repeat(1000) + val error2 = assertFailsWith { Nip44v2.EncryptedInfo.decodePayload(garbage, maxPayloadLength = 500) } + assertTrue(error2.message!!.contains("exceeds"), error2.message) + + assertEquals("a".repeat(1000), Nip44v2(maxPayloadLength = payload.length).decrypt(payload, key)) + } + + @Test + fun defaultMaxPayloadAcceptsTheLongestSpecVector() { + // 20,000,000-byte plaintext from encrypt_decrypt_long_msg. + val rawLen = 1 + 32 + 6 + nip44v2.calcPaddedLen(20_000_000) + 32 + val base64Len = (rawLen + 2) / 3 * 4 + assertTrue(base64Len <= Nip44v2.DEFAULT_MAX_PAYLOAD_LENGTH) + } + + @Test + fun rejectsShortDecodedPayload() { + // Long enough as text (132 chars) but only 98 bytes once decoded. + val tooShort = Base64.encode(byteArrayOf(2) + ByteArray(97)) + assertEquals(132, tooShort.length) + assertFailsWith { Nip44v2.EncryptedInfo.decodePayload(tooShort) } + } + + private fun specPaddedLen(n: Long): Long { + if (n <= 32) return 32 + val bitLength = 64 - (n - 1).countLeadingZeroBits() + val nextPower = 1L shl bitLength + val chunk = if (nextPower <= 256) 32 else nextPower / 8 + return chunk * ((n - 1) / chunk + 1) + } +} diff --git a/quartz/src/commonTest/resources/nip44.vectors.json b/quartz/src/commonTest/resources/nip44.vectors.json index 4d105420f3..09b7a7882b 100644 --- a/quartz/src/commonTest/resources/nip44.vectors.json +++ b/quartz/src/commonTest/resources/nip44.vectors.json @@ -526,6 +526,30 @@ "repeat": 20000000, "plaintext_sha256": "aded0ea9b4d06589b13d00bab483faf479d61ed5de21f1760aa7018a28e330e5", "payload_sha256": "9e683311894d52e48a825837883c539263c7787c7fe024e1590d96776a31684b" + }, + { + "conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d", + "nonce": "0000000000000000000000000000000000000000000000000000000000000001", + "pattern": "a", + "repeat": 65535, + "plaintext_sha256": "6e1bebca6a8229364a162a72ef064826c4cd7457bf54f190ef782bd9deff3e42", + "payload_sha256": "6d8c2810d1e870fbaa1f0a0937126cca837a15f9260e27060c331d70a3c0bc84" + }, + { + "conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d", + "nonce": "0000000000000000000000000000000000000000000000000000000000000001", + "pattern": "a", + "repeat": 65536, + "plaintext_sha256": "bf718b6f653bebc184e1479f1935b8da974d701b893afcf49e701f3e2f9f9c5a", + "payload_sha256": "b7b4edb36ba92e267d322d56d9aebc22e7fa96ff52e3c12adc07f07a43cbc616" + }, + { + "conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d", + "nonce": "0000000000000000000000000000000000000000000000000000000000000001", + "pattern": "a", + "repeat": 65537, + "plaintext_sha256": "008ffc88d3c96a9f307524eb361e47c5222a887fc45fa0c1fb8d429c5c23b430", + "payload_sha256": "eeb7c7c5373894ea2c1547cfd3ccb15d5a0b2d619da852e5c79df792dcc9e435" } ] }, From ffadc6a807fc5bc048ac43f5cb88d06ccf22c807 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:24:04 +0000 Subject: [PATCH 08/24] fix: NIP-B0 keeps non-https schemes in the web bookmark d tag NIP-B0 now omits the part before the hostname only for https; http:// and every other scheme stay in the d tag. urlToDTag no longer strips http://, and url() adds https:// only when the d tag has no scheme (it produced https://ftp://... before). The spec is silent on trailing slashes and case, so the existing trailing-slash trim is kept and case is untouched. Editing a bookmark now keeps its original published_at, and when the saved d tag differs from the edited one (URL changed, or an http bookmark stored scheme-less by the old rule is re-saved with http://) the old address gets a NIP-09 deletion instead of being left behind. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc --- .../vitorpamplona/amethyst/model/Account.kt | 20 +++- .../webBookmarks/WebBookmarksScreen.kt | 2 +- .../nipB0WebBookmarks/WebBookmarkEvent.kt | 71 +++++++++++-- .../nipB0WebBookmarks/WebBookmarkEventTest.kt | 99 +++++++++++++++++++ 4 files changed, 180 insertions(+), 12 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/WebBookmarkEventTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index e2a51f8237..2412c5adf3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2163,14 +2163,32 @@ class Account( title: String?, description: String, hashtags: List = emptyList(), + editing: WebBookmarkEvent? = null, ) { if (!isWriteable()) return - val template = WebBookmarkEvent.build(url, title, description, tags = hashtags) + val now = TimeUtils.now() + val template = + WebBookmarkEvent.build( + url, + title, + description, + tags = hashtags, + createdAt = now, + firstPublishedAt = editing?.publishedAt() ?: now, + ) val signedEvent = signer.sign(template) cache.justConsumeMyOwnEvent(signedEvent) client.publish(signedEvent, computeRelayListToBroadcast(signedEvent)) + + // A different d tag is a different address, so the edit would otherwise leave the old + // bookmark behind. That happens when the URL was changed, and when re-saving a bookmark + // stored under the pre-2026 NIP-B0 rule, which also dropped `http://` (the d tag then + // reads as https, and saving the real http URL now keeps the scheme). + if (editing != null && editing.dTag() != signedEvent.dTag()) { + deleteWebBookmark(editing) + } } suspend fun deleteWebBookmark(event: WebBookmarkEvent) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt index 149d7d79f5..d39857b4df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt @@ -242,7 +242,7 @@ private fun WebBookmarkCard( onDismiss = { showEditDialog = false }, onSave = { url, title, description, tags -> accountViewModel.launchSigner { - accountViewModel.account.sendWebBookmark(url, title, description, tags) + accountViewModel.account.sendWebBookmark(url, title, description, tags, editing = event) } showEditDialog = false }, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/WebBookmarkEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/WebBookmarkEvent.kt index 9f928defbc..97cac54110 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/WebBookmarkEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/WebBookmarkEvent.kt @@ -55,10 +55,11 @@ class WebBookmarkEvent( visitor.visit(description()) } - fun url(): String { - val dTagValue = dTag() - return if (dTagValue.isNotEmpty()) "https://$dTagValue" else "" - } + /** + * The bookmarked URI. NIP-B0 only drops the scheme for `https`, so a d tag without a scheme + * is an https URL and anything else (`http://`, `gemini://`, `magnet:`...) is already complete. + */ + fun url(): String = dTagToUrl(dTag()) fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) @@ -71,23 +72,73 @@ class WebBookmarkEvent( companion object { const val KIND = 39701 - fun urlToDTag(url: String): String = - url - .removePrefix("https://") - .removePrefix("http://") - .trimEnd('/') + private const val HTTPS_PREFIX = "https://" + // RFC 3986: scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) followed by ":" + private val SCHEME = Regex("^([A-Za-z][A-Za-z0-9+.\\-]*):(.*)$") + + // What follows "host:" when the colon introduces a port rather than ending a scheme. + private val PORT = Regex("^[0-9]{1,5}([/?#].*)?$") + + /** + * True when [uri] starts with a scheme (`http://`, `mailto:`, `magnet:`) rather than a + * `host[:port]` of a scheme-less https d tag. `://` always means a scheme; otherwise a + * dotted or `localhost` prefix, or a numeric port after the colon, means a host. + */ + fun hasScheme(uri: String): Boolean { + val match = SCHEME.find(uri) ?: return false + val candidate = match.groupValues[1] + val rest = match.groupValues[2] + if (rest.startsWith("//")) return true + if (candidate.contains('.') || candidate.equals("localhost", ignoreCase = true)) return false + return !PORT.matches(rest) + } + + /** + * NIP-B0 d tag: the URI itself, except that for `https` everything before the hostname + * (scheme, `//` and any userinfo) is omitted. Other schemes, `http` included, are kept. + * + * The spec says nothing about trailing slashes or case; this keeps the long-standing + * trailing-slash trim (so re-saving an old bookmark lands on the same address) and does + * not change case. + */ + fun urlToDTag(url: String): String { + val trimmed = url.trim() + val uri = + if (trimmed.startsWith(HTTPS_PREFIX, ignoreCase = true)) { + val afterScheme = trimmed.substring(HTTPS_PREFIX.length) + val authorityEnd = afterScheme.indexOfAny(charArrayOf('/', '?', '#')).let { if (it < 0) afterScheme.length else it } + val userInfoEnd = afterScheme.lastIndexOf('@', authorityEnd - 1) + if (userInfoEnd >= 0) afterScheme.substring(userInfoEnd + 1) else afterScheme + } else { + trimmed + } + return uri.trimEnd('/') + } + + fun dTagToUrl(dTag: String): String = + when { + dTag.isEmpty() -> "" + hasScheme(dTag) -> dTag + else -> HTTPS_PREFIX + dTag + } + + /** + * @param firstPublishedAt when the bookmark was first published; pass the original value when + * editing so it survives the replacement. + */ fun build( url: String, bookmarkTitle: String?, description: String, tags: List = emptyList(), createdAt: Long = TimeUtils.now(), + firstPublishedAt: Long = createdAt, initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, description, createdAt) { dTag(urlToDTag(url)) bookmarkTitle?.let { title(it) } - publishedAt(createdAt) + publishedAt(firstPublishedAt) hashtags(tags) initializer() } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/WebBookmarkEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/WebBookmarkEventTest.kt new file mode 100644 index 0000000000..761912059d --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/WebBookmarkEventTest.kt @@ -0,0 +1,99 @@ +/* + * 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.nipB0WebBookmarks + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WebBookmarkEventTest { + private fun bookmark(dTag: String) = WebBookmarkEvent("0".repeat(64), "1".repeat(64), 1L, arrayOf(arrayOf("d", dTag)), "", "") + + @Test + fun httpsDropsEverythingBeforeTheHostname() { + assertEquals("alice.blog/post", WebBookmarkEvent.urlToDTag("https://alice.blog/post")) + assertEquals("alice.blog/post", WebBookmarkEvent.urlToDTag("HTTPS://alice.blog/post")) + assertEquals("alice.blog/post", WebBookmarkEvent.urlToDTag("https://user:pw@alice.blog/post")) + assertEquals("alice.blog:8443/post?q=1#f", WebBookmarkEvent.urlToDTag("https://alice.blog:8443/post?q=1#f")) + } + + @Test + fun otherSchemesAreKept() { + assertEquals("http://alice.i2p/post", WebBookmarkEvent.urlToDTag("http://alice.i2p/post")) + assertEquals("ftp://files.example.com/a.txt", WebBookmarkEvent.urlToDTag("ftp://files.example.com/a.txt")) + assertEquals("gemini://alice.space/post", WebBookmarkEvent.urlToDTag("gemini://alice.space/post")) + assertEquals("magnet:?xt=urn:btih:abc", WebBookmarkEvent.urlToDTag("magnet:?xt=urn:btih:abc")) + } + + @Test + fun schemelessInputIsAlreadyInHttpsForm() { + assertEquals("alice.blog/post", WebBookmarkEvent.urlToDTag("alice.blog/post")) + assertEquals("localhost:8080/x", WebBookmarkEvent.urlToDTag("localhost:8080/x")) + } + + @Test + fun trailingSlashIsTrimmedAsBefore() { + // NIP-B0 is silent on trailing slashes; keep the long-standing trim so existing d tags still match. + assertEquals("alice.blog", WebBookmarkEvent.urlToDTag("https://alice.blog/")) + assertEquals("http://alice.blog", WebBookmarkEvent.urlToDTag("http://alice.blog/")) + } + + @Test + fun urlRestoresHttpsOnlyWhenTheDTagHasNoScheme() { + assertEquals("https://alice.blog/post", bookmark("alice.blog/post").url()) + assertEquals("https://alice.blog:8443/post", bookmark("alice.blog:8443/post").url()) + assertEquals("https://localhost:8080/x", bookmark("localhost:8080/x").url()) + assertEquals("http://alice.i2p/post", bookmark("http://alice.i2p/post").url()) + assertEquals("gemini://alice.space/post", bookmark("gemini://alice.space/post").url()) + assertEquals("magnet:?xt=urn:btih:abc", bookmark("magnet:?xt=urn:btih:abc").url()) + assertEquals("", bookmark("").url()) + } + + @Test + fun roundTripsThroughTheDTag() { + listOf( + "https://alice.blog/post", + "http://alice.i2p/post", + "ftp://files.example.com/a.txt", + "magnet:?xt=urn:btih:abc", + ).forEach { url -> + assertEquals(url, bookmark(WebBookmarkEvent.urlToDTag(url)).url()) + } + } + + @Test + fun detectsSchemes() { + assertTrue(WebBookmarkEvent.hasScheme("http://a.b")) + assertTrue(WebBookmarkEvent.hasScheme("mailto:alice@a.b")) + assertFalse(WebBookmarkEvent.hasScheme("a.b/c")) + assertFalse(WebBookmarkEvent.hasScheme("a.b:80/c")) + assertFalse(WebBookmarkEvent.hasScheme("localhost:80")) + assertFalse(WebBookmarkEvent.hasScheme("192.168.0.1:80/c")) + } + + @Test + fun buildKeepsTheOriginalPublishedAt() { + val template = WebBookmarkEvent.build("http://alice.i2p/post", "t", "", createdAt = 200, firstPublishedAt = 100) + assertEquals("http://alice.i2p/post", template.tags.first { it[0] == "d" }[1]) + assertEquals("100", template.tags.first { it[0] == "published_at" }[1]) + } +} From 14e1d3fa8f5a23662dc15a6cd22fe240c0a2ea86 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 18:21:48 +0000 Subject: [PATCH 09/24] feat: move NIP-A3 payment targets out of experimental, follow the merged spec NIP-A3 is merged upstream, so quartz's experimental/nipA3 package becomes nipA3PaymentTargets (imports updated in every module). - PaymentTargetTag lowercases the type on parse and assemble (the spec says the type is always lowercase). - The spec's Cash App type is `cashme`; `cashapp` stays as an alias of it, and a bare cashtag gets its $ in the cash.app link. - URI hand-off for the spec's listed types: nano: for nano, bitcoin:?sp= for bip352 silent payments (BIP-321), revolut.me for revolut, on top of the existing schemes; bip353 and unknown types fall back to payto://type/addr. - The payment targets editor stores the spec spelling of known aliases. - Profile chips get styles for cashme, nano, revolut, bip352 and bip353. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc --- .../nostr-expert/references/nip-catalog.md | 2 +- .../amethyst/LocalPreferences.kt | 2 +- .../vitorpamplona/amethyst/model/Account.kt | 2 +- .../amethyst/model/AccountSettings.kt | 2 +- .../NipA3PaymentTargetsState.kt | 4 +- .../amethyst/model/zap/RailCapability.kt | 2 +- .../service/payments/PayToAppAvailability.kt | 2 +- .../FilterAccountInfoAndListsFromKey.kt | 2 +- .../paymentTargets/PaymentTargetsScreen.kt | 2 +- .../paymentTargets/PaymentTargetsViewModel.kt | 6 +- .../amethyst/ui/note/ReactionsRow.kt | 4 +- .../loggedIn/backups/BackupConflictCards.kt | 2 +- .../backups/BackupConflictListViews.kt | 6 +- .../backups/BackupConflictPresentation.kt | 2 +- .../profile/header/DisplayPaymentTargets.kt | 18 ++++-- .../profile/header/PaymentTargetsDialog.kt | 2 +- .../profile/header/ProfilePaymentRailChips.kt | 4 +- .../screen/loggedIn/relays/KindDisplayName.kt | 2 +- .../loggedIn/settings/NIP47SetupScreen.kt | 2 +- .../amethyst/commons/model/User.kt | 4 +- .../backups/ReplaceableBackupConflict.kt | 2 +- .../commons/model/cache/EventCache.kt | 2 +- .../model/payments/PayToRailMatcher.kt | 2 +- .../model/payments/PaymentTargetTypes.kt | 34 ++++++++++- .../profile/FilterUserProfileLists.kt | 2 +- .../user/watchers/FilterUserMetadataForKey.kt | 2 +- .../model/payments/PaymentTargetTypesTest.kt | 38 +++++++++++- .../vitorpamplona/quartz/kinds/KindNames.kt | 2 +- .../PaymentTarget.kt | 2 +- .../PaymentTargetTag.kt | 8 ++- .../PaymentTargetsDiff.kt | 2 +- .../PaymentTargetsEvent.kt | 2 +- .../quartz/utils/EventFactory.kt | 2 +- .../PaymentTargetTagTest.kt | 61 +++++++++++++++++++ 34 files changed, 186 insertions(+), 47 deletions(-) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{experimental/nipA3 => nipA3PaymentTargets}/PaymentTarget.kt (95%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{experimental/nipA3 => nipA3PaymentTargets}/PaymentTargetTag.kt (85%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{experimental/nipA3 => nipA3PaymentTargets}/PaymentTargetsDiff.kt (96%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{experimental/nipA3 => nipA3PaymentTargets}/PaymentTargetsEvent.kt (98%) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipA3PaymentTargets/PaymentTargetTagTest.kt diff --git a/.claude/skills/nostr-expert/references/nip-catalog.md b/.claude/skills/nostr-expert/references/nip-catalog.md index 996addba10..88b1488950 100644 --- a/.claude/skills/nostr-expert/references/nip-catalog.md +++ b/.claude/skills/nostr-expert/references/nip-catalog.md @@ -88,6 +88,7 @@ ent for NIP-04) | | 96 | `nip96FileStorage/` | HTTP file storage | HTTP-based file storage | | 99 | `nip99Classifieds/` | ClassifiedsEvent.kt | Classifieds/marketplace (kind 30402) | | A0 | `nipA0VoiceMessages/` | Voice messages | Voice message events | +| A3 | `nipA3PaymentTargets/` | PaymentTargetsEvent.kt | Payment targets (`payto` tags, kind 10133) | | B7 | `nipB7Blossom/` | Blossom server URLs | Blossom file storage | ### Web/Storage/Other @@ -127,7 +128,6 @@ Located at `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ | `limits/` | Limit enforcement | | `medical/` | Medical data | | `nip95/` | File storage support | -| `nipA3/` | A3 protocol extension | | `nns/` | Nostr Name System | | `profileGallery/` | Profile gallery lists | | `publicMessages/` | Public message lists | diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 256a27ed35..707167a37c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -67,7 +67,6 @@ import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -98,6 +97,7 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent +import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 2412c5adf3..5f837dbb83 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -199,7 +199,6 @@ import com.vitorpamplona.quartz.experimental.nip95.header.dimension import com.vitorpamplona.quartz.experimental.nip95.header.fileSize import com.vitorpamplona.quartz.experimental.nip95.header.hash import com.vitorpamplona.quartz.experimental.nip95.header.mimeType -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.experimental.profileGallery.blurhash import com.vitorpamplona.quartz.experimental.profileGallery.dimension @@ -337,6 +336,7 @@ import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.utils.DualCase diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 81932f49de..fbf80b4a67 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -50,7 +50,6 @@ import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event @@ -84,6 +83,7 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent +import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.toImmutableList diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipA3PaymentTargets/NipA3PaymentTargetsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipA3PaymentTargets/NipA3PaymentTargetsState.kt index b2012e8794..fc58597571 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipA3PaymentTargets/NipA3PaymentTargetsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipA3PaymentTargets/NipA3PaymentTargetsState.kt @@ -23,9 +23,9 @@ package com.vitorpamplona.amethyst.model.nipA3PaymentTargets import com.vitorpamplona.amethyst.commons.model.NoteState import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.model.AccountSettings -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget +import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt index 286ded391f..993c9e1355 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt @@ -28,12 +28,12 @@ import com.vitorpamplona.amethyst.commons.model.payments.PaymentTargetTypes import com.vitorpamplona.amethyst.model.MIN_ONCHAIN_ZAP_SATS import com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState import com.vitorpamplona.amethyst.service.payments.PayToAppAvailability -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip57Zaps.splits.BaseZapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupLnAddress import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup +import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget /** * What payment rails would actually reach the recipient(s) of a zap. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/payments/PayToAppAvailability.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/payments/PayToAppAvailability.kt index 4970e9aec4..8657b94992 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/payments/PayToAppAvailability.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/payments/PayToAppAvailability.kt @@ -31,7 +31,7 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.core.graphics.drawable.toBitmap import androidx.core.net.toUri import com.vitorpamplona.amethyst.commons.model.payments.PaymentTargetTypes -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget +import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt index a4403f5659..d4c69458f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt @@ -25,7 +25,6 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFil import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState.Companion.APP_SPECIFIC_DATA_D_TAG import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent @@ -53,6 +52,7 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent +import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/paymentTargets/PaymentTargetsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/paymentTargets/PaymentTargetsScreen.kt index 8aacd82061..3be6faab17 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/paymentTargets/PaymentTargetsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/paymentTargets/PaymentTargetsScreen.kt @@ -74,7 +74,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.grayText import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget +import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget @Composable fun PaymentTargetsScreen( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/paymentTargets/PaymentTargetsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/paymentTargets/PaymentTargetsViewModel.kt index 362df0c9cc..4192f2599a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/paymentTargets/PaymentTargetsViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/paymentTargets/PaymentTargetsViewModel.kt @@ -23,9 +23,10 @@ package com.vitorpamplona.amethyst.ui.actions.paymentTargets import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.model.payments.PaymentTargetTypes import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget +import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -62,7 +63,8 @@ class PaymentTargetsViewModel : ViewModel() { type: String, authority: String, ) { - val trimmedType = type.trim().lowercase() + // Stores the NIP-A3 spelling of known aliases (btc -> bitcoin, cashapp -> cashme). + val trimmedType = PaymentTargetTypes.canonical(type) val trimmedAuthority = authority.trim() if (trimmedType.isEmpty() || trimmedAuthority.isEmpty()) return val target = PaymentTarget(trimmedType, trimmedAuthority) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index bc90957b86..ea9b705ab1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -238,8 +238,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.PaymentTarge import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.paymentTargetStyleFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainZapSendDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.navigateToReloadMint -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -248,6 +246,8 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent +import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget +import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictCards.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictCards.kt index 942c0c7827..4091812db7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictCards.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictCards.kt @@ -100,7 +100,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDiff import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDiff -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsDiff import com.vitorpamplona.quartz.nip01Core.diff.ContentChange import com.vitorpamplona.quartz.nip01Core.diff.EventDiff import com.vitorpamplona.quartz.nip01Core.metadata.MetadataDiff @@ -118,6 +117,7 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListDiff import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListDiff import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataDiff import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListDiff +import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsDiff import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListDiff import org.jetbrains.compose.resources.PluralStringResource import org.jetbrains.compose.resources.StringResource diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictListViews.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictListViews.kt index 9be726a715..fc4184018b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictListViews.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictListViews.kt @@ -127,9 +127,6 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDiff import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDiff import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsDiff -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event @@ -161,6 +158,9 @@ import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListDiff import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget +import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsDiff +import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListDiff import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent import org.jetbrains.compose.resources.PluralStringResource diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictPresentation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictPresentation.kt index ab29c78d75..9980be0f5d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictPresentation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictPresentation.kt @@ -118,7 +118,6 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDiff import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDiff -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsDiff import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.diff.ContentChange @@ -149,6 +148,7 @@ import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListDiff import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataDiff import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListDiff +import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsDiff import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListDiff import org.jetbrains.compose.resources.StringResource diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayPaymentTargets.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayPaymentTargets.kt index 142417dafe..4f65d19674 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayPaymentTargets.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayPaymentTargets.kt @@ -39,7 +39,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.commons.ui.theme.Size16Modifier import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.payment.ProfilePaymentMethod -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget +import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.SegwitAddress fun isLightningPaymentTarget(rawType: String): Boolean = rawType.trim().lowercase() in PaymentTargetTypes.LIGHTNING_TYPES @@ -72,7 +72,7 @@ fun inAppPaymentRouteFor( /** * The URI an external wallet app should receive for [target]: the type's own - * scheme (`bitcoin:`, `lightning:`, `https://cash.app/…`) and RFC 8905 + * scheme (`bitcoin:`, `lightning:`, `https://cash.app/$…`) and RFC 8905 * `payto://` for types Amethyst has no dedicated scheme for. Shared with the * payment-target dialog so the same pill hands off to the same app wherever * it is tapped. @@ -159,6 +159,10 @@ fun paymentTargetStyleFor(rawType: String): PaymentTargetStyle { return when (type) { "bitcoin", "btc", "onchain" -> PaymentTargetStyle(MaterialSymbols.CurrencyBitcoin, BitcoinOrange, "BITCOIN") + "bip352" -> + PaymentTargetStyle(MaterialSymbols.CurrencyBitcoin, BitcoinOrange, "SILENT PAYMENT") + "bip353" -> + PaymentTargetStyle(MaterialSymbols.CurrencyBitcoin, BitcoinOrange, "BIP-353") "lightning", "ln" -> PaymentTargetStyle(MaterialSymbols.Bolt, BitcoinOrange, "LIGHTNING") "lnurl" -> @@ -183,8 +187,12 @@ fun paymentTargetStyleFor(rawType: String): PaymentTargetStyle { PaymentTargetStyle(walletIcon, SOLANA_PURPLE, "SOLANA") "tron", "trx" -> PaymentTargetStyle(walletIcon, TRON_RED, "TRON") - "cashapp" -> - PaymentTargetStyle(walletIcon, CASHAPP_LIME, "CASHAPP") + "cashme", "cashapp" -> + PaymentTargetStyle(walletIcon, CASHAPP_LIME, "CASH APP") + "nano", "xno" -> + PaymentTargetStyle(walletIcon, NANO_BLUE, "NANO") + "revolut" -> + PaymentTargetStyle(walletIcon, REVOLUT_BLUE, "REVOLUT") "venmo" -> PaymentTargetStyle(walletIcon, VENMO_BLUE, "VENMO") "paypal" -> @@ -213,4 +221,6 @@ private val TRON_RED = Color(0xFFEF0027) private val CASHAPP_LIME = Color(0xFF00E64D) private val VENMO_BLUE = Color(0xFF008CFF) private val PAYPAL_DEEP_BLUE = Color(0xFF003087) +private val NANO_BLUE = Color(0xFF209CE9) +private val REVOLUT_BLUE = Color(0xFF0666EB) private val GENERIC_TARGET_COLOR = Color(0xFF7C8DA0) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentTargetsDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentTargetsDialog.kt index 1941a50d95..d1ed9b394a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentTargetsDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentTargetsDialog.kt @@ -69,7 +69,7 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget +import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget import kotlinx.coroutines.launch @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfilePaymentRailChips.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfilePaymentRailChips.kt index 775012e84e..6ee12c1654 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfilePaymentRailChips.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfilePaymentRailChips.kt @@ -78,8 +78,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.payment.ProfilePaymentMethod import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.payment.rememberProfileClinkOffer import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent +import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget +import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress import kotlinx.coroutines.launch diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/KindDisplayName.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/KindDisplayName.kt index c8b6e75aaf..1b80cdec86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/KindDisplayName.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/KindDisplayName.kt @@ -195,7 +195,6 @@ import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent @@ -324,6 +323,7 @@ import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NIP47SetupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NIP47SetupScreen.kt index 2e091a4cf1..7d155b5587 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NIP47SetupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NIP47SetupScreen.kt @@ -64,7 +64,7 @@ import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletViewModel -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget +import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget @Composable fun NIP47SetupScreen( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt index aaa614f854..9168c65c5e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt @@ -30,8 +30,6 @@ import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.UserCards import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.toShortDisplay import com.vitorpamplona.amethyst.commons.util.withLock -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata @@ -43,6 +41,8 @@ import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent import com.vitorpamplona.quartz.nip61Nutzaps.info.tags.NutzapMintTag import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget +import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent import com.vitorpamplona.quartz.utils.Hex import kotlin.concurrent.Volatile diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/ReplaceableBackupConflict.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/ReplaceableBackupConflict.kt index b1ffb3334d..b77d36e4d9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/ReplaceableBackupConflict.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/ReplaceableBackupConflict.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.commons.model.backups import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent @@ -53,6 +52,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.ClientTag +import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent /** Which of the account's backed-up events a conflict is about. Drives the dialog's wording. */ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt index 0954ef7a9e..8a7ed5cdd6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt @@ -177,7 +177,6 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.Softw import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent @@ -392,6 +391,7 @@ import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PayToRailMatcher.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PayToRailMatcher.kt index fa8bccab2f..b360bbad8d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PayToRailMatcher.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PayToRailMatcher.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.model.payments -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget +import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget /** * Picks the NIP-A3 payment targets a sender can hand off to when paying a note's diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypes.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypes.kt index e82aa23c43..a6d03e4aa3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypes.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypes.kt @@ -37,6 +37,9 @@ package com.vitorpamplona.amethyst.commons.model.payments * and the installed-app probe (which needs the URI before it has an authority). */ object PaymentTargetTypes { + private const val CASHME = "cashme" + private const val DOLLAR = "\$" + /** Lightning-family types Amethyst can pay in-app through the Send Payment screen. */ val LIGHTNING_TYPES = setOf("lightning", "ln", "lnurl") @@ -62,10 +65,14 @@ object PaymentTargetTypes { "doge" to "dogecoin", "sol" to "solana", "trx" to "tron", + "xno" to "nano", + // NIP-A3 names the Cash App cashtag type `cashme`; `cashapp` is what + // Amethyst (and others) wrote before the spec settled on it. + "cashapp" to "cashme", ) /** Types whose hand-off is a web page rather than a registered URI scheme. */ - private val WEB_TYPES = setOf("cashapp", "venmo", "paypal") + private val WEB_TYPES = setOf("cashme", "venmo", "paypal", "revolut") /** Types with a dedicated URI scheme, keyed by canonical name. */ private val SCHEMES = @@ -82,16 +89,31 @@ object PaymentTargetTypes { "dogecoin" to "dogecoin", "solana" to "solana", "tron" to "tron", + "nano" to "nano", + ) + + /** + * Types whose scheme carries the address in a query parameter instead of the path: + * a BIP-352 silent payment address goes in a BIP-321 `bitcoin:` URI's `sp` field. + */ + private val QUERY_SCHEMES = + mapOf( + "bip352" to "bitcoin:?sp=", ) private val WEB_HOSTS = mapOf( - "cashapp" to "https://cash.app/", + "cashme" to "https://cash.app/", "venmo" to "https://venmo.com/", "paypal" to "https://paypal.me/", + "revolut" to "https://revolut.me/", ) - /** Trims, lowercases and collapses known aliases onto one family name. */ + /** + * Trims, lowercases and collapses known aliases onto one family name. The result is the + * NIP-A3 spelling where the spec lists one (`cashapp` -> `cashme`), so it is also what a + * new `payto` tag should carry. + */ fun canonical(rawType: String): String { val trimmed = rawType.trim().lowercase() return ALIASES[trimmed] ?: trimmed @@ -130,6 +152,12 @@ object PaymentTargetTypes { val type = canonical(rawType) val value = authority.trim() SCHEMES[type]?.let { return "$it:$value" } + QUERY_SCHEMES[type]?.let { return "$it$value" } + if (type == CASHME) { + // cash.app/$cashtag: NIP-A3 describes the `$`-prefixed tag, but accept a bare one. + val cashtag = if (value.isEmpty() || value.startsWith(DOLLAR)) value else DOLLAR + value + return WEB_HOSTS.getValue(CASHME) + cashtag + } WEB_HOSTS[type]?.let { return "$it$value" } return "payto://$type/$value" } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/profile/FilterUserProfileLists.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/profile/FilterUserProfileLists.kt index 444f4e1695..f27c2fc3bc 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/profile/FilterUserProfileLists.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/profile/FilterUserProfileLists.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.commons.relayClient.profile import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip51Lists.PinListEvent @@ -33,6 +32,7 @@ import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListEvent import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent +import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent val UserProfileListKinds = listOf( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterUserMetadataForKey.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterUserMetadataForKey.kt index 0d1290113d..bab6f362a6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterUserMetadataForKey.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterUserMetadataForKey.kt @@ -25,7 +25,6 @@ import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.forEachChunk import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer @@ -37,6 +36,7 @@ import com.vitorpamplona.quartz.nip38UserStatus.UserStatusEvent import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent import com.vitorpamplona.quartz.utils.mapOfSet diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypesTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypesTest.kt index 3c63157b04..07cdef970b 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypesTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypesTest.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.model.payments -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget +import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -76,6 +76,42 @@ class PaymentTargetTypesTest { assertFalse(PaymentTargetTypes.isWebTarget("iban")) } + @Test + fun specCashmeIsCashAppAndCashappStaysAnAlias() { + assertEquals("cashme", PaymentTargetTypes.canonical("cashapp")) + assertEquals("cashme", PaymentTargetTypes.canonical("CashMe")) + assertEquals("https://cash.app/\$vitor", PaymentTargetTypes.uriFor("cashme", "\$vitor")) + // NIP-A3 describes a $-prefixed cashtag; accept the bare name too. + assertEquals("https://cash.app/\$vitor", PaymentTargetTypes.uriFor("cashme", "vitor")) + assertTrue(PaymentTargetTypes.isWebTarget("cashme")) + assertEquals(PaymentTargetTypes.probeKeyFor("cashapp"), PaymentTargetTypes.probeKeyFor("cashme")) + } + + @Test + fun specListedTypesUseTheirNativeScheme() { + assertEquals("bitcoin:bc1q", PaymentTargetTypes.uriFor("bitcoin", "bc1q")) + assertEquals("bitcoincash:qq1", PaymentTargetTypes.uriFor("bitcoincash", "qq1")) + assertEquals("ethereum:0xabc", PaymentTargetTypes.uriFor("ethereum", "0xabc")) + assertEquals("lightning:me@ln.tips", PaymentTargetTypes.uriFor("lightning", "me@ln.tips")) + assertEquals("litecoin:ltc1", PaymentTargetTypes.uriFor("litecoin", "ltc1")) + assertEquals("monero:4A", PaymentTargetTypes.uriFor("monero", "4A")) + assertEquals("nano:nano_1dctq", PaymentTargetTypes.uriFor("nano", "nano_1dctq")) + assertEquals("solana:So1", PaymentTargetTypes.uriFor("solana", "So1")) + assertEquals("tron:T9", PaymentTargetTypes.uriFor("tron", "T9")) + assertEquals("zcash:zs1", PaymentTargetTypes.uriFor("zcash", "zs1")) + // BIP-352 silent payment addresses travel in a BIP-321 bitcoin: URI's sp parameter. + assertEquals("bitcoin:?sp=sp1qq", PaymentTargetTypes.uriFor("bip352", "sp1qq")) + assertEquals("https://paypal.me/vitor", PaymentTargetTypes.uriFor("paypal", "vitor")) + assertEquals("https://venmo.com/vitor", PaymentTargetTypes.uriFor("venmo", "vitor")) + assertEquals("https://revolut.me/vitor", PaymentTargetTypes.uriFor("revolut", "vitor")) + } + + @Test + fun specListedTypesWithoutASchemeUsePayto() { + // A BIP-353 name has to be resolved over DNS first; there is no URI scheme for it. + assertEquals("payto://bip353/vitor@example.com", PaymentTargetTypes.uriFor("bip353", "vitor@example.com")) + } + @Test fun unknownTypesFallBackToPayto() { assertEquals("payto://iban/DE75512108001245126199", PaymentTargetTypes.uriFor("IBAN", "DE75512108001245126199")) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt index 0df568b07a..5e75663ce5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt @@ -59,7 +59,6 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.Softw import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent @@ -309,6 +308,7 @@ import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTarget.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA3PaymentTargets/PaymentTarget.kt similarity index 95% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTarget.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA3PaymentTargets/PaymentTarget.kt index 66e17243ca..9868f40b19 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTarget.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA3PaymentTargets/PaymentTarget.kt @@ -18,7 +18,7 @@ * 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.nipA3 +package com.vitorpamplona.quartz.nipA3PaymentTargets data class PaymentTarget( val type: String, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA3PaymentTargets/PaymentTargetTag.kt similarity index 85% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetTag.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA3PaymentTargets/PaymentTargetTag.kt index 5be6ef4927..442963e6ad 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA3PaymentTargets/PaymentTargetTag.kt @@ -18,7 +18,7 @@ * 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.nipA3 +package com.vitorpamplona.quartz.nipA3PaymentTargets import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.utils.ensure @@ -38,9 +38,11 @@ class PaymentTargetTag { ensure(tag.has(2)) { return null } ensure(tag[2].isNotEmpty()) { return null } - return PaymentTarget(tag[1], tag[2]) + // NIP-A3: the type is always lowercase. Normalize what other clients wrote so + // `Bitcoin` and `bitcoin` are one rail. + return PaymentTarget(tag[1].lowercase(), tag[2]) } - fun assemble(paymentTarget: PaymentTarget) = arrayOf(TAG_NAME, paymentTarget.type, paymentTarget.authority) + fun assemble(paymentTarget: PaymentTarget) = arrayOf(TAG_NAME, paymentTarget.type.lowercase(), paymentTarget.authority) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA3PaymentTargets/PaymentTargetsDiff.kt similarity index 96% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsDiff.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA3PaymentTargets/PaymentTargetsDiff.kt index 29ddcc0f85..b55619134a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsDiff.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA3PaymentTargets/PaymentTargetsDiff.kt @@ -18,7 +18,7 @@ * 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.nipA3 +package com.vitorpamplona.quartz.nipA3PaymentTargets import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.diff.EventDiff diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA3PaymentTargets/PaymentTargetsEvent.kt similarity index 98% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA3PaymentTargets/PaymentTargetsEvent.kt index c1982a6d02..55e1d53d27 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA3PaymentTargets/PaymentTargetsEvent.kt @@ -18,7 +18,7 @@ * 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.nipA3 +package com.vitorpamplona.quartz.nipA3PaymentTargets import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index a3986c0516..ec0c5a0a4f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -149,7 +149,6 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.Softw import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent @@ -411,6 +410,7 @@ import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipA3PaymentTargets/PaymentTargetTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipA3PaymentTargets/PaymentTargetTagTest.kt new file mode 100644 index 0000000000..18f75b4609 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipA3PaymentTargets/PaymentTargetTagTest.kt @@ -0,0 +1,61 @@ +/* + * 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.nipA3PaymentTargets + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class PaymentTargetTagTest { + @Test + fun parsesTheSpecExample() { + val tags = + arrayOf( + arrayOf("payto", "bitcoin", "bc1qxq66e0t8d7ugdecwnmv58e90tpry23nc84pg9k"), + arrayOf("payto", "nano", "nano_1dctqbmqxfppo9pswbm6kg9d4s4mbraqn8i4m7ob9gnzz91aurmuho48jx3c"), + arrayOf("payto", "unknowntype", "l7tbta5b9xze6ckkfc99uohzxd009b0r"), + ) + val event = PaymentTargetsEvent("0".repeat(64), "1".repeat(64), 1L, tags, "", "") + assertEquals( + listOf( + PaymentTarget("bitcoin", "bc1qxq66e0t8d7ugdecwnmv58e90tpry23nc84pg9k"), + PaymentTarget("nano", "nano_1dctqbmqxfppo9pswbm6kg9d4s4mbraqn8i4m7ob9gnzz91aurmuho48jx3c"), + PaymentTarget("unknowntype", "l7tbta5b9xze6ckkfc99uohzxd009b0r"), + ), + event.paymentTargets(), + ) + } + + @Test + fun typeIsAlwaysLowercase() { + assertEquals(PaymentTarget("bitcoin", "bc1q"), PaymentTargetTag.parse(arrayOf("payto", "Bitcoin", "bc1q"))) + assertContentEquals(arrayOf("payto", "cashme", "\$Vitor"), PaymentTargetTag.assemble(PaymentTarget("CashMe", "\$Vitor"))) + } + + @Test + fun rejectsIncompleteTags() { + assertNull(PaymentTargetTag.parse(arrayOf("payto", "bitcoin"))) + assertNull(PaymentTargetTag.parse(arrayOf("payto", "", "bc1q"))) + assertNull(PaymentTargetTag.parse(arrayOf("payto", "bitcoin", ""))) + assertNull(PaymentTargetTag.parse(arrayOf("r", "bitcoin", "bc1q"))) + } +} From 92fe94e19550b5756833bebc299a470f05a3738d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 19:27:58 +0000 Subject: [PATCH 10/24] test(nip01): pin limit:0 REQ semantics on the relay engine NIP-01 now says a filter with `limit: 0` MUST NOT return stored events, the relay MUST still send EOSE, and the subscription MUST stay open for new matching events. The engine already behaves this way (the store compiles limit 0 to LIMIT 0 and LiveEventStore keeps the live tail); these tests pin it, including a multi-filter REQ where only the limit-0 filter is silenced. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc --- .../relay/server/NostrServerLimitZeroTest.kt | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerLimitZeroTest.kt diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerLimitZeroTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerLimitZeroTest.kt new file mode 100644 index 0000000000..e1b89c4c49 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerLimitZeroTest.kt @@ -0,0 +1,129 @@ +/* + * 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.server + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * NIP-01: "When `limit` is zero, the relay MUST NOT return stored events for that + * filter. After the initial queries for all filters are complete, the relay MUST send + * `EOSE` and MUST keep the subscription active for newly received matching events." + */ +@OptIn(ExperimentalCoroutinesApi::class) +class NostrServerLimitZeroTest { + private val pubkey = "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d" + private val sig = "4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce" + + private val noop: (String) -> Unit = {} + + private fun hexId(n: Int): String = n.toString().padStart(64, '0') + + private fun testEvent( + id: String, + kind: Int = 1, + createdAt: Long = 1000L, + ) = Event(id, pubkey, createdAt, kind, emptyArray(), "hello", sig) + + private fun server( + dispatcher: CoroutineDispatcher, + store: EventStore, + ) = NostrServer(store = store, policyBuilder = { EmptyPolicy }, parentContext = dispatcher) + + private class Collector { + val messages = mutableListOf() + val send: (String) -> Unit = { messages.add(it) } + + fun parsed(): List = + messages + .filter { it.startsWith("[\"EVENT\"") || it.startsWith("[\"EOSE\"") } + .map { OptimizedJsonMapper.fromJsonToMessage(it) } + + fun events() = parsed().filterIsInstance() + + fun eoses() = parsed().filterIsInstance() + } + + private suspend fun EventStore.seed(count: Int) { + for (i in 1..count) insert(testEvent(hexId(i), createdAt = i.toLong())) + } + + // -- NIP-01: limit 0 --------------------------------------------------------- + + @Test + fun limitZeroSendsNoStoredEventsThenEoseAndStaysLive() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + store.seed(5) + val server = server(dispatcher, store) + val collector = Collector() + val c1 = server.connect(collector.send) + val publisher = server.connect(noop) + + c1.receive("""["REQ","sub1",{"kinds":[1],"limit":0}]""") + + assertEquals(0, collector.events().size, "limit 0 MUST NOT return stored events") + assertEquals(1, collector.eoses().size, "EOSE is still sent") + assertEquals("sub1", collector.eoses().single().subId) + + publisher.receive(OptimizedJsonMapper.toJson(EventCmd(testEvent(hexId(100), createdAt = 5000L)))) + + val live = collector.events() + assertEquals(1, live.size, "the subscription stays open for new events") + assertEquals(hexId(100), live.single().event.id) + assertTrue(collector.messages.indexOfFirst { it.startsWith("[\"EOSE\"") } < collector.messages.indexOfFirst { it.startsWith("[\"EVENT\"") }) + + server.close() + } + + @Test + fun limitZeroOnlySilencesItsOwnFilter() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + store.seed(3) + store.insert(testEvent(hexId(50), kind = 7, createdAt = 50L)) + val server = server(dispatcher, store) + val collector = Collector() + val c1 = server.connect(collector.send) + + c1.receive("""["REQ","sub1",{"kinds":[1],"limit":0},{"kinds":[7]}]""") + + assertEquals(listOf(hexId(50)), collector.events().map { it.event.id }) + assertEquals(1, collector.eoses().size) + + server.close() + } +} From 15f207e1489c5c205ecfbeaa48b918209689a31d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 19:28:14 +0000 Subject: [PATCH 11/24] feat(nip47): follow the core/extension split of NIP-47 NIP-47 moved notifications, hold invoices, keysend, list_transactions, metadata and deep links out of core into NWC extension specs (02..08), advertised by an `extensions` info-event tag. - NwcInfoEvent.supportsNotifications() also accepts `02` in `extensions` or a non-empty `notifications` tag. New-spec wallets no longer put the `notifications` token in the content, so Amethyst stopped subscribing to their kind 23197 notifications (NwcNotificationsEoseManager). - EncryptionTag/NotificationsTag/ExtensionsTag emit ONE space-separated value (["extensions", "02 03 04"]); parsing still accepts multi-element. - GetInfoResult gains `extensions` (kotlinx serializer both ways). - ExtensionsTag names the known specs and maps methods to them (forMethod / forCapabilities). Nip47Server advertises `extensions` derived from its capabilities and returns them in get_info. - NwcInfoEvent.mayUseExtensionMethod(): skip an extension method only when the wallet publishes an `extensions` tag lacking the extension AND its content lacks the method. Legacy wallets keep being asked. Used to gate list_transactions (05) in WalletViewModel and pay_keysend (04) in V4VPaymentHandler, with new user-facing messages. - README/KDoc say which methods are core and which come from extensions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc --- .../amethyst/service/V4VPaymentHandler.kt | 25 +++++- .../screen/loggedIn/wallet/WalletViewModel.kt | 15 ++++ .../composeResources/values/strings.xml | 2 + .../quartz/nip47WalletConnect/Nip47Server.kt | 35 +++++--- .../quartz/nip47WalletConnect/README.md | 66 ++++++++++---- .../nip47WalletConnect/events/NwcInfoEvent.kt | 40 ++++++++- .../Nip47ResponseKSerializer.kt | 4 + .../nip47WalletConnect/rpc/NwcMethod.kt | 18 +++- .../quartz/nip47WalletConnect/rpc/Response.kt | 3 + .../nip47WalletConnect/tags/EncryptionTag.kt | 4 +- .../nip47WalletConnect/tags/ExtensionsTag.kt | 67 ++++++++++++++- .../tags/NotificationsTag.kt | 4 +- .../nip47WalletConnect/NwcInfoEventTest.kt | 86 +++++++++++++++++++ .../quartz/nip47WalletConnect/ResponseTest.kt | 22 +++++ .../quartz/nip47WalletConnect/TagsTest.kt | 45 ++++++++-- 15 files changed, 392 insertions(+), 44 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt index cb7cc0ef22..9a8bd71f10 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt @@ -29,14 +29,17 @@ import com.vitorpamplona.amethyst.commons.resources.error_dialog_pay_invoice_err import com.vitorpamplona.amethyst.commons.resources.error_parsing_error_message import com.vitorpamplona.amethyst.commons.resources.error_unable_to_fetch_invoice import com.vitorpamplona.amethyst.commons.resources.podcast_value_error_title +import com.vitorpamplona.amethyst.commons.resources.podcast_value_keysend_not_supported import com.vitorpamplona.amethyst.commons.resources.podcast_value_keysend_requires_nwc import com.vitorpamplona.amethyst.commons.resources.podcast_value_no_recipients import com.vitorpamplona.amethyst.commons.ui.loadStringRes import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.ui.nwc.nwcFailureDetail import com.vitorpamplona.amethyst.ui.nwc.nwcTimeoutMessage import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip47WalletConnect.rpc.TlvRecord @@ -50,6 +53,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import okhttp3.OkHttpClient /** @@ -110,7 +114,14 @@ class V4VPaymentHandler( // Keysend (node) recipients can only be paid over NWC. if (nodeShares.isNotEmpty()) { if (account.nip47SignerState.hasWalletConnectSetup()) { - payNodeSharesViaKeysend(nodeShares, boostagram, context, onError) + if (defaultWalletMayKeysend()) { + payNodeSharesViaKeysend(nodeShares, boostagram, context, onError) + } else { + onError( + loadStringRes(Res.string.podcast_value_error_title), + loadStringRes(Res.string.podcast_value_keysend_not_supported), + ) + } } else { onError( loadStringRes(Res.string.podcast_value_error_title), @@ -140,6 +151,18 @@ class V4VPaymentHandler( onProgress(1f) } + /** + * `pay_keysend` lives in the NWC-04 extension. Only a wallet whose info event publishes an + * `extensions` tag without 04 (and doesn't list `pay_keysend` in its content) is skipped; + * a legacy wallet, or one whose info is unknown, is still asked, as before extensions + * existed. See [com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent.mayUseExtensionMethod]. + */ + private suspend fun defaultWalletMayKeysend(): Boolean { + val walletUri = account.nip47SignerState.defaultWalletUri.value ?: return true + val info = withTimeoutOrNull(NwcSignerState.NIP44_NEGOTIATION_WAIT_MS) { account.nwcInfoCache.currentOrFetch(walletUri) } + return info?.mayUseExtensionMethod(NwcMethod.PAY_KEYSEND) != false + } + /** Hex-encodes a TLV value string as NIP-47 `pay_keysend` requires (UTF-8 bytes → hex). */ private suspend fun hexTlv(value: String): String = value.encodeToByteArray().toHexKey() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt index c2ea98850b..40de2f46ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -38,9 +38,11 @@ import com.vitorpamplona.amethyst.commons.resources.wallet_request_timed_out import com.vitorpamplona.amethyst.commons.resources.wallet_request_timed_out_spoofed import com.vitorpamplona.amethyst.commons.resources.wallet_transactions_load_failed import com.vitorpamplona.amethyst.commons.resources.wallet_transactions_load_more_failed +import com.vitorpamplona.amethyst.commons.resources.wallet_transactions_not_supported import com.vitorpamplona.amethyst.commons.ui.loadPluralStringRes import com.vitorpamplona.amethyst.commons.ui.loadStringRes import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState import com.vitorpamplona.amethyst.service.ClinkDebitPayer import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency @@ -59,6 +61,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsSuccessRe import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse @@ -72,6 +75,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import org.jetbrains.compose.resources.StringResource sealed class SendState { @@ -572,6 +576,17 @@ class WalletViewModel : ViewModel() { _isLoading.value = true _error.value = null _hasMoreTransactions.value = true + // list_transactions lives in the NWC-05 extension. Only a wallet that publishes an + // `extensions` tag without 05 (and without the method in its content) is skipped; + // legacy wallets are still asked. See NwcInfoEvent.mayUseExtensionMethod. + val info = withTimeoutOrNull(NwcSignerState.NIP44_NEGOTIATION_WAIT_MS) { acc.nwcInfoCache.currentOrFetch(walletUri) } + if (info?.mayUseExtensionMethod(NwcMethod.LIST_TRANSACTIONS) == false) { + allTransactions.value = emptyList() + _hasMoreTransactions.value = false + _error.value = text(Res.string.wallet_transactions_not_supported) + _isLoading.value = false + return@launch + } var requestId: HexKey? = null val timeoutJob = launchTimeout({ requestId }) { _isLoading.value = false } try { diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 500df7421b..398c0f229f 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -4630,6 +4630,7 @@ Short audio or video preview Value-for-Value error Connect a Nostr Wallet Connect wallet to send to keysend (node) recipients. + Your wallet does not support keysend, so node recipients were skipped. This podcast has no payable value recipients. Connect a Nostr Wallet Connect or debit wallet to stream sats while listening. This user has no Lightning address @@ -5280,6 +5281,7 @@ Could not load transactions Could not load more transactions + Your wallet does not offer transaction history Shows a warning message when posts or profiles have reports from your follows Warn on reports Add Web Bookmark diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt index da7baebe4c..004570a987 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt @@ -46,6 +46,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentSentNotification import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.tags.ExtensionsTag /** * High-level NIP-47 Wallet Connect server (wallet service). @@ -86,18 +87,30 @@ class Nip47Server( val useNip44: Boolean = false, val encryptionSchemes: List? = null, val notificationTypes: List? = null, + /** + * NWC extension specs to advertise. Null derives them from [capabilities] and + * [notificationTypes] via [ExtensionsTag.forCapabilities] (eg. `pay_keysend` → `04`). + */ + extensions: List? = null, ) { + val extensions: List = extensions ?: ExtensionsTag.forCapabilities(capabilities, notificationTypes) + // --- Info event --- /** * Builds a kind 13194 info event advertising wallet capabilities. * Sign and publish this event to your relay. + * + * The content lists every method in [capabilities], including extension methods + * (NIP-47: they SHOULD also be in the content), and the `extensions` tag lists + * [extensions] as one space-separated value when there are any. */ fun buildInfoEvent() = NwcInfoEvent.build( capabilities = capabilities, encryptionSchemes = encryptionSchemes, notificationTypes = notificationTypes, + extensions = extensions.ifEmpty { null }, ) // --- Request parsing --- @@ -188,20 +201,22 @@ class Nip47Server( methods: List? = null, notifications: List? = null, lud16: String? = null, + extensions: List? = this.extensions.ifEmpty { null }, ): NwcResponseEvent = buildResponse( GetInfoSuccessResponse( GetInfoSuccessResponse.GetInfoResult( - alias, - color, - pubkey, - network, - blockHeight, - blockHash, - methods, - notifications, - null, - lud16, + alias = alias, + color = color, + pubkey = pubkey, + network = network, + block_height = blockHeight, + block_hash = blockHash, + methods = methods, + notifications = notifications, + metadata = null, + lud16 = lud16, + extensions = extensions, ), ), requestEvent, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md index 7ee4df5c25..a932edbfd4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md @@ -99,7 +99,8 @@ nip47WalletConnect/ ├── NostrWalletConnectResponseCache.kt # Response decryption cache └── tags/ ├── EncryptionTag.kt # "encryption" tag parsing - └── NotificationsTag.kt # "notifications" tag parsing + ├── ExtensionsTag.kt # "extensions" tag + method → NWC extension map + └── NotificationsTag.kt # "notifications" tag parsing (NWC-02) ``` ## Event Kinds @@ -109,26 +110,55 @@ nip47WalletConnect/ | 13194 | `NwcInfoEvent` | Wallet → Relay | Service capabilities | | 23194 | `NwcRequestEvent` | Client → Wallet | NWC request | | 23195 | `NwcResponseEvent` | Wallet → Client | NWC response | -| 23196 | `NwcNotificationEvent` | Wallet → Client | Notification (NIP-04, legacy) | -| 23197 | `NwcNotificationEvent` | Wallet → Client | Notification (NIP-44) | +| 23196 | `NwcNotificationEvent` | Wallet → Client | Notification (NWC-02, NIP-04, legacy) | +| 23197 | `NwcNotificationEvent` | Wallet → Client | Notification (NWC-02, NIP-44) | ## Supported Methods -| Method | `Nip47Client` method | Request Class | Success Response Class | -|----------------------|-----------------------------|---------------------------|-----------------------------------| -| `pay_invoice` | `payInvoice()` | `PayInvoiceMethod` | `PayInvoiceSuccessResponse` | -| `pay_keysend` | `payKeysend()` | `PayKeysendMethod` | `PayKeysendSuccessResponse` | -| `make_invoice` | `makeInvoice()` | `MakeInvoiceMethod` | `MakeInvoiceSuccessResponse` | -| `lookup_invoice` | `lookupInvoiceByHash/ByInvoice()` | `LookupInvoiceMethod`| `LookupInvoiceSuccessResponse` | -| `list_transactions` | `listTransactions()` | `ListTransactionsMethod` | `ListTransactionsSuccessResponse` | -| `get_balance` | `getBalance()` | `GetBalanceMethod` | `GetBalanceSuccessResponse` | -| `get_info` | `getInfo()` | `GetInfoMethod` | `GetInfoSuccessResponse` | -| `get_budget` | `getBudget()` | `GetBudgetMethod` | `GetBudgetSuccessResponse` | -| `sign_message` | `signMessage()` | `SignMessageMethod` | `SignMessageSuccessResponse` | -| `create_connection` | `buildRequest()` | `CreateConnectionMethod` | `CreateConnectionSuccessResponse` | -| `make_hold_invoice` | `makeHoldInvoice()` | `MakeHoldInvoiceMethod` | `MakeHoldInvoiceSuccessResponse` | -| `cancel_hold_invoice`| `cancelHoldInvoice()` | `CancelHoldInvoiceMethod` | `CancelHoldInvoiceSuccessResponse`| -| `settle_hold_invoice`| `settleHoldInvoice()` | `SettleHoldInvoiceMethod` | `SettleHoldInvoiceSuccessResponse`| +NIP-47 now defines only a small **core** command set. Everything else lives in optional +extension specs maintained at (`02.md`, `03.md`, …). +The "Spec" column says where each method is defined; `ExtensionsTag.forMethod()` returns the +same mapping in code. + +| Method | Spec | `Nip47Client` method | Request Class | Success Response Class | +|----------------------|--------------|-----------------------------|---------------------------|-----------------------------------| +| `pay_invoice` | core | `payInvoice()` | `PayInvoiceMethod` | `PayInvoiceSuccessResponse` | +| `make_invoice` | core | `makeInvoice()` | `MakeInvoiceMethod` | `MakeInvoiceSuccessResponse` | +| `lookup_invoice` | core | `lookupInvoiceByHash/ByInvoice()` | `LookupInvoiceMethod`| `LookupInvoiceSuccessResponse` | +| `get_balance` | core | `getBalance()` | `GetBalanceMethod` | `GetBalanceSuccessResponse` | +| `get_info` | core | `getInfo()` | `GetInfoMethod` | `GetInfoSuccessResponse` | +| `make_hold_invoice` | NWC-03 | `makeHoldInvoice()` | `MakeHoldInvoiceMethod` | `MakeHoldInvoiceSuccessResponse` | +| `cancel_hold_invoice`| NWC-03 | `cancelHoldInvoice()` | `CancelHoldInvoiceMethod` | `CancelHoldInvoiceSuccessResponse`| +| `settle_hold_invoice`| NWC-03 | `settleHoldInvoice()` | `SettleHoldInvoiceMethod` | `SettleHoldInvoiceSuccessResponse`| +| `pay_keysend` | NWC-04 | `payKeysend()` | `PayKeysendMethod` | `PayKeysendSuccessResponse` | +| `list_transactions` | NWC-05 | `listTransactions()` | `ListTransactionsMethod` | `ListTransactionsSuccessResponse` | +| `get_budget` | not in a published spec | `getBudget()` | `GetBudgetMethod` | `GetBudgetSuccessResponse` | +| `sign_message` | not in a published spec | `signMessage()` | `SignMessageMethod` | `SignMessageSuccessResponse` | +| `create_connection` | not in a published spec | `buildRequest()` | `CreateConnectionMethod` | `CreateConnectionSuccessResponse` | + +Notifications (`payment_received`, `payment_sent`, kinds 23197/23196) are NWC-02; +`hold_invoice_accepted` is NWC-03 delivered over NWC-02. The `metadata` key conventions are +NWC-06, deep links (`nostrnwc://`) NWC-07. + +## Extension discovery + +A wallet advertises extensions in its kind 13194 info event with ONE space-separated tag +value, `["extensions", "02 03 04"]`, and SHOULD also list extension methods in the content. +`get_info` may return the per-connection set as `"extensions": ["02", "05"]` +(`GetInfoResult.extensions`). The `encryption`, `notifications` and `extensions` tag builders +all emit a single space-separated value; the parsers still accept multi-element tags. + +- `NwcInfoEvent.supportsExtension(id)` — strict: silence means **no**. Use it before changing + a request in a way the wallet might reject (e.g. NWC-06 `metadata`). +- `NwcInfoEvent.supportsNotifications()` — true for the legacy `notifications` content token, + `02` in `extensions`, or a non-empty `notifications` tag. +- `NwcInfoEvent.mayUseExtensionMethod(method)` — lenient: only `false` when the wallet + publishes an `extensions` tag that lacks the method's extension AND its content does not + list the method. Pre-extensions wallets (no `extensions` tag) are still sent the request, + as before, and answer `NOT_IMPLEMENTED` if they can't. Amethyst uses this to gate + `list_transactions` (05) and `pay_keysend` (04). +- `Nip47Server` derives its `extensions` tag from its capabilities and notification types + (`ExtensionsTag.forCapabilities`) unless an explicit list is passed. Any method can also return `NwcErrorResponse` or (for `pay_invoice`) `PayInvoiceErrorResponse`. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/NwcInfoEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/NwcInfoEvent.kt index 5e3299e7d6..c6eeeb54b9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/NwcInfoEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/NwcInfoEvent.kt @@ -43,7 +43,19 @@ class NwcInfoEvent( fun supportsMethod(method: String): Boolean = capabilities().contains(method) - fun supportsNotifications(): Boolean = capabilities().contains("notifications") + /** + * Whether the wallet sends NWC-02 notifications (kind 23197/23196). + * + * Three signals count, since wallets predate and postdate the move of + * notifications out of NIP-47 core into the NWC-02 extension: + * - legacy: the bare `notifications` token in the content; + * - current: `02` in the `extensions` tag; + * - either: a non-empty `notifications` tag listing the notification types. + */ + fun supportsNotifications(): Boolean = + capabilities().contains(ExtensionsTag.LEGACY_NOTIFICATIONS_CAPABILITY) || + supportsExtension(ExtensionsTag.NOTIFICATIONS) || + notificationTypes().isNotEmpty() // NIP-47 carries the schemes/types as a single space-separated string in one // tag value (e.g. ["encryption", "nip44_v2 nip04"]). Split on whitespace so we @@ -71,6 +83,32 @@ class NwcInfoEvent( */ fun supportsExtension(id: String) = extensions().contains(id) + /** Whether the wallet publishes an `extensions` tag at all (a post-extensions NIP-47 wallet). */ + fun advertisesExtensions() = tags.any { ExtensionsTag.parse(it) != null } + + /** + * Whether a client should send [method], a method defined by NWC extension [extension] + * (see [ExtensionsTag.forMethod]). Core methods (no extension) always pass. + * + * - `true` when the content lists [method] or the `extensions` tag lists [extension]. + * - `false` only when the wallet publishes an `extensions` tag that lacks [extension] + * AND the content does not list [method]: that wallet speaks the extension-aware + * NIP-47 and has told us it does not implement it. + * - `true` otherwise: a pre-extensions wallet that simply doesn't mention the method + * is given the benefit of the doubt, as clients always did, and answers with + * `NOT_IMPLEMENTED` if it really can't. + * + * Unlike [supportsExtension] this errs towards sending, because the cost of a wrong + * guess is a clean error response rather than a changed request. + */ + fun mayUseExtensionMethod( + method: String, + extension: String? = ExtensionsTag.forMethod(method), + ): Boolean { + if (extension == null || supportsMethod(method) || supportsExtension(extension)) return true + return !advertisesExtensions() + } + companion object { const val KIND = 13194 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt index 92ee8668cd..a148954cb9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt @@ -237,6 +237,9 @@ object Nip47ResponseKSerializer : KSerializer { } result.metadata?.let { put("metadata", anyToJsonElement(it)) } result.lud16?.let { put("lud16", it) } + result.extensions?.let { extensions -> + put("extensions", buildJsonArray { extensions.forEach { add(it) } }) + } } private fun serializeGetBudgetResult(result: GetBudgetSuccessResponse.GetBudgetResult): JsonObject = @@ -528,6 +531,7 @@ object Nip47ResponseKSerializer : KSerializer { notifications = it.stringListOrNull("notifications"), metadata = it.anyMapOrNull("metadata"), lud16 = it.stringOrNull("lud16"), + extensions = it.stringListOrNull("extensions"), ) }, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcMethod.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcMethod.kt index 6441c74927..740b8b91bc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcMethod.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcMethod.kt @@ -20,17 +20,31 @@ */ package com.vitorpamplona.quartz.nip47WalletConnect.rpc +/** + * NWC method names. NIP-47 core defines `pay_invoice`, `make_invoice`, `lookup_invoice`, + * `get_balance` and `get_info`; the rest come from optional NWC extension specs + * (github.com/nostr-wallet-connect/nwc) — see [com.vitorpamplona.quartz.nip47WalletConnect.tags.ExtensionsTag.forMethod]. + */ object NwcMethod { + // NIP-47 core const val PAY_INVOICE = "pay_invoice" - const val PAY_KEYSEND = "pay_keysend" const val MAKE_INVOICE = "make_invoice" const val LOOKUP_INVOICE = "lookup_invoice" - const val LIST_TRANSACTIONS = "list_transactions" const val GET_BALANCE = "get_balance" const val GET_INFO = "get_info" + + // NWC-04 keysend payments + const val PAY_KEYSEND = "pay_keysend" + + // NWC-05 transaction history + const val LIST_TRANSACTIONS = "list_transactions" + + // Not (yet) in a published NWC spec const val GET_BUDGET = "get_budget" const val SIGN_MESSAGE = "sign_message" const val CREATE_CONNECTION = "create_connection" + + // NWC-03 hold invoices const val MAKE_HOLD_INVOICE = "make_hold_invoice" const val CANCEL_HOLD_INVOICE = "cancel_hold_invoice" const val SETTLE_HOLD_INVOICE = "settle_hold_invoice" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Response.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Response.kt index 6144cfdd47..92addab6e7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Response.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Response.kt @@ -155,9 +155,12 @@ class GetInfoSuccessResponse( val block_height: Long? = null, val block_hash: String? = null, val methods: List? = null, + // NWC-02: notification types authorized for this connection. val notifications: List? = null, val metadata: Map? = null, val lud16: String? = null, + // NIP-47: optional NWC extension specs supported by this connection (eg. ["02", "05"]). + val extensions: List? = null, ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/EncryptionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/EncryptionTag.kt index 3d732528fe..f28dfa1823 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/EncryptionTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/EncryptionTag.kt @@ -36,6 +36,8 @@ class EncryptionTag { return tag.drop(1) } - fun assemble(schemes: List) = arrayOf(TAG_NAME, *schemes.toTypedArray()) + // NIP-47 carries the list as ONE space-separated value (eg. ["encryption", "a b c"]); + // [parse] still tolerates wallets that spread it across several elements. + fun assemble(schemes: List) = arrayOf(TAG_NAME, schemes.joinToString(" ")) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/ExtensionsTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/ExtensionsTag.kt index df3fdec6b0..d14c38c927 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/ExtensionsTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/ExtensionsTag.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip47WalletConnect.tags import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod import com.vitorpamplona.quartz.utils.ensure /** @@ -36,11 +37,69 @@ class ExtensionsTag { companion object { const val TAG_NAME = "extensions" - // The specs this client knows how to use, so a caller names a constant - // rather than a bare string at each gate. + // The NWC extension specs (github.com/nostr-wallet-connect/nwc) this library + // knows about, so a caller names a constant rather than a bare string at each gate. + + /** NWC-02: notifications (kind 23197/23196, `notifications` tag, `payment_received`, `payment_sent`). */ + const val NOTIFICATIONS = "02" + + /** NWC-03: `make_hold_invoice`, `cancel_hold_invoice`, `settle_hold_invoice`, `hold_invoice_accepted`. */ + const val HOLD_INVOICES = "03" + + /** NWC-04: `pay_keysend`. */ + const val KEYSEND = "04" + + /** NWC-05: `list_transactions`. */ const val TRANSACTION_HISTORY = "05" + + /** NWC-06: `metadata` conventions on invoices and payments. */ const val METADATA_CONVENTIONS = "06" + /** NWC-07: `nostrnwc://` deep links for pairing. */ + const val DEEP_LINKS = "07" + + /** NWC-08: client-initiated connection creation. */ + const val CLIENT_INITIATED_CONNECTIONS = "08" + + /** + * The NWC extension that defines [method], or null when the method is core + * NIP-47 (`pay_invoice`, `make_invoice`, `lookup_invoice`, `get_balance`, + * `get_info`) or not assigned to any published extension spec. + */ + fun forMethod(method: String): String? = + when (method) { + NwcMethod.MAKE_HOLD_INVOICE, + NwcMethod.CANCEL_HOLD_INVOICE, + NwcMethod.SETTLE_HOLD_INVOICE, + -> HOLD_INVOICES + + NwcMethod.PAY_KEYSEND -> KEYSEND + + NwcMethod.LIST_TRANSACTIONS -> TRANSACTION_HISTORY + + else -> null + } + + /** + * The extensions a wallet service implementing [methods] (and, when non-empty, + * sending [notificationTypes]) should advertise in its info event, in spec order. + */ + fun forCapabilities( + methods: List, + notificationTypes: List? = null, + ): List { + val result = mutableSetOf() + if (!notificationTypes.isNullOrEmpty() || methods.contains(LEGACY_NOTIFICATIONS_CAPABILITY)) result.add(NOTIFICATIONS) + methods.forEach { method -> forMethod(method)?.let { result.add(it) } } + return result.sorted() + } + + /** + * Pre-extensions wallets listed the bare word `notifications` among the + * methods in the info event content to say they send notifications. + */ + const val LEGACY_NOTIFICATIONS_CAPABILITY = "notifications" + fun parse(tag: Array): List? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } @@ -48,6 +107,8 @@ class ExtensionsTag { return tag.drop(1) } - fun assemble(extensions: List) = arrayOf(TAG_NAME, *extensions.toTypedArray()) + // NIP-47 carries the list as ONE space-separated value (eg. ["extensions", "a b c"]); + // [parse] still tolerates wallets that spread it across several elements. + fun assemble(extensions: List) = arrayOf(TAG_NAME, extensions.joinToString(" ")) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/NotificationsTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/NotificationsTag.kt index cb42cce542..575bdf48f7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/NotificationsTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/NotificationsTag.kt @@ -36,6 +36,8 @@ class NotificationsTag { return tag.drop(1) } - fun assemble(types: List) = arrayOf(TAG_NAME, *types.toTypedArray()) + // NIP-47 carries the list as ONE space-separated value (eg. ["notifications", "a b c"]); + // [parse] still tolerates wallets that spread it across several elements. + fun assemble(types: List) = arrayOf(TAG_NAME, types.joinToString(" ")) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEventTest.kt index bb87b31171..a6ec9c2716 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEventTest.kt @@ -20,7 +20,9 @@ */ package com.vitorpamplona.quartz.nip47WalletConnect +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod import com.vitorpamplona.quartz.utils.DeterministicSigner import com.vitorpamplona.quartz.utils.nsecToKeyPair import kotlin.test.Test @@ -153,4 +155,88 @@ class NwcInfoEventTest { assertTrue(event.encryptionSchemes().isEmpty()) assertTrue(event.notificationTypes().isEmpty()) } + + private fun info( + content: String, + vararg tags: Array, + ) = NwcInfoEvent("id", "pub", 0L, arrayOf(*tags), content, "sig") + + @Test + fun testSupportsNotificationsViaExtensionsTag() { + // Post-extensions NIP-47: notifications moved to NWC-02, advertised as `02`, + // and the content no longer carries the `notifications` token. + val event = info("pay_invoice get_balance get_info", arrayOf("encryption", "nip44_v2"), arrayOf("extensions", "02 03 04")) + assertTrue(event.supportsNotifications()) + } + + @Test + fun testSupportsNotificationsViaNotificationsTag() { + val event = info("pay_invoice get_info", arrayOf("notifications", "payment_received payment_sent")) + assertTrue(event.supportsNotifications()) + } + + @Test + fun testNoNotificationsWhenExtensionsLackIt() { + val event = info("pay_invoice get_info", arrayOf("extensions", "04 05")) + assertFalse(event.supportsNotifications()) + } + + @Test + fun testBuildEmitsSingleValueTags() { + val template = + NwcInfoEvent.build( + listOf("pay_invoice", "get_info"), + encryptionSchemes = listOf("nip44_v2", "nip04"), + notificationTypes = listOf("payment_received", "payment_sent"), + extensions = listOf("02", "05"), + ) + val event = signer.sign(template) + + assertTrue(event.tags.any { it.contentEquals(arrayOf("encryption", "nip44_v2 nip04")) }) + assertTrue(event.tags.any { it.contentEquals(arrayOf("notifications", "payment_received payment_sent")) }) + assertTrue(event.tags.any { it.contentEquals(arrayOf("extensions", "02 05")) }) + assertEquals(listOf("02", "05"), event.extensions()) + assertEquals(listOf("nip44_v2", "nip04"), event.encryptionSchemes()) + } + + @Test + fun testMayUseExtensionMethod() { + // Listed in content: allowed regardless of the extensions tag. + assertTrue(info("pay_invoice list_transactions", arrayOf("extensions", "02")).mayUseExtensionMethod(NwcMethod.LIST_TRANSACTIONS)) + // Advertised extension: allowed even if the content forgot the method. + assertTrue(info("pay_invoice", arrayOf("extensions", "05")).mayUseExtensionMethod(NwcMethod.LIST_TRANSACTIONS)) + // New-spec wallet that advertises extensions but neither 05 nor the method: skip. + assertFalse(info("pay_invoice get_info", arrayOf("extensions", "02 03")).mayUseExtensionMethod(NwcMethod.LIST_TRANSACTIONS)) + assertFalse(info("pay_invoice get_info", arrayOf("extensions", "02 03")).mayUseExtensionMethod(NwcMethod.PAY_KEYSEND)) + // Legacy wallet with no extensions tag: keep sending, it answers NOT_IMPLEMENTED if needed. + assertTrue(info("pay_invoice get_info").mayUseExtensionMethod(NwcMethod.LIST_TRANSACTIONS)) + assertTrue(info("pay_invoice get_info").mayUseExtensionMethod(NwcMethod.PAY_KEYSEND)) + // Core methods always pass. + assertTrue(info("get_info", arrayOf("extensions", "02")).mayUseExtensionMethod(NwcMethod.PAY_INVOICE)) + } + + @Test + fun testServerAdvertisesExtensions() { + val server = + Nip47Server( + signer = NostrSignerInternal(signer.key), + capabilities = listOf(NwcMethod.PAY_INVOICE, NwcMethod.GET_INFO, NwcMethod.PAY_KEYSEND, NwcMethod.LIST_TRANSACTIONS, NwcMethod.MAKE_HOLD_INVOICE), + notificationTypes = listOf("payment_received"), + ) + val event = signer.sign(server.buildInfoEvent()) + + assertEquals(listOf("02", "03", "04", "05"), event.extensions()) + assertTrue(event.tags.any { it.contentEquals(arrayOf("extensions", "02 03 04 05")) }) + // Extension methods SHOULD also be listed in the content. + assertTrue(event.supportsMethod(NwcMethod.PAY_KEYSEND)) + assertTrue(event.supportsMethod(NwcMethod.LIST_TRANSACTIONS)) + assertTrue(event.supportsNotifications()) + } + + @Test + fun testServerWithCoreMethodsOnlyHasNoExtensionsTag() { + val server = Nip47Server(signer = NostrSignerInternal(signer.key), capabilities = listOf(NwcMethod.PAY_INVOICE, NwcMethod.GET_INFO)) + val event = signer.sign(server.buildInfoEvent()) + assertFalse(event.advertisesExtensions()) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt index be2824b4c9..425ecbdfc2 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt @@ -395,6 +395,28 @@ class ResponseTest { assertEquals(listOf("pay_invoice", "get_balance"), response.result?.methods) } + @Test + fun testGetInfoExtensionsRoundTrip() { + val json = + """{"result_type":"get_info","result":{"methods":["pay_invoice","get_info","list_transactions"],"extensions":["02","05"],"notifications":["payment_received"]}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(listOf("02", "05"), response.result?.extensions) + assertEquals(listOf("payment_received"), response.result?.notifications) + + val reparsed = OptimizedJsonMapper.fromJsonTo(OptimizedJsonMapper.toJson(response)) + assertIs(reparsed) + assertEquals(listOf("02", "05"), reparsed.result?.extensions) + } + + @Test + fun testGetInfoWithoutExtensions() { + val json = """{"result_type":"get_info","result":{"methods":["pay_invoice"]}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNull(response.result?.extensions) + } + // --- ListTransactions with total_count --- @Test diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/TagsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/TagsTest.kt index 8010589356..b8fa4377fb 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/TagsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/TagsTest.kt @@ -20,7 +20,9 @@ */ package com.vitorpamplona.quartz.nip47WalletConnect +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod import com.vitorpamplona.quartz.nip47WalletConnect.tags.EncryptionTag +import com.vitorpamplona.quartz.nip47WalletConnect.tags.ExtensionsTag import com.vitorpamplona.quartz.nip47WalletConnect.tags.NotificationsTag import kotlin.test.Test import kotlin.test.assertEquals @@ -71,11 +73,11 @@ class TagsTest { @Test fun testEncryptionTagAssemble() { + // NIP-47: one space-separated value, eg. ["encryption", "nip44_v2 nip04"] val tag = EncryptionTag.assemble(listOf("nip44_v2", "nip04")) assertEquals("encryption", tag[0]) - assertEquals("nip44_v2", tag[1]) - assertEquals("nip04", tag[2]) - assertEquals(3, tag.size) + assertEquals("nip44_v2 nip04", tag[1]) + assertEquals(2, tag.size) } @Test @@ -120,12 +122,41 @@ class TagsTest { @Test fun testNotificationsTagAssemble() { + // NWC-02: one space-separated value, eg. ["notifications", "payment_received payment_sent"] val tag = NotificationsTag.assemble(listOf("payment_received", "payment_sent", "hold_invoice_accepted")) assertEquals("notifications", tag[0]) - assertEquals("payment_received", tag[1]) - assertEquals("payment_sent", tag[2]) - assertEquals("hold_invoice_accepted", tag[3]) - assertEquals(4, tag.size) + assertEquals("payment_received payment_sent hold_invoice_accepted", tag[1]) + assertEquals(2, tag.size) + } + + // --- ExtensionsTag --- + + @Test + fun testExtensionsTagAssemble() { + // NIP-47: ["extensions", "02 03 04"] + val tag = ExtensionsTag.assemble(listOf("02", "03", "04")) + assertEquals(2, tag.size) + assertEquals("extensions", tag[0]) + assertEquals("02 03 04", tag[1]) + } + + @Test + fun testExtensionsTagParseToleratesMultiElement() { + assertEquals(listOf("02", "05"), ExtensionsTag.parse(arrayOf("extensions", "02", "05"))) + assertEquals(listOf("02 05"), ExtensionsTag.parse(arrayOf("extensions", "02 05"))) + assertNull(ExtensionsTag.parse(arrayOf("extensions"))) + assertNull(ExtensionsTag.parse(arrayOf("other", "02"))) + } + + @Test + fun testExtensionForMethod() { + assertEquals(ExtensionsTag.KEYSEND, ExtensionsTag.forMethod(NwcMethod.PAY_KEYSEND)) + assertEquals(ExtensionsTag.TRANSACTION_HISTORY, ExtensionsTag.forMethod(NwcMethod.LIST_TRANSACTIONS)) + assertEquals(ExtensionsTag.HOLD_INVOICES, ExtensionsTag.forMethod(NwcMethod.MAKE_HOLD_INVOICE)) + assertEquals(ExtensionsTag.HOLD_INVOICES, ExtensionsTag.forMethod(NwcMethod.SETTLE_HOLD_INVOICE)) + assertEquals(ExtensionsTag.HOLD_INVOICES, ExtensionsTag.forMethod(NwcMethod.CANCEL_HOLD_INVOICE)) + assertNull(ExtensionsTag.forMethod(NwcMethod.PAY_INVOICE)) + assertNull(ExtensionsTag.forMethod(NwcMethod.GET_INFO)) } @Test From 84bc404dfe3b93a95ed71128e472007732ad377b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 19:28:26 +0000 Subject: [PATCH 12/24] fix(nip46): answer malformed and rate-limited requests, surface bunker errors NIP-46 now says requests with unknown or unsupported methods MUST be replied with an error. Bunker side: - A known method with bad params (e.g. sign_event with no params) used to throw inside JSON parsing, so NostrConnectSignerService logged "could not decrypt" and dropped it; the client waited for its timeout (#2375). BunkerRequestParser (shared by the kotlinx and Jackson decoders) now returns BunkerRequestInvalid, and BunkerRequestProcessor replies "invalid params for : " with the request id. Params are read leniently (missing/non-array/non-string) so the id survives. - Unknown methods keep getting "unsupported method: " (now tested). - switch_relays is answered with "null": this signer does not migrate. - Rate limiting still happens before decrypting, but the first over-limit request per author per window is decrypted and answered "rate limited"; the rest of the window is dropped. One decrypt + reply per window keeps the flood bound the limiter exists for. Client side: - SignerResult.Rejected carries the bunker's error text; every response parser passes it and convertExceptions puts it in the exception message. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc --- .../nip46RemoteSigner/BunkerRequestInvalid.kt | 38 +++++++++ .../nip46RemoteSigner/BunkerRequestParser.kt | 59 +++++++++++++ .../BunkerMessageKSerializer.kt | 64 +------------- .../BunkerRequestKSerializer.kt | 41 ++++----- .../server/BunkerRequestProcessor.kt | 34 +++++++- .../server/NostrConnectSignerService.kt | 74 +++++++++++++--- .../signer/ConnectResponse.kt | 2 +- .../signer/Nip04DecryptResponse.kt | 2 +- .../signer/Nip04EncryptResponse.kt | 2 +- .../signer/Nip44DecryptResponse.kt | 2 +- .../signer/Nip44EncryptResponse.kt | 2 +- .../signer/NostrSignerRemote.kt | 6 +- .../nip46RemoteSigner/signer/PingResponse.kt | 2 +- .../signer/PubKeyResponse.kt | 2 +- .../nip46RemoteSigner/signer/SignResponse.kt | 2 +- .../nip46RemoteSigner/signer/SignerResult.kt | 9 +- .../nip46RemoteSigner/BunkerRequestTest.kt | 32 +++++++ .../server/NostrConnectSignerServiceTest.kt | 84 ++++++++++++++++++- .../signer/ConvertExceptionsTest.kt | 8 ++ .../signer/ResponseParserTest.kt | 1 + .../jackson/BunkerRequestDeserializer.kt | 33 +++----- 21 files changed, 365 insertions(+), 134 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestInvalid.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestParser.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestInvalid.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestInvalid.kt new file mode 100644 index 0000000000..d4c19c6cea --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestInvalid.kt @@ -0,0 +1,38 @@ +/* + * 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.nip46RemoteSigner + +/** + * A request whose `id` and `method` could be read but whose `params` could not be + * turned into the typed request for a method this library knows (e.g. `sign_event` + * with no params, or a param that is not an event template). + * + * [BunkerRequestParser] returns this instead of throwing so the remote signer can + * still answer with an error carrying the request id — NIP-46: "Requests made with + * unknown or unsupported methods MUST be replied with an error" — rather than + * dropping the request and leaving the client to time out. + */ +class BunkerRequestInvalid( + id: String, + method: String, + params: Array, + val reason: String, +) : BunkerRequest(id, method, params) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestParser.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestParser.kt new file mode 100644 index 0000000000..4957c01560 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestParser.kt @@ -0,0 +1,59 @@ +/* + * 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.nip46RemoteSigner + +import kotlinx.coroutines.CancellationException + +/** + * Turns a decoded NIP-46 request envelope into its typed [BunkerRequest]. Shared by + * every JSON backend (kotlinx and Jackson) so they agree on the method table and on + * how bad params are handled. + * + * A known method with bad params never throws: it yields a [BunkerRequestInvalid] + * so the remote signer can reply with an error the client can correlate. Unknown + * methods come back as a plain [BunkerRequest], which the signer also answers with + * an error. + */ +object BunkerRequestParser { + fun parse( + id: String, + method: String, + params: Array, + ): BunkerRequest = + try { + when (method) { + BunkerRequestConnect.METHOD_NAME -> BunkerRequestConnect.parse(id, params) + BunkerRequestGetPublicKey.METHOD_NAME -> BunkerRequestGetPublicKey.parse(id, params) + BunkerRequestGetRelays.METHOD_NAME -> BunkerRequestGetRelays.parse(id, params) + BunkerRequestNip04Decrypt.METHOD_NAME -> BunkerRequestNip04Decrypt.parse(id, params) + BunkerRequestNip04Encrypt.METHOD_NAME -> BunkerRequestNip04Encrypt.parse(id, params) + BunkerRequestNip44Decrypt.METHOD_NAME -> BunkerRequestNip44Decrypt.parse(id, params) + BunkerRequestNip44Encrypt.METHOD_NAME -> BunkerRequestNip44Encrypt.parse(id, params) + BunkerRequestPing.METHOD_NAME -> BunkerRequestPing.parse(id, params) + BunkerRequestSign.METHOD_NAME -> BunkerRequestSign.parse(id, params) + else -> BunkerRequest(id, method, params) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + BunkerRequestInvalid(id, method, params, e.message ?: e::class.simpleName ?: "malformed params") + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerMessageKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerMessageKSerializer.kt index 025e586cc4..23d46b8fb2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerMessageKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerMessageKSerializer.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip46RemoteSigner.kotlinSerialization import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerMessage import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestParser import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.SerialDescriptor @@ -30,7 +31,6 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.JsonDecoder import kotlinx.serialization.json.JsonEncoder -import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive @@ -58,68 +58,10 @@ object BunkerMessageKSerializer : KSerializer { return if (isRequest) { val id = jsonObject["id"]!!.jsonPrimitive.content val method = jsonObject["method"]!!.jsonPrimitive.content - val params = - jsonObject["params"]?.jsonArray?.map { it.jsonPrimitive.content }?.toTypedArray() - ?: emptyArray() - dispatchBunkerRequest(id, method, params) + val params = BunkerRequestKSerializer.lenientParams(jsonObject["params"]) + BunkerRequestParser.parse(id, method, params) } else { BunkerResponseKSerializer.deserializeFromElement(jsonObject) } } - - private fun dispatchBunkerRequest( - id: String, - method: String, - params: Array, - ): BunkerRequest = - when (method) { - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect.METHOD_NAME -> { - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect - .parse(id, params) - } - - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey.METHOD_NAME -> { - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey - .parse(id, params) - } - - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays.METHOD_NAME -> { - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays - .parse(id, params) - } - - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt.METHOD_NAME -> { - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt - .parse(id, params) - } - - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt.METHOD_NAME -> { - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt - .parse(id, params) - } - - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt.METHOD_NAME -> { - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt - .parse(id, params) - } - - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt.METHOD_NAME -> { - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt - .parse(id, params) - } - - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing.METHOD_NAME -> { - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing - .parse(id, params) - } - - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign.METHOD_NAME -> { - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign - .parse(id, params) - } - - else -> { - BunkerRequest(id, method, params) - } - } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerRequestKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerRequestKSerializer.kt index 7797cf3371..6c2e8a5ea8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerRequestKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerRequestKSerializer.kt @@ -21,27 +21,20 @@ package com.vitorpamplona.quartz.nip46RemoteSigner.kotlinSerialization import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestParser import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.buildClassSerialDescriptor import kotlinx.serialization.descriptors.element import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonEncoder import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put @@ -80,21 +73,19 @@ object BunkerRequestKSerializer : KSerializer { val jsonObject = jsonDecoder.decodeJsonElement().jsonObject val id = jsonObject["id"]!!.jsonPrimitive.content val method = jsonObject["method"]!!.jsonPrimitive.content - val params = - jsonObject["params"]?.jsonArray?.map { it.jsonPrimitive.content }?.toTypedArray() - ?: emptyArray() + val params = lenientParams(jsonObject["params"]) - return when (method) { - BunkerRequestConnect.METHOD_NAME -> BunkerRequestConnect.parse(id, params) - BunkerRequestGetPublicKey.METHOD_NAME -> BunkerRequestGetPublicKey.parse(id, params) - BunkerRequestGetRelays.METHOD_NAME -> BunkerRequestGetRelays.parse(id, params) - BunkerRequestNip04Decrypt.METHOD_NAME -> BunkerRequestNip04Decrypt.parse(id, params) - BunkerRequestNip04Encrypt.METHOD_NAME -> BunkerRequestNip04Encrypt.parse(id, params) - BunkerRequestNip44Decrypt.METHOD_NAME -> BunkerRequestNip44Decrypt.parse(id, params) - BunkerRequestNip44Encrypt.METHOD_NAME -> BunkerRequestNip44Encrypt.parse(id, params) - BunkerRequestPing.METHOD_NAME -> BunkerRequestPing.parse(id, params) - BunkerRequestSign.METHOD_NAME -> BunkerRequestSign.parse(id, params) - else -> BunkerRequest(id, method, params) - } + return BunkerRequestParser.parse(id, method, params) } + + /** + * `params` as strings, tolerating a missing or non-array value and non-string + * elements (kept as their JSON text), so a malformed request still yields its id + * and method and can be answered with an error. + */ + fun lenientParams(element: JsonElement?): Array = + (element as? JsonArray) + ?.map { (it as? JsonPrimitive)?.content ?: it.toString() } + ?.toTypedArray() + ?: emptyArray() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt index 2328aa3d5c..1335a4ca25 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestInvalid import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt @@ -73,9 +74,11 @@ import kotlinx.coroutines.sync.withLock * - **per-operation consent** ([Nip46RequestAuthorizer.authorize]) gates * signing/encryption/decryption. * - * All failures — decryption, authorization, an unsupported method, or an - * exception from the signer — are turned into a [BunkerResponseError] carrying - * the request id, so the client always gets a reply it can correlate. + * All failures — authorization, an unsupported method, a known method with + * unparseable params ([BunkerRequestInvalid]), or an exception from the signer — + * are turned into a [BunkerResponseError] carrying the request id, so the client + * always gets a reply it can correlate (NIP-46: unknown or unsupported methods MUST + * be replied with an error). `switch_relays` is answered with `null`. * * Pairs with [NostrConnectSignerService], which subscribes to the relays, * decrypts each kind-24133 request, calls [process], and publishes the reply. @@ -105,6 +108,10 @@ class BunkerRequestProcessor( ): BunkerResponse = try { when (request) { + // A known method whose params could not be parsed: still answer, so the client + // gets an error it can correlate instead of timing out. + is BunkerRequestInvalid -> BunkerResponseError(request.id, "$ERROR_INVALID_PARAMS for ${request.method}: ${request.reason}") + is BunkerRequestConnect -> when (val decision = authorizer.onConnect(clientPubKey, request)) { is Nip46ConnectDecision.Accept -> BunkerResponse(request.id, decision.ackSecret, null) @@ -158,7 +165,11 @@ class BunkerRequestProcessor( authorizer.onLogout(clientPubKey) BunkerResponseAck(request.id) } - else -> BunkerResponseError(request.id, "unsupported method: ${request.method}") + // This signer listens on a fixed relay set it does not migrate, so there is + // never an update to hand out: NIP-46 says reply `null` ("nothing to change"). + // `result` is a string on the wire, so this is the JSON-stringified null. + METHOD_SWITCH_RELAYS -> BunkerResponse(request.id, RESULT_NULL, null) + else -> BunkerResponseError(request.id, "$ERROR_UNSUPPORTED_METHOD: ${request.method}") } } } catch (e: CancellationException) { @@ -219,5 +230,20 @@ class BunkerRequestProcessor( /** NIP-46 `logout` method name — the client asks to be disconnected. */ const val METHOD_LOGOUT: String = "logout" + + /** NIP-46 `switch_relays` method name — the client asks whether the signer moved relays. */ + const val METHOD_SWITCH_RELAYS: String = "switch_relays" + + /** JSON-stringified `null`, the `switch_relays` answer for "no relay change". */ + const val RESULT_NULL: String = "null" + + /** Error prefix for a known method whose params could not be parsed ([BunkerRequestInvalid]). */ + const val ERROR_INVALID_PARAMS: String = "invalid params" + + /** Error prefix for a method this signer does not implement. */ + const val ERROR_UNSUPPORTED_METHOD: String = "unsupported method" + + /** Error returned to a client whose requests exceed the service's rate limit. */ + const val ERROR_RATE_LIMITED: String = "rate limited" } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt index bc473bcaf1..eaf76ae30c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt @@ -86,7 +86,10 @@ class NostrConnectSignerService( * NIP-55 op on the identity signer, so the queue must not run away. */ val maxQueue: Int = 256, - /** Max requests decrypted per author within [rateWindowSeconds] before further ones are dropped. */ + /** + * Max requests decrypted per author within [rateWindowSeconds]. Past it, the first extra request + * in the window is answered with a `rate limited` error and the rest are dropped unanswered. + */ val maxRequestsPerWindow: Int = 40, val rateWindowSeconds: Long = 10, /** Cap on distinct authors tracked for rate-limiting (evicts oldest) so key-rotation can't grow it. */ @@ -132,18 +135,30 @@ class NostrConnectSignerService( private class Window( var start: Long, var count: Int, + var notified: Boolean = false, ) + enum class Decision { + ALLOW, + + /** Over the limit, and the first such request this window: answer it with an error. */ + DENY_AND_NOTIFY, + + /** Over the limit and the author was already told this window: drop silently. */ + DENY, + } + private val windows = LinkedHashMap() - fun allow( + fun check( author: String, now: Long, - ): Boolean { + ): Decision { val window = windows.getOrPut(author) { Window(now, 0) } if (now - window.start >= windowSeconds) { window.start = now window.count = 0 + window.notified = false } if (windows.size > maxAuthors) { windows.iterator().let { @@ -151,9 +166,13 @@ class NostrConnectSignerService( it.remove() } } - if (window.count >= maxPerWindow) return false + if (window.count >= maxPerWindow) { + if (window.notified) return Decision.DENY + window.notified = true + return Decision.DENY_AND_NOTIFY + } window.count++ - return true + return Decision.ALLOW } } @@ -226,11 +245,32 @@ class NostrConnectSignerService( Log.w("NIP46Signer") { "ignoring stale request ${event.id.take(8)}… (created ${event.createdAt})" } continue } - // Rate-limit per author BEFORE decrypting — decryption can be an external-signer - // round-trip, so a flooding client must not force one per event. - if (!rateLimiter.allow(event.pubKey, TimeUtils.now())) { - Log.w("NIP46Signer") { "rate-limited request from ${event.pubKey.take(8)}…" } - continue + // Rate-limit per author BEFORE decrypting: the limit exists to bound the work (envelope + // decrypt, reply encrypt/sign/publish, identity-signer ops) a flooding client can force. + // The request id is inside the encrypted content, so answering costs a decrypt plus a + // reply. That is paid ONCE per author per window: the first over-limit request gets a + // `rate limited` error (so a legitimate bursty client learns why instead of timing out), + // the rest of the window is dropped silently. + when (rateLimiter.check(event.pubKey, TimeUtils.now())) { + RateLimiter.Decision.ALLOW -> {} + + RateLimiter.Decision.DENY_AND_NOTIFY -> { + Log.w("NIP46Signer") { "rate-limited request from ${event.pubKey.take(8)}…; replying with an error" } + handleGate.acquire() + launch { + try { + replyRateLimited(event) + } finally { + handleGate.release() + } + } + continue + } + + RateLimiter.Decision.DENY -> { + Log.w("NIP46Signer") { "rate-limited request from ${event.pubKey.take(8)}…" } + continue + } } // Remember this id (persisted by the host) so a later restart won't re-service the replay. // Done on the single consumer — BEFORE fanning out — because the host's seen-id store is @@ -254,6 +294,20 @@ class NostrConnectSignerService( } } + /** Answers an over-limit request with [BunkerRequestProcessor.ERROR_RATE_LIMITED], without processing it. */ + private suspend fun replyRateLimited(event: NostrConnectEvent) { + val client = event.talkingWith(transportSigner.pubKey) + try { + val request = event.decryptMessage(transportSigner) as? BunkerRequest ?: return + val reply = NostrConnectEvent.create(BunkerResponseError(request.id, BunkerRequestProcessor.ERROR_RATE_LIMITED), client, transportSigner) + this.client.publish(reply, relays) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w("NIP46Signer") { "could not answer rate-limited request ${event.id.take(8)}: ${e.message}" } + } + } + private suspend fun handle(event: NostrConnectEvent) { val client = event.talkingWith(transportSigner.pubKey) val request = diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConnectResponse.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConnectResponse.kt index a6583a80b0..acfcb8047c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConnectResponse.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConnectResponse.kt @@ -29,7 +29,7 @@ class ConnectResponse { return if (response.error.contains("already connected", ignoreCase = true)) { SignerResult.RequestAddressed.Successful(ConnectResult.AlreadyConnected) } else { - SignerResult.RequestAddressed.Rejected() + SignerResult.RequestAddressed.Rejected(response.error) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip04DecryptResponse.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip04DecryptResponse.kt index 855ad31335..f565cd9b8f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip04DecryptResponse.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip04DecryptResponse.kt @@ -33,7 +33,7 @@ class Nip04DecryptResponse { } is BunkerResponseError -> { - SignerResult.RequestAddressed.Rejected() + SignerResult.RequestAddressed.Rejected(response.error) } else -> { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip04EncryptResponse.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip04EncryptResponse.kt index 185a7fa859..9f9eaef894 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip04EncryptResponse.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip04EncryptResponse.kt @@ -33,7 +33,7 @@ class Nip04EncryptResponse { } is BunkerResponseError -> { - SignerResult.RequestAddressed.Rejected() + SignerResult.RequestAddressed.Rejected(response.error) } else -> { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip44DecryptResponse.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip44DecryptResponse.kt index 1baeefbd35..dab618ec42 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip44DecryptResponse.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip44DecryptResponse.kt @@ -33,7 +33,7 @@ class Nip44DecryptResponse { } is BunkerResponseError -> { - SignerResult.RequestAddressed.Rejected() + SignerResult.RequestAddressed.Rejected(response.error) } else -> { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip44EncryptResponse.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip44EncryptResponse.kt index 2819cc82a5..b4049c6ed6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip44EncryptResponse.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip44EncryptResponse.kt @@ -33,7 +33,7 @@ class Nip44EncryptResponse { } is BunkerResponseError -> { - SignerResult.RequestAddressed.Rejected() + SignerResult.RequestAddressed.Rejected(response.error) } else -> { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt index 5a7820cc32..9dc84100a1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt @@ -354,7 +354,11 @@ class NostrSignerRemote( is SignerResult.RequestAddressed.ReceivedButCouldNotParseEventFromResult<*> -> IllegalStateException("$title: Failed to parse event: ${result.eventJson}.") is SignerResult.RequestAddressed.ReceivedButCouldNotVerifyResultingEvent<*> -> IllegalStateException("$title: Failed to verify event: ${result.invalidEvent.toJson()}.") is SignerResult.RequestAddressed.ReceivedButCouldNotPerform<*> -> SignerExceptions.CouldNotPerformException("$title: ${result.message}") - is SignerResult.RequestAddressed.Rejected<*> -> SignerExceptions.ManuallyUnauthorizedException("$title: User has rejected the request.") + is SignerResult.RequestAddressed.Rejected<*> -> + SignerExceptions.ManuallyUnauthorizedException( + result.message?.takeIf { it.isNotBlank() }?.let { "$title: Remote signer returned an error: $it" } + ?: "$title: User has rejected the request.", + ) is SignerResult.RequestAddressed.TimedOut<*> -> SignerExceptions.TimedOutException("$title: User didn't accept or reject in time.") } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/PingResponse.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/PingResponse.kt index 644dd89ba4..987c47bc90 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/PingResponse.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/PingResponse.kt @@ -33,7 +33,7 @@ class PingResponse { } is BunkerResponseError -> { - SignerResult.RequestAddressed.Rejected() + SignerResult.RequestAddressed.Rejected(response.error) } else -> { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/PubKeyResponse.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/PubKeyResponse.kt index ed639a9067..6d09d561b9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/PubKeyResponse.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/PubKeyResponse.kt @@ -33,7 +33,7 @@ class PubKeyResponse { } is BunkerResponseError -> { - SignerResult.RequestAddressed.Rejected() + SignerResult.RequestAddressed.Rejected(response.error) } else -> { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignResponse.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignResponse.kt index b7343cc67d..dc8ba68b83 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignResponse.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignResponse.kt @@ -35,7 +35,7 @@ class SignResponse { SignerResult.RequestAddressed.Successful(SignResult(response.event)) } } else if (response is BunkerResponseError) { - SignerResult.RequestAddressed.Rejected() + SignerResult.RequestAddressed.Rejected(response.error) } else { SignerResult.RequestAddressed.ReceivedButCouldNotPerform() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignerResult.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignerResult.kt index a0cba9eb9a..d34214b653 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignerResult.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignerResult.kt @@ -28,7 +28,14 @@ sealed interface SignerResult { val result: T, ) : RequestAddressed - class Rejected : RequestAddressed + /** + * The remote signer answered with an error. [message] is the bunker's `error` + * text (e.g. "unauthorized", "invalid params for sign_event: ..."), kept so the + * UI/logs can show why instead of a generic rejection. + */ + class Rejected( + val message: String? = null, + ) : RequestAddressed class TimedOut : RequestAddressed diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestTest.kt index bcc724acd7..989144ae8d 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestTest.kt @@ -36,6 +36,38 @@ class BunkerRequestTest { assertEquals(1, bunkerRequest.event.kind) } + @Test + fun testSignEventWithoutParamsIsInvalidNotThrown() { + val bunkerRequest = OptimizedJsonMapper.fromJsonTo("""{"id":"x1","method":"sign_event","params":[]}""") + assertTrue(bunkerRequest is BunkerRequestInvalid) + assertEquals("x1", bunkerRequest.id) + assertEquals("sign_event", bunkerRequest.method) + } + + @Test + fun testSignEventWithMissingParamsFieldIsInvalid() { + val bunkerRequest = OptimizedJsonMapper.fromJsonTo("""{"id":"x2","method":"sign_event"}""") + assertTrue(bunkerRequest is BunkerRequestInvalid) + assertEquals("x2", bunkerRequest.id) + } + + @Test + fun testSignEventWithObjectParamKeepsTheId() { + // Some clients send the template as an object instead of a JSON string. + val bunkerRequest = + OptimizedJsonMapper.fromJsonTo("""{"id":"x3","method":"sign_event","params":[{"kind":1,"created_at":1,"tags":[],"content":"hi"}]}""") + assertTrue(bunkerRequest is BunkerRequest) + assertEquals("x3", bunkerRequest.id) + } + + @Test + fun testUnknownMethodStaysGeneric() { + val bunkerRequest = OptimizedJsonMapper.fromJsonTo("""{"id":"x4","method":"frobnicate","params":["a"]}""") + assertTrue(bunkerRequest is BunkerRequest) + assertEquals("frobnicate", bunkerRequest.method) + assertTrue(bunkerRequest !is BunkerRequestInvalid) + } + @Test fun testConnectWithoutMetadata() { val requestJson = """{"id":"1","method":"connect","params":["abc","mysecret","sign_event"]}""" diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt index db6c888430..b653dbaf39 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEvent import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePublicKey import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent @@ -48,6 +49,8 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull import kotlin.test.assertTrue /** @@ -328,12 +331,89 @@ class NostrConnectSignerServiceTest { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { service.run() } - // Five distinct requests from the same author within one window → only 2 are serviced. + // Five distinct requests from the same author within one window → only 2 are serviced, + // the first over-limit one is answered with a `rate limited` error, the rest are dropped. repeat(5) { i -> client.deliver(request(BunkerRequestConnect(id = "req$i", remoteKey = serverKey, secret = "s"))) } - assertEquals(2, client.published.size) + assertEquals(3, client.published.size) + val replies = client.published.map { (it as NostrConnectEvent).decryptMessage(clientSigner()) as BunkerResponse } + assertEquals(listOf("req0", "req1", "req2"), replies.map { it.id }) + assertEquals(BunkerRequestProcessor.ERROR_RATE_LIMITED, replies[2].error) + } + + @Test + fun signEventWithoutParamsGetsAnErrorReply() = + runTest { + val client = LoopbackClient() + val signer = serverSigner() + val processor = BunkerRequestProcessor(signer, { setOf(relay) }, AllowAuthorizer()) + val service = NostrConnectSignerService(client, signer, processor, setOf(relay)) + + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { service.run() } + + // A known method with missing params used to throw while parsing and be dropped, leaving + // the client to time out. It must now be answered with an error carrying the request id. + client.deliver(request(BunkerRequest(id = "noparams", method = BunkerRequestSign.METHOD_NAME))) + + val reply = (client.published.single() as NostrConnectEvent).decryptMessage(clientSigner()) + assertIs(reply) + assertEquals("noparams", reply.id) + assertTrue(reply.error!!.startsWith(BunkerRequestProcessor.ERROR_INVALID_PARAMS), reply.error) + } + + @Test + fun signEventWithGarbageParamsGetsAnErrorReply() = + runTest { + val client = LoopbackClient() + val signer = serverSigner() + val processor = BunkerRequestProcessor(signer, { setOf(relay) }, AllowAuthorizer()) + val service = NostrConnectSignerService(client, signer, processor, setOf(relay)) + + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { service.run() } + + client.deliver(request(BunkerRequest(id = "garbage", method = BunkerRequestSign.METHOD_NAME, params = arrayOf("not an event")))) + + val reply = (client.published.single() as NostrConnectEvent).decryptMessage(clientSigner()) + assertIs(reply) + assertEquals("garbage", reply.id) + } + + @Test + fun unknownMethodGetsAnErrorReply() = + runTest { + val client = LoopbackClient() + val signer = serverSigner() + val processor = BunkerRequestProcessor(signer, { setOf(relay) }, AllowAuthorizer()) + val service = NostrConnectSignerService(client, signer, processor, setOf(relay)) + + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { service.run() } + + client.deliver(request(BunkerRequest(id = "unknown", method = "frobnicate"))) + + val reply = (client.published.single() as NostrConnectEvent).decryptMessage(clientSigner()) + assertIs(reply) + assertEquals("unknown", reply.id) + assertEquals("${BunkerRequestProcessor.ERROR_UNSUPPORTED_METHOD}: frobnicate", reply.error) + } + + @Test + fun switchRelaysIsAnsweredWithNull() = + runTest { + val client = LoopbackClient() + val signer = serverSigner() + val processor = BunkerRequestProcessor(signer, { setOf(relay) }, AllowAuthorizer()) + val service = NostrConnectSignerService(client, signer, processor, setOf(relay)) + + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { service.run() } + + client.deliver(request(BunkerRequest(id = "sw", method = BunkerRequestProcessor.METHOD_SWITCH_RELAYS))) + + val reply = (client.published.single() as NostrConnectEvent).decryptMessage(clientSigner()) as BunkerResponse + assertEquals("sw", reply.id) + assertEquals("null", reply.result) + assertNull(reply.error) } @Test diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConvertExceptionsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConvertExceptionsTest.kt index 73b6b12012..14e5939def 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConvertExceptionsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConvertExceptionsTest.kt @@ -52,6 +52,14 @@ class ConvertExceptionsTest { assertIs(ex) } + @Test + fun rejectedCarriesTheBunkerErrorText() { + val result = SignerResult.RequestAddressed.Rejected("invalid params for sign_event: bad") + val ex = remote.convertExceptions("Test", result) + assertIs(ex) + assertTrue(ex.message!!.contains("invalid params for sign_event: bad"), ex.message) + } + @Test fun timedOutReturnsTimedOutException() { val result = SignerResult.RequestAddressed.TimedOut() diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ResponseParserTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ResponseParserTest.kt index e925b1e373..dbe80bc1a3 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ResponseParserTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ResponseParserTest.kt @@ -177,6 +177,7 @@ class ResponseParserTest { val response = BunkerResponseError("req-3", "denied") val result = SignResponse.parse(response) assertIs>(result) + assertEquals("denied", result.message) } @Test diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/jackson/BunkerRequestDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/jackson/BunkerRequestDeserializer.kt index 01f03ce8c0..9da29cae41 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/jackson/BunkerRequestDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/jackson/BunkerRequestDeserializer.kt @@ -26,15 +26,7 @@ import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.deser.std.StdDeserializer import com.vitorpamplona.quartz.nip01Core.jackson.toTypedArray import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestParser class BunkerRequestDeserializer : StdDeserializer(BunkerRequest::class.java) { override fun deserialize( @@ -44,19 +36,16 @@ class BunkerRequestDeserializer : StdDeserializer(BunkerRequest:: val jsonObject: JsonNode = jp.codec.readTree(jp) val id = jsonObject.get("id").asText() val method = jsonObject.get("method").asText() - val params = jsonObject.get("params")?.toTypedArray { it.asText() } ?: emptyArray() + // Lenient: a missing/non-array `params` or non-string element must not lose the id, + // so the signer can still answer with an error (see BunkerRequestParser). + val paramsNode = jsonObject.get("params") + val params = + if (paramsNode != null && paramsNode.isArray) { + paramsNode.toTypedArray { if (it.isValueNode) it.asText() else it.toString() } + } else { + emptyArray() + } - return when (method) { - BunkerRequestConnect.METHOD_NAME -> BunkerRequestConnect.parse(id, params) - BunkerRequestGetPublicKey.METHOD_NAME -> BunkerRequestGetPublicKey.parse(id, params) - BunkerRequestGetRelays.METHOD_NAME -> BunkerRequestGetRelays.parse(id, params) - BunkerRequestNip04Decrypt.METHOD_NAME -> BunkerRequestNip04Decrypt.parse(id, params) - BunkerRequestNip04Encrypt.METHOD_NAME -> BunkerRequestNip04Encrypt.parse(id, params) - BunkerRequestNip44Decrypt.METHOD_NAME -> BunkerRequestNip44Decrypt.parse(id, params) - BunkerRequestNip44Encrypt.METHOD_NAME -> BunkerRequestNip44Encrypt.parse(id, params) - BunkerRequestPing.METHOD_NAME -> BunkerRequestPing.parse(id, params) - BunkerRequestSign.METHOD_NAME -> BunkerRequestSign.parse(id, params) - else -> BunkerRequest(id, method, params) - } + return BunkerRequestParser.parse(id, method, params) } } From 00eb7cf447f179bc38faecca66c2c194b3b2d402 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 19:28:38 +0000 Subject: [PATCH 13/24] feat(nip67): EOSE completeness hints (finish / more / auth) Wire model: - EoseMessage gains optional `hints` (the third EOSE element) with isFinished()/hasMore()/needsAuth(); both the kotlinx and Jackson parsers read it (non-string/unknown entries ignored, non-array third element ignored) and both serializers write it only when present. The two-element fast path of toJson() is unchanged. Client: - SubscriptionListener gets onEose(relay, forFilters, hints); the pool calls it and the default forwards to the old two-argument onEose. - fetchAllPages: "finish" ends the walk without the extra empty-page REQ (DRAINED, or LIMIT_REACHED/UNPAGEABLE when a filter already dropped out); "more" keeps paging; "auth" waits once for the NIP-42 verdict like an auth-required CLOSED and reads the post-AUTH re-served page, dropping ids it already delivered. An unanswered "auth" can never yield DRAINED. - RelayAuthenticator treats an EOSE "auth" hint like an auth-required CLOSED (non-interactive re-auth on the stored challenge, whose OK re-REQs via syncFilters), at most once per challenge. Relay engine (opt-in, RelayServerBase.completenessHints, default off): - One filter with limit L: query L+1, forward L, send "more" if the extra row exists else "finish". All filters unbounded: "finish". Several filters: "finish" only when fewer rows than the smallest limit; else no hint. limit 0 and the policy-screened path never get a hint. Opt-in because it relies on the backend honouring limit exactly. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc --- .../kotlinSerialization/MessageKSerializer.kt | 12 +- .../NostrClientFetchAllPagesExt.kt | 76 +++++++ .../relay/client/auth/RelayAuthenticator.kt | 34 +++ .../relay/client/pool/PoolRequests.kt | 1 + .../relay/client/reqs/SubscriptionListener.kt | 12 ++ .../relay/commands/toClient/EoseMessage.kt | 25 ++- .../relay/server/EoseCompletenessProbe.kt | 99 +++++++++ .../nip01Core/relay/server/RelayServerBase.kt | 10 + .../nip01Core/relay/server/RelaySession.kt | 38 +++- .../relay/server/NostrServerEoseTest.kt | 198 +++++++++++++++++ .../commands/toClient/MessageDeserializer.kt | 23 +- .../commands/toClient/MessageSerializer.kt | 6 + .../NostrClientFetchAllPagesEoseHintsTest.kt | 201 ++++++++++++++++++ .../commands/toClient/EoseHintsParsingTest.kt | 135 ++++++++++++ 14 files changed, 855 insertions(+), 15 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/EoseCompletenessProbe.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerEoseTest.kt create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesEoseHintsTest.kt create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EoseHintsParsingTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt index c0a7972e10..394d41831c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt @@ -37,6 +37,7 @@ import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.buildClassSerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonDecoder import kotlinx.serialization.json.JsonEncoder import kotlinx.serialization.json.JsonObject @@ -98,6 +99,10 @@ object MessageKSerializer : KSerializer { is EoseMessage -> { add(JsonPrimitive(value.subId)) + // NIP-67: optional third element, the completeness hints. + value.hints?.let { hints -> + add(buildJsonArray { hints.forEach { add(JsonPrimitive(it)) } }) + } } is LimitsMessage -> { @@ -136,7 +141,12 @@ object MessageKSerializer : KSerializer { } EoseMessage.LABEL -> { - EoseMessage(array[1].jsonPrimitive.content) + // NIP-67: an optional array of hint strings; anything else there is ignored. + val hints = + (array.getOrNull(2) as? JsonArray)?.mapNotNull { hint -> + (hint as? JsonPrimitive)?.takeIf { it.isString }?.content + } + EoseMessage(array[1].jsonPrimitive.content, hints) } NoticeMessage.LABEL -> { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt index adacdbaba0..b5faa9ba59 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.auth.awaitAuthOutcome import com.vitorpamplona.quartz.nip01Core.relay.client.auth.hasAuthResponder import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -190,6 +191,23 @@ data class PagedFetchResult( * it a `limit` to bound that single page; without one you get the relay's default page * of top hits. * + * **NIP-67 completeness hints.** A relay may append hints to a page's `EOSE`: + * + * - `"finish"` — every stored match was sent, so the walk stops right there instead of + * spending one more REQ just to observe an empty page. It ends + * [PagedFetchResult.End.DRAINED] (or LIMIT_REACHED / UNPAGEABLE when a filter had + * already dropped out of the page, since `finish` can only speak for what was asked). + * - `"more"` — the relay holds more; paging continues, which is what the walk does + * anyway until it sees an empty page, so this needs no special handling. + * - `"auth"` — more may be available after NIP-42. Handled like an `auth-required:` + * CLOSED: when a responder is attached the walk waits (once) for the AUTH verdict and, + * on success, reads the page the relay re-serves after the AUTH's re-REQ, dropping the + * events it already delivered. If the AUTH does not happen, the page's events still + * count but the walk can no longer claim DRAINED: it ends + * [PagedFetchResult.End.AUTH_REQUIRED] wherever it would have ended DRAINED. + * + * Hints are only ever a shortcut; their absence changes nothing (the heuristic above). + * * @param relay The relay to query. * @param filters Filters to apply on every page (the `until` field is overwritten per page). * @param idleTimeoutMs Idle window per page — like every accessory timeout, it is measured @@ -336,6 +354,16 @@ suspend fun INostrClient.fetchAllPages( // declining to give one. var pageEnd: PageSignal? = null + // NIP-67 hints of the EOSE that ended this page (null: none sent). Written on the + // relay's reader thread before the EOSE signal is sent; the channel orders it. + var eoseHints: List? = null + + // Ids delivered on this page, kept only while an EOSE `"auth"` hint could still make + // the relay re-serve the page after AUTH (at most once per walk), so the re-served + // copies of events already handed to [onEvent] are dropped. Reader-thread only. + val pageIds: HashSet? = if (pendingOnAuthRequired && !authRetried) HashSet() else null + var reServing = false + try { val listener = object : SubscriptionListener { @@ -363,6 +391,9 @@ suspend fun INostrClient.fetchAllPages( // Drop a boundary-second event we already delivered on an // earlier page (the inclusive re-fetch returns it again). if (boundary != null && event.createdAt == boundary && event.id in seenAtBoundary) return + // The relay re-serving this page after an EOSE "auth" hint: skip what + // this page already delivered. + if (reServing && pageIds != null && event.id in pageIds) return // Count this event against every active filter it satisfies // (one event can match more than one). Only a non-search filter @@ -390,6 +421,7 @@ suspend fun INostrClient.fetchAllPages( if (atLeastOne) { onEvent(event) delivered++ + pageIds?.add(event.id) // Track the oldest advancing second and the ids delivered // in it — that becomes the next boundary and its dedup set. if (advancesCursor) { @@ -414,6 +446,15 @@ suspend fun INostrClient.fetchAllPages( doneChannel.trySend(PageSignal.EOSE) } + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + hints: List?, + ) { + eoseHints = hints + doneChannel.trySend(PageSignal.EOSE) + } + override fun onClosed( message: String, relay: NormalizedRelayUrl, @@ -454,6 +495,18 @@ suspend fun INostrClient.fetchAllPages( clock.bump() pageEnd = doneChannel.receiveWithinIdle(clock, idleTimeoutMs) } + } else if (pageEnd == PageSignal.EOSE && eoseHints.hasHint(EoseMessage.HINT_AUTH) && pendingOnAuthRequired && !authRetried) { + // NIP-67 "auth": the page was answered, but the relay says it held some back. + // Same wait as the CLOSED case — the relay sent its challenge before this EOSE, + // and the AUTH's OK re-sends this very REQ — except the page already delivered + // events, so the re-served copies are dropped via [pageIds]. + authRetried = true + reServing = true + if (awaitAuthOutcome(relay, authMark, DEFAULT_AUTH_GRACE_MS, idleTimeoutMs) == AuthOutcome.AUTHENTICATED) { + eoseHints = null + clock.bump() + pageEnd = doneChannel.receiveWithinIdle(clock, idleTimeoutMs) + } } unsubscribe(subId) @@ -465,6 +518,10 @@ suspend fun INostrClient.fetchAllPages( totalEvents += delivered + // The page ended on an EOSE saying more is visible only after AUTH, and no AUTH + // took the wall down: whatever it did deliver stands, but it cannot prove absence. + val authBlocked = pageEnd == PageSignal.EOSE && eoseHints.hasHint(EoseMessage.HINT_AUTH) + // The relay sent nothing at-or-below `until`. Whether that DRAINS the set // depends on why the page ended and on what was asked: // @@ -490,6 +547,23 @@ suspend fun INostrClient.fetchAllPages( pageEnd == null -> PagedFetchResult.End.IDLE cappedByLimit -> PagedFetchResult.End.LIMIT_REACHED filters.any { it.search != null } -> PagedFetchResult.End.UNPAGEABLE + authBlocked -> PagedFetchResult.End.AUTH_REQUIRED + else -> PagedFetchResult.End.DRAINED + } + break + } + + // NIP-67 "finish": the relay says it sent every stored match for this page's + // filters, so there is nothing below the cursor to ask for — stop now rather than + // spend a REQ to watch an empty page come back. It only speaks for the filters this + // page actually carried; one that already dropped out (limit met, or a search after + // its single page) keeps the reading it would have had. + if (pageEnd == PageSignal.EOSE && eoseHints.hasHint(EoseMessage.HINT_FINISH)) { + end = + when { + authBlocked -> PagedFetchResult.End.AUTH_REQUIRED + filters.indices.any { i -> filters[i].limit.let { it != null && matchCountPerFilter[i] >= it } } -> PagedFetchResult.End.LIMIT_REACHED + filters.any { it.search != null } -> PagedFetchResult.End.UNPAGEABLE else -> PagedFetchResult.End.DRAINED } break @@ -587,3 +661,5 @@ suspend fun INostrClient.fetchAllPages( onNewPage = onNewPage, onEvent = onEvent, ) + +private fun List?.hasHint(hint: String) = this != null && contains(hint) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt index 90e62f2991..160e8c13d5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnection import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage @@ -113,6 +114,9 @@ class RelayAuthenticator( // from RelayAuthStatus.snapshot(). private val authStatus = LargeCache() + /** The challenge each relay was last re-authenticated on because of an EOSE `"auth"` hint. */ + private val authHintRetried = LargeCache() + private val _authStateFlow = MutableStateFlow>(persistentMapOf()) /** @@ -143,6 +147,7 @@ class RelayAuthenticator( is AuthMessage -> authenticate(relay, msg.challenge, interactive = true) is OkMessage -> checkAuthResults(relay, msg) is ClosedMessage -> reauthenticateIfAuthRequired(relay, msg) + is EoseMessage -> reauthenticateIfAuthHinted(relay, msg) } } @@ -153,6 +158,7 @@ class RelayAuthenticator( override fun onDisconnected(relay: IRelayClient) { authStatus.remove(relay.url) + authHintRetried.remove(relay.url) publishSnapshot(relay.url) } } @@ -222,6 +228,34 @@ class RelayAuthenticator( msg: ClosedMessage, ) { if (MachineReadablePrefix.parse(msg.message) != MachineReadablePrefix.AUTH_REQUIRED) return + reauthenticateWithStoredChallenge(relay) + } + + /** + * NIP-67 / NIP-42: an `EOSE` carrying the `"auth"` hint says the relay may hold more + * matches for this subscription if we authenticate. The relay MUST have sent its + * `AUTH` challenge before that EOSE, so the challenge is already stored and the normal + * [authenticate] pass has usually run on it. This takes the same path as an + * `auth-required:` CLOSED: re-attach any approved identity not yet sent on that + * challenge (never prompting), and let the AUTH's `OK` → [INostrClient.syncFilters] + * re-send the REQ so the relay can serve what it held back. Deduped per + * (pubkey, challenge) and skipped while an AUTH is in flight, so it cannot loop. + */ + private fun reauthenticateIfAuthHinted( + relay: IRelayClient, + msg: EoseMessage, + ) { + if (!msg.needsAuth()) return + // A relay that keeps refusing us may tag EVERY EOSE with "auth". Unlike a CLOSED, the + // subscription is still answered, so there is no refusal to recover from — one retry + // per challenge is enough, and it spares an external signer a pass per subscription. + val challenge = authStatus.get(relay.url)?.lastChallenge() ?: return + if (authHintRetried.get(relay.url) == challenge) return + authHintRetried.put(relay.url, challenge) + reauthenticateWithStoredChallenge(relay) + } + + private fun reauthenticateWithStoredChallenge(relay: IRelayClient) { val status = authStatus.get(relay.url) ?: return // Coalesce the burst: a relay refuses EVERY currently-open sub with its own `auth-required` // CLOSED, so a single missing identity yields many CLOSEDs at once. Re-signing on each would diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt index 4630a5c58b..0f99c7208f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt @@ -302,6 +302,7 @@ class PoolRequests( desiredSubListeners.get(msg.subId)?.onEose( relay = relay.url, forFilters = forFilters, + hints = msg.hints, ) // send a newer version when done diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/SubscriptionListener.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/SubscriptionListener.kt index 15486f55af..172800d6a8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/SubscriptionListener.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/SubscriptionListener.kt @@ -30,6 +30,18 @@ interface SubscriptionListener { forFilters: List?, ) {} + /** + * EOSE together with its NIP-67 completeness [hints] (`finish`, `more`, `auth`, …; + * null when the relay sent the plain two-element EOSE). The pool calls this one; + * the default forwards to the two-argument [onEose], so listeners that don't care + * about hints keep overriding that. + */ + fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + hints: List?, + ) = onEose(relay, forFilters) + suspend fun onEvent( event: Event, isLive: Boolean, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EoseMessage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EoseMessage.kt index f376e4bc82..becf423a80 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EoseMessage.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EoseMessage.kt @@ -20,11 +20,29 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.commands.toClient +/** + * `["EOSE", ]`, optionally with NIP-67 completeness hints: + * `["EOSE", , [, ...]]`. + * + * [hints] is null when the relay sent the two-element form. Hints are only + * about stored events; their presence is definitive, their absence is not + * (see [isFinished] / [hasMore]). Unknown hint values are kept but ignored. + */ class EoseMessage( val subId: String, + val hints: List? = null, ) : Message { override fun label() = LABEL + /** NIP-67 `finish`: every stored match was sent; do not paginate further. */ + fun isFinished() = hints?.contains(HINT_FINISH) == true + + /** NIP-67 `more`: the relay holds more stored matches than it sent; paginate. */ + fun hasMore() = hints?.contains(HINT_MORE) == true + + /** NIP-67 `auth`: more stored matches may be available after NIP-42 AUTH. */ + fun needsAuth() = hints?.contains(HINT_AUTH) == true + /** * Wire form is `["EOSE",""]` — sent once per REQ, so it is on * the per-subscription floor. Splice it directly when [subId] needs no @@ -33,7 +51,7 @@ class EoseMessage( * any exotic subId falls back. */ override fun toJson(): String { - if (!isEscapeFreeAscii(subId)) return super.toJson() + if (hints != null || !isEscapeFreeAscii(subId)) return super.toJson() return buildString(subId.length + 12) { append("[\"EOSE\",\"") append(subId) @@ -43,5 +61,10 @@ class EoseMessage( companion object { const val LABEL = "EOSE" + + // NIP-67 hint values. + const val HINT_FINISH = "finish" + const val HINT_MORE = "more" + const val HINT_AUTH = "auth" } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/EoseCompletenessProbe.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/EoseCompletenessProbe.kt new file mode 100644 index 0000000000..9d6cdb5349 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/EoseCompletenessProbe.kt @@ -0,0 +1,99 @@ +/* + * 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.server + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter + +/** + * Works out the NIP-67 completeness hint for one REQ's stored replay, only ever + * claiming what the store's answer actually proves: + * + * - **One filter with `limit = L > 0`**: the store is asked for `L + 1` rows and the + * extra (oldest) row is withheld. Seeing it proves the relay holds more (`"more"`); + * not seeing it proves the replay was complete (`"finish"`). The client receives + * exactly the `L` events it would have without the probe. + * - **Every filter unbounded (`limit = null`)**: the store returns every match + * (STORE-F12), so the replay is complete (`"finish"`). + * - **Several filters, some limited**: limits are per filter and the replay is their + * deduped union, so rows cannot be attributed back to a filter without matching + * each one. Only the cheap, sound case is claimed: fewer rows than the smallest + * limit means no filter reached its limit (`"finish"`). Anything else sends no hint, + * which NIP-67 allows (absence is not definitive). + * + * `limit = 0` never gets a hint and is never probed: NIP-01 forbids returning stored + * events for it, so it keeps its exact query. + * + * This assumes the backing store honours `limit` exactly and treats `null` as + * unbounded, which is why [RelaySession] only uses it when the server opts in. + */ +internal class EoseCompletenessProbe private constructor( + /** The filters to actually query (the limit may be raised by one). */ + val queryFilters: List, + /** Max stored events to forward; the rest are only counted. Null: forward all. */ + private val forwardCap: Int?, + private val rule: Rule, +) { + private enum class Rule { PROBE, ALL_UNBOUNDED, UNDER_MIN_LIMIT } + + private val minLimit: Int = if (rule == Rule.UNDER_MIN_LIMIT) queryFilters.minOf { it.limit ?: Int.MAX_VALUE } else 0 + + /** Stored events the store produced for this REQ, forwarded or not. */ + private var seen = 0 + + /** + * Counts one stored event and says whether it should be forwarded to the client. + * Called from the single replay coroutine, before EOSE. + */ + fun onStored(): Boolean { + seen++ + return forwardCap == null || seen <= forwardCap + } + + /** The hints for this replay's EOSE, or null for none. */ + fun hints(): List? = + when (rule) { + Rule.PROBE -> if (seen > forwardCap!!) MORE else FINISH + Rule.ALL_UNBOUNDED -> FINISH + Rule.UNDER_MIN_LIMIT -> if (seen < minLimit) FINISH else null + } + + companion object { + private val FINISH = listOf(EoseMessage.HINT_FINISH) + private val MORE = listOf(EoseMessage.HINT_MORE) + + fun of(filters: List): EoseCompletenessProbe? { + if (filters.isEmpty()) return null + if (filters.any { it.limit == 0 }) return null + + if (filters.size == 1) { + val limit = filters[0].limit + if (limit != null && limit < Int.MAX_VALUE) { + return EoseCompletenessProbe(listOf(filters[0].copy(limit = limit + 1)), limit, Rule.PROBE) + } + } + + if (filters.all { it.limit == null }) return EoseCompletenessProbe(filters, null, Rule.ALL_UNBOUNDED) + + return EoseCompletenessProbe(filters, null, Rule.UNDER_MIN_LIMIT) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayServerBase.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayServerBase.kt index b3fcb9ee5b..da26993604 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayServerBase.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayServerBase.kt @@ -63,6 +63,15 @@ abstract class RelayServerBase( /** Number of connections currently registered with this server. */ val activeConnections: Long get() = connections.active + /** + * NIP-67 opt-in: when true, connections opened from now on append `"finish"` / + * `"more"` to their EOSEs where the stored replay proves it (see + * [RelaySession.completenessHints]). Enable it only for a backend that honours + * `limit` exactly and returns every match for an unbounded filter, and advertise + * `67` in the NIP-11 `supported_nips` when you do. + */ + var completenessHints: Boolean = false + /** * Builds the per-connection policy, prepending a [LimitsPolicy] when * [limits] is set so requests are clamped/rejected before the application @@ -91,6 +100,7 @@ abstract class RelayServerBase( sink = sink, onClose = { connections.unregister(it.id) }, negentropySettings = negentropySettings, + completenessHints = completenessHints, ), ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt index df77566329..76743461ee 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt @@ -79,6 +79,13 @@ class RelaySession( * open/close of the same connection. Defaults to a fresh monotonic id. */ val id: Long = nextConnectionId(), + /** + * NIP-67: append a completeness hint (`"finish"` / `"more"`) to each REQ's `EOSE` + * when the stored replay proves one — see [EoseCompletenessProbe]. Off by default: + * the proof assumes the [store] honours `limit` exactly and returns every match + * for an unbounded filter, which an arbitrary backend need not do. + */ + val completenessHints: Boolean = false, ) : AutoCloseable { /** The original, string-only constructor; every frame goes to [onSend] as wire JSON. */ constructor( @@ -353,7 +360,15 @@ class RelaySession( } // Policy may rewrite filters to match the user's access level. - val filters = (result as PolicyResult.Accepted).cmd.filters + val acceptedFilters = (result as PolicyResult.Accepted).cmd.filters + + // NIP-67: may raise a single filter's limit by one to detect "more"; the extra + // stored row is counted but never sent. Zero-decode path only: the screened path's + // single `onEach` also carries live events accepted mid-replay, so stored rows can't + // be told apart there, and a policy that vetoes rows could not honestly say "finish". + val probe = if (completenessHints && !policy.filtersOutgoingEvents) EoseCompletenessProbe.of(acceptedFilters) else null + val filters = probe?.queryFilters ?: acceptedFilters + val eose = { send(EoseMessage(cmd.subId, probe?.hints())) } // UNDISPATCHED: the stored replay runs inline on this coroutine — // the reader-pool acquire doesn't suspend when a connection is @@ -377,7 +392,7 @@ class RelaySession( send(EventMessage(cmd.subId, event)) } }, - onEose = { send(EoseMessage(cmd.subId)) }, + onEose = { eose() }, ) } else { // Zero-decode path: the stored replay splices raw @@ -395,13 +410,16 @@ class RelaySession( ctx = requestContext, filters = filters, onEachStored = { raw -> - sendRaw( - buildString(framePrefix.length + raw.jsonTags.length + raw.content.length + 256) { - append(framePrefix) - raw.appendJsonObjectTo(this) - append(']') - }, - ) + // NIP-67 probe: the one extra row it asked for is counted, not sent. + if (probe == null || probe.onStored()) { + sendRaw( + buildString(framePrefix.length + raw.jsonTags.length + raw.content.length + 256) { + append(framePrefix) + raw.appendJsonObjectTo(this) + append(']') + }, + ) + } }, // Live events arrive with their wire body already // serialized (once per event, shared across every @@ -417,7 +435,7 @@ class RelaySession( }, ) }, - onEose = { send(EoseMessage(cmd.subId)) }, + onEose = { eose() }, ) } } catch (e: CancellationException) { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerEoseTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerEoseTest.kt new file mode 100644 index 0000000000..3c779e1975 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerEoseTest.kt @@ -0,0 +1,198 @@ +/* + * 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.server + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** The relay engine's opt-in NIP-67 EOSE completeness hints. */ +@OptIn(ExperimentalCoroutinesApi::class) +class NostrServerEoseTest { + private val pubkey = "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d" + private val sig = "4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce" + + private val noop: (String) -> Unit = {} + + private fun hexId(n: Int): String = n.toString().padStart(64, '0') + + private fun testEvent( + id: String, + kind: Int = 1, + createdAt: Long = 1000L, + ) = Event(id, pubkey, createdAt, kind, emptyArray(), "hello", sig) + + private fun server( + dispatcher: CoroutineDispatcher, + store: EventStore, + hints: Boolean = false, + ) = NostrServer(store = store, policyBuilder = { EmptyPolicy }, parentContext = dispatcher).also { + it.completenessHints = hints + } + + private class Collector { + val messages = mutableListOf() + val send: (String) -> Unit = { messages.add(it) } + + fun parsed(): List = + messages + .filter { it.startsWith("[\"EVENT\"") || it.startsWith("[\"EOSE\"") } + .map { OptimizedJsonMapper.fromJsonToMessage(it) } + + fun events() = parsed().filterIsInstance() + + fun eoses() = parsed().filterIsInstance() + } + + private suspend fun EventStore.seed(count: Int) { + for (i in 1..count) insert(testEvent(hexId(i), createdAt = i.toLong())) + } + + // -- NIP-67: completeness hints ------------------------------------------------ + + @Test + fun hintsAreOffByDefault() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + store.seed(3) + val server = server(dispatcher, store) + val collector = Collector() + server.connect(collector.send).receive("""["REQ","s",{"kinds":[1]}]""") + + assertTrue(collector.messages.contains("""["EOSE","s"]""")) + assertNull(collector.eoses().single().hints) + server.close() + } + + @Test + fun truncatedByLimitSendsMore() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + store.seed(10) + val server = server(dispatcher, store, hints = true) + val collector = Collector() + server.connect(collector.send).receive("""["REQ","s",{"kinds":[1],"limit":3}]""") + + // The probe asks the store for 4 but only the newest 3 go out. + assertEquals(listOf(hexId(10), hexId(9), hexId(8)), collector.events().map { it.event.id }) + assertEquals(listOf(EoseMessage.HINT_MORE), collector.eoses().single().hints) + server.close() + } + + @Test + fun exactlyTheLimitSendsFinish() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + store.seed(10) + val server = server(dispatcher, store, hints = true) + val collector = Collector() + server.connect(collector.send).receive("""["REQ","s",{"kinds":[1],"limit":10}]""") + + assertEquals(10, collector.events().size) + assertEquals(listOf(EoseMessage.HINT_FINISH), collector.eoses().single().hints) + server.close() + } + + @Test + fun unboundedFilterSendsFinish() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + store.seed(4) + val server = server(dispatcher, store, hints = true) + val collector = Collector() + server.connect(collector.send).receive("""["REQ","s",{"kinds":[1]}]""") + + assertEquals(4, collector.events().size) + assertEquals(listOf(EoseMessage.HINT_FINISH), collector.eoses().single().hints) + server.close() + } + + @Test + fun multiFilterHintsOnlyWhatTheCountProves() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + store.seed(4) + val server = server(dispatcher, store, hints = true) + + val under = Collector() + server.connect(under.send).receive("""["REQ","s",{"kinds":[1],"limit":10},{"kinds":[2],"limit":20}]""") + assertEquals(4, under.events().size) + assertEquals(listOf(EoseMessage.HINT_FINISH), under.eoses().single().hints, "4 rows < smallest limit: nothing was cut") + + val ambiguous = Collector() + server.connect(ambiguous.send).receive("""["REQ","s",{"kinds":[1],"limit":2},{"kinds":[2],"limit":20}]""") + assertEquals(2, ambiguous.events().size) + assertNull(ambiguous.eoses().single().hints, "cannot attribute rows to filters cheaply, so no claim") + + server.close() + } + + @Test + fun limitZeroNeverGetsAHint() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + store.seed(4) + val server = server(dispatcher, store, hints = true) + val collector = Collector() + server.connect(collector.send).receive("""["REQ","s",{"kinds":[1],"limit":0}]""") + + assertEquals(0, collector.events().size) + assertNull(collector.eoses().single().hints) + server.close() + } + + @Test + fun probeDoesNotHoldBackLiveEvents() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + store.seed(5) + val server = server(dispatcher, store, hints = true) + val collector = Collector() + server.connect(collector.send).receive("""["REQ","s",{"kinds":[1],"limit":1}]""") + assertEquals(1, collector.events().size) + + server.connect(noop).receive(OptimizedJsonMapper.toJson(EventCmd(testEvent(hexId(100), createdAt = 9000L)))) + server.connect(noop).receive(OptimizedJsonMapper.toJson(EventCmd(testEvent(hexId(101), createdAt = 9001L)))) + + assertEquals(listOf(hexId(5), hexId(100), hexId(101)), collector.events().map { it.event.id }) + server.close() + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt index a2d3e10df3..a2b853840f 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt @@ -54,9 +54,26 @@ class MessageDeserializer : StdDeserializer(Message::class.java) { } EoseMessage.LABEL -> { - EoseMessage( - subId = jp.nextTextValue(), - ) + val subId = jp.nextTextValue() + // NIP-67: an optional third element, an array of hint strings. The array is + // consumed here (stepping past its END_ARRAY) so the drain loop below only + // ever sees the outer frame's tokens. + val hints = + if (jp.nextToken() == JsonToken.START_ARRAY) { + val list = ArrayList(2) + while (jp.nextToken() != JsonToken.END_ARRAY) { + if (jp.currentToken == JsonToken.VALUE_STRING) { + list.add(jp.text) + } else { + jp.skipChildren() + } + } + jp.nextToken() + list + } else { + null + } + EoseMessage(subId, hints) } NoticeMessage.LABEL -> { diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt index 76671a396f..a3c588e1af 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt @@ -78,6 +78,12 @@ class MessageSerializer : StdSerializer(Message::class.java) { is EoseMessage -> { gen.writeString(msg.subId) + // NIP-67: optional third element, the completeness hints. + msg.hints?.let { hints -> + gen.writeStartArray() + hints.forEach { gen.writeString(it) } + gen.writeEndArray() + } } is LimitsMessage -> { diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesEoseHintsTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesEoseHintsTest.kt new file mode 100644 index 0000000000..620fa58d64 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesEoseHintsTest.kt @@ -0,0 +1,201 @@ +/* + * 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 + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PagedFetchResult +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * NIP-67 completeness hints steering [fetchAllPages]: `finish` ends the walk without the + * extra empty-page REQ, `more` keeps paging, and an unanswered `auth` hint keeps the walk + * from claiming DRAINED. + */ +class NostrClientFetchAllPagesEoseHintsTest { + private class ScriptedClient : INostrClient by EmptyNostrClient() { + @Volatile + var listener: SubscriptionListener? = null + + @Volatile + var subscribeCount = 0 + + override fun subscribe( + subId: String, + filters: Map>, + listener: SubscriptionListener?, + ) { + subscribeCount++ + this.listener = listener + } + + suspend fun awaitPage(n: Int) { + while (subscribeCount < n) delay(2) + } + } + + private val relay = RelayUrlNormalizer.normalize("wss://hints.example.com") + + private fun event(createdAt: Long) = + Event( + id = createdAt.toString(16).padStart(64, '0'), + pubKey = "f".repeat(64), + createdAt = createdAt, + kind = 1, + tags = emptyArray(), + content = "e$createdAt", + sig = "0".repeat(128), + ) + + @Test + fun finishEndsTheWalkWithoutAnotherPage() = + runBlocking { + val client = ScriptedClient() + val feeder = + launch { + client.awaitPage(1) + client.listener!!.onEvent(event(2000), false, relay, null) + client.listener!!.onEvent(event(1000), false, relay, null) + client.listener!!.onEose(relay, null, listOf("finish")) + } + + val result = client.fetchAllPages(relay = relay, filters = listOf(Filter(kinds = listOf(1))), idleTimeoutMs = 2_000) { } + feeder.join() + + assertEquals(2, result.downloaded) + assertEquals(PagedFetchResult.End.DRAINED, result.end, "the relay said it sent every stored match") + assertEquals(1, client.subscribeCount, "no second REQ just to observe an empty page") + } + + @Test + fun moreKeepsPaging() = + runBlocking { + val client = ScriptedClient() + val feeder = + launch { + client.awaitPage(1) + client.listener!!.onEvent(event(2000), false, relay, null) + client.listener!!.onEose(relay, null, listOf("more")) + + client.awaitPage(2) + client.listener!!.onEvent(event(1000), false, relay, null) + client.listener!!.onEose(relay, null, listOf("finish")) + } + + val result = client.fetchAllPages(relay = relay, filters = listOf(Filter(kinds = listOf(1))), idleTimeoutMs = 2_000) { } + feeder.join() + + assertEquals(2, result.downloaded) + assertEquals(PagedFetchResult.End.DRAINED, result.end) + assertEquals(2, client.subscribeCount) + } + + @Test + fun unknownHintsAreIgnored() = + runBlocking { + val client = ScriptedClient() + val feeder = + launch { + client.awaitPage(1) + client.listener!!.onEvent(event(2000), false, relay, null) + client.listener!!.onEose(relay, null, listOf("somethingNew")) + client.awaitPage(2) + client.listener!!.onEose(relay, null, emptyList()) + } + + val result = client.fetchAllPages(relay = relay, filters = listOf(Filter(kinds = listOf(1))), idleTimeoutMs = 2_000) { } + feeder.join() + + assertEquals(1, result.downloaded) + assertEquals(PagedFetchResult.End.DRAINED, result.end, "falls back to the empty-page heuristic") + assertEquals(2, client.subscribeCount) + } + + @Test + fun finishWithAnUnansweredAuthHintCannotClaimDrained() = + runBlocking { + // No NIP-42 responder is attached, so the "auth" hint cannot be acted on. + val client = ScriptedClient() + val feeder = + launch { + client.awaitPage(1) + client.listener!!.onEvent(event(2000), false, relay, null) + client.listener!!.onEose(relay, null, listOf("auth", "finish")) + } + + val result = client.fetchAllPages(relay = relay, filters = listOf(Filter(kinds = listOf(1))), idleTimeoutMs = 2_000) { } + feeder.join() + + assertEquals(1, result.downloaded, "what was delivered still counts") + assertEquals(PagedFetchResult.End.AUTH_REQUIRED, result.end, "the relay may hold more for an authenticated user") + assertEquals(1, client.subscribeCount) + } + + @Test + fun anEmptyPageWithAnAuthHintIsNotADrain() = + runBlocking { + val client = ScriptedClient() + val feeder = + launch { + client.awaitPage(1) + client.listener!!.onEvent(event(2000), false, relay, null) + client.listener!!.onEose(relay, null, listOf("auth")) + client.awaitPage(2) + client.listener!!.onEose(relay, null, listOf("auth")) + } + + val result = client.fetchAllPages(relay = relay, filters = listOf(Filter(kinds = listOf(1))), idleTimeoutMs = 2_000) { } + feeder.join() + + assertEquals(1, result.downloaded) + assertEquals(PagedFetchResult.End.AUTH_REQUIRED, result.end) + } + + @Test + fun finishAfterAFilterMetItsLimitReportsLimitReached() = + runBlocking { + val client = ScriptedClient() + val feeder = + launch { + client.awaitPage(1) + client.listener!!.onEvent(event(2000), false, relay, null) + client.listener!!.onEvent(event(1000), false, relay, null) + client.listener!!.onEose(relay, null, listOf("finish")) + } + + val result = client.fetchAllPages(relay = relay, filters = listOf(Filter(kinds = listOf(1), limit = 2)), idleTimeoutMs = 2_000) { } + feeder.join() + + assertEquals(2, result.downloaded) + assertEquals(PagedFetchResult.End.LIMIT_REACHED, result.end) + assertEquals(1, client.subscribeCount) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EoseHintsParsingTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EoseHintsParsingTest.kt new file mode 100644 index 0000000000..7fff7c738e --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EoseHintsParsingTest.kt @@ -0,0 +1,135 @@ +/* + * 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.commands.toClient + +import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.KotlinSerializationMapper +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** NIP-67 EOSE completeness hints, through both JSON backends. */ +class EoseHintsParsingTest { + private val parsers: List Message>> = + listOf( + "jackson" to { json -> JacksonMapper.fromJsonToMessage(json) }, + "kotlinx" to { json -> KotlinSerializationMapper.fromJsonToMessage(json) }, + ) + + private fun eachParser( + json: String, + check: (String, EoseMessage) -> Unit, + ) = parsers.forEach { (name, parse) -> + val msg = parse(json) + assertIs(msg, name) + check(name, msg) + } + + @Test + fun twoElementEoseHasNoHints() = + eachParser("""["EOSE","sub1"]""") { name, msg -> + assertEquals("sub1", msg.subId, name) + assertNull(msg.hints, name) + assertFalse(msg.isFinished(), name) + assertFalse(msg.hasMore(), name) + assertFalse(msg.needsAuth(), name) + } + + @Test + fun finishHint() = + eachParser("""["EOSE","sub2",["finish"]]""") { name, msg -> + assertEquals("sub2", msg.subId, name) + assertEquals(listOf("finish"), msg.hints, name) + assertTrue(msg.isFinished(), name) + assertFalse(msg.hasMore(), name) + } + + @Test + fun moreHint() = + eachParser("""["EOSE","sub2b",["more"]]""") { name, msg -> + assertTrue(msg.hasMore(), name) + assertFalse(msg.isFinished(), name) + } + + @Test + fun multipleAndUnknownHints() = + eachParser("""["EOSE","sub4",["auth","finish","somethingNew"]]""") { name, msg -> + assertEquals(listOf("auth", "finish", "somethingNew"), msg.hints, name) + assertTrue(msg.needsAuth(), name) + assertTrue(msg.isFinished(), name) + } + + @Test + fun emptyHintArray() = + eachParser("""["EOSE","sub5",[]]""") { name, msg -> + assertEquals(emptyList(), msg.hints, name) + assertFalse(msg.isFinished(), name) + } + + @Test + fun nonStringHintsAreIgnored() = + eachParser("""["EOSE","sub6",[1,{"a":[2]},["x"],"finish",null]]""") { name, msg -> + assertEquals(listOf("finish"), msg.hints, name) + } + + @Test + fun nonArrayThirdElementIsIgnored() = + eachParser("""["EOSE","sub7","finish",{"x":1}]""") { name, msg -> + assertEquals("sub7", msg.subId, name) + assertNull(msg.hints, name) + } + + @Test + fun trailingElementsAfterHintsAreTolerated() = + eachParser("""["EOSE","sub8",["more"],"extra",5]""") { name, msg -> + assertEquals(listOf("more"), msg.hints, name) + } + + @Test + fun serializesWithoutHintsAsTwoElements() { + val msg = EoseMessage("sub1") + assertEquals("""["EOSE","sub1"]""", msg.toJson()) + assertEquals("""["EOSE","sub1"]""", JacksonMapper.toJson(msg)) + assertEquals("""["EOSE","sub1"]""", KotlinSerializationMapper.toJson(msg)) + } + + @Test + fun serializesHintsAsThirdElement() { + val msg = EoseMessage("sub1", listOf("auth", "finish")) + val expected = """["EOSE","sub1",["auth","finish"]]""" + assertEquals(expected, msg.toJson()) + assertEquals(expected, JacksonMapper.toJson(msg)) + assertEquals(expected, KotlinSerializationMapper.toJson(msg)) + } + + @Test + fun roundTripsThroughBothBackends() { + val json = EoseMessage("s", listOf("more")).toJson() + parsers.forEach { (name, parse) -> + val back = parse(json) + assertIs(back, name) + assertEquals(listOf("more"), back.hints, name) + } + } +} From 4f196ab1f14aedd3dfe8cb9d8ccaeb7e9263bdfd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 20:13:03 +0000 Subject: [PATCH 14/24] feat: NIP-84 highlight sources from i tags and non-URL r tags NIP-84 now lets a highlight name its source with NIP-73 `i` tags and says an `r` tag may hold any text, not only a URL. - HighlightEvent: inExternalIds()/inExternalIdValues() read `i` tags; inReference() returns the `r` source (preferring the `source` marker, skipping `mention` ones); inUrl() only returns it when it is a web address. - The builders take externalIds, keep a non-URL `r` verbatim instead of gluing https:// onto it, and mark the source `r` of a quote highlight. - The renderer shows an `i` source with the existing NIP-73 external id display, and a non-URL `r` (or an unknown `i`) as plain text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc --- .../amethyst/ui/note/types/Highlight.kt | 57 ++++++++- .../quartz/nip84Highlights/HighlightEvent.kt | 78 +++++++++++- .../nip84Highlights/HighlightSourceTest.kt | 116 ++++++++++++++++++ 3 files changed, 242 insertions(+), 9 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightSourceTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt index 2fd5c0428f..5418504ac7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt @@ -40,6 +40,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.model.EmptyTagList @@ -67,13 +68,16 @@ import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.measureSpaceWidth import com.vitorpamplona.amethyst.ui.components.rememberTranslation import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.note.nip22Comments.DisplayExternalId import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindName import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.firstTagValueFor import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.references.HttpUrlFormatter import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers @@ -100,7 +104,9 @@ fun RenderHighlight( highlight = noteEvent.quote(), context = noteEvent.contextOrReconstructed(), authorHex = noteEvent.author(), - url = noteEvent.inUrl(), + url = noteEvent.inReference(), + externalId = remember(noteEvent) { noteEvent.inExternalIds().firstOrNull() }, + externalIdText = remember(noteEvent) { noteEvent.inExternalIdValues().firstOrNull() }, textFragmentPrefix = selector?.prefix, textFragmentSuffix = selector?.suffix, postAddress = noteEvent.inPostAddress(), @@ -176,6 +182,8 @@ fun DisplayHighlight( postAddress: Address?, postVersion: ETag?, makeItShort: Boolean, + externalId: ExternalId? = null, + externalIdText: String? = null, canPreview: Boolean, quotesLeft: Int, backgroundColor: MutableState, @@ -251,6 +259,8 @@ fun DisplayHighlight( textFragmentSuffix = textFragmentSuffix, postAddress = postAddress, postVersion = postVersion, + externalId = externalId, + externalIdText = externalIdText, accountViewModel = accountViewModel, nav = nav, ) @@ -306,6 +316,8 @@ private fun DisplayQuoteAuthor( textFragmentSuffix: String? = null, postAddress: Address?, postVersion: ETag?, + externalId: ExternalId? = null, + externalIdText: String? = null, accountViewModel: AccountViewModel, nav: INav, ) { @@ -362,15 +374,25 @@ private fun DisplayQuoteAuthor( } } - baseUrl != null -> { + // NIP-84: `r` may be a URL or any text naming the source; only a web address is a link. + baseUrl != null && HighlightEvent.isUrlReference(baseUrl) -> { val url = remember(baseUrl, highlightQuote, textFragmentPrefix, textFragmentSuffix) { - buildTextFragmentUrl(baseUrl, highlightQuote, textFragmentPrefix, textFragmentSuffix) + buildTextFragmentUrl(HttpUrlFormatter.addSchemeIfNeeded(baseUrl), highlightQuote, textFragmentPrefix, textFragmentSuffix) } DisplayEntryForAUrl(url, userBase, accountViewModel, nav) } + // A NIP-73 source (`i` tag): a book, paper, podcast episode... + externalId != null -> { + DisplayEntryForExternalId(externalId, userBase, accountViewModel, nav) + } + + baseUrl != null || externalIdText != null -> { + DisplayEntryForText(baseUrl ?: externalIdText ?: "", userBase, accountViewModel, nav) + } + userBase != null -> { userBase?.let { DisplayEntryForUser(it, accountViewModel, nav) @@ -437,6 +459,35 @@ fun DisplayEntryForNote( } } +@Composable +fun DisplayEntryForExternalId( + externalId: ExternalId, + userBase: User?, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (userBase != null) { + DisplayEntryForUser(userBase, accountViewModel, nav) + Text("-", maxLines = 1) + } + DisplayExternalId(externalId, accountViewModel, nav) +} + +/** A source named in plain text (a non-URL `r` tag, or an `i` value no NIP-73 parser knows). */ +@Composable +fun DisplayEntryForText( + source: String, + userBase: User?, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (userBase != null) { + DisplayEntryForUser(userBase, accountViewModel, nav) + Text("-", maxLines = 1) + } + Text(source, maxLines = 2, overflow = TextOverflow.Ellipsis) +} + @Composable fun DisplayEntryForAUrl( url: String, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt index d5af20931b..8812301aa7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.events.firstTaggedEvent import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.firstTaggedUserId +import com.vitorpamplona.quartz.nip01Core.tags.references.HttpUrlFormatter import com.vitorpamplona.quartz.nip01Core.tags.references.ReferenceTag import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag @@ -49,8 +50,10 @@ import com.vitorpamplona.quartz.nip19Bech32.eventIds import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints import com.vitorpamplona.quartz.nip19Bech32.pubKeys import com.vitorpamplona.quartz.nip22Comments.RootScope +import com.vitorpamplona.quartz.nip22Comments.tags.ReplyIdentifierTag import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor import com.vitorpamplona.quartz.nip50Search.SearchableEvent +import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId import com.vitorpamplona.quartz.nip84Highlights.tags.CommentTag import com.vitorpamplona.quartz.nip84Highlights.tags.ContextTag import com.vitorpamplona.quartz.nip84Highlights.tags.TextQuoteSelectorTag @@ -126,7 +129,32 @@ class HighlightEvent( return pHints + nip19Hints } - fun inUrl() = tags.firstNotNullOfOrNull(ReferenceTag::parse) + /** + * The highlight's `r` source. NIP-84 lets it be a URL or any other text naming the source, + * so this is not necessarily a URL: check with [isUrlReference] before opening it. Prefers the + * tag marked `source` and skips `mention` ones, which quote highlights use for URLs cited in + * their comment. + */ + fun inReference(): String? { + var firstUnmarked: String? = null + tags.forEach { tag -> + if (tag.size > 1 && tag[0] == ReferenceTag.TAG_NAME && tag[1].isNotBlank()) { + val marker = tag.getOrNull(2) + if (marker == SOURCE_MARKER) return tag[1] + if (marker != MENTION_MARKER && firstUnmarked == null) firstUnmarked = tag[1] + } + } + return firstUnmarked + } + + /** The `r` source when it is a web address; see [inReference] for any text. */ + fun inUrl(): String? = inReference()?.takeIf(::isUrlReference) + + /** The NIP-73 external ids (`i` tags) the highlight was taken from: a book, paper, podcast... */ + fun inExternalIds(): List = tags.mapNotNull(ReplyIdentifierTag::parseExternalId) + + /** Raw `i` tag values, including the ones no [ExternalId] parser recognizes. */ + fun inExternalIdValues(): List = tags.mapNotNull(ReplyIdentifierTag::parse) /** * The pubkey of the author of the highlighted content. @@ -194,6 +222,29 @@ class HighlightEvent( /** NIP-84 role marker on the `p` tag that identifies the highlighted content's author. */ private const val AUTHOR_MARKER = "author" + /** NIP-84 marker for the `r` tag naming the highlighted source in a quote highlight. */ + const val SOURCE_MARKER = "source" + + /** NIP-84 marker for `r`/`p` tags cited by a quote highlight's comment. */ + const val MENTION_MARKER = "mention" + + private val WHITESPACE = Regex("\\s") + + /** + * Whether an `r` value reads as a web address. NIP-84 allows any text there ("Chapter 3 + * of Dune"), which must neither be opened as a link nor get `https://` glued on. + */ + fun isUrlReference(reference: String): Boolean { + val trimmed = reference.trim() + if (trimmed.isEmpty() || WHITESPACE.containsMatchIn(trimmed)) return false + if (trimmed.startsWith("https://", ignoreCase = true) || trimmed.startsWith("http://", ignoreCase = true)) { + return HttpUrlFormatter.isValidUrl(trimmed) + } + // Scheme-less host/path, as some clients write it: needs a dotted host. + val host = trimmed.substringBefore('/').substringBefore('?').substringBefore('#') + return host.contains('.') && !host.startsWith('.') && !host.endsWith('.') && HttpUrlFormatter.isValidUrl(trimmed) + } + /** Any run of whitespace (spaces, tabs, newlines) — collapsed to a single space. */ private val WHITESPACE_RUN = Regex("\\s+") @@ -211,8 +262,10 @@ class HighlightEvent( * - [address] → an `a` reference to a nostr addressable source (e.g. a NIP-23 article), * - [event] → an `e` reference to a specific nostr event version highlighted, * - [author] → a `p` attribution to the highlighted content's author, - * - [url] → an `r` source reference (normalized by [ReferenceTag]; clean it of - * trackers with [com.vitorpamplona.quartz.nip84Highlights.parse.UrlTrackerCleaner] first), + * - [url] → an `r` source reference. A web address is normalized by [ReferenceTag] (clean + * it of trackers with [com.vitorpamplona.quartz.nip84Highlights.parse.UrlTrackerCleaner] + * first); any other text is kept verbatim, as NIP-84 allows, + * - [externalIds] → `i` tags for NIP-73 sources (ISBNs, DOIs, podcast episodes...), * - [prefix]/[suffix] → a `textquoteselector` anchor (the `exact` field stays a * placeholder since the passage already lives in `content`), * - [context] → the surrounding paragraph as a `context` tag, @@ -231,9 +284,10 @@ class HighlightEvent( address: String? = null, event: String? = null, author: String? = null, + externalIds: List = emptyList(), signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): HighlightEvent = signer.sign(createdAt, KIND, assembleTags(url, prefix, suffix, comment, context, address, event, author), quote) + ): HighlightEvent = signer.sign(createdAt, KIND, assembleTags(url, prefix, suffix, comment, context, address, event, author, externalIds), quote) /** * The unsigned [EventTemplate] counterpart of [create], for the app's @@ -250,11 +304,12 @@ class HighlightEvent( address: String? = null, event: String? = null, author: String? = null, + externalIds: List = emptyList(), createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ): EventTemplate = eventTemplate(KIND, quote, createdAt) { - addAll(assembleTags(url, prefix, suffix, comment, context, address, event, author)) + addAll(assembleTags(url, prefix, suffix, comment, context, address, event, author, externalIds)) initializer() } @@ -267,6 +322,7 @@ class HighlightEvent( address: String? = null, event: String? = null, author: String? = null, + externalIds: List = emptyList(), ): Array> { val tags = mutableListOf>() @@ -281,8 +337,18 @@ class HighlightEvent( // carries `mention` p tags — the producer-side counterpart of that reader logic. tags.add(arrayOf(PTag.TAG_NAME, author, "", AUTHOR_MARKER)) } + externalIds.forEach { tags.add(ReplyIdentifierTag.assemble(it)) } if (!url.isNullOrBlank()) { - tags.add(ReferenceTag.assemble(url)) + // `r` may be any text: only a web address gets URL-normalized. A quote highlight + // marks its source so the comment's own cited URLs (`mention`) can't be mistaken for it. + val reference = if (isUrlReference(url)) HttpUrlFormatter.normalize(url) else url.trim() + tags.add( + if (comment.isNullOrBlank()) { + arrayOf(ReferenceTag.TAG_NAME, reference) + } else { + arrayOf(ReferenceTag.TAG_NAME, reference, SOURCE_MARKER) + }, + ) } if (!prefix.isNullOrEmpty() || !suffix.isNullOrEmpty()) { tags.add(TextQuoteSelectorTag.assemble(null, prefix, suffix)) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightSourceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightSourceTest.kt new file mode 100644 index 0000000000..3c577edf1b --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightSourceTest.kt @@ -0,0 +1,116 @@ +/* + * 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.nip84Highlights + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip73ExternalIds.books.BookId +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** NIP-84 sources: `i` tags for NIP-73 ids, and `r` tags that may hold any text. */ +class HighlightSourceTest { + private val signer = NostrSignerInternal(KeyPair()) + + private fun highlight(vararg tags: Array) = HighlightEvent("00", "00", 0, arrayOf(*tags), "a passage", "00") + + @Test + fun readsNip73ExternalIds() { + val event = highlight(arrayOf("i", "isbn:9780765382030"), arrayOf("i", "custom:thing")) + val ids = event.inExternalIds() + assertEquals(1, ids.size) + assertEquals("isbn:9780765382030", ids.first().toScope()) + assertEquals(listOf("isbn:9780765382030", "custom:thing"), event.inExternalIdValues()) + } + + @Test + fun aTextReferenceIsNotAUrl() { + val event = highlight(arrayOf("r", "Dune, chapter 3")) + assertEquals("Dune, chapter 3", event.inReference()) + assertNull(event.inUrl()) + } + + @Test + fun aWebReferenceIsStillAUrl() { + val event = highlight(arrayOf("r", "https://example.com/post")) + assertEquals("https://example.com/post", event.inReference()) + assertEquals("https://example.com/post", event.inUrl()) + } + + @Test + fun prefersTheSourceMarkedReferenceOverMentions() { + val event = + highlight( + arrayOf("r", "https://cited.example.com", "mention"), + arrayOf("r", "https://source.example.com", "source"), + ) + assertEquals("https://source.example.com", event.inReference()) + + val onlyMention = highlight(arrayOf("r", "https://cited.example.com", "mention")) + assertNull(onlyMention.inReference()) + } + + @Test + fun classifiesReferences() { + assertTrue(HighlightEvent.isUrlReference("https://example.com/post?x=1")) + assertTrue(HighlightEvent.isUrlReference("http://example.com")) + assertTrue(HighlightEvent.isUrlReference("example.com/post")) + assertFalse(HighlightEvent.isUrlReference("Dune, chapter 3")) + assertFalse(HighlightEvent.isUrlReference("The Bitcoin whitepaper")) + assertFalse(HighlightEvent.isUrlReference("Bitcoin")) + assertFalse(HighlightEvent.isUrlReference("")) + } + + @Test + fun builderKeepsATextReferenceVerbatim() = + runTest { + val event = HighlightEvent.create(quote = "a passage", url = " Dune, chapter 3 ", signer = signer) + assertContentEquals(arrayOf("r", "Dune, chapter 3"), event.tags.first { it[0] == "r" }) + assertEquals("Dune, chapter 3", event.inReference()) + } + + @Test + fun builderStillNormalizesAWebReference() = + runTest { + val event = HighlightEvent.create(quote = "a passage", url = "example.com/post", signer = signer) + assertEquals("https://example.com/post", event.inUrl()) + } + + @Test + fun builderMarksTheSourceOfAQuoteHighlight() = + runTest { + val event = HighlightEvent.create(quote = "a passage", url = "https://example.com/post", comment = "so true", signer = signer) + assertContentEquals(arrayOf("r", "https://example.com/post", "source"), event.tags.first { it[0] == "r" }) + } + + @Test + fun builderWritesExternalIds() = + runTest { + val event = HighlightEvent.create(quote = "a passage", externalIds = listOf(BookId("978-0765382030")), signer = signer) + assertContentEquals(arrayOf("i", "isbn:9780765382030"), event.tags.first { it[0] == "i" }) + assertEquals("isbn:9780765382030", event.inExternalIds().single().toScope()) + } +} From 519785b1c95d01a58ee7eab39747cb6534299a20 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 20:13:16 +0000 Subject: [PATCH 15/24] feat: NIP-51 favorite follow sets, private-only 10013, set pointers in 10012/10015 - Kind 10021 "Favorite follow sets" (`a` pointers to kind:30000, public or NIP-44 private), modelled on the 10090 favorite feeds list and registered in EventFactory/KindNames/EventCache. The account loads and tracks it, the follow-set card gets a star toggle, and favorited sets - other people's included - show up as people-list feeds in the top-nav picker. - Kind 10013 private relays MUST be NIP-44 private: updateRelayList now moves plain relay tags left by other clients into the encrypted content, and the misleading build() (which wrote "public" relays through a builder named privateRelays) now takes one list and encrypts it; the unused builder extension is removed. It was already published to the NIP-65 write relays. - Kind 10012 reads `a` pointers to kind:30002 relay sets and kind 10015 to kind:30015 interest sets; relay/hashtag edits keep them (tested). - The sync InterestListEvent.create swapped public and private hashtags. - Kind 30008 badge sets: title/image/description and isLegacyProfileBadges() on the existing class (it already parses the a/e pairs of any d tag). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc --- .../vitorpamplona/amethyst/model/Account.kt | 9 + .../FavoriteFollowSetsListState.kt | 107 +++++++++++ .../FilterAccountInfoAndListsFromKey.kt | 3 + .../ui/note/types/FavoriteFollowSetToggle.kt | 89 +++++++++ .../amethyst/ui/note/types/PeopleList.kt | 29 +-- .../amethyst/ui/screen/TopNavFilterState.kt | 20 +- .../ui/screen/loggedIn/AccountViewModel.kt | 4 + .../commons/model/cache/EventCache.kt | 2 + .../FavoriteFollowSetsListDecryptionCache.kt | 36 ++++ .../composeResources/values/strings.xml | 2 + .../vitorpamplona/quartz/kinds/KindNames.kt | 2 + .../PrivateOutboxRelayListEvent.kt | 26 ++- .../FavoriteFollowSetsListEvent.kt | 181 ++++++++++++++++++ .../FavoriteFollowSetsListExt.kt | 48 +++++ .../interestList/InterestListEvent.kt | 10 +- .../nip51Lists/interestList/TagArrayExt.kt | 5 + .../relayLists/FavoriteRelayListEvent.kt | 9 + .../relayLists/tags/TagArrayBuilderExt.kt | 3 - .../nip51Lists/relayLists/tags/TagArrayExt.kt | 5 + .../accepted/AcceptedBadgeSetEvent.kt | 18 ++ .../quartz/utils/EventFactory.kt | 2 + .../SetPointersInStandardListsTest.kt | 96 ++++++++++ .../FavoriteFollowSetsListEventTest.kt | 96 ++++++++++ .../relayLists/RelayListPublicEntriesTest.kt | 21 +- .../quartz/nip58Badges/BadgeSetEventTest.kt | 61 ++++++ 25 files changed, 850 insertions(+), 34 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteFollowSetsLists/FavoriteFollowSetsListState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FavoriteFollowSetToggle.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/favoriteFollowSetsLists/FavoriteFollowSetsListDecryptionCache.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteFollowSetsList/FavoriteFollowSetsListEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteFollowSetsList/FavoriteFollowSetsListExt.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/SetPointersInStandardListsTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteFollowSetsList/FavoriteFollowSetsListEventTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip58Badges/BadgeSetEventTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 5f837dbb83..9b08bff1d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -76,6 +76,7 @@ import com.vitorpamplona.amethyst.commons.model.nip51Lists.blockPeopleList.Block import com.vitorpamplona.amethyst.commons.model.nip51Lists.blockedRelays.BlockedRelayListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListDecryptionCache +import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteFollowSetsLists.FavoriteFollowSetsListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteRelays.FavoriteRelayListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip51Lists.followSets.FollowSetDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip51Lists.geohashLists.GeohashListDecryptionCache @@ -149,6 +150,7 @@ import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayLis import com.vitorpamplona.amethyst.model.nip51Lists.bookmarkSets.BookmarkSetsState import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState import com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListState +import com.vitorpamplona.amethyst.model.nip51Lists.favoriteFollowSetsLists.FavoriteFollowSetsListState import com.vitorpamplona.amethyst.model.nip51Lists.favoriteRelays.FavoriteRelayListState import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSetsState import com.vitorpamplona.amethyst.model.nip51Lists.followSets.StarterPacksState @@ -765,6 +767,9 @@ class Account( val favoriteAlgoFeedsList = FavoriteAlgoFeedsListState(signer, cache, favoriteAlgoFeedsListDecryptionCache, scope, settings) val favoriteAlgoFeedsOrchestrator = FavoriteAlgoFeedsOrchestrator(this, scope) + val favoriteFollowSetsListDecryptionCache = FavoriteFollowSetsListDecryptionCache(signer) + val favoriteFollowSetsList = FavoriteFollowSetsListState(signer, cache, favoriteFollowSetsListDecryptionCache, scope) + val geohashListDecryptionCache = GeohashListDecryptionCache(signer) val geohashList = GeohashListState(signer, cache, geohashListDecryptionCache, scope, settings) @@ -2136,6 +2141,10 @@ class Account( fun isFavoriteAlgoFeed(dvm: Address): Boolean = favoriteAlgoFeedsList.flow.value.contains(dvm) + suspend fun followFavoriteFollowSet(followSet: AddressBookmark) = sendMyPublicAndPrivateOutbox(favoriteFollowSetsList.follow(followSet)) + + suspend fun unfollowFavoriteFollowSet(followSet: Address) = sendMyPublicAndPrivateOutbox(favoriteFollowSetsList.unfollow(followSet)) + suspend fun followGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.follow(geohash)) suspend fun unfollowGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.unfollow(geohash)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteFollowSetsLists/FavoriteFollowSetsListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteFollowSetsLists/FavoriteFollowSetsListState.kt new file mode 100644 index 0000000000..e7b8309762 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteFollowSetsLists/FavoriteFollowSetsListState.kt @@ -0,0 +1,107 @@ +/* + * 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.amethyst.model.nip51Lists.favoriteFollowSetsLists + +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.NoteState +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteFollowSetsLists.FavoriteFollowSetsListDecryptionCache +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark +import com.vitorpamplona.quartz.nip51Lists.favoriteFollowSetsList.FavoriteFollowSetsListEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest + +/** + * The account's NIP-51 kind 10021 "Favorite follow sets": kind:30000 follow sets, the user's own + * or anyone else's, pinned so they show up as feeds in the top-nav picker. + */ +class FavoriteFollowSetsListState( + val signer: NostrSigner, + val cache: LocalCache, + val decryptionCache: FavoriteFollowSetsListDecryptionCache, + val scope: CoroutineScope, +) { + // Creates a long-term reference for this note so that the GC doesn't collect the note itself + val favoriteFollowSetsListNote = cache.getOrCreateAddressableNote(getFavoriteFollowSetsListAddress()) + + fun getFavoriteFollowSetsListAddress() = FavoriteFollowSetsListEvent.createAddress(signer.pubKey) + + fun getFavoriteFollowSetsListFlow(): StateFlow = favoriteFollowSetsListNote.flow().metadata.stateFlow + + fun getFavoriteFollowSetsList(): FavoriteFollowSetsListEvent? = favoriteFollowSetsListNote.event as? FavoriteFollowSetsListEvent + + suspend fun favoriteFollowSets(note: Note): Set
{ + val event = note.event as? FavoriteFollowSetsListEvent ?: return emptySet() + return decryptionCache.favoriteFollowSets(event) + } + + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow> = + getFavoriteFollowSetsListFlow() + .transformLatest { noteState -> + emit(favoriteFollowSets(noteState.note)) + }.onStart { + emit(favoriteFollowSets(favoriteFollowSetsListNote)) + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + val flowNotes: StateFlow> = + flow + .map { addresses -> + addresses.map { cache.getOrCreateAddressableNote(it) } + }.onStart { + emit(flow.value.map { cache.getOrCreateAddressableNote(it) }) + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + suspend fun follow(followSet: AddressBookmark): FavoriteFollowSetsListEvent { + val list = getFavoriteFollowSetsList() + return if (list == null) { + FavoriteFollowSetsListEvent.create(followSet, false, signer) + } else { + FavoriteFollowSetsListEvent.add(list, followSet, false, signer) + } + } + + suspend fun unfollow(followSet: Address): FavoriteFollowSetsListEvent? { + val list = getFavoriteFollowSetsList() ?: return null + return FavoriteFollowSetsListEvent.remove(list, followSet, signer) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt index d4c69458f8..877aefd589 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.nip38UserStatus.UserStatusEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteFollowSetsList.FavoriteFollowSetsListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListEvent import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent @@ -85,6 +86,8 @@ val AccountInfoAndListsFromKeyKinds2 = Bolt12OfferListEvent.KIND, FavoriteRelayListEvent.KIND, InterestSetEvent.KIND, + // NIP-51 kind 10021: follow sets (anyone's) pinned as feeds in the top-nav picker. + FavoriteFollowSetsListEvent.KIND, // NIP-51 "simple groups" list (kind 10009): the user's joined NIP-29 groups + servers. // Loaded up-front so "My Groups" and group memberships resolve immediately at login, // without waiting for the groups screen to mount its own subscription. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FavoriteFollowSetToggle.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FavoriteFollowSetToggle.kt new file mode 100644 index 0000000000..6979d181a4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FavoriteFollowSetToggle.kt @@ -0,0 +1,89 @@ +/* + * 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.amethyst.ui.note.types + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.add_follow_set_to_favorites +import com.vitorpamplona.amethyst.commons.resources.remove_follow_set_from_favorites +import com.vitorpamplona.amethyst.commons.ui.components.ClickableBox +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark + +/** + * Star toggle that adds a kind:30000 follow set (anyone's) to the account's NIP-51 kind 10021 + * favorite follow sets, which puts it in the top-nav feed picker; tapping again removes it. + */ +@Composable +fun FavoriteFollowSetToggle( + followSetNote: AddressableNote, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, + iconSizeModifier: Modifier = Size20Modifier, +) { + if (!accountViewModel.isWriteable()) return + + val favorites by accountViewModel.account.favoriteFollowSetsList.flow + .collectAsStateWithLifecycle() + + val isFavorite = favorites.contains(followSetNote.address) + + ClickableBox( + modifier = modifier, + onClick = { + if (isFavorite) { + accountViewModel.unfollowFavoriteFollowSet(followSetNote.address) + } else { + accountViewModel.followFavoriteFollowSet( + AddressBookmark( + address = followSetNote.address, + relayHint = followSetNote.relayHintUrl(), + ), + ) + } + }, + ) { + if (isFavorite) { + Icon( + symbol = MaterialSymbols.Star, + contentDescription = stringRes(Res.string.remove_follow_set_from_favorites), + modifier = iconSizeModifier, + tint = MaterialTheme.colorScheme.primary, + ) + } else { + Icon( + symbol = MaterialSymbols.StarBorder, + contentDescription = stringRes(Res.string.add_follow_set_to_favorites), + modifier = iconSizeModifier, + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt index 1f5a19840b..e567b43ac5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav @@ -83,17 +84,23 @@ fun DisplayPeopleList( val name by remember(noteEvent) { derivedStateOf { "#${noteEvent.titleOrName() ?: noteEvent.dTag()}" } } - Text( - text = name, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .fillMaxWidth() - .padding(5.dp), - textAlign = TextAlign.Center, - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = name, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .weight(1f) + .padding(5.dp), + textAlign = TextAlign.Center, + ) + + (baseNote as? AddressableNote)?.let { + FavoriteFollowSetToggle(it, accountViewModel) + } + } LaunchedEffect(noteEvent) { accountViewModel.loadUsers(noteEvent.taggedUserIds()) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index 04e304dcee..5e1b5da3ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -140,6 +140,7 @@ class TopNavFilterState( fun mergePeopleLists( peopleLists: List, followLists: List, + favoriteFollowSets: List = emptyList(), ): List { val peopleListsDefs = peopleLists.map { @@ -157,19 +158,36 @@ class TopNavFilterState( ) } - return (peopleListsDefs + followListsDefs).sortedBy { it.name.name() } + // NIP-51 kind 10021: follow sets the user favorited, including other people's. The + // user's own sets are already listed above, so those are not repeated. + val listed = peopleLists.mapTo(HashSet()) { it.address } + val favoriteDefs = + favoriteFollowSets.mapNotNull { + if (it.address in listed) { + null + } else { + FeedDefinition( + TopFilter.PeopleList(it.address), + PeopleListName(it), + ) + } + } + + return (peopleListsDefs + followListsDefs + favoriteDefs).sortedBy { it.name.name() } } val livePeopleListsFlow: Flow> = combine( account.followSets.peopleListNotes, account.starterPacks.starterPackNotes, + account.favoriteFollowSetsList.flowNotes, ::mergePeopleLists, ).onStart { emit( mergePeopleLists( account.followSets.peopleListNotes.value, account.starterPacks.starterPackNotes.value, + account.favoriteFollowSetsList.flowNotes.value, ), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 8f49870418..7fc0981a6e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1935,6 +1935,10 @@ class AccountViewModel( fun unfollowFavoriteAlgoFeed(dvm: Address) = launchSigner { account.unfollowFavoriteAlgoFeed(dvm) } + fun followFavoriteFollowSet(followSet: AddressBookmark) = launchSigner { account.followFavoriteFollowSet(followSet) } + + fun unfollowFavoriteFollowSet(followSet: Address) = launchSigner { account.unfollowFavoriteFollowSet(followSet) } + fun refreshFavoriteAlgoFeed(dvm: Address) = account.favoriteAlgoFeedsOrchestrator.refresh(dvm) fun followRelayFeed(url: NormalizedRelayUrl) = launchSigner { account.followRelayFeed(url) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt index 8a7ed5cdd6..992eb25bcf 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt @@ -291,6 +291,7 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkSet.BookmarkSetEvent import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteFollowSetsList.FavoriteFollowSetsListEvent import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListEvent @@ -3864,6 +3865,7 @@ open class EventCache : is LiveChessDrawOfferEvent, is InterestListEvent, is FavoriteAlgoFeedsListEvent, + is FavoriteFollowSetsListEvent, is IndexerRelayListEvent, is InteractiveStoryPrologueEvent, is InteractiveStorySceneEvent, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/favoriteFollowSetsLists/FavoriteFollowSetsListDecryptionCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/favoriteFollowSetsLists/FavoriteFollowSetsListDecryptionCache.kt new file mode 100644 index 0000000000..b625d90e31 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/favoriteFollowSetsLists/FavoriteFollowSetsListDecryptionCache.kt @@ -0,0 +1,36 @@ +/* + * 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.amethyst.commons.model.nip51Lists.favoriteFollowSetsLists + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache +import com.vitorpamplona.quartz.nip51Lists.favoriteFollowSetsList.FavoriteFollowSetsListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteFollowSetsList.favoriteFollowSetsSet + +class FavoriteFollowSetsListDecryptionCache( + val signer: NostrSigner, +) { + val cachedPrivateLists = PrivateTagArrayEventCache(signer) + + fun cachedFavoriteFollowSets(event: FavoriteFollowSetsListEvent) = cachedPrivateLists.mergeTagListPrecached(event).favoriteFollowSetsSet() + + suspend fun favoriteFollowSets(event: FavoriteFollowSetsListEvent) = cachedPrivateLists.mergeTagList(event).favoriteFollowSetsSet() +} diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 5ad1e808a5..7bb901c4ae 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -2474,6 +2474,8 @@ Select an option to filter the feed Add feed algorithm to favorites Remove from favorites + Add follow set to favorite feeds + Remove follow set from favorite feeds Feed algorithms you star appear here and as filter chips on the Home feed. Pin your favorite algorithms Tap "%1$s" below to browse algorithms. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt index 5e75663ce5..588e9782b7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt @@ -163,6 +163,7 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkSet.BookmarkSetEvent import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteFollowSetsList.FavoriteFollowSetsListEvent import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.gitAuthorList.GitAuthorListEvent @@ -607,6 +608,7 @@ object KindNames { EncryptionKeyListEvent.KIND to KindName("Encryption Keys", null), KeyPackageRelayListEvent.KIND to KindName("MLS KeyPackage Relays", null), FavoriteAlgoFeedsListEvent.KIND to KindName("Favorite Feeds", "51"), + FavoriteFollowSetsListEvent.KIND to KindName("Favorite Follow Sets", "51"), GoodWikiAuthorListEvent.KIND to KindName("Wiki Authors", "51"), GoodWikiRelayListEvent.KIND to KindName("Wiki Relays", "51"), UserGraspListEvent.KIND to KindName("GRASP Servers", "34"), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip37Drafts/privateOutbox/PrivateOutboxRelayListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip37Drafts/privateOutbox/PrivateOutboxRelayListEvent.kt index 9734cb1d4f..765ba27086 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip37Drafts/privateOutbox/PrivateOutboxRelayListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip37Drafts/privateOutbox/PrivateOutboxRelayListEvent.kt @@ -38,12 +38,14 @@ import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayListDiff import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.RelayTag -import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.privateRelays -import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relays import com.vitorpamplona.quartz.nip51Lists.remove -import com.vitorpamplona.quartz.nip51Lists.splitRelayListUpdate import com.vitorpamplona.quartz.utils.TimeUtils +/** + * NIP-51 / NIP-37 kind 10013, "Private relays": where the user keeps drafts and other private + * data. NIP-51 requires these relays to be NIP-44 encrypted, so every relay this class writes goes + * into the private content. [publicRelays] still reads plain tags other clients may have left. + */ @Immutable class PrivateOutboxRelayListEvent( id: HexKey, @@ -83,12 +85,10 @@ class PrivateOutboxRelayListEvent( ): PrivateOutboxRelayListEvent { val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() - // Keeps public relays public and private relays private: rewriting them all as - // private tags blanks the list out for clients that only read the plain tags. - val split = splitRelayListUpdate(earlierVersion.tags.relays(), privateTags.relays(), relays) - - val publicTags = earlierVersion.tags.remove(RelayTag::match).plus(split.publicRelays.map { RelayTag.assemble(it) }) - val newPrivateTags = privateTags.remove(RelayTag::match).plus(split.privateRelays.map { RelayTag.assemble(it) }) + // Unlike the other relay lists, 10013 relays MUST be private (NIP-51): plain relay + // tags another client left behind are moved into the encrypted content, not kept public. + val publicTags = earlierVersion.tags.remove(RelayTag::match) + val newPrivateTags = privateTags.remove(RelayTag::match).plus(relays.map { RelayTag.assemble(it) }) return signer.signNip51List(createdAt, KIND, publicTags, newPrivateTags) } @@ -111,19 +111,17 @@ class PrivateOutboxRelayListEvent( return signer.signNip51List(createdAt, KIND, emptyArray(), privateTagArray) } + /** Builds the list with every relay NIP-44 encrypted in the content, as NIP-51 requires. */ suspend fun build( - publicRelays: List = emptyList(), - privateRelays: List = emptyList(), + relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate( kind = KIND, - description = PrivateTagsInContent.encryptNip44(privateRelays.map { RelayTag.assemble(it) }.toTypedArray(), signer), + description = PrivateTagsInContent.encryptNip44(relays.map { RelayTag.assemble(it) }.toTypedArray(), signer), createdAt = createdAt, ) { - privateRelays(publicRelays) - initializer() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteFollowSetsList/FavoriteFollowSetsListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteFollowSetsList/FavoriteFollowSetsListEvent.kt new file mode 100644 index 0000000000..fde30eb82f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteFollowSetsList/FavoriteFollowSetsListEvent.kt @@ -0,0 +1,181 @@ +/* + * 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.nip51Lists.favoriteFollowSetsList + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-51 kind 10021, "Favorite follow sets": `a` pointers to kind:30000 follow sets, the + * user's own or anyone else's, that the user wants to keep at hand (e.g. as feeds). + * Like the other standard lists, entries can be public tags or NIP-44 private tags. + */ +@Immutable +class FavoriteFollowSetsListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + // A plain replaceable (10000..19999): a stray `d` tag must not split its address. + override fun dTag() = FIXED_D_TAG + + override fun diffFrom(older: Event): FavoriteFollowSetsListDiff? { + if (older !is FavoriteFollowSetsListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null + return FavoriteFollowSetsListDiff( + ListDiff.of(older.publicFavoriteFollowSets(), publicFavoriteFollowSets(), { it.address }, { a, b -> a.relayHint == b.relayHint }), + privateItemsChangeFrom(older), + ) + } + + fun publicFavoriteFollowSets(): List = tags.favoriteFollowSetBookmarks() + + suspend fun privateFavoriteFollowSets(signer: NostrSigner): List? = privateTags(signer)?.favoriteFollowSetBookmarks() + + companion object { + const val KIND = 10021 + const val FIXED_D_TAG = "" + + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) + + suspend fun create( + followSet: AddressBookmark, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteFollowSetsListEvent = + if (isPrivate) { + create(publicFollowSets = emptyList(), privateFollowSets = listOf(followSet), signer = signer, createdAt = createdAt) + } else { + create(publicFollowSets = listOf(followSet), privateFollowSets = emptyList(), signer = signer, createdAt = createdAt) + } + + suspend fun add( + earlierVersion: FavoriteFollowSetsListEvent, + followSet: AddressBookmark, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteFollowSetsListEvent = + if (isPrivate) { + val privateTags = + earlierVersion.privateTags(signer) + ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + tags = earlierVersion.tags, + privateTags = privateTags.remove(followSet.toTagIdOnly()) + followSet.toTagArray(), + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.remove(followSet.toTagIdOnly()) + followSet.toTagArray(), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun remove( + earlierVersion: FavoriteFollowSetsListEvent, + followSet: Address, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteFollowSetsListEvent { + val idOnly = AddressBookmark.assemble(followSet, null) + val privateTags = earlierVersion.privateTags(signer) + return if (privateTags != null) { + resign( + privateTags = privateTags.remove(idOnly), + tags = earlierVersion.tags.remove(idOnly), + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.remove(idOnly), + signer = signer, + createdAt = createdAt, + ) + } + } + + suspend fun resign( + tags: TagArray, + privateTags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = resign( + content = PrivateTagsInContent.encryptNip44(privateTags, signer), + tags = tags, + signer = signer, + createdAt = createdAt, + ) + + suspend fun resign( + content: String, + tags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteFollowSetsListEvent = signer.sign(createdAt, KIND, tags, content) + + suspend fun create( + publicFollowSets: List = emptyList(), + privateFollowSets: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteFollowSetsListEvent = signer.sign(build(publicFollowSets, privateFollowSets, signer, createdAt)) + + suspend fun build( + publicFollowSets: List = emptyList(), + privateFollowSets: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip44(privateFollowSets.map { it.toTagArray() }.toTypedArray(), signer), + createdAt = createdAt, + ) { + favoriteFollowSets(publicFollowSets) + + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteFollowSetsList/FavoriteFollowSetsListExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteFollowSetsList/FavoriteFollowSetsListExt.kt new file mode 100644 index 0000000000..a633a6f94b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteFollowSetsList/FavoriteFollowSetsListExt.kt @@ -0,0 +1,48 @@ +/* + * 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.nip51Lists.favoriteFollowSetsList + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.diff.ContentChange +import com.vitorpamplona.quartz.nip01Core.diff.EventDiff +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark +import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent + +/** `a` pointers to kind:30000 follow sets; pointers to any other kind are not follow sets and are skipped. */ +fun TagArray.favoriteFollowSetBookmarks() = mapNotNull { tag -> AddressBookmark.parse(tag)?.takeIf { it.address.kind == FollowSetEvent.KIND } } + +fun TagArray.favoriteFollowSetsSet() = mapNotNullTo(mutableSetOf()) { tag -> AddressBookmark.parseAddress(tag)?.takeIf { it.kind == FollowSetEvent.KIND } } + +fun TagArrayBuilder.favoriteFollowSet(followSet: AddressBookmark) = add(followSet.toTagArray()) + +fun TagArrayBuilder.favoriteFollowSets(followSets: List) = addAll(followSets.map { it.toTagArray() }) + +/** Changes to the favorite follow sets: public ones as parsed bookmarks, private ones as a whole. */ +@Immutable +class FavoriteFollowSetsListDiff( + val followSets: ListDiff, + val privateItems: ContentChange, +) : EventDiff { + override fun removesData() = privateItems.publicRemovalsAreLoss(followSets.hasRemovals()) || privateItems.isRemoval() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/interestList/InterestListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/interestList/InterestListEvent.kt index eaac7eaab7..8933fa48cd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/interestList/InterestListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/interestList/InterestListEvent.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List import com.vitorpamplona.quartz.nip51Lists.removeAny @@ -60,6 +61,11 @@ class InterestListEvent( fun publicHashtags() = tags.mapNotNull(HashtagTag::parse) + /** NIP-51: besides hashtags, kind 10015 can point (`a`) to kind:30015 interest sets. */ + fun publicInterestSets(): List = tags.interestSetPointers() + + suspend fun privateInterestSets(signer: NostrSigner): List? = privateTags(signer)?.interestSetPointers() + companion object { const val KIND = 10015 const val FIXED_D_TAG = "" @@ -194,8 +200,8 @@ class InterestListEvent( signer: NostrSignerSync, createdAt: Long = TimeUtils.now(), ): InterestListEvent { - val privateTagArray = publicHashtags.map { HashtagTag.assemble(it) }.toTypedArray() - val publicTagArray = privateHashtags.map { HashtagTag.assemble(it) }.toTypedArray() + val publicTagArray = publicHashtags.map { HashtagTag.assemble(it) }.toTypedArray() + val privateTagArray = privateHashtags.map { HashtagTag.assemble(it) }.toTypedArray() return signer.signNip51List(createdAt, KIND, publicTagArray, privateTagArray) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/interestList/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/interestList/TagArrayExt.kt index 9819d231a8..463fdd46b5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/interestList/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/interestList/TagArrayExt.kt @@ -22,7 +22,12 @@ package com.vitorpamplona.quartz.nip51Lists.interestList import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark +import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent fun TagArray.hashtagList() = mapNotNull(HashtagTag::parse) fun TagArray.hashtagSet() = mapNotNullTo(mutableSetOf(), HashtagTag::parse) + +/** `a` pointers to kind:30015 interest sets, as kind 10015 may carry them. */ +fun TagArray.interestSetPointers() = mapNotNull { tag -> AddressBookmark.parse(tag)?.takeIf { it.address.kind == InterestSetEvent.KIND } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/FavoriteRelayListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/FavoriteRelayListEvent.kt index 29179f5d54..c0409803a8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/FavoriteRelayListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/FavoriteRelayListEvent.kt @@ -34,10 +34,12 @@ import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.RelayTag import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relayFeeds +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relaySetPointers import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relays import com.vitorpamplona.quartz.nip51Lists.remove import com.vitorpamplona.quartz.nip51Lists.splitRelayListUpdate @@ -64,6 +66,13 @@ class FavoriteRelayListEvent( suspend fun decryptRelays(signer: NostrSigner): List = publicRelays() + (decryptPrivateRelays(signer) ?: emptyList()) + /** NIP-51: besides relays, kind 10012 can point (`a`) to kind:30002 relay sets. */ + fun publicRelaySets(): List = tags.relaySetPointers() + + suspend fun decryptPrivateRelaySets(signer: NostrSigner) = privateTags(signer)?.relaySetPointers() + + suspend fun decryptRelaySets(signer: NostrSigner): List = publicRelaySets() + (decryptPrivateRelaySets(signer) ?: emptyList()) + companion object { const val KIND = 10012 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayBuilderExt.kt index c2dfb98574..bbb2ba4bbd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayBuilderExt.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.quartz.nip51Lists.relayLists.tags import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent @@ -48,8 +47,6 @@ fun TagArrayBuilder.indexerRelays(relays: List.proxyRelays(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) -fun TagArrayBuilder.privateRelays(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) - fun TagArrayBuilder.relaySet(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) fun TagArrayBuilder.relayFeeds(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayExt.kt index 43cb125f91..ef4185e9c5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayExt.kt @@ -21,7 +21,12 @@ package com.vitorpamplona.quartz.nip51Lists.relayLists.tags import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark +import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent fun TagArray.relays() = mapNotNull(RelayTag::parse) fun TagArray.relaySet() = mapNotNullTo(mutableSetOf(), RelayTag::parse) + +/** `a` pointers to kind:30002 relay sets, as kind 10012 may carry them. */ +fun TagArray.relaySetPointers() = mapNotNull { tag -> AddressBookmark.parse(tag)?.takeIf { it.address.kind == RelaySetEvent.KIND } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/accepted/AcceptedBadgeSetEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/accepted/AcceptedBadgeSetEvent.kt index fe4d7be2ba..526dbb8230 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/accepted/AcceptedBadgeSetEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/accepted/AcceptedBadgeSetEvent.kt @@ -34,9 +34,17 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag +import com.vitorpamplona.quartz.nip51Lists.tags.ImageTag +import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag import com.vitorpamplona.quartz.nip58Badges.accepted.tags.AcceptedBadge import com.vitorpamplona.quartz.utils.TimeUtils +/** + * Kind 30008. NIP-58 now defines it as a NIP-51 "Badge set" (categorized groups of badges, any + * `d` tag, `a` badge definitions paired with `e` awards); the `profile_badges` d tag is the legacy + * form of the kind 10008 Profile Badges list, which is what [createAddress] points to. + */ @Immutable class AcceptedBadgeSetEvent( id: HexKey, @@ -67,6 +75,16 @@ class AcceptedBadgeSetEvent( fun badgeAwardDefinitions() = tags.badgeAwardDefinitions() + /** True for the legacy profile-badges list (`d` = `profile_badges`), false for a general badge set. */ + fun isLegacyProfileBadges() = dTag() == STANDARD_D_TAG + + /** NIP-51 set metadata of a general badge set. */ + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) + + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) + + fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse) + companion object { const val KIND = 30008 private const val STANDARD_D_TAG = "profile_badges" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index ec0c5a0a4f..e122de8707 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -263,6 +263,7 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkSet.BookmarkSetEvent import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteFollowSetsList.FavoriteFollowSetsListEvent import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.gitAuthorList.GitAuthorListEvent @@ -702,6 +703,7 @@ class EventFactory { GoodWikiRelayListEvent.KIND -> GoodWikiRelayListEvent(id, pubKey, createdAt, tags, content, sig) ZapGoalEvent.KIND -> ZapGoalEvent(id, pubKey, createdAt, tags, content, sig) FavoriteAlgoFeedsListEvent.KIND -> FavoriteAlgoFeedsListEvent(id, pubKey, createdAt, tags, content, sig) + FavoriteFollowSetsListEvent.KIND -> FavoriteFollowSetsListEvent(id, pubKey, createdAt, tags, content, sig) InterestListEvent.KIND -> InterestListEvent(id, pubKey, createdAt, tags, content, sig) HighlightEvent.KIND -> HighlightEvent(id, pubKey, createdAt, tags, content, sig) HTTPAuthorizationEvent.KIND -> HTTPAuthorizationEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/SetPointersInStandardListsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/SetPointersInStandardListsTest.kt new file mode 100644 index 0000000000..6e1c50e096 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/SetPointersInStandardListsTest.kt @@ -0,0 +1,96 @@ +/* + * 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.nip51Lists + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * NIP-51: kind 10012 may point to kind:30002 relay sets and kind 10015 to kind:30015 interest + * sets. Those `a` pointers must be readable and must survive edits of the relays/hashtags. + */ +class SetPointersInStandardListsTest { + private val keyPair = "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair() + private val signer = NostrSignerInternal(keyPair) + + private val damus = RelayUrlNormalizer.normalize("wss://relay.damus.io") + private val nosLol = RelayUrlNormalizer.normalize("wss://nos.lol") + + private val relaySet = "30002:" + "a".repeat(64) + ":my-relays" + private val interestSet = "30015:" + "b".repeat(64) + ":nostr" + private val notASet = "30023:" + "b".repeat(64) + ":article" + + @Test + fun favoriteRelayListReadsAndKeepsRelaySetPointers() = + runTest { + val before = + signer.sign( + EventTemplate( + 1740669816, + FavoriteRelayListEvent.KIND, + arrayOf(arrayOf("relay", damus.url), arrayOf("a", relaySet), arrayOf("a", notASet)), + "", + ), + ) + assertEquals(listOf(Address.parse(relaySet)), before.publicRelaySets().map { it.address }) + + val after = FavoriteRelayListEvent.updateRelayList(before, listOf(damus, nosLol), signer, 1740669817) + assertEquals(listOf(Address.parse(relaySet)), after.decryptRelaySets(signer).map { it.address }) + assertEquals(setOf(damus, nosLol), after.decryptRelays(signer).toSet()) + } + + @Test + fun interestListReadsAndKeepsInterestSetPointers() = + runTest { + val before = + signer.sign( + EventTemplate( + 1740669816, + InterestListEvent.KIND, + arrayOf(arrayOf("t", "nostr"), arrayOf("a", interestSet), arrayOf("a", notASet)), + "", + ), + ) + assertEquals(listOf(Address.parse(interestSet)), before.publicInterestSets().map { it.address }) + + val added = InterestListEvent.add(before, "bitcoin", isPrivate = false, signer = signer, createdAt = 1740669817) + val removed = InterestListEvent.remove(added, "nostr", signer, 1740669818) + assertEquals(listOf(Address.parse(interestSet)), removed.publicInterestSets().map { it.address }) + assertEquals(listOf("bitcoin"), removed.publicHashtags()) + } + + @Test + fun syncInterestListCreateKeepsPublicAndPrivateApart() = + runTest { + val event = InterestListEvent.create(listOf("public"), listOf("private"), NostrSignerSync(keyPair), 1740669816) + assertEquals(listOf("public"), event.publicHashtags()) + assertEquals(listOf("private"), event.privateTags(signer)?.mapNotNull { if (it[0] == "t") it[1] else null }) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteFollowSetsList/FavoriteFollowSetsListEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteFollowSetsList/FavoriteFollowSetsListEventTest.kt new file mode 100644 index 0000000000..1d04837ba7 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteFollowSetsList/FavoriteFollowSetsListEventTest.kt @@ -0,0 +1,96 @@ +/* + * 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.nip51Lists.favoriteFollowSetsList + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark +import com.vitorpamplona.quartz.utils.EventFactory +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class FavoriteFollowSetsListEventTest { + private val signer = NostrSignerInternal("nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair()) + + private fun followSet( + pubkey: String, + dTag: String = "friends", + ) = AddressBookmark(Address(30000, pubkey, dTag)) + + @Test + fun kindMatchesSpec() { + assertEquals(10021, FavoriteFollowSetsListEvent.KIND) + assertEquals(10021, FavoriteFollowSetsListEvent.createAddress("a".repeat(64)).kind) + assertEquals("", FavoriteFollowSetsListEvent.createAddress("a".repeat(64)).dTag) + } + + @Test + fun eventFactoryBuildsTheTypedEvent() { + val event = EventFactory.create("0".repeat(64), "1".repeat(64), 1L, 10021, emptyArray(), "", "0".repeat(128)) + assertIs(event) + } + + @Test + fun addsAndRemovesPublicFollowSets() = + runTest { + val mine = followSet(signer.pubKey) + val theirs = followSet("b".repeat(64), "devs") + + val first = FavoriteFollowSetsListEvent.create(mine, isPrivate = false, signer = signer, createdAt = 1740669816) + val second = FavoriteFollowSetsListEvent.add(first, theirs, isPrivate = false, signer = signer, createdAt = 1740669817) + val dupe = FavoriteFollowSetsListEvent.add(second, theirs, isPrivate = false, signer = signer, createdAt = 1740669818) + + assertEquals(listOf(mine.address, theirs.address), dupe.publicFavoriteFollowSets().map { it.address }) + + val removed = FavoriteFollowSetsListEvent.remove(dupe, mine.address, signer, 1740669819) + assertEquals(listOf(theirs.address), removed.publicFavoriteFollowSets().map { it.address }) + } + + @Test + fun keepsPrivateFollowSetsEncrypted() = + runTest { + val secret = followSet("c".repeat(64), "secret") + val event = FavoriteFollowSetsListEvent.create(secret, isPrivate = true, signer = signer, createdAt = 1740669816) + + assertTrue(event.publicFavoriteFollowSets().isEmpty()) + assertEquals(listOf(secret.address), event.privateFavoriteFollowSets(signer)?.map { it.address }) + + val removed = FavoriteFollowSetsListEvent.remove(event, secret.address, signer, 1740669817) + assertEquals(emptyList(), removed.privateFavoriteFollowSets(signer)?.map { it.address }) + } + + @Test + fun ignoresPointersThatAreNotFollowSets() { + val tags = + arrayOf( + arrayOf("a", "30000:" + "a".repeat(64) + ":friends"), + arrayOf("a", "30002:" + "a".repeat(64) + ":relays"), + arrayOf("a", "39089:" + "a".repeat(64) + ":pack"), + ) + assertEquals(listOf("friends"), tags.favoriteFollowSetBookmarks().map { it.address.dTag }) + assertEquals(setOf(Address(30000, "a".repeat(64), "friends")), tags.favoriteFollowSetsSet()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayListPublicEntriesTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayListPublicEntriesTest.kt index 484ecb6f2f..459a1c7383 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayListPublicEntriesTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayListPublicEntriesTest.kt @@ -116,11 +116,26 @@ class RelayListPublicEntriesTest { } @Test - fun privateOutboxRelayListKeepsTheSurvivorPublic() = + fun privateOutboxRelayListMovesEveryRelayIntoThePrivateContent() = runTest { + // The exception: NIP-51 requires kind 10013 relays to be NIP-44 encrypted, so the plain + // tags another client wrote are moved into the content instead of being kept public. val before = publicListFromAnotherClient(PrivateOutboxRelayListEvent.KIND) - val after = PrivateOutboxRelayListEvent.updateRelayList(before, listOf(damus), signer, 1740669817) - assertEquals(listOf(damus), after.tags.relays()) + val after = PrivateOutboxRelayListEvent.updateRelayList(before, listOf(damus, nosLol), signer, 1740669817) + assertEquals(emptyList(), after.tags.relays()) + assertEquals(listOf(damus, nosLol), after.privateRelays(signer)) + } + + @Test + fun privateOutboxRelayListBuildsOnlyPrivateRelays() = + runTest { + val created = PrivateOutboxRelayListEvent.create(listOf(damus), signer, 1740669817) + assertEquals(emptyList(), created.tags.relays()) + assertEquals(listOf(damus), created.privateRelays(signer)) + + val built = signer.sign(PrivateOutboxRelayListEvent.build(listOf(damus), signer, 1740669817)) + assertEquals(emptyList(), built.tags.relays()) + assertEquals(listOf(damus), built.privateRelays(signer)) } @Test diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip58Badges/BadgeSetEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip58Badges/BadgeSetEventTest.kt new file mode 100644 index 0000000000..d45cb7b6ee --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip58Badges/BadgeSetEventTest.kt @@ -0,0 +1,61 @@ +/* + * 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.nip58Badges + +import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class BadgeSetEventTest { + private val issuer = "a".repeat(64) + + @Test + fun readsAGeneralNip51BadgeSet() { + val set = + AcceptedBadgeSetEvent( + "0".repeat(64), + "1".repeat(64), + 1L, + arrayOf( + arrayOf("d", "conferences"), + arrayOf("title", "Conferences"), + arrayOf("description", "Badges from events I attended"), + arrayOf("a", "30009:$issuer:nostrasia"), + arrayOf("e", "b".repeat(64)), + ), + "", + "", + ) + + assertFalse(set.isLegacyProfileBadges()) + assertEquals("Conferences", set.title()) + assertEquals("Badges from events I attended", set.description()) + assertEquals(1, set.acceptedBadges().size) + } + + @Test + fun recognizesTheLegacyProfileBadges() { + val legacy = AcceptedBadgeSetEvent("0".repeat(64), "1".repeat(64), 1L, arrayOf(arrayOf("d", "profile_badges")), "", "") + assertTrue(legacy.isLegacyProfileBadges()) + } +} From 7e2713d65a4c67fad6f056b9592a805915f9b436 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 20:13:26 +0000 Subject: [PATCH 16/24] feat: NIP-02 petnames as a display fallback and petname path resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Quartz: PetnamePath parses `~/erin/charlie`, `~npub1…/erin` and `~name@domain/erin` (only [A-Za-z0-9_] names qualify), and PetnameResolver walks it one follow list at a time through caller-supplied lookups, so it stays a pure resolver. Unit tested. - UserAssertionsState gains displayNicknameFlow/cachedDisplayNickname: the NIP-85 nickname when it has a petname, else the petname the account's own kind-3 list gives the user (indexed once per follow-list version). displayNameFlow uses it too. Username, mention, tag and chat author renderers switch to it; the nickname editor keeps the NIP-85-only flow. - The search box resolves a petname path over the follow lists already in the cache (NIP-05 roots through the NIP-05 client), next to its NIP-05 and npub resolution. No UI to set petnames. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc --- .../reqCommand/user/UserObservers.kt | 15 ++ .../amethyst/ui/components/ClickableRoute.kt | 4 +- .../amethyst/ui/components/RichTextViewer.kt | 4 +- .../amethyst/ui/note/UsernameDisplay.kt | 4 +- .../loggedIn/chats/feed/DrawAuthorInfo.kt | 4 +- .../loggedIn/search/SearchBarViewModel.kt | 25 +++ .../UserAssertionsState.kt | 71 +++++- .../nip02FollowList/petnames/PetnamePath.kt | 212 ++++++++++++++++++ .../petnames/PetnamePathTest.kt | 143 ++++++++++++ 9 files changed, 470 insertions(+), 12 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/petnames/PetnamePath.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip02FollowList/petnames/PetnamePathTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt index ba253f5585..54d6667e67 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -89,6 +89,21 @@ fun observeUserNickname( return flow.collectAsStateWithLifecycle(remember(user) { userAssertions.cachedNickname(user) }) } +/** + * The name the account knows [user] by, for display: the NIP-85 nickname, else the NIP-02 petname + * from the account's own follow list. Editing UIs should keep using [observeUserNickname]. + */ +@Composable +fun observeUserDisplayNickname( + user: User, + accountViewModel: AccountViewModel, +): State { + val userAssertions = accountViewModel.account.userAssertions + val flow = remember(user) { userAssertions.displayNicknameFlow(user) } + + return flow.collectAsStateWithLifecycle(remember(user) { userAssertions.cachedDisplayNickname(user) }) +} + @Composable fun observeUserAboutMe( user: User, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index c1029548c3..91d448980f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -55,8 +55,8 @@ import com.vitorpamplona.amethyst.commons.ui.richtext.CustomEmojiChecker import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.util.njumpLink import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserDisplayNickname import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent @@ -306,7 +306,7 @@ fun RenderUserAsClickableText( nav: INav, ) { val userState by observeUserInfo(baseUser, accountViewModel) - val nickname by observeUserNickname(baseUser, accountViewModel) + val nickname by observeUserDisplayNickname(baseUser, accountViewModel) val petName = nickname?.petName CreateClickableTextWithEmoji( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 981807d040..38e6e84d96 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -124,8 +124,8 @@ import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.commons.ui.theme.inlinePlaceholder import com.vitorpamplona.amethyst.commons.util.toShortDisplay import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserDisplayNickname import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.creators.invoice.ClinkOfferPreview @@ -982,7 +982,7 @@ private fun DisplayUserFromTag( nav: INav, ) { val meta by observeUserInfo(baseUser, accountViewModel) - val nickname by observeUserNickname(baseUser, accountViewModel) + val nickname by observeUserDisplayNickname(baseUser, accountViewModel) val petName = nickname?.petName CrossfadeIfEnabled(targetState = meta, label = "DisplayUserFromTag") { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt index 98fd7a4478..fbf94432b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt @@ -40,8 +40,8 @@ import com.vitorpamplona.amethyst.commons.ui.note.UserNameText import com.vitorpamplona.amethyst.commons.ui.theme.StdButtonSizeModifier import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserDisplayNickname import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname import com.vitorpamplona.amethyst.service.tts.TextToSpeechHelper import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.utils.Log @@ -103,7 +103,7 @@ fun UsernameDisplay( accountViewModel: AccountViewModel, ) { val userMetadata by observeUserInfo(baseUser, accountViewModel) - val nickname by observeUserNickname(baseUser, accountViewModel) + val nickname by observeUserDisplayNickname(baseUser, accountViewModel) CrossfadeIfEnabled(targetState = userMetadata, modifier = weight, label = "UsernameDisplay") { // the account's own nickname for this user wins over the user's metadata; diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt index 38541c96f9..ddbc9a2cba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt @@ -38,8 +38,8 @@ import com.vitorpamplona.amethyst.commons.ui.richtext.CreateTextWithEmoji import com.vitorpamplona.amethyst.commons.ui.theme.Size20dp import com.vitorpamplona.amethyst.commons.ui.theme.Size5Modifier import com.vitorpamplona.amethyst.commons.ui.theme.isLight +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserDisplayNickname import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname import com.vitorpamplona.amethyst.ui.note.InnerUserPicture import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderUserCards import com.vitorpamplona.amethyst.ui.note.WatchUserFollows @@ -84,7 +84,7 @@ private fun WatchAndDisplayUser( nav: INav, ) { val userState by observeUserInfo(author, accountViewModel) - val nickname by observeUserNickname(author, accountViewModel) + val nickname by observeUserDisplayNickname(author, accountViewModel) val petName = nickname?.petName // A geohash message's `n` nickname wins over the (usually empty) profile of a throwaway key. val displayName = nameOverride ?: petName ?: userState?.info?.bestName() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index 0e00dd0e72..369946244e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -55,6 +55,9 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip02FollowList.petnames.PetnamePath +import com.vitorpamplona.quartz.nip02FollowList.petnames.PetnameResolver import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id import com.vitorpamplona.quartz.nip10Notes.content.findHashtags @@ -228,6 +231,12 @@ class SearchBarViewModel( searchTerm .debounce(400) .mapLatest { term -> + // NIP-02 petname path (`~/erin/charlie`, `~npub1…/erin`, `~name@domain/erin`), + // walked over the follow lists already in the cache. + PetnamePath.parse(term)?.let { path -> + return@mapLatest resolvePetnamePath(path) + } + // NIP-05 resolution: user@domain or bare .bit domain val nip05 = if (term.contains('@')) { @@ -293,6 +302,22 @@ class SearchBarViewModel( } }.flowOn(Dispatchers.IO) + private suspend fun resolvePetnamePath(path: PetnamePath): User? = + runCatching { + PetnameResolver + .resolve( + path = path, + currentUser = account.userProfile().pubkeyHex, + followListOf = { pubKey -> + (account.cache.getAddressableNoteIfExists(ContactListEvent.createAddress(pubKey))?.event as? ContactListEvent)?.tags + }, + resolveNip05 = { identifier -> + Nip05Id.parse(identifier)?.let { nip05Client.get(it)?.pubkey } + }, + )?.let { account.cache.getOrCreateUser(it) } + }.onFailure { if (it is CancellationException) throw it } + .getOrNull() + /** * The routes the box opens on its own, which is now **only** an invite link. * diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionsState.kt index 1837e2334c..c422e772a2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionsState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionsState.kt @@ -22,12 +22,15 @@ package com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip02FollowList.petnames.PetnameResolver import com.vitorpamplona.quartz.nip85TrustedAssertions.users.UserAssertionEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -59,6 +62,28 @@ class UserAssertionsState( ) { private val accountUser: User? by lazy { cache.getOrCreateUser(signer.pubKey) } + // The account's own kind-3 follow list, whose `p` tags may carry NIP-02 petnames. + private val followListNote: AddressableNote by lazy { cache.getOrCreateAddressableNote(ContactListEvent.createAddress(signer.pubKey)) } + + // pubkey -> petname for the latest follow list, rebuilt only when that list changes. + private class PetnameIndex( + val event: ContactListEvent, + val petnames: Map, + ) + + private var petnameIndex: PetnameIndex? = null + + private fun followListPetname( + followList: ContactListEvent?, + target: HexKey, + ): String? { + if (followList == null) return null + val index = + petnameIndex?.takeIf { it.event === followList } + ?: PetnameIndex(followList, PetnameResolver.petnames(followList.tags)).also { petnameIndex = it } + return index.petnames[target] + } + fun createCardAddress(target: HexKey): Address = UserAssertionEvent.createAddress(signer.pubKey, target) fun getCardNote(target: HexKey): AddressableNote = cache.getOrCreateAddressableNote(createCardAddress(target)) @@ -114,21 +139,59 @@ class UserAssertionsState( return decryptionCache.cachedNickname(card) } + /** + * The NIP-02 petname the account's own follow list (kind 3) gives [target], if any. It is the + * fallback for display when the account has no NIP-85 nickname for them. + */ + fun followListPetnameFlow(target: User): Flow = + followListNote + .flow() + .metadata + .stateFlow + .map { followListPetname(it.note.event as? ContactListEvent, target.pubkeyHex) } + .distinctUntilChanged() + + /** Synchronous counterpart of [followListPetnameFlow]. */ + fun cachedFollowListPetname(target: User): String? = followListPetname(followListNote.event as? ContactListEvent, target.pubkeyHex) + + /** + * The name the account knows [target] by, for rendering: the NIP-85 nickname when it has a + * petname, otherwise the kind-3 (NIP-02) petname. Use [nicknameFlow] instead when editing + * the nickname, which must not pick up the follow-list fallback. + */ + fun displayNicknameFlow(target: User): Flow = + combine(nicknameFlow(target), followListPetnameFlow(target), ::withFollowListFallback) + .distinctUntilChanged() + + /** Synchronous counterpart of [displayNicknameFlow], from already-decrypted data. */ + fun cachedDisplayNickname(target: User): Nickname? = withFollowListFallback(cachedNickname(target), cachedFollowListPetname(target)) + + private fun withFollowListFallback( + nickname: Nickname?, + followListPetname: String?, + ): Nickname? = + if (nickname?.petName != null || followListPetname == null) { + nickname + } else { + // The card's tags can't describe a name that didn't come from the card. + Nickname(followListPetname, nickname?.summary, EmptyTagList) + } + /** * The name to render for [target], per the NIP-81 policy: the nickname the - * account gave them wins over the profile's own display name, falling back - * to the short npub when neither exists. + * account gave them wins over the profile's own display name, then the + * account's NIP-02 follow-list petname, falling back to the short npub. */ fun displayNameFlow(target: User): Flow = combine( target.metadata().flow, - nicknameFlow(target), + displayNicknameFlow(target), ) { info, nickname -> nickname?.petName ?: info?.info?.bestName() ?: target.pubkeyDisplayHex() }.distinctUntilChanged() /** Synchronous first value for [displayNameFlow], from already-decrypted data. */ - fun cachedDisplayName(target: User): String = cachedNickname(target)?.petName ?: target.toBestDisplayName() + fun cachedDisplayName(target: User): String = cachedDisplayNickname(target)?.petName ?: target.toBestDisplayName() suspend fun petName(target: HexKey): String? = getCard(target)?.let { decryptionCache.petName(it) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/petnames/PetnamePath.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/petnames/PetnamePath.kt new file mode 100644 index 0000000000..8f2d9c064d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/petnames/PetnamePath.kt @@ -0,0 +1,212 @@ +/* + * 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.nip02FollowList.petnames + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull +import com.vitorpamplona.quartz.nip01Core.core.fastForEach +import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import kotlinx.coroutines.CancellationException + +/** Where a NIP-02 petname path starts. */ +@Immutable +sealed interface PetnameRoot { + /** `~/…`: the logged-in user's own follow list. */ + data object CurrentUser : PetnameRoot + + /** `~npub1…/…`: an absolute root by key. */ + data class PubKey( + val pubKey: HexKey, + ) : PetnameRoot + + /** `~name@domain/…`: an absolute root by NIP-05 identifier. */ + data class Nip05( + val identifier: String, + ) : PetnameRoot +} + +/** + * A NIP-02 petname path such as `~/erin/charlie`, `~npub1…/erin/charlie` or + * `~carol@names.com/erin/charlie`. + * + * Each component is looked up in the follow list (kind 3) of the profile the previous + * component resolved to: `~/erin/charlie` is whoever Erin calls `charlie`, where Erin is + * whoever the current user calls `erin`. Only petnames made of ASCII letters, digits and + * `_` take part in path resolution. + */ +@Immutable +data class PetnamePath( + val root: PetnameRoot, + val names: List, +) { + fun encode(): String { + val prefix = + when (root) { + PetnameRoot.CurrentUser -> "$PREFIX" + is PetnameRoot.PubKey -> PREFIX + NPub.create(root.pubKey) + is PetnameRoot.Nip05 -> PREFIX + root.identifier + } + return if (names.isEmpty()) prefix else prefix + SEPARATOR + names.joinToString(SEPARATOR.toString()) + } + + companion object { + const val PREFIX = '~' + const val SEPARATOR = '/' + + /** NIP-02: only ASCII letters, numbers or `_` qualify for petname resolution. */ + fun isEligible(petname: String): Boolean = + petname.isNotEmpty() && + petname.all { it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' || it == '_' } + + /** How a contact of a contact is shown in the current user's context: `~/erin/charlie`. */ + fun display(names: List): String = PREFIX + SEPARATOR.toString() + names.joinToString(SEPARATOR.toString()) + + /** Parses [input] into a path, or null when it is not a well-formed petname path. */ + fun parse(input: String): PetnamePath? { + val text = input.trim() + if (text.length < 2 || text[0] != PREFIX) return null + + val parts = text.substring(1).split(SEPARATOR) + val rootToken = parts[0] + val names = parts.drop(1) + if (!names.all(::isEligible)) return null + + val root = + when { + rootToken.isEmpty() -> PetnameRoot.CurrentUser + rootToken.startsWith("npub1") || rootToken.startsWith("nprofile1") -> PetnameRoot.PubKey(decodeKey(rootToken) ?: return null) + looksLikeNip05(rootToken) -> PetnameRoot.Nip05(rootToken) + else -> return null + } + + // `~/` alone names nobody; an absolute root on its own names the root. + if (root == PetnameRoot.CurrentUser && names.isEmpty()) return null + + return PetnamePath(root, names) + } + + private fun decodeKey(token: String): HexKey? = + try { + when (val entity = Nip19Parser.uriToRoute(token)?.entity) { + is NPub -> entity.hex + is NProfile -> entity.hex + else -> null + } + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + + private fun looksLikeNip05(token: String): Boolean { + val at = token.indexOf('@') + if (at < 0 || at == token.length - 1) return false + val domain = token.substring(at + 1) + return domain.contains('.') && !domain.contains('@') + } + } +} + +/** + * Pure NIP-02 petname lookups over follow-list tags. Knows nothing about caches or relays: + * callers hand in how to get a follow list and how to resolve a NIP-05 identifier. + */ +object PetnameResolver { + /** + * The petname [followList] gives [pubKey], if any. Any non-blank petname counts here: + * the character restriction only applies to resolving paths. + */ + fun petnameOf( + followList: TagArray, + pubKey: HexKey, + ): String? = + followList.fastFirstNotNullOfOrNull { tag -> + if (tag.size > 3 && ContactTag.isTagged(tag, pubKey) && tag[3].isNotBlank()) tag[3] else null + } + + /** + * Every petname in [followList], by pubkey (the first one wins when a key repeats). Meant to be + * built once per follow-list version and reused for every name rendered against it. + */ + fun petnames(followList: TagArray): Map { + val result = HashMap() + followList.fastForEach { tag -> + if (tag.size > 3 && ContactTag.isTagged(tag) && tag[3].isNotBlank() && tag[1] !in result) { + result[tag[1]] = tag[3] + } + } + return result + } + + /** The pubkey [followList] calls [petname] (exact, case-sensitive match; eligible names only). */ + fun findByPetname( + followList: TagArray, + petname: String, + ): HexKey? { + if (!PetnamePath.isEligible(petname)) return null + return followList.fastFirstNotNullOfOrNull { tag -> + if (tag.size > 3 && tag[3] == petname && ContactTag.isTagged(tag)) tag[1] else null + } + } + + /** + * Resolves [path] one component at a time. + * + * @param currentUser the logged-in user, required for `~/…` paths + * @param followListOf the kind-3 tags of a user, or null when unknown + * @param resolveNip05 resolves a `name@domain` root; the default resolves nothing + * @return the pubkey the path points to, or null when any step cannot be resolved + */ + suspend fun resolve( + path: PetnamePath, + currentUser: HexKey?, + followListOf: suspend (HexKey) -> TagArray?, + resolveNip05: suspend (String) -> HexKey? = { null }, + ): HexKey? { + var current: HexKey = + when (val root = path.root) { + PetnameRoot.CurrentUser -> currentUser + is PetnameRoot.PubKey -> root.pubKey + is PetnameRoot.Nip05 -> resolveNip05(root.identifier) + } ?: return null + + for (name in path.names) { + val followList = followListOf(current) ?: return null + current = findByPetname(followList, name) ?: return null + } + return current + } + + /** Parses and resolves [input]; null when it is not a petname path or does not resolve. */ + suspend fun resolve( + input: String, + currentUser: HexKey?, + followListOf: suspend (HexKey) -> TagArray?, + resolveNip05: suspend (String) -> HexKey? = { null }, + ): HexKey? { + val path = PetnamePath.parse(input) ?: return null + return resolve(path, currentUser, followListOf, resolveNip05) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip02FollowList/petnames/PetnamePathTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip02FollowList/petnames/PetnamePathTest.kt new file mode 100644 index 0000000000..3ff657f1fc --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip02FollowList/petnames/PetnamePathTest.kt @@ -0,0 +1,143 @@ +/* + * 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.nip02FollowList.petnames + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PetnamePathTest { + private val me = "a".repeat(64) + private val erin = "e".repeat(64) + private val charlie = "c".repeat(64) + private val carol = "b".repeat(64) + private val dave = "d".repeat(64) + + private fun p( + pubKey: HexKey, + petname: String? = null, + ) = if (petname == null) arrayOf("p", pubKey) else arrayOf("p", pubKey, "", petname) + + private val followLists: Map = + mapOf( + me to arrayOf(p(erin, "erin"), p(dave), p(carol, "Carol Smith")), + erin to arrayOf(p(charlie, "charlie"), p(me, "boss")), + carol to arrayOf(p(erin, "erin_2")), + ) + + private suspend fun follows(pubKey: HexKey): TagArray? = followLists[pubKey] + + @Test + fun parsesRelativePaths() { + assertEquals(PetnamePath(PetnameRoot.CurrentUser, listOf("erin", "charlie")), PetnamePath.parse("~/erin/charlie")) + assertEquals(PetnamePath(PetnameRoot.CurrentUser, listOf("erin")), PetnamePath.parse(" ~/erin ")) + } + + @Test + fun parsesAbsoluteRoots() { + val npub = NPub.create(carol) + assertEquals(PetnamePath(PetnameRoot.PubKey(carol), listOf("erin_2", "charlie")), PetnamePath.parse("~$npub/erin_2/charlie")) + assertEquals(PetnamePath(PetnameRoot.PubKey(carol), emptyList()), PetnamePath.parse("~$npub")) + assertEquals(PetnamePath(PetnameRoot.Nip05("carol@names.com"), listOf("erin", "charlie")), PetnamePath.parse("~carol@names.com/erin/charlie")) + } + + @Test + fun rejectsMalformedPaths() { + assertNull(PetnamePath.parse("erin/charlie")) + assertNull(PetnamePath.parse("~")) + assertNull(PetnamePath.parse("~/")) + assertNull(PetnamePath.parse("~/erin//charlie")) + assertNull(PetnamePath.parse("~/erin/")) + assertNull(PetnamePath.parse("~/Carol Smith")) + assertNull(PetnamePath.parse("~/josé")) + assertNull(PetnamePath.parse("~npub1notakey/erin")) + assertNull(PetnamePath.parse("~carol/erin")) + } + + @Test + fun onlyAsciiLettersDigitsAndUnderscoreAreEligible() { + assertTrue(PetnamePath.isEligible("erin_2")) + assertTrue(PetnamePath.isEligible("ERIN9")) + assertFalse(PetnamePath.isEligible("")) + assertFalse(PetnamePath.isEligible("erin-2")) + assertFalse(PetnamePath.isEligible("erin.2")) + assertFalse(PetnamePath.isEligible("Carol Smith")) + assertFalse(PetnamePath.isEligible("émile")) + } + + @Test + fun encodesBackToText() { + assertEquals("~/erin/charlie", PetnamePath(PetnameRoot.CurrentUser, listOf("erin", "charlie")).encode()) + assertEquals("~carol@names.com/erin", PetnamePath(PetnameRoot.Nip05("carol@names.com"), listOf("erin")).encode()) + assertEquals("~" + NPub.create(carol) + "/erin_2", PetnamePath(PetnameRoot.PubKey(carol), listOf("erin_2")).encode()) + assertEquals("~/erin/charlie", PetnamePath.display(listOf("erin", "charlie"))) + } + + @Test + fun resolvesOneComponentAtATime() = + runTest { + assertEquals(erin, PetnameResolver.resolve("~/erin", me, ::follows)) + assertEquals(charlie, PetnameResolver.resolve("~/erin/charlie", me, ::follows)) + assertEquals(me, PetnameResolver.resolve("~/erin/boss", me, ::follows)) + } + + @Test + fun resolvesAbsoluteRootsWithoutALoggedInUser() = + runTest { + val npub = NPub.create(carol) + assertEquals(charlie, PetnameResolver.resolve("~$npub/erin_2/charlie", null, ::follows)) + assertEquals(carol, PetnameResolver.resolve("~$npub", null, ::follows)) + assertEquals( + charlie, + PetnameResolver.resolve("~carol@names.com/erin_2/charlie", null, ::follows) { if (it == "carol@names.com") carol else null }, + ) + } + + @Test + fun failsWhenAnyStepIsMissing() = + runTest { + assertNull(PetnameResolver.resolve("~/erin", null, ::follows)) + assertNull(PetnameResolver.resolve("~/nobody", me, ::follows)) + assertNull(PetnameResolver.resolve("~/erin/charlie/x", me, ::follows)) // charlie's list is unknown + assertNull(PetnameResolver.resolve("~/Erin", me, ::follows)) // exact match + assertNull(PetnameResolver.resolve("~carol@names.com/erin_2", null, ::follows)) // no NIP-05 resolver + assertNull(PetnameResolver.resolve("not a path", me, ::follows)) + } + + @Test + fun petnameOfReturnsAnyPetnameForDisplay() { + val mine = followLists.getValue(me) + assertEquals("erin", PetnameResolver.petnameOf(mine, erin)) + assertEquals("Carol Smith", PetnameResolver.petnameOf(mine, carol)) + assertNull(PetnameResolver.petnameOf(mine, dave)) + assertNull(PetnameResolver.petnameOf(mine, charlie)) + // Ineligible names still display but never resolve a path. + assertNull(PetnameResolver.findByPetname(mine, "Carol Smith")) + + assertEquals(mapOf(erin to "erin", carol to "Carol Smith"), PetnameResolver.petnames(mine)) + } +} From e9ccc110cbe00ffd25df1f7fd51b7f05c5015187 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 27 Sep 2026 16:40:37 -0400 Subject: [PATCH 17/24] fix(relay): drop a kind the relay refuses by name instead of losing the whole REQ relay.us.whitenoise.chat (strfry plus a kind allowlist) CLOSES any REQ whose filter names a kind it does not serve, even when the other kinds are fine: ERROR: bad req: filter validation failed: kind not allowed: 21059 Amethyst's gift-wrap subscription asks for [1059, 21059] in one filter, so an account whose DM relay is that relay never received a gift wrap. That meant no NIP-17 DMs and no Marmot Welcomes: White Noise invites never arrived. The same relay refused 17 other kinds across other subscriptions. The pool treated each CLOSED as a refusal of the whole filter set, so the kinds it does serve were lost too. RelayReqRefusals now learns the kinds named in a CLOSED reason ("kind not allowed: N", "kinds not allowed: a, b", "kind N is not allowed"), per relay. PoolRequests narrows every REQ it builds for that relay (on change and on reconnect) with RelayReqRefusals.narrow: a refused kind is stripped, and a filter left with no kinds is dropped rather than sent empty (which would ask for every kind). Because the kind is learned before the CLOSED sub is re-decided, the refused subscription goes straight back out without it. Verified on device: after the change the tablet joined both White Noise invites that had been waiting on the relay (the White Noise app's 1:1 chat and a wn CLI group), and each kind refusal from that relay appears once instead of on every re-issue. Tests: PoolRequestsKindNotAllowedTest (immediate narrowed retry, reconnects stay narrowed, an only-refused-kind filter is not sent, other relays are unaffected; the first three failed before the change) and parser cases in RelayReqRefusalsTest. Quartz relay suites green (515). Not covered here: the same relay also CLOSES REQs with 4+ filters ("invalid number of filters: N") and advertises no max_filters in NIP-11. Co-Authored-By: Claude Opus 5.5 --- .../relay/client/pool/PoolRequests.kt | 5 +- .../relay/client/pool/RelayReqRefusals.kt | 71 +++++++- .../pool/PoolRequestsKindNotAllowedTest.kt | 154 ++++++++++++++++++ .../relay/client/pool/RelayReqRefusalsTest.kt | 14 ++ 4 files changed, 237 insertions(+), 7 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequestsKindNotAllowedTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt index 4630a5c58b..bddc058da3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt @@ -373,7 +373,7 @@ class PoolRequests( // this relay has structurally refused too many times — replaying it on // every reconnect is the doomed-REQ loop this guard exists to stop. desiredSubs.forEach { subId, perRelayFilters -> - val filters = perRelayFilters[relay] + val filters = perRelayFilters[relay]?.let { relayRefusals.narrow(relay, it) } if (!filters.isNullOrEmpty()) { val send = subState(subId).let { state -> @@ -478,7 +478,8 @@ class PoolRequests( relay: NormalizedRelayUrl, ): Command? { val oldFilters = state.currentFilters(relay) - val newFilters = desiredSubs.get(subId)?.get(relay) + // As the relay will serve them: kinds it refused by name are stripped. + val newFilters = desiredSubs.get(subId)?.get(relay)?.let { relayRefusals.narrow(relay, it) } return when { newFilters.isNullOrEmpty() -> { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayReqRefusals.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayReqRefusals.kt index 0ec74ff71d..8aa828e211 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayReqRefusals.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayReqRefusals.kt @@ -99,6 +99,7 @@ class RelayReqRefusals( relay: NormalizedRelayUrl, reason: String, ): Boolean { + learnDisallowedKinds(relay, reason) val candidate = classify(reason) ?: return false // NO_READS is the strictest verdict; once reached, nothing softens it. if (blocked[relay] == Policy.NO_READS) return false @@ -126,14 +127,60 @@ class RelayReqRefusals( relay: NormalizedRelayUrl, filters: List, ): Boolean = - when (blocked[relay]) { - Policy.NO_READS -> true - Policy.SEARCH_ONLY -> filters.isNotEmpty() && filters.all { it.search.isNullOrEmpty() } - null -> false - } + // Everything this REQ asks for is a kind the relay refused. + (filters.isNotEmpty() && narrow(relay, filters).isEmpty()) || + when (blocked[relay]) { + Policy.NO_READS -> true + Policy.SEARCH_ONLY -> filters.isNotEmpty() && filters.all { it.search.isNullOrEmpty() } + null -> false + } fun blockedRelays(): Map = blocked.snapshot() + // Kinds a relay has said it will not serve at all ("kind not allowed: 21059"). + private val disallowedKinds = ConcurrentMap>() + + /** + * Learn the kinds a relay named as not allowed in a CLOSED reason. + * + * A relay with a kind allowlist refuses the WHOLE REQ over one kind it doesn't + * serve, so the kinds it does serve in that filter are lost with it. The message + * names the kind, so one refusal is enough to learn it: nothing is guessed, and + * [narrow] strips exactly that kind for exactly this relay. + */ + private fun learnDisallowedKinds( + relay: NormalizedRelayUrl, + reason: String, + ) { + val kinds = parseDisallowedKinds(reason) + if (kinds.isEmpty()) return + disallowedKinds.merge(relay, kinds) { old, new -> old + new } + } + + /** + * [filters] as [relay] will actually serve them: kinds the relay refused are + * removed. A filter whose kinds all got removed is dropped, never sent with an + * empty kind list, which would ask for EVERY kind. A filter with no kind list + * is left alone. + */ + fun narrow( + relay: NormalizedRelayUrl, + filters: List, + ): List { + val refused = disallowedKinds[relay] ?: return filters + return filters.mapNotNull { filter -> + val kinds = filter.kinds ?: return@mapNotNull filter + val kept = kinds.filterNot { it in refused } + when { + kept.size == kinds.size -> filter + kept.isEmpty() -> null + else -> filter.copy(kinds = kept) + } + } + } + + fun disallowedKinds(relay: NormalizedRelayUrl): Set = disallowedKinds[relay] ?: emptySet() + private fun classify(reason: String): Policy? { val t = reason.lowercase() if (SEARCH_REQUIRED_MARKERS.any { it in t }) return Policy.SEARCH_ONLY @@ -142,6 +189,20 @@ class RelayReqRefusals( } companion object { + // "kind not allowed: 21059", "kinds not allowed: 7374, 30382", "kind 21059 is not allowed" + private val KINDS_AFTER_MARKER = Regex("""kinds? (?:is |are )?not allowed:?\s*([0-9][0-9,\s]*)""") + private val KIND_BEFORE_MARKER = Regex("""kind ([0-9]+) (?:is )?not allowed""") + + fun parseDisallowedKinds(reason: String): Set { + val t = reason.lowercase() + val kinds = mutableSetOf() + KINDS_AFTER_MARKER.findAll(t).forEach { m -> + m.groupValues[1].split(',', ' ').mapNotNullTo(kinds) { it.trim().toIntOrNull() } + } + KIND_BEFORE_MARKER.findAll(t).forEach { m -> m.groupValues[1].toIntOrNull()?.let { kinds.add(it) } } + return kinds + } + // The relay only serves NIP-50 search REQs (a plain feed REQ is refused). private val SEARCH_REQUIRED_MARKERS = listOf( diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequestsKindNotAllowedTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequestsKindNotAllowedTest.kt new file mode 100644 index 0000000000..e87a6220e1 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequestsKindNotAllowedTest.kt @@ -0,0 +1,154 @@ +/* + * 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.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * A relay with a kind allowlist CLOSES any REQ that names a kind it doesn't serve, + * even when the other kinds in the filter are fine. relay.us.whitenoise.chat answers + * a `{kinds:[1059, 21059], #p:[me]}` gift-wrap REQ with + * `ERROR: bad req: filter validation failed: kind not allowed: 21059`, so an account + * whose only DM relay it is never received a DM or a Marmot Welcome: the whole + * subscription was refused because of the ephemeral kind riding along. + */ +class PoolRequestsKindNotAllowedTest { + private val relay = NormalizedRelayUrl("wss://relay.us.whitenoise.chat/") + private val other = NormalizedRelayUrl("wss://nos.lol/") + private val refusal = "ERROR: bad req: filter validation failed: kind not allowed: 21059" + + private class RecordingRelayClient( + override val url: NormalizedRelayUrl, + ) : IRelayClient { + val sent = mutableListOf() + + override fun connect() = Unit + + override fun needsToReconnect() = false + + override fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean) = Unit + + override fun isConnected() = true + + override fun sendOrConnectAndSync(cmd: Command) { + sent.add(cmd) + } + + override fun sendIfConnected(cmd: Command) { + sent.add(cmd) + } + + override fun disconnect() = Unit + } + + private fun giftWraps() = listOf(Filter(kinds = listOf(1059, 21059), tags = mapOf("p" to listOf("a".repeat(64))))) + + private fun sync( + pool: PoolRequests, + url: NormalizedRelayUrl, + ): List { + pool.onConnecting(url) + val sent = mutableListOf() + pool.syncState(url) { sent.add(it) } + return sent.filterIsInstance() + } + + @Test + fun theRefusedKindIsDroppedAndTheRestIsRequestedAgainAtOnce() = + kotlinx.coroutines.test.runTest { + val pool = PoolRequests() + pool.addOrUpdate("giftwraps", mapOf(relay to giftWraps()), null) + assertEquals( + listOf(1059, 21059), + sync(pool, relay) + .single() + .filters + .single() + .kinds, + ) + + val client = RecordingRelayClient(relay) + pool.onIncomingMessage(client, ClosedMessage("giftwraps", refusal)) + + val retry = client.sent.filterIsInstance().single() + assertEquals(listOf(1059), retry.filters.single().kinds, "the CLOSED sub comes straight back without 21059") + assertEquals(mapOf("p" to listOf("a".repeat(64))), retry.filters.single().tags, "and nothing else about it changes") + } + + @Test + fun laterReconnectsNeverOfferTheRefusedKindAgain() = + kotlinx.coroutines.test.runTest { + val pool = PoolRequests() + pool.addOrUpdate("giftwraps", mapOf(relay to giftWraps()), null) + sync(pool, relay) + pool.onIncomingMessage(RecordingRelayClient(relay), ClosedMessage("giftwraps", refusal)) + + repeat(3) { + assertEquals( + listOf(1059), + sync(pool, relay) + .single() + .filters + .single() + .kinds, + ) + } + } + + @Test + fun aFilterAskingOnlyForTheRefusedKindIsNotSent() = + kotlinx.coroutines.test.runTest { + val pool = PoolRequests() + pool.addOrUpdate("giftwraps", mapOf(relay to giftWraps()), null) + sync(pool, relay) + pool.onIncomingMessage(RecordingRelayClient(relay), ClosedMessage("giftwraps", refusal)) + + pool.addOrUpdate("ephemeral", mapOf(relay to listOf(Filter(kinds = listOf(21059)))), null) + val reqs = sync(pool, relay) + assertTrue(reqs.none { it.subId == "ephemeral" }, "a REQ with no kind left would ask for EVERY kind") + } + + @Test + fun theKindIsOnlyRefusedOnTheRelayThatRefusedIt() = + kotlinx.coroutines.test.runTest { + val pool = PoolRequests() + pool.addOrUpdate("giftwraps", mapOf(relay to giftWraps(), other to giftWraps()), null) + sync(pool, relay) + pool.onIncomingMessage(RecordingRelayClient(relay), ClosedMessage("giftwraps", refusal)) + + assertEquals( + listOf(1059, 21059), + sync(pool, other) + .single() + .filters + .single() + .kinds, + ) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayReqRefusalsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayReqRefusalsTest.kt index bed37f8339..21ca1d14e3 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayReqRefusalsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayReqRefusalsTest.kt @@ -104,4 +104,18 @@ class RelayReqRefusalsTest { assertFalse(refusals.shouldSuppress(relay, plain()), "no single class reached the threshold") } + + @Test + fun parsesTheKindsARelaySaysItDoesNotAllow() { + assertEquals(setOf(21059), RelayReqRefusals.parseDisallowedKinds("ERROR: bad req: filter validation failed: kind not allowed: 21059")) + assertEquals(setOf(7374, 30382), RelayReqRefusals.parseDisallowedKinds("blocked: kinds not allowed: 7374, 30382")) + assertEquals(setOf(4), RelayReqRefusals.parseDisallowedKinds("restricted: kind 4 is not allowed")) + } + + @Test + fun otherRefusalsNameNoKind() { + assertEquals(emptySet(), RelayReqRefusals.parseDisallowedKinds("auth-required: please authenticate")) + assertEquals(emptySet(), RelayReqRefusals.parseDisallowedKinds("error: search filter is required")) + assertEquals(emptySet(), RelayReqRefusals.parseDisallowedKinds("blocked: not allowed to read")) + } } From 9aaba7881eed8851f4bffd91f7b571117c09776a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 22:42:26 +0000 Subject: [PATCH 18/24] feat(quartz): NIP-43 relay membership engine and relay-command hook - EventCommandHandler: RelaySession offers each EVENT to an optional server-wide handler before the policy chain; a returned OK consumes it (neither stored nor fanned out). Installed via RelayServerBase.eventCommandHandler. - RelayMembershipServer: answers kind 28934 joins (signature, created_at window, reusable invite code from BanStore claims) and kind 28936 leaves (NIP-70 "-" required), and reconciles the relay-signed 13534 / 33534 / 8000 / 8001 events (plus NIP-09 deletion for removed roles) against the BanStore, with monotonic created_at for replaceables. - BanListPolicy.membersOnly: allow list gates writes even while empty. - Nip86Server: nip43Methods flag (don't advertise role/claim RPCs without a NIP-43 engine) and an afterMutation hook. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc --- .../relay/server/EventCommandHandler.kt | 50 +++ .../nip01Core/relay/server/RelayServerBase.kt | 10 + .../nip01Core/relay/server/RelaySession.kt | 21 ++ .../server/RelayMembershipServer.kt | 348 ++++++++++++++++++ .../server/BanListPolicy.kt | 13 +- .../server/Nip86Server.kt | 53 ++- .../server/RelayMembershipServerTest.kt | 172 +++++++++ .../server/Nip86ServerTest.kt | 50 +++ 8 files changed, 703 insertions(+), 14 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/EventCommandHandler.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/server/RelayMembershipServer.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/server/RelayMembershipServerTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/EventCommandHandler.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/EventCommandHandler.kt new file mode 100644 index 0000000000..cd13621328 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/EventCommandHandler.kt @@ -0,0 +1,50 @@ +/* + * 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.server + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.server.backend.RequestContext + +/** + * Answers EVENT commands that are requests *to the relay* rather than content + * *for* it — e.g. a NIP-43 join (kind 28934) or leave (kind 28936) request. + * + * [RelaySession] offers every inbound EVENT to the handler **before** the + * connection's policy chain and the store. Returning `null` means "not mine": + * the event continues down the normal path (policies, then the store). + * Returning an [OkMessage] consumes the event — the session sends that `OK` + * and the event is neither stored nor fanned out to subscribers. + * + * Because it runs ahead of the policy chain, a handler owns the whole + * validation of the events it consumes: signature (with parallel verify on, + * nothing upstream of the store has checked it), timestamps, and any + * allow/deny decision. A throw (other than cancellation) is answered with + * `OK false "error: …"`. + * + * Install one on a server with [RelayServerBase.eventCommandHandler]. + */ +fun interface EventCommandHandler { + suspend fun handle( + event: Event, + ctx: RequestContext, + ): OkMessage? +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayServerBase.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayServerBase.kt index da26993604..8e87715e29 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayServerBase.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayServerBase.kt @@ -72,6 +72,15 @@ abstract class RelayServerBase( */ var completenessHints: Boolean = false + /** + * Consumes EVENTs addressed to the relay itself (e.g. NIP-43 join/leave + * requests) before the policy chain runs — see [EventCommandHandler]. + * Applies to connections opened after it is set, so install it before + * the server starts accepting traffic. Null (the default) leaves every + * EVENT on the normal policy + store path. + */ + var eventCommandHandler: EventCommandHandler? = null + /** * Builds the per-connection policy, prepending a [LimitsPolicy] when * [limits] is set so requests are clamped/rejected before the application @@ -101,6 +110,7 @@ abstract class RelayServerBase( onClose = { connections.unregister(it.id) }, negentropySettings = negentropySettings, completenessHints = completenessHints, + commandHandler = eventCommandHandler, ), ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt index 76743461ee..7cc72adcb2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt @@ -86,6 +86,12 @@ class RelaySession( * for an unbounded filter, which an arbitrary backend need not do. */ val completenessHints: Boolean = false, + /** + * Consumes EVENTs addressed to the relay itself (e.g. NIP-43 join/leave + * requests) ahead of the [policy] chain; see [EventCommandHandler]. + * Null (the default) sends every EVENT down the normal path. + */ + private val commandHandler: EventCommandHandler? = null, ) : AutoCloseable { /** The original, string-only constructor; every frame goes to [onSend] as wire JSON. */ constructor( @@ -221,6 +227,21 @@ class RelaySession( } private suspend fun handleEvent(cmd: EventCmd) { + if (commandHandler != null) { + val handled = + try { + commandHandler.handle(cmd.event, requestContext) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + OkMessage.rejected(cmd.event.id, MachineReadablePrefix.ERROR, e.message ?: "request failed") + } + if (handled != null) { + send(handled) + return + } + } + val result = policy.accept(cmd) if (result is PolicyResult.Rejected) { send(OkMessage(cmd.event.id, false, result.reason)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/server/RelayMembershipServer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/server/RelayMembershipServer.kt new file mode 100644 index 0000000000..0dca4f2a07 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/server/RelayMembershipServer.kt @@ -0,0 +1,348 @@ +/* + * 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.nip43RelayMembers.server + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.server.EventCommandHandler +import com.vitorpamplona.quartz.nip01Core.relay.server.backend.RequestContext +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +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.nip01Core.tags.kinds.kind +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent +import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent +import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEvent +import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.claim +import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent +import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent +import com.vitorpamplona.quartz.nip43RelayMembers.list.membersWithRoles +import com.vitorpamplona.quartz.nip43RelayMembers.list.tags.RelayMember +import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent +import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole +import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRoleEvent +import com.vitorpamplona.quartz.nip43RelayMembers.roles.roleColor +import com.vitorpamplona.quartz.nip43RelayMembers.roles.roleDescription +import com.vitorpamplona.quartz.nip43RelayMembers.roles.roleLabel +import com.vitorpamplona.quartz.nip43RelayMembers.roles.roleOrder +import com.vitorpamplona.quartz.nip70ProtectedEvts.isProtected +import com.vitorpamplona.quartz.nip86RelayManagement.server.BanStore +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.math.abs +import kotlin.math.max + +/** + * Relay-side NIP-43 engine: admits kind 28934 join requests, honours kind + * 28936 leave requests, and keeps the relay-signed membership events — + * kind 13534 (member list), 33534 (role definitions), 8000 / 8001 + * (member added / removed) — in step with the [BanStore]. + * + * **Membership is the NIP-86 pubkey allow list.** A join allow-lists the + * pubkey, a leave un-allow-lists it, and `allowpubkey` / `unallowpubkey` / + * `banpubkey` over NIP-86 are membership changes too. Role definitions and + * assignments are the [BanStore]'s NIP-43 roles; kind 13534 lists every + * allow-listed pubkey followed by the ids of the roles assigned to it. + * + * **Publishing is a reconcile, not a log.** [sync] compares the [BanStore] + * with what this relay last published and signs only the difference: + * 8000 / 8001 for each pubkey that entered / left the allow list, a fresh + * 13534 when the member set or any member's roles changed, a 33534 for each + * new or edited role, and a NIP-09 kind 5 (`a` tag on the role's address) + * for each deleted role. So every mutation path — a join, an admin RPC, a + * hand-edited state file picked up at boot — converges on the same events, + * and running [sync] twice publishes nothing the second time. The + * "last published" state is read back from the relay's own store (via + * [load]) the first time [sync] runs, so a restart doesn't republish. + * + * Replaceable events (13534, 33534) are stamped `max(now, previous + 1)` so + * two changes within the same second still supersede each other instead of + * tying on `created_at` (where the lower id, not the newer event, would win). + * + * Every event is signed with [signer], which must be the key the relay + * advertises as NIP-11 `self`, and handed to [publish], which must store it + * *bypassing* the relay's write policies (it is authored by the relay) and + * fan it out to live subscribers — e.g. `NostrServer.ingest`. + * + * Join/leave requests are validated here, ahead of the policy chain (see + * [EventCommandHandler]): signature, `created_at` within + * [requestWindowSeconds] of now, a `claim` tag naming one of the + * [BanStore]'s invite codes (joins), and a NIP-70 `-` tag (leaves). Invite + * codes are **reusable** until an admin revokes them with `deleteclaim`: + * NIP-86 lists them as "invite codes currently accepted by the relay" and + * gives revocation its own method. Neither request is stored or broadcast + * — they carry the invite code. + */ +class RelayMembershipServer( + private val signer: NostrSignerSync, + val banStore: BanStore, + /** Stores a relay-signed event, bypassing write policies. Returns whether it was accepted. */ + private val publish: suspend (Event) -> Boolean, + /** Reads the relay's own store; used once to learn what was published before a restart. */ + private val load: suspend (Filter) -> List, + /** Where [requestSync] runs its background reconcile. Null makes [requestSync] a no-op. */ + scope: CoroutineScope? = null, + /** Name used in the join welcome message, e.g. the relay URL. */ + private val relayName: String? = null, + /** How far a join/leave request's `created_at` may be from now ("now, plus or minus a few minutes"). */ + val requestWindowSeconds: Long = DEFAULT_REQUEST_WINDOW_SECONDS, + private val clock: () -> Long = TimeUtils::now, +) : EventCommandHandler { + /** The relay's NIP-11 `self` pubkey — the author of every event this class publishes. */ + val selfPubKey: HexKey = signer.pubKey + + private val mutex = Mutex() + + // What this relay has published, as last seen. Guarded by [mutex]. + private var loaded = false + private var publishedMembers: List = emptyList() + private var membersCreatedAt = 0L + private val publishedRoles = HashMap() + + /** Newest `created_at` used per role address, deletions included. */ + private val roleCreatedAt = HashMap() + + private val pokes = Channel(Channel.CONFLATED) + + init { + scope?.launch { + for (poke in pokes) { + try { + sync() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w("RelayMembershipServer") { "NIP-43 republish failed: ${e.message}" } + } + } + } + } + + /** + * Schedules a [sync] on the background scope without waiting for it. + * Conflated: a burst of mutations collapses into one reconcile. Wire it + * to [BanStore]'s mutation hook so changes made outside the RPC and + * join/leave paths are published too. + */ + fun requestSync() { + pokes.trySend(Unit) + } + + override suspend fun handle( + event: Event, + ctx: RequestContext, + ): OkMessage? = + when (event.kind) { + RelayJoinRequestEvent.KIND -> join(event) + RelayLeaveRequestEvent.KIND -> leave(event) + else -> null + } + + /** Processes a kind 28934 join request and returns the `OK` to answer it with. */ + suspend fun join(event: Event): OkMessage { + validateRequest(event)?.let { return it } + + val claim = + event.tags.claim() + ?: return OkMessage.rejected(event.id, MachineReadablePrefix.RESTRICTED, "a join request needs a claim tag with an invite code.") + + if (banStore.isBanned(event.pubKey)) { + return OkMessage.rejected(event.id, MachineReadablePrefix.RESTRICTED, "you are banned from this relay.") + } + if (banStore.isAllowedPubkey(event.pubKey)) { + return OkMessage(event.id, true, MachineReadablePrefix.DUPLICATE.format("you are already a member of this relay.")) + } + if (!banStore.isValidClaim(claim)) { + return OkMessage.rejected(event.id, MachineReadablePrefix.RESTRICTED, "that is an invalid invite code.") + } + + banStore.allowPubkey(event.pubKey, "$JOIN_REASON_PREFIX$claim") + sync() + + val welcome = relayName?.let { "welcome to $it!" } ?: "welcome!" + return OkMessage(event.id, true, "info: $welcome") + } + + /** Processes a kind 28936 leave request and returns the `OK` to answer it with. */ + suspend fun leave(event: Event): OkMessage { + validateRequest(event)?.let { return it } + + if (!event.tags.isProtected()) { + return OkMessage.rejected(event.id, MachineReadablePrefix.INVALID, "a leave request must carry a NIP-70 \"-\" tag.") + } + if (!banStore.isAllowedPubkey(event.pubKey)) { + return OkMessage(event.id, true, MachineReadablePrefix.DUPLICATE.format("you are not a member of this relay.")) + } + + banStore.unallowPubkey(event.pubKey) + sync() + + return OkMessage(event.id, true, "info: you have left this relay.") + } + + private fun validateRequest(event: Event): OkMessage? { + if (!event.verify()) { + return OkMessage.rejected(event.id, MachineReadablePrefix.INVALID, "bad signature or id.") + } + if (abs(clock() - event.createdAt) > requestWindowSeconds) { + return OkMessage.rejected( + event.id, + MachineReadablePrefix.INVALID, + "created_at must be within $requestWindowSeconds seconds of the relay's clock.", + ) + } + return null + } + + /** + * Publishes whatever the [BanStore] changed since the last publish (see + * the class docs). Serialized; safe to call from any coroutine. + */ + suspend fun sync() { + mutex.withLock { + if (!loaded) { + loadPublished() + loaded = true + } + // Roles first, so a 13534 never references a role id whose + // definition hasn't been published yet. + syncRoles() + syncMembers() + } + } + + private suspend fun loadPublished() { + load(Filter(kinds = listOf(RelayMembershipListEvent.KIND), authors = listOf(selfPubKey))) + .maxByOrNull { it.createdAt } + ?.let { + publishedMembers = it.tags.membersWithRoles() + membersCreatedAt = it.createdAt + } + + load(Filter(kinds = listOf(RelayRoleEvent.KIND), authors = listOf(selfPubKey))) + .sortedBy { it.createdAt } + .forEach { event -> + val id = event.tags.dTag() + publishedRoles[id] = roleOf(id, event) + roleCreatedAt[id] = max(roleCreatedAt[id] ?: 0L, event.createdAt) + } + + // A deleted role's address stays tombstoned up to the deletion's + // created_at, so a re-created role must be stamped after it. + val rolePrefix = "${RelayRoleEvent.KIND}:$selfPubKey:" + load(Filter(kinds = listOf(DeletionRequestEvent.KIND), authors = listOf(selfPubKey))).forEach { deletion -> + deletion.tags.forEach { tag -> + val address = ATag.parseAddressId(tag) + if (address != null && address.startsWith(rolePrefix)) { + val id = address.substring(rolePrefix.length) + roleCreatedAt[id] = max(roleCreatedAt[id] ?: 0L, deletion.createdAt) + } + } + } + } + + private fun roleOf( + id: String, + event: Event, + ) = RelayRole( + id = id, + label = event.tags.roleLabel(), + description = event.tags.roleDescription(), + color = event.tags.roleColor(), + order = event.tags.roleOrder(), + ) + + private suspend fun syncRoles() { + val desired = banStore.listRoles() + val desiredIds = HashSet(desired.size) + + for (role in desired) { + desiredIds.add(role.id) + if (publishedRoles[role.id] == role) continue + val createdAt = nextCreatedAt(roleCreatedAt[role.id]) + if (publish(signer.sign(RelayRoleEvent.build(role, createdAt)))) { + publishedRoles[role.id] = role + roleCreatedAt[role.id] = createdAt + } + } + + val deleted = publishedRoles.keys.filter { it !in desiredIds } + for (id in deleted) { + val createdAt = nextCreatedAt(roleCreatedAt[id]) + val deletion = + eventTemplate(DeletionRequestEvent.KIND, "", createdAt) { + add(ATag.assemble(RelayRoleEvent.KIND, selfPubKey, id, null)) + kind(RelayRoleEvent.KIND) + } + if (publish(signer.sign(deletion))) { + publishedRoles.remove(id) + roleCreatedAt[id] = createdAt + } + } + } + + private suspend fun syncMembers() { + val desired = banStore.listAllowedPubkeys().map { (pk, _) -> RelayMember(pk, banStore.rolesOf(pk)) } + if (membersCreatedAt > 0 && desired.toSet() == publishedMembers.toSet()) return + + val before = publishedMembers.mapTo(HashSet()) { it.pubKey } + val after = desired.mapTo(HashSet()) { it.pubKey } + + // One event per pubkey, as in NIP-43's examples. + for (member in desired) { + if (member.pubKey !in before) { + publish(signer.sign(RelayAddMemberEvent.build(listOf(member.pubKey), clock()))) + } + } + for (member in publishedMembers) { + if (member.pubKey !in after) { + publish(signer.sign(RelayRemoveMemberEvent.build(listOf(member.pubKey), clock()))) + } + } + + val createdAt = nextCreatedAt(membersCreatedAt.takeIf { it > 0 }) + if (publish(signer.sign(RelayMembershipListEvent.buildWithRoles(desired, createdAt)))) { + publishedMembers = desired + membersCreatedAt = createdAt + } + } + + private fun nextCreatedAt(previous: Long?): Long = max(clock(), (previous ?: 0L) + 1) + + companion object { + /** "Now, plus or minus a few minutes." */ + const val DEFAULT_REQUEST_WINDOW_SECONDS = 300L + + /** Allow-list reason recorded for a pubkey admitted by a join request, followed by the invite code. */ + const val JOIN_REASON_PREFIX = "nip43 join with invite code: " + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanListPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanListPolicy.kt index aed433d44a..4c26b51938 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanListPolicy.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanListPolicy.kt @@ -47,9 +47,16 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult * static policies would silently diverge after the first admin call. * Geode seeds the [BanStore] from `[authorization]` at first boot * instead — see `com.vitorpamplona.geode.config.RuntimeConfig`. + * + * [membersOnly] is the NIP-43 members-only mode: the pubkey allow list + * *is* the relay's member list, so it gates writes even while it is + * empty (a fresh members-only relay is closed, not open, until someone + * joins). Off by default, which keeps the NIP-86 reading: an empty allow + * list restricts nothing. */ class BanListPolicy( val banStore: BanStore, + val membersOnly: Boolean = false, ) : PassThroughPolicy() { override fun accept(cmd: EventCmd): PolicyResult { val ev = cmd.event @@ -62,7 +69,11 @@ class BanListPolicy( if (banStore.isBanned(ev.pubKey)) { return PolicyResult.Rejected("blocked: pubkey is banned") } - if (banStore.hasAllowList() && !banStore.isAllowedPubkey(ev.pubKey)) { + if (membersOnly) { + if (!banStore.isAllowedPubkey(ev.pubKey)) { + return PolicyResult.Rejected("restricted: only members can write to this relay; request access with a NIP-43 join request") + } + } else if (banStore.hasAllowList() && !banStore.isAllowedPubkey(ev.pubKey)) { return PolicyResult.Rejected("blocked: pubkey is not on the allow list") } if (!banStore.isKindAllowed(ev.kind)) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86Server.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86Server.kt index 1a838ea773..e92e20b6dc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86Server.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86Server.kt @@ -68,10 +68,13 @@ import kotlinx.serialization.json.int * * The NIP-43 role (`createrole`, `editrole`, `deleterole`, `assignrole`, * `unassignrole`) and invite-code (`listclaims`, `createclaim`, - * `deleteclaim`) methods only maintain the [BanStore]'s records. A relay - * that publishes kind 13534 / 33534 events or admits kind 28934 join - * requests reads them from there ([BanStore.listRoles], - * [BanStore.rolesOf], [BanStore.isValidClaim]). + * `deleteclaim`) methods only maintain the [BanStore]'s records; they mean + * something only when a NIP-43 engine consumes them (e.g. + * [com.vitorpamplona.quartz.nip43RelayMembers.server.RelayMembershipServer], + * which publishes kinds 13534 / 33534 and admits kind 28934 joins). A relay + * without one passes `nip43Methods = false` so it neither advertises nor + * accepts them, and a relay with one wires [afterMutation] so the engine + * republishes before the RPC answers. */ class Nip86Server( val banStore: BanStore, @@ -107,6 +110,21 @@ class Nip86Server( * case-insensitively (lowercased on entry). */ allowList: Set = emptySet(), + /** + * Whether the NIP-43 role and invite-code methods are offered. `false` + * drops them from [supportedMethods] and answers them as unsupported — + * for relays that don't run a NIP-43 engine, where they'd be silent + * no-ops. + */ + val nip43Methods: Boolean = true, + /** + * Runs after every state-changing method has been applied, before the + * response is returned — e.g. to republish the relay-signed NIP-43 + * events so a client reading the relay right after the RPC sees the + * change. A throw turns the response into an `internal:` error, although + * the [BanStore] mutation itself stays applied. + */ + private val afterMutation: suspend () -> Unit = {}, ) { private val allowList: Set = allowList.mapTo(HashSet()) { it.lowercase() } @@ -121,7 +139,7 @@ class Nip86Server( } val supportedMethods: List = - listOf( + listOfNotNull( Nip86Method.SUPPORTED_METHODS, Nip86Method.BAN_PUBKEY, Nip86Method.UNBAN_PUBKEY, @@ -129,14 +147,14 @@ class Nip86Server( Nip86Method.ALLOW_PUBKEY, Nip86Method.UNALLOW_PUBKEY, Nip86Method.LIST_ALLOWED_PUBKEYS, - Nip86Method.CREATE_ROLE, - Nip86Method.EDIT_ROLE, - Nip86Method.DELETE_ROLE, - Nip86Method.ASSIGN_ROLE, - Nip86Method.UNASSIGN_ROLE, - Nip86Method.LIST_CLAIMS, - Nip86Method.CREATE_CLAIM, - Nip86Method.DELETE_CLAIM, + Nip86Method.CREATE_ROLE.takeIf { nip43Methods }, + Nip86Method.EDIT_ROLE.takeIf { nip43Methods }, + Nip86Method.DELETE_ROLE.takeIf { nip43Methods }, + Nip86Method.ASSIGN_ROLE.takeIf { nip43Methods }, + Nip86Method.UNASSIGN_ROLE.takeIf { nip43Methods }, + Nip86Method.LIST_CLAIMS.takeIf { nip43Methods }, + Nip86Method.CREATE_CLAIM.takeIf { nip43Methods }, + Nip86Method.DELETE_CLAIM.takeIf { nip43Methods }, Nip86Method.BAN_EVENT, Nip86Method.UNBAN_EVENT, Nip86Method.ALLOW_EVENT, @@ -152,6 +170,10 @@ class Nip86Server( Nip86Method.CHANGE_RELAY_ICON, ) + /** The [supportedMethods] that change state (everything but the queries). */ + private val mutatingMethods: Set = + supportedMethods.filterTo(HashSet()) { it != Nip86Method.SUPPORTED_METHODS && !it.startsWith("list") } + /** * Dispatches a single RPC request from [pubkey] (the caller, as * authenticated by the transport — NIP-98 over HTTP, NIP-42 over @@ -169,6 +191,9 @@ class Nip86Server( if (!isAuthorized(pubkey)) { return Nip86Response(error = "pubkey is not on the admin list") } + if (req.method !in supportedMethods) { + return Nip86Response(error = "method not supported: ${req.method}") + } return runCatching { when (req.method) { Nip86Method.SUPPORTED_METHODS -> { @@ -309,6 +334,8 @@ class Nip86Server( else -> { Nip86Response(error = "method not supported: ${req.method}") } + }.also { response -> + if (response.error == null && req.method in mutatingMethods) afterMutation() } }.getOrElse { e -> // CancellationException must propagate so structured diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/server/RelayMembershipServerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/server/RelayMembershipServerTest.kt new file mode 100644 index 0000000000..869e5daebd --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/server/RelayMembershipServerTest.kt @@ -0,0 +1,172 @@ +/* + * 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.nip43RelayMembers.server + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.server.backend.RequestContext +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent +import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEvent +import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent +import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent +import com.vitorpamplona.quartz.nip43RelayMembers.list.membersWithRoles +import com.vitorpamplona.quartz.nip43RelayMembers.list.tags.RelayMember +import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent +import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole +import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRoleEvent +import com.vitorpamplona.quartz.nip86RelayManagement.server.BanStore +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class RelayMembershipServerTest { + private val relay = NostrSignerSync(KeyPair()) + private val alice = NostrSignerSync(KeyPair()) + + private val now = 1_700_000_000L + + /** Everything the engine published, in order. */ + private val published = mutableListOf() + + private fun server( + banStore: BanStore = BanStore(), + stored: List = emptyList(), + ) = RelayMembershipServer( + signer = relay, + banStore = banStore, + publish = { + published += it + true + }, + load = { filter -> stored.filter { filter.match(it) } }, + relayName = "wss://test.relay", + clock = { now }, + ) + + private fun ofKind(kind: Int) = published.filter { it.kind == kind } + + @Test + fun joinAdmitsAValidClaimAndPublishesSignedEvents() = + runTest { + val store = BanStore().apply { createClaim("code") } + val server = server(store) + + val ok = server.join(alice.sign(RelayJoinRequestEvent.build("code", now))) + assertTrue(ok.success, ok.message) + assertEquals("info: welcome to wss://test.relay!", ok.message) + assertTrue(store.isAllowedPubkey(alice.pubKey)) + + assertTrue(published.all { it.pubKey == relay.pubKey }, "every NIP-43 event is signed by the relay's key") + assertEquals(listOf(alice.pubKey), (ofKind(RelayAddMemberEvent.KIND).single() as RelayAddMemberEvent).memberPubKeys()) + assertEquals(listOf(RelayMember(alice.pubKey)), ofKind(RelayMembershipListEvent.KIND).single().tags.membersWithRoles()) + } + + @Test + fun joinFailuresUseNip01Prefixes() = + runTest { + val store = BanStore().apply { createClaim("code") } + val server = server(store) + + assertEquals("restricted: that is an invalid invite code.", server.join(alice.sign(RelayJoinRequestEvent.build("other", now))).message) + assertTrue(server.join(alice.sign(RelayJoinRequestEvent.build("code", now - 3600))).message.startsWith("invalid:")) + val noClaim = alice.sign(now, RelayJoinRequestEvent.KIND, arrayOf(arrayOf("-")), "") + assertTrue(server.join(noClaim).message.startsWith("restricted:")) + + assertFalse(store.isAllowedPubkey(alice.pubKey)) + assertTrue(published.isEmpty()) + } + + @Test + fun leaveNeedsTheProtectedTag() = + runTest { + val store = BanStore().apply { allowPubkey(alice.pubKey) } + val server = server(store) + // The boot-time sync: publishes alice as a member (8000 + 13534). + server.sync() + assertEquals(1, ofKind(RelayAddMemberEvent.KIND).size) + + val unprotected = alice.sign(now, RelayLeaveRequestEvent.KIND, emptyArray(), "") + assertFalse(server.leave(unprotected).success) + assertTrue(store.isAllowedPubkey(alice.pubKey)) + + val ok = server.leave(alice.sign(RelayLeaveRequestEvent.build(now))) + assertTrue(ok.success) + assertFalse(store.isAllowedPubkey(alice.pubKey)) + assertEquals(listOf(alice.pubKey), (ofKind(RelayRemoveMemberEvent.KIND).single() as RelayRemoveMemberEvent).memberPubKeys()) + } + + @Test + fun syncIsIdempotentAndStampsMonotonically() = + runTest { + val store = BanStore() + val server = server(store) + + store.createRole(RelayRole("mod", label = "Moderator", color = 120, order = 1)) + server.sync() + store.editRole(RelayRole("mod", label = "Mod")) + server.sync() + server.sync() + + val roles = ofKind(RelayRoleEvent.KIND).map { it as RelayRoleEvent } + assertEquals(listOf("Moderator", "Mod"), roles.map { it.label() }) + // Same clock second, yet the edit is strictly newer, so it supersedes. + assertEquals(now + 1, roles[1].createdAt) + assertTrue(roles[1].createdAt > roles[0].createdAt) + // Three syncs, but the (empty) member list changed only once: at first publish. + assertEquals(1, ofKind(RelayMembershipListEvent.KIND).size) + } + + @Test + fun startsFromWhatTheStoreAlreadyHolds() = + runTest { + val store = BanStore().apply { allowPubkey(alice.pubKey) } + val previous = relay.sign(RelayMembershipListEvent.buildWithRoles(listOf(RelayMember(alice.pubKey)), now - 10)) + val server = server(store, stored = listOf(previous)) + + server.sync() + assertTrue(published.isEmpty(), "nothing changed since the stored 13534") + + store.unallowPubkey(alice.pubKey) + server.sync() + assertEquals(1, ofKind(RelayRemoveMemberEvent.KIND).size) + assertTrue(ofKind(RelayMembershipListEvent.KIND).single().createdAt > previous.createdAt) + } + + @Test + fun otherKindsAreNotHandled() = + runTest { + val server = server() + assertNull(server.handle(alice.sign(now, 1, emptyArray(), "hi"), ctx = NoContext)) + } + + private object NoContext : RequestContext { + override val connectionId = 0L + override val policy: IRelayPolicy = EmptyPolicy + override val authenticatedUsers = emptySet() + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86ServerTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86ServerTest.kt index 615009c503..a366fd1a43 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86ServerTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86ServerTest.kt @@ -397,4 +397,54 @@ class Nip86ServerTest { assertTrue(server.isAuthorized(admin)) assertTrue(server.isAuthorized(admin.uppercase())) } + + @Test + fun nip43MethodsOffAreNeitherAdvertisedNorDispatched() { + runBlocking { + val store = BanStore() + val holder = Holder(Nip11RelayInformation(name = "n")) + val server = Nip86Server(banStore = store, infoHolder = holder, allowList = setOf(admin), nip43Methods = false) + + val names = (server.dispatch(admin, Nip86Request.supportedMethods()).result as JsonArray).map { it.jsonPrimitive.content } + listOf( + Nip86Method.CREATE_ROLE, + Nip86Method.EDIT_ROLE, + Nip86Method.DELETE_ROLE, + Nip86Method.ASSIGN_ROLE, + Nip86Method.UNASSIGN_ROLE, + Nip86Method.LIST_CLAIMS, + Nip86Method.CREATE_CLAIM, + Nip86Method.DELETE_CLAIM, + ).forEach { assertFalse(it in names, it) } + assertTrue(Nip86Method.BAN_PUBKEY in names) + + assertNotNull(server.dispatch(admin, Nip86Request.createClaim("code")).error) + assertNotNull(server.dispatch(admin, Nip86Request.createRole("mod")).error) + assertTrue(store.listClaims().isEmpty()) + assertTrue(store.listRoles().isEmpty()) + } + } + + @Test + fun afterMutationRunsOnlyAfterSuccessfulStateChanges() { + runBlocking { + var calls = 0 + val store = BanStore() + val holder = Holder(Nip11RelayInformation(name = "n")) + val server = Nip86Server(banStore = store, infoHolder = holder, allowList = setOf(admin), afterMutation = { calls++ }) + + server.dispatch(admin, Nip86Request.supportedMethods()) + server.dispatch(admin, Nip86Request.listAllowedPubkeys()) + server.dispatch(admin, Nip86Request.listClaims()) + assertEquals(0, calls, "queries don't change state") + + server.dispatch(admin, Nip86Request.allowPubkey(pk)) + server.dispatch(admin, Nip86Request.createRole("mod")) + assertEquals(2, calls) + + // A failed mutation (unknown role) changes nothing and doesn't fire. + assertNotNull(server.dispatch(admin, Nip86Request.assignRole(pk, "ghost")).error) + assertEquals(2, calls) + } + } } From 42bff8a2ba8b10b1694fb0d0a8d47701d5e35dc2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 22:42:32 +0000 Subject: [PATCH 19/24] feat(geode): NIP-43 membership, relay identity and NIP-11 self - [identity] secret_key / secret_key_file (generated if missing, or next to [admin].state_file when membership needs a key); NIP-11 self is forced to the relay key's pubkey. - [membership] enabled: members-only writes (allow list == members), join / leave requests, relay-signed 13534 / 33534 / 8000 / 8001 stored in the relay's own store via NostrServer.ingest (bypassing write policies), republished after every NIP-86 change, NIP-11 advertises 43. Off by default; while off the role / claim RPCs are no longer advertised. - Tests for join/leave/claims/roles/persistence/identity; plan doc and README / config / cli README updates. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc --- cli/README.md | 2 +- geode/README.md | 19 +- geode/config.example.toml | 29 ++ geode/plans/2026-09-27-nip43-membership.md | 108 +++++ .../kotlin/com/vitorpamplona/geode/Main.kt | 21 +- .../com/vitorpamplona/geode/RelayEngine.kt | 146 +++++- .../geode/config/RelayIdentity.kt | 93 ++++ .../geode/config/StaticConfig.kt | 41 ++ .../geode/membership/Nip43MembershipTest.kt | 440 ++++++++++++++++++ 9 files changed, 889 insertions(+), 10 deletions(-) create mode 100644 geode/plans/2026-09-27-nip43-membership.md create mode 100644 geode/src/main/kotlin/com/vitorpamplona/geode/config/RelayIdentity.kt create mode 100644 geode/src/test/kotlin/com/vitorpamplona/geode/membership/Nip43MembershipTest.kt diff --git a/cli/README.md b/cli/README.md index a7e5518690..d226121f21 100644 --- a/cli/README.md +++ b/cli/README.md @@ -457,7 +457,7 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever` | `amy admin RELAY allow-event ID [--reason R]` / `unallow-event ID` / `list-allowed-events` / `list-needing-moderation` | Event allow list (approve an event: it also lifts any ban) and the moderation queue. | | `amy admin RELAY create-role ID [--label L] [--description D] [--color HUE] [--order N]` / `edit-role ID …` / `delete-role ID` | NIP-43 member roles (kind 33534); `--color` is a hue 0–360. | | `amy admin RELAY assign-role HEX ROLE` / `unassign-role HEX ROLE` | Give / take a role. | -| `amy admin RELAY create-claim CODE` / `delete-claim CODE` / `list-claims` | NIP-43 invite codes for kind 28934 join requests. | +| `amy admin RELAY create-claim CODE` / `delete-claim CODE` / `list-claims` | NIP-43 invite codes for kind 28934 join requests. The role and claim methods only exist on relays that run NIP-43 (geode: `[membership] enabled = true`, which then publishes the 13534 / 33534 events); elsewhere they fail with `method not supported`. | | `amy admin RELAY allow-kind N` / `disallow-kind N` / `list-allowed-kinds` / `list-disallowed-kinds` | Kind allow / deny lists. | | `amy admin RELAY block-ip IP [--reason R]` / `unblock-ip IP` / `list-blocked-ips` | IP block list. | | `amy admin RELAY change-name S` / `change-description S` / `change-icon URL` | Relay metadata. | diff --git a/geode/README.md b/geode/README.md index 4c45f710fc..7e80fb4471 100644 --- a/geode/README.md +++ b/geode/README.md @@ -97,8 +97,23 @@ geode --version Key sections: `[info]` (NIP-11 doc), `[network]` (bind + thread pools), `[database]` (SQLite path/tuning), `[options]` (AUTH / verify / search), -`[authorization]` (allow/deny lists), `[[mirror]]` (upstream mirroring), and -`[admin]` (NIP-86 management). See the example file for every knob. +`[authorization]` (allow/deny lists), `[[mirror]]` (upstream mirroring), +`[admin]` (NIP-86 management), `[identity]` (the relay's own key, NIP-11 `self`) +and `[membership]` (NIP-43). See the example file for every knob. + +## Membership (NIP-43) + +With `[membership] enabled = true` geode is a members-only relay: the NIP-86 +pubkey allow list is the member list and gates writes. Admins mint invite codes +with `createclaim` (e.g. `amy admin RELAY create-claim CODE`); a user joins by +sending a kind 28934 request carrying one (codes stay valid until +`deleteclaim`) and leaves with a kind 28936. The relay signs — with its +`[identity]` key, advertised as NIP-11 `self` — and serves kind 13534 (members +and their role ids), 33534 (roles from `createrole` / `editrole`; `deleterole` +publishes a NIP-09 deletion) and 8000 / 8001 (member added / removed), keeping +them in step with every join, leave and admin change. Off by default, and while +off the role / claim RPCs aren't offered. Design notes: +[`plans/2026-09-27-nip43-membership.md`](plans/2026-09-27-nip43-membership.md). ## Verbs diff --git a/geode/config.example.toml b/geode/config.example.toml index 4651105623..0704f6fdcc 100644 --- a/geode/config.example.toml +++ b/geode/config.example.toml @@ -183,3 +183,32 @@ require_auth = false # state is in-memory only and forgotten on every restart. Convention # is to place this next to the SQLite event-store file. # state_file = "/var/lib/geode/events.db.admin.json" + +[identity] +# The relay's own Nostr key — advertised as `self` in the NIP-11 doc and +# used to sign relay-authored events (NIP-43 member lists and roles). +# Either the key itself (nsec1… or 64-char hex; wins if both are set) … +# secret_key = "nsec1..." +# … or a file holding it, created with a fresh key (mode 0600) when +# missing. With neither set, and [membership] enabled, geode keeps a +# generated key at "<[admin].state_file>.relay-key" (in memory only, +# with a warning, when there is no state file). +# secret_key_file = "/var/lib/geode/relay.key" + +[membership] +# NIP-43 relay membership. When enabled: +# - writes are members-only; the NIP-86 pubkey allow list IS the member +# list (allowpubkey / unallowpubkey / banpubkey change membership) and +# gates writes even while empty; +# - kind 28934 join requests carrying an invite code (NIP-86 +# createclaim; codes stay valid until deleteclaim) add the author, +# kind 28936 leave requests (with a NIP-70 "-" tag) remove them; +# - the relay publishes, signed by [identity]: kind 13534 (members + +# their role ids), 33534 (roles from createrole/editrole; deleterole +# publishes a NIP-09 deletion), 8000 / 8001 (member added / removed); +# - NIP-11 advertises 43, and the NIP-86 role/claim methods are offered +# (they are not advertised at all while this is off). +# See geode/plans/2026-09-27-nip43-membership.md. +# enabled = false +# How far a join/leave request's created_at may be from the relay's clock. +# request_window_seconds = 300 diff --git a/geode/plans/2026-09-27-nip43-membership.md b/geode/plans/2026-09-27-nip43-membership.md new file mode 100644 index 0000000000..be1e0a8c44 --- /dev/null +++ b/geode/plans/2026-09-27-nip43-membership.md @@ -0,0 +1,108 @@ +# NIP-43 relay membership in geode + +Status: implemented (2026-09-27). + +PR #4236 added the NIP-86 role / invite-code RPCs (`createrole`, `editrole`, +`deleterole`, `assignrole`, `unassignrole`, `listclaims`, `createclaim`, +`deleteclaim`) and persisted their state in `BanStore` / `RuntimeConfig`, but +nothing consumed that state: geode advertised the methods and they were silent +no-ops. This plan makes them do what NIP-43 says. + +## Survey + +| Piece | Where | Status | +|---|---|---| +| NIP-43 event models (13534, 33534, 8000, 8001, 28934, 28936) | `quartz/nip43RelayMembers/*` | reused as-is | +| Roles, assignments, claims, allow list | `quartz/nip86RelayManagement/server/BanStore` | reused as-is | +| Local ingest bypassing policies | `NostrServer.ingest(event, skipVerify)` | reused (mirror path) | +| Hook for EVENTs addressed to the relay | — | **new**: `EventCommandHandler` in `quartz/nip01Core/relay/server` | +| Join / leave / republish engine | — | **new**: `quartz/nip43RelayMembers/server/RelayMembershipServer` (generic, any Quartz relay can use it) | +| Members-only write gate | `BanListPolicy` | extended: `membersOnly` | +| Role / claim RPC gating + post-RPC hook | `Nip86Server` | extended: `nip43Methods`, `afterMutation` | +| Relay key, config, NIP-11 `self` | geode `RelayEngine`, `StaticConfig`, `RelayIdentity`, `Main` | geode wiring | + +## Decisions + +**Membership is the NIP-86 pubkey allow list.** NIP-43 defines 13534 as "pubkeys +that have access to a given relay"; NIP-86's allow list is exactly the set of +pubkeys with write access, and NIP-86 already says `banpubkey` removes a pubkey +from it. Keeping one set avoids two lists drifting apart. So: + +- join (28934) = `allowpubkey`; leave (28936) = `unallowpubkey`; +- `allowpubkey`, `unallowpubkey` and `banpubkey` over NIP-86 are membership + changes and produce 8000 / 8001 + a fresh 13534; +- `[authorization].pubkey_whitelist` seeds the initial members. + +**Members-only means closed even when empty.** Plain NIP-86 treats an empty +allow list as "no restriction". With membership on, that would leave a fresh +relay wide open until its first join, and the first join would suddenly close it. +`BanListPolicy(membersOnly = true)` rejects every non-member write with +`restricted: …` regardless of list size. Relay-authored events use +`NostrServer.ingest` and never see the policy; join / leave requests are +consumed before it. Reads are not gated — use NIP-42 + a read policy for that. + +**Invite codes are reusable until revoked.** Neither NIP spells out single-use. +NIP-86 calls `listclaims` "invite codes currently accepted by the relay" and +gives revocation its own method (`deleteclaim`); NIP-43 lets users mint claims +(`createclaim`) to share. Consuming a code on first use would make shared invite +links fail for the second person, so a code keeps working until an admin deletes +it. The allow-list reason records which code admitted each pubkey (audit trail). + +**Roles are independent of membership.** Assignments live in `BanStore` +regardless of whether the pubkey is currently a member; 13534 lists only members +(with their roles). Leaving keeps assignments, so a returning member gets them +back — role assignment is an operator decision, not the member's. + +**Publishing is a reconcile.** `RelayMembershipServer.sync()` diffs the +`BanStore` against what the relay last published (read back from its own store +on first sync, so restarts don't republish) and signs only the difference: +8000 / 8001 per pubkey entering / leaving, one 13534 when members or roles +changed, one 33534 per new / edited role, one NIP-09 kind 5 (`a` = +`33534::`, `k` = 33534) per deleted role. Every mutation path — join, +admin RPC, a state file edited while offline — converges on the same events, and +repeated syncs are no-ops. The first sync on a fresh relay emits 8000 for each +seeded member (they were added) plus the initial 13534. + +Replaceable events are stamped `max(now, previous + 1)` so two edits within one +second still supersede each other (a `created_at` tie would be won by the lower +id, not the newer event), and a re-created role is stamped after its deletion's +tombstone. + +**deleterole → NIP-09.** 33534 is addressable; NIP-43 defines no removal, so the +relay deletes the address with a kind 5 it signs itself — the standard way for an +author to retract an addressable event, and what the store already enforces. + +**Join / leave run ahead of the policy chain** (`EventCommandHandler`): + +- signature and id verified (parallel verify means nothing upstream checked it); +- `created_at` within `[membership].request_window_seconds` (default 300) of now → else `invalid:`; +- join: `claim` tag required → else `restricted:`; banned → `restricted:`; already a member → `OK true "duplicate: …"`; unknown / revoked code → `restricted: that is an invalid invite code.`; success → `OK true "info: welcome to !"`; +- leave: NIP-70 `-` tag required (the spec's MUST) → else `invalid:`; not a member → `OK true "duplicate: …"`; success → `OK true "info: you have left this relay."`; +- neither request is stored or fanned out — a join carries the invite code, and + both are ephemeral kinds anyway. + +They don't require NIP-42 AUTH: the request's signature already proves the +author, and the relay is the recipient rather than a re-publisher (NIP-70's AUTH +rule is about accepting protected events for storage). A captured leave request +could be replayed within the window, which only re-removes the same user. + +**Relay identity.** `[identity].secret_key` (nsec or hex) or +`[identity].secret_key_file` (created with a fresh key, mode 0600, if missing). +With membership on and neither set, the key is generated at +`<[admin].state_file>.relay-key`; with no state file either, an in-memory key +is used with a warning. When a key exists, NIP-11 `self` is forced to its pubkey +at boot (overriding a persisted doc). `43` is added to `supported_nips` iff +membership is on — and removed otherwise, since clients only send 28934 to +relays that advertise it. + +**Off by default.** `[membership].enabled = false` keeps geode exactly as before +except that the eight role / claim RPCs are no longer advertised or accepted +(`method not supported`) — they were silent no-ops, which is what the review +flagged. Their persisted state is kept untouched in the state file. + +## Not done + +- Rate limiting join attempts (brute-forcing short invite codes). +- Read gating for members-only relays. +- Kind 28935 (invite request) — deprecated in the current NIP-43. +- Cleaning up 13534 / 33534 signed by a previous relay key after a key change. diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt index 1ed17bbcdb..199ddb8948 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.geode import com.vitorpamplona.geode.config.BannedEntry import com.vitorpamplona.geode.config.MirrorFilterValidator +import com.vitorpamplona.geode.config.RelayIdentity import com.vitorpamplona.geode.config.RuntimeConfig import com.vitorpamplona.geode.config.RuntimeConfigData import com.vitorpamplona.geode.config.StaticConfig @@ -85,8 +86,9 @@ import java.io.File * * Every section is enforced: `[info]` populates the NIP-11 doc, * `[network]` controls the bind, `[database]` chooses the SQLite path, - * `[options]` toggles AUTH/verify/future-skew, and `[authorization]` - * seeds the runtime + * `[options]` toggles AUTH/verify/future-skew, `[identity]` holds the + * relay's own key (NIP-11 `self`), `[membership]` turns on NIP-43, and + * `[authorization]` seeds the runtime * [com.vitorpamplona.quartz.nip86RelayManagement.server.BanStore] on * first boot (see [com.vitorpamplona.geode.config.RuntimeConfig]). * @@ -337,6 +339,14 @@ private fun serve(args: Array) { maxSyncEvents = config.negentropy.max_sync_events, maxSessionsPerConnection = config.negentropy.max_sessions_per_connection, ) + // The relay's own key (NIP-11 `self`): explicit config, else generated + // next to the admin state file when NIP-43 membership needs one. + val relayKey = + RelayIdentity.resolve( + identity = config.identity, + needed = config.membership.enabled, + stateFile = config.admin.state_file, + ) val relay = RelayEngine( advertisedUrl, @@ -346,6 +356,9 @@ private fun serve(args: Array) { parallelVerify = parallelVerify, negentropySettings = negentropySettings, adminPubkeys = config.admin.pubkeys.toSet(), + relayKey = relayKey, + membership = config.membership.enabled, + membershipRequestWindowSeconds = config.membership.request_window_seconds, ) val server = KtorRelay( @@ -498,6 +511,10 @@ private fun serve(args: Array) { println("geode listening on ${server.url}") println("NIP-11 info doc: curl -H 'Accept: application/nostr+json' http://$advertisedHost:$port$path") + relay.relaySigner?.let { println("relay identity (NIP-11 self): ${it.pubKey}") } + if (relay.membership) { + println("NIP-43 membership on: members-only writes; join with a kind 28934 request carrying an invite code (NIP-86 createclaim)") + } if (upstreams.isNotEmpty()) { val trusted = upstreams.count { it.trusted } println("mirroring ${upstreams.size} upstream relay(s), $trusted trusted (signature verification skipped)") diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/RelayEngine.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayEngine.kt index 8e79606e44..0be784be98 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/RelayEngine.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayEngine.kt @@ -24,19 +24,28 @@ import com.vitorpamplona.geode.config.RuntimeConfig import com.vitorpamplona.geode.config.RuntimeConfigData import com.vitorpamplona.geode.config.seedInto import com.vitorpamplona.geode.config.snapshotOf +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation +import com.vitorpamplona.quartz.nip43RelayMembers.server.RelayMembershipServer import com.vitorpamplona.quartz.nip77Negentropy.NegentropySettings import com.vitorpamplona.quartz.nip86RelayManagement.server.BanListPolicy import com.vitorpamplona.quartz.nip86RelayManagement.server.BanStore import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86Server +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlin.coroutines.CoroutineContext /** @@ -103,9 +112,37 @@ class RelayEngine( * owns *who* is admin; the transport owns *how* admins authenticate. */ adminPubkeys: Set = emptySet(), + /** + * The relay's own identity. When set, the NIP-11 doc advertises its + * pubkey as `self` (overriding whatever the persisted doc says), and + * it signs the relay-authored NIP-43 events. Null (the default) leaves + * `self` as configured and the relay unable to sign anything. + */ + relayKey: KeyPair? = null, + /** + * NIP-43 membership (see `geode/plans/2026-09-27-nip43-membership.md`). + * When on — requires [relayKey] — the NIP-86 pubkey allow list is the + * member list and gates writes even while empty, kind 28934 / 28936 + * join and leave requests are answered by [membershipServer], the + * relay publishes kinds 13534 / 33534 / 8000 / 8001 (and NIP-09 + * deletions for removed roles) signed by [relayKey], the NIP-86 role + * and invite-code methods are offered, and NIP-11 advertises 43. Off + * (the default): none of that — the role / claim RPCs are not even + * advertised, and NIP-43 is stripped from `supported_nips`. + */ + val membership: Boolean = false, + /** How far a join / leave request's `created_at` may be from now. */ + membershipRequestWindowSeconds: Long = RelayMembershipServer.DEFAULT_REQUEST_WINDOW_SECONDS, ) : AutoCloseable { + init { + require(!membership || relayKey != null) { "NIP-43 membership needs the relay's own key (relayKey) to sign its events" } + } + private val boot: RuntimeConfigData = runtimeConfig.effective() + /** Signs as the relay's NIP-11 `self`; null when no [relayKey] was configured. */ + val relaySigner: NostrSignerSync? = relayKey?.let { NostrSignerSync(it) } + /** * Live NIP-11 doc. Mutable via [updateInfo] so NIP-86 admin RPCs * can swap it atomically; readers (NIP-11 GET) re-read every @@ -115,9 +152,35 @@ class RelayEngine( * empty NIP-11. */ @Volatile - var info: RelayInfo = RelayInfo(boot.info!!) + var info: RelayInfo = RelayInfo(boot.info!!.advertisingIdentity()) private set + /** + * Stamps the boot-time NIP-11 doc with what this engine actually runs: + * `self` = [relaySigner]'s pubkey, and NIP-43 in `supported_nips` iff + * [membership] is on — a persisted or operator-written doc may say + * otherwise, and clients only send join requests to relays that + * advertise 43. + */ + private fun Nip11RelayInformation.advertisingIdentity(): Nip11RelayInformation { + val nips = supported_nips + val doc = + when { + membership && (nips == null || NIP_43 !in nips) -> { + copy(supported_nips = ((nips ?: emptyList()) + NIP_43).sortedBy { it.toIntOrNull() ?: Int.MAX_VALUE }) + } + + !membership && nips != null && NIP_43 in nips -> { + copy(supported_nips = nips - NIP_43) + } + + else -> { + this + } + } + return relaySigner?.let { doc.copy(self = it.pubKey) } ?: doc + } + /** Mutates the live NIP-11 doc and persists the snapshot. */ fun updateInfo(transform: (Nip11RelayInformation) -> Nip11RelayInformation) { info = RelayInfo(transform(info.document)) @@ -131,8 +194,14 @@ class RelayEngine( * seed on first boot) without firing the mutation hook. */ val banStore: BanStore = - BanStore(onMutation = ::snapshot) - .apply { boot.seedInto(this) } + BanStore( + onMutation = { + snapshot() + // Covers mutations outside the RPC / join paths (which + // sync synchronously) — e.g. direct BanStore calls. + membershipServer?.requestSync() + }, + ).apply { boot.seedInto(this) } /** * NIP-86 admin RPC dispatcher. Transport-agnostic — `KtorRelay` @@ -150,6 +219,12 @@ class RelayEngine( }, onBan = { filter -> store.delete(filter) }, allowList = adminPubkeys, + // Without a NIP-43 engine the role / claim methods would be + // silent no-ops, so they're only offered with membership on. + nip43Methods = membership, + // Republish before the RPC answers, so a client that reads the + // relay right after an admin change sees the new events. + afterMutation = { membershipServer?.sync() }, ) /** @@ -174,12 +249,73 @@ class RelayEngine( // BanListPolicy alone; otherwise stack so both must accept. policyBuilder = { val user = policyBuilder() - if (user === EmptyPolicy) BanListPolicy(banStore) else user + BanListPolicy(banStore) + val banList = BanListPolicy(banStore, membersOnly = membership) + if (user === EmptyPolicy) banList else user + banList }, parentContext = parentContext, parallelVerify = parallelVerify, negentropySettings = negentropySettings, ) - override fun close() = server.close() + /** Background scope for [RelayMembershipServer.requestSync]; cancelled on [close]. */ + private val membershipScope = CoroutineScope(parentContext + SupervisorJob(parentContext[Job])) + + /** + * The NIP-43 engine, when [membership] is on: answers join / leave + * requests on every connection and republishes the relay-signed + * membership events whenever the [banStore] changes. + */ + val membershipServer: RelayMembershipServer? = + if (membership) { + RelayMembershipServer( + signer = relaySigner!!, + banStore = banStore, + publish = ::publishOwn, + load = { filter -> store.query(filter) }, + scope = membershipScope, + relayName = url.url, + requestWindowSeconds = membershipRequestWindowSeconds, + ) + } else { + null + } + + init { + membershipServer?.let { + server.eventCommandHandler = it + // Bring the stored 13534 / 33534 in line with the boot state + // (first boot, a key change, a hand-edited state file). + it.requestSync() + } + } + + /** + * Stores a relay-authored event: through the group-commit writer and + * live fan-out like any publish, but skipping the policy chain (the + * relay's own events aren't subject to its write rules) and signature + * verification (we just signed it). + */ + private suspend fun publishOwn(event: Event): Boolean { + val outcome = CompletableDeferred() + server.ingest(event, skipVerify = true) { outcome.complete(it) } + return when (val result = outcome.await()) { + IEventStore.InsertOutcome.Accepted -> { + true + } + + else -> { + Log.w("RelayEngine") { "relay-signed kind ${event.kind} not stored: $result" } + false + } + } + } + + override fun close() { + membershipScope.cancel() + server.close() + } + + companion object { + private const val NIP_43 = "43" + } } diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/config/RelayIdentity.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/config/RelayIdentity.kt new file mode 100644 index 0000000000..260bec6045 --- /dev/null +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/config/RelayIdentity.kt @@ -0,0 +1,93 @@ +/* + * 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.geode.config + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.nio.file.attribute.PosixFilePermissions + +/** + * Resolves the relay's own key pair (NIP-11 `self`) from + * [StaticConfig.IdentitySection] — see its docs for the precedence. + */ +object RelayIdentity { + /** + * @param needed whether a feature that signs as the relay (NIP-43 + * membership) is on; without it and without explicit config there + * is no identity (`null`). + * @param stateFile `[admin].state_file`, next to which a generated key + * is kept when [needed] and nothing else is configured. + * @param warn where to report falling back to an in-memory key. + */ + fun resolve( + identity: StaticConfig.IdentitySection, + needed: Boolean, + stateFile: String?, + warn: (String) -> Unit = { System.err.println("warning: $it") }, + ): KeyPair? { + identity.secret_key?.let { return parse(it, "[identity].secret_key") } + identity.secret_key_file?.let { return loadOrCreate(File(it)) } + if (!needed) return null + stateFile?.let { return loadOrCreate(File("$it$KEY_FILE_SUFFIX")) } + warn( + "no relay key configured ([identity].secret_key / secret_key_file, or [admin].state_file); " + + "using a throwaway identity — the NIP-11 self pubkey will change on every restart", + ) + return KeyPair() + } + + /** Reads the key in [file], or writes a newly generated one there (owner-only) when it's missing. */ + fun loadOrCreate(file: File): KeyPair { + if (file.exists()) return parse(file.readText(), file.path) + + val key = KeyPair() + file.absoluteFile.parentFile?.mkdirs() + val tmp = File(file.absoluteFile.parentFile, "${file.name}.tmp") + tmp.delete() + try { + Files.createFile(tmp.toPath(), PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------"))) + } catch (_: UnsupportedOperationException) { + // Not a POSIX filesystem (Windows): fall back to the default ACLs. + tmp.createNewFile() + } + tmp.writeText(key.privKey!!.toHexKey() + "\n") + Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.ATOMIC_MOVE) + return key + } + + private fun parse( + value: String, + source: String, + ): KeyPair { + val hex = + decodePrivateKeyAsHexOrNull(value.trim())?.takeIf { it.length == 64 } + ?: throw IllegalArgumentException("$source is not a valid secret key (expected nsec1… or 64-char hex)") + return KeyPair(hex.hexToByteArray()) + } + + /** Suffix appended to `[admin].state_file` for the generated key file. */ + const val KEY_FILE_SUFFIX = ".relay-key" +} diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt index 8cfecb4e41..5535de9a39 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt @@ -44,6 +44,8 @@ data class StaticConfig( val options: OptionsSection = OptionsSection(), val authorization: AuthorizationSection = AuthorizationSection(), val admin: AdminSection = AdminSection(), + val identity: IdentitySection = IdentitySection(), + val membership: MembershipSection = MembershipSection(), val negentropy: NegentropySection = NegentropySection(), /** `[[mirror]]` entries — upstream relays this relay streams from. */ val mirror: List = emptyList(), @@ -288,6 +290,42 @@ data class StaticConfig( val state_file: String? = null, ) + /** + * The relay's own Nostr identity — the NIP-11 `self` key that signs + * relay-authored events (NIP-43 membership lists, roles, add/remove). + * Resolved by [RelayIdentity.resolve]: + * + * - [secret_key]: the key itself, `nsec1…` or 64-char hex. Wins over + * [secret_key_file]. + * - [secret_key_file]: a file holding the key (nsec or hex). Created + * with a freshly generated key (owner-only permissions) when it + * doesn't exist yet. + * + * Neither set: no identity, unless [MembershipSection.enabled] needs + * one — then the key lives in `<[AdminSection.state_file]>.relay-key` + * (generated on first boot), or, with no state file either, in memory + * only (a new identity every restart, with a warning). + */ + data class IdentitySection( + val secret_key: String? = null, + val secret_key_file: String? = null, + ) + + /** + * NIP-43 relay membership. [enabled] turns the relay members-only: the + * NIP-86 pubkey allow list becomes the member list (and gates writes + * even while empty), kind 28934 join requests carrying an invite code + * from NIP-86 `createclaim` add members, kind 28936 leave requests + * remove them, and the relay publishes signed kind 13534 / 33534 / + * 8000 / 8001 events. Needs the relay identity ([IdentitySection]). + * [request_window_seconds] bounds how far a join/leave request's + * `created_at` may drift from the relay's clock. + */ + data class MembershipSection( + val enabled: Boolean = false, + val request_window_seconds: Long = 300, + ) + /** * Boot-time sanity check for values the TOML types can't constrain. * Throws [IllegalArgumentException] (fail-loud at startup) rather @@ -300,6 +338,9 @@ data class StaticConfig( database.readers?.let { require(it >= 1) { "[database].readers must be >= 1 (got $it); a 0/negative pool can never answer a query" } } + require(membership.request_window_seconds > 0) { + "[membership].request_window_seconds must be > 0 (got ${membership.request_window_seconds})" + } database.optimize_interval_seconds?.let { require(it > 0) { "[database].optimize_interval_seconds must be > 0 (got $it); a non-positive interval busy-loops PRAGMA optimize under the writer mutex" diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/membership/Nip43MembershipTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/membership/Nip43MembershipTest.kt new file mode 100644 index 0000000000..d94170e8fe --- /dev/null +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/membership/Nip43MembershipTest.kt @@ -0,0 +1,440 @@ +/* + * 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.geode.membership + +import com.vitorpamplona.geode.RelayEngine +import com.vitorpamplona.geode.RelayIndexingStrategy +import com.vitorpamplona.geode.RelayInfo +import com.vitorpamplona.geode.config.RelayIdentity +import com.vitorpamplona.geode.config.RuntimeConfig +import com.vitorpamplona.geode.config.RuntimeConfigData +import com.vitorpamplona.geode.config.StaticConfig +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip19Bech32.toNsec +import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent +import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEvent +import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent +import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent +import com.vitorpamplona.quartz.nip43RelayMembers.list.tags.RelayMember +import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent +import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole +import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRoleEvent +import com.vitorpamplona.quartz.nip70ProtectedEvts.isProtected +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * NIP-43 on geode: join / leave requests answered over the wire, NIP-86 + * admin changes republished as relay-signed 13534 / 33534 / 8000 / 8001 + * events, all signed by the NIP-11 `self` key, and the state surviving a + * restart. + */ +class Nip43MembershipTest { + private val url = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") + private val relayKey = KeyPair() + private val admin = NostrSignerSync(KeyPair()) + private val alice = NostrSignerSync(KeyPair()) + private val bob = NostrSignerSync(KeyPair()) + + private lateinit var dir: File + private val engines = mutableListOf() + + @BeforeTest + fun setup() { + dir = Files.createTempDirectory("geode-nip43-").toFile() + } + + @AfterTest + fun teardown() { + engines.forEach { runCatching { it.close() } } + dir.deleteRecursively() + } + + private fun relay( + membership: Boolean = true, + stateFile: File? = null, + store: EventStore? = null, + key: KeyPair = relayKey, + ): RelayEngine = + RelayEngine( + url = url, + store = store ?: EventStore(dbName = null, relay = url, indexStrategy = RelayIndexingStrategy), + runtimeConfig = RuntimeConfig(stateFile, RuntimeConfigData(info = RelayInfo.default(url).document)), + adminPubkeys = setOf(admin.pubKey), + relayKey = key, + membership = membership, + ).also { engines += it } + + /** One client connection with a queue of the frames the relay sent it. */ + private class Client( + relay: RelayEngine, + ) { + private val inbox = Channel(Channel.UNLIMITED) + private val session: RelaySession = relay.server.connect { inbox.trySend(it) } + + /** Sends [event] and returns the relay's `OK` as (accepted, message). */ + suspend fun publish(event: Event): Pair { + session.receive("[\"EVENT\",${event.toJson()}]") + return withTimeout(10_000) { + while (true) { + val frame = Json.parseToJsonElement(inbox.receive()).jsonArray + if (frame[0].jsonPrimitive.content == "OK" && frame[1].jsonPrimitive.content == event.id) { + return@withTimeout frame[2].jsonPrimitive.boolean to frame[3].jsonPrimitive.content + } + } + @Suppress("UNREACHABLE_CODE") + error("unreachable") + } + } + } + + private suspend fun RelayEngine.rpc(req: Nip86Request): Nip86Response = nip86Server.dispatch(admin.pubKey, req) + + private suspend fun RelayEngine.memberList(): RelayMembershipListEvent? = + store + .query(Filter(kinds = listOf(RelayMembershipListEvent.KIND), authors = listOf(relaySigner!!.pubKey))) + .singleOrNull() + + private suspend fun RelayEngine.roles(): List = store.query(Filter(kinds = listOf(RelayRoleEvent.KIND))) + + private suspend fun RelayEngine.added(): List = store.query(Filter(kinds = listOf(RelayAddMemberEvent.KIND))) + + private suspend fun RelayEngine.removed(): List = store.query(Filter(kinds = listOf(RelayRemoveMemberEvent.KIND))) + + private fun join( + signer: NostrSignerSync, + claim: String, + createdAt: Long = TimeUtils.now(), + ) = signer.sign(RelayJoinRequestEvent.build(claim, createdAt)) + + private fun leave( + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ) = signer.sign(RelayLeaveRequestEvent.build(createdAt)) + + @Test + fun nip11SelfIsTheSigningKeyAndAdvertisesNip43() = + runBlocking { + val relay = relay() + val doc = relay.info.document + assertEquals(relayKey.pubKey.toHexKey(), doc.self) + assertEquals(relay.relaySigner!!.pubKey, doc.self) + assertTrue("43" in doc.supported_nips!!) + + // Every relay-authored event is signed by that same key. + relay.membershipServer!!.sync() + val list = assertNotNull(relay.memberList()) + assertEquals(doc.self, list.pubKey) + assertTrue(list.tags.isProtected(), "13534 must carry the NIP-70 - tag") + } + + @Test + fun membershipOffKeepsTodaysBehaviour() = + runBlocking { + val relay = relay(membership = false) + assertFalse("43" in relay.info.document.supported_nips!!) + assertNull(relay.membershipServer) + + val methods = (relay.rpc(Nip86Request.supportedMethods()).result as JsonArray).map { it.jsonPrimitive.content } + assertFalse(Nip86Method.CREATE_ROLE in methods, "role RPCs must not be advertised without a NIP-43 engine") + assertFalse(Nip86Method.CREATE_CLAIM in methods) + assertNotNull(relay.rpc(Nip86Request.createClaim("code")).error) + + // Open relay: anyone writes; a 28934 is just an ephemeral event. + val client = Client(relay) + assertTrue(client.publish(alice.sign(TextNoteEvent.build("hi"))).first) + assertTrue(client.publish(join(alice, "code")).first) + assertNull(relay.memberList()) + } + + @Test + fun joinWithValidClaimAddsMemberAndPublishes() = + runBlocking { + val relay = relay() + val client = Client(relay) + assertNull(relay.rpc(Nip86Request.createClaim("invite-1")).error) + + // Members-only: an outsider can't write before joining. + val (preOk, preMsg) = client.publish(alice.sign(TextNoteEvent.build("before"))) + assertFalse(preOk) + assertTrue(preMsg.startsWith("restricted:"), preMsg) + + val request = join(alice, "invite-1") + val (ok, msg) = client.publish(request) + assertTrue(ok, msg) + assertTrue(msg.startsWith("info: welcome"), msg) + assertTrue(relay.banStore.isAllowedPubkey(alice.pubKey)) + + assertEquals(listOf(RelayMember(alice.pubKey)), relay.memberList()!!.membersWithRoles()) + val added = relay.added().single() + assertEquals(relay.relaySigner!!.pubKey, added.pubKey) + assertEquals(listOf(alice.pubKey), (added as RelayAddMemberEvent).memberPubKeys()) + // The join request (it carries the invite code) is never stored. + assertTrue(relay.store.query(Filter(ids = listOf(request.id))).isEmpty()) + + // Now a member: writes go through. + assertTrue(client.publish(alice.sign(TextNoteEvent.build("after"))).first) + + // Invite codes are reusable until revoked. + assertTrue(client.publish(join(bob, "invite-1")).first) + assertEquals(setOf(alice.pubKey, bob.pubKey), relay.memberList()!!.members().toSet()) + + // Joining again is a no-op answered as a duplicate. + val (dupOk, dupMsg) = client.publish(join(alice, "invite-1")) + assertTrue(dupOk) + assertTrue(dupMsg.startsWith("duplicate:"), dupMsg) + assertEquals(2, relay.added().size) + } + + @Test + fun joinWithInvalidRevokedOrStaleClaimIsRejected() = + runBlocking { + val relay = relay() + val client = Client(relay) + relay.rpc(Nip86Request.createClaim("good")) + + val (badOk, badMsg) = client.publish(join(alice, "nope")) + assertFalse(badOk) + assertEquals("restricted: that is an invalid invite code.", badMsg) + + // Outside the created_at window ("now, plus or minus a few minutes"). + val (oldOk, oldMsg) = client.publish(join(alice, "good", createdAt = TimeUtils.now() - 3600)) + assertFalse(oldOk) + assertTrue(oldMsg.startsWith("invalid:"), oldMsg) + val (futureOk, _) = client.publish(join(alice, "good", createdAt = TimeUtils.now() + 3600)) + assertFalse(futureOk) + + // A revoked (deleteclaim) code no longer admits anyone. + relay.rpc(Nip86Request.deleteClaim("good")) + val (revokedOk, revokedMsg) = client.publish(join(alice, "good")) + assertFalse(revokedOk) + assertTrue(revokedMsg.startsWith("restricted:"), revokedMsg) + + // A banned pubkey can't join even with a valid code. + relay.rpc(Nip86Request.createClaim("fresh")) + relay.rpc(Nip86Request.banPubkey(bob.pubKey, "spam")) + val (bannedOk, bannedMsg) = client.publish(join(bob, "fresh")) + assertFalse(bannedOk) + assertTrue(bannedMsg.startsWith("restricted:"), bannedMsg) + + // A forged signature is refused before anything else. + val forged = join(alice, "fresh").let { Event(it.id, it.pubKey, it.createdAt, it.kind, it.tags, it.content, "0".repeat(128)) } + val (forgedOk, forgedMsg) = client.publish(forged) + assertFalse(forgedOk) + assertTrue(forgedMsg.startsWith("invalid:"), forgedMsg) + + assertFalse(relay.banStore.isAllowedPubkey(alice.pubKey)) + assertFalse(relay.banStore.isAllowedPubkey(bob.pubKey)) + assertTrue(relay.added().isEmpty()) + } + + @Test + fun leaveRemovesMemberAndPublishes() = + runBlocking { + val relay = relay() + val client = Client(relay) + relay.rpc(Nip86Request.createClaim("c")) + assertTrue(client.publish(join(alice, "c")).first) + assertTrue(client.publish(join(bob, "c")).first) + + // The NIP-70 "-" tag is mandatory on leave requests. + val unprotected = alice.sign(TimeUtils.now(), RelayLeaveRequestEvent.KIND, emptyArray(), "") + val (noTagOk, noTagMsg) = client.publish(unprotected) + assertFalse(noTagOk) + assertTrue(noTagMsg.startsWith("invalid:"), noTagMsg) + + val (staleOk, _) = client.publish(leave(alice, createdAt = TimeUtils.now() - 3600)) + assertFalse(staleOk) + assertTrue(relay.banStore.isAllowedPubkey(alice.pubKey)) + + val (ok, msg) = client.publish(leave(alice)) + assertTrue(ok, msg) + assertFalse(relay.banStore.isAllowedPubkey(alice.pubKey)) + assertEquals(listOf(bob.pubKey), relay.memberList()!!.members()) + val removed = relay.removed().single() as RelayRemoveMemberEvent + assertEquals(listOf(alice.pubKey), removed.memberPubKeys()) + assertEquals(relay.relaySigner!!.pubKey, removed.pubKey) + + // Leaving twice is a duplicate, and a non-member can't write any more. + assertTrue(client.publish(leave(alice)).second.startsWith("duplicate:")) + assertFalse(client.publish(alice.sign(TextNoteEvent.build("still here?"))).first) + } + + @Test + fun adminMembershipChangesRepublish() = + runBlocking { + val relay = relay() + assertNull(relay.rpc(Nip86Request.allowPubkey(alice.pubKey)).error) + assertEquals(listOf(alice.pubKey), relay.memberList()!!.members()) + assertEquals(1, relay.added().size) + + relay.rpc(Nip86Request.allowPubkey(bob.pubKey)) + relay.rpc(Nip86Request.banPubkey(alice.pubKey, "spam")) + assertEquals(listOf(bob.pubKey), relay.memberList()!!.members()) + assertEquals(listOf(alice.pubKey), (relay.removed().single() as RelayRemoveMemberEvent).memberPubKeys()) + + relay.rpc(Nip86Request.unallowPubkey(bob.pubKey)) + assertEquals(emptyList(), relay.memberList()!!.members()) + assertEquals(2, relay.removed().size) + } + + @Test + fun rolesArePublishedAndCarriedByTheMemberList() = + runBlocking { + val relay = relay() + relay.rpc(Nip86Request.allowPubkey(alice.pubKey)) + + assertNull(relay.rpc(Nip86Request.createRole("mod", "Moderator", "keeps order", 200, 2)).error) + val role = relay.roles().single() + assertEquals(relay.relaySigner!!.pubKey, role.pubKey) + assertTrue(role.tags.isProtected()) + assertEquals(RelayRole("mod", "Moderator", "keeps order", 200, 2), role.role()) + + assertNull(relay.rpc(Nip86Request.assignRole(alice.pubKey, "mod")).error) + assertEquals(listOf(RelayMember(alice.pubKey, listOf("mod"))), relay.memberList()!!.membersWithRoles()) + + // Edits within the same second still supersede (created_at is kept monotonic). + relay.rpc(Nip86Request.editRole("mod", "Mod", null, 10, 1)) + relay.rpc(Nip86Request.editRole("mod", "Moderators", null, 11, 1)) + assertEquals(RelayRole("mod", "Moderators", null, 11, 1), relay.roles().single().role()) + + relay.rpc(Nip86Request.createRole("king")) + relay.rpc(Nip86Request.assignRole(alice.pubKey, "king")) + assertEquals( + listOf("mod", "king"), + relay + .memberList()!! + .membersWithRoles() + .single() + .roles, + ) + + relay.rpc(Nip86Request.unassignRole(alice.pubKey, "king")) + assertEquals( + listOf("mod"), + relay + .memberList()!! + .membersWithRoles() + .single() + .roles, + ) + + // deleterole: NIP-09 deletion of the 33534, and the id leaves every member. + relay.rpc(Nip86Request.deleteRole("mod")) + assertEquals(listOf("king"), relay.roles().map { it.roleId() }) + assertEquals(listOf(RelayMember(alice.pubKey)), relay.memberList()!!.membersWithRoles()) + val deletion = relay.store.query(Filter(kinds = listOf(5), authors = listOf(relay.relaySigner!!.pubKey))).single() + assertTrue(deletion.tags.any { it[0] == "a" && it[1] == "33534:${relay.relaySigner!!.pubKey}:mod" }) + + // A role re-created under a deleted id is published again, after the tombstone. + relay.rpc(Nip86Request.createRole("mod", "Back")) + assertEquals(setOf("king", "mod"), relay.roles().map { it.roleId() }.toSet()) + } + + @Test + fun membershipSurvivesARestartWithoutRepublishing() = + runBlocking { + val stateFile = File(dir, "admin.json") + val dbFile = File(dir, "events.db").path + val key = RelayIdentity.loadOrCreate(File(dir, "relay.key")) + + val r1 = relay(stateFile = stateFile, store = EventStore(dbName = dbFile, relay = url), key = key) + r1.rpc(Nip86Request.createClaim("c")) + r1.rpc(Nip86Request.createRole("mod", "Moderator")) + assertTrue(Client(r1).publish(join(alice, "c")).first) + r1.rpc(Nip86Request.assignRole(alice.pubKey, "mod")) + val list1 = r1.memberList()!! + r1.close() + engines.remove(r1) + + // Same key from disk, same state file, same event store. + val key2 = RelayIdentity.loadOrCreate(File(dir, "relay.key")) + assertEquals(key.pubKey.toHexKey(), key2.pubKey.toHexKey()) + val r2 = relay(stateFile = stateFile, store = EventStore(dbName = dbFile, relay = url), key = key2) + assertEquals(key.pubKey.toHexKey(), r2.info.document.self) + assertTrue(r2.banStore.isAllowedPubkey(alice.pubKey)) + assertEquals(listOf("mod"), r2.banStore.rolesOf(alice.pubKey)) + assertEquals(listOf("c"), r2.banStore.listClaims()) + + r2.membershipServer!!.sync() + assertEquals(list1.id, r2.memberList()!!.id, "an unchanged member list is not re-signed on boot") + assertEquals(1, r2.added().size) + assertEquals(1, r2.roles().size) + + // Still a member after the restart. + assertTrue(Client(r2).publish(alice.sign(TextNoteEvent.build("back"))).first) + } + + @Test + fun relayIdentityResolution() { + val nsecKey = KeyPair() + val fromNsec = + RelayIdentity.resolve(StaticConfig.IdentitySection(secret_key = nsecKey.privKey!!.toNsec()), needed = false, stateFile = null) + assertEquals(nsecKey.pubKey.toHexKey(), fromNsec!!.pubKey.toHexKey()) + + val fromHex = + RelayIdentity.resolve(StaticConfig.IdentitySection(secret_key = nsecKey.privKey!!.toHexKey()), needed = false, stateFile = null) + assertEquals(nsecKey.pubKey.toHexKey(), fromHex!!.pubKey.toHexKey()) + + // Nothing configured and nothing needs it: no identity. + assertNull(RelayIdentity.resolve(StaticConfig.IdentitySection(), needed = false, stateFile = null)) + + // Membership needs one: generated next to the state file, stable across boots. + val state = File(dir, "state.json").path + val first = RelayIdentity.resolve(StaticConfig.IdentitySection(), needed = true, stateFile = state)!! + val second = RelayIdentity.resolve(StaticConfig.IdentitySection(), needed = true, stateFile = state)!! + assertEquals(first.pubKey.toHexKey(), second.pubKey.toHexKey()) + assertTrue(File(state + RelayIdentity.KEY_FILE_SUFFIX).exists()) + + // No state file either: an in-memory key, with a warning. + val warnings = mutableListOf() + assertNotNull(RelayIdentity.resolve(StaticConfig.IdentitySection(), needed = true, stateFile = null, warn = { warnings += it })) + assertEquals(1, warnings.size) + } +} From fa534e09755b2863dec56632f890fe4d70c68cf2 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 27 Sep 2026 18:57:15 -0400 Subject: [PATCH 20/24] fix(marmot): apply edits from other clients, live and after restart A White Noise edit (inner kind:1009) decrypted fine and never showed: - A 1009 has no typed event class, so LocalCache's dispatch has no case for it and justConsume dropped it ("Event Not Supported" in logcat). The edit's note was left without an event, and Note.latestMarmotEdit, which matches on the event's kind and author, skipped it. - Because justConsume never called it new, the edit was also never persisted to the group's message log. And the startup restore of that log did not re-link edits to their message at all, so even a persisted edit was lost on restart. AccountMarmotActions.indexMarmotInnerEvent now does the indexing for both live decryption and the startup restore. It caches the inner event and holds it on its note with loadEvent, which also sets the author the overlay checks. An edit, which the cache cannot type, is attached directly instead of being passed to justConsume. It links an edit to the message it replaces. It reports "new" correctly for an edit (its note was empty), so edits are persisted and come back after a restart. Verified on device: a White Noise edit made hours earlier now renders in Amethyst ("hello from WhiteNoise 1 EDITED (edited)"), in the message and in a reply quoting it, after a restart, via the restore path. The live path needs the group's messages to arrive at all; on the White Noise relay that also depends on #4239 (filter-count cap). Co-Authored-By: Claude Opus 5.5 --- .../vitorpamplona/amethyst/model/Account.kt | 8 ++-- .../amethyst/model/AccountMarmotActions.kt | 43 +++++++++++++++++++ .../loggedIn/DecryptAndIndexProcessor.kt | 34 +++------------ 3 files changed, 51 insertions(+), 34 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 7f149f0610..29d1baedd8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -4017,11 +4017,9 @@ class Account( // via wasVerified=false) would silently drop // kind:7 reactions / kind:5 deletions since // they never carry a Schnorr signature. - val isNew = cache.justConsume(innerEvent, null, true) - val innerNote = cache.getOrCreateNote(innerEvent.id) - if (isNew) { - innerNote.event = innerEvent - } + // Same indexing as live decryption, so a restored + // kind:1009 edit is re-linked to its message too. + val innerNote = marmot.indexMarmotInnerEvent(innerEvent).note marmotGroupList.addMessage(groupId, innerNote) } catch (e: Exception) { Log.w( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt index bdb1f9a9e6..15df5f3552 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt @@ -27,6 +27,8 @@ import com.vitorpamplona.quartz.marmot.appComponents.GroupAvatarUrlV1 import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1 import com.vitorpamplona.quartz.marmot.appComponents.MarmotWebUrl import com.vitorpamplona.quartz.marmot.appComponents.MessageRetentionV1 +import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent +import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotMessageEdit import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState @@ -247,6 +249,47 @@ class AccountMarmotActions( manager.persistDecryptedMessage(nostrGroupId, innerEvent.toJson()) } + /** + * Index a decrypted Marmot inner event: cache it, hold it on its note, and link an + * edit to the message it replaces. Shared by live decryption and the startup restore + * of the stored message log, so both see the same thing. + * + * A kind:1009 edit has no typed class, so `LocalCache` has no case for it and + * `justConsume` drops it ("Event Not Supported"). The note then had no event and + * `Note.latestMarmotEdit`, which matches on the event's kind, skipped it: an edit + * from White Noise decrypted fine and never showed. An edit needs nothing from the + * cache but its note, so it is attached directly. + */ + fun indexMarmotInnerEvent(innerEvent: Event): IndexedInnerEvent { + val cache = account.cache + val isEdit = innerEvent.kind == MarmotAppEvent.KIND_EDIT + val innerNote = cache.getOrCreateNote(innerEvent.id) + // wasVerified=true: MIP-03 inner events are unsigned rumors; MLS authenticated the sender. + // For an edit, "new" is whether its note was empty — which also decides whether it is + // persisted, so an edit is kept in the local log and survives a restart. + val isNew = if (isEdit) innerNote.event == null else cache.justConsume(innerEvent, null, true) + if (isNew || innerNote.event == null) { + // loadEvent, not a bare `event =`: the overlay also matches the edit's AUTHOR to the + // message's, and only loadEvent sets it. + innerNote.loadEvent(innerEvent, cache.getOrCreateUser(innerEvent.pubKey), emptyList()) + } + + // The overlay's rules (author-only, latest wins) are applied at render time by + // `Note.latestMarmotEdit`: the target's author may not be known yet. + if (isEdit) { + MarmotMessageEdit.fromAppEvent(MarmotAppEvent.fromEvent(innerEvent))?.let { edit -> + cache.getOrCreateNote(edit.targetId).addEdit(innerNote) + } + } + return IndexedInnerEvent(innerNote, isNew) + } + + /** [note] holds the inner event; [isNew] is true the first time this client indexed it. */ + class IndexedInnerEvent( + val note: Note, + val isNew: Boolean, + ) + /** * The Marmot group [note] was received or sent in, or null when it is not a * Marmot message. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index e09c5e6e4a..83d907fb90 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -32,8 +32,6 @@ import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.marmot.GroupEventResult import com.vitorpamplona.quartz.marmot.MarmotInboundProcessor import com.vitorpamplona.quartz.marmot.WelcomeResult -import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent -import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotMessageEdit import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.nip01Core.core.Event @@ -672,15 +670,9 @@ class GroupEventHandler( // needs this path to surface the note (otherwise the // operator saw nothing but a misleading "inner event // already in cache" log and the message never rendered). - val isNew = cache.justConsume(innerEvent, null, true) - val innerNote = cache.getOrCreateNote(innerEvent.id) - if (isNew) { - innerNote.event = innerEvent - } else { - Log.d("MarmotDbg") { - "GroupEventHandler.add: inner event already in cache — surfacing in chatroom anyway" - } - } + val indexed = account.marmot.indexMarmotInnerEvent(innerEvent) + val innerNote = indexed.note + val isNew = indexed.isNew // Link the envelope to its inner note and copy over the // relays that delivered/accepted the kind-445 so far, so @@ -694,24 +686,8 @@ class GroupEventHandler( cache.copyRelaysFromTo(outerNote, innerEvent.id) } - // A kind:1009 edit is anchored to the message it replaces, - // exactly like a Concord edit or a reaction: the bubble reads - // `Note.edits`, and holding the edit as a hard-referenced - // child of its target is what keeps it alive as long as that - // target is. A Marmot inner event is decrypted exactly once — - // the ratchet has moved on by the time anyone could re-fetch - // it — so an edit left orphaned in the soft cache could be - // collected and never come back. - // - // The overlay's own rules (author-only, latest wins) are - // applied at render time by `Note.latestMarmotEdit`, not here: - // the target's author is not necessarily known yet when the - // edit arrives, and a link is not an endorsement. - if (innerEvent.kind == MarmotAppEvent.KIND_EDIT) { - MarmotMessageEdit.fromAppEvent(MarmotAppEvent.fromEvent(innerEvent))?.let { edit -> - cache.getOrCreateNote(edit.targetId).addEdit(innerNote) - } - } + // A kind:1009 edit was linked to the message it replaces by + // indexMarmotInnerEvent, which also holds it on its note. // Push token gossip (kinds 447/448/449) is routing data for // a notification server, addressed to the other members' From 6ccf75491595e0d862e05d84535644aa28a8aca2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 22:57:34 +0000 Subject: [PATCH 21/24] fix(quartz): review fixes for group list move, NIP-46 rate limit, NIP-44 legacy padding, paging perf - SimpleGroupListEvent.replace matches entries by group id + normalized relay on both sides (an unnormalized stored url is now removed) and keeps a private-only entry private instead of re-adding it as a public tag. - NostrConnectSignerService records the id of a rate-limited request it answered with an error, so a relay replay after restart is not serviced. - Nip44v2.unpad also accepts the padded length older builds produced with float math above 2^24; encryption stays spec-exact. - fetchAllPages appends page ids to a plain list and builds the dedup set only when a NIP-67 "auth" hint makes the relay re-serve the page. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc --- .../NostrClientFetchAllPagesExt.kt | 17 +++++-- .../quartz/nip44Encryption/Nip44v2.kt | 22 +++++++++- .../server/NostrConnectSignerService.kt | 3 ++ .../simpleGroupList/SimpleGroupListEvent.kt | 43 +++++++++++++----- .../nip29RelayGroups/Nip29SpecUpdatesTest.kt | 34 ++++++++++++++ .../nip44Encryption/Nip44v2PaddingTest.kt | 44 +++++++++++++++++++ .../server/NostrConnectSignerServiceTest.kt | 30 +++++++++++++ 7 files changed, 176 insertions(+), 17 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt index b5faa9ba59..72c0cfb90e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt @@ -360,8 +360,11 @@ suspend fun INostrClient.fetchAllPages( // Ids delivered on this page, kept only while an EOSE `"auth"` hint could still make // the relay re-serve the page after AUTH (at most once per walk), so the re-served - // copies of events already handed to [onEvent] are dropped. Reader-thread only. - val pageIds: HashSet? = if (pendingOnAuthRequired && !authRetried) HashSet() else null + // copies of events already handed to [onEvent] are dropped. The hint is rare, so the + // page only appends to a plain list (no hashing, no per-entry node); the lookup set + // is built from it once, on the first re-served event. Both are reader-thread only. + val pageIds: ArrayList? = if (pendingOnAuthRequired && !authRetried) ArrayList() else null + var reServedIds: HashSet? = null var reServing = false try { @@ -393,7 +396,10 @@ suspend fun INostrClient.fetchAllPages( if (boundary != null && event.createdAt == boundary && event.id in seenAtBoundary) return // The relay re-serving this page after an EOSE "auth" hint: skip what // this page already delivered. - if (reServing && pageIds != null && event.id in pageIds) return + if (reServing && pageIds != null) { + val seenOnPage = reServedIds ?: HashSet(pageIds).also { reServedIds = it } + if (event.id in seenOnPage) return + } // Count this event against every active filter it satisfies // (one event can match more than one). Only a non-search filter @@ -421,7 +427,10 @@ suspend fun INostrClient.fetchAllPages( if (atLeastOne) { onEvent(event) delivered++ - pageIds?.add(event.id) + if (pageIds != null) { + val seenOnPage = reServedIds + if (seenOnPage != null) seenOnPage.add(event.id) else pageIds.add(event.id) + } // Track the oldest advancing second and the ids delivered // in it — that becomes the next boundary and its dedup set. if (advancesCursor) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt index 5c3efeceac..fb0794a279 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt @@ -28,6 +28,8 @@ import com.vitorpamplona.quartz.utils.Secp256k1Instance import com.vitorpamplona.quartz.utils.equalsConstantTime import kotlinx.coroutines.CancellationException import kotlin.io.encoding.Base64 +import kotlin.math.floor +import kotlin.math.log2 /** * NIP-44 v2 encryption. @@ -160,6 +162,21 @@ class Nip44v2( return chunk * ((len - 1) / chunk + 1) } + /** + * The padded length earlier Amethyst builds produced: the spec formula evaluated in Float/Int + * math, which rounds `len - 1` to the nearest Float above 2^24 and so sometimes jumps a bucket. + * Only [unpad] uses it, to keep decrypting payloads those builds encrypted. Replicates the old + * code exactly, Int overflow included; lengths that never fit an Int could not have been padded. + */ + private fun legacyCalcPaddedLen(len: Long): Long { + if (len <= 0 || len > Int.MAX_VALUE) return -1 + val intLen = len.toInt() + if (intLen <= 32) return 32 + val nextPower = 1 shl (floor(log2(intLen - 1f)) + 1).toInt() + val chunk = if (nextPower <= 256) 32 else nextPower / 8 + return (chunk * (floor((intLen - 1f) / chunk).toInt() + 1)).toLong() + } + fun pad(plaintext: String): ByteArray { val unpadded = plaintext.encodeToByteArray() val unpaddedLen = unpadded.size @@ -204,7 +221,10 @@ class Nip44v2( "Invalid size $unpaddedLenExt not between $extMinPlaintextSize and $extMaxPlaintextSize" } - check(padded.size.toLong() == 6 + calcPaddedLen(unpaddedLenExt)) { + // Encryption is spec-exact, but earlier Amethyst builds padded with float math that picks a + // bigger bucket for some lengths above 2^24; accept those so old payloads still decrypt. + val paddedLen = padded.size.toLong() - 6 + check(paddedLen == calcPaddedLen(unpaddedLenExt) || paddedLen == legacyCalcPaddedLen(unpaddedLenExt)) { "Invalid padding ${calcPaddedLen(unpaddedLenExt)} != $unpaddedLenExt" } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt index eaf76ae30c..ee47c16950 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt @@ -256,6 +256,9 @@ class NostrConnectSignerService( RateLimiter.Decision.DENY_AND_NOTIFY -> { Log.w("NIP46Signer") { "rate-limited request from ${event.pubKey.take(8)}…; replying with an error" } + // The client is told this request failed, so a relay replaying it after a + // restart must not get it serviced: persist its id like a serviced one. + onHandledId?.invoke(event.id) handleGate.acquire() launch { try { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/SimpleGroupListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/SimpleGroupListEvent.kt index d7041b22f7..4a0fcadc40 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/SimpleGroupListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/SimpleGroupListEvent.kt @@ -116,8 +116,14 @@ class SimpleGroupListEvent( /** * Swaps [from] for [to] in one signed version — e.g. a NIP-29 group that migrated to another - * relay keeps its id but gets a new relay hint. [from] is dropped from both the public tags and - * the private items; [to] is added as a public tag. + * relay keeps its id but gets a new relay hint. + * + * Entries are matched by group id + *normalized* relay url on both sides, so a stored + * `wss://relay.example` (another client's spelling) is still found when [from] carries the + * normalized `wss://relay.example/`. Every copy of [from] and [to] is dropped from both the + * public tags and the private items, then [to] is added back where [from] lived: as a private + * item when [from] was only in the encrypted items (so a private membership stays private), + * otherwise as a public tag. */ suspend fun replace( earlierVersion: SimpleGroupListEvent, @@ -127,18 +133,31 @@ class SimpleGroupListEvent( createdAt: Long = TimeUtils.now(), ): SimpleGroupListEvent { val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() - return resign( - privateTags = privateTags.remove(from.toTagIdOnly()), - tags = - earlierVersion.tags - .remove(from.toTagIdOnly()) - .remove(to.toTagIdOnly()) - .plus(to.toTagArray()), - signer = signer, - createdAt = createdAt, - ) + + val fromKey = groupKey(from.groupId, from.relayUrl) + val toKey = groupKey(to.groupId, to.relayUrl) + val isFrom = { tag: Array -> tagGroupKey(tag) == fromKey } + val isFromOrTo = { tag: Array -> tagGroupKey(tag).let { it == fromKey || it == toKey } } + + val wasPrivateOnly = privateTags.any(isFrom) && earlierVersion.tags.none(isFrom) + + val newPublic = earlierVersion.tags.remove(isFromOrTo) + val newPrivate = privateTags.remove(isFromOrTo) + + return if (wasPrivateOnly) { + resign(tags = newPublic, privateTags = newPrivate.plus(to.toTagArray()), signer = signer, createdAt = createdAt) + } else { + resign(tags = newPublic.plus(to.toTagArray()), privateTags = newPrivate, signer = signer, createdAt = createdAt) + } } + private fun groupKey( + groupId: String, + relayUrl: String, + ) = groupId + "@" + (RelayUrlNormalizer.normalizeOrNull(relayUrl)?.url ?: relayUrl) + + private fun tagGroupKey(tag: Array): String? = GroupTag.parse(tag)?.let { groupKey(it.groupId, it.relayUrl) } + suspend fun resign( tags: TagArray, privateTags: TagArray, diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29SpecUpdatesTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29SpecUpdatesTest.kt index 92f92d52a8..ef3d3f2d77 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29SpecUpdatesTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29SpecUpdatesTest.kt @@ -190,4 +190,38 @@ class Nip29SpecUpdatesTest { ) assertEquals(2, moved.publicGroups().size) } + + @Test + fun replaceMatchesAnUnnormalizedStoredRelayUrl() = + runTest { + val signer = NostrSignerInternal(KeyPair()) + // Stored by another client without the trailing slash our normalizer adds. + val stored = GroupTag(gid, "wss://old.example.com", "Pizza") + val list = SimpleGroupListEvent.create(publicGroups = listOf(stored), signer = signer) + + val moved = + SimpleGroupListEvent.replace( + list, + GroupTag(gid, "wss://old.example.com/", "Pizza"), + GroupTag(gid, "wss://new.example.com/", "Pizza"), + signer, + ) + + assertEquals(listOf(gid to "wss://new.example.com/"), moved.publicGroups().map { it.groupId to it.relayUrl }) + assertEquals(emptyList(), moved.privateGroups(signer)?.map { it.groupId to it.relayUrl }) + } + + @Test + fun replaceKeepsAPrivateEntryPrivate() = + runTest { + val signer = NostrSignerInternal(KeyPair()) + val publicOther = GroupTag("other", "wss://old.example.com/", null) + val secret = GroupTag(gid, "wss://old.example.com/", "Pizza") + val list = SimpleGroupListEvent.create(publicGroups = listOf(publicOther), privateGroups = listOf(secret), signer = signer) + + val moved = SimpleGroupListEvent.replace(list, secret, GroupTag(gid, "wss://new.example.com/", "Pizza"), signer) + + assertEquals(listOf("other" to "wss://old.example.com/"), moved.publicGroups().map { it.groupId to it.relayUrl }) + assertEquals(listOf(gid to "wss://new.example.com/"), moved.privateGroups(signer)?.map { it.groupId to it.relayUrl }) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2PaddingTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2PaddingTest.kt index 9ff794032c..aa722c0157 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2PaddingTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2PaddingTest.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.quartz.nip44Encryption import com.vitorpamplona.quartz.utils.RandomInstance import kotlin.io.encoding.Base64 +import kotlin.math.floor +import kotlin.math.log2 import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -87,6 +89,48 @@ class Nip44v2PaddingTest { assertFailsWith { nip44v2.unpad(padded2) } } + @Test + fun unpadAcceptsLegacyFloatPaddingAbove2e24() { + // Earlier builds padded 20,971,520 bytes to 25,165,824 (float bucket) instead of 20,971,520. + val len = 20_971_520 + val legacy = extendedPadded(len, paddedLen = 25_165_824) + assertEquals(len, nip44v2.unpad(legacy).length) + + // The spec-correct padding for the same length still decodes. + assertEquals(len, nip44v2.unpad(extendedPadded(len, paddedLen = 20_971_520)).length) + + // Neither bucket: still rejected. + assertFailsWith { nip44v2.unpad(extendedPadded(len, paddedLen = 20_971_520 + 32)) } + } + + @Test + fun unpadAcceptsLegacyFloatPaddingAt2e25() { + // 2^25 - 1 rounds up to 2^25 as a Float, so the old math jumped to the 2^26 power bucket. + val len = 1 shl 25 + assertEquals(41_943_040, extendedPaddedLenLegacy(len)) + assertEquals(len, nip44v2.unpad(extendedPadded(len, paddedLen = 41_943_040)).length) + } + + // The old Float formula, verbatim, as an independent oracle for the crafted arrays above. + private fun extendedPaddedLenLegacy(len: Int): Int { + val nextPower = 1 shl (floor(log2(len - 1f)) + 1).toInt() + val chunk = if (nextPower <= 256) 32 else nextPower / 8 + return chunk * (floor((len - 1f) / chunk).toInt() + 1) + } + + private fun extendedPadded( + len: Int, + paddedLen: Int, + ): ByteArray { + val padded = ByteArray(6 + paddedLen) + padded[2] = (len shr 24).toByte() + padded[3] = (len shr 16).toByte() + padded[4] = (len shr 8).toByte() + padded[5] = (len and 0xFF).toByte() + padded.fill('a'.code.toByte(), 6, 6 + len) + return padded + } + @Test fun unpadRejectsZeroLength() { assertFailsWith { nip44v2.unpad(ByteArray(2 + 32)) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt index b653dbaf39..f3295c43da 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt @@ -343,6 +343,36 @@ class NostrConnectSignerServiceTest { assertEquals(BunkerRequestProcessor.ERROR_RATE_LIMITED, replies[2].error) } + @Test + fun aRateLimitedRequestThatWasAnsweredIsRecordedAsHandled() = + runTest { + val client = LoopbackClient() + val signer = serverSigner() + val processor = BunkerRequestProcessor(signer, { setOf(relay) }, AllowAuthorizer()) + val handled = mutableListOf() + val service = + NostrConnectSignerService( + client, + signer, + processor, + setOf(relay), + maxRequestsPerWindow = 1, + rateWindowSeconds = 3600, + onHandledId = { handled.add(it) }, + ) + + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { service.run() } + + val serviced = request(BunkerRequestConnect(id = "ok", remoteKey = serverKey, secret = "s")) + val limited = request(BunkerRequestConnect(id = "limited", remoteKey = serverKey, secret = "s")) + client.deliver(serviced) + client.deliver(limited) + + // The client was told `limited` failed; a relay replaying it after a restart must not get it + // serviced, so its id is persisted just like a serviced one. + assertEquals(listOf(serviced.id, limited.id), handled) + } + @Test fun signEventWithoutParamsGetsAnErrorReply() = runTest { From 0d485b51bb59037334c90b028d3790b0bfdf263f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 22:57:44 +0000 Subject: [PATCH 22/24] fix(cli): relaygroup pin/unpin never overwrites pins from a failed read The current kind-39005 is now fetched with authors = the relay's NIP-11 `self`, and only an EOSE-backed empty answer counts as "no pins". A timeout aborts with `timeout` (exit 124), any other unanswered read with `fetch_failed`, and a relay with no readable `self` with `no_relay_key` (exit 1) - instead of publishing a full-replacement 9010 that wipes pins. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc --- cli/README.md | 2 +- .../commands/RelayGroupModerationCommands.kt | 66 ++++++++++++-- .../amethyst/cli/RelayGroupPinReadTest.kt | 85 +++++++++++++++++++ 3 files changed, 145 insertions(+), 8 deletions(-) create mode 100644 cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/RelayGroupPinReadTest.kt diff --git a/cli/README.md b/cli/README.md index a7e5518690..96f433da99 100644 --- a/cli/README.md +++ b/cli/README.md @@ -599,7 +599,7 @@ screen speaks. | `amy relaygroup invite RELAY GID --code CODE` | Mint an invite code (9009, moderator). | | `amy relaygroup put-user RELAY GID PUBKEY [--role admin\|moderator]` | Add or promote a user (9000, moderator). | | `amy relaygroup remove-user RELAY GID PUBKEY` | Kick a user (9001, moderator). | -| `amy relaygroup pin RELAY GID REF` / `unpin …` | Add/remove a pin (9010, moderator). REF is a note1/nevent1/hex id (`e`) or naddr1/`kind:pubkey:d` (`a`); the rest of the current 39005 list is kept. | +| `amy relaygroup pin RELAY GID REF` / `unpin …` | Add/remove a pin (9010, moderator). REF is a note1/nevent1/hex id (`e`) or naddr1/`kind:pubkey:d` (`a`); the rest of the current 39005 list (signed by the relay's NIP-11 `self`) is kept; if that list cannot be read the command aborts (`timeout` → 124, `fetch_failed`/`no_relay_key` → 1) rather than overwrite it. | ### Buzz workspaces (block/buzz — NIP-29 dialect) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt index 91cdbbbad1..569120d012 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt @@ -24,7 +24,10 @@ import com.vitorpamplona.amethyst.cli.Args import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.FetchAllResult import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupCreateInviteEvent @@ -144,6 +147,10 @@ object RelayGroupModerationCommands { * list, so this reads the group's current kind-39005 and re-submits it with REF added (at the * end) or removed — every other pin, `e` or `a`, kept verbatim. REF is an event (`note1`, * `nevent1`, 64-hex → `e`) or an addressable event (`naddr1`, `kind:pubkey:d` → `a`). + * + * The 39005 is only trusted when signed by the relay's NIP-11 `self` key, and a read that did not + * reach EOSE aborts instead of being taken as "no pins" — publishing a full replacement from a + * failed read would wipe every existing pin. */ suspend fun pin( dataDir: DataDir, @@ -162,14 +169,23 @@ object RelayGroupModerationCommands { Context.open(dataDir).use { ctx -> ctx.prepare() - val filter = Filter(kinds = listOf(GroupPinnedEvent.KIND), tags = mapOf("d" to listOf(groupId)), limit = 1) + // NIP-29: the 39005 is "signed by the relay keypair … as stated by the NIP-11 `self`". + // Without that key we cannot tell the real list from a forged one, so do not rewrite it. + val relayKey = + ctx.relayInfo(relay)?.self + ?: return Output.error("no_relay_key", "could not read the NIP-11 self pubkey of ${relay.url}; refusing to rewrite the pin list") + val filter = + Filter( + kinds = listOf(GroupPinnedEvent.KIND), + authors = listOf(relayKey), + tags = mapOf("d" to listOf(groupId)), + limit = 1, + ) val current = - ctx - .drain(mapOf(relay to listOf(filter)), 6_000) - .map { it.second } - .filterIsInstance() - .maxByOrNull { it.createdAt } - ?.pins() ?: emptyList() + when (val read = readPinList(ctx.drainResult(mapOf(relay to listOf(filter)), 6_000), relay, relayKey)) { + is PinListRead.Found -> read.pins + is PinListRead.Failed -> return Output.error(read.code, read.detail) + } val updated = if (pin) { @@ -195,6 +211,42 @@ object RelayGroupModerationCommands { } } + /** The outcome of reading a group's current kind-39005 before a read-merge-write. */ + internal sealed interface PinListRead { + class Found( + val pins: List, + ) : PinListRead + + class Failed( + val code: String, + val detail: String, + ) : PinListRead + } + + /** + * The latest relay-signed 39005 in [result]; an empty list only when the relay answered (EOSE) + * and had none. A timeout maps to `timeout` (exit 124), any other unanswered read to + * `fetch_failed` (exit 1). + */ + internal fun readPinList( + result: FetchAllResult, + relay: NormalizedRelayUrl, + relayKey: HexKey, + ): PinListRead { + val latest = + result.events + .map { it.second } + .filterIsInstance() + .filter { it.pubKey == relayKey } + .maxByOrNull { it.createdAt } + return when { + latest != null -> PinListRead.Found(latest.pins()) + result.anyRelayServed -> PinListRead.Found(emptyList()) + relay in result.stalled -> PinListRead.Failed("timeout", "${relay.url} did not answer the pin-list read; refusing to overwrite pins") + else -> PinListRead.Failed("fetch_failed", "could not read the pin list from ${relay.url} (${result.doneReasons[relay] ?: "no answer"}); refusing to overwrite pins") + } + } + /** `relaygroup invite RELAY GROUP_ID --code CODE` → 9009. */ suspend fun invite( dataDir: DataDir, diff --git a/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/RelayGroupPinReadTest.kt b/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/RelayGroupPinReadTest.kt new file mode 100644 index 0000000000..9262d824b9 --- /dev/null +++ b/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/RelayGroupPinReadTest.kt @@ -0,0 +1,85 @@ +/* + * 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.amethyst.cli + +import com.vitorpamplona.amethyst.cli.commands.RelayGroupModerationCommands +import com.vitorpamplona.amethyst.cli.commands.RelayGroupModerationCommands.PinListRead +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.DONE_REASON_EOSE +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.FetchAllResult +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +/** + * `relaygroup pin|unpin` re-submits the whole 39005 list, so a failed read must abort instead of + * being read as "no pins" (which would publish an empty replacement and wipe them). + */ +class RelayGroupPinReadTest { + private val relay = RelayUrlNormalizer.normalize("wss://groups.example.com") + private val relayKey = "aa".repeat(32) + private val stranger = "bb".repeat(32) + private val pinnedId = "cc".repeat(32) + + private fun pinned( + author: String, + createdAt: Long, + ) = GroupPinnedEvent( + id = "dd".repeat(32), + pubKey = author, + createdAt = createdAt, + tags = arrayOf(arrayOf("d", "gid"), arrayOf("e", pinnedId)), + content = "", + sig = "ee".repeat(64), + ) + + @Test + fun eoseWithoutAListMeansNoPins() { + val read = RelayGroupModerationCommands.readPinList(FetchAllResult(emptyList(), mapOf(relay to DONE_REASON_EOSE), emptySet()), relay, relayKey) + assertEquals(emptyList(), assertIs(read).pins) + } + + @Test + fun aTimedOutReadAbortsWithTimeout() { + val read = RelayGroupModerationCommands.readPinList(FetchAllResult(emptyList(), emptyMap(), setOf(relay)), relay, relayKey) + assertEquals("timeout", assertIs(read).code) + } + + @Test + fun aClosedOrUnreachableReadAborts() { + val read = RelayGroupModerationCommands.readPinList(FetchAllResult(emptyList(), mapOf(relay to "cannot:refused"), emptySet()), relay, relayKey) + assertEquals("fetch_failed", assertIs(read).code) + } + + @Test + fun onlyTheRelaySignedListCounts() { + val events = listOf(relay to pinned(stranger, 200), relay to pinned(relayKey, 100)) + val read = RelayGroupModerationCommands.readPinList(FetchAllResult(events, mapOf(relay to DONE_REASON_EOSE), emptySet()), relay, relayKey) + assertEquals(listOf(pinnedId), assertIs(read).pins.map { it.ref }) + } + + @Test + fun aForgedListAloneIsNotAnAnswerWithoutEose() { + val read = RelayGroupModerationCommands.readPinList(FetchAllResult(listOf(relay to pinned(stranger, 200)), emptyMap(), setOf(relay)), relay, relayKey) + assertEquals("timeout", assertIs(read).code) + } +} From 52d5f748ee5b77691c793c38e1dabacc33847d88 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 22:57:55 +0000 Subject: [PATCH 23/24] fix: admin cache merge+cap, grouped a-pin backfill, petname index safety, NIP-43 members self gate - RelayGroupAdminCache.restore merges the disk map under in-memory entries (a remember() that beat the async read wins) and the cache is capped at 256 groups, dropping the least recently changed; the store persists any map that differs from disk instead of dropping the flow's first value. - filterRelayGroupState back-fills `a` pins with one filter per (kind, author) carrying all pinned d tags. - UserAssertionsState: petname index is an immutable snapshot behind @Volatile, rebuilt on IO by the flow; the composition-time accessor only reads it. KDoc now states the real order: NIP-85 nickname, NIP-02 petname, profile name, npub. - RelayMembersScreen resolves the relay's NIP-11 `self` first and fetches 13534/33534 once, by that author only; with no `self` it shows an explanatory state instead of lists anyone could have signed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc --- .../relays/nip43/RelayMembersScreen.kt | 47 +++++-- .../nip29RelayGroups/RelayGroupMigration.kt | 43 +++++- .../UserAssertionsState.kt | 24 +++- .../preferences/RelayGroupAdminCacheStore.kt | 32 +++-- .../channel/FilterRelayGroupState.kt | 15 +- .../RelayGroupMigrationDetectorTest.kt | 30 ++++ .../UserAssertionsDisplayNameTest.kt | 128 ++++++++++++++++++ .../channel/FilterRelayGroupStateTest.kt | 32 +++++ .../composeResources/values/strings.xml | 1 + 9 files changed, 312 insertions(+), 40 deletions(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionsDisplayNameTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt index c33f421c56..4ed732db3a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt @@ -55,9 +55,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.nip43RelayMembers.ui.RelayRoleChips @@ -72,13 +74,14 @@ import com.vitorpamplona.amethyst.commons.resources.relay_members_loading import com.vitorpamplona.amethyst.commons.resources.relay_members_request_join import com.vitorpamplona.amethyst.commons.resources.relay_members_request_leave import com.vitorpamplona.amethyst.commons.resources.relay_members_title +import com.vitorpamplona.amethyst.commons.resources.relay_members_unverifiable import com.vitorpamplona.amethyst.commons.resources.relay_members_you_are_member import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn -import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo import com.vitorpamplona.amethyst.ui.note.UserCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.fetchAsFlow import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -107,20 +110,37 @@ fun RelayMembersScreen( var members by remember { mutableStateOf>(emptyList()) } var roles by remember { mutableStateOf>(emptyMap()) } var isLoading by remember { mutableStateOf(true) } + // The relay publishes no NIP-11 `self`, so nothing it serves can be verified as relay-signed. + var isUnverifiable by remember { mutableStateOf(false) } var isMember by remember { mutableStateOf(false) } var joinRequestSent by remember { mutableStateOf(false) } var leaveRequestSent by remember { mutableStateOf(false) } var inviteCode by remember { mutableStateOf("") } val scope = rememberCoroutineScope() - // NIP-43 lists (13534) and roles (33534) MUST be signed by the relay's NIP-11 `self`. - // Filter by it once the doc resolves; until then, take whatever the relay serves. - val relayInfo by loadRelayInfo(normalizedRelayUrl) - val relaySelf = relayInfo.self - - LaunchedEffect(normalizedRelayUrl, relaySelf) { + // NIP-43 lists (13534) and roles (33534) MUST be signed by the relay's NIP-11 `self`. Resolve it + // first (loading state meanwhile) and fetch once, by that author only. A relay that publishes no + // `self` gets an explanatory state instead of lists anyone could have signed. + LaunchedEffect(normalizedRelayUrl) { launch(Dispatchers.IO) { - val authors = relaySelf?.let { listOf(it) } + var relaySelf: HexKey? = null + Amethyst.instance.nip11Cache.loadRelayInfo( + relay = normalizedRelayUrl, + onInfo = { relaySelf = it.self }, + onError = { _, _, _ -> }, + ) + + val self = relaySelf + if (self == null) { + members = emptyList() + roles = emptyMap() + isMember = false + isUnverifiable = true + isLoading = false + return@launch + } + + val authors = listOf(self) val filters = listOf( Filter(kinds = listOf(RelayMembershipListEvent.KIND), authors = authors, limit = 1), @@ -135,14 +155,13 @@ fun RelayMembersScreen( val membershipEvent = events ?.mapNotNull { it as? RelayMembershipListEvent } + ?.filter { it.pubKey == self } ?.maxByOrNull { it.createdAt } - // Only trust role definitions from whoever signed the member list. - val roleSigner = relaySelf ?: membershipEvent?.pubKey roles = events ?.mapNotNull { it as? RelayRoleEvent } - ?.filter { it.pubKey == roleSigner } + ?.filter { it.pubKey == self } ?.groupBy { it.roleId() } ?.mapValues { (_, versions) -> versions.maxBy { it.createdAt }.role() } ?: emptyMap() @@ -150,6 +169,7 @@ fun RelayMembersScreen( val memberList = membershipEvent?.membersWithRoles() ?: emptyList() members = memberList isMember = memberList.any { it.pubKey == accountViewModel.account.signer.pubKey } + isUnverifiable = false isLoading = false } } @@ -218,7 +238,7 @@ fun RelayMembersScreen( } } else if (members.isEmpty()) { Column( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -230,8 +250,9 @@ fun RelayMembersScreen( ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = stringRes(Res.string.relay_members_empty), + text = stringRes(if (isUnverifiable) Res.string.relay_members_unverifiable else Res.string.relay_members_empty), color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, ) } } else { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMigration.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMigration.kt index 523d87f95b..2638f54f53 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMigration.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMigration.kt @@ -115,6 +115,14 @@ object RelayGroupMigrationDetector { * [com.vitorpamplona.amethyst.commons.model.preferences.RelayGroupAdminCacheStore]. */ object RelayGroupAdminCache { + /** + * Most groups kept. The cache is device-global (shared by every account on the device) and is + * filled for any group whose migration bar is shown, so it is bounded by recency of change instead + * of by one account's kind-10009: past the cap, the entry changed least recently is dropped. + */ + const val MAX_GROUPS = 256 + + // Insertion order = recency of change (oldest first), so the cap trims from the front. private val admins = MutableStateFlow>>(emptyMap()) val flow: StateFlow>> = admins @@ -131,13 +139,40 @@ object RelayGroupAdminCache { while (true) { val current = admins.value if (current[key] == pubkeys) return - if (admins.compareAndSet(current, current + (key to pubkeys))) return + val next = LinkedHashMap(current) + next.remove(key) + next[key] = pubkeys + if (admins.compareAndSet(current, next.trimToCap())) return } } - /** Replaces the whole map — used to restore from disk at startup. */ - fun restore(map: Map>) { - admins.value = map + /** + * Merges the map read from disk at startup UNDER the in-memory one: the disk read is async, so a + * [remember] that landed before it completed is newer and wins. Returns the merged map. + */ + fun restore(fromDisk: Map>): Map> { + while (true) { + val current = admins.value + val merged = LinkedHashMap>(fromDisk.size + current.size) + merged.putAll(fromDisk) + for ((key, value) in current) { + merged.remove(key) + merged[key] = value + } + val next = merged.trimToCap() + if (admins.compareAndSet(current, next)) return next + } + } + + private fun LinkedHashMap>.trimToCap(): LinkedHashMap> { + if (size > MAX_GROUPS) { + val oldestFirst = entries.iterator() + repeat(size - MAX_GROUPS) { + oldestFirst.next() + oldestFirst.remove() + } + } + return this } /** Test-only: clears the cache so unit tests don't leak state into each other. */ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionsState.kt index c422e772a2..c1d3189c25 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionsState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionsState.kt @@ -43,6 +43,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest +import kotlin.concurrent.Volatile /** * The account's own kind:30382 contact cards — one card per target user, signed @@ -65,14 +66,19 @@ class UserAssertionsState( // The account's own kind-3 follow list, whose `p` tags may carry NIP-02 petnames. private val followListNote: AddressableNote by lazy { cache.getOrCreateAddressableNote(ContactListEvent.createAddress(signer.pubKey)) } - // pubkey -> petname for the latest follow list, rebuilt only when that list changes. + // pubkey -> petname for the latest follow list, rebuilt only when that list changes. An immutable + // snapshot published through a volatile field: the flows rebuild it on an IO thread while + // composition reads it through [cachedFollowListPetname], and a racing rebuild only costs a + // redundant (identical) index, never a torn one. private class PetnameIndex( val event: ContactListEvent, val petnames: Map, ) + @Volatile private var petnameIndex: PetnameIndex? = null + /** Looks [target] up in [followList]'s index, rebuilding it when stale. Parses tags: keep off the main thread. */ private fun followListPetname( followList: ContactListEvent?, target: HexKey, @@ -150,9 +156,14 @@ class UserAssertionsState( .stateFlow .map { followListPetname(it.note.event as? ContactListEvent, target.pubkeyHex) } .distinctUntilChanged() + .flowOn(Dispatchers.IO) - /** Synchronous counterpart of [followListPetnameFlow]. */ - fun cachedFollowListPetname(target: User): String? = followListPetname(followListNote.event as? ContactListEvent, target.pubkeyHex) + /** + * Synchronous counterpart of [followListPetnameFlow], for initial values in composition: it only + * reads the last index the flows built (possibly for the previous version of the follow list — + * the flow corrects it right after) and never parses the follow list on the calling thread. + */ + fun cachedFollowListPetname(target: User): String? = petnameIndex?.petnames?.get(target.pubkeyHex) /** * The name the account knows [target] by, for rendering: the NIP-85 nickname when it has a @@ -178,9 +189,10 @@ class UserAssertionsState( } /** - * The name to render for [target], per the NIP-81 policy: the nickname the - * account gave them wins over the profile's own display name, then the - * account's NIP-02 follow-list petname, falling back to the short npub. + * The name to render for [target]: the NIP-85 nickname the account gave them (NIP-81 policy), + * then the account's NIP-02 follow-list petname — the user's own local name for the contact, so + * it too wins over the profile's self-chosen name — then the profile's display name, falling + * back to the short npub. */ fun displayNameFlow(target: User): Flow = combine( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayGroupAdminCacheStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayGroupAdminCacheStore.kt index 2fcbf9eaf7..d9fda8fb27 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayGroupAdminCacheStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayGroupAdminCacheStore.kt @@ -29,7 +29,6 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupAdmin import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlin.coroutines.cancellation.CancellationException @@ -47,19 +46,30 @@ class RelayGroupAdminCacheStore( ) { init { scope.launch { - restoreFromDisk() - RelayGroupAdminCache.flow.drop(1).collect { persist(it) } + // What the disk holds; only a map that differs from it is written back. Comparing instead + // of dropping the flow's first value also persists a remember() that raced the restore. + var onDisk = restoreFromDisk() + RelayGroupAdminCache.flow.collect { + if (it != onDisk) { + persist(it) + onDisk = it + } + } } } - private suspend fun restoreFromDisk() { - try { - val raw = store.data.first()[KEY] ?: return - if (raw.isNotEmpty()) RelayGroupAdminCache.restore(decode(raw)) - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e("RelayGroupAdminCache") { "Error reading cached group admins: ${e.message}" } - } + /** Merges the saved map into the cache (in-memory entries win) and returns what the disk held. */ + private suspend fun restoreFromDisk(): Map> { + val fromDisk = + try { + store.data.first()[KEY]?.let(::decode) ?: emptyMap() + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("RelayGroupAdminCache") { "Error reading cached group admins: ${e.message}" } + emptyMap() + } + RelayGroupAdminCache.restore(fromDisk) + return fromDisk } private suspend fun persist(map: Map>) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupState.kt index 92c1253805..92f702038b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupState.kt @@ -66,23 +66,26 @@ fun filterRelayGroupState( relays.map { RelayBasedFilter(relay = it, filter = ExplainedFilter(purpose = SubPurpose.RELAY_GROUPS, ids = pinnedIds)) } } - // Same back-fill for `a` pins (addressable events, NIP-29 #2416): one kind+author+#d filter per - // pinned address. No `since` either — the pin stays valid however old the latest version is. + // Same back-fill for `a` pins (addressable events, NIP-29 #2416): one kind+author filter per + // (kind, author) pair carrying every pinned `d` of that pair — the union of the per-address + // filters, in as few filters as the pins allow. No `since` either — the pin stays valid however + // old the latest version is. val pinnedAddresses = channel.pinnedAddresses val addressPins = if (pinnedAddresses.isEmpty()) { emptyList() } else { + val byKindAndAuthor = pinnedAddresses.groupBy({ it.kind to it.pubKeyHex }, { it.dTag }) relays.flatMap { relay -> - pinnedAddresses.map { address -> + byKindAndAuthor.map { (kindAndAuthor, dTags) -> RelayBasedFilter( relay = relay, filter = ExplainedFilter( purpose = SubPurpose.RELAY_GROUPS, - kinds = listOf(address.kind), - authors = listOf(address.pubKeyHex), - tags = mapOf("d" to listOf(address.dTag)), + kinds = listOf(kindAndAuthor.first), + authors = listOf(kindAndAuthor.second), + tags = mapOf("d" to dTags.distinct()), ), ) } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMigrationDetectorTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMigrationDetectorTest.kt index edf8158329..58c80731d8 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMigrationDetectorTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMigrationDetectorTest.kt @@ -136,4 +136,34 @@ class RelayGroupMigrationDetectorTest { assertEquals(mapOf(current.toKey() to setOf(admin, friend)), restored) assertEquals(setOf(admin, friend), RelayGroupAdminCache.adminsOf(current)) } + + @Test + fun restoringFromDiskKeepsRememberCallsThatLandedFirst() { + val other = GroupId("other", newRelay) + // The async disk read completes after the UI already recorded a fresher roster. + RelayGroupAdminCache.remember(current, setOf(friend)) + + val merged = RelayGroupAdminCache.restore(mapOf(current.toKey() to setOf(admin), other.toKey() to setOf(stranger))) + + assertEquals(setOf(friend), RelayGroupAdminCache.adminsOf(current), "the in-memory entry is newer and wins") + assertEquals(setOf(stranger), RelayGroupAdminCache.adminsOf(other), "disk-only entries are restored") + assertEquals(RelayGroupAdminCache.flow.value, merged) + } + + @Test + fun adminCacheIsCappedDroppingTheLeastRecentlyChanged() { + val first = GroupId("g0", newRelay) + RelayGroupAdminCache.remember(first, setOf(admin)) + repeat(RelayGroupAdminCache.MAX_GROUPS) { i -> RelayGroupAdminCache.remember(GroupId("g${i + 1}", newRelay), setOf(admin)) } + + assertEquals(RelayGroupAdminCache.MAX_GROUPS, RelayGroupAdminCache.flow.value.size) + assertTrue(RelayGroupAdminCache.adminsOf(first).isEmpty(), "the oldest entry is evicted") + assertEquals(setOf(admin), RelayGroupAdminCache.adminsOf(GroupId("g${RelayGroupAdminCache.MAX_GROUPS}", newRelay))) + + // A restore past the cap trims too, keeping the in-memory (newer) entries. + val fromDisk = (0 until RelayGroupAdminCache.MAX_GROUPS).associate { GroupId("disk$it", newRelay).toKey() to setOf(friend) } + RelayGroupAdminCache.restore(fromDisk) + assertEquals(RelayGroupAdminCache.MAX_GROUPS, RelayGroupAdminCache.flow.value.size) + assertEquals(setOf(admin), RelayGroupAdminCache.adminsOf(GroupId("g${RelayGroupAdminCache.MAX_GROUPS}", newRelay))) + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionsDisplayNameTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionsDisplayNameTest.kt new file mode 100644 index 0000000000..138fa1a50a --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionsDisplayNameTest.kt @@ -0,0 +1,128 @@ +/* + * 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.amethyst.commons.model.nip85TrustedAssertions + +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.Channel +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.UserContext +import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.model.nip01Core.UserInfo +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer +import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The display-name order: NIP-85 nickname, then the account's NIP-02 follow-list petname (the + * user's own local name for the contact, so it beats the profile's self-chosen name), then the + * profile's name, then the short npub. + */ +class UserAssertionsDisplayNameTest { + private val signer = NostrSignerInternal(KeyPair()) + private val target = "aa".repeat(32) + + private class MapCache : ICacheProvider { + private val context = UserContext { addr -> AddressableNote(addr) } + val users = HashMap() + val addressables = HashMap() + + override val relayHints = HintIndexer() + + override fun getAnyChannel(note: Note): Channel? = null + + override fun getUserIfExists(pubkey: HexKey): User? = users[pubkey] + + override fun countUsers(predicate: (String, User) -> Boolean): Int = 0 + + override fun getNoteIfExists(hexKey: HexKey): Note? = null + + override fun checkGetOrCreateNote(hexKey: HexKey): Note? = null + + override fun getOrCreateAddressableNote(address: Address): AddressableNote = addressables.getOrPut(address) { AddressableNote(address) } + + override fun getEventStream(): ICacheEventStream = error("unused") + + override fun hasBeenDeleted(event: Any): Boolean = false + + override fun getOrCreateUser(pubkey: HexKey): User = users.getOrPut(pubkey) { User(pubkey, context) } + + override fun consumeEmbedded(event: Event) = Unit + + override fun justConsumeMyOwnEvent(event: Event): Boolean = false + } + + private fun setProfileName( + user: User, + name: String, + ) { + user.metadata().flow.value = UserInfo(UserMetadata().apply { this.name = name }, EmptyTagList, emptyList(), 1) + } + + private fun loadFollowList( + cache: MapCache, + vararg tags: Array, + ) { + val event = ContactListEvent("11".repeat(32), signer.pubKey, 10, arrayOf(*tags), "", "22".repeat(64)) + cache.getOrCreateAddressableNote(ContactListEvent.createAddress(signer.pubKey)).loadEvent(event, cache.getOrCreateUser(signer.pubKey), emptyList()) + } + + @Test + fun followListPetnameBeatsTheProfileName() = + runTest { + val cache = MapCache() + val state = UserAssertionsState(signer, cache, UserAssertionDecryptionCache(signer), EmojiPackState(signer, cache, backgroundScope)) + val user = cache.getOrCreateUser(target) + setProfileName(user, "Profile Name") + + assertEquals("Profile Name", state.displayNameFlow(user).first()) + + loadFollowList(cache, arrayOf("p", target, "", "bestie")) + assertEquals("bestie", state.displayNameFlow(user).first()) + // The synchronous initial value reads the index the flow just built. + assertEquals("bestie", state.cachedDisplayName(user)) + } + + @Test + fun withoutAPetnameTheProfileNameShows() = + runTest { + val cache = MapCache() + val state = UserAssertionsState(signer, cache, UserAssertionDecryptionCache(signer), EmojiPackState(signer, cache, backgroundScope)) + val user = cache.getOrCreateUser(target) + setProfileName(user, "Profile Name") + loadFollowList(cache, arrayOf("p", target)) + + assertEquals("Profile Name", state.displayNameFlow(user).first()) + assertEquals("Profile Name", state.cachedDisplayName(user)) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupStateTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupStateTest.kt index 813b8d82ae..f45d56aa1d 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupStateTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupStateTest.kt @@ -152,4 +152,36 @@ class FilterRelayGroupStateTest { assertEquals(listOf("article"), addressFilter.filter.tags!!["d"]) assertNull(addressFilter.filter.since) } + + @Test + fun `address pins sharing a kind and author collapse into one filter`() { + val channel = RelayGroupChannel(groupId) + val author = "b".repeat(64) + val other = "e".repeat(64) + channel.updatePinned( + GroupPinnedEvent( + id = "d".repeat(64), + pubKey = relaySignKey, + createdAt = 100L, + tags = + arrayOf( + arrayOf("d", "g1"), + arrayOf("a", "30023:$author:one"), + arrayOf("a", "30023:$author:two"), + arrayOf("a", "30023:$other:three"), + arrayOf("a", "30311:$author:live"), + ), + content = "", + sig = sig, + ), + ) + + val addressFilters = filterRelayGroupState(channel, since = null).filter { it.filter.authors != null } + assertEquals(3, addressFilters.size, "one filter per (kind, author), not per address") + + val byKey = addressFilters.associateBy { it.filter.kinds!!.single() to it.filter.authors!!.single() } + assertEquals(listOf("one", "two"), byKey[30023 to author]!!.filter.tags!!["d"]) + assertEquals(listOf("three"), byKey[30023 to other]!!.filter.tags!!["d"]) + assertEquals(listOf("live"), byKey[30311 to author]!!.filter.tags!!["d"]) + } } diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 84674960c8..9843e6cbf1 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -2856,6 +2856,7 @@ You are a member Invite code This relay admits members with an invite code from its operator + This relay does not publish its identity key (NIP-11 self), so its member list cannot be verified Relay membership list Member added to relay %1$d members added to relay From 119de9f68aa8a4011b437f3d74b037695a3d2f0b Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:52:58 +0000 Subject: [PATCH 24/24] chore: sync Crowdin translations and seed translator npub placeholders --- .../composeResources/values-pl-rPL/strings.xml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml b/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml index d5296874d7..a9bdb24080 100644 --- a/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml @@ -6165,6 +6165,20 @@ Koordynator nie odpowiedział, więc nic się nie zmieniło. Spróbuj ponownie za chwilę. %1$s nie opublikował jeszcze pakietu kluczy na tym koordynatorze, więc nie można go jeszcze dodać. Poproś go, aby najpierw uruchomił program cordn na tym koordynatorze. Nie można samodzielnie usunąć się z grupy cordn. + Nie udało się przydzielić %1$s do grupy, więc dodanie zostało przerwane. + %1$s nie jest w tej grupie. + Ta osoba + Ten koordynator obsłużył pakiet kluczy należący do kogoś innego, więc %1$s nie został dodany. Nic nie zostało zmienione. + Klucze szyfrujące grup, pakiety kluczy oraz wszystkie wiadomości w tych grupach. Każdy, kto je otworzy, może przeczytać treść rozmów. Hasło jest jedyną ochroną tych danych po opuszczeniu serwisu Amethyst. + Co znajduje się w pliku + Twoje nsec nie pozwala na przywrócenie grup cordn. Grupa cordn istnieje jako stan szyfrowania na tym urządzeniu oraz jako stream koordynatora, który nie może jej odczytywać — utrata urządzenia bez kopii zapasowej oznacza utratę grupy, łącznie ze wszystkimi danymi, które już się w niej znajdowały. + Eksport + Kopia zapasowa zapisana. + To nie zadziałało. Sprawdź hasło i plik. + Hasło + Przywróć + Przywrócenie powoduje zastąpienie grup „cordn” na tym urządzeniu grupami zawartymi w pliku. Funkcja ta jest przeznaczona dla telefonu, który przejął ustawienia od innego urządzenia — nie służy do jednoczesnego korzystania z tych samych grup na dwóch urządzeniach. + Wszystkie grupy Cordn znajdujące się obecnie na tym urządzeniu zostaną usunięte i zastąpione grupami zawartymi w pliku.\n\nNie przywracaj danych na drugim telefonie, dopóki pierwszy jest nadal w użyciu. Oba urządzenia miałyby wtedy te same klucze grupowe, a gdy oba wyślą wiadomość, grupa zostanie rozpadnięta dla wszystkich — w sposób niewidoczny i bez możliwości powrotu do poprzedniego stanu. Czy zamienić grupy cordn tego urządzenia? Przywróć Przywrócono.