Merge remote-tracking branch 'origin/main' into claude/nip-2421-pr-review-6znvdd

# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt
This commit is contained in:
Claude
2026-07-24 23:50:40 +00:00
18 changed files with 997 additions and 45 deletions
+8 -6
View File
@@ -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
@@ -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,
@@ -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") {
@@ -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<HexKey, Entry>()
private val inFlight = ConcurrentHashMap.newKeySet<HexKey>()
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
}
}
@@ -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<Set<String>>(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<NwcTransaction>(extraBufferCapacity = 32, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val incomingNonZapPayments: SharedFlow<NwcTransaction> = _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<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,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<Account?>,
) {
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)
}
}
}
}
}
@@ -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,
)
}
}
@@ -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),
)
@@ -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<AccountQueryState>,
) : PerUserEoseManager<AccountQueryState>(client, allKeys) {
private val startSince = TimeUtils.now()
private val seen = ConcurrentHashMap.newKeySet<HexKey>()
private val userJobMap = mutableMapOf<User, List<Job>>()
override fun user(key: AccountQueryState) = key.account.userProfile()
override fun updateFilter(
key: AccountQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
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<Filter>?,
) {
newEose(key, relay, TimeUtils.now(), forFilters)
}
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
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)
}
}
@@ -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
@@ -1906,6 +1906,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>
@@ -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")))
}
}
@@ -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()
}
@@ -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(
@@ -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
@@ -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"))
}
}
@@ -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")