mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
@@ -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") {
|
||||
|
||||
+121
@@ -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
|
||||
}
|
||||
}
|
||||
+81
-11
@@ -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(
|
||||
|
||||
+13
@@ -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)
|
||||
|
||||
+67
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+3
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
+151
@@ -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)
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
+145
@@ -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")))
|
||||
}
|
||||
}
|
||||
+119
@@ -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()
|
||||
}
|
||||
+2
@@ -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(
|
||||
|
||||
+15
-2
@@ -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
|
||||
|
||||
+114
@@ -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"))
|
||||
}
|
||||
}
|
||||
+31
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user