From bfd00ccc340f337593e86947f367aab25b9d5cb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 23:31:31 +0000 Subject: [PATCH] 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 --- .../model/nip60Cashu/CashuWalletOps.kt | 106 ++++++++++++++++++ .../model/nip60Cashu/CashuWalletState.kt | 68 +++++++++++ .../ui/components/ReusableZapButton.kt | 4 + .../amethyst/ui/note/ReactionsRow.kt | 85 +++++++++++++- .../ui/screen/loggedIn/AccountViewModel.kt | 48 ++++++++ amethyst/src/main/res/values/strings.xml | 4 + .../nip61Nutzaps/info/NutzapInfoEvent.kt | 3 + .../nip60Cashu/mintApi/CashuMintOperations.kt | 59 ++++++++++ 8 files changed, 376 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt index 3779d1c301..5623164882 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt @@ -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, + message: String, + available: List, + ): 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, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index 3dae04c837..e78530e32e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -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, + 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, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ReusableZapButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ReusableZapButton.kt index 772948a63d..0a594d4131 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ReusableZapButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ReusableZapButton.kt @@ -207,6 +207,10 @@ fun ReusableZapButton( onchainZapAmount = amount showOnchainDialog = true }, + nutzapEnabled = + baseNote.author?.pubkeyHex?.let { recipient -> + accountViewModel.account.cashuWalletState.peekNutzapTarget(recipient) != null + } ?: false, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index e99817bec8..e6be8fcda3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -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) -> Unit, onchainZapAmountChoices: ImmutableList = 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) -> 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 = persistentListOf(), onOnchainAmount: (Long?) -> Unit = {}, + nutzapAmountChoices: ImmutableList = 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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 5ec44ad8c1..940a226a37 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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() + 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, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e52c1f7a70..e5a1ec4f3c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1905,6 +1905,10 @@ ✓ Mint is reachable ✓ %1$s Could not reach mint: %1$s + Nutzap + Nutzap failed + No recipient pubkey on the note + Cannot build event reference Create token Redeem Done diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/info/NutzapInfoEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/info/NutzapInfoEvent.kt index 0feaf81e72..175fba5db1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/info/NutzapInfoEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/info/NutzapInfoEvent.kt @@ -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, relays: List, diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt index 1a8d331313..b4ba2ee2ff 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt @@ -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, + 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, signatures: List,