feat(clink): make offer payments zappable

When a noffer is rendered in someone's note, paying it now attaches a NIP-57
zap request so the offer service issues a zappable invoice and publishes a zap
receipt — turning the payment into a real zap on the author instead of a silent
invoice:
- ClinkOfferPayer.requestInvoice forwards a serialized zap request via
  OfferClient's existing zap field.
- ClinkOfferPreview builds the 9734 from the threaded authorPubKey using the
  account's default zap type (skipped for NONZAP), at the resolved amount.
- RichTextViewer threads authorPubKey into the offer-segment renderers; the
  markdown/secret paths default to null (plain invoice, no target).

Falls back to a plain invoice when there's no author or the user opted out of
zaps. :amethyst compiles; the round-trip and receipt remain untested end-to-end.
This commit is contained in:
Claude
2026-06-09 23:13:26 +00:00
parent 94ee192641
commit db2f65d300
3 changed files with 36 additions and 5 deletions
@@ -52,13 +52,14 @@ object ClinkOfferPayer {
account: Account,
offer: NOffer,
amountSats: Long? = null,
zap: String? = null,
timeoutMs: Long = DEFAULT_TIMEOUT_MS,
): OfferResponse? {
val relays = offer.relays.toSet()
if (relays.isEmpty()) return null
val client = OfferClient(offer, account.signer)
val request = client.requestInvoice(amountSats)
val request = client.requestInvoice(amountSats = amountSats, zap = zap)
val reply = CompletableDeferred<OfferEvent>()
val subId = "clink-offer-${request.id}"
@@ -368,6 +368,7 @@ private fun RenderRegular(
callbackUri,
accountViewModel,
nav,
authorPubKey,
)
}
}
@@ -381,6 +382,7 @@ private fun RenderRegular(
backgroundColor,
accountViewModel,
nav,
authorPubKey,
)
}
}
@@ -476,6 +478,7 @@ private fun RenderWordWithoutPreview(
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
authorPubKey: String? = null,
) {
when (word) {
// Don't preview Images
@@ -510,7 +513,7 @@ private fun RenderWordWithoutPreview(
// Decoding is local and the network round-trip only fires on the Pay tap,
// so the offer card is safe to render even in the no-preview path.
is ClinkOfferSegment -> ClinkOfferPreview(word.offer, accountViewModel)
is ClinkOfferSegment -> ClinkOfferPreview(word.offer, accountViewModel, authorPubKey)
is EmailSegment -> ClickableEmail(word.segmentText)
@@ -547,6 +550,7 @@ private fun RenderWordWithPreview(
callbackUri: String? = null,
accountViewModel: AccountViewModel,
nav: INav,
authorPubKey: String? = null,
) {
when (word) {
is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
@@ -558,7 +562,7 @@ private fun RenderWordWithPreview(
is InvoiceSegment -> MayBeInvoicePreview(word.segmentText, accountViewModel)
is WithdrawSegment -> MayBeWithdrawal(word.segmentText, accountViewModel)
is CashuSegment -> CashuPreview(word.segmentText, accountViewModel)
is ClinkOfferSegment -> ClinkOfferPreview(word.offer, accountViewModel)
is ClinkOfferSegment -> ClinkOfferPreview(word.offer, accountViewModel, authorPubKey)
is EmailSegment -> ClickableEmail(word.segmentText)
is SecretEmoji -> DisplaySecretEmoji(word, state, callbackUri, true, quotesLeft, backgroundColor, accountViewModel, nav)
is MathSegment -> LatexEquation(word.latex, word.displayMode, word.leading, word.trailing)
@@ -63,6 +63,7 @@ import com.vitorpamplona.quartz.experimental.clink.common.SatRange
import com.vitorpamplona.quartz.experimental.clink.offers.OfferErrorCode
import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer
import com.vitorpamplona.quartz.experimental.clink.pointers.OfferPriceType
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import kotlinx.coroutines.launch
/**
@@ -70,11 +71,18 @@ import kotlinx.coroutines.launch
* runs the offer round-trip ([ClinkOfferPayer]) to fetch a fresh BOLT-11 over Nostr,
* then pays it through the user's default payment source (confirmed for in-app wallets,
* see [InvoicePaymentDispatcher]).
*
* When [authorPubKey] is known (the offer appears in someone's note) and the user's
* default zap type isn't NONZAP, the request carries a NIP-57 zap request so the offer
* service issues a zappable invoice and publishes a zap receipt — making the payment a
* real zap on the author rather than a silent invoice. With no author it falls back to
* a plain invoice.
*/
@Composable
fun ClinkOfferPreview(
offer: NOffer,
accountViewModel: AccountViewModel,
authorPubKey: String? = null,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -191,8 +199,26 @@ fun ClinkOfferPreview(
onClick = {
requesting = true
scope.launch {
val amount = if (amountRequired) amountInput.toLongOrNull() else null
val response = ClinkOfferPayer.requestInvoice(accountViewModel.account, offer, amountSats = amount)
val amount = if (amountRequired) amountInput.toLongOrNull() else offer.price?.toLong()
// Attach a NIP-57 zap request so paying the offer becomes a real zap
// on the author (skipped when there's no author or the user opted out
// of zaps via a NONZAP default).
val zapType = accountViewModel.defaultZapType()
val zapRequest =
if (authorPubKey != null && zapType != LnZapEvent.ZapType.NONZAP) {
val author = accountViewModel.account.cache.getOrCreateUser(authorPubKey)
accountViewModel.account
.createZapRequestFor(
user = author,
zapType = zapType,
amountMillisats = amount?.times(1000),
).toJson()
} else {
null
}
val response = ClinkOfferPayer.requestInvoice(accountViewModel.account, offer, amountSats = amount, zap = zapRequest)
requesting = false
val bolt11 = response?.bolt11