mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 16:14:40 +00:00
Merge pull request #3177 from vitorpamplona/claude/trusting-mayer-6o0yd5
Implement CLINK (Common Lightning Interface for Nostr Keys)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
+3
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+4
-6
@@ -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())
|
||||
|
||||
|
||||
+38
@@ -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),
|
||||
|
||||
+4
@@ -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()
|
||||
|
||||
+287
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+146
@@ -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)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
+11
-3
@@ -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(
|
||||
|
||||
+19
@@ -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(
|
||||
|
||||
+48
-29
@@ -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 =
|
||||
|
||||
+30
-13
@@ -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) },
|
||||
|
||||
+128
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+214
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+135
@@ -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)
|
||||
}
|
||||
}
|
||||
+58
-5
@@ -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(
|
||||
|
||||
+106
-19
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
+51
-47
@@ -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}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,3 +2,4 @@ marmot/state/
|
||||
marmot/state-headless/
|
||||
dm/state-dm-headless/
|
||||
nests/state/
|
||||
clink/state-clink-headless/
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+156
@@ -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
|
||||
+54
@@ -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())
|
||||
}
|
||||
+55
@@ -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
|
||||
}
|
||||
}
|
||||
+51
@@ -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()
|
||||
}
|
||||
+6
@@ -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)
|
||||
|
||||
+7
@@ -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,
|
||||
|
||||
+8
@@ -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) {
|
||||
|
||||
+1
@@ -26,4 +26,5 @@ data class TorRelaySettings(
|
||||
val dmRelaysViaTor: Boolean = false,
|
||||
val newRelaysViaTor: Boolean = false,
|
||||
val trustedRelaysViaTor: Boolean = false,
|
||||
val moneyOperationsViaTor: Boolean = false,
|
||||
)
|
||||
|
||||
+89
@@ -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)
|
||||
}
|
||||
}
|
||||
+62
@@ -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 })
|
||||
}
|
||||
}
|
||||
+43
@@ -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() {
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
# CLINK on Quartz + Amethyst
|
||||
|
||||
Status: implemented (Phases 0–3 + receive side + CLI) — see "Final
|
||||
implementation state" at the bottom for what actually shipped, the audit
|
||||
results, and the spec-vs-SDK gotchas. The body below is the original
|
||||
(proposed) design and is kept for context; where it disagrees with the final
|
||||
state, the final state wins.
|
||||
Date: 2026-06-09
|
||||
Owner: TBD
|
||||
|
||||
## Decisions (locked)
|
||||
|
||||
1. **Scope:** implement all three CLINK specs (Offers, Debits, Manage).
|
||||
2. **Sidedness:** Quartz implements **both client and server** for every spec
|
||||
(so `amy` and interop tests can drive both ends). Amethyst is
|
||||
**consume-only** — it never hosts offers or approves incoming debits.
|
||||
3. **Wallet model:** CLINK plugs into Amethyst's wallet layer *like NWC*. The
|
||||
only spec that backs a spendable wallet is **Debits** — a stored `ndebit`
|
||||
pointer is the CLINK analogue of an NWC connection string. It lives in the
|
||||
same wallet list and adds a payment route to `ZapPaymentHandler`. `noffer`
|
||||
(pay others) and `nmanage` (offer admin) are not wallet connections.
|
||||
4. **Pointer parsing:** `noffer` / `ndebit` / `nmanage` are parsed by a
|
||||
**dedicated `ClinkPointerParser`**, NOT folded into `Nip19Parser`. We do not
|
||||
loosen the NIP-19 regexes app-wide for these prefixes.
|
||||
|
||||
## What CLINK is
|
||||
|
||||
"Common Lightning Interface for Nostr Keys" — three Nostr-native Lightning
|
||||
protocols from ShockNet. Where NWC (NIP-47) is RPC-style remote control of your
|
||||
*own* wallet, CLINK is peer-to-peer / app-to-service: no HTTPS callbacks, no
|
||||
LNURL web server, no onion messages. Each interaction is a NIP-44-encrypted
|
||||
ephemeral request→response event over a relay, addressed by a bech32 pointer.
|
||||
|
||||
| Spec | Kind | Pointer | Purpose | LN analogue |
|
||||
|---------|---------|--------------|-----------------------------------------------------|----------------------|
|
||||
| Offers | `21001` | `noffer1…` | Static code → request a fresh BOLT-11 over Nostr | LNURL-Pay / BOLT-12 |
|
||||
| Debits | `21002` | `ndebit1…` | Authorize a counterparty to pull a payment | LNURL-withdraw |
|
||||
| Manage | `21003` | `nmanage1…` | Delegate CRUD of offers to an external app | — |
|
||||
|
||||
Shared envelope: ephemeral kind, `["p", recipient]` + `["clink_version","1"]`
|
||||
tags, `["e", reqId]` on responses, NIP-44 JSON content, GFY error codes
|
||||
(`{"res":"GFY","code":1..6,...}`), 30s freshness window.
|
||||
|
||||
### Pointer TLV fields (verify against @shocknet/clink-sdk in Phase 0)
|
||||
|
||||
- **noffer:** 0=receiver pubkey, 1=relay, 2=offer-id, 3=price-type
|
||||
(0 fixed / 1 variable / 2 spontaneous), 4=price sats, 5=currency.
|
||||
- **ndebit:** 0=service pubkey, 1=relay, 2=pointer-id (opt), 3=32-byte `k1`
|
||||
session id (opt; single-use).
|
||||
- **nmanage:** 0=server pubkey, 1=relay, 2=pointer-id (opt).
|
||||
|
||||
## Reuse matrix
|
||||
|
||||
| Component | Status | Location | Action |
|
||||
|-----------------------------------|------------|----------------------------------------------------------------------|----------------------------------------------------|
|
||||
| NIP-44 encrypt/decrypt | ✅ Reuse | `quartz/.../nip44Encryption/` + `signer.nip44Encrypt/Decrypt` | Use as-is |
|
||||
| Bech32 + TLV codec | ✅ Reuse | `nip19Bech32/bech32/`, `tlv/TlvBuilder.kt`, `tlv/Tlv.kt` | Use the codec; do NOT reuse `Nip19Parser` |
|
||||
| Entity TLV pattern (reference) | 📦 Mirror | `nip19Bech32/entities/NProfile.kt` | Copy shape into new Clink entities |
|
||||
| Encrypted req/resp event pattern | 📦 Mirror | `nip47WalletConnect/events/LnZapPaymentRequestEvent.kt` (kind 23194) | Clone for kinds 21001/2/3, client + server |
|
||||
| Request/response over relay | ✅ Reuse | `Nip47Client.responseFilter()`, `relayClient/reqCommand/nwc/` | Same filter-by-`e`-tag pattern |
|
||||
| BOLT-11 parsing | ✅ Reuse | `quartz/.../lightning/LnInvoiceUtil.kt` | Use as-is |
|
||||
| LN payment execution | ✅ Reuse | `amethyst/.../service/ZapPaymentHandler.kt`, `NwcSignerState` | Add CLINK routes |
|
||||
| Wallet/zap account settings | 📦 Extend | `AccountSettings.kt` (`nwcWallets`, `defaultNwcWalletId`) | Add CLINK debit pointers to the wallet list |
|
||||
| Rich-text inline detect/render | 📦 Extend | `commons/.../richtext/RichTextParser.kt`, `RichTextViewer.kt`, `InvoicePreview.kt` | Add `noffer1…` token → pay card |
|
||||
| EventFactory registry | 📦 Extend | `quartz/.../utils/EventFactory.kt` | Register 21001/21002/21003 |
|
||||
|
||||
Genuinely new: 3 event classes, 3 bech32 entities + `ClinkPointerParser`, JSON
|
||||
DTOs, `ClinkClient` + `ClinkServer`, the debit-pointer wallet store, UI surfaces.
|
||||
|
||||
## Phase 0 — Quartz foundation (client + server, all three)
|
||||
|
||||
New package `quartz/.../nipClink/` (mirroring the single-package NWC layout),
|
||||
with `events/`, `pointers/`, `rpc/` subfolders.
|
||||
|
||||
1. **TLV constants** — CLINK-local TLV indices (do not overload NIP-19
|
||||
`TlvTypes`, which is NIP-19-specific).
|
||||
2. **Pointers** — `NOffer`, `NDebit`, `NManage` data classes with
|
||||
`parse(bytes)` / `create(...)` built like `NProfile.kt` (`Tlv.parse` +
|
||||
`TlvBuilder`). A standalone `ClinkPointerParser` decodes/encodes the three
|
||||
prefixes — NOT wired into `Nip19Parser`. Verify HRP + checksum and the
|
||||
TLV namespace against `@shocknet/clink-sdk` with a round-trip test.
|
||||
3. **Events** — `OfferEvent(21001)`, `DebitEvent(21002)`, `ManageEvent(21003)`,
|
||||
each `isContentEncoded() = true`, with `createRequest()` / `createResponse()`
|
||||
companions that NIP-44-encrypt JSON and set `p` / `clink_version` / `e` tags
|
||||
(direct analogue of `LnZapPaymentRequestEvent`). Register in `EventFactory`.
|
||||
4. **DTOs + errors** — Jackson request/response classes per spec, shared
|
||||
`GfyError(code, message, range?, retryAfter?, delta?)`, Offers error model
|
||||
(`code` 1..5, `range`, `latest`).
|
||||
5. **ClinkClient / ClinkServer** — high-level: `decode(pointer)`,
|
||||
`buildRequest(...)`, `responseFilter(reqId)`, `parseResponse(...)`; server
|
||||
side validates freshness, `k1` single-use, app-scoped offer ownership.
|
||||
6. **Tests** — quartz unit tests with spec TLV vectors + round-trip against
|
||||
clink-sdk fixtures; `amy clink decode|offer-pay|debit|manage` verbs (thin
|
||||
assembly only).
|
||||
|
||||
## Phase 1 — Offers consume (Amethyst)
|
||||
|
||||
Ship order: scan/paste → inline card → profile button.
|
||||
|
||||
- **Scan/paste-to-pay:** QR scanner + clipboard handle `noffer1…` → decode →
|
||||
send 21001 → await BOLT-11 → existing send sheet → `ZapPaymentHandler`.
|
||||
Smallest correct slice; proves the client end-to-end.
|
||||
- **Inline feed card (headline):** `RichTextParser` recognizes a `noffer1…`
|
||||
token; render a "⚡ Pay" card next to the existing BOLT-11 `InvoicePreview`.
|
||||
Tap reuses the same decode→21001→pay path.
|
||||
- **Profile pay button:** read `noffer` from kind-0 metadata; profile Zap
|
||||
button prefers CLINK offer over LNURL when present.
|
||||
|
||||
## Phase 2 — Debits as a wallet (Amethyst, consume-only)
|
||||
|
||||
This is the "add a wallet via CLINK" answer.
|
||||
|
||||
- **Account store:** extend the wallet list so a saved `ndebit` pointer sits
|
||||
alongside NWC connections (`AccountSettings`, parallel to `nwcWallets`;
|
||||
selectable as default funding source).
|
||||
- **Payment route:** `ZapPaymentHandler` gains a CLINK-debit route — send a
|
||||
21002 request, await `{"res":"ok",preimage}`, handle GFY. Always behind an
|
||||
explicit confirmation; honor `k1` single-use; never auto-approve.
|
||||
- **Out of scope (server side):** receiving/approving incoming 21002 requests
|
||||
and session-`k1` scan-to-pull. Quartz has the server code; Amethyst does not
|
||||
expose it (consume-only decision).
|
||||
|
||||
## Phase 3 — Manage (Quartz only)
|
||||
|
||||
Full client + server in Quartz + `amy clink manage …` (create/update/get/
|
||||
list/delete, app-scoped ownership, GFY errors). **No Amethyst UI** — Amethyst
|
||||
is an offer *consumer*, not a host. Revisit an in-app "my offers" minter only
|
||||
if users want to mint offers from inside the app later.
|
||||
|
||||
## Risks / to verify in Phase 0
|
||||
|
||||
- Exact bech32 HRP + whether CLINK uses its own TLV namespace vs NIP-19's —
|
||||
pin against `@shocknet/clink-sdk` source, not the client-rendered docs.
|
||||
- Relay selection for ephemeral req/resp (pointer relay vs account relays).
|
||||
- Response-timeout + retry UX (30s freshness window, GFY code 3 deltas).
|
||||
|
||||
---
|
||||
|
||||
## Final implementation state
|
||||
|
||||
Everything below reflects what is on the branch now, not the proposal above.
|
||||
Read this section first if you're touching CLINK.
|
||||
|
||||
### What shipped
|
||||
|
||||
**Phase 0 — Quartz foundation** (`quartz/.../experimental/clink/`)
|
||||
- Pointers (`pointers/NOffer.kt`, `NDebit.kt`, `NManage.kt`) decoded/encoded
|
||||
via a dedicated `ClinkPointerParser` (bech32 + TLV), **not** wired into
|
||||
`Nip19Parser`. Round-trip tested against the canonical interop vectors
|
||||
(`ClinkPointerTest`, `ClinkInteropTest`).
|
||||
- Events `OfferEvent(21001)`, `DebitEvent(21002)`, `ManageEvent(21003)`,
|
||||
refactored to the codebase convention: `eventTemplate(KIND, content, …){…}`
|
||||
+ tag-class DSL (`pTag`, `eTag`, `alt`, `clinkVersion`) and typed accessors
|
||||
(`PTag::parseKey`, `ETag::parseId`). Registered in `EventFactory`.
|
||||
- High-level clients `OfferClient` / `DebitClient` / `ManageClient` build the
|
||||
request event, expose a `responseFilter` (filtered by **both** `e=reqId`
|
||||
and `p=self`), and parse the NIP-44-decrypted response DTO.
|
||||
- Shared `clink_version` is its own tag class (`tags/ClinkVersionTag.kt`,
|
||||
`CURRENT="1"`) reused by all three events, with a `clinkVersion()` builder
|
||||
extension. The old `Clink.kt` constants object was retired.
|
||||
|
||||
**Phase 1 — Offers consume** (Amethyst): inline feed card
|
||||
(`ClinkOfferPreview.kt`) renders a payable "⚡" card for a `noffer1…` token,
|
||||
with a variable-amount field for SPONTANEOUS offers, moved-offer follow
|
||||
(GFY code 3 `latest`), and the resolved `activeOffer` price. Payment routes
|
||||
through the shared `InvoicePaymentDispatcher` (confirm-then-pay) into
|
||||
`ZapPaymentHandler`. **Zappable offers were explicitly reverted** — do not
|
||||
re-add NIP-57 zaps to offer payment.
|
||||
|
||||
**Phase 2 — Debits as a wallet** (Amethyst, consume-only): a stored `ndebit`
|
||||
pointer is a first-class payment source alongside NWC. `PaymentSource`
|
||||
(sealed: `Nwc` / `ClinkDebit`), `PaymentSourceResolver`, and
|
||||
`AccountSettings.defaultPaymentSource()` unify default selection;
|
||||
`defaultNwcWalletId` was migrated to `defaultPaymentSourceId`.
|
||||
`ZapPaymentHandler` dispatches on the resolved source. Budgets/recurring via
|
||||
`DebitClient.requestBudget` + `DebitFrequency` units (day/week/month).
|
||||
|
||||
**Phase 3 — Manage** (Quartz only, no Amethyst UI): nested request/response
|
||||
shape — `ManageRequest(resource, action, pointer, offer: ManageOffer?)`,
|
||||
`ManageOffer(id, fields: OfferFields)`, `OfferData(...)`, `ManageResponse(...)`.
|
||||
|
||||
**Receive side** (advertise your own `noffer`): kind-0 `clink_offer` field
|
||||
(`UserMetadata.clinkOffer` + dual-written `clink_offer` tag via
|
||||
`ClinkOfferTag`, NIP-1770 pattern) **and** NIP-05 `.well-known` discovery
|
||||
(`Nip05Parser.parseClinkOffer`, `INip05Client.loadClinkOffer`). The profile
|
||||
header (`DisplayClinkOffer`) prefers whichever is present, with a
|
||||
256-entry `LruCache` over the NIP-05 lookups.
|
||||
|
||||
**CLI** (`amy`): `offer info|request`, `debit info|pay|budget` (thin assembly
|
||||
only). New `Context.requestResponse(...)` does subscribe→publish→await-first-
|
||||
matching-live-reply (vs `drain`, which returns at EOSE).
|
||||
|
||||
### Audit findings & resolutions
|
||||
|
||||
- **Offer price is an UNSIGNED 4-byte BE integer.** `NOffer.price` is `Long?`;
|
||||
decode reads the 4 bytes as unsigned (SDK does `parseInt(hex)`), encode
|
||||
writes the low 32 bits. The earlier `Int` typing produced a negative price
|
||||
for any amount ≥ 2^31. Regression:
|
||||
`ClinkPointerTest.offerLargePriceRoundTripIsUnsigned` (3_000_000_000L).
|
||||
- **Decrypt guard.** `OfferEvent`/`DebitEvent`/`ManageEvent` replaced the old
|
||||
self-fallback `talkingWith()` with `conversationPeer(myPubKey)` that returns
|
||||
`null` when the signer is neither author nor recipient; `decryptContent`
|
||||
then throws `UnauthorizedDecryptionException`. Regression:
|
||||
`ClinkEventTest.cannotDecryptAuthoredEventMissingRecipient`.
|
||||
- **Payer hang fix.** `ClinkOfferPayer` / `ClinkDebitPayer` wrap `parseResponse`
|
||||
in try/catch and return `null` on a decode failure — an uncaught
|
||||
`SerializationException` previously hung the UI waiting on a coroutine that
|
||||
never completed. `payInvoiceViaClinkDebit` now delivers `onResult` on
|
||||
`Dispatchers.Main`.
|
||||
- **NIP-05 cache correctness.** The offer cache distinguishes a cache-miss
|
||||
from a cached-`null` (explicit presence check), so a profile with no offer
|
||||
isn't re-fetched on every recomposition.
|
||||
|
||||
### CRITICAL — spec vs SDK (do NOT "fix" these)
|
||||
|
||||
The `@shocknet/clink-sdk` (1.5.5) **lags the published spec**. Two things look
|
||||
like bugs against the SDK but are correct against the spec
|
||||
(`raw.githubusercontent.com/shocknet/CLINK/main/specs/clink-*.md`) and were
|
||||
verified there directly:
|
||||
|
||||
1. **Offer moved → GFY `code 3` carries `latest`** (a fresh pointer). The
|
||||
client follows it. The SDK omits this; the spec defines it. Keep the
|
||||
follow logic in `ClinkOfferPreview` / `OfferClient`.
|
||||
2. **ndebit session `k1` lives at TLV index 3.** The SDK doesn't read it; the
|
||||
spec defines it as the optional single-use session id. Keep decoding it.
|
||||
|
||||
Other shape notes for future maintainers:
|
||||
- **Manage uses the nested `offer.fields` shape** (above), not a flat object.
|
||||
- **`ManageResponse.details` is parsed as a single object**, not an array —
|
||||
Jackson's `ACCEPT_SINGLE_VALUE_AS_ARRAY` is OFF in this repo, and the
|
||||
reference service returns one object. Documented as a known limitation in
|
||||
`ManageMessages.kt`; revisit if a service returns a list.
|
||||
|
||||
### Verification matrix
|
||||
|
||||
| Level | What it covers | Status |
|
||||
|-------|----------------|--------|
|
||||
| JVM unit tests | pointer codecs (incl. unsigned price), decrypt guard, Manage nested shape, DTOs, metadata `clink_offer`, NIP-05 discovery, `PaymentSourceResolver` | green |
|
||||
| `amy` CLI | `offer info`/`request`, `debit info`/`pay`/`budget` — local decode verified end-to-end | green |
|
||||
| Shell harness | `cli/tests/clink/clink-headless.sh` — decode of canonical vectors + arg-error paths | 12/12 |
|
||||
|
||||
**Remaining gap:** the live NIP-44 request→response round-trip over a relay
|
||||
against a real CLINK service is not automated (needs a device or a
|
||||
mock/live service). Feasible later via quartz's in-process relay server
|
||||
(`nip01Core/relay/server/`) plus a mock CLINK responder; flagged but not
|
||||
built.
|
||||
|
||||
---
|
||||
|
||||
## Interop review & spec-conformance pass (2026-06-10)
|
||||
|
||||
Reviewed against the whole `shocknet/CLINK` ecosystem (Lightning.Pub, clink-sdk,
|
||||
ShockWallet, Zeus, Stacker News, bridgelet, clinkme.dev) and re-audited every
|
||||
spec file line-by-line against the code. **Verdict: the consume-only
|
||||
payer/requestor role is conformant and interoperable; no real correctness bugs.**
|
||||
|
||||
**Interop fixes shipped:**
|
||||
1. Manage `details` single-object responses parse (Jackson
|
||||
`ACCEPT_SINGLE_VALUE_AS_ARRAY`) — Lightning.Pub returns a bare object for
|
||||
create/update/get, an array only for `list`.
|
||||
2. `NOffer.encode()` always emits the price-type TLV (3); decode defaults an
|
||||
absent/unknown type to SPONTANEOUS. The SDK + bridgelet decoders throw on a
|
||||
missing TLV 3, so omitting it made our pointers undecodable by JS consumers.
|
||||
3. `Nip05Parser.parseClinkOffer` accepts bridgelet's flat-string shape as well
|
||||
as the spec's per-name map.
|
||||
4. Offer receipts are a parseable primitive (`OfferEvent.createReceipt` /
|
||||
`decryptReceipt`, `OfferClient.parseReceipt`, `OfferReceipt.isOk`).
|
||||
5. `ClinkOfferPayer` signs offer requests with an ephemeral key (privacy parity
|
||||
with the SDK/Zeus/Stacker News). Debits keep the persistent account key
|
||||
(budgets need a stable app identity).
|
||||
|
||||
**Conformance hardening shipped:** `NDebit.parse` rejects a non-32-byte k1;
|
||||
`DebitClient.requestBudget` validates `frequency.unit ∈ {day,week,month}`;
|
||||
`OfferClient` caps `description` at 100 chars; `DebitResponse.failureDetail()`
|
||||
surfaces GFY `range`/`retry_after` in the debit zap error path.
|
||||
|
||||
**New tests:** `ClinkWireShapeTest` (golden JSON payloads from the public-domain
|
||||
specs), a clink-demo/SDK canonical `DEFAULT_NOFFER` vector, plus regressions for
|
||||
each fix above.
|
||||
|
||||
**Three "CRITICAL" review findings were verified FALSE — do NOT re-chase them:**
|
||||
- *Offer price encoding for ≥2³¹ sats* is correct: `addInt` writes the low 32
|
||||
bits, which are bit-identical to the unsigned 4-byte BE (proven by
|
||||
`offerLargePriceRoundTripIsUnsigned`, 3e9 sats). Only ≥2³² loses data, which
|
||||
the spec's 4-byte field can't represent anyway.
|
||||
- *Parser "wrongly accepts `nostr:`/`lightning:` wrappers"* is not a violation:
|
||||
the MUST-NOT-wrap rule governs producing QR (our `encode()` is bare); lenient
|
||||
decode is Postel-legal.
|
||||
- *Manage "create shape deviation"* — the spec's inline-create example is the
|
||||
outlier; Lightning.Pub/SDK/clink-demo all use nested `offer.fields` for create,
|
||||
which we match.
|
||||
|
||||
**Remaining non-bugs (intentional / out-of-role):** we don't reject responses
|
||||
lacking `clink_version` (Lightning.Pub omits it — rejecting breaks interop); the
|
||||
offer Payment Receipt UI subscription is unwired (primitive ships); `clink_debit`
|
||||
discovery and the unrestricted-access debit request are creditor/app-side
|
||||
features outside the consume-only role; noffer TLV 5 currency is dormant
|
||||
(unimplemented by the SDK and Lightning.Pub too).
|
||||
+96
@@ -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)
|
||||
}
|
||||
+109
@@ -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)
|
||||
}
|
||||
+96
@@ -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)
|
||||
}
|
||||
+48
@@ -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
|
||||
}
|
||||
+128
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+96
@@ -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"
|
||||
}
|
||||
}
|
||||
+128
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+108
@@ -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"
|
||||
}
|
||||
}
|
||||
+155
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.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
|
||||
}
|
||||
+69
@@ -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 0–2 are common;
|
||||
* 3–4 are reused with different meanings per spec (priceType/price for offers, k1
|
||||
* for debit sessions), mirroring `@shocknet/clink-sdk`.
|
||||
*/
|
||||
internal object ClinkTlv {
|
||||
const val PUBKEY: Byte = 0
|
||||
const val RELAY: Byte = 1
|
||||
const val POINTER: Byte = 2
|
||||
|
||||
// Offers
|
||||
const val PRICE_TYPE: Byte = 3
|
||||
const val PRICE: Byte = 4
|
||||
|
||||
// Debits
|
||||
const val K1: Byte = 3
|
||||
}
|
||||
|
||||
/** Encodes a small unsigned value as a single-byte hex string for one-byte TLV values. */
|
||||
internal fun Int.toSingleByteHex(): String = (this and 0xFF).toString(16).padStart(2, '0')
|
||||
+72
@@ -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()
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.experimental.clink.pointers
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32
|
||||
import com.vitorpamplona.quartz.nip19Bech32.tlv.Tlv
|
||||
import com.vitorpamplona.quartz.nip19Bech32.tlv.TlvBuilder
|
||||
|
||||
/**
|
||||
* CLINK Debits pointer (`ndebit1…`, kind 21002): authorizes a counterparty to pull a
|
||||
* payment from the pointed-to wallet. A pointer is either *static* (TLV 0–2, long-lived,
|
||||
* app-initiated) or a single-use *session* carrying a 32-byte [k1] (TLV 3), used for the
|
||||
* LNURL-withdraw-like scan-to-pull flow.
|
||||
* See https://github.com/shocknet/clink/blob/master/specs/clink-debits.md
|
||||
*/
|
||||
@Immutable
|
||||
data class NDebit(
|
||||
override val pubKey: HexKey,
|
||||
override val relays: List<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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+100
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -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 }
|
||||
}
|
||||
}
|
||||
+107
@@ -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))
|
||||
}
|
||||
+44
@@ -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)
|
||||
}
|
||||
}
|
||||
+26
@@ -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))
|
||||
+8
@@ -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(
|
||||
|
||||
+3
@@ -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))
|
||||
|
||||
+8
@@ -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
|
||||
|
||||
+39
@@ -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)
|
||||
}
|
||||
}
|
||||
+19
-4
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -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 {
|
||||
|
||||
+8
@@ -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()
|
||||
|
||||
|
||||
+29
@@ -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)
|
||||
|
||||
+129
@@ -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))
|
||||
}
|
||||
}
|
||||
+188
@@ -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)
|
||||
}
|
||||
}
|
||||
+304
@@ -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)
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.experimental.clink.pointers
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* Cross-implementation interop vectors. The bech32 strings below were produced by the
|
||||
* reference TypeScript implementation @shocknet/clink-sdk@1.5.5 (`nofferEncode`,
|
||||
* `ndebitEncode`, `nmanageEncode`) for a fixed pubkey/relay.
|
||||
*
|
||||
* TLV is order-independent on decode, and the two implementations emit their fields in
|
||||
* different orders (we ascend 0→4, the SDK descends), so we assert *functional* interop
|
||||
* rather than byte-identical strings:
|
||||
*
|
||||
* 1. Decode: our parser reads the SDK's bytes into the expected fields.
|
||||
* 2. Round-trip: re-encoding then re-parsing our output preserves every field.
|
||||
*
|
||||
* The reverse direction — that the SDK decodes our (ascending-order) output back to the
|
||||
* same fields — was verified out-of-band by feeding our encoding to `decodeBech32`.
|
||||
*/
|
||||
class ClinkInteropTest {
|
||||
private val pubKey = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e"
|
||||
private val relay = "wss://relay.shocknet.dev"
|
||||
|
||||
// --- noffer ---
|
||||
|
||||
private val offerFixed =
|
||||
"noffer1qszqqqzjpqpszqqzpphkven9wgkkjeqprpmhxue69uhhyetvv9ujuumgda3kkmn9wshxgetkqqs8ul5ug253hlh3n75jne0a5xmjur4urfxpzst88cnegg6ds6ka7nsx7zr9c"
|
||||
private val offerSpontaneous =
|
||||
"noffer1qvqsyqs9wd5x7up3qyv8wumn8ghj7un9d3shjtnndphkx6mwv46zuer9wcqzqln7n3p2jxl77x06j209lksmwtswhsdycy2pvulz09prfkr2mh6wexeyu2"
|
||||
private val offerVariable =
|
||||
"noffer1qszqqqqp7spszqgzq9mqzxrhwden5te0wfjkccte9eeksmmrddhx2apwv3jhvqpq0elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qhv7h2j"
|
||||
|
||||
@Test
|
||||
fun decodesAndRoundTripsOfferFixed() {
|
||||
val offer = ClinkPointerParser.parse(offerFixed) as NOffer
|
||||
assertEquals(pubKey, offer.pubKey)
|
||||
assertEquals(RelayUrlNormalizer.normalizeOrNull(relay), offer.relays.single())
|
||||
assertEquals("offer-id", offer.pointer)
|
||||
assertEquals(OfferPriceType.FIXED, offer.priceType)
|
||||
assertEquals(21000L, offer.price)
|
||||
assertEquals(offer, ClinkPointerParser.parse(offer.encode()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodesAndRoundTripsOfferSpontaneous() {
|
||||
val offer = ClinkPointerParser.parse(offerSpontaneous) as NOffer
|
||||
assertEquals("shop1", offer.pointer)
|
||||
assertEquals(OfferPriceType.SPONTANEOUS, offer.priceType)
|
||||
assertNull(offer.price)
|
||||
assertEquals(offer, ClinkPointerParser.parse(offer.encode()))
|
||||
}
|
||||
|
||||
// The canonical example offer shipped by @shocknet/clink-sdk: it is both the README usage
|
||||
// example (MIT) and clink-demo / clinkme.dev's `DEFAULT_NOFFER` (public domain). A
|
||||
// real-world spontaneous, relay-bearing, no-price offer whose offer-id is a 64-char hex string.
|
||||
private val clinkDemoDefaultOffer =
|
||||
"noffer1qvqsyqjqxuurvwpcxc6rvvrxxsurqep5vfjk2wf4v33nsenrxumnyvesxfnrswfkvycrwdp3x93xydf5xg6rzce4vv6xgdfh8quxgct9x5erxvspremhxue69uhhgetnwskhyetvv9ujumrfva58gmnfdenjuur4vgqzpccxc30wpf78wf2q78wg3vq008fd8ygtl4qy06gstpye3h5unc47xmee6z"
|
||||
|
||||
@Test
|
||||
fun decodesClinkDemoDefaultOffer() {
|
||||
val offer = ClinkPointerParser.parse(clinkDemoDefaultOffer) as NOffer
|
||||
assertEquals("e306c45ee0a7c772540f1dc88b00f79d2d3910bfd4047e910584998de9c9e2be", offer.pubKey)
|
||||
assertEquals(RelayUrlNormalizer.normalizeOrNull("wss://test-relay.lightning.pub"), offer.relays.single())
|
||||
assertEquals("786886460f480d4bee95dc8fc772302f896a07411bb54241c5c4d5788dae5232", offer.pointer)
|
||||
assertEquals(OfferPriceType.SPONTANEOUS, offer.priceType)
|
||||
assertNull(offer.price)
|
||||
assertEquals(offer, ClinkPointerParser.parse(offer.encode()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodesAndRoundTripsOfferVariable() {
|
||||
val offer = ClinkPointerParser.parse(offerVariable) as NOffer
|
||||
assertEquals("v", offer.pointer)
|
||||
assertEquals(OfferPriceType.VARIABLE, offer.priceType)
|
||||
assertEquals(500L, offer.price)
|
||||
assertEquals(offer, ClinkPointerParser.parse(offer.encode()))
|
||||
}
|
||||
|
||||
// --- ndebit ---
|
||||
|
||||
private val debitWithPointer =
|
||||
"ndebit1qgyhqmmfde6x2u3dxuq3samnwvaz7tmjv4kxz7fwwd5x7cmtdejhgtnyv4mqqgr706wy92gmlmcel2ffuh76rdewp67p5nq3g9nnufu5ydxcdtwlfcg94z44"
|
||||
private val debitWithoutPointer =
|
||||
"ndebit1qyv8wumn8ghj7un9d3shjtnndphkx6mwv46zuer9wcqzqln7n3p2jxl77x06j209lksmwtswhsdycy2pvulz09prfkr2mh6wcrvl9u"
|
||||
|
||||
@Test
|
||||
fun decodesAndRoundTripsDebitWithPointer() {
|
||||
val debit = ClinkPointerParser.parse(debitWithPointer) as NDebit
|
||||
assertEquals(pubKey, debit.pubKey)
|
||||
assertEquals(RelayUrlNormalizer.normalizeOrNull(relay), debit.relays.single())
|
||||
assertEquals("pointer-7", debit.pointer)
|
||||
assertNull(debit.k1)
|
||||
assertEquals(debit, ClinkPointerParser.parse(debit.encode()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodesAndRoundTripsDebitWithoutPointer() {
|
||||
val debit = ClinkPointerParser.parse(debitWithoutPointer) as NDebit
|
||||
assertNull(debit.pointer)
|
||||
assertEquals(pubKey, debit.pubKey)
|
||||
assertEquals(debit, ClinkPointerParser.parse(debit.encode()))
|
||||
}
|
||||
|
||||
// --- nmanage ---
|
||||
|
||||
private val manageWithPointer =
|
||||
"nmanage1qgrxzurs956ryqgcwaehxw309aex2mrp0yh8x6r0vd4kuet59ejx2asqypl8a8zz4ydlauvl4y57tldpkuhqa0q6fsg5zee7y72zxnvx4h05ufx6vef"
|
||||
private val manageWithoutPointer =
|
||||
"nmanage1qyv8wumn8ghj7un9d3shjtnndphkx6mwv46zuer9wcqzqln7n3p2jxl77x06j209lksmwtswhsdycy2pvulz09prfkr2mh6wr57t3u"
|
||||
|
||||
@Test
|
||||
fun decodesAndRoundTripsManageWithPointer() {
|
||||
val manage = ClinkPointerParser.parse(manageWithPointer) as NManage
|
||||
assertEquals(pubKey, manage.pubKey)
|
||||
assertEquals(RelayUrlNormalizer.normalizeOrNull(relay), manage.relays.single())
|
||||
assertEquals("app-42", manage.pointer)
|
||||
assertEquals(manage, ClinkPointerParser.parse(manage.encode()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodesAndRoundTripsManageWithoutPointer() {
|
||||
val manage = ClinkPointerParser.parse(manageWithoutPointer) as NManage
|
||||
assertNull(manage.pointer)
|
||||
assertEquals(pubKey, manage.pubKey)
|
||||
assertEquals(manage, ClinkPointerParser.parse(manage.encode()))
|
||||
}
|
||||
}
|
||||
+133
@@ -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)
|
||||
}
|
||||
}
|
||||
+24
@@ -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}}"""
|
||||
|
||||
+38
@@ -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 {
|
||||
|
||||
+4
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user