From 5e41a266787ca6d33473f0a8258bdf9e88713aaf Mon Sep 17 00:00:00 2001
From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com>
Date: Tue, 14 Jul 2026 15:22:01 +0000
Subject: [PATCH 01/14] chore: sync Crowdin translations and seed translator
npub placeholders
---
amethyst/src/main/res/values-cs/strings.xml | 1 -
amethyst/src/main/res/values-de-rDE/strings.xml | 1 -
amethyst/src/main/res/values-es-rES/strings.xml | 1 -
amethyst/src/main/res/values-es-rMX/strings.xml | 1 -
amethyst/src/main/res/values-es-rUS/strings.xml | 1 -
amethyst/src/main/res/values-fa-rIR/strings.xml | 1 -
amethyst/src/main/res/values-fr-rFR/strings.xml | 1 -
amethyst/src/main/res/values-hi-rIN/strings.xml | 1 -
amethyst/src/main/res/values-hu-rHU/strings.xml | 1 -
amethyst/src/main/res/values-nl-rNL/strings.xml | 1 -
amethyst/src/main/res/values-pl-rPL/strings.xml | 1 -
amethyst/src/main/res/values-pt-rBR/strings.xml | 1 -
amethyst/src/main/res/values-sl-rSI/strings.xml | 1 -
amethyst/src/main/res/values-sv-rSE/strings.xml | 1 -
amethyst/src/main/res/values-th-rTH/strings.xml | 1 -
amethyst/src/main/res/values-zh-rCN/strings.xml | 3 ++-
amethyst/src/main/res/values-zh-rHK/strings.xml | 1 -
amethyst/src/main/res/values-zh-rSG/strings.xml | 1 -
amethyst/src/main/res/values-zh-rTW/strings.xml | 1 -
19 files changed, 2 insertions(+), 19 deletions(-)
diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml
index 706a9fdc4b..d60ee8dc55 100644
--- a/amethyst/src/main/res/values-cs/strings.xml
+++ b/amethyst/src/main/res/values-cs/strings.xml
@@ -2797,7 +2797,6 @@
Zdroj:
Servery:
Otevřít
- OTS: %1$s
Důkaz časového razítka
Existuje důkaz, že tento příspěvek byl podepsán někdy před %1$s. Důkaz byl označen v Bitcoin blockchainu v tomto datu a čase.
Upravit článek
diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml
index c8903d7063..be4bfcb024 100644
--- a/amethyst/src/main/res/values-de-rDE/strings.xml
+++ b/amethyst/src/main/res/values-de-rDE/strings.xml
@@ -2683,7 +2683,6 @@
Quelle:
Server:
Öffnen
- OTS: %1$s
Zeitstempel Beweis
Es gibt einen Beweis, dass dieser Beitrag irgendwann vor %1$s signiert wurde. Der Beweis wurde zu diesem Datum und Uhrzeit in der Bitcoin-Blockchain gestempelt.
Artikel bearbeiten
diff --git a/amethyst/src/main/res/values-es-rES/strings.xml b/amethyst/src/main/res/values-es-rES/strings.xml
index b8ce45f477..3b8df32c0f 100644
--- a/amethyst/src/main/res/values-es-rES/strings.xml
+++ b/amethyst/src/main/res/values-es-rES/strings.xml
@@ -2297,7 +2297,6 @@
Fuente:
Servidores:
Abrir
- OTS: %1$s
Prueba de marca de tiempo
Hay prueba de que esta publicación se firmó en algún momento antes de %1$s. La prueba se marcó en la cadena de bloques de Bitcoin en esa fecha y hora.
Editar artículo
diff --git a/amethyst/src/main/res/values-es-rMX/strings.xml b/amethyst/src/main/res/values-es-rMX/strings.xml
index d57b90ddfe..2c9f2c92f3 100644
--- a/amethyst/src/main/res/values-es-rMX/strings.xml
+++ b/amethyst/src/main/res/values-es-rMX/strings.xml
@@ -2288,7 +2288,6 @@
Fuente:
Servidores:
Abrir
- OTS: %1$s
Prueba de marca de tiempo
Hay prueba de que esta publicación se firmó en algún momento antes de %1$s. La prueba se marcó en la cadena de bloques de Bitcoin en esa fecha y hora.
Editar artículo
diff --git a/amethyst/src/main/res/values-es-rUS/strings.xml b/amethyst/src/main/res/values-es-rUS/strings.xml
index d83d1cbde8..7835174c36 100644
--- a/amethyst/src/main/res/values-es-rUS/strings.xml
+++ b/amethyst/src/main/res/values-es-rUS/strings.xml
@@ -2288,7 +2288,6 @@
Fuente:
Servidores:
Abrir
- OTS: %1$s
Prueba de marca de tiempo
Hay prueba de que esta publicación se firmó en algún momento antes de %1$s. La prueba se marcó en la cadena de bloques de Bitcoin en esa fecha y hora.
Editar artículo
diff --git a/amethyst/src/main/res/values-fa-rIR/strings.xml b/amethyst/src/main/res/values-fa-rIR/strings.xml
index 8ffe8921b4..201d7e725d 100644
--- a/amethyst/src/main/res/values-fa-rIR/strings.xml
+++ b/amethyst/src/main/res/values-fa-rIR/strings.xml
@@ -2253,7 +2253,6 @@
منبع:
سرورها:
باز کردن
- OTS: %1$s
اثبات مهر زمان
مدرکی وجود دارد که ثابت کند این پست پیش از %1$s امضا شده است. این مدرک در بلاکچین بیتکوین در آن تاریخ و زمان ثبت شده است.
ویرایش مقاله
diff --git a/amethyst/src/main/res/values-fr-rFR/strings.xml b/amethyst/src/main/res/values-fr-rFR/strings.xml
index 89536d9bac..7e01e4ac0c 100644
--- a/amethyst/src/main/res/values-fr-rFR/strings.xml
+++ b/amethyst/src/main/res/values-fr-rFR/strings.xml
@@ -2511,7 +2511,6 @@
Source :
Serveurs :
Ouvrir
- OTS: %1$s
Preuve d\'horodatage
Il y a une preuve que ce message a été signé avant %1$s. La preuve a été estampillée dans la blockchain Bitcoin à cette date et à cette heure.
Modifier l\'article
diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml
index e096ff2974..4b2fd9b00d 100644
--- a/amethyst/src/main/res/values-hi-rIN/strings.xml
+++ b/amethyst/src/main/res/values-hi-rIN/strings.xml
@@ -2730,7 +2730,6 @@
स्रोत :
सेवासंगणक :
खोलें
- ओ॰टी॰एस : %1$s
समयांकन प्रमाण
प्रमाण उपलब्ध है कि इस पत्र पर हस्ताक्षर किया गया %1$s के कुछ पहले। प्रमाण अंकित किया गया द्व्यंकरूप्य खण्डश्रृंखला में उस समय उस दिन पर।
लेख सम्पादित करें
diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml
index c451b2e909..93a41749de 100644
--- a/amethyst/src/main/res/values-hu-rHU/strings.xml
+++ b/amethyst/src/main/res/values-hu-rHU/strings.xml
@@ -2731,7 +2731,6 @@
Forrás:
Kiszolgálók:
Megnyitás
- OTS: %1$s
Időbélyeg-igazolás
Bizonyíték van arra, hogy ezt a bejegyzést valamikor %1$s előtt írták alá. A bizonyítékot ezen a napon és időpontban bélyegezték a Bitcoin blokkláncába.
Cikk szerkesztése
diff --git a/amethyst/src/main/res/values-nl-rNL/strings.xml b/amethyst/src/main/res/values-nl-rNL/strings.xml
index 88635bb0a0..bb876140cb 100644
--- a/amethyst/src/main/res/values-nl-rNL/strings.xml
+++ b/amethyst/src/main/res/values-nl-rNL/strings.xml
@@ -2566,7 +2566,6 @@
Bron:
Servers:
Openen
- OTS: %1$s
Bewijs van tijdstempel
Er is cryptografisch bewijs dat dit bericht vóór %1$s is ondertekend. Dit bewijs is vastgelegd in de Bitcoin-blockchain.
Artikel bewerken
diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml
index 9bc2d19653..5137fd21d6 100644
--- a/amethyst/src/main/res/values-pl-rPL/strings.xml
+++ b/amethyst/src/main/res/values-pl-rPL/strings.xml
@@ -2798,7 +2798,6 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
Źródło:
Serwery:
Otwórz
- OTS: %1$s
Potwierdzenie znacznika czasu
Istnieje dowód na to, że ten post został podpisany przed %1$s. Dowód został opatrzony pieczęcią w łańcuchu bloków Bitcoin w tym dniu i czasie.
Redaguj artykuł
diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml
index 8f23756f3a..fa993c1730 100644
--- a/amethyst/src/main/res/values-pt-rBR/strings.xml
+++ b/amethyst/src/main/res/values-pt-rBR/strings.xml
@@ -2680,7 +2680,6 @@
Fonte:
Servidores:
Abrir
- OTS: %1$s
Prova de Carimbo de data/hora
Há prova de que esta postagem foi assinada antes de %1$s. A prova foi carimbada no blockchain do Bitcoin naquela data e hora.
Editar artigo
diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml
index 7dd53b26e3..42ecf5cf1d 100644
--- a/amethyst/src/main/res/values-sl-rSI/strings.xml
+++ b/amethyst/src/main/res/values-sl-rSI/strings.xml
@@ -2813,7 +2813,6 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov,
Vir:
Strežniki:
Odpri
- OTS: %1$s
Dokaz časovnega žiga
Obstaja dokaz, da je bil ta zapisek podpisan pred %1$s. Dokaz je bil ožigosan v Bitcoin verigi blokov na ta datum in čas.
Uredi članek
diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml
index 6bbc9cff50..664c06a385 100644
--- a/amethyst/src/main/res/values-sv-rSE/strings.xml
+++ b/amethyst/src/main/res/values-sv-rSE/strings.xml
@@ -2688,7 +2688,6 @@
Källa:
Servrar:
Öppna
- OTS: %1$s
Tidsstämpel Bevis
Det finns bevis på att detta inlägg signerades någon gång före %1$s. Beviset stämplades i Bitcoin-blockchainen vid det datumet och den tiden.
Redigera artikel
diff --git a/amethyst/src/main/res/values-th-rTH/strings.xml b/amethyst/src/main/res/values-th-rTH/strings.xml
index dcb597e18d..49b6c3d5ac 100644
--- a/amethyst/src/main/res/values-th-rTH/strings.xml
+++ b/amethyst/src/main/res/values-th-rTH/strings.xml
@@ -2171,7 +2171,6 @@
แหล่งที่มา:
เซิร์ฟเวอร์:
เปิด
- OTS: %1$s
Timestamp ถูกยืนยัน
มีหลักฐานว่าโพสต์นี้มีการลงนามก่อน %1$s หลักฐานถูกประทับตราใน Bitcoin blockchain ณ วันและเวลาดังกล่าว
แก้ไขบทความ
diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml
index 3e56dab2f5..d7098aaa94 100644
--- a/amethyst/src/main/res/values-zh-rCN/strings.xml
+++ b/amethyst/src/main/res/values-zh-rCN/strings.xml
@@ -2697,7 +2697,6 @@
源:
服务器:
打开
- OTS:%1$s
OpenTimestamps 证明
%1$s之前的某个时候签署了此帖子的证明。此证明是在那个日期和时间在比特币区块链中盖章的。
编辑文章
@@ -3175,6 +3174,8 @@
播放
自动
+ 编辑昵称
+ 仅对您可见
投射到设备
停止投影
diff --git a/amethyst/src/main/res/values-zh-rHK/strings.xml b/amethyst/src/main/res/values-zh-rHK/strings.xml
index 21cb66c936..b93c51de96 100644
--- a/amethyst/src/main/res/values-zh-rHK/strings.xml
+++ b/amethyst/src/main/res/values-zh-rHK/strings.xml
@@ -2232,7 +2232,6 @@
源:
服务器:
打开
- OTS:%1$s
OpenTimestamps 证明
%1$s之前的某个时候签署了此帖子的证明。此证明是在那个日期和时间在比特币区块链中盖章的。
编辑文章
diff --git a/amethyst/src/main/res/values-zh-rSG/strings.xml b/amethyst/src/main/res/values-zh-rSG/strings.xml
index 65bb95dfd3..8436609a81 100644
--- a/amethyst/src/main/res/values-zh-rSG/strings.xml
+++ b/amethyst/src/main/res/values-zh-rSG/strings.xml
@@ -2232,7 +2232,6 @@
源:
服务器:
打开
- OTS:%1$s
OpenTimestamps 证明
%1$s之前的某个时候签署了此帖子的证明。此证明是在那个日期和时间在比特币区块链中盖章的。
编辑文章
diff --git a/amethyst/src/main/res/values-zh-rTW/strings.xml b/amethyst/src/main/res/values-zh-rTW/strings.xml
index 98a024b0ee..9c9f7e0b50 100644
--- a/amethyst/src/main/res/values-zh-rTW/strings.xml
+++ b/amethyst/src/main/res/values-zh-rTW/strings.xml
@@ -2209,7 +2209,6 @@
來源:
伺服器:
開啟
- OTS:%1$s
時間戳證明
有在 %1$s 之前的某個時間簽署了此帖子的證明。此證明是在那個日期和時間在比特幣區塊鏈中記錄的時間戳。
編輯文章
From 63f26b5cb4617423c1b547e110de7cf6403115f1 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 14 Jul 2026 19:55:12 +0000
Subject: [PATCH 02/14] feat: fall back to the relay's NIP-11 icon for NIP-29
rooms without a picture
In the Messages list, a NIP-29 group whose kind-39000 metadata has no
(or a blank) picture now shows its host relay's NIP-11 icon instead of
jumping straight to the robohash fallback. loadRelayInfo fetches the
NIP-11 document on demand if it isn't cached yet, and the robohash
still kicks in when the relay has no icon either.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01RsPtTBsJ3sJkjodN1Knbx6
---
.../loggedIn/chats/rooms/ChatroomHeaderCompose.kt | 13 ++++++++++++-
1 file changed, 12 insertions(+), 1 deletion(-)
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 52aec3f3c0..ad8946e39e 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
@@ -372,9 +372,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(
From 5507b1debfd5fb79d9992aa972599a6ac456cb91 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 14 Jul 2026 20:06:20 +0000
Subject: [PATCH 03/14] feat: bigger relay icons and NIP-29 support warnings on
Find Groups
The relay rows on the Find Groups (browse) screen now use the 55dp
LargeRelayIconModifier, matching the avatar size of the Messages list,
instead of the 13dp default.
When a relay's NIP-11 document resolves with a supported_nips list that
lacks "29" and no self key, the row shows a 'May not support groups
(NIP-29)' warning, and the relay's (empty) group directory screen says
the relay doesn't advertise NIP-29 instead of the generic empty text.
The check lives in looksLikeNonNip29Relay next to the other NIP-29
NIP-11 helpers; a null supported_nips (doc still loading or fetch
failed) never warns.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01RsPtTBsJ3sJkjodN1Knbx6
---
.../model/nip11RelayInfo/RelaySupportsNip.kt | 9 +++++++
.../relayGroup/RelayGroupChannelListScreen.kt | 14 ++++++++--
.../relayGroup/RelayGroupServerList.kt | 27 +++++++++++++++++++
amethyst/src/main/res/values/strings.xml | 2 ++
4 files changed, 50 insertions(+), 2 deletions(-)
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/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/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml
index fc352bc4ad..f38e025deb 100644
--- a/amethyst/src/main/res/values/strings.xml
+++ b/amethyst/src/main/res/values/strings.xml
@@ -2017,6 +2017,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
From 58e018c87777413c1c4e4d44ebd2352f126a0de8 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 14 Jul 2026 20:29:09 +0000
Subject: [PATCH 04/14] feat(cli): graperank scores always persist locally; add
publish + rank verbs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Every `amy graperank` / `graperank score` run now reconciles its result into
the local event store as NIP-85 kind:30382 cards signed by the per-observer
service key (cutoff --min-rank, default 2): changed ranks are re-signed,
unchanged ones skipped (no event-id churn), dropped targets retracted with a
kind:5 the store applies on insert. The store is the source of truth, so the
score of a run is durable and reusable instead of ephemeral stdout.
New verbs complete the crawl -> score -> publish pipeline:
- `graperank publish [OBSERVER] [--relay URL[,URL...]]` — transport only:
one NIP-77 up-only reconcile per operator relay over the provider key's
kind:30382 + kind:5, converging the relay to the local set (deletions
propagate, lost cards restored, full-set publish fallback for relays
without negentropy); also refreshes the observer's kind:10040 pointer.
- `graperank rank USER [--provider P] [--refresh]` — the consumer side:
newest card per provider from the local store, with a relay drain on miss.
GrapeRankPublisher (quartz) is reworked accordingly: reconcileAndPublish is
replaced by reconcileLocal (sign+insert+retract against the store) and
syncToRelays (NegentropyStoreSync up-only + blastPublish fallback), with a
commonTest covering upsert/skip/retract and re-carding a retracted target.
BREAKING (--json / flags): `--publish`, `--publish-limit`, `--publish-relay`
and `--bench-sign` are removed from the score command. Its JSON drops
published/publish_rejected/deleted/delete_rejected/skipped_unchanged/
publish_truncated/published_kind/published_to/publish_error/observer_10040/
bench_signed/bench_sign_ms and gains provider_pubkey/min_rank/cards_total/
cards_signed/cards_unchanged/cards_retracted/cards_ms. Publishing moved to
the new `graperank publish` verb.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56
---
cli/README.md | 43 +-
cli/ROADMAP.md | 2 +-
.../com/vitorpamplona/amethyst/cli/Main.kt | 29 +-
.../amethyst/cli/commands/GrapeRankCommand.kt | 377 ++++++++++++------
.../graperank/GrapeRankPublisher.kt | 315 ++++++++++-----
.../graperank/GrapeRankPublisherTest.kt | 128 ++++++
6 files changed, 645 insertions(+), 249 deletions(-)
create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisherTest.kt
diff --git a/cli/README.md b/cli/README.md
index 928009c996..bf393da508 100644
--- a/cli/README.md
+++ b/cli/README.md
@@ -389,14 +389,16 @@ 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]` | 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. **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). `graperank score` is the local-only variant (same as `--offline`). |
+| `amy graperank publish [OBSERVER] [--relay URL[,URL…]]` | 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 operator [status \| relay … \| providers]` | 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; `providers` lists the observer → service-pubkey map. |
| `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 providers [USER]` | List a user's declared NIP-85 trusted providers (public + your own private entries). |
-#### 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 +408,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
+providers` prints the `observer → service-pubkey` mapping to wire their
+kind:10040 out-of-band.
### Direct messages (NIP-17)
diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md
index d0fc574d7c..6144320f7b 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` / `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/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt
index f8c304711d..d7410753bc 100644
--- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt
+++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt
@@ -611,17 +611,30 @@ private fun printUsage() {
| [--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] [--min-rank N] --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.
+ | OBSERVER: npub|nprofile|hex|name@domain (default:
+ | active account). --offline scores from the local
+ | store only. EVERY score run persists its result
+ | locally as NIP-85 kind:30382 cards signed by a
+ | per-observer service key (ranks >= --min-rank,
+ | default 2): changed ranks re-signed, unchanged
+ | skipped, dropped targets retracted (kind:5).
+ | `graperank publish` pushes that set to relays.
+ | graperank score [OBSERVER] local only: build the graph from the store and
+ | score (same as bare --offline; same flags). Fast
+ | and param-tunable without re-crawling.
+ | graperank publish [OBSERVER] transport only: make the operator relay(s) match
+ | [--relay URL[,URL…]] [--timeout SECS] the local card set via one NIP-77 up-only
+ | [--relay-concurrency N] reconcile per relay (nothing re-scored or
+ | re-signed; full-set publish fallback when a relay
+ | can't reconcile). Also refreshes the observer's
+ | kind:10040 pointer when we hold their key.
+ | graperank rank USER [--provider PUBKEY] read the kind:30382 cards about USER — one rank
+ | [--refresh] [--timeout SECS] per provider, local store first, --refresh drains
+ | the operator/declared/bootstrap relays.
| 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
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..124756afae 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
@@ -35,11 +35,9 @@ import com.vitorpamplona.quartz.experimental.graperank.TrustGraphBuilder
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.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
@@ -53,13 +51,8 @@ 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 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,26 +73,33 @@ 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 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 graperank 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.
* - 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).
@@ -204,6 +204,8 @@ object GrapeRankCommand {
"probe" -> probe(dataDir, tail.drop(1).toTypedArray())
"update" -> update(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 +229,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 +344,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
@@ -780,37 +735,201 @@ 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")
+ val relayConcurrency = args.intFlag("relay-concurrency", 4)
+ 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 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 … | providers]`
*
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/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()
+ }
+}
From a81561424724b7146345e39b397f5dbc1beeb790 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 14 Jul 2026 20:42:17 +0000
Subject: [PATCH 05/14] refactor(cli): move the relay census to `amy relay
probe`
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The census probes the whole known relay universe and feeds the shared NIP-66
reachability cache (kind:30166) that every reachability-aware command reads —
it was never graperank-specific, so it moves from GrapeRankCommand to
RelayCommands as `amy relay probe`. `amy graperank probe` stays as an alias,
and flags, JSON output, and behaviour are unchanged.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56
---
cli/README.md | 1 +
.../com/vitorpamplona/amethyst/cli/Main.kt | 11 ++-
.../amethyst/cli/commands/GrapeRankCommand.kt | 89 ++-----------------
.../amethyst/cli/commands/RelayCommands.kt | 88 +++++++++++++++++-
4 files changed, 101 insertions(+), 88 deletions(-)
diff --git a/cli/README.md b/cli/README.md
index bf393da508..91446e2e74 100644
--- a/cli/README.md
+++ b/cli/README.md
@@ -553,6 +553,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`/`update`) 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/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt
index d7410753bc..f4b2d7a5ef 100644
--- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt
+++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt
@@ -484,6 +484,11 @@ 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/
+ | update) skip dead relays and wait once
| outbox USER [--refresh] show USER's NIP-65 read/write relays (outbox model)
| [--timeout SECS] (USER: npub|nprofile|hex|name@domain)
|
@@ -639,10 +644,8 @@ private fun printUsage() {
| [--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 probe alias for `relay probe` (the census moved there —
+ | it feeds the shared NIP-66 reachability cache).
| 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
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 124756afae..ac836681fb 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
@@ -44,7 +44,6 @@ 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.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.serviceProviders
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ProviderTypes
@@ -92,9 +91,10 @@ import kotlin.math.roundToInt
* 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 graperank probe` — the relay census: mass-connect every relay the store
+ * - `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 experience — discovery and consumption:
@@ -201,7 +201,10 @@ object GrapeRankCommand {
// `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())
+ // 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())
"update" -> update(dataDir, tail.drop(1).toTypedArray())
"score" -> run(dataDir, tail.drop(1).toTypedArray(), forceOffline = true)
"publish" -> publish(dataDir, tail.drop(1).toTypedArray())
@@ -540,86 +543,6 @@ object GrapeRankCommand {
return 0
}
- /**
- * `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`.
- */
- 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)
-
- 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
- }
-
/**
* `amy graperank update [flags]` — refresh every locally-known author's WoT
* record kinds (0 / 3 / 10002 / 1984) straight from their own outbox, so the
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..33207c6790 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,90 @@ 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)
+ val timeoutMs = args.longFlag("timeout", 15L) * 1000
+ val waveSize = args.intFlag("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
// ------------------------------------------------------------------
From 8b822029e29f42a4881bb8cd7a87d3261f98e80e Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 14 Jul 2026 20:52:12 +0000
Subject: [PATCH 06/14] feat(cli): graperank refresh rename, unregister verb,
operator keys rename
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- `graperank update` -> `graperank refresh`: the verb says what it does
(refresh the WoT record kinds from each author's outbox) and stops
colliding with the sync/crawl naming tangle. `update` stays as an alias.
- New `graperank unregister PROVIDER [--service KIND:TAG] [--relay URL]`:
the missing inverse of `register` — removes matching entries (public +
private) from the account's kind:10040 and re-publishes it; without
narrowing flags every entry for that provider key is dropped.
- `graperank operator providers` -> `operator keys`: it lists the
observer -> service-key map, and the old name collided with
`graperank providers` (the kind:10040 read). `providers` stays as an
alias; the JSON key `providers` becomes `keys` in both the listing and
`operator status` (breaking, matches the rename).
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56
---
cli/README.md | 8 +-
cli/ROADMAP.md | 2 +-
.../com/vitorpamplona/amethyst/cli/Main.kt | 13 +-
.../amethyst/cli/commands/GrapeRankCommand.kt | 120 ++++++++++++++++--
4 files changed, 123 insertions(+), 20 deletions(-)
diff --git a/cli/README.md b/cli/README.md
index 91446e2e74..4a6cf5a7a7 100644
--- a/cli/README.md
+++ b/cli/README.md
@@ -392,8 +392,10 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever`
| `amy graperank [OBSERVER] [--offline] [--min-rank N]` | 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. **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). `graperank score` is the local-only variant (same as `--offline`). |
| `amy graperank publish [OBSERVER] [--relay URL[,URL…]]` | 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 operator [status \| relay … \| providers]` | 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; `providers` lists the observer → service-pubkey map. |
+| `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 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). |
#### GrapeRank scores are persisted locally, then published (NIP-85)
@@ -432,7 +434,7 @@ 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
-providers` prints the `observer → service-pubkey` mapping to wire their
+keys` prints the `observer → service-pubkey` mapping to wire their
kind:10040 out-of-band.
### Direct messages (NIP-17)
@@ -553,7 +555,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`/`update`) read it to skip dead relays and pre-connect live ones. (`amy graperank probe` remains as an alias.) |
+| `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 6144320f7b..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`); 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` / `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/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt
index f4b2d7a5ef..2c76144d85 100644
--- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt
+++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt
@@ -646,7 +646,7 @@ private fun printUsage() {
| storm (seeded from the reachability cache).
| graperank probe alias for `relay probe` (the census moved there —
| it feeds the shared NIP-66 reachability cache).
- | graperank update [--down] [--up] refresh every locally-known author's WoT record kinds
+ | graperank refresh [--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
@@ -654,14 +654,19 @@ private fun printUsage() {
| 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.
+ | can't reconcile via negentropy. (`update` is the
+ | pre-rename alias.)
| graperank operator [status|relay … manage the machine's operator keys (~/.amy/operator/,
- | |providers] independent of accounts): relay sets where cards +
+ | |keys] independent of accounts): relay sets where cards +
| retractions publish; status shows master + relays;
- | providers lists observer -> service-pubkey.
+ | keys 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 unregister PROVIDER remove matching provider entries (public + private)
+ | [--service KIND:TAG] [--relay URL] from your kind:10040 and re-publish it; --service/
+ | --relay narrow the match, else every entry for
+ | that provider key is dropped.
| graperank providers [USER] [--refresh] list a user's declared NIP-85 trusted providers
| [--timeout SECS] (default: active account).
|
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 ac836681fb..324945b1da 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
@@ -102,7 +102,8 @@ import kotlin.math.roundToInt
* 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,16 +197,19 @@ 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`).
+ // canonical (disambiguates from negentropy `amy sync` / `graperank refresh`).
"crawl", "sync" -> crawl(dataDir, tail.drop(1).toTypedArray())
// 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())
- "update" -> update(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())
@@ -544,7 +548,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.
*
@@ -563,7 +567,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 {
@@ -854,13 +858,15 @@ object GrapeRankCommand {
}
/**
- * `amy graperank operator [status | relay … | providers]`
+ * `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,
@@ -877,11 +883,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
@@ -896,14 +902,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)")
}
}
@@ -990,6 +996,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]`
*
From 883365d6a5c446a3f700a061507af5b164f8d194 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 14 Jul 2026 21:18:36 +0000
Subject: [PATCH 07/14] feat(cli): graperank status verb, flag consistency,
deprecate the sync alias
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- New `graperank status`: read-only local inventory with no network, no
signing, and no side effects — WoT record counts in the store (the "do I
need to crawl again?" answer), reachability-cache size + newest-record
age, operator/service-key state, and persisted card + retraction counts
per observer. Reads the reachability cache through a throwaway signer so
a fresh machine's status never lazily creates the operator master.
- Flag consistency: --relay-concurrency and --concurrency are now accepted
interchangeably on `graperank refresh`, `graperank publish`, and
`relay probe` (each keeps its documented spelling as canonical), and the
help text states what --timeout means per verb (per-REQ drain 10s for
crawl/score, idle watchdog 30s for refresh/publish, per wave 15s for
probe, drain 8s for rank/register/providers).
- `graperank sync` now prints a deprecation warning before running crawl —
the name points at the wrong concept since the negentropy record refresh
became `graperank refresh` — and is removed from the docs; removal comes
in a later release.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56
---
cli/README.md | 1 +
.../com/vitorpamplona/amethyst/cli/Main.kt | 17 ++-
.../amethyst/cli/commands/GrapeRankCommand.kt | 118 ++++++++++++++++--
.../amethyst/cli/commands/RelayCommands.kt | 6 +-
4 files changed, 126 insertions(+), 16 deletions(-)
diff --git a/cli/README.md b/cli/README.md
index 4a6cf5a7a7..1350cd478b 100644
--- a/cli/README.md
+++ b/cli/README.md
@@ -393,6 +393,7 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever`
| `amy graperank publish [OBSERVER] [--relay URL[,URL…]]` | 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. |
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 2c76144d85..cf5f6d2e4e 100644
--- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt
+++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt
@@ -488,7 +488,8 @@ private fun printUsage() {
| [--concurrency N] knows and record live/dead + measured rtt-open
| into the reachability cache (NIP-66 kind:30166),
| so reachability-aware commands (graperank crawl/
- | update) skip dead relays and wait once
+ | 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)
|
@@ -616,7 +617,8 @@ private fun printUsage() {
| [--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] [--min-rank N] --max-hops bounds follow distance, e.g. 8;
+ | [--diagnose] [--min-rank N] --timeout: per-REQ drain, default 10s;
+ | --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).
@@ -637,9 +639,15 @@ private fun printUsage() {
| re-signed; full-set publish fallback when a relay
| can't reconcile). Also refreshes the observer's
| kind:10040 pointer when we hold their key.
+ | --timeout: idle watchdog per relay, default 30s.
| graperank rank USER [--provider PUBKEY] read the kind:30382 cards about USER — one rank
| [--refresh] [--timeout SECS] per provider, local store first, --refresh drains
- | the operator/declared/bootstrap relays.
+ | the operator/declared/bootstrap relays
+ | (--timeout: drain, default 8s).
+ | graperank status read-only local inventory, no network: WoT record
+ | counts (do I need to crawl again?), reachability
+ | cache size + age, operator state, and the
+ | persisted card set per observer.
| 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
@@ -655,7 +663,8 @@ private fun printUsage() {
| an uploaded record was rejected (author retracted it).
| Falls back to a full paged download when a relay
| can't reconcile via negentropy. (`update` is the
- | pre-rename alias.)
+ | pre-rename alias; --timeout: idle watchdog per
+ | relay, default 30s.)
| graperank operator [status|relay … manage the machine's operator keys (~/.amy/operator/,
| |keys] independent of accounts): relay sets where cards +
| retractions publish; status shows master + relays;
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 324945b1da..121ee79953 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
@@ -35,6 +35,8 @@ import com.vitorpamplona.quartz.experimental.graperank.TrustGraphBuilder
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
@@ -44,12 +46,16 @@ 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.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.utils.TimeUtils
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.withContext
@@ -77,9 +83,12 @@ import kotlin.math.roundToInt
* 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.
+ * 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.
+ * (`sync` is a deprecated alias — it warns and will be removed.)
+ * - `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
@@ -200,9 +209,19 @@ object GrapeRankCommand {
"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 refresh`).
- "crawl", "sync" -> crawl(dataDir, tail.drop(1).toTypedArray())
+ "crawl" -> crawl(dataDir, tail.drop(1).toTypedArray())
+ // Deprecated pre-rename alias for `crawl`. Actively misleading now that
+ // the negentropy record refresh is `graperank refresh` (and `amy sync`
+ // is the generic negentropy verb), so it warns before it runs — next
+ // step is removal.
+ "sync" -> {
+ System.err.println(
+ "[graperank] `graperank sync` is deprecated and will be removed — use `graperank crawl` " +
+ "(or `graperank refresh` to re-sync known authors' records).",
+ )
+ 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.
@@ -505,9 +524,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`.
*/
@@ -547,6 +566,80 @@ object GrapeRankCommand {
return 0
}
+ /**
+ * `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 status(dataDir: DataDir): Int {
+ Context.openOrAnonymous(dataDir).use { ctx ->
+ // 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(
+ "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(
+ "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,
+ ),
+ )
+ }
+ return 0
+ }
+
/**
* `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
@@ -596,7 +689,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,
@@ -679,7 +772,10 @@ object GrapeRankCommand {
val args = Args(rest)
val observerArg = args.positionalOrNull(0)
val relayArg = args.flag("relay")
- val relayConcurrency = args.intFlag("relay-concurrency", 4)
+ // --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 ->
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 33207c6790..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
@@ -296,8 +296,12 @@ object RelayCommands {
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
- val waveSize = args.intFlag("concurrency", Context.defaultPreconnectCap)
+ // 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()
From 65eac0f359992ae1e8a1f51605240d8e2d82ab5c Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 14 Jul 2026 21:33:49 +0000
Subject: [PATCH 08/14] refactor(cli): remove the graperank `sync` alias;
reject non-64-hex user ids
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`graperank sync` is gone — `crawl` is the only spelling. Because the dispatch
treats any non-verb first token as an OBSERVER, this exposed a latent leniency
in the shared resolver: decodePublicKeyAsHexOrNull's fallback runs Hex.decode
without a length check, so a short bech32/hex-ish word like `sync` decodes to a
bogus few-byte "pubkey" instead of failing — turning `graperank sync` into a
silent network crawl over garbage. Context.requireUserHex now requires the
resolved value to be exactly 64 hex chars (a pubkey is always 32 bytes), so a
mistyped OBSERVER/USER on any amy command errors cleanly (exit 2) instead of
silently scoring/fetching against a garbage key. `graperank sync` now returns
`bad_args` rather than crawling.
Also drops the never-built `graperank compare` idea from the roadmap.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56
---
.../com/vitorpamplona/amethyst/cli/Context.kt | 19 +++++++++++++++----
.../amethyst/cli/commands/GrapeRankCommand.kt | 12 ------------
2 files changed, 15 insertions(+), 16 deletions(-)
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 b478ef8b85..90ca163159 100644
--- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt
+++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt
@@ -470,11 +470,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/commands/GrapeRankCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt
index 121ee79953..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
@@ -85,7 +85,6 @@ import kotlin.math.roundToInt
* - `amy graperank crawl [OBSERVER]` — network only: crawl the reachable graph's
* 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.
- * (`sync` is a deprecated alias — it warns and will be removed.)
* - `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.
@@ -210,17 +209,6 @@ object GrapeRankCommand {
"providers" -> providers(dataDir, tail.drop(1).toTypedArray())
"operator" -> operator(dataDir, tail.drop(1).toTypedArray())
"crawl" -> crawl(dataDir, tail.drop(1).toTypedArray())
- // Deprecated pre-rename alias for `crawl`. Actively misleading now that
- // the negentropy record refresh is `graperank refresh` (and `amy sync`
- // is the generic negentropy verb), so it warns before it runs — next
- // step is removal.
- "sync" -> {
- System.err.println(
- "[graperank] `graperank sync` is deprecated and will be removed — use `graperank crawl` " +
- "(or `graperank refresh` to re-sync known authors' records).",
- )
- 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
From 1a8a4012a6297ec3ab8000699751c434486d6ad8 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 14 Jul 2026 21:44:26 +0000
Subject: [PATCH 09/14] docs(cli): audit graperank docs against code; simplify
the --help block
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Audited every graperank verb/flag in the code against the three doc
surfaces and fixed the drift:
- Main.kt `--help`: rewrote the GrapeRank block to match the terse house
style (one line per command, key flags on continuation lines) — it had
grown to ~68 lines of prose. Now ~33 lines, reordered into pipeline
order (crawl -> score -> publish, then rank/status/refresh, then the
provider/operator discovery group), with the per-verb timeout-semantics
prose dropped (it lives in the KDoc). Verbs and primary flags verified
against the dispatch and each function's arg reads.
- README: added the missing `graperank crawl` and `graperank score` rows
(stage 1 and 2 of the pipeline — crawl had no table row at all, score
was only mentioned inline) and labelled the three pipeline stages.
No stale references remain (no `graperank sync`, no removed publish/
bench flags, `operator providers`/`update` only as documented aliases).
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56
---
cli/README.md | 6 +-
.../com/vitorpamplona/amethyst/cli/Main.kt | 98 ++++++-------------
2 files changed, 36 insertions(+), 68 deletions(-)
diff --git a/cli/README.md b/cli/README.md
index 1350cd478b..925c456afc 100644
--- a/cli/README.md
+++ b/cli/README.md
@@ -389,8 +389,10 @@ 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] [--min-rank N]` | 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. **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). `graperank score` is the local-only variant (same as `--offline`). |
-| `amy graperank publish [OBSERVER] [--relay URL[,URL…]]` | 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 [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. |
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 cf5f6d2e4e..a3bfeb8f28 100644
--- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt
+++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt
@@ -612,72 +612,38 @@ 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] [--min-rank N] --timeout: per-REQ drain, default 10s;
- | --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).
- | OBSERVER: npub|nprofile|hex|name@domain (default:
- | active account). --offline scores from the local
- | store only. EVERY score run persists its result
- | locally as NIP-85 kind:30382 cards signed by a
- | per-observer service key (ranks >= --min-rank,
- | default 2): changed ranks re-signed, unchanged
- | skipped, dropped targets retracted (kind:5).
- | `graperank publish` pushes that set to relays.
- | graperank score [OBSERVER] local only: build the graph from the store and
- | score (same as bare --offline; same flags). Fast
- | and param-tunable without re-crawling.
- | graperank publish [OBSERVER] transport only: make the operator relay(s) match
- | [--relay URL[,URL…]] [--timeout SECS] the local card set via one NIP-77 up-only
- | [--relay-concurrency N] reconcile per relay (nothing re-scored or
- | re-signed; full-set publish fallback when a relay
- | can't reconcile). Also refreshes the observer's
- | kind:10040 pointer when we hold their key.
- | --timeout: idle watchdog per relay, default 30s.
- | graperank rank USER [--provider PUBKEY] read the kind:30382 cards about USER — one rank
- | [--refresh] [--timeout SECS] per provider, local store first, --refresh drains
- | the operator/declared/bootstrap relays
- | (--timeout: drain, default 8s).
- | graperank status read-only local inventory, no network: WoT record
- | counts (do I need to crawl again?), reachability
- | cache size + age, operator state, and the
- | persisted card set per observer.
- | 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 alias for `relay probe` (the census moved there —
- | it feeds the shared NIP-66 reachability cache).
- | graperank refresh [--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. (`update` is the
- | pre-rename alias; --timeout: idle watchdog per
- | relay, default 30s.)
- | graperank operator [status|relay … manage the machine's operator keys (~/.amy/operator/,
- | |keys] independent of accounts): relay sets where cards +
- | retractions publish; status shows master + relays;
- | keys 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 unregister PROVIDER remove matching provider entries (public + private)
- | [--service KIND:TAG] [--relay URL] from your kind:10040 and re-publish it; --service/
- | --relay narrow the match, else every entry for
- | that provider key is dropped.
- | 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).
|
|Zaps (NIP-57):
| zap user USER SATS build a profile zap-request, fetch a BOLT11
From f628783186c0892b7648188f7f74ccee874a7060 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 14 Jul 2026 22:43:44 +0000
Subject: [PATCH 10/14] refactor(cli): rename `amy wot` -> `amy fof`
(follows-of-follows)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`wot` claimed the whole web-of-trust concept for what is actually a cheap
single-hop metric — the count of your follows who also follow X. The real
computed web of trust is `graperank`. Renamed the command (and WotCommand ->
FofCommand) to `fof`, reframing its KDoc/usage away from "trust," and kept
`wot` as a deprecation alias that warns and points at both `fof` and
`graperank`.
Also documents the command for the first time: `amy wot` had no --help block
and no README row. Added both (help block + three README rows for
get/list/sync), so the follows-of-follows score and its relationship to
graperank are now discoverable. JSON output shape is unchanged.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56
---
cli/README.md | 3 +++
.../com/vitorpamplona/amethyst/cli/Main.kt | 19 +++++++++++++--
.../commands/{WotCommand.kt => FofCommand.kt} | 24 ++++++++++++-------
3 files changed, 35 insertions(+), 11 deletions(-)
rename cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/{WotCommand.kt => FofCommand.kt} (90%)
diff --git a/cli/README.md b/cli/README.md
index 925c456afc..468211a712 100644
--- a/cli/README.md
+++ b/cli/README.md
@@ -400,6 +400,9 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever`
| `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.) |
#### GrapeRank scores are persisted locally, then published (NIP-85)
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 a3bfeb8f28..e5f29ecbd0 100644
--- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt
+++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt
@@ -35,6 +35,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
@@ -70,7 +71,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
@@ -292,7 +292,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)
+ }
else -> {
System.err.println("unknown subcommand: $head")
printUsage()
@@ -645,6 +652,14 @@ private fun printUsage() {
| -> 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
| [--comment X] [--anon|--private] invoice from the recipient's LN service
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
From ccd4677f5d7afe2c9dd02b9ab879e61049c82e13 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 14 Jul 2026 22:45:25 +0000
Subject: [PATCH 11/14] fix: render p-tagged NIP-29 group replies on the
Notifications tab
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A reply to my message inside a joined NIP-29 relay group is a kind-9
ChatEvent that p-tags me (see ChannelNewMessageViewModel). It is already
fetched at startup by filterGroupNotificationsToPubkey (scoped `#p`=me +
`#h`=my groups on the group's host relay), but NotificationFeedFilter's
`acceptableEvent` checks `kind in NOTIFICATION_KINDS` before the p-tag
gate, and kind 9 (ChatEvent) was missing from that set — so those replies
were dropped before ever reaching the p-tag check and never surfaced.
Add ChatEvent.KIND to NOTIFICATION_KINDS. The existing gates handle the
rest: my own messages are excluded (author != me), the reply p-tags me
(isTaggedUser), and its `q`-tag to my message makes it pass
tagsAnEventByUser in Selected mode too. Pin the behaviour with a tripwire
test in NotificationKindsContractTest.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01VxpT7J4xt37EF5yJDw1htK
---
.../dal/NotificationFeedFilter.kt | 7 +++++++
.../dal/NotificationKindsContractTest.kt | 20 +++++++++++++++++++
2 files changed, 27 insertions(+)
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 124e578ad0..8f4cdc9eb2 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
@@ -81,6 +81,7 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
+import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import kotlinx.coroutines.flow.MutableStateFlow
@@ -136,6 +137,12 @@ class NotificationFeedFilter(
setOf(
BadgeAwardEvent.KIND,
ChannelMessageEvent.KIND,
+ // NIP-29 group chat (kind 9). A reply to my group message is a
+ // kind-9 that p-tags me (see ChannelNewMessageViewModel), fetched
+ // at startup by filterGroupNotificationsToPubkey. Without kind 9
+ // here the acceptableEvent kind gate drops it before the p-tag
+ // check, so those replies never render on the Notifications tab.
+ ChatEvent.KIND,
ChatMessageEvent.KIND,
ChatMessageEncryptedFileHeaderEvent.KIND,
CommentEvent.KIND,
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,
+ )
+ }
}
From bf2b283cdc0b899c153b40894e1c359e46a9acfe Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 14 Jul 2026 23:06:18 +0000
Subject: [PATCH 12/14] fix: keep NIP-29 group reactions in the group so likes
notify
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A "like" on a group message was built as a plain NIP-25 reaction — `e`
(message) + `p` (author) + `k` — with no `h` tag. The recipient's only
notification query that reaches the group's host relay,
filterGroupNotificationsToPubkey, is scoped `#p`=them AND `#h`=their
groups (kind 7 is already in GroupNotificationKinds), so a like with no
`h` tag is never matched there. It would only surface if NIP-65 routing
happened to drop it on one of the recipient's inbox relays — never for a
host-relay-only group — so likes on group messages effectively never
notified.
Copy the target's `h` tag onto public reactions to group-scoped events,
mirroring how kind-9 replies carry it. ReactionEvent.build gains an
`initializer` (the API GroupScope's KDoc already documented); ReactionAction
applies the group `h` tag for both the tracked and fire-and-forget paths.
The like now lands on the host relay in-group and the existing kind-7
`#p`+`#h` query picks it up.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01VxpT7J4xt37EF5yJDw1htK
---
.../model/nip25Reactions/ReactionAction.kt | 64 +++++++++++--------
.../nip25Reactions/ReactionActionTest.kt | 37 +++++++++++
.../quartz/nip25Reactions/ReactionEvent.kt | 5 ++
3 files changed, 79 insertions(+), 27 deletions(-)
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/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()
}
}
}
From 0acf5359c37789967098f9755275db0eaa6eca05 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 14 Jul 2026 23:37:47 +0000
Subject: [PATCH 13/14] fix: avoid StateFlow.value write in composition in
NoteHeader preview
Use featureSet.tryEmit(...) instead of assigning .value inside the
@Composable preview, matching the pattern used elsewhere (AppSettingsScreen).
Fixes the StateFlowValueCalledInComposition lint error.
---
.../amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
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")
From b37a0ae3bb320d0c1365d0de3a7b010bccf27085 Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 15 Jul 2026 01:44:21 +0000
Subject: [PATCH 14/14] feat: add FAB to create a public chat on the Public
Chats screen
Adds a "+" FloatingActionButton to the Public Chats screen (reached from
the left drawer) that navigates to the NIP-28 channel-creation flow
(Route.ChannelMetadataEdit with no id), mirroring the create-public-chat
action already offered by the Messages FAB.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01SDKFHhUqcDJvNEA7s7Qspm
---
.../publicChats/NewPublicChatButton.kt | 52 +++++++++++++++++++
.../loggedIn/publicChats/PublicChatsScreen.kt | 6 +++
amethyst/src/main/res/values/strings.xml | 1 +
3 files changed, 59 insertions(+)
create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/NewPublicChatButton.kt
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/strings.xml b/amethyst/src/main/res/values/strings.xml
index f38e025deb..67fe71c930 100644
--- a/amethyst/src/main/res/values/strings.xml
+++ b/amethyst/src/main/res/values/strings.xml
@@ -334,6 +334,7 @@
and thus chat messages disappear over time
Public Chat
+ Create a new public chat
MLS Group
No messages yet
Public Chat Metadata