feat(cashu): mint URL directory + autocomplete

Adds a cache-backed Cashu mint directory sibling to LocalCache.relayHints
that aggregates mint URLs from every relevant event the cache sees, and
wires it into the AddCashuWallet mint-URL text field as inline
autocomplete so users don't have to remember mint URLs.

What feeds the directory:
- NutzapInfoEvent (kind:10019) — every nostr user with a Cashu wallet
  publishes their accepted mints there. A typical inbox of cached
  profiles seeds a useful starter directory automatically.
- MintRecommendationEvent (kind:38000) — explicit public vouches.
- CashuMintEvent (kind:38172) — formal mint announcements from the
  NIP-87 directory subscription.

How it's populated:
- LocalCache.updateMintIndex(event) is called from
  justConsumeAndUpdateIndexes alongside updateHintIndexes, so every new
  event with a mint URL adds to the index. wasNew gating prevents
  re-emissions from inflating popularity counters.
- LocalCache.ensureMintDirectoryBackfilled() does a one-shot scan of the
  existing notes + addressables maps. The autocomplete UI kicks this in
  a LaunchedEffect on screen open so suggestions are useful before the
  next relay round-trip.

Where it surfaces today:
- AddCashuWalletScreen — under the mint-URL OutlinedTextField, a
  MintSuggestionList card shows up to 6 cache-derived suggestions ranked
  by popularity desc + URL asc. Tapping a row fills the field (does not
  auto-add — users typically want to Verify first). Filters out URLs the
  user already added and exact matches of what they typed.

The MintPicker dropdown inside the Receive / Send dialogs is unchanged
— those only need to choose between mints the user already has in their
wallet, so no directory autocomplete applies there.

Tests: 8 unit tests cover normalisation (case-insensitive, trailing-slash
stripping, http(s) gating), popularity ranking, substring filtering,
limit enforcement, and malformed-URL handling.

URL normalisation: trimmed, lower-cased, trailing `/` stripped, scheme
must be http(s). Same URL with different casing or trailing slash
collapses to one entry so popularity counts correctly.

Implementation notes:
- MintDirectoryIndex lives in commons/jvmAndroid (uses ConcurrentHashMap;
  iOS doesn't ship Cashu wallet yet).
- Thread-safe; safe to read from any dispatcher.
- No persistence — purely in-memory, accumulates over the session.
- Entries are never removed: stale entries don't hurt (user always
  verifies before adding), and tracking which event added which URL
  would add bookkeeping without UX benefit.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
This commit is contained in:
Claude
2026-05-27 15:17:42 +00:00
parent fa9c7503ed
commit 94121c71c9
4 changed files with 356 additions and 0 deletions
@@ -25,6 +25,7 @@ package com.vitorpamplona.amethyst.model
import android.util.LruCache
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.cashu.MintDirectoryIndex
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
@@ -338,6 +339,45 @@ object LocalCache : ILocalCache, ICacheProvider {
val relayHints = HintIndexer()
/**
* Cashu mint URL directory, populated passively as
* `NutzapInfoEvent` / `MintRecommendationEvent` / `CashuMintEvent`
* flow through `updateMintIndex`. Backs the autocomplete in any text
* field where the user types a mint URL.
*
* Use [mintDirectoryBackfilled] semantics via [ensureMintDirectoryBackfilled]
* when reading from the UI — the first call sweeps already-cached
* events so suggestions are useful before the next relay round-trip.
*/
val mintDirectory = MintDirectoryIndex()
@Volatile private var mintDirectoryBackfilled = false
private val mintDirectoryBackfillLock = Any()
/**
* Sweeps `notes` + `addressables` for any NIP-87 / NIP-61 event the
* cache already holds and feeds them into [mintDirectory]. The wallet
* directory + every other user's kind:10019 we've ever loaded may
* have arrived BEFORE [updateMintIndex] existed (or before any
* caller cared), so without this backfill the autocomplete is empty
* until new events arrive.
*
* Idempotent — subsequent calls return immediately. Best-effort: a
* scan failure is swallowed so the index stays usable even if the
* cache is in an unexpected state.
*/
fun ensureMintDirectoryBackfilled() {
if (mintDirectoryBackfilled) return
synchronized(mintDirectoryBackfillLock) {
if (mintDirectoryBackfilled) return
runCatching {
notes.forEach { _, note -> note.event?.let(::updateMintIndex) }
addressables.forEach { _, note -> note.event?.let(::updateMintIndex) }
}
mintDirectoryBackfilled = true
}
}
val deletionIndex = DeletionIndex()
/**
@@ -2922,6 +2962,7 @@ object LocalCache : ILocalCache, ICacheProvider {
if (wasNew) {
updateHintIndexes(event)
updateMintIndex(event)
}
if (relay != null) {
@@ -2985,6 +3026,29 @@ object LocalCache : ILocalCache, ICacheProvider {
}
}
/**
* Feeds the Cashu mint URL directory from every event that names a
* mint, regardless of which user authored it. Three sources today:
*
* - NutzapInfoEvent (kind:10019) — every nostr user with a Cashu
* wallet publishes their accepted mints here, so a typical inbox
* of cached profiles seeds a useful starter directory.
* - MintRecommendationEvent (kind:38000) — explicit public vouches.
* - CashuMintEvent (kind:38172) — formal mint announcements.
*
* Called only when the event is newly consumed (mirrors
* updateHintIndexes) so a re-emission of a cached event doesn't
* inflate the popularity counter.
*/
fun updateMintIndex(event: Event) {
when (event) {
is NutzapInfoEvent -> mintDirectory.addAll(event.mints().map { it.mintUrl })
is MintRecommendationEvent -> mintDirectory.addAll(event.mintUrls())
is CashuMintEvent -> event.mintUrl()?.let { mintDirectory.add(it) }
else -> Unit
}
}
@Suppress("DEPRECATION")
private fun justConsumeInnerInner(
event: Event,
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -31,6 +32,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
@@ -49,6 +51,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
@@ -62,6 +65,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@@ -83,6 +87,11 @@ fun AddCashuWalletScreen(
val mints = remember { mutableStateListOf<String>() }
var mintInput by remember { mutableStateOf("") }
// Kick the one-shot backfill so the mint-URL autocomplete has
// suggestions on first open instead of waiting for the next relay
// round-trip. Cheap (one sweep over the existing cache); idempotent.
LaunchedEffect(Unit) { LocalCache.ensureMintDirectoryBackfilled() }
var keyMode by remember {
mutableStateOf(
if (isEditMode) CashuWalletViewModel.P2pkKeyMode.KeepCurrent else CashuWalletViewModel.P2pkKeyMode.AutoGenerate,
@@ -229,6 +238,33 @@ fun AddCashuWalletScreen(
}
}
// Autocomplete from the cache-backed mint directory.
// Suggestions are filtered to URLs the user hasn't already
// added and that aren't an exact case-insensitive match for
// the current input (no point suggesting what they already
// typed). Wrapped in `derivedStateOf` so the recompute only
// fires when `mintInput` or `mints` change, not on every
// recomposition of the surrounding form.
val suggestions by remember(mints) {
derivedStateOf {
val typed = mintInput.trim().trimEnd('/').lowercase()
val alreadyAdded = mints.map { it.lowercase().trimEnd('/') }.toSet()
LocalCache.mintDirectory
.suggest(typed, limit = 6)
.filter { it != typed && it !in alreadyAdded }
}
}
if (suggestions.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
MintSuggestionList(
suggestions = suggestions,
onPick = { url ->
mintInput = url
viewModel.resetMintPing()
},
)
}
when (val ps = pingState) {
is MintPingState.Ok -> {
Spacer(modifier = Modifier.height(4.dp))
@@ -377,3 +413,47 @@ private fun P2pkRadio(
}
}
}
/**
* Cache-backed mint-URL autocomplete rendered inline under the mint
* input. Each row fills the text field on tap so the user can verify /
* add the same way they would with a hand-typed URL — we deliberately
* don't auto-add on tap because users often want to ping first.
*/
@Composable
private fun MintSuggestionList(
suggestions: List<String>,
onPick: (String) -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(modifier = Modifier.padding(vertical = 4.dp)) {
suggestions.forEach { url ->
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable { onPick(url) }
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = MaterialSymbols.AccountBalanceWallet,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = url,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
}
}
}
}
}
@@ -0,0 +1,100 @@
/*
* 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.cashu
import java.util.concurrent.ConcurrentHashMap
/**
* In-memory directory of Cashu mint URLs the app has seen on the network,
* sibling to `LocalCache.relayHints`. Used to offer autocomplete in any
* text field where the user types a mint URL — typing "min" suggests
* mints other nostr users have already published in a kind:10019
* (`NutzapInfoEvent`), kind:38000 (`MintRecommendationEvent`), or kind:38172
* (`CashuMintEvent`), so the user doesn't have to memorise URLs.
*
* Population is passive — `LocalCache.updateMintIndex(event)` adds entries
* as events flow through `justConsumeAndUpdateIndexes`. Entries are never
* removed: the index is a best-effort suggestion source, not authoritative,
* and a still-listed mint URL never hurts (the user can always tap Verify
* before adding it). Each call to [add] increments a counter that
* [suggest] uses to rank popular mints first.
*
* Normalisation: incoming URLs are trimmed, lower-cased, and the trailing
* `/` is stripped so `https://Mint.Example.com/` and `https://mint.example.com`
* collapse to the same entry. URLs that don't carry an `http://` or
* `https://` scheme are dropped — autocomplete on garbage tags would
* surface noise.
*
* Thread-safe via `ConcurrentHashMap`; safe to call from any dispatcher.
*/
class MintDirectoryIndex {
private val counts = ConcurrentHashMap<String, Int>()
fun add(rawUrl: String) {
val key = normalize(rawUrl) ?: return
counts.merge(key, 1, Int::plus)
}
fun addAll(rawUrls: Iterable<String>) = rawUrls.forEach(::add)
/**
* Up to [limit] mint URLs whose normalised form contains [query]
* (case-insensitive substring), ranked by occurrence count descending
* then by URL ascending. Empty [query] returns the most popular mints
* overall, capped at [limit].
*/
fun suggest(
query: String,
limit: Int = 8,
): List<String> {
val needle = query.trim().lowercase()
val ranking =
compareByDescending<Map.Entry<String, Int>> { it.value }
.thenBy { it.key }
return counts.entries
.asSequence()
.filter { needle.isEmpty() || it.key.contains(needle) }
.sortedWith(ranking)
.take(limit)
.map { it.key }
.toList()
}
/** Total number of unique mint URLs in the index. */
fun size(): Int = counts.size
/** Clears the index — for tests; production should let it accumulate. */
fun clear() = counts.clear()
companion object {
/**
* Returns the canonical lowercased URL with trailing `/` stripped,
* or `null` if the input doesn't look like an HTTP(S) URL.
*/
fun normalize(raw: String): String? {
val trimmed = raw.trim()
if (trimmed.isEmpty()) return null
val lower = trimmed.lowercase()
if (!lower.startsWith("https://") && !lower.startsWith("http://")) return null
return lower.trimEnd('/')
}
}
}
@@ -0,0 +1,112 @@
/*
* 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.cashu
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class MintDirectoryIndexTest {
@Test
fun `normalize lowercases, trims, strips trailing slash`() {
assertEquals("https://mint.example.com", MintDirectoryIndex.normalize(" https://Mint.Example.com/ "))
assertEquals("https://mint.example.com", MintDirectoryIndex.normalize("https://mint.example.com"))
assertEquals("http://localhost:3338", MintDirectoryIndex.normalize("http://localhost:3338/"))
}
@Test
fun `normalize rejects non-http urls`() {
assertNull(MintDirectoryIndex.normalize("mint.example.com"))
assertNull(MintDirectoryIndex.normalize("ftp://mint.example.com"))
assertNull(MintDirectoryIndex.normalize(""))
assertNull(MintDirectoryIndex.normalize(" "))
}
@Test
fun `add dedupes case-insensitively`() {
val idx = MintDirectoryIndex()
idx.add("https://mint.example.com")
idx.add("https://Mint.Example.com/")
idx.add("HTTPS://MINT.EXAMPLE.COM")
assertEquals(1, idx.size())
}
@Test
fun `suggest ranks more-popular mints first`() {
val idx = MintDirectoryIndex()
// mint.b seen 3 times, mint.a once, mint.c twice
repeat(3) { idx.add("https://mint.b.example.com") }
idx.add("https://mint.a.example.com")
repeat(2) { idx.add("https://mint.c.example.com") }
val all = idx.suggest("")
assertEquals(
listOf(
"https://mint.b.example.com",
"https://mint.c.example.com",
"https://mint.a.example.com",
),
all,
)
}
@Test
fun `suggest filters by substring case-insensitively`() {
val idx = MintDirectoryIndex()
idx.add("https://mint.example.com")
idx.add("https://nutmix.io")
idx.add("https://my-mint.org")
val hits = idx.suggest("MINT")
assertEquals(setOf("https://mint.example.com", "https://my-mint.org"), hits.toSet())
}
@Test
fun `suggest honors limit`() {
val idx = MintDirectoryIndex()
repeat(10) { i -> idx.add("https://mint-$i.example.com") }
assertEquals(3, idx.suggest("", limit = 3).size)
}
@Test
fun `empty query returns all mints sorted`() {
val idx = MintDirectoryIndex()
idx.add("https://b.example.com")
idx.add("https://a.example.com")
// Same count, so alphabetical fallback applies.
assertEquals(
listOf("https://a.example.com", "https://b.example.com"),
idx.suggest(""),
)
}
@Test
fun `add drops malformed urls silently`() {
val idx = MintDirectoryIndex()
idx.add("not-a-url")
idx.add("")
idx.add("ftp://example.com")
idx.add("https://valid.example.com")
assertEquals(1, idx.size())
assertTrue(idx.suggest("").contains("https://valid.example.com"))
}
}