Merge pull request #3508 from nrobi144/feat/desktop-dm-reliability

feat(desktop): NIP-17 DM reliability — AUTH banner, strict inbox resolution, relay hints
This commit is contained in:
Vitor Pamplona
2026-07-09 08:51:20 -04:00
committed by GitHub
31 changed files with 3740 additions and 112 deletions
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.defaults
/**
* Curated indexer relays for resolving NIP-17 inbox lookups (kind:10050).
*
* Used by [com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.DmInboxRelayResolver]
* via a SEPARATE unauthenticated NostrClient — these queries MUST NOT carry an
* AUTH event back to the user's identity key (security review F-01: an
* authenticated indexer fan-out turns "indexer learns we queried for pubkey X"
* into "indexer learns Amethyst user U queried for pubkey X").
*
* Set selected for known kind:10050 indexing coverage; `purplepag.es` is
* deliberately excluded (metadata indexer, weak kind:10050 coverage).
*/
object DefaultDmIndexerRelays {
val RELAYS: List<String> =
listOf(
"wss://relay.nos.social",
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.nostr.band",
"wss://purplerelay.com",
)
}
@@ -114,6 +114,24 @@ class User(
fun dmInboxRelays() = dmInboxRelayList()?.relays()?.ifEmpty { null } ?: inboxRelays()
/**
* Strict variant of [dmInboxRelays] that returns ONLY the user's NIP-17
* inbox relays (kind:10050) and never falls back to the NIP-65 read
* marker (kind:10002).
*
* Per NIP-17 §Publishing, gift wraps MUST land on relays advertised in
* the recipient's kind:10050 — the NIP-65 read fallback in
* [dmInboxRelays] is a UI-convenience heuristic that leaks the DM
* metadata to relays the recipient did not designate for DMs. Any code
* that decides "can I actually deliver a NIP-17 wrap to this user"
* should call this strict variant; UI hints and probe-time bootstrap
* paths may continue to use the lenient one.
*
* Returns `null` when the recipient has no published kind:10050 (or an
* empty one) — callers treat this as "unreachable via NIP-17".
*/
fun dmInboxRelaysStrict() = dmInboxRelayList()?.relays()?.ifEmpty { null }
fun bestRelayHint() = authorRelayList()?.writeRelaysNorm()?.firstOrNull() ?: mostUsedNonLocalRelay()
fun allUsedRelaysOrNull() = relays?.allOrNull()
@@ -0,0 +1,156 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.relayClient.auth
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
/**
* Inline AUTH approval banner.
*
* Renders one row per pending tier-2 NIP-42 AUTH challenge with three
* actions: `[Once]` `[Always]` `[Never]`. Each press calls [onResolve]
* with the user's choice, which the parent (typically a coordinator)
* uses to complete the underlying [PendingAuthApproval.decision]
* deferred and persist the scope.
*
* Stacks up to 3 entries inline; the rest collapse into a `+N more` row
* (a future iteration may expand them on click — keep simple for now).
*
* The component is platform-agnostic and lives in `commons` so Android
* and Desktop can render the same UX once the wire-up is built on each
* platform.
*/
@Composable
fun AuthApprovalBanner(
pending: List<PendingAuthApproval>,
onResolve: (NormalizedRelayUrl, AuthApprovalScope) -> Unit,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = pending.isNotEmpty(),
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
modifier = modifier,
) {
Column(modifier = Modifier.fillMaxWidth()) {
val visible = pending.take(3)
val hidden = pending.size - visible.size
visible.forEach { approval ->
AuthApprovalRow(approval = approval, onResolve = onResolve)
}
if (hidden > 0) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "+$hidden more relay${if (hidden == 1) "" else "s"} pending approval",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
)
}
}
}
}
}
@Composable
private fun AuthApprovalRow(
approval: PendingAuthApproval,
onResolve: (NormalizedRelayUrl, AuthApprovalScope) -> Unit,
) {
Surface(
color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.6f),
modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.4f)),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = MaterialSymbols.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = approval.relayUrl.displayUrl(),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
Text(
text =
if (approval.pendingCount > 1) {
"requires authentication for ${approval.pendingCount} messages"
} else {
"requires authentication to deliver this message"
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.8f),
)
}
Spacer(Modifier.width(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
TextButton(onClick = { onResolve(approval.relayUrl, AuthApprovalScope.ONCE) }) {
Text("Once", style = MaterialTheme.typography.labelMedium)
}
TextButton(onClick = { onResolve(approval.relayUrl, AuthApprovalScope.ALWAYS) }) {
Text("Always", style = MaterialTheme.typography.labelMedium)
}
TextButton(onClick = { onResolve(approval.relayUrl, AuthApprovalScope.BLOCKED) }) {
Text("Never", style = MaterialTheme.typography.labelMedium)
}
}
}
}
}
@@ -0,0 +1,200 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.relayClient.auth
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CompletableDeferred
/**
* Persisted scope for an AUTH approval decision.
*
* `ONCE` is in-memory only — never written to disk. `ALWAYS` and `BLOCKED`
* persist via [AuthApprovalStore].
*/
enum class AuthApprovalScope {
/** Approve this session; don't persist. */
ONCE,
/** Approve indefinitely (or until the store's TTL expires the row). */
ALWAYS,
/** Reject indefinitely. Future AUTH challenges from this relay are silently dropped. */
BLOCKED,
}
/**
* The classifier verdict for a single AUTH challenge.
*
* `Allow` and `Block` are immediate. `Pending` means the user needs to decide;
* the policy hands back a [CompletableDeferred] that the UI banner completes
* once the user picks `[Once] [Always] [Never]`.
*/
sealed interface AuthApprovalDecision {
/** Auto-sign the AUTH event for this relay. */
data object Allow : AuthApprovalDecision
/** Silently drop the AUTH challenge. */
data object Block : AuthApprovalDecision
/**
* Suspend the signer until the user resolves the prompt.
*
* @property pending populated with the user's choice when the banner is
* actioned. The signer awaits this deferred; if it resolves to
* [AuthApprovalScope.BLOCKED] the AUTH is dropped, otherwise signed.
*/
data class Pending(
val pending: CompletableDeferred<AuthApprovalScope>,
) : AuthApprovalDecision
}
/**
* A pending tier-2 AUTH approval surfaced to the user.
*
* Created when the policy decides a challenge needs user consent. Subscribers
* (an `AccountAuthApprovals` ViewModel — wired in P2.5) render a banner with
* `[Once] [Always] [Never]` buttons that resolve [decision] via `complete()`.
*
* `pendingCount` lets the banner coalesce multiple challenges from the same
* relay into one row (`"<relay-url> requires authentication for 3 messages"`)
* rather than stacking duplicate banners.
*/
data class PendingAuthApproval(
val relayUrl: NormalizedRelayUrl,
val decision: CompletableDeferred<AuthApprovalScope>,
val pendingCount: Int = 1,
)
/**
* Per-account approval store. Implementations persist `ALWAYS` / `BLOCKED`
* grants (typically to a SQLite `auth_approvals` table, wired in P2.4).
*
* `getScope` returns `null` if no decision is recorded for the relay.
*/
interface AuthApprovalStore {
/** Returns the persisted decision for `relayUrl`, or `null` if unknown. */
suspend fun getScope(relayUrl: NormalizedRelayUrl): AuthApprovalScope?
/**
* Record a user decision. `ONCE` decisions are NOT persisted by contract —
* the policy caches them in-memory for the current session only.
*/
suspend fun setScope(
relayUrl: NormalizedRelayUrl,
scope: AuthApprovalScope,
)
/** Wipe all persisted approvals. Called on account delete / logout. */
suspend fun clear()
}
/**
* In-memory [AuthApprovalStore] used as a development scaffold and as the
* `ONCE` cache layer on top of a persistent store. Tier-2 banner approvals
* with `ONCE` scope live here for the session and are dropped on logout.
*/
class InMemoryAuthApprovalStore : AuthApprovalStore {
private val scopes = mutableMapOf<NormalizedRelayUrl, AuthApprovalScope>()
private val lock = Any()
override suspend fun getScope(relayUrl: NormalizedRelayUrl): AuthApprovalScope? = synchronized(lock) { scopes[relayUrl] }
override suspend fun setScope(
relayUrl: NormalizedRelayUrl,
scope: AuthApprovalScope,
) {
synchronized(lock) { scopes[relayUrl] = scope }
}
override suspend fun clear() {
synchronized(lock) { scopes.clear() }
}
}
/**
* The classifier between the relay client's `signWithAllLoggedInUsers` lambda
* and the actual signer.
*
* Two tiers:
*
* - **Tier 1 (auto-allow):** the relay is in the user's own outbox or
* NIP-17 DM-inbox set, or has a persisted `ALWAYS` grant. Sign immediately,
* no prompt. These are relays the user has already declared they trust.
* - **Tier 2 (prompt):** anything else, with the exception of relays that
* carry a persisted `BLOCKED` grant. Surface a [PendingAuthApproval] via
* [onPromptRequired] and suspend until the user resolves the
* [CompletableDeferred]. If `ONCE`, cache for this session; if `ALWAYS` or
* `BLOCKED`, persist via the store.
*
* No tier-3: every challenge is either auto-allowed, blocked by a persisted
* decision, or surfaced to the user. There is no silent third path.
*
* @property selfApprovedRelays the union of own outbox + DM-inbox + any
* account-level pre-approval. Recomputed by the caller on Account state
* changes. Tier 1 if the challenger is in this set.
* @property store persistence layer (SQLite-backed in production, in-memory in
* tests).
* @property onPromptRequired called when a [PendingAuthApproval] needs to be
* surfaced to the UI. The UI subscribes to this side-channel and completes
* the contained [CompletableDeferred] with the user's pick.
*/
class AuthApprovalPolicy(
val selfApprovedRelays: () -> Set<NormalizedRelayUrl>,
val store: AuthApprovalStore,
val onPromptRequired: (PendingAuthApproval) -> Unit,
) {
/**
* Decide what to do with an AUTH challenge from `relayUrl`.
*
* @return [AuthApprovalDecision.Allow] for tier-1 / persisted-ALWAYS,
* [AuthApprovalDecision.Block] for persisted-BLOCKED,
* [AuthApprovalDecision.Pending] (and emits to [onPromptRequired]) for
* unknown relays.
*/
suspend fun classify(relayUrl: NormalizedRelayUrl): AuthApprovalDecision {
// Persisted decision wins over tier-1: if user explicitly blocked a
// relay that happens to also be in their outbox, respect the block.
when (store.getScope(relayUrl)) {
AuthApprovalScope.ALWAYS -> return AuthApprovalDecision.Allow
AuthApprovalScope.BLOCKED -> return AuthApprovalDecision.Block
AuthApprovalScope.ONCE -> return AuthApprovalDecision.Allow
null -> Unit
}
if (relayUrl in selfApprovedRelays()) {
return AuthApprovalDecision.Allow
}
val deferred = CompletableDeferred<AuthApprovalScope>()
onPromptRequired(PendingAuthApproval(relayUrl, deferred))
return AuthApprovalDecision.Pending(deferred)
}
/** Persist (or cache) the user's choice from a [PendingAuthApproval] resolution. */
suspend fun recordDecision(
relayUrl: NormalizedRelayUrl,
scope: AuthApprovalScope,
) {
// `ONCE` lives in-memory only (the InMemoryAuthApprovalStore handles
// this transparently). `ALWAYS` and `BLOCKED` persist via the store.
store.setScope(relayUrl, scope)
}
}
@@ -0,0 +1,136 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.relayClient.nip17Dm
import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Resolves a recipient's NIP-17 inbox relays (kind:10050) for DM delivery.
*
* Three-layer lookup, in order:
*
* 1. **LocalCache hit** — the caller has already seen the user's kind:10050
* via the normal feed subscription pipeline. Cheapest; no I/O.
* 2. **In-memory LRU cache** — a prior resolve() succeeded for this pubkey
* within the TTL. Avoids re-querying indexers when the user opens a
* conversation list and clicks several recipients in sequence.
* 3. **Indexer fan-out** — query a curated set of indexer relays for the
* user's kind:10050 via [RecipientRelayFetcher]. The client passed in
* here MUST be an **unauthenticated** instance (no [RelayAuthenticator]
* attached) — otherwise an indexer's AUTH challenge would extract an
* identity-key signature from the user, turning the metadata leak
* "indexer learns who we want to DM" into "indexer learns user U wants
* to DM pubkey X".
*
* Filters to **kind:10050 only**. Per NIP-17 §Publishing, gift wraps MUST
* land on relays in the recipient's kind:10050; this resolver never
* substitutes the NIP-65 read marker as a fallback, because doing so leaks
* DMs to relays the recipient did not explicitly designate for DMs.
*
* Empty result is the canonical "we don't know where to send" signal — the
* caller should refuse to publish rather than fall back to its own relays
* (see [com.vitorpamplona.amethyst.desktop.model.DesktopIAccount.resolveDmInboxRelaysStrict]).
*
* @property unauthenticatedClient NostrClient WITHOUT a RelayAuthenticator
* attached. Use a dedicated instance — do NOT pass the app's primary
* client.
* @property indexerRelays Curated indexer set. Typically
* [com.vitorpamplona.amethyst.commons.defaults.DefaultDmIndexerRelays].
* @property localLookup Callback the resolver invokes first to check the
* LocalCache — returns the user's current kind:10050 list or null if
* unknown. Allows commons/headless callers to plug in a CLI-safe lookup.
* @property cacheTtlMs LRU cache TTL. 1h matches the brainstorm's open
* question; configurable here for tests.
* @property cacheSize LRU bound. 100 entries × ~200 bytes each is trivial
* memory; matches typical active-conversation count for power users.
*/
class DmInboxRelayResolver(
private val unauthenticatedClient: INostrClient,
private val indexerRelays: Set<NormalizedRelayUrl>,
private val localLookup: (HexKey) -> List<NormalizedRelayUrl>?,
private val cacheTtlMs: Long = 60 * 60 * 1_000L,
private val cacheSize: Int = 100,
private val nowMs: () -> Long = {
kotlin.time.Clock.System
.now()
.toEpochMilliseconds()
},
) {
private data class Entry(
val relays: List<NormalizedRelayUrl>,
val expiresAtMs: Long,
)
private val cache = linkedMapOf<HexKey, Entry>()
private val mutex = Mutex()
/**
* Resolve `pubkey`'s NIP-17 inbox relays. Returns empty list if neither
* the LocalCache nor the indexer fan-out yielded a kind:10050.
*/
suspend fun resolve(pubkey: HexKey): List<NormalizedRelayUrl> {
localLookup(pubkey)?.takeIf { it.isNotEmpty() }?.let { return it }
val now = nowMs()
mutex.withLock {
cache[pubkey]?.let { entry ->
if (entry.expiresAtMs > now) {
// Refresh LRU order on hit
cache.remove(pubkey)
cache[pubkey] = entry
return entry.relays
} else {
cache.remove(pubkey)
}
}
}
if (indexerRelays.isEmpty()) return emptyList()
val lists = RecipientRelayFetcher.fetchRelayLists(unauthenticatedClient, pubkey, indexerRelays)
// Strict: kind:10050 ONLY. No NIP-65 fallback. Empty = canonical
// "unreachable" signal; caller refuses to publish.
val relays = lists.dmInbox
mutex.withLock {
cache[pubkey] = Entry(relays, now + cacheTtlMs)
while (cache.size > cacheSize) {
cache.remove(cache.keys.iterator().next())
}
}
return relays
}
/** Evict a specific entry — e.g. when LocalCache observes a fresh kind:10050. */
suspend fun invalidate(pubkey: HexKey) {
mutex.withLock { cache.remove(pubkey) }
}
/** Wipe the entire cache — e.g. on account switch. */
suspend fun clear() {
mutex.withLock { cache.clear() }
}
}
@@ -43,7 +43,7 @@ fun SigningAwareButton(
tint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
) {
when (signingState.state) {
is SigningOpState.Pending -> {
is SigningOpState.Pending, is SigningOpState.Progress -> {
Box(modifier = modifier.size(32.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
@@ -37,11 +37,29 @@ sealed class SigningOpState {
data object Pending : SigningOpState()
/**
* Signing is in flight AND has a known step count — typically a NIP-17
* group send via a remote signer (bunker), where the UI can usefully show
* "Encrypting via remote signer ([current] of [total])".
*
* Treated as Pending for all is-pending checks via [isPending] below;
* existing callers that branch on `is Pending` keep working unchanged.
* New callers can render the counter when [SigningOpState] is `Progress`.
*/
data class Progress(
val current: Int,
val total: Int,
val label: String? = null,
) : SigningOpState()
data class Error(
val message: String,
) : SigningOpState()
}
/** True when signing is in flight, regardless of whether step counts are known. */
fun SigningOpState.isPending(): Boolean = this is SigningOpState.Pending || this is SigningOpState.Progress
/**
* Global signing status — any [SigningState] instance updates this when signing starts/ends.
* Observe [globalState] from a screen-level composable to show a persistent status bar.
@@ -86,7 +104,7 @@ class SigningState {
private set
suspend fun <T> execute(block: suspend () -> T): T? {
if (state is SigningOpState.Pending) return null
if (state.isPending()) return null
state = SigningOpState.Pending
GlobalSigningStatus.onPending()
errorMessage = null
@@ -114,6 +132,23 @@ class SigningState {
}
}
/**
* Update the in-flight signing state with a progress counter. Use during
* multi-step operations (NIP-17 group send via bunker, batch zaps) to
* show the user how far the in-flight op has progressed.
*
* Only takes effect while [state] is Pending or Progress — no-op
* otherwise so callers don't have to gate on Idle/Error themselves.
*/
fun updateProgress(
current: Int,
total: Int,
label: String? = null,
) {
if (!state.isPending()) return
state = SigningOpState.Progress(current, total, label)
}
private fun setError(message: String) {
errorMessage = message
state = SigningOpState.Error(message)
@@ -87,6 +87,21 @@ fun SigningStatusBar(
}
}
is SigningOpState.Progress -> {
Snackbar(
shape = RoundedCornerShape(8.dp),
containerColor = MaterialTheme.colorScheme.inverseSurface,
contentColor = MaterialTheme.colorScheme.inverseOnSurface,
modifier = Modifier.padding(horizontal = 16.dp),
) {
val label = opState.label ?: "Signing"
Text(
text = "$label (${opState.current} of ${opState.total})",
style = MaterialTheme.typography.bodyMedium,
)
}
}
is SigningOpState.Error -> {
Snackbar(
shape = RoundedCornerShape(8.dp),
@@ -25,6 +25,8 @@ import androidx.compose.ui.text.input.TextFieldValue
import com.vitorpamplona.amethyst.commons.model.IAccount
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.references.references
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
@@ -41,6 +43,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* Slim shared state for DM message composition.
@@ -56,6 +59,19 @@ class ChatNewMessageState(
val account: IAccount,
val cache: ICacheProvider,
val scope: CoroutineScope,
/**
* Optional resolver for probing kind:10050 via curated indexer relays
* when a peer's DM inbox isn't in the local cache. When provided,
* [updateRecipientRelayStatus] falls through to the resolver on a cache
* miss so the pre-send UI stops falsely reporting "recipient has no DM
* relay list" for accounts whose 10050 sits on an indexer we haven't
* subscribed to yet.
*
* When null (default, e.g. Android's ChatNewMessageViewModel which
* hasn't been wired to a resolver yet), behaviour matches the
* cache-only strict check.
*/
private val dmInboxResolver: (suspend (HexKey) -> List<NormalizedRelayUrl>?)? = null,
) {
private val _message = MutableStateFlow(TextFieldValue(""))
val message: StateFlow<TextFieldValue> = _message.asStateFlow()
@@ -86,20 +102,66 @@ class ChatNewMessageState(
}
/**
* Check if all recipients have DM relay lists.
* Messages can only be sent via NIP-17, so recipients must have
* either a DM inbox relay list (kind 10050) or NIP-65 inbox relays.
* Check whether every participant in the current room is reachable via
* NIP-17 — i.e. has a published kind:10050 that lists at least one
* relay.
*
* Uses the **strict** check ([com.vitorpamplona.amethyst.commons.model.User.dmInboxRelaysStrict],
* kind:10050 only) instead of the lenient [dmInboxRelays] that falls
* back to the NIP-65 read marker — the send path also uses strict
* resolution (see `DesktopIAccount.resolveDmInboxRelaysStrict`), and
* disagreement here caused the pre-send UI to green-light sends that
* would then fail at send time.
*
* Two-phase check:
*
* 1. Synchronous cache check — every peer's kind:10050 sits in
* [cache]. If all present with at least one relay, unblock
* immediately.
* 2. Async resolver probe (only when [dmInboxResolver] is provided) —
* for peers whose 10050 isn't cached, kick off a curated-indexer
* fan-out. If any peer's relays turn up, update the flag to
* unblock the composer without requiring the user to restart the
* conversation view.
*
* The blocking flag is set optimistically during the probe so the
* user still sees the warning until we've confirmed the peer is
* genuinely unreachable via NIP-17. This preserves the "don't allow
* silent-fail sends" invariant.
*/
fun updateRecipientRelayStatus() {
val currentRoom = _room.value
if (currentRoom != null) {
_recipientsMissingDmRelays.value =
currentRoom.users.any { hexKey ->
val user = cache.getOrCreateUser(hexKey)
user?.dmInboxRelays().isNullOrEmpty()
}
} else {
if (currentRoom == null) {
_recipientsMissingDmRelays.value = false
return
}
val missing =
currentRoom.users.filter { hexKey ->
val user = cache.getOrCreateUser(hexKey)
user?.dmInboxRelaysStrict().isNullOrEmpty()
}
if (missing.isEmpty()) {
_recipientsMissingDmRelays.value = false
return
}
// Cache miss → block optimistically, then probe indexers for the
// missing peers. If any of them turn up a kind:10050, unblock.
_recipientsMissingDmRelays.value = true
val resolver = dmInboxResolver ?: return
scope.launch {
val stillMissing =
missing.any { hexKey ->
val fanOut = resolver(hexKey)
fanOut.isNullOrEmpty()
}
// The room may have changed while we were probing; only apply
// the result if we're still looking at the same conversation.
if (_room.value == currentRoom) {
_recipientsMissingDmRelays.value = stillMissing
}
}
}
@@ -0,0 +1,169 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.relayClient.auth
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip42RelayAuth.tags.RelayTag
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.yield
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
/**
* End-to-end exercise of the AUTH stack: policy classification +
* RelayAuthEvent.build template + real NostrSignerInternal signing.
*
* This is the lambda-level shape that [DesktopAuthCoordinator]'s
* `signWithAllLoggedInUsers` calls into. It isolates the policy/signer
* round-trip from the live websocket layer (which has its own coverage
* in `geode/.../KtorRelayTest.kt` against a Ktor mock relay).
*
* Together with the existing AuthApprovalPolicyTest (classifier),
* PoolEventOutboxStateTest (auth-required carve-out), and
* GiftWrapRelayHintTest (NIP-17 hint placement), this covers the AUTH
* pipeline at unit granularity — the geode Ktor tests handle the
* websocket-level round-trip.
*/
class AuthApprovalEndToEndTest {
private val signer = NostrSignerInternal(KeyPair())
private val ownInbox = NormalizedRelayUrl("wss://own.inbox/")
private val unknown = NormalizedRelayUrl("wss://unknown.relay/")
private val challenge = "test-challenge-abc123"
private fun newPolicy(
ownSet: Set<NormalizedRelayUrl> = setOf(ownInbox),
onPrompt: (PendingAuthApproval) -> Unit = {},
): Pair<AuthApprovalPolicy, AuthApprovalStore> {
val store = InMemoryAuthApprovalStore()
return AuthApprovalPolicy(
selfApprovedRelays = { ownSet },
store = store,
onPromptRequired = onPrompt,
) to store
}
/**
* Coordinator's lambda shape, distilled. Returns the signed AUTH event
* (or null on Block / not-signed-by-policy).
*/
private suspend fun signWithPolicy(
relay: NormalizedRelayUrl,
policy: AuthApprovalPolicy,
): RelayAuthEvent? {
val template = RelayAuthEvent.build(relay, challenge)
val relayFromTemplate = template.tags.firstNotNullOfOrNull(RelayTag::parse)
assertEquals(relay, relayFromTemplate, "RelayAuthEvent.build must round-trip via RelayTag.parse")
return when (val decision = policy.classify(relay)) {
AuthApprovalDecision.Allow -> signer.sign(template)
AuthApprovalDecision.Block -> null
is AuthApprovalDecision.Pending -> {
val resolved = decision.pending.await()
if (resolved != AuthApprovalScope.ONCE) policy.recordDecision(relay, resolved)
if (resolved == AuthApprovalScope.BLOCKED) null else signer.sign(template)
}
}
}
@Test
fun tier1OwnInboxAutoSignsValidAuthEvent() =
runTest {
val (policy, _) = newPolicy()
val signed = signWithPolicy(ownInbox, policy)
assertNotNull(signed)
assertEquals(RelayAuthEvent.KIND, signed.kind)
assertEquals(signer.pubKey, signed.pubKey)
assertEquals(challenge, signed.challenge())
assertEquals(ownInbox, signed.relay())
}
@Test
fun tier2UnknownPromptsAndOnceResolutionSigns() =
runTest {
var prompted: PendingAuthApproval? = null
val (policy, _) = newPolicy(onPrompt = { prompted = it })
coroutineScope {
// Concurrent: lambda suspends inside policy.classify; we
// resolve the deferred from outside as the banner UI would.
val deferred = async { signWithPolicy(unknown, policy) }
yieldUntilNotNull { prompted }
prompted!!.decision.complete(AuthApprovalScope.ONCE)
val signed = deferred.await()
assertNotNull(signed)
assertEquals(unknown, signed.relay())
}
}
@Test
fun tier2BlockedResolutionReturnsNullAndPersists() =
runTest {
var prompted: PendingAuthApproval? = null
val (policy, store) = newPolicy(onPrompt = { prompted = it })
coroutineScope {
val deferred = async { signWithPolicy(unknown, policy) }
yieldUntilNotNull { prompted }
prompted!!.decision.complete(AuthApprovalScope.BLOCKED)
val signed = deferred.await()
assertNull(signed)
assertEquals(AuthApprovalScope.BLOCKED, store.getScope(unknown))
}
}
@Test
fun tier2AlwaysPersistsAndSkipsPromptNextTime() =
runTest {
var promptCount = 0
val (policy, store) =
newPolicy(onPrompt = {
it.decision.complete(AuthApprovalScope.ALWAYS)
promptCount++
})
// First call: prompts and resolves to ALWAYS.
val first = signWithPolicy(unknown, policy)
assertNotNull(first)
assertEquals(1, promptCount)
assertEquals(AuthApprovalScope.ALWAYS, store.getScope(unknown))
// Second call: should NOT prompt again.
val second = signWithPolicy(unknown, policy)
assertNotNull(second)
assertEquals(1, promptCount, "ALWAYS persisted — no second prompt")
}
}
private suspend inline fun <T> yieldUntilNotNull(crossinline supplier: () -> T?): T {
repeat(100) {
supplier()?.let { return it }
yield()
}
error("supplier never produced a value within 100 yields")
}
@@ -0,0 +1,163 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.relayClient.auth
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertSame
import kotlin.test.assertTrue
class AuthApprovalPolicyTest {
private val ownOutbox = NormalizedRelayUrl("wss://own.outbox/")
private val unknown = NormalizedRelayUrl("wss://unknown.relay/")
private val blockedRelay = NormalizedRelayUrl("wss://blocked.relay/")
private fun newPolicy(
ownSet: Set<NormalizedRelayUrl> = setOf(ownOutbox),
onPrompt: (PendingAuthApproval) -> Unit = {},
): Pair<AuthApprovalPolicy, AuthApprovalStore> {
val store = InMemoryAuthApprovalStore()
val policy =
AuthApprovalPolicy(
selfApprovedRelays = { ownSet },
store = store,
onPromptRequired = onPrompt,
)
return policy to store
}
@Test
fun tier1OwnOutboxRelayIsAutoAllowed() =
runTest {
val (policy, _) = newPolicy()
val decision = policy.classify(ownOutbox)
assertSame(AuthApprovalDecision.Allow, decision)
}
@Test
fun unknownRelayPromptsAndReturnsPending() =
runTest {
val prompts = mutableListOf<PendingAuthApproval>()
val (policy, _) = newPolicy(onPrompt = { prompts += it })
val decision = policy.classify(unknown)
assertIs<AuthApprovalDecision.Pending>(decision)
assertEquals(1, prompts.size)
assertEquals(unknown, prompts.first().relayUrl)
assertSame(decision.pending, prompts.first().decision)
}
@Test
fun persistedAlwaysIsAutoAllowed() =
runTest {
val (policy, store) = newPolicy()
store.setScope(unknown, AuthApprovalScope.ALWAYS)
val decision = policy.classify(unknown)
assertSame(AuthApprovalDecision.Allow, decision)
}
@Test
fun persistedBlockedIsAutoBlockedEvenForOwnOutbox() =
runTest {
// Explicit user `[Never]` overrides tier-1 — if the user blocked a relay
// that happens to be in their outbox, respect that.
val (policy, store) = newPolicy()
store.setScope(ownOutbox, AuthApprovalScope.BLOCKED)
val decision = policy.classify(ownOutbox)
assertSame(AuthApprovalDecision.Block, decision)
}
@Test
fun recordDecisionPersistsAndChangesSubsequentClassification() =
runTest {
var promptCount = 0
val (policy, _) = newPolicy(onPrompt = { promptCount++ })
// First call prompts.
val first = policy.classify(unknown)
assertIs<AuthApprovalDecision.Pending>(first)
assertEquals(1, promptCount)
// User picks `[Always]`.
policy.recordDecision(unknown, AuthApprovalScope.ALWAYS)
// Subsequent calls return Allow without prompting.
val second = policy.classify(unknown)
assertSame(AuthApprovalDecision.Allow, second)
assertEquals(1, promptCount, "should not prompt again after Always grant")
}
@Test
fun blockedDecisionPersistsAndStaysBlocked() =
runTest {
var promptCount = 0
val (policy, _) = newPolicy(onPrompt = { promptCount++ })
// First call prompts.
policy.classify(blockedRelay)
assertEquals(1, promptCount)
// User picks `[Never]`.
policy.recordDecision(blockedRelay, AuthApprovalScope.BLOCKED)
// Subsequent classify returns Block without prompting.
val decision = policy.classify(blockedRelay)
assertSame(AuthApprovalDecision.Block, decision)
assertEquals(1, promptCount, "should not prompt again after Never")
}
@Test
fun selfApprovedRelaysIsReevaluatedPerCall() =
runTest {
// Account state changes (user adds a relay to their outbox) must take
// effect immediately — the policy reads the supplier per classify.
var ownSet = setOf<NormalizedRelayUrl>()
val policy =
AuthApprovalPolicy(
selfApprovedRelays = { ownSet },
store = InMemoryAuthApprovalStore(),
onPromptRequired = {},
)
assertIs<AuthApprovalDecision.Pending>(policy.classify(ownOutbox))
ownSet = setOf(ownOutbox)
assertSame(AuthApprovalDecision.Allow, policy.classify(ownOutbox))
}
@Test
fun storeClearWipesAllApprovals() =
runTest {
val store = InMemoryAuthApprovalStore()
store.setScope(unknown, AuthApprovalScope.ALWAYS)
store.setScope(blockedRelay, AuthApprovalScope.BLOCKED)
store.clear()
// Both relays now unknown → fresh classification prompts.
assertTrue(store.getScope(unknown) == null)
assertTrue(store.getScope(blockedRelay) == null)
}
}
@@ -0,0 +1,155 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.relayClient.nip17Dm
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class DmInboxRelayResolverTest {
private val peer: HexKey = "0".repeat(64)
private val cachedRelay = NormalizedRelayUrl("wss://cached.relay/")
private val indexer = NormalizedRelayUrl("wss://indexer.example/")
private fun newResolver(
localLookup: (HexKey) -> List<NormalizedRelayUrl>?,
indexers: Set<NormalizedRelayUrl> = setOf(indexer),
now: () -> Long = { 0L },
ttlMs: Long = 60_000L,
) = DmInboxRelayResolver(
unauthenticatedClient = EmptyNostrClient(),
indexerRelays = indexers,
localLookup = localLookup,
cacheTtlMs = ttlMs,
cacheSize = 4,
nowMs = now,
)
@Test
fun localLookupHitShortCircuitsIndexerFanOut() =
runTest {
var indexerCalled = false
// The indexer would only run if RecipientRelayFetcher.fetchRelayLists ran. EmptyNostrClient returns no events,
// so even if it did, we'd get an empty list — but assert indirectly via the result.
val resolver = newResolver(localLookup = { listOf(cachedRelay) })
val result = resolver.resolve(peer)
assertEquals(listOf(cachedRelay), result)
assertTrue(!indexerCalled) // we never set this; LocalLookup returns first
}
@Test
fun emptyIndexerSetReturnsEmpty() =
runTest {
val resolver = newResolver(localLookup = { null }, indexers = emptySet())
val result = resolver.resolve(peer)
assertEquals(emptyList(), result)
}
@Test
fun emptyLocalAndEmptyIndexerYieldsEmpty() =
runTest {
// EmptyNostrClient.fetchAll returns no events → resolver yields empty.
val resolver = newResolver(localLookup = { null })
val result = resolver.resolve(peer)
assertEquals(emptyList(), result)
}
@Test
fun cacheHitWithinTtlSkipsIndexer() =
runTest {
// First call: localLookup returns null, indexer empty → caches [] for peer.
// Second call: same peer within TTL → returns cached [], no new indexer call.
var localLookupCalls = 0
val resolver =
newResolver(
localLookup = {
localLookupCalls++
null
},
)
resolver.resolve(peer)
resolver.resolve(peer)
// localLookup is invoked on every resolve (cheap), but the indexer
// fan-out + cache write only happens once. Hard to assert directly
// on RecipientRelayFetcher without a mock client; cache TTL behaviour
// is exercised below.
assertEquals(2, localLookupCalls)
}
@Test
fun cacheExpiryTriggersFreshIndexerCall() =
runTest {
var nowMs = 0L
val ttl = 1_000L
val resolver = newResolver(localLookup = { null }, now = { nowMs }, ttlMs = ttl)
resolver.resolve(peer) // caches [] with expiresAt = ttl
nowMs = ttl + 1 // past expiry
val second = resolver.resolve(peer)
assertEquals(emptyList(), second) // still empty from EmptyNostrClient — but went through the indexer path again
}
@Test
fun clearWipesAllEntries() =
runTest {
val resolver = newResolver(localLookup = { null })
resolver.resolve(peer)
resolver.clear()
// No way to introspect cache directly; assert through the resolve API
// continuing to work (would NPE if internal state were corrupt).
val result = resolver.resolve(peer)
assertEquals(emptyList(), result)
}
@Test
fun invalidateRemovesNamedEntry() =
runTest {
val resolver = newResolver(localLookup = { null })
resolver.resolve(peer)
resolver.invalidate(peer)
val result = resolver.resolve(peer)
assertEquals(emptyList(), result)
}
@Test
fun localLookupReturningEmptyListFallsThroughToCacheAndIndexer() =
runTest {
// Subtle: localLookup must return null OR a non-empty list. An EMPTY
// list from localLookup means "I know this user has no 10050" — but
// we want "I don't know" to fall through. The resolver guards with
// `takeIf { it.isNotEmpty() }`.
var localLookupCalls = 0
val resolver =
newResolver(
localLookup = {
localLookupCalls++
emptyList()
},
)
val result = resolver.resolve(peer)
assertEquals(emptyList(), result)
assertEquals(1, localLookupCalls)
}
}
@@ -164,6 +164,18 @@ class RelayLatencyTracker(
/**
* Expires pending entries older than the configured TTLs and records the TTL value as the
* sample (per the brainstorm: "punish silent relays"). Idempotent and cheap.
*
* The per-relay pending maps are `Collections.synchronizedMap(LinkedHashMap)` — their
* individual reads and writes are thread-safe, but iteration is NOT: per
* `Collections.synchronizedMap` javadoc, the caller MUST hold the returned map's
* monitor while iterating. Directly iterating triggers a
* `ConcurrentModificationException` when a producer thread (network dispatcher)
* mutates the map while the sweep is walking it — reliably reproduced on macOS
* during any relay-add on Amethyst Desktop as of 2026-07-06.
*
* Fix: iterate under `synchronized(pending)` blocks so the network dispatcher
* waits until sweep releases the monitor. The sweep is O(pending), typically
* ~single-digit entries per relay, so the hold time is negligible.
*/
override fun sweep(nowMs: Long) {
// Per-relay pending maps are `Collections.synchronizedMap(LinkedHashMap)` — the
@@ -71,23 +71,28 @@ import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
import com.vitorpamplona.amethyst.commons.defaults.DefaultDmIndexerRelays
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideMaterialSymbols
import com.vitorpamplona.amethyst.commons.moderation.LocalHashtagSpamSettings
import com.vitorpamplona.amethyst.commons.moderation.LocalSpamExemptKeys
import com.vitorpamplona.amethyst.commons.moderation.PreferencesHashtagSpamSettings
import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalBanner
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.DmInboxRelayResolver
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull
import com.vitorpamplona.amethyst.commons.wot.LocalWoTReady
import com.vitorpamplona.amethyst.commons.wot.LocalWoTService
import com.vitorpamplona.amethyst.desktop.account.AccountManager
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.auth.DesktopAuthCoordinator
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.model.DesktopAccountRelays
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
import com.vitorpamplona.amethyst.desktop.model.DesktopRelayCategories
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher
import com.vitorpamplona.amethyst.desktop.platform.PlatformInfo
import com.vitorpamplona.amethyst.desktop.platform.applyNativeWindowChrome
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup
@@ -124,9 +129,12 @@ import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard
import com.vitorpamplona.amethyst.desktop.ui.settings.ImageCompressionSettings
import com.vitorpamplona.amethyst.desktop.ui.settings.MediaServerSettings
import com.vitorpamplona.amethyst.desktop.ui.settings.NamecoinSettingsSection
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
@@ -922,6 +930,39 @@ private fun AppInner(
}
val nip11Fetcher = remember { Nip11Fetcher() }
// Dedicated unauthenticated NostrClient for kind:10050 lookups against
// curated indexer relays. MUST NOT have a RelayAuthenticator attached —
// an authenticated indexer query would extract identity-key signatures
// and turn "indexer learns who we want to DM" into "indexer learns user
// U wants to DM pubkey X" (security review F-01).
val indexerClient =
remember(httpClient) {
NostrClient(BasicOkHttpWebSocket.Builder(httpClient::getHttpClient)).also { it.connect() }
}
DisposableEffect(indexerClient) {
onDispose { indexerClient.disconnect() }
}
// Resolver consults LocalCache first, then its own LRU, then the indexer
// client. Strict kind:10050 only — no NIP-65 read-marker fallback.
val dmInboxResolver =
remember(indexerClient, localCache) {
DmInboxRelayResolver(
unauthenticatedClient = indexerClient,
indexerRelays =
DefaultDmIndexerRelays.RELAYS
.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
.toSet(),
localLookup = { pubkey ->
// Strict kind:10050 only — the lenient dmInboxRelays() falls
// back to NIP-65 read relays, which this fast-path would
// return before the strict indexer fan-out ran, leaking DM
// metadata to relays the recipient never designated for DMs.
localCache.getUserIfExists(pubkey)?.dmInboxRelaysStrict()
},
)
}
// Start 1Hz metrics snapshot for relay dashboard
LaunchedEffect(relayManager) {
relayManager.startMetricsSnapshot(this)
@@ -953,11 +994,20 @@ private fun AppInner(
).also { it.startCleanupLoop() }
}
// NIP-42 AUTH coordinator — wires relay-auth challenges through the
// tier classifier so own DM-inbox relays auto-sign and unknown relays
// surface a tier-2 banner approval via authCoordinator.pendingApprovals.
val authCoordinator =
remember(relayManager, localCache) {
DesktopAuthCoordinator(relayManager, localCache, scope)
}
// Clear cache and subscriptions on logout or account switch
var previousAccountPubKey by remember { mutableStateOf<String?>(null) }
LaunchedEffect(accountState) {
when (val state = accountState) {
is AccountState.LoggedOut -> {
authCoordinator.onLogout()
subscriptionsCoordinator.clear()
localCache.accountPubkey = null
localCache.clear()
@@ -970,6 +1020,7 @@ private fun AppInner(
val currentPubKey = state.pubKeyHex
if (previousAccountPubKey != null && previousAccountPubKey != currentPubKey) {
// Account switched — clear old data so new feed loads fresh
authCoordinator.onLogout()
subscriptionsCoordinator.clear()
localCache.accountPubkey = null
localCache.clear()
@@ -994,6 +1045,7 @@ private fun AppInner(
scope.launch(Dispatchers.IO) {
localRelayStore.hydrate(localCache)
}
authCoordinator.onLogin(state)
previousAccountPubKey = currentPubKey
}
@@ -1271,32 +1323,53 @@ private fun AppInner(
LocalNamecoinService provides namecoinService,
LocalSpamExemptKeys provides spamExemptKeys,
) {
MainContent(
layoutMode = layoutMode,
deckState = deckState,
workspaceManager = workspaceManager,
singlePaneState = singlePaneState,
pinnedNavBarState = pinnedNavBarState,
relayManager = relayManager,
localCache = localCache,
accountManager = accountManager,
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
indexRelaysStore = indexRelaysStore,
nip11Fetcher = nip11Fetcher,
appScope = scope,
torStatus = currentTorStatus,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onShowAppDrawer = onShowAppDrawer,
onOpenFeedsDrawer = {
appDrawerInitialTab =
com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawerTab.FEEDS
onShowAppDrawer()
},
onShowImportFollowListDialog = onShowImportFollowListDialog,
)
val pendingAuthApprovals by authCoordinator.pendingApprovals.collectAsState()
Column(modifier = Modifier.fillMaxSize()) {
// On macOS the window uses `apple.awt.fullWindowContent`
// (see [applyNativeWindowChrome]), so the traffic-light
// buttons sit over the top-left corner of content. Clear
// that zone so the banner text/icon aren't occluded.
val bannerModifier =
if (PlatformInfo.isMacOS) {
Modifier.padding(start = 80.dp, top = 8.dp, end = 8.dp, bottom = 4.dp)
} else {
Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
}
AuthApprovalBanner(
pending = pendingAuthApprovals.values.toList(),
onResolve = { url, scope -> authCoordinator.resolve(url, scope) },
modifier = bannerModifier,
)
Box(modifier = Modifier.weight(1f)) {
MainContent(
layoutMode = layoutMode,
deckState = deckState,
workspaceManager = workspaceManager,
singlePaneState = singlePaneState,
pinnedNavBarState = pinnedNavBarState,
relayManager = relayManager,
localCache = localCache,
accountManager = accountManager,
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
indexRelaysStore = indexRelaysStore,
nip11Fetcher = nip11Fetcher,
dmInboxResolver = dmInboxResolver,
appScope = scope,
torStatus = currentTorStatus,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onShowAppDrawer = onShowAppDrawer,
onOpenFeedsDrawer = {
appDrawerInitialTab =
com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawerTab.FEEDS
onShowAppDrawer()
},
onShowImportFollowListDialog = onShowImportFollowListDialog,
)
}
}
// Import Follow List dialog (triggered from File menu /
// Cmd+Shift+I). Rendered inside this CompositionLocalProvider
@@ -1408,6 +1481,7 @@ fun MainContent(
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
indexRelaysStore: com.vitorpamplona.amethyst.commons.relays.index.PreferencesIndexRelays,
nip11Fetcher: Nip11Fetcher,
dmInboxResolver: DmInboxRelayResolver,
appScope: CoroutineScope,
torStatus: com.vitorpamplona.amethyst.commons.tor.TorServiceStatus,
onShowComposeDialog: () -> Unit,
@@ -1434,8 +1508,8 @@ fun MainContent(
}
val iAccount =
remember(account, localCache, relayManager, dmSendTracker, accountRelays) {
DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope, accountRelays)
remember(account, localCache, relayManager, dmSendTracker, accountRelays, dmInboxResolver) {
DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope, accountRelays, dmInboxResolver)
}
// When iAccount is replaced (account switch), the previous WoTService's
@@ -0,0 +1,186 @@
/*
* 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.desktop.auth
import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalDecision
import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalPolicy
import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalScope
import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalStore
import com.vitorpamplona.amethyst.commons.relayClient.auth.PendingAuthApproval
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.collections.immutable.PersistentMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
/**
* Desktop NIP-42 AUTH wiring.
*
* Today the desktop has NO AUTH wiring — relays demanding AUTH from desktop
* users get silently ignored. This coordinator closes that gap, but does it
* the security-conscious way:
*
* - **Tier 1 (auto-allow):** the relay is in the active account's NIP-17 DM
* inbox set (kind:10050). Sign immediately, no prompt.
* - **Tier 2 (prompt):** anything else. Surface a [PendingAuthApproval] on
* [pendingApprovals]; the (forthcoming) inline AUTH banner reads from
* there and calls [resolve] with the user's `[Once] [Always] [Never]`
* pick.
*
* **Until the banner UI lands**, tier-2 approvals accumulate in
* [pendingApprovals] but nothing resolves them — so tier-2 relays don't get
* an AUTH response. Behaviour-wise that's the same outcome as the pre-this-
* commit world (no AUTH at all). The improvement is tier-1: own DM-inbox
* relays now AUTH automatically.
*
* Persisted `ALWAYS` / `BLOCKED` decisions are scoped per-account via
* [PreferencesAuthApprovalStore].
*
* Lifecycle: bind to [AccountState] from the host (Main.kt) — call [onLogin]
* when an account becomes [AccountState.LoggedIn] and [onLogout] on logout /
* account-switch. Each call tears down the prior [RelayAuthenticator] and
* cancels any pending deferreds.
*/
class DesktopAuthCoordinator(
private val relayManager: RelayConnectionManager,
private val localCache: DesktopLocalCache,
private val scope: CoroutineScope,
) {
private val lock = Any()
@Volatile
private var active: ActiveAuth? = null
private val _pendingApprovals = MutableStateFlow<PersistentMap<NormalizedRelayUrl, PendingAuthApproval>>(persistentMapOf())
/**
* Tier-2 AUTH challenges awaiting the user's `[Once] [Always] [Never]`
* decision. The banner UI subscribes and calls [resolve] to settle each.
*/
val pendingApprovals: StateFlow<PersistentMap<NormalizedRelayUrl, PendingAuthApproval>> = _pendingApprovals.asStateFlow()
/** Wire AUTH for a newly logged-in account. Idempotent. */
fun onLogin(account: AccountState.LoggedIn) {
synchronized(lock) {
if (active?.pubKeyHex == account.pubKeyHex) return
tearDownLocked()
val store = PreferencesAuthApprovalStore(account.pubKeyHex)
val policy =
AuthApprovalPolicy(
selfApprovedRelays = { selfApprovedRelaysFor(account.pubKeyHex) },
store = store,
onPromptRequired = { pending ->
_pendingApprovals.update { it.put(pending.relayUrl, pending) }
},
)
val authenticator =
RelayAuthenticator(
client = relayManager.client,
scope = scope,
signWithAllLoggedInUsers = { relayUrl, template ->
val signed = signWithPolicy(account, relayUrl, template, policy)
signed?.let { listOf(it) } ?: emptyList()
},
)
active = ActiveAuth(account.pubKeyHex, store, policy, authenticator)
Log.d("DesktopAuthCoordinator") { "AUTH wired for ${account.pubKeyHex.take(8)}" }
}
}
/** Tear down AUTH on logout / account switch. */
fun onLogout() {
synchronized(lock) { tearDownLocked() }
}
/**
* Resolve a tier-2 [PendingAuthApproval] from the banner UI.
*
* Removes the entry from [pendingApprovals] before completing the
* deferred, so the suspended signer wakes up exactly once.
*/
fun resolve(
relayUrl: NormalizedRelayUrl,
scope: AuthApprovalScope,
) {
val pending = _pendingApprovals.value[relayUrl] ?: return
_pendingApprovals.update { it.remove(relayUrl) }
pending.decision.complete(scope)
}
private fun tearDownLocked() {
val prev = active ?: return
prev.authenticator.destroy()
// Cancel any in-flight tier-2 prompts so suspended signers wake up.
_pendingApprovals.value.values.forEach { it.decision.complete(AuthApprovalScope.BLOCKED) }
_pendingApprovals.value = persistentMapOf()
active = null
}
private fun selfApprovedRelaysFor(pubKeyHex: String): Set<NormalizedRelayUrl> {
// Tier-1 = the user's own NIP-17 DM-inbox (kind:10050). Strict
// by design — write/read relays (NIP-65 kind:10002) are NOT included,
// because the user may have read-only relays they don't intend to
// identify themselves to via AUTH.
//
// MUST use dmInboxRelaysStrict (kind:10050 only) rather than the
// lenient dmInboxRelays helper, which falls back to NIP-65 read
// relays and would silently expand tier-1 to include every relay
// in the user's outbox. That defeats the tier-2 prompt for any
// relay in the user's normal read set.
val user = localCache.getOrCreateUser(pubKeyHex)
return user.dmInboxRelaysStrict()?.toSet() ?: emptySet()
}
private suspend fun signWithPolicy(
account: AccountState.LoggedIn,
relayUrl: NormalizedRelayUrl,
template: EventTemplate<RelayAuthEvent>,
policy: AuthApprovalPolicy,
): RelayAuthEvent? =
when (val decision = policy.classify(relayUrl)) {
AuthApprovalDecision.Allow -> account.signer.sign(template)
AuthApprovalDecision.Block -> null
is AuthApprovalDecision.Pending -> {
val resolved = decision.pending.await()
if (resolved != AuthApprovalScope.ONCE) {
policy.recordDecision(relayUrl, resolved)
}
if (resolved == AuthApprovalScope.BLOCKED) null else account.signer.sign(template)
}
}
private data class ActiveAuth(
val pubKeyHex: String,
val store: AuthApprovalStore,
val policy: AuthApprovalPolicy,
val authenticator: RelayAuthenticator,
)
}
@@ -0,0 +1,81 @@
/*
* 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.desktop.auth
import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalScope
import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalStore
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import java.util.prefs.Preferences
/**
* Desktop persistence backend for [AuthApprovalStore] using
* `java.util.prefs.Preferences`.
*
* Trade-offs vs the full SQLite `auth_approvals` table proposed in the plan:
*
* - **Pro**: zero new dependencies, no schema migration, already proven for
* other small desktop settings (per memory: `SearchHistoryStore`,
* `DesktopPreferences`).
* - **Con**: flat key/value, no transactions, no native TTL. Acceptable here
* because the approval set per account is small (≪50 relays for any user)
* and the read pattern is "look up before signing AUTH" — once per relay
* per session, easily cached in memory by the [AuthApprovalPolicy] layer.
*
* Per-account scoping is by Preferences node: each account gets its own node
* at `/com/vitorpamplona/amethyst/desktop/auth/<full-pubkey>/`. Logout calls
* [clear] which `removeNode()`s the per-account subtree.
*
* `ONCE` scope is never persisted — that's the in-memory contract enforced
* by the [AuthApprovalStore] interface. This implementation only writes
* `ALWAYS` and `BLOCKED`.
*/
class PreferencesAuthApprovalStore(
private val accountPubKeyHex: String,
) : AuthApprovalStore {
private val node: Preferences =
Preferences.userRoot().node(
"/com/vitorpamplona/amethyst/desktop/auth/$accountPubKeyHex",
)
override suspend fun getScope(relayUrl: NormalizedRelayUrl): AuthApprovalScope? {
val raw = node.get(relayUrl.url, null) ?: return null
return runCatching { AuthApprovalScope.valueOf(raw) }.getOrNull()
}
override suspend fun setScope(
relayUrl: NormalizedRelayUrl,
scope: AuthApprovalScope,
) {
if (scope == AuthApprovalScope.ONCE) {
// ONCE is the session-only contract from AuthApprovalStore — must
// not touch the persistent store, otherwise it would silently
// upgrade to "until next clear()".
return
}
node.put(relayUrl.url, scope.name)
node.flush()
}
override suspend fun clear() {
node.removeNode()
node.flush()
}
}
@@ -31,10 +31,13 @@ import com.vitorpamplona.amethyst.commons.model.nip51Lists.OldBookmarkListState
import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListRepository
import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListState
import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.DmInboxRelayResolver
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
@@ -71,6 +74,7 @@ class DesktopIAccount(
val dmSendTracker: DmSendTracker,
private val scope: CoroutineScope,
private val accountRelays: DesktopAccountRelays? = null,
val dmInboxResolver: DmInboxRelayResolver? = null,
) : IAccount {
override val signer: NostrSigner = NostrSignerWithClientTag(accountState.signer, CLIENT_TAG_NAME)
@@ -187,7 +191,8 @@ class DesktopIAccount(
override suspend fun sendNip17PrivateMessage(template: EventTemplate<ChatMessageEvent>) {
if (!isWriteable()) return
val result = NIP17Factory().createMessageNIP17(template, signer)
val hints = recipientRelayHints(template.tags)
val result = NIP17Factory().createMessageNIP17(template, signer, recipientRelayHints = { hints[it] })
// Optimistic local add — use the inner ChatMessageEvent, not the wraps
val innerMsg = result.msg as ChatMessageEvent
@@ -197,18 +202,7 @@ class DesktopIAccount(
val batch =
result.wraps.map { wrap ->
val recipientKey = wrap.recipientPubKey()
val targetRelays =
if (recipientKey != null) {
val dmRelays =
localCache
.getOrCreateUser(recipientKey)
.dmInboxRelays()
?.toSet()
dmRelays?.ifEmpty { null }
?: relayManager.connectedRelays.value
} else {
relayManager.connectedRelays.value
}
val targetRelays = resolveDmInboxRelaysStrict(recipientKey)
wrap to targetRelays
}
@@ -218,7 +212,8 @@ class DesktopIAccount(
override suspend fun sendNip17EncryptedFile(template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>) {
if (!isWriteable()) return
val result = NIP17Factory().createEncryptedFileNIP17(template, signer)
val hints = recipientRelayHints(template.tags)
val result = NIP17Factory().createEncryptedFileNIP17(template, signer, recipientRelayHints = { hints[it] })
// Optimistic local add
val innerEvent = result.msg as ChatMessageEncryptedFileHeaderEvent
@@ -228,18 +223,7 @@ class DesktopIAccount(
val batch =
result.wraps.map { wrap ->
val recipientKey = wrap.recipientPubKey()
val targetRelays =
if (recipientKey != null) {
val dmRelays =
localCache
.getOrCreateUser(recipientKey)
.dmInboxRelays()
?.toSet()
dmRelays?.ifEmpty { null }
?: relayManager.connectedRelays.value
} else {
relayManager.connectedRelays.value
}
val targetRelays = resolveDmInboxRelaysStrict(recipientKey)
wrap to targetRelays
}
@@ -250,24 +234,69 @@ class DesktopIAccount(
val batch =
wraps.map { wrap ->
val recipientKey = wrap.recipientPubKey()
val targetRelays =
if (recipientKey != null) {
val dmRelays =
localCache
.getOrCreateUser(recipientKey)
.dmInboxRelays()
?.toSet()
dmRelays?.ifEmpty { null }
?: relayManager.connectedRelays.value
} else {
relayManager.connectedRelays.value
}
val targetRelays = resolveDmInboxRelaysStrict(recipientKey)
wrap to targetRelays
}
scope.launch { dmSendTracker.sendBatch(batch) }
}
/**
* NIP-17 inbox-relay resolution, strict variant — no fallback to the
* user's connected relays.
*
* Per NIP-17 §Publishing, a gift wrap MUST only land on relays advertised
* in the recipient's kind:10050. Falling back to the sender's connected
* relays when 10050 is missing publishes the wrap to relays the recipient
* does NOT consult — at best the message never arrives, at worst it leaks
* the conversation metadata (recipient pubkey + send timestamp) to relays
* outside the recipient's chosen inbox.
*
* Three-layer lookup when a [dmInboxResolver] is injected (default in
* Main.kt):
* 1. LocalCache hit (fast, no I/O)
* 2. Resolver's in-memory LRU cache
* 3. Curated indexer fan-out via an unauthenticated NostrClient
*
* Without a resolver (legacy / tests), falls back to LocalCache-only.
*
* Empty result means the wrap will not be sent; [DmSendTracker.sendBatch]
* surfaces this as a "No relays available" failure to the user.
*/
private suspend fun resolveDmInboxRelaysStrict(recipientKey: HexKey?): Set<NormalizedRelayUrl> = resolveDmInboxRelaysStrictOrdered(recipientKey).toSet()
/**
* Ordered variant of [resolveDmInboxRelaysStrict]. Preserves the relay
* order declared in the recipient's kind:10050 so the first element is the
* recipient's *primary* DM inbox — used as the NIP-17 gift-wrap `p`-tag
* relay hint. The unordered [resolveDmInboxRelaysStrict] derives from this.
*/
private suspend fun resolveDmInboxRelaysStrictOrdered(recipientKey: HexKey?): List<NormalizedRelayUrl> {
if (recipientKey == null) return emptyList()
val resolver = dmInboxResolver
return if (resolver != null) {
resolver.resolve(recipientKey)
} else {
localCache
.getOrCreateUser(recipientKey)
.dmInboxRelaysStrict()
?.ifEmpty { null }
?: emptyList()
}
}
/**
* Per-recipient primary DM-inbox relay, keyed by recipient pubkey, for the
* NIP-17 gift-wrap `p`-tag hint (`["p", <pubkey>, <primary-relay>]`). Built
* from the recipient `p` tags on the outgoing message template. A recipient
* with no resolvable kind:10050 maps to `null`, which keeps the historical
* 2-element `p` tag for that recipient.
*/
private suspend fun recipientRelayHints(tags: Array<Array<String>>): Map<HexKey, NormalizedRelayUrl?> {
val recipients = tags.mapNotNull { if (it.size >= 2 && it[0] == "p") it[1] else null }.toSet()
return recipients.associateWith { resolveDmInboxRelaysStrictOrdered(it).firstOrNull() }
}
private fun addEventToChatroom(
event: com.vitorpamplona.quartz.nip01Core.core.Event,
roomKey: com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey,
@@ -27,7 +27,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Filter builders for DM subscriptions on desktop.
@@ -116,20 +115,18 @@ object FilterDMs {
* Creates a filter for NIP-59 gift-wrapped events TO the user.
* Gift wraps (kind 1059) contain encrypted NIP-17 DMs.
*
* The since is adjusted back by 2 days because gift wrap created_at
* timestamps are randomized within a 2-day window for privacy.
* No `since` is exposed: per NIP-17, seal (kind 13) and gift wrap (kind 1059)
* `created_at` are randomized up to 2 days in the past for privacy. Any
* `since` window applied here silently drops wraps whose randomized
* timestamp predates it — losing real DMs and suppressing the unread badge.
* DMs are low-volume, so subscribing without a `since` is safe.
*
* @param userPubKeyHex The user's public key (hex)
* @param since Optional since timestamp (will be adjusted -2 days)
*/
fun giftWrapsToMe(
userPubKeyHex: HexKey,
since: Long? = null,
): Filter =
fun giftWrapsToMe(userPubKeyHex: HexKey): Filter =
Filter(
kinds = listOf(GiftWrapEvent.KIND, EphemeralGiftWrapEvent.KIND),
tags = mapOf("p" to listOf(userPubKeyHex)),
since = since?.minus(TimeUtils.twoDays()),
)
}
@@ -184,11 +181,13 @@ fun createNip04DmOutboxSubscription(
/**
* Creates a subscription config for NIP-59 gift-wrapped DMs TO the user.
* Subscribes on DM/inbox relays.
*
* No `since` parameter: see [FilterDMs.giftWrapsToMe] for why NIP-17 wraps
* cannot use a `since` window without dropping legitimate messages.
*/
fun createGiftWrapSubscription(
relays: Set<NormalizedRelayUrl>,
userPubKeyHex: HexKey,
since: Long? = null,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig? {
@@ -196,7 +195,7 @@ fun createGiftWrapSubscription(
return SubscriptionConfig(
subId = generateSubId("giftwrap-${userPubKeyHex.take(8)}"),
filters = listOf(FilterDMs.giftWrapsToMe(userPubKeyHex, since)),
filters = listOf(FilterDMs.giftWrapsToMe(userPubKeyHex)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
@@ -246,7 +246,15 @@ private fun CompactMessagesContent(
}
val messageState =
remember(currentRoom) {
ChatNewMessageState(account, cacheProvider, scope)
ChatNewMessageState(
account,
cacheProvider,
scope,
dmInboxResolver =
(account as? DesktopIAccount)?.dmInboxResolver?.let { resolver ->
{ hexKey -> resolver.resolve(hexKey) }
},
)
}
val broadcastStatus =
if (account is DesktopIAccount) {
@@ -342,7 +350,15 @@ private fun SplitMessagesContent(
}
val messageState =
remember(currentRoom) {
ChatNewMessageState(account, cacheProvider, scope)
ChatNewMessageState(
account,
cacheProvider,
scope,
dmInboxResolver =
(account as? DesktopIAccount)?.dmInboxResolver?.let { resolver ->
{ hexKey -> resolver.resolve(hexKey) }
},
)
}
val broadcastStatus =
if (account is DesktopIAccount) {
@@ -222,8 +222,13 @@ fun NewDmDialog(
val userResults =
bech32Results.filterIsInstance<SearchResult.UserResult>()
items(userResults) { result ->
// getOrCreateUser (not getUserIfExists): a DM recipient is
// identified purely by pubkey, so a valid npub must be
// selectable even when we have no metadata (kind:0) cached
// for them yet. Otherwise pasting the npub of anyone the
// cache hasn't seen renders a dead, non-clickable row.
val user =
cacheProvider.getUserIfExists(result.pubKeyHex)
cacheProvider.getOrCreateUser(result.pubKeyHex)
if (user != null) {
UserSearchCard(
user = user,
@@ -231,7 +236,8 @@ fun NewDmDialog(
modifier = selectedModifier(isSelected(user)),
)
} else {
// Minimal card for unloaded users
// Only reached if the key itself can't be resolved
// (malformed) — show a non-selectable hint.
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant,
@@ -0,0 +1,703 @@
---
title: Desktop DM Reliability
type: feat
status: active
date: 2026-06-10
origin: docs/brainstorms/2026-06-10-desktop-dm-reliability-brainstorm.md
---
# ✨ Desktop DM Reliability
## Overview
Two-track program to close the reliability gap between Amethyst Desktop's NIP-17 DMs and the reference clients **wisp.mobile** (Kotlin/Compose, github.com/barrydeen/wisp) and **nospeak.chat** (SvelteKit, github.com/psic4t/nospeak):
1. **Track A — Reliability plumbing.** Port the publish-path, AUTH, subscription, and discovery patterns that make wisp/nospeak feel reliable. Most are small surgical fixes; together they close the "messages silently disappear" failure modes.
2. **Track B — Bunker speed.** Spec + implement a NIP-46 `get_conversation_keys` batch RPC so bunker users decrypt N gift wraps in 1 round-trip instead of N — Vitor's stated direction, replacing the rejected NIP-4E path.
Carried forward from brainstorm:
- Explicitly out of scope: NIP-4E adoption, NIP-29 group chats, NIP-04 cleanup, new DM UX features (typing/read receipts, attachments redesign)
- NIP-04 stays visible with `legacy` badge (brainstorm Q1)
- Bunker SEND latency shown as live progress (brainstorm Q3) — see Deepening §6
- Tier-2 AUTH consent = inline chat-column banner `[Once] [Always] [Never]` (brainstorm Q5)
- Self-copy wrap → remote DM relays only, not local relay (brainstorm Q7)
- Desktop-first; Android inherits `commons/` changes (brainstorm Q8)
## Deepening Synthesis (2026-06-10)
Eleven parallel review passes (skills + reviewers) revealed substantial corrections. **Six P0 security blockers, ~30% scope compression, plus architectural fixes.** Apply BEFORE `/ce:work`.
### Desktop-only scope (2026-06-10 amendment)
**This plan ships desktop-only.** Android may incidentally benefit from `commons/` and `quartz/` changes (it shares those modules), but no Android-specific code changes, no Android UI work, no Android-side audits, no Android tests in acceptance. If a `commons/` change has Android-visible behavior change, that's a side effect — not a goal — and we don't gate this plan on Android validation.
**Removed from scope:**
- ~~Android `AccountGiftWrapsEoseManager.kt:55-61` `since` fix~~ — defer to Android pass
- ~~Android `Account.kt:1156-1167` security-fix audit~~ — same Android pass
- "Android inherits" framing in acceptance criteria
- Cross-platform `User.dmInboxRelays()` audit beyond desktop callers (still touch the commons helper; just don't validate Android consumers)
- Splitting `RetryQueueCoordinator` into commons-interface + desktop-impl — desktop-only, single file in `desktopApp/`
- Splitting `AccountAuthApprovals` for Android inheritance — keep desktop-side if simpler; if natural to put in commons it stays there but no Android UI ships
### Phase restructure (simplicity + scope)
- **R1 collapses to verification + KDoc.** Desktop already passes no `since` (`DesktopRelaySubscriptionsCoordinator.kt:345`). Add a regression test confirming wraps with `created_at = now - 1.5d` arrive; drop the `since` parameter from `FilterDMs.giftWrapsToMe` to lock the invariant. No longer a phase — one item under Phase 2.
- **Cut R6 (proactive window-focus re-AUTH)** as a separate coordinator. Replace with: use Compose-native `LocalWindowInfo.isWindowFocused` + `snapshotFlow`; let AUTH heal *lazily* on next `auth-required:` via the existing `RelayAuthenticator.checkAuthResults → syncFilters` path. **The plan's "force AUTH via benign kind:0 sub" trick is wrong** — most relays only AUTH-challenge on restricted REQs.
- **R10 (self-copy)**: already half-implemented via `BaseDMGroupEvent.groupMembers() = recipients.plus(pubKey)`. On desktop, port the pre-consume + alias-note pattern (Android has it; we replicate the *technique* in `DesktopIAccount`, not import the Android code) + route self-wrap to `account.dmInboxRelays()`, not `connectedRelays`.
- **R11 (relay hint on p-tag)**: a one-line change in `GiftWrapEvent.create:117-122`; keep but no separate sub-phase.
- **R12**: drop as standalone scope item — compress to a single regression test under Phase 5.
- **Phase 6 decouple**: spec PR + bunker batch RPC has external coordination dependencies (nsec.app/Amber/Keychat). Spin into its own plan file; Phases 15 ship independently.
- **Manual relay-entry dialog → simple error message** (Phase 4): replace `DmInboxRelayMissingDialog` UI with a Snackbar "Can't find DM relays for `<name>`. They need to publish their NIP-17 inbox first." Only re-introduce manual entry if security validation requirements (F-02) are met.
### Cross-cutting corrections
**P0 security blockers (must fix before merge):**
| ID | Issue | Fix |
|---|---|---|
| F-01 | Indexer fan-out uses authenticated client → identity-key leak to `purplepag.es` etc. | Open a dedicated `NostrClient` with `RelayAuthenticator` NOT attached; use it for all `RecipientRelayFetcher` calls. Add unit test: indexer sends AUTH → client sends NO AUTH event. |
| F-02 | Manual relay-entry has no URL validation | If we ship manual entry at all: hard-reject non-`wss://`; Levenshtein-1 typosquat warning vs curated set; "this DM will be visible to this relay operator" confirmation interstitial. **Default: drop the dialog entirely** per simplicity reviewer; use a Snackbar error. |
| F-03 | Current `RelayAuthenticator.authenticate()` (`quartz/.../auth/RelayAuthenticator.kt:81-94`) auto-signs **every** challenge with no rate limit + no tier check + across **all** logged-in accounts (multi-account linkage leak) | `shouldAutoAuth` must **REPLACE** the unconditional path, not be added in front. Per-account scoping. Rate-limit: max 1 AUTH/relay/60s, max M AUTHs/min account-wide. Default = do NOT sign unless tier-1. |
| F-04 | Retry queue stores plaintext recipient pubkey + relay URL + timestamp + last_error → social graph leak via disk forensics | Account-delete purges `retry_queue WHERE account_pubkey = ?`; 24h hard TTL on `created_at`; verify directory perms 0700; or store under `DesktopAccountStorage` AES-GCM wrapper. |
| F-07 | Plan says relay hint on "seal's p tag" — seal has NO p tag. NIP-17 spec puts hint on wrap (`["p", recipientPubkey, relay-url]`, GiftWrapEvent kind:1059). | Update plan to "wrap's p tag per NIP-17 spec"; regression test asserting it's there. (Security agent argued for rumor; but the rumor is encrypted, so other devices can't read the hint until AFTER decrypt — defeats its purpose. Spec is correct.) |
| F-10 | NIP-46 batch RPC response untrusted | Spec PR mandates: `result.length == request.pubkeys.length`, positions match, MAC self-test on first decrypt, bunker echoes `request.id`. Client validates on every call. |
**Architecture corrections (move/rename, no behavior change):**
| Subject | Plan says | Correct |
|---|---|---|
| Indexer-relay set | `commons/.../relayClient/dm/` | `commons/defaults/` |
| `relayClient/dm/` package | `dm/` | `nip17Dm/` (match siblings) |
| `AccountAuthApprovals` ViewModel | `commons/.../viewmodels/` | `commons/.../relayClient/auth/` (colocated with feature) |
| `ConversationKeyCache` | `commons/.../service/cache/` (path doesn't exist) | `quartz/.../nip46RemoteSigner/cache/` |
| `shouldAutoAuth` tier classifier | Quartz `RelayAuthenticator` | `commons/.../relayClient/auth/AuthApprovalPolicy.kt`; Quartz takes a `Set<RelayUrl>` of pre-approved relays via existing `signWithAllLoggedInUsers` lambda seam |
| `RetryQueueCoordinator` | `desktopApp/...` only | Split: `commons/.../service/RetryQueueCoordinator` (interface + no-op default for Android) + `desktopApp/.../SqliteRetryQueueCoordinator` (impl) |
| AUTH state shape | `MutableStateFlow<Map<RelayUrl, RelayAuthStatus>>` (status is a mutable holder — won't emit on inner change) | `MutableStateFlow<PersistentMap<NormalizedRelayUrl, RelayAuthSnapshot>>` (immutable snapshot, identity changes on update) |
| `authCompleted` event | New `SharedFlow`/`Channel` | Derive from `authStatusFlow.scan` transitions; no new primitive needed |
| Path key | `pubkey8` (8-hex prefix, collision risk) | Full 64-hex pubkey; one-time rename migration |
| SQLite tables location | `LocalRelayStore.kt` events.db | **Sibling `outbox.db`** with `PRAGMA synchronous = NORMAL` (events.db has `synchronous = OFF` — unsafe for "durable send" semantics) |
**Data-integrity schema rewrite** (apply to Phase 3 + Phase 2):
```sql
-- ~/.amethyst/accounts/<FULL-pubkey>/outbox.db (sibling to events.db)
-- synchronous = NORMAL; journal_mode = WAL; foreign_keys = OFF
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
-- bind file to account: INSERT meta('account_pubkey', '<full hex>')
CREATE TABLE auth_approvals (
account_pubkey TEXT NOT NULL,
relay_url TEXT NOT NULL,
scope TEXT NOT NULL CHECK (scope IN ('always','blocked')),
granted_at INTEGER NOT NULL,
expires_at INTEGER,
PRIMARY KEY (account_pubkey, relay_url),
CHECK (length(account_pubkey) = 64),
CHECK (relay_url LIKE 'wss://%' OR relay_url LIKE 'ws://%')
) WITHOUT ROWID;
CREATE INDEX auth_approvals_expiry ON auth_approvals(expires_at) WHERE expires_at IS NOT NULL;
CREATE TABLE retry_queue (
account_pubkey TEXT NOT NULL,
gift_wrap_id TEXT NOT NULL,
relay_url TEXT NOT NULL,
rumor_id TEXT NOT NULL,
event_json TEXT NOT NULL,
attempt INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 12, -- raised from 8; backoff seq 1,2,4,8,16,32,64,128,256,512,600,600s
next_attempt_at INTEGER NOT NULL,
last_error TEXT,
created_at INTEGER NOT NULL,
PRIMARY KEY (account_pubkey, gift_wrap_id, relay_url),
CHECK (length(account_pubkey) = 64),
CHECK (length(gift_wrap_id) = 64),
CHECK (length(rumor_id) = 64),
CHECK (attempt >= 0 AND attempt <= max_attempts),
CHECK (max_attempts > 0 AND max_attempts <= 32),
CHECK (length(event_json) < 200000),
CHECK (relay_url LIKE 'wss://%' OR relay_url LIKE 'ws://%')
) WITHOUT ROWID;
CREATE INDEX retry_queue_due ON retry_queue(account_pubkey, next_attempt_at) WHERE attempt < max_attempts;
```
`LocalRelayMaintenance.kt` must purge expired AUTH approvals + dead-letter retry_queue rows older than 30d.
**Performance corrections (apply throughout):**
- **Per-rumor `StateFlow`, not global map** (Phase 3). Replace `MutableStateFlow<Map<EventId, MessageDeliveryState>>` with `LargeCache<EventId, MutableStateFlow<MessageDeliveryState>>` — each bubble subscribes to its own flow; 50 visible bubbles × global map churn = ~75k unnecessary recompositions otherwise. Non-optional.
- **Delete-bundler or periodic sweep for retry_queue** (Phase 3). Per-OK DELETEs fsync individually → 100 OKs = 100 transactions ≈ 5s of IO. Either add a 250ms bundler or have the coordinator sweep `attempt=0 AND created_at > 60s` every 30s.
- **Bunker concurrency cap** (until Phase 6 ships). Wrap `NIP17Factory.createWraps`' `mapNotNullAsync` in a `Semaphore(4)` when `signer is NostrSignerRemote`. Today: 5 recipients × 2 calls = 10 concurrent bunker round-trips saturate the bunker socket.
- **Indexer fan-out: first-result + pre-warm + persistent cache** (Phase 4). 8s `fetchAll` timeout → first-message latency 1-3s. Short-circuit on first non-empty result after 2s; pre-warm on conversation-list render; persist LRU cache across restart.
- **Retry-queue triggers reactive, not 1s polling** (Phase 3). `Channel<Unit>(CONFLATED)` + `select { wake.onReceive(); onTimeout(nextDue) }`. Triggered by enqueue, authCompleted, network reconnect.
- **`withTimeout(15s)` on `client.publish`** inside retry coordinator — wedged-socket protection.
**NIP-17 protocol corrections:**
- `User.dmInboxRelays()` at `commons/.../model/User.kt:115` **silently falls back to `inboxRelays()` (NIP-65 read marker)** when kind:10050 missing. This is the FIRST silent leak layer (before `DesktopIAccount.connectedRelays` fallback). Cross-platform bug. Fix: add `dmInboxRelaysStrict()` returning null on missing; audit all 4 callers. **Android `Account.kt:1156-1167` has the same fallback bug** — security fix applies cross-platform, NOT desktop-only.
- `RecipientRelayFetcher.fetchRelayLists` returns kind:10050 + 10051 + 10002. `DmInboxRelayResolver` must use **`lists.dmInbox` only**, NOT `dmInboxOrFallback` (which falls through to NIP-65 read).
- Shared `rumor.created_at` applies to rumor (kind 14) ONLY; seal (13) and wrap (1059) `created_at` MUST stay independently randomized per NIP-17 §"randomized up to 2 days back."
- Drop `purplepag.es` from indexer set (not authoritative for kind:10050); add `purplerelay.com`. Curated set: `relay.nos.social`, `relay.damus.io`, `nos.lol`, `relay.nostr.band`, `purplerelay.com`.
- Multi-indexer agreement: only trust a kind:10050 if ≥2 indexers return the same `event.id`. One compromised indexer can otherwise mass-redirect DMs.
**NIP-46 batch RPC corrections (Phase 6):**
- Method name: **`nip44_get_conversation_keys`** (consistent with `nip44_encrypt`/`nip44_decrypt`), not `get_conversation_keys`.
- Request params: variadic `[pk1, pk2, ..., pkN]` (matches existing `nip44_encrypt` shape), NOT one stringified JSON blob.
- Response: `result = JSON.stringify(["base64key1", ...])` (NIP-46 mandates single-string result). Errors = all-or-nothing; client falls back to per-call.
- **Drop `result.capabilities` mechanism.** Use optimistic probe + per-bunker-pubkey negative-cache for the session. Adding capabilities expands the spec PR surface.
- **2 sequential round-trips** (wrap layer keys → peel wraps → seal layer keys), not 1 parallel. Acceptance criterion: `≤4` round-trips for 200 wraps (100-pubkey spec cap).
- Two-tier cache: NO cache for ephemeral wrap pubkeys (single-use), LRU 1000 for sender-identity seal keys. Cache key = `(selfPubkey, peerPubkey)`. Wipe on logout AND account-switch.
- Bunker validation: assert `result.length == params.length`, position binding, MAC self-test on first decrypt.
### Open questions resolved during deepening
- Tier-3 silent drop → **dropped from design**. Only 2 tiers: auto / prompt (user picks `[Once|Always|Never]`).
- Indexer-relay Settings UI → **dropped**. Hardcoded curated 5; override via system property `-Damethyst.dmIndexers=...` if needed.
- Bunker progress "N of M" → **simplified to spinner + "Encrypting…"**. Counter requires extending `SigningOpState` with `current,total` fields; net UX win is small.
- `account_pubkey` in SQLite schema → **kept** but constraint-bound to `meta('account_pubkey')` for defense-in-depth.
- Conversation-key cache TTL → **session-only**, wipe on logout/switch, no disk.
- Self-copy fallback → **own DM relays only**; no NIP-65 fallback (avoids the same leak class).
## Problem Statement
The Amethyst lead's prompt cited cross-client NIP-17 working reliably between nospeak.chat ↔ wisp.mobile and asked whether NIP-4E was the missing piece for bunker users. Research showed three things:
1. **Neither nospeak nor wisp uses bunker, and neither implements NIP-4E.** Their reliability comes from publish-path semantics + AUTH retry + idempotency.
2. **Vitor (Amethyst maintainer) NACKs both NIP-4E PRs** (#1647, #2361) on five technical grounds — trial-decryption pathology, custody downgrade, no rotation story, nsec loses recovery, fragmentation across own devices. His counter is the NIP-46 batch RPC.
3. **Amethyst Desktop's DM path has concrete reliability gaps** the survey identified:
- AUTH-walled subscriptions die silently when the 3-try cap in `PoolEventOutboxState.Tries.isDone()` is hit
- No persistent retry queue — `DmSendTracker` 10s-timeouts and resets
- **Security bug**: `DesktopIAccount.sendNip17PrivateMessage` falls back to `connectedRelays.value` when recipient has no kind:10050 — leaks DM to non-inbox relays (violates the 2026-04-20 "block DM fallback" decision)
- No bubble-level per-message delivery feedback (DmSendTracker is global to the composer, not keyed by EventId)
- Android's kind:1059 sub still passes `since`, silently dropping wraps with randomized 2-day-past timestamps
- 10050 lookup never fans out to indexer relays — if user's `LocalCache` doesn't have the recipient's 10050, it falls through to the buggy fallback above
Goal: send a DM and have visible confirmation it landed (or visible reason it didn't), survive bunker timeouts and AUTH-walled relays without silent drops, and let bunker users open a 200-wrap inbox in seconds instead of minutes.
## Proposed Solution
Six phases. Phases 15 are Track A (Reliability), Phase 6 is Track B (Bunker speed, parallel). Each phase is independently shippable.
```
Phase 1 — Receive resilience (R1) ┐
Phase 2 — AUTH end-to-end (R2,R3,R5,R6) │ Track A
Phase 3 — Send visibility (R7,R8 + bunker UI)│ (sequential)
Phase 4 — Discovery hardening + security fix │
(R4,R11 + 10050 fallback) │
Phase 5 — Correctness (R9,R10,R12) ┘
Phase 6 — NIP-46 batch RPC (spec + impl) — Track B (parallel)
```
## Technical Approach
### Architecture
The bulk of the change lives in `quartz/.../nip01Core/relay/client/` (publish path, AUTH state) and `commons/.../relayClient/` (filter assemblers, subscriptions). UI hooks are in `desktopApp/` (window focus listener, AUTH banner, bubble delivery indicator). Persistence lands in `desktopApp/.../desktop/relay/LocalRelayStore.kt` (existing SQLite, add tables).
**Key reusable existing infrastructure** (survey-discovered):
- `RelayAuthenticator.kt` already calls `syncFilters` on AUTH success — re-publishes pending outbox + re-sends REQs. The path exists; we extend it.
- `RecipientRelayFetcher` (`quartz/.../marmot/`) already fans out kind:10050/10002 lookups against a relay set — wire it into the DM send path.
- `LocalRelayStore` (`~/.amethyst/accounts/<pubkey8>/events.db`) + `BasicBundledInsert` (250ms batching) — host the retry queue table.
- `SigningState` pattern (shipped 2026-03-20) — reuse for bunker SEND progress UI.
- `RelayInsertConfirmationCollector` — pattern for per-OK aggregation; lift into a per-message delivery `StateFlow`.
- `geode/.../KtorRelayTest.kt:208,254` — mock Ktor relay with real `auth-required:` round-trip support. Reusable test infra.
### Phase 1 — Receive resilience (R1)
**Goal:** stop silently dropping inbound gift wraps.
**Scope:**
- Verify desktop kind:1059 sub does not pass `since` (already true — `DesktopRelaySubscriptionsCoordinator.kt:345` passes nothing → `FilterDMs.giftWrapsToMe(userPubKeyHex)` default `since=null`).
- Fix Android: `amethyst/.../AccountGiftWrapsEoseManager.kt:55-61` currently passes `since?.get(relay)?.time`. Replace with `since=null` (or relax to a wide window — e.g. `since - 30 days` for users that want bounded backfill).
- Document the invariant in code: add a KDoc on `FilterDMs.giftWrapsToMe` explaining that seal timestamps are randomized up to 2 days back per NIP-17, so `since` is unsafe.
- **Belt-and-braces**: change `FilterDMs.giftWrapsToMe` signature to drop the `since` parameter entirely. Forces all callers to be explicit.
**Files:**
- `desktopApp/.../desktop/subscriptions/FilterDMs.kt:125-133` — remove `since` param
- `amethyst/.../service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt:55-61` — drop `since` arg
- `commons/.../relayClient/nip17Dm/FilterGiftWrapsToPubkey.kt:31-49` — same
**Acceptance:**
- [ ] `FilterDMs.giftWrapsToMe` has no `since` parameter
- [ ] All call sites updated; build green
- [ ] Add unit test: subscribe to kind:1059 → server returns wrap with `created_at = now - 1.5 days` → wrap is received
- [ ] Add KDoc explaining the NIP-17 randomized-timestamp invariant
### Phase 2 — AUTH end-to-end (R2, R3, R5, R6)
**Goal:** AUTH-walled relays never silently drop messages, user-consents to tier-2 relays once and remembers.
**Scope:**
1. **Lift the 3-try cap for `auth-required:` responses.** In `PoolEventOutboxState.kt:64-93 newResponse`, if the response message starts with `auth-required:`, do NOT count it toward the `Tries.isDone()` budget. The retry happens once `RelayAuthenticator.checkAuthResults``syncFilters` fires.
2. **Expose AUTH state as a public `StateFlow`.** Convert `RelayAuthenticator.authStatusCache: LargeCache<RelayUrl, RelayAuthStatus>` into a `MutableStateFlow<Map<RelayUrl, RelayAuthStatus>>` so UI can subscribe. Add a flow event `authCompleted(relayUrl)` for downstream wakeups (re-subscribe to kind:1059 explicitly, refresh retry queue, etc.).
3. **Tiered AUTH classification** in `RelayAuthenticator.shouldAutoAuth(relayUrl, account)`:
- **Tier 1 (auto-sign):** relay is in `account.outboxRelays` OR `account.dmInboxRelays` (own NIP-17 inbox) OR was previously approved.
- **Tier 2 (prompt):** relay is marked `dmDeliveryTarget` (set by `DesktopIAccount.sendNip17PrivateMessage` before publish — mirrors wisp's `markDmDeliveryTarget`) OR was never seen before.
- **Tier 3 (silent drop):** anything else.
- Persist tier-2 approvals: new SQLite table `auth_approvals(account_pubkey TEXT, relay_url TEXT, scope TEXT, expires_at INT)` in `LocalRelayStore`. Scope `"once"` is in-memory only; `"always"` rows persist.
4. **Inline AUTH banner UX** (desktop only — Android inherits classification, separate UI pass later):
- Add `AccountAuthApprovals` ViewModel exposing `MutableStateFlow<List<PendingAuthApproval>>`.
- In `ChatPane` (the right pane of `DesktopMessagesScreen`), render an inline banner above the message list when an approval is pending: `"<relay-url> requires authentication to deliver this message. [Once] [Always] [Never]"`.
- `[Once]` → grants for the current session, no persistence. `[Always]` → writes `auth_approvals(scope="always")`. `[Never]` → writes `auth_approvals(scope="blocked")`, drops the wrap.
- **Survey precedent**: check current behavior in Coracle, Damus, Primal, 0xchat before final mockup (open question Q5 in brainstorm).
5. **Re-subscribe to kind:1059 on `authCompleted`** (R3):
- Wire `RelayAuthenticator.checkAuthResults` to emit `authCompleted(relayUrl)` after a successful AUTH-OK.
- The desktop `DesktopRelaySubscriptionsCoordinator` already calls `syncFilters` indirectly via outbox sync. Verify the kind:1059 REQ is re-sent on that relay specifically. Add an integration test using `geode/KtorRelayTest.kt` pattern: client connects → relay sends `AUTH challenge` → client sends `AUTH event` → relay OKs → relay sends gift wrap → client receives it.
6. **Proactive re-AUTH on window focus** (R6 desktop-only):
- Register `WindowFocusListener` on the `ComposeWindow` in `desktopApp/.../Main.kt:316`.
- On `windowGainedFocus`, push to `MutableStateFlow<Boolean>(focused)`.
- A coordinator (e.g. `DesktopFocusReAuthCoordinator` under `desktopApp/.../desktop/coordinators/`) collects this flow; on `false → true` transition, calls `relayManager.client.reconnect(true)` (forces reconnect of dead sockets) AND for each AUTHENTICATED relay older than 5 min, triggers a no-op AUTH challenge (subscribe to a benign `kind:0` filter on that relay — relays re-issue AUTH challenges on subsequent REQs).
**Files (touch list):**
- `quartz/.../nip01Core/relay/client/pool/PoolEventOutboxState.kt``newResponse` skip `auth-required:` from try budget
- `quartz/.../nip01Core/relay/client/auth/RelayAuthenticator.kt` — convert cache to StateFlow, add `authCompleted` event, add `shouldAutoAuth` tier logic
- `quartz/.../nip01Core/relay/client/auth/RelayAuthStatus.kt` — extend with `lastAuthSuccessAt`
- `desktopApp/.../desktop/relay/LocalRelayStore.kt` — new `auth_approvals` table + helpers
- `desktopApp/.../desktop/Main.kt` — wire `WindowFocusListener`
- `desktopApp/.../desktop/coordinators/DesktopFocusReAuthCoordinator.kt` — new file
- `desktopApp/.../ui/chats/ChatPane.kt` — inline AUTH banner
- `commons/.../viewmodels/AccountAuthApprovals.kt` — new ViewModel (commons, so Android inherits later)
- `desktopApp/.../desktop/model/DesktopIAccount.kt:179-208` — call `relayPool.markDmDeliveryTarget(url)` before publish
**Acceptance:**
- [ ] Test: mock relay returns `auth-required:` → client signs AUTH → publishes → message accepted on retry. Outbox tries counter not incremented by `auth-required:`.
- [ ] Test: account has 3 outbox relays, 1 DM-inbox relay. Sending to a recipient whose DM relay is unknown → AUTH banner appears. `[Always]` persists across app restart.
- [ ] Test: AUTH state flow emits `Authenticated(url)` when AUTH-OK received. Subscriber on kind:1059 receives a wrap delivered to that relay after AUTH.
- [ ] Test: window unfocused → focused. Stale connections reconnect. Verify via mock-relay log.
- [ ] No regression: nsec-local user can still send + receive in &lt;1s end-to-end (no extra round-trips introduced).
### Phase 3 — Send-path visibility (R7, R8 + bunker progress UI)
**Goal:** every outgoing message has a visible delivery state per relay; no fire-and-forget; persistent retry across app restart.
**Scope:**
1. **Per-message delivery state** (R8):
- Replace transient `DmSendTracker` with a persistent `MutableStateFlow<Map<EventId, MessageDeliveryState>>` keyed by **rumor id** (not gift-wrap id — multiple wraps share one rumor). `MessageDeliveryState` = `{relayDeliverySet: Map<RelayUrl, Confirmation>, sentAt, lastAttemptAt, error?}`.
- In `quartz/.../accessories/RelayInsertConfirmationCollector.kt`, add a `collectByRumor(rumorId)` overload that aggregates OKs across all gift wraps for a rumor.
- Surface in `DmConversationViewModel` so chat bubble subscribes per-bubble: `messageBubbleState(rumorId): StateFlow<MessageDeliveryState>`.
- Bubble UI shows: `✓` (≥1 relay accepted), `✓✓` (all relays accepted), `⟳` (in flight), `⚠` (zero relays accepted after retry exhausted).
2. **Persistent retry queue** (R7):
- New SQLite table in `LocalRelayStore`:
```sql
CREATE TABLE retry_queue (
id TEXT PRIMARY KEY, -- gift_wrap_event_id || ":" || relay_url
account_pubkey TEXT NOT NULL,
rumor_id TEXT NOT NULL,
event_json TEXT NOT NULL, -- serialized GiftWrapEvent
relay_url TEXT NOT NULL,
attempt INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 8,
next_attempt_at INT NOT NULL,
last_error TEXT,
created_at INT NOT NULL
);
CREATE INDEX retry_queue_next_attempt ON retry_queue(next_attempt_at);
```
- `RetryQueueCoordinator` (new, `desktopApp/.../desktop/relay/RetryQueueCoordinator.kt`): on app start, scan retry_queue for `next_attempt_at < now`; for each, attempt `client.publish(event, listOf(relayUrl))`. On OK → delete row. On AUTH-required → wait for AUTH (Phase 2's flow already handles). On other rejection → exp-backoff `next_attempt_at = now + min(30s, 2^attempt seconds)`, `attempt++`. On `attempt >= max_attempts` → delete row, surface to UI as permanent failure.
- Enqueue path: `DesktopIAccount.sendNip17PrivateMessage` calls `retryQueue.enqueue(wrap, relays)` BEFORE `client.publish` so we don't lose anything if the app dies between send and confirmation.
- On OK from publish path → `retryQueue.confirm(wrapId, relayUrl)`.
3. **Bunker SEND progress UI** (brainstorm Q3 = "Live progress in send button"):
- Reuse existing `SigningState` pattern from 2026-03-20 plan.
- In `DesktopIAccount.sendNip17PrivateMessage`, wrap each `signer.sign()` call with progress emission: `SigningState.InProgress(current=2, total=5, label="Encrypting via remote signer")`.
- Compose-side: send button shows linear progress + label when state is `InProgress`.
- Only active when `signer is NostrSignerRemote`; nsec users see no change.
**Files:**
- `quartz/.../accessories/RelayInsertConfirmationCollector.kt` — add `collectByRumor`
- `commons/.../viewmodels/DmConversationViewModel.kt` — expose `messageBubbleState(rumorId)`
- `desktopApp/.../desktop/relay/LocalRelayStore.kt` — `retry_queue` table + DAO methods
- `desktopApp/.../desktop/relay/RetryQueueCoordinator.kt` — new
- `desktopApp/.../desktop/model/DesktopIAccount.kt` — enqueue → publish → confirm pattern, signing-state emission
- `desktopApp/.../ui/chats/ChatMessageBubble.kt` — render delivery indicators
- `desktopApp/.../ui/chats/MessageComposer.kt` — bunker progress
**Acceptance:**
- [ ] Send DM, kill app mid-publish (during bunker sign). Restart → retry queue drains → message lands.
- [ ] Send DM to 3 relays; relay #2 returns `auth-required:` while #1 and #3 OK. Bubble shows `✓ 2/3` immediately; after AUTH completes, updates to `✓ 3/3`.
- [ ] Bunker user sends to 5 recipients. Send button shows "Encrypting via remote signer (3 of 5)" until last signature lands.
- [ ] No retry-queue table growth in nsec-local mode under normal conditions.
- [ ] Retry queue respects per-account isolation (multi-account users don't see each other's queued sends).
### Phase 4 — Discovery hardening + security fix (R4, R11)
**Goal:** kind:10050 lookup is robust against missing/stale data; stop the silent metadata-leak fallback.
**Scope:**
1. **Indexer-relay fan-out for kind:10050** (R4):
- New `DmInboxRelayResolver` (commons, so Android inherits). API: `suspend fun resolveDmInboxRelays(pubkey: HexKey): Result<List<RelayUrl>>`.
- Wraps `RecipientRelayFetcher` (already in `quartz/.../marmot/`). Configures it with a discovery set (curated indexer relays).
- LRU cache (100 entries, TTL 1h) on `(pubkey → relays)` results.
- Decoupled from `relayManager.connectedRelays` — uses ephemeral connections to indexers.
2. **Security fix: stop falling back to `connectedRelays.value`** in `DesktopIAccount.sendNip17PrivateMessage:179` and twin methods (sendNip17EncryptedFile, sendGiftWraps).
- New flow: `dmInboxRelays()` → if null → `DmInboxRelayResolver.resolveDmInboxRelays(recipient)` → if empty → **block send and surface UI prompt** "Could not find DM relays for <name>. [Enter manually] [Cancel]".
- Never silently fall back to user's connected relays for DMs (matches 2026-04-20 Relay Power Tools decision).
- The "[Enter manually]" path is a one-shot dialog with relay-URL chips; user-entered relays are NOT persisted to recipient's 10050 (we don't publish on their behalf), only used for this send.
3. **Indexer-relay set** (open question Q2 in brainstorm — decided here):
- Hardcoded curated list in `commons/.../relayClient/dm/DefaultIndexerRelays.kt`:
- `wss://purplepag.es`
- `wss://relay.nos.social`
- `wss://relay.damus.io`
- `wss://nos.lol`
- `wss://relay.nostr.band`
- Configurable in Settings → DMs → "Inbox-relay discovery" (advanced). Default = curated list.
4. **Relay hint on `p` tags** (R11):
- In `NIP17Factory.createWraps`, when building the seal's `p` tag for the recipient, include the recipient's primary DM relay URL: `["p", recipientPubkey, primaryDmRelay]`.
- "Primary" = first relay from `resolveDmInboxRelays(recipient)` result. Empty string if unknown.
**Files:**
- `commons/.../relayClient/dm/DmInboxRelayResolver.kt` — new
- `commons/.../relayClient/dm/DefaultIndexerRelays.kt` — new
- `desktopApp/.../desktop/model/DesktopIAccount.kt:179-261` — three send methods updated
- `desktopApp/.../ui/chats/DmInboxRelayMissingDialog.kt` — new
- `quartz/.../nip17Dm/NIP17Factory.kt` — relay hint on `p` tag
- `desktopApp/.../ui/settings/DmSettingsScreen.kt` — new (indexer-relay config)
**Acceptance:**
- [ ] Recipient has no kind:10050 in our cache and indexers return nothing → user sees "Enter manually" dialog. No silent send to non-inbox relays.
- [ ] Recipient has 10050 in cache → no indexer call. Cache TTL respected (1h).
- [ ] Recipient has no 10050 in cache, indexers return [r1, r2] → cache populated, send proceeds.
- [ ] `p` tag in seal contains recipient's primary DM relay URL when known.
- [ ] Settings allows custom indexer set.
- [ ] **Security regression test**: send DM where recipient has no 10050 anywhere → verify zero outbound traffic to user's own outbox/general relays.
### Phase 5 — Correctness (R9, R10, R12)
**Goal:** group DMs, cross-device sync, and dedupe behave correctly.
**Scope:**
1. **Shared `rumorCreatedAt` across recipient wraps** (R9):
- In `NIP17Factory.createWraps` (currently `quartz/.../nip17Dm/NIP17Factory.kt:43-72`), compute `rumorCreatedAt = TimeUtils.now()` once before the per-recipient `mapNotNullAsync` loop. Pass into every `SealedRumorEvent.create(...)` so all seals encode the same rumor (same `rumor.id`).
- Same `rumorId` becomes the dedupe anchor + receipt target across all recipients of a group message.
2. **Self-copy gift wrap to own DM relays** (R10):
- In each `DesktopIAccount.sendNip17*` method, after building wraps for all recipients, also build one wrap addressed to self.
- Route to `account.dmInboxRelays` (or write relays as fallback per wisp's pattern). **NOT to local relay** (brainstorm Q7).
- Pre-mark `LocalCache.seenGiftWraps[selfWrap.id]` (or equivalent) to avoid double-render when it loops back from the relay.
3. **Persistent seen-index** (R12) — verify, don't re-build:
- The existing `LocalCache.consume()` + write-through to `LocalRelayStore` (`DesktopLocalCache.kt:216-219`) already provides on-disk dedupe across restart.
- Add a regression test: kill desktop app with N gift wraps in-cache; on restart, re-deliver same wraps from a mock relay; verify they're rejected as duplicates at `LocalCache.consume` (no decryption attempt → no bunker round-trip).
**Files:**
- `quartz/.../nip17Dm/NIP17Factory.kt:43-78` — shared rumor created_at
- `desktopApp/.../desktop/model/DesktopIAccount.kt` — add self-copy in three send methods
- `commons/.../service/LocalCache.kt` (or `desktopApp/.../desktop/cache/DesktopLocalCache.kt`) — pre-mark seenGiftWraps if not already supported
- Tests in `desktopApp/.../jvmTest/` and `quartz/.../commonTest/`
**Acceptance:**
- [ ] Group DM to 4 recipients: all seals share one rumor.id. Reaction event targeting that rumor.id by recipient #2 is correctly received by sender and other recipients.
- [ ] Send DM from desktop install A; open same account on desktop install B. Self-copy arrives via 10050 → conversation appears on B.
- [ ] Kill app with 50 wraps in cache. Mock relay re-broadcasts same 50 wraps. App restart → decryption attempted 0 times (verified via signer-call counter).
### Phase 6 — NIP-46 batch RPC (parallel track)
**Goal:** bunker users receive N gift wraps in 12 round-trips instead of N.
**Spec proposal:**
- File NIP-46 PR in `nostr-protocol/nips` proposing method `get_conversation_keys`:
```
Request: { id, method: "get_conversation_keys", params: [pubkeys_json_array] }
Response: { id, result: keys_json_array, error?: string }
```
- `pubkeys_json_array` = JSON-encoded array of hex pubkeys. Result is parallel array of base64-encoded 32-byte NIP-44 conversation keys (same key the bunker would derive for the corresponding `nip44_encrypt`/`nip44_decrypt`).
- Bunker MAY rate-limit or reject (e.g. if more than 100 pubkeys). Client falls back to per-call `nip44_decrypt` if `get_conversation_keys` returns error or capability not advertised.
- Capability advertised via NIP-46 `connect` response: `result.capabilities: ["get_conversation_keys"]` (or via a `get_capabilities` method if spec evolves).
**Coordination:**
- Open NIPs PR + cross-post to bunker maintainers:
- **nsec.app** (Yegor) — github.com/nostrband/nsec.app
- **Amber** (greenart7c3) — github.com/greenart7c3/Amber
- **Keychat** — github.com/keychat-io
- Resolve open semantics question (brainstorm Q4):
- For NIP-17, the receiver needs `ecdh(self, ephemeral_pubkey_in_each_wrap)` for the wrap layer, AND `ecdh(self, sender_pubkey)` for the seal layer.
- Wrap layer: N ephemeral pubkeys → N keys. Pass all in one batch call.
- Seal layer: M unique sender pubkeys (often M << N). Pass all in one batch call.
- Net: 2 bunker calls instead of 2N. Confirmed acceptable shape.
**Amethyst-side implementation:**
- Capability probe: on bunker `connect`, parse `result.capabilities` (or fall back to a feature-flag pref).
- `RemoteSignerManager.getConversationKeys(pubkeys: List<HexKey>): List<ByteArray>` — new method. Sends one NIP-46 request, awaits response, parses keys.
- Wire into NIP-17 receive path: when `LocalCache.consume` ingests a batch of kind:1059 events, before decrypting, collect all unique ephemeral pubkeys + sender pubkeys, call `getConversationKeys` once, then decrypt locally with the returned keys.
- Wire into NIP-17 send path: per-recipient conversation key fetched once (cached), used for seal encryption locally.
- Cache conversation keys in-memory (LRU 500); wipe on logout. Conversation keys are NOT persisted — re-derivable from bunker on next session.
**Files:**
- `quartz/.../nip46RemoteSigner/signer/RemoteSignerManager.kt` — add `getConversationKeys`
- `quartz/.../nip46RemoteSigner/dto/` — new request/response DTOs
- `quartz/.../nip46RemoteSigner/signer/NostrSignerRemote.kt` — expose batch path
- `quartz/.../nip17Dm/NIP17Factory.kt` — switch to batch path when signer is remote and capability available
- `quartz/.../nip17Dm/Nip17Receiver.kt` or wherever wraps are decrypted (likely under `commons/.../service/`) — batch-decrypt path
- `commons/.../service/cache/ConversationKeyCache.kt` — new LRU
- `quartz/.../commonTest/.../GetConversationKeysTest.kt` — round-trip test with mock bunker
**Acceptance (gated on spec PR being open at least; impl can land behind capability flag):**
- [ ] NIPs PR opened with discussion-ready spec.
- [ ] Mock bunker test: client calls `get_conversation_keys([10 pubkeys])` → receives 10 keys → uses them to decrypt 10 wraps with 0 further bunker calls.
- [ ] Capability fallback: bunker doesn't advertise capability → client falls back to per-call `nip44_decrypt`. No regression.
- [ ] Inbox-load benchmark: 200 wraps via bunker. Without batch RPC: ~200 round-trips. With batch RPC: ≤2 round-trips. Measured via signer-call counter.
## Alternative Approaches Considered
| Alternative | Why rejected |
|---|---|
| **Implement NIP-4E (PRs #1647/#2361)** | Vitor (maintainer) NACKed both with 5 technical objections. Externalizes trial-decryption cost on every legacy peer. Politically infeasible. |
| **Read-side NIP-4E compat only** (honor peers' kind:10044 + n-tag on receive) | Spec contested, no merge in sight. Adds receive-path complexity for unclear win — Jumble + Coop are small. Defer until spec lands. |
| **Migrate to MLS/Marmot for DMs** | Larger orthogonal program. Marmot already in tree (per `quartz/.../marmot/`). Separate track. Doesn't solve NIP-17 reliability for users on non-MLS peers. |
| **Drop bunker support for DMs entirely** | wisp + nospeak do this; works but regresses Amethyst's bunker UX. Better path is to make bunker fast (Phase 6) than to drop it. |
| **Per-relay outbox max-tries config without auth-required carve-out** | Half-measure; doesn't solve the silent-drop case where AUTH succeeds AFTER 3 retries exhausted. The carve-out is required regardless. |
| **In-memory only retry queue** | Loses messages on app crash / kill. Persistent SQLite is cheap given `LocalRelayStore` already exists. |
| **Self-copy wrap to embedded local relay** (instead of remote DM relays) | Brainstorm Q7: rejected to match wisp behavior + keep local relay's "cache only" role. |
## System-Wide Impact
### Interaction Graph
**Outgoing DM (post-Phase 3):**
```
ComposeUI(send button click)
→ DmConversationViewModel.send(text)
→ DesktopIAccount.sendNip17PrivateMessage(text, recipients)
→ DmInboxRelayResolver.resolveDmInboxRelays(recipient) ── Phase 4
→ RecipientRelayFetcher.fetch([indexer relays])
→ NIP17Factory.createWraps(text, recipients, signer) ── shared rumor_created_at (Phase 5)
→ for each recipient (parallel):
→ SealedRumorEvent.create(rumor, recipient, signer)
→ signer.sign(seal) (bunker round-trip if Remote) ── batch via Phase 6 capability
→ signer.nip44Encrypt(rumor, recipient) (bunker round-trip)── batch via Phase 6 capability
→ GiftWrapEvent.create(seal, recipient, ephemeralKey)
→ for each wrap:
→ retryQueue.enqueue(wrap, relays) ── Phase 3
→ client.publish(wrap, relays)
→ relay returns OK → retryQueue.confirm(wrapId, relayUrl)
→ relay returns auth-required → RelayAuthenticator handles ── Phase 2
→ relay rejects → retryQueue.scheduleRetry(wrapId, relayUrl)
→ self-copy wrap published to own DM relays ── Phase 5
ChatBubble subscribes to messageBubbleState(rumorId) ── Phase 3
→ renders ✓ ✓✓ ⟳ ⚠ based on state changes
```
**Incoming DM (post-Phase 6):**
```
RelayConnection.onIncomingMessage(EventMessage(kind=1059))
→ LocalCache.consume(wrap)
→ if seen → drop ── Phase 5 (already exists)
→ batch collected by ingestion buffer (250ms window)
→ batch decrypt path:
→ collect unique sender pubkeys from wraps in batch
→ if signer is Remote AND batch capability: getConversationKeys() ── Phase 6
→ for each wrap: decrypt locally with cached key
→ on rumor decrypted → DesktopMessagesScreen.conversationFlow updates
```
### Error & Failure Propagation
| Layer | Error class | Today | Post-plan |
|---|---|---|---|
| Relay socket | `WebSocketDisconnected` | reconnect attempt, in-flight events tries-counted | reconnect, queue preserves event, retries on reconnect |
| Relay OK | `auth-required:` | counts toward 3-try cap, often silently dropped | NOT counted; held until AUTH completes; user sees banner if tier-2 |
| Relay OK | `pow:` / `replaced:` / `invalid:` | discarded (correct) | unchanged |
| Bunker RPC | `BunkerTimeout` (65s) | request continuation removed; late response discarded | retry queue re-attempts on next coordinator tick; bubble shows ⚠ |
| Bunker RPC | `DecryptCache` poisoning (per 2026-05-04 plan) | permanent cache poison until app restart | unchanged this plan — covered by prior plan |
| Bunker RPC | `get_conversation_keys` not supported | N/A | fall back to per-call `nip44_decrypt` |
| 10050 lookup | recipient has no 10050 anywhere | **falls back to user's connected relays (metadata leak)** | Phase 4: blocks send + prompts user for manual relay entry |
| Signer | `signer.sign` returns null | DmSendTracker → Failed → resets in 3s | retry queue keeps the event, scheduler retries; bubble stays ⟳ |
### State Lifecycle Risks
1. **Retry queue rows must be deleted on permanent failure or success, never orphaned.** Coordinator deletes on `attempt >= max_attempts` even if no UI sees it. Alternative: archive to `retry_queue_dead_letter` table for diagnostics.
2. **AUTH-approvals table must be account-scoped.** Multi-account users share the SQLite store across accounts but each row carries `account_pubkey`. Test: account A approves relay X "always"; account B sending to relay X gets tier-2 prompt independently.
3. **Indexer cache invalidation.** If recipient publishes a new 10050, our 1h TTL hides it. Mitigation: on receiving a fresh kind:10050 event for any user via the normal relay feed, eagerly update the cache.
4. **Self-copy wrap can race the original.** If self-copy lands first, recipient #1's wrap arrives second and we already have the rumor — dedupe at rumor.id should handle it. Test.
5. **Retry queue + AUTH banner can dual-drive UI** — if a wrap is queued AND the relay's AUTH is pending, we don't want two notifications. Coordinator suppresses retry attempts on relays in `AUTHENTICATING` state.
6. **Conversation-key cache (Phase 6) lives in memory only.** On logout / account switch, must wipe to prevent cross-account leakage.
### API Surface Parity
| Surface | Effect |
|---|---|
| Desktop NIP-17 send | full plan |
| Android NIP-17 send | inherits all `commons/` + `quartz/` changes. Android-specific UI (AUTH banner) NOT in this plan — separate Android pass. Android keeps current AUTH-prompt-less behavior until then; the underlying classifier still works (silent drops for tier-3, auto for tier-1, **silent drop for tier-2** — Android users with bunker won't see banner; safer than current). |
| CLI (`amy`) | `commons/` changes apply. CLI doesn't render banners. Tier-2 AUTH approvals via a config file (out of scope, file follow-up). |
| Marmot/MLS DMs | unaffected (separate event kinds + path) |
| NIP-04 legacy DMs | unaffected (no AUTH retry, no retry queue — legacy path stays as-is per brainstorm Q1) |
### Integration Test Scenarios
1. **AUTH retry across restart.** Send DM to relay R that demands AUTH. Sign + send AUTH. Kill app before AUTH-OK arrives. Restart. Verify retry queue resumes, AUTH handshake completes, original wrap accepted.
2. **Tier-2 prompt persistence.** Recipient has 10050 pointing to a relay user has never seen. Send → banner appears → user clicks `[Always]`. Send another DM to same recipient → no banner; relay AUTH'd silently using stored approval.
3. **No-10050 security path.** Recipient has no kind:10050 in our cache and on indexers. Send → dialog shows "Enter manually". Cancel → zero outbound traffic to general relays. Verify via mock-relay sniffer.
4. **Bunker batch RPC inbox load.** 200-wrap inbox, bunker user. Pre-plan: ~200 round-trips (~minutes). Post-plan with capability: ≤2 round-trips (~seconds). Measured via signer-call counter.
5. **Group DM rumor coherence.** Send to [A, B, C]. Each receives a wrap. A reacts to message → reaction `e` tag references shared `rumorId`. B and C see the reaction associated with the right message. Sender sees it too.
6. **Self-copy cross-device.** Account on desktop install X sends DM to recipient. Open same account on install Y (cold cache). Y's first 10050 fetch returns sender's own DM relays → self-copy wrap arrives → conversation pre-populates.
7. **Window-focus re-AUTH.** Mac sleeps 1h. Wake → focus desktop app → mock relay's AUTH challenges fire → client AUTHs all stale connections within 5s. No user input required.
## Acceptance Criteria
### Functional
- [ ] Phase 1: kind:1059 sub on both Desktop and Android passes no `since` (or a 30-day default at most). Wraps with timestamps 2 days in the past arrive.
- [ ] Phase 2: All five sub-items shipping (tier classifier, persisted approvals, banner, re-sub-on-auth-completed, focus re-AUTH).
- [ ] Phase 3: Per-message bubble delivery indicator. Persistent retry queue. Bunker progress UI.
- [ ] Phase 4: No silent fallback to user's connected relays for DMs. Indexer fan-out + manual entry dialog.
- [ ] Phase 5: Shared rumor.created_at. Self-copy wrap. Persistent dedupe verified.
- [ ] Phase 6: NIPs PR open. Capability negotiation + fallback. Batch decrypt wired for receive path.
### Non-functional
- [ ] No regression for nsec-local users: end-to-end DM round-trip stays under 1s on healthy relays.
- [ ] Bunker inbox load (200 wraps): post-Phase 6 ≤10s vs current ≥120s.
- [ ] Retry queue size stays under 100 rows under normal use (i.e. high-success-rate publish path keeps it empty most of the time).
- [ ] No new secrets persisted: AUTH approvals carry no key material; only relay URLs + scope flags.
### Quality gates
- [ ] All new code passes `./gradlew spotlessApply` + `./gradlew test`.
- [ ] Integration test count: ≥1 per phase, ≥7 total.
- [ ] Mock relay infra (`geode/.../KtorRelayTest.kt` pattern) reused where possible; new mock-bunker for Phase 6.
- [ ] No new uses of `runBlocking` in publish path.
- [ ] Code-review pass with `compose-expert`, `relay-client`, `auth-signers`, `nostr-expert` skills before merge per phase.
## Success Metrics
| Metric | Pre-plan baseline | Target |
|---|---|---|
| Silent message drops on AUTH-walled relays | unknown (likely common) | 0 |
| Inbox load time, 200 wraps via bunker | ~2 min | ≤10 s |
| Successful delivery rate on first try (nsec, healthy network) | ~95% (estimated) | ≥99% |
| Successful delivery rate including retry queue, 24h window | unknown | ≥99.5% |
| User reports of "DM never arrived" / "DM never sent" | baseline TBD | 50% reduction over 3 months |
| Crash/ANR rate on DM screen | baseline TBD | no regression |
## Dependencies & Prerequisites
- **Phase 6 blocked on NIPs PR consensus.** Spec authors (nsec.app/Amber/Keychat) need to weigh in. If consensus stalls, Phase 6 implementation can still land **behind a feature flag** as a discussion prototype.
- Phases 15 are sequential within Track A but each is independently shippable.
- Phase 4's manual-entry dialog needs design pass (no Figma assumed; brainstorm spec is the source).
- Mock bunker test infra for Phase 6 — small new utility, no external deps.
## Risk Analysis & Mitigation
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Phase 2 AUTH classifier wrongly tier-3-drops a legit relay | M | High (silent drop) | Default tier-3 to "log warning" not "silently drop" during rollout; flip later. Tier-1 + tier-2 catch the common cases. |
| Retry queue grows unbounded (e.g. dead relay) | M | M | `max_attempts=8` + exp-backoff caps total relay-time per event to ~10 min. Dead-letter table for inspection. UI surfaces "permanent failure" ⚠. |
| Phase 6 NIPs PR rejected | M | M | Implement Vitor's design as a discussion prototype regardless; if PR rejected, ship as Amethyst↔nsec.app/Amber bilateral capability negotiation (slightly worse interop, equivalent end-user outcome). |
| Security fix (Phase 4) breaks existing users who relied on the leaky fallback | L | L | Surface dialog + provide manual-entry fallback. Document migration in release notes. Add telemetry for "no-10050" send attempts in 1.0 to size the affected population. |
| Window-focus listener leaks on close | L | L | Standard `addWindowFocusListener` / `removeWindowFocusListener` pairing; unit-test via headless ComposeWindow. |
| Conversation-key cache (Phase 6) leaks across account switch | L | High (cross-account decryption) | Wipe `ConversationKeyCache` in `AccountStateHolder.onAccountChanged`. Unit test. |
| Persistent AUTH approvals get out of sync with relay's actual AUTH state | L | L | TTL of 30 days on `auth_approvals.expires_at`; re-prompt after expiry. |
| Android inheriting `commons/` changes regresses Android DM screens | M | M | Run full Android test suite after each phase; manual smoke test on Android emulator before merge. |
| Spec change in NIP-46 mid-implementation | M | M | Capability negotiation isolates Amethyst from spec churn; fallback path always works. |
## Resource Requirements
- Solo engineer; estimated 46 weeks of focused work for Phases 15, plus indefinite coordination for Phase 6.
- No new infrastructure / hosting.
- New dev-dep on a mock-bunker test utility (small, in-tree).
## Future Considerations
- **Receipts UX**: read receipts in wisp + nospeak use a "high water mark" per conversation. Out of scope here, natural follow-up plan.
- **NIP-04 visibility cleanup**: brainstorm Q1 said "keep legacy badge"; revisit when NIP-17 adoption hits a threshold.
- **Marmot/MLS DMs**: separate program. The reliability plumbing (AUTH, retry queue) is reusable — Phase 6's batch RPC concept does NOT apply (MLS uses different keying).
- **WoT inbox relays** (e.g. pyramid.fiatjaf.com/inbox) — open question Q5 in brainstorm. AUTH plumbing makes us publishable. Surface relay rejection messages to UI as toast for diagnostics. No special WoT machinery needed for now.
- **Android UI parity** for AUTH banner and bunker progress — separate Android-only pass, plan TBD.
- **Telemetry** — add anonymous metric `dm.delivery.outcome = {ok, retry, dropped}` (opt-in only, behind Settings flag).
## Documentation Plan
- Update `desktopApp/.../README.md` (if any) with new DM reliability features.
- Update `MEMORY.md` summary at end of work.
- Add `commons/ARCHITECTURE.md` entries for `DmInboxRelayResolver` and `RetryQueueCoordinator`.
- KDoc on new public surfaces: `RelayAuthenticator.authCompleted`, `DesktopIAccount.messageBubbleState`, `RemoteSignerManager.getConversationKeys`.
- Release-notes entries per phase (`docs/release-notes/`?). User-facing: "DMs now show per-relay delivery status", "AUTH-walled relays handled automatically", "Bunker users can open large inboxes much faster".
## Sources & References
### Origin
- **Brainstorm document**: [docs/brainstorms/2026-06-10-desktop-dm-reliability-brainstorm.md](../brainstorms/2026-06-10-desktop-dm-reliability-brainstorm.md).
Key decisions carried forward: two-track umbrella (reliability + bunker speed), R1R12 inventory, NIP-04 stays legacy, bunker progress UI, AUTH inline banner, self-copy → remote only, desktop-first Android-inherits.
### Internal references
| Concern | File:line |
|---|---|
| Desktop kind:1059 sub site | `desktopApp/.../subscriptions/DesktopRelaySubscriptionsCoordinator.kt:338-349` |
| Android kind:1059 sub site (needs fix) | `amethyst/.../AccountGiftWrapsEoseManager.kt:55-61` |
| Filter assembler (commons) | `commons/.../relayClient/nip17Dm/FilterGiftWrapsToPubkey.kt:31-49` |
| Filter assembler (desktop) | `desktopApp/.../subscriptions/FilterDMs.kt:125-133` |
| Publish path entry | `quartz/.../nip01Core/relay/client/NostrClient.kt:233-245` |
| Per-event outbox | `quartz/.../nip01Core/relay/client/pool/PoolEventOutboxState.kt:64-108` |
| AUTH state cache | `quartz/.../nip01Core/relay/client/auth/RelayAuthenticator.kt:57-104` |
| AUTH event builder | `quartz/.../nip42RelayAuth/RelayAuthEvent.kt` |
| Bunker signer manager | `quartz/.../nip46RemoteSigner/signer/RemoteSignerManager.kt:44-102` |
| NIP-17 factory | `quartz/.../nip17Dm/NIP17Factory.kt:43-78` |
| Recipient-relay fetcher | `quartz/.../marmot/RecipientRelayFetcher.kt:38-114` |
| Desktop send path (security bug) | `desktopApp/.../model/DesktopIAccount.kt:179-261` |
| DmSendTracker (to replace) | `desktopApp/.../ui/chats/DmSendTracker.kt:32-86` |
| Window state on desktop | `desktopApp/.../Main.kt:250-316` |
| LocalRelayStore (retry queue host) | `desktopApp/.../desktop/relay/LocalRelayStore.kt` |
| Mock-relay AUTH test infra | `geode/.../KtorRelayTest.kt:208,254` |
| Server-side AUTH test | `quartz/.../commonTest/.../nip01Core/relay/server/NostrServerAuthTest.kt` |
### Related prior plans (carry constraints / reuse infra)
- **2026-04-20 Relay Power Tools** — shipped "block DM fallback to all relays" decision (Phase 4 enforces). `desktopApp/.../docs/plans/2026-04-20-feat-relay-power-tools-plan.md`.
- **2026-05-04 Bunker Timeouts & Decryption** — shipped DecryptCache poisoning fix. Retry queue (Phase 3) inherits the lesson: persist request state across timeouts. `docs/plans/2026-05-04-fix-bunker-timeouts-and-decryption-plan.md`.
- **2026-05-09 Embedded Local Relay** — shipped `LocalRelayStore` SQLite + `BasicBundledInsert`. Phase 3 retry queue uses the same store. `desktopApp/plans/2026-05-09-embedded-local-relay-plan.md`.
- **2026-03-20 Remote Signer Loading & Error UX** — shipped `SigningState` pattern. Phase 3 bunker progress UI reuses. `docs/plans/2026-03-20-feat-remote-signer-loading-error-ux-plan.md`.
### External references
| Source | URL |
|---|---|
| NIP-17 spec | https://github.com/nostr-protocol/nips/blob/master/17.md |
| NIP-42 spec | https://github.com/nostr-protocol/nips/blob/master/42.md |
| NIP-46 spec | https://github.com/nostr-protocol/nips/blob/master/46.md |
| NIP-4E PR #1647 (contested) | https://github.com/nostr-protocol/nips/pull/1647 |
| NIP-17 keys PR #2361 (contested) | https://github.com/nostr-protocol/nips/pull/2361 |
| wisp source | https://github.com/barrydeen/wisp |
| nospeak source | https://github.com/psic4t/nospeak |
### Files worth diffing line-by-line during implementation
- wisp: `app/src/main/kotlin/com/wisp/app/relay/RelayPool.kt:518-563` (tiered AUTH)
- wisp: `app/src/main/kotlin/com/wisp/app/viewmodel/StartupCoordinator.kt:353-364, 734-743` (re-sub on AUTH-OK, no-`since` 1059 filter)
- wisp: `app/src/main/kotlin/com/wisp/app/viewmodel/DmConversationViewModel.kt:654-665, 702-712, 766` (shared rumor_created_at, self-copy)
- wisp: `app/src/main/kotlin/com/wisp/app/repo/DmRelayLookup.kt` (indexer fan-out)
- nospeak: `src/lib/core/connection/RetryQueue.ts` (Dexie-backed retry queue)
- nospeak: `src/lib/core/connection/publishWithDeadline.ts:136` (AUTH retry inside publish)
- nospeak: `src/lib/core/connection/ConnectionManager.ts:345-410` (re-AUTH on visibilitychange)
- nospeak: `src/lib/stores/sending.ts` (per-relay delivery counter)
---
## Unanswered questions
Resolved during deepening (see Deepening Synthesis §"Open questions resolved"): tier-3 dropped (2 tiers only); indexer set hardcoded curated 5 + system-property override; conversation-key cache session-only; retry dead-letter 30d; self-copy own DM relays only; bunker progress = spinner not counter; NIP-46 capability via optimistic probe (no spec extension).
Still open:
- Android UI parity for tier-2 banner — separate plan or include here? Lean separate.
- Manual relay-entry: ship dialog with full validation (F-02) or drop entirely + Snackbar error only? Lean drop; revisit after dogfooding.
- Bunker batch RPC chunk size cap — spec authors to set (recommend 100 pubkeys/call).
- WoT relay rejection messages — toast all `:` -prefixed OK reasons or filter? Lean all.
- F-13 multi-indexer agreement — require ≥2 indexers, or accept ≥1 with NIP-11 pubkey pinning? Decide in Phase 4.
- NIP-09 deletion of self-copies on kind:10050 rotation (F-06) — implement now or release-note disclosure? Lean disclosure now, implement later.
- AUTH approval revoke UI placement (F-05 P1) — Settings → DMs → "Approved relays" list. Confirm during Phase 2 design.
- TLS SPKI + NIP-11 pubkey pinning for AUTH approvals (F-05) — defer or include in Phase 2? Lean include — protects against relay-ownership swap mid-TTL.
@@ -0,0 +1,241 @@
# Desktop DM Reliability — Testing Sheet
**Branch:** `feat/desktop-dm-reliability`
**Date:** 2026-06-12
**Plan:** [docs/plans/2026-06-10-feat-desktop-dm-reliability-plan.md](2026-06-10-feat-desktop-dm-reliability-plan.md)
**Tester:**
## Scope
17 commits across `quartz` / `commons` / `desktopApp`. Net ~1,797 LOC (incl. ~600 LOC tests).
The branch ships:
1. NIP-42 AUTH end-to-end on desktop (today: nothing) — tier-1 auto-sign, tier-2 banner
2. P0 security fix: NIP-17 sends no longer fall back to user's connected relays
3. Dedicated unauthenticated NostrClient for kind:10050 indexer probes (no identity-key leak)
4. NIP-17 relay hint on gift-wrap p-tag (correct per spec)
5. Bunker concurrency cap (Semaphore(4)) in NIP17Factory
6. `auth-required:` carved out of outbox try cap
7. Drop `since` from kind:1059 subscription
8. Compose-observable AUTH state, `SigningOpState.Progress` variant
9. Per-account AUTH approval persistence via `java.util.prefs.Preferences`
---
## Pre-test setup
- [ ] `git -C .worktrees/feat/desktop-dm-reliability log --oneline ^origin/main` shows 17 commits
- [ ] Note current `~/.amethyst/accounts/<pubkey8>/` paths so they can be inspected after the run
- [ ] Run `defaults read /Library/Preferences/com.apple.security ...` baseline — irrelevant; `Preferences.userRoot()` lives in `~/Library/Preferences/com.apple.java.util.prefs.plist` on macOS, `~/.java/.userPrefs` on Linux. Note path for later verification.
---
## A. Automated verification (Claude can run these)
**Run on 2026-06-12 against worktree at HEAD `4ed0ff241`.**
### A1: Compile every module
| # | Step | Expected | Pass? | Notes |
|---|------|----------|-------|-------|
| A1.1 | `./gradlew :quartz:compileKotlinJvm` | BUILD SUCCESSFUL, no errors | ✅ | Only pre-existing `BirthdayTolerantSerializer` opt-in warning |
| A1.2 | `./gradlew :commons:compileKotlinJvm` | BUILD SUCCESSFUL | ✅ | |
| A1.3 | `./gradlew :desktopApp:compileKotlin` | BUILD SUCCESSFUL | ✅ | |
| A1.4 | `./gradlew :amethyst:compileFdroidDebugKotlin` (Android) | BUILD SUCCESSFUL — commons changes don't break Android | ✅ | Note: task is `:amethyst:compileFdroidDebugKotlin`, not the non-flavour `compileDebugKotlin` originally listed |
| A1.5 | `./gradlew :cli:compileKotlin` | BUILD SUCCESSFUL — quartz API changes don't break amy | ✅ | |
### A2: Unit + integration tests
| # | Step | Expected | Pass? | Notes |
|---|------|----------|-------|-------|
| A2.1 | `./gradlew :quartz:jvmTest` | All pass; `PoolEventOutboxStateTest` (4) + `GiftWrapRelayHintTest` (3) included | ✅ | |
| A2.2 | `./gradlew :commons:jvmTest` | All pass; `AuthApprovalPolicyTest` (8) + `AuthApprovalEndToEndTest` (4) + `DmInboxRelayResolverTest` (8) included | ✅ | |
| A2.3 | `./gradlew :desktopApp:test` | All pass | ✅ | |
| A2.4 | `./gradlew :amethyst:testFdroidDebugUnitTest` | All pass — no Android regression from commons changes | ✅ | |
### A3: Static analysis
| # | Step | Expected | Pass? | Notes |
|---|------|----------|-------|-------|
| A3.1 | `./gradlew spotlessCheck` | All formatted | ✅ | |
| A3.2 | Pre-commit hook runs on every commit (already exercised 17 times this branch) | Hook runs spotlessCheck + tests, passes | ✅ | |
### A4: Package build
| # | Step | Expected | Pass? | Notes |
|---|------|----------|-------|-------|
| A4.1 | `./gradlew :desktopApp:createDistributable -Pcompose.desktop.packaging.checkJdkVendor=false` | Produces `Amethyst.app`; new code (`DesktopAuthCoordinator`, `AuthApprovalPolicy`, `AuthApprovalBanner`, `RelayAuthSnapshot`, `DmInboxRelayResolver`) inside the bundled JARs | ✅ | `packageDistributionForCurrentOS` blocked on local Homebrew JDK vendor check — pre-existing env issue, not a branch regression. `createDistributable` with the flag works fine. |
| A4.2 | Launch the built `Amethyst.app` for 10s | No crash, no exceptions in log | ✅ | Confirmed: `DesktopAuthCoordinator` + `indexerClient` + `DmInboxRelayResolver` + banner mount all initialize cleanly. Only pre-existing VLC plugin warnings in stderr. |
### A5: Branch integrity
| # | Step | Expected | Pass? | Notes |
|---|------|----------|-------|-------|
| A5.1 | `git log --grep "Co-Authored-By" feat/desktop-dm-reliability ^origin/main \| wc -l` | 0 — no Claude footer leaked into commits | ✅ | 0 matches |
| A5.2 | All commits GPG-signed: `git log --pretty="%G?" feat/desktop-dm-reliability ^origin/main \| sort -u` | Only `G` (good signature) | ✅ | Only `G` |
| A5.3 | No `--no-verify` or `--no-gpg-sign` flags in reflog | clean | ✅ | Hook passed on every commit; one GPG retry mid-session resolved by re-unlock, no flags used |
---
## B. Manual desktop verification (human required)
**Pre-step:** `./gradlew :desktopApp:run` on the worktree. Use at least two test accounts: one nsec, one bunker (`bunker://...` from nsec.app or Amber).
### B1: AUTH end-to-end — tier 1 (own DM-inbox relay)
| # | Step | Expected | Pass? | Notes |
|---|------|----------|-------|-------|
| B1.1 | Log in with nsec account A. Verify the user has a `kind:10050` published with at least one relay (e.g. `wss://relay.nos.social`). | Account loads; feed shows | | |
| B1.2 | Open Settings → Relays. Confirm one of A's DM-inbox relays is in the connected set. | DM relay listed, status connected | | |
| B1.3 | Trigger an AUTH-walled action: ideally send a DM TO yourself (NIP-17). Watch DevTools / log output (or `~/.amethyst/logs/` if Tor is on). | Mock relay should send `AUTH ...`; client signs + replies; outbox publish OK | | |
| B1.4 | Confirm NO banner appears for tier-1 relays | Banner stays empty | | |
| B1.5 | Inspect Preferences node: `defaults read com.vitorpamplona.amethyst.desktop.auth.<full-pubkey>` (macOS) OR `cat ~/.java/.userPrefs/com/vitorpamplona/amethyst/desktop/auth/<full-pubkey>/prefs.xml` (Linux) | Empty / does not exist yet — tier-1 doesn't write | | |
### B2: AUTH end-to-end — tier 2 (unknown relay, banner)
| # | Step | Expected | Pass? | Notes |
|---|------|----------|-------|-------|
| B2.1 | Add an AUTH-required relay to the user's settings that is NOT in their `kind:10050` (e.g. `wss://pyramid.fiatjaf.com` if accessible, or any test relay with `auth-required` policy). | Relay appears in list | | |
| B2.2 | Send a DM that would route through that relay (e.g. user it's listed for). Or just connect and let the relay challenge. | Yellow/inline AUTH banner appears at top of content area with relay URL + `[Once] [Always] [Never]` | | |
| B2.3 | Click `[Once]` | Banner dismisses; AUTH proceeds for this session only | | |
| B2.4 | Restart app, repeat connection — banner appears again | banner returns (ONCE was session-only) | | |
| B2.5 | This time click `[Always]` | Banner dismisses; AUTH proceeds | | |
| B2.6 | Restart app. Banner does NOT appear for this relay. | tier-2 → tier-1 once persisted | | |
| B2.7 | Inspect Preferences node: should contain `relay.example.url=ALWAYS` | persistence verified | | |
| B2.8 | Block a different relay via `[Never]` | Future AUTH challenges from it are silently dropped (no banner, no AUTH event sent) | | |
| B2.9 | Confirm clicking `[Never]` writes `relay.url=BLOCKED` | persistence verified | | |
### B3: AUTH banner — multiple concurrent challenges
| # | Step | Expected | Pass? | Notes |
|---|------|----------|-------|-------|
| B3.1 | Connect to 3 different AUTH-required relays back-to-back, none auto-approved | 3 banner rows stack vertically | | |
| B3.2 | Resolve middle one with `[Once]` | Only that row dismisses; other 2 remain | | |
| B3.3 | Trigger 5 simultaneous AUTH challenges from different relays | First 3 visible inline; "+2 more relays pending approval" row at bottom | | |
### B4: Banner lifecycle — logout / account switch
| # | Step | Expected | Pass? | Notes |
|---|------|----------|-------|-------|
| B4.1 | With at least 2 pending banner rows visible, log out | Banners disappear; coordinator's `onLogout` completes all pending deferreds with BLOCKED | | |
| B4.2 | Log in to account B (different pubkey); trigger same AUTH challenges | Banners reappear because B has no persisted approvals from A | | |
| B4.3 | Verify B's `[Always]` writes to B's Preferences node, NOT A's | per-account isolation | | |
| B4.4 | Account A's persisted approvals still intact: log back into A → no banner for previously-approved relays | persistence stable across switches | | |
### B5: NIP-17 send — security fix (no-10050 case)
| # | Step | Expected | Pass? | Notes |
|---|------|----------|-------|-------|
| B5.1 | Pick a recipient who has NEVER published a kind:10050 (rare in practice; can fabricate a npub) | account known, no DM inbox advertised | | |
| B5.2 | Try to send a DM | `DmSendTracker` shows "No relays available" failure briefly | | |
| B5.3 | Inspect outgoing socket activity (e.g. Wireshark filtered to `wss://`) | NO gift wrap is published anywhere — neither to user's outbox nor general relays | | |
| B5.4 | DesktopRelayConnectionManager metrics: no spike for this send | confirmed | | |
### B6: NIP-17 send — DmInboxRelayResolver indexer fan-out
| # | Step | Expected | Pass? | Notes |
|---|------|----------|-------|-------|
| B6.1 | Pick a recipient who HAS a kind:10050 but whose 10050 is NOT in your LocalCache (fresh, never-DM'd contact) | empty LocalCache for that user | | |
| B6.2 | Click compose DM to them | Resolver consults indexer relays; brief delay (sub-second to ~3s) | | |
| B6.3 | Inspect network: indexer client (port-share with primary client?) makes one-shot queries to `relay.nos.social`, `relay.damus.io`, `nos.lol`, `relay.nostr.band`, `purplerelay.com` | indexer fan-out confirmed | | |
| B6.4 | **CRITICAL — F-01**: verify NO AUTH event was sent on the indexer client even if any indexer challenged | confirms unauth client. If wrong, that's a security regression. Easy check: filter pcap for kind:22242 on the indexer connections. | | |
| B6.5 | Send succeeds; recipient's actual DM-inbox relay receives the wrap | normal NIP-17 delivery | | |
| B6.6 | Immediately compose another DM to the same recipient | Resolver hits LRU cache; no second indexer call | | |
| B6.7 | Wait > 1 hour (or set system clock forward); compose again | Resolver fans out again (cache expired) | | |
### B7: NIP-17 send — group DM rumor coherence
| # | Step | Expected | Pass? | Notes |
|---|------|----------|-------|-------|
| B7.1 | Create a 3-recipient group DM | NIP17Factory builds 3 wraps + 1 self-copy | | |
| B7.2 | Inspect the rumor inside each seal (use a Nostr event inspector or relay log) — `rumor.id` matches across all 3 wraps | shared rumor_created_at confirmed | | |
| B7.3 | One recipient sends a reaction (`+`) on their device | reaction targets shared `rumor.id`; all participants see it | | |
| B7.4 | Verify `wrap.created_at` is randomized per-wrap (within 2 days past) | seal/wrap timestamps stay independent | | |
### B8: NIP-17 relay hint on wrap p-tag
| # | Step | Expected | Pass? | Notes |
|---|------|----------|-------|-------|
| B8.1 | Send a DM. Capture the published kind:1059 event via your DM-inbox relay UI or a tool like `nostr-tool`. | event captured | | |
| B8.2 | Inspect the `p` tag on the wrap | shape is `["p", recipient_pubkey, relay_url]` — relay_url is recipient's primary DM relay if known, else 2-element shape | | |
| B8.3 | Verify the SEAL (kind 13) does NOT carry the hint | NIP-17 spec compliance | | |
### B9: Outbox AUTH carve-out
| # | Step | Expected | Pass? | Notes |
|---|------|----------|-------|-------|
| B9.1 | Pre-condition: relay R demanding AUTH. User has account that needs to AUTH. | configured | | |
| B9.2 | Publish a note to R while NOT yet authenticated | Relay replies `auth-required: ...` | | |
| B9.3 | Inspect outbox state: event remains queued for relay R | NOT discarded after 1 try | | |
| B9.4 | Watch for AUTH event sign and submission (tier-1 auto-sign or banner approval) | AUTH OK | | |
| B9.5 | Original note re-publishes successfully on R after AUTH | E.g. via syncFilters() | | |
| B9.6 | Repeat: send 5 notes in rapid succession during AUTH window | All 5 re-publish after AUTH, none silently dropped | | |
### B10: Bunker concurrency cap
| # | Step | Expected | Pass? | Notes |
|---|------|----------|-------|-------|
| B10.1 | Log in with a NIP-46 bunker account | bunker connected | | |
| B10.2 | Send a 5-recipient group DM | NIP17Factory caps at 4 concurrent bunker RPCs | | |
| B10.3 | Inspect bunker request timing (nsec.app / Amber log) | At most 4 in-flight at any moment | | |
| B10.4 | Compare to a 5-recipient group DM with a local nsec account | local-nsec runs all 5 in parallel; no semaphore overhead | | |
| B10.5 | Verify DM still delivers correctly to all recipients | functional parity | | |
### B11: kind:1059 subscription — no `since` filter
| # | Step | Expected | Pass? | Notes |
|---|------|----------|-------|-------|
| B11.1 | Inspect the actual REQ message sent for kind:1059 subscription (e.g. via relay debug or `nostr-tool` proxy) | filter has `kinds:[1059]`, `#p:[user_pubkey]`, NO `since` field | | |
| B11.2 | Have someone send you a DM with `created_at = now() - 1.5 days` (use a custom client) | wrap arrives, unread badge increments | | |
| B11.3 | Send self a DM, restart app, verify still loaded | persistent dedupe still working | | |
### B12: SigningOpState.Progress
| # | Step | Expected | Pass? | Notes |
|---|------|----------|-------|-------|
| B12.1 | Existing zap / sign flows: trigger a sign, inspect status bar | "Waiting for signer approval... (Ns)" — unchanged | | |
| B12.2 | (Manual / future) Set `SigningState.updateProgress(2, 5)` from somewhere | Status bar shows "Signing (2 of 5)" | | |
---
## C. Cross-platform sanity (manual, Android only if you have a device)
### C1: Android — commons inheritance check
| # | Step | Expected | Pass? | Notes |
|---|------|----------|-------|-------|
| C1.1 | Build and install `./gradlew :amethyst:installDebug` | Android app launches | | |
| C1.2 | Send a NIP-17 DM from Android | works as before (Android doesn't yet use DmInboxRelayResolver / DesktopAuthCoordinator) | | |
| C1.3 | `User.dmInboxRelays()` behaviour: unchanged on Android | no regression | | |
| C1.4 | Confirm Android signing still goes through Android-only `AuthCoordinator` (not the new desktop one) | platform separation intact | | |
---
## D. Security audit (mostly Claude-verifiable)
### D1: Code/git inspection
| # | Step | Expected | Pass? | Notes |
|---|------|----------|-------|-------|
| D1.1 | `grep -rn "connectedRelays.value" desktopApp/.../DesktopIAccount.kt` | Zero remaining matches in NIP-17 paths (NIP-04 path may keep its broadcast-to-connected behaviour by design) | ✅ | 3 hits: lines 113-114 (`DesktopAccountRelays` defaults — unrelated), line 173 (NIP-04 broadcast — intentional). All three NIP-17 send paths use `resolveDmInboxRelaysStrict`. |
| D1.2 | `grep -rn "RelayAuthenticator" desktopApp/` | Only `DesktopAuthCoordinator` references; no other coordinator | ✅ | Single construction site: `DesktopAuthCoordinator.kt:106`. Doc-strings reference it elsewhere; no other coordinator class. |
| D1.3 | DmInboxRelayResolver uses a NostrClient distinct from `relayManager.client` | verified in Main.kt | ✅ | Main.kt:848-853 constructs `indexerClient = NostrClient(BasicOkHttpWebSocket.Builder(...))` separately; never passed `RelayAuthenticator`. |
| D1.4 | `grep -rn "dmInboxOrFallback" commons/ desktopApp/` | Zero matches — resolver uses `lists.dmInbox` strict | ✅ | Zero matches outside the Quartz definition site. |
### D2: Threat checks
| # | Step | Expected | Pass? | Notes |
|---|------|----------|-------|-------|
| D2.1 | Inspect a single wrap's `p` tag: confirm only ONE pubkey listed (the recipient), no leakage of group members | wrap p-tag is single-recipient | | |
| D2.2 | Logout: verify `PreferencesAuthApprovalStore.clear()` is called for each account | (currently called via `DesktopAuthCoordinator.onLogout`'s `tearDownLocked`; but does it call `store.clear()`? Looking at code: it does NOT call clear — see follow-up below.) | | This is a known gap. See note. |
### Known gap surfaced during sheet writing
**D2.2**: `DesktopAuthCoordinator.onLogout` tears down the authenticator and completes pending deferreds, but does **not** call `store.clear()`. This is by design (`ALWAYS`/`BLOCKED` decisions persist across login sessions for the same account, scoped by `account_pubkey` in the Preferences node). Account deletion (separate from logout) is the trigger that should call `clear()`. **Follow-up:** verify Amethyst Desktop account-delete path calls `PreferencesAuthApprovalStore(pubKey).clear()`. Not in scope of this branch.
---
## E. Sign-off
| Section | Pass? | Notes |
|---|---|---|
| A — Automated | | |
| B — Desktop manual | | |
| C — Android sanity | | |
| D — Security audit | | |
| **Overall** | | |
---
## What this branch does NOT ship (out-of-scope for testing)
These were captured in the deepening synthesis and are explicit follow-ups, NOT regressions:
- Persistent retry queue with exp-backoff (SQLite-backed) — substrate not yet built
- Per-message delivery state in chat bubbles (`✓` `✓✓` `⟳` `⚠`) — `DmSendTracker` is still global
- Bunker progress UI wiring — `SigningOpState.Progress` substrate landed but no caller yet emits per-step counts
- Window-focus re-AUTH coordinator — replaced with lazy reactive AUTH per deepening
- NIP-46 batch `get_conversation_keys` RPC — spec PR proposed in plan, no implementation
- Android UI parity for AUTH banner — Android stays on its existing unconditional `AuthCoordinator`
- Manual relay-entry dialog when recipient has no 10050 — replaced with Snackbar-equivalent "no relays" failure today
- NIP-09 deletion of self-copies on kind:10050 rotation — release-note disclosure only
@@ -0,0 +1,731 @@
# Desktop DM Reliability — Testing Playbook
**Branch:** `feat/desktop-dm-reliability` rebased onto `upstream/main`
**Tester:** _______________________
**Date:** _______________________
**Instructions:** Follow this top to bottom. Every step is an action or an observation. Don't skip ahead — later tests assume state from earlier ones. Total ≈ 40 min for full pass.
---
## Session results — 2026-07-09 (live run)
| Test | Result | Notes |
|------|--------|-------|
| T1 startup / AUTH wired | ✅ PASS | both `Init, Subscribe` + `AUTH wired` logged; no CME crash |
| T2 tier-1 self-DM (no banner) | ✅ PASS | published, no `PendingAuthApproval` prompt |
| T3.a tier-2 banner render | ✅ PASS | `relay.ditto.pub` banner, icon clear of traffic lights, 3 buttons |
| T3.c `Always` persists | ✅ PASS | `auth/<pubkey>/ wss://relay.ditto.pub/ = ALWAYS` in the plist |
| T6 no-10050 blocks send | ✅ PASS | "Recipient has no DM relay list", send disabled |
| T6b kind:10002-only blocks | ✅ PASS | same block; zero publish to the NIP-65 read relay |
| T8 wrap `p`-tag relay hint | ✅ PASS (after fix) | 3-element `["p", hex, wss://nos.lol/]` on the wrap |
| T12 kind:1059 sub has no `since` | ✅ PASS | `since` removed from `giftWrapsToMe` signature |
| T3.b `Once` / T3.d `Never` | ⏭️ not run | logic covered by `AuthApprovalEndToEndTest` |
| T9 group rumor.id | ⏭️ covered-by-construction | rumor signed once before the per-recipient loop |
| T10 AUTH-under-load / T11 bunker | ⏭️ not run | no challenging relay / bunker on hand |
**Bugs found & fixed this run:**
1. `DmInboxRelayResolver` LocalCache fast-path used lenient `dmInboxRelays()` → NIP-65 read-relay leak. Now `dmInboxRelaysStrict()`.
2. `NewDmDialog` rendered pasted npubs of metadata-less users as non-clickable → couldn't start a DM by npub. Now `getOrCreateUser`.
3. NIP-17 `p`-tag relay hint was plumbed in quartz but never passed by `DesktopIAccount` → every wrap shipped a 2-element `p` tag. Now wired.
---
## Setup (once, ~3 min)
**1.** In a terminal, cd to the worktree and confirm you're on the right commit:
```bash
cd /path/to/AmethystMultiplatform/.worktrees/feat/desktop-dm-reliability
git rev-parse HEAD
```
- Expect: `fcfc43eb44` (or later). If different: `git pull` and re-verify.
**2.** Wipe any prior AUTH grants so persistence tests start clean:
```bash
rm -rf ~/.java/.userPrefs/com/vitorpamplona/amethyst/desktop/auth
```
**3.** Launch the app (keep this terminal visible — we'll read logs from it):
```bash
./gradlew :desktopApp:run
```
- Expect: window appears in 1530 s (cold) / 5 s (warm).
**4.** In the app, log in with your primary account. Call this **User A**.
- Expect: sidebar loads, feed populates.
**5.** In the terminal, look for these two lines (they appear within 5 s of login):
```
[RelayAuthenticator] Init, Subscribe
[DesktopAuthCoordinator] AUTH wired for <pubkey8>
```
- **If both appear:** ✅ setup complete. Proceed to T1.
- **If either is missing:** STOP. Tell me the terminal output.
---
## T1 — Startup smoke check (already ✅ during setup)
Nothing extra to do — the two log lines above ARE T1.
- [ ] **T1 PASS** — both `Init, Subscribe` and `AUTH wired for <pubkey8>` printed with no exceptions
---
## T2 — Tier-1 self-DM (no banner) — 2 min
**Goal:** verify your own DM-inbox relays auto-AUTH silently.
**Steps:**
**1.** In the sidebar, click **Chats** (chat bubble icon).
**2.** At the top of the conversation list, click the **`+` icon** (new conversation).
**3.** Paste your OWN npub into the recipient field. Confirm.
**4.** In the message box, type: `t1 self-dm test`
**5.** Watch the top of the content area (where the yellow banner would appear).
- **Expected:** send button enables blue → no yellow AUTH banner appears anywhere.
**6.** Click the **send arrow** (right side of the message box).
**7.** Wait 3 s. The message should appear in your inbox.
- **Expected:** message appears in the conversation. Terminal has NO `AuthApprovalPolicy` prompt lines.
**Record:**
- [ ] **T2.1** No AUTH banner appeared: **YES / NO**
- [ ] **T2.2** Message arrived: **YES / NO**
- [ ] **T2 PASS** — both YES
---
## T3 — Tier-2 banner + persistence — 8 min
**Goal:** trigger a challenge from an AUTH-required relay NOT in your `kind:10050`, verify the banner renders + all three buttons persist correctly.
### T3.a — Trigger the banner
**1.** Open Settings. (Look for a gear/cog icon in the sidebar. If absent, try the app menu → Settings.)
**2.** Go to the **Relays** tab.
**3.** Find the "Add relay" input. Paste: `wss://pyramid.fiatjaf.com`
**4.** Save/apply (button label varies — usually "Add" or "Save").
**5.** Wait 13 s. Watch the **top of the content area** (below the title bar, above the main content).
- **Expected:** a yellow-tinted horizontal row slides in showing:
- Lock icon on the left (with proper margin from window edge — 80dp — not overlapping the traffic lights)
- `pyramid.fiatjaf.com` in a semi-bold heading
- Subtext: "requires authentication to deliver this message"
- Three buttons on the right: **`Once`** **`Always`** **`Never`**
Record:
- [ ] **T3.a.1** Banner appeared within 3 s: **YES / NO**
- [ ] **T3.a.2** Icon + text NOT overlapping traffic lights: **YES / NO**
- [ ] **T3.a.3** All three buttons visible: **YES / NO**
### T3.b — `[Once]` behaviour (session-only, no persistence)
**6.** Click `Once`.
- **Expected:** banner slides away immediately, no visible change to relay state.
**7.** In a second terminal, check the Preferences store did NOT get written for this relay.
> **Prefs location (macOS).** This JVM uses the `MacOSXPreferences` backing
> store, NOT `~/.java/.userPrefs`. Java prefs land in
> `~/Library/Preferences/com.vitorpamplona.amethyst.plist` under an
> `auth/<full-pubkey>/` node. Read it with `plutil`:
```bash
plutil -convert xml1 -o - ~/Library/Preferences/com.vitorpamplona.amethyst.plist | grep -i "pyramid\|ditto"
```
- **Expected:** empty output (ONCE is not persisted).
**8.** Close the app (Cmd+Q). Wait 2 s. Relaunch via `./gradlew :desktopApp:run`. Log in as A again.
**9.** Wait ~5 s. The banner for `pyramid.fiatjaf.com` should reappear (session state was not saved).
Record:
- [ ] **T3.b.1** After `[Once]`: Preferences NOT written: **YES / NO**
- [ ] **T3.b.2** After restart: banner reappeared: **YES / NO**
### T3.c — `[Always]` behaviour (persisted grant)
**10.** In the banner that just reappeared, click `Always`.
- **Expected:** banner slides away, `pyramid.fiatjaf.com` now shows "Authenticated" in Settings → Relays.
**11.** Check Preferences was written:
```bash
plutil -convert xml1 -o - ~/Library/Preferences/com.vitorpamplona.amethyst.plist | grep -i "pyramid\|ditto\|ALWAYS"
```
- **Expected:** the relay URL (e.g. `wss://relay.ditto.pub/`) followed by `ALWAYS`, under the `auth/<full-pubkey>/` node.
**12.** Close app (Cmd+Q). Relaunch. Log in as A.
- **Expected:** relay auto-authenticates in the background. **No banner appears** for `pyramid.fiatjaf.com`.
Record:
- [ ] **T3.c.1** Preferences shows `ALWAYS`: **YES / NO**
- [ ] **T3.c.2** After restart: no banner, auto-authenticated: **YES / NO**
### T3.d — `[Never]` behaviour (persisted block)
**13.** Add a different AUTH-required relay. If you have another, use it. Otherwise, try `wss://nostr.wine` (they AUTH-challenge non-subscribers) or `wss://relay.snort.social`.
**14.** Wait for the new banner to appear.
**15.** Click `Never`.
- **Expected:** banner disappears. The relay shows as connected but "not authenticated".
**16.** Check Preferences:
```bash
plutil -convert xml1 -o - ~/Library/Preferences/com.vitorpamplona.amethyst.plist | grep -i "<relay-domain>\|BLOCKED"
```
- **Expected:** the relay URL followed by `BLOCKED`, under the `auth/<full-pubkey>/` node.
**17.** Restart the app. Log in as A.
- **Expected:** the BLOCKED relay never surfaces a banner. It stays "not authenticated". No `kind:22242` AUTH event ever sent to it.
Record:
- [ ] **T3.d.1** Preferences shows `BLOCKED`: **YES / NO**
- [ ] **T3.d.2** After restart: no banner, no AUTH sent: **YES / NO**
---
**T3 sign-off:** Complete `T3.a`, `T3.b`, `T3.c`, `T3.d`.
- [ ] **T3 PASS** — all four subs green
---
## T4 — Multiple concurrent banners — 3 min
**Goal:** verify multiple pending banners stack correctly and resolve independently.
**Steps:**
**1.** In Settings → Relays, quickly add 3 different AUTH-required relays back-to-back. Suggested set:
- `wss://pyramid.fiatjaf.com` (if not already blocked/allowed)
- `wss://relay.nostr.com.au`
- `wss://nostr.wine`
**2.** Watch the banner area — all 3 rows should appear stacked vertically within ~3 s.
**3.** Click `Once` on the **middle** row.
- **Expected:** ONLY the middle row disappears. The other two remain visible.
**4.** (Optional stress test) Add 5+ more AUTH-required relays.
- **Expected:** first 3 shown inline; row at the bottom reads "+N more relays pending approval".
Record:
- [ ] **T4.1** 3 banners stack vertically: **YES / NO**
- [ ] **T4.2** Middle-row dismiss only affects itself: **YES / NO**
- [ ] **T4.3** "+N more" row shows when >3 pending: **YES / NO**
- [ ] **T4 PASS** — all three YES
---
## T5 — Per-account isolation — 4 min
**Goal:** verify AUTH grants are scoped per-account and cleaned on logout.
**Steps:**
**1.** Ensure A has at least one `ALWAYS` grant (from T3.c: `pyramid.fiatjaf.com`).
**2.** Log out of A (sidebar → profile → Logout, or app menu).
- **Terminal:** watch for `DesktopAuthCoordinator` teardown lines (no exceptions).
**3.** Log in as User B (different pubkey — nsec, npub, or bunker).
- **Terminal:** expect `[DesktopAuthCoordinator] AUTH wired for <B-pubkey8>` — different from A's.
**4.** Add `wss://pyramid.fiatjaf.com` in Settings → Relays for B.
- **Expected:** banner appears (B does NOT inherit A's `ALWAYS` grant).
**5.** Check that A's and B's Preferences are separate:
```bash
ls ~/.java/.userPrefs/com/vitorpamplona/amethyst/desktop/auth/
```
- **Expected:** two directories, one per full pubkey.
**6.** Click `Always` on B's banner.
**7.** Log out of B. Log back into A.
- **Terminal:** `AUTH wired for <A-pubkey8>` again.
**8.** Watch for banners.
- **Expected:** no banner for `pyramid.fiatjaf.com` (A's `ALWAYS` still persisted).
Record:
- [ ] **T5.1** Coordinator teardown clean on logout (no exceptions): **YES / NO**
- [ ] **T5.2** B sees banner for A-approved relay (isolation): **YES / NO**
- [ ] **T5.3** Two separate Preferences dirs exist: **YES / NO**
- [ ] **T5.4** Re-login to A: no re-prompt: **YES / NO**
- [ ] **T5 PASS** — all four YES
---
## T6 — P0 security fix (no-10050 recipient) — 5 min
**Goal:** verify DMs are NOT silently broadcast to your general relays when the recipient has no `kind:10050`.
### T6.a — Create a "no-inbox" test recipient
**1.** In a terminal, generate a fresh nsec/npub pair:
```bash
# Option: use nak
nak key generate
# Copy the printed nsec and npub
```
Or use any known npub of an account that never published `kind:10050`.
**2.** In Amethyst as A, click **`+`** in Chats. Paste the test npub. Confirm.
### T6.b — Verify the UI blocks send
**3.** Type any message.
**4.** Look at the row below the message input.
- **Expected:** red text "**Recipient has no DM relay list — messages cannot be delivered**"
- **Expected:** send button is grey/disabled.
**5.** Try clicking send anyway.
- **Expected:** nothing happens (button disabled). Or, if enabled by upstream UI quirk, `DmSendTracker` shows "No relays available" briefly.
### T6.c — Verify no wrap leaves the app (optional, for the security-conscious)
**6.** In a terminal, run:
```bash
sudo tcpdump -i any -A -s 0 'tcp port 443 or tcp port 80' 2>/dev/null | grep -i "kind\":1059"
```
**7.** In the app, try to send. Watch the tcpdump output for 30 s.
- **Expected:** zero output. No gift wrap (kind 1059) publishes anywhere.
**8.** Stop tcpdump with Ctrl+C.
Record:
- [ ] **T6.1** UI shows "no DM relay list" warning: **YES / NO**
- [ ] **T6.2** Send button disabled: **YES / NO**
- [ ] **T6.3** No `kind":1059` in outgoing traffic during send attempt: **YES / NO / SKIPPED**
- [ ] **T6 PASS** — T6.1 and T6.2 both YES (T6.3 optional but recommended)
---
## T6b — Strict kind:10050 (NIP-65 read-relay non-leak) — 4 min
**Goal:** verify the review fix — a recipient that DOES publish NIP-65 read
relays (kind:10002) but has NO `kind:10050` is still treated as unreachable.
The lenient fast-path bug would have published the wrap to those NIP-65 read
relays; the fix must NOT.
### T6b.a — Create a recipient with kind:10002 but no kind:10050
**1.** Generate a fresh key and publish ONLY a NIP-65 relay list (no 10050):
```bash
nak key generate # copy nsec + npub
# publish a kind:10002 with a read relay, and NO kind:10050:
echo '{"kind":10002,"tags":[["r","wss://relay.damus.io","read"]],"content":""}' \
| nak event --sec <nsec> wss://relay.damus.io wss://nos.lol
```
**2.** As User A, open a new chat to that npub so A's LocalCache ingests the
recipient's kind:10002 (send/hover the profile so the relay list loads).
### T6b.b — Verify send is blocked, not routed to the read relay
**3.** Type a message. Observe the row under the input.
- **Expected:** same "no DM relay list — messages cannot be delivered"
warning as T6; send disabled.
- **Wrong (pre-fix bug):** send is ENABLED and the wrap goes to
`wss://relay.damus.io` (the recipient's NIP-65 *read* relay).
**4.** (Optional, definitive) tcpdump as in T6.c while attempting send.
- **Expected:** zero `kind":1059` frames to the recipient's kind:10002 relays.
Record:
- [ ] **T6b.1** Send blocked despite recipient having kind:10002: **YES / NO**
- [ ] **T6b.2** No wrap sent to NIP-65 read relay (if tcpdump run): **YES / NO / SKIPPED**
- [ ] **T6b PASS** — T6b.1 YES
---
## T7 — Indexer fan-out + F-01 unauth check — 6 min
**Goal:** verify the resolver probes indexer relays with an UNAUTHENTICATED client (no `kind:22242` AUTH events leaked to indexers).
### T7.a — Prime the state
**1.** Restart the app (Cmd+Q, then `./gradlew :desktopApp:run`).
- Fresh LocalCache = maximum chance the resolver actually fires.
**2.** Log in as A.
### T7.b — Set up traffic capture (optional but revealing)
**3.** In a second terminal, start capturing all WebSocket traffic:
```bash
sudo tshark -i any -Y 'websocket' -T fields -e ws.payload 2>/dev/null | head -c 100000
```
Or (simpler):
```bash
sudo tcpdump -i any -A -s 0 'tcp port 443' 2>/dev/null > /tmp/dm-traffic.log &
```
### T7.c — Trigger the resolver
**4.** Pick a recipient who HAS a `kind:10050` published (a NIP-17-active account) but whom you have NEVER DM'd from account A.
**5.** In Amethyst, click **`+`** in Chats. Paste the recipient's npub. Confirm.
**6.** Watch the pre-send row.
- **Expected sequence:**
- Initial: red "no DM relay list" warning (LocalCache miss).
- Within 25 s: warning disappears (resolver probe found the recipient's `kind:10050` on an indexer).
- Send button turns blue.
### T7.d — Verify F-01 (no AUTH to indexer)
**7.** Search the captured traffic for `kind:22242` AUTH events:
```bash
grep -i "\"kind\":22242" /tmp/dm-traffic.log | head -20
```
- **Expected:** any `kind:22242` events found should only be to relays in your existing DM-inbox set — NOT to the indexer set (`relay.nos.social`, `relay.damus.io`, `nos.lol`, `relay.nostr.band`, `purplerelay.com`).
**8.** In the app, type a message and send.
- **Expected:** send succeeds. Recipient's actual DM-inbox relay receives the wrap.
**9.** Stop tcpdump: `sudo pkill tcpdump`
### T7.e — Verify LRU cache hit on second send
**10.** Immediately compose a second DM to the same recipient. Send.
- **Expected:** send is immediate, no delay. Resolver hits its LRU cache, no new indexer probe.
Record:
- [ ] **T7.1** Warning cleared within 5 s (resolver probe worked): **YES / NO**
- [ ] **T7.2** Send button became enabled after probe: **YES / NO**
- [ ] **T7.3** No `kind:22242` AUTH sent to indexer relays: **YES / NO / SKIPPED**
- [ ] **T7.4** DM delivered to recipient: **YES / NO**
- [ ] **T7.5** Second DM to same recipient: no probe delay: **YES / NO**
- [ ] **T7 PASS** — T7.1, T7.2, T7.4, T7.5 all YES
---
## T8 — Wrap `p`-tag relay hint — 4 min
**Goal:** verify the outgoing gift wrap includes the recipient's primary DM relay as the third element of the `p` tag.
**Steps:**
**1.** Send a DM to any recipient with a known `kind:10050` (e.g. the one from T7).
**2.** In a terminal, use `nak` (or `websocat`) to query one of the recipient's DM-inbox relays for their gift wraps:
```bash
RECIPIENT_HEX=<paste-recipient-hex-pubkey>
DM_RELAY=<paste-one-of-their-10050-relays>
nak req -k 1059 --tag "p=$RECIPIENT_HEX" "$DM_RELAY" | head -5
```
**3.** Find the wrap you just sent (highest `created_at`). Look at its `p` tag.
- **Expected:** `["p", "<recipient-hex>", "wss://recipient-primary-relay/"]` — 3 elements, third is a valid relay URL.
**4.** For contrast, send a DM to a recipient whose `kind:10050` you have NO indexer/cache hit for (e.g. the one from T6 if you have their nsec to simulate — otherwise skip).
- **Expected:** wrap's `p` tag has only 2 elements: `["p", "<hex>"]` — no fake empty third element.
Record:
- [ ] **T8.1** With known relay: 3-element `p` tag: **YES / NO**
- [ ] **T8.2** Without known relay: 2-element `p` tag (no empty third): **YES / NO / SKIPPED**
- [ ] **T8 PASS** — T8.1 YES
---
## T9 — Group DM shared `rumor.id` — 5 min
**Goal:** verify all recipient wraps in a group DM decrypt to a rumor with the SAME `id`.
**Steps:**
**1.** In Amethyst as A, click **`+`** in Chats. Add 3 recipient npubs (you can include yourself as one, plus 2 others whose `kind:10050` is known).
**2.** Type a distinctive message: `t9 group rumor coherence test`. Send.
**3.** For each recipient, use `nak` to fetch the gift wrap from their DM-inbox relay:
```bash
for RECIPIENT in $RECIPIENT_A $RECIPIENT_B $RECIPIENT_C; do
nak req -k 1059 --tag "p=$RECIPIENT" wss://relay.example/ | head -3
done
```
**4.** Ideally decrypt each wrap (requires each recipient's nsec). But since all 3 seals encode the same rumor, the rumor `id` should be identical across the 3 wraps.
**5.** If you have at least 2 recipient nsecs, decrypt via `nak`:
```bash
nak decrypt --sec $NSEC "<encrypted-wrap-content>"
# Look at the inner rumor's "id" field
```
**6.** Compare the rumor `id` across the wraps.
- **Expected:** all 3 rumor `id`s are IDENTICAL.
**7.** (Bonus) Have one of the recipients (in another Amethyst instance or via nak) react to the message with `+`.
**8.** Confirm A and other recipients see the reaction.
- **Expected:** reaction targets the shared `rumor.id` and appears cross-recipient.
Record:
- [ ] **T9.1** All wraps decrypt to same rumor.id: **YES / NO / SKIPPED (needs multi-account decrypt)**
- [ ] **T9.2** Cross-recipient reaction visible: **YES / NO / SKIPPED**
- [ ] **T9 PASS** — T9.1 YES (or explicitly skipped)
---
## T10 — Outbox AUTH carve-out under load — 4 min
**Goal:** verify multiple queued events are NOT silently dropped during AUTH negotiation.
**Steps:**
**1.** In Settings → Relays, ensure you have `wss://pyramid.fiatjaf.com` connected. If you `[Always]`-approved it in T3.c, first log out and back in so the relay reconnects and re-challenges.
**2.** In the compose dialog, publish 5 notes rapidly (10 seconds apart is fine):
```
t10 note 1
t10 note 2
t10 note 3
t10 note 4
t10 note 5
```
**3.** In the terminal, watch for AUTH activity on `pyramid.fiatjaf.com`:
```
[RelayAuthenticator] ... auth-required: ...
[RelayAuthenticator] ... AUTH accepted ...
```
**4.** Once AUTH completes, all 5 notes should publish to `pyramid.fiatjaf.com`.
**5.** Verify by querying `pyramid.fiatjaf.com` for your recent notes:
```bash
nak req -a $A_HEX -k 1 wss://pyramid.fiatjaf.com | head -10
```
- **Expected:** all 5 `t10 note N` events present on `pyramid.fiatjaf.com`.
Record:
- [ ] **T10.1** All 5 notes visible on `pyramid.fiatjaf.com`: **YES / NO**
- [ ] **T10.2** Terminal shows AUTH succeeded before drops: **YES / NO**
- [ ] **T10 PASS** — T10.1 YES
---
## T11 — Bunker `Semaphore(4)` (bunker users only) — 3 min
**Skip if you don't have a NIP-46 bunker (nsec.app / Amber).**
**Goal:** verify NIP-17 group DMs are rate-limited to ≤4 concurrent bunker RPCs.
**Steps:**
**1.** Log out. Log in with a bunker (`bunker://` URI).
**2.** Compose a group DM to **5 recipients** (5 different npubs with known `kind:10050`).
**3.** In nsec.app / Amber, watch the request feed as you click Send.
- **Expected:** at most **4** requests in-flight at any moment. Requests process in batches of 4.
**4.** For comparison: log out, log back in with a local nsec. Repeat the 5-recipient send.
- **Expected:** local signer runs all 5 requests in parallel (no Semaphore cap).
Record:
- [ ] **T11.1** Bunker: ≤4 concurrent RPCs: **YES / NO / SKIPPED (no bunker)**
- [ ] **T11.2** Local: fully parallel: **YES / NO / SKIPPED**
- [ ] **T11 PASS** — either both YES or both SKIPPED
---
## T12 — kind:1059 subscription has no `since` — 3 min
**Goal:** verify the outgoing REQ for gift wraps has NO `since` filter (would silently drop old-timestamped wraps).
**Steps:**
**1.** In one terminal, run a WebSocket relay proxy that echoes traffic (or use `nak` to inspect):
```bash
# Simplest: read the desktop subscription code directly
grep -n "FilterDMs.giftWrapsToMe\|since" desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterDMs.kt
```
- **Expected:** signature `fun giftWrapsToMe(userPubKeyHex: HexKey)`**NO `since` parameter**.
**2.** Live check (harder but definitive): use `mitmproxy` or `websocat` in proxy mode to intercept WebSocket traffic from the app.
**3.** Alternatively: rely on the unit tests. Confirm they pass:
```bash
./gradlew :quartz:jvmTest --tests "com.vitorpamplona.quartz.nip59Giftwrap.wraps.*"
```
- **Expected:** BUILD SUCCESSFUL.
Record:
- [ ] **T12.1** `giftWrapsToMe` signature has no `since`: **YES / NO**
- [ ] **T12.2** Unit tests pass: **YES / NO**
- [ ] **T12 PASS** — both YES
---
## T13 — Pre-send alignment + resolver probe (verified working) — sanity re-check, 3 min
**Already confirmed** during the pre-launch fix. Quick re-check:
**Steps:**
**1.** In Amethyst as A, open a fresh DM with a recipient whose `kind:10050` is NOT in your LocalCache (e.g. a fresh contact — click a new profile, then compose DM).
**2.** Watch the pre-send row.
- **Expected:** red "no DM relay list" warning appears initially.
**3.** Wait 25 s.
- **Expected:** warning clears on its own (resolver probe found the recipient via indexer). Send button turns blue.
Record:
- [ ] **T13.1** Warning appears initially: **YES / NO**
- [ ] **T13.2** Warning clears within 5 s (resolver worked): **YES / NO**
- [ ] **T13 PASS** — both YES
---
## Sign-off
| Test | Pass? | Notes |
|---|---|---|
| Setup | ⬜ | |
| T1 startup wiring | ⬜ | |
| T2 tier-1 self-DM | ⬜ | |
| T3 tier-2 banner (T3.aT3.d) | ⬜ | |
| T4 multiple banners | ⬜ | |
| T5 per-account isolation | ⬜ | |
| **T6 P0 SECURITY** | ⬜ | Highest priority |
| **T7 F-01 unauth indexer** | ⬜ | Highest priority |
| T8 wrap p-tag relay hint | ⬜ | |
| T9 group DM rumor id | ⬜ | |
| T10 outbox AUTH carve-out | ⬜ | |
| T11 bunker Semaphore | ⬜ | Skip if no bunker |
| T12 no since filter | ⬜ | |
| T13 pre-send alignment | ⬜ | |
**Overall:** ⬜ PASS — ready for PR / ⬜ FAIL — see blockers / ⬜ NEEDS REVISIT
**Blockers:** _______________________________________________________
**Tester signature:** _______________________ **Date:** _______________________
---
## Known pre-existing issues (NOT branch regressions)
- **`ConcurrentModificationException` at `RelayLatencyTracker.sweep:182`** during rapid account switching. Kills UI thread; coroutines keep running. Documented in memory `desktop_relay_health_cme_crash`.
- **`NoClassDefFoundError` for `CompressionQuality`** on stale gradle daemon. Fix: `./gradlew --stop && ./gradlew :desktopApp:run`.
## Known non-issues (don't file as bugs)
- `[GiftWrapEvent] Couldn't Decrypt the content …` debug lines — normal LocalCache trial-decrypt for wraps not addressed to you.
- VLC `securetransport tls client error` — pre-existing media playback warnings.
- `[NIP19 Parser] Issue trying to Decode NIP19 …` — pre-existing, malformed identifiers in some events.
- `DmBroadcastBanner` (send-progress) may render simultaneously with `AuthApprovalBanner` — distinguish by buttons: AUTH banner has `Once/Always/Never`; broadcast has send-count status.
## Out of scope for this branch
Explicit follow-ups per the deepening synthesis:
- Persistent retry queue with exp-backoff (SQLite-backed)
- Per-message delivery state in bubbles (`✓` `✓✓` `⟳` `⚠`)
- Bunker progress UI wiring
- Window-focus re-AUTH
- NIP-46 batch `get_conversation_keys` RPC
- Android UI parity for AUTH banner
- Manual relay-entry dialog when recipient has no 10050
- NIP-09 deletion of self-copies on kind:10050 rotation
- Fix for pre-existing `RelayLatencyTracker.sweep` CME
@@ -0,0 +1,58 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay.client.auth
import androidx.compose.runtime.Immutable
/**
* Compose-stable per-relay AUTH snapshot exposed by [RelayAuthenticator].
*
* The internal [RelayAuthStatus] is a mutable holder around concurrent LRU
* caches necessary for the per-relay OkHttp dispatcher, but unsuitable as
* a [kotlinx.coroutines.flow.StateFlow] value (mutating it doesn't change
* identity, so distinct-until-changed swallows updates).
*
* [RelayAuthSnapshot] is the immutable view downstream consumers (UI banner,
* retry coordinator, indexer-fan-out gate) subscribe to.
*/
@Immutable
data class RelayAuthSnapshot(
val phase: Phase,
val lastAuthSuccessAt: Long?,
) {
enum class Phase {
/** Connected; no AUTH challenge has been received yet. */
IDLE,
/** Signed AUTH event in flight; awaiting OK from the relay. */
AUTHENTICATING,
/** Last AUTH succeeded; relay accepts authenticated REQs. */
AUTHENTICATED,
/** Last AUTH attempt failed; subsequent challenges may still arrive. */
AUTH_FAILED,
}
companion object {
val IDLE = RelayAuthSnapshot(Phase.IDLE, lastAuthSuccessAt = null)
}
}
@@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.auth
import androidx.collection.LruCache
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.concurrent.Volatile
class RelayAuthStatus {
// Keeps track of auth responses to update the relay with all filters
@@ -32,6 +34,12 @@ class RelayAuthStatus {
// Avoids sending multiple replies for each auth.
private val uniqueAuthChallengesSent: LruCache<ChallengePair, ChallengePair> = LruCache(10)
// Latest epoch-second at which a tracked AUTH event received a successful OK.
// Read by RelayAuthSnapshot consumers for staleness checks (e.g. proactive
// re-AUTH on window focus).
@Volatile
private var lastAuthSuccessAt: Long? = null
enum class AuthEventReceiptStatus {
AUTHENTICATING,
AUTHENTICATED,
@@ -66,6 +74,7 @@ class RelayAuthStatus {
return if (wasAlreadyAuthenticated != null) {
if (success) {
authResponseWatcher.put(eventId, AuthEventReceiptStatus.AUTHENTICATED)
lastAuthSuccessAt = TimeUtils.now()
} else {
authResponseWatcher.put(eventId, AuthEventReceiptStatus.NOT_AUTHENTICATED)
}
@@ -77,4 +86,29 @@ class RelayAuthStatus {
}
fun hasFinishedAllAuths() = authResponseWatcher.snapshot().all { it.value != AuthEventReceiptStatus.AUTHENTICATING }
/**
* Build an immutable Compose-stable snapshot of the current per-relay AUTH
* state. The phase is derived from the response watcher:
*
* - any AUTHENTICATING entry [RelayAuthSnapshot.Phase.AUTHENTICATING]
* - else any AUTHENTICATED entry [RelayAuthSnapshot.Phase.AUTHENTICATED]
* - else any NOT_AUTHENTICATED entry [RelayAuthSnapshot.Phase.AUTH_FAILED]
* - else (no tracked challenges) [RelayAuthSnapshot.Phase.IDLE]
*
* The watcher LRU caps at 10 entries; a long-running connection that has
* already AUTHed will still report AUTHENTICATED even after older entries
* roll off, because the LRU keeps the most recent.
*/
fun snapshot(): RelayAuthSnapshot {
val entries = authResponseWatcher.snapshot()
val phase =
when {
entries.isEmpty() -> RelayAuthSnapshot.Phase.IDLE
entries.values.any { it == AuthEventReceiptStatus.AUTHENTICATING } -> RelayAuthSnapshot.Phase.AUTHENTICATING
entries.values.any { it == AuthEventReceiptStatus.AUTHENTICATED } -> RelayAuthSnapshot.Phase.AUTHENTICATED
else -> RelayAuthSnapshot.Phase.AUTH_FAILED
}
return RelayAuthSnapshot(phase, lastAuthSuccessAt)
}
}
@@ -33,10 +33,16 @@ import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.collections.immutable.PersistentMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
@@ -61,8 +67,32 @@ class RelayAuthenticator(
// Connection callbacks fire on the per-relay OkHttp dispatcher thread, so
// this state is mutated concurrently — LargeCache wraps a platform-tuned
// concurrent map (ConcurrentSkipListMap on jvmAndroid, CacheMap on Apple).
//
// This stays mutable because RelayAuthStatus carries an LruCache that has
// to be addressable from the dispatcher thread. The Compose-observable
// view of the same data is published on [authStateFlow] below, sourced
// from RelayAuthStatus.snapshot().
private val authStatus = LargeCache<NormalizedRelayUrl, RelayAuthStatus>()
private val _authStateFlow = MutableStateFlow<PersistentMap<NormalizedRelayUrl, RelayAuthSnapshot>>(persistentMapOf())
/**
* Per-relay AUTH state as an immutable Compose-stable snapshot map.
*
* Downstream consumers (UI banner, retry queue, indexer-fan-out gate)
* subscribe to this flow instead of polling [authStatus] directly.
* Identity changes on every mutation, so [kotlinx.coroutines.flow.distinctUntilChanged]
* downstream and Compose `@Immutable` skipping both work correctly.
*/
val authStateFlow: StateFlow<PersistentMap<NormalizedRelayUrl, RelayAuthSnapshot>> = _authStateFlow.asStateFlow()
private fun publishSnapshot(relayUrl: NormalizedRelayUrl) {
val status = authStatus.get(relayUrl)
_authStateFlow.update { current ->
if (status == null) current.remove(relayUrl) else current.put(relayUrl, status.snapshot())
}
}
private val clientListener =
object : RelayConnectionListener {
override fun onIncomingMessage(
@@ -78,10 +108,12 @@ class RelayAuthenticator(
override fun onConnecting(relay: IRelayClient) {
authStatus.put(relay.url, RelayAuthStatus())
publishSnapshot(relay.url)
}
override fun onDisconnected(relay: IRelayClient) {
authStatus.remove(relay.url)
publishSnapshot(relay.url)
}
}
@@ -102,6 +134,7 @@ class RelayAuthenticator(
// only send replies to new challenges to avoid infinite loop:
if (authStatus.get(relay.url)?.saveAuthSubmission(authEvent) == true) {
relay.sendIfConnected(AuthCmd(authEvent))
publishSnapshot(relay.url)
}
}
} catch (e: CancellationException) {
@@ -118,8 +151,12 @@ class RelayAuthenticator(
relay: IRelayClient,
msg: OkMessage,
) {
val transitioned = authStatus.get(relay.url)?.checkAuthResults(msg.eventId, msg.success) == true
// Publish even on failure transitions so the UI can clear "AUTHENTICATING"
// banners and reflect AUTH_FAILED state.
publishSnapshot(relay.url)
// if this is the OK of an auth event, renew all subscriptions and resend all outgoing events.
if (authStatus.get(relay.url)?.checkAuthResults(msg.eventId, msg.success) == true) {
if (transitioned) {
client.syncFilters(relay)
}
}
@@ -66,11 +66,15 @@ class PoolEventOutboxState(
success: Boolean,
message: String,
) {
val currentTries = failures[url]
if (success || message.shouldDiscard()) {
relaysRemaining = relaysRemaining - url
failures = failures - url
} else if (message.isAuthRequired()) {
// NIP-42 AUTH challenge in flight — don't count toward the try cap.
// RelayAuthenticator signs + relay re-issues OK; syncFilters() then
// re-pumps this outbox so the original publish is retried.
} else {
val currentTries = failures[url]
if (currentTries != null) {
currentTries.addResponse(message)
} else {
@@ -91,6 +95,8 @@ class PoolEventOutboxState(
this.startsWith("deleted:") ||
this.startsWith("invalid:")
fun String.isAuthRequired() = this.startsWith("auth-required:")
// Tries 3 times
class Tries(
var tries: List<Long> = listOf(),
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip17Dm
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds
@@ -33,9 +34,12 @@ import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.utils.mapNotNullAsync
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
class NIP17Factory {
data class Result(
@@ -43,10 +47,34 @@ class NIP17Factory {
val wraps: List<GiftWrapEvent>,
)
/**
* Build one NIP-59 gift wrap per recipient.
*
* The rumor (kind 14) `created_at` is implicitly shared across all wraps
* because [event] is signed once by the caller before the per-recipient
* loop runs every seal encodes the same rumor `id`. This anchors
* cross-recipient dedupe + reaction/receipt targeting on group sends.
*
* Per NIP-17, the gift wrap's `p` tag MAY carry the recipient's primary
* DM inbox relay as a hint. Pass [recipientRelayHints] to surface those;
* the default `{ null }` lambda preserves the historical 2-element tag
* shape for every recipient.
*
* When [signer] is a [NostrSignerRemote] (NIP-46 bunker), seal building
* is rate-limited to [BUNKER_PARALLELISM] concurrent operations. Each
* seal needs `nip44_encrypt` + `sign` round-trips against the bunker; a
* 5-recipient group otherwise launches 10 concurrent in-flight RPCs and
* saturates the bunker socket. Local signers (NostrSignerInternal,
* NostrSignerSync) run fully parallel no semaphore overhead.
*
* The proper fix is the batched `nip44_get_conversation_keys` NIP-46
* RPC (separate plan); this is the interim throttle until that lands.
*/
private suspend fun createWraps(
event: Event,
to: Set<HexKey>,
signer: NostrSigner,
recipientRelayHints: (HexKey) -> NormalizedRelayUrl? = { null },
): List<GiftWrapEvent> {
val innerExpDelta =
event.expiration()?.let {
@@ -57,29 +85,47 @@ class NIP17Factory {
}
}
val bunkerLimiter = if (signer is NostrSignerRemote) Semaphore(BUNKER_PARALLELISM) else null
return mapNotNullAsync(
to.toList(),
) { next ->
GiftWrapEvent.create(
event =
SealedRumorEvent.create(
event = event,
encryptTo = next,
expirationDelta = innerExpDelta,
signer = signer,
),
recipientPubKey = next,
expirationDelta = innerExpDelta,
)
val build: suspend () -> GiftWrapEvent = {
GiftWrapEvent.create(
event =
SealedRumorEvent.create(
event = event,
encryptTo = next,
expirationDelta = innerExpDelta,
signer = signer,
),
recipientPubKey = next,
expirationDelta = innerExpDelta,
recipientRelayHint = recipientRelayHints(next),
)
}
bunkerLimiter?.withPermit { build() } ?: build()
}
}
companion object {
/**
* Max concurrent in-flight NIP-46 RPCs when building wraps via a
* remote signer. Empirically a sweet spot covers parallelism
* speedup for 24 recipient sends without saturating typical
* bunker apps (nsec.app, Amber, Keychat) that serialize requests
* internally past ~10 in-flight.
*/
const val BUNKER_PARALLELISM = 4
}
suspend fun createMessageNIP17(
template: EventTemplate<ChatMessageEvent>,
signer: NostrSigner,
recipientRelayHints: (HexKey) -> NormalizedRelayUrl? = { null },
): Result {
val senderMessage = signer.sign(template)
val wraps = createWraps(senderMessage, senderMessage.groupMembers(), signer)
val wraps = createWraps(senderMessage, senderMessage.groupMembers(), signer, recipientRelayHints)
return Result(
msg = senderMessage,
wraps = wraps,
@@ -108,9 +154,10 @@ class NIP17Factory {
suspend fun createEncryptedFileNIP17(
template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>,
signer: NostrSigner,
recipientRelayHints: (HexKey) -> NormalizedRelayUrl? = { null },
): Result {
val senderMessage = signer.sign(template)
val wraps = createWraps(senderMessage, senderMessage.groupMembers(), signer)
val wraps = createWraps(senderMessage, senderMessage.groupMembers(), signer, recipientRelayHints)
return Result(
msg = senderMessage,
@@ -142,12 +189,13 @@ class NIP17Factory {
originalNote: EventHintBundle<Event>,
to: List<HexKey>,
signer: NostrSigner,
recipientRelayHints: (HexKey) -> NormalizedRelayUrl? = { null },
): Result {
val senderPublicKey = signer.pubKey
val template = ReactionEvent.build(content, originalNote)
val senderReaction = signer.sign(template)
val wraps = createWraps(senderReaction, to.plus(senderPublicKey).toSet(), signer)
val wraps = createWraps(senderReaction, to.plus(senderPublicKey).toSet(), signer, recipientRelayHints)
return Result(
msg = senderReaction,
wraps = wraps,
@@ -159,12 +207,13 @@ class NIP17Factory {
originalNote: EventHintBundle<Event>,
to: List<HexKey>,
signer: NostrSigner,
recipientRelayHints: (HexKey) -> NormalizedRelayUrl? = { null },
): Result {
val senderPublicKey = signer.pubKey
val template = ReactionEvent.build(emojiUrl, originalNote)
val senderReaction = signer.sign(template)
val wraps = createWraps(senderReaction, to.plus(senderPublicKey).toSet(), signer)
val wraps = createWraps(senderReaction, to.plus(senderPublicKey).toSet(), signer, recipientRelayHints)
return Result(
msg = senderReaction,
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
@@ -96,11 +97,22 @@ open class GiftWrapEvent(
const val KIND = 1059
const val ALT = "Encrypted event"
/**
* Build a NIP-59 gift wrap addressed to `recipientPubKey`.
*
* Per NIP-17 §Publishing, the `p` tag on the wrap MAY carry the
* recipient's primary DM inbox relay as a hint, so other clients
* the recipient runs (or relays acting as inbox routers) can locate
* the wrap without a separate kind:10050 lookup. Pass it via
* [recipientRelayHint] `null` (the default) preserves the
* historical 2-element `["p", pubkey]` shape.
*/
fun create(
event: Event,
recipientPubKey: HexKey,
expirationDelta: Long? = null,
createdAt: Long = TimeUtils.randomWithTwoDays(),
recipientRelayHint: NormalizedRelayUrl? = null,
): GiftWrapEvent {
val signer = NostrSignerSync(KeyPair()) // GiftWrap is always a random key
@@ -109,11 +121,11 @@ open class GiftWrapEvent(
// minimum expiration is two days in the future due to the random created at
// this will make sure the even arrives and is not deleted because of the 2 days.
arrayOf(
PTag.assemble(recipientPubKey, null),
PTag.assemble(recipientPubKey, recipientRelayHint),
ExpirationTag.assemble(createdAt + it + TimeUtils.twoDays()),
)
} ?: arrayOf(
PTag.assemble(recipientPubKey, null),
PTag.assemble(recipientPubKey, recipientRelayHint),
)
return signer.sign(
@@ -0,0 +1,97 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay.client.pool
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class PoolEventOutboxStateTest {
private val relay = NormalizedRelayUrl("wss://relay.example/")
private fun fakeEvent() =
Event(
id = "0".repeat(64),
pubKey = "0".repeat(64),
createdAt = 0L,
kind = 1,
tags = emptyArray(),
content = "",
sig = "0".repeat(128),
)
@Test
fun authRequiredResponseDoesNotConsumeTryBudget() {
val state = PoolEventOutboxState(fakeEvent(), setOf(relay))
// Simulate 5 `auth-required:` responses — relay keeps challenging while
// RelayAuthenticator signs + sends AUTH events asynchronously. None of
// these should be counted against the 3-response try cap.
repeat(5) {
state.newResponse(relay, success = false, message = "auth-required: please authenticate")
}
// Even after a follow-up newTry, the relay must remain in the outbox so
// syncFilters() can re-publish once AUTH succeeds.
state.newTry(relay)
assertContains(state.relaysLeft(), relay)
assertFalse(state.isDone())
}
@Test
fun regularRejectionStillBoundedByTryCap() {
val state = PoolEventOutboxState(fakeEvent(), setOf(relay))
// 3 non-AUTH rejections accumulate normally.
repeat(3) {
state.newResponse(relay, success = false, message = "error: rate limited")
}
state.newTry(relay)
// After the 4th newTry (with 3 prior responses already in flight), the
// Tries cap kicks in and the relay is dropped from the outbox.
assertFalse(state.relaysLeft().contains(relay))
}
@Test
fun terminalRejectionImmediatelyDropsRelay() {
val state = PoolEventOutboxState(fakeEvent(), setOf(relay))
state.newResponse(relay, success = false, message = "invalid: malformed event")
assertFalse(state.relaysLeft().contains(relay))
assertTrue(state.isDone())
}
@Test
fun successDropsRelayFromOutbox() {
val state = PoolEventOutboxState(fakeEvent(), setOf(relay))
state.newResponse(relay, success = true, message = "")
assertEquals(emptySet(), state.relaysLeft())
assertTrue(state.isDone())
}
}
@@ -0,0 +1,104 @@
/*
* 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.nip59Giftwrap.wraps
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* NIP-17 relay-hint placement contract.
*
* Per NIP-17 §Publishing, the gift wrap's `p` tag MAY carry the recipient's
* primary DM inbox relay as a third element so other devices of the recipient
* can discover the wrap without a separate kind:10050 lookup. The hint
* deliberately lives on the public wrap, NOT on the encrypted seal putting
* it on the seal would hide the routing information inside the encryption
* envelope, defeating the purpose.
*/
class GiftWrapRelayHintTest {
private val recipient = KeyPair()
private fun innerEvent(): Event {
val signer = NostrSignerSync(KeyPair())
return signer.sign(
createdAt = 0L,
kind = 1,
tags = emptyArray(),
content = "hello",
)
}
@Test
fun defaultsToNoRelayHintForBackwardsCompat() =
runTest {
// Existing callers that don't pass a hint must continue to emit the
// historical ["p", recipientPubKey] two-element tag shape.
val wrap =
GiftWrapEvent.create(
event = innerEvent(),
recipientPubKey = recipient.pubKey.toHexKey(),
)
val pTag = wrap.tags.first { it.firstOrNull() == "p" }
assertEquals(2, pTag.size, "p tag must be 2 elements when no hint passed")
assertEquals(recipient.pubKey.toHexKey(), pTag[1])
}
@Test
fun relayHintLandsOnWrapPTagAsThirdElement() =
runTest {
// When a hint is passed, it must appear as the THIRD element of the
// wrap's p tag — NIP-17 spec. Not inside the encrypted seal.
val hint = NormalizedRelayUrl("wss://dm.relay.example/")
val wrap =
GiftWrapEvent.create(
event = innerEvent(),
recipientPubKey = recipient.pubKey.toHexKey(),
recipientRelayHint = hint,
)
val pTag = wrap.tags.first { it.firstOrNull() == "p" }
assertEquals(3, pTag.size, "p tag carries [tag, pubkey, relay-hint]")
assertEquals(recipient.pubKey.toHexKey(), pTag[1])
assertEquals(hint.url, pTag[2])
}
@Test
fun absentHintDoesNotAddTrailingEmptyElement() =
runTest {
// Defensive: a null hint must not produce `["p", pubkey, ""]` — that
// would be a leak (broadcasts the user has no canonical inbox) and
// a wire-format change from the historical shape.
val wrap =
GiftWrapEvent.create(
event = innerEvent(),
recipientPubKey = recipient.pubKey.toHexKey(),
recipientRelayHint = null,
)
val pTag = wrap.tags.first { it.firstOrNull() == "p" }
assertNull(pTag.getOrNull(2), "third element must be absent, not empty string")
}
}