Merge pull request #3177 from vitorpamplona/claude/trusting-mayer-6o0yd5

Implement CLINK (Common Lightning Interface for Nostr Keys)
This commit is contained in:
Vitor Pamplona
2026-06-11 14:42:25 -04:00
committed by GitHub
88 changed files with 6158 additions and 196 deletions
@@ -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)
}
@@ -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<List<ClinkDebitWalletEntry>>(clinkDebitWalletsStr)?.mapNotNull { it.normalize() } ?: emptyList()
}
val defaultFileServer = async { parseOrNull<ServerName>(defaultFileServerStr) ?: DEFAULT_MEDIA_SERVERS[0] }
val viewedPollResultNoteIds = async { parseOrNull<Map<String, Long>>(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,
@@ -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<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
val defaultFollowPacksFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val nwcWallets: MutableStateFlow<List<NwcWalletEntryNorm>> = MutableStateFlow(emptyList()),
val defaultNwcWalletId: MutableStateFlow<String?> = MutableStateFlow(null),
val clinkDebitWallets: MutableStateFlow<List<ClinkDebitWalletEntryNorm>> = 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<String?> = 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
}
@@ -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,
)
}
@@ -65,12 +65,10 @@ class NwcSignerState(
* Flow of the default wallet's NWC URI, derived from multi-wallet settings.
*/
val defaultWalletUri: StateFlow<Nip47WalletConnect.Nip47URINorm?> =
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())
@@ -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<Set<NormalizedRelayUrl>> =
accountsCache.accounts
.debounce(200)
.transformLatest { snapshot ->
val perAccountFlows =
snapshot.map { (_, account) ->
combine(
account.settings.nwcWallets,
account.settings.clinkDebitWallets,
) { nwcWallets, clinkDebitWallets ->
val relays = mutableSetOf<NormalizedRelayUrl>()
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<NormalizedRelayUrl>()
perAccount.forEach { moneyOpRelays.addAll(it) }
moneyOpRelays.toSet()
},
)
}.onEach {
torEvaluatorFlow.moneyOpRelays.tryEmit(it)
}.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
}
@@ -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<Set<NormalizedRelayUrl>>(emptySet())
val trustedRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(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<Set<NormalizedRelayUrl>>(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<Map<NormalizedRelayUrl, Int>>(emptyMap())
private fun currentMoneyOpRelays(): Set<NormalizedRelayUrl> = 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<NormalizedRelayUrl>) {
if (relays.isEmpty()) return
adHocMoneyOpCounts.update { current ->
current.toMutableMap().apply {
relays.forEach { this[it] = (this[it] ?: 0) + 1 }
}
}
}
fun unregisterMoneyOpRelays(relays: Set<NormalizedRelayUrl>) {
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<NormalizedRelayUrl>, dmRelayList: Set<NormalizedRelayUrl> ->
moneyOpRelays,
adHocMoneyOpCounts,
) {
torSettings: TorRelaySettings,
trustedRelayList: Set<NormalizedRelayUrl>,
dmRelayList: Set<NormalizedRelayUrl>,
moneyOpRelayList: Set<NormalizedRelayUrl>,
adHocMoneyOps: Map<NormalizedRelayUrl, Int>,
->
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))
}
@@ -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<DebitEvent>()
// 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<NormalizedRelayUrl, List<Filter>> = relays.associateWith { listOf(client.responseFilter(request.id)) }
val listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
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)
}
}
}
@@ -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<OfferEvent>()
// 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<NormalizedRelayUrl, List<Filter>> = relays.associateWith { listOf(client.responseFilter(request.id)) }
val listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
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)
}
}
}
}
@@ -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<Paid> {
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<Payable>,
pointer: NDebit,
onError: (String, String, User?) -> Unit,
onProgress: (percent: Float) -> Unit,
context: Context,
): List<Paid> {
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)
},
@@ -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),
@@ -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 = ""
@@ -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)
@@ -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<Route.WalletAdd> { AddWalletScreen(accountViewModel, nav) }
composableFromEndArgs<Route.WalletAddNwc> { AddNwcWalletScreen(accountViewModel, nav, it.nip47) }
composableFromEnd<Route.WalletAddCashu> { AddCashuWalletScreen(accountViewModel, nav) }
composableFromEndArgs<Route.WalletAddClinkDebit> { AddClinkDebitWalletScreen(accountViewModel, nav, it.ndebit) }
composableFromEnd<Route.CashuWallet> { CashuWalletScreen(accountViewModel, nav) }
composableFromEnd<Route.CashuWalletSettings> { CashuWalletSettingsScreen(accountViewModel, nav) }
@@ -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()
@@ -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<String?>(null) }
var payingInvoice by remember { mutableStateOf<String?>(null) }
var amountInput by remember { mutableStateOf("") }
var needsAmount by remember { mutableStateOf(offer.priceType == OfferPriceType.SPONTANEOUS) }
var amountRange by remember { mutableStateOf<SatRange?>(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,
)
}
}
}
}
@@ -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)) }
},
)
}
@@ -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<String?>(null) }
var payingInvoice by remember { mutableStateOf<String?>(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(
@@ -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(
@@ -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 =
@@ -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) },
@@ -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<String, ResolvedClinkOffer>(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,
)
}
}
}
@@ -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<String?>(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))
}
}
}
}
@@ -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) },
)
}
}
}
@@ -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)
}
}
@@ -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(
@@ -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<List<NwcWalletEntryNorm>>(emptyList())
val wallets = _wallets.asStateFlow()
private val _debitWallets = MutableStateFlow<List<ClinkDebitWalletEntryNorm>>(emptyList())
val debitWallets = _debitWallets.asStateFlow()
private val _defaultWalletId = MutableStateFlow<String?>(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()
}
+24
View File
@@ -112,6 +112,30 @@
<string name="log_out">Logout</string>
<string name="show_more">Show More</string>
<string name="lightning_invoice">Lightning Invoice</string>
<string name="clink_lightning_offer">CLINK Offer</string>
<string name="clink_requesting_invoice">Requesting invoice…</string>
<string name="clink_debit_no_response">The debit service did not complete the payment.</string>
<string name="clink_confirm_payment_title">Confirm payment</string>
<string name="clink_confirm_pay_amount_via_source">Pay %1$s via %2$s?</string>
<string name="clink_confirm_pay_via_source">Pay this invoice via %1$s?</string>
<string name="clink_offer_amount_sats">Amount (sats)</string>
<string name="clink_offer_invalid_amount">Enter a valid amount for this offer.</string>
<string name="clink_offer_amount_range">Allowed range: %1$s%2$s sats</string>
<string name="clink_offer_label">CLINK Offer (noffer)</string>
<string name="clink_budget_set">Budget</string>
<string name="clink_budget_title">Spending budget</string>
<string name="clink_budget_amount_sats">Amount (sats)</string>
<string name="clink_budget_request">Request</string>
<string name="clink_budget_approved">Budget approved</string>
<string name="clink_budget_one_time">One-time</string>
<string name="clink_budget_daily">Daily</string>
<string name="clink_budget_weekly">Weekly</string>
<string name="clink_budget_monthly">Monthly</string>
<string name="clink_debit_pay_only">Pay only</string>
<string name="wallet_add_clink_title">CLINK Debit</string>
<string name="wallet_add_clink_description">Pay and zap from a wallet that pre-authorized your account. Spend only — no balance or history.</string>
<string name="wallet_add_clink_invalid">Invalid CLINK debit pointer. Expected an ndebit1… string.</string>
<string name="wallet_paste_ndebit">Paste ndebit pointer</string>
<string name="pay">Pay</string>
<string name="lightning_tips">Lightning Tips</string>
<string name="note_to_receiver">Note to Receiver</string>
@@ -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<Response?>()
// 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}")
}
}
+15
View File
@@ -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
@@ -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<NormalizedRelayUrl>,
responseFilter: Filter,
timeoutMs: Long = 15_000,
): Event? {
if (relays.isEmpty()) return null
val reply = CompletableDeferred<Event>()
val subId = newSubId()
val filters = relays.associateWith { listOf(responseFilter) }
val listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
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
@@ -182,6 +182,14 @@ private suspend fun dispatch(argv: Array<String>): 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]
@@ -63,11 +63,14 @@ object Output {
fun error(
code: String,
detail: String? = null,
extra: Map<String, Any?> = emptyMap(),
): Int {
val cleanExtra = extra.filterValues { it != null }
when (mode) {
Mode.JSON -> {
val payload = mutableMapOf<String, Any>("error" to code)
val payload = mutableMapOf<String, Any?>("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
@@ -115,4 +115,14 @@ object Commands {
dataDir: DataDir,
tail: Array<String>,
): Int = ZapCommand.dispatch(dataDir, tail)
suspend fun offer(
dataDir: DataDir,
tail: Array<String>,
): Int = OfferCommands.dispatch(dataDir, tail)
suspend fun debit(
dataDir: DataDir,
tail: Array<String>,
): Int = DebitCommands.dispatch(dataDir, tail)
}
@@ -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 <ndebit>` decodes a pointer locally (no network).
* - `pay <ndebit> <bolt11> [--amount SATS]` runs the kind-21002 pay round-trip.
* - `budget <ndebit> --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<String>,
): Int {
if (tail.isEmpty()) return Output.error("bad_args", "debit <info|pay|budget>")
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<String>): 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<String>,
): 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<String>,
): 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<String, Any?> =
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?,
)
}
@@ -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 <noffer>` decodes a pointer locally (no network).
* - `discover <nip05>` resolves a profile's advertised offer from its NIP-05 `.well-known`.
* - `request <noffer> [--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 <noffer> --with <ndebit> [--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<String>,
): Int {
if (tail.isEmpty()) return Output.error("bad_args", "offer <info|discover|request|pay>")
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 <user>`.
*/
private suspend fun discover(
dataDir: DataDir,
rest: Array<String>,
): 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<String>): 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<String>,
): 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 <ndebit>`.
*/
private suspend fun pay(
dataDir: DataDir,
rest: Array<String>,
): 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 <ndebit> 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<String, Any?> =
mapOf(
"code" to response.code,
"latest" to response.latest,
"range" to response.range?.let { mapOf("min" to it.min, "max" to it.max) },
)
}
@@ -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,
)
}
@@ -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 <ndebit>` 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<String>,
): Int {
if (rest.size < 2) return Output.error("bad_args", "zap user <user> <sats> [--comment X] [--anon] [--timeout SECS]")
if (rest.size < 2) return Output.error("bad_args", "zap user <user> <sats> [--comment X] [--anon] [--with <ndebit>] [--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<String>,
): Int {
if (rest.size < 2) return Output.error("bad_args", "zap event <event-id> <sats> [--comment X] [--anon] [--private] [--timeout SECS]")
if (rest.size < 2) return Output.error("bad_args", "zap event <event-id> <sats> [--comment X] [--anon] [--private] [--with <ndebit>] [--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<String, Any?> {
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<String, Any?> =
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<ZapActions.ZapRequestForSplit>,
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
+1
View File
@@ -2,3 +2,4 @@ marmot/state/
marmot/state-headless/
dm/state-dm-headless/
nests/state/
clink/state-clink-headless/
+5
View File
@@ -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
+156
View File
@@ -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 <ndebit>"
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
@@ -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())
}
@@ -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
}
}
@@ -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<NwcWalletEntryNorm>,
debitWallets: List<ClinkDebitWalletEntryNorm>,
): List<PaymentSource> = nwcWallets.map { PaymentSource.Nwc(it) } + debitWallets.map { PaymentSource.ClinkDebit(it) }
fun resolveDefault(
nwcWallets: List<NwcWalletEntryNorm>,
debitWallets: List<ClinkDebitWalletEntryNorm>,
defaultId: String?,
): PaymentSource? = resolveDefault(all(nwcWallets, debitWallets), defaultId)
fun resolveDefault(
sources: List<PaymentSource>,
defaultId: String?,
): PaymentSource? = defaultId?.let { id -> sources.firstOrNull { it.id == id } } ?: sources.firstOrNull()
}
@@ -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)
@@ -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,
@@ -28,6 +28,7 @@ class TorRelayEvaluation(
val torSettings: TorRelaySettings,
val trustedRelayList: Set<NormalizedRelayUrl>,
val dmRelayList: Set<NormalizedRelayUrl>,
val moneyOpRelayList: Set<NormalizedRelayUrl> = 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) {
@@ -26,4 +26,5 @@ data class TorRelaySettings(
val dmRelaysViaTor: Boolean = false,
val newRelaysViaTor: Boolean = false,
val trustedRelaysViaTor: Boolean = false,
val moneyOperationsViaTor: Boolean = false,
)
@@ -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)
}
}
@@ -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<ClinkOfferSegment>()
.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 })
}
}
@@ -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<NormalizedRelayUrl> = setOf(dmRelay),
trustedRelays: Set<NormalizedRelayUrl> = setOf(trustedRelay),
moneyOpRelays: Set<NormalizedRelayUrl> = 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() {
+298
View File
@@ -0,0 +1,298 @@
# CLINK on Quartz + Amethyst
Status: implemented (Phases 03 + 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).
@@ -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<NormalizedRelayUrl> 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)
}
@@ -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<NormalizedRelayUrl> get() = pointer.relays
suspend fun createOffer(
label: String? = null,
priceSats: Long? = null,
callbackUrl: String? = null,
payerData: List<String>? = 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<String>? = 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)
}
@@ -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<NormalizedRelayUrl> 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<String, Any?>? = 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)
}
@@ -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
}
@@ -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<Array<String>>,
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<DebitRequest>(decryptContent(signer))
suspend fun decryptResponse(signer: NostrSigner): DebitResponse = OptimizedJsonMapper.fromJsonTo<DebitResponse>(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)
},
)
}
}
}
@@ -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"
}
}
@@ -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<Array<String>>,
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<ManageRequest>(decryptContent(signer))
suspend fun decryptResponse(signer: NostrSigner): ManageResponse = OptimizedJsonMapper.fromJsonTo<ManageResponse>(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)
},
)
}
}
}
@@ -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<String>? = 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<String>? = 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<OfferData>? = 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"
}
}
@@ -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<Array<String>>,
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<OfferRequest>(decryptContent(signer))
suspend fun decryptResponse(signer: NostrSigner): OfferResponse = OptimizedJsonMapper.fromJsonTo<OfferResponse>(decryptContent(signer))
suspend fun decryptReceipt(signer: NostrSigner): OfferReceipt = OptimizedJsonMapper.fromJsonTo<OfferReceipt>(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)
},
)
}
}
}
@@ -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<String, Any?>? = 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
}
@@ -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<NormalizedRelayUrl>
/** 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 02 are common;
* 34 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')
@@ -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<ClinkPointer> = clinkRegex.findAll(content).mapNotNull { parse(it.value) }.toList()
}
@@ -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 02, 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<NormalizedRelayUrl>,
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)
}
}
}
@@ -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<NormalizedRelayUrl>,
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)
}
}
}
@@ -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<NormalizedRelayUrl>,
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)
}
}
}
@@ -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 }
}
}
@@ -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<String>()
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))
}
@@ -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>): 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)
}
}
@@ -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 <T : Event> TagArrayBuilder<T>.clinkVersion(version: String = ClinkVersionTag.CURRENT) = addUnique(ClinkVersionTag.assemble(version))
@@ -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<MetadataEvent>.() -> Unit = {},
): EventTemplate<MetadataEvent> {
@@ -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<MetadataEvent>.() -> Unit = {},
): EventTemplate<MetadataEvent> {
@@ -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(
@@ -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<MetadataEvent>.lud06(lud06: String) = addUnique(Lud06Tag.ass
fun TagArrayBuilder<MetadataEvent>.banner(banner: String) = addUnique(BannerTag.assemble(banner))
fun TagArrayBuilder<MetadataEvent>.pronouns(pronouns: String) = addUnique(PronounsTag.assemble(pronouns))
fun TagArrayBuilder<MetadataEvent>.clinkOffer(offer: String) = addUnique(ClinkOfferTag.assemble(offer))
@@ -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
@@ -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>): 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)
}
}
@@ -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()
}
}
@@ -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 {
@@ -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()
@@ -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=<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,
@@ -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)
@@ -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<IllegalArgumentException> {
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))
}
}
@@ -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<OfferRequest>(OptimizedJsonMapper.toJson(request))
assertEquals("abc", parsed.offer)
assertEquals(1500, parsed.amount_sats)
assertEquals("coffee", parsed.description)
}
@Test
fun offerResponseInvoiceParses() {
val parsed = OptimizedJsonMapper.fromJsonTo<OfferResponse>("""{"bolt11":"lnbc1..."}""")
assertTrue(parsed.isSuccess())
assertEquals("lnbc1...", parsed.bolt11)
}
@Test
fun offerResponseInvalidAmountParsesRange() {
val parsed =
OptimizedJsonMapper.fromJsonTo<OfferResponse>(
"""{"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<DebitResponse>(
"""{"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<DebitResponse>("""{"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<ManageRequest>(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<ManageResponse>("""{"res":"GFY","code":5,"error":"bad","field":"price_sats"}""")
assertFalse(parsed.isOk())
assertEquals("price_sats", parsed.field)
}
}
@@ -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 <reified T : OptimizedSerializable> parse(json: String): T = OptimizedJsonMapper.fromJsonTo<T>(json)
// ---------- Offers (kind 21001) ----------
@Test
fun offerRequest() {
val req =
parse<OfferRequest>(
"""{"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<OfferResponse>("""{"bolt11":"lnbc10u1pexample"}""")
assertTrue(res.isSuccess())
assertEquals("lnbc10u1pexample", res.bolt11)
}
@Test
fun offerErrorInvalidOffer() {
val res = parse<OfferResponse>("""{"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<OfferResponse>("""{"error":"Offer has expired.","code":3}""")
assertEquals(3, res.code)
assertNull(res.latest)
}
@Test
fun offerErrorMovedWithLatest() {
val res =
parse<OfferResponse>(
"""{"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<OfferResponse>("""{"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<OfferReceipt>("""{"res":"ok","preimage":"${"ab".repeat(32)}"}""")
assertTrue(receipt.isOk())
assertEquals("ab".repeat(32), receipt.preimage)
}
@Test
fun offerReceiptInternalSettlement() {
val receipt = parse<OfferReceipt>("""{"res":"ok"}""")
assertTrue(receipt.isOk())
assertNull(receipt.preimage)
}
// ---------- Debits (kind 21002) ----------
@Test
fun debitDirectPaymentRequest() {
val req =
parse<DebitRequest>(
"""{"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<DebitRequest>(
"""{"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<DebitResponse>("""{"res":"ok","preimage":"${"cd".repeat(32)}"}""")
assertTrue(res.isOk())
assertEquals("cd".repeat(32), res.preimage)
}
@Test
fun debitSuccessInternalOrBudgetApproval() {
val res = parse<DebitResponse>("""{"res":"ok"}""")
assertTrue(res.isOk())
assertNull(res.preimage)
}
@Test
fun debitGfyRequestDenied() {
val res = parse<DebitResponse>("""{"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<DebitResponse>(
"""{"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<DebitResponse>("""{"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<DebitResponse>("""{"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<DebitResponse>("""{"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<DebitResponse>("""{"res":"GFY","code":4,"error":"Rate Limited","retry_after":1750000000}""").failureDetail()!!
assertTrue(rateLimited.contains("Rate Limited"))
assertTrue(rateLimited.contains("1750000000"))
assertNull(parse<DebitResponse>("""{"res":"ok"}""").failureDetail())
}
@Test
fun debitGfyInvalidRequest() {
val res = parse<DebitResponse>("""{"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<ManageRequest>(
"""{"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<ManageRequest>("""{"resource":"offer","action":"list"}""")
assertEquals(ManageRequest.ACTION_LIST, req.action)
assertNull(req.offer)
}
@Test
fun manageDeleteRequest() {
val req = parse<ManageRequest>("""{"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<ManageResponse>(
"""{"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<ManageResponse>(
"""{"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<ManageResponse>("""{"res":"ok","resource":"offer"}""")
assertTrue(res.isOk())
assertNull(res.details)
}
@Test
fun manageGfyInvalidFieldWithFieldAndRange() {
val res =
parse<ManageResponse>(
"""{"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<ManageResponse>("""{"res":"GFY","code":4,"error":"Rate Limited","retry_after":600}""")
assertEquals(4, res.code)
assertEquals(600L, res.retry_after)
}
}
@@ -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 04, 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()))
}
}
@@ -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)
}
}
@@ -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<UserMetadata>("""{"name":"Test","clink_offer":"noffer1abc"}""")
assertEquals("noffer1abc", metadata.clinkOffer)
}
@Test
fun parseBirthdayFull() {
val json = """{"name":"Test","birthday":{"year":1990,"month":6,"day":15}}"""
@@ -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 {
@@ -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(