From fcbb6f405d887161c67eca73dfda14acbbec939d Mon Sep 17 00:00:00 2001 From: The Daniel Date: Thu, 11 Jun 2026 15:36:11 -0400 Subject: [PATCH 1/3] feat(clink): pay CLINK noffer offers from notes, profiles & wallet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of wisp-ios#360. Adds CLINK Offers (noffer1…) payment support: - Noffer: bech32 + TLV decoder, pricing types (fixed / variable / spontaneous), isNofferString / stripNostrPrefix helpers - NofferClient: kind-21001 NIP-44 RPC on the offer's relay with code-3 expired/moved latest-retry - NofferPaySheet + NofferCard: shared pay sheet that pays via the active wallet or falls back to a scannable bare-noffer QR - Notes: RichContent detects noffer tokens and renders a Pay-offer card - Profiles: clink_offer parsed tolerantly from kind-0, editor field, Pay-offer header button, offer QR inside the Lightning tab with a contextual address/offer switch - Wallet send: noffer detection routes through the native amount → confirm flow; fixed-price offers skip straight to confirm - Tests: decoder round-trip coverage mirroring the CLINK spec TLVs Spec: https://github.com/shocknet/CLINK/blob/main/specs/clink-offers.md --- .../main/kotlin/com/wisp/app/nostr/Noffer.kt | 187 +++++++ .../kotlin/com/wisp/app/nostr/ProfileData.kt | 11 + .../kotlin/com/wisp/app/repo/NofferClient.kt | 213 ++++++++ .../wisp/app/ui/component/NofferPaySheet.kt | 462 ++++++++++++++++++ .../wisp/app/ui/component/ProfileQrSheet.kt | 51 +- .../com/wisp/app/ui/component/RichContent.kt | 52 +- .../app/ui/component/WispDrawerContent.kt | 1 + .../wisp/app/ui/screen/ProfileEditScreen.kt | 13 + .../wisp/app/ui/screen/UserProfileScreen.kt | 34 ++ .../com/wisp/app/ui/screen/WalletScreen.kt | 8 +- .../wisp/app/viewmodel/ProfileViewModel.kt | 10 + .../com/wisp/app/viewmodel/WalletViewModel.kt | 68 ++- app/src/main/res/values/strings.xml | 2 +- .../kotlin/com/wisp/app/nostr/NofferTest.kt | 122 +++++ 14 files changed, 1219 insertions(+), 15 deletions(-) create mode 100644 app/src/main/kotlin/com/wisp/app/nostr/Noffer.kt create mode 100644 app/src/main/kotlin/com/wisp/app/repo/NofferClient.kt create mode 100644 app/src/main/kotlin/com/wisp/app/ui/component/NofferPaySheet.kt create mode 100644 app/src/test/kotlin/com/wisp/app/nostr/NofferTest.kt diff --git a/app/src/main/kotlin/com/wisp/app/nostr/Noffer.kt b/app/src/main/kotlin/com/wisp/app/nostr/Noffer.kt new file mode 100644 index 00000000..1d410a69 --- /dev/null +++ b/app/src/main/kotlin/com/wisp/app/nostr/Noffer.kt @@ -0,0 +1,187 @@ +package com.wisp.app.nostr + +/** + * CLINK Offers (`noffer1…`) — a Nostr-native successor to LNURL-pay. + * + * Spec: https://github.com/shocknet/CLINK/blob/main/specs/clink-offers.md + * + * A `noffer1…` bech32 string carries TLVs describing a static payment offer: + * + * TLV 0 — 32-byte service pubkey (hex) + * TLV 1 — recommended relay URL (utf-8) where the service listens + * TLV 2 — opaque offer identifier (utf-8) + * TLV 3 — (opt) pricing type: 0=Fixed, 1=Variable, 2=Spontaneous (default) + * TLV 4 — (opt) price in sats (big-endian integer) + * TLV 5 — (opt) currency code (utf-8; only meaningful with Variable) + * + * The payer NIP-44 encrypts a kind-21001 request to the service pubkey and the + * service replies with an encrypted kind-21001 carrying a bolt11 invoice — + * see `NofferClient`. + */ +enum class NofferPricing { + FIXED, + VARIABLE, + SPONTANEOUS; + + companion object { + /** + * TLV 3 byte → pricing type. Matches the CLINK spec and the zap.cooking + * reference decoder: 0=Fixed, 1=Variable, anything else (incl. 2) = + * Spontaneous. Absent TLV 3 also defaults to Spontaneous. + */ + fun fromByte(byte: Int): NofferPricing = when (byte) { + 0 -> FIXED + 1 -> VARIABLE + else -> SPONTANEOUS + } + } +} + +data class NofferData( + /** 32-byte service pubkey, hex-encoded lowercase. (TLV 0) */ + val pubkey: String, + /** Recommended relay URL where the service listens. (TLV 1) */ + val relay: String, + /** Opaque offer identifier the service uses to look up the offer. (TLV 2) */ + val offerId: String, + /** Pricing type — defaults to SPONTANEOUS when TLV 3 is absent. */ + val pricing: NofferPricing, + /** Price in sats. (TLV 4) Present for Fixed offers and as a hint for Variable. */ + val price: Long?, + /** Currency code (TLV 5) — only meaningful when pricing == VARIABLE. */ + val currency: String?, + /** + * The bare `noffer1…` string (no `nostr:` prefix) this was decoded from. + * Kept so callers can re-display / QR-encode the offer verbatim — the spec + * requires QR payloads to be exactly the bech32 string. + */ + val raw: String +) { + /** + * True when the payer must (or may) supply an amount: Spontaneous always + * needs one; Variable lets the payer hint at one (the service decides); + * Fixed bakes the amount into the offer. + */ + val acceptsAmount: Boolean + get() = pricing != NofferPricing.FIXED +} + +/** + * Typed failure surfaced by the CLINK service (or the client on timeout / + * transport failure). `code` follows the spec's error table; `code == 0` + * marks a local/transport failure with no service code. + */ +class NofferException( + val code: Int, + message: String, + val rangeMin: Long? = null, + val rangeMax: Long? = null, + val latest: String? = null +) : Exception(message) + +object Noffer { + + private val nofferRegex = Regex( + """^(nostr:)?noffer1[023456789acdefghjklmnpqrstuvwxyz]{20,}$""", + RegexOption.IGNORE_CASE + ) + + /** + * Lightweight detector — checks shape only, does not decode TLVs. Use this + * in segment / paste parsers where you just need "is this noffer-shaped"; + * call [decode] for real use. + */ + fun isNofferString(s: String?): Boolean { + if (s == null) return false + val trimmed = s.trim() + if (trimmed.isEmpty()) return false + return nofferRegex.matches(trimmed) + } + + /** + * Strip a leading `nostr:` prefix (and surrounding whitespace) and return + * the bare `noffer1…` token. Use before building a QR payload — the spec + * requires the QR to be exactly the bech32 string with no scheme prefix. + */ + fun stripNostrPrefix(noffer: String): String { + val trimmed = noffer.trim() + return if (trimmed.startsWith("nostr:", ignoreCase = true)) { + trimmed.substring("nostr:".length) + } else { + trimmed + } + } + + /** + * Decode a `noffer1…` (or `nostr:noffer1…`) string into its TLV fields. + * Throws on wrong HRP, non-bech32 input, truncated TLVs, or a missing / + * wrong-length required TLV (0/1/2). + */ + fun decode(input: String): NofferData { + val cleaned = stripNostrPrefix(input) + require(cleaned.lowercase().startsWith("noffer1")) { "Not a noffer string" } + val (hrp, data) = Nip19.bech32Decode(cleaned) + require(hrp == "noffer") { "Expected noffer, got $hrp" } + + val tlvs = parseTlvs(data) + + val pubkeyTlv = tlvs.firstOrNull { it.first == 0 } + require(pubkeyTlv != null && pubkeyTlv.second.size == 32) { "noffer missing service pubkey" } + val relay = tlvs.firstOrNull { it.first == 1 }?.second?.toString(Charsets.UTF_8) + require(!relay.isNullOrEmpty()) { "noffer missing relay" } + val offerTlv = tlvs.firstOrNull { it.first == 2 } + require(offerTlv != null) { "noffer missing offer id" } + val offerId = offerTlv.second.toString(Charsets.UTF_8) + + val pricing = tlvs.firstOrNull { it.first == 3 }?.second?.firstOrNull() + ?.let { NofferPricing.fromByte(it.toInt() and 0xFF) } + ?: NofferPricing.SPONTANEOUS + + val price = tlvs.firstOrNull { it.first == 4 }?.second + ?.takeIf { it.isNotEmpty() } + ?.let { bigEndianLong(it) } + + val currency = tlvs.firstOrNull { it.first == 5 }?.second + ?.takeIf { it.isNotEmpty() } + ?.toString(Charsets.UTF_8) + + return NofferData( + pubkey = pubkeyTlv.second.toHex(), + relay = relay, + offerId = offerId, + pricing = pricing, + price = price, + currency = currency, + raw = cleaned + ) + } + + /** Decode tolerantly, returning null instead of throwing. */ + fun decodeOrNull(input: String): NofferData? = try { + decode(input) + } catch (_: Exception) { + null + } + + // --- TLV parsing --- + + private fun parseTlvs(bytes: ByteArray): List> { + val tlvs = mutableListOf>() + var i = 0 + while (i < bytes.size) { + require(i + 2 <= bytes.size) { "Truncated TLV" } + val type = bytes[i].toInt() and 0xFF + val length = bytes[i + 1].toInt() and 0xFF + require(i + 2 + length <= bytes.size) { "Truncated TLV value" } + tlvs.add(type to bytes.copyOfRange(i + 2, i + 2 + length)) + i += 2 + length + } + return tlvs + } + + private fun bigEndianLong(bytes: ByteArray): Long { + var n = 0L + for (b in bytes) n = n * 256 + (b.toInt() and 0xFF) + return n + } +} diff --git a/app/src/main/kotlin/com/wisp/app/nostr/ProfileData.kt b/app/src/main/kotlin/com/wisp/app/nostr/ProfileData.kt index c021227d..7350a97b 100644 --- a/app/src/main/kotlin/com/wisp/app/nostr/ProfileData.kt +++ b/app/src/main/kotlin/com/wisp/app/nostr/ProfileData.kt @@ -17,6 +17,12 @@ data class ProfileData( val banner: String?, val nip05: String?, val lud16: String?, + /** + * CLINK offer (`noffer1…`) advertised in kind-0. Read tolerantly from + * `clink_offer`, `noffer`, or `offer` for cross-client compatibility. + * Spec: https://github.com/shocknet/CLINK/blob/main/specs/clink-offers.md + */ + val clinkOffer: String? = null, val updatedAt: Long ) { val displayString: String @@ -40,6 +46,11 @@ data class ProfileData( banner = obj["banner"]?.jsonPrimitive?.content, nip05 = obj["nip05"]?.jsonPrimitive?.content, lud16 = obj["lud16"]?.jsonPrimitive?.content, + clinkOffer = ( + obj["clink_offer"]?.jsonPrimitive?.content + ?: obj["noffer"]?.jsonPrimitive?.content + ?: obj["offer"]?.jsonPrimitive?.content + ).takeUnlessBlank(), updatedAt = event.created_at ) } catch (_: Exception) { diff --git a/app/src/main/kotlin/com/wisp/app/repo/NofferClient.kt b/app/src/main/kotlin/com/wisp/app/repo/NofferClient.kt new file mode 100644 index 00000000..95988305 --- /dev/null +++ b/app/src/main/kotlin/com/wisp/app/repo/NofferClient.kt @@ -0,0 +1,213 @@ +package com.wisp.app.repo + +import com.wisp.app.nostr.ClientMessage +import com.wisp.app.nostr.Filter +import com.wisp.app.nostr.Keys +import com.wisp.app.nostr.Nip44 +import com.wisp.app.nostr.NofferData +import com.wisp.app.nostr.NofferException +import com.wisp.app.nostr.Noffer +import com.wisp.app.nostr.NostrEvent +import com.wisp.app.nostr.RelayMessage +import com.wisp.app.nostr.hexToByteArray +import com.wisp.app.nostr.toHex +import com.wisp.app.relay.Relay +import com.wisp.app.relay.RelayConfig +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.util.UUID + +/** + * CLINK noffer kind-21001 RPC client (payer side). + * + * Spec: https://github.com/shocknet/CLINK/blob/main/specs/clink-offers.md + * + * Flow: + * 1. Build a JSON request payload with the offer id and (when needed) the + * amount in sats. + * 2. NIP-44 encrypt the payload to the offer's service pubkey. + * 3. Sign + publish a kind-21001 event tagged `["p", servicePubkey]` and + * `["clink_version","1"]` on the relay carried in the noffer's TLV 1. + * 4. Subscribe to kind-21001 on the same relay, filtered to events from the + * service tagged for us. Decrypt the first response and parse it as either + * `{bolt11}` (success) or `{error,code,…}` (typed failure → [NofferException]). + * + * The caller pays the returned bolt11 via the active wallet provider. + */ +object NofferClient { + + private const val KIND_NOFFER_RPC = 21001 + private val json = Json { ignoreUnknownKeys = true } + + /** + * Request a bolt11 invoice for [noffer], following a `code: 3` + * "expired/moved" response to its `latest` offer once (per spec). + * Throws [NofferException] on failure or timeout. + */ + suspend fun requestInvoice( + noffer: NofferData, + keypair: Keys.Keypair, + amountSats: Long?, + description: String? = null, + zapRequest: String? = null, + timeoutMs: Long = 30_000, + allowRetry: Boolean = true + ): String { + return try { + requestInvoiceOnce(noffer, keypair, amountSats, description, zapRequest, timeoutMs) + } catch (err: NofferException) { + // Expired or moved — if the service handed us a replacement, decode + // it and retry exactly once against the new offer. + if (allowRetry && err.code == 3) { + val updated = err.latest?.let { Noffer.decodeOrNull(it) } ?: throw err + requestInvoice( + noffer = updated, keypair = keypair, amountSats = amountSats, + description = description, zapRequest = zapRequest, + timeoutMs = timeoutMs, allowRetry = false + ) + } else { + throw err + } + } + } + + private suspend fun requestInvoiceOnce( + noffer: NofferData, + keypair: Keys.Keypair, + amountSats: Long?, + description: String?, + zapRequest: String?, + timeoutMs: Long + ): String { + if (keypair.privkey.size != 32) { + throw NofferException(0, "Sign in with a key that can sign to pay an offer.") + } + val servicePubkey = noffer.pubkey + val peer = try { + servicePubkey.hexToByteArray() + } catch (_: Exception) { + throw NofferException(0, "Invalid offer service pubkey.") + } + val convKey = try { + Nip44.getConversationKey(keypair.privkey, peer) + } catch (_: Exception) { + throw NofferException(0, "Could not derive an encryption key for the offer.") + } + + // Build the encrypted request payload. amount_sats is required for + // Spontaneous/Variable offers; harmless to include for Fixed. + val payload = buildJsonObject { + put("offer", JsonPrimitive(noffer.offerId)) + if (amountSats != null && amountSats > 0) put("amount_sats", JsonPrimitive(amountSats)) + if (!description.isNullOrEmpty()) put("description", JsonPrimitive(description.take(100))) + if (!zapRequest.isNullOrEmpty()) put("zap", JsonPrimitive(zapRequest)) + }.toString() + val ciphertext = Nip44.encrypt(payload, convKey) + + val event = NostrEvent.create( + privkey = keypair.privkey, + pubkey = keypair.pubkey, + kind = KIND_NOFFER_RPC, + content = ciphertext, + tags = listOf(listOf("p", servicePubkey), listOf("clink_version", "1")) + ) + + val subId = "noffer-${UUID.randomUUID().toString().take(8)}" + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val relay = Relay(RelayConfig(noffer.relay), Relay.createClient(), scope = scope) + try { + relay.connect() + return withTimeout(timeoutMs) { + relay.connectionState.first { it } + + // Subscribe BEFORE publishing so a fast response isn't raced + // away. 5s grace on `since` covers slight clock skew with the + // service. + val filter = Filter( + kinds = listOf(KIND_NOFFER_RPC), + authors = listOf(servicePubkey), + pTags = listOf(keypair.pubkey.toHex()), + since = System.currentTimeMillis() / 1000 - 5 + ) + relay.send(ClientMessage.req(subId, filter)) + relay.send(ClientMessage.event(event)) + + var result: String? = null + relay.messages.first { msg -> + if (msg !is RelayMessage.EventMsg) return@first false + if (msg.subscriptionId != subId) return@first false + val response = msg.event + // `authors` already guards this server-side; defend against + // relays that ignore filters. + if (response.pubkey != servicePubkey) return@first false + val plaintext = try { + Nip44.decrypt(response.content, convKey) + } catch (_: Exception) { + return@first false + } + val parsed = parseResponse(plaintext) ?: return@first false + when { + !parsed.bolt11.isNullOrEmpty() -> { + result = parsed.bolt11 + true + } + parsed.code != null || parsed.error != null -> { + throw NofferException( + code = parsed.code ?: 0, + message = parsed.error ?: "The offer request failed.", + rangeMin = parsed.rangeMin, + rangeMax = parsed.rangeMax, + latest = parsed.latest + ) + } + // Unrecognised payload — keep listening; maybe a stale event. + else -> false + } + } + result ?: throw NofferException(0, "The offer request failed.") + } + } catch (_: TimeoutCancellationException) { + throw NofferException(0, "The offer request timed out. The recipient's service may be offline.") + } finally { + relay.send(ClientMessage.close(subId)) + relay.disconnect() + scope.cancel() + } + } + + private data class Response( + val bolt11: String?, + val error: String?, + val code: Int?, + val rangeMin: Long?, + val rangeMax: Long?, + val latest: String? + ) + + private fun parseResponse(plaintext: String): Response? { + return try { + val obj = json.parseToJsonElement(plaintext).jsonObject + val range = obj["range"]?.jsonObject + Response( + bolt11 = obj["bolt11"]?.jsonPrimitive?.content?.trim(), + error = obj["error"]?.jsonPrimitive?.content, + code = obj["code"]?.jsonPrimitive?.content?.toDoubleOrNull()?.toInt(), + rangeMin = range?.get("min")?.jsonPrimitive?.content?.toLongOrNull(), + rangeMax = range?.get("max")?.jsonPrimitive?.content?.toLongOrNull(), + latest = obj["latest"]?.jsonPrimitive?.content + ) + } catch (_: Exception) { + null + } + } +} diff --git a/app/src/main/kotlin/com/wisp/app/ui/component/NofferPaySheet.kt b/app/src/main/kotlin/com/wisp/app/ui/component/NofferPaySheet.kt new file mode 100644 index 00000000..87cc525d --- /dev/null +++ b/app/src/main/kotlin/com/wisp/app/ui/component/NofferPaySheet.kt @@ -0,0 +1,462 @@ +package com.wisp.app.ui.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.QrCode +import androidx.compose.material.icons.outlined.CurrencyBitcoin +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.wisp.app.R +import com.wisp.app.nostr.NofferData +import com.wisp.app.nostr.NofferException +import com.wisp.app.nostr.NofferPricing +import com.wisp.app.nostr.ProfileData +import com.wisp.app.nostr.toNpub +import com.wisp.app.repo.EventRepository +import com.wisp.app.repo.KeyRepository +import com.wisp.app.repo.NofferClient +import com.wisp.app.ui.theme.WispThemeColors +import com.wisp.app.ui.util.AmountFormatter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private fun shortNpub(pubkeyHex: String): String = + pubkeyHex.toNpub().let { "${it.take(12)}...${it.takeLast(4)}" } + +private fun priceLabel(price: Long, currency: String?): String = + if (!currency.isNullOrEmpty()) "${AmountFormatter.formatSatsOnly(price)} $currency" + else "${AmountFormatter.formatSatsOnly(price)} sats" + +/** + * Inline "Pay offer" card rendered for a CLINK `noffer1…` found in a note body. + * Tapping opens [NofferPaySheet]. Resolves the recipient's name itself so it + * reads consistently across feed and thread — the offer recipient is usually + * not the note author, so callers' profile maps often haven't fetched it. + */ +@Composable +fun NofferCard( + noffer: NofferData, + eventRepo: EventRepository? = null, + onPayInvoice: (suspend (String) -> Boolean)? = null +) { + val zapColor = WispThemeColors.zapColor + var showSheet by remember { mutableStateOf(false) } + + val profileVersion = eventRepo?.profileVersion?.collectAsState() + val profile = remember(noffer.pubkey, profileVersion?.value) { + eventRepo?.getProfileData(noffer.pubkey) + } + LaunchedEffect(noffer.pubkey) { + eventRepo?.requestProfileIfMissing(noffer.pubkey) + } + val recipientName = profile?.displayString ?: shortNpub(noffer.pubkey) + + Surface( + shape = RoundedCornerShape(14.dp), + color = zapColor.copy(alpha = 0.08f), + border = BorderStroke(1.dp, zapColor.copy(alpha = 0.25f)), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + .clip(RoundedCornerShape(14.dp)) + .clickable { showSheet = true } + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(30.dp) + .background(zapColor.copy(alpha = 0.15f), CircleShape) + ) { + ZapBoltIcon(tint = zapColor, size = 16) + } + Spacer(Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + "Pay offer", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + recipientName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Spacer(Modifier.width(8.dp)) + val price = noffer.price + if (price != null && price > 0) { + Text( + priceLabel(price, noffer.currency), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = zapColor + ) + Spacer(Modifier.width(4.dp)) + } + Icon( + Icons.Default.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + } + } + + if (showSheet) { + NofferPaySheet( + noffer = noffer, + recipientProfile = profile, + onPayInvoice = onPayInvoice, + onDismiss = { showSheet = false } + ) + } +} + +@Composable +private fun ZapBoltIcon(tint: Color, size: Int) { + val useZapBolt = com.wisp.app.ui.util.useBoltIcon() + if (useZapBolt) { + Icon( + painter = painterResource(R.drawable.ic_bolt), + contentDescription = null, + tint = tint, + modifier = Modifier.size(size.dp) + ) + } else { + Icon( + Icons.Outlined.CurrencyBitcoin, + contentDescription = null, + tint = tint, + modifier = Modifier.size(size.dp) + ) + } +} + +/** + * Bottom sheet that requests a bolt11 invoice for a CLINK offer and pays it + * with the active wallet via [onPayInvoice], or falls back to a scannable + * bare-`noffer` QR for an external CLINK-aware wallet (Zeus, ShockWallet, …). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NofferPaySheet( + noffer: NofferData, + recipientProfile: ProfileData? = null, + onPayInvoice: (suspend (String) -> Boolean)? = null, + onDismiss: () -> Unit +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val zapColor = WispThemeColors.zapColor + val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current + val scope = rememberCoroutineScope() + + // Spontaneous offers require the payer to name an amount. Fixed/Variable + // let the service decide (Fixed from the offer, Variable on request). + val needsAmountField = noffer.pricing == NofferPricing.SPONTANEOUS + + var amountText by remember { + mutableStateOf( + if (needsAmountField && (noffer.price ?: 0) > 0) noffer.price.toString() else "" + ) + } + var status by remember { mutableStateOf(null) } + var inFlight by remember { mutableStateOf(false) } + var didPay by remember { mutableStateOf(false) } + var showExternal by remember { mutableStateOf(false) } + + val recipientName = recipientProfile?.displayString ?: shortNpub(noffer.pubkey) + val amountSats = amountText.filter { it.isDigit() }.toLongOrNull()?.takeIf { it > 0 } + val canPay = onPayInvoice != null && !inFlight && (!needsAmountField || amountSats != null) + + fun pay() { + val payInvoice = onPayInvoice ?: return + scope.launch { + inFlight = true + status = null + try { + val keypair = withContext(Dispatchers.IO) { KeyRepository(context.applicationContext).getKeypair() } + if (keypair == null) { + status = "Sign in to pay an offer." + return@launch + } + val bolt11 = NofferClient.requestInvoice( + noffer = noffer, + keypair = keypair, + amountSats = if (needsAmountField) amountSats else null + ) + if (payInvoice(bolt11)) { + didPay = true + } else { + status = "Payment failed." + } + } catch (err: NofferException) { + status = if (err.code == 5 && err.rangeMin != null && err.rangeMax != null) { + "Amount must be between ${AmountFormatter.formatSatsOnly(err.rangeMin!!)} and ${AmountFormatter.formatSatsOnly(err.rangeMax!!)} sats." + } else { + err.message ?: "The offer request failed." + } + } catch (err: Exception) { + status = err.message ?: "The offer request failed." + } finally { + inFlight = false + } + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp) + .padding(bottom = 32.dp) + ) { + // Recipient header + ProfilePicture(url = recipientProfile?.picture, size = 64) + Spacer(Modifier.height(8.dp)) + Text( + recipientName, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.height(16.dp)) + + if (didPay) { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + tint = zapColor, + modifier = Modifier.size(56.dp) + ) + Spacer(Modifier.height(12.dp)) + Text("Payment sent", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(16.dp)) + TextButton(onClick = onDismiss) { Text("Done") } + } else { + // Offer details + Surface( + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(14.dp)) { + Row(modifier = Modifier.fillMaxWidth()) { + Text( + "Offer", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.weight(1f)) + Text( + when (noffer.pricing) { + NofferPricing.FIXED -> "Fixed price" + NofferPricing.VARIABLE -> "Variable price" + NofferPricing.SPONTANEOUS -> "Pay what you want" + }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold + ) + } + val price = noffer.price + if (price != null && price > 0 && !needsAmountField) { + Spacer(Modifier.height(8.dp)) + Row(modifier = Modifier.fillMaxWidth()) { + Text( + "Amount", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.weight(1f)) + Text( + priceLabel(price, noffer.currency), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = zapColor + ) + } + } + } + } + + if (needsAmountField) { + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = amountText, + onValueChange = { new -> amountText = new.filter { it.isDigit() } }, + label = { Text("Amount in sats") }, + singleLine = true, + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( + keyboardType = androidx.compose.ui.text.input.KeyboardType.Number + ), + modifier = Modifier.fillMaxWidth() + ) + } + + status?.let { + Spacer(Modifier.height(12.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + Icons.Default.ErrorOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(Modifier.height(16.dp)) + + Button( + onClick = { pay() }, + enabled = canPay, + colors = ButtonDefaults.buttonColors( + containerColor = zapColor, + contentColor = Color.White + ), + modifier = Modifier.fillMaxWidth() + ) { + if (inFlight) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = Color.White + ) + } else { + Text(if (onPayInvoice == null) "Connect a wallet to pay" else "Pay") + } + } + + Spacer(Modifier.height(12.dp)) + + // External wallet fallback — bare-noffer QR per spec + TextButton(onClick = { showExternal = !showExternal }) { + Icon(Icons.Default.QrCode, contentDescription = null, modifier = Modifier.size(18.dp), tint = zapColor) + Spacer(Modifier.width(6.dp)) + Text( + if (showExternal) "Hide QR code" else "Pay with another wallet", + color = zapColor + ) + Spacer(Modifier.width(4.dp)) + Icon( + if (showExternal) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = zapColor + ) + } + + AnimatedVisibility(visible = showExternal) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Spacer(Modifier.height(8.dp)) + val qr = remember(noffer.raw) { generateQrBitmap(noffer.raw) } + Box( + modifier = Modifier + .size(230.dp) + .clip(RoundedCornerShape(14.dp)) + .background(Color.White) + .padding(12.dp) + ) { + Image( + bitmap = qr.asImageBitmap(), + contentDescription = "CLINK offer QR code", + modifier = Modifier.size(206.dp) + ) + } + Spacer(Modifier.height(10.dp)) + Text( + "Scan with Zeus, ShockWallet, or another CLINK-aware wallet.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + TextButton(onClick = { + clipboardManager.setText(AnnotatedString(noffer.raw)) + }) { + Icon(Icons.Default.ContentCopy, contentDescription = null, modifier = Modifier.size(16.dp), tint = zapColor) + Spacer(Modifier.width(6.dp)) + Text("Copy offer", color = zapColor) + } + } + } + } + } + } +} diff --git a/app/src/main/kotlin/com/wisp/app/ui/component/ProfileQrSheet.kt b/app/src/main/kotlin/com/wisp/app/ui/component/ProfileQrSheet.kt index 9d12c4bc..9b31a618 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/component/ProfileQrSheet.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/component/ProfileQrSheet.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.SwapHoriz import androidx.compose.material.icons.outlined.CurrencyBitcoin import androidx.compose.material.icons.outlined.Person import androidx.compose.material.icons.outlined.QrCodeScanner @@ -50,6 +51,7 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.wisp.app.R import com.wisp.app.nostr.Nip19 +import com.wisp.app.nostr.Noffer import com.wisp.app.nostr.hexToByteArray import com.wisp.app.toRoute import com.wisp.app.ui.theme.WispThemeColors @@ -65,6 +67,7 @@ fun ProfileQrSheet( pubkeyHex: String, avatarUrl: String? = null, lud16: String? = null, + clinkOffer: String? = null, onNavigate: (String) -> Unit = {}, onDismiss: () -> Unit ) { @@ -77,12 +80,23 @@ fun ProfileQrSheet( val npub = remember(pubkeyHex) { Nip19.npubEncode(pubkeyHex.hexToByteArray()) } val npubQr = remember(npub) { generateQrBitmap(npub) } val lightningQr = remember(lud16) { lud16?.let { generateQrBitmap(it) } } + // QR payload must be exactly the bech32 string per the CLINK spec. + val bareOffer = remember(clinkOffer) { + clinkOffer?.takeIf { Noffer.isNofferString(it) }?.let { Noffer.stripNostrPrefix(it) } + } + val offerQr = remember(bareOffer) { bareOffer?.let { generateQrBitmap(it) } } val hasLightning = lud16 != null + val hasOffer = bareOffer != null + // The Lightning pane shows the address QR and/or the CLINK offer QR. + val hasPayment = hasLightning || hasOffer + // True = the offer QR is showing instead of the address QR. Offer-only + // profiles always show the offer. + var showOffer by remember { mutableStateOf(!hasLightning) } // Page indices: 0 = Nostr, 1 = Lightning (if present), last = Scan. - val lightningPage = if (hasLightning) 1 else -1 - val scanPage = if (hasLightning) 2 else 1 - val pageCount = if (hasLightning) 3 else 2 + val lightningPage = if (hasPayment) 1 else -1 + val scanPage = if (hasPayment) 2 else 1 + val pageCount = if (hasPayment) 3 else 2 val pagerState = rememberPagerState(pageCount = { pageCount }) var scanError by remember { mutableStateOf(null) } @@ -108,7 +122,7 @@ fun ProfileQrSheet( text = { Text("Nostr") }, icon = { Icon(Icons.Outlined.Person, null, modifier = Modifier.size(18.dp)) } ) - if (hasLightning) { + if (hasPayment) { Tab( selected = pagerState.currentPage == lightningPage, onClick = { scope.launch { pagerState.animateScrollToPage(lightningPage) } }, @@ -196,7 +210,9 @@ fun ProfileQrSheet( }) } lightningPage -> { - if (lightningQr != null && lud16 != null) { + val payQr = if (showOffer) offerQr else lightningQr + val payValue = if (showOffer) bareOffer else lud16 + if (payQr != null && payValue != null) { Box( contentAlignment = Alignment.Center, modifier = Modifier @@ -206,8 +222,8 @@ fun ProfileQrSheet( .padding(8.dp) ) { Image( - bitmap = lightningQr.asImageBitmap(), - contentDescription = "Lightning QR Code", + bitmap = payQr.asImageBitmap(), + contentDescription = if (showOffer) "CLINK offer QR Code" else "Lightning QR Code", modifier = Modifier.matchParentSize() ) Box( @@ -239,14 +255,29 @@ fun ProfileQrSheet( Spacer(Modifier.height(16.dp)) Text( - "Lightning Address", + if (showOffer) "CLINK Offer" else "Lightning Address", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(Modifier.height(4.dp)) - CopyableRow(text = lud16, onCopy = { - clipboardManager.setText(AnnotatedString(lud16)) + CopyableRow(text = payValue, onCopy = { + clipboardManager.setText(AnnotatedString(payValue)) }) + + // Only when the profile has both artifacts; a + // single artifact needs no switch. + if (hasLightning && hasOffer) { + Spacer(Modifier.height(8.dp)) + androidx.compose.material3.TextButton(onClick = { showOffer = !showOffer }) { + Icon( + Icons.Default.SwapHoriz, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(6.dp)) + Text(if (showOffer) "Switch to Lightning address" else "Switch to CLINK Offer") + } + } } } scanPage -> { diff --git a/app/src/main/kotlin/com/wisp/app/ui/component/RichContent.kt b/app/src/main/kotlin/com/wisp/app/ui/component/RichContent.kt index 6cb7127c..6d8485cd 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/component/RichContent.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/component/RichContent.kt @@ -106,6 +106,8 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.TextButton import com.wisp.app.R import com.wisp.app.nostr.Bolt11 +import com.wisp.app.nostr.Noffer +import com.wisp.app.nostr.NofferData import com.wisp.app.nostr.toNpub import com.wisp.app.nostr.Nip19 import com.wisp.app.nostr.Nip30 @@ -205,6 +207,8 @@ internal sealed interface ContentSegment { data class CustomEmojiSegment(val shortcode: String, val url: String) : ContentSegment data class HashtagSegment(val tag: String) : ContentSegment data class LightningInvoiceSegment(val invoice: String, val decoded: Bolt11.DecodedInvoice) : ContentSegment + /** CLINK payment offer (`noffer1…`) — rendered as a "Pay offer" card. */ + data class NofferSegment(val noffer: NofferData) : ContentSegment data class GroupInviteSegment(val relayUrl: String, val groupId: String) : ContentSegment } @@ -383,10 +387,20 @@ internal fun parseContent(content: String, emojiMap: Map = empty } // Third pass: detect lightning invoices in text segments - val finalResult = mutableListOf() + val afterInvoices = mutableListOf() for (segment in afterEmoji) { if (segment is ContentSegment.TextSegment) { - finalResult.addAll(splitTextForInvoices(segment.text)) + afterInvoices.addAll(splitTextForInvoices(segment.text)) + } else { + afterInvoices.add(segment) + } + } + + // Fourth pass: detect CLINK offers (noffer1…) in text segments + val finalResult = mutableListOf() + for (segment in afterInvoices) { + if (segment is ContentSegment.TextSegment) { + finalResult.addAll(splitTextForNoffers(segment.text)) } else { finalResult.add(segment) } @@ -434,6 +448,33 @@ private fun splitTextForInvoices(text: String): List { return result } +private val nofferRegex = Regex( + """(?:nostr:)?noffer1[023456789acdefghjklmnpqrstuvwxyz]{20,}""", + RegexOption.IGNORE_CASE +) + +private fun splitTextForNoffers(text: String): List { + val matches = nofferRegex.findAll(text).toList() + if (matches.isEmpty()) return listOf(ContentSegment.TextSegment(text)) + val result = mutableListOf() + var lastEnd = 0 + var anyFound = false + for (match in matches) { + val decoded = Noffer.decodeOrNull(match.value) ?: continue + anyFound = true + if (match.range.first > lastEnd) { + result.add(ContentSegment.TextSegment(text.substring(lastEnd, match.range.first))) + } + result.add(ContentSegment.NofferSegment(decoded)) + lastEnd = match.range.last + 1 + } + if (!anyFound) return listOf(ContentSegment.TextSegment(text)) + if (lastEnd < text.length) { + result.add(ContentSegment.TextSegment(text.substring(lastEnd))) + } + return result +} + private fun splitTextForEmojis(text: String, emojiMap: Map): List { val result = mutableListOf() var lastEnd = 0 @@ -1024,6 +1065,13 @@ fun RichContent( onPayInvoice = noteActions?.onPayInvoice ) } + is ContentSegment.NofferSegment -> { + NofferCard( + noffer = segment.noffer, + eventRepo = eventRepo, + onPayInvoice = noteActions?.onPayInvoice + ) + } is ContentSegment.GroupInviteSegment -> { GroupInviteCard( relayUrl = segment.relayUrl, diff --git a/app/src/main/kotlin/com/wisp/app/ui/component/WispDrawerContent.kt b/app/src/main/kotlin/com/wisp/app/ui/component/WispDrawerContent.kt index d2f400d8..5a0153e5 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/component/WispDrawerContent.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/component/WispDrawerContent.kt @@ -370,6 +370,7 @@ fun WispDrawerContent( pubkeyHex = pubkey, avatarUrl = profile?.picture, lud16 = profile?.lud16, + clinkOffer = profile?.clinkOffer, onNavigate = { route -> showProfileQr = false onScanResult(route) diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/ProfileEditScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/ProfileEditScreen.kt index 7a9f2056..0fe3952c 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/ProfileEditScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/ProfileEditScreen.kt @@ -63,6 +63,7 @@ fun ProfileEditScreen( val nip05 by viewModel.nip05.collectAsState() val banner by viewModel.banner.collectAsState() val lud16 by viewModel.lud16.collectAsState() + val clinkOffer by viewModel.clinkOffer.collectAsState() val publishing by viewModel.publishing.collectAsState() val uploading by viewModel.uploading.collectAsState() val error by viewModel.error.collectAsState() @@ -198,6 +199,18 @@ fun ProfileEditScreen( singleLine = true, modifier = Modifier.fillMaxWidth().scrollOnFocus() ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = clinkOffer, + onValueChange = { new -> if (!NsecPasteGuard.blockIfNsec(clinkOffer, new)) viewModel.updateClinkOffer(new) }, + label = { Text("CLINK offer") }, + placeholder = { Text("noffer1…") }, + singleLine = true, + supportingText = { + Text("Self-custodial Lightning payments. Generate one with Zeus, ShockWallet or Lightning.Pub.") + }, + modifier = Modifier.fillMaxWidth().scrollOnFocus() + ) Spacer(Modifier.height(16.dp)) error?.let { diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/UserProfileScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/UserProfileScreen.kt index ee8ccf25..4901061c 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/UserProfileScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/UserProfileScreen.kt @@ -50,6 +50,7 @@ import androidx.compose.material.icons.filled.QrCodeScanner import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.outlined.CurrencyBitcoin +import androidx.compose.material.icons.outlined.Sell import com.wisp.app.nostr.Nip30 import com.wisp.app.nostr.toNpub import com.wisp.app.ui.component.Nip05Badge @@ -357,11 +358,27 @@ fun UserProfileScreen( var showQrDialog by remember { mutableStateOf(false) } var showAddToListDialog by remember { mutableStateOf(false) } + // Decoded CLINK offer advertised on the profile, when present and valid. + val profileClinkOffer = remember(profile?.clinkOffer) { + profile?.clinkOffer?.let { com.wisp.app.nostr.Noffer.decodeOrNull(it) } + } + var showOfferPaySheet by remember { mutableStateOf(false) } + + if (showOfferPaySheet && profileClinkOffer != null) { + com.wisp.app.ui.component.NofferPaySheet( + noffer = profileClinkOffer, + recipientProfile = profile, + onPayInvoice = onPayInvoice, + onDismiss = { showOfferPaySheet = false } + ) + } + if (showQrDialog) { ProfileQrSheet( pubkeyHex = profilePubkey, avatarUrl = profile?.picture, lud16 = profile?.lud16, + clinkOffer = profile?.clinkOffer, onDismiss = { showQrDialog = false } ) } @@ -631,6 +648,7 @@ fun UserProfileScreen( onNavigateToProfile = onNavigateToProfile, onSendDm = onSendDm, onZapClick = if (onZapProfile != null) { { showProfileZapDialog = true } } else null, + onPayOffer = if (profileClinkOffer != null) { { showOfferPaySheet = true } } else null, followingCount = followList.size, followerCount = followers.size.takeIf { followers.isNotEmpty() }, followedBy = followedBy, @@ -1275,6 +1293,7 @@ private fun ProfileHeader( onNavigateToProfile: ((String) -> Unit)? = null, onSendDm: (() -> Unit)? = null, onZapClick: (() -> Unit)? = null, + onPayOffer: (() -> Unit)? = null, followingCount: Int = 0, followerCount: Int? = null, followedBy: List = emptyList(), @@ -1386,6 +1405,21 @@ private fun ProfileHeader( } } } + if (onPayOffer != null) { + Surface( + onClick = onPayOffer, + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.size(40.dp) + ) { + Icon( + Icons.Outlined.Sell, + contentDescription = "Pay offer", + tint = Color(0xFFFFC107), + modifier = Modifier.padding(10.dp) + ) + } + } // Follow circle button Surface( onClick = onToggleFollow, diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt index a6f1f93d..3ceb4d37 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt @@ -1595,8 +1595,14 @@ private fun SendAmountContent( style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant ) + // A noffer bech32 is far too long to show whole — ellipsize the middle. + val displayAddress = if (com.wisp.app.nostr.Noffer.isNofferString(address)) { + "${address.take(16)}…${address.takeLast(8)}" + } else { + address + } Text( - address, + displayAddress, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface ) diff --git a/app/src/main/kotlin/com/wisp/app/viewmodel/ProfileViewModel.kt b/app/src/main/kotlin/com/wisp/app/viewmodel/ProfileViewModel.kt index dd953fc0..63a07d49 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/ProfileViewModel.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/ProfileViewModel.kt @@ -9,6 +9,7 @@ import androidx.lifecycle.viewModelScope import com.wisp.app.nostr.ClientMessage import com.wisp.app.nostr.Filter import com.wisp.app.nostr.LocalSigner +import com.wisp.app.nostr.Noffer import com.wisp.app.nostr.NostrEvent import com.wisp.app.nostr.NostrSigner import com.wisp.app.relay.RelayPool @@ -47,6 +48,9 @@ class ProfileViewModel(app: Application) : AndroidViewModel(app) { private val _lud16 = MutableStateFlow("") val lud16: StateFlow = _lud16 + private val _clinkOffer = MutableStateFlow("") + val clinkOffer: StateFlow = _clinkOffer + private val _publishing = MutableStateFlow(false) val publishing: StateFlow = _publishing @@ -59,6 +63,7 @@ class ProfileViewModel(app: Application) : AndroidViewModel(app) { fun updateNip05(value: String) { _nip05.value = value } fun updateBanner(value: String) { _banner.value = value } fun updateLud16(value: String) { _lud16.value = value } + fun updateClinkOffer(value: String) { _clinkOffer.value = value } private val _uploading = MutableStateFlow(null) val uploading: StateFlow = _uploading @@ -107,6 +112,7 @@ class ProfileViewModel(app: Application) : AndroidViewModel(app) { _nip05.value = profile.nip05 ?: "" _banner.value = profile.banner ?: "" _lud16.value = profile.lud16 ?: "" + _clinkOffer.value = profile.clinkOffer ?: "" } // Request fresh profile from relays @@ -129,6 +135,7 @@ class ProfileViewModel(app: Application) : AndroidViewModel(app) { _nip05.value = updated.nip05 ?: "" _banner.value = updated.banner ?: "" _lud16.value = updated.lud16 ?: "" + _clinkOffer.value = updated.clinkOffer ?: "" } } } @@ -153,6 +160,9 @@ class ProfileViewModel(app: Application) : AndroidViewModel(app) { if (_nip05.value.isNotBlank()) put("nip05", JsonPrimitive(_nip05.value)) if (_banner.value.isNotBlank()) put("banner", JsonPrimitive(_banner.value)) if (_lud16.value.isNotBlank()) put("lud16", JsonPrimitive(_lud16.value)) + if (_clinkOffer.value.isNotBlank()) { + put("clink_offer", JsonPrimitive(Noffer.stripNostrPrefix(_clinkOffer.value))) + } }.toString() viewModelScope.launch { diff --git a/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt b/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt index 1aa20571..fcc0905e 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt @@ -8,6 +8,10 @@ import com.wisp.app.nostr.Filter import com.wisp.app.nostr.LocalSigner import com.wisp.app.nostr.Nip57 import com.wisp.app.nostr.Nip78 +import com.wisp.app.nostr.Noffer +import com.wisp.app.nostr.NofferData +import com.wisp.app.nostr.NofferException +import com.wisp.app.nostr.NofferPricing import com.wisp.app.nostr.NostrEvent import com.wisp.app.nostr.NostrSigner import com.wisp.app.nostr.toHex @@ -16,6 +20,7 @@ import com.wisp.app.repo.SigningMode import com.wisp.app.repo.EventRepository import com.wisp.app.repo.KeyRepository import com.wisp.app.repo.BalanceUnit +import com.wisp.app.repo.NofferClient import com.wisp.app.repo.NwcRepository import com.wisp.app.repo.SparkRepository import com.wisp.app.repo.WalletMode @@ -1070,6 +1075,20 @@ class WalletViewModel( _sendError.value = null when { + Noffer.isNofferString(trimmed) -> { + val noffer = Noffer.decodeOrNull(trimmed) + if (noffer == null) { + _sendError.value = "Invalid CLINK offer" + return + } + if (noffer.pricing == NofferPricing.FIXED) { + // Amount is baked into the offer — go straight to the invoice. + resolveNofferInvoice(noffer, null) + } else { + _sendAmount.value = "" + navigateTo(WalletPage.SendAmount(noffer.raw)) + } + } trimmed.lowercase().startsWith("lnbc") -> { val decoded = Bolt11.decode(trimmed) if (decoded == null) { @@ -1092,12 +1111,23 @@ class WalletViewModel( navigateTo(WalletPage.SendAmount(trimmed)) } else -> { - _sendError.value = "Enter a lightning address (user@domain) or BOLT11 invoice" + _sendError.value = "Enter a lightning address (user@domain), BOLT11 invoice, or CLINK offer" } } } fun resolveLightningAddress(address: String, amountSats: Long) { + // The SendAmount page is shared between lightning addresses and CLINK + // offers — route noffer-shaped input through the kind-21001 RPC. + if (Noffer.isNofferString(address)) { + val noffer = Noffer.decodeOrNull(address) + if (noffer == null) { + _sendError.value = "Invalid CLINK offer" + return + } + resolveNofferInvoice(noffer, amountSats) + return + } _isLoading.value = true viewModelScope.launch { try { @@ -1137,6 +1167,42 @@ class WalletViewModel( } } + /** + * Request a bolt11 invoice for a CLINK offer via the kind-21001 RPC and + * move to the confirm page. [amountSats] is null for Fixed offers (the + * service knows the price). + */ + private fun resolveNofferInvoice(noffer: NofferData, amountSats: Long?) { + _isLoading.value = true + viewModelScope.launch { + try { + val keypair = keyRepo.getKeypair() + if (keypair == null) { + _sendError.value = "Sign in with a key that can sign to pay an offer" + return@launch + } + val invoice = NofferClient.requestInvoice(noffer, keypair, amountSats) + val decoded = Bolt11.decode(invoice) + navigateTo(WalletPage.SendConfirm( + invoice = invoice, + amountSats = decoded?.amountSats ?: amountSats, + paymentHash = decoded?.paymentHash, + description = decoded?.description ?: "CLINK offer" + )) + } catch (e: NofferException) { + _sendError.value = if (e.code == 5 && e.rangeMin != null && e.rangeMax != null) { + "Amount out of range: ${e.rangeMin}-${e.rangeMax} sats" + } else { + e.message ?: "Failed to resolve offer" + } + } catch (e: Exception) { + _sendError.value = e.message ?: "Failed to resolve offer" + } finally { + _isLoading.value = false + } + } + } + fun prepareFee(invoice: String) { if (_walletMode.value != WalletMode.SPARK) { _feeState.value = FeeState.Unavailable diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 565d7414..a30ce792 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -749,7 +749,7 @@ Set up your Lightning Address Send Receive - Lightning address or invoice + Lightning address, invoice, or CLINK offer Paste Scan QR Gallery diff --git a/app/src/test/kotlin/com/wisp/app/nostr/NofferTest.kt b/app/src/test/kotlin/com/wisp/app/nostr/NofferTest.kt new file mode 100644 index 00000000..8ca4a469 --- /dev/null +++ b/app/src/test/kotlin/com/wisp/app/nostr/NofferTest.kt @@ -0,0 +1,122 @@ +package com.wisp.app.nostr + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for the CLINK `noffer1…` decoder. We don't have a publicly-shared + * production noffer to pin as a fixture, so each test encodes a synthetic + * offer via the app's own bech32 encoder and round-trips it through + * [Noffer.decode], pinning the TLV byte layout from the CLINK spec. + */ +class NofferTest { + + private val pubkeyHex = "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + private val relay = "wss://relay.example.com" + private val offerId = "tip-jar" + + private fun encode(tlvs: List>): String { + val bytes = mutableListOf() + for ((type, value) in tlvs) { + bytes.add(type.toByte()) + bytes.add(value.size.toByte()) + for (b in value) bytes.add(b) + } + return Nip19.bech32Encode("noffer", bytes.toByteArray()) + } + + private val pubkeyBytes get() = pubkeyHex.hexToByteArray() + private val relayBytes get() = relay.toByteArray(Charsets.UTF_8) + private val offerBytes get() = offerId.toByteArray(Charsets.UTF_8) + + @Test + fun decodesMinimumTlvsAndDefaultsToSpontaneous() { + val noffer = encode(listOf(0 to pubkeyBytes, 1 to relayBytes, 2 to offerBytes)) + val decoded = Noffer.decode(noffer) + assertEquals(pubkeyHex, decoded.pubkey) + assertEquals(relay, decoded.relay) + assertEquals(offerId, decoded.offerId) + assertEquals(NofferPricing.SPONTANEOUS, decoded.pricing) + assertNull(decoded.price) + assertNull(decoded.currency) + } + + @Test + fun decodesFixedPricingWithPrice() { + val noffer = encode(listOf( + 0 to pubkeyBytes, + 1 to relayBytes, + 2 to offerBytes, + 3 to byteArrayOf(0), // Fixed + 4 to byteArrayOf(0x27, 0x10) // 10000 big-endian + )) + val decoded = Noffer.decode(noffer) + assertEquals(NofferPricing.FIXED, decoded.pricing) + assertEquals(10_000L, decoded.price) + } + + @Test + fun decodesVariablePricingWithCurrency() { + val noffer = encode(listOf( + 0 to pubkeyBytes, + 1 to relayBytes, + 2 to offerBytes, + 3 to byteArrayOf(1), // Variable + 5 to "USD".toByteArray(Charsets.UTF_8) + )) + val decoded = Noffer.decode(noffer) + assertEquals(NofferPricing.VARIABLE, decoded.pricing) + assertEquals("USD", decoded.currency) + } + + @Test + fun acceptsNostrUriPrefix() { + val noffer = encode(listOf(0 to pubkeyBytes, 1 to relayBytes, 2 to offerBytes)) + assertEquals(pubkeyHex, Noffer.decode("nostr:$noffer").pubkey) + assertEquals(pubkeyHex, Noffer.decode("NOSTR:$noffer").pubkey) + // `raw` always strips the scheme prefix. + assertEquals(noffer, Noffer.decode("nostr:$noffer").raw) + } + + @Test + fun rejectsWrongHrp() { + assertNull(Noffer.decodeOrNull("npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")) + } + + @Test + fun rejectsMissingRequiredTlvs() { + val noffer = encode(listOf(0 to pubkeyBytes)) // missing relay + offer id + assertNull(Noffer.decodeOrNull(noffer)) + } + + @Test + fun rejectsWrongLengthPubkey() { + val noffer = encode(listOf( + 0 to ByteArray(16), // 16-byte pubkey is invalid + 1 to relayBytes, + 2 to offerBytes + )) + assertNull(Noffer.decodeOrNull(noffer)) + } + + @Test + fun isNofferStringMatchesShapeOnly() { + val noffer = encode(listOf(0 to pubkeyBytes, 1 to relayBytes, 2 to offerBytes)) + assertTrue(Noffer.isNofferString(noffer)) + assertTrue(Noffer.isNofferString("nostr:$noffer")) + assertFalse(Noffer.isNofferString("npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")) + assertFalse(Noffer.isNofferString("")) + assertFalse(Noffer.isNofferString(null)) + } + + @Test + fun stripNostrPrefixWorks() { + assertEquals("noffer1abc", Noffer.stripNostrPrefix("nostr:noffer1abc")) + assertEquals("noffer1abc", Noffer.stripNostrPrefix("NOSTR:noffer1abc")) + assertEquals("noffer1abc", Noffer.stripNostrPrefix(" nostr:noffer1abc ")) + assertEquals("noffer1abc", Noffer.stripNostrPrefix("noffer1abc")) + } +} From 789c1d395c44548e5c6de04be758de421bb55b97 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Thu, 11 Jun 2026 15:51:36 -0400 Subject: [PATCH 2/3] fix(clink): plumb onPayInvoice into DM, group & search note actions The NoteActions built for DM conversations, group DMs, group rooms, and search results never set onPayInvoice, so the noffer pay sheet (and lightning invoice cards) on those surfaces degraded to "Connect a wallet to pay" even with an active wallet. --- app/src/main/kotlin/com/wisp/app/Navigation.kt | 4 ++++ app/src/main/kotlin/com/wisp/app/ui/screen/SearchScreen.kt | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/wisp/app/Navigation.kt b/app/src/main/kotlin/com/wisp/app/Navigation.kt index 80c11255..2dd479d2 100644 --- a/app/src/main/kotlin/com/wisp/app/Navigation.kt +++ b/app/src/main/kotlin/com/wisp/app/Navigation.kt @@ -1277,6 +1277,7 @@ fun WispNavHost( SearchScreen( viewModel = searchViewModel, relayPool = feedViewModel.relayPool, + onPayInvoice = { bolt11 -> feedViewModel.payInvoice(bolt11) }, eventRepo = feedViewModel.eventRepo, muteRepo = feedViewModel.muteRepo, contactRepo = feedViewModel.contactRepo, @@ -1435,6 +1436,7 @@ fun WispNavHost( noteActions = remember { com.wisp.app.ui.component.NoteActions( nip05Repo = feedViewModel.nip05Repo, + onPayInvoice = { bolt11 -> feedViewModel.payInvoice(bolt11) }, onAddEmojiSet = { pk, dTag -> feedViewModel.addSetToEmojiList(pk, dTag) }, onRemoveEmojiSet = { pk, dTag -> feedViewModel.removeSetFromEmojiList(pk, dTag) }, isEmojiSetAdded = { pk, dTag -> @@ -1522,6 +1524,7 @@ fun WispNavHost( noteActions = remember { com.wisp.app.ui.component.NoteActions( nip05Repo = feedViewModel.nip05Repo, + onPayInvoice = { bolt11 -> feedViewModel.payInvoice(bolt11) }, onAddEmojiSet = { pk, dTag -> feedViewModel.addSetToEmojiList(pk, dTag) }, onRemoveEmojiSet = { pk, dTag -> feedViewModel.removeSetFromEmojiList(pk, dTag) }, isEmojiSetAdded = { pk, dTag -> @@ -1757,6 +1760,7 @@ fun WispNavHost( noteActions = remember { com.wisp.app.ui.component.NoteActions( nip05Repo = feedViewModel.nip05Repo, + onPayInvoice = { bolt11 -> feedViewModel.payInvoice(bolt11) }, onNoteClick = { eventId -> navController.navigate("thread/$eventId") }, onAddEmojiSet = { pk, dTag -> feedViewModel.addSetToEmojiList(pk, dTag) }, onRemoveEmojiSet = { pk, dTag -> feedViewModel.removeSetFromEmojiList(pk, dTag) }, diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/SearchScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/SearchScreen.kt index 0aa0b7eb..870e1d2b 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/SearchScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/SearchScreen.kt @@ -119,7 +119,8 @@ fun SearchScreen( onAddEmojiSet: ((String, String) -> Unit)? = null, onRemoveEmojiSet: ((String, String) -> Unit)? = null, isEmojiSetAdded: ((String, String) -> Boolean)? = null, - nip05Repo: com.wisp.app.repo.Nip05Repository? = null + nip05Repo: com.wisp.app.repo.Nip05Repository? = null, + onPayInvoice: (suspend (String) -> Boolean)? = null ) { val query by viewModel.query.collectAsState() val filter by viewModel.filter.collectAsState() @@ -151,6 +152,7 @@ fun SearchScreen( isEmojiSetAdded = isEmojiSetAdded, onPollVote = onPollVote, nip05Repo = nip05Repo, + onPayInvoice = onPayInvoice, ) } From f0ed00637dea4a47080305a19b1e83f90d42695e Mon Sep 17 00:00:00 2001 From: The Daniel Date: Thu, 11 Jun 2026 16:34:03 -0400 Subject: [PATCH 3/3] feat(clink): payee recognition, full-height pay sheet & cache re-parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Map noffer payment hashes to the offer's service pubkey via ZapSender.persistRecipient in both pay paths, so wallet history resolves the payee's profile (name + avatar) like zaps do - Show the offer service's profile name on the wallet amount page and in the confirm-page description fallback - Make NofferPaySheet full viewport height (status-bar inset so the drag handle clears camera cutouts), matching the iOS pay modal - ProfileRepository schema migration: reset cached profile timestamps once so kind-0s re-parse with the new clinkOffer field — stale pre-upgrade cache entries otherwise never pick it up --- .../com/wisp/app/repo/ProfileRepository.kt | 20 +++++++++++++++++++ .../wisp/app/ui/component/NofferPaySheet.kt | 19 ++++++++++++++++-- .../com/wisp/app/ui/screen/WalletScreen.kt | 8 +++++++- .../com/wisp/app/viewmodel/WalletViewModel.kt | 11 +++++++++- 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/com/wisp/app/repo/ProfileRepository.kt b/app/src/main/kotlin/com/wisp/app/repo/ProfileRepository.kt index 1608a893..3fba6ed4 100644 --- a/app/src/main/kotlin/com/wisp/app/repo/ProfileRepository.kt +++ b/app/src/main/kotlin/com/wisp/app/repo/ProfileRepository.kt @@ -13,6 +13,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class ProfileRepository(context: Context) { + private companion object { + /** Bump when ProfileData gains fields that need a cache re-parse. */ + const val SCHEMA_VERSION = 1 + } + private val prefs: SharedPreferences = context.getSharedPreferences("wisp_profiles", Context.MODE_PRIVATE) private val json = Json { ignoreUnknownKeys = true } @@ -23,9 +28,24 @@ class ProfileRepository(context: Context) { val avatarDir = File(context.filesDir, "avatars").also { it.mkdirs() } init { + migrateSchema() loadFromPrefs() } + /** + * Cached ProfileData is parsed JSON, so profiles stored before a new + * field existed (e.g. clinkOffer) deserialize without it — and the + * timestamp guard in [updateFromEvent] blocks re-parsing an unchanged + * kind-0. On schema bump, drop the stored timestamps (keeping the + * profiles) so the next received event re-parses with the new fields. + */ + private fun migrateSchema() { + if (prefs.getInt("schema_version", 0) >= SCHEMA_VERSION) return + val editor = prefs.edit() + prefs.all.keys.filter { it.startsWith("p_ts_") }.forEach { editor.remove(it) } + editor.putInt("schema_version", SCHEMA_VERSION).apply() + } + fun updateFromEvent(event: NostrEvent): ProfileData? { if (event.kind != 0) return null val existing = timestamps.get(event.pubkey) diff --git a/app/src/main/kotlin/com/wisp/app/ui/component/NofferPaySheet.kt b/app/src/main/kotlin/com/wisp/app/ui/component/NofferPaySheet.kt index 87cc525d..aa1ac4f3 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/component/NofferPaySheet.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/component/NofferPaySheet.kt @@ -10,10 +10,13 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape @@ -62,6 +65,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.wisp.app.R +import com.wisp.app.nostr.Bolt11 import com.wisp.app.nostr.NofferData import com.wisp.app.nostr.NofferException import com.wisp.app.nostr.NofferPricing @@ -70,6 +74,7 @@ import com.wisp.app.nostr.toNpub import com.wisp.app.repo.EventRepository import com.wisp.app.repo.KeyRepository import com.wisp.app.repo.NofferClient +import com.wisp.app.repo.ZapSender import com.wisp.app.ui.theme.WispThemeColors import com.wisp.app.ui.util.AmountFormatter import kotlinx.coroutines.Dispatchers @@ -248,6 +253,11 @@ fun NofferPaySheet( keypair = keypair, amountSats = if (needsAmountField) amountSats else null ) + // Map the payment hash to the offer's service pubkey so the + // wallet transaction history resolves the payee, same as zaps. + Bolt11.decode(bolt11)?.paymentHash?.let { + ZapSender.persistRecipient(it, noffer.pubkey) + } if (payInvoice(bolt11)) { didPay = true } else { @@ -269,12 +279,17 @@ fun NofferPaySheet( ModalBottomSheet( onDismissRequest = onDismiss, - sheetState = sheetState + sheetState = sheetState, + // Full-height sheet to match the iOS pay modal — inset below the + // status bar so the drag handle clears the camera cutout. + modifier = Modifier + .fillMaxHeight() + .statusBarsPadding() ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier - .fillMaxWidth() + .fillMaxSize() .verticalScroll(rememberScrollState()) .padding(horizontal = 20.dp) .padding(bottom = 32.dp) diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt index 3ceb4d37..66012335 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt @@ -388,8 +388,14 @@ fun WalletScreen( ) is WalletPage.SendAmount -> { val page = currentPage as WalletPage.SendAmount + // For a CLINK offer, show the service's profile name + // instead of the raw noffer string. + val nofferPayee = remember(page.address) { + com.wisp.app.nostr.Noffer.decodeOrNull(page.address) + ?.let { viewModel.getProfileData(it.pubkey)?.displayString } + } SendAmountContent( - address = page.address, + address = nofferPayee ?: page.address, amount = viewModel.sendAmount.collectAsState().value, error = viewModel.sendError.collectAsState().value, isLoading = viewModel.isLoading.collectAsState().value, diff --git a/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt b/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt index fcc0905e..8010c3f3 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt @@ -1081,6 +1081,9 @@ class WalletViewModel( _sendError.value = "Invalid CLINK offer" return } + // Resolve the offer service's profile so the send pages and + // transaction history can show the payee by name. + eventRepo.requestProfileIfMissing(noffer.pubkey) if (noffer.pricing == NofferPricing.FIXED) { // Amount is baked into the offer — go straight to the invoice. resolveNofferInvoice(noffer, null) @@ -1183,11 +1186,17 @@ class WalletViewModel( } val invoice = NofferClient.requestInvoice(noffer, keypair, amountSats) val decoded = Bolt11.decode(invoice) + // Map the payment hash to the offer's service pubkey so the + // transaction history resolves the payee's profile, same as zaps. + decoded?.paymentHash?.let { ZapSender.persistRecipient(it, noffer.pubkey) } + val payeeName = eventRepo.getProfileData(noffer.pubkey)?.displayString navigateTo(WalletPage.SendConfirm( invoice = invoice, amountSats = decoded?.amountSats ?: amountSats, paymentHash = decoded?.paymentHash, - description = decoded?.description ?: "CLINK offer" + description = decoded?.description?.takeIf { it.isNotBlank() } + ?: payeeName?.let { "CLINK offer to $it" } + ?: "CLINK offer" )) } catch (e: NofferException) { _sendError.value = if (e.code == 5 && e.rangeMin != null && e.rangeMax != null) {