feat(cashu): send NIP-61 nutzaps from the zap picker

Adds an end-to-end "Nutzap" path to the existing zap chooser popup.

Protocol layer (quartz)
  * CashuMintOperations.swapToLocked: mints P2PK-locked outputs for a
    recipient pubkey alongside the unlocked change. Uses the new
    lockedOutputFor() helper which encodes NUT-11 P2PK secret strings
    before blinding.
  * NutzapInfoEvent.createAddress() mirrors CashuWalletEvent's helper so
    LocalCache.getOrCreateAddressableNote can look up a recipient's
    kind:10019 by pubkey alone.

Wallet ops (amethyst)
  * CashuWalletOps.sendNutzap: spends [available] proofs at [mintUrl] to
    produce locked outputs worth [amountSats], publishes a kind:9321 with
    those proofs + the zappedEvent + recipient p-tag, rolls leftover
    change into a new kind:7375 (with `del` referencing the sources),
    NIP-09-deletes the source token events, and logs kind:7376 (direction
    OUT, destroyed/created references).
  * CashuWalletState.peekNutzapTarget(recipient): pure read against the
    cached kind:10019 + our mint set. Returns a NutzapTarget (mint URL +
    recipient P2PK pubkey) if (a) we have a Cashu wallet, (b) recipient
    published kind:10019 with a P2PK pubkey, and (c) we share at least
    one mint with them. Returns null otherwise so the UI can hide the
    nutzap chip.
  * CashuWalletState.sendNutzap: orchestrates target lookup + ops call.

UI integration
  * ReactionsRow.ZapAmountChoicePopup gains a `nutzapEnabled: Boolean`
    parameter. When true, the popup renders a NutzapAmountChip per zap
    amount (tertiary-color, wallet icon) inline with the existing LN +
    on-chain chips. Tap fires AccountViewModel.sendNutzap which
    forwards into CashuWalletState.sendNutzap. Errors surface via the
    same toast path as LN-zap errors.
  * ReusableZapButton computes nutzapEnabled from the recipient's
    cached kind:10019; chip is hidden when no nutzap target resolves.

Sender currently has to have the recipient's kind:10019 already in
LocalCache for the chip to appear (typical when viewing a note whose
author the user has interacted with). Background prefetch of kind:10019
for unfamiliar authors is a follow-up.

24/24 NIP-60 jvm tests still passing. Both playDebug and fdroidDebug
compile clean.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
This commit is contained in:
Claude
2026-05-27 15:17:40 +00:00
parent bbd43e34e9
commit bfd00ccc34
8 changed files with 376 additions and 1 deletions
@@ -22,8 +22,10 @@ package com.vitorpamplona.amethyst.model.nip60Cashu
import com.vitorpamplona.amethyst.service.cashu.v4.V4Encoder
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
@@ -414,6 +416,102 @@ class CashuWalletOps(
)
}
/**
* Send a NIP-61 nutzap.
*
* Spends [available] proofs at [mintUrl] to produce P2PK-locked outputs
* worth [amountSats] for [recipientPubKey]'s [recipientP2pkPubkeyHex].
* Publishes a kind:9321 with the locked proofs + message, rolls leftover
* change into a new kind:7375, NIP-09-deletes the source token events,
* and records the spend in kind:7376.
*
* Throws if `available` doesn't sum to at least [amountSats].
*/
suspend fun sendNutzap(
mintUrl: String,
amountSats: Long,
recipientPubKey: HexKey,
recipientP2pkPubkeyHex: String,
zappedEvent: EventHintBundle<out Event>,
message: String,
available: List<TokenEntry>,
): NutzapSent {
if (amountSats <= 0) throw IllegalArgumentException("Amount must be positive")
val (selected, totalSelected) = selectProofsCovering(available, amountSats)
if (totalSelected < amountSats) throw IllegalStateException("Insufficient balance for $mintUrl")
val swap =
ops(mintUrl).swapToLocked(
proofs = selected.flatMap { it.content.proofs },
recipientP2pkPubkeyHex = recipientP2pkPubkeyHex,
targetSplit = amountSats,
)
// Build the kind:9321 first so we have its id to reference from history.
val proofJsons = swap.send.map { nutzapProofJson.encodeToString(NutzapProofJson.serializer(), it.toNutzapJson()) }
val nutzapTemplate =
NutzapEvent.build(
message = message,
proofs = proofJsons,
mintUrl = mintUrl,
unit = "sat",
zappedEvent = zappedEvent,
recipientPubKey = recipientPubKey,
)
val nutzapEvent = signer.sign(nutzapTemplate)
publish(nutzapEvent)
// Roll over change locally if any.
val keepEvent =
if (swap.keep.isNotEmpty()) {
val content = TokenContent(mint = mintUrl, proofs = swap.keep, del = selected.map { it.event.id })
val template = CashuTokenEvent.build(content, signer)
val signed = signer.sign(template)
publish(signed)
signed
} else {
null
}
// NIP-09 delete the source token events.
val deleteEvent =
run {
val template = DeletionEvent.build(selected.map { it.event })
signer.sign(template).also { publish(it) }
}
val historyTemplate =
CashuSpendingHistoryEvent.build(
direction = SpendingDirection.OUT,
amount = amountSats,
tokenReferences =
buildList {
selected.forEach { add(TokenReference(it.event.id, null, TokenReference.MARKER_DESTROYED)) }
keepEvent?.let { add(TokenReference(it.id, null, TokenReference.MARKER_CREATED)) }
},
signer = signer,
)
val historyEvent = signer.sign(historyTemplate)
publish(historyEvent)
return NutzapSent(
nutzapEvent = nutzapEvent,
keepEvent = keepEvent,
deleteEvent = deleteEvent,
historyEvent = historyEvent,
amount = amountSats,
)
}
private fun CashuProof.toNutzapJson() =
NutzapProofJson(
id = id,
amount = amount,
secret = secret,
c = c,
witness = witness,
)
/**
* Redeem an inbound NIP-61 nutzap.
*
@@ -603,3 +701,11 @@ data class RedeemCompleted(
val historyEvent: CashuSpendingHistoryEvent,
val rawToken: String,
)
data class NutzapSent(
val nutzapEvent: NutzapEvent,
val keepEvent: CashuTokenEvent?,
val deleteEvent: DeletionEvent,
val historyEvent: CashuSpendingHistoryEvent,
val amount: Long,
)
@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
@@ -34,6 +35,7 @@ import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent
import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent
import com.vitorpamplona.quartz.nip60Cashu.token.TokenContent
import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -438,6 +440,66 @@ class CashuWalletState(
}
}
// ============================================================
// Send nutzap
// ============================================================
/**
* Information needed to nutzap [recipientPubKey], resolved from their
* kind:10019 + our wallet's mint list. Returns null if:
* - we don't have a Cashu wallet,
* - the recipient hasn't published a kind:10019,
* - we share no mint with them, or
* - their kind:10019 has no P2PK pubkey.
*/
fun peekNutzapTarget(recipientPubKey: HexKey): NutzapTarget? {
val ourMints = _mints.value.toSet()
if (ourMints.isEmpty()) return null
val infoNote = cache.getOrCreateAddressableNote(NutzapInfoEvent.createAddress(recipientPubKey))
val info = infoNote.event as? NutzapInfoEvent ?: return null
val recipientPubkeyHex = info.p2pkPubkey() ?: return null
val shared = info.mints().firstOrNull { it.mintUrl in ourMints } ?: return null
return NutzapTarget(
mintUrl = shared.mintUrl,
recipientP2pkPubkeyHex = recipientPubkeyHex,
)
}
/**
* Send a NIP-61 nutzap of [amountSats] to [recipientPubKey] referencing
* [zappedEvent]. Returns the resulting [NutzapSent] on success or throws
* — callers should surface errors via [describeMintError].
*/
suspend fun sendNutzap(
amountSats: Long,
recipientPubKey: HexKey,
zappedEvent: EventHintBundle<out Event>,
message: String = "",
): NutzapSent {
check(started) { "CashuWalletState.start() not called" }
val target =
peekNutzapTarget(recipientPubKey)
?: throw IllegalStateException("Recipient does not accept nutzaps from any of our mints")
val available = _tokenEntries.value.filter { it.content.mint == target.mintUrl }
if (available.isEmpty()) {
throw IllegalStateException("No proofs available at ${target.mintUrl}")
}
return ops.sendNutzap(
mintUrl = target.mintUrl,
amountSats = amountSats,
recipientPubKey = recipientPubKey,
recipientP2pkPubkeyHex = target.recipientP2pkPubkeyHex,
zappedEvent = zappedEvent,
message = message,
available = available,
)
}
// ============================================================
// Publish bridge
// ============================================================
@@ -453,3 +515,9 @@ class CashuWalletState(
publish(event)
}
}
/** Mint + recipient pubkey resolved from a kind:10019. */
data class NutzapTarget(
val mintUrl: String,
val recipientP2pkPubkeyHex: String,
)
@@ -207,6 +207,10 @@ fun ReusableZapButton(
onchainZapAmount = amount
showOnchainDialog = true
},
nutzapEnabled =
baseNote.author?.pubkeyHex?.let { recipient ->
accountViewModel.account.cashuWalletState.peekNutzapTarget(recipient) != null
} ?: false,
)
}
@@ -1873,6 +1873,16 @@ fun ZapAmountChoicePopup(
accountViewModel.account.settings.syncedSettings.zaps.onchainZapAmountChoices
.collectAsStateWithLifecycle()
// Nutzap chips appear only when (a) we have a Cashu wallet, (b) the
// recipient has a kind:10019 with a P2PK pubkey, and (c) we share at
// least one mint with them. peekNutzapTarget() returns null otherwise.
val nutzapTarget =
remember(baseNote) {
baseNote.author?.pubkeyHex?.let { recipientPubKey ->
accountViewModel.account.cashuWalletState.peekNutzapTarget(recipientPubKey)
}
}
ZapAmountChoicePopup(
baseNote = baseNote,
zapAmountChoices = zapAmountChoices,
@@ -1886,6 +1896,7 @@ fun ZapAmountChoicePopup(
onPayViaIntent = onPayViaIntent,
onchainZapAmountChoices = if (onOnchainAmount != null) onchainZapAmountChoices else persistentListOf(),
onOnchainAmount = onOnchainAmount ?: {},
nutzapEnabled = nutzapTarget != null,
)
}
@@ -1903,9 +1914,24 @@ fun ZapAmountChoicePopup(
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
onchainZapAmountChoices: ImmutableList<Long> = persistentListOf(),
onOnchainAmount: (Long?) -> Unit = {},
nutzapEnabled: Boolean = false,
) {
val visibilityState = rememberVisibilityState(onDismiss)
ZapAmountChoicePopup(baseNote, zapAmountChoices, onchainZapAmountChoices, accountViewModel, popupYOffset, visibilityState, onZapStarts, onChangeAmount, onOnchainAmount, onError, onProgress, onPayViaIntent)
ZapAmountChoicePopup(
baseNote,
zapAmountChoices,
onchainZapAmountChoices,
accountViewModel,
popupYOffset,
visibilityState,
onZapStarts,
onChangeAmount,
onOnchainAmount,
onError,
onProgress,
onPayViaIntent,
nutzapEnabled,
)
}
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
@@ -1923,6 +1949,7 @@ fun ZapAmountChoicePopup(
onError: (title: String, text: String, user: User?) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
nutzapEnabled: Boolean = false,
) {
val context = LocalContext.current
val yOffset = with(LocalDensity.current) { -popupYOffset.toPx().toInt() }
@@ -1961,6 +1988,17 @@ fun ZapAmountChoicePopup(
onOnchainAmount(amount)
visibilityState.targetState = false
},
nutzapAmountChoices = if (nutzapEnabled) zapAmountChoices else persistentListOf(),
onNutzap = { amountInSats ->
onZapStarts()
accountViewModel.sendNutzap(
baseNote = baseNote,
amountSats = amountInSats,
message = "",
onError = onError,
)
visibilityState.targetState = false
},
)
}
}
@@ -1974,6 +2012,8 @@ fun ZapAmountChoicePopupContent(
onChangeAmount: () -> Unit,
onchainZapAmountChoices: ImmutableList<Long> = persistentListOf(),
onOnchainAmount: (Long?) -> Unit = {},
nutzapAmountChoices: ImmutableList<Long> = persistentListOf(),
onNutzap: (Long) -> Unit = {},
) {
Box(HalfPadding, contentAlignment = Center) {
ElevatedCard(
@@ -2001,6 +2041,13 @@ fun ZapAmountChoicePopupContent(
onLongClick = onChangeAmount,
)
}
nutzapAmountChoices.forEach { amountInSats ->
NutzapAmountChip(
amountInSats = amountInSats,
onClick = { onNutzap(amountInSats) },
onLongClick = onChangeAmount,
)
}
ClickableBox(
modifier =
Modifier
@@ -2021,6 +2068,42 @@ fun ZapAmountChoicePopupContent(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun NutzapAmountChip(
amountInSats: Long,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
Surface(
shape = ButtonBorder,
color = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 6.dp),
) {
Row(
modifier =
Modifier
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = CenterVertically,
) {
Icon(
symbol = MaterialSymbols.AccountBalanceWallet,
contentDescription = stringRes(R.string.nutzap),
modifier = Size18Modifier,
tint = MaterialTheme.colorScheme.onTertiary,
)
Spacer(Modifier.width(2.dp))
Text(
text = showAmount(amountInSats.toBigDecimal().setScale(1)),
color = MaterialTheme.colorScheme.onTertiary,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ZapAmountChip(
@@ -905,6 +905,54 @@ class AccountViewModel(
)
}
/**
* Fire-and-forget NIP-61 nutzap from the zap picker. Picks a mint the
* recipient accepts (via their kind:10019) that we also have proofs at,
* swaps proofs to P2PK-locked outputs, and publishes a kind:9321
* referencing [note]. Errors are surfaced to [onError]; success is
* indicated by the resulting kind:9321 landing in the cache.
*/
fun sendNutzap(
baseNote: Note,
amountSats: Long,
message: String,
onError: (String, String, User?) -> Unit,
) = launchSigner {
val recipient = baseNote.author?.pubkeyHex
if (recipient == null) {
onError(
stringRes(com.vitorpamplona.amethyst.Amethyst.instance.appContext, R.string.nutzap_failed_title),
stringRes(com.vitorpamplona.amethyst.Amethyst.instance.appContext, R.string.nutzap_failed_no_recipient),
null,
)
return@launchSigner
}
val zappedEvent = baseNote.toEventHint<com.vitorpamplona.quartz.nip01Core.core.Event>()
if (zappedEvent == null) {
onError(
stringRes(com.vitorpamplona.amethyst.Amethyst.instance.appContext, R.string.nutzap_failed_title),
stringRes(com.vitorpamplona.amethyst.Amethyst.instance.appContext, R.string.nutzap_failed_no_event),
baseNote.author,
)
return@launchSigner
}
try {
account.cashuWalletState.sendNutzap(
amountSats = amountSats,
recipientPubKey = recipient,
zappedEvent = zappedEvent,
message = message,
)
} catch (e: Exception) {
onError(
stringRes(com.vitorpamplona.amethyst.Amethyst.instance.appContext, R.string.nutzap_failed_title),
com.vitorpamplona.amethyst.model.nip60Cashu
.describeMintError(e),
baseNote.author,
)
}
}
fun report(
note: Note,
type: ReportType,
+4
View File
@@ -1905,6 +1905,10 @@
<string name="cashu_mint_reachable">✓ Mint is reachable</string>
<string name="cashu_mint_reachable_named">✓ %1$s</string>
<string name="cashu_mint_unreachable">Could not reach mint: %1$s</string>
<string name="nutzap">Nutzap</string>
<string name="nutzap_failed_title">Nutzap failed</string>
<string name="nutzap_failed_no_recipient">No recipient pubkey on the note</string>
<string name="nutzap_failed_no_event">Cannot build event reference</string>
<string name="cashu_create_token">Create token</string>
<string name="cashu_redeem_button">Redeem</string>
<string name="cashu_done">Done</string>
@@ -21,6 +21,7 @@
package com.vitorpamplona.quartz.nip61Nutzaps.info
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
@@ -49,6 +50,8 @@ class NutzapInfoEvent(
const val KIND = 10019
const val ALT_DESCRIPTION = "Nutzap receiving preferences"
fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, BaseReplaceableEvent.FIXED_D_TAG)
fun build(
mints: List<NutzapMintTag>,
relays: List<NormalizedRelayUrl>,
@@ -158,6 +158,49 @@ class CashuMintOperations(
return swap(unlocked, targetSplit = null)
}
/**
* Swap our unlocked proofs so that [targetSplit] sats are returned as
* NUT-11 P2PK-locked proofs (recipient-spendable only with their
* private key), and the remainder stays unlocked in our wallet.
*
* The caller bundles `result.send` into a kind:9321 NutzapEvent and
* keeps `result.keep` as the change in a new kind:7375.
*/
suspend fun swapToLocked(
proofs: List<CashuProof>,
recipientP2pkPubkeyHex: String,
targetSplit: Long,
): SwapResult {
if (proofs.isEmpty()) throw IllegalArgumentException("Nothing to swap")
if (targetSplit <= 0) throw IllegalArgumentException("Target split must be > 0")
val total = proofs.sumOf { it.amount }
if (targetSplit > total) throw IllegalArgumentException("Target split exceeds available proofs")
val keyset = fetchKeyset()
val sendOutputs = splitAmounts(targetSplit).map { lockedOutputFor(it, keyset, recipientP2pkPubkeyHex) }
val keepOutputs = splitAmounts(total - targetSplit).map { secretOutputFor(it, keyset) }
val allOutputs = sendOutputs + keepOutputs
val response =
client.swap(
SwapRequestDto(
inputs = proofs.map { it.toDto() },
outputs = allOutputs.map { it.toDto() },
),
)
if (response.signatures.size != allOutputs.size) {
throw MintProtocolException(
"Mint returned ${response.signatures.size} signatures for ${allOutputs.size} outputs",
)
}
val unblinded = unblindAll(allOutputs, response.signatures, keyset)
val sendProofs = unblinded.subList(0, sendOutputs.size)
val keepProofs = unblinded.subList(sendOutputs.size, unblinded.size)
return SwapResult(send = sendProofs, keep = keepProofs, keysetId = keyset.id)
}
/**
* Ask the mint for a bolt11 melt quote: how much in fees this invoice will
* cost on top of its amount. Caller selects the proofs that cover
@@ -251,6 +294,22 @@ class CashuMintOperations(
return BlindOutput(amount, keyset.id, r, secretHex, bTick)
}
/**
* Like [secretOutputFor] but the secret is a NUT-11 P2PK lock string so
* the resulting proof can only be redeemed with [recipientPubKeyHex]'s
* private key. Used by [swapToLocked] when minting nutzap outputs.
*/
private fun lockedOutputFor(
amount: Long,
keyset: KeysetDto,
recipientPubKeyHex: String,
): BlindOutput {
val r = Bdhke.randomScalar()
val secret = P2PK.lockedSecret(recipientPubKeyHex)
val bTick = Bdhke.blind(secret.encodeToByteArray(), r)
return BlindOutput(amount, keyset.id, r, secret, bTick)
}
private fun unblindAll(
outputs: List<BlindOutput>,
signatures: List<BlindSignatureDto>,