mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
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:
@@ -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,
|
||||
)
|
||||
|
||||
+68
@@ -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(
|
||||
|
||||
+48
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
+3
@@ -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>,
|
||||
|
||||
+59
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user