diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RelaySupportsNip.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RelaySupportsNip.kt index 779efc2079..792daf74fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RelaySupportsNip.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RelaySupportsNip.kt @@ -43,6 +43,15 @@ fun relayAdvertisesNip( /** NIP-29 (relay-based groups): the relay must run it for its groups to be real. */ fun relayAdvertisesNip29(relay: NormalizedRelayUrl): Boolean = relayAdvertisesNip(relay, "29") +/** + * Whether [relayInfo] affirmatively signals that its relay does NOT run NIP-29 groups: the doc + * resolved with an explicit `supported_nips` list that lacks "29" and no `self` key (the field + * NIP-29 relays publish so clients can verify their relay-signed group metadata — see + * [isRelaySignedRelayGroup]). A doc with a null `supported_nips` proves nothing (still loading, + * or the fetch failed), so it never triggers the warning. + */ +fun looksLikeNonNip29Relay(relayInfo: Nip11RelayInformation): Boolean = relayInfo.supported_nips?.none { it == "29" } == true && relayInfo.self == null + /** * Whether [channel]'s relay-signed metadata is genuinely from its host relay, per NIP-29: * "these are addressable events signed by the relay keypair directly … as stated by the NIP-11 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt index efac0ed3ed..c8b2a534ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt @@ -90,7 +90,8 @@ fun NoteHeaderFirstRowDensityPreview() { val nav = EmptyNav() // The jump-to-parent arrow only renders in complete UI mode. - accountViewModel.settings.uiSettingsFlow.featureSet.value = FeatureSetType.COMPLETE + accountViewModel.settings.uiSettingsFlow.featureSet + .tryEmit(FeatureSetType.COMPLETE) // Let DisplayLocation resolve the geohash synchronously from the cache. CachedReversedGeoLocations.locationNames.put(GEOHASH, "Belo Horizonte") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index 6821e1ba79..6a1f8cb62c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -56,6 +56,7 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChann import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo +import com.vitorpamplona.amethyst.model.nip11RelayInfo.looksLikeNonNip29Relay import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -65,6 +66,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupWarmupSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelaySubscription import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.warningColor import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl @@ -139,11 +141,19 @@ fun RelayGroupChannelListScreen( ) { padding -> val myPubkey = accountViewModel.userProfile().pubkeyHex if (channels.isEmpty()) { + // An empty directory on a relay whose NIP-11 says it doesn't run NIP-29 is almost + // certainly the wrong relay, not a young one — say so instead of the generic empty text. + val notNip29 = looksLikeNonNip29Relay(relayInfo) Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { Text( - text = stringRes(R.string.relay_group_channels_empty), + text = + if (notNip29) { + stringRes(R.string.relay_group_channels_not_nip29) + } else { + stringRes(R.string.relay_group_channels_empty) + }, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = if (notNip29) MaterialTheme.colorScheme.warningColor else MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(32.dp), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt index e3068e3e06..53ebc9913c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt @@ -35,11 +35,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo +import com.vitorpamplona.amethyst.model.nip11RelayInfo.looksLikeNonNip29Relay import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.LargeRelayIconModifier +import com.vitorpamplona.amethyst.ui.theme.warningColor import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl @@ -54,6 +59,7 @@ fun RelayGroupServerRow( val host = relay?.displayUrl() ?: relayUrl val info = relay?.let { loadRelayInfo(it) } val name = info?.value?.name?.takeIf { it.isNotBlank() } ?: host + val missingNip29 = info?.value?.let { looksLikeNonNip29Relay(it) } == true Row( modifier = @@ -70,6 +76,7 @@ fun RelayGroupServerRow( loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), pingInMs = 0, + iconModifier = LargeRelayIconModifier, ) Column(Modifier.weight(1f)) { Text( @@ -87,6 +94,26 @@ fun RelayGroupServerRow( overflow = TextOverflow.Ellipsis, ) } + if (missingNip29) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + symbol = MaterialSymbols.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.warningColor, + modifier = Modifier.size(14.dp), + ) + Text( + text = stringRes(R.string.relay_group_relay_not_nip29), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.warningColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } } Icon( symbol = MaterialSymbols.ChevronRight, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index f83f92f634..33c5682ff7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -394,9 +394,20 @@ private fun RelayGroupRoomCompose( stringRes(R.string.relay_group_no_messages_yet) } + val groupPicture = channel.profilePicture()?.ifBlank { null } + val channelPicture = + if (groupPicture != null) { + groupPicture + } else { + // Missing/blank group picture: fall back to the host relay's NIP-11 icon + // (loadRelayInfo fetches the doc on a cache miss). + val relayInfo by loadRelayInfo(channel.groupId.relayUrl) + relayInfo.icon?.ifBlank { null } + } + ChannelName( channelIdHex = channel.groupId.id, - channelPicture = channel.profilePicture(), + channelPicture = channelPicture, channelTitle = { modifier -> Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index a75a45fc40..7f2e2ca265 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -138,9 +138,15 @@ class NotificationFeedFilter( setOf( BadgeAwardEvent.KIND, ChannelMessageEvent.KIND, - // kind-9 chat message (NIP-C7 / Concord): notifies only when it p-tags me — an inline - // reply (Concord's default reply mode) or an @-mention. A minichat reply is a kind-1111 - // CommentEvent below; a plain channel message tags no one and never reaches here. + // kind-9 chat message, shared by two features: + // • NIP-29 group chat: a reply to my group message is a kind-9 that p-tags me + // (see ChannelNewMessageViewModel), fetched at startup by + // filterGroupNotificationsToPubkey. + // • NIP-C7 / Concord: an inline reply (Concord's default reply mode) or an @-mention + // p-tags me; a minichat reply is a kind-1111 CommentEvent below. + // Either way it notifies only when it p-tags me — a plain channel message tags no one + // and never reaches here. Without kind 9 the acceptableEvent kind gate would drop these + // replies before the p-tag check, so they'd never render on the Notifications tab. ChatEvent.KIND, ChatMessageEvent.KIND, ChatMessageEncryptedFileHeaderEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/NewPublicChatButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/NewPublicChatButton.kt new file mode 100644 index 0000000000..4347f862ca --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/NewPublicChatButton.kt @@ -0,0 +1,52 @@ +/* + * 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.publicChats + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size26Modifier +import com.vitorpamplona.amethyst.ui.theme.Size55Modifier + +@Composable +fun NewPublicChatButton(nav: INav) { + FloatingActionButton( + onClick = { nav.nav(Route.ChannelMetadataEdit()) }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + symbol = MaterialSymbols.Add, + contentDescription = stringRes(id = R.string.new_public_chat), + modifier = Size26Modifier, + tint = Color.White, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsScreen.kt index ae177d56d9..88340294fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsScreen.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -73,6 +74,11 @@ fun PublicChatsScreen( } } }, + floatingButton = { + FabBottomBarPadded(nav) { + NewPublicChatButton(nav) + } + }, accountViewModel = accountViewModel, ) { RefresheableBox(publicChatsFeedContentState, true) { diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 23ab209d4c..d7098aaa94 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -3174,6 +3174,8 @@ 播放 自动 + 编辑昵称 + 仅对您可见 投射到设备 停止投影 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index f80f1a97bf..5eca19c786 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -412,6 +412,7 @@ and thus chat messages disappear over time Public Chat + Create a new public chat MLS Group No messages yet Public Chat Metadata @@ -2095,6 +2096,8 @@ Threads Open group No groups on this relay yet. + This relay does not advertise support for groups (NIP-29), so it may not host any. + May not support groups (NIP-29) Preparing invite… Private Invite-only diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationKindsContractTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationKindsContractTest.kt index 0e49022cf9..886bf711b9 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationKindsContractTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationKindsContractTest.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal import com.vitorpamplona.amethyst.commons.moderation.notifications.NotificationKinds import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import org.junit.Assert.assertTrue import org.junit.Test @@ -73,4 +74,23 @@ class NotificationKindsContractTest { unaccounted.isEmpty(), ) } + + /** + * A reply to my message inside a NIP-29 relay group is a kind-9 [ChatEvent] + * that p-tags me. It is fetched at startup by `filterGroupNotificationsToPubkey` + * (scoped `#p`=me + `#h`=my groups on the group's host relay), but the + * `acceptableEvent` gate first checks `kind in NOTIFICATION_KINDS`, so without + * kind 9 in the set the reply is dropped before the p-tag check and never + * surfaces on the Notifications tab. Pin its presence so it can't silently + * regress. + */ + @Test + fun `nip-29 group chat replies render on the Android notifications tab`() { + assertTrue( + "ChatEvent.KIND (9) is missing from NOTIFICATION_KINDS. NIP-29 group " + + "replies that p-tag the user would be dropped by the acceptableEvent " + + "kind gate before the p-tag check and never notify.", + ChatEvent.KIND in NotificationFeedFilter.NOTIFICATION_KINDS, + ) + } } diff --git a/cli/README.md b/cli/README.md index 928009c996..468211a712 100644 --- a/cli/README.md +++ b/cli/README.md @@ -389,14 +389,24 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever` | `amy profile show [USER]` | Print kind:0 metadata. USER accepts npub/nprofile/hex/NIP-05; defaults to self. | | `amy profile edit --name … --about … --picture URL …` | Patch and re-publish your kind:0. | | `amy follow USER` / `amy unfollow USER` | Add/remove USER from your kind:3 contact list (fetches the freshest list first). | -| `amy graperank [OBSERVER] [--offline] [--publish] [--min-rank N] [--publish-relay URL]` | Compute GrapeRank web-of-trust scores (0..1) over the follow/mute/report graph. Exhaustively crawls each user's kind:10002 outbox for their latest kind:3/10000/1984 until every discovered user is checked (no user cap), dropping reports the author retracted via NIP-09. With `--publish`, reconciles NIP-85 kind:30382 cards signed by a per-observer **service key**: publishes changed/new ranks (cutoff `--min-rank`, default 2), skips unchanged, and **retracts** (kind:5) any card whose target left the graph or fell below the cutoff. | -| `amy graperank operator [status \| relay … \| providers]` | Manage the machine's operator keys (independent of any account, under `~/.amy/operator/`). `relay` sets where cards + retractions publish; `status` shows the master pubkey and relays; `providers` lists the observer → service-pubkey map. | +| `amy graperank [OBSERVER] [--offline] [--min-rank N]` | Crawl + score: compute GrapeRank web-of-trust scores (0..1) over the follow/mute/report graph, then persist the result. Exhaustively crawls each user's kind:10002 outbox for their latest kind:3/10000/1984 until every discovered user is checked (no user cap), dropping reports the author retracted via NIP-09. **Every score run persists its result locally**: the ranks (cutoff `--min-rank`, default 2) are reconciled into the shared store as NIP-85 kind:30382 cards signed by a per-observer **service key** — changed ranks re-signed, unchanged skipped (no event-id churn), dropped targets retracted (kind:5). `--offline` skips the crawl. | +| `amy graperank crawl [OBSERVER] [--max-hops N] [--no-preconnect]` | Pipeline stage 1 — network only: crawl the follow/mute/report graph (kind 3/10000/1984/10002) into the local store, no scoring. Idempotent and cumulative: run it a few times to load everything, then `score`. | +| `amy graperank score [OBSERVER]` | Pipeline stage 2 — local only: score from the store and persist the cards (identical to bare `--offline`; same scoring flags). No network, so re-run with different `--rigor`/`--attenuation`/`--min-rank` without re-crawling. | +| `amy graperank publish [OBSERVER] [--relay URL[,URL…]]` | Pipeline stage 3 — transport only: make the operator relay(s) converge to the locally persisted card set — one NIP-77 up-only reconcile per relay over the service key's kind:30382 + kind:5 (nothing is re-scored or re-signed; a relay that can't reconcile gets the full set published instead). Also refreshes the observer's kind:10040 pointer when we hold their key. | +| `amy graperank rank USER [--provider PUBKEY] [--refresh]` | The consumer side: read the kind:30382 cards about USER — one rank per provider, newest card each. Local store first; `--refresh` (or a miss) drains the operator relays, the relays your kind:10040 declares, and the bootstrap set. | +| `amy graperank refresh [--down] [--up]` | Refresh every locally-known author's WoT record kinds (0/3/10002/1984) from their own outbox: one NIP-77 negentropy reconcile per write relay scoped to its authors, so the next `score` runs on current data without a full re-crawl. (`update` is the pre-rename alias.) | +| `amy graperank status` | Read-only local inventory, no network, no signing: WoT record counts in the store (the "do I need to crawl again?" answer), reachability-cache size + age, operator/service-key state, and the persisted card + retraction counts per observer. | +| `amy graperank operator [status \| relay … \| keys]` | Manage the machine's operator keys (independent of any account, under `~/.amy/operator/`). `relay` sets where `publish` sends cards + retractions; `status` shows the master pubkey and relays; `keys` lists the observer → service-pubkey map (`providers` is the pre-rename alias). | | `amy graperank register [PROVIDER] [--service KIND:TAG] [--relay URL]` | Declare a NIP-85 provider in your kind:10040 so clients can discover it (default: self as the `30382:rank` provider). | +| `amy graperank unregister PROVIDER [--service KIND:TAG] [--relay URL]` | The inverse of `register`: remove matching entries (public + private) from your kind:10040 and re-publish it. `--service`/`--relay` narrow the match; without them every entry for that provider key is dropped. | | `amy graperank providers [USER]` | List a user's declared NIP-85 trusted providers (public + your own private entries). | +| `amy fof get USER` | Follows-of-follows social proof: how many accounts you follow also follow USER. Single-hop, cheap — **not** the computed web of trust (that's `graperank`). Read from the local store; run `fof sync` first to freshen it. | +| `amy fof list [--threshold N] [--limit N]` | Rank accounts by that social-proof score — who's most-followed inside your network (discovery). Defaults: `--threshold 1`, `--limit 50`. | +| `amy fof sync [--timeout SECS]` | Pull your follows' latest kind:3 from the index relays so the next `get`/`list` is current. (`amy wot …` remains as a deprecation alias for all three.) | -#### Publishing GrapeRank scores (NIP-85) +#### GrapeRank scores are persisted locally, then published (NIP-85) -Ranks are published as kind:30382 cards, but **not** under your account key. A +Ranks are signed as kind:30382 cards, but **not** under your account key. A machine holds one **operator master** seed (`~/.amy/operator/`, stored via the same `--secret-backend` as accounts, independent of any account). From it a distinct, deterministic **service key** is derived per observer: @@ -406,23 +416,32 @@ serviceKey(observer) = sha256(masterPriv ‖ "graperank-provider:" ‖ observerH ``` Because kind:30382 is addressable (`pubkey + d-tag`), the stable per-observer key -means re-publishing **replaces** a target's card instead of orphaning it — and -losing everything but the master seed still re-derives every key. Set up once and -publish: +means re-signing **replaces** a target's card instead of orphaning it — and +losing everything but the master seed still re-derives every key. + +**Every score run persists its cards.** After scoring, Amy reconciles the result +into the local store: new or changed ranks (≥ `--min-rank`, default 2) are +signed; unchanged ranks are skipped (no new event id); and any card whose target +dropped out of the graph or fell below the cutoff is **retracted** with a kind:5 +(the store applies it; the tombstone is kept). The local store is the source of +truth — `graperank rank USER` reads it offline, and `graperank publish` mirrors +it out: ```bash amy graperank operator relay wss://relay.example.com # where all cards live -amy graperank --publish # sign with the observer's service key +amy graperank # crawl + score + persist cards locally +amy graperank publish # make the operator relay match the local set ``` -Each publish **reconciles** against what the service key already published: new or -changed ranks (≥ `--min-rank`, default 2) are signed and sent; unchanged ranks are -skipped (no new event id); and any card whose target dropped out of the graph or -fell below the cutoff is **retracted** with a kind:5. When the observer is your -own account (we hold the key), Amy also writes their kind:10040 pointing -`30382:rank → serviceKey @ operator relay` to their outbox, so clients can find -the cards. For a third-party observer, `graperank operator providers` prints the -`observer → service-pubkey` mapping to wire their kind:10040 out-of-band. +`publish` never re-scores or re-signs: it runs one NIP-77 up-only reconcile per +relay over the service key's kind:30382 + kind:5, so the relay converges to the +local card set (deletions included, lost cards restored); a relay that can't +negentropy-reconcile gets the full set published event-by-event instead. When +the observer is your own account (we hold the key), `publish` also writes their +kind:10040 pointing `30382:rank → serviceKey @ operator relay` to their outbox, +so clients can find the cards. For a third-party observer, `graperank operator +keys` prints the `observer → service-pubkey` mapping to wire their +kind:10040 out-of-band. ### Direct messages (NIP-17) @@ -542,6 +561,7 @@ the last facet removes R entirely. | `amy relay add URL` / `remove URL` | Fan-out to the transport lists (nip65 `both` + `dm` + `key-package`). | | `amy relay list` | Print every configured relay bucket. | | `amy relay publish-lists` | Broadcast every configured relay list to the union of your relays. | +| `amy relay probe [--timeout SECS] [--concurrency N]` | The relay census: mass-connect every relay the local store knows (all stored kind:10002 relays + the reachability cache) in parallel waves and record live/dead + measured `rtt-open` into the NIP-66 reachability cache (kind:30166). Reachability-aware commands (`graperank crawl`/`refresh`) read it to skip dead relays and pre-connect live ones. (`amy graperank probe` remains as an alias.) | ### Local store maintenance diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index d0fc574d7c..217e53814d 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -61,7 +61,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command · | NIP-51 lists (bookmarks, mute, follow sets) | 🆕 | `amethyst/model/nip51Lists/` | | NIP-57 zaps (send + verify) | 🆕 | Needs LN-URL plumbing; `amethyst/service/lnurl/`. | | NIP-65 outbox model queries | 🆕 | | -| NIP-85 GrapeRank web-of-trust (`amy graperank`) | ✅ | `GrapeRankCommand` — outbox-model crawl + scoring engine in `commons/wot/` (`GrapeRank`, `TrustGraph`, `TrustGraphBuilder`); publishes kind:30382 `ContactCardEvent` (diffed against prior ranks), plus `register` / `providers` for the kind:10040 `TrustProviderListEvent` discovery layer. | +| NIP-85 GrapeRank web-of-trust (`amy graperank`) | ✅ | `GrapeRankCommand` — outbox-model crawl + scoring engine in `commons/wot/` (`GrapeRank`, `TrustGraph`, `TrustGraphBuilder`); every score run persists kind:30382 `ContactCardEvent` cards to the local store (diffed against prior ranks, kind:5 retractions), `publish` mirrors that set to the operator relays via NIP-77 up-sync, `rank` reads cards back, plus `register` / `unregister` / `providers` for the kind:10040 `TrustProviderListEvent` discovery layer. | | NIP-72 communities | 🆕 | | | NIP-78 app-specific data (settings sync) | 🆕 | | | Long-form (NIP-23) publish / read | 🆕 | | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index 1c79588135..00aa30c9ad 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -516,11 +516,22 @@ class Context( * accept the exact same identifier formats. Throws on unrecognised input — * command handlers catch [IllegalArgumentException] at the top level and * translate to `{"error": "bad_args"}`. + * + * A pubkey is always 32 bytes, so we require exactly 64 hex chars: the shared + * resolver's fallback runs a lenient `Hex.decode` that turns a short + * bech32/hex-ish word (e.g. a mistyped verb like `sync`) into a bogus few-byte + * "pubkey" instead of failing — this rejects that so a bad OBSERVER/USER errors + * cleanly rather than silently scoring/fetching garbage. */ - suspend fun requireUserHex(input: String): com.vitorpamplona.quartz.nip01Core.core.HexKey = - com.vitorpamplona.quartz.nip05DnsIdentifiers - .resolveUserHexOrNull(input, nip05Client) - ?: throw IllegalArgumentException("Could not resolve user: '$input' (accepts npub, nprofile, 64-hex, or name@domain.tld)") + suspend fun requireUserHex(input: String): com.vitorpamplona.quartz.nip01Core.core.HexKey { + val notResolved = "Could not resolve user: '$input' (accepts npub, nprofile, 64-hex, or name@domain.tld)" + val hex = + com.vitorpamplona.quartz.nip05DnsIdentifiers + .resolveUserHexOrNull(input, nip05Client) + ?: throw IllegalArgumentException(notResolved) + require(hex.length == 64) { notResolved } + return hex + } /** * Outbox / NIP-65 write relays for this account. Read from the diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 8fe4996c7a..b627e9d6d9 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.cli.commands.EncryptCommand import com.vitorpamplona.amethyst.cli.commands.EventCommand import com.vitorpamplona.amethyst.cli.commands.FetchCommand import com.vitorpamplona.amethyst.cli.commands.FilterCommand +import com.vitorpamplona.amethyst.cli.commands.FofCommand import com.vitorpamplona.amethyst.cli.commands.FollowCommand import com.vitorpamplona.amethyst.cli.commands.GiftCommands import com.vitorpamplona.amethyst.cli.commands.GitCommands @@ -71,7 +72,6 @@ import com.vitorpamplona.amethyst.cli.commands.SubscribeCommand import com.vitorpamplona.amethyst.cli.commands.SyncCommand import com.vitorpamplona.amethyst.cli.commands.UseCommand import com.vitorpamplona.amethyst.cli.commands.VerifyCommand -import com.vitorpamplona.amethyst.cli.commands.WotCommand import com.vitorpamplona.amethyst.cli.commands.ZapCommand import com.vitorpamplona.amethyst.cli.commands.cashu.CashuCommands import com.vitorpamplona.amethyst.cli.commands.cashu.CashuMintCommands @@ -293,7 +293,14 @@ private suspend fun dispatch(argv: Array): Int { "podcast" -> PodcastCommands.dispatch(dataDir, tail) "podcast20" -> Podcast20Commands.dispatch(dataDir, tail) "bunker" -> BunkerCommand.run(dataDir, tail) - "wot" -> WotCommand.dispatch(dataDir, tail) + "fof" -> FofCommand.dispatch(dataDir, tail) + // `wot` overclaimed the whole web-of-trust concept for a cheap + // single-hop follower count; renamed to `fof` (follows-of-follows). + // Kept as a warning alias — the real WoT engine is `graperank`. + "wot" -> { + System.err.println("[amy] `wot` is deprecated — use `fof` (follows-of-follows). The computed web of trust is `graperank`.") + FofCommand.dispatch(dataDir, tail) + } "concord" -> ConcordCommands.dispatch(dataDir, tail) else -> { System.err.println("unknown subcommand: $head") @@ -486,6 +493,12 @@ private fun printUsage() { | relay list print every configured relay bucket | relay publish-lists broadcast every configured relay list | relay info URL fetch + print a relay's NIP-11 info document + | relay probe [--timeout SECS] relay census: mass-connect every relay the store + | [--concurrency N] knows and record live/dead + measured rtt-open + | into the reachability cache (NIP-66 kind:30166), + | so reachability-aware commands (graperank crawl/ + | refresh) skip dead relays and wait once + | (--timeout: per wave, default 15s) | outbox USER [--refresh] show USER's NIP-65 read/write relays (outbox model) | [--timeout SECS] (USER: npub|nprofile|hex|name@domain) | @@ -608,48 +621,46 @@ private fun printUsage() { | (USER: npub|nprofile|hex|name@domain) | |Web of Trust (GrapeRank): - | graperank [OBSERVER] compute subjective trust scores (0..1) for every - | [--limit N] [--min-score X] user reachable in the follow/mute/report graph. - | [--rigor X] [--attenuation X] Exhaustively crawls each user's kind:10002 outbox - | [--max-rounds N] [--max-hops N] for their latest kind:3/10000/1984 until every - | [--offline] [--timeout SECS] discovered user has been checked (no user cap; - | [--diagnose] --max-hops bounds follow distance, e.g. 8; - | --diagnose dumps per-relay telemetry: outcome - | mix, yield, latency, and a LIVE/DEAD + limits - | classification table of every relay contacted). - | [--publish] [--min-rank N] OBSERVER: npub|nprofile|hex|name@domain (default: - | [--publish-limit N] [--publish-relay URL] active account). --offline scores from the local - | store only. --publish reconciles NIP-85 kind:30382 - | cards signed by a per-observer service key: sends - | new/changed ranks >= --min-rank (default 2), skips - | unchanged, and retracts (kind:5) any card whose - | target left the graph or fell below the cutoff. - | graperank crawl [OBSERVER] network only: crawl the WoT graph (kind 3/10000/ - | [--max-hops N] [--preconnect-cap N] 1984/10002) into the local store without scoring. - | [--no-preconnect] Pre-connects every known-live relay in one parallel - | storm (seeded from the reachability cache). - | graperank probe [--timeout SECS] relay census: mass-connect every relay the store - | [--concurrency N] knows and record live/dead + measured rtt-open into - | the reachability cache (NIP-66 kind:30166), so the - | next crawl skips dead relays and waits once. - | graperank update [--down] [--up] refresh every locally-known author's WoT record kinds - | [--no-sync-deletions] [--timeout SECS] (0/3/10002/1984) from their own outbox: reads all - | [--relay-concurrency N] [--author-chunk N] kind:10002 in the store, groups authors by write - | [--min-authors N] [--report-limit N] relay, and runs one NIP-77 negentropy reconcile per - | relay scoped to its authors. Bidirectional by default; - | the deletion settle downloads the relay's kind:5 when - | an uploaded record was rejected (author retracted it). - | Falls back to a full paged download when a relay - | can't reconcile via negentropy. - | graperank operator [status|relay … manage the machine's operator keys (~/.amy/operator/, - | |providers] independent of accounts): relay sets where cards + - | retractions publish; status shows master + relays; - | providers lists observer -> service-pubkey. - | graperank register [PROVIDER] declare a NIP-85 provider in your kind:10040 so - | [--service KIND:TAG] [--relay URL] clients can discover it (default: self as the - | [--private] 30382:rank provider at your first outbox relay). - | graperank providers [USER] [--refresh] list a user's declared NIP-85 trusted providers - | [--timeout SECS] (default: active account). + | graperank [OBSERVER] crawl + score: subjective trust (0..1) over the + | [--min-rank N] [--offline] follow/mute/report graph, then persist the result + | [--limit N] [--min-score X] as local NIP-85 kind:30382 cards (ranks >= + | [--rigor X] [--attenuation X] --min-rank, default 2). --offline skips the crawl. + | [--max-hops N] [--diagnose] OBSERVER: npub|nprofile|hex|name@domain (self). + | graperank crawl [OBSERVER] network only: crawl the graph (kind 3/10000/1984/ + | [--max-hops N] [--max-rounds N] 10002) into the local store, no scoring. + | [--no-preconnect] [--preconnect-cap N] Idempotent — run a few times to load everything. + | graperank score [OBSERVER] local only: score from the store + persist cards + | (= bare --offline; same flags). No network. + | graperank publish [OBSERVER] push local cards to the operator relay(s) via a + | [--relay URL[,URL…]] [--timeout SECS] NIP-77 up-sync (nothing re-scored), and refresh + | [--relay-concurrency N] the observer's kind:10040 when we hold their key. + | graperank rank USER [--provider PUBKEY] read the kind:30382 cards about USER, one rank per + | [--refresh] [--timeout SECS] provider; --refresh drains relays on a miss. + | graperank status read-only local inventory: record counts, cache + | freshness, operator state, cards per observer. + | graperank refresh [--down] [--up] re-sync known authors' records (kind 0/3/10002/ + | [--relay-concurrency N] [--author-chunk N] 1984) from their outboxes via NIP-77, so score + | [--min-authors N] [--report-limit N] runs on current data. (`update` is the alias.) + | [--no-sync-deletions] [--timeout SECS] + | graperank register [PROVIDER] declare a NIP-85 provider in your kind:10040 + | [--service KIND:TAG] [--relay URL] (default: self as 30382:rank at your 1st outbox). + | [--private] + | graperank unregister PROVIDER remove matching entries from your kind:10040; + | [--service KIND:TAG] [--relay URL] --service/--relay narrow, else all for that key. + | graperank providers [USER] [--refresh] list a user's declared NIP-85 providers. + | [--timeout SECS] + | graperank operator operator keys (~/.amy/operator/): `relay URL…` + | [status | relay URL… | keys] sets the publish target; `keys` maps observer + | -> service-key. (default: status) + | graperank probe alias for `relay probe` (the relay census). + | + |Follows-of-follows (social proof — the cheap counterpart to graperank): + | fof get USER USER's score: how many accounts you follow also + | follow them (single-hop social proof, not trust). + | fof list [--threshold N] [--limit N] accounts ranked by that score — who's most + | followed inside your network (default N: 1 / 50). + | fof sync [--timeout SECS] refresh your follows' kind:3 from the index relays + | so the next get/list is current (`wot` = alias). | |Zaps (NIP-57): | zap user USER SATS build a profile zap-request, fetch a BOLT11 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/WotCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FofCommand.kt similarity index 90% rename from cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/WotCommand.kt rename to cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FofCommand.kt index c81ce32c4b..a10d700fbc 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/WotCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FofCommand.kt @@ -38,15 +38,21 @@ import kotlinx.coroutines.cancel import java.util.Collections /** - * `amy wot ` — Web-of-Trust score queries. + * `amy fof ` — follows-of-follows social-proof scores. * * The score for a pubkey X is the count of accounts in the active user's - * kind-3 follow set who also follow X. `get` and `list` are read-only — - * they hydrate the score map from whatever kind-3 events already live in - * the local event store. `sync` pulls fresh kind-3 events from the + * kind:3 follow set who also follow X — cheap single-hop social proof, NOT + * the computed web of trust (that's `amy graperank`). `get` and `list` are + * read-only — they hydrate the score map from whatever kind:3 events already + * live in the local event store. `sync` pulls fresh kind:3 events from the * configured relay pool so the next `get` / `list` is up to date. + * + * - `fof get USER` — X's score (how many of your follows follow X). + * - `fof list` — accounts ranked by score (discovery: who's most + * followed inside your network). + * - `fof sync` — refresh your follows' kind:3 from relays. */ -object WotCommand { +object FofCommand { suspend fun dispatch( dataDir: DataDir, rest: Array, @@ -61,13 +67,13 @@ object WotCommand { } } - private fun usage(): Int = Output.error("bad_args", "wot ") + private fun usage(): Int = Output.error("bad_args", "fof ") private suspend fun get( dataDir: DataDir, rest: Array, ): Int { - if (rest.isEmpty()) return Output.error("bad_args", "wot get ") + if (rest.isEmpty()) return Output.error("bad_args", "fof get ") val userArg = rest[0] Context.open(dataDir).use { ctx -> ctx.prepare() @@ -148,8 +154,8 @@ object WotCommand { // Amy's store lookup is suspending; can't do // it here. The dispatcher then falls through // to Phase 1 discovery for every author, which - // matches the old `amy wot sync` behaviour of - // always re-asking. A future optimisation + // matches the `fof sync` behaviour of always + // re-asking. A future optimisation // could pre-populate a `Map` before dispatch. null diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt index 243b88264d..302fc6d02e 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt @@ -36,30 +36,28 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent 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.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionIndex import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent -import com.vitorpamplona.quartz.nip66RelayMonitor.reachability.RelayProber +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent +import com.vitorpamplona.quartz.nip66RelayMonitor.reachability.RelayReachabilityStore import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.list.serviceProviders import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ProviderTypes import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceType import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.RankTag +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.withContext import java.net.InetSocketAddress import java.net.Socket @@ -80,29 +78,40 @@ import kotlin.math.roundToInt * unreachable outbox is retried a few times), then runs the scoring engine in * `commons/wot`. * - * Prints a ranked list (text, or one JSON object under `--json`). With - * `--publish`, results are also published as NIP-85 kind:30382 `ContactCardEvent` - * trusted assertions (one per scored user, `rank = round(score*100)`). - * - * The crawl and the computation are separable, because the crawl persists every - * event it fetches to the store and the score is a pure function over it: + * The pipeline is three separable stages, each with its own verb, and the local + * store is the source of truth between them (the crawl persists every event it + * fetches; the score is a pure function over the store; every score run persists + * its result as locally-signed NIP-85 kind:30382 cards): * - `amy graperank crawl [OBSERVER]` — network only: crawl the reachable graph's - * kind 3/10000/1984/10002 into the local store (aliased as the former `sync`). - * Idempotent and cumulative, so run it a few times to make sure everything is - * loaded. Scores nothing. - * - `amy graperank score [OBSERVER]` — local only: build the graph from the store - * and score (same as bare `--offline`). Instant and param-tunable; repeat with - * different `--rigor`/`--attenuation`/cutoffs without re-crawling. - * - `amy graperank probe` — the relay census: mass-connect every relay the store + * kind 3/10000/1984/10002 into the local store. Idempotent and cumulative, so + * run it a few times to make sure everything is loaded. Scores nothing. + * - `amy graperank status` — read-only inventory of all of the above: WoT record + * counts, reachability-cache freshness, operator state, persisted card sets. + * Answers "do I need to crawl again?" with no network and no signing. + * - `amy graperank score [OBSERVER]` — local only: build the graph from the store, + * score (same as bare `--offline`), and ALWAYS reconcile the result into the + * store as kind:30382 [ContactCardEvent] cards signed by the observer's + * per-observer service key (`rank = round(score*100)`, cutoff `--min-rank`): + * changed ranks are re-signed, unchanged ones skipped, dropped targets + * retracted with a kind:5. That persisted card set is what `publish` and + * `rank` reuse — scores are never ephemeral. + * - `amy graperank publish [OBSERVER]` — transport only: make the operator + * relay(s) converge to the local card set via a NIP-77 up-only reconcile + * (nothing is re-signed or re-scored), and refresh the observer's kind:10040 + * pointer when we hold their key. + * - `amy relay probe` — the relay census: mass-connect every relay the store * knows and record live/dead + measured RTT into the reachability cache, so the * next crawl skips the dead and pre-connects the living in one parallel storm. + * Lives in [RelayCommands] (`graperank probe` is kept as an alias). * - bare `amy graperank [OBSERVER]` — the convenience combo: crawl then score. * - * Sub-verbs complete the NIP-85 provider experience — the discovery layer that - * lets clients find and consume those assertions: + * Sub-verbs complete the NIP-85 experience — discovery and consumption: + * - `amy graperank rank USER` — read the kind:30382 cards about USER (local + * store first, `--refresh` to drain providers' relays): the consumer side. * - `amy graperank register` — advertise a `30382:rank` provider in the * account's kind:10040 [TrustProviderListEvent] (defaults to self, so a - * provider publishing ranks announces where to find them). + * provider publishing ranks announces where to find them); + * `amy graperank unregister PROVIDER` removes entries again. * - `amy graperank providers [USER]` — list a user's trusted providers. */ object GrapeRankCommand { @@ -196,14 +205,21 @@ object GrapeRankCommand { // NIP-05, or nothing) is the OBSERVER positional for a score computation. when (tail.firstOrNull()) { "register" -> register(dataDir, tail.drop(1).toTypedArray()) + "unregister" -> unregister(dataDir, tail.drop(1).toTypedArray()) "providers" -> providers(dataDir, tail.drop(1).toTypedArray()) "operator" -> operator(dataDir, tail.drop(1).toTypedArray()) - // `sync` is the pre-rename name kept as a back-compat alias; `crawl` is - // canonical (disambiguates from negentropy `amy sync` / `graperank update`). - "crawl", "sync" -> crawl(dataDir, tail.drop(1).toTypedArray()) - "probe" -> probe(dataDir, tail.drop(1).toTypedArray()) - "update" -> update(dataDir, tail.drop(1).toTypedArray()) + "crawl" -> crawl(dataDir, tail.drop(1).toTypedArray()) + "status" -> status(dataDir) + // The relay census outgrew graperank (it feeds the shared NIP-66 + // reachability cache every command reads) and moved to `amy relay + // probe`; this alias keeps the old spelling working. + "probe" -> RelayCommands.probe(dataDir, tail.drop(1).toTypedArray()) + // `refresh` is canonical (it refreshes the WoT record kinds from each + // author's outbox); `update` is the pre-rename back-compat alias. + "refresh", "update" -> refresh(dataDir, tail.drop(1).toTypedArray()) "score" -> run(dataDir, tail.drop(1).toTypedArray(), forceOffline = true) + "publish" -> publish(dataDir, tail.drop(1).toTypedArray()) + "rank" -> rank(dataDir, tail.drop(1).toTypedArray()) else -> run(dataDir, tail) } @@ -227,17 +243,11 @@ object GrapeRankCommand { // the result JSON, so keep local copies for that. val parkTimeoutMs = args.longFlag("park-timeout", 40L) * 1000 val insertBatch = args.intFlag("insert-batch", 500) - val doPublish = args.bool("publish") - // Publish cutoff: only cards with rank >= this are published; existing - // cards for targets below it (or gone from the graph) are retracted. Rank - // is round(score*100), so 2 drops the ~0.015-and-below barely-trusted tail. + // Card cutoff: only scores with rank >= this get a local kind:30382 card; + // existing cards for targets below it (or gone from the graph) are + // retracted. Rank is round(score*100), so 2 drops the ~0.015-and-below + // barely-trusted tail. val minRank = args.intFlag("min-rank", 2) - val publishLimit = args.intFlag("publish-limit", 500) - val publishRelaysArg = args.flag("publish-relay") - // Benchmark: build + sign one kind:30382 card per scored user (rank >= - // --min-rank) with a throwaway key and time it, WITHOUT publishing. - // Measures the id-hash + Schnorr-sign cost of emitting the full card set. - val benchSign = args.bool("bench-sign") val params = GrapeRankParams( @@ -348,84 +358,43 @@ object GrapeRankCommand { }, ) - if (doPublish) { - // The cards for THIS observer are signed by a dedicated, stable - // per-observer service key derived from the machine's operator - // master (see OperatorKeys) — not the account key. Same key across - // runs means re-signing a card replaces the addressable prior one. - val opKeys = ctx.dataDir.operatorKeys() - val serviceKey = opKeys.serviceKey(observer) - val serviceSigner = NostrSignerInternal(serviceKey) - val providerPubkey = serviceKey.pubKey.toHexKey() - result["provider_pubkey"] = providerPubkey + // Every score run persists its result: the desired card set (every user + // at or above the rank cutoff) is reconciled into the LOCAL store as + // kind:30382 cards — signed by a dedicated, stable per-observer service + // key derived from the machine's operator master (see OperatorKeys), + // not the account key. Changed ranks are re-signed (the addressable + // card is replaced), unchanged ones skipped, dropped targets retracted + // with a kind:5. `graperank publish` and `graperank rank` reuse this + // set; no relay is touched here. + val opKeys = ctx.dataDir.operatorKeys() + val serviceKey = opKeys.serviceKey(observer) + val providerPubkey = serviceKey.pubKey.toHexKey() + val desiredCards = + rankedIds + .filter { rankOf(scores[it]) >= minRank } + .map { graph.pubkeyOf(it) to rankOf(scores[it]) } - // Cards go to the operator's own relay(s), where the whole - // trusted-assertion set lives; --publish-relay overrides. - val relays = - publishRelaysArg - ?.split(",") - ?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it.trim()) } - ?.toSet() - ?.takeIf { it.isNotEmpty() } - ?: opKeys.operatorRelays() + val cardsStart = System.nanoTime() + val local = + GrapeRankPublisher(ctx.store) { System.err.println(it) } + .reconcileLocal( + providerSigner = NostrSignerInternal(serviceKey), + providerPubkey = providerPubkey, + scored = desiredCards, + ) + val cardsMs = (System.nanoTime() - cardsStart) / 1_000_000 + System.err.println( + "[graperank] local cards: ${local.signed} signed, ${local.unchanged} unchanged, " + + "${local.retracted} retracted in $cardsMs ms — `amy graperank publish` pushes them to the operator relay", + ) - if (relays.isEmpty()) { - result["published"] = 0 - result["publish_error"] = "no operator relay configured — run `amy graperank operator relay ` or pass --publish-relay" - } else { - // The scorer's desired card set: every user at or above the rank - // cutoff, as (target, rank). GrapeRankPublisher reconciles this - // against what this provider key already published and upserts / - // retracts the difference. - val publishable = - rankedIds - .filter { rankOf(scores[it]) >= minRank } - .map { graph.pubkeyOf(it) to rankOf(scores[it]) } - - val publisher = GrapeRankPublisher(ctx.store) { event, to -> ctx.publish(event, to) } - val pub = - publisher.reconcileAndPublish( - providerSigner = serviceSigner, - providerPubkey = providerPubkey, - scored = publishable, - relays = relays, - publishLimit = publishLimit, - ) - - result["skipped_unchanged"] = pub.skippedUnchanged - if (pub.truncated > 0) result["publish_truncated"] = pub.truncated - result["published"] = pub.published - result["publish_rejected"] = pub.publishRejected - result["deleted"] = pub.deleted - result["delete_rejected"] = pub.deleteRejected - result["published_kind"] = ContactCardEvent.KIND - result["published_to"] = relays.map { it.url } - - // Help the observer point clients at this provider: publish their - // kind:10040 (30382:rank -> providerPubkey @ operator relay) to - // their outbox — but only when we actually hold their key. - maybePublishObserverProviderList(ctx, observer, providerPubkey, relays.first())?.let { - result["observer_10040"] = it - } - } - } - - if (benchSign) { - // Throwaway key — these cards are for timing only and never leave - // the process, so no real identity signs them. - val tempSigner = NostrSignerInternal(KeyPair()) - val cards = - rankedIds - .filter { rankOf(scores[it]) >= minRank } - .map { graph.pubkeyOf(it) to rankOf(scores[it]) } - val signStart = System.nanoTime() - val signed = signCards(cards, tempSigner) - val signMs = (System.nanoTime() - signStart) / 1_000_000 - val perSec = if (signMs > 0) signed * 1000L / signMs else 0 - System.err.println("[graperank] signed $signed kind:30382 cards in $signMs ms ($perSec/s, temp key, not published)") - result["bench_signed"] = signed - result["bench_sign_ms"] = signMs - } + result["provider_pubkey"] = providerPubkey + result["min_rank"] = minRank + result["cards_total"] = desiredCards.size + result["cards_signed"] = local.signed + result["cards_unchanged"] = local.unchanged + result["cards_retracted"] = local.retracted + result["cards_ms"] = cardsMs Output.emit(result) return 0 @@ -543,9 +512,9 @@ object GrapeRankCommand { } /** - * `amy graperank crawl [OBSERVER]` — network-only WoT data crawl (aliased as the - * former `sync`). Crawls the reachable follow/mute/report graph into the local - * store (kind 3/10000/1984/10002) and reports what it loaded, WITHOUT scoring. + * `amy graperank crawl [OBSERVER]` — network-only WoT data crawl. Crawls the + * reachable follow/mute/report graph into the local store (kind + * 3/10000/1984/10002) and reports what it loaded, WITHOUT scoring. * Idempotent + cumulative: run it a few times to make sure everything is loaded, * then `graperank score`. */ @@ -586,79 +555,73 @@ object GrapeRankCommand { } /** - * `amy graperank probe [--timeout SECS] [--concurrency N]` — - * the relay census. Mass-connects the ENTIRE relay universe the local store knows - * (every relay advertised in any stored kind:10002, deduped per host, plus - * everything already in the reachability cache) in parallel waves with a no-op - * REQ, so the "is this relay alive, and how slow?" wait is paid once, up front, - * concurrently — then records per-relay verdicts with real measured `rtt-open` - * into the NIP-66 reachability cache (kind:30166). - * - * The next `graperank crawl` reads that cache to (a) skip the dead set without - * dialing it and (b) pre-connect the live set in one storm — separating "working - * but slow" (kept; the crawler's patient park path waits for them) from "not - * working" (skipped entirely). Typical flow the first time: - * `graperank crawl --max-hops 2` (cheap, saves the relay lists) → `graperank - * probe` → full `graperank crawl`. + * `amy graperank status` — read-only inventory of everything a GrapeRank run + * depends on, straight from the local store: WoT record counts (the "do I + * need to crawl again?" answer), reachability-cache size + freshness, + * operator/service-key state, and the persisted card set per observer. + * No network, no signing, no side effects. */ - private suspend fun probe( - dataDir: DataDir, - rest: Array, - ): Int { - val args = Args(rest) - val timeoutMs = args.longFlag("timeout", 15L) * 1000 - val waveSize = args.intFlag("concurrency", Context.defaultPreconnectCap) - + private suspend fun status(dataDir: DataDir): Int { Context.openOrAnonymous(dataDir).use { ctx -> - ctx.prepare() - val cached = ctx.reachability.snapshot() - val universe = RelayProber.knownRelayUniverse(ctx.store) + cached.live + cached.dead - if (universe.isEmpty()) { - Output.emit( + // Deliberately no ctx.prepare(): status must stay offline (nothing here + // needs a relay connection or marmot state). + suspend fun countKind(kind: Int) = ctx.store.count(Filter(kinds = listOf(kind))) + + // The store keeps only the newest replaceable event per author, so the + // kind:3 count is "users whose follow list we hold" — the graph size a + // `score` would see. + val contactLists = countKind(ContactListEvent.KIND) + + // Read-only reachability view: ctx.reachability would lazily derive the + // monitor key and thereby CREATE the operator master on a fresh machine; + // a throwaway signer reads the same kind:30166 records without that + // side effect (the signer is only used for writes). + val reach = RelayReachabilityStore(store = ctx.store, signer = NostrSignerInternal(KeyPair())).snapshot() + val newestReachRecord = + ctx.store + .query(Filter(kinds = listOf(RelayDiscoveryEvent.KIND), limit = 1)) + .firstOrNull() + ?.createdAt + + val opKeys = ctx.dataDir.operatorKeys() + val cards = + opKeys.providers().map { (observer, rec) -> linkedMapOf( - "probed" to 0, - "note" to "no relays known locally — run `amy graperank crawl` first to gather kind:10002 relay lists", - ), - ) - return 0 - } - - System.err.println( - "[relay-probe] probing ${universe.size} relays in waves of $waveSize " + - "(${timeoutMs / 1000}s per wave; open-files limit ${Context.maxFileDescriptors})", - ) - val result = - RelayProber(ctx.client) { System.err.println(it) } - .probe(universe, timeoutMs, waveSize) - - ctx.reachability.recordProbed(result.reachableRttMs(), result.deadRelays()) - - val rtts = - result.reachable - .map { it.rttOpenMs } - .filter { it >= 0 } - .sorted() - - fun pct(p: Int): Long? = if (rtts.isEmpty()) null else rtts[(rtts.size - 1) * p / 100] - val slowest = - result.reachable - .filter { it.rttOpenMs >= 0 } - .sortedByDescending { it.rttOpenMs } - .take(10) - .map { mapOf("relay" to it.relay.url, "rtt_open_ms" to it.rttOpenMs) } - val authWalled = result.reachable.count { it.error?.startsWith("closed:") == true } + "observer" to observer, + "provider_pubkey" to rec.providerPubKey, + "cards" to ctx.store.count(Filter(kinds = listOf(ContactCardEvent.KIND), authors = listOf(rec.providerPubKey))), + "retractions" to ctx.store.count(Filter(kinds = listOf(DeletionEvent.KIND), authors = listOf(rec.providerPubKey))), + ) + } Output.emit( linkedMapOf( - "probed" to result.verdicts.size, - "reachable" to result.reachable.size, - "dead" to result.dead.size, - "closed_by_policy" to authWalled, - "elapsed_ms" to result.elapsedMs, - "rtt_open_p50_ms" to pct(50), - "rtt_open_p90_ms" to pct(90), - "rtt_open_p99_ms" to pct(99), - "slowest" to slowest, + "store" to + linkedMapOf( + "profiles" to countKind(MetadataEvent.KIND), + "contact_lists" to contactLists, + "mute_lists" to countKind(MuteListEvent.KIND), + "reports" to countKind(ReportEvent.KIND), + "relay_lists" to countKind(AdvertisedRelayListEvent.KIND), + ), + "reachability" to + linkedMapOf( + "live" to reach.live.size, + "dead" to reach.dead.size, + "newest_record_age_s" to newestReachRecord?.let { (TimeUtils.now() - it).coerceAtLeast(0) }, + ), + "operator" to + if (opKeys.exists()) { + linkedMapOf( + "initialized" to true, + "master_pubkey" to opKeys.masterPubKey(), + "relays" to opKeys.operatorRelays().map { it.url }, + ) + } else { + linkedMapOf("initialized" to false) + }, + "cards" to cards, + "note" to if (contactLists == 0) "no contact lists in the local store — run `amy graperank crawl` first" else null, ), ) } @@ -666,7 +629,7 @@ object GrapeRankCommand { } /** - * `amy graperank update [flags]` — refresh every locally-known author's WoT + * `amy graperank refresh [flags]` (alias: `update`) — refresh every locally-known author's WoT * record kinds (0 / 3 / 10002 / 1984) straight from their own outbox, so the * next `graperank score` runs on current data without a full follow-graph crawl. * @@ -685,7 +648,7 @@ object GrapeRankCommand { * `--report-limit N` (per-relay rows in the JSON, default 50), * `--down` / `--up` / `--no-sync-deletions`. */ - private suspend fun update( + private suspend fun refresh( dataDir: DataDir, rest: Array, ): Int { @@ -714,7 +677,7 @@ object GrapeRankCommand { down = downFlag || !upFlag, up = upFlag || !downFlag, syncDeletions = !args.bool("no-sync-deletions"), - relayConcurrency = args.intFlag("relay-concurrency", 4), + relayConcurrency = args.intFlag("relay-concurrency", args.intFlag("concurrency", 4)), authorChunk = args.intFlag("author-chunk", 500), minAuthors = args.intFlag("min-authors", 1), idleTimeoutMs = args.longFlag("timeout", 30L) * 1000, @@ -780,45 +743,214 @@ object GrapeRankCommand { } /** - * Build + sign one kind:30382 [ContactCardEvent] per (target, rank), fanned - * out across CPU cores (id-hash + Schnorr sign is CPU-bound). The signed - * events are discarded — this only exists to time card generation. Returns - * the number signed. + * `amy graperank publish [OBSERVER] [--relay URL[,URL…]] [--relay-concurrency N] [--timeout SECS]` + * + * Transport only: make the operator relay(s) converge to the local card set + * that `graperank score` persisted for OBSERVER (default: the active account). + * One NIP-77 up-only reconcile per relay over the provider service key's + * kind:30382 cards + kind:5 retractions — nothing is re-scored or re-signed, + * and a card the relay lost is restored. A relay that can't reconcile gets the + * full local set blast-published instead. Also refreshes the observer's + * kind:10040 provider pointer when we hold their key. */ - private suspend fun signCards( - cards: List>, - signer: NostrSigner, + private suspend fun publish( + dataDir: DataDir, + rest: Array, ): Int { - if (cards.isEmpty()) return 0 - val cores = Runtime.getRuntime().availableProcessors().coerceAtLeast(1) - val chunkSize = ((cards.size + cores - 1) / cores).coerceAtLeast(1) - return coroutineScope { - cards - .chunked(chunkSize) - .map { chunk -> - async(Dispatchers.Default) { - for ((target, rank) in chunk) { - ContactCardEvent.create( - targetUser = target, - signer = signer, - publicInitializer = { add(RankTag.assemble(rank)) }, + val args = Args(rest) + val observerArg = args.positionalOrNull(0) + val relayArg = args.flag("relay") + // --relay-concurrency is canonical for "relays worked at once" across the + // graperank verbs; --concurrency is accepted everywhere as its alias. + val relayConcurrency = args.intFlag("relay-concurrency", args.intFlag("concurrency", 4)) + // Idle watchdog per relay reconcile (not a total budget), like `refresh`. + val idleTimeoutMs = args.longFlag("timeout", 30L) * 1000 + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val observer = observerArg?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex + val opKeys = ctx.dataDir.operatorKeys() + val providerPubkey = opKeys.serviceKey(observer).pubKey.toHexKey() + + // Cards live on the operator's own relay(s); --relay overrides. + val relays = + relayArg + ?.split(",") + ?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it.trim()) } + ?.toSet() + ?.takeIf { it.isNotEmpty() } + ?: opKeys.operatorRelays() + if (relays.isEmpty()) { + return Output.error("no_relays", "no operator relay configured — run `amy graperank operator relay ` or pass --relay") + } + + val publisher = GrapeRankPublisher(ctx.store) { System.err.println(it) } + val sync = + publisher.syncToRelays( + client = ctx.client, + providerPubkey = providerPubkey, + relays = relays, + relayConcurrency = relayConcurrency, + idleTimeoutMs = idleTimeoutMs, + ) + + if (sync.cards == 0 && sync.deletions == 0) { + Output.emit( + linkedMapOf( + "observer" to observer, + "provider_pubkey" to providerPubkey, + "cards" to 0, + "note" to "no local cards for this observer — run `amy graperank score` first", + ), + ) + return 0 + } + + // Help the observer point clients at this provider: publish their + // kind:10040 (30382:rank -> providerPubkey @ operator relay) to + // their outbox — but only when we actually hold their key. + val observer10040 = maybePublishObserverProviderList(ctx, observer, providerPubkey, relays.first()) + + Output.emit( + linkedMapOf( + "observer" to observer, + "provider_pubkey" to providerPubkey, + "cards" to sync.cards, + "deletions" to sync.deletions, + "relays" to sync.perRelay.size, + "relays_ok" to sync.perRelay.count { it.ok }, + "relays_failed" to sync.perRelay.count { !it.ok }, + "uploaded" to sync.perRelay.sumOf { it.uploaded }, + "fallback_published" to sync.perRelay.sumOf { it.fallbackPublished }, + "per_relay" to + sync.perRelay.map { + linkedMapOf( + "relay" to it.relay.url, + "uploaded" to it.uploaded, + "fallback_published" to it.fallbackPublished, + "fallback_rejected" to it.fallbackRejected, + "error" to it.error, ) - } - chunk.size - } - }.awaitAll() - .sum() + }, + "observer_10040" to observer10040, + ), + ) + return 0 } } /** - * `amy graperank operator [status | relay … | providers]` + * `amy graperank rank USER [--provider PUBKEY] [--refresh] [--timeout SECS]` + * + * The consumer side of NIP-85: read the kind:30382 cards about USER and print + * one rank per provider (newest card each). Cache-first — a `graperank score` + * run on this machine already left its cards in the store — falling back to a + * relay drain on a miss or with `--refresh` (sources: the operator relays, the + * relays declared in the account's kind:10040, and the bootstrap set). + * `--provider` narrows to one provider key. + */ + private suspend fun rank( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val userArg = + args.positionalOrNull(0) + ?: return Output.error("bad_args", "usage: amy graperank rank USER [--provider PUBKEY] [--refresh] [--timeout SECS]") + val providerArg = args.flag("provider") + val refresh = args.bool("refresh") + val timeoutMs = args.longFlag("timeout", 8L) * 1000 + + Context.openOrAnonymous(dataDir).use { ctx -> + ctx.prepare() + val user = ctx.requireUserHex(userArg) + val provider = providerArg?.let { ctx.requireUserHex(it) } + val cardFilter = + Filter( + kinds = listOf(ContactCardEvent.KIND), + tags = mapOf("d" to listOf(user)), + authors = provider?.let { listOf(it) }, + ) + + suspend fun localCards(): List = ctx.store.query(cardFilter).filterIsInstance() + + var cards = localCards() + if (refresh || cards.isEmpty()) { + val relays = rankSourceRelays(ctx, provider) + if (relays.isNotEmpty()) { + ctx.drain(relays.associateWith { listOf(cardFilter.copy(limit = 50)) }, timeoutMs) + cards = localCards() + } + } + + // Newest card per provider key, strongest assertion first. + val newest = + cards + .groupBy { it.pubKey } + .mapNotNull { (_, list) -> list.maxByOrNull { it.createdAt } } + .sortedWith(compareByDescending { it.rank() ?: -1 }.thenByDescending { it.createdAt }) + + // A provider key this machine's operator master derived maps back to + // the observer whose subjective view the rank expresses. + val providerToObserver = + ctx.dataDir + .operatorKeys() + .providers() + .entries + .associate { (observer, rec) -> rec.providerPubKey to observer } + + Output.emit( + linkedMapOf( + "user" to user, + "found" to newest.isNotEmpty(), + "cards" to + newest.map { card -> + linkedMapOf( + "provider" to card.pubKey, + "rank" to card.rank(), + "observer" to providerToObserver[card.pubKey], + "created_at" to card.createdAt, + "event_id" to card.id, + ) + }, + ), + ) + return 0 + } + } + + /** + * Relays worth draining for someone's kind:30382 cards: the machine's own + * operator relay(s), every relay the account's kind:10040 declares for a + * 30382 service (narrowed to [provider] when given), and the bootstrap set. + */ + private suspend fun rankSourceRelays( + ctx: Context, + provider: HexKey?, + ): Set { + val declared = + if (!ctx.anonymous) { + providerListOf(ctx, ctx.identity.pubKeyHex) + ?.serviceProviders() + ?.filter { it.service.kind == ContactCardEvent.KIND && (provider == null || it.pubkey == provider) } + ?.map { it.relayUrl } + .orEmpty() + } else { + emptyList() + } + return ctx.dataDir.operatorKeys().operatorRelays() + declared + ctx.bootstrapRelays() + Constants.eventFinderRelays + } + + /** + * `amy graperank operator [status | relay … | keys]` * * Manage the machine's operator keys used to sign trusted-assertion cards. - * - `status` (default): master pubkey, configured relay(s), provider count. + * - `status` (default): master pubkey, configured relay(s), service-key count. * - `relay …`: set the operator relay(s) the cards + retractions publish * to; creates the operator master on first use. - * - `providers`: the observer -> provider-pubkey mapping learned so far. + * - `keys` (alias: the pre-rename `providers`, which collided with + * `graperank providers`): the observer -> service-key mapping derived so + * far — what a third-party observer wires into their kind:10040. */ private fun operator( dataDir: DataDir, @@ -835,11 +967,11 @@ object GrapeRankCommand { 0 } - "providers" -> { + "keys", "providers" -> { Output.emit( mapOf( "master_pubkey" to if (opKeys.exists()) opKeys.masterPubKey() else null, - "providers" to opKeys.providers().map { (observer, rec) -> mapOf("observer" to observer, "provider_pubkey" to rec.providerPubKey) }, + "keys" to opKeys.providers().map { (observer, rec) -> mapOf("observer" to observer, "provider_pubkey" to rec.providerPubKey) }, ), ) 0 @@ -854,14 +986,14 @@ object GrapeRankCommand { "initialized" to true, "master_pubkey" to opKeys.masterPubKey(), "relays" to opKeys.operatorRelays().map { it.url }, - "providers" to opKeys.providers().size, + "keys" to opKeys.providers().size, ), ) } 0 } - else -> Output.error("bad_args", "unknown operator subcommand '${rest.first()}' (status | relay | providers)") + else -> Output.error("bad_args", "unknown operator subcommand '${rest.first()}' (status | relay | keys)") } } @@ -948,6 +1080,96 @@ object GrapeRankCommand { } } + /** + * `amy graperank unregister PROVIDER [--service KIND:TAG] [--relay URL] [--timeout SECS]` + * + * The inverse of [register]: drop matching provider entries — public AND + * private — from the account's kind:10040 [TrustProviderListEvent] and + * re-publish it. PROVIDER is required; `--service` / `--relay` narrow the + * match when the same key is listed for several services or relays — without + * them, every entry for that provider key is removed. Fetches the freshest + * list first so the removal applies to the current provider set. + */ + private suspend fun unregister( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val providerArg = + args.positionalOrNull(0) + ?: args.flag("provider") + ?: return Output.error("bad_args", "usage: amy graperank unregister PROVIDER [--service KIND:TAG] [--relay URL]") + val serviceArg = args.flag("service") + val relayArg = args.flag("relay") + val timeoutMs = args.longFlag("timeout", 8L) * 1000 + + val service = + serviceArg?.let { + ServiceType.parse(it) ?: return Output.error("bad_args", "--service must be KIND:TAG, e.g. 30382:rank") + } + val relay = + relayArg?.let { + RelayUrlNormalizer.normalizeOrNull(it) ?: return Output.error("bad_args", "--relay is not a valid relay URL") + } + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val provider = ctx.requireUserHex(providerArg) + val outbox = ctx.outboxRelays() + + val latest = + fetchLatestProviderList(ctx, ctx.identity.pubKeyHex, outbox, timeoutMs) + ?: return Output.error("not_found", "no kind:10040 provider list found for this account") + + fun matches(tag: ServiceProviderTag) = + tag.pubkey == provider && + (service == null || tag.service == service) && + (relay == null || tag.relayUrl == relay) + + val publicMatches = latest.serviceProviders().filter(::matches) + val privateMatches = + latest + .privateTags(ctx.signer) + ?.serviceProviders() + .orEmpty() + .filter(::matches) + val toRemove = (publicMatches + privateMatches).distinct() + + if (toRemove.isEmpty()) { + Output.emit( + mapOf( + "provider" to provider, + "changed" to false, + "removed" to emptyList(), + "based_on" to latest.id, + ), + ) + return 0 + } + + // remove() strips the tag from both the public and the private set, + // re-signing each round; only the final version is published. + var event = latest + for (tag in toRemove) { + event = TrustProviderListEvent.remove(event, tag, ctx.signer) + } + + val ack = ctx.publish(event, outbox) + Output.emit( + mapOf( + "provider" to provider, + "changed" to true, + "removed" to toRemove.map { mapOf("service" to it.service.toValue(), "relay" to it.relayUrl.url) }, + "event_id" to event.id, + "based_on" to latest.id, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + "rejected_by" to ack.filterValues { !it }.keys.map { it.url }, + ), + ) + return 0 + } + } + /** * `amy graperank providers [USER] [--refresh] [--timeout SECS]` * diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt index 74f48c1506..255808199e 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType +import com.vitorpamplona.quartz.nip66RelayMonitor.reachability.RelayProber import okhttp3.OkHttpClient import okhttp3.Request @@ -86,7 +87,7 @@ import okhttp3.Request */ object RelayCommands { private const val USAGE = - "relay …" + "relay …" // ------------------------------------------------------------------ // Flat buckets — a plain list of relay URLs, one Nostr replaceable kind. @@ -216,6 +217,7 @@ object RelayCommands { // `info` is also intercepted in Main before account resolution // (it needs no account); routed here too for when one exists. "info" -> info(rest) + "probe" -> probe(dataDir, rest) "add" -> fanOut(dataDir, Args(rest), add = true) "remove", "rm" -> fanOut(dataDir, Args(rest), add = false) "outbox" -> facetVerb(dataDir, Facet.OUTBOX, rest) @@ -269,6 +271,94 @@ object RelayCommands { } } + // ------------------------------------------------------------------ + // relay probe — the relay census (feeds the NIP-66 reachability cache) + // ------------------------------------------------------------------ + + /** + * `amy relay probe [--timeout SECS] [--concurrency N]` — + * the relay census. Mass-connects the ENTIRE relay universe the local store knows + * (every relay advertised in any stored kind:10002, deduped per host, plus + * everything already in the reachability cache) in parallel waves with a no-op + * REQ, so the "is this relay alive, and how slow?" wait is paid once, up front, + * concurrently — then records per-relay verdicts with real measured `rtt-open` + * into the NIP-66 reachability cache (kind:30166). + * + * Every reachability-aware command reads that cache to skip the dead set without + * dialing it: `graperank crawl` also pre-connects the live set in one storm, + * separating "working but slow" (kept; the crawler's patient park path waits for + * them) from "not working" (skipped entirely). Typical flow the first time: + * `graperank crawl --max-hops 2` (cheap, saves the relay lists) → `relay probe` + * → full `graperank crawl`. (`graperank probe` is kept as an alias.) + */ + suspend fun probe( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + // Per probe WAVE, not per relay or total — a wave's stragglers are cut off + // together when it elapses. + val timeoutMs = args.longFlag("timeout", 15L) * 1000 + // Relays dialed at once; --relay-concurrency accepted as the alias the + // graperank verbs spell it with. + val waveSize = args.intFlag("concurrency", args.intFlag("relay-concurrency", Context.defaultPreconnectCap)) + + Context.openOrAnonymous(dataDir).use { ctx -> + ctx.prepare() + val cached = ctx.reachability.snapshot() + val universe = RelayProber.knownRelayUniverse(ctx.store) + cached.live + cached.dead + if (universe.isEmpty()) { + Output.emit( + linkedMapOf( + "probed" to 0, + "note" to "no relays known locally — run `amy graperank crawl` first to gather kind:10002 relay lists", + ), + ) + return 0 + } + + System.err.println( + "[relay-probe] probing ${universe.size} relays in waves of $waveSize " + + "(${timeoutMs / 1000}s per wave; open-files limit ${Context.maxFileDescriptors})", + ) + val result = + RelayProber(ctx.client) { System.err.println(it) } + .probe(universe, timeoutMs, waveSize) + + ctx.reachability.recordProbed(result.reachableRttMs(), result.deadRelays()) + + val rtts = + result.reachable + .map { it.rttOpenMs } + .filter { it >= 0 } + .sorted() + + fun pct(p: Int): Long? = if (rtts.isEmpty()) null else rtts[(rtts.size - 1) * p / 100] + val slowest = + result.reachable + .filter { it.rttOpenMs >= 0 } + .sortedByDescending { it.rttOpenMs } + .take(10) + .map { mapOf("relay" to it.relay.url, "rtt_open_ms" to it.rttOpenMs) } + val authWalled = result.reachable.count { it.error?.startsWith("closed:") == true } + + Output.emit( + linkedMapOf( + "probed" to result.verdicts.size, + "reachable" to result.reachable.size, + "dead" to result.dead.size, + "closed_by_policy" to authWalled, + "elapsed_ms" to result.elapsedMs, + "rtt_open_p50_ms" to pct(50), + "rtt_open_p90_ms" to pct(90), + "rtt_open_p99_ms" to pct(99), + "slowest" to slowest, + ), + ) + } + return 0 + } + // ------------------------------------------------------------------ // Flat-bucket verbs // ------------------------------------------------------------------ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionAction.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionAction.kt index ffeeaff9d3..6f07b1b5ad 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionAction.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionAction.kt @@ -24,12 +24,16 @@ import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User 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.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds import com.vitorpamplona.quartz.nip17Dm.NIP17Factory import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip29RelayGroups.groupId +import com.vitorpamplona.quartz.nip29RelayGroups.hTag import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag /** @@ -64,21 +68,39 @@ object ReactionAction { throw IllegalStateException("Cannot react publicly to a private rumor") } - // Handle custom emoji reactions (format: ":emoji_name:") - val template = - if (reaction.startsWith(":")) { - val emojiUrl = EmojiUrlTag.decode(reaction) - if (emojiUrl != null) { - ReactionEvent.build(emojiUrl, eventHint) - } else { - // Fallback to text if emoji decode fails - ReactionEvent.build(reaction, eventHint) - } - } else { - ReactionEvent.build(reaction, eventHint) - } + return signer.sign(buildPublicReaction(eventHint, reaction)) + } - return signer.sign(template) + /** + * Builds a public reaction template for [eventHint], decoding a custom-emoji + * reaction when present and falling back to plain text otherwise. + * + * When the target is a NIP-29 group event (it carries an `h` tag), the + * reaction copies that `h` tag so the like stays scoped to the group and + * lands on the group's host relay — where the recipient's group-notification + * subscription (`#p`=them + `#h`=their groups, kind 7 included) can match it. + * Without the `h` tag the like is a plain kind-7 that the host-relay query + * never sees, so a reaction to someone's group message would only reach them + * on the off chance NIP-65 routing delivered it to one of their inbox relays + * — never for a host-relay-only group. This mirrors how kind-9 replies carry + * the `h` tag to be notifiable. + */ + private fun buildPublicReaction( + eventHint: EventHintBundle, + reaction: String, + ): EventTemplate { + val groupScope: TagArrayBuilder.() -> Unit = { + eventHint.event.groupId()?.let { hTag(it) } + } + + if (reaction.startsWith(":")) { + val emojiUrl = EmojiUrlTag.decode(reaction) + if (emojiUrl != null) { + return ReactionEvent.build(emojiUrl, eventHint, initializer = groupScope) + } + // Fallback to text if emoji decode fails + } + return ReactionEvent.build(reaction, eventHint, initializer = groupScope) } /** @@ -159,19 +181,7 @@ object ReactionAction { ) } else { // Public reaction - val template = - if (reaction.startsWith(":")) { - val emojiUrl = EmojiUrlTag.decode(reaction) - if (emojiUrl != null) { - ReactionEvent.build(emojiUrl, eventHint) - } else { - ReactionEvent.build(reaction, eventHint) - } - } else { - ReactionEvent.build(reaction, eventHint) - } - - onPublic(signer.sign(template)) + onPublic(signer.sign(buildPublicReaction(eventHint, reaction))) } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionActionTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionActionTest.kt index c17e2aed36..bcf477442a 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionActionTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionActionTest.kt @@ -27,9 +27,12 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.pTags import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip29RelayGroups.hTag +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.test.fail @@ -57,12 +60,46 @@ class ReactionActionTest { publicCalls++ assertTrue(reaction.sig.isNotEmpty(), "public reaction must be signed") assertTrue(reaction.tags.any { it.size >= 2 && it[0] == "e" && it[1] == note.id }) + assertFalse( + reaction.tags.any { it.isNotEmpty() && it[0] == "h" }, + "a reaction to a non-group note must not carry an `h` tag", + ) }, onPrivate = { fail("reaction to a public note must not be gift-wrapped") }, ) assertEquals(1, publicCalls) } + @Test + fun reactionToRelayGroupMessage_carriesTheGroupHTag() = + runTest { + // A NIP-29 group chat message: a kind-9 ChatEvent scoped by `h`. + val groupId = "abcd1234" + val groupMessage = aliceSigner.sign(ChatEvent.build("gm") { hTag(groupId) }) + + var publicCalls = 0 + ReactionAction.reactToWithGroupSupport( + eventHint = EventHintBundle(groupMessage, null), + reaction = "+", + signer = bobSigner, + onPublic = { reaction -> + publicCalls++ + // Standard NIP-25 targeting … + assertTrue(reaction.tags.any { it.size >= 2 && it[0] == "e" && it[1] == groupMessage.id }) + assertTrue(reaction.tags.any { it.size >= 2 && it[0] == "p" && it[1] == aliceSigner.pubKey }) + // … plus the group `h` tag copied from the target, so the like + // stays in the group and the recipient's `#p`+`#h` host-relay + // notification query can match it. + assertTrue( + reaction.tags.any { it.size >= 2 && it[0] == "h" && it[1] == groupId }, + "a reaction to a NIP-29 group message must copy the group's `h` tag", + ) + }, + onPrivate = { fail("a public group message reaction must not be gift-wrapped") }, + ) + assertEquals(1, publicCalls) + } + @Test fun reactionToUnsealedRumor_isGiftWrappedToAllParticipants() = runTest { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisher.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisher.kt index 1ccc26cea2..9ff48d83ef 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisher.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisher.kt @@ -22,6 +22,9 @@ package com.vitorpamplona.quartz.experimental.graperank import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.NegentropyStoreSync +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirmDetailed import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner @@ -29,92 +32,165 @@ import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.RankTag +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlin.coroutines.cancellation.CancellationException /** - * Publishes a set of GrapeRank scores as NIP-85 kind:30382 [ContactCardEvent] - * trusted assertions (one `rank` card per scored user), reconciled against what - * this provider key has already published so a repeat run only writes what moved. + * Persists a set of GrapeRank scores as NIP-85 kind:30382 [ContactCardEvent] + * trusted assertions in the local [IEventStore] — the durable, reusable form of a + * score run — and pushes that local card set out to the operator's relays on + * demand. The store is the source of truth; the two halves are separable: * - * Reconciliation, given the desired `(target, rank)` set the scorer produced: - * - **skip** a target whose stored card already carries the same rank string — - * re-signing an unchanged card would churn a new event id for no client benefit; - * - **upsert** a target whose rank changed (or that has no card yet), up to a - * publish limit; - * - **retract** every stored card whose target is no longer in the desired set - * (it fell below the caller's cutoff, or dropped out of the graph) with a NIP-09 - * kind:5 deletion, batched so the frame stays under the ~64KB event cap. - * - * Transport-agnostic like [GrapeRankCrawler]: it reads prior cards from an - * [IEventStore] and emits through an injected [publish] function (event + relays → - * per-relay ack), so the store/relay wiring stays in the application while the - * reconcile + card-construction logic is reusable (e.g. by the Android app). + * - [reconcileLocal] runs after every scoring pass. It diffs the desired + * `(target, rank)` set against the cards this provider key already holds in the + * store, signs + inserts only what moved (an unchanged rank is skipped so no new + * event id churns), and retracts every card whose target dropped out with a + * NIP-09 kind:5. The store applies kind:5 on insert, so a retracted card + * disappears locally while the deletion event remains as the durable tombstone + * to propagate. + * - [syncToRelays] is pure transport: a NIP-77 up-only reconcile of everything the + * provider key authored (kind:30382 cards + kind:5 retractions) against each + * relay, so the relay converges to the local set — nothing is re-signed, and a + * card the relay lost (or never had) is restored. A relay that can't reconcile + * gets the full local set blast-published instead (relays dedup by id). */ class GrapeRankPublisher( private val store: IEventStore, - private val publish: suspend (Event, Set) -> Map, + private val log: (String) -> Unit = {}, ) { - /** Outcome counts for one reconcile: what was written, retracted, and skipped. */ - class Result( - val published: Int, - val publishRejected: Int, - val deleted: Int, - val deleteRejected: Int, - val skippedUnchanged: Int, - /** Changed cards beyond [publishLimit] that were not upserted this run. */ - val truncated: Int, + /** Outcome of one [reconcileLocal]: what was signed, retracted, and left alone. */ + class LocalResult( + /** New or rank-changed cards signed and inserted into the store. */ + val signed: Int, + /** Stale cards retracted (kind:5) because their target left the desired set. */ + val retracted: Int, + /** Desired cards whose stored rank already matched — no new signature. */ + val unchanged: Int, ) /** * Reconcile the desired [scored] `(target, rank)` set (the caller has already - * applied any rank cutoff) against the cards [providerPubkey] previously - * published, then upsert the changes and retract the stale cards, all signed by - * [providerSigner]. At most [publishLimit] changed cards are upserted per run. + * applied any rank cutoff) into the local store, signed by [providerSigner]: + * upsert the changed cards, retract the stale ones, skip the unchanged rest. + * Every card and retraction is stamped [createdAt], so a replacement is + * strictly newer than what it displaces. */ - suspend fun reconcileAndPublish( + suspend fun reconcileLocal( providerSigner: NostrSigner, providerPubkey: HexKey, scored: List>, - relays: Set, - publishLimit: Int, - publishConcurrency: Int = PUBLISH_CONCURRENCY, - ): Result { - // Newest card per target this provider already published (read back from - // the store, which every published card was persisted to). + createdAt: Long = TimeUtils.now(), + ): LocalResult { val existing = existingCards(providerPubkey) - val publishableTargets = scored.mapTo(HashSet()) { it.first } + val desiredTargets = scored.mapTo(HashSet()) { it.first } - // Upsert publishable targets whose rank tag STRING would change (or that - // have no card yet). RankTag.assemble writes rank.toString(), so we diff - // that exact string — an unchanged score is skipped so clients only sync - // ranks that moved. + // Upsert targets whose rank tag STRING would change (or that have no card + // yet). RankTag.assemble writes rank.toString(), so we diff that exact + // string — an unchanged score never produces a new signature. val changed = scored.filter { (target, rank) -> existing[target]?.let(::rankTagValue) != rank.toString() } - val toUpsert = changed.take(publishLimit) - // Retract existing cards whose target is no longer publishable — it dropped - // out of the graph, or fell below the caller's cutoff. We won't leave a - // stale assertion standing. - val toDelete = existing.filterKeys { it !in publishableTargets }.values.toList() + // Retract cards whose target is no longer desired — it dropped out of the + // graph, or fell below the caller's cutoff. No stale assertion is left standing. + val toRetract = existing.filterKeys { it !in desiredTargets }.values.toList() - val (ok, rejected) = publishCards(providerSigner, toUpsert, relays, publishConcurrency) - val (deleted, deleteRejected) = publishDeletions(providerSigner, toDelete, relays) + val signed = signAndInsertCards(providerSigner, changed, createdAt) + val retracted = insertRetractions(providerSigner, toRetract, createdAt) - return Result( - published = ok, - publishRejected = rejected, - deleted = deleted, - deleteRejected = deleteRejected, - skippedUnchanged = scored.size - changed.size, - truncated = (changed.size - toUpsert.size).coerceAtLeast(0), + return LocalResult( + signed = signed, + retracted = retracted, + unchanged = scored.size - changed.size, ) } + /** Per-relay outcome of a [syncToRelays]. */ + class RelaySyncResult( + val relay: NormalizedRelayUrl, + /** Events the NIP-77 reconcile found missing on the relay and uploaded. */ + val uploaded: Int, + /** Events blast-published because the relay couldn't reconcile. */ + val fallbackPublished: Int, + val fallbackRejected: Int, + /** The negentropy failure that triggered the fallback, or null when it reconciled. */ + val error: String?, + ) { + /** True when the relay now converged to the local set by either path. */ + val ok: Boolean get() = error == null || (fallbackPublished > 0 && fallbackRejected == 0) + } + + /** Aggregate outcome of a [syncToRelays]. */ + class SyncResult( + /** kind:30382 cards this provider key holds locally (the set being mirrored). */ + val cards: Int, + /** kind:5 retraction events this provider key holds locally. */ + val deletions: Int, + val perRelay: List, + ) + /** - * The newest kind:30382 card [providerPubkey] published per target, read from - * the store (every card [publish] sends is persisted first, so on repeat runs - * this reflects what is already out there). + * Make every relay in [relays] converge to the local card set of + * [providerPubkey]: one NIP-77 up-only reconcile per relay over the provider's + * kind:30382 + kind:5 (nothing is downloaded — the store is the source of + * truth), falling back to blast-publishing the full local set when a relay + * can't reconcile. Best-effort per relay; a failure never aborts the others. + */ + suspend fun syncToRelays( + client: INostrClient, + providerPubkey: HexKey, + relays: Set, + relayConcurrency: Int = 4, + idleTimeoutMs: Long = 30_000L, + publishTimeoutSecs: Long = 15, + ): SyncResult { + val cards = store.query(Filter(kinds = listOf(ContactCardEvent.KIND), authors = listOf(providerPubkey))) + val deletions = store.query(Filter(kinds = listOf(DeletionEvent.KIND), authors = listOf(providerPubkey))) + + val filter = Filter(kinds = listOf(ContactCardEvent.KIND, DeletionEvent.KIND), authors = listOf(providerPubkey)) + val groups = + NegentropyStoreSync( + client = client, + store = store, + config = + NegentropyStoreSync.Config( + // Up-only: the relay must converge to the store, never the + // reverse — pulling a stale card back down would resurrect a + // locally-retracted assertion. The kind:5s ride the same + // filter, so deletions propagate as ordinary uploads. + down = false, + up = true, + syncDeletions = false, + // The paged fallback DOWNLOADS the filter — wrong direction + // for a push. Failed groups get [blastPublish] instead. + pageFallback = false, + concurrency = relayConcurrency, + idleTimeoutMs = idleTimeoutMs, + publishTimeoutSecs = publishTimeoutSecs, + ), + log = log, + ).sync(relays.associateWith { listOf(filter) }) + + val perRelay = + groups.map { g -> + if (g.error == null) { + RelaySyncResult(g.relay, uploaded = g.uploaded, fallbackPublished = 0, fallbackRejected = 0, error = null) + } else { + log("[graperank] ${g.relay.url}: negentropy failed (${g.error}) — publishing the full local set instead") + val (ok, rejected) = blastPublish(client, cards + deletions, g.relay, publishTimeoutSecs) + RelaySyncResult(g.relay, uploaded = g.uploaded, fallbackPublished = ok, fallbackRejected = rejected, error = g.error) + } + } + + return SyncResult(cards = cards.size, deletions = deletions.size, perRelay = perRelay) + } + + /** + * The newest kind:30382 card [providerPubkey] holds per target in the local + * store. Locally-retracted cards never show up — inserting their kind:5 + * removed them — so a target can be re-carded later without fighting a ghost. */ private suspend fun existingCards(providerPubkey: HexKey): Map = store @@ -129,7 +205,7 @@ class GrapeRankPublisher( /** * The raw `rank` tag value string on a card — exactly what a client diffs, so an * unchanged score never produces a new signature. Our cards carry only a `rank` - * tag (plus the d-tag target), so this one value decides whether a re-publish + * tag (plus the d-tag target), so this one value decides whether a re-sign * would differ. */ private fun rankTagValue(card: ContactCardEvent): String? = @@ -137,63 +213,112 @@ class GrapeRankPublisher( if (tag.size > 1 && tag[0] == RankTag.TAG_NAME) tag[1] else null } - /** Build + publish one kind:30382 card per (target, rank), bounded-concurrently. */ - private suspend fun publishCards( + /** + * Build + sign one kind:30382 card per (target, rank) — fanned out on + * [Dispatchers.Default], since id-hash + Schnorr sign is CPU-bound — and insert + * each into the store. Batched so a whole-network card set never materializes + * in memory at once. Returns how many the store accepted. + */ + private suspend fun signAndInsertCards( signer: NostrSigner, cards: List>, - relays: Set, - concurrency: Int, - ): Pair { - var published = 0 - var rejected = 0 - for (batch in cards.chunked(concurrency)) { - val acks = + createdAt: Long, + ): Int { + if (cards.isEmpty()) return 0 + var inserted = 0 + for (batch in cards.chunked(SIGN_BATCH)) { + val signedBatch = coroutineScope { batch - .map { (pubkey, rank) -> - async { - val card = - ContactCardEvent.create( - targetUser = pubkey, - signer = signer, - publicInitializer = { add(RankTag.assemble(rank)) }, - ) - publish(card, relays) + .map { (target, rank) -> + async(Dispatchers.Default) { + ContactCardEvent.create( + targetUser = target, + signer = signer, + createdAt = createdAt, + publicInitializer = { add(RankTag.assemble(rank)) }, + ) } }.awaitAll() } - for (ack in acks) { - if (ack.values.any { it }) published++ else rejected++ + for (card in signedBatch) { + if (insertQuietly(card)) inserted++ } } - return published to rejected + return inserted } /** * Retract stale cards with NIP-09 kind:5 deletions signed by [signer] (the same - * key that signed the cards). Batches [DELETE_PER_EVENT] addressable coordinates - * per deletion so the kind:5 frame stays under the ~64KB event cap; each carries - * the card's `a` tag (30382:provider:target), so re-publishing a newer version - * later isn't blocked. Returns (deleted, rejected) card counts. + * key that signed the cards), inserted into the store — which applies them, so + * the retracted cards vanish locally and only the tombstone remains to sync. + * Batches [DELETE_PER_EVENT] addressable coordinates per deletion so the kind:5 + * frame stays under the ~64KB event cap; each carries the card's `a` tag + * (30382:provider:target), so re-publishing a newer version later isn't blocked. + * Returns how many cards were retracted. */ - private suspend fun publishDeletions( + private suspend fun insertRetractions( signer: NostrSigner, cards: List, - relays: Set, - ): Pair { - if (cards.isEmpty()) return 0 to 0 - var deleted = 0 - var rejected = 0 + createdAt: Long, + ): Int { + if (cards.isEmpty()) return 0 + var retracted = 0 for (chunk in cards.chunked(DELETE_PER_EVENT)) { - val event = signer.sign(DeletionEvent.build(chunk)) - val ack = publish(event, relays) - if (ack.values.any { it }) deleted += chunk.size else rejected += chunk.size + val deletion = signer.sign(DeletionEvent.build(chunk, createdAt)) + if (insertQuietly(deletion)) retracted += chunk.size } - return deleted to rejected + return retracted + } + + /** + * Insert without letting a single-row failure abort the whole reconcile. The + * one expected rejection is the store's own deletion guard (a re-carded target + * whose kind:5 landed in the same second); anything else is logged. Returns + * whether the store accepted the event. + */ + private suspend fun insertQuietly(event: Event): Boolean = + try { + store.insert(event) + true + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log("[graperank] store rejected ${event.kind}/${event.id.take(8)}: ${e.message}") + false + } + + /** + * Fallback for a relay without working NIP-77: publish every event of the local + * set individually (the relay dedups by id), bounded-concurrently. Returns + * (accepted, rejected) event counts. + */ + private suspend fun blastPublish( + client: INostrClient, + events: List, + relay: NormalizedRelayUrl, + publishTimeoutSecs: Long, + ): Pair { + var ok = 0 + var rejected = 0 + val relaySet = setOf(relay) + for (batch in events.chunked(PUBLISH_CONCURRENCY)) { + val acks = + coroutineScope { + batch.map { event -> async { client.publishAndConfirmDetailed(event, relaySet, publishTimeoutSecs) } }.awaitAll() + } + for (ack in acks) { + if (ack.values.any { it }) ok++ else rejected++ + } + } + return ok to rejected } companion object { - /** Concurrent card publishes when upserting. */ + /** Cards signed per batch — bounds live event objects, not parallelism. */ + const val SIGN_BATCH = 1024 + + /** Concurrent per-event publishes in the [blastPublish] fallback. */ const val PUBLISH_CONCURRENCY = 16 /** diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip25Reactions/ReactionEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip25Reactions/ReactionEvent.kt index 69901c3965..e8b7e9c4a1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip25Reactions/ReactionEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip25Reactions/ReactionEvent.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent 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.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider @@ -88,6 +89,7 @@ class ReactionEvent( reaction: String, reactedTo: EventHintBundle, createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, reaction, createdAt) { eTag(reactedTo.toETag()) if (reactedTo.event is AddressableEvent) { @@ -95,12 +97,14 @@ class ReactionEvent( } pTag(reactedTo.event.pubKey, reactedTo.relay) kind(reactedTo.event.kind) + initializer() } fun build( reaction: EmojiUrlTag, reactedTo: EventHintBundle, createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, reaction.toContentEncode(), createdAt) { eTag(reactedTo.toETag()) if (reactedTo.event is AddressableEvent) { @@ -109,6 +113,7 @@ class ReactionEvent( pTag(reactedTo.event.pubKey, reactedTo.relay) kind(reactedTo.event.kind) emoji(reaction) + initializer() } } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisherTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisherTest.kt new file mode 100644 index 0000000000..cbd3fbb6de --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisherTest.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.experimental.graperank + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * [GrapeRankPublisher.reconcileLocal] is what makes a score run durable: the + * local store must end up holding exactly the desired card set — changed ranks + * re-signed, unchanged ranks untouched (no event-id churn), dropped targets + * retracted via kind:5 (which the store applies on insert) with the tombstone + * kept for a later relay sync. + */ +class GrapeRankPublisherTest { + private fun hexKey(n: Int): String = n.toString(16).padStart(64, '0') + + private suspend fun cardsByTarget( + store: IEventStore, + provider: String, + ): Map = + store + .query(Filter(kinds = listOf(ContactCardEvent.KIND), authors = listOf(provider))) + .filterIsInstance() + .associate { it.aboutUser() to it.rank() } + + @Test + fun reconcileLocalUpsertsSkipsAndRetracts() = + runBlocking { + val store = EventStore(null) + val signer = NostrSignerInternal(KeyPair()) + val provider = signer.pubKey + val publisher = GrapeRankPublisher(store) + + val a = hexKey(0xA) + val b = hexKey(0xB) + val c = hexKey(0xC) + + // First run: every card is new. + val r1 = publisher.reconcileLocal(signer, provider, listOf(a to 50, b to 10), createdAt = 1_000L) + assertEquals(2, r1.signed) + assertEquals(0, r1.unchanged) + assertEquals(0, r1.retracted) + assertEquals(mapOf(a to 50, b to 10), cardsByTarget(store, provider)) + val firstIds = + store + .query(Filter(kinds = listOf(ContactCardEvent.KIND), authors = listOf(provider))) + .map { it.id } + .toSet() + + // Same ranks again: nothing is re-signed, no event id churns. + val r2 = publisher.reconcileLocal(signer, provider, listOf(a to 50, b to 10), createdAt = 2_000L) + assertEquals(0, r2.signed) + assertEquals(2, r2.unchanged) + assertEquals(0, r2.retracted) + val secondIds = + store + .query(Filter(kinds = listOf(ContactCardEvent.KIND), authors = listOf(provider))) + .map { it.id } + .toSet() + assertEquals(firstIds, secondIds) + + // a's rank moved, b dropped out, c is new: a + c signed, b retracted. + val r3 = publisher.reconcileLocal(signer, provider, listOf(a to 60, c to 5), createdAt = 3_000L) + assertEquals(2, r3.signed) + assertEquals(0, r3.unchanged) + assertEquals(1, r3.retracted) + + // The store converged to exactly the desired set — b's card is gone… + assertEquals(mapOf(a to 60, c to 5), cardsByTarget(store, provider)) + + // …but its kind:5 tombstone remains, ready to sync to relays. + val deletions = store.query(Filter(kinds = listOf(DeletionEvent.KIND), authors = listOf(provider))) + assertEquals(1, deletions.size) + + store.close() + } + + @Test + fun retractedTargetCanBeReCardedLater() = + runBlocking { + val store = EventStore(null) + val signer = NostrSignerInternal(KeyPair()) + val provider = signer.pubKey + val publisher = GrapeRankPublisher(store) + + val a = hexKey(0xA) + + publisher.reconcileLocal(signer, provider, listOf(a to 40), createdAt = 1_000L) + publisher.reconcileLocal(signer, provider, emptyList(), createdAt = 2_000L) + assertEquals(emptyMap(), cardsByTarget(store, provider)) + + // A NEWER card outranks the older kind:5 (NIP-09 deletions only cover + // versions up to their created_at), so the target comes back cleanly. + val r = publisher.reconcileLocal(signer, provider, listOf(a to 45), createdAt = 3_000L) + assertEquals(1, r.signed) + assertEquals(mapOf(a to 45), cardsByTarget(store, provider)) + + store.close() + } +}