From 94121c71c96060bf8971f88a67b621e6d3a9bef5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 00:10:10 +0000 Subject: [PATCH] feat(cashu): mint URL directory + autocomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../amethyst/model/LocalCache.kt | 64 ++++++++++ .../loggedIn/wallet/AddCashuWalletScreen.kt | 80 +++++++++++++ .../commons/cashu/MintDirectoryIndex.kt | 100 ++++++++++++++++ .../commons/cashu/MintDirectoryIndexTest.kt | 112 ++++++++++++++++++ 4 files changed, 356 insertions(+) create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/MintDirectoryIndex.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cashu/MintDirectoryIndexTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index f4dd274eff..a5bb966492 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddCashuWalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddCashuWalletScreen.kt index dd61bd882a..e0c14bc96a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddCashuWalletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddCashuWalletScreen.kt @@ -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() } 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, + 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), + ) + } + } + } + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/MintDirectoryIndex.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/MintDirectoryIndex.kt new file mode 100644 index 0000000000..6d61a538b0 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/MintDirectoryIndex.kt @@ -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() + + fun add(rawUrl: String) { + val key = normalize(rawUrl) ?: return + counts.merge(key, 1, Int::plus) + } + + fun addAll(rawUrls: Iterable) = 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 { + val needle = query.trim().lowercase() + val ranking = + compareByDescending> { 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('/') + } + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cashu/MintDirectoryIndexTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cashu/MintDirectoryIndexTest.kt new file mode 100644 index 0000000000..1f2b883206 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cashu/MintDirectoryIndexTest.kt @@ -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")) + } +}