diff --git a/amethyst/plans/2026-07-24-nwc-bolt12-pay.md b/amethyst/plans/2026-07-24-nwc-bolt12-pay.md index 305aded535..8ce2db65f7 100644 --- a/amethyst/plans/2026-07-24-nwc-bolt12-pay.md +++ b/amethyst/plans/2026-07-24-nwc-bolt12-pay.md @@ -157,12 +157,14 @@ recipient offers it. ## Phase 3 as shipped (capability gate + lightning fallback) -- `NwcSignerState.defaultWalletCapabilities` caches the default wallet's advertised - method names. `Account` refetches them via nwc#2 `get_info` whenever the default - wallet changes (init collector on `defaultWalletUri`); `get_info.methods` → - the set. Empty until fetched or when the wallet doesn't advertise, which reads as - "no BOLT12". -- `Account.defaultWalletSupportsBolt12Pay()` = `pay` ∈ capabilities. The zap path's +- `Account.defaultWalletSupportsBolt12Pay()` reads the default wallet's cached + kind:13194 info event via `NwcSignerState.infoCache` (added on `main` for + encryption negotiation; it already refreshes on wallet change) and checks + `supportsMethod("pay")`. A missing/unfetched info event reads as false. (An earlier + cut fetched `get_info.methods` into its own state; unified onto the 13194 cache on + the `main` merge to avoid a redundant fetch — the info event is the canonical + capability advertisement.) +- The zap path's `canBolt12` now requires it, so a recipient with an offer but a wallet that can't `pay` **falls back to lightning** via the existing partition instead of erroring. - `AccountViewModel.canPayBolt12ViaNwc()` gates the profile "pay with wallet" action diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 2af6467ef4..6d902c7577 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 @@ -875,6 +876,17 @@ 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). + // 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, + 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 b2b2dce3dc..53b3d90d0a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -101,6 +101,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 @@ -293,8 +294,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.rpc.GetInfoMethod -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod @@ -481,7 +481,21 @@ 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) + // 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) @@ -1430,11 +1444,15 @@ class Account( /** * True when the default NWC wallet advertises the nwc#2 `pay` method — the rail a - * BOLT12 zap needs to obtain a payer proof. Empty capabilities (not yet fetched, or a - * wallet that doesn't advertise it) read as false, so the zap path falls back to - * lightning rather than attempting a `pay` the wallet can't honor. + * BOLT12 zap needs to obtain a payer proof. Read from the wallet's cached kind:13194 + * info event (its capability advertisement), which [NwcSignerState] already refreshes + * on wallet change. A missing/unfetched info event reads as false, so the zap path + * falls back to lightning rather than attempting a `pay` the wallet can't honor. */ - fun defaultWalletSupportsBolt12Pay(): Boolean = nip47SignerState.defaultWalletCapabilities.value.contains(NwcMethod.PAY) + fun defaultWalletSupportsBolt12Pay(): Boolean { + val uri = nip47SignerState.defaultWalletUri.value ?: return false + return nip47SignerState.infoCache?.current(uri)?.supportsMethod(NwcMethod.PAY) == true + } /** * Sends a NIP-XX BOLT12 zap to [recipientPubKey] over the default NWC wallet. @@ -5871,24 +5889,6 @@ class Account( } } - // Track which methods the default NWC wallet advertises (nwc#2 `get_info.methods`) - // so a zap prefers the BOLT12 `pay` rail only when the wallet supports it, and - // otherwise falls back to lightning. Refetched whenever the default wallet changes. - scope.launch(Dispatchers.IO) { - nip47SignerState.defaultWalletUri.collect { uri -> - nip47SignerState.defaultWalletCapabilities.value = emptySet() - if (uri != null) { - runCatching { - sendNwcRequestToWallet(uri, GetInfoMethod.create()) { response -> - if (response is GetInfoSuccessResponse) { - nip47SignerState.defaultWalletCapabilities.value = response.result?.methods?.toSet() ?: emptySet() - } - } - }.onFailure { Log.w("Account", "NWC get_info for capabilities failed", it) } - } - } - } - scope.launch { cache.live.newEventBundles.collect { newNotes -> logTime("Account ${userProfile().toBestDisplayName()} newEventBundle Update with ${newNotes.size} new notes") { 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 d98a9cd063..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,15 +37,25 @@ 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.MutableStateFlow +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 +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -61,6 +71,13 @@ class NwcSignerState( val cache: LocalCache, val scope: CoroutineScope, val settings: AccountSettings, + /** + * 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 infoCache: NwcInfoCache? = null, ) : INwcSignerState { /** * Flow of the default wallet's NWC URI, derived from multi-wallet settings. @@ -101,19 +118,36 @@ class NwcSignerState( }.flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Eagerly, NostrWalletConnectResponseCache(nip47Signer.value)) - /** - * The NIP-47 method names the default wallet advertises (nwc#2 `get_info.methods`). - * Empty until fetched or when no wallet is set. [Account] refreshes it whenever the - * default wallet changes; the zap path reads it to decide whether the BOLT12 `pay` - * rail is available before preferring it over lightning. - */ - val defaultWalletCapabilities = MutableStateFlow>(emptySet()) - fun buildSigner(uri: Nip47WalletConnect.Nip47URINorm?) = uri?.secret?.hexToByteArray()?.let { NostrSignerInternal(KeyPair(it)) } + init { + // 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 } + .collect { infoCache?.refreshIfStale(it) } + } + } + + /** + * Non-blocking read of the negotiated encryption preference for a wallet. + * 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 + infoCache?.refreshIfStale(uri) + return infoCache?.current(uri)?.encryptionSchemes()?.any { it.equals("nip44_v2", ignoreCase = true) } ?: false + } + fun hasWalletConnectSetup(): Boolean = settings.nwcWallets.value.isNotEmpty() override fun isNIP47Author(pubKey: HexKey?): Boolean = nip47Signer.value.pubKey == pubKey @@ -128,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. */ @@ -147,7 +217,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( @@ -193,7 +263,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..f74bddd9b8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NwcPaymentNotificationWatcher.kt @@ -0,0 +1,67 @@ +/* + * 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.model.Account +import com.vitorpamplona.amethyst.service.notifications.renderers.NwcPaymentNotifier +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch + +/** + * Posts tray notifications for non-zap Lightning payments reported by the logged-in + * account's connected NWC wallets. + * + * 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. + * + * 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 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.nip47SignerState.incomingNonZapPayments.collect { tx -> + 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/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) + } +} 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 12c980bf3c..cae38941c2 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1906,6 +1906,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 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"))) + } +} 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/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/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")) + } +} 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")