diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 2dfa6bd053..733ec3a8ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -428,7 +428,7 @@ class AppModules( // Connects the INostrClient class with okHttp val websocketBuilder = OkHttpWebSocket.Builder { url -> - val useTor = torEvaluatorFlow.flow.value.useTor(url) + val useTor = torEvaluatorFlow.shouldUseTorForRelay(url) okHttpClientForRelays.getHttpClient(useTor) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 3bf5f7eeca..720d8d6375 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -25,6 +25,7 @@ import android.content.Context import android.content.SharedPreferences import androidx.compose.runtime.Immutable import androidx.core.content.edit +import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntry import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntry import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm import com.vitorpamplona.amethyst.model.AccountSettings @@ -123,7 +124,9 @@ private object PrefKeys { const val DEFAULT_FOLLOW_PACKS_FOLLOW_LIST = "defaultFollowPacksFollowList" const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer" // legacy, kept for migration const val NWC_WALLETS = "nwcWallets" - const val DEFAULT_NWC_WALLET_ID = "defaultNwcWalletId" + const val DEFAULT_NWC_WALLET_ID = "defaultNwcWalletId" // legacy, migrated into DEFAULT_PAYMENT_SOURCE_ID + const val CLINK_DEBIT_WALLETS = "clinkDebitWallets" + const val DEFAULT_PAYMENT_SOURCE_ID = "defaultPaymentSourceId" const val LATEST_USER_METADATA = "latestUserMetadata" const val LATEST_CONTACT_LIST = "latestContactList" const val LATEST_DM_RELAY_LIST = "latestDMRelayList" @@ -401,9 +404,19 @@ object LocalPreferences { } else { remove(PrefKeys.NWC_WALLETS) } - settings.defaultNwcWalletId.value?.let { - putString(PrefKeys.DEFAULT_NWC_WALLET_ID, it) - } ?: remove(PrefKeys.DEFAULT_NWC_WALLET_ID) + + val debitEntries = settings.clinkDebitWallets.value.map { it.denormalize() } + if (debitEntries.isNotEmpty()) { + putString(PrefKeys.CLINK_DEBIT_WALLETS, JsonMapper.toJson(debitEntries)) + } else { + remove(PrefKeys.CLINK_DEBIT_WALLETS) + } + + settings.defaultPaymentSourceId.value?.let { + putString(PrefKeys.DEFAULT_PAYMENT_SOURCE_ID, it) + } ?: remove(PrefKeys.DEFAULT_PAYMENT_SOURCE_ID) + // Legacy NWC-only default key is superseded by DEFAULT_PAYMENT_SOURCE_ID. + remove(PrefKeys.DEFAULT_NWC_WALLET_ID) // Remove legacy key after migration remove(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER) @@ -565,6 +578,8 @@ object LocalPreferences { val zapPaymentRequestServerStr = getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null) val nwcWalletsStr = getString(PrefKeys.NWC_WALLETS, null) val defaultNwcWalletIdStr = getString(PrefKeys.DEFAULT_NWC_WALLET_ID, null) + val clinkDebitWalletsStr = getString(PrefKeys.CLINK_DEBIT_WALLETS, null) + val defaultPaymentSourceIdStr = getString(PrefKeys.DEFAULT_PAYMENT_SOURCE_ID, null) val defaultFileServerStr = getString(PrefKeys.DEFAULT_FILE_SERVER, null) val pendingAttestationsStr = getString(PrefKeys.PENDING_ATTESTATIONS, null) @@ -619,6 +634,10 @@ object LocalPreferences { } } } + val clinkDebitsLoaded = + async { + parseOrNull>(clinkDebitWalletsStr)?.mapNotNull { it.normalize() } ?: emptyList() + } val defaultFileServer = async { parseOrNull(defaultFileServerStr) ?: DEFAULT_MEDIA_SERVERS[0] } val viewedPollResultNoteIds = async { parseOrNull>(viewedPollResultNoteIdsStr) ?: mapOf() } @@ -694,7 +713,15 @@ object LocalPreferences { defaultCommunitiesFollowList = MutableStateFlow(followListPrefs.communities), defaultFollowPacksFollowList = MutableStateFlow(followListPrefs.followPacks), nwcWallets = MutableStateFlow(nwcWalletsLoaded.await().first), - defaultNwcWalletId = MutableStateFlow(nwcWalletsLoaded.await().second), + clinkDebitWallets = MutableStateFlow(clinkDebitsLoaded.await()), + // Prefer the new unified default; migrate from the legacy NWC default; + // else fall back to the first configured source (NWC before debits). + defaultPaymentSourceId = + MutableStateFlow( + defaultPaymentSourceIdStr + ?: nwcWalletsLoaded.await().second + ?: clinkDebitsLoaded.await().firstOrNull()?.id, + ), hideDeleteRequestDialog = hideDeleteRequestDialog, hideBlockAlertDialog = hideBlockAlertDialog, hideNIP17WarningDialog = hideNIP17WarningDialog, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index a899b593e8..89e3159f35 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -22,9 +22,12 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle +import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatRepository import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListRepository import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm +import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource +import com.vitorpamplona.amethyst.commons.model.payments.PaymentSourceResolver import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName @@ -193,7 +196,10 @@ class AccountSettings( val defaultCommunitiesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), val defaultFollowPacksFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val nwcWallets: MutableStateFlow> = MutableStateFlow(emptyList()), - val defaultNwcWalletId: MutableStateFlow = MutableStateFlow(null), + val clinkDebitWallets: MutableStateFlow> = MutableStateFlow(emptyList()), + // The unified default spend rail (an NWC wallet OR a CLINK debit). Persisted under a + // new key, migrated from the legacy NWC-only `defaultNwcWalletId`. + val defaultPaymentSourceId: MutableStateFlow = MutableStateFlow(null), var hideDeleteRequestDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false, var hideNIP17WarningDialog: Boolean = false, @@ -335,14 +341,17 @@ class AccountSettings( return false } + /** The selected default spend rail across both NWC wallets and CLINK debits. */ + fun defaultPaymentSource(): PaymentSource? = PaymentSourceResolver.resolveDefault(nwcWallets.value, clinkDebitWallets.value, defaultPaymentSourceId.value) + + /** + * The NWC wallet to use for NWC-only flows (balance display, mint top-up). Resolves + * the unified default when it points at an NWC wallet, otherwise falls back to the + * first NWC wallet so those flows keep working even when a debit is the zap default. + */ fun defaultNwcWallet(): NwcWalletEntryNorm? { - val id = defaultNwcWalletId.value val wallets = nwcWallets.value - return if (id != null) { - wallets.firstOrNull { it.id == id } - } else { - wallets.firstOrNull() - } + return wallets.firstOrNull { it.id == defaultPaymentSourceId.value } ?: wallets.firstOrNull() } fun defaultZapPaymentRequest(): Nip47WalletConnect.Nip47URINorm? = defaultNwcWallet()?.uri @@ -353,8 +362,10 @@ class AccountSettings( nwcWallets.tryEmit(nwcWallets.value.toMutableList().apply { set(existing, wallet) }) } else { nwcWallets.tryEmit(nwcWallets.value + wallet) - if (nwcWallets.value.size == 1) { - defaultNwcWalletId.tryEmit(wallet.id) + // First configured source of any kind becomes the default; adding more never + // silently changes an existing default. + if (defaultPaymentSourceId.value == null) { + defaultPaymentSourceId.tryEmit(wallet.id) } } saveAccountSettings() @@ -364,16 +375,68 @@ class AccountSettings( fun removeNwcWallet(walletId: String): Boolean { val wallets = nwcWallets.value.filter { it.id != walletId } nwcWallets.tryEmit(wallets) - if (defaultNwcWalletId.value == walletId) { - defaultNwcWalletId.tryEmit(wallets.firstOrNull()?.id) + reassignDefaultIfRemoved(walletId) + saveAccountSettings() + return true + } + + fun addClinkDebitWallet(wallet: ClinkDebitWalletEntryNorm): Boolean { + val existing = clinkDebitWallets.value.indexOfFirst { it.id == wallet.id } + if (existing >= 0) { + clinkDebitWallets.tryEmit(clinkDebitWallets.value.toMutableList().apply { set(existing, wallet) }) + } else { + clinkDebitWallets.tryEmit(clinkDebitWallets.value + wallet) + if (defaultPaymentSourceId.value == null) { + defaultPaymentSourceId.tryEmit(wallet.id) + } } saveAccountSettings() return true } - fun setDefaultNwcWallet(walletId: String): Boolean { - if (defaultNwcWalletId.value != walletId && nwcWallets.value.any { it.id == walletId }) { - defaultNwcWalletId.tryEmit(walletId) + fun removeClinkDebitWallet(walletId: String): Boolean { + clinkDebitWallets.tryEmit(clinkDebitWallets.value.filter { it.id != walletId }) + reassignDefaultIfRemoved(walletId) + saveAccountSettings() + return true + } + + fun renameClinkDebitWallet( + walletId: String, + newName: String, + ): Boolean { + val wallets = clinkDebitWallets.value.toMutableList() + val index = wallets.indexOfFirst { it.id == walletId } + if (index >= 0) { + wallets[index] = wallets[index].copy(name = newName) + clinkDebitWallets.tryEmit(wallets) + saveAccountSettings() + return true + } + return false + } + + /** When the removed source was the default, fall back to the first remaining source. */ + private fun reassignDefaultIfRemoved(walletId: String) { + if (defaultPaymentSourceId.value == walletId) { + defaultPaymentSourceId.tryEmit(PaymentSourceResolver.resolveDefault(nwcWallets.value, clinkDebitWallets.value, null)?.id) + } + } + + /** Resets the default to the first remaining source if it no longer points at anything. */ + private fun reassignDefaultIfMissing() { + val id = defaultPaymentSourceId.value ?: return + val exists = nwcWallets.value.any { it.id == id } || clinkDebitWallets.value.any { it.id == id } + if (!exists) { + defaultPaymentSourceId.tryEmit(PaymentSourceResolver.resolveDefault(nwcWallets.value, clinkDebitWallets.value, null)?.id) + } + } + + /** Selects the unified default across both NWC wallets and CLINK debits. */ + fun setDefaultPaymentSource(sourceId: String): Boolean { + val exists = nwcWallets.value.any { it.id == sourceId } || clinkDebitWallets.value.any { it.id == sourceId } + if (defaultPaymentSourceId.value != sourceId && exists) { + defaultPaymentSourceId.tryEmit(sourceId) saveAccountSettings() return true } @@ -399,7 +462,7 @@ class AccountSettings( if (newServer == null) { if (nwcWallets.value.isNotEmpty()) { nwcWallets.tryEmit(emptyList()) - defaultNwcWalletId.tryEmit(null) + reassignDefaultIfMissing() saveAccountSettings() return true } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt index d3179dd07f..3bacb99eb3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt @@ -68,6 +68,7 @@ class UserMetadataState( nip05: String? = null, lnAddress: String? = null, lnURL: String? = null, + clinkOffer: String? = null, ): MetadataEvent { val latest = getUserMetadataEvent() @@ -85,6 +86,7 @@ class UserMetadataState( nip05 = nip05, lnAddress = lnAddress, lnURL = lnURL, + clinkOffer = clinkOffer, ) } else { MetadataEvent.createNew( @@ -98,6 +100,7 @@ class UserMetadataState( nip05 = nip05, lnAddress = lnAddress, lnURL = lnURL, + clinkOffer = clinkOffer, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt index d7abdecf83..8b06eee55b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt @@ -65,12 +65,10 @@ class NwcSignerState( * Flow of the default wallet's NWC URI, derived from multi-wallet settings. */ val defaultWalletUri: StateFlow = - combine(settings.nwcWallets, settings.defaultNwcWalletId) { wallets, defaultId -> - if (defaultId != null) { - wallets.firstOrNull { it.id == defaultId }?.uri - } else { - wallets.firstOrNull()?.uri - } + combine(settings.nwcWallets, settings.defaultPaymentSourceId) { wallets, defaultId -> + // Use the NWC wallet the unified default points at; otherwise fall back to the + // first NWC wallet so NWC zap routing is unchanged for NWC-only users. + (wallets.firstOrNull { it.id == defaultId } ?: wallets.firstOrNull())?.uri }.flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Eagerly, settings.defaultZapPaymentRequest()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/AccountsTorStateConnector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/AccountsTorStateConnector.kt index 68aada42cb..a437179a8e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/AccountsTorStateConnector.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/AccountsTorStateConnector.kt @@ -105,4 +105,42 @@ class AccountsTorStateConnector( SharingStarted.Eagerly, emptySet(), ) + + // Persistent money-operation relays across all accounts: NIP-47 wallet relays and saved CLINK + // Debits service relays. Feeds TorRelayState.moneyOpRelays so these connections honor the + // money-operations Tor preference instead of being classified as generic "new" relays. + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) + val allMoneyOpRelaysFlow: Flow> = + accountsCache.accounts + .debounce(200) + .transformLatest { snapshot -> + val perAccountFlows = + snapshot.map { (_, account) -> + combine( + account.settings.nwcWallets, + account.settings.clinkDebitWallets, + ) { nwcWallets, clinkDebitWallets -> + val relays = mutableSetOf() + nwcWallets.forEach { relays.add(it.uri.relayUri) } + clinkDebitWallets.forEach { relays.addAll(it.pointer.relays) } + relays.toSet() + } + } + + val ready = perAccountFlows.ifEmpty { listOf(MutableStateFlow(emptySet())) } + + emitAll( + combine(ready) { perAccount -> + val moneyOpRelays = mutableSetOf() + perAccount.forEach { moneyOpRelays.addAll(it) } + moneyOpRelays.toSet() + }, + ) + }.onEach { + torEvaluatorFlow.moneyOpRelays.tryEmit(it) + }.stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt index b6822fc21d..bd0366a1df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt @@ -34,6 +34,7 @@ import kotlinx.coroutines.flow.combineTransform import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import okhttp3.OkHttpClient @Stable @@ -45,6 +46,58 @@ class TorRelayState( val dmRelays = MutableStateFlow>(emptySet()) val trustedRelays = MutableStateFlow>(emptySet()) + /** + * Relays known to be used for money operations from persistent configuration: NIP-47 wallet + * relays and saved CLINK Debits service relays. Fed by [AccountsTorStateConnector] across all + * logged-in accounts. These follow the money-operations Tor preference (see [TorRelayEvaluation]). + */ + val moneyOpRelays = MutableStateFlow>(emptySet()) + + /** + * Money-operation relays registered for the lifetime of a single ad-hoc round-trip whose relay + * isn't a saved wallet — e.g. paying someone's CLINK offer (`noffer`) pointer. Reference-counted + * so overlapping payments that share a relay don't unregister it while another is still in flight. + */ + private val adHocMoneyOpCounts = MutableStateFlow>(emptyMap()) + + private fun currentMoneyOpRelays(): Set = moneyOpRelays.value + adHocMoneyOpCounts.value.keys + + /** + * Marks [relays] as money-operation relays until a matching [unregisterMoneyOpRelays] call. + * Used by the CLINK offer/debit payers so a one-off payment relay honors the money-operations + * Tor preference instead of being treated as a generic "new" relay. + */ + fun registerMoneyOpRelays(relays: Set) { + if (relays.isEmpty()) return + adHocMoneyOpCounts.update { current -> + current.toMutableMap().apply { + relays.forEach { this[it] = (this[it] ?: 0) + 1 } + } + } + } + + fun unregisterMoneyOpRelays(relays: Set) { + if (relays.isEmpty()) return + adHocMoneyOpCounts.update { current -> + current.toMutableMap().apply { + relays.forEach { + val next = (this[it] ?: 0) - 1 + if (next <= 0) remove(it) else this[it] = next + } + } + } + } + + private fun currentSettings() = + TorRelaySettings( + torType = torSettingsFlow.torType.value, + onionRelaysViaTor = torSettingsFlow.onionRelaysViaTor.value, + dmRelaysViaTor = torSettingsFlow.dmRelaysViaTor.value, + newRelaysViaTor = torSettingsFlow.newRelaysViaTor.value, + trustedRelaysViaTor = torSettingsFlow.trustedRelaysViaTor.value, + moneyOperationsViaTor = torSettingsFlow.moneyOperationsViaTor.value, + ) + val torSettings = combine( torSettingsFlow.torType, @@ -66,27 +119,15 @@ class TorRelayState( newRelaysViaTor = newRelaysViaTor, trustedRelaysViaTor = trustedRelaysViaTor, ) + }.combine(torSettingsFlow.moneyOperationsViaTor) { settings, moneyOperationsViaTor -> + settings.copy(moneyOperationsViaTor = moneyOperationsViaTor) }.onStart { - emit( - TorRelaySettings( - torType = torSettingsFlow.torType.value, - onionRelaysViaTor = torSettingsFlow.onionRelaysViaTor.value, - dmRelaysViaTor = torSettingsFlow.dmRelaysViaTor.value, - newRelaysViaTor = torSettingsFlow.newRelaysViaTor.value, - trustedRelaysViaTor = torSettingsFlow.trustedRelaysViaTor.value, - ), - ) + emit(currentSettings()) }.flowOn(Dispatchers.IO) .stateIn( scope, SharingStarted.Eagerly, - TorRelaySettings( - torType = torSettingsFlow.torType.value, - onionRelaysViaTor = torSettingsFlow.onionRelaysViaTor.value, - dmRelaysViaTor = torSettingsFlow.dmRelaysViaTor.value, - newRelaysViaTor = torSettingsFlow.newRelaysViaTor.value, - trustedRelaysViaTor = torSettingsFlow.trustedRelaysViaTor.value, - ), + currentSettings(), ) val flow = @@ -94,12 +135,21 @@ class TorRelayState( torSettings, trustedRelays, dmRelays, - ) { torSettings: TorRelaySettings, trustedRelayList: Set, dmRelayList: Set -> + moneyOpRelays, + adHocMoneyOpCounts, + ) { + torSettings: TorRelaySettings, + trustedRelayList: Set, + dmRelayList: Set, + moneyOpRelayList: Set, + adHocMoneyOps: Map, + -> emit( TorRelayEvaluation( torSettings = torSettings, trustedRelayList = trustedRelayList, dmRelayList = dmRelayList, + moneyOpRelayList = moneyOpRelayList + adHocMoneyOps.keys, ), ) }.onStart { @@ -108,6 +158,7 @@ class TorRelayState( torSettings = torSettings.value, trustedRelayList = trustedRelays.value, dmRelayList = dmRelays.value, + moneyOpRelayList = currentMoneyOpRelays(), ), ) }.flowOn(Dispatchers.IO) @@ -118,10 +169,22 @@ class TorRelayState( torSettings = torSettings.value, trustedRelayList = trustedRelays.value, dmRelayList = dmRelays.value, + moneyOpRelayList = currentMoneyOpRelays(), ), ) - fun shouldUseTorForRelay(relay: NormalizedRelayUrl) = flow.value.useTor(relay) + /** + * Resolves the Tor preference for [relay] from live source values rather than the cached [flow] + * snapshot. This makes ad-hoc money-op registration ([registerMoneyOpRelays]) take effect on the + * very next connection attempt, with no dependency on the combine pipeline having propagated yet. + */ + fun shouldUseTorForRelay(relay: NormalizedRelayUrl) = + TorRelayEvaluation( + torSettings = currentSettings(), + trustedRelayList = trustedRelays.value, + dmRelayList = dmRelays.value, + moneyOpRelayList = currentMoneyOpRelays(), + ).useTor(relay) fun okHttpClientForRelay(url: NormalizedRelayUrl): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForRelay(url)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt new file mode 100644 index 0000000000..664b55ed61 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt @@ -0,0 +1,152 @@ +/* + * 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 + +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.experimental.clink.client.DebitClient +import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent +import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency +import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Drives the CLINK Debits payer round-trips: publishes a kind-21002 request (pay an + * invoice, or authorize a spending budget) and waits for the encrypted reply. The wallet + * authorizes against the account's own identity (no shared secret). + * + * This is the CLINK-debit spend rail that the zap button / offer card route through + * when a debit pointer is the selected default payment source. It MUST only be invoked + * after an explicit user confirmation — a debit moves real sats. + * + * Consume-only: Amethyst sends debit requests, it never answers them. + */ +object ClinkDebitPayer { + const val DEFAULT_TIMEOUT_MS = 30_000L + + /** + * Asks the wallet to pay [bolt11]. + * + * @return the decrypted response (`res:"ok"` with optional preimage, or a `GFY` + * failure), or null if no reply arrived in time or the pointer carried no relay. + */ + suspend fun payInvoice( + account: Account, + pointer: NDebit, + bolt11: String, + amountSats: Long? = null, + timeoutMs: Long = DEFAULT_TIMEOUT_MS, + ): DebitResponse? = + // Off the Main thread: building the request signs + NIP-44 encrypts, and callers reach + // this from Compose (Main) scopes (offer card, lightning-address row). See ClinkOfferPayer. + withContext(Dispatchers.IO) { + val client = clientFor(pointer, account) ?: return@withContext null + sendAndAwait(account, client, client.payInvoice(bolt11, amountSats), timeoutMs) + } + + /** + * Asks the wallet to authorize a spending budget. Omit [frequency] for a one-time + * budget; otherwise it recurs every `frequency` (day/week/month). + */ + suspend fun requestBudget( + account: Account, + pointer: NDebit, + amountSats: Long, + frequency: DebitFrequency? = null, + timeoutMs: Long = DEFAULT_TIMEOUT_MS, + ): DebitResponse? = + withContext(Dispatchers.IO) { + val client = clientFor(pointer, account) ?: return@withContext null + sendAndAwait(account, client, client.requestBudget(amountSats, frequency), timeoutMs) + } + + // Debits sign with the persistent account identity (unlike offer requests, which use a + // throwaway key — see ClinkOfferPayer): the service must see one stable app identity so a + // budget authorization can cover repeat debits instead of prompting on every payment. + private fun clientFor( + pointer: NDebit, + account: Account, + ): DebitClient? = if (pointer.relays.isEmpty()) null else DebitClient(pointer, account.signer) + + /** Publishes [request] to the pointer's relays and awaits the matching kind-21002 reply. */ + private suspend fun sendAndAwait( + account: Account, + client: DebitClient, + request: DebitEvent, + timeoutMs: Long, + ): DebitResponse? { + val relays = client.pointer.relays.toSet() + + val reply = CompletableDeferred() + // A random short id: relays cap subscription ids at 64 chars (NIP-01); the reply is matched + // by request id in the listener, not by subId. + val subId = newSubId() + val filters: Map> = relays.associateWith { listOf(client.responseFilter(request.id)) } + + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event is DebitEvent && event.requestId() == request.id && !reply.isCompleted) { + reply.complete(event) + } + } + } + + // Saved debit wallets are already fed into the money-op relay set by AccountsTorStateConnector, + // but register here too so a freshly-added wallet paid before that flow propagates — and any + // non-saved debit pointer — still routes under the money-operations Tor preference rather than + // the generic `newRelaysViaTor` policy. + val torState = Amethyst.instance.torEvaluatorFlow + torState.registerMoneyOpRelays(relays) + account.client.subscribe(subId, filters, listener) + return try { + account.client.publish(request, relays) + val response = withTimeoutOrNull(timeoutMs) { reply.await() } ?: return null + // Treat an undecryptable/malformed reply as no usable response rather than + // throwing — callers only handle null, and an uncaught decode error would + // leave the calling UI hung (spinner stuck, no toast, sibling zaps cancelled). + try { + client.parseResponse(response) + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + } finally { + account.client.unsubscribe(subId) + torState.unregisterMoneyOpRelays(relays) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt new file mode 100644 index 0000000000..28a0d64fb0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service + +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.experimental.clink.client.OfferClient +import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent +import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse +import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Drives the CLINK Offers payer round-trip: publishes a kind-21001 request to the + * offer's relays and waits for the service's encrypted reply, returning the decrypted + * [OfferResponse] (an invoice or an error). UI then hands a successful `bolt11` to the + * existing pay path (e.g. `payViaIntent`). + * + * Consume-only: Amethyst never answers offer requests, it only asks. + */ +object ClinkOfferPayer { + const val DEFAULT_TIMEOUT_MS = 30_000L + + /** + * @param amountSats overrides the pointer's embedded price (required for spontaneous offers). + * @return the decrypted response, or null if no reply arrived before [timeoutMs] (or the + * pointer carried no relay to reach). + */ + suspend fun requestInvoice( + account: Account, + offer: NOffer, + amountSats: Long? = null, + timeoutMs: Long = DEFAULT_TIMEOUT_MS, + ): OfferResponse? { + val relays = offer.relays.toSet() + if (relays.isEmpty()) return null + + // Keep the round-trip off the Main thread: the ephemeral keygen, JSON serialization, + // NIP-44 encryption and signing are CPU/crypto-heavy, and callers reach this from a + // Compose (Main) scope. StrictMode flags any of it running on the UI thread. + return withContext(Dispatchers.IO) { + // Sign the request with a fresh throwaway key, like the reference SDK/Zeus/Stacker + // News do: an offer round-trip is self-contained (the reply is NIP-44'd back to this + // key and decrypted with it), so there is no reason to expose the user's real identity + // to every offer service they pay. Payer identity, when needed, travels in the request + // body (payer_data / a signed zap request), not the transport key. + val ephemeralSigner = NostrSignerInternal(KeyPair()) + val client = OfferClient(offer, ephemeralSigner) + val request = client.requestInvoice(amountSats = amountSats) + + val reply = CompletableDeferred() + // A random short id: relays cap subscription ids at 64 chars (NIP-01) and reject an + // over-long REQ outright. The reply is matched by request id in the listener, not by subId. + val subId = newSubId() + val filters: Map> = relays.associateWith { listOf(client.responseFilter(request.id)) } + + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event is OfferEvent && event.requestId() == request.id && !reply.isCompleted) { + reply.complete(event) + } + } + } + + // The offer's relays are an ad-hoc payment endpoint, not a saved wallet, so register them + // as money-operation relays for the duration of the round-trip. Otherwise account.client + // would treat them as generic "new" relays and route them per `newRelaysViaTor`, silently + // pushing the payment through Tor (and failing on services that block Tor exits) even when + // the user disabled Tor for money operations. The subscribe() below triggers a reconnect, and + // BasicRelayClient rebuilds any socket left on the now-wrong (Tor) transport onto clearnet. + val torState = Amethyst.instance.torEvaluatorFlow + torState.registerMoneyOpRelays(relays) + account.client.subscribe(subId, filters, listener) + try { + account.client.publish(request, relays) + val response = withTimeoutOrNull(timeoutMs) { reply.await() } ?: return@withContext null + // A reply that can't be decrypted/parsed (corrupt ciphertext, malformed JSON + // from a buggy or hostile relay) is treated as no usable response rather than + // thrown — callers only handle null, and an uncaught decode error would hang + // the UI (the Pay button stuck on "Requesting…"). + try { + client.parseResponse(response) + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + } finally { + account.client.unsubscribe(subId) + torState.unregisterMoneyOpRelays(relays) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt index 9eb62ea3b7..977c0d5959 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -23,12 +23,14 @@ package com.vitorpamplona.amethyst.service import android.content.Context import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent @@ -44,8 +46,10 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.OkHttpClient +import java.util.concurrent.atomic.AtomicInteger import kotlin.math.round class ZapPaymentHandler( @@ -206,14 +210,27 @@ class ZapPaymentHandler( onProgress(0.75f) } - if (account.nip47SignerState.hasWalletConnectSetup()) { - payViaNWC(payables, note, onError = onError, onProgress = { - onProgress(it * 0.25f + 0.75f) // keeps within range. - }, context) - // onProgress(1f) - } else { - onPayViaIntent(payables.toImmutableList()) - onProgress(0f) + // Route through the user's selected default payment source. A CLINK debit takes + // precedence over NWC when it is the chosen default; NWC-only users are unaffected + // (defaultPaymentSource() resolves to their NWC wallet). No source -> wallet app. + when (val source = account.settings.defaultPaymentSource()) { + is PaymentSource.ClinkDebit -> { + payViaClinkDebit(payables, source.wallet.pointer, onError = onError, onProgress = { + onProgress(it * 0.25f + 0.75f) + }, context) + } + + is PaymentSource.Nwc -> { + payViaNWC(payables, note, onError = onError, onProgress = { + onProgress(it * 0.25f + 0.75f) // keeps within range. + }, context) + // onProgress(1f) + } + + null -> { + onPayViaIntent(payables.toImmutableList()) + onProgress(0f) + } } } @@ -337,7 +354,7 @@ class ZapPaymentHandler( onProgress: (percent: Float) -> Unit, context: Context, ): List { - var progressAllPayments = 0.00f + val progress = PaymentProgress(payables.size, onProgress) return mapNotNullAsync( items = payables, @@ -346,9 +363,8 @@ class ZapPaymentHandler( bolt11 = payable.invoice, zappedNote = note, onResponse = { response -> + progress.step() if (response is PayInvoiceErrorResponse) { - progressAllPayments += 0.5f / payables.size - onProgress(progressAllPayments) onError( stringRes(context, R.string.error_dialog_pay_invoice_error), stringRes( @@ -359,15 +375,68 @@ class ZapPaymentHandler( ), payable.info.user, ) - } else { - progressAllPayments += 0.5f / payables.size - onProgress(progressAllPayments) } }, ) - progressAllPayments += 0.5f / payables.size - onProgress(progressAllPayments) + progress.step() + + Paid(payable, true) + }, + ) + } + + /** + * Thread-safe progress accumulator for the parallel pay rails. Each payable advances in two + * half-steps (request dispatched, then response/settlement), reported as a 0..1 fraction. + * The counter is atomic because `mapNotNullAsync` runs the payables concurrently and the + * response half-step fires from an async callback, so plain `+=` would lose updates. + */ + private class PaymentProgress( + payableCount: Int, + private val onProgress: (percent: Float) -> Unit, + ) { + private val totalSteps = (payableCount * 2).coerceAtLeast(1) + private val done = AtomicInteger(0) + + fun step() = onProgress(done.incrementAndGet().toFloat() / totalSteps) + } + + /** + * Pays each zap invoice by asking the user's CLINK debit service (kind 21002) to + * settle the BOLT-11. The service authorizes against the account identity. + * + * Fire-and-forget, like the NWC rail ([payViaNWC]): the request is dispatched on the + * account scope and each payable is reported paid optimistically so the zap UI completes + * promptly. A `GFY`/failure (or no reply within the debit timeout) surfaces later through + * [onError] rather than blocking the zap on the service's response. + */ + suspend fun payViaClinkDebit( + payables: List, + pointer: NDebit, + onError: (String, String, User?) -> Unit, + onProgress: (percent: Float) -> Unit, + context: Context, + ): List { + val progress = PaymentProgress(payables.size, onProgress) + + return mapNotNullAsync( + items = payables, + runRequestFor = { payable: Payable -> + account.scope.launch { + val response = ClinkDebitPayer.payInvoice(account, pointer, payable.invoice) + progress.step() + if (response?.isOk() != true) { + onError( + stringRes(context, R.string.error_dialog_pay_invoice_error), + response?.failureDetail() + ?: stringRes(context, R.string.clink_debit_no_response), + payable.info.user, + ) + } + } + + progress.step() Paid(payable, true) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt index c83719af68..4c1f3263d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt @@ -303,6 +303,22 @@ fun NewUserMetadataScreen( singleLine = true, ) + Spacer(modifier = Modifier.height(10.dp)) + + OutlinedTextField( + label = { Text(text = stringRes(R.string.clink_offer_label)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.clinkOffer.value, + onValueChange = { postViewModel.clinkOffer.value = it }, + placeholder = { + Text( + text = "noffer1…", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + singleLine = true, + ) + // -- Social Proofs -- ExpandableSection( title = stringRes(R.string.social_proof), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt index 826b849028..4d7a908ab9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -60,6 +60,7 @@ class NewUserMetadataViewModel : ViewModel() { val nip05 = mutableStateOf("") val lnAddress = mutableStateOf("") val lnURL = mutableStateOf("") + val clinkOffer = mutableStateOf("") val twitter = mutableStateOf("") val github = mutableStateOf("") @@ -85,6 +86,7 @@ class NewUserMetadataViewModel : ViewModel() { nip05.value = it.info.nip05 ?: "" lnAddress.value = it.info.lud16 ?: "" lnURL.value = it.info.lud06 ?: "" + clinkOffer.value = it.info.clinkOffer ?: "" } twitter.value = "" @@ -124,6 +126,7 @@ class NewUserMetadataViewModel : ViewModel() { nip05 = nip05.value, lnAddress = lnAddress.value, lnURL = lnURL.value, + clinkOffer = clinkOffer.value, ) val identities = @@ -149,6 +152,7 @@ class NewUserMetadataViewModel : ViewModel() { nip05.value = "" lnAddress.value = "" lnURL.value = "" + clinkOffer.value = "" twitter.value = "" github.value = "" mastodon.value = "" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index e7145aa8cc..e39f2e4334 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -70,6 +70,7 @@ import com.vitorpamplona.amethyst.commons.richtext.Base64Segment import com.vitorpamplona.amethyst.commons.richtext.BechSegment import com.vitorpamplona.amethyst.commons.richtext.BlossomUriSegment import com.vitorpamplona.amethyst.commons.richtext.CashuSegment +import com.vitorpamplona.amethyst.commons.richtext.ClinkOfferSegment import com.vitorpamplona.amethyst.commons.richtext.EmailSegment import com.vitorpamplona.amethyst.commons.richtext.EmojiSegment import com.vitorpamplona.amethyst.commons.richtext.HashIndexEventSegment @@ -108,6 +109,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.note.creators.invoice.ClinkOfferPreview import com.vitorpamplona.amethyst.ui.note.creators.invoice.MayBeInvoicePreview import com.vitorpamplona.amethyst.ui.note.toShortDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -503,6 +505,10 @@ private fun RenderWordWithoutPreview( // as a wall of base64. is CashuSegment -> CashuPreview(word.segmentText, accountViewModel) + // Decoding is local and the network round-trip only fires on the Pay tap, + // so the offer card is safe to render even in the no-preview path. + is ClinkOfferSegment -> ClinkOfferPreview(word.offer, accountViewModel) + is EmailSegment -> ClickableEmail(word.segmentText) is SecretEmoji -> Text(word.segmentText) @@ -549,6 +555,7 @@ private fun RenderWordWithPreview( is InvoiceSegment -> MayBeInvoicePreview(word.segmentText, accountViewModel) is WithdrawSegment -> MayBeWithdrawal(word.segmentText, accountViewModel) is CashuSegment -> CashuPreview(word.segmentText, accountViewModel) + is ClinkOfferSegment -> ClinkOfferPreview(word.offer, accountViewModel) is EmailSegment -> ClickableEmail(word.segmentText) is SecretEmoji -> DisplaySecretEmoji(word, state, callbackUri, true, quotesLeft, backgroundColor, accountViewModel, nav) is MathSegment -> LatexEquation(word.latex, word.displayMode, word.leading, word.trailing) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 95264c003d..b6b99019e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -197,6 +197,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.NewHlsVideoScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddCashuWalletScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddClinkDebitWalletScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddNwcWalletScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddWalletScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.CashuWalletScreen @@ -317,6 +318,7 @@ fun BuildNavigation( composableFromEnd { AddWalletScreen(accountViewModel, nav) } composableFromEndArgs { AddNwcWalletScreen(accountViewModel, nav, it.nip47) } composableFromEnd { AddCashuWalletScreen(accountViewModel, nav) } + composableFromEndArgs { AddClinkDebitWalletScreen(accountViewModel, nav, it.ndebit) } composableFromEnd { CashuWalletScreen(accountViewModel, nav) } composableFromEnd { CashuWalletSettingsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 9c592b4e8a..748a84b465 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -210,6 +210,10 @@ sealed class Route { @Serializable object WalletAddCashu : Route() + @Serializable data class WalletAddClinkDebit( + val ndebit: String? = null, + ) : Route() + @Serializable object CashuWallet : Route() @Serializable object CashuWalletSettings : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt new file mode 100644 index 0000000000..ed8bb6d80d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt @@ -0,0 +1,287 @@ +/* + * 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.ui.note.creators.invoice + +import android.widget.Toast +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons +import com.vitorpamplona.amethyst.commons.hashtags.Lightning +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.service.ClinkOfferPayer +import com.vitorpamplona.amethyst.ui.components.util.setText +import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.subtleBorder +import com.vitorpamplona.quartz.experimental.clink.common.SatRange +import com.vitorpamplona.quartz.experimental.clink.offers.OfferErrorCode +import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser +import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer +import com.vitorpamplona.quartz.experimental.clink.pointers.OfferPriceType +import kotlinx.coroutines.launch + +/** + * Inline card for a CLINK Offers pointer (`noffer1…`) found in a note. Tapping "Pay" + * runs the offer round-trip ([ClinkOfferPayer]) to fetch a fresh BOLT-11 over Nostr, + * then pays it through the user's default payment source (confirmed for in-app wallets, + * see [InvoicePaymentDispatcher]). + */ +@Composable +fun ClinkOfferPreview( + offer: NOffer, + accountViewModel: AccountViewModel, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val clipboard = LocalClipboard.current + + var requesting by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + var payingInvoice by remember { mutableStateOf(null) } + var amountInput by remember { mutableStateOf("") } + var needsAmount by remember { mutableStateOf(offer.priceType == OfferPriceType.SPONTANEOUS) } + var amountRange by remember { mutableStateOf(null) } + // The pointer actually paid: starts as the rendered offer, swapped if the service + // replies "Expired or Moved" (code 3) with a replacement noffer. + var activeOffer by remember(offer) { mutableStateOf(offer) } + + errorMessage?.let { + ErrorMessageDialog( + title = stringRes(context, R.string.error_dialog_pay_invoice_error), + textContent = it, + onDismiss = { errorMessage = null }, + ) + } + + InvoicePaymentDispatcher( + bolt11 = payingInvoice, + accountViewModel = accountViewModel, + onClear = { payingInvoice = null }, + onError = { errorMessage = it }, + ) + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 20.dp, top = 10.dp, bottom = 10.dp) + .clip(shape = QuoteBorder) + .border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(20.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 10.dp), + ) { + Icon( + imageVector = CustomHashTagIcons.Lightning, + contentDescription = null, + modifier = Size20Modifier, + tint = Color.Unspecified, + ) + + Text( + text = stringRes(R.string.clink_lightning_offer), + fontSize = 20.sp, + fontWeight = FontWeight.W500, + modifier = Modifier.padding(start = 10.dp), + ) + + Spacer(modifier = Modifier.weight(1f)) + + val copiedMessage = stringRes(R.string.copied_to_clipboard) + IconButton( + onClick = { + scope.launch { + clipboard.setText(activeOffer.encode()) + Toast.makeText(context, copiedMessage, Toast.LENGTH_SHORT).show() + } + }, + ) { + Icon( + symbol = MaterialSymbols.ContentCopy, + contentDescription = stringRes(R.string.copy_to_clipboard), + tint = MaterialTheme.colorScheme.primary, + modifier = Size20Modifier, + ) + } + } + + HorizontalDivider(thickness = DividerThickness) + + // FIXED offers display their preset price; SPONTANEOUS offers (and the default + // when the pointer omits a price type) require the payer to enter an amount. + // Reflect the pointer actually being charged (which may have changed if the + // service redirected us to a replacement noffer via "Expired or Moved"). + val effectiveType = activeOffer.priceType + + if (effectiveType == OfferPriceType.FIXED) { + activeOffer.price?.let { + Text( + text = "$it ${stringRes(id = R.string.sats)}", + fontSize = 25.sp, + fontWeight = FontWeight.W500, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + ) + } + } + + if (needsAmount) { + OutlinedTextField( + value = amountInput, + onValueChange = { new -> amountInput = new.filter(Char::isDigit) }, + label = { Text(stringRes(R.string.clink_offer_amount_sats)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + supportingText = + amountRange?.let { range -> + val min = range.min + val max = range.max + if (min != null && max != null) { + { Text(stringRes(R.string.clink_offer_amount_range, min.toString(), max.toString())) } + } else { + null + } + }, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + ) + } + + val amountRequired = needsAmount + + suspend fun runOfferRequest( + useOffer: NOffer, + followMoved: Boolean, + ) { + val amount = if (amountRequired) amountInput.toLongOrNull() else useOffer.price + + val response = ClinkOfferPayer.requestInvoice(accountViewModel.account, useOffer, amountSats = amount) + + val bolt11 = response?.bolt11 + val movedTo = + if (response?.code == OfferErrorCode.EXPIRED_OR_MOVED && followMoved) { + response.latest?.let { ClinkPointerParser.parse(it) as? NOffer } + } else { + null + } + + when { + bolt11 != null -> { + requesting = false + payingInvoice = bolt11 + } + // Follow a relocated offer once, paying the replacement pointer. + movedTo != null -> { + activeOffer = movedTo + runOfferRequest(movedTo, followMoved = false) + } + response?.code == OfferErrorCode.INVALID_AMOUNT -> { + // Reveal the amount field (or refine it) with the service's range. + requesting = false + needsAmount = true + amountRange = response.range + errorMessage = + response.error?.takeIf { it.isNotBlank() } + ?: stringRes(context, R.string.clink_offer_invalid_amount) + } + else -> { + requesting = false + errorMessage = + response?.error?.takeIf { it.isNotBlank() } + ?: stringRes(context, R.string.error_dialog_pay_invoice_error) + } + } + } + + Button( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + enabled = !requesting && (!amountRequired || (amountInput.toLongOrNull() ?: 0L) > 0L), + onClick = { + requesting = true + scope.launch { runOfferRequest(activeOffer, followMoved = true) } + }, + shape = QuoteBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text( + text = stringRes(if (requesting) R.string.clink_requesting_invoice else R.string.pay), + color = Color.White, + fontSize = 20.sp, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePaymentDispatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePaymentDispatcher.kt new file mode 100644 index 0000000000..3487f7fd44 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePaymentDispatcher.kt @@ -0,0 +1,146 @@ +/* + * 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.ui.note.creators.invoice + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource +import com.vitorpamplona.amethyst.ui.note.payViaIntent +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.lightning.LnInvoiceUtil +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse + +/** + * Pays a single BOLT-11 from an in-post card (offer card, invoice card) through the + * user's selected default payment source. + * + * Unlike the zap button — a deliberate small-amount tap that fires immediately — a + * card "Pay" can be a larger or variable amount, so an **in-app** payment (NWC or CLINK + * debit) is gated behind a confirmation dialog. The external-wallet path needs no extra + * confirmation: the wallet app presents its own. + * + * Drive it from a nullable `bolt11` state: set it to trigger, [onClear] resets it. + */ +@Composable +fun InvoicePaymentDispatcher( + bolt11: String?, + accountViewModel: AccountViewModel, + onClear: () -> Unit, + onError: (String) -> Unit, + onSuccess: () -> Unit = {}, +) { + if (bolt11 == null) return + val context = LocalContext.current + + val source = remember(bolt11) { accountViewModel.account.settings.defaultPaymentSource() } + + if (source == null) { + // No in-app wallet configured -> hand off to an external wallet app (it confirms). + LaunchedEffect(bolt11) { + payViaIntent(bolt11, context, onSuccess, onError) + onClear() + } + return + } + + val amountSats = + remember(bolt11) { + try { + LnInvoiceUtil.getAmountInSats(bolt11).toLong().takeIf { it > 0 } + } catch (_: Exception) { + null + } + } + + ConfirmPaymentDialog( + amountSats = amountSats, + sourceName = source.name, + onConfirm = { + when (source) { + is PaymentSource.Nwc -> + accountViewModel.sendZapPaymentRequestFor(bolt11, null) { response -> + when (response) { + is PayInvoiceSuccessResponse -> onSuccess() + is PayInvoiceErrorResponse -> + onError( + response.error?.message + ?: response.error?.code?.toString() + ?: stringRes(context, R.string.error_parsing_error_message), + ) + else -> {} + } + } + + is PaymentSource.ClinkDebit -> + accountViewModel.payInvoiceViaClinkDebit(source.wallet.pointer, bolt11) { response -> + if (response?.isOk() == true) { + onSuccess() + } else { + onError( + response?.failureDetail() + ?: stringRes(context, R.string.clink_debit_no_response), + ) + } + } + } + onClear() + }, + onDismiss = onClear, + ) +} + +@Composable +private fun ConfirmPaymentDialog( + amountSats: Long?, + sourceName: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val message = + if (amountSats != null) { + val amountText = "$amountSats ${stringRes(context, R.string.sats)}" + stringRes(context, R.string.clink_confirm_pay_amount_via_source, amountText, sourceName) + } else { + stringRes(context, R.string.clink_confirm_pay_via_source, sourceName) + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.clink_confirm_payment_title)) }, + text = { Text(message) }, + confirmButton = { + Button(onClick = onConfirm) { Text(stringRes(R.string.pay)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePreview.kt index ae6c23419f..23b5be1417 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePreview.kt @@ -54,7 +54,6 @@ import com.vitorpamplona.amethyst.service.lnurl.CachedLnInvoiceParser import com.vitorpamplona.amethyst.service.lnurl.InvoiceAmount import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog -import com.vitorpamplona.amethyst.ui.note.payViaIntent import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -89,7 +88,7 @@ fun MayBeInvoicePreview( LoadValueFromInvoice(lnbcWord = lnbcWord) { invoiceAmount -> CrossfadeIfEnabled(targetState = invoiceAmount, label = "MayBeInvoicePreview", accountViewModel = accountViewModel) { if (it != null) { - InvoicePreview(it.invoice, it.amount) + InvoicePreview(it.invoice, it.amount, accountViewModel) } else { Text( text = lnbcWord, @@ -104,10 +103,12 @@ fun MayBeInvoicePreview( fun InvoicePreview( lnInvoice: String, amount: String?, + accountViewModel: AccountViewModel, ) { val context = LocalContext.current var showErrorMessageDialog by remember { mutableStateOf(null) } + var payingInvoice by remember { mutableStateOf(null) } if (showErrorMessageDialog != null) { ErrorMessageDialog( @@ -117,6 +118,13 @@ fun InvoicePreview( ) } + InvoicePaymentDispatcher( + bolt11 = payingInvoice, + accountViewModel = accountViewModel, + onClear = { payingInvoice = null }, + onError = { showErrorMessageDialog = it }, + ) + Column( modifier = Modifier @@ -172,7 +180,7 @@ fun InvoicePreview( Modifier .fillMaxWidth() .padding(vertical = 10.dp), - onClick = { payViaIntent(lnInvoice, context, { }) { showErrorMessageDialog = it } }, + onClick = { payingInvoice = lnInvoice }, shape = QuoteBorder, colors = ButtonDefaults.buttonColors( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index fc1b395991..7b6182f0ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.privacyOptions.EmptyRoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder +import com.vitorpamplona.amethyst.service.ClinkDebitPayer import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor @@ -88,6 +89,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.ReloadMintRequest import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow +import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent @@ -2034,6 +2037,22 @@ class AccountViewModel( onSent() } + /** + * Pays a single BOLT-11 through a CLINK debit pointer (kind 21002) — the debit-rail + * counterpart of [sendZapPaymentRequestFor]. [onResult] receives the decrypted + * response (`isOk()` with optional preimage, or a GFY failure), or null on timeout, + * delivered on the main dispatcher so UI callbacks (toasts, dialogs) are safe. + * Untested end-to-end. + */ + fun payInvoiceViaClinkDebit( + pointer: NDebit, + bolt11: String, + onResult: (DebitResponse?) -> Unit, + ) = launchSigner { + val response = ClinkDebitPayer.payInvoice(account, pointer, bolt11) + withContext(Dispatchers.Main) { onResult(response) } + } + fun getInteractiveStoryReadingState(dATag: String): AddressableNote = LocalCache.getOrCreateAddressableNote(InteractiveStoryReadingStateEvent.createAddress(account.signer.pubKey, dATag)) fun updateInteractiveStoryReadingState( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt index b112e5264a..02b4cdc9ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription @@ -379,39 +380,57 @@ fun DvmPaymentActions( if (invoice != null) { val context = LocalContext.current Button(onClick = { - if (accountViewModel.account.nip47SignerState.hasWalletConnectSetup()) { - accountViewModel.sendZapPaymentRequestFor( - bolt11 = invoice, - zappedNote = null, - onSent = { - onStatusUpdate(nwcPaymentRequest) - }, - onResponse = { response -> + when (val source = accountViewModel.account.settings.defaultPaymentSource()) { + is PaymentSource.ClinkDebit -> { + onStatusUpdate(nwcPaymentRequest) + accountViewModel.payInvoiceViaClinkDebit(source.wallet.pointer, invoice) { response -> onStatusUpdate( - if (response is PayInvoiceErrorResponse) { - stringRes( - context, - R.string.wallet_connect_pay_invoice_error_error, - response.error?.message - ?: response.error?.code?.toString() ?: "Error parsing error message", - ) - } else { + if (response?.isOk() == true) { thankYou + } else { + response?.error?.takeIf { it.isNotBlank() } + ?: stringRes(context, R.string.clink_debit_no_response) }, ) - }, - ) - } else { - payViaIntent( - invoice, - context, - onPaid = { - onStatusUpdate(thankYou) - }, - onError = { - onStatusUpdate(it) - }, - ) + } + } + + is PaymentSource.Nwc -> { + accountViewModel.sendZapPaymentRequestFor( + bolt11 = invoice, + zappedNote = null, + onSent = { + onStatusUpdate(nwcPaymentRequest) + }, + onResponse = { response -> + onStatusUpdate( + if (response is PayInvoiceErrorResponse) { + stringRes( + context, + R.string.wallet_connect_pay_invoice_error_error, + response.error?.message + ?: response.error?.code?.toString() ?: "Error parsing error message", + ) + } else { + thankYou + }, + ) + }, + ) + } + + null -> { + payViaIntent( + invoice, + context, + onPaid = { + onStatusUpdate(thankYou) + }, + onError = { + onStatusUpdate(it) + }, + ) + } } }) { val amountInInvoice = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt index 2b2ac59469..38337579a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.actions.InformationDialog import com.vitorpamplona.amethyst.ui.components.util.LongPressCopyText @@ -109,22 +110,38 @@ fun DisplayLNAddress( lud16, user, accountViewModel, - onSuccess = { + onSuccess = { invoice -> zapExpanded = false - // pay directly - if (accountViewModel.account.nip47SignerState.hasWalletConnectSetup()) { - accountViewModel.sendZapPaymentRequestFor(it, null) { response -> - if (response is PayInvoiceSuccessResponse) { - showInfoMessageDialog = stringRes(context, R.string.payment_successful) - } else if (response is PayInvoiceErrorResponse) { - showErrorMessageDialog = - response.error?.message - ?: response.error?.code?.toString() - ?: stringRes(context, R.string.error_parsing_error_message) + // pay directly through the selected default payment source + when (val source = accountViewModel.account.settings.defaultPaymentSource()) { + is PaymentSource.ClinkDebit -> { + accountViewModel.payInvoiceViaClinkDebit(source.wallet.pointer, invoice) { response -> + if (response?.isOk() == true) { + showInfoMessageDialog = stringRes(context, R.string.payment_successful) + } else { + showErrorMessageDialog = + response?.error?.takeIf { it.isNotBlank() } + ?: stringRes(context, R.string.clink_debit_no_response) + } } } - } else { - payViaIntent(it, context, { zapExpanded = false }, { showErrorMessageDialog = it }) + + is PaymentSource.Nwc -> { + accountViewModel.sendZapPaymentRequestFor(invoice, null) { response -> + if (response is PayInvoiceSuccessResponse) { + showInfoMessageDialog = stringRes(context, R.string.payment_successful) + } else if (response is PayInvoiceErrorResponse) { + showErrorMessageDialog = + response.error?.message + ?: response.error?.code?.toString() + ?: stringRes(context, R.string.error_parsing_error_message) + } + } + } + + null -> { + payViaIntent(invoice, context, { zapExpanded = false }, { showErrorMessageDialog = it }) + } } }, onError = { title, message -> accountViewModel.toastManager.toast(title, message) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index f10296ca4a..d066d9b143 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -21,6 +21,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header import android.content.ClipData +import android.util.LruCache +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row @@ -28,15 +32,19 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -52,6 +60,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.nip01Core.UserInfo import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State import com.vitorpamplona.amethyst.commons.util.toShortDisplay import com.vitorpamplona.amethyst.model.User @@ -63,6 +72,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.DrawPlayName import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol +import com.vitorpamplona.amethyst.ui.note.creators.invoice.ClinkOfferPreview import com.vitorpamplona.amethyst.ui.note.lastSeenSentence import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -71,13 +81,18 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.UserApp import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.badges.DisplayBadges import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.identity.UserExternalIdentitiesViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.Size15Modifier +import com.vitorpamplona.amethyst.ui.theme.Size16Modifier import com.vitorpamplona.amethyst.ui.theme.SpacedBy3dp import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser +import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id import com.vitorpamplona.quartz.nip39ExtIdentities.GitHubIdentity import com.vitorpamplona.quartz.nip39ExtIdentities.IdentityClaimTag import com.vitorpamplona.quartz.nip39ExtIdentities.MastodonIdentity @@ -87,6 +102,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext private const val IDENTITY_ICON_CACHE_KEY = 0 @@ -218,6 +234,8 @@ fun DrawAdditionalInfo( } DisplayLNAddress(lud16, baseUser, accountViewModel, nav) + DisplayClinkOffer(user, accountViewModel) + DisplayPaymentTargets(baseUser, accountViewModel) val website = user.info.website @@ -377,3 +395,113 @@ fun getIdentityClaimDescription(identity: IdentityClaimTag): Int = is GitHubIdentity -> R.string.github else -> R.string.github } + +/** + * Process-wide cache of NIP-05 `.well-known` `clink_offer` lookups, keyed by the + * lowercased nip05 address (NIP-05 identifiers are case-insensitive). Without it, every + * profile visit (and every relay-pushed kind-0 refresh while a profile is open) would + * re-fetch the domain's nostr.json. Caches "no offer" results too so profiles without one + * aren't re-hit. A [ResolvedClinkOffer] wrapper holds the nullable parsed pointer + * (LruCache can't store nulls); absence means "not fetched yet". + */ +private class ResolvedClinkOffer( + val noffer: NOffer?, +) + +private val clinkOfferNip05Cache = LruCache(256) + +/** + * Shows a profile's advertised CLINK Offer as a compact, tappable chip (preferring the kind-0 + * `clink_offer` field, falling back to the NIP-05 `.well-known` `clink_offer`, cached). Tapping + * the chip expands the payable [ClinkOfferPreview] card — collapsed by default so the full card + * isn't shown until the user opts in. + */ +@Composable +private fun DisplayClinkOffer( + userInfo: UserInfo, + accountViewModel: AccountViewModel, +) { + val kind0Offer = + remember(userInfo) { + userInfo.info.clinkOffer()?.let { ClinkPointerParser.parse(it) as? NOffer } + } + + var offer by remember(userInfo) { mutableStateOf(kind0Offer) } + + val nip05 = userInfo.info.nip05 + LaunchedEffect(kind0Offer, nip05) { + if (kind0Offer != null) { + offer = kind0Offer + return@LaunchedEffect + } + // Fall back to the NIP-05 .well-known clink_offer (cached per address). + val id = nip05?.let { Nip05Id.parse(it) } + offer = + if (id != null && nip05 != null) { + // Distinguish "cache miss" from a cached "no offer" (null) so we don't refetch. + val cacheKey = nip05.lowercase() + val cached = clinkOfferNip05Cache.get(cacheKey) + if (cached != null) { + cached.noffer + } else { + val fetched = withContext(Dispatchers.IO) { accountViewModel.nip05ClientBuilder().loadClinkOffer(id) } + val parsed = fetched?.let { ClinkPointerParser.parse(it) as? NOffer } + clinkOfferNip05Cache.put(cacheKey, ResolvedClinkOffer(parsed)) + parsed + } + } else { + null + } + } + + offer?.let { resolved -> + var expanded by remember(resolved) { mutableStateOf(false) } + Column { + ClinkOfferChip(expanded) { expanded = !expanded } + if (expanded) { + ClinkOfferPreview(resolved, accountViewModel) + } + } + } +} + +/** + * Compact, payment-target-style chip for a profile's CLINK Offer. Tapping it toggles the + * payable [ClinkOfferPreview] card open/closed; collapsed by default so the profile mirrors the + * other payment-target chips instead of showing the full card up front. + */ +@Composable +private fun ClinkOfferChip( + expanded: Boolean, + onClick: () -> Unit, +) { + val label = stringRes(R.string.clink_lightning_offer) + Surface( + shape = RoundedCornerShape(50), + color = BitcoinOrange.copy(alpha = 0.10f), + border = BorderStroke(1.dp, BitcoinOrange.copy(alpha = if (expanded) 0.6f else 0.35f)), + modifier = + Modifier + .padding(vertical = 4.dp) + .clickable(onClick = onClick), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + ) { + Icon( + symbol = MaterialSymbols.Bolt, + contentDescription = label, + tint = BitcoinOrange, + modifier = Size16Modifier, + ) + Text( + text = label, + color = BitcoinOrange, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddClinkDebitWalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddClinkDebitWalletScreen.kt new file mode 100644 index 0000000000..9de69a8906 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddClinkDebitWalletScreen.kt @@ -0,0 +1,214 @@ +/* + * 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.ui.screen.loggedIn.wallet + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.components.util.getText +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.painterRes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size24Modifier +import kotlinx.coroutines.launch + +/** + * Adds a CLINK Debits pointer (`ndebit1…`) as a spend-only payment source. Unlike NWC + * there is no secret to paste — authorization is the account's own identity, pre-approved + * on the wallet service — so this screen only collects a name and the pointer. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddClinkDebitWalletScreen( + accountViewModel: AccountViewModel, + nav: INav, + ndebit: String? = null, +) { + val walletViewModel: WalletViewModel = viewModel() + walletViewModel.init(accountViewModel) + + var walletName by remember { mutableStateOf("") } + var ndebitUri by remember { mutableStateOf(ndebit.orEmpty()) } + var error by remember { mutableStateOf(null) } + var qrScanning by remember { mutableStateOf(false) } + + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringRes(R.string.wallet_add_clink_title)) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon( + symbol = MaterialSymbols.AutoMirrored.ArrowBack, + contentDescription = stringRes(R.string.back), + ) + } + }, + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .consumeWindowInsets(padding) + .imePadding() + .padding(horizontal = 16.dp), + ) { + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = walletName, + onValueChange = { walletName = it }, + label = { Text(stringRes(R.string.wallet_name)) }, + placeholder = { Text(stringRes(R.string.wallet_name_hint)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + // Paste from clipboard + IconButton( + onClick = { + scope.launch { + val clipText = clipboardManager.getText() + if (clipText != null) { + ndebitUri = clipText + error = null + } + } + }, + ) { + Icon( + symbol = MaterialSymbols.ContentPaste, + contentDescription = stringRes(id = R.string.paste_from_clipboard), + modifier = Size24Modifier, + tint = MaterialTheme.colorScheme.primary, + ) + } + + // QR code scanner + IconButton(onClick = { qrScanning = true }) { + Icon( + painter = painterRes(R.drawable.ic_qrcode, 3), + contentDescription = stringRes(id = R.string.accessibility_scan_qr_code), + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + + if (qrScanning) { + SimpleQrCodeScanner { + qrScanning = false + if (!it.isNullOrEmpty()) { + ndebitUri = it + error = null + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = ndebitUri, + onValueChange = { + ndebitUri = it + error = null + }, + label = { Text(stringRes(R.string.wallet_paste_ndebit)) }, + placeholder = { Text("ndebit1...") }, + minLines = 3, + maxLines = 5, + modifier = Modifier.fillMaxWidth(), + ) + + if (error != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = error!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + val invalidMessage = stringRes(R.string.wallet_add_clink_invalid) + Button( + onClick = { + if (walletViewModel.addClinkDebitWallet(walletName.trim(), ndebitUri.trim())) { + nav.popBack() + } else { + error = invalidMessage + } + }, + enabled = ndebitUri.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringRes(R.string.wallet_save)) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddWalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddWalletScreen.kt index 0c4d4e90de..2c33e6523f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddWalletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddWalletScreen.kt @@ -105,6 +105,12 @@ fun AddWalletScreen( description = stringRes(R.string.wallet_add_cashu_description), onClick = { nav.popUpTo(Route.WalletAddCashu, Route.WalletAdd::class) }, ) + WalletTypeCard( + icon = MaterialSymbols.Bolt, + title = stringRes(R.string.wallet_add_clink_title), + description = stringRes(R.string.wallet_add_clink_description), + onClick = { nav.popUpTo(Route.WalletAddClinkDebit(), Route.WalletAdd::class) }, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ClinkBudgetDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ClinkBudgetDialog.kt new file mode 100644 index 0000000000..735a2f70ae --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ClinkBudgetDialog.kt @@ -0,0 +1,135 @@ +/* + * 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.ui.screen.loggedIn.wallet + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency + +/** + * Asks the user for a CLINK debit spending budget: an amount and a cadence (one-time, or + * recurring per day/week/month). [onConfirm] receives the amount in sats and the chosen + * [DebitFrequency] (null for one-time). + */ +@Composable +fun ClinkBudgetDialog( + onConfirm: (amountSats: Long, frequency: DebitFrequency?) -> Unit, + onDismiss: () -> Unit, +) { + var amount by remember { mutableStateOf("") } + var cadence by remember { mutableStateOf(BudgetCadence.ONE_TIME) } + + val parsedAmount = amount.toLongOrNull() ?: 0L + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.clink_budget_title)) }, + text = { + Column { + OutlinedTextField( + value = amount, + onValueChange = { new -> amount = new.filter(Char::isDigit) }, + label = { Text(stringRes(R.string.clink_budget_amount_sats)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + + BudgetCadence.entries.forEach { option -> + CadenceRow( + option = option, + selected = cadence == option, + onSelect = { cadence = option }, + ) + } + } + }, + confirmButton = { + Button( + enabled = parsedAmount > 0, + onClick = { onConfirm(parsedAmount, cadence.toFrequency()) }, + ) { + Text(stringRes(R.string.clink_budget_request)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) } + }, + ) +} + +@Composable +private fun CadenceRow( + option: BudgetCadence, + selected: Boolean, + onSelect: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .height(44.dp) + .selectable(selected = selected, onClick = onSelect), + ) { + RadioButton(selected = selected, onClick = onSelect) + Text(stringRes(option.labelRes)) + } +} + +private enum class BudgetCadence( + val labelRes: Int, +) { + ONE_TIME(R.string.clink_budget_one_time), + DAILY(R.string.clink_budget_daily), + WEEKLY(R.string.clink_budget_weekly), + MONTHLY(R.string.clink_budget_monthly), + ; + + fun toFrequency(): DebitFrequency? = + when (this) { + ONE_TIME -> null + DAILY -> DebitFrequency(1, DebitFrequency.UNIT_DAY) + WEEKLY -> DebitFrequency(1, DebitFrequency.UNIT_WEEK) + MONTHLY -> DebitFrequency(1, DebitFrequency.UNIT_MONTH) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt index cf096aa501..2f99a9c5b9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet +import android.widget.Toast import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -66,6 +67,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -82,6 +84,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency import kotlinx.coroutines.launch import java.text.NumberFormat import androidx.compose.material3.Icon as Material3Icon @@ -214,6 +217,7 @@ private fun MultiWalletHomeContent( cashuMintCount: Int, ) { val walletInfoList by walletViewModel.walletInfoList.collectAsState() + val context = LocalContext.current LaunchedEffect(Unit) { walletViewModel.fetchAllBalances() @@ -234,8 +238,11 @@ private fun MultiWalletHomeContent( WalletCard( walletInfo = walletInfo, onSelect = { - walletViewModel.selectWallet(walletInfo.walletId) - nav.nav(Route.WalletDetail(walletInfo.walletId)) + // The detail screen is NWC-only (balance/transactions); debits have neither. + if (walletInfo.canShowBalance) { + walletViewModel.selectWallet(walletInfo.walletId) + nav.nav(Route.WalletDetail(walletInfo.walletId)) + } }, onSetDefault = { walletViewModel.setDefaultWallet(walletInfo.walletId) @@ -246,6 +253,24 @@ private fun MultiWalletHomeContent( onRemove = { walletViewModel.removeWallet(walletInfo.walletId) }, + // Spending-budget authorization is a CLINK-debit-only capability. + onSetBudget = + if (!walletInfo.canShowBalance) { + { amount, frequency -> + walletViewModel.requestDebitBudget(walletInfo.walletId, amount, frequency) { response -> + val error = response?.failureDetail() + val msg = + when { + response?.isOk() == true -> context.getString(R.string.clink_budget_approved) + !error.isNullOrBlank() -> error + else -> context.getString(R.string.clink_debit_no_response) + } + Toast.makeText(context, msg, Toast.LENGTH_LONG).show() + } + } + } else { + null + }, ) } @@ -285,9 +310,21 @@ private fun WalletCard( onSetDefault: () -> Unit, onRename: (String) -> Unit, onRemove: () -> Unit, + onSetBudget: ((Long, DebitFrequency?) -> Unit)? = null, ) { var showRemoveDialog by remember { mutableStateOf(false) } var showRenameDialog by remember { mutableStateOf(false) } + var showBudgetDialog by remember { mutableStateOf(false) } + + if (showBudgetDialog && onSetBudget != null) { + ClinkBudgetDialog( + onConfirm = { amount, frequency -> + showBudgetDialog = false + onSetBudget(amount, frequency) + }, + onDismiss = { showBudgetDialog = false }, + ) + } if (showRemoveDialog) { AlertDialog( @@ -325,7 +362,7 @@ private fun WalletCard( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onSelect), + .clickable(enabled = walletInfo.canShowBalance, onClick = onSelect), shape = RoundedCornerShape(16.dp), border = if (walletInfo.isDefault) { @@ -377,8 +414,14 @@ private fun WalletCard( } } - // Balance - if (walletInfo.isLoading && walletInfo.balanceSats == null) { + // Balance — debits are spend-only, so show a capability badge instead. + if (!walletInfo.canShowBalance) { + Text( + text = stringRes(R.string.clink_debit_pay_only), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else if (walletInfo.isLoading && walletInfo.balanceSats == null) { CircularProgressIndicator(modifier = Modifier.size(24.dp)) } else { Column(horizontalAlignment = Alignment.End) { @@ -440,6 +483,16 @@ private fun WalletCard( Text(stringRes(R.string.wallet_rename), style = MaterialTheme.typography.bodySmall) } + if (onSetBudget != null) { + OutlinedButton( + onClick = { showBudgetDialog = true }, + modifier = Modifier.height(36.dp), + shape = RoundedCornerShape(8.dp), + ) { + Text(stringRes(R.string.clink_budget_set), style = MaterialTheme.typography.bodySmall) + } + } + Spacer(modifier = Modifier.weight(1f)) IconButton( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt index e335b033a6..e988daeed4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -22,9 +22,15 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.ClinkDebitPayer import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency +import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse +import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod @@ -94,6 +100,8 @@ data class WalletInfo( val isDefault: Boolean = false, val isLoading: Boolean = false, val error: String? = null, + // CLINK debits are spend-only: no balance/transactions to fetch or show. + val canShowBalance: Boolean = true, ) private const val NWC_TIMEOUT_MS = 30_000L @@ -111,23 +119,43 @@ class WalletViewModel : ViewModel() { private val _wallets = MutableStateFlow>(emptyList()) val wallets = _wallets.asStateFlow() + private val _debitWallets = MutableStateFlow>(emptyList()) + val debitWallets = _debitWallets.asStateFlow() + private val _defaultWalletId = MutableStateFlow(null) val defaultWalletId = _defaultWalletId.asStateFlow() val walletInfoList = - combine(_wallets, _defaultWalletId, walletInfoMap) { wallets, defaultId, infoMap -> - wallets.map { wallet -> - val info = infoMap[wallet.id] - WalletInfo( - walletId = wallet.id, - name = wallet.name, - alias = info?.alias, - balanceSats = info?.balanceSats, - isDefault = wallet.id == defaultId || (defaultId == null && wallet == wallets.firstOrNull()), - isLoading = info?.isLoading == true, - error = info?.error, - ) - } + combine(_wallets, _debitWallets, _defaultWalletId, walletInfoMap) { wallets, debits, defaultId, infoMap -> + // The unified default falls back to the first source overall (NWC before debits). + val effectiveDefault = defaultId ?: wallets.firstOrNull()?.id ?: debits.firstOrNull()?.id + + val nwcRows = + wallets.map { wallet -> + val info = infoMap[wallet.id] + WalletInfo( + walletId = wallet.id, + name = wallet.name, + alias = info?.alias, + balanceSats = info?.balanceSats, + isDefault = wallet.id == effectiveDefault, + isLoading = info?.isLoading == true, + error = info?.error, + canShowBalance = true, + ) + } + + val debitRows = + debits.map { debit -> + WalletInfo( + walletId = debit.id, + name = debit.name, + isDefault = debit.id == effectiveDefault, + canShowBalance = false, + ) + } + + nwcRows + debitRows }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) // Selected wallet for detail view @@ -218,8 +246,9 @@ class WalletViewModel : ViewModel() { fun refreshWalletList() { val acc = account ?: return _wallets.value = acc.settings.nwcWallets.value - _defaultWalletId.value = acc.settings.defaultNwcWalletId.value - _hasWalletSetup.value = _wallets.value.isNotEmpty() + _debitWallets.value = acc.settings.clinkDebitWallets.value + _defaultWalletId.value = acc.settings.defaultPaymentSourceId.value + _hasWalletSetup.value = _wallets.value.isNotEmpty() || _debitWallets.value.isNotEmpty() } fun refreshWalletSetup() { @@ -258,16 +287,70 @@ class WalletViewModel : ViewModel() { fun setDefaultWallet(walletId: String) { val acc = account ?: return - acc.settings.setDefaultNwcWallet(walletId) - _defaultWalletId.value = walletId + // Only reflect the change locally if it actually persisted (the id must exist + // in one of the lists); otherwise the star and the stored default would diverge. + if (acc.settings.setDefaultPaymentSource(walletId)) { + _defaultWalletId.value = walletId + } } fun removeWallet(walletId: String) { val acc = account ?: return - acc.settings.removeNwcWallet(walletId) + if (_debitWallets.value.any { it.id == walletId }) { + acc.settings.removeClinkDebitWallet(walletId) + } else { + acc.settings.removeNwcWallet(walletId) + } refreshWalletList() } + /** Adds a CLINK debit pointer (`ndebit1…`) as a spend-only payment source. */ + fun addClinkDebitWallet( + name: String, + ndebit: String, + ): Boolean { + val acc = account ?: return false + val pointer = ClinkPointerParser.parse(ndebit.trim()) as? NDebit ?: return false + val entry = + ClinkDebitWalletEntryNorm( + id = + java.util.UUID + .randomUUID() + .toString(), + name = name.ifBlank { "Debit" }, + pointer = pointer, + ) + acc.settings.addClinkDebitWallet(entry) + refreshWalletList() + return true + } + + /** + * Asks a CLINK debit wallet to authorize a spending budget (kind-21002). Omit + * [frequency] for a one-time budget; otherwise it recurs every day/week/month. + * [onResult] reports the wallet's decision (ok, GFY error text, or null on timeout). + */ + fun requestDebitBudget( + walletId: String, + amountSats: Long, + frequency: DebitFrequency?, + onResult: (DebitResponse?) -> Unit, + ) { + val acc = account ?: return + val pointer = _debitWallets.value.firstOrNull { it.id == walletId }?.pointer ?: return + viewModelScope.launch { + // A malformed budget (e.g. an out-of-spec frequency unit) makes requestBudget throw; + // treat it as "no response" so the dialog dismisses instead of hanging on a spinner. + val response = + try { + ClinkDebitPayer.requestBudget(acc, pointer, amountSats, frequency) + } catch (_: IllegalArgumentException) { + null + } + onResult(response) + } + } + fun addWallet( name: String, uri: Nip47WalletConnect.Nip47URINorm, @@ -292,7 +375,11 @@ class WalletViewModel : ViewModel() { newName: String, ) { val acc = account ?: return - acc.settings.renameNwcWallet(walletId, newName) + if (_debitWallets.value.any { it.id == walletId }) { + acc.settings.renameClinkDebitWallet(walletId, newName) + } else { + acc.settings.renameNwcWallet(walletId, newName) + } refreshWalletList() } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index ad5044f7e9..a90bbac779 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -112,6 +112,30 @@ Logout Show More Lightning Invoice + CLINK Offer + Requesting invoice… + The debit service did not complete the payment. + Confirm payment + Pay %1$s via %2$s? + Pay this invoice via %1$s? + Amount (sats) + Enter a valid amount for this offer. + Allowed range: %1$s–%2$s sats + CLINK Offer (noffer) + Budget + Spending budget + Amount (sats) + Request + Budget approved + One-time + Daily + Weekly + Monthly + Pay only + CLINK Debit + Pay and zap from a wallet that pre-authorized your account. Spend only — no balance or history. + Invalid CLINK debit pointer. Expected an ndebit1… string. + Paste ndebit pointer Pay Lightning Tips Note to Receiver diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt index ed03deb8e7..a9b1ef80cd 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt @@ -31,9 +31,12 @@ import com.vitorpamplona.amethyst.commons.actions.FollowActions import com.vitorpamplona.amethyst.commons.actions.SearchActions import com.vitorpamplona.amethyst.commons.actions.ZapActions import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet +import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull import com.vitorpamplona.amethyst.commons.service.lnurl.LightningAddressResolver +import com.vitorpamplona.amethyst.service.ClinkDebitPayer import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeNewThreadFeedFilter +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -1408,7 +1411,7 @@ class AmethystAppFunctions { // "I zapped Alice 21 sats" instead of "here's a BOLT11 invoice // for you to paste somewhere." Falls back to manual when NWC // isn't set up or the wallet declines. - val nwc = payViaNwcOrNull(account, invoice, null) + val nwc = payViaDefaultSourceOrNull(account, invoice, null) return ZapResult( chain = "lightning", @@ -1633,7 +1636,7 @@ class AmethystAppFunctions { // Try NWC for every invoice that came back. Failed splits // stay as a manual invoice with nwcError set — the others // still go through. - val nwc = invoice?.let { payViaNwcOrNull(account, it, note) } + val nwc = invoice?.let { payViaDefaultSourceOrNull(account, it, note) } ZapInvoice( recipientNpub = req.recipient.pubkey?.let { NPub.create(it) }, recipientPubkeyHex = req.recipient.pubkey, @@ -1694,42 +1697,65 @@ class AmethystAppFunctions { ?.lnAddress() } - /** Internal result of [payViaNwcOrNull]. */ - private data class NwcOutcome( + /** Internal result of [payViaDefaultSourceOrNull]. */ + private data class PayOutcome( val success: Boolean, val preimage: String?, val errorMessage: String?, ) /** - * Try to pay [bolt11] through the active account's Nostr Wallet - * Connect setup. Returns null when no NWC wallet is configured — - * caller should fall back to surfacing the invoice for manual - * payment. Returns an outcome with [NwcOutcome.success] = true on - * a wallet-confirmed payment, false (with [NwcOutcome.errorMessage] - * set) on rejection or timeout. - * - * The wallet's response can take a few seconds; bounded by - * [NWC_PAYMENT_TIMEOUT_MS] so a hung wallet can't stall the - * dispatch. + * Try to pay [bolt11] through the account's selected default payment source — an NWC + * wallet or a CLINK debit. Returns null when no in-app source is configured (caller + * should fall back to surfacing the invoice for manual payment), otherwise an outcome + * with [PayOutcome.success] = true on a wallet-confirmed payment, or false (with + * [PayOutcome.errorMessage]) on rejection or timeout. */ - private suspend fun payViaNwcOrNull( + private suspend fun payViaDefaultSourceOrNull( account: com.vitorpamplona.amethyst.model.Account, bolt11: String, zappedNote: com.vitorpamplona.amethyst.model.Note?, - ): NwcOutcome? { - if (!account.nip47SignerState.hasWalletConnectSetup()) return null + ): PayOutcome? = + when (val source = account.settings.defaultPaymentSource()) { + is PaymentSource.Nwc -> payViaNwc(account, bolt11, zappedNote) + is PaymentSource.ClinkDebit -> payViaClinkDebit(account, source.wallet.pointer, bolt11) + null -> null + } + /** Pays [bolt11] via a CLINK debit pointer, mapping the kind-21002 reply to a [PayOutcome]. */ + private suspend fun payViaClinkDebit( + account: com.vitorpamplona.amethyst.model.Account, + pointer: NDebit, + bolt11: String, + ): PayOutcome { + val response = ClinkDebitPayer.payInvoice(account, pointer, bolt11) + return when { + response == null -> + PayOutcome(false, null, "CLINK debit wallet didn't respond within ${ClinkDebitPayer.DEFAULT_TIMEOUT_MS / 1000}s") + response.isOk() -> PayOutcome(true, response.preimage, null) + else -> + PayOutcome(false, null, response.error?.takeIf { it.isNotBlank() } ?: "debit declined (code ${response.code})") + } + } + + /** + * Pays [bolt11] via the default NWC wallet. The wallet's response can take a few + * seconds; bounded by [NWC_PAYMENT_TIMEOUT_MS] so a hung wallet can't stall dispatch. + */ + private suspend fun payViaNwc( + account: com.vitorpamplona.amethyst.model.Account, + bolt11: String, + zappedNote: com.vitorpamplona.amethyst.model.Note?, + ): PayOutcome { val deferred = CompletableDeferred() - // sendZapPaymentRequestFor fires onResponse exactly once when - // the wallet replies (success, error, or NwcError). On timeout - // we discard the late response. + // sendZapPaymentRequestFor fires onResponse exactly once when the wallet replies + // (success, error, or NwcError). On timeout we discard the late response. account.sendZapPaymentRequestFor(bolt11, zappedNote) { response -> if (!deferred.isCompleted) deferred.complete(response) } val response = withTimeoutOrNull(NWC_PAYMENT_TIMEOUT_MS) { deferred.await() } - ?: return NwcOutcome( + ?: return PayOutcome( success = false, preimage = null, errorMessage = @@ -1739,35 +1765,13 @@ class AmethystAppFunctions { return when (response) { is PayInvoiceSuccessResponse -> - NwcOutcome( - success = true, - preimage = response.result?.preimage, - errorMessage = null, - ) + PayOutcome(true, response.result?.preimage, null) is PayInvoiceErrorResponse -> - NwcOutcome( - success = false, - preimage = null, - errorMessage = - response.error?.message - ?: response.error?.code?.name - ?: "wallet returned an unspecified pay_invoice error", - ) + PayOutcome(false, null, response.error?.message ?: response.error?.code?.name ?: "wallet returned an unspecified pay_invoice error") is NwcErrorResponse -> - NwcOutcome( - success = false, - preimage = null, - errorMessage = - response.error?.message - ?: response.error?.code?.name - ?: "wallet returned an NWC error", - ) + PayOutcome(false, null, response.error?.message ?: response.error?.code?.name ?: "wallet returned an NWC error") else -> - NwcOutcome( - success = false, - preimage = null, - errorMessage = "Unexpected NWC response type: ${response::class.simpleName}", - ) + PayOutcome(false, null, "Unexpected NWC response type: ${response::class.simpleName}") } } diff --git a/cli/README.md b/cli/README.md index b90d86d480..1cd4fe0f82 100644 --- a/cli/README.md +++ b/cli/README.md @@ -244,6 +244,21 @@ $ amy relay publish-lists # broadcast updated kind:10002/10050/10051 | `amy marmot message react GID EVENT_ID EMOJI` | Publish a kind:7 reaction. | | `amy marmot message delete GID EVENT_ID …` | Publish a kind:5 deletion. | +### CLINK Offers + +| Command | What it does | +|---|---| +| `amy offer info NOFFER` | Decode a `noffer1…` pointer (pubkey, relays, price type/amount). Local, no network. | +| `amy offer request NOFFER [--amount SATS] [--timeout MS]` | kind:21001 round-trip: publish the request to the pointer's relays and print the returned BOLT11. `--amount` is required for spontaneous offers; fixed offers default to the pointer's price. | + +### CLINK Debits + +| Command | What it does | +|---|---| +| `amy debit info NDEBIT` | Decode an `ndebit1…` pointer (pubkey, relays, pointer id, session flag). Local, no network. | +| `amy debit pay NDEBIT BOLT11 [--amount SATS] [--timeout MS]` | kind:21002 round-trip: ask the pointed-to wallet to pay the invoice; print the preimage or the service's GFY error. | +| `amy debit budget NDEBIT --amount SATS [--frequency day\|week\|month] [--timeout MS]` | Authorize a spending budget; omit `--frequency` for a one-time budget. | + ### Wait-for-condition (`await`) Every `await` verb blocks until the condition holds, then prints the diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index f8c4a21ee6..b50689a36d 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -49,6 +49,7 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.selects.select @@ -310,6 +311,43 @@ class Context( return collected } + /** + * Publish [request] to [relays], then wait for the FIRST event matching [responseFilter] + * — a live reply that arrives after our own EOSE, which [drain] would miss (it returns at + * EOSE). Verifies and stores the reply. Returns it, or null on timeout; always tears the + * subscription down. Used for request/response round-trips (e.g. a CLINK offer invoice). + */ + suspend fun requestResponse( + request: Event, + relays: Set, + responseFilter: Filter, + timeoutMs: Long = 15_000, + ): Event? { + if (relays.isEmpty()) return null + val reply = CompletableDeferred() + val subId = newSubId() + val filters = relays.associateWith { listOf(responseFilter) } + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (!reply.isCompleted) reply.complete(event) + } + } + client.subscribe(subId, filters, listener) + return try { + publish(request, relays) + val event = withTimeoutOrNull(timeoutMs) { reply.await() } ?: return null + if (verifyAndStore(event)) event else null + } finally { + client.unsubscribe(subId) + } + } + /** * Verify [event]'s NIP-01 id+signature and, if valid, persist it * to [store]. Returns `true` when the event was accepted (and diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 1ea7b3d0e3..03b6ac97d8 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -182,6 +182,14 @@ private suspend fun dispatch(argv: Array): Int { Commands.zap(dataDir, tail) } + "offer" -> { + Commands.offer(dataDir, tail) + } + + "debit" -> { + Commands.debit(dataDir, tail) + } + else -> { System.err.println("unknown subcommand: $head") printUsage() @@ -363,6 +371,19 @@ private fun printUsage() { | [--comment X] [--anon|--private] event (must be in local store) | [--timeout SECS] | + |CLINK Offers: + | offer info NOFFER decode a noffer1… pointer (local, no network) + | offer request NOFFER [--amount SATS] kind:21001 round-trip: ask the service for a + | [--timeout MS] fresh BOLT11 (amount required for spontaneous + | offers; defaults to the pointer's fixed price) + | + |CLINK Debits: + | debit info NDEBIT decode an ndebit1… pointer (local, no network) + | debit pay NDEBIT BOLT11 [--amount SATS] kind:21002 round-trip: ask the wallet to pay the + | [--timeout MS] invoice; prints the preimage or a GFY error + | debit budget NDEBIT --amount SATS authorize a spending budget; omit --frequency + | [--frequency day|week|month] [--timeout MS] for a one-time budget + | |Search (NIP-50): | search user QUERY [--limit N] search kind:0 profiles | [--timeout SECS] diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Output.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Output.kt index 60b27319af..7f30d19349 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Output.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Output.kt @@ -63,11 +63,14 @@ object Output { fun error( code: String, detail: String? = null, + extra: Map = emptyMap(), ): Int { + val cleanExtra = extra.filterValues { it != null } when (mode) { Mode.JSON -> { - val payload = mutableMapOf("error" to code) + val payload = mutableMapOf("error" to code) if (detail != null) payload["detail"] = detail + payload.putAll(cleanExtra) System.err.println(mapper.writeValueAsString(payload)) } @@ -75,7 +78,9 @@ object Output { val color = Ansi.forStream(isStderr = true) val prefix = color.bold(color.red("error")) val codePart = color.yellow(code) - System.err.println(if (detail != null) "$prefix: $codePart: $detail" else "$prefix: $codePart") + val base = if (detail != null) "$prefix: $codePart: $detail" else "$prefix: $codePart" + val suffix = if (cleanExtra.isEmpty()) "" else cleanExtra.entries.joinToString(", ", " (", ")") { "${it.key}=${it.value}" } + System.err.println(base + suffix) } } return 1 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt index 446b3acf1c..527101e9ef 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt @@ -115,4 +115,14 @@ object Commands { dataDir: DataDir, tail: Array, ): Int = ZapCommand.dispatch(dataDir, tail) + + suspend fun offer( + dataDir: DataDir, + tail: Array, + ): Int = OfferCommands.dispatch(dataDir, tail) + + suspend fun debit( + dataDir: DataDir, + tail: Array, + ): Int = DebitCommands.dispatch(dataDir, tail) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DebitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DebitCommands.kt new file mode 100644 index 0000000000..ba90f6f01c --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DebitCommands.kt @@ -0,0 +1,218 @@ +/* + * 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.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.experimental.clink.client.DebitClient +import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent +import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency +import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse +import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit + +/** + * `amy debit …` — CLINK Debits (`ndebit1…`) from the command line, for headless interop + * testing against a real debit service (e.g. a Lightning.Pub that pre-authorized this + * account's npub). + * + * - `info ` decodes a pointer locally (no network). + * - `pay [--amount SATS]` runs the kind-21002 pay round-trip. + * - `budget --amount SATS [--frequency day|week|month]` authorizes a budget. + * + * Thin assembly only: pointer decode + the request/response events live in `quartz` + * (`ClinkPointerParser`, `DebitClient`); the round-trip uses `Context.requestResponse`. + */ +object DebitCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int { + if (tail.isEmpty()) return Output.error("bad_args", "debit ") + val rest = tail.drop(1).toTypedArray() + return when (tail[0]) { + "info" -> info(rest) + "pay" -> pay(dataDir, rest) + "budget" -> budget(dataDir, rest) + else -> Output.error("bad_args", "debit ${tail[0]} (expected info|pay|budget)") + } + } + + /** Local decode of an `ndebit` pointer — no network, no account needed. */ + private fun info(rest: Array): Int { + val args = Args(rest) + val debit = + ClinkPointerParser.parse(args.positional(0, "ndebit").trim()) as? NDebit + ?: return Output.error("bad_args", "not a valid ndebit pointer") + + Output.emit( + mapOf( + "pubkey" to debit.pubKey, + "relays" to debit.relays.map { it.url }, + "pointer" to debit.pointer, + "session" to debit.isSession, + ), + ) + return 0 + } + + /** Ask the wallet to pay [bolt11] (kind-21002 round-trip). */ + private suspend fun pay( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val bolt11 = args.positional(1, "bolt11") + val amount = args.flag("amount")?.toLongOrNull() + val timeoutMs = args.longFlag("timeout", 15_000) + + return roundTrip(dataDir, args, timeoutMs) { client -> client.payInvoice(bolt11, amount) } + } + + /** Ask the wallet to authorize a spending budget; omit --frequency for a one-time budget. */ + private suspend fun budget( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val amount = + args.flag("amount")?.toLongOrNull() + ?: return Output.error("bad_args", "--amount SATS is required for a budget") + val frequency = parseFrequency(args.flag("frequency")) ?: return Output.error("bad_args", "unknown --frequency '${args.flag("frequency")}' (day|week|month)") + val timeoutMs = args.longFlag("timeout", 15_000) + + return roundTrip(dataDir, args, timeoutMs) { client -> client.requestBudget(amount, frequency.value) } + } + + /** + * Shared 21002 round-trip: decode the pointer (positional 0), build the request via + * [buildRequest], publish, await the reply, and emit the preimage or GFY error. + */ + private suspend fun roundTrip( + dataDir: DataDir, + args: Args, + timeoutMs: Long, + buildRequest: suspend (DebitClient) -> DebitEvent, + ): Int { + val debit = + ClinkPointerParser.parse(args.positional(0, "ndebit").trim()) as? NDebit + ?: return Output.error("bad_args", "not a valid ndebit pointer") + if (debit.relays.isEmpty()) return Output.error("bad_pointer", "ndebit carries no relay to reach") + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + return when (val outcome = settle(ctx, debit, timeoutMs, buildRequest)) { + Settle.Timeout -> { + Output.error("timeout", "no response from the debit service within ${timeoutMs}ms") + 124 + } + Settle.BadReply -> Output.error("bad_response", "service reply was not a kind-21002 debit event") + is Settle.Replied -> emitDebit(outcome, debit.pubKey) + } + } finally { + ctx.close() + } + } + + /** Emit a [DebitResponse] as the standard `ok`+preimage success or a structured GFY error. */ + internal fun emitDebit( + outcome: Settle.Replied, + servicePubKey: String, + ): Int { + val response = outcome.response + return if (response.isOk()) { + Output.emit( + mapOf( + "result" to "ok", + "preimage" to response.preimage, + "request_id" to outcome.requestId, + "service" to servicePubKey, + ), + ) + 0 + } else { + Output.error( + "debit_error", + response.error?.takeIf { it.isNotBlank() } ?: "service returned error code ${response.code}", + gfyExtra(response), + ) + } + } + + /** Structured GFY extras (code + any actionable range/retry_after/delta) for error output. */ + internal fun gfyExtra(response: DebitResponse): Map = + mapOf( + "code" to response.code, + "range" to response.range?.let { mapOf("min" to it.min, "max" to it.max) }, + "retry_after" to response.retry_after, + "delta" to response.delta?.let { mapOf("max_delta_ms" to it.max_delta_ms, "actual_delta_ms" to it.actual_delta_ms) }, + ) + + /** Result of a single 21002 round-trip, decoupled from how it is emitted. */ + internal sealed interface Settle { + data class Replied( + val requestId: String, + val response: DebitResponse, + ) : Settle + + data object Timeout : Settle + + data object BadReply : Settle + } + + /** + * Core 21002 round-trip against an already-decoded [debit] on an open [ctx]: build the + * request, publish, await the reply, decrypt. Reused by `debit pay/budget` and by + * `offer pay` (fetch invoice → settle via debit). + */ + internal suspend fun settle( + ctx: Context, + debit: NDebit, + timeoutMs: Long, + buildRequest: suspend (DebitClient) -> DebitEvent, + ): Settle { + val client = DebitClient(debit, ctx.signer) + val requestEvent = buildRequest(client) + val reply = + ctx.requestResponse(requestEvent, debit.relays.toSet(), client.responseFilter(requestEvent.id), timeoutMs) + ?: return Settle.Timeout + val response = (reply as? DebitEvent)?.let { client.parseResponse(it) } ?: return Settle.BadReply + return Settle.Replied(requestEvent.id, response) + } + + /** Parses a `--frequency` value into a one-time (null) or recurring cadence. Null = invalid. */ + internal fun parseFrequency(raw: String?): Frequency? = + when (raw?.lowercase()) { + null, "once", "one-time" -> Frequency(null) + "day", "daily" -> Frequency(DebitFrequency(1, DebitFrequency.UNIT_DAY)) + "week", "weekly" -> Frequency(DebitFrequency(1, DebitFrequency.UNIT_WEEK)) + "month", "monthly" -> Frequency(DebitFrequency(1, DebitFrequency.UNIT_MONTH)) + else -> null + } + + /** Wrapper so a valid "one-time" budget (null cadence) is distinguishable from an invalid flag. */ + internal data class Frequency( + val value: DebitFrequency?, + ) +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OfferCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OfferCommands.kt new file mode 100644 index 0000000000..65971e9773 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OfferCommands.kt @@ -0,0 +1,291 @@ +/* + * 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.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.experimental.clink.client.OfferClient +import com.vitorpamplona.quartz.experimental.clink.offers.OfferErrorCode +import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent +import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse +import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit +import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer +import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id + +/** + * `amy offer …` — CLINK Offers (`noffer1…`) from the command line, for headless interop + * testing against a real offer service. + * + * - `info ` decodes a pointer locally (no network). + * - `discover ` resolves a profile's advertised offer from its NIP-05 `.well-known`. + * - `request [--amount N] [--timeout MS] [--follow]` runs the kind-21001 round-trip: + * publishes the request to the pointer's relays and prints the returned BOLT-11. With + * `--follow` it chases an "Expired or Moved" (code 3) reply to the `latest` pointer. + * - `pay --with [--amount N]` fetches the invoice and settles it end-to-end + * through a CLINK debit pointer (offer round-trip → debit round-trip). + * + * Thin assembly only: pointer decode + the request/response events live in `quartz` + * (`ClinkPointerParser`, `OfferClient`, `DebitClient`); the relay round-trips use + * `Context.requestResponse` (debit settlement is shared with [DebitCommands]). + */ +object OfferCommands { + private const val MAX_FOLLOW_HOPS = 3 + + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int { + if (tail.isEmpty()) return Output.error("bad_args", "offer ") + val rest = tail.drop(1).toTypedArray() + return when (tail[0]) { + "info" -> info(rest) + "discover" -> discover(dataDir, rest) + "request" -> request(dataDir, rest) + "pay" -> pay(dataDir, rest) + else -> Output.error("bad_args", "offer ${tail[0]} (expected info|discover|request|pay)") + } + } + + /** + * Resolve a profile's advertised offer from its NIP-05 `.well-known/nostr.json` `clink_offer` + * (the app's discovery fallback). A profile's kind-0 `clink_offer` is readable via + * `amy profile show `. + */ + private suspend fun discover( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val id = + Nip05Id.parse(args.positional(0, "nip05").trim()) + ?: return Output.error("bad_args", "not a valid NIP-05 address (e.g. bob@example.com)") + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val noffer = ctx.nip05Client.loadClinkOffer(id) + if (noffer == null) { + Output.emit(mapOf("nip05" to id.toDisplayValue(), "found" to false)) + return 0 + } + val offer = ClinkPointerParser.parse(noffer) as? NOffer + Output.emit( + mapOf( + "nip05" to id.toDisplayValue(), + "found" to true, + "noffer" to noffer, + "pubkey" to offer?.pubKey, + "relays" to offer?.relays?.map { it.url }, + "pointer" to offer?.pointer, + "price_type" to offer?.priceType?.name?.lowercase(), + "price_sats" to offer?.price, + ), + ) + return 0 + } finally { + ctx.close() + } + } + + /** Local decode of a `noffer` pointer — no network, no account needed. */ + private fun info(rest: Array): Int { + val args = Args(rest) + val offer = + ClinkPointerParser.parse(args.positional(0, "noffer").trim()) as? NOffer + ?: return Output.error("bad_args", "not a valid noffer pointer") + + Output.emit( + mapOf( + "pubkey" to offer.pubKey, + "relays" to offer.relays.map { it.url }, + "pointer" to offer.pointer, + "price_type" to offer.priceType.name.lowercase(), + "price_sats" to offer.price, + ), + ) + return 0 + } + + /** Request a fresh BOLT-11 from the offer service (kind-21001 round-trip). */ + private suspend fun request( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val amount = args.flag("amount")?.toLongOrNull() + val timeoutMs = args.longFlag("timeout", 15_000) + val follow = args.bool("follow") + + var offer = + ClinkPointerParser.parse(args.positional(0, "noffer").trim()) as? NOffer + ?: return Output.error("bad_args", "not a valid noffer pointer") + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + var hops = 0 + while (hops <= MAX_FOLLOW_HOPS) { + val relays = offer.relays.toSet() + if (relays.isEmpty()) return Output.error("bad_pointer", "noffer carries no relay to reach") + + val client = OfferClient(offer, ctx.signer) + val requestEvent = client.requestInvoice(amountSats = amount) + + val reply = ctx.requestResponse(requestEvent, relays, client.responseFilter(requestEvent.id), timeoutMs) + if (reply == null) { + Output.error("timeout", "no response from the offer service within ${timeoutMs}ms") + return 124 + } + + val response = + (reply as? OfferEvent)?.let { client.parseResponse(it) } + ?: return Output.error("bad_response", "service reply was not a kind-21001 offer event") + + if (response.isSuccess()) { + Output.emit( + mapOf( + "bolt11" to response.bolt11, + "request_id" to requestEvent.id, + "service" to offer.pubKey, + "followed_hops" to hops, + ), + ) + return 0 + } + + // "Expired or Moved" (code 3) may carry a replacement `noffer`; chase it on --follow. + val moved = response.latest?.let { ClinkPointerParser.parse(it) as? NOffer } + if (follow && response.code == OfferErrorCode.EXPIRED_OR_MOVED && moved != null) { + offer = moved + hops++ + continue + } + + return Output.error( + "offer_error", + response.error?.takeIf { it.isNotBlank() } ?: "service returned error code ${response.code}", + offerErrorExtra(response), + ) + } + return Output.error("offer_error", "too many redirects following moved offers (>$MAX_FOLLOW_HOPS)") + } finally { + ctx.close() + } + } + + /** + * Pay an offer end-to-end: fetch a fresh BOLT-11 (kind-21001) and settle it through a + * CLINK debit pointer (kind-21002). The CLI is stateless, so the funding source is given + * explicitly with `--with `. + */ + private suspend fun pay( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val amount = args.flag("amount")?.toLongOrNull() + val timeoutMs = args.longFlag("timeout", 15_000) + + val offer = + ClinkPointerParser.parse(args.positional(0, "noffer").trim()) as? NOffer + ?: return Output.error("bad_args", "not a valid noffer pointer") + val withFlag = + args.flag("with") + ?: return Output.error("bad_args", "offer pay needs --with to settle the fetched invoice") + val debit = + ClinkPointerParser.parse(withFlag.trim()) as? NDebit + ?: return Output.error("bad_args", "--with is not a valid ndebit pointer") + + val offerRelays = offer.relays.toSet() + if (offerRelays.isEmpty()) return Output.error("bad_pointer", "noffer carries no relay to reach") + if (debit.relays.isEmpty()) return Output.error("bad_pointer", "ndebit carries no relay to reach") + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + + // 1. fetch a fresh BOLT-11 from the offer service. + val offerClient = OfferClient(offer, ctx.signer) + val offerReq = offerClient.requestInvoice(amountSats = amount) + val offerReply = ctx.requestResponse(offerReq, offerRelays, offerClient.responseFilter(offerReq.id), timeoutMs) + if (offerReply == null) { + Output.error("timeout", "no response from the offer service within ${timeoutMs}ms") + return 124 + } + val offerResp = + (offerReply as? OfferEvent)?.let { offerClient.parseResponse(it) } + ?: return Output.error("bad_response", "offer reply was not a kind-21001 event") + if (!offerResp.isSuccess()) { + return Output.error( + "offer_error", + offerResp.error?.takeIf { it.isNotBlank() } ?: "service returned error code ${offerResp.code}", + offerErrorExtra(offerResp), + ) + } + val bolt11 = + offerResp.bolt11 + ?: return Output.error("bad_response", "offer succeeded but returned no bolt11") + + // 2. settle the invoice through the debit service (shared with `debit pay`). + return when (val outcome = DebitCommands.settle(ctx, debit, timeoutMs) { it.payInvoice(bolt11, amount) }) { + DebitCommands.Settle.Timeout -> { + Output.error("timeout", "no response from the debit service within ${timeoutMs}ms") + 124 + } + DebitCommands.Settle.BadReply -> Output.error("bad_response", "debit reply was not a kind-21002 event") + is DebitCommands.Settle.Replied -> + if (outcome.response.isOk()) { + Output.emit( + mapOf( + "result" to "ok", + "preimage" to outcome.response.preimage, + "bolt11" to bolt11, + "offer_request_id" to offerReq.id, + "debit_request_id" to outcome.requestId, + "offer_service" to offer.pubKey, + "debit_service" to debit.pubKey, + ), + ) + 0 + } else { + Output.error( + "debit_error", + outcome.response.error?.takeIf { it.isNotBlank() } ?: "service returned error code ${outcome.response.code}", + DebitCommands.gfyExtra(outcome.response), + ) + } + } + } finally { + ctx.close() + } + } + + /** Structured offer-error extras (code + moved `latest` pointer + acceptable range). */ + private fun offerErrorExtra(response: OfferResponse): Map = + mapOf( + "code" to response.code, + "latest" to response.latest, + "range" to response.range?.let { mapOf("min" to it.min, "max" to it.max) }, + ) +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt index 0b827167f0..170e792189 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt @@ -24,6 +24,8 @@ import com.vitorpamplona.amethyst.cli.Args import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser +import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -139,17 +141,23 @@ object ProfileCommands { val twitter = args.flag("twitter") val mastodon = args.flag("mastodon") val github = args.flag("github") + val clinkOffer = args.flag("clink-offer") val timeoutSecs = args.longFlag("timeout", 8L) + // A non-blank --clink-offer must be a real noffer; pass "" to clear the field. + if (!clinkOffer.isNullOrBlank() && ClinkPointerParser.parse(clinkOffer.trim()) !is NOffer) { + return Output.error("bad_args", "--clink-offer is not a valid noffer pointer (pass \"\" to clear)") + } + val touched = - listOf(name, displayName, about, picture, banner, website, nip05, lud16, lud06, pronouns, twitter, mastodon, github) + listOf(name, displayName, about, picture, banner, website, nip05, lud16, lud06, pronouns, twitter, mastodon, github, clinkOffer) .any { it != null } if (!touched) { return Output.error( "bad_args", "profile edit needs at least one of " + "--name --display-name --about --picture --banner --website " + - "--nip05 --lud16 --lud06 --pronouns --twitter --mastodon --github", + "--nip05 --lud16 --lud06 --pronouns --twitter --mastodon --github --clink-offer", ) } @@ -182,6 +190,7 @@ object ProfileCommands { twitter = twitter, mastodon = mastodon, github = github, + clinkOffer = clinkOffer, ) } else { MetadataEvent.createNew( @@ -198,6 +207,7 @@ object ProfileCommands { twitter = twitter, mastodon = mastodon, github = github, + clinkOffer = clinkOffer, ) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt index 8a9292f37a..6ef33e4557 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt @@ -26,6 +26,8 @@ import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.amethyst.commons.actions.ZapActions import com.vitorpamplona.amethyst.commons.service.lnurl.LightningAddressResolver +import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent @@ -51,8 +53,10 @@ import okhttp3.OkHttpClient * 4. POST it to the recipient's LNURL-pay callback via * [LightningAddressResolver] to receive a BOLT11 invoice. * - * The invoice is printed but **not** auto-paid — amy has no NWC wallet - * wired up yet. Paste the invoice into any LN wallet to settle. + * By default the invoice is printed but **not** auto-paid — paste it into any LN + * wallet to settle. Pass `--with ` to settle it in-place through a CLINK + * debit pointer (kind-21002), mirroring how the app routes a zap through its + * default payment source; each recipient then also reports `paid` + `preimage`. */ object ZapCommand { suspend fun dispatch( @@ -72,7 +76,7 @@ object ZapCommand { dataDir: DataDir, rest: Array, ): Int { - if (rest.size < 2) return Output.error("bad_args", "zap user [--comment X] [--anon] [--timeout SECS]") + if (rest.size < 2) return Output.error("bad_args", "zap user [--comment X] [--anon] [--with ] [--timeout SECS]") val userArg = rest[0] val sats = rest[1].toLongOrNull()?.takeIf { it > 0 } @@ -81,6 +85,14 @@ object ZapCommand { val comment = args.flag("comment") ?: "" val zapType = parseZapType(args) val timeoutMs = args.longFlag("timeout", 8L) * 1000 + val withFlag = args.flag("with") + val settleWith = + if (withFlag == null) { + null + } else { + (ClinkPointerParser.parse(withFlag.trim()) as? NDebit)?.takeIf { it.relays.isNotEmpty() } + ?: return Output.error("bad_args", "--with must be a valid ndebit pointer with a relay") + } val ctx = Context.open(dataDir) try { @@ -103,7 +115,7 @@ object ZapCommand { zapType = zapType, ) - emitZapResult(ctx, sats, lnAddress, comment, request, zapType) + emitZapResult(ctx, sats, lnAddress, comment, request, zapType, timeoutMs, settleWith) return 0 } finally { ctx.close() @@ -114,7 +126,7 @@ object ZapCommand { dataDir: DataDir, rest: Array, ): Int { - if (rest.size < 2) return Output.error("bad_args", "zap event [--comment X] [--anon] [--private] [--timeout SECS]") + if (rest.size < 2) return Output.error("bad_args", "zap event [--comment X] [--anon] [--private] [--with ] [--timeout SECS]") val eventId = rest[0] if (eventId.length != 64) return Output.error("bad_args", "event-id must be 64-hex (nevent bech32 not yet supported)") val sats = @@ -124,6 +136,14 @@ object ZapCommand { val comment = args.flag("comment") ?: "" val zapType = parseZapType(args) val timeoutMs = args.longFlag("timeout", 8L) * 1000 + val withFlag = args.flag("with") + val settleWith = + if (withFlag == null) { + null + } else { + (ClinkPointerParser.parse(withFlag.trim()) as? NDebit)?.takeIf { it.relays.isNotEmpty() } + ?: return Output.error("bad_args", "--with must be a valid ndebit pointer with a relay") + } val ctx = Context.open(dataDir) try { @@ -175,7 +195,7 @@ object ZapCommand { ) } - emitSplitZapResult(ctx, sats, comment, zappedEvent.id, zapType, requests) + emitSplitZapResult(ctx, sats, comment, zappedEvent.id, zapType, requests, timeoutMs, settleWith) return 0 } finally { ctx.close() @@ -189,6 +209,8 @@ object ZapCommand { comment: String, request: LnZapRequestEvent, zapType: LnZapEvent.ZapType, + timeoutMs: Long, + settleWith: NDebit?, zappedEventId: HexKey? = null, ) { // Reuse the same OkHttp instance the Context uses for nip-05 / WS; @@ -205,8 +227,8 @@ object ZapCommand { when (result) { is LightningAddressResolver.Result.Success -> { - Output.emit( - buildMap { + val base = + buildMap { put("ln_address", lnAddress) put("amount_sats", sats) put("zap_type", zapType.name.lowercase()) @@ -214,8 +236,9 @@ object ZapCommand { put("zap_request_id", request.id) if (zappedEventId != null) put("zapped_event_id", zappedEventId) put("invoice", result.invoice) - }, - ) + } + val settled = if (settleWith != null) settleEntry(ctx, settleWith, result.invoice, timeoutMs) else emptyMap() + Output.emit(base + settled) } is LightningAddressResolver.Result.Error -> { Output.error("invoice_failed", result.message) @@ -223,6 +246,32 @@ object ZapCommand { } } + /** + * Settles [bolt11] through a CLINK debit pointer (kind-21002, reusing [DebitCommands.settle]) + * and returns the result fields (`paid` + `preimage`/`pay_error`) to merge into the zap + * output. Per-recipient for splits. + */ + private suspend fun settleEntry( + ctx: Context, + debit: NDebit, + bolt11: String, + timeoutMs: Long, + ): Map = + when (val outcome = DebitCommands.settle(ctx, debit, timeoutMs) { it.payInvoice(bolt11, null) }) { + DebitCommands.Settle.Timeout -> mapOf("paid" to false, "pay_error" to "no response from the debit service") + DebitCommands.Settle.BadReply -> mapOf("paid" to false, "pay_error" to "debit reply was not a kind-21002 event") + is DebitCommands.Settle.Replied -> + if (outcome.response.isOk()) { + mapOf("paid" to true, "preimage" to outcome.response.preimage, "debit_request_id" to outcome.requestId) + } else { + mapOf( + "paid" to false, + "pay_error" to (outcome.response.error?.takeIf { it.isNotBlank() } ?: "code ${outcome.response.code}"), + "debit_request_id" to outcome.requestId, + ) + } + } + /** * Multi-recipient (split-aware) event-zap result emitter. Fetches one * BOLT11 invoice per [ZapActions.ZapRequestForSplit] and writes a @@ -238,6 +287,8 @@ object ZapCommand { zappedEventId: HexKey, zapType: LnZapEvent.ZapType, requests: List, + timeoutMs: Long, + settleWith: NDebit?, ) { val resolver = LightningAddressResolver(httpClient = sharedOkHttp(ctx)) @@ -260,8 +311,10 @@ object ZapCommand { "zap_request_id" to req.request.id, ) when (result) { - is LightningAddressResolver.Result.Success -> + is LightningAddressResolver.Result.Success -> { entry["invoice"] = result.invoice + if (settleWith != null) entry.putAll(settleEntry(ctx, settleWith, result.invoice, timeoutMs)) + } is LightningAddressResolver.Result.Error -> entry["invoice_error"] = result.message diff --git a/cli/tests/.gitignore b/cli/tests/.gitignore index 1b89e7da4f..3d5dc27cf2 100644 --- a/cli/tests/.gitignore +++ b/cli/tests/.gitignore @@ -2,3 +2,4 @@ marmot/state/ marmot/state-headless/ dm/state-dm-headless/ nests/state/ +clink/state-clink-headless/ diff --git a/cli/tests/README.md b/cli/tests/README.md index 4b1aeeb171..0fc0100f90 100644 --- a/cli/tests/README.md +++ b/cli/tests/README.md @@ -25,6 +25,11 @@ cli/tests/ └── README.md # operator brief + per-test matrix ``` +The CLINK suite is local-only (no relay): `clink/clink-headless.sh` asserts that +`amy offer info` / `amy debit info` decode the canonical interop vectors to the +right fields, plus the argument-error paths. The round-trip verbs (`offer +request`, `debit pay/budget`) need a live CLINK service and aren't covered here. + The Marmot harnesses come in two flavours, same scenarios: - **`marmot/marmot-interop.sh`** — interactive. Drives B/C via `wn` and diff --git a/cli/tests/clink/clink-headless.sh b/cli/tests/clink/clink-headless.sh new file mode 100755 index 0000000000..e27c2850e0 --- /dev/null +++ b/cli/tests/clink/clink-headless.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# +# clink-headless.sh — local-decode checks for `amy offer info` / `amy debit info`. +# +# These verbs are pure pointer decode (no network), so this suite needs no relay: +# it asserts that amy decodes the canonical CLINK interop vectors (the same fixtures +# the quartz ClinkInteropTest uses) to the right fields, in both success and error +# paths. The round-trip verbs (`offer request`, `debit pay`, `debit budget`) need a +# live CLINK service and are out of scope here. +# +# Usage: ./clink-headless.sh [--no-build] +# +set -uo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)" +TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" +STATE_DIR="$SCRIPT_DIR/state-clink-headless" +LOG_DIR="$STATE_DIR/logs" + +RUN_TS="$(date +%Y%m%d-%H%M%S)" +LOG_FILE="$LOG_DIR/run-$RUN_TS.log" +RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv" +AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy" + +NO_BUILD=0 +[[ "${1:-}" == "--no-build" ]] && NO_BUILD=1 + +mkdir -p "$LOG_DIR" +: >"$RESULTS_FILE" + +# shellcheck source=../lib.sh +source "$TESTS_DIR/lib.sh" +# shellcheck source=../headless/helpers.sh +source "$TESTS_DIR/headless/helpers.sh" + +cleanup() { + local rc=$? + trap - EXIT INT TERM HUP + print_summary + exit "$rc" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +# Canonical interop vectors (fixed pubkey/relay), from quartz ClinkInteropTest. +EXPECTED_PUB="7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e" +NOFFER_FIXED="noffer1qszqqqzjpqpszqqzpphkven9wgkkjeqprpmhxue69uhhyetvv9ujuumgda3kkmn9wshxgetkqqs8ul5ug253hlh3n75jne0a5xmjur4urfxpzst88cnegg6ds6ka7nsx7zr9c" +NOFFER_SPONT="noffer1qvqsyqs9wd5x7up3qyv8wumn8ghj7un9d3shjtnndphkx6mwv46zuer9wcqzqln7n3p2jxl77x06j209lksmwtswhsdycy2pvulz09prfkr2mh6wexeyu2" +NDEBIT_STATIC="ndebit1qgyhqmmfde6x2u3dxuq3samnwvaz7tmjv4kxz7fwwd5x7cmtdejhgtnyv4mqqgr706wy92gmlmcel2ffuh76rdewp67p5nq3g9nnufu5ydxcdtwlfcg94z44" + +banner "CLINK pointer decode headless ($RUN_TS)" + +if [[ "$NO_BUILD" -eq 0 ]]; then + step "Building amy (installDist)" + (cd "$REPO_ROOT" && ./gradlew -q :cli:installDist >>"$LOG_FILE" 2>&1) || + { fail_msg "amy build failed (see $LOG_FILE)"; exit 1; } +fi +[[ -x "$AMY_BIN" ]] || { fail_msg "amy binary missing: $AMY_BIN"; exit 1; } + +# Bare local account — `init` does no relay traffic, which is all we need. +rm -rf "${STATE_DIR:?}/.amy" +amy_a init >>"$LOG_FILE" 2>&1 || { fail_msg "amy init failed (see $LOG_FILE)"; exit 1; } + +# --- offer info: fixed-price --- +step "offer info decodes a fixed-price noffer" +OUT=$(amy_json offer info "$NOFFER_FIXED") || true +assert_eq "$(jq -r '.pubkey' <<<"$OUT")" "$EXPECTED_PUB" offer.info.pubkey && + record_result offer.info.pubkey pass "pubkey decoded" +assert_eq "$(jq -r '.pointer' <<<"$OUT")" "offer-id" offer.info.pointer && + record_result offer.info.pointer pass "pointer=offer-id" +assert_eq "$(jq -r '.price_type' <<<"$OUT")" "fixed" offer.info.price_type && + record_result offer.info.price_type pass "price_type=fixed" +assert_eq "$(jq -r '.price_sats' <<<"$OUT")" "21000" offer.info.price_sats && + record_result offer.info.price_sats pass "price_sats=21000" + +# --- offer info: spontaneous (no price) --- +step "offer info decodes a spontaneous noffer (no price)" +OUT=$(amy_json offer info "$NOFFER_SPONT") || true +assert_eq "$(jq -r '.price_type' <<<"$OUT")" "spontaneous" offer.info.spont_type && + record_result offer.info.spont_type pass "price_type=spontaneous" +assert_eq "$(jq -r '.price_sats' <<<"$OUT")" "null" offer.info.spont_price && + record_result offer.info.spont_price pass "price_sats=null" + +# --- offer info: bad pointer => non-zero exit --- +step "offer info rejects a non-noffer string" +if amy_a offer info "definitely-not-a-noffer" >>"$LOG_FILE" 2>&1; then + record_result offer.info.bad fail "bad pointer should exit non-zero" +else + record_result offer.info.bad pass "bad pointer exits non-zero" +fi + +# --- debit info: static pointer --- +step "debit info decodes a static ndebit" +OUT=$(amy_json debit info "$NDEBIT_STATIC") || true +assert_eq "$(jq -r '.pubkey' <<<"$OUT")" "$EXPECTED_PUB" debit.info.pubkey && + record_result debit.info.pubkey pass "pubkey decoded" +assert_eq "$(jq -r '.pointer' <<<"$OUT")" "pointer-7" debit.info.pointer && + record_result debit.info.pointer pass "pointer=pointer-7" +assert_eq "$(jq -r '.session' <<<"$OUT")" "false" debit.info.session && + record_result debit.info.session pass "session=false (no k1)" + +# --- debit budget: argument validation (no network needed) --- +step "debit budget rejects an unknown frequency" +if amy_a debit budget "$NDEBIT_STATIC" --amount 1000 --frequency fortnight >>"$LOG_FILE" 2>&1; then + record_result debit.budget.badfreq fail "unknown frequency should exit non-zero" +else + record_result debit.budget.badfreq pass "unknown frequency exits non-zero" +fi + +step "debit budget requires --amount" +if amy_a debit budget "$NDEBIT_STATIC" >>"$LOG_FILE" 2>&1; then + record_result debit.budget.noamount fail "missing --amount should exit non-zero" +else + record_result debit.budget.noamount pass "missing --amount exits non-zero" +fi + +# --- offer pay: requires a --with funding pointer (validated before any network) --- +step "offer pay requires --with " +if amy_a offer pay "$NOFFER_SPONT" --amount 1000 >>"$LOG_FILE" 2>&1; then + record_result offer.pay.nowith fail "missing --with should exit non-zero" +else + record_result offer.pay.nowith pass "missing --with exits non-zero" +fi + +step "offer pay rejects a non-ndebit --with" +if amy_a offer pay "$NOFFER_SPONT" --with "not-an-ndebit" >>"$LOG_FILE" 2>&1; then + record_result offer.pay.badwith fail "bad --with should exit non-zero" +else + record_result offer.pay.badwith pass "bad --with exits non-zero" +fi + +# --- profile edit --clink-offer: validates the noffer locally before publishing --- +step "profile edit rejects a non-noffer --clink-offer" +if amy_a profile edit --clink-offer "not-a-noffer" >>"$LOG_FILE" 2>&1; then + record_result profile.clinkoffer.bad fail "bad --clink-offer should exit non-zero" +else + record_result profile.clinkoffer.bad pass "bad --clink-offer exits non-zero" +fi + +# --- zap --with: rejects a non-ndebit funding pointer (validated before any network) --- +step "zap user rejects a non-ndebit --with" +if amy_a zap user "$EXPECTED_PUB" 1000 --with "not-an-ndebit" >>"$LOG_FILE" 2>&1; then + record_result zap.with.bad fail "bad --with should exit non-zero" +else + record_result zap.with.bad pass "bad --with exits non-zero" +fi + +# --- offer discover: rejects a malformed NIP-05 (validated before any network) --- +step "offer discover rejects a non-nip05 address" +if amy_a offer discover "not-a-nip05" >>"$LOG_FILE" 2>&1; then + record_result offer.discover.bad fail "bad nip05 should exit non-zero" +else + record_result offer.discover.bad pass "bad nip05 exits non-zero" +fi diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/clink/ClinkDebitWalletEntry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/clink/ClinkDebitWalletEntry.kt new file mode 100644 index 0000000000..88ae93fe20 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/clink/ClinkDebitWalletEntry.kt @@ -0,0 +1,54 @@ +/* + * 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.commons.model.clink + +import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit +import kotlinx.serialization.Serializable +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * A saved CLINK Debits pointer the user can spend from — the `ndebit` counterpart of + * [com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntry]. + * + * Unlike NWC, a debit carries no secret (authorization is the account's own identity, + * pre-approved on the wallet service) and exposes no balance or transaction history — + * it is a spend-only payment source. The persisted form keeps the raw `ndebit1…` + * string; [normalize] decodes it for use. + */ +@OptIn(ExperimentalUuidApi::class) +@Serializable +data class ClinkDebitWalletEntry( + val id: String = Uuid.random().toString(), + val name: String, + val ndebit: String, +) { + fun normalize(): ClinkDebitWalletEntryNorm? = (ClinkPointerParser.parse(ndebit) as? NDebit)?.let { ClinkDebitWalletEntryNorm(id, name, it) } +} + +data class ClinkDebitWalletEntryNorm( + val id: String, + val name: String, + val pointer: NDebit, +) { + fun denormalize(): ClinkDebitWalletEntry = ClinkDebitWalletEntry(id, name, pointer.encode()) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentSource.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentSource.kt new file mode 100644 index 0000000000..1c751d519e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentSource.kt @@ -0,0 +1,55 @@ +/* + * 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.commons.model.payments + +import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm +import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm + +/** + * A user-configured way to pay a BOLT-11 — the unit the zap button (and any other + * "pay this invoice" path) selects a default from. Today either a NIP-47 NWC wallet + * or a CLINK Debits pointer; absence of any source means falling back to an external + * wallet app (intent). + * + * [canShowBalance] is the honest capability marker: NWC can report balance/history, + * a CLINK debit cannot, so the UI renders the two rows differently. + */ +sealed interface PaymentSource { + val id: String + val name: String + val canShowBalance: Boolean + + data class Nwc( + val wallet: NwcWalletEntryNorm, + ) : PaymentSource { + override val id: String get() = wallet.id + override val name: String get() = wallet.name + override val canShowBalance: Boolean get() = true + } + + data class ClinkDebit( + val wallet: ClinkDebitWalletEntryNorm, + ) : PaymentSource { + override val id: String get() = wallet.id + override val name: String get() = wallet.name + override val canShowBalance: Boolean get() = false + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentSourceResolver.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentSourceResolver.kt new file mode 100644 index 0000000000..90c9a79c1d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentSourceResolver.kt @@ -0,0 +1,51 @@ +/* + * 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.commons.model.payments + +import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm +import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm + +/** + * Builds the unified list of configured [PaymentSource]s (NWC + CLINK debit) and + * resolves which one is the default for "pay this invoice" paths. + * + * The default is a single id spanning both lists (ids are random UUIDs, unique across + * types), so one selector picks the spend rail regardless of type. When no explicit + * default is set, the first configured source wins — NWC wallets are listed before + * debits, preserving today's "first NWC wallet" fallback. + */ +object PaymentSourceResolver { + fun all( + nwcWallets: List, + debitWallets: List, + ): List = nwcWallets.map { PaymentSource.Nwc(it) } + debitWallets.map { PaymentSource.ClinkDebit(it) } + + fun resolveDefault( + nwcWallets: List, + debitWallets: List, + defaultId: String?, + ): PaymentSource? = resolveDefault(all(nwcWallets, debitWallets), defaultId) + + fun resolveDefault( + sources: List, + defaultId: String?, + ): PaymentSource? = defaultId?.let { id -> sources.firstOrNull { it.id == id } } ?: sources.firstOrNull() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index bfd18c5985..bb6ddb4430 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.commons.richtext import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.commons.util.isValidUrl +import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser +import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer import com.vitorpamplona.quartz.experimental.inlineMetadata.Nip54InlineMetadata import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip31Alts.AltTag @@ -389,6 +391,10 @@ class RichTextParser { if (word.startsWith("cashuA", true) || word.startsWith("cashuB", true)) return CashuSegment(word) + if (word.startsWith("noffer1", true)) { + (ClinkPointerParser.parse(word) as? NOffer)?.let { return ClinkOfferSegment(word, it) } + } + if (word.startsWith('#')) return parseHash(word, tags) if (EmojiCoder.isCoded(word)) return SecretEmoji(word) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt index 25d3eae75d..0c5e7c062a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.richtext import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists +import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap @@ -92,6 +93,12 @@ class CashuSegment( segment: String, ) : Segment(segment) +@Immutable +class ClinkOfferSegment( + segment: String, + val offer: NOffer, +) : Segment(segment) + @Immutable class EmailSegment( segment: String, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluation.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluation.kt index eb5ddbacd3..42987dfb56 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluation.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluation.kt @@ -28,6 +28,7 @@ class TorRelayEvaluation( val torSettings: TorRelaySettings, val trustedRelayList: Set, val dmRelayList: Set, + val moneyOpRelayList: Set = emptySet(), ) { fun useTor(relay: NormalizedRelayUrl): Boolean = if (torSettings.torType == TorType.OFF) { @@ -36,7 +37,14 @@ class TorRelayEvaluation( if (relay.isLocalHost()) { false } else if (relay.isOnion()) { + // .onion is only reachable over Tor regardless of any other classification. torSettings.onionRelaysViaTor + } else if (relay in moneyOpRelayList) { + // Relays used for money operations (NIP-47 wallets, CLINK offer/debit services) + // follow the dedicated money-operations preference, taking precedence over the + // generic DM/trusted/new classification so a payment never silently inherits a + // different Tor policy than the one the user set for money. + torSettings.moneyOperationsViaTor } else if (relay in dmRelayList) { torSettings.dmRelaysViaTor } else if (relay in trustedRelayList) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelaySettings.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelaySettings.kt index cc0e7e3143..781cb88211 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelaySettings.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelaySettings.kt @@ -26,4 +26,5 @@ data class TorRelaySettings( val dmRelaysViaTor: Boolean = false, val newRelaysViaTor: Boolean = false, val trustedRelaysViaTor: Boolean = false, + val moneyOperationsViaTor: Boolean = false, ) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentSourceResolverTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentSourceResolverTest.kt new file mode 100644 index 0000000000..9f4f317af5 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentSourceResolverTest.kt @@ -0,0 +1,89 @@ +/* + * 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.commons.model.payments + +import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm +import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PaymentSourceResolverTest { + private val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!! + private val pubKey = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e" + + private fun nwc(id: String) = NwcWalletEntryNorm(id, "nwc-$id", Nip47WalletConnect.Nip47URINorm(pubKey, relay, secret = "ab".repeat(32))) + + private fun debit(id: String) = ClinkDebitWalletEntryNorm(id, "debit-$id", NDebit(pubKey, listOf(relay), "pointer-$id", null)) + + @Test + fun allListsNwcBeforeDebits() { + val sources = PaymentSourceResolver.all(listOf(nwc("a")), listOf(debit("b"))) + assertTrue(sources[0] is PaymentSource.Nwc) + assertTrue(sources[1] is PaymentSource.ClinkDebit) + } + + @Test + fun explicitDefaultSelectsAcrossEitherType() { + val nwcWallets = listOf(nwc("a")) + val debits = listOf(debit("b")) + + // a debit can be the unified default even when an NWC wallet exists + val resolved = PaymentSourceResolver.resolveDefault(nwcWallets, debits, defaultId = "b") + assertTrue(resolved is PaymentSource.ClinkDebit) + assertEquals("b", resolved.id) + } + + @Test + fun fallsBackToFirstNwcWhenNoExplicitDefault() { + val resolved = PaymentSourceResolver.resolveDefault(listOf(nwc("a")), listOf(debit("b")), defaultId = null) + assertTrue(resolved is PaymentSource.Nwc) + assertEquals("a", resolved.id) + } + + @Test + fun fallsBackToFirstDebitWhenNoNwc() { + val resolved = PaymentSourceResolver.resolveDefault(emptyList(), listOf(debit("b"), debit("c")), defaultId = null) + assertTrue(resolved is PaymentSource.ClinkDebit) + assertEquals("b", resolved.id) + } + + @Test + fun staleDefaultIdFallsBackToFirst() { + val resolved = PaymentSourceResolver.resolveDefault(listOf(nwc("a")), listOf(debit("b")), defaultId = "deleted") + assertEquals("a", resolved?.id) + } + + @Test + fun noSourcesResolvesToNull() { + assertNull(PaymentSourceResolver.resolveDefault(emptyList(), emptyList(), defaultId = null)) + } + + @Test + fun debitSourceCannotShowBalance() { + assertTrue(PaymentSource.Nwc(nwc("a")).canShowBalance) + assertTrue(!PaymentSource.ClinkDebit(debit("b")).canShowBalance) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClinkOfferSegmentTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClinkOfferSegmentTest.kt new file mode 100644 index 0000000000..a10963ede4 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClinkOfferSegmentTest.kt @@ -0,0 +1,62 @@ +/* + * 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.commons.richtext + +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.quartz.experimental.clink.pointers.OfferPriceType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ClinkOfferSegmentTest { + // generated by @shocknet/clink-sdk@1.5.5 (see quartz ClinkInteropTest) + private val offerFixed = + "noffer1qszqqqzjpqpszqqzpphkven9wgkkjeqprpmhxue69uhhyetvv9ujuumgda3kkmn9wshxgetkqqs8ul5ug253hlh3n75jne0a5xmjur4urfxpzst88cnegg6ds6ka7nsx7zr9c" + + private fun words(text: String) = + RichTextParser() + .parseText(text, EmptyTagList, null) + .paragraphs + .flatMap { it.words } + + @Test + fun detectsNofferInlineAsClinkOfferSegment() { + val segment = + words("Pay me here $offerFixed thanks") + .filterIsInstance() + .single() + + assertEquals(offerFixed, segment.segmentText) + assertEquals("offer-id", segment.offer.pointer) + assertEquals(OfferPriceType.FIXED, segment.offer.priceType) + assertEquals(21000L, segment.offer.price) + } + + @Test + fun malformedNofferIsNotDetected() { + assertTrue(words("nope noffer1notvalidbech32 end").none { it is ClinkOfferSegment }) + } + + @Test + fun plainTextHasNoOfferSegment() { + assertTrue(words("just a normal sentence").none { it is ClinkOfferSegment }) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluationTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluationTest.kt index c5e65775d1..a6b441a8e4 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluationTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluationTest.kt @@ -33,6 +33,7 @@ class TorRelayEvaluationTest { private val localNetworkRelay = NormalizedRelayUrl("ws://192.168.1.100:8080/") private val dmRelay = NormalizedRelayUrl("wss://dm.relay.com/") private val trustedRelay = NormalizedRelayUrl("wss://trusted.relay.com/") + private val moneyRelay = NormalizedRelayUrl("wss://wallet.relay.com/") private fun buildEvaluation( torType: TorType = TorType.INTERNAL, @@ -40,8 +41,10 @@ class TorRelayEvaluationTest { dmViaTor: Boolean = true, newViaTor: Boolean = true, trustedViaTor: Boolean = false, + moneyViaTor: Boolean = false, dmRelays: Set = setOf(dmRelay), trustedRelays: Set = setOf(trustedRelay), + moneyOpRelays: Set = setOf(moneyRelay), ) = TorRelayEvaluation( torSettings = TorRelaySettings( @@ -50,9 +53,11 @@ class TorRelayEvaluationTest { dmRelaysViaTor = dmViaTor, newRelaysViaTor = newViaTor, trustedRelaysViaTor = trustedViaTor, + moneyOperationsViaTor = moneyViaTor, ), trustedRelayList = trustedRelays, dmRelayList = dmRelays, + moneyOpRelayList = moneyOpRelays, ) // --- Tor OFF --- @@ -107,6 +112,44 @@ class TorRelayEvaluationTest { @Test fun unknown_disabled_returnsFalse() = assertFalse(buildEvaluation(newViaTor = false).useTor(clearnetRelay)) + // --- Money-operation relays --- + @Test + fun money_enabled_returnsTrue() = assertTrue(buildEvaluation(moneyViaTor = true).useTor(moneyRelay)) + + @Test + fun money_disabled_returnsFalse() = assertFalse(buildEvaluation(moneyViaTor = false).useTor(moneyRelay)) + + @Test + fun money_takesPrecedenceOverNew() { + // A money-op relay not in any other list must NOT fall through to the new-relay policy. + val eval = buildEvaluation(moneyViaTor = false, newViaTor = true, dmRelays = emptySet(), trustedRelays = emptySet()) + assertFalse(eval.useTor(moneyRelay)) + } + + @Test + fun money_takesPrecedenceOverTrustedAndDm() { + // When the same relay is both a money-op relay and trusted/DM, money policy wins. + val both = NormalizedRelayUrl("wss://wallet-and-trusted.relay.com/") + val eval = + buildEvaluation( + moneyViaTor = true, + dmViaTor = false, + trustedViaTor = false, + dmRelays = setOf(both), + trustedRelays = setOf(both), + moneyOpRelays = setOf(both), + ) + assertTrue(eval.useTor(both)) + } + + @Test + fun money_onionStillWins() { + // .onion reachability check precedes the money classification. + val onionMoney = NormalizedRelayUrl("wss://wallet.onion/") + val eval = buildEvaluation(onionViaTor = false, moneyViaTor = true, moneyOpRelays = setOf(onionMoney)) + assertFalse(eval.useTor(onionMoney)) + } + // --- Priority --- @Test fun onionInDmList_treatedAsOnion() { diff --git a/quartz/plans/2026-06-09-clink.md b/quartz/plans/2026-06-09-clink.md new file mode 100644 index 0000000000..d7b91db15e --- /dev/null +++ b/quartz/plans/2026-06-09-clink.md @@ -0,0 +1,298 @@ +# CLINK on Quartz + Amethyst + +Status: implemented (Phases 0–3 + receive side + CLI) — see "Final +implementation state" at the bottom for what actually shipped, the audit +results, and the spec-vs-SDK gotchas. The body below is the original +(proposed) design and is kept for context; where it disagrees with the final +state, the final state wins. +Date: 2026-06-09 +Owner: TBD + +## Decisions (locked) + +1. **Scope:** implement all three CLINK specs (Offers, Debits, Manage). +2. **Sidedness:** Quartz implements **both client and server** for every spec + (so `amy` and interop tests can drive both ends). Amethyst is + **consume-only** — it never hosts offers or approves incoming debits. +3. **Wallet model:** CLINK plugs into Amethyst's wallet layer *like NWC*. The + only spec that backs a spendable wallet is **Debits** — a stored `ndebit` + pointer is the CLINK analogue of an NWC connection string. It lives in the + same wallet list and adds a payment route to `ZapPaymentHandler`. `noffer` + (pay others) and `nmanage` (offer admin) are not wallet connections. +4. **Pointer parsing:** `noffer` / `ndebit` / `nmanage` are parsed by a + **dedicated `ClinkPointerParser`**, NOT folded into `Nip19Parser`. We do not + loosen the NIP-19 regexes app-wide for these prefixes. + +## What CLINK is + +"Common Lightning Interface for Nostr Keys" — three Nostr-native Lightning +protocols from ShockNet. Where NWC (NIP-47) is RPC-style remote control of your +*own* wallet, CLINK is peer-to-peer / app-to-service: no HTTPS callbacks, no +LNURL web server, no onion messages. Each interaction is a NIP-44-encrypted +ephemeral request→response event over a relay, addressed by a bech32 pointer. + +| Spec | Kind | Pointer | Purpose | LN analogue | +|---------|---------|--------------|-----------------------------------------------------|----------------------| +| Offers | `21001` | `noffer1…` | Static code → request a fresh BOLT-11 over Nostr | LNURL-Pay / BOLT-12 | +| Debits | `21002` | `ndebit1…` | Authorize a counterparty to pull a payment | LNURL-withdraw | +| Manage | `21003` | `nmanage1…` | Delegate CRUD of offers to an external app | — | + +Shared envelope: ephemeral kind, `["p", recipient]` + `["clink_version","1"]` +tags, `["e", reqId]` on responses, NIP-44 JSON content, GFY error codes +(`{"res":"GFY","code":1..6,...}`), 30s freshness window. + +### Pointer TLV fields (verify against @shocknet/clink-sdk in Phase 0) + +- **noffer:** 0=receiver pubkey, 1=relay, 2=offer-id, 3=price-type + (0 fixed / 1 variable / 2 spontaneous), 4=price sats, 5=currency. +- **ndebit:** 0=service pubkey, 1=relay, 2=pointer-id (opt), 3=32-byte `k1` + session id (opt; single-use). +- **nmanage:** 0=server pubkey, 1=relay, 2=pointer-id (opt). + +## Reuse matrix + +| Component | Status | Location | Action | +|-----------------------------------|------------|----------------------------------------------------------------------|----------------------------------------------------| +| NIP-44 encrypt/decrypt | ✅ Reuse | `quartz/.../nip44Encryption/` + `signer.nip44Encrypt/Decrypt` | Use as-is | +| Bech32 + TLV codec | ✅ Reuse | `nip19Bech32/bech32/`, `tlv/TlvBuilder.kt`, `tlv/Tlv.kt` | Use the codec; do NOT reuse `Nip19Parser` | +| Entity TLV pattern (reference) | 📦 Mirror | `nip19Bech32/entities/NProfile.kt` | Copy shape into new Clink entities | +| Encrypted req/resp event pattern | 📦 Mirror | `nip47WalletConnect/events/LnZapPaymentRequestEvent.kt` (kind 23194) | Clone for kinds 21001/2/3, client + server | +| Request/response over relay | ✅ Reuse | `Nip47Client.responseFilter()`, `relayClient/reqCommand/nwc/` | Same filter-by-`e`-tag pattern | +| BOLT-11 parsing | ✅ Reuse | `quartz/.../lightning/LnInvoiceUtil.kt` | Use as-is | +| LN payment execution | ✅ Reuse | `amethyst/.../service/ZapPaymentHandler.kt`, `NwcSignerState` | Add CLINK routes | +| Wallet/zap account settings | 📦 Extend | `AccountSettings.kt` (`nwcWallets`, `defaultNwcWalletId`) | Add CLINK debit pointers to the wallet list | +| Rich-text inline detect/render | 📦 Extend | `commons/.../richtext/RichTextParser.kt`, `RichTextViewer.kt`, `InvoicePreview.kt` | Add `noffer1…` token → pay card | +| EventFactory registry | 📦 Extend | `quartz/.../utils/EventFactory.kt` | Register 21001/21002/21003 | + +Genuinely new: 3 event classes, 3 bech32 entities + `ClinkPointerParser`, JSON +DTOs, `ClinkClient` + `ClinkServer`, the debit-pointer wallet store, UI surfaces. + +## Phase 0 — Quartz foundation (client + server, all three) + +New package `quartz/.../nipClink/` (mirroring the single-package NWC layout), +with `events/`, `pointers/`, `rpc/` subfolders. + +1. **TLV constants** — CLINK-local TLV indices (do not overload NIP-19 + `TlvTypes`, which is NIP-19-specific). +2. **Pointers** — `NOffer`, `NDebit`, `NManage` data classes with + `parse(bytes)` / `create(...)` built like `NProfile.kt` (`Tlv.parse` + + `TlvBuilder`). A standalone `ClinkPointerParser` decodes/encodes the three + prefixes — NOT wired into `Nip19Parser`. Verify HRP + checksum and the + TLV namespace against `@shocknet/clink-sdk` with a round-trip test. +3. **Events** — `OfferEvent(21001)`, `DebitEvent(21002)`, `ManageEvent(21003)`, + each `isContentEncoded() = true`, with `createRequest()` / `createResponse()` + companions that NIP-44-encrypt JSON and set `p` / `clink_version` / `e` tags + (direct analogue of `LnZapPaymentRequestEvent`). Register in `EventFactory`. +4. **DTOs + errors** — Jackson request/response classes per spec, shared + `GfyError(code, message, range?, retryAfter?, delta?)`, Offers error model + (`code` 1..5, `range`, `latest`). +5. **ClinkClient / ClinkServer** — high-level: `decode(pointer)`, + `buildRequest(...)`, `responseFilter(reqId)`, `parseResponse(...)`; server + side validates freshness, `k1` single-use, app-scoped offer ownership. +6. **Tests** — quartz unit tests with spec TLV vectors + round-trip against + clink-sdk fixtures; `amy clink decode|offer-pay|debit|manage` verbs (thin + assembly only). + +## Phase 1 — Offers consume (Amethyst) + +Ship order: scan/paste → inline card → profile button. + +- **Scan/paste-to-pay:** QR scanner + clipboard handle `noffer1…` → decode → + send 21001 → await BOLT-11 → existing send sheet → `ZapPaymentHandler`. + Smallest correct slice; proves the client end-to-end. +- **Inline feed card (headline):** `RichTextParser` recognizes a `noffer1…` + token; render a "⚡ Pay" card next to the existing BOLT-11 `InvoicePreview`. + Tap reuses the same decode→21001→pay path. +- **Profile pay button:** read `noffer` from kind-0 metadata; profile Zap + button prefers CLINK offer over LNURL when present. + +## Phase 2 — Debits as a wallet (Amethyst, consume-only) + +This is the "add a wallet via CLINK" answer. + +- **Account store:** extend the wallet list so a saved `ndebit` pointer sits + alongside NWC connections (`AccountSettings`, parallel to `nwcWallets`; + selectable as default funding source). +- **Payment route:** `ZapPaymentHandler` gains a CLINK-debit route — send a + 21002 request, await `{"res":"ok",preimage}`, handle GFY. Always behind an + explicit confirmation; honor `k1` single-use; never auto-approve. +- **Out of scope (server side):** receiving/approving incoming 21002 requests + and session-`k1` scan-to-pull. Quartz has the server code; Amethyst does not + expose it (consume-only decision). + +## Phase 3 — Manage (Quartz only) + +Full client + server in Quartz + `amy clink manage …` (create/update/get/ +list/delete, app-scoped ownership, GFY errors). **No Amethyst UI** — Amethyst +is an offer *consumer*, not a host. Revisit an in-app "my offers" minter only +if users want to mint offers from inside the app later. + +## Risks / to verify in Phase 0 + +- Exact bech32 HRP + whether CLINK uses its own TLV namespace vs NIP-19's — + pin against `@shocknet/clink-sdk` source, not the client-rendered docs. +- Relay selection for ephemeral req/resp (pointer relay vs account relays). +- Response-timeout + retry UX (30s freshness window, GFY code 3 deltas). + +--- + +## Final implementation state + +Everything below reflects what is on the branch now, not the proposal above. +Read this section first if you're touching CLINK. + +### What shipped + +**Phase 0 — Quartz foundation** (`quartz/.../experimental/clink/`) +- Pointers (`pointers/NOffer.kt`, `NDebit.kt`, `NManage.kt`) decoded/encoded + via a dedicated `ClinkPointerParser` (bech32 + TLV), **not** wired into + `Nip19Parser`. Round-trip tested against the canonical interop vectors + (`ClinkPointerTest`, `ClinkInteropTest`). +- Events `OfferEvent(21001)`, `DebitEvent(21002)`, `ManageEvent(21003)`, + refactored to the codebase convention: `eventTemplate(KIND, content, …){…}` + + tag-class DSL (`pTag`, `eTag`, `alt`, `clinkVersion`) and typed accessors + (`PTag::parseKey`, `ETag::parseId`). Registered in `EventFactory`. +- High-level clients `OfferClient` / `DebitClient` / `ManageClient` build the + request event, expose a `responseFilter` (filtered by **both** `e=reqId` + and `p=self`), and parse the NIP-44-decrypted response DTO. +- Shared `clink_version` is its own tag class (`tags/ClinkVersionTag.kt`, + `CURRENT="1"`) reused by all three events, with a `clinkVersion()` builder + extension. The old `Clink.kt` constants object was retired. + +**Phase 1 — Offers consume** (Amethyst): inline feed card +(`ClinkOfferPreview.kt`) renders a payable "⚡" card for a `noffer1…` token, +with a variable-amount field for SPONTANEOUS offers, moved-offer follow +(GFY code 3 `latest`), and the resolved `activeOffer` price. Payment routes +through the shared `InvoicePaymentDispatcher` (confirm-then-pay) into +`ZapPaymentHandler`. **Zappable offers were explicitly reverted** — do not +re-add NIP-57 zaps to offer payment. + +**Phase 2 — Debits as a wallet** (Amethyst, consume-only): a stored `ndebit` +pointer is a first-class payment source alongside NWC. `PaymentSource` +(sealed: `Nwc` / `ClinkDebit`), `PaymentSourceResolver`, and +`AccountSettings.defaultPaymentSource()` unify default selection; +`defaultNwcWalletId` was migrated to `defaultPaymentSourceId`. +`ZapPaymentHandler` dispatches on the resolved source. Budgets/recurring via +`DebitClient.requestBudget` + `DebitFrequency` units (day/week/month). + +**Phase 3 — Manage** (Quartz only, no Amethyst UI): nested request/response +shape — `ManageRequest(resource, action, pointer, offer: ManageOffer?)`, +`ManageOffer(id, fields: OfferFields)`, `OfferData(...)`, `ManageResponse(...)`. + +**Receive side** (advertise your own `noffer`): kind-0 `clink_offer` field +(`UserMetadata.clinkOffer` + dual-written `clink_offer` tag via +`ClinkOfferTag`, NIP-1770 pattern) **and** NIP-05 `.well-known` discovery +(`Nip05Parser.parseClinkOffer`, `INip05Client.loadClinkOffer`). The profile +header (`DisplayClinkOffer`) prefers whichever is present, with a +256-entry `LruCache` over the NIP-05 lookups. + +**CLI** (`amy`): `offer info|request`, `debit info|pay|budget` (thin assembly +only). New `Context.requestResponse(...)` does subscribe→publish→await-first- +matching-live-reply (vs `drain`, which returns at EOSE). + +### Audit findings & resolutions + +- **Offer price is an UNSIGNED 4-byte BE integer.** `NOffer.price` is `Long?`; + decode reads the 4 bytes as unsigned (SDK does `parseInt(hex)`), encode + writes the low 32 bits. The earlier `Int` typing produced a negative price + for any amount ≥ 2^31. Regression: + `ClinkPointerTest.offerLargePriceRoundTripIsUnsigned` (3_000_000_000L). +- **Decrypt guard.** `OfferEvent`/`DebitEvent`/`ManageEvent` replaced the old + self-fallback `talkingWith()` with `conversationPeer(myPubKey)` that returns + `null` when the signer is neither author nor recipient; `decryptContent` + then throws `UnauthorizedDecryptionException`. Regression: + `ClinkEventTest.cannotDecryptAuthoredEventMissingRecipient`. +- **Payer hang fix.** `ClinkOfferPayer` / `ClinkDebitPayer` wrap `parseResponse` + in try/catch and return `null` on a decode failure — an uncaught + `SerializationException` previously hung the UI waiting on a coroutine that + never completed. `payInvoiceViaClinkDebit` now delivers `onResult` on + `Dispatchers.Main`. +- **NIP-05 cache correctness.** The offer cache distinguishes a cache-miss + from a cached-`null` (explicit presence check), so a profile with no offer + isn't re-fetched on every recomposition. + +### CRITICAL — spec vs SDK (do NOT "fix" these) + +The `@shocknet/clink-sdk` (1.5.5) **lags the published spec**. Two things look +like bugs against the SDK but are correct against the spec +(`raw.githubusercontent.com/shocknet/CLINK/main/specs/clink-*.md`) and were +verified there directly: + +1. **Offer moved → GFY `code 3` carries `latest`** (a fresh pointer). The + client follows it. The SDK omits this; the spec defines it. Keep the + follow logic in `ClinkOfferPreview` / `OfferClient`. +2. **ndebit session `k1` lives at TLV index 3.** The SDK doesn't read it; the + spec defines it as the optional single-use session id. Keep decoding it. + +Other shape notes for future maintainers: +- **Manage uses the nested `offer.fields` shape** (above), not a flat object. +- **`ManageResponse.details` is parsed as a single object**, not an array — + Jackson's `ACCEPT_SINGLE_VALUE_AS_ARRAY` is OFF in this repo, and the + reference service returns one object. Documented as a known limitation in + `ManageMessages.kt`; revisit if a service returns a list. + +### Verification matrix + +| Level | What it covers | Status | +|-------|----------------|--------| +| JVM unit tests | pointer codecs (incl. unsigned price), decrypt guard, Manage nested shape, DTOs, metadata `clink_offer`, NIP-05 discovery, `PaymentSourceResolver` | green | +| `amy` CLI | `offer info`/`request`, `debit info`/`pay`/`budget` — local decode verified end-to-end | green | +| Shell harness | `cli/tests/clink/clink-headless.sh` — decode of canonical vectors + arg-error paths | 12/12 | + +**Remaining gap:** the live NIP-44 request→response round-trip over a relay +against a real CLINK service is not automated (needs a device or a +mock/live service). Feasible later via quartz's in-process relay server +(`nip01Core/relay/server/`) plus a mock CLINK responder; flagged but not +built. + +--- + +## Interop review & spec-conformance pass (2026-06-10) + +Reviewed against the whole `shocknet/CLINK` ecosystem (Lightning.Pub, clink-sdk, +ShockWallet, Zeus, Stacker News, bridgelet, clinkme.dev) and re-audited every +spec file line-by-line against the code. **Verdict: the consume-only +payer/requestor role is conformant and interoperable; no real correctness bugs.** + +**Interop fixes shipped:** +1. Manage `details` single-object responses parse (Jackson + `ACCEPT_SINGLE_VALUE_AS_ARRAY`) — Lightning.Pub returns a bare object for + create/update/get, an array only for `list`. +2. `NOffer.encode()` always emits the price-type TLV (3); decode defaults an + absent/unknown type to SPONTANEOUS. The SDK + bridgelet decoders throw on a + missing TLV 3, so omitting it made our pointers undecodable by JS consumers. +3. `Nip05Parser.parseClinkOffer` accepts bridgelet's flat-string shape as well + as the spec's per-name map. +4. Offer receipts are a parseable primitive (`OfferEvent.createReceipt` / + `decryptReceipt`, `OfferClient.parseReceipt`, `OfferReceipt.isOk`). +5. `ClinkOfferPayer` signs offer requests with an ephemeral key (privacy parity + with the SDK/Zeus/Stacker News). Debits keep the persistent account key + (budgets need a stable app identity). + +**Conformance hardening shipped:** `NDebit.parse` rejects a non-32-byte k1; +`DebitClient.requestBudget` validates `frequency.unit ∈ {day,week,month}`; +`OfferClient` caps `description` at 100 chars; `DebitResponse.failureDetail()` +surfaces GFY `range`/`retry_after` in the debit zap error path. + +**New tests:** `ClinkWireShapeTest` (golden JSON payloads from the public-domain +specs), a clink-demo/SDK canonical `DEFAULT_NOFFER` vector, plus regressions for +each fix above. + +**Three "CRITICAL" review findings were verified FALSE — do NOT re-chase them:** +- *Offer price encoding for ≥2³¹ sats* is correct: `addInt` writes the low 32 + bits, which are bit-identical to the unsigned 4-byte BE (proven by + `offerLargePriceRoundTripIsUnsigned`, 3e9 sats). Only ≥2³² loses data, which + the spec's 4-byte field can't represent anyway. +- *Parser "wrongly accepts `nostr:`/`lightning:` wrappers"* is not a violation: + the MUST-NOT-wrap rule governs producing QR (our `encode()` is bare); lenient + decode is Postel-legal. +- *Manage "create shape deviation"* — the spec's inline-create example is the + outlier; Lightning.Pub/SDK/clink-demo all use nested `offer.fields` for create, + which we match. + +**Remaining non-bugs (intentional / out-of-role):** we don't reject responses +lacking `clink_version` (Lightning.Pub omits it — rejecting breaks interop); the +offer Payment Receipt UI subscription is unwired (primitive ships); `clink_debit` +discovery and the unrestricted-access debit request are creditor/app-side +features outside the consume-only role; noffer TLV 5 currency is dormant +(unimplemented by the SDK and Lightning.Pub too). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/DebitClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/DebitClient.kt new file mode 100644 index 0000000000..b6b5fd2704 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/DebitClient.kt @@ -0,0 +1,96 @@ +/* + * 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.experimental.clink.client + +import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent +import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency +import com.vitorpamplona.quartz.experimental.clink.debits.DebitRequest +import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * High-level CLINK Debits requestor. + * + * Wraps a decoded [NDebit] pointer. A session pointer's single-use `k1` (TLV 3) is + * carried automatically into every request; static pointers send no `k1`, per spec. + */ +class DebitClient( + val pointer: NDebit, + val signer: NostrSigner, +) { + val servicePubKey: HexKey get() = pointer.pubKey + val relays: List get() = pointer.relays + + /** Asks the service to pay [bolt11] from the pointed-to wallet (kind 21002). */ + suspend fun payInvoice( + bolt11: String, + amountSats: Long? = null, + description: String? = null, + createdAt: Long = TimeUtils.now(), + ): DebitEvent { + val request = + DebitRequest( + pointer = pointer.pointer, + amount_sats = amountSats, + bolt11 = bolt11, + description = description, + k1 = pointer.k1, + ) + return DebitEvent.createRequest(request, servicePubKey, signer, createdAt) + } + + /** Requests a spending budget; omit [frequency] for a one-time budget. */ + suspend fun requestBudget( + amountSats: Long, + frequency: DebitFrequency? = null, + description: String? = null, + createdAt: Long = TimeUtils.now(), + ): DebitEvent { + // Per spec the recurring cadence unit is one of day/week/month; reject anything else + // rather than sending a request a node service will GFY. + require(frequency == null || frequency.unit in DebitFrequency.VALID_UNITS) { + "Invalid budget frequency unit: ${frequency?.unit}" + } + val request = + DebitRequest( + pointer = pointer.pointer, + amount_sats = amountSats, + description = description, + k1 = pointer.k1, + frequency = frequency, + ) + return DebitEvent.createRequest(request, servicePubKey, signer, createdAt) + } + + fun responseFilter(requestId: HexKey): Filter = + Filter( + kinds = listOf(DebitEvent.KIND), + authors = listOf(servicePubKey), + tags = mapOf("e" to listOf(requestId), "p" to listOf(signer.pubKey)), + ) + + suspend fun parseResponse(event: DebitEvent): DebitResponse = event.decryptResponse(signer) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/ManageClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/ManageClient.kt new file mode 100644 index 0000000000..a27d5fe985 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/ManageClient.kt @@ -0,0 +1,109 @@ +/* + * 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.experimental.clink.client + +import com.vitorpamplona.quartz.experimental.clink.manage.ManageEvent +import com.vitorpamplona.quartz.experimental.clink.manage.ManageOffer +import com.vitorpamplona.quartz.experimental.clink.manage.ManageRequest +import com.vitorpamplona.quartz.experimental.clink.manage.ManageResponse +import com.vitorpamplona.quartz.experimental.clink.manage.OfferFields +import com.vitorpamplona.quartz.experimental.clink.pointers.NManage +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * High-level CLINK Manage app client for delegated offer CRUD (kind 21003) against a + * wallet server addressed by an [NManage] pointer. + */ +class ManageClient( + val pointer: NManage, + val signer: NostrSigner, +) { + val serverPubKey: HexKey get() = pointer.pubKey + val relays: List get() = pointer.relays + + suspend fun createOffer( + label: String? = null, + priceSats: Long? = null, + callbackUrl: String? = null, + payerData: List? = null, + createdAt: Long = TimeUtils.now(), + ): ManageEvent = + send( + ManageRequest( + resource = ManageRequest.RESOURCE_OFFER, + action = ManageRequest.ACTION_CREATE, + pointer = pointer.pointer, + offer = ManageOffer(fields = OfferFields(label, priceSats, callbackUrl, payerData)), + ), + createdAt, + ) + + suspend fun updateOffer( + id: String, + label: String? = null, + priceSats: Long? = null, + callbackUrl: String? = null, + payerData: List? = null, + createdAt: Long = TimeUtils.now(), + ): ManageEvent = + send( + ManageRequest( + resource = ManageRequest.RESOURCE_OFFER, + action = ManageRequest.ACTION_UPDATE, + offer = ManageOffer(id = id, fields = OfferFields(label, priceSats, callbackUrl, payerData)), + ), + createdAt, + ) + + suspend fun getOffer( + id: String, + createdAt: Long = TimeUtils.now(), + ): ManageEvent = send(ManageRequest(ManageRequest.RESOURCE_OFFER, ManageRequest.ACTION_GET, offer = ManageOffer(id = id)), createdAt) + + suspend fun listOffers(createdAt: Long = TimeUtils.now()): ManageEvent = + send( + ManageRequest(ManageRequest.RESOURCE_OFFER, ManageRequest.ACTION_LIST, pointer = pointer.pointer), + createdAt, + ) + + suspend fun deleteOffer( + id: String, + createdAt: Long = TimeUtils.now(), + ): ManageEvent = send(ManageRequest(ManageRequest.RESOURCE_OFFER, ManageRequest.ACTION_DELETE, offer = ManageOffer(id = id)), createdAt) + + private suspend fun send( + request: ManageRequest, + createdAt: Long, + ): ManageEvent = ManageEvent.createRequest(request, serverPubKey, signer, createdAt) + + fun responseFilter(requestId: HexKey): Filter = + Filter( + kinds = listOf(ManageEvent.KIND), + authors = listOf(serverPubKey), + tags = mapOf("e" to listOf(requestId), "p" to listOf(signer.pubKey)), + ) + + suspend fun parseResponse(event: ManageEvent): ManageResponse = event.decryptResponse(signer) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/OfferClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/OfferClient.kt new file mode 100644 index 0000000000..cd7fc87edf --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/OfferClient.kt @@ -0,0 +1,96 @@ +/* + * 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.experimental.clink.client + +import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent +import com.vitorpamplona.quartz.experimental.clink.offers.OfferReceipt +import com.vitorpamplona.quartz.experimental.clink.offers.OfferRequest +import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse +import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * High-level CLINK Offers payer. + * + * Wraps a decoded [NOffer] pointer: builds the request event, exposes the relays to + * publish on, the filter to await the reply, and the response parser. + * + * ```kotlin + * val offer = ClinkPointerParser.parse("noffer1...") as NOffer + * val client = OfferClient(offer, signer) + * val request = client.requestInvoice(amountSats = 1000) + * // publish `request` to client.relays, then subscribe with client.responseFilter(request.id) + * val response = client.parseResponse(replyEvent) // -> OfferResponse(bolt11=...) or error + * ``` + */ +class OfferClient( + val pointer: NOffer, + val signer: NostrSigner, +) { + val servicePubKey: HexKey get() = pointer.pubKey + val relays: List get() = pointer.relays + + /** + * Builds the kind-21001 request asking the service for a fresh BOLT-11. When + * [amountSats] is null it falls back to the pointer's embedded price (for fixed offers). + */ + suspend fun requestInvoice( + amountSats: Long? = null, + description: String? = null, + payerData: Map? = null, + zap: String? = null, + expiresInSeconds: Long? = null, + createdAt: Long = TimeUtils.now(), + ): OfferEvent { + val request = + OfferRequest( + offer = pointer.pointer, + amount_sats = amountSats ?: pointer.price, + payer_data = payerData, + zap = zap, + expires_in_seconds = expiresInSeconds, + // The spec caps the invoice description at 100 chars; trim so an over-long + // value doesn't get the whole request rejected by the service. + description = description?.take(100), + ) + return OfferEvent.createRequest(request, servicePubKey, signer, createdAt) + } + + /** Subscribe with this on [relays] after publishing the request to await its reply. */ + fun responseFilter(requestId: HexKey): Filter = + Filter( + kinds = listOf(OfferEvent.KIND), + authors = listOf(servicePubKey), + tags = mapOf("e" to listOf(requestId), "p" to listOf(signer.pubKey)), + ) + + suspend fun parseResponse(event: OfferEvent): OfferResponse = event.decryptResponse(signer) + + /** + * Parses a post-settlement receipt — the optional second kind-21001 event the service + * sends (on the same `e`+`p` filter as [responseFilter]) once the returned invoice is paid. + */ + suspend fun parseReceipt(event: OfferEvent): OfferReceipt = event.decryptReceipt(signer) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/common/SatRange.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/common/SatRange.kt new file mode 100644 index 0000000000..a5827690fb --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/common/SatRange.kt @@ -0,0 +1,48 @@ +/* + * 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.experimental.clink.common + +import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable + +/** Inclusive sats range returned with "Invalid Amount" errors so the payer can correct and retry. */ +class SatRange( + var min: Long? = null, + var max: Long? = null, +) : OptimizedSerializable + +/** Timing detail attached to GFY "Expired Request" (code 3) errors. */ +class GfyDelta( + var max_delta_ms: Long? = null, + var actual_delta_ms: Long? = null, +) : OptimizedSerializable + +/** + * Shared "GFY" failure codes used by Debits (21002) and Manage (21003). + * The accompanying payload may carry [SatRange] (5), `retry_after` (4), or [GfyDelta] (3). + */ +object GfyErrorCode { + const val REQUEST_DENIED = 1 + const val TEMPORARY_FAILURE = 2 + const val EXPIRED_REQUEST = 3 + const val RATE_LIMITED = 4 + const val INVALID_AMOUNT = 5 + const val INVALID_REQUEST = 6 +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitEvent.kt new file mode 100644 index 0000000000..d5dc713ef3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitEvent.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.clink.debits + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.clink.tags.ClinkVersionTag +import com.vitorpamplona.quartz.experimental.clink.tags.clinkVersion +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * CLINK Debits event (kind 21002). The same kind carries both the request (requestor → + * service) and the response (service → requestor); a response is distinguished by an `e` + * tag referencing the request. Content is NIP-44 encrypted between the two parties. + * + * See https://github.com/shocknet/clink/blob/master/specs/clink-debits.md + */ +@Immutable +class DebitEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + override fun isContentEncoded() = true + + fun recipientPubKey() = tags.firstNotNullOfOrNull(PTag::parseKey) + + fun requestId() = tags.firstNotNullOfOrNull(ETag::parseId) + + fun isResponse() = requestId() != null + + fun version() = tags.firstNotNullOfOrNull(ClinkVersionTag::parse) + + /** + * The NIP-44 conversation peer for [myPubKey]: the counterparty, or null if I am neither + * the author nor the addressed recipient. No self-fallback — a malformed event (e.g. an + * authored request missing its `p` tag) yields null and fails cleanly instead of deriving + * a conversation key with myself. + */ + private fun conversationPeer(myPubKey: HexKey): HexKey? = + when (myPubKey) { + pubKey -> recipientPubKey() + recipientPubKey() -> pubKey + else -> null + } + + fun canDecrypt(signer: NostrSigner) = conversationPeer(signer.pubKey) != null + + suspend fun decryptContent(signer: NostrSigner): String { + val peer = conversationPeer(signer.pubKey) ?: throw SignerExceptions.UnauthorizedDecryptionException() + return signer.nip44Decrypt(content, peer) + } + + suspend fun decryptRequest(signer: NostrSigner): DebitRequest = OptimizedJsonMapper.fromJsonTo(decryptContent(signer)) + + suspend fun decryptResponse(signer: NostrSigner): DebitResponse = OptimizedJsonMapper.fromJsonTo(decryptContent(signer)) + + companion object { + const val KIND = 21002 + const val ALT = "CLINK debit" + + /** Builds a request event (requestor side) addressed to the debit service. */ + suspend fun createRequest( + request: DebitRequest, + servicePubKey: HexKey, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): DebitEvent { + val encrypted = signer.nip44Encrypt(OptimizedJsonMapper.toJson(request), servicePubKey) + return signer.sign( + eventTemplate(KIND, encrypted, createdAt) { + pTag(servicePubKey, null) + clinkVersion() + alt(ALT) + }, + ) + } + + /** Builds a response event (service side) referencing the original [requestEvent]. */ + suspend fun createResponse( + response: DebitResponse, + requestEvent: DebitEvent, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): DebitEvent { + val requestorPubKey = requestEvent.pubKey + val encrypted = signer.nip44Encrypt(OptimizedJsonMapper.toJson(response), requestorPubKey) + return signer.sign( + eventTemplate(KIND, encrypted, createdAt) { + pTag(requestorPubKey, null) + add(ETag.assemble(requestEvent.id, null, null)) + clinkVersion() + alt(ALT) + }, + ) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitMessages.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitMessages.kt new file mode 100644 index 0000000000..8d4fd67381 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitMessages.kt @@ -0,0 +1,96 @@ +/* + * 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.experimental.clink.debits + +import com.vitorpamplona.quartz.experimental.clink.common.GfyDelta +import com.vitorpamplona.quartz.experimental.clink.common.SatRange +import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable + +/** + * Decrypted request to a CLINK Debits service (kind 21002). Two shapes: + * - direct payment: [bolt11] (+ optional [amount_sats]); [k1] required for session pointers + * - budget approval: [amount_sats] + [frequency] (omit [frequency] for a one-time budget) + */ +class DebitRequest( + var pointer: String? = null, + var amount_sats: Long? = null, + var bolt11: String? = null, + var description: String? = null, + var k1: String? = null, + var frequency: DebitFrequency? = null, +) : OptimizedSerializable + +/** Recurring-budget cadence; [unit] is one of [UNIT_DAY], [UNIT_WEEK], [UNIT_MONTH]. */ +class DebitFrequency( + var number: Int? = null, + var unit: String? = null, +) : OptimizedSerializable { + companion object { + const val UNIT_DAY = "day" + const val UNIT_WEEK = "week" + const val UNIT_MONTH = "month" + + /** The only cadence units the spec defines. */ + val VALID_UNITS = setOf(UNIT_DAY, UNIT_WEEK, UNIT_MONTH) + } +} + +/** + * Decrypted response from a CLINK Debits service. [res] is `"ok"` on success (with + * [preimage] for Lightning payouts, absent for internal settlements/budget approvals) + * or `"GFY"` on failure, in which case [code] (see + * [com.vitorpamplona.quartz.experimental.clink.common.GfyErrorCode]) and [error] are set. + */ +class DebitResponse( + var res: String? = null, + var preimage: String? = null, + var code: Int? = null, + var error: String? = null, + var range: SatRange? = null, + var retry_after: Long? = null, + var delta: GfyDelta? = null, +) : OptimizedSerializable { + fun isOk(): Boolean = res == OK + + /** + * A failure detail string for a GFY response: the service's [error] plus the actionable + * structured extra the spec attaches per code — the allowed [range] (code 5) or + * [retry_after] timestamp (code 4). Returns null for a success ([isOk]) response. The + * text is protocol-neutral (English, like the service's own `error`), meant to be shown + * as the detail line under a localized "payment failed" title. + */ + fun failureDetail(): String? { + if (isOk()) return null + val base = error?.takeIf { it.isNotBlank() } + val extra = + when { + range != null -> "allowed ${range?.min ?: "?"} to ${range?.max ?: "?"} sats" + retry_after != null -> "retry after $retry_after" + else -> null + } + return listOfNotNull(base, extra?.let { "($it)" }).joinToString(" ").ifBlank { null } + } + + companion object { + const val OK = "ok" + const val GFY = "GFY" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageEvent.kt new file mode 100644 index 0000000000..c2901a8669 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageEvent.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.clink.manage + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.clink.tags.ClinkVersionTag +import com.vitorpamplona.quartz.experimental.clink.tags.clinkVersion +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * CLINK Manage event (kind 21003). The same kind carries both the request (app → + * wallet server) and the response (server → app); a response is distinguished by an `e` + * tag referencing the request. Content is NIP-44 encrypted between the two parties. + * + * See https://github.com/shocknet/clink/blob/master/specs/clink-manage.md + */ +@Immutable +class ManageEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + override fun isContentEncoded() = true + + fun recipientPubKey() = tags.firstNotNullOfOrNull(PTag::parseKey) + + fun requestId() = tags.firstNotNullOfOrNull(ETag::parseId) + + fun isResponse() = requestId() != null + + fun version() = tags.firstNotNullOfOrNull(ClinkVersionTag::parse) + + /** + * The NIP-44 conversation peer for [myPubKey]: the counterparty, or null if I am neither + * the author nor the addressed recipient. No self-fallback — a malformed event (e.g. an + * authored request missing its `p` tag) yields null and fails cleanly instead of deriving + * a conversation key with myself. + */ + private fun conversationPeer(myPubKey: HexKey): HexKey? = + when (myPubKey) { + pubKey -> recipientPubKey() + recipientPubKey() -> pubKey + else -> null + } + + fun canDecrypt(signer: NostrSigner) = conversationPeer(signer.pubKey) != null + + suspend fun decryptContent(signer: NostrSigner): String { + val peer = conversationPeer(signer.pubKey) ?: throw SignerExceptions.UnauthorizedDecryptionException() + return signer.nip44Decrypt(content, peer) + } + + suspend fun decryptRequest(signer: NostrSigner): ManageRequest = OptimizedJsonMapper.fromJsonTo(decryptContent(signer)) + + suspend fun decryptResponse(signer: NostrSigner): ManageResponse = OptimizedJsonMapper.fromJsonTo(decryptContent(signer)) + + companion object { + const val KIND = 21003 + const val ALT = "CLINK manage" + + /** Builds a request event (app side) addressed to the wallet server. */ + suspend fun createRequest( + request: ManageRequest, + serverPubKey: HexKey, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): ManageEvent { + val encrypted = signer.nip44Encrypt(OptimizedJsonMapper.toJson(request), serverPubKey) + return signer.sign( + eventTemplate(KIND, encrypted, createdAt) { + pTag(serverPubKey, null) + clinkVersion() + alt(ALT) + }, + ) + } + + /** Builds a response event (server side) referencing the original [requestEvent]. */ + suspend fun createResponse( + response: ManageResponse, + requestEvent: ManageEvent, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): ManageEvent { + val appPubKey = requestEvent.pubKey + val encrypted = signer.nip44Encrypt(OptimizedJsonMapper.toJson(response), appPubKey) + return signer.sign( + eventTemplate(KIND, encrypted, createdAt) { + pTag(appPubKey, null) + add(ETag.assemble(requestEvent.id, null, null)) + clinkVersion() + alt(ALT) + }, + ) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageMessages.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageMessages.kt new file mode 100644 index 0000000000..42dc289b8d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageMessages.kt @@ -0,0 +1,108 @@ +/* + * 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.experimental.clink.manage + +import com.vitorpamplona.quartz.experimental.clink.common.GfyDelta +import com.vitorpamplona.quartz.experimental.clink.common.SatRange +import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable + +/** + * The editable fields of a managed offer. `payer_data` is a list of field NAMES the offer + * requests from the payer (e.g. `["email", "shipping_address"]`), per the CLINK Manage spec. + */ +class OfferFields( + var label: String? = null, + var price_sats: Long? = null, + var callback_url: String? = null, + var payer_data: List? = null, +) : OptimizedSerializable + +/** + * The `offer` envelope on a Manage request: [id] identifies the target for update/get/delete, + * [fields] carries the values for create/update. + */ +class ManageOffer( + var id: String? = null, + var fields: OfferFields? = null, +) : OptimizedSerializable + +/** + * Decrypted request to a CLINK Manage service (kind 21003) for delegated offer CRUD. + * The offer data is NESTED under [offer] (not flat): `create` sets `offer.fields`, `update` + * sets `offer.id` + `offer.fields`, `get`/`delete` set `offer.id`, and `list` filters by + * [pointer]. (The published spec shows `create` with fields directly under `offer`; the + * reference SDK nests both create and update under `offer.fields`, which we follow for + * interop with SDK-built services.) + */ +class ManageRequest( + var resource: String? = null, + var action: String? = null, + var pointer: String? = null, + var offer: ManageOffer? = null, +) : OptimizedSerializable { + companion object { + const val RESOURCE_OFFER = "offer" + const val ACTION_CREATE = "create" + const val ACTION_UPDATE = "update" + const val ACTION_GET = "get" + const val ACTION_LIST = "list" + const val ACTION_DELETE = "delete" + } +} + +/** A managed offer as returned by the service: [OfferFields] plus its [id] and server-generated [noffer]. */ +class OfferData( + var id: String? = null, + var noffer: String? = null, + var label: String? = null, + var price_sats: Long? = null, + var callback_url: String? = null, + var payer_data: List? = null, +) : OptimizedSerializable + +/** + * Decrypted response from a CLINK Manage service. On success [res] is `"ok"` and [details] + * carries the affected offer(s); on failure [res] is `"GFY"` with [code]/[error] (and [field] + * naming the invalid input for validation errors). + * + * NOTE: the spec types `details` as `OfferData | OfferData[]` — a single object for + * create/update/get, an array for list. We model it as a list and rely on the JSON mapper + * coercing a lone object into a one-element list (Jackson `ACCEPT_SINGLE_VALUE_AS_ARRAY` on + * JVM/Android), so both shapes parse. + */ +class ManageResponse( + var res: String? = null, + var resource: String? = null, + var details: List? = null, + var code: Int? = null, + var error: String? = null, + var field: String? = null, + var range: SatRange? = null, + var retry_after: Long? = null, + var delta: GfyDelta? = null, +) : OptimizedSerializable { + fun isOk(): Boolean = res == OK + + companion object { + const val OK = "ok" + const val GFY = "GFY" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferEvent.kt new file mode 100644 index 0000000000..fb35b0b57f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferEvent.kt @@ -0,0 +1,155 @@ +/* + * 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.experimental.clink.offers + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.clink.tags.ClinkVersionTag +import com.vitorpamplona.quartz.experimental.clink.tags.clinkVersion +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * CLINK Offers event (kind 21001). The same kind carries both the request (payer → + * service) and the response (service → payer); a response is distinguished by an `e` + * tag referencing the request. Content is NIP-44 encrypted between the two parties. + * + * See https://github.com/shocknet/clink/blob/master/specs/clink-offers.md + */ +@Immutable +class OfferEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + override fun isContentEncoded() = true + + /** The `p` tag — the counterparty this message is addressed to. */ + fun recipientPubKey() = tags.firstNotNullOfOrNull(PTag::parseKey) + + /** The `e` tag — present only on responses, referencing the request event id. */ + fun requestId() = tags.firstNotNullOfOrNull(ETag::parseId) + + fun isResponse() = requestId() != null + + fun version() = tags.firstNotNullOfOrNull(ClinkVersionTag::parse) + + /** + * The NIP-44 conversation peer for [myPubKey]: the counterparty, or null if I am neither + * the author nor the addressed recipient. No self-fallback — a malformed event (e.g. an + * authored request missing its `p` tag) yields null and fails cleanly instead of deriving + * a conversation key with myself. + */ + private fun conversationPeer(myPubKey: HexKey): HexKey? = + when (myPubKey) { + pubKey -> recipientPubKey() + recipientPubKey() -> pubKey + else -> null + } + + fun canDecrypt(signer: NostrSigner) = conversationPeer(signer.pubKey) != null + + suspend fun decryptContent(signer: NostrSigner): String { + val peer = conversationPeer(signer.pubKey) ?: throw SignerExceptions.UnauthorizedDecryptionException() + return signer.nip44Decrypt(content, peer) + } + + suspend fun decryptRequest(signer: NostrSigner): OfferRequest = OptimizedJsonMapper.fromJsonTo(decryptContent(signer)) + + suspend fun decryptResponse(signer: NostrSigner): OfferResponse = OptimizedJsonMapper.fromJsonTo(decryptContent(signer)) + + suspend fun decryptReceipt(signer: NostrSigner): OfferReceipt = OptimizedJsonMapper.fromJsonTo(decryptContent(signer)) + + companion object { + const val KIND = 21001 + const val ALT = "CLINK offer" + + /** Builds a request event (payer side) addressed to the offer service. */ + suspend fun createRequest( + request: OfferRequest, + servicePubKey: HexKey, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): OfferEvent { + val encrypted = signer.nip44Encrypt(OptimizedJsonMapper.toJson(request), servicePubKey) + return signer.sign( + eventTemplate(KIND, encrypted, createdAt) { + pTag(servicePubKey, null) + clinkVersion() + alt(ALT) + }, + ) + } + + /** Builds a response event (service side) referencing the original [requestEvent]. */ + suspend fun createResponse( + response: OfferResponse, + requestEvent: OfferEvent, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): OfferEvent { + val payerPubKey = requestEvent.pubKey + val encrypted = signer.nip44Encrypt(OptimizedJsonMapper.toJson(response), payerPubKey) + return signer.sign( + eventTemplate(KIND, encrypted, createdAt) { + pTag(payerPubKey, null) + add(ETag.assemble(requestEvent.id, null, null)) + clinkVersion() + alt(ALT) + }, + ) + } + + /** + * Builds a post-settlement receipt event (service side). Like [createResponse] it + * references the original [requestEvent] by `e` tag and is addressed to the payer, but + * carries an [OfferReceipt] (`{"res":"ok"[,"preimage"]}`) sent after the invoice is paid. + */ + suspend fun createReceipt( + receipt: OfferReceipt, + requestEvent: OfferEvent, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): OfferEvent { + val payerPubKey = requestEvent.pubKey + val encrypted = signer.nip44Encrypt(OptimizedJsonMapper.toJson(receipt), payerPubKey) + return signer.sign( + eventTemplate(KIND, encrypted, createdAt) { + pTag(payerPubKey, null) + add(ETag.assemble(requestEvent.id, null, null)) + clinkVersion() + alt(ALT) + }, + ) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferMessages.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferMessages.kt new file mode 100644 index 0000000000..075aa723f0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferMessages.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.clink.offers + +import com.vitorpamplona.quartz.experimental.clink.common.SatRange +import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable + +/** + * Decrypted request payload sent to a CLINK Offers service (kind 21001) to obtain a + * fresh BOLT-11 invoice. `offer` echoes the pointer's offer-id; `amount_sats` is + * required for spontaneous/variable offers. `description` is capped at 100 chars. + */ +class OfferRequest( + var offer: String? = null, + var amount_sats: Long? = null, + var payer_data: Map? = null, + var zap: String? = null, + var expires_in_seconds: Long? = null, + var description: String? = null, +) : OptimizedSerializable + +/** + * Decrypted response from a CLINK Offers service. Either an invoice ([bolt11] set) or + * an error ([code] set). On "Expired or Moved" (code 3) the service may include + * [latest] with a replacement `noffer1…`; on "Invalid Amount" (code 5) it includes [range]. + */ +class OfferResponse( + var bolt11: String? = null, + var error: String? = null, + var code: Int? = null, + var range: SatRange? = null, + var latest: String? = null, +) : OptimizedSerializable { + fun isSuccess(): Boolean = bolt11 != null +} + +/** + * Optional post-settlement receipt (kind 21001): a second event the service sends after the + * invoice it returned is paid, confirming receipt out-of-band. `preimage` is absent for + * internal (non-Lightning) settlements — the payer's own wallet already holds it for LN pays. + */ +class OfferReceipt( + var res: String? = null, + var preimage: String? = null, +) : OptimizedSerializable { + fun isOk(): Boolean = res == OK + + companion object { + const val OK = "ok" + } +} + +/** Error codes returned by a CLINK Offers service in [OfferResponse.code]. */ +object OfferErrorCode { + const val INVALID_OFFER = 1 + const val TEMPORARY_FAILURE = 2 + const val EXPIRED_OR_MOVED = 3 + const val UNSUPPORTED_FEATURE = 4 + const val INVALID_AMOUNT = 5 +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointer.kt new file mode 100644 index 0000000000..79589453d6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointer.kt @@ -0,0 +1,69 @@ +/* + * 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.experimental.clink.pointers + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * CLINK ("Common Lightning Interface for Nostr Keys") addresses a remote Lightning + * service through a bech32-encoded pointer that carries a small TLV payload. There + * are three pointer kinds — one per CLINK spec — and they all share the first three + * TLV fields (pubkey, relay, pointer-id). See https://github.com/shocknet/clink. + * + * Pointers use **standard** bech32 (not bech32m) with their own human-readable + * prefixes (`noffer`, `ndebit`, `nmanage`), so they are intentionally parsed by + * [ClinkPointerParser] rather than the NIP-19 [com.vitorpamplona.quartz.nip19Bech32.Nip19Parser]. + */ +sealed interface ClinkPointer { + /** TLV 0 — 32-byte public key of the service that listens for requests. */ + val pubKey: HexKey + + /** TLV 1 — relay(s) where the service listens. May be empty when shared out-of-band. */ + val relays: List + + /** TLV 2 — opaque routing pointer the service uses to disambiguate (optional). */ + val pointer: String? + + /** Re-encodes this pointer to its `noffer1…` / `ndebit1…` / `nmanage1…` string. */ + fun encode(): String +} + +/** + * Shared TLV field indices across the CLINK pointer specs. Indices 0–2 are common; + * 3–4 are reused with different meanings per spec (priceType/price for offers, k1 + * for debit sessions), mirroring `@shocknet/clink-sdk`. + */ +internal object ClinkTlv { + const val PUBKEY: Byte = 0 + const val RELAY: Byte = 1 + const val POINTER: Byte = 2 + + // Offers + const val PRICE_TYPE: Byte = 3 + const val PRICE: Byte = 4 + + // Debits + const val K1: Byte = 3 +} + +/** Encodes a small unsigned value as a single-byte hex string for one-byte TLV values. */ +internal fun Int.toSingleByteHex(): String = (this and 0xFF).toString(16).padStart(2, '0') diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerParser.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerParser.kt new file mode 100644 index 0000000000..5b39369124 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerParser.kt @@ -0,0 +1,72 @@ +/* + * 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.experimental.clink.pointers + +import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException + +/** + * Decodes CLINK bech32 pointers (`noffer1…`, `ndebit1…`, `nmanage1…`). + * + * Kept deliberately separate from [com.vitorpamplona.quartz.nip19Bech32.Nip19Parser]: + * these prefixes are not NIP-19 entities, so we don't widen the app-wide NIP-19 + * matching to recognize them. + */ +object ClinkPointerParser { + /** All three HRPs, anchored at a `1` separator. Used to spot pointers inside free text. */ + val clinkRegex: Regex = + Regex( + "(noffer1|ndebit1|nmanage1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)", + RegexOption.IGNORE_CASE, + ) + + /** + * Parses a single pointer string. Tolerates a leading `nostr:`/`lightning:` scheme + * and surrounding whitespace. Returns null on any malformed input. + */ + fun parse(pointer: String): ClinkPointer? { + val cleaned = + pointer + .trim() + .removePrefix("nostr:") + .removePrefix("lightning:") + .trim() + + return try { + val (hrp, bytes, _) = Bech32.decodeBytes(cleaned) + when (hrp.lowercase()) { + NOffer.HRP -> NOffer.parse(bytes) + NDebit.HRP -> NDebit.parse(bytes) + NManage.HRP -> NManage.parse(bytes) + else -> null + } + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + Log.d("ClinkPointerParser") { "Failed to decode CLINK pointer $cleaned: ${e.message}" } + null + } + } + + /** Finds and decodes every CLINK pointer embedded in [content]. */ + fun parseAll(content: String): List = clinkRegex.findAll(content).mapNotNull { parse(it.value) }.toList() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NDebit.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NDebit.kt new file mode 100644 index 0000000000..3e884ed7a9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NDebit.kt @@ -0,0 +1,82 @@ +/* + * 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.experimental.clink.pointers + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 +import com.vitorpamplona.quartz.nip19Bech32.tlv.Tlv +import com.vitorpamplona.quartz.nip19Bech32.tlv.TlvBuilder + +/** + * CLINK Debits pointer (`ndebit1…`, kind 21002): authorizes a counterparty to pull a + * payment from the pointed-to wallet. A pointer is either *static* (TLV 0–2, long-lived, + * app-initiated) or a single-use *session* carrying a 32-byte [k1] (TLV 3), used for the + * LNURL-withdraw-like scan-to-pull flow. + * See https://github.com/shocknet/clink/blob/master/specs/clink-debits.md + */ +@Immutable +data class NDebit( + override val pubKey: HexKey, + override val relays: List, + override val pointer: String?, + /** TLV 3 — 32-byte single-use session id (lowercase hex). Null for static pointers. */ + val k1: HexKey?, +) : ClinkPointer { + /** True when this is a single-use session pointer (carries [k1]). */ + val isSession: Boolean get() = k1 != null + + // Note: SDK 1.5.5 does not encode k1; it is a spec-level session extension at TLV index 3. + override fun encode(): String = + TlvBuilder() + .apply { + addHex(ClinkTlv.PUBKEY, pubKey) + relays.forEach { addStringIfNotNull(ClinkTlv.RELAY, it.url) } + addStringIfNotNull(ClinkTlv.POINTER, pointer) + addHexIfNotNull(ClinkTlv.K1, k1) + }.build() + .let { Bech32.encodeBytes(HRP, it, Bech32.Encoding.Bech32) } + + companion object { + const val HRP = "ndebit" + + fun parse(bytes: ByteArray): NDebit? { + if (bytes.isEmpty()) return null + + val tlv = Tlv.parse(bytes) + + val pubKey = tlv.firstAsHex(ClinkTlv.PUBKEY) ?: return null + if (pubKey.isBlank()) return null + + val relays = tlv.asString(ClinkTlv.RELAY)?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } ?: emptyList() + val pointer = tlv.firstAsString(ClinkTlv.POINTER) + val k1 = tlv.firstAsHex(ClinkTlv.K1) + // A session id (TLV 3) MUST be exactly 32 bytes (64 hex chars) per the spec; a + // wrong-length value means a malformed session pointer, so reject the whole thing + // rather than silently treating it as static or sending a bad k1. + if (k1 != null && k1.length != 64) return null + + return NDebit(pubKey, relays, pointer, k1) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NManage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NManage.kt new file mode 100644 index 0000000000..8f11f875c7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NManage.kt @@ -0,0 +1,68 @@ +/* + * 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.experimental.clink.pointers + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 +import com.vitorpamplona.quartz.nip19Bech32.tlv.Tlv +import com.vitorpamplona.quartz.nip19Bech32.tlv.TlvBuilder + +/** + * CLINK Manage pointer (`nmanage1…`, kind 21003): a shareable token that grants an + * external app delegated CRUD over a user's offers on a wallet server. + * See https://github.com/shocknet/clink/blob/master/specs/clink-manage.md + */ +@Immutable +data class NManage( + override val pubKey: HexKey, + override val relays: List, + override val pointer: String?, +) : ClinkPointer { + override fun encode(): String = + TlvBuilder() + .apply { + addHex(ClinkTlv.PUBKEY, pubKey) + relays.forEach { addStringIfNotNull(ClinkTlv.RELAY, it.url) } + addStringIfNotNull(ClinkTlv.POINTER, pointer) + }.build() + .let { Bech32.encodeBytes(HRP, it, Bech32.Encoding.Bech32) } + + companion object { + const val HRP = "nmanage" + + fun parse(bytes: ByteArray): NManage? { + if (bytes.isEmpty()) return null + + val tlv = Tlv.parse(bytes) + + val pubKey = tlv.firstAsHex(ClinkTlv.PUBKEY) ?: return null + if (pubKey.isBlank()) return null + + val relays = tlv.asString(ClinkTlv.RELAY)?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } ?: emptyList() + val pointer = tlv.firstAsString(ClinkTlv.POINTER) + + return NManage(pubKey, relays, pointer) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NOffer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NOffer.kt new file mode 100644 index 0000000000..3e1956f8a2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NOffer.kt @@ -0,0 +1,100 @@ +/* + * 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.experimental.clink.pointers + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 +import com.vitorpamplona.quartz.nip19Bech32.tlv.Tlv +import com.vitorpamplona.quartz.nip19Bech32.tlv.TlvBuilder + +/** + * CLINK Offers pointer (`noffer1…`, kind 21001): a static payment code that lets a + * payer request a fresh BOLT-11 invoice over Nostr — the Nostr-native analogue of + * LNURL-Pay. See https://github.com/shocknet/clink/blob/master/specs/clink-offers.md + */ +@Immutable +data class NOffer( + override val pubKey: HexKey, + override val relays: List, + override val pointer: String?, + /** + * TLV 3 — how the offer is priced. Always a concrete type: when the wire field is + * absent it is [OfferPriceType.SPONTANEOUS], per the CLINK spec. + */ + val priceType: OfferPriceType, + /** TLV 4 — price in sats (display/fixed offers), 4-byte big-endian *unsigned* per the SDK. */ + val price: Long?, +) : ClinkPointer { + override fun encode(): String = + TlvBuilder() + .apply { + addHex(ClinkTlv.PUBKEY, pubKey) + relays.forEach { addStringIfNotNull(ClinkTlv.RELAY, it.url) } + addStringIfNotNull(ClinkTlv.POINTER, pointer) + // Always emit TLV 3, even for spontaneous offers: the reference SDK and + // bridgelet decoders throw on a missing price-type field, so an absent TLV 3 + // would make our pointers undecodable by every JS consumer. + addHex(ClinkTlv.PRICE_TYPE, priceType.code.toSingleByteHex()) + // addInt writes the low 32 bits big-endian; for an unsigned price up to + // 2^32-1 that is the correct 4-byte field even when it overflows a signed Int. + price?.let { addInt(ClinkTlv.PRICE, it.toInt()) } + }.build() + .let { Bech32.encodeBytes(HRP, it, Bech32.Encoding.Bech32) } + + companion object { + const val HRP = "noffer" + + fun parse(bytes: ByteArray): NOffer? { + if (bytes.isEmpty()) return null + + val tlv = Tlv.parse(bytes) + + val pubKey = tlv.firstAsHex(ClinkTlv.PUBKEY) ?: return null + if (pubKey.isBlank()) return null + + val relays = tlv.asString(ClinkTlv.RELAY)?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } ?: emptyList() + val pointer = tlv.firstAsString(ClinkTlv.POINTER) + // Per the CLINK Offers spec, an absent (or unrecognized) price-type defaults to + // spontaneous — the payer chooses the amount. + val priceType = + tlv.data[ClinkTlv.PRICE_TYPE]?.firstOrNull()?.firstOrNull()?.let { + OfferPriceType.fromCode(it.toInt() and 0xFF) + } ?: OfferPriceType.SPONTANEOUS + // The SDK decodes price as an UNSIGNED big-endian integer (parseInt of the hex); + // reading it as a signed Int would turn prices >= 2^31 sats into negative amounts. + val price = + tlv.data[ClinkTlv.PRICE] + ?.firstOrNull() + ?.takeIf { it.size == 4 } + ?.let { + ((it[0].toLong() and 0xFF) shl 24) or + ((it[1].toLong() and 0xFF) shl 16) or + ((it[2].toLong() and 0xFF) shl 8) or + (it[3].toLong() and 0xFF) + } + + return NOffer(pubKey, relays, pointer, priceType, price) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/OfferPriceType.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/OfferPriceType.kt new file mode 100644 index 0000000000..a9157d7ad3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/OfferPriceType.kt @@ -0,0 +1,45 @@ +/* + * 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.experimental.clink.pointers + +/** + * How a CLINK Offer is priced, encoded in TLV field 3 of a `noffer` pointer. + * + * When the field is absent the pointer defaults to [SPONTANEOUS] (payer chooses + * the amount), per the CLINK Offers spec. + */ +enum class OfferPriceType( + val code: Int, +) { + /** A fixed amount the payer must match; the amount travels in TLV 4. */ + FIXED(0), + + /** A fiat-denominated amount that the service converts to sats at request time. */ + VARIABLE(1), + + /** No preset amount; the payer specifies `amount_sats` in the request. */ + SPONTANEOUS(2), + ; + + companion object { + fun fromCode(code: Int): OfferPriceType? = entries.firstOrNull { it.code == code } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/server/ClinkServer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/server/ClinkServer.kt new file mode 100644 index 0000000000..8f8f2b09b1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/server/ClinkServer.kt @@ -0,0 +1,107 @@ +/* + * 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.experimental.clink.server + +import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent +import com.vitorpamplona.quartz.experimental.clink.manage.ManageEvent +import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.math.abs + +/** + * Service-side helpers for the three CLINK protocols. A CLINK service listens for + * requests addressed to it (`#p` = its pubkey) on its relays, validates freshness, + * then replies with `Event.createResponse(...)`. + * + * Amethyst is consume-only and does not run a service; these live in quartz so `amy` + * and interop tests can exercise the responder side. + */ +object ClinkServer { + /** Requests older/newer than this (vs. the service clock) must be rejected as stale. */ + const val MAX_REQUEST_AGE_SECONDS = 30L + + fun isFresh( + requestCreatedAt: Long, + now: Long = TimeUtils.now(), + ): Boolean = abs(now - requestCreatedAt) <= MAX_REQUEST_AGE_SECONDS + + /** + * Subscribes for incoming requests addressed to [servicePubKey]. Note the kind also + * carries responses, so the caller should skip events where [OfferEvent.isResponse] + * is true (they reference a request via `e`). + */ + fun offerRequestFilter( + servicePubKey: HexKey, + since: Long? = null, + ): Filter = requestFilter(OfferEvent.KIND, servicePubKey, since) + + fun debitRequestFilter( + servicePubKey: HexKey, + since: Long? = null, + ): Filter = requestFilter(DebitEvent.KIND, servicePubKey, since) + + fun manageRequestFilter( + serverPubKey: HexKey, + since: Long? = null, + ): Filter = requestFilter(ManageEvent.KIND, serverPubKey, since) + + private fun requestFilter( + kind: Int, + recipient: HexKey, + since: Long?, + ): Filter = + Filter( + kinds = listOf(kind), + tags = mapOf("p" to listOf(recipient)), + since = since, + ) +} + +/** + * Tracks single-use Debit session identifiers (`k1`). Per the Debits spec, a `k1` is + * consumed once the service accepts a request for it, scoped per `pointer` (or per + * service pubkey when no pointer is present). Structural/validation failures should + * NOT consume it, so callers consume only after a request validates. + * + * In-memory and not synchronized; a real service should back this with durable, + * concurrency-safe storage. + */ +class K1Tracker { + private val consumed = mutableSetOf() + + private fun key( + scope: String, + k1: HexKey, + ) = "$scope:$k1" + + fun isConsumed( + scope: String, + k1: HexKey, + ): Boolean = consumed.contains(key(scope, k1)) + + /** Marks [k1] consumed for [scope]; returns false if it was already consumed. */ + fun consume( + scope: String, + k1: HexKey, + ): Boolean = consumed.add(key(scope, k1)) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/tags/ClinkVersionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/tags/ClinkVersionTag.kt new file mode 100644 index 0000000000..4d3c727cda --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/tags/ClinkVersionTag.kt @@ -0,0 +1,44 @@ +/* + * 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.experimental.clink.tags + +import com.vitorpamplona.quartz.utils.ensure + +/** + * Protocol-version tag shared by all three CLINK message kinds (21001-3). Every CLINK + * request and response carries a `["clink_version", "1"]` tag alongside its `p` tag, + * with the content NIP-44 encrypted between the two parties. + */ +class ClinkVersionTag { + companion object { + const val TAG_NAME = "clink_version" + const val CURRENT = "1" + + fun parse(tag: Array): String? { + ensure(tag.size > 1) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(version: String = CURRENT) = arrayOf(TAG_NAME, version) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/tags/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/tags/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..8c0d2da7b0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/tags/TagArrayBuilderExt.kt @@ -0,0 +1,26 @@ +/* + * 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.experimental.clink.tags + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.clinkVersion(version: String = ClinkVersionTag.CURRENT) = addUnique(ClinkVersionTag.assemble(version)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt index d7084aad9e..08f00d54ad 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.builder import com.vitorpamplona.quartz.nip01Core.metadata.tags.AboutTag import com.vitorpamplona.quartz.nip01Core.metadata.tags.BannerTag +import com.vitorpamplona.quartz.nip01Core.metadata.tags.ClinkOfferTag import com.vitorpamplona.quartz.nip01Core.metadata.tags.DisplayNameTag import com.vitorpamplona.quartz.nip01Core.metadata.tags.Lud06Tag import com.vitorpamplona.quartz.nip01Core.metadata.tags.Lud16Tag @@ -127,6 +128,7 @@ class MetadataEvent( twitter: String? = null, mastodon: String? = null, github: String? = null, + clinkOffer: String? = null, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ): EventTemplate { @@ -145,6 +147,7 @@ class MetadataEvent( lnAddress, lnURL, pronouns, + clinkOffer, ) val newJsonObject = JsonObject(currentMetadata) @@ -182,6 +185,7 @@ class MetadataEvent( twitter: String? = null, mastodon: String? = null, github: String? = null, + clinkOffer: String? = null, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ): EventTemplate { @@ -200,6 +204,7 @@ class MetadataEvent( lnAddress, lnURL, pronouns, + clinkOffer, ) val newJsonObject = JsonObject(currentMetadata) @@ -233,6 +238,7 @@ class MetadataEvent( lnAddress: String? = null, lnURL: String? = null, pronouns: String? = null, + clinkOffer: String? = null, ) { name?.let { addIfNotBlank(currentMetadata, NameTag.TAG_NAME, it) } displayName?.let { addIfNotBlank(currentMetadata, DisplayNameTag.TAG_NAME, it) } @@ -244,6 +250,7 @@ class MetadataEvent( nip05?.let { addIfNotBlank(currentMetadata, Nip05Tag.TAG_NAME, it) } lnAddress?.let { addIfNotBlank(currentMetadata, Lud16Tag.TAG_NAME, it) } lnURL?.let { addIfNotBlank(currentMetadata, Lud06Tag.TAG_NAME, it) } + clinkOffer?.let { addIfNotBlank(currentMetadata, ClinkOfferTag.TAG_NAME, it) } } // For https://github.com/nostr-protocol/nips/pull/1770 @@ -258,6 +265,7 @@ class MetadataEvent( currentMetadata[Nip05Tag.TAG_NAME]?.let { nip05(it.text) } ?: run { remove(Nip05Tag.TAG_NAME) } currentMetadata[Lud16Tag.TAG_NAME]?.let { lud16(it.text) } ?: run { remove(Lud16Tag.TAG_NAME) } currentMetadata[Lud06Tag.TAG_NAME]?.let { lud06(it.text) } ?: run { remove(Lud06Tag.TAG_NAME) } + currentMetadata[ClinkOfferTag.TAG_NAME]?.let { clinkOffer(it.text) } ?: run { remove(ClinkOfferTag.TAG_NAME) } } private fun addIfNotBlank( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/TagArrayBuilderExt.kt index 51e37b3d84..163e0df3de 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/TagArrayBuilderExt.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip01Core.metadata import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.metadata.tags.AboutTag import com.vitorpamplona.quartz.nip01Core.metadata.tags.BannerTag +import com.vitorpamplona.quartz.nip01Core.metadata.tags.ClinkOfferTag import com.vitorpamplona.quartz.nip01Core.metadata.tags.DisplayNameTag import com.vitorpamplona.quartz.nip01Core.metadata.tags.Lud06Tag import com.vitorpamplona.quartz.nip01Core.metadata.tags.Lud16Tag @@ -51,3 +52,5 @@ fun TagArrayBuilder.lud06(lud06: String) = addUnique(Lud06Tag.ass fun TagArrayBuilder.banner(banner: String) = addUnique(BannerTag.assemble(banner)) fun TagArrayBuilder.pronouns(pronouns: String) = addUnique(PronounsTag.assemble(pronouns)) + +fun TagArrayBuilder.clinkOffer(offer: String) = addUnique(ClinkOfferTag.assemble(offer)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt index 6ebd2ad25c..b902f1604b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt @@ -56,12 +56,18 @@ class UserMetadata { var lud06: String? = null var lud16: String? = null + /** CLINK Offers pointer (`noffer1…`) the user advertises to receive payments over Nostr. */ + @SerialName("clink_offer") + var clinkOffer: String? = null + var twitter: String? = null fun anyName(): String? = displayName ?: name fun lnAddress(): String? = lud16 ?: lud06 + fun clinkOffer(): String? = clinkOffer?.takeIf { it.isNotBlank() } + fun bestName(): String? = displayName ?: name fun firstName(): String? { @@ -93,6 +99,7 @@ class UserMetadata { if (name?.isNotEmpty() == true) name = name?.trim() if (lud06?.isNotEmpty() == true) lud06 = lud06?.trim() if (lud16?.isNotEmpty() == true) lud16 = lud16?.trim() + if (clinkOffer?.isNotEmpty() == true) clinkOffer = clinkOffer?.trim() if (pronouns?.isNotEmpty() == true) pronouns = pronouns?.trim() if (banner?.isNotEmpty() == true) banner = banner?.trim() @@ -105,6 +112,7 @@ class UserMetadata { if (name?.isBlank() == true) name = null if (lud06?.isBlank() == true) lud06 = null if (lud16?.isBlank() == true) lud16 = null + if (clinkOffer?.isBlank() == true) clinkOffer = null if (banner?.isBlank() == true) banner = null if (website?.isBlank() == true) website = null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/tags/ClinkOfferTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/tags/ClinkOfferTag.kt new file mode 100644 index 0000000000..a744501813 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/tags/ClinkOfferTag.kt @@ -0,0 +1,39 @@ +/* + * 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.nip01Core.metadata.tags + +import com.vitorpamplona.quartz.utils.ensure + +/** CLINK Offers pointer (`noffer1…`) advertised in a kind-0 profile, mirroring the NIP-05 `clink_offer` key. */ +class ClinkOfferTag { + companion object { + const val TAG_NAME = "clink_offer" + + fun parse(tag: Array): String? { + ensure(tag.size > 1) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(offer: String) = arrayOf(TAG_NAME, offer) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt index 33877f0f88..d1159083b6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt @@ -212,12 +212,27 @@ open class BasicRelayClient( } override fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean) { - if (!isConnectionStarted() && !connectingMutex.load()) { - // waits 60 seconds to reconnect after disconnected. - if (ignoreRetryDelays || TimeUtils.now() > lastConnectTentativeInSeconds + delayToConnectInSeconds) { - upRelayDelayToConnect() + if (connectingMutex.load()) return + + if (isConnectionStarted()) { + // A socket already exists. Normally leave it alone, but if it was opened for the wrong + // transport — e.g. the relay's Tor classification changed since (a clearnet relay now routed + // through the Tor proxy, or vice-versa) — tear it down and rebuild on the current transport. + // Without this an in-flight dial on the wrong transport can never be preempted: a still- + // connecting socket leaves isConnectionStarted() true (so the old guard skipped it) yet + // isConnected() false (so RelayPool.reconnectIfNeedsTo's connected-relay branch never runs), + // and the request blocks until that hung dial finally times out. + if (socket?.needsReconnect() == true) { + disconnect() connect() } + return + } + + // waits 60 seconds to reconnect after disconnected. + if (ignoreRetryDelays || TimeUtils.now() > lastConnectTentativeInSeconds + delayToConnectInSeconds) { + upRelayDelayToConnect() + connect() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/INip05Client.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/INip05Client.kt index b47acf1f26..1991c87660 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/INip05Client.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/INip05Client.kt @@ -33,6 +33,9 @@ interface INip05Client { suspend fun load(nip05: Nip05Id): KeyInfoSet? suspend fun list(domain: String): KeyInfoSet + + /** The CLINK Offers pointer advertised in the NIP-05 `.well-known/nostr.json`, if any. */ + suspend fun loadClinkOffer(nip05: Nip05Id): String? = null } class EmptyNip05Client : INip05Client { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt index c7520138d9..c4c3b1944f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt @@ -73,6 +73,14 @@ class Nip05Client( override suspend fun list(domain: String) = parser.parse(fetchNip05Data(domain)) + override suspend fun loadClinkOffer(nip05: Nip05Id): String? = + try { + parser.parseClinkOffer(nip05, fetchNip05Data(nip05)) + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + suspend fun fetchNip05Data(nip05: Nip05Id): String { val url = nip05.toUserUrl() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Parser.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Parser.kt index 1614c056d3..bc80d7c79b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Parser.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Parser.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.nip05DnsIdentifiers import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.utils.text import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive @@ -43,6 +45,33 @@ class Nip05Parser { ?.jsonPrimitive ?.content + /** + * Reads a CLINK Offers pointer (`noffer1…`) from a NIP-05 `.well-known/nostr.json`. + * + * Two shapes are seen in the wild and both are accepted: + * - the spec/SDK map, keyed by the local name like `names` — + * `{"clink_offer": {"bob": "noffer1…"}}` (queried as `?name=`); + * - bridgelet's flat top-level string — `{"clink_offer": "noffer1…"}` (one alias per file). + * + * A missing or differently-shaped entry simply yields null. + */ + fun parseClinkOffer( + nip05: Nip05Id, + json: String, + ): String? = + try { + val element = Json.parseToJsonElement(json).jsonObject["clink_offer"] + val raw = + when (element) { + is JsonObject -> element[nip05.name]?.jsonPrimitive?.content + is JsonPrimitive -> element.content + else -> null + } + raw?.takeIf { it.isNotBlank() } + } catch (_: Exception) { + null + } + fun parseHexKeyAndRelays( nip05: Nip05Id, json: String, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 6356a1667a..8e4e1747cd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -30,6 +30,9 @@ import com.vitorpamplona.quartz.experimental.attestations.request.AttestationReq import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent +import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent +import com.vitorpamplona.quartz.experimental.clink.manage.ManageEvent +import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent @@ -462,6 +465,9 @@ class EventFactory { LnZapEvent.KIND -> LnZapEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentRequestEvent.KIND -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentResponseEvent.KIND -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig) + OfferEvent.KIND -> OfferEvent(id, pubKey, createdAt, tags, content, sig) + DebitEvent.KIND -> DebitEvent(id, pubKey, createdAt, tags, content, sig) + ManageEvent.KIND -> ManageEvent(id, pubKey, createdAt, tags, content, sig) NwcInfoEvent.KIND -> NwcInfoEvent(id, pubKey, createdAt, tags, content, sig) NwcNotificationEvent.KIND -> NwcNotificationEvent(id, pubKey, createdAt, tags, content, sig) NwcNotificationEvent.LEGACY_KIND -> NwcNotificationEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkClientServerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkClientServerTest.kt new file mode 100644 index 0000000000..083c015cdc --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkClientServerTest.kt @@ -0,0 +1,129 @@ +/* + * 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.experimental.clink + +import com.vitorpamplona.quartz.experimental.clink.client.DebitClient +import com.vitorpamplona.quartz.experimental.clink.client.OfferClient +import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent +import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency +import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent +import com.vitorpamplona.quartz.experimental.clink.offers.OfferReceipt +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit +import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer +import com.vitorpamplona.quartz.experimental.clink.pointers.OfferPriceType +import com.vitorpamplona.quartz.experimental.clink.server.ClinkServer +import com.vitorpamplona.quartz.experimental.clink.server.K1Tracker +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ClinkClientServerTest { + private val signer = NostrSignerInternal(KeyPair()) + private val servicePubKey = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e" + private val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.shocknet.dev")!! + + @Test + fun offerClientExposesPointerRoutingAndResponseFilter() { + val client = OfferClient(NOffer(servicePubKey, listOf(relay), "offer-id", OfferPriceType.SPONTANEOUS, null), signer) + + assertEquals(servicePubKey, client.servicePubKey) + assertEquals(listOf(relay), client.relays) + + val filter = client.responseFilter("d".repeat(64)) + assertEquals(listOf(OfferEvent.KIND), filter.kinds) + assertEquals(listOf(servicePubKey), filter.authors) + assertEquals(listOf("d".repeat(64)), filter.tags?.get("e")) + } + + @Test + fun offerReceiptRoundTripsFromServiceToPayer() = + kotlinx.coroutines.test.runTest { + val payer = NostrSignerInternal(KeyPair()) + val service = NostrSignerInternal(KeyPair()) + val offer = NOffer(service.pubKey, listOf(relay), "offer-id", OfferPriceType.SPONTANEOUS, null) + val client = OfferClient(offer, payer) + + // payer asks, service settles and sends a receipt referencing the request + val request = client.requestInvoice(amountSats = 1000) + val receiptEvent = OfferEvent.createReceipt(OfferReceipt(res = OfferReceipt.OK, preimage = "ab".repeat(32)), request, service) + + assertEquals(request.id, receiptEvent.requestId()) + val receipt = client.parseReceipt(receiptEvent) + assertTrue(receipt.isOk()) + assertEquals("ab".repeat(32), receipt.preimage) + } + + @Test + fun requestBudgetRejectsInvalidFrequencyUnit() = + kotlinx.coroutines.test.runTest { + val client = DebitClient(NDebit(servicePubKey, listOf(relay), null, null), signer) + kotlin.test.assertFailsWith { + client.requestBudget(1000, DebitFrequency(1, "fortnight")) + } + // valid units do not throw + client.requestBudget(1000, DebitFrequency(1, DebitFrequency.UNIT_MONTH)) + } + + @Test + fun offerRequestTruncatesDescriptionTo100Chars() = + kotlinx.coroutines.test.runTest { + val service = NostrSignerInternal(KeyPair()) + val client = OfferClient(NOffer(service.pubKey, listOf(relay), "o", OfferPriceType.SPONTANEOUS, null), signer) + val event = client.requestInvoice(amountSats = 100, description = "x".repeat(150)) + val decrypted = event.decryptRequest(service) + assertEquals(100, decrypted.description?.length) + } + + @Test + fun serverRequestFilterTargetsRecipientByPTag() { + val filter = ClinkServer.debitRequestFilter(servicePubKey, since = 100L) + + assertEquals(listOf(DebitEvent.KIND), filter.kinds) + assertEquals(listOf(servicePubKey), filter.tags?.get("p")) + assertEquals(100L, filter.since) + } + + @Test + fun freshnessHonors30SecondWindow() { + assertTrue(ClinkServer.isFresh(requestCreatedAt = 1000, now = 1000)) + assertTrue(ClinkServer.isFresh(requestCreatedAt = 1000, now = 1030)) + assertTrue(ClinkServer.isFresh(requestCreatedAt = 1030, now = 1000)) + assertFalse(ClinkServer.isFresh(requestCreatedAt = 1000, now = 1031)) + } + + @Test + fun k1TrackerIsSingleUsePerScope() { + val tracker = K1Tracker() + val k1 = "4caa9ee5f0f0a0b1c2d3e4f5061728394a5b6c7d8e9f00112233445566778899" + + assertFalse(tracker.isConsumed("pointer-7", k1)) + assertTrue(tracker.consume("pointer-7", k1)) + assertTrue(tracker.isConsumed("pointer-7", k1)) + // second consume of the same scope+k1 fails + assertFalse(tracker.consume("pointer-7", k1)) + // same k1 under a different scope is independent + assertTrue(tracker.consume("pointer-8", k1)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkEventTest.kt new file mode 100644 index 0000000000..8fb62cb979 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkEventTest.kt @@ -0,0 +1,188 @@ +/* + * 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.experimental.clink + +import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse +import com.vitorpamplona.quartz.experimental.clink.manage.ManageOffer +import com.vitorpamplona.quartz.experimental.clink.manage.ManageRequest +import com.vitorpamplona.quartz.experimental.clink.manage.ManageResponse +import com.vitorpamplona.quartz.experimental.clink.manage.OfferFields +import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent +import com.vitorpamplona.quartz.experimental.clink.offers.OfferRequest +import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse +import com.vitorpamplona.quartz.experimental.clink.tags.ClinkVersionTag +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Pure-logic + JSON (de)serialization tests for the CLINK message kinds. The actual + * NIP-44 encrypt/decrypt round-trip lives in androidDeviceTest because NIP-44 crypto + * requires lazysodium, which is unavailable in JVM unit tests. + */ +class ClinkEventTest { + private val payer = NostrSignerInternal(KeyPair()) + private val service = NostrSignerInternal(KeyPair()) + private val stranger = NostrSignerInternal(KeyPair()) + + private fun buildEvent( + author: String, + recipient: String, + requestId: String? = null, + ) = OfferEvent( + id = "a".repeat(64), + pubKey = author, + createdAt = 1L, + tags = + buildList { + add(arrayOf("p", recipient)) + if (requestId != null) add(arrayOf("e", requestId)) + add(ClinkVersionTag.assemble()) + }.toTypedArray(), + content = "encrypted-placeholder", + sig = "b".repeat(128), + ) + + // --- tag logic --- + + @Test + fun requestHasNoEventTag() { + val request = buildEvent(payer.pubKey, service.pubKey) + assertFalse(request.isResponse()) + assertNull(request.requestId()) + assertEquals(service.pubKey, request.recipientPubKey()) + assertEquals(ClinkVersionTag.CURRENT, request.version()) + } + + @Test + fun responseReferencesRequest() { + val response = buildEvent(service.pubKey, payer.pubKey, requestId = "c".repeat(64)) + assertTrue(response.isResponse()) + assertEquals("c".repeat(64), response.requestId()) + } + + @Test + fun canDecryptOnlyByEitherParty() { + val request = buildEvent(payer.pubKey, service.pubKey) + assertTrue(request.canDecrypt(payer)) + assertTrue(request.canDecrypt(service)) + assertFalse(request.canDecrypt(stranger)) + } + + @Test + fun cannotDecryptAuthoredEventMissingRecipient() { + // A malformed event I authored but with no `p` tag: my own key must NOT be used as + // the conversation peer (no self-fallback), so neither party can derive a key. + val noRecipient = + OfferEvent( + id = "a".repeat(64), + pubKey = payer.pubKey, + createdAt = 1L, + tags = arrayOf(ClinkVersionTag.assemble()), + content = "encrypted-placeholder", + sig = "b".repeat(128), + ) + assertFalse(noRecipient.canDecrypt(payer)) + assertFalse(noRecipient.canDecrypt(service)) + } + + // --- JSON DTOs --- + + @Test + fun offerRequestJsonRoundTrip() { + val request = OfferRequest(offer = "abc", amount_sats = 1500, description = "coffee") + val parsed = OptimizedJsonMapper.fromJsonTo(OptimizedJsonMapper.toJson(request)) + + assertEquals("abc", parsed.offer) + assertEquals(1500, parsed.amount_sats) + assertEquals("coffee", parsed.description) + } + + @Test + fun offerResponseInvoiceParses() { + val parsed = OptimizedJsonMapper.fromJsonTo("""{"bolt11":"lnbc1..."}""") + assertTrue(parsed.isSuccess()) + assertEquals("lnbc1...", parsed.bolt11) + } + + @Test + fun offerResponseInvalidAmountParsesRange() { + val parsed = + OptimizedJsonMapper.fromJsonTo( + """{"error":"Invalid Amount","code":5,"range":{"min":1000,"max":50000}}""", + ) + assertFalse(parsed.isSuccess()) + assertEquals(5, parsed.code) + assertEquals(1000, parsed.range?.min) + assertEquals(50000, parsed.range?.max) + } + + @Test + fun debitGfyResponseParses() { + val parsed = + OptimizedJsonMapper.fromJsonTo( + """{"res":"GFY","code":4,"error":"Rate Limited","retry_after":1717000000}""", + ) + assertFalse(parsed.isOk()) + assertEquals(4, parsed.code) + assertEquals(1717000000, parsed.retry_after) + } + + @Test + fun debitOkResponseParses() { + val parsed = OptimizedJsonMapper.fromJsonTo("""{"res":"ok","preimage":"deadbeef"}""") + assertTrue(parsed.isOk()) + assertEquals("deadbeef", parsed.preimage) + } + + @Test + fun manageCreateRequestSerializesNested() { + val request = + ManageRequest( + resource = ManageRequest.RESOURCE_OFFER, + action = ManageRequest.ACTION_CREATE, + offer = ManageOffer(fields = OfferFields("Coffee", 1500L, "https://x/cb", listOf("email", "name"))), + ) + val json = OptimizedJsonMapper.toJson(request) + + // Offer data is nested under offer.fields (not flat). + assertTrue(json.contains("\"offer\""), json) + assertTrue(json.contains("\"fields\""), json) + + val parsed = OptimizedJsonMapper.fromJsonTo(json) + assertEquals("Coffee", parsed.offer?.fields?.label) + assertEquals(1500L, parsed.offer?.fields?.price_sats) + // payer_data is a list of field names, not a map. + assertEquals(listOf("email", "name"), parsed.offer?.fields?.payer_data) + } + + @Test + fun manageFailureResponseParsesField() { + val parsed = OptimizedJsonMapper.fromJsonTo("""{"res":"GFY","code":5,"error":"bad","field":"price_sats"}""") + assertFalse(parsed.isOk()) + assertEquals("price_sats", parsed.field) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkWireShapeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkWireShapeTest.kt new file mode 100644 index 0000000000..11182bf76a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkWireShapeTest.kt @@ -0,0 +1,304 @@ +/* + * 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.experimental.clink + +import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency +import com.vitorpamplona.quartz.experimental.clink.debits.DebitRequest +import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse +import com.vitorpamplona.quartz.experimental.clink.manage.ManageRequest +import com.vitorpamplona.quartz.experimental.clink.manage.ManageResponse +import com.vitorpamplona.quartz.experimental.clink.offers.OfferReceipt +import com.vitorpamplona.quartz.experimental.clink.offers.OfferRequest +import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Golden wire-shape fixtures: the literal decrypted JSON payload bodies documented in the + * CLINK specs (`shocknet/CLINK/specs/clink-{offers,debits,manage}.md`, declared public domain) + * must deserialize into our DTOs with the right fields. These guard the request/response shapes + * we exchange with every CLINK service against drift — they are the encrypted-content half that + * the bech32 pointer vectors (`ClinkInteropTest`) don't cover. + * + * Placeholders shown as `<…>` in the spec markdown are substituted with realistic concrete + * values; numeric placeholders (`actual_delta_ms`, `retry_after`) use numbers, not the + * markdown's quoted strings, matching what real services emit. + */ +class ClinkWireShapeTest { + private inline fun parse(json: String): T = OptimizedJsonMapper.fromJsonTo(json) + + // ---------- Offers (kind 21001) ---------- + + @Test + fun offerRequest() { + val req = + parse( + """{"offer":"coffee","amount_sats":21000,"payer_data":{},"zap":"{...}","expires_in_seconds":3600,"description":"A coffee"}""", + ) + assertEquals("coffee", req.offer) + assertEquals(21000L, req.amount_sats) + assertEquals(3600L, req.expires_in_seconds) + assertEquals("A coffee", req.description) + assertEquals("{...}", req.zap) + assertNotNull(req.payer_data) + } + + @Test + fun offerSuccessBolt11() { + val res = parse("""{"bolt11":"lnbc10u1pexample"}""") + assertTrue(res.isSuccess()) + assertEquals("lnbc10u1pexample", res.bolt11) + } + + @Test + fun offerErrorInvalidOffer() { + val res = parse("""{"error":"Invalid Offer","code":1}""") + assertFalse(res.isSuccess()) + assertEquals(1, res.code) + assertEquals("Invalid Offer", res.error) + assertNull(res.bolt11) + } + + @Test + fun offerErrorExpiredNoForwarding() { + val res = parse("""{"error":"Offer has expired.","code":3}""") + assertEquals(3, res.code) + assertNull(res.latest) + } + + @Test + fun offerErrorMovedWithLatest() { + val res = + parse( + """{"error":"Offer has been replaced or moved.","code":3,"latest":"noffer1qqsmoved"}""", + ) + assertEquals(3, res.code) + assertEquals("noffer1qqsmoved", res.latest) + } + + @Test + fun offerErrorInvalidAmountRange() { + val res = parse("""{"error":"Invalid Amount","code":5,"range":{"min":10,"max":10000000}}""") + assertEquals(5, res.code) + assertEquals(10L, res.range?.min) + assertEquals(10000000L, res.range?.max) + } + + @Test + fun offerReceiptWithPreimage() { + val receipt = parse("""{"res":"ok","preimage":"${"ab".repeat(32)}"}""") + assertTrue(receipt.isOk()) + assertEquals("ab".repeat(32), receipt.preimage) + } + + @Test + fun offerReceiptInternalSettlement() { + val receipt = parse("""{"res":"ok"}""") + assertTrue(receipt.isOk()) + assertNull(receipt.preimage) + } + + // ---------- Debits (kind 21002) ---------- + + @Test + fun debitDirectPaymentRequest() { + val req = + parse( + """{"pointer":"app-7","amount_sats":10000,"bolt11":"lnbc100n1pexample","description":"zap","k1":"${"4c".repeat(32)}"}""", + ) + assertEquals("app-7", req.pointer) + assertEquals(10000L, req.amount_sats) + assertEquals("lnbc100n1pexample", req.bolt11) + assertEquals("4c".repeat(32), req.k1) + assertNull(req.frequency) + } + + @Test + fun debitBudgetRequest() { + val req = + parse( + """{"pointer":"app-7","amount_sats":50000,"frequency":{"number":1,"unit":"month"},"description":"sub"}""", + ) + assertEquals(50000L, req.amount_sats) + assertEquals(1, req.frequency?.number) + assertEquals(DebitFrequency.UNIT_MONTH, req.frequency?.unit) + assertNull(req.bolt11) + } + + @Test + fun debitSuccessWithPreimage() { + val res = parse("""{"res":"ok","preimage":"${"cd".repeat(32)}"}""") + assertTrue(res.isOk()) + assertEquals("cd".repeat(32), res.preimage) + } + + @Test + fun debitSuccessInternalOrBudgetApproval() { + val res = parse("""{"res":"ok"}""") + assertTrue(res.isOk()) + assertNull(res.preimage) + } + + @Test + fun debitGfyRequestDenied() { + val res = parse("""{"res":"GFY","code":1,"error":"Request Denied"}""") + assertFalse(res.isOk()) + assertEquals(1, res.code) + assertEquals("Request Denied", res.error) + } + + @Test + fun debitGfyExpiredWithDelta() { + val res = + parse( + """{"res":"GFY","code":3,"error":"Expired Request","delta":{"max_delta_ms":30000,"actual_delta_ms":31200}}""", + ) + assertEquals(3, res.code) + assertEquals(30000L, res.delta?.max_delta_ms) + assertEquals(31200L, res.delta?.actual_delta_ms) + } + + @Test + fun debitGfyRateLimitedRetryAfter() { + val res = parse("""{"res":"GFY","code":4,"error":"Rate Limited","retry_after":1750000000}""") + assertEquals(4, res.code) + assertEquals(1750000000L, res.retry_after) + } + + @Test + fun debitGfyInvalidAmountRange() { + val res = parse("""{"res":"GFY","code":5,"error":"Invalid Amount","range":{"min":1000,"max":500000}}""") + assertEquals(5, res.code) + assertEquals(1000L, res.range?.min) + assertEquals(500000L, res.range?.max) + } + + @Test + fun debitFailureDetailSurfacesRangeAndRetryAfter() { + val range = parse("""{"res":"GFY","code":5,"error":"Invalid Amount","range":{"min":1000,"max":500000}}""").failureDetail()!! + assertTrue(range.contains("Invalid Amount")) + assertTrue(range.contains("1000") && range.contains("500000")) + + val rateLimited = parse("""{"res":"GFY","code":4,"error":"Rate Limited","retry_after":1750000000}""").failureDetail()!! + assertTrue(rateLimited.contains("Rate Limited")) + assertTrue(rateLimited.contains("1750000000")) + + assertNull(parse("""{"res":"ok"}""").failureDetail()) + } + + @Test + fun debitGfyInvalidRequest() { + val res = parse("""{"res":"GFY","code":6,"error":"Invalid Request: K1 already processed"}""") + assertEquals(6, res.code) + assertEquals("Invalid Request: K1 already processed", res.error) + } + + // ---------- Manage (kind 21003) ---------- + // Requests use the nested `offer.fields` shape (the form the reference SDK, Lightning.Pub, + // and clink-demo exchange — see ManageMessages.kt), not the spec's inline-create example. + + @Test + fun manageUpdateRequestNestedFields() { + val req = + parse( + """{"resource":"offer","action":"update","offer":{"id":"off-1","fields":{"label":"Updated Product X","price_sats":23456,"callback_url":"https://m.app/cb","payer_data":["email","shipping_address"]}}}""", + ) + assertEquals(ManageRequest.RESOURCE_OFFER, req.resource) + assertEquals(ManageRequest.ACTION_UPDATE, req.action) + assertEquals("off-1", req.offer?.id) + assertEquals("Updated Product X", req.offer?.fields?.label) + assertEquals(23456L, req.offer?.fields?.price_sats) + assertEquals(listOf("email", "shipping_address"), req.offer?.fields?.payer_data) + } + + @Test + fun manageListRequest() { + val req = parse("""{"resource":"offer","action":"list"}""") + assertEquals(ManageRequest.ACTION_LIST, req.action) + assertNull(req.offer) + } + + @Test + fun manageDeleteRequest() { + val req = parse("""{"resource":"offer","action":"delete","offer":{"id":"off-1"}}""") + assertEquals(ManageRequest.ACTION_DELETE, req.action) + assertEquals("off-1", req.offer?.id) + } + + @Test + fun manageSuccessSingleDetailsObjectCoercesToList() { + // create/update/get return a bare object for `details`; it must coerce into our list. + val res = + parse( + """{"res":"ok","resource":"offer","details":{"id":"off-1","label":"Product X","price_sats":12345,"callback_url":"https://m.app/cb","payer_data":["email"],"noffer":"noffer1qqsabc"}}""", + ) + assertTrue(res.isOk()) + assertEquals(1, res.details?.size) + assertEquals("off-1", res.details?.first()?.id) + assertEquals("Product X", res.details?.first()?.label) + assertEquals(12345L, res.details?.first()?.price_sats) + assertEquals("noffer1qqsabc", res.details?.first()?.noffer) + } + + @Test + fun manageSuccessListDetailsArray() { + val res = + parse( + """{"res":"ok","resource":"offer","details":[{"id":"off-1","label":"Product X","price_sats":12345,"noffer":"noffer1qqsabc"}]}""", + ) + assertTrue(res.isOk()) + assertEquals(1, res.details?.size) + assertEquals("off-1", res.details?.first()?.id) + } + + @Test + fun manageDeleteSuccessNoDetails() { + val res = parse("""{"res":"ok","resource":"offer"}""") + assertTrue(res.isOk()) + assertNull(res.details) + } + + @Test + fun manageGfyInvalidFieldWithFieldAndRange() { + val res = + parse( + """{"res":"GFY","code":5,"error":"Invalid Field/Value","field":"price_sats","range":{"min":1000,"max":1000000}}""", + ) + assertFalse(res.isOk()) + assertEquals(5, res.code) + assertEquals("price_sats", res.field) + assertEquals(1000L, res.range?.min) + assertEquals(1000000L, res.range?.max) + } + + @Test + fun manageGfyRateLimitedRetryAfter() { + val res = parse("""{"res":"GFY","code":4,"error":"Rate Limited","retry_after":600}""") + assertEquals(4, res.code) + assertEquals(600L, res.retry_after) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkInteropTest.kt new file mode 100644 index 0000000000..bc313a356e --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkInteropTest.kt @@ -0,0 +1,150 @@ +/* + * 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.experimental.clink.pointers + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * Cross-implementation interop vectors. The bech32 strings below were produced by the + * reference TypeScript implementation @shocknet/clink-sdk@1.5.5 (`nofferEncode`, + * `ndebitEncode`, `nmanageEncode`) for a fixed pubkey/relay. + * + * TLV is order-independent on decode, and the two implementations emit their fields in + * different orders (we ascend 0→4, the SDK descends), so we assert *functional* interop + * rather than byte-identical strings: + * + * 1. Decode: our parser reads the SDK's bytes into the expected fields. + * 2. Round-trip: re-encoding then re-parsing our output preserves every field. + * + * The reverse direction — that the SDK decodes our (ascending-order) output back to the + * same fields — was verified out-of-band by feeding our encoding to `decodeBech32`. + */ +class ClinkInteropTest { + private val pubKey = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e" + private val relay = "wss://relay.shocknet.dev" + + // --- noffer --- + + private val offerFixed = + "noffer1qszqqqzjpqpszqqzpphkven9wgkkjeqprpmhxue69uhhyetvv9ujuumgda3kkmn9wshxgetkqqs8ul5ug253hlh3n75jne0a5xmjur4urfxpzst88cnegg6ds6ka7nsx7zr9c" + private val offerSpontaneous = + "noffer1qvqsyqs9wd5x7up3qyv8wumn8ghj7un9d3shjtnndphkx6mwv46zuer9wcqzqln7n3p2jxl77x06j209lksmwtswhsdycy2pvulz09prfkr2mh6wexeyu2" + private val offerVariable = + "noffer1qszqqqqp7spszqgzq9mqzxrhwden5te0wfjkccte9eeksmmrddhx2apwv3jhvqpq0elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qhv7h2j" + + @Test + fun decodesAndRoundTripsOfferFixed() { + val offer = ClinkPointerParser.parse(offerFixed) as NOffer + assertEquals(pubKey, offer.pubKey) + assertEquals(RelayUrlNormalizer.normalizeOrNull(relay), offer.relays.single()) + assertEquals("offer-id", offer.pointer) + assertEquals(OfferPriceType.FIXED, offer.priceType) + assertEquals(21000L, offer.price) + assertEquals(offer, ClinkPointerParser.parse(offer.encode())) + } + + @Test + fun decodesAndRoundTripsOfferSpontaneous() { + val offer = ClinkPointerParser.parse(offerSpontaneous) as NOffer + assertEquals("shop1", offer.pointer) + assertEquals(OfferPriceType.SPONTANEOUS, offer.priceType) + assertNull(offer.price) + assertEquals(offer, ClinkPointerParser.parse(offer.encode())) + } + + // The canonical example offer shipped by @shocknet/clink-sdk: it is both the README usage + // example (MIT) and clink-demo / clinkme.dev's `DEFAULT_NOFFER` (public domain). A + // real-world spontaneous, relay-bearing, no-price offer whose offer-id is a 64-char hex string. + private val clinkDemoDefaultOffer = + "noffer1qvqsyqjqxuurvwpcxc6rvvrxxsurqep5vfjk2wf4v33nsenrxumnyvesxfnrswfkvycrwdp3x93xydf5xg6rzce4vv6xgdfh8quxgct9x5erxvspremhxue69uhhgetnwskhyetvv9ujumrfva58gmnfdenjuur4vgqzpccxc30wpf78wf2q78wg3vq008fd8ygtl4qy06gstpye3h5unc47xmee6z" + + @Test + fun decodesClinkDemoDefaultOffer() { + val offer = ClinkPointerParser.parse(clinkDemoDefaultOffer) as NOffer + assertEquals("e306c45ee0a7c772540f1dc88b00f79d2d3910bfd4047e910584998de9c9e2be", offer.pubKey) + assertEquals(RelayUrlNormalizer.normalizeOrNull("wss://test-relay.lightning.pub"), offer.relays.single()) + assertEquals("786886460f480d4bee95dc8fc772302f896a07411bb54241c5c4d5788dae5232", offer.pointer) + assertEquals(OfferPriceType.SPONTANEOUS, offer.priceType) + assertNull(offer.price) + assertEquals(offer, ClinkPointerParser.parse(offer.encode())) + } + + @Test + fun decodesAndRoundTripsOfferVariable() { + val offer = ClinkPointerParser.parse(offerVariable) as NOffer + assertEquals("v", offer.pointer) + assertEquals(OfferPriceType.VARIABLE, offer.priceType) + assertEquals(500L, offer.price) + assertEquals(offer, ClinkPointerParser.parse(offer.encode())) + } + + // --- ndebit --- + + private val debitWithPointer = + "ndebit1qgyhqmmfde6x2u3dxuq3samnwvaz7tmjv4kxz7fwwd5x7cmtdejhgtnyv4mqqgr706wy92gmlmcel2ffuh76rdewp67p5nq3g9nnufu5ydxcdtwlfcg94z44" + private val debitWithoutPointer = + "ndebit1qyv8wumn8ghj7un9d3shjtnndphkx6mwv46zuer9wcqzqln7n3p2jxl77x06j209lksmwtswhsdycy2pvulz09prfkr2mh6wcrvl9u" + + @Test + fun decodesAndRoundTripsDebitWithPointer() { + val debit = ClinkPointerParser.parse(debitWithPointer) as NDebit + assertEquals(pubKey, debit.pubKey) + assertEquals(RelayUrlNormalizer.normalizeOrNull(relay), debit.relays.single()) + assertEquals("pointer-7", debit.pointer) + assertNull(debit.k1) + assertEquals(debit, ClinkPointerParser.parse(debit.encode())) + } + + @Test + fun decodesAndRoundTripsDebitWithoutPointer() { + val debit = ClinkPointerParser.parse(debitWithoutPointer) as NDebit + assertNull(debit.pointer) + assertEquals(pubKey, debit.pubKey) + assertEquals(debit, ClinkPointerParser.parse(debit.encode())) + } + + // --- nmanage --- + + private val manageWithPointer = + "nmanage1qgrxzurs956ryqgcwaehxw309aex2mrp0yh8x6r0vd4kuet59ejx2asqypl8a8zz4ydlauvl4y57tldpkuhqa0q6fsg5zee7y72zxnvx4h05ufx6vef" + private val manageWithoutPointer = + "nmanage1qyv8wumn8ghj7un9d3shjtnndphkx6mwv46zuer9wcqzqln7n3p2jxl77x06j209lksmwtswhsdycy2pvulz09prfkr2mh6wr57t3u" + + @Test + fun decodesAndRoundTripsManageWithPointer() { + val manage = ClinkPointerParser.parse(manageWithPointer) as NManage + assertEquals(pubKey, manage.pubKey) + assertEquals(RelayUrlNormalizer.normalizeOrNull(relay), manage.relays.single()) + assertEquals("app-42", manage.pointer) + assertEquals(manage, ClinkPointerParser.parse(manage.encode())) + } + + @Test + fun decodesAndRoundTripsManageWithoutPointer() { + val manage = ClinkPointerParser.parse(manageWithoutPointer) as NManage + assertNull(manage.pointer) + assertEquals(pubKey, manage.pubKey) + assertEquals(manage, ClinkPointerParser.parse(manage.encode())) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerTest.kt new file mode 100644 index 0000000000..0b494dcef4 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerTest.kt @@ -0,0 +1,133 @@ +/* + * 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.experimental.clink.pointers + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ClinkPointerTest { + private val pubKey = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e" + private val k1 = "4caa9ee5f0f0a0b1c2d3e4f5061728394a5b6c7d8e9f00112233445566778899" + private val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.shocknet.dev")!! + + @Test + fun offerSpontaneousRoundTrip() { + // A spontaneous offer always carries TLV 3 on the wire and decodes back to SPONTANEOUS. + val offer = NOffer(pubKey, listOf(relay), "my-offer-id", OfferPriceType.SPONTANEOUS, null) + val encoded = offer.encode() + + assertTrue(encoded.startsWith("noffer1"), "expected noffer1 prefix, got $encoded") + assertEquals(offer, NOffer.parse(Bech32.decodeBytes(encoded).second)) + assertEquals(offer, ClinkPointerParser.parse(encoded)) + } + + @Test + fun offerFixedPriceRoundTrip() { + val offer = NOffer(pubKey, listOf(relay), null, OfferPriceType.FIXED, 21_000) + val parsed = ClinkPointerParser.parse(offer.encode()) as NOffer + + assertEquals(OfferPriceType.FIXED, parsed.priceType) + assertEquals(21_000, parsed.price) + assertEquals(offer, parsed) + } + + @Test + fun offerLargePriceRoundTripIsUnsigned() { + // A price with the high bit set (> Int.MAX_VALUE) must round-trip as a positive + // Long — the price is an unsigned 4-byte big-endian integer, so reading it signed + // would wrap it negative. + val price = 3_000_000_000L + val offer = NOffer(pubKey, listOf(relay), null, OfferPriceType.FIXED, price) + val parsed = ClinkPointerParser.parse(offer.encode()) as NOffer + + assertEquals(price, parsed.price) + assertEquals(offer, parsed) + } + + @Test + fun debitStaticRoundTrip() { + val debit = NDebit(pubKey, listOf(relay), "pointer-7", null) + val parsed = ClinkPointerParser.parse(debit.encode()) as NDebit + + assertEquals(debit, parsed) + assertTrue(!parsed.isSession) + } + + @Test + fun debitSessionRoundTrip() { + val debit = NDebit(pubKey, listOf(relay), null, k1) + val parsed = ClinkPointerParser.parse(debit.encode()) as NDebit + + assertEquals(k1, parsed.k1) + assertTrue(parsed.isSession) + assertEquals(debit, parsed) + } + + @Test + fun debitRejectsMalformedK1Length() { + // A session id (TLV 3) must be exactly 32 bytes; a wrong-length k1 is a malformed + // session pointer and the parser must reject it rather than accept a bad session. + val shortK1 = "abcd" + val encoded = NDebit(pubKey, listOf(relay), null, shortK1).encode() + assertNull(ClinkPointerParser.parse(encoded)) + } + + @Test + fun manageRoundTrip() { + val manage = NManage(pubKey, listOf(relay), null) + val encoded = manage.encode() + + assertTrue(encoded.startsWith("nmanage1")) + assertEquals(manage, ClinkPointerParser.parse(encoded)) + } + + @Test + fun parserStripsSchemeAndWhitespace() { + val offer = NOffer(pubKey, listOf(relay), null, OfferPriceType.SPONTANEOUS, null) + val encoded = offer.encode() + + assertEquals(offer, ClinkPointerParser.parse(" nostr:$encoded ")) + assertEquals(offer, ClinkPointerParser.parse("lightning:$encoded")) + } + + @Test + fun parserRejectsGarbageAndForeignPrefixes() { + assertNull(ClinkPointerParser.parse("not-a-pointer")) + assertNull(ClinkPointerParser.parse("npub1xxxxxxxxxxxxx")) + assertNull(ClinkPointerParser.parse("")) + } + + @Test + fun parseAllFindsEmbeddedPointers() { + val offer = NOffer(pubKey, listOf(relay), null, OfferPriceType.SPONTANEOUS, null).encode() + val debit = NDebit(pubKey, listOf(relay), null, null).encode() + val text = "Pay me at $offer or pull from $debit thanks" + + val found = ClinkPointerParser.parseAll(text) + assertEquals(2, found.size) + assertTrue(found[0] is NOffer) + assertTrue(found[1] is NDebit) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UpdateMetadataTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UpdateMetadataTest.kt index 18b408439d..86aa586878 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UpdateMetadataTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UpdateMetadataTest.kt @@ -197,6 +197,30 @@ class UpdateMetadataTest { assertEquals(expected3, test3) } + @Test + fun clinkOfferRoundTrip() { + val noffer = "noffer1qqsxexampleclinkofferpointer" + val event = signer.sign(MetadataEvent.createNew(name = "Vitor", clinkOffer = noffer, createdAt = 1740669816)) + + // written into kind-0 content under the spec's `clink_offer` key + assertEquals(true, event.content.contains("\"clink_offer\":\"$noffer\"")) + + // and dual-written as a kind-0 tag (NIP-1770 pattern), like the other fields + assertContentEquals(arrayOf("clink_offer", noffer), event.tags.firstOrNull { it.firstOrNull() == "clink_offer" }) + + // and parses back out of the content + val metadata = event.contactMetaData() + assertNotNull(metadata) + assertEquals(noffer, metadata.clinkOffer) + assertEquals(noffer, metadata.clinkOffer()) + } + + @Test + fun parseClinkOffer() { + val metadata = JsonMapper.fromJson("""{"name":"Test","clink_offer":"noffer1abc"}""") + assertEquals("noffer1abc", metadata.clinkOffer) + } + @Test fun parseBirthdayFull() { val json = """{"name":"Test","birthday":{"year":1990,"month":6,"day":15}}""" diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt index 2f9e17c34b..fac6835a5e 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt @@ -45,6 +45,44 @@ class Nip05Test { } """.trimIndent() + @Test + fun `parse clink_offer for a name`() = + runTest { + val noffer = "noffer1qqsexampleclinkoffer" + val json = """{ "names": { "bob": "abc" }, "clink_offer": { "bob": "$noffer" } }""" + val nip05 = Nip05Id.parse("bob@domain.com") + assertNotNull(nip05) + assertEquals(noffer, parser.parseClinkOffer(nip05, json)) + } + + @Test + fun `parse clink_offer as a flat top-level string (bridgelet shape)`() = + runTest { + val noffer = "noffer1qqsexampleclinkoffer" + val json = """{ "names": { "bob": "abc" }, "clink_offer": "$noffer" }""" + val nip05 = Nip05Id.parse("bob@domain.com") + assertNotNull(nip05) + assertEquals(noffer, parser.parseClinkOffer(nip05, json)) + } + + @Test + fun `parse clink_offer absent yields null`() = + runTest { + val json = """{ "names": { "bob": "abc" } }""" + val nip05 = Nip05Id.parse("bob@domain.com") + assertNotNull(nip05) + assertNull(parser.parseClinkOffer(nip05, json)) + } + + @Test + fun `parse clink_offer for a missing name yields null`() = + runTest { + val json = """{ "clink_offer": { "alice": "noffer1abc" } }""" + val nip05 = Nip05Id.parse("bob@domain.com") + assertNotNull(nip05) + assertNull(parser.parseClinkOffer(nip05, json)) + } + @Test fun `test with matching case on user name`() = runTest { diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt index b8abc79d82..b73047c0e5 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt @@ -75,6 +75,10 @@ class JacksonMapper { .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .configure(DeserializationFeature.FAIL_ON_TRAILING_TOKENS, false) .configure(DeserializationFeature.UNWRAP_ROOT_VALUE, false) + // Tolerate a single JSON object where a list is declared. Several Nostr-native + // RPCs (e.g. CLINK Manage `details`, typed `OfferData | OfferData[]`) return a + // bare object for single-item results and an array for lists; accept both. + .configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true) .enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) .setDefaultPrettyPrinter(defaultPrettyPrinter) .registerModule(