feat(clink): pay CLINK noffer offers from notes, profiles & wallet (port wisp PRs)

- NIP-69-style noffer parsing (Noffer.kt) + encrypted RPC client
  (NofferClient) for fetching invoices from CLINK services
- Full-height NofferPaySheet with payee recognition and amount entry
- noffer1 strings in notes, profiles, QR sheet and drawer resolve to a
  pay action; onPayInvoice plumbed through DM, group & search notes
- Offer field on profile edit; payee re-parse on profile cache update
- NofferClient routes through the relay client provider (Tor-aware)
This commit is contained in:
The Daniel
2026-06-12 12:52:34 -04:00
parent 5622e11c2e
commit e2bb1a3f31
17 changed files with 1319 additions and 21 deletions
@@ -1245,6 +1245,7 @@ fun WispNavHost(
SearchScreen(
viewModel = searchViewModel,
relayPool = feedViewModel.relayPool,
onPayInvoice = { bolt11 -> feedViewModel.payInvoice(bolt11) },
eventRepo = feedViewModel.eventRepo,
muteRepo = feedViewModel.muteRepo,
contactRepo = feedViewModel.contactRepo,
@@ -1403,6 +1404,7 @@ fun WispNavHost(
noteActions = remember {
com.darkwisp.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 ->
@@ -1491,6 +1493,7 @@ fun WispNavHost(
noteActions = remember {
com.darkwisp.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 ->
@@ -1729,6 +1732,7 @@ fun WispNavHost(
noteActions = remember {
com.darkwisp.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) },
@@ -0,0 +1,187 @@
package com.darkwisp.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<Pair<Int, ByteArray>> {
val tlvs = mutableListOf<Pair<Int, ByteArray>>()
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
}
}
@@ -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) {
@@ -0,0 +1,213 @@
package com.darkwisp.app.repo
import com.darkwisp.app.nostr.ClientMessage
import com.darkwisp.app.nostr.Filter
import com.darkwisp.app.nostr.Keys
import com.darkwisp.app.nostr.Nip44
import com.darkwisp.app.nostr.NofferData
import com.darkwisp.app.nostr.NofferException
import com.darkwisp.app.nostr.Noffer
import com.darkwisp.app.nostr.NostrEvent
import com.darkwisp.app.nostr.RelayMessage
import com.darkwisp.app.nostr.hexToByteArray
import com.darkwisp.app.nostr.toHex
import com.darkwisp.app.relay.Relay
import com.darkwisp.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
}
}
}
@@ -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)
@@ -0,0 +1,477 @@
package com.darkwisp.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.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
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.darkwisp.app.R
import com.darkwisp.app.nostr.Bolt11
import com.darkwisp.app.nostr.NofferData
import com.darkwisp.app.nostr.NofferException
import com.darkwisp.app.nostr.NofferPricing
import com.darkwisp.app.nostr.ProfileData
import com.darkwisp.app.nostr.toNpub
import com.darkwisp.app.repo.EventRepository
import com.darkwisp.app.repo.KeyRepository
import com.darkwisp.app.repo.NofferClient
import com.darkwisp.app.repo.ZapSender
import com.darkwisp.app.ui.theme.WispThemeColors
import com.darkwisp.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.darkwisp.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<String?>(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
)
// 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 {
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,
// 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
.fillMaxSize()
.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)
}
}
}
}
}
}
}
@@ -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.darkwisp.app.R
import com.darkwisp.app.nostr.Nip19
import com.darkwisp.app.nostr.Noffer
import com.darkwisp.app.nostr.hexToByteArray
import com.darkwisp.app.toRoute
import com.darkwisp.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<String?>(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 -> {
@@ -106,6 +106,9 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.TextButton
import com.darkwisp.app.R
import com.darkwisp.app.nostr.Bolt11
import com.darkwisp.app.nostr.Noffer
import com.darkwisp.app.nostr.NofferData
import com.darkwisp.app.nostr.toNpub
import com.darkwisp.app.nostr.Nip19
import com.darkwisp.app.nostr.Nip30
import com.darkwisp.app.nostr.toHex
@@ -202,6 +205,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
}
@@ -380,10 +385,20 @@ internal fun parseContent(content: String, emojiMap: Map<String, String> = empty
}
// Third pass: detect lightning invoices in text segments
val finalResult = mutableListOf<ContentSegment>()
val afterInvoices = mutableListOf<ContentSegment>()
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<ContentSegment>()
for (segment in afterInvoices) {
if (segment is ContentSegment.TextSegment) {
finalResult.addAll(splitTextForNoffers(segment.text))
} else {
finalResult.add(segment)
}
@@ -431,6 +446,33 @@ private fun splitTextForInvoices(text: String): List<ContentSegment> {
return result
}
private val nofferRegex = Regex(
"""(?:nostr:)?noffer1[023456789acdefghjklmnpqrstuvwxyz]{20,}""",
RegexOption.IGNORE_CASE
)
private fun splitTextForNoffers(text: String): List<ContentSegment> {
val matches = nofferRegex.findAll(text).toList()
if (matches.isEmpty()) return listOf(ContentSegment.TextSegment(text))
val result = mutableListOf<ContentSegment>()
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<String, String>): List<ContentSegment> {
val result = mutableListOf<ContentSegment>()
var lastEnd = 0
@@ -939,6 +981,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,
@@ -365,6 +365,7 @@ fun WispDrawerContent(
pubkeyHex = pubkey,
avatarUrl = profile?.picture,
lud16 = profile?.lud16,
clinkOffer = profile?.clinkOffer,
onNavigate = { route ->
showProfileQr = false
onScanResult(route)
@@ -47,6 +47,7 @@ import com.darkwisp.app.relay.RelayPool
import com.darkwisp.app.viewmodel.ProfileViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import com.darkwisp.app.ui.component.NsecPasteGuard
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
@@ -62,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()
@@ -197,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 {
@@ -113,7 +113,8 @@ fun SearchScreen(
onAddEmojiSet: ((String, String) -> Unit)? = null,
onRemoveEmojiSet: ((String, String) -> Unit)? = null,
isEmojiSetAdded: ((String, String) -> Boolean)? = null,
nip05Repo: com.darkwisp.app.repo.Nip05Repository? = null
nip05Repo: com.darkwisp.app.repo.Nip05Repository? = null,
onPayInvoice: (suspend (String) -> Boolean)? = null
) {
val query by viewModel.query.collectAsState()
val filter by viewModel.filter.collectAsState()
@@ -145,6 +146,7 @@ fun SearchScreen(
isEmojiSetAdded = isEmojiSetAdded,
onPollVote = onPollVote,
nip05Repo = nip05Repo,
onPayInvoice = onPayInvoice,
)
}
@@ -23,8 +23,13 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.PersonRemove
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Block
import androidx.compose.material.icons.outlined.Lock
@@ -40,6 +45,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.darkwisp.app.nostr.Nip30
import com.darkwisp.app.ui.component.Nip05Badge
import com.darkwisp.app.ui.component.PaymentTargetSheet
@@ -90,6 +96,7 @@ import com.darkwisp.app.nostr.Nip69
import com.darkwisp.app.nostr.NipA3
import com.darkwisp.app.nostr.NostrEvent
import com.darkwisp.app.nostr.ProfileData
import com.darkwisp.app.nostr.toNpub
import com.darkwisp.app.relay.RelayConfig
import com.darkwisp.app.relay.RelayPool
import com.darkwisp.app.repo.ContactRepository
@@ -356,11 +363,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.darkwisp.app.nostr.Noffer.decodeOrNull(it) }
}
var showOfferPaySheet by remember { mutableStateOf(false) }
if (showOfferPaySheet && profileClinkOffer != null) {
com.darkwisp.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 }
)
}
@@ -568,10 +591,13 @@ 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,
followedBy = followedBy,
followsYou = !isOwnProfile && userPubkey != null && followList.any { it.pubkey == userPubkey },
isBlocked = isBlocked,
onMuteUser = onBlockUser,
onUnmuteUser = onUnblockUser,
paymentTargets = paymentTargets,
onPaymentTargetClick = { paymentTargetSheetTarget = it }
)
@@ -1145,10 +1171,13 @@ private fun ProfileHeader(
onNavigateToProfile: ((String) -> Unit)? = null,
onSendDm: (() -> Unit)? = null,
onZapClick: (() -> Unit)? = null,
onPayOffer: (() -> Unit)? = null,
followingCount: Int = 0,
followedBy: List<String> = emptyList(),
followsYou: Boolean = false,
isBlocked: Boolean = false,
onMuteUser: (() -> Unit)? = null,
onUnmuteUser: (() -> Unit)? = null,
paymentTargets: List<NipA3.PaymentTarget> = emptyList(),
onPaymentTargetClick: (NipA3.PaymentTarget) -> Unit = {}
) {
@@ -1232,10 +1261,51 @@ private fun ProfileHeader(
}
}
}
FollowButton(
isFollowing = isFollowing,
onClick = onToggleFollow
)
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,
shape = CircleShape,
color = if (isFollowing) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.primary,
modifier = Modifier.size(40.dp)
) {
Icon(
imageVector = if (isFollowing) Icons.Default.PersonRemove else Icons.Default.PersonAdd,
contentDescription = if (isFollowing) "Unfollow" else "Follow",
tint = if (isFollowing) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.padding(9.dp)
)
}
// Mute circle button
if (onMuteUser != null || onUnmuteUser != null) {
Surface(
onClick = { if (isBlocked) onUnmuteUser?.invoke() else onMuteUser?.invoke() },
shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.size(40.dp)
) {
Icon(
imageVector = if (isBlocked) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff,
contentDescription = if (isBlocked) "Unmute" else "Mute",
tint = if (isBlocked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(9.dp)
)
}
}
}
}
}
@@ -437,8 +437,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.darkwisp.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,
@@ -1835,8 +1841,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.darkwisp.app.nostr.Noffer.isNofferString(address)) {
"${address.take(16)}${address.takeLast(8)}"
} else {
address
}
Text(
address,
displayAddress,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
@@ -9,6 +9,7 @@ import androidx.lifecycle.viewModelScope
import com.darkwisp.app.nostr.ClientMessage
import com.darkwisp.app.nostr.Filter
import com.darkwisp.app.nostr.LocalSigner
import com.darkwisp.app.nostr.Noffer
import com.darkwisp.app.nostr.NostrEvent
import com.darkwisp.app.nostr.NostrSigner
import com.darkwisp.app.relay.RelayPool
@@ -47,6 +48,9 @@ class ProfileViewModel(app: Application) : AndroidViewModel(app) {
private val _lud16 = MutableStateFlow("")
val lud16: StateFlow<String> = _lud16
private val _clinkOffer = MutableStateFlow("")
val clinkOffer: StateFlow<String> = _clinkOffer
private val _publishing = MutableStateFlow(false)
val publishing: StateFlow<Boolean> = _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<String?>(null)
val uploading: StateFlow<String?> = _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 {
@@ -9,6 +9,10 @@ import com.darkwisp.app.nostr.LocalSigner
import com.darkwisp.app.nostr.Nip57
import com.darkwisp.app.nostr.Nip78
import com.darkwisp.app.nostr.NipA3
import com.darkwisp.app.nostr.Noffer
import com.darkwisp.app.nostr.NofferData
import com.darkwisp.app.nostr.NofferException
import com.darkwisp.app.nostr.NofferPricing
import com.darkwisp.app.nostr.NostrEvent
import com.darkwisp.app.nostr.NostrSigner
import com.darkwisp.app.nostr.RemoteSigner
@@ -21,6 +25,7 @@ import com.darkwisp.app.repo.SigningMode
import com.darkwisp.app.repo.EventRepository
import com.darkwisp.app.repo.KeyRepository
import com.darkwisp.app.repo.BalanceUnit
import com.darkwisp.app.repo.NofferClient
import com.darkwisp.app.repo.NwcRepository
import com.darkwisp.app.repo.SparkRepository
import com.darkwisp.app.repo.WalletMode
@@ -1332,6 +1337,23 @@ class WalletViewModel(
_sendError.value = null
when {
Noffer.isNofferString(trimmed) -> {
val noffer = Noffer.decodeOrNull(trimmed)
if (noffer == null) {
_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)
} else {
_sendAmount.value = ""
navigateTo(WalletPage.SendAmount(noffer.raw))
}
}
trimmed.lowercase().startsWith("lnbc") -> {
val decoded = Bolt11.decode(trimmed)
if (decoded == null) {
@@ -1354,12 +1376,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 {
@@ -1399,6 +1432,48 @@ 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)
// 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?.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) {
"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
+1 -1
View File
@@ -735,7 +735,7 @@
<string name="wallet_set_up_lightning">Set up your Lightning Address</string>
<string name="wallet_send">Send</string>
<string name="wallet_receive">Receive</string>
<string name="wallet_lightning_address_invoice">Lightning address or invoice</string>
<string name="wallet_lightning_address_invoice">Lightning address, invoice, or CLINK offer</string>
<string name="wallet_paste">Paste</string>
<string name="wallet_scan_qr">Scan QR</string>
<string name="wallet_gallery">Gallery</string>
@@ -0,0 +1,122 @@
package com.darkwisp.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<Pair<Int, ByteArray>>): String {
val bytes = mutableListOf<Byte>()
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"))
}
}