feat(wallet): prefer NIP-44 for NWC and notify non-zap payments

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDAAS4ktFbWtRnEVXQsjfs
This commit is contained in:
Claude
2026-07-24 21:34:55 +00:00
parent c2f6aa9992
commit cd82d7ffff
8 changed files with 321 additions and 4 deletions
@@ -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,
@@ -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)
@@ -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<HexKey, Boolean>()
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<LnZapPaymentRequestEvent, NormalizedRelayUrl> {
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(
@@ -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)
@@ -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<Account?>,
) {
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<NwcWalletEntryNorm>,
) = 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<HexKey>()
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)
}
}
@@ -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,
)
}
}
@@ -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(
+6
View File
@@ -1858,6 +1858,12 @@
<string name="app_notification_zaps_channel_message_from">From %1$s</string>
<string name="app_notification_zaps_channel_message_for">for %1$s</string>
<string name="app_notification_payments_channel_id" translatable="false">PaymentsReceivedID</string>
<string name="app_notification_payments_channel_name">Payments Received</string>
<string name="app_notification_payments_channel_description">Notifies you when your connected wallet receives a payment that is not a Nostr zap</string>
<string name="app_notification_payments_channel_message">Received %1$s sats</string>
<string name="app_notification_payments_summary">New payments</string>
<string name="app_notification_reply_label">Reply</string>
<string name="app_notification_mark_read_label">Mark Read</string>
<string name="app_notification_me">Me</string>