From c2f6aa9992cd1427a589d83db95463945feddce2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 21:34:32 +0000 Subject: [PATCH 1/5] feat(nip47): add NWC-07 deep-link helper and NIP-44 request opt-in Add Nip47DeepLink for the NWC-07 same-device pairing convention: build/parse the `nostrnwc://connect` request (client -> wallet) and the callback URI that returns the `nostr+walletconnect://` pairing code (wallet -> client). All params are URI-encoded per the spec. Also thread `useNip44` through LnZapPaymentRequestEvent.create so pay_invoice requests can opt into NIP-44, matching createRequest. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RDAAS4ktFbWtRnEVXQsjfs --- .../nip47WalletConnect/Nip47DeepLink.kt | 119 ++++++++++++++++++ .../events/LnZapPaymentRequestEvent.kt | 2 + .../nip47WalletConnect/Nip47DeepLinkTest.kt | 114 +++++++++++++++++ 3 files changed, 235 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47DeepLink.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47DeepLinkTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47DeepLink.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47DeepLink.kt new file mode 100644 index 0000000000..4d1f26b026 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47DeepLink.kt @@ -0,0 +1,119 @@ +/* + * 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.nip47WalletConnect + +import com.vitorpamplona.quartz.utils.UriParser +import com.vitorpamplona.quartz.utils.UrlEncoder + +/** + * NWC-07 deep-link pairing conventions. + * + * These deep links let a NWC **client** (e.g. Amethyst) and a NWC **wallet** app + * installed on the *same device* pair without QR codes or manual copy/paste: + * + * 1. The client opens `nostrnwc://connect?appname=…&appicon=…&callback=…` (or the + * app-scoped `nostrnwc+{app}://connect` variant to target a specific wallet). + * 2. The wallet creates a connection and opens the client's `callback` URI with + * the resulting `nostr+walletconnect://…` pairing code in a `value` parameter. + * 3. The client parses `value` with [Nip47WalletConnect.parse] and stores it. + * + * The pairing code carried in `value` is exactly the connection string this module + * already understands — this deep link is only the transport used to obtain one. + * + * All URI parameters MUST be URI-encoded (NWC-07). + */ +object Nip47DeepLink { + const val SCHEME = "nostrnwc" + const val HOST = "connect" + + /** + * A parsed `nostrnwc://connect` request (wallet side). + */ + class ConnectRequest( + val callback: String, + val appName: String? = null, + val appIcon: String? = null, + ) + + /** + * Builds the outgoing deep link a NWC client opens to ask a wallet app on the + * same device to create a connection. + * + * @param callback the URI scheme the wallet must open to return the pairing code + * @param appName human-readable name of the requesting client + * @param appIcon URL of the requesting client's icon + * @param walletAppName optional wallet selector; when set, targets + * `nostrnwc+{walletAppName}://connect` instead of the generic + * `nostrnwc://connect`. + */ + fun buildConnectUri( + callback: String, + appName: String? = null, + appIcon: String? = null, + walletAppName: String? = null, + ): String { + val scheme = if (walletAppName.isNullOrBlank()) SCHEME else "$SCHEME+$walletAppName" + val params = + buildList { + appName?.let { add("appname=" + UrlEncoder.encode(it)) } + appIcon?.let { add("appicon=" + UrlEncoder.encode(it)) } + add("callback=" + UrlEncoder.encode(callback)) + } + return "$scheme://$HOST?" + params.joinToString("&") + } + + /** + * Parses an incoming `nostrnwc://connect` (or `nostrnwc+{app}://connect`) + * request. Returns null when the URI is not a NWC connect deep link or has no + * callback. + */ + fun parseConnectUri(uri: String): ConnectRequest? { + val parser = UriParser(uri) + val scheme = parser.scheme() ?: return null + if (scheme != SCHEME && !scheme.startsWith("$SCHEME+")) return null + + val callback = parser.getQueryParameter("callback")?.firstOrNull() ?: return null + return ConnectRequest( + callback = callback, + appName = parser.getQueryParameter("appname")?.firstOrNull(), + appIcon = parser.getQueryParameter("appicon")?.firstOrNull(), + ) + } + + /** + * Builds the callback URI a wallet opens to return a pairing code to the + * client (wallet side). The pairing code is placed in a `value` parameter. + */ + fun buildCallbackUri( + callback: String, + pairingCode: String, + ): String { + val separator = if (callback.contains('?')) '&' else '?' + return callback + separator + "value=" + UrlEncoder.encode(pairingCode) + } + + /** + * Extracts the `nostr+walletconnect://…` pairing code returned by a wallet in + * a callback deep link (client side). Returns null when the `value` parameter + * is absent. + */ + fun parseCallbackValue(uri: String): String? = UriParser(uri).getQueryParameter("value")?.firstOrNull() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/LnZapPaymentRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/LnZapPaymentRequestEvent.kt index e9de87634e..010ebbef74 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/LnZapPaymentRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/LnZapPaymentRequestEvent.kt @@ -63,12 +63,14 @@ class LnZapPaymentRequestEvent( walletServicePubkey: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), + useNip44: Boolean = false, ): LnZapPaymentRequestEvent = createRequest( PayInvoiceMethod.create(lnInvoice), walletServicePubkey, signer, createdAt, + useNip44, ) suspend fun createRequest( diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47DeepLinkTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47DeepLinkTest.kt new file mode 100644 index 0000000000..b0f1c773de --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47DeepLinkTest.kt @@ -0,0 +1,114 @@ +/* + * 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.nip47WalletConnect + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class Nip47DeepLinkTest { + private val nwcUri = + "nostr+walletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4" + + "?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100571c5" + + @Test + fun testBuildConnectUri() { + val uri = + Nip47DeepLink.buildConnectUri( + callback = "amethystnwc://callback", + appName = "Amethyst", + appIcon = "https://amethyst.social/icon.png", + ) + + assertTrue(uri.startsWith("nostrnwc://connect?")) + // All params URI-encoded. + assertTrue(uri.contains("appname=Amethyst")) + assertTrue(uri.contains("appicon=https%3A%2F%2Famethyst.social%2Ficon.png")) + assertTrue(uri.contains("callback=amethystnwc%3A%2F%2Fcallback")) + } + + @Test + fun testBuildConnectUriWithWalletSelector() { + val uri = + Nip47DeepLink.buildConnectUri( + callback = "amethystnwc://callback", + appName = "Amethyst", + walletAppName = "alby", + ) + + assertTrue(uri.startsWith("nostrnwc+alby://connect?")) + } + + @Test + fun testConnectUriRoundTrip() { + val uri = + Nip47DeepLink.buildConnectUri( + callback = "amethystnwc://callback", + appName = "Amethyst", + appIcon = "https://amethyst.social/icon.png", + ) + + val parsed = Nip47DeepLink.parseConnectUri(uri) + assertNotNull(parsed) + assertEquals("amethystnwc://callback", parsed.callback) + assertEquals("Amethyst", parsed.appName) + assertEquals("https://amethyst.social/icon.png", parsed.appIcon) + } + + @Test + fun testParseConnectUriRejectsNonNwcScheme() { + assertNull(Nip47DeepLink.parseConnectUri("https://example.com/connect?callback=x")) + } + + @Test + fun testParseConnectUriRequiresCallback() { + assertNull(Nip47DeepLink.parseConnectUri("nostrnwc://connect?appname=Amethyst")) + } + + @Test + fun testCallbackRoundTrip() { + val callbackUri = Nip47DeepLink.buildCallbackUri("amethystnwc://callback", nwcUri) + + // The pairing code must be URI-encoded inside the value param. + assertTrue(callbackUri.contains("value=nostr%2Bwalletconnect")) + + val value = Nip47DeepLink.parseCallbackValue(callbackUri) + assertEquals(nwcUri, value) + + // And the returned value parses as a normal NWC connection URI. + val config = Nip47WalletConnect.parse(value!!) + assertEquals("b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4", config.pubKeyHex) + } + + @Test + fun testBuildCallbackUriWhenCallbackAlreadyHasQuery() { + val callbackUri = Nip47DeepLink.buildCallbackUri("myapp://cb?foo=bar", nwcUri) + assertTrue(callbackUri.contains("myapp://cb?foo=bar&value=")) + assertEquals(nwcUri, Nip47DeepLink.parseCallbackValue(callbackUri)) + } + + @Test + fun testParseCallbackValueAbsent() { + assertNull(Nip47DeepLink.parseCallbackValue("amethystnwc://callback")) + } +} From cd82d7ffff27b2fcc6277b6b7300a403e49ad8d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 21:34:55 +0000 Subject: [PATCH 2/5] feat(wallet): prefer NIP-44 for NWC and notify non-zap payments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NIP-44 encryption preference: NwcSignerState now fetches each wallet's kind 13194 info event (via the relay client, injected by Account), caches whether it advertises `nip44_v2`, and sends pay/RPC requests with NIP-44 when supported — satisfying NIP-47's "client should always prefer nip44 if supported by the wallet service". Falls back to NIP-04 (the legacy default) when unknown or unsupported; response decryption already auto-detects the scheme. Non-zap payment notifications: NwcPaymentNotificationWatcher keeps a standing subscription to each connected wallet's NIP-47 notification stream (kind 23197/23196) on the wallet relay, decrypts payment_received events, and posts a tray notification on a new Payments Received channel. Payments carrying a NIP-57 zap request are skipped, since those already surface through the kind-9735 ZapNotification path — so only plain, non-zap incoming payments are announced. Deep-link pairing: the "Connect wallet via app" button now builds its `nostrnwc://connect` URI through the tested quartz Nip47DeepLink helper instead of a hardcoded percent-encoded string. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RDAAS4ktFbWtRnEVXQsjfs --- .../com/vitorpamplona/amethyst/AppModules.kt | 11 ++ .../vitorpamplona/amethyst/model/Account.kt | 18 ++- .../nip47WalletConnect/NwcSignerState.kt | 64 ++++++++- .../notifications/NotificationCategory.kt | 13 ++ .../NwcPaymentNotificationWatcher.kt | 128 ++++++++++++++++++ .../renderers/NwcPaymentNotifier.kt | 78 +++++++++++ .../loggedIn/wallet/AddNwcWalletScreen.kt | 7 +- amethyst/src/main/res/values/strings.xml | 6 + 8 files changed, 321 insertions(+), 4 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NwcPaymentNotificationWatcher.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/NwcPaymentNotifier.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index dee33b33c8..b799d3b5ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -71,6 +71,7 @@ import com.vitorpamplona.amethyst.service.images.ThumbnailDiskCache import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.notifications.AlwaysOnNotificationServiceManager import com.vitorpamplona.amethyst.service.notifications.NotificationDispatcher +import com.vitorpamplona.amethyst.service.notifications.NwcPaymentNotificationWatcher import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManagerForRelays @@ -882,6 +883,16 @@ class AppModules( scope = applicationIOScope, ) + // Surfaces non-zap Lightning payments reported by the logged-in account's NWC + // wallet(s) as tray notifications (zaps are already shown via ZapNotification). + val nwcPaymentNotificationWatcher = + NwcPaymentNotificationWatcher( + context = appContext, + client = client, + scope = applicationIOScope, + accountFlow = sessionManager.accountContent.map { (it as? AccountState.LoggedIn)?.account }, + ).also { it.start() } + fun subscribedFlow( address: Address, account: Account, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index a5102f3d39..d591c36714 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -291,6 +291,7 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftEventCache import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent @@ -470,7 +471,22 @@ class Account( // account never surfaces under another (the old cache was a process-wide singleton). val relayNotifications = NotifyRequestsCache() - override val nip47SignerState = NwcSignerState(signer, nwcFilterAssembler, cache, scope, settings) + override val nip47SignerState = + NwcSignerState( + signer, + nwcFilterAssembler, + cache, + scope, + settings, + fetchInfoEvent = { uri -> + // Fetch the wallet's kind 13194 info event so NwcSignerState can prefer + // NIP-44 when the wallet advertises it (NIP-47 encryption negotiation). + client.fetchFirst( + uri.relayUri, + Filter(kinds = listOf(NwcInfoEvent.KIND), authors = listOf(uri.pubKeyHex), limit = 1), + ) as? NwcInfoEvent + }, + ) val nip65RelayList = Nip65RelayListState(signer, cache, scope, settings) val localRelayList = LocalRelayListState(signer, cache, scope, settings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt index 8b06eee55b..e11405c3dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt @@ -37,18 +37,24 @@ import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectReque import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectResponseCache import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap /** * Manages NIP-47 (Nostr Wallet Connect) related signing operations and decryption cache for a given account. @@ -60,6 +66,12 @@ class NwcSignerState( val cache: LocalCache, val scope: CoroutineScope, val settings: AccountSettings, + /** + * Fetches a wallet's kind 13194 info event so we can negotiate encryption. + * Injected by [com.vitorpamplona.amethyst.model.Account] (which owns the relay + * client). Null in tests / when unavailable — requests then fall back to NIP-04. + */ + val fetchInfoEvent: (suspend (Nip47WalletConnect.Nip47URINorm) -> NwcInfoEvent?)? = null, ) : INwcSignerState { /** * Flow of the default wallet's NWC URI, derived from multi-wallet settings. @@ -105,6 +117,54 @@ class NwcSignerState( NostrSignerInternal(KeyPair(it)) } + /** + * Per-wallet (keyed by wallet service pubkey) cache of whether the wallet + * advertises NIP-44 (`nip44_v2`) support in its kind 13194 info event. + * NIP-47 says a client "should always prefer nip44 if supported by the wallet + * service"; absent/unknown means we keep the NIP-04 legacy default. + */ + private val nip44SupportByWallet = ConcurrentHashMap() + + init { + // Warm the encryption preference in the background whenever the default + // wallet changes so the payment hot path can read it without blocking. + scope.launch(Dispatchers.IO) { + defaultWalletUri + .filterNotNull() + .distinctUntilChanged { a, b -> a.pubKeyHex == b.pubKeyHex && a.relayUri == b.relayUri } + .collectLatest { warmEncryptionPreference(it) } + } + } + + /** + * Fetches the wallet's info event once and records whether it supports NIP-44. + * Best-effort: any failure leaves the wallet on the NIP-04 fallback. + */ + private suspend fun warmEncryptionPreference(uri: Nip47WalletConnect.Nip47URINorm) { + val fetch = fetchInfoEvent ?: return + if (nip44SupportByWallet.containsKey(uri.pubKeyHex)) return + + val supports = + try { + fetch(uri)?.encryptionSchemes()?.any { it.equals("nip44_v2", ignoreCase = true) } == true + } catch (e: Exception) { + if (e is CancellationException) throw e + false + } + + nip44SupportByWallet[uri.pubKeyHex] = supports + } + + /** + * Non-blocking read of the negotiated encryption preference for a wallet. + * Returns true only once the info event has been fetched and advertised + * `nip44_v2`; otherwise NIP-04 (the legacy default). + */ + private fun prefersNip44(uri: Nip47WalletConnect.Nip47URINorm?): Boolean { + uri ?: return false + return nip44SupportByWallet[uri.pubKeyHex] ?: false + } + fun hasWalletConnectSetup(): Boolean = settings.nwcWallets.value.isNotEmpty() override fun isNIP47Author(pubKey: HexKey?): Boolean = nip47Signer.value.pubKey == pubKey @@ -138,7 +198,7 @@ class NwcSignerState( val walletService = walletUri ?: throw IllegalArgumentException("No NIP47 setup") val walletSigner = buildSigner(walletService) ?: signer - val event = LnZapPaymentRequestEvent.createRequest(request, walletService.pubKeyHex, walletSigner) + val event = LnZapPaymentRequestEvent.createRequest(request, walletService.pubKeyHex, walletSigner, useNip44 = prefersNip44(walletService)) val filter = NWCPaymentQueryState( @@ -184,7 +244,7 @@ class NwcSignerState( ): Pair { val walletService = defaultWalletUri.value ?: throw IllegalArgumentException("No NIP47 setup") - val event = LnZapPaymentRequestEvent.create(bolt11, walletService.pubKeyHex, nip47Signer.value) + val event = LnZapPaymentRequestEvent.create(bolt11, walletService.pubKeyHex, nip47Signer.value, useNip44 = prefersNip44(walletService)) val filter = NWCPaymentQueryState( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationCategory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationCategory.kt index 83c09331d3..e7b476fc49 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationCategory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationCategory.kt @@ -226,6 +226,19 @@ enum class NotificationCategory( group = "com.vitorpamplona.amethyst.CHESS_NOTIFICATION", summaryId = 0x30000, ), + PAYMENT_RECEIVED( + channelIdRes = R.string.app_notification_payments_channel_id, + channelNameRes = R.string.app_notification_payments_channel_name, + channelDescriptionRes = R.string.app_notification_payments_channel_description, + summaryTextRes = R.string.app_notification_payments_summary, + importance = NotificationManager.IMPORTANCE_DEFAULT, + color = 0xFF16B979.toInt(), // lightning green — distinct from the gold zap channel + smallIcon = R.drawable.ic_notif_zap, + settingsIcon = MaterialSymbols.AccountBalanceWallet, + channelGroup = NotifChannelGroup.PAYMENTS, + group = "com.vitorpamplona.amethyst.PAYMENT_RECEIVED_NOTIFICATION", + summaryId = 0xC0000, + ), ; fun channelId(context: Context): String = stringRes(context, channelIdRes) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NwcPaymentNotificationWatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NwcPaymentNotificationWatcher.kt new file mode 100644 index 0000000000..4467af7d04 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NwcPaymentNotificationWatcher.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.notifications + +import android.content.Context +import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.notifications.renderers.NwcPaymentNotifier +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch + +/** + * Always-on watcher that turns NIP-47 wallet notifications (kind 23197/23196) into + * user-facing tray notifications for the logged-in account. + * + * For each configured NWC wallet it opens a standing subscription on that wallet's + * own relay, filtered to notifications `p`-tagged to our client pubkey. Each event + * is decrypted with the per-wallet connection secret and, when it is an incoming + * `payment_received`, posted via [NwcPaymentNotifier] — **unless** the transaction + * metadata carries a NIP-57 zap request, in which case it is dropped: zaps already + * surface through the kind-9735 `ZapNotification` path, so notifying here would + * double up. + * + * Only new notifications (`since` = watch start) are surfaced, so relaunching the + * app never replays old payments as fresh alerts; a per-subscription id set + * de-duplicates re-delivery across relay reconnects. + */ +class NwcPaymentNotificationWatcher( + private val context: Context, + private val client: INostrClient, + private val scope: CoroutineScope, + private val accountFlow: Flow, +) { + fun start() { + scope.launch(Dispatchers.IO) { + accountFlow + .distinctUntilChanged { a, b -> a?.signer?.pubKey == b?.signer?.pubKey } + .collectLatest { account -> + account ?: return@collectLatest + account.settings.nwcWallets.collectLatest { wallets -> + watchWallets(account, wallets) + } + } + } + } + + private suspend fun watchWallets( + account: Account, + wallets: List, + ) = coroutineScope { + wallets.forEach { wallet -> + val signer = account.nip47SignerState.buildSigner(wallet.uri) ?: return@forEach + + val filter = + Filter( + kinds = listOf(NwcNotificationEvent.KIND, NwcNotificationEvent.LEGACY_KIND), + authors = listOf(wallet.uri.pubKeyHex), + tags = mapOf("p" to listOf(signer.pubKey)), + since = TimeUtils.now(), + ) + + launch(Dispatchers.IO) { + val seen = HashSet() + client.subscribeAsFlow(wallet.uri.relayUri, filter).collect { events -> + events.forEach { event -> + val notification = event as? NwcNotificationEvent ?: return@forEach + if (seen.add(notification.id)) { + handle(account, notification, signer) + } + } + } + } + } + } + + private suspend fun handle( + account: Account, + event: NwcNotificationEvent, + signer: NostrSigner, + ) { + val notification = + try { + event.decryptNotification(signer) + } catch (e: Exception) { + if (e is CancellationException) throw e + return + } + + val tx = (notification as? PaymentReceivedNotification)?.notification ?: return + + // A payment carrying a NIP-57 zap request is already shown by ZapNotification. + if (tx.parsedMetadata()?.nostr != null) return + + NwcPaymentNotifier.notify(context, account, tx) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/NwcPaymentNotifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/NwcPaymentNotifier.kt new file mode 100644 index 0000000000..215417f7ac --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/NwcPaymentNotifier.kt @@ -0,0 +1,78 @@ +/* + * 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.service.notifications.renderers + +import android.content.Context +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.notifications.NotificationCategory +import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard +import com.vitorpamplona.amethyst.service.notifications.notificationManager +import com.vitorpamplona.amethyst.ui.note.showAmount +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Posts a tray notification for an incoming Lightning payment reported by the + * connected NWC wallet (NIP-47 `payment_received`). Renders on the green Payments + * channel with a wallet icon; the title leads with the amount. + * + * Zaps are intentionally NOT routed here — a `payment_received` whose transaction + * metadata carries a NIP-57 zap request is filtered out upstream by + * [com.vitorpamplona.amethyst.service.notifications.NwcPaymentNotificationWatcher], + * because those already surface through the kind-9735 [ZapNotification] path. + */ +object NwcPaymentNotifier { + suspend fun notify( + context: Context, + account: Account, + tx: NwcTransaction, + ) { + val nm = context.notificationManager() + if (!nm.areNotificationsEnabled()) return + + val msats = tx.amount ?: return + val amount = showAmount((msats / 1000L).toBigDecimal()) + + val id = tx.payment_hash ?: tx.invoice ?: tx.created_at?.toString() ?: return + val time = tx.settled_at ?: tx.created_at ?: TimeUtils.now() + + val title = stringRes(context, R.string.app_notification_payments_channel_message, amount) + val comment = (tx.parsedMetadata()?.comment ?: tx.description)?.ifBlank { null } + val body = comment ?: title + + val accountNpub = NotificationRoutes.accountNpub(account) + val uri = NotificationRoutes.notificationsUri(accountNpub, id) + + nm.postStandard( + category = NotificationCategory.PAYMENT_RECEIVED, + id = id, + messageTitle = title, + messageBody = body, + time = time, + pictureUrl = null, + uri = uri, + applicationContext = context, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddNwcWalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddNwcWalletScreen.kt index 73004d6428..57957d568e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddNwcWalletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddNwcWalletScreen.kt @@ -65,6 +65,7 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.Size24Modifier +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47DeepLink import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch @@ -137,7 +138,11 @@ fun AddNwcWalletScreen( onClick = { try { uri.openUri( - "nostrnwc://connect?appname=Amethyst&appicon=https%3A%2F%2Fraw.githubusercontent.com%2Fvitorpamplona%2Famethyst%2Frefs%2Fheads%2Fmain%2Ficon.png&callback=amethyst%2Bwalletconnect%3A%2F%2Fdlnwc", + Nip47DeepLink.buildConnectUri( + callback = "amethyst+walletconnect://dlnwc", + appName = "Amethyst", + appIcon = "https://raw.githubusercontent.com/vitorpamplona/amethyst/refs/heads/main/icon.png", + ), ) } catch (_: IllegalArgumentException) { accountViewModel.toastManager.toast( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 2052e7b286..a6cfcf2618 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1858,6 +1858,12 @@ From %1$s for %1$s + PaymentsReceivedID + Payments Received + Notifies you when your connected wallet receives a payment that is not a Nostr zap + Received %1$s sats + New payments + Reply Mark Read Me From a4329912aa5191161b142e3ae75e8aeba7674b58 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 22:19:17 +0000 Subject: [PATCH 3/5] refactor(wallet): share a TTL'd NWC info-event cache across features Replace the single-purpose per-wallet "supports nip44" boolean with a shared NwcInfoCache that stores each wallet's full kind 13194 info event (capabilities + encryption schemes + notification support), keyed by wallet pubkey and owned by Account. - Entries expire after 2 days so a wallet that changes its advertised capabilities is eventually re-checked. Reads never block: the payment path reads the cached value and nudges a background refresh when the entry is missing or stale (self-healing without holding up the tx); failed fetches are not cached, so a transient error retries next use. - NwcSignerState derives the NIP-44 preference from the cache. - NwcPaymentNotificationWatcher now consults supportsNotifications() and skips opening a relay subscription for wallets that advertise none (fail-open when the info event is unknown). Adds NwcInfoCacheTest covering caching, TTL expiry, no-cache-on-failure, and definitive-missing-info caching (injectable clock). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RDAAS4ktFbWtRnEVXQsjfs --- .../vitorpamplona/amethyst/model/Account.kt | 20 +-- .../model/nip47WalletConnect/NwcInfoCache.kt | 121 +++++++++++++++ .../nip47WalletConnect/NwcSignerState.kt | 57 ++----- .../NwcPaymentNotificationWatcher.kt | 22 ++- .../nip47WalletConnect/NwcInfoCacheTest.kt | 145 ++++++++++++++++++ 5 files changed, 306 insertions(+), 59 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcInfoCache.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcInfoCacheTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index d591c36714..3c6ff2259e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -99,6 +99,7 @@ import com.vitorpamplona.amethyst.model.nip17Dms.DmInboxRelayState import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPacksState import com.vitorpamplona.amethyst.model.nip46Signer.Nip46SignerState +import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcInfoCache import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState import com.vitorpamplona.amethyst.model.nip51Lists.GitRepositoryListState @@ -471,23 +472,22 @@ class Account( // account never surfaces under another (the old cache was a process-wide singleton). val relayNotifications = NotifyRequestsCache() - override val nip47SignerState = - NwcSignerState( - signer, - nwcFilterAssembler, - cache, - scope, - settings, - fetchInfoEvent = { uri -> - // Fetch the wallet's kind 13194 info event so NwcSignerState can prefer - // NIP-44 when the wallet advertises it (NIP-47 encryption negotiation). + // Shared cache of connected wallets' kind 13194 info events (capabilities + + // encryption + notification support). Backs NIP-44 negotiation in + // NwcSignerState and notification gating in NwcPaymentNotificationWatcher. + val nwcInfoCache = + NwcInfoCache( + fetch = { uri -> client.fetchFirst( uri.relayUri, Filter(kinds = listOf(NwcInfoEvent.KIND), authors = listOf(uri.pubKeyHex), limit = 1), ) as? NwcInfoEvent }, + scope = scope, ) + override val nip47SignerState = NwcSignerState(signer, nwcFilterAssembler, cache, scope, settings, nwcInfoCache) + val nip65RelayList = Nip65RelayListState(signer, cache, scope, settings) val localRelayList = LocalRelayListState(signer, cache, scope, settings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcInfoCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcInfoCache.kt new file mode 100644 index 0000000000..d3cfd355c9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcInfoCache.kt @@ -0,0 +1,121 @@ +/* + * 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.model.nip47WalletConnect + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap + +/** + * Per-account cache of NWC wallets' kind 13194 info events, keyed by wallet + * service pubkey. One fetch backs every capability question we ask about a + * wallet — the advertised encryption schemes (NIP-44 vs NIP-04 negotiation), the + * supported RPC methods, and whether it emits notifications. + * + * Entries expire after [ttlSeconds] (default 2 days) so a wallet that later + * changes its advertised capabilities is eventually re-checked. Reads never block + * on the network: + * + * - [current] returns whatever is cached (possibly stale, possibly null) with no + * side effect — for the payment hot path. + * - [refreshIfStale] triggers a background fetch when the entry is missing or + * expired, and returns immediately — call it right before using a wallet so a + * stale entry self-heals without holding up the transaction. + * - [getFresh] is the suspending variant for callers that can await (e.g. the + * notification watcher deciding whether to open a subscription). + * + * A completed fetch — including a definitive "wallet published no info event" + * (null) — is cached with a timestamp. A *failed* fetch (network error/timeout) + * is never cached, so a transient error retries on the next use instead of + * pinning the wallet to the fallback for the whole TTL window. + */ +class NwcInfoCache( + private val fetch: suspend (Nip47WalletConnect.Nip47URINorm) -> NwcInfoEvent?, + private val scope: CoroutineScope, + private val ttlSeconds: Long = DEFAULT_TTL_SECONDS, + private val now: () -> Long = { TimeUtils.now() }, +) { + private class Entry( + val info: NwcInfoEvent?, + val fetchedAt: Long, + ) + + private val cache = ConcurrentHashMap() + private val inFlight = ConcurrentHashMap.newKeySet() + + private fun isFresh(entry: Entry): Boolean = now() - entry.fetchedAt < ttlSeconds + + /** Non-blocking read of the currently cached info event (may be stale or null). */ + fun current(uri: Nip47WalletConnect.Nip47URINorm): NwcInfoEvent? = cache[uri.pubKeyHex]?.info + + /** + * Non-blocking. Kicks off a background fetch when the wallet's entry is missing + * or expired; a fetch already running for that wallet is not duplicated. Safe + * to call on the hot path — it never suspends. + */ + fun refreshIfStale(uri: Nip47WalletConnect.Nip47URINorm) { + val entry = cache[uri.pubKeyHex] + if (entry != null && isFresh(entry)) return + if (!inFlight.add(uri.pubKeyHex)) return + + scope.launch(Dispatchers.IO) { + try { + fetchAndStore(uri) + } finally { + inFlight.remove(uri.pubKeyHex) + } + } + } + + /** + * Suspends until a fresh-enough info event is available, fetching when the + * entry is missing or expired. Returns the last cached (possibly stale) value + * if the fetch fails. + */ + suspend fun getFresh(uri: Nip47WalletConnect.Nip47URINorm): NwcInfoEvent? { + val entry = cache[uri.pubKeyHex] + if (entry != null && isFresh(entry)) return entry.info + return fetchAndStore(uri) + } + + private suspend fun fetchAndStore(uri: Nip47WalletConnect.Nip47URINorm): NwcInfoEvent? { + val info = + try { + fetch(uri) + } catch (e: Exception) { + if (e is CancellationException) throw e + return cache[uri.pubKeyHex]?.info // keep the old value; retry on next use + } + + cache[uri.pubKeyHex] = Entry(info, now()) + return info + } + + companion object { + const val DEFAULT_TTL_SECONDS = 2L * 24 * 60 * 60 // 2 days + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt index e11405c3dc..15f9fdedbc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt @@ -37,16 +37,14 @@ import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectReque import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectResponseCache import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent -import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull @@ -54,7 +52,6 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import java.util.concurrent.ConcurrentHashMap /** * Manages NIP-47 (Nostr Wallet Connect) related signing operations and decryption cache for a given account. @@ -67,11 +64,12 @@ class NwcSignerState( val scope: CoroutineScope, val settings: AccountSettings, /** - * Fetches a wallet's kind 13194 info event so we can negotiate encryption. - * Injected by [com.vitorpamplona.amethyst.model.Account] (which owns the relay - * client). Null in tests / when unavailable — requests then fall back to NIP-04. + * Shared cache of wallets' kind 13194 info events, used here to negotiate + * encryption. Injected by [com.vitorpamplona.amethyst.model.Account] (which + * owns the relay client). Null in tests / when unavailable — requests then + * fall back to NIP-04. */ - val fetchInfoEvent: (suspend (Nip47WalletConnect.Nip47URINorm) -> NwcInfoEvent?)? = null, + val infoCache: NwcInfoCache? = null, ) : INwcSignerState { /** * Flow of the default wallet's NWC URI, derived from multi-wallet settings. @@ -117,52 +115,29 @@ class NwcSignerState( NostrSignerInternal(KeyPair(it)) } - /** - * Per-wallet (keyed by wallet service pubkey) cache of whether the wallet - * advertises NIP-44 (`nip44_v2`) support in its kind 13194 info event. - * NIP-47 says a client "should always prefer nip44 if supported by the wallet - * service"; absent/unknown means we keep the NIP-04 legacy default. - */ - private val nip44SupportByWallet = ConcurrentHashMap() - init { - // Warm the encryption preference in the background whenever the default - // wallet changes so the payment hot path can read it without blocking. + // Warm the info cache in the background whenever the default wallet changes + // so the payment hot path can read the encryption preference without waiting. scope.launch(Dispatchers.IO) { defaultWalletUri .filterNotNull() .distinctUntilChanged { a, b -> a.pubKeyHex == b.pubKeyHex && a.relayUri == b.relayUri } - .collectLatest { warmEncryptionPreference(it) } + .collect { infoCache?.refreshIfStale(it) } } } - /** - * Fetches the wallet's info event once and records whether it supports NIP-44. - * Best-effort: any failure leaves the wallet on the NIP-04 fallback. - */ - private suspend fun warmEncryptionPreference(uri: Nip47WalletConnect.Nip47URINorm) { - val fetch = fetchInfoEvent ?: return - if (nip44SupportByWallet.containsKey(uri.pubKeyHex)) return - - val supports = - try { - fetch(uri)?.encryptionSchemes()?.any { it.equals("nip44_v2", ignoreCase = true) } == true - } catch (e: Exception) { - if (e is CancellationException) throw e - false - } - - nip44SupportByWallet[uri.pubKeyHex] = supports - } - /** * Non-blocking read of the negotiated encryption preference for a wallet. - * Returns true only once the info event has been fetched and advertised - * `nip44_v2`; otherwise NIP-04 (the legacy default). + * NIP-47 says a client "should always prefer nip44 if supported by the wallet + * service". Returns true only when the cached info event advertises `nip44_v2`; + * otherwise NIP-04 (the legacy default). Also nudges a background refresh so a + * stale/expired entry self-heals for the next transaction without blocking this + * one. */ private fun prefersNip44(uri: Nip47WalletConnect.Nip47URINorm?): Boolean { uri ?: return false - return nip44SupportByWallet[uri.pubKeyHex] ?: false + infoCache?.refreshIfStale(uri) + return infoCache?.current(uri)?.encryptionSchemes()?.any { it.equals("nip44_v2", ignoreCase = true) } ?: false } fun hasWalletConnectSetup(): Boolean = settings.nwcWallets.value.isNotEmpty() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NwcPaymentNotificationWatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NwcPaymentNotificationWatcher.kt index 4467af7d04..0b92166271 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NwcPaymentNotificationWatcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NwcPaymentNotificationWatcher.kt @@ -83,15 +83,21 @@ class NwcPaymentNotificationWatcher( wallets.forEach { wallet -> val signer = account.nip47SignerState.buildSigner(wallet.uri) ?: return@forEach - val filter = - Filter( - kinds = listOf(NwcNotificationEvent.KIND, NwcNotificationEvent.LEGACY_KIND), - authors = listOf(wallet.uri.pubKeyHex), - tags = mapOf("p" to listOf(signer.pubKey)), - since = TimeUtils.now(), - ) - launch(Dispatchers.IO) { + // Skip wallets that explicitly advertise no notification support, so we + // don't hold open a relay connection that will never deliver. Fail open + // when the info event is unknown (null) — better to listen than miss. + val info = account.nwcInfoCache.getFresh(wallet.uri) + if (info != null && !info.supportsNotifications()) return@launch + + val filter = + Filter( + kinds = listOf(NwcNotificationEvent.KIND, NwcNotificationEvent.LEGACY_KIND), + authors = listOf(wallet.uri.pubKeyHex), + tags = mapOf("p" to listOf(signer.pubKey)), + since = TimeUtils.now(), + ) + val seen = HashSet() client.subscribeAsFlow(wallet.uri.relayUri, filter).collect { events -> events.forEach { event -> diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcInfoCacheTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcInfoCacheTest.kt new file mode 100644 index 0000000000..c74f7e02f2 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcInfoCacheTest.kt @@ -0,0 +1,145 @@ +/* + * 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.model.nip47WalletConnect + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class NwcInfoCacheTest { + private var clock = 1_000L + private val scope = CoroutineScope(Dispatchers.Unconfined) + private val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!! + + private fun uri(pubkey: String) = Nip47WalletConnect.Nip47URINorm(pubkey, relay, "secret") + + private fun info(content: String) = NwcInfoEvent("id", "pub", 0L, arrayOf(arrayOf("encryption", "nip44_v2")), content, "sig") + + @Test + fun cachesWithinTtl() = + runBlocking { + var calls = 0 + val cache = + NwcInfoCache( + fetch = { + calls++ + info("pay_invoice notifications") + }, + scope = scope, + ttlSeconds = 100, + now = { clock }, + ) + + val a = cache.getFresh(uri("wallet1")) + val b = cache.getFresh(uri("wallet1")) + + assertEquals(1, calls) + assertNotNull(a) + assertNotNull(b) + } + + @Test + fun refetchesAfterTtlExpires() = + runBlocking { + var calls = 0 + val cache = + NwcInfoCache( + fetch = { + calls++ + info("pay_invoice") + }, + scope = scope, + ttlSeconds = 100, + now = { clock }, + ) + + cache.getFresh(uri("wallet1")) + clock += 101 // advance past the TTL window + cache.getFresh(uri("wallet1")) + + assertEquals(2, calls) + } + + @Test + fun doesNotCacheFailures() = + runBlocking { + var calls = 0 + val cache = + NwcInfoCache( + fetch = { + calls++ + if (calls == 1) throw RuntimeException("boom") else info("pay_invoice") + }, + scope = scope, + ttlSeconds = 100, + now = { clock }, + ) + + // First fetch throws -> returns the (absent) prior value and is NOT cached. + assertNull(cache.getFresh(uri("wallet1"))) + // Second call retries instead of being pinned to the failure. + assertNotNull(cache.getFresh(uri("wallet1"))) + assertEquals(2, calls) + } + + @Test + fun cachesDefinitiveMissingInfo() = + runBlocking { + var calls = 0 + val cache = + NwcInfoCache( + fetch = { + calls++ + null // wallet published no info event + }, + scope = scope, + ttlSeconds = 100, + now = { clock }, + ) + + assertNull(cache.getFresh(uri("wallet1"))) + assertNull(cache.getFresh(uri("wallet1"))) + assertEquals(1, calls) // a definitive "no info" is cached within the TTL + } + + @Test + fun currentIsNonBlockingAndReflectsCache() = + runBlocking { + val cache = + NwcInfoCache( + fetch = { info("pay_invoice") }, + scope = scope, + ttlSeconds = 100, + now = { clock }, + ) + + assertNull(cache.current(uri("wallet1"))) // nothing fetched yet + cache.getFresh(uri("wallet1")) + assertNotNull(cache.current(uri("wallet1"))) + } +} From 9789ff61f49f251b277cf8ddc8a82ef71c680b4e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 22:42:12 +0000 Subject: [PATCH 4/5] fix(nip47): parse space-separated encryption/notification tags; drop accumulating notification subscription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues found in an audit of the NWC changes: 1. (correctness, high) NIP-44 negotiation never triggered against real wallets. The info event carries schemes in a single space-separated tag value (["encryption", "nip44_v2 nip04"]), but encryptionSchemes() returned tag.drop(1) = ["nip44_v2 nip04"], so the nip44_v2 membership check never matched and every request fell back to NIP-04. Split each tag value on whitespace in encryptionSchemes()/notificationTypes() so both the spec's space-separated form and a multi-element tag normalize to individual tokens. Adds NwcInfoEvent tests for the wire format. 2. (performance) NwcPaymentNotificationWatcher subscribed via subscribeAsFlow, which accumulates every event into an ever-growing list and re-emits the whole list per event — wrong for a lifetime subscription (unbounded retention + O(n) rescan per event). Replace with a raw client.subscribe listener (callbackFlow) that emits each event once; reconnect re-delivery is still de-duped by the seen set. Also documents why the watcher keys the account flow on pubkey. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RDAAS4ktFbWtRnEVXQsjfs --- .../NwcPaymentNotificationWatcher.kt | 49 ++++++++++++++++--- .../nip47WalletConnect/events/NwcInfoEvent.kt | 17 ++++++- .../nip47WalletConnect/NwcInfoEventTest.kt | 31 ++++++++++++ 3 files changed, 88 insertions(+), 9 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NwcPaymentNotificationWatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NwcPaymentNotificationWatcher.kt index 0b92166271..35fe138cb6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NwcPaymentNotificationWatcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NwcPaymentNotificationWatcher.kt @@ -24,10 +24,13 @@ import android.content.Context import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.notifications.renderers.NwcPaymentNotifier +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification @@ -35,8 +38,11 @@ import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch @@ -66,6 +72,9 @@ class NwcPaymentNotificationWatcher( fun start() { scope.launch(Dispatchers.IO) { accountFlow + // Key on pubkey to avoid re-subscribing on unrelated Account churn. + // Account switches pass through a null (Loading) emission, which is + // distinct from any pubkey and so still restarts the watch cleanly. .distinctUntilChanged { a, b -> a?.signer?.pubKey == b?.signer?.pubKey } .collectLatest { account -> account ?: return@collectLatest @@ -99,18 +108,44 @@ class NwcPaymentNotificationWatcher( ) val seen = HashSet() - client.subscribeAsFlow(wallet.uri.relayUri, filter).collect { events -> - events.forEach { event -> - val notification = event as? NwcNotificationEvent ?: return@forEach - if (seen.add(notification.id)) { - handle(account, notification, signer) - } + subscribe(wallet.uri.relayUri, filter).collect { event -> + val notification = event as? NwcNotificationEvent ?: return@collect + if (seen.add(notification.id)) { + handle(account, notification, signer) } } } } } + /** + * A standing subscription that emits each matching event once — unlike the + * accumulating `subscribeAsFlow`, which retains and re-emits the full event + * list on every event (wrong for a lifetime subscription). Reconnect + * re-delivery is de-duplicated by the caller's `seen` set. + */ + private fun subscribe( + relay: NormalizedRelayUrl, + filter: Filter, + ): Flow = + callbackFlow { + val subId = newSubId() + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + trySend(event) + } + } + + client.subscribe(subId, mapOf(relay to listOf(filter)), listener) + awaitClose { client.unsubscribe(subId) } + } + private suspend fun handle( account: Account, event: NwcNotificationEvent, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/NwcInfoEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/NwcInfoEvent.kt index 8c48c2e6e4..3f9b4b5557 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/NwcInfoEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/NwcInfoEvent.kt @@ -44,9 +44,22 @@ class NwcInfoEvent( fun supportsNotifications(): Boolean = capabilities().contains("notifications") - fun encryptionSchemes() = tags.mapNotNull(EncryptionTag::parse).flatten() + // NIP-47 carries the schemes/types as a single space-separated string in one + // tag value (e.g. ["encryption", "nip44_v2 nip04"]). Split on whitespace so we + // return individual tokens, while still tolerating a multi-element tag. + fun encryptionSchemes() = + tags + .mapNotNull(EncryptionTag::parse) + .flatten() + .flatMap { it.split(" ") } + .filter { it.isNotBlank() } - fun notificationTypes() = tags.mapNotNull(NotificationsTag::parse).flatten() + fun notificationTypes() = + tags + .mapNotNull(NotificationsTag::parse) + .flatten() + .flatMap { it.split(" ") } + .filter { it.isNotBlank() } companion object { const val KIND = 13194 diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEventTest.kt index 74a5d023e2..bb87b31171 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEventTest.kt @@ -96,6 +96,37 @@ class NwcInfoEventTest { assertTrue(schemes.contains("nip04")) } + @Test + fun testEncryptionSchemesSpaceSeparated() { + // NIP-47 wire format: all schemes in a single space-separated tag value. + val event = + NwcInfoEvent("id", "pub", 0L, arrayOf(arrayOf("encryption", "nip44_v2 nip04")), "pay_invoice", "sig") + + val schemes = event.encryptionSchemes() + assertEquals(2, schemes.size) + assertTrue(schemes.contains("nip44_v2")) + assertTrue(schemes.contains("nip04")) + } + + @Test + fun testNotificationTypesSpaceSeparated() { + // NIP-47 wire format: all types in a single space-separated tag value. + val event = + NwcInfoEvent( + "id", + "pub", + 0L, + arrayOf(arrayOf("notifications", "payment_received payment_sent")), + "pay_invoice notifications", + "sig", + ) + + val types = event.notificationTypes() + assertEquals(2, types.size) + assertTrue(types.contains("payment_received")) + assertTrue(types.contains("payment_sent")) + } + @Test fun testNotificationTypes() { val capabilities = listOf("pay_invoice", "notifications") From 40820db10f77dcd6006045313133d3c020966d6c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 23:17:15 +0000 Subject: [PATCH 5/5] refactor(wallet): move NWC notification subscription into the always-on account subscription layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bespoke applicationIOScope watcher that opened its own relay subscription for NIP-47 wallet notifications is replaced by an EOSE manager registered in AccountFilterAssembler.group — the same always-on scheme as the account's zap/notification inbox subscriptions. It is now owned by the single AccountFilterAssemblerSubscription in LoggedInPage, kept warm in the background by NotificationRelayService, and torn down on logout — matching zap-receipt lifecycle exactly (and not gated on OS notification permission). - NwcNotificationsEoseManager (PerUserEoseManager): one filter per connected wallet's own relay (kind 23197/23196, #p = per-wallet client pubkey), re-invalidating when the wallet set changes, `since`-floored at watch start, deduped by a seen set. Because these events are ephemeral, encrypted, and never land in LocalCache, onEvent decrypts them via NwcSignerState.handleIncomingNotification. - NwcSignerState.handleIncomingNotification decrypts with the matching wallet's connection secret, drops zap-carrying payments, and publishes non-zap payments to a new incomingNonZapPayments SharedFlow — a clean seam a future in-app Notifications-tab consumer can also drain. - NwcPaymentNotificationWatcher is now just the Context-bound bridge that drains that flow into an OS tray notification (no relay work, no client dependency). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RDAAS4ktFbWtRnEVXQsjfs --- .../com/vitorpamplona/amethyst/AppModules.kt | 3 +- .../nip47WalletConnect/NwcSignerState.kt | 44 +++++ .../NwcPaymentNotificationWatcher.kt | 130 ++------------- .../account/AccountFilterAssembler.kt | 3 + .../NwcNotificationsEoseManager.kt | 151 ++++++++++++++++++ 5 files changed, 214 insertions(+), 117 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip47WalletConnect/NwcNotificationsEoseManager.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index b799d3b5ea..f4222d8d6f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -885,10 +885,11 @@ class AppModules( // Surfaces non-zap Lightning payments reported by the logged-in account's NWC // wallet(s) as tray notifications (zaps are already shown via ZapNotification). + // The relay subscription lives in the always-on AccountFilterAssembler; this + // only drains the decoded-payment flow into an OS notification. val nwcPaymentNotificationWatcher = NwcPaymentNotificationWatcher( context = appContext, - client = client, scope = applicationIOScope, accountFlow = sessionManager.accountContent.map { (it as? AccountState.LoggedIn)?.account }, ).also { it.start() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt index 15f9fdedbc..5ce3a777eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt @@ -37,13 +37,21 @@ import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectReque import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectResponseCache import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -154,6 +162,42 @@ class NwcSignerState( return zapPaymentResponseDecryptionCache.value.decryptResponse(event) } + // Non-zap incoming payments reported by connected wallets (NIP-47 + // payment_received). Buffered + drop-oldest so a burst never blocks the + // decrypt coroutine; consumers (e.g. the tray-notification poster) collect it. + private val _incomingNonZapPayments = + MutableSharedFlow(extraBufferCapacity = 32, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val incomingNonZapPayments: SharedFlow = _incomingNonZapPayments.asSharedFlow() + + /** + * Decrypts an incoming NWC notification (kind 23197/23196) with the matching + * wallet's connection secret and, when it is a non-zap `payment_received`, + * publishes its transaction to [incomingNonZapPayments]. Zap-carrying payments + * are dropped — those already surface via the kind-9735 ZapNotification path. + */ + suspend fun handleIncomingNotification(event: NwcNotificationEvent) { + if (!hasWalletConnectSetup()) return + + // The notification is `p`-tagged to the per-wallet client pubkey; match it + // to the wallet whose connection secret derives that key. + val clientPubKey = event.clientPubKey() ?: return + val wallet = settings.nwcWallets.value.firstOrNull { buildSigner(it.uri)?.pubKey == clientPubKey } ?: return + val walletSigner = buildSigner(wallet.uri) ?: return + + val notification = + try { + event.decryptNotification(walletSigner) + } catch (e: Exception) { + if (e is CancellationException) throw e + return + } + + val tx = (notification as? PaymentReceivedNotification)?.notification ?: return + if (tx.parsedMetadata()?.nostr != null) return // zap — already shown by ZapNotification + + _incomingNonZapPayments.tryEmit(tx) + } + /** * Sends a generic NIP-47 request to the default wallet. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NwcPaymentNotificationWatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NwcPaymentNotificationWatcher.kt index 35fe138cb6..f74bddd9b8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NwcPaymentNotificationWatcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NwcPaymentNotificationWatcher.kt @@ -21,149 +21,47 @@ package com.vitorpamplona.amethyst.service.notifications import android.content.Context -import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.notifications.renderers.NwcPaymentNotifier -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener -import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification -import com.vitorpamplona.quartz.utils.TimeUtils -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch /** - * Always-on watcher that turns NIP-47 wallet notifications (kind 23197/23196) into - * user-facing tray notifications for the logged-in account. + * Posts tray notifications for non-zap Lightning payments reported by the logged-in + * account's connected NWC wallets. * - * For each configured NWC wallet it opens a standing subscription on that wallet's - * own relay, filtered to notifications `p`-tagged to our client pubkey. Each event - * is decrypted with the per-wallet connection secret and, when it is an incoming - * `payment_received`, posted via [NwcPaymentNotifier] — **unless** the transaction - * metadata carries a NIP-57 zap request, in which case it is dropped: zaps already - * surface through the kind-9735 `ZapNotification` path, so notifying here would - * double up. + * The relay subscription that receives these events is NOT here — it lives in + * [com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip47WalletConnect.NwcNotificationsEoseManager], + * grouped with the account's always-on zap/notification inbox subscriptions so it + * shares their lifecycle (open while logged in, warm in the background). That + * manager decrypts each notification and publishes non-zap payments to + * `NwcSignerState.incomingNonZapPayments`; this class is only the Context-bound + * bridge that drains that flow into an OS notification. * - * Only new notifications (`since` = watch start) are surfaced, so relaunching the - * app never replays old payments as fresh alerts; a per-subscription id set - * de-duplicates re-delivery across relay reconnects. + * Keeping decode and display decoupled means the flow stays populated even when OS + * notifications are denied (a future in-app Notifications-tab consumer can drain + * the same flow); [NwcPaymentNotifier] itself is what no-ops when the tray is off. */ class NwcPaymentNotificationWatcher( private val context: Context, - private val client: INostrClient, private val scope: CoroutineScope, private val accountFlow: Flow, ) { fun start() { scope.launch(Dispatchers.IO) { accountFlow - // Key on pubkey to avoid re-subscribing on unrelated Account churn. - // Account switches pass through a null (Loading) emission, which is - // distinct from any pubkey and so still restarts the watch cleanly. .distinctUntilChanged { a, b -> a?.signer?.pubKey == b?.signer?.pubKey } .collectLatest { account -> account ?: return@collectLatest - account.settings.nwcWallets.collectLatest { wallets -> - watchWallets(account, wallets) + account.nip47SignerState.incomingNonZapPayments.collect { tx -> + NwcPaymentNotifier.notify(context, account, tx) } } } } - - private suspend fun watchWallets( - account: Account, - wallets: List, - ) = coroutineScope { - wallets.forEach { wallet -> - val signer = account.nip47SignerState.buildSigner(wallet.uri) ?: return@forEach - - launch(Dispatchers.IO) { - // Skip wallets that explicitly advertise no notification support, so we - // don't hold open a relay connection that will never deliver. Fail open - // when the info event is unknown (null) — better to listen than miss. - val info = account.nwcInfoCache.getFresh(wallet.uri) - if (info != null && !info.supportsNotifications()) return@launch - - val filter = - Filter( - kinds = listOf(NwcNotificationEvent.KIND, NwcNotificationEvent.LEGACY_KIND), - authors = listOf(wallet.uri.pubKeyHex), - tags = mapOf("p" to listOf(signer.pubKey)), - since = TimeUtils.now(), - ) - - val seen = HashSet() - subscribe(wallet.uri.relayUri, filter).collect { event -> - val notification = event as? NwcNotificationEvent ?: return@collect - if (seen.add(notification.id)) { - handle(account, notification, signer) - } - } - } - } - } - - /** - * A standing subscription that emits each matching event once — unlike the - * accumulating `subscribeAsFlow`, which retains and re-emits the full event - * list on every event (wrong for a lifetime subscription). Reconnect - * re-delivery is de-duplicated by the caller's `seen` set. - */ - private fun subscribe( - relay: NormalizedRelayUrl, - filter: Filter, - ): Flow = - callbackFlow { - val subId = newSubId() - val listener = - object : SubscriptionListener { - override fun onEvent( - event: Event, - isLive: Boolean, - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - trySend(event) - } - } - - client.subscribe(subId, mapOf(relay to listOf(filter)), listener) - awaitClose { client.unsubscribe(subId) } - } - - private suspend fun handle( - account: Account, - event: NwcNotificationEvent, - signer: NostrSigner, - ) { - val notification = - try { - event.decryptNotification(signer) - } catch (e: Exception) { - if (e is CancellationException) throw e - return - } - - val tx = (notification as? PaymentReceivedNotification)?.notification ?: return - - // A payment carrying a NIP-57 zap request is already shown by ZapNotification. - if (tx.parsedMetadata()?.nostr != null) return - - NwcPaymentNotifier.notify(context, account, tx) - } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt index 7220fc739d..f049140194 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.marmot. import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata.AccountMetadataEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromInboxRelaysManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsHistoryEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip47WalletConnect.NwcNotificationsEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsHistoryEoseManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates @@ -71,6 +72,8 @@ class AccountFilterAssembler( AccountDraftsEoseManager(client, ::allKeys), notifications, notificationsHistory, + // Live tail: NIP-47 wallet notifications (payment_received) on each connected wallet's own relay. + NwcNotificationsEoseManager(client, ::allKeys), MarmotGroupEventsEoseManager(client, ::allKeys), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip47WalletConnect/NwcNotificationsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip47WalletConnect/NwcNotificationsEoseManager.kt new file mode 100644 index 0000000000..fc3a5b8f3a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip47WalletConnect/NwcNotificationsEoseManager.kt @@ -0,0 +1,151 @@ +/* + * 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.service.relayClient.reqCommand.account.nip47WalletConnect + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap + +/** + * Always-on subscription for NIP-47 wallet notifications (kind 23197/23196), + * grouped in [com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssembler] + * so it shares the exact lifecycle of the account's zap/notification inbox + * subscription: open while logged in, kept warm in the background by + * `NotificationRelayService`, torn down on logout. + * + * For each configured NWC wallet it queries the wallet's own relay (not the inbox + * relays) for notifications `p`-tagged to that wallet's client pubkey. Unlike the + * inbox managers — whose events land in `LocalCache` — these are ephemeral and + * encrypted per wallet, so decryption + fan-out happens in [onEvent] via + * `NwcSignerState.handleIncomingNotification`, which publishes non-zap payments to + * `incomingNonZapPayments`. Since `since` is floored at watch start, relaunching + * never replays old payments; the `seen` set de-dupes re-delivery on reconnect. + */ +class NwcNotificationsEoseManager( + client: INostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + private val startSince = TimeUtils.now() + private val seen = ConcurrentHashMap.newKeySet() + private val userJobMap = mutableMapOf>() + + override fun user(key: AccountQueryState) = key.account.userProfile() + + override fun updateFilter( + key: AccountQueryState, + since: SincePerRelayMap?, + ): List { + val account = key.account + + return account.settings.nwcWallets.value.flatMap { wallet -> + val signer = account.nip47SignerState.buildSigner(wallet.uri) ?: return@flatMap emptyList() + + // Skip wallets that advertise no notification support; warm the cache + // (and re-evaluate on the next invalidation) when the info is unknown. + account.nwcInfoCache.refreshIfStale(wallet.uri) + if (account.nwcInfoCache.current(wallet.uri)?.supportsNotifications() == false) { + return@flatMap emptyList() + } + + listOf( + RelayBasedFilter( + relay = wallet.uri.relayUri, + filter = + Filter( + kinds = listOf(NwcNotificationEvent.KIND, NwcNotificationEvent.LEGACY_KIND), + authors = listOf(wallet.uri.pubKeyHex), + tags = mapOf("p" to listOf(signer.pubKey)), + since = since?.get(wallet.uri.relayUri)?.time ?: startSince, + ), + ), + ) + } + } + + @OptIn(FlowPreview::class) + override fun newSub(key: AccountQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + // Re-subscribe when the wallet set changes so relays/keys are added or dropped. + key.account.scope.launch(Dispatchers.IO) { + key.account.settings.nwcWallets + .sample(1000) + .collectLatest { invalidateFilters() } + }, + ) + + return requestNewSubscription( + object : SubscriptionListener { + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + newEose(key, relay, TimeUtils.now(), forFilters) + } + + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (isLive) newEose(key, relay, TimeUtils.now(), forFilters) + + val notification = event as? NwcNotificationEvent ?: return + if (seen.add(notification.id)) { + key.account.scope.launch(Dispatchers.IO) { + key.account.nip47SignerState.handleIncomingNotification(notification) + } + } + } + }, + ) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + userJobMap.remove(key) + } +}